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

43 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-09-15 14:46 +0000

1#!/usr/bin/env python3 

2"""The delegation half of `check_make_targets.py`: the shim, its pin, and its catalogue. 

3 

4**Why this is a module and not four more functions in the probe.** Through template v1.3 a 

5repo's gates were `make` targets, so `check_make_targets.py` answered "what gates are 

6there?" by reading the makefile chain — one instrument, one file. Template v1.4 moved the 

7gates into a *pinned CLI* behind a shim whose ``%:`` rule answers every target name, which 

8splits the question in two: reading makefiles now finds only the shim and its pin, and the 

9catalogue has to be asked of the runner. Those are different instruments with different 

10failure modes — a missing pin is not a failed subprocess — and keeping them in one file put 

11it 20% over this repo's 500-line bar. 

12 

13The split is by *concern*: everything about a repo that **delegates** is here — whether its 

14makefile is a shim at all, what it pins, what the runner then offers, and what to advise a 

15caller who finds one. Probing ordinary make targets stays behind. 

16 

17This module never walks the include chain. The caller passes in the chain it already has, 

18so nothing here imports its own orchestrator — the rule `_rhiza_common`'s docstring spells 

19out, and the reason that one exists. 

20""" 

21 

22from __future__ import annotations 

23 

24import re 

25import shutil 

26import subprocess # nosec B404 

27from collections.abc import Iterable 

28from pathlib import Path 

29 

30# A catch-all rule: `%:` (the shim's delegation to its task runner) or `.DEFAULT:`. Both 

31# require the colon to follow the name immediately, which is what keeps `%.o: %.c` — an 

32# ordinary pattern rule — and `.DEFAULT_GOAL := help` out. Used only to *name* the file 

33# in the notes; whether probing works at all is decided by asking make, which is 

34# `check_make_targets.resolves_everything`. 

35_CATCH_ALL = re.compile(r"^(?:%|\.DEFAULT)[ \t]*::?[^=]", re.MULTILINE) 

36# The task runner a v1.4 shim pins: `RHIZA_TASK ?= rhiza-task@1.1.0`. The pin is the whole 

37# point of reading it — `uvx rhiza-task list` answers for whatever release is current, 

38# which is not necessarily the one this repo's gates run under. 

39_TASK_RUNNER_PIN = re.compile(r"^RHIZA_TASK\s*\??=\s*(\S+)", re.MULTILINE) 

40# A plausible task name in `rhiza-task list` output. Deliberately the same shape as a make 

41# target: the names carried over from the make layer unchanged. 

42_TASK_NAME = re.compile(r"[a-z][a-z0-9-]*") 

43 

44 

45def catch_all_source(makefiles: Iterable[Path]) -> Path | None: 

46 """Return the first of *makefiles* that defines a catch-all rule, or None. 

47 

48 Explanatory only — it names a file for the notes. Whether probing can be trusted at 

49 all is `check_make_targets.resolves_everything`'s question, which asks make instead of 

50 reading text, and so catches a catch-all built from variables that this cannot see. 

51 

52 >>> import tempfile, pathlib 

53 >>> d = pathlib.Path(tempfile.mkdtemp()) 

54 >>> mk = d / "Makefile" 

55 >>> _ = mk.write_text(".DEFAULT_GOAL := help") 

56 >>> catch_all_source([mk]) is None 

57 True 

58 >>> _ = mk.write_text("%: ; @uvx rhiza-task $@") 

59 >>> catch_all_source([mk]).name 

60 'Makefile' 

61 """ 

62 return next( 

63 ( 

64 makefile 

65 for makefile in makefiles 

66 if _CATCH_ALL.search(makefile.read_text(encoding="utf-8", errors="ignore")) 

67 ), 

68 None, 

69 ) 

70 

71 

72def pin_from(makefiles: Iterable[Path]) -> str | None: 

73 """Return the `rhiza-task` pin *makefiles* carry, or None. 

74 

75 A v1.4 shim is a delegation to a *pinned* CLI, and that pin is the only thing on disk 

76 saying which task catalogue this repo's gates actually come from. Reading it is what 

77 turns "ask the runner" into a command a caller can run verbatim. 

78 

79 >>> import tempfile, pathlib 

80 >>> d = pathlib.Path(tempfile.mkdtemp()) 

81 >>> mk = d / "Makefile" 

82 >>> _ = mk.write_text("RHIZA_TASK ?= rhiza-task@1.1.0") 

83 >>> pin_from([mk]) 

84 'rhiza-task@1.1.0' 

85 

86 A makefile that defines its targets outright pins nothing, and that is the signal 

87 "this repo does not delegate" rather than an error: 

88 

89 >>> _ = mk.write_text("test: ; @pytest") 

90 >>> pin_from([mk]) is None 

91 True 

92 """ 

93 for makefile in makefiles: 

94 found = _TASK_RUNNER_PIN.search(makefile.read_text(encoding="utf-8", errors="ignore")) 

95 if found: 

96 return found.group(1) 

97 return None 

98 

99 

100def parse_task_list(output: str) -> list[str]: 

101 r"""Return the task names in ``rhiza-task list`` output, in order. 

102 

103 The runner renders a table whose first column is the task name and whose longer rows 

104 wrap into continuation lines with that column blank. There is no machine-readable 

105 mode, so the column is located once from the header and every row is read at that 

106 offset — stable under the width changes that reflow the *other* columns, which is why 

107 this reads a fixed offset instead of splitting on whitespace. 

108 

109 >>> parse_task_list(" task section\n book Book\n more text\n test Python") 

110 ['book', 'test'] 

111 

112 A blank line, a row whose name column is empty, and anything that is not a plausible 

113 task name are all skipped rather than guessed at: 

114 

115 >>> parse_task_list(" task section\n\n orphan\n Not-A-Task x\n fmt Quality") 

116 ['fmt'] 

117 

118 With no header the column is unknown, so nothing is read: an error banner printed where 

119 a table was expected yields no tasks rather than a list of misparsed words. 

120 

121 >>> parse_task_list("error: could not resolve rhiza-task\n") 

122 [] 

123 """ 

124 rows = iter(output.splitlines()) 

125 column = next( 

126 (line.index("task") for line in rows if line.strip().split()[:1] == ["task"]), 

127 None, 

128 ) 

129 if column is None: 

130 return [] 

131 names: list[str] = [] 

132 for line in rows: # `rows` is an iterator, so this resumes after the header 

133 if len(line) <= column or line[column] == " ": 

134 continue 

135 name = line[column:].split()[0] 

136 if _TASK_NAME.fullmatch(name) and name not in names: 

137 names.append(name) 

138 return names 

139 

140 

141def tasks(pin: str | None, cwd: Path) -> list[str] | None: 

142 """Return the task names *pin* provides when run in *cwd*, or None. 

143 

144 Run **in the repo**, because the runner resolves which language layer applies from the 

145 files around it: the same pin lists a different catalogue beside a `Cargo.toml` than 

146 beside a `pyproject.toml`. Asking from anywhere else answers for the wrong layer. 

147 

148 None — never an empty list — whenever the question cannot be asked: no pin (so not a 

149 delegating repo), no ``uvx`` on PATH, or a runner that failed. That is the probe's 

150 "undetermined, not unavailable" distinction, and it matters at the call site: a caller 

151 must not read a failed enumeration as a repo with no gates and score every concern 

152 out-of-scope. 

153 """ 

154 if pin is None: 

155 return None 

156 uvx = shutil.which("uvx") 

157 if uvx is None: 

158 return None 

159 result = subprocess.run( # nosec B603 

160 [uvx, pin, "list"], 

161 cwd=cwd, 

162 capture_output=True, 

163 text=True, 

164 check=False, 

165 ) 

166 if result.returncode != 0: 

167 return None 

168 return parse_task_list(result.stdout) 

169 

170 

171def delegating_notes(gate_count: int, where: str, pin: str | None) -> list[str]: 

172 """The guidance a delegating repo's probe carries, as three notes. 

173 

174 Pure text over three facts, so it lives beside the runner it talks about rather than 

175 beside the makefile reader that discovered them. Every note exists to stop a *scoring* 

176 mistake rather than to describe the repo: "undetermined" is not availability, the 

177 runner is the authority on what exists, and an unknown task was never a gate. 

178 

179 >>> notes = delegating_notes(7, "a catch-all rule in Makefile", "rhiza-task@1.1.0") 

180 >>> len(notes) 

181 3 

182 >>> "all 7 named gate(s)" in notes[0] 

183 True 

184 >>> "uvx rhiza-task@1.1.0 list" in notes[1] 

185 True 

186 

187 With no pin the advice still names a runner, because the shim delegates to one either 

188 way — it just cannot say which release: 

189 

190 >>> "uvx rhiza-task list" in delegating_notes(1, "a catch-all rule", None)[1] 

191 True 

192 """ 

193 runner = pin if pin else "rhiza-task" 

194 return [ 

195 f"this makefile resolves *every* target ({where}), so `make -n` cannot tell a " 

196 f"real gate from a typo — all {gate_count} named gate(s) are reported " 

197 "undetermined rather than available, which is what they are.", 

198 f"enumerate the repo's real tasks with `uvx {runner} list`, which is the " 

199 + ("pin this makefile carries" if pin else "runner a shim delegates to") 

200 + " and the authority on what exists; `make help` shows the same catalogue plus " 

201 "any `local.mk` targets. Match each named gate to a task before running it.", 

202 "a gate that fails with an unknown-task error was never provided: score it " 

203 "out-of-scope, never FAIL.", 

204 ]