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
« 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.
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.
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.
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"""
22from __future__ import annotations
24import re
25import shutil
26import subprocess # nosec B404
27from collections.abc import Iterable
28from pathlib import Path
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-]*")
45def catch_all_source(makefiles: Iterable[Path]) -> Path | None:
46 """Return the first of *makefiles* that defines a catch-all rule, or None.
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.
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 )
72def pin_from(makefiles: Iterable[Path]) -> str | None:
73 """Return the `rhiza-task` pin *makefiles* carry, or None.
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.
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'
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:
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
100def parse_task_list(output: str) -> list[str]:
101 r"""Return the task names in ``rhiza-task list`` output, in order.
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.
109 >>> parse_task_list(" task section\n book Book\n more text\n test Python")
110 ['book', 'test']
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:
115 >>> parse_task_list(" task section\n\n orphan\n Not-A-Task x\n fmt Quality")
116 ['fmt']
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.
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
141def tasks(pin: str | None, cwd: Path) -> list[str] | None:
142 """Return the task names *pin* provides when run in *cwd*, or None.
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.
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)
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.
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.
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
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:
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 ]