Coverage for scripts/validate.py: 100%
316 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-31 06:18 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-31 06:18 +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
37# Hosts the template may target; mirrors rhiza.models.template.GitHost.
38GIT_HOSTS = ("github", "gitlab")
41# --------------------------------------------------------------------------- #
42# logging shim
43# --------------------------------------------------------------------------- #
44class Log:
45 """Tiny stand-in for the CLI's loguru sink.
47 Prints human-readable, symbol-prefixed lines to stderr and, so `--json`
48 can report a structured verdict, accumulates the ERROR/WARNING messages.
49 """
51 _SYMBOLS = {"error": "✗", "warning": "!", "success": "✓", "info": " ", "debug": " "}
53 def __init__(self, *, verbose: bool = False) -> None:
54 """Start the sink with empty error/warning buffers."""
55 self.errors: list[str] = []
56 self.warnings: list[str] = []
57 self._verbose = verbose
59 def _emit(self, level: str, message: str) -> None:
60 """Print a symbol-prefixed line (debug only when verbose)."""
61 if level == "debug" and not self._verbose:
62 return
63 print(f"{self._SYMBOLS[level]} {message}", file=sys.stderr)
65 def error(self, message: str) -> None:
66 """Record and print an error."""
67 self.errors.append(message)
68 self._emit("error", message)
70 def warning(self, message: str) -> None:
71 """Record and print a warning."""
72 self.warnings.append(message)
73 self._emit("warning", message)
75 def success(self, message: str) -> None:
76 """Print a success line."""
77 self._emit("success", message)
79 def info(self, message: str) -> None:
80 """Print an info line."""
81 self._emit("info", message)
83 def debug(self, message: str) -> None:
84 """Print a debug line (verbose only)."""
85 self._emit("debug", message)
88# --------------------------------------------------------------------------- #
89# language-specific project structure
90# --------------------------------------------------------------------------- #
91def _validate_python_structure(log: Log, target: Path) -> bool:
92 """Python needs pyproject.toml (required); src/ and tests/ are warnings."""
93 passed = True
94 if not (target / "pyproject.toml").exists():
95 log.error(f"pyproject.toml not found: {target / 'pyproject.toml'}")
96 log.error("pyproject.toml is required for Python projects")
97 log.info("Run /rhiza:init to set the repo up, which creates a pyproject.toml")
98 passed = False
99 else:
100 log.success(f"pyproject.toml exists: {target / 'pyproject.toml'}")
102 for name in ("src", "tests"):
103 d = target / name
104 if not d.exists():
105 log.warning(f"Standard '{name}' folder not found: {d}")
106 log.warning(f"Consider creating a '{name}' directory")
107 else:
108 log.success(f"'{name}' folder exists: {d}")
109 return passed
112def _validate_go_structure(log: Log, target: Path) -> bool:
113 """Go needs go.mod (required); cmd/ and pkg/|internal/ are warnings."""
114 passed = True
115 if not (target / "go.mod").exists():
116 log.error(f"go.mod not found: {target / 'go.mod'}")
117 log.error("go.mod is required for Go projects")
118 log.info("Run 'go mod init <module-name>' to create go.mod")
119 passed = False
120 else:
121 log.success(f"go.mod exists: {target / 'go.mod'}")
123 cmd_dir, pkg_dir, internal_dir = target / "cmd", target / "pkg", target / "internal"
124 if not cmd_dir.exists():
125 log.warning(f"Standard 'cmd' folder not found: {cmd_dir}")
126 log.warning("Consider creating a 'cmd' directory for main applications")
127 else:
128 log.success(f"'cmd' folder exists: {cmd_dir}")
130 if not pkg_dir.exists() and not internal_dir.exists():
131 log.warning("Neither 'pkg' nor 'internal' folder found")
132 log.warning(
133 "Consider creating 'pkg' for public libraries or 'internal' for private packages"
134 )
135 else:
136 if pkg_dir.exists():
137 log.success(f"'pkg' folder exists: {pkg_dir}")
138 if internal_dir.exists():
139 log.success(f"'internal' folder exists: {internal_dir}")
140 return passed
143# Registry of language -> structure validator; extend here to add a language.
144_VALIDATORS = {"python": _validate_python_structure, "go": _validate_go_structure}
147def _check_project_structure(log: Log, target: Path, language: str) -> bool:
148 """Dispatch to the language validator; unsupported languages pass with a warning."""
149 log.debug(f"Validating project structure for language: {language}")
150 validator = _VALIDATORS.get(language.lower())
151 if validator is None:
152 log.warning(f"No validator found for language '{language}'")
153 log.warning(f"Supported languages: {', '.join(_VALIDATORS)}")
154 log.warning("Skipping project structure validation")
155 return True
156 return validator(log, target)
159# --------------------------------------------------------------------------- #
160# preconditions
161# --------------------------------------------------------------------------- #
162def _check_git_repository(log: Log, target: Path) -> bool:
163 """Require *target* to be a git repository."""
164 if not (target / ".git").is_dir():
165 log.error(f"Target directory is not a git repository: {target}")
166 log.error("Initialize a git repository with 'git init' first")
167 return False
168 return True
171def _check_template_file_exists(
172 log: Log, target: Path, template_file: Path | None
173) -> tuple[bool, Path]:
174 """Locate template.yml and confirm it exists."""
175 if template_file is None:
176 template_file = target / ".rhiza" / "template.yml"
177 try:
178 display = template_file.relative_to(target)
179 except ValueError:
180 display = template_file
181 if not template_file.exists():
182 log.error(f"No template file found at: {display}")
183 log.error("The template configuration must be in the .rhiza folder.")
184 log.info("To fix this:")
185 log.info(" • If you're starting fresh, run: /rhiza:init")
186 log.info(" • It writes .rhiza/template.yml, the only file the sync needs")
187 return False, template_file
188 log.success(f"Template file exists: {display}")
189 return True, template_file
192def _parse_template_file(log: Log, template_file: Path) -> tuple[bool, dict[str, Any] | None]:
193 """Load and parse template.yml into a config dict."""
194 log.debug(f"Parsing template file: {template_file}")
195 try:
196 config = load_yaml(template_file)
197 except ValueError as exc:
198 log.error(f"Invalid YAML in template.yml: {exc}")
199 log.error("Fix the YAML syntax errors and try again")
200 return False, None
201 except OSError as exc:
202 log.error(f"Could not read template.yml: {exc}")
203 return False, None
205 if not config:
206 log.error("template.yml is empty")
207 log.error("Add configuration to template.yml, or run /rhiza:init to generate it")
208 return False, None
210 log.success("YAML syntax is valid")
211 return True, config
214# --------------------------------------------------------------------------- #
215# configuration-mode + field validators
216# --------------------------------------------------------------------------- #
217def _validate_profiles_field(log: Log, config: dict[str, Any]) -> bool | None:
218 """None when absent, True/False when present and valid/invalid."""
219 if "profiles" not in config:
220 return None
221 profiles = config["profiles"]
222 if not isinstance(profiles, list):
223 log.error(f"profiles must be a list, got {type(profiles).__name__}")
224 log.error("Example: profiles: [github-project]")
225 return False
226 if not profiles:
227 log.error("profiles list cannot be empty")
228 log.error("Example: profiles: [github-project]")
229 return False
230 for p in profiles:
231 if not isinstance(p, str) or not p.strip():
232 log.error(f"Each entry in profiles must be a non-empty string, got: {p!r}")
233 return False
234 return True
237def _validate_configuration_mode(log: Log, config: dict[str, Any]) -> bool:
238 """Validate the profiles/templates/include selection mode."""
239 log.debug("Validating configuration mode")
240 has_templates = bool(config.get("templates"))
241 has_include = bool(config.get("include"))
243 profiles_valid = _validate_profiles_field(log, config)
244 if profiles_valid is False:
245 return False
246 has_profiles = profiles_valid is True
248 if "bundles" in config:
249 log.error("Field 'bundles' has been renamed to 'templates'")
250 log.error("Update your .rhiza/template.yml: bundles: [...] → templates: [...]")
251 return False
253 if not has_profiles and not has_templates and not has_include:
254 log.error(
255 "Must specify at least one of 'profiles', 'templates', or 'include' in template.yml"
256 )
257 log.error(" • Profile-based: profiles: [github-project]")
258 log.error(" • Template-based: templates: [core, tests, github]")
259 log.error(" • Path-based: include: [.rhiza, .github, ...]")
260 log.error(" • Hybrid: specify both templates and include")
261 return False
263 if has_profiles:
264 log.success(f"Using profile mode (profiles: {config['profiles']})")
265 elif has_templates and has_include:
266 log.success("Using hybrid mode (templates + include)")
267 elif has_templates:
268 log.success("Using template-based mode")
269 else:
270 log.success("Using path-based mode")
271 return True
274def _validate_required_fields(log: Log, config: dict[str, Any]) -> bool:
275 """Require a 'template-repository' or 'repository' string field."""
276 log.debug("Validating required fields")
277 has_template_repo = "template-repository" in config
278 has_repo = "repository" in config
279 if not has_template_repo and not has_repo:
280 log.error("Missing required field: 'template-repository' or 'repository'")
281 log.error("Add 'template-repository' or 'repository' to your template.yml")
282 return False
284 repo_field = "template-repository" if has_template_repo else "repository"
285 repo_value = config[repo_field]
286 if not isinstance(repo_value, str):
287 log.error(f"Field '{repo_field}' must be of type str, got {type(repo_value).__name__}")
288 log.error(f"Fix the type of '{repo_field}' in template.yml")
289 return False
290 log.success(f"Field '{repo_field}' is present and valid")
291 return True
294def _validate_repository_format(log: Log, config: dict[str, Any]) -> bool:
295 """Check the repository field is in 'owner/repo' form."""
296 log.debug("Validating repository format")
297 repo_field = (
298 "template-repository"
299 if "template-repository" in config
300 else "repository"
301 if "repository" in config
302 else None
303 )
304 if repo_field is None:
305 return True # caught by _validate_required_fields
306 repo = config[repo_field]
307 if not isinstance(repo, str):
308 log.error(f"{repo_field} must be a string, got {type(repo).__name__}")
309 log.error("Example: 'owner/repository'")
310 return False
311 if "/" not in repo:
312 log.error(f"{repo_field} must be in format 'owner/repo', got: {repo}")
313 log.error("Example: 'jebel-quant/rhiza'")
314 return False
315 log.success(f"{repo_field} format is valid: {repo}")
316 return True
319def _validate_string_list(log: Log, config: dict[str, Any], field: str, example: str) -> bool:
320 """Shared check for the `templates` / `include` list fields."""
321 log.debug(f"Validating {field} field")
322 if field not in config:
323 return True
324 value = config[field]
325 if not isinstance(value, list):
326 log.error(f"{field} must be a list, got {type(value).__name__}")
327 log.error(f"Example: {example}")
328 return False
329 if len(value) == 0:
330 log.error(f"{field} list cannot be empty")
331 log.error("Add at least one entry to materialize")
332 return False
333 log.success(f"{field} list has {len(value)} entr{'y' if len(value) == 1 else 'ies'}")
334 for item in value:
335 if not isinstance(item, str):
336 log.warning(f"{field} entry should be a string, got {type(item).__name__}: {item}")
337 else:
338 log.info(f" - {item}")
339 return True
342def _validate_branch_field(log: Log, config: dict[str, Any]) -> None:
343 """Warn if the branch/ref field is present but not a string."""
344 branch_field = (
345 "template-branch" if "template-branch" in config else "ref" if "ref" in config else None
346 )
347 if branch_field is None:
348 return
349 branch = config[branch_field]
350 if not isinstance(branch, str):
351 log.warning(f"{branch_field} should be a string, got {type(branch).__name__}: {branch}")
352 log.warning("Example: 'main' or 'develop'")
353 else:
354 log.success(f"{branch_field} is valid: {branch}")
357def _validate_host_field(log: Log, config: dict[str, Any]) -> None:
358 """Warn if template-host is non-string or an unsupported host."""
359 if "template-host" not in config:
360 return
361 host = config["template-host"]
362 if not isinstance(host, str):
363 log.warning(f"template-host should be a string, got {type(host).__name__}: {host}")
364 log.warning("Must be 'github' or 'gitlab'")
365 elif host not in GIT_HOSTS:
366 log.warning(f"template-host should be 'github' or 'gitlab', got: {host}")
367 log.warning("Other hosts are not currently supported")
368 else:
369 log.success(f"template-host is valid: {host}")
372def _validate_language_field(log: Log, config: dict[str, Any]) -> None:
373 """Warn if the language field is non-string or unrecognized."""
374 if "language" not in config:
375 return
376 language = config["language"]
377 if not isinstance(language, str):
378 log.warning(f"language should be a string, got {type(language).__name__}: {language}")
379 log.warning("Example: 'python', 'go'")
380 elif language.lower() not in _VALIDATORS:
381 log.warning(f"language '{language}' is not recognized")
382 log.warning(f"Supported languages: {', '.join(_VALIDATORS)}")
383 else:
384 log.success(f"language is valid: {language}")
387def _validate_exclude_field(log: Log, config: dict[str, Any]) -> None:
388 """Warn if the exclude field is malformed."""
389 if "exclude" not in config:
390 return
391 exclude = config["exclude"]
392 if not isinstance(exclude, list):
393 log.warning(f"exclude should be a list, got {type(exclude).__name__}")
394 log.warning("Example: exclude: ['.github/workflows/ci.yml']")
395 return
396 log.success(f"exclude list has {len(exclude)} path(s)")
397 for path in exclude:
398 if not isinstance(path, str):
399 log.warning(f"exclude path should be a string, got {type(path).__name__}: {path}")
400 else:
401 log.info(f" - {path}")
404def _validate_optional_fields(log: Log, config: dict[str, Any]) -> None:
405 """Run the non-fatal optional-field checks."""
406 log.debug("Validating optional fields")
407 _validate_branch_field(log, config)
408 _validate_host_field(log, config)
409 _validate_language_field(log, config)
410 _validate_exclude_field(log, config)
413# --------------------------------------------------------------------------- #
414# orchestration
415# --------------------------------------------------------------------------- #
416def _load_valid_config(log: Log, target: Path, template_file: Path | None) -> dict[str, Any] | None:
417 """Run the hard-stop preconditions and return the parsed config, else None."""
418 if not _check_git_repository(log, target):
419 return None
420 exists, template_file = _check_template_file_exists(log, target, template_file)
421 if not exists:
422 return None
423 ok, config = _parse_template_file(log, template_file)
424 if not ok or config is None:
425 return None
427 language = config.get("language", "python")
428 log.info(f"Project language: {language}")
429 if not _check_project_structure(log, target, str(language)):
430 return None
431 if not _validate_configuration_mode(log, config):
432 return None
433 return config
436def _validate_config_fields(log: Log, config: dict[str, Any]) -> bool:
437 """Field-level checks; these do NOT short-circuit so all errors surface at once."""
438 passed = _validate_required_fields(log, config)
439 if not _validate_repository_format(log, config):
440 passed = False
441 if config.get("templates") and not _validate_string_list(
442 log, config, "templates", "templates: [core, tests, github]"
443 ):
444 passed = False
445 if config.get("include") and not _validate_string_list(
446 log, config, "include", "include: ['.github', '.gitignore']"
447 ):
448 passed = False
449 _validate_optional_fields(log, config)
450 return passed
453def validate(log: Log, target: Path, template_file: Path | None = None) -> bool:
454 """Validate template.yml; return True on success, False on failure."""
455 target = target.resolve()
456 log.info(f"Validating template configuration in: {target}")
458 config = _load_valid_config(log, target, template_file)
459 if config is None:
460 return False
462 passed = _validate_config_fields(log, config)
463 log.debug("Validation complete, determining final result")
464 if passed:
465 log.success("Validation passed: template.yml is valid")
466 return True
467 log.error("Validation failed: template.yml has errors")
468 log.error("Fix the errors above and run validate again")
469 return False
472def main(argv: list[str] | None = None) -> int:
473 """Entry point: validate, optionally emit JSON, and return an exit code."""
474 parser = argparse.ArgumentParser(
475 description="Validate .rhiza/template.yml configuration.",
476 )
477 parser.add_argument(
478 "target",
479 nargs="?",
480 default=".",
481 help="Repository root to validate (default: current directory).",
482 )
483 parser.add_argument(
484 "--path-to-template",
485 dest="path_to_template",
486 default=None,
487 help="Directory holding template.yml (default: <TARGET>/.rhiza; '.' for the project root).",
488 )
489 parser.add_argument(
490 "--json",
491 dest="json_output",
492 action="store_true",
493 help="Emit {valid, errors, warnings} as a JSON object on stdout.",
494 )
495 parser.add_argument(
496 "--verbose",
497 action="store_true",
498 help="Also print debug-level progress lines.",
499 )
500 args = parser.parse_args(argv)
502 template_file = None
503 if args.path_to_template is not None:
504 template_file = Path(args.path_to_template) / "template.yml"
506 log = Log(verbose=args.verbose)
507 valid = validate(log, Path(args.target), template_file=template_file)
509 if args.json_output:
510 print(
511 json.dumps({"valid": valid, "errors": log.errors, "warnings": log.warnings}, indent=2)
512 )
513 return 0 if valid else 1
516if __name__ == "__main__":
517 raise SystemExit(main())