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

141 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-15 14:46 +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 

68 

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

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

71_MAKEFILES = ("Makefile", "makefile", "GNUmakefile") 

72# A self-documenting target — `test: ## Run the suite` — the convention every rhiza 

73# Makefile uses for `make help`. Undocumented internal targets are deliberately not 

74# matched: they are not gates anyone meant to expose. `test::` (a double-colon rule, 

75# which rust.mk uses) counts too. 

76_DOCUMENTED = re.compile(r"^([a-z][a-z0-9_-]*)::?.*?##\s*(.+)$", re.MULTILINE) 

77# An `include`/`-include` line, with its (possibly glob) operands. 

78_INCLUDE = re.compile(r"^\s*-?include\s+(.+?)\s*$", re.MULTILINE) 

79# How deep to follow includes. Makefile -> .rhiza/rhiza.mk -> .rhiza/make.d/*.mk is two, 

80# so three leaves room without risking a pathological chain. 

81_INCLUDE_DEPTH = 3 

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

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

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

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

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

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

88 

89EXIT_OK = 0 

90EXIT_UNAVAILABLE = 1 

91EXIT_NO_GATES = 2 

92 

93 

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

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

96 

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

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

99 """ 

100 if not command_file.is_file(): 

101 return [] 

102 seen: list[str] = [] 

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

104 if target not in seen: 

105 seen.append(target) 

106 return seen 

107 

108 

109def find_makefile(target_dir: Path) -> Path | None: 

110 """Return the repo's makefile, or None when there isn't one.""" 

111 return next((target_dir / n for n in _MAKEFILES if (target_dir / n).is_file()), None) 

112 

113 

114def makefile_chain(target_dir: Path, *, depth: int = _INCLUDE_DEPTH) -> list[Path]: 

115 """Return the repo's makefile plus the files it ``include``s, in reading order. 

116 

117 **Reading only the root makefile finds nothing on a pre-v1.4 repo.** Up to template 

118 v1.3 a synced repo's `Makefile` was a stub — a few variables and 

119 `include .rhiza/rhiza.mk` — which in turn ended with `-include .rhiza/make.d/*.mk`, 

120 and *that* is where every gate lived. Probing was unaffected (``make -n`` follows 

121 includes itself), but discovery read one file where make reads a dozen, so a synced 

122 Rust repo reported zero discovered targets while `.rhiza/make.d/rust.mk` was sitting 

123 there defining `deps`, `license` and `coverage`. The mechanism that exists to stop 

124 `/quality` reporting "nothing could be checked" was doing exactly that. 

125 

126 Template v1.4 retired that layer: neither file is shipped any more, the gates are 

127 `rhiza-task` tasks, and the `Makefile` is a shim that forwards to the pinned CLI. 

128 Following includes is kept because a repo may pin any ref, so both shapes are live — 

129 and on a current one the chain is simply the root makefile plus whatever `local.mk` 

130 the repo added itself. 

131 

132 Globs are expanded and each file is visited once. An operand containing `$` is 

133 skipped: it is a make variable this parser cannot resolve, and guessing is worse 

134 than omitting. 

135 """ 

136 root = find_makefile(target_dir) 

137 if root is None: 

138 return [] 

139 chain: list[Path] = [] 

140 seen: set[Path] = set() 

141 

142 def _walk(path: Path, remaining: int) -> None: 

143 resolved = path.resolve() 

144 if resolved in seen or not path.is_file(): 

145 return 

146 seen.add(resolved) 

147 chain.append(path) 

148 if remaining <= 0: 

149 return 

150 for operands in _INCLUDE.findall(path.read_text(encoding="utf-8", errors="ignore")): 

151 for operand in operands.split(): 

152 if "$" in operand: 

153 continue 

154 for included in sorted(target_dir.glob(operand)): 

155 _walk(included, remaining - 1) 

156 

157 _walk(root, depth) 

158 return chain 

159 

160 

161def documented_targets(target_dir: Path) -> dict[str, str]: 

162 """Return the repo's self-documenting `make` targets, mapped to their descriptions. 

163 

164 The gate list in the prose is the **Python** profile. A Go or Rust repo synced from 

165 a sibling template offers a different set, and naming those from a table here would 

166 mean asserting targets for templates this plugin has never seen — the failure mode 

167 that had `/quality` scoring repos against gates that did not exist. 

168 

169 So they are discovered instead, from the ``target: ## description`` convention every 

170 rhiza Makefile uses to build ``make help`` — across the whole include chain, because 

171 that is where make itself looks (see :func:`makefile_chain`). What comes back is what 

172 the repo really offers, whatever language it is. 

173 """ 

174 found: dict[str, str] = {} 

175 for makefile in makefile_chain(target_dir): 

176 for name, description in _DOCUMENTED.findall( 

177 makefile.read_text(encoding="utf-8", errors="ignore") 

178 ): 

179 found.setdefault(name, description.strip()) 

180 return found 

181 

182 

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

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

185 

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

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

188 """ 

189 make = shutil.which("make") 

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

191 return False 

192 env = os.environ.copy() 

193 env["NO_COLOR"] = "1" 

194 result = subprocess.run( # nosec B603 

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

196 cwd=str(target_dir), 

197 capture_output=True, 

198 text=True, 

199 env=env, 

200 check=False, 

201 ) 

202 return result.returncode == 0 

203 

204 

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

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

207 

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

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

210 """ 

211 return _runner.pin_from(makefile_chain(target_dir)) 

212 

213 

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

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

216 

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

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

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

220 """ 

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

222 

223 

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

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

226 

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

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

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

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

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

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

233 variables. 

234 """ 

235 return target_exists(target_dir, _SENTINEL_TARGET) 

236 

237 

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

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

240 

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

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

243 """ 

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

245 return _runner.delegating_notes( 

246 len(targets), 

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

248 pinned_task_runner(target_dir), 

249 ) 

250 

251 

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

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

254 

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

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

257 other names must not be reported as unscoreable. 

258 """ 

259 notes: list[str] = [] 

260 if unavailable: 

261 notes.append( 

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

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

264 ) 

265 if not available: 

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

267 

268 if not (undeclared and unavailable): 

269 return notes 

270 

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

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

273 # hidden from exactly the repos it exists for. 

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

275 notes.append( 

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

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

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

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

280 ) 

281 else: 

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

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

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

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

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

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

288 notes.append( 

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

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

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

292 ) 

293 return notes 

294 

295 

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

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

298 

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

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

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

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

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

304 """ 

305 documented = documented_targets(target_dir) 

306 return { 

307 "targets": targets, 

308 "available": [], 

309 "unavailable": [], 

310 "undetermined": targets, 

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

312 "documented": documented, 

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

314 "exit_code": EXIT_OK, 

315 } 

316 

317 

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

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

320 

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

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

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

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

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

326 writes it at every version. 

327 

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

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

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

331 """ 

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

333 return { 

334 "targets": targets, 

335 "available": [], 

336 "unavailable": targets, 

337 "undetermined": [], 

338 "undeclared": [], 

339 "documented": {}, 

340 "notes": [ 

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

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

343 ], 

344 "exit_code": EXIT_UNAVAILABLE, 

345 } 

346 return { 

347 "targets": targets, 

348 "available": [], 

349 "unavailable": [], 

350 "undetermined": targets, 

351 "undeclared": [], 

352 "documented": {}, 

353 "notes": [ 

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

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

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

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

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

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

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

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

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

363 ], 

364 "exit_code": EXIT_UNAVAILABLE, 

365 } 

366 

367 

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

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

370 targets = gate_targets(command_file) 

371 if not targets: 

372 return { 

373 "targets": [], 

374 "available": [], 

375 "unavailable": [], 

376 "undetermined": [], 

377 "undeclared": [], 

378 "documented": {}, 

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

380 "exit_code": EXIT_NO_GATES, 

381 } 

382 

383 if find_makefile(target_dir) is None: 

384 return _no_makefile_probe(target_dir, targets) 

385 

386 if resolves_everything(target_dir): 

387 return _delegating_probe(target_dir, targets) 

388 

389 documented = documented_targets(target_dir) 

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

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

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

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

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

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

396 

397 return { 

398 "targets": targets, 

399 "available": available, 

400 "unavailable": unavailable, 

401 "undetermined": [], 

402 "undeclared": undeclared, 

403 "documented": documented, 

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

405 "exit_code": EXIT_OK, 

406 } 

407 

408 

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

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

411 if target in summary["undetermined"]: 

412 return "undetermined" 

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

414 

415 

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

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

418 

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

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

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

422 """ 

423 found = runner_tasks(target_dir) 

424 if found is None: 

425 print( 

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

427 file=sys.stderr, 

428 ) 

429 return EXIT_UNAVAILABLE 

430 for task in found: 

431 print(task) 

432 return EXIT_OK 

433 

434 

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

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

437 parser = argparse.ArgumentParser( 

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

439 ) 

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

441 parser.add_argument( 

442 "--from", 

443 dest="command_file", 

444 default=None, 

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

446 ) 

447 parser.add_argument( 

448 "--require", 

449 action="store_true", 

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

451 ) 

452 parser.add_argument( 

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

454 ) 

455 parser.add_argument( 

456 "--tasks", 

457 action="store_true", 

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

459 ) 

460 args = parser.parse_args(argv) 

461 

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

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

464 if args.tasks: 

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

466 

467 command_file = ( 

468 Path(args.command_file) 

469 if args.command_file 

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

471 ) 

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

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

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

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

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

477 summary["exit_code"] = EXIT_UNAVAILABLE 

478 

479 if args.json_output: 

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

481 else: 

482 for target in summary["targets"]: 

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

484 for target in summary["undeclared"]: 

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

486 for note in summary["notes"]: 

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

488 return int(summary["exit_code"]) 

489 

490 

491if __name__ == "__main__": 

492 raise SystemExit(main())