Coverage for scripts/check_make_targets.py: 100%
64 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-31 06:18 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-31 06:18 +0000
1#!/usr/bin/env python3
2"""Probe the `make` targets a command names — behind `/rhiza:quality`'s step 0.
4`/quality` runs seven gates, all of them `make` targets that the template sync
5delivers. It used to run them without checking they existed, and the result was the
6worst kind of failure: in an unsynced repo all seven returned "No rule to make
7target", were scored FAIL, and the repo was reported as broken when the truth was
8that it was unsynced. Six of the seven are absent in this plugin's own repo.
10Two things make that hard to catch by hand, and this script addresses both:
12* **The target list is derived from the command's prose**, not duplicated here. It is
13 parsed out of the numbered gate list in `commands/quality.md`, so the probe and the
14 command cannot drift — add a gate to the prose and it gets probed automatically.
15* **Availability varies by profile.** `typecheck`, `security` and `docs-coverage` come
16 from the template's *tests* bundle and `fmt`/`deptry` from *core*, so a repo on a
17 reduced profile legitimately lacks some. An absent target is reported as
18 **unavailable**, which `/quality` scores as out-of-scope — never as a failure.
20Probing uses `make -n`, which resolves the target without running any recipe.
22Usage:
23 uv run --python 3.12 --no-project python \
24 scripts/check_make_targets.py [--target-dir DIR] [--from FILE] \
25 [--require] [--json]
27Exit codes:
28 0 probed successfully (some targets may be unavailable — that is not an error)
29 1 no makefile to probe, or --require was given and a target is missing
30 2 no gate list could be parsed from the command prose
31"""
33from __future__ import annotations
35import argparse
36import json
37import os
38import re
39import shutil
40import subprocess # nosec B404
41import sys
42from pathlib import Path
43from typing import Any
45# The numbered gate list in commands/quality.md: "1. `make fmt` — …".
46_GATE = re.compile(r"^\s*\d+\.\s+`make ([a-z][a-z0-9-]*)`", re.MULTILINE)
47_MAKEFILES = ("Makefile", "makefile", "GNUmakefile")
49EXIT_OK = 0
50EXIT_UNAVAILABLE = 1
51EXIT_NO_GATES = 2
54def gate_targets(command_file: Path) -> list[str]:
55 """Return the `make` targets named in *command_file*'s numbered gate list.
57 Parsed rather than hardcoded so the probe follows the prose. Order is preserved,
58 because `/quality` runs the gates cheapest-first and the report should match.
59 """
60 if not command_file.is_file():
61 return []
62 seen: list[str] = []
63 for target in _GATE.findall(command_file.read_text()):
64 if target not in seen:
65 seen.append(target)
66 return seen
69def find_makefile(target_dir: Path) -> Path | None:
70 """Return the repo's makefile, or None when there isn't one."""
71 return next((target_dir / n for n in _MAKEFILES if (target_dir / n).is_file()), None)
74def target_exists(target_dir: Path, target: str) -> bool:
75 """Is *target* resolvable by make in *target_dir*?
77 ``make -n`` expands the recipe without executing it, so this stays side-effect
78 free even for targets that would build or install something.
79 """
80 make = shutil.which("make")
81 if make is None: # pragma: no cover - make is present everywhere this runs
82 return False
83 env = os.environ.copy()
84 env["NO_COLOR"] = "1"
85 result = subprocess.run( # nosec B603
86 [make, "-n", target],
87 cwd=str(target_dir),
88 capture_output=True,
89 text=True,
90 env=env,
91 check=False,
92 )
93 return result.returncode == 0
96def probe(target_dir: Path, command_file: Path) -> dict[str, Any]:
97 """Probe every gate target named in *command_file*; return a summary dict."""
98 targets = gate_targets(command_file)
99 if not targets:
100 return {
101 "targets": [],
102 "available": [],
103 "unavailable": [],
104 "notes": [f"no `make <target>` gate list found in {command_file.name}"],
105 "exit_code": EXIT_NO_GATES,
106 }
108 if find_makefile(target_dir) is None:
109 return {
110 "targets": targets,
111 "available": [],
112 "unavailable": targets,
113 "notes": [
114 "no makefile — the repo is not synced, so every gate is unavailable. "
115 "Run /rhiza:update before scoring."
116 ],
117 "exit_code": EXIT_UNAVAILABLE,
118 }
120 available = [t for t in targets if target_exists(target_dir, t)]
121 unavailable = [t for t in targets if t not in available]
122 notes: list[str] = []
123 if unavailable:
124 notes.append(
125 f"{len(unavailable)} target(s) not defined for this profile — score them "
126 "out-of-scope, never FAIL: " + ", ".join(unavailable)
127 )
128 if not available:
129 notes.append("no gate is available — check that the template sync completed")
131 return {
132 "targets": targets,
133 "available": available,
134 "unavailable": unavailable,
135 "notes": notes,
136 "exit_code": EXIT_OK,
137 }
140def main(argv: list[str] | None = None) -> int:
141 """Entry point: probe the gate targets and return an exit code."""
142 parser = argparse.ArgumentParser(
143 description="Probe the make targets a rhiza command names as its gates.",
144 )
145 parser.add_argument("--target-dir", default=".", help="Repository root (default: cwd).")
146 parser.add_argument(
147 "--from",
148 dest="command_file",
149 default=None,
150 help="Command file to read the gate list from (default: the bundled quality.md).",
151 )
152 parser.add_argument(
153 "--require",
154 action="store_true",
155 help="Exit non-zero when any target is unavailable (for a repo that expects all).",
156 )
157 parser.add_argument(
158 "--json", dest="json_output", action="store_true", help="Emit the summary as JSON."
159 )
160 args = parser.parse_args(argv)
162 command_file = (
163 Path(args.command_file)
164 if args.command_file
165 else Path(__file__).resolve().parent.parent / "commands" / "quality.md"
166 )
167 summary = probe(Path(args.target_dir).resolve(), command_file)
168 if args.require and summary["unavailable"]:
169 summary["exit_code"] = EXIT_UNAVAILABLE
171 if args.json_output:
172 print(json.dumps(summary, indent=2))
173 else:
174 for target in summary["targets"]:
175 state = "available" if target in summary["available"] else "unavailable"
176 print(f"{state:<12} make {target}")
177 for note in summary["notes"]:
178 print(f"note {note}", file=sys.stderr)
179 return int(summary["exit_code"])
182if __name__ == "__main__":
183 raise SystemExit(main())