Coverage for plugin/scripts/validate.py: 100%
106 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 14:46 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 14:46 +0000
1#!/usr/bin/env python3
2"""Validate `.rhiza/template.yml` configuration.
4A stdlib-only port of the `rhiza validate` command, bundled with this plugin so
5`/rhiza:status` can report config validity without the `rhiza` CLI (or PyYAML)
6installed. It is also a **gate**: it exits non-zero on an invalid configuration, so
7it works in CI independently of the command. It checks
8that the target is a git repo, that the template file exists and parses, that
9the project has the expected language-specific structure, and that the
10configuration's required/optional fields are present and well-typed.
12Usage:
13 uv run --python 3.12 --no-project python \
14 scripts/validate.py [TARGET] [--path-to-template DIR] [--json]
16 TARGET repository root to validate (default: current directory)
17 --path-to-template directory containing template.yml (default: <TARGET>/.rhiza;
18 use '.' to keep the file in the project root)
19 --json emit {"valid", "errors", "warnings"} as JSON on stdout;
20 human-readable progress still goes to stderr
22Exit code is 0 when validation passes, 1 when it fails — same contract as
23`rhiza validate`, so it drops into CI unchanged.
24"""
26from __future__ import annotations
28import argparse
29import json
30import sys
31from pathlib import Path
32from typing import Any
34sys.path.insert(0, str(Path(__file__).resolve().parent))
35from _rhiza_yaml import load_yaml # noqa: E402
36from _validate_fields import ( # noqa: E402
37 validate_configuration_mode,
38 validate_optional_fields,
39 validate_repository_format,
40 validate_required_fields,
41 validate_string_list,
42)
43from _validate_log import Log # noqa: E402
44from _validate_structure import check_project_structure # noqa: E402
46__all__ = ["Log", "main", "validate"]
49# --------------------------------------------------------------------------- #
50# preconditions
51# --------------------------------------------------------------------------- #
52def _check_git_repository(log: Log, target: Path) -> bool:
53 """Require *target* to be a git repository."""
54 if not (target / ".git").is_dir():
55 log.error(f"Target directory is not a git repository: {target}")
56 log.error("Initialize a git repository with 'git init' first")
57 return False
58 return True
61def _check_template_file_exists(
62 log: Log, target: Path, template_file: Path | None
63) -> tuple[bool, Path]:
64 """Locate template.yml and confirm it exists."""
65 if template_file is None:
66 template_file = target / ".rhiza" / "template.yml"
67 # `.as_posix()` on both arms: this string is only ever shown to the user, and the rest
68 # of the tool quotes repo paths with forward slashes on every platform.
69 try:
70 display = template_file.relative_to(target).as_posix()
71 except ValueError:
72 display = template_file.as_posix()
73 if not template_file.exists():
74 log.error(f"No template file found at: {display}")
75 log.error("The template configuration must be in the .rhiza folder.")
76 log.info("To fix this:")
77 log.info(" • If you're starting fresh, run /rhiza:init — or init_scaffold.py directly")
78 log.info(" • It writes .rhiza/template.yml, the only file the sync needs")
79 return False, template_file
80 log.success(f"Template file exists: {display}")
81 return True, template_file
84def _parse_template_file(log: Log, template_file: Path) -> tuple[bool, dict[str, Any] | None]:
85 """Load and parse template.yml into a config dict."""
86 log.debug(f"Parsing template file: {template_file}")
87 try:
88 config = load_yaml(template_file)
89 except ValueError as exc:
90 log.error(f"Invalid YAML in template.yml: {exc}")
91 log.error("Fix the YAML syntax errors and try again")
92 return False, None
93 except OSError as exc:
94 log.error(f"Could not read template.yml: {exc}")
95 return False, None
97 if not config:
98 log.error("template.yml is empty")
99 log.error(
100 "Add configuration to template.yml, or generate it with /rhiza:init "
101 "(init_scaffold.py directly)"
102 )
103 return False, None
105 log.success("YAML syntax is valid")
106 return True, config
109# --------------------------------------------------------------------------- #
110# orchestration
111# --------------------------------------------------------------------------- #
112def _load_valid_config(log: Log, target: Path, template_file: Path | None) -> dict[str, Any] | None:
113 """Run the hard-stop preconditions and return the parsed config, else None."""
114 if not _check_git_repository(log, target):
115 return None
116 exists, template_file = _check_template_file_exists(log, target, template_file)
117 if not exists:
118 return None
119 ok, config = _parse_template_file(log, template_file)
120 if not ok or config is None:
121 return None
123 language = config.get("language", "python")
124 log.info(f"Project language: {language}")
125 if not check_project_structure(log, target, str(language)):
126 return None
127 if not validate_configuration_mode(log, config):
128 return None
129 return config
132def _validate_config_fields(log: Log, config: dict[str, Any]) -> bool:
133 """Field-level checks; these do NOT short-circuit so all errors surface at once."""
134 passed = validate_required_fields(log, config)
135 if not validate_repository_format(log, config):
136 passed = False
137 if config.get("templates") and not validate_string_list(
138 log, config, "templates", "templates: [core, tests, github]"
139 ):
140 passed = False
141 if config.get("include") and not validate_string_list(
142 log, config, "include", "include: ['.github', '.gitignore']"
143 ):
144 passed = False
145 validate_optional_fields(log, config)
146 return passed
149def validate(log: Log, target: Path, template_file: Path | None = None) -> bool:
150 """Validate template.yml; return True on success, False on failure."""
151 target = target.resolve()
152 log.info(f"Validating template configuration in: {target}")
154 config = _load_valid_config(log, target, template_file)
155 if config is None:
156 return False
158 passed = _validate_config_fields(log, config)
159 log.debug("Validation complete, determining final result")
160 if passed:
161 log.success("Validation passed: template.yml is valid")
162 return True
163 log.error("Validation failed: template.yml has errors")
164 log.error("Fix the errors above and run validate again")
165 return False
168def main(argv: list[str] | None = None) -> int:
169 """Entry point: validate, optionally emit JSON, and return an exit code."""
170 parser = argparse.ArgumentParser(
171 description="Validate .rhiza/template.yml configuration.",
172 )
173 parser.add_argument(
174 "target",
175 nargs="?",
176 default=".",
177 help="Repository root to validate (default: current directory).",
178 )
179 parser.add_argument(
180 "--path-to-template",
181 dest="path_to_template",
182 default=None,
183 help="Directory holding template.yml (default: <TARGET>/.rhiza; '.' for the project root).",
184 )
185 parser.add_argument(
186 "--json",
187 dest="json_output",
188 action="store_true",
189 help="Emit {valid, errors, warnings} as a JSON object on stdout.",
190 )
191 parser.add_argument(
192 "--verbose",
193 action="store_true",
194 help="Also print debug-level progress lines.",
195 )
196 args = parser.parse_args(argv)
198 template_file = None
199 if args.path_to_template is not None:
200 template_file = Path(args.path_to_template) / "template.yml"
202 log = Log(verbose=args.verbose)
203 valid = validate(log, Path(args.target), template_file=template_file)
205 if args.json_output:
206 print(
207 json.dumps({"valid": valid, "errors": log.errors, "warnings": log.warnings}, indent=2)
208 )
209 return 0 if valid else 1
212if __name__ == "__main__":
213 raise SystemExit(main())