Coverage for plugin/scripts/_issue_signals.py: 100%
58 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-26 04:21 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-10-26 04:21 +0000
1#!/usr/bin/env python3
2"""What an issue's *text* says about whether it can be fixed without a judgement call.
4``issue_status.py``'s other half. That module talks to a forge — two CLIs that disagree
5about the shape of an issue — and this one never does: everything here is a pure
6function over a title, a body and the repo's lock file, which is why it carries the
7doctests and why it can be covered exhaustively.
9**The discriminator is the acceptance criterion, not the size of the issue.** The
10tempting signal is length — a two-line deletion feels safe and a nine-point rewrite
11feels risky — and it is the wrong one in both directions. What actually separates an
12issue a command may fix from one it may not is whether the issue says what "done" means
13and whether that sentence admits exactly one outcome. ``/rhiza:quality`` writes such a
14sentence (``prompts/scorecard.md`` §4 requires a ``done when…``), so its own issues are
15the easy case; a hand-written issue that happens to carry one is just as fixable, and
16one of its own that offers the reader a choice is not.
18Three issues filed by the same ``/quality`` run make the point:
20 #95 "done when… `make test` runs without the warning" one outcome
21 #94 "done when… every claim above matches the repo" one outcome
22 #96 "done when… **either** plot.py is under ~250 lines … two outcomes
23 **or** the split is judged not worth it"
25#96 names six exact functions and a target file. It is the most mechanically specified
26of the three and it is the one that must not be fixed, because the issue says the
27non-split is an equally good outcome. **A disjunction in the acceptance criterion is
28disqualifying on its own**, whatever the rest of the body looks like.
30The categories are ordered worst-first and the first match wins, so a single decision
31marker outranks every mechanical signal beneath it. The category this module derives is
32a *starting point*: ``skills/fix/SKILL.md`` re-verifies each claim against the tree and
33may demote it. Nothing here may promote an issue that the prose demoted.
34"""
36from __future__ import annotations
38import re
39from typing import Any
41# Worst-first. `classify` returns the first that matches, so the order is the policy.
42CATEGORIES = ("blocked", "stale", "decision", "optional", "mechanical")
44# The acceptance criterion `prompts/scorecard.md` asks every filed finding to carry.
45# Matched case-insensitively and without the bolding, because a hand-written issue that
46# says "done when" in plain prose is making the same promise as a generated one.
47_ACCEPTANCE = re.compile(
48 r"(?:\*\*)?done\s+when(?:\s*…|\s*\.\.\.|:)?(?:\*\*)?\s*(.+?)(?:\n\s*\n|\Z)",
49 re.IGNORECASE | re.DOTALL,
50)
52# "Either A or B", and the two phrasings that say the same thing without "either".
53_DISJUNCTIVE = (
54 re.compile(r"\beither\b.+?\bor\b", re.IGNORECASE | re.DOTALL),
55 re.compile(r"\bequally good outcome\b", re.IGNORECASE),
56 re.compile(r"\bor the \w+ is judged\b", re.IGNORECASE),
57)
59# Phrases in which an issue hands the reader a choice rather than an instruction. Each
60# one is quoted from a real issue body; this is a list of observed spellings, not an
61# attempt at natural-language understanding.
62_DECIDE_MARKERS = (
63 re.compile(r"\btwo (?:honest )?ways\b", re.IGNORECASE),
64 re.compile(r"\bpick one\b", re.IGNORECASE),
65 re.compile(r"\bthe issue is to\b", re.IGNORECASE),
66 re.compile(r"\bleaning toward\b", re.IGNORECASE),
67 re.compile(r"\bor delete it\b", re.IGNORECASE),
68 re.compile(r"^\s*consider\b", re.IGNORECASE),
69)
71# `#123`, but not `#123` inside a fenced block or a URL fragment. Kept deliberately
72# simple: a false positive costs a resolved reference the caller then ignores, while a
73# false negative costs a missed supersession, which is the expensive direction.
74_REFERENCE = re.compile(r"(?<![\w/])#(\d{1,6})\b")
76# A reference the issue *depends on*, as opposed to one it merely cites. The difference
77# decides whether a merged reference makes an issue stale, and getting it wrong is not
78# symmetric: #94 cites seven merged PRs while explaining what drifted — treating those
79# as dependencies would mark the most fixable issue in the tracker as superseded and
80# refuse it forever. Only a clause that declares an ordering counts.
81_DEPENDENCY = re.compile(
82 r"(?:sequencing\s*:?\s*|\bafter\b|\bblocked by\b|\bdepends on\b|\bonce\b|"
83 r"\bsupersed\w*\s+by\b|\bwaits? on\b)[^.\n#]{0,40}#(\d{1,6})\b",
84 re.IGNORECASE,
85)
87# A backticked token that looks like a repo path: it has a slash or a known suffix, and
88# no spaces. `make test` and `[tool.pytest.ini_options]` must not match.
89_BACKTICKED = re.compile(r"`([^`\n]+)`")
90_PATH_SUFFIX = re.compile(
91 r"\.(?:py|toml|md|ya?ml|cfg|ini|txt|json|lock|tex|sh|rs|go|js|ts)$", re.IGNORECASE
92)
93# A `file.py:67` citation. The line number is dropped: it drifts, and `skills/fix` is
94# required to verify by content rather than by line anyway.
95_LINE_SUFFIX = re.compile(r":\d+(?:-\d+)?$")
98def acceptance(body: str) -> str | None:
99 """The issue's ``done when…`` sentence, or None when it carries none.
101 >>> acceptance("**done when…** the warning is gone.")
102 'the warning is gone.'
104 The bolding and the ellipsis are both optional, so a hand-written issue reaches
105 this too:
107 >>> acceptance("Done when: every claim matches the repo.")
108 'every claim matches the repo.'
110 An issue that never says what done means gets None, which is the single strongest
111 signal in the module:
113 >>> acceptance("Two honest ways out, and the issue is to pick one.") is None
114 True
115 """
116 match = _ACCEPTANCE.search(body)
117 return " ".join(match.group(1).split()) if match else None
120def is_disjunctive(text: str) -> bool:
121 """Whether *text* admits more than one outcome.
123 >>> is_disjunctive("either plot.py is under 250 lines or the split is judged "
124 ... "not worth the indirection")
125 True
126 >>> is_disjunctive("the warning is gone and testpaths is unchanged")
127 False
129 "An equally good outcome" is the same claim without the word "either":
131 >>> is_disjunctive("...and that judgement is recorded, which is an equally "
132 ... "good outcome.")
133 True
134 """
135 return any(pattern.search(text) for pattern in _DISJUNCTIVE)
138def decide_markers(body: str) -> list[str]:
139 """Every phrase in *body* that hands the reader a choice.
141 >>> decide_markers("Two honest ways out, and the issue is to pick one.")
142 ['pick one', 'the issue is to', 'two honest ways']
144 >>> decide_markers("Delete the dead table from pyproject.toml.")
145 []
146 """
147 found = {
148 match.group(0).strip().lower()
149 for pattern in _DECIDE_MARKERS
150 if (match := pattern.search(body))
151 }
152 return sorted(found)
155def references(body: str) -> list[int]:
156 """The issue and request numbers *body* mentions, in ascending order.
158 >>> references("Sequencing: after #75. See also #86 and #75 again.")
159 [75, 86]
161 A bare number, or one inside a path, is not a reference:
163 >>> references("bumped to 1.8.0 and src/x.py#L12")
164 []
165 """
166 return sorted({int(m.group(1)) for m in _REFERENCE.finditer(body)})
169def dependencies(body: str) -> list[int]:
170 """The references *body* declares an ordering against, in ascending order.
172 A subset of :func:`references`, and the distinction is the one that decides
173 staleness. An issue sequenced behind work that has since merged may have been
174 overtaken by it; an issue that merely *cites* a merged PR has not:
176 >>> dependencies("**Sequencing:** after #75. Building on a caption that lies "
177 ... "means writing the fix twice.")
178 [75]
180 >>> dependencies("the fuzzing entries were pruned in #71 and #73, and the "
181 ... "version moved to a git tag in #91")
182 []
184 Both spellings of a block, and a supersession named outright:
186 >>> dependencies("Blocked by #12; superseded by #34.")
187 [12, 34]
188 """
189 return sorted({int(m.group(1)) for m in _DEPENDENCY.finditer(body)})
192def paths(body: str) -> list[str]:
193 """The backticked tokens in *body* that are shaped like repository paths.
195 A slash or a known file suffix qualifies; anything else in backticks is prose,
196 a command or a config key, and those outnumber the paths in a typical body:
198 >>> paths("Delete `[tool.pytest.ini_options]` from `pyproject.toml`, then "
199 ... "run `make test` and check `src/jointview/plot.py`.")
200 ['pyproject.toml', 'src/jointview/plot.py']
202 A token carrying a space is a command, whatever else it looks like:
204 >>> paths("`uv run pytest tests/test_x.py`")
205 []
207 A `file:line` citation names the file; the line number is dropped, because line
208 numbers drift and the file is the part still worth checking:
210 >>> paths("see `src/jointview/cli.py:67`")
211 ['src/jointview/cli.py']
213 A leading `./` is removed without eating the dot of a dotfile — `.rhiza/tests`
214 is a directory, not `rhiza/tests`:
216 >>> paths("`./setup.cfg` and `.rhiza/template.yml`")
217 ['.rhiza/template.yml', 'setup.cfg']
219 A command namespaced with a colon is not a path, however many slashes it has:
221 >>> paths("run `/rhiza:update` afterwards")
222 []
223 """
224 found = set()
225 for match in _BACKTICKED.finditer(body):
226 token = match.group(1).strip()
227 if " " in token or not token:
228 continue
229 token = _LINE_SUFFIX.sub("", token)
230 if ":" in token:
231 continue
232 token = token.removeprefix("./").lstrip("/")
233 if token and ("/" in token or _PATH_SUFFIX.search(token)):
234 found.add(token)
235 return sorted(found)
238def quality_filed(body: str) -> bool:
239 """Whether *body* has the shape ``/rhiza:quality`` writes.
241 ``prompts/scorecard.md`` §5 requires a subcategory line and an acceptance
242 criterion, so both together identify an issue this plugin wrote — which means its
243 structure can be relied on rather than inferred.
245 >>> quality_filed("**Subcategory:** config hygiene — **9 → 10**\\n\\n"
246 ... "**done when…** the warning is gone.")
247 True
248 >>> quality_filed("**done when…** the warning is gone.")
249 False
250 """
251 return "**Subcategory:**" in body and acceptance(body) is not None
254def classify(
255 body: str,
256 *,
257 has_open_request: bool = False,
258 superseded: bool = False,
259) -> str:
260 """Which of :data:`CATEGORIES` *body* falls into, worst first.
262 The two keyword arguments carry what the text cannot know, and both are facts
263 rather than readings: whether a request is already open against this issue, and
264 whether a reference the issue **sequences itself behind** has since settled. The
265 caller resolves them; this decides.
267 Work already under way elsewhere is not work to start again:
269 >>> classify("**done when…** it works.", has_open_request=True)
270 'blocked'
272 An issue queued behind something that has since merged may have been overtaken by
273 it, and that is a question for a human:
275 >>> classify("**Sequencing:** after #75.", superseded=True)
276 'stale'
278 No acceptance criterion at all means nobody has said what done looks like:
280 >>> classify("Two honest ways out, and the issue is to pick one.")
281 'decision'
283 A criterion that admits two outcomes is a choice wearing a criterion's clothes:
285 >>> classify("**done when…** either it is split or the decision is recorded.")
286 'optional'
288 And the case the command exists for:
290 >>> classify("**done when…** `make test` runs without the warning.")
291 'mechanical'
293 Note what is deliberately **not** here. An issue that *mentions* a template-owned
294 file, or one whose backticked prose names a path that does not resolve, is not
295 demoted on that evidence: #95 quotes `pytest.ini` to explain why the table in
296 `pyproject.toml` is dead, and #94 names a dozen paths while describing what a
297 document got wrong about them. Mentioning a file is not editing it, and inferring
298 the target of a fix from the nouns in its prose is precisely the guessing this
299 command exists not to do. Both are reported by :func:`signals` as cautions for the
300 verification step — which reads the issue properly — to weigh.
301 """
302 if has_open_request:
303 return "blocked"
304 if superseded:
305 return "stale"
306 criterion = acceptance(body)
307 if criterion is None or decide_markers(body):
308 return "decision"
309 if is_disjunctive(criterion):
310 return "optional"
311 return "mechanical"
314def signals(
315 body: str,
316 *,
317 template_owned: tuple[str, ...] = (),
318 missing: tuple[str, ...] = (),
319 open_requests: tuple[int, ...] = (),
320 superseded_by: tuple[int, ...] = (),
321) -> dict[str, Any]:
322 """Every derived fact about *body*, plus the category they add up to.
324 One call so a caller cannot read half the signals and reach its own verdict.
326 >>> report = signals("**Subcategory:** x\\n\\n**done when…** the warning is gone.")
327 >>> report["category"], report["quality_filed"]
328 ('mechanical', True)
330 ``template_owned`` and ``missing`` are carried into the report but never into the
331 category — see :func:`classify` for why a mention is not a target:
333 >>> signals("fix it", template_owned=("pytest.ini",))["cautions"]
334 ['mentions template-owned paths: pytest.ini']
335 """
336 criterion = acceptance(body)
337 cautions: list[str] = []
338 if template_owned:
339 cautions.append(f"mentions template-owned paths: {', '.join(template_owned)}")
340 if missing:
341 cautions.append(f"names paths that do not resolve: {', '.join(missing)}")
342 return {
343 "acceptance": criterion,
344 "acceptance_disjunctive": criterion is not None and is_disjunctive(criterion),
345 "quality_filed": quality_filed(body),
346 "decide_markers": decide_markers(body),
347 "paths": paths(body),
348 "missing_paths": list(missing),
349 "template_owned": list(template_owned),
350 "cautions": cautions,
351 "references": references(body),
352 "dependencies": dependencies(body),
353 "open_requests": list(open_requests),
354 "superseded_by": list(superseded_by),
355 "category": classify(
356 body,
357 has_open_request=bool(open_requests),
358 superseded=bool(superseded_by),
359 ),
360 }