Coverage for plugin/scripts/check_make_targets.py: 100%

108 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-10-26 04:21 +0000

1#!/usr/bin/env python3 

2"""Probe the `make` targets a command names — behind `/rhiza:quality`'s step 0. 

3 

4Seven of `/quality`'s gates are `make` targets that the template sync delivers, and those 

5seven are what this probes. It used to run them without checking they existed, and the 

6result was the worst kind of failure: in an unsynced repo all seven returned "No rule to 

7make target", were scored FAIL, and the repo was reported as broken when the truth was 

8that it was unsynced. Six of the seven were absent in this plugin's own repo when that 

9was found; it now carries the v1.4 shim, so all seven come back undetermined instead. 

10 

11Two things make that hard to catch by hand, and this script addresses both: 

12 

13* **The target list is derived from the command's prose**, not duplicated here. It is 

14 parsed out of the numbered gate list in `skills/quality/SKILL.md`, so the probe and the 

15 command cannot drift — add a `make` gate to the prose and it gets probed automatically. 

16 The gate list is longer than the target list: the one entry backed by a bundled 

17 checker rather than a `make` target (the example checker) is shipped with the plugin and 

18 resolves without a sync, so there is nothing to probe and the regex passes over it. 

19* **Availability varies by profile.** `typecheck`, `security` and `docs-coverage` come 

20 from the template's *tests* bundle and `fmt`/`deps` from *core*, so a repo on a 

21 reduced profile legitimately lacks some. An absent target is reported as 

22 **unavailable**, which `/quality` scores as out-of-scope — never as a failure. 

23* **Some makefiles answer everything, and then probing proves nothing.** Template v1.4 

24 retired the make layer for a task runner behind a shim whose `%:` rule forwards any 

25 unknown target, so `make -n <anything>` exits 0. That turned this script's one 

26 instrument into a tautology and produced the *inverse* of the bug above: every gate 

27 reported available, the retired `deptry` alias included, on a repo whose task is 

28 called `deps`, so `/quality` ran a gate that does not exist and scored the runner's 

29 "unknown task" error as a FAIL. Such a repo's gates are reported **undetermined** — 

30 neither available nor a failure — and `_make_targets_runner` is what answers them, 

31 reachable here as `runner_tasks` and on the CLI as `--tasks`. 

32 

33It also **discovers** what the repo documents beyond that list. The prose names the 

34Python profile's gates; a Go or Rust repo synced from a sibling template has different 

35ones, and hard-coding those would mean asserting targets for templates this plugin has 

36never seen. Instead every `target: ## description` in the makefile is read, and the 

37ones the prose didn't name come back as `undeclared` — so a non-Python repo yields real 

38gates to run rather than a report that nothing is available. 

39 

40Probing uses `make -n`, which resolves the target without running any recipe. 

41 

42Usage: 

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

44 scripts/check_make_targets.py [--target-dir DIR] [--from FILE] \ 

45 [--require] [--json] [--tasks] 

46 

47Exit codes: 

48 0 probed successfully (targets may be unavailable or undetermined — neither is an error) 

49 1 no makefile to probe — either an unsynced repo or a v1.4 one that kept no shim, 

50 which the notes tell apart — or --require was given and a target is missing or 

51 undetermined 

52 2 no gate list could be parsed from the command prose 

53""" 

54 

55from __future__ import annotations 

56 

57import argparse 

58import json 

59import os 

60import re 

61import shutil 

62import subprocess # nosec B404 

63import sys 

64from pathlib import Path 

65from typing import Any 

66 

67import _make_targets_runner as _runner 

68from _make_targets_chain import documented_targets, find_makefile, makefile_chain 

69 

70# The numbered gate list in skills/quality/SKILL.md: "1. `make fmt` — …". 

71_GATE = re.compile(r"^\s*\d+\.\s+`make ([a-z][a-z0-9-]*)`", re.MULTILINE) 

72# A target no repository would define, used to ask make whether it answers anything. 

73_SENTINEL_TARGET = "rhiza-probe-no-such-target" 

74# The one artefact every sync writes, at every template version. `.rhiza/rhiza.mk` used 

75# to stand in for it and stopped being written at template v1.4, so it now answers "was 

76# this repo ever synced?" wrongly for every repo on the current template. 

77_LOCK_REL = Path(".rhiza") / "template.lock" 

78 

79EXIT_OK = 0 

80EXIT_UNAVAILABLE = 1 

81EXIT_NO_GATES = 2 

82 

83 

84def gate_targets(command_file: Path) -> list[str]: 

85 """Return the `make` targets named in *command_file*'s numbered gate list. 

86 

87 Parsed rather than hardcoded so the probe follows the prose. Order is preserved, 

88 because `/quality` runs the gates cheapest-first and the report should match. 

89 """ 

90 if not command_file.is_file(): 

91 return [] 

92 seen: list[str] = [] 

93 for target in _GATE.findall(command_file.read_text(encoding="utf-8")): 

94 if target not in seen: 

95 seen.append(target) 

96 return seen 

97 

98 

99def target_exists(target_dir: Path, target: str) -> bool: 

100 """Is *target* resolvable by make in *target_dir*? 

101 

102 ``make -n`` expands the recipe without executing it, so this stays side-effect 

103 free even for targets that would build or install something. 

104 """ 

105 make = shutil.which("make") 

106 if make is None: # pragma: no cover - make is present everywhere this runs 

107 return False 

108 env = os.environ.copy() 

109 env["NO_COLOR"] = "1" 

110 result = subprocess.run( # nosec B603 

111 [make, "-n", target], 

112 cwd=str(target_dir), 

113 capture_output=True, 

114 text=True, 

115 env=env, 

116 check=False, 

117 ) 

118 return result.returncode == 0 

119 

120 

121def pinned_task_runner(target_dir: Path) -> str | None: 

122 """Return the `rhiza-task` pin *target_dir*'s makefile chain carries, or None. 

123 

124 The chain walk is this module's; the pattern and its examples are 

125 `_make_targets_runner.pin_from`'s. This is the seam between them. 

126 """ 

127 return _runner.pin_from(makefile_chain(target_dir)) 

128 

129 

130def runner_tasks(target_dir: Path) -> list[str] | None: 

131 """Return the task names *target_dir*'s pinned `rhiza-task` provides, or None. 

132 

133 The v1.4+ answer to "what gates does this repo have?", where 

134 :func:`documented_targets` was the v1.3 one. `_make_targets_runner.tasks` owns the 

135 behaviour, including why None is not an empty list. 

136 """ 

137 return _runner.tasks(pinned_task_runner(target_dir), target_dir) 

138 

139 

140def resolves_everything(target_dir: Path) -> bool: 

141 """Does this repo's make answer a target that cannot exist? 

142 

143 Behavioural rather than textual, and that is the point. Template v1.4's `Makefile` 

144 is a shim: `%:` forwards every unresolved target to `uvx rhiza-task`, so 

145 :func:`target_exists` returns True for anything at all and the whole probe becomes a 

146 tautology. Asking about a target no repo would define is the one question whose 

147 answer separates the two cases — and it catches a catch-all this parser cannot see, 

148 whether it arrives through an `include` past the depth limit or is built from make 

149 variables. 

150 """ 

151 return target_exists(target_dir, _SENTINEL_TARGET) 

152 

153 

154def _delegating_notes(target_dir: Path, targets: list[str]) -> list[str]: 

155 """Guidance for a repo whose makefile answers every target. 

156 

157 Reads the two facts the notes need out of the chain and hands them to 

158 `_make_targets_runner.delegating_notes`, which owns the wording. 

159 """ 

160 source = _runner.catch_all_source(makefile_chain(target_dir)) 

161 return _runner.delegating_notes( 

162 len(targets), 

163 f"a catch-all rule in {source.name}" if source else "a catch-all rule", 

164 pinned_task_runner(target_dir), 

165 ) 

166 

167 

168def _probe_notes(available: list[str], unavailable: list[str], undeclared: list[str]) -> list[str]: 

169 """Build the guidance notes for a completed probe. 

170 

171 Every note here exists to stop a *scoring* mistake rather than to describe the repo: 

172 an absent target is out-of-scope, never a FAIL, and a repo whose real gates sit under 

173 other names must not be reported as unscoreable. 

174 """ 

175 notes: list[str] = [] 

176 if unavailable: 

177 notes.append( 

178 f"{len(unavailable)} target(s) not defined for this profile — score them " 

179 "out-of-scope, never FAIL: " + ", ".join(unavailable) 

180 ) 

181 if not available: 

182 notes.append("no gate is available — check that the template sync completed") 

183 

184 if not (undeclared and unavailable): 

185 return notes 

186 

187 # Deliberately "most missing" rather than "all missing": a Go or Rust template will 

188 # almost certainly define `test`, so requiring zero matches would keep the hint 

189 # hidden from exactly the repos it exists for. 

190 if len(unavailable) > len(available): 

191 notes.append( 

192 f"most named gates are absent, but this repo documents {len(undeclared)} " 

193 "other target(s). If this is a Go, Rust or non-standard template, those are " 

194 "its real gates — run the relevant ones from `undeclared` and score them, " 

195 "rather than reporting that nothing could be checked." 

196 ) 

197 else: 

198 # The milder case: most named gates resolve (`fmt`, `test`, `typecheck` are named 

199 # the same in every language layer) while the one that doesn't has a 

200 # differently-named analogue sitting in `undeclared`. `deptry`/`deps` used to be 

201 # the example and no longer is — the prose names `deps`, which every language layer 

202 # agrees on — but the shape recurs whenever a template renames a target, and scoring 

203 # the absent one out-of-scope would silently skip a gate the repo does provide. 

204 notes.append( 

205 f"{len(unavailable)} named gate(s) are absent while this repo documents " 

206 f"{len(undeclared)} other target(s) — check `undeclared` for the equivalent " 

207 "under a different name and score that instead of skipping the concern." 

208 ) 

209 return notes 

210 

211 

212def _delegating_probe(target_dir: Path, targets: list[str]) -> dict[str, Any]: 

213 """The summary for a repo whose makefile answers every target. 

214 

215 Nothing is `available`, and reporting otherwise is the worse error of the two: a 

216 shim says yes to every probe, so trusting it is how the retired `deptry` alias came 

217 back available on a repo whose only task is `deps`. What survives is `undeclared` — 

218 the `##` convention still reads, and on a shim the repo-owned targets at the foot of 

219 the file are the only ones anything on disk can vouch for. 

220 """ 

221 documented = documented_targets(target_dir) 

222 return { 

223 "targets": targets, 

224 "available": [], 

225 "unavailable": [], 

226 "undetermined": targets, 

227 "undeclared": sorted(name for name in documented if name not in targets), 

228 "documented": documented, 

229 "notes": _delegating_notes(target_dir, targets), 

230 "exit_code": EXIT_OK, 

231 } 

232 

233 

234def _no_makefile_probe(target_dir: Path, targets: list[str]) -> dict[str, Any]: 

235 """The summary for a repo with no makefile at all — which is now two repos. 

236 

237 Until template v1.4 there was only one: an unsynced repo, whose gates genuinely do 

238 not exist anywhere and whose fix is `/rhiza:update`. v1.4 retired the make layer for 

239 a pinned task runner and stopped shipping a `Makefile`, so a repo that is fully 

240 synced and up to date reaches this branch too — and telling *that* repo to sync is 

241 both wrong and unactionable. `.rhiza/template.lock` separates them, because a sync 

242 writes it at every version. 

243 

244 The gates of a synced repo are reported **undetermined** rather than unavailable, for 

245 the same reason a shim's are: nothing here can see the task runner's catalogue, so 

246 "absent" is a claim this probe cannot make. What it can do is say where to look. 

247 """ 

248 if not (target_dir / _LOCK_REL).is_file(): 

249 return { 

250 "targets": targets, 

251 "available": [], 

252 "unavailable": targets, 

253 "undetermined": [], 

254 "undeclared": [], 

255 "documented": {}, 

256 "notes": [ 

257 "no makefile and no `.rhiza/template.lock` — the repo is not synced, so " 

258 "every gate is unavailable. Run /rhiza:update before scoring." 

259 ], 

260 "exit_code": EXIT_UNAVAILABLE, 

261 } 

262 return { 

263 "targets": targets, 

264 "available": [], 

265 "unavailable": [], 

266 "undetermined": targets, 

267 "undeclared": [], 

268 "documented": {}, 

269 "notes": [ 

270 "no makefile, but `.rhiza/template.lock` is present: this repo *is* synced. " 

271 "Template v1.4 retired the make layer for a pinned task runner and this repo " 

272 "kept no shim `Makefile`, so all " 

273 f"{len(targets)} named gate(s) are undetermined rather than unavailable — " 

274 "they moved, they are not missing. Do not run /rhiza:update over this.", 

275 "enumerate the real tasks with `uvx rhiza-task list` and run each gate as " 

276 "`uvx rhiza-task <task>`, matching it to a task first. There is no makefile " 

277 "to read a pin out of, so that resolves to the current release.", 

278 "a gate with no matching task was never provided: score it out-of-scope, never FAIL.", 

279 ], 

280 "exit_code": EXIT_UNAVAILABLE, 

281 } 

282 

283 

284def probe(target_dir: Path, command_file: Path) -> dict[str, Any]: 

285 """Probe every gate target named in *command_file*; return a summary dict.""" 

286 targets = gate_targets(command_file) 

287 if not targets: 

288 return { 

289 "targets": [], 

290 "available": [], 

291 "unavailable": [], 

292 "undetermined": [], 

293 "undeclared": [], 

294 "documented": {}, 

295 "notes": [f"no `make <target>` gate list found in {command_file.name}"], 

296 "exit_code": EXIT_NO_GATES, 

297 } 

298 

299 if find_makefile(target_dir) is None: 

300 return _no_makefile_probe(target_dir, targets) 

301 

302 if resolves_everything(target_dir): 

303 return _delegating_probe(target_dir, targets) 

304 

305 documented = documented_targets(target_dir) 

306 available = [t for t in targets if target_exists(target_dir, t)] 

307 unavailable = [t for t in targets if t not in available] 

308 # Targets the repo documents that the prose never named. On a Python repo this is 

309 # usually noise (`book`, `clean`); on a Go or Rust one it is where the real gates 

310 # are, because the prose list describes a template this repo isn't using. 

311 undeclared = sorted(name for name in documented if name not in targets) 

312 

313 return { 

314 "targets": targets, 

315 "available": available, 

316 "unavailable": unavailable, 

317 "undetermined": [], 

318 "undeclared": undeclared, 

319 "documented": documented, 

320 "notes": _probe_notes(available, unavailable, undeclared), 

321 "exit_code": EXIT_OK, 

322 } 

323 

324 

325def _state(target: str, summary: dict[str, Any]) -> str: 

326 """How *target* is labelled in the text report.""" 

327 if target in summary["undetermined"]: 

328 return "undetermined" 

329 return "available" if target in summary["available"] else "unavailable" 

330 

331 

332def _report_tasks(target_dir: Path) -> int: 

333 """Print the pinned runner's tasks one per line; the body of `--tasks`. 

334 

335 Exits 1 with a reason rather than printing nothing and exiting 0. Silence at 0 reads 

336 as "this repo has no tasks", which is the misreading `runner_tasks`' None return 

337 exists to prevent — so the CLI must not launder it into an empty list. 

338 """ 

339 found = runner_tasks(target_dir) 

340 if found is None: 

341 print( 

342 "could not enumerate tasks: no rhiza-task pin, no uvx, or the runner failed", 

343 file=sys.stderr, 

344 ) 

345 return EXIT_UNAVAILABLE 

346 for task in found: 

347 print(task) 

348 return EXIT_OK 

349 

350 

351def main(argv: list[str] | None = None) -> int: 

352 """Entry point: probe the gate targets and return an exit code.""" 

353 parser = argparse.ArgumentParser( 

354 description="Probe the make targets a rhiza command names as its gates.", 

355 ) 

356 parser.add_argument("--target-dir", default=".", help="Repository root (default: cwd).") 

357 parser.add_argument( 

358 "--from", 

359 dest="command_file", 

360 default=None, 

361 help="Command file to read the gate list from (default: the bundled quality.md).", 

362 ) 

363 parser.add_argument( 

364 "--require", 

365 action="store_true", 

366 help="Exit non-zero unless every target was confirmed present.", 

367 ) 

368 parser.add_argument( 

369 "--json", dest="json_output", action="store_true", help="Emit the summary as JSON." 

370 ) 

371 parser.add_argument( 

372 "--tasks", 

373 action="store_true", 

374 help="List the tasks the repo's pinned rhiza-task provides, one per line, and exit.", 

375 ) 

376 args = parser.parse_args(argv) 

377 

378 # Answered before the probe, and instead of it: on a shim repo the probe can only say 

379 # "undetermined", and this is the question a caller asks next. 

380 if args.tasks: 

381 return _report_tasks(Path(args.target_dir).resolve()) 

382 

383 command_file = ( 

384 Path(args.command_file) 

385 if args.command_file 

386 else Path(__file__).resolve().parent.parent / "skills" / "quality" / "SKILL.md" 

387 ) 

388 summary = probe(Path(args.target_dir).resolve(), command_file) 

389 # `undetermined` fails `--require` too. The flag asks whether every gate is there, 

390 # and a makefile that answers everything cannot say — treating "could not tell" as 

391 # "yes" is the whole defect this reports. 

392 if args.require and (summary["unavailable"] or summary["undetermined"]): 

393 summary["exit_code"] = EXIT_UNAVAILABLE 

394 

395 if args.json_output: 

396 print(json.dumps(summary, indent=2)) 

397 else: 

398 for target in summary["targets"]: 

399 print(f"{_state(target, summary):<12} make {target}") 

400 for target in summary["undeclared"]: 

401 print(f"{'discovered':<12} make {target} # {summary['documented'][target]}") 

402 for note in summary["notes"]: 

403 print(f"note {note}", file=sys.stderr) 

404 return int(summary["exit_code"]) 

405 

406 

407if __name__ == "__main__": 

408 raise SystemExit(main())