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
« 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.
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
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"
89EXIT_OK = 0
90EXIT_UNAVAILABLE = 1
91EXIT_NO_GATES = 2
94def gate_targets(command_file: Path) -> list[str]:
95 """Return the `make` targets named in *command_file*'s numbered gate list.
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
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)
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.
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.
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.
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()
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)
157 _walk(root, depth)
158 return chain
161def documented_targets(target_dir: Path) -> dict[str, str]:
162 """Return the repo's self-documenting `make` targets, mapped to their descriptions.
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.
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
183def target_exists(target_dir: Path, target: str) -> bool:
184 """Is *target* resolvable by make in *target_dir*?
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
205def pinned_task_runner(target_dir: Path) -> str | None:
206 """Return the `rhiza-task` pin *target_dir*'s makefile chain carries, or None.
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))
214def runner_tasks(target_dir: Path) -> list[str] | None:
215 """Return the task names *target_dir*'s pinned `rhiza-task` provides, or None.
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)
224def resolves_everything(target_dir: Path) -> bool:
225 """Does this repo's make answer a target that cannot exist?
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)
238def _delegating_notes(target_dir: Path, targets: list[str]) -> list[str]:
239 """Guidance for a repo whose makefile answers every target.
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 )
252def _probe_notes(available: list[str], unavailable: list[str], undeclared: list[str]) -> list[str]:
253 """Build the guidance notes for a completed probe.
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")
268 if not (undeclared and unavailable):
269 return notes
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
296def _delegating_probe(target_dir: Path, targets: list[str]) -> dict[str, Any]:
297 """The summary for a repo whose makefile answers every target.
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 }
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.
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.
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 }
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 }
383 if find_makefile(target_dir) is None:
384 return _no_makefile_probe(target_dir, targets)
386 if resolves_everything(target_dir):
387 return _delegating_probe(target_dir, targets)
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)
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 }
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"
416def _report_tasks(target_dir: Path) -> int:
417 """Print the pinned runner's tasks one per line; the body of `--tasks`.
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
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)
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())
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
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"])
491if __name__ == "__main__":
492 raise SystemExit(main())