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

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 

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 

45 

46__all__ = ["Log", "main", "validate"] 

47 

48 

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 

59 

60 

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 

82 

83 

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 

96 

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 

104 

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

106 return True, config 

107 

108 

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 

122 

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 

130 

131 

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 

147 

148 

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

153 

154 config = _load_valid_config(log, target, template_file) 

155 if config is None: 

156 return False 

157 

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 

166 

167 

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) 

197 

198 template_file = None 

199 if args.path_to_template is not None: 

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

201 

202 log = Log(verbose=args.verbose) 

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

204 

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 

210 

211 

212if __name__ == "__main__": 

213 raise SystemExit(main())