Coverage for scripts/validate.py: 100%

316 statements  

« 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. 

3 

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. 

11 

12Usage: 

13 uv run --python 3.12 --no-project python \ 

14 scripts/validate.py [TARGET] [--path-to-template DIR] [--json] 

15 

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 

21 

22Exit code is 0 when validation passes, 1 when it fails — same contract as 

23`rhiza validate`, so it drops into CI unchanged. 

24""" 

25 

26from __future__ import annotations 

27 

28import argparse 

29import json 

30import sys 

31from pathlib import Path 

32from typing import Any 

33 

34sys.path.insert(0, str(Path(__file__).resolve().parent)) 

35from _rhiza_yaml import load_yaml # noqa: E402 

36 

37# Hosts the template may target; mirrors rhiza.models.template.GitHost. 

38GIT_HOSTS = ("github", "gitlab") 

39 

40 

41# --------------------------------------------------------------------------- # 

42# logging shim 

43# --------------------------------------------------------------------------- # 

44class Log: 

45 """Tiny stand-in for the CLI's loguru sink. 

46 

47 Prints human-readable, symbol-prefixed lines to stderr and, so `--json` 

48 can report a structured verdict, accumulates the ERROR/WARNING messages. 

49 """ 

50 

51 _SYMBOLS = {"error": "✗", "warning": "!", "success": "✓", "info": " ", "debug": " "} 

52 

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 

58 

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) 

64 

65 def error(self, message: str) -> None: 

66 """Record and print an error.""" 

67 self.errors.append(message) 

68 self._emit("error", message) 

69 

70 def warning(self, message: str) -> None: 

71 """Record and print a warning.""" 

72 self.warnings.append(message) 

73 self._emit("warning", message) 

74 

75 def success(self, message: str) -> None: 

76 """Print a success line.""" 

77 self._emit("success", message) 

78 

79 def info(self, message: str) -> None: 

80 """Print an info line.""" 

81 self._emit("info", message) 

82 

83 def debug(self, message: str) -> None: 

84 """Print a debug line (verbose only).""" 

85 self._emit("debug", message) 

86 

87 

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'}") 

101 

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 

110 

111 

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'}") 

122 

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}") 

129 

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 

141 

142 

143# Registry of language -> structure validator; extend here to add a language. 

144_VALIDATORS = {"python": _validate_python_structure, "go": _validate_go_structure} 

145 

146 

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) 

157 

158 

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 

169 

170 

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 

190 

191 

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 

204 

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 

209 

210 log.success("YAML syntax is valid") 

211 return True, config 

212 

213 

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 

235 

236 

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")) 

242 

243 profiles_valid = _validate_profiles_field(log, config) 

244 if profiles_valid is False: 

245 return False 

246 has_profiles = profiles_valid is True 

247 

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 

252 

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 

262 

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 

272 

273 

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 

283 

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 

292 

293 

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 

317 

318 

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 

340 

341 

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}") 

355 

356 

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}") 

370 

371 

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}") 

385 

386 

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}") 

402 

403 

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) 

411 

412 

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 

426 

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 

434 

435 

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 

451 

452 

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}") 

457 

458 config = _load_valid_config(log, target, template_file) 

459 if config is None: 

460 return False 

461 

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 

470 

471 

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) 

501 

502 template_file = None 

503 if args.path_to_template is not None: 

504 template_file = Path(args.path_to_template) / "template.yml" 

505 

506 log = Log(verbose=args.verbose) 

507 valid = validate(log, Path(args.target), template_file=template_file) 

508 

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 

514 

515 

516if __name__ == "__main__": 

517 raise SystemExit(main())