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
« 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.
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.
11Two things make that hard to catch by hand, and this script addresses both:
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`.
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.
40Probing uses `make -n`, which resolves the target without running any recipe.
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]
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"""
55from __future__ import annotations
57import argparse
58import json
59import os
60import re
61import shutil
62import subprocess # nosec B404
63import sys
64from pathlib import Path
65from typing import Any
67import _make_targets_runner as _runner
68from _make_targets_chain import documented_targets, find_makefile, makefile_chain
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"
79EXIT_OK = 0
80EXIT_UNAVAILABLE = 1
81EXIT_NO_GATES = 2
84def gate_targets(command_file: Path) -> list[str]:
85 """Return the `make` targets named in *command_file*'s numbered gate list.
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
99def target_exists(target_dir: Path, target: str) -> bool:
100 """Is *target* resolvable by make in *target_dir*?
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
121def pinned_task_runner(target_dir: Path) -> str | None:
122 """Return the `rhiza-task` pin *target_dir*'s makefile chain carries, or None.
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))
130def runner_tasks(target_dir: Path) -> list[str] | None:
131 """Return the task names *target_dir*'s pinned `rhiza-task` provides, or None.
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)
140def resolves_everything(target_dir: Path) -> bool:
141 """Does this repo's make answer a target that cannot exist?
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)
154def _delegating_notes(target_dir: Path, targets: list[str]) -> list[str]:
155 """Guidance for a repo whose makefile answers every target.
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 )
168def _probe_notes(available: list[str], unavailable: list[str], undeclared: list[str]) -> list[str]:
169 """Build the guidance notes for a completed probe.
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")
184 if not (undeclared and unavailable):
185 return notes
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
212def _delegating_probe(target_dir: Path, targets: list[str]) -> dict[str, Any]:
213 """The summary for a repo whose makefile answers every target.
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 }
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.
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.
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 }
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 }
299 if find_makefile(target_dir) is None:
300 return _no_makefile_probe(target_dir, targets)
302 if resolves_everything(target_dir):
303 return _delegating_probe(target_dir, targets)
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)
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 }
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"
332def _report_tasks(target_dir: Path) -> int:
333 """Print the pinned runner's tasks one per line; the body of `--tasks`.
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
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)
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())
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
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"])
407if __name__ == "__main__":
408 raise SystemExit(main())