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

1#!/usr/bin/env python3 

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

3 

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. 

9 

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

11 

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. 

19 

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

21 

22Usage: 

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

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

25 [--require] [--json] 

26 

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""" 

32 

33from __future__ import annotations 

34 

35import argparse 

36import json 

37import os 

38import re 

39import shutil 

40import subprocess # nosec B404 

41import sys 

42from pathlib import Path 

43from typing import Any 

44 

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") 

48 

49EXIT_OK = 0 

50EXIT_UNAVAILABLE = 1 

51EXIT_NO_GATES = 2 

52 

53 

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

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

56 

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 

67 

68 

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) 

72 

73 

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

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

76 

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 

94 

95 

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 } 

107 

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 } 

119 

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") 

130 

131 return { 

132 "targets": targets, 

133 "available": available, 

134 "unavailable": unavailable, 

135 "notes": notes, 

136 "exit_code": EXIT_OK, 

137 } 

138 

139 

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) 

161 

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 

170 

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"]) 

180 

181 

182if __name__ == "__main__": 

183 raise SystemExit(main())