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

1#!/usr/bin/env python3 

2"""What an issue's *text* says about whether it can be fixed without a judgement call. 

3 

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. 

8 

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. 

17 

18Three issues filed by the same ``/quality`` run make the point: 

19 

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" 

24 

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. 

29 

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

35 

36from __future__ import annotations 

37 

38import re 

39from typing import Any 

40 

41# Worst-first. `classify` returns the first that matches, so the order is the policy. 

42CATEGORIES = ("blocked", "stale", "decision", "optional", "mechanical") 

43 

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) 

51 

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) 

58 

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) 

70 

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

75 

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) 

86 

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+)?$") 

96 

97 

98def acceptance(body: str) -> str | None: 

99 """The issue's ``done when…`` sentence, or None when it carries none. 

100 

101 >>> acceptance("**done when…** the warning is gone.") 

102 'the warning is gone.' 

103 

104 The bolding and the ellipsis are both optional, so a hand-written issue reaches 

105 this too: 

106 

107 >>> acceptance("Done when: every claim matches the repo.") 

108 'every claim matches the repo.' 

109 

110 An issue that never says what done means gets None, which is the single strongest 

111 signal in the module: 

112 

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 

118 

119 

120def is_disjunctive(text: str) -> bool: 

121 """Whether *text* admits more than one outcome. 

122 

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 

128 

129 "An equally good outcome" is the same claim without the word "either": 

130 

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) 

136 

137 

138def decide_markers(body: str) -> list[str]: 

139 """Every phrase in *body* that hands the reader a choice. 

140 

141 >>> decide_markers("Two honest ways out, and the issue is to pick one.") 

142 ['pick one', 'the issue is to', 'two honest ways'] 

143 

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) 

153 

154 

155def references(body: str) -> list[int]: 

156 """The issue and request numbers *body* mentions, in ascending order. 

157 

158 >>> references("Sequencing: after #75. See also #86 and #75 again.") 

159 [75, 86] 

160 

161 A bare number, or one inside a path, is not a reference: 

162 

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

167 

168 

169def dependencies(body: str) -> list[int]: 

170 """The references *body* declares an ordering against, in ascending order. 

171 

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: 

175 

176 >>> dependencies("**Sequencing:** after #75. Building on a caption that lies " 

177 ... "means writing the fix twice.") 

178 [75] 

179 

180 >>> dependencies("the fuzzing entries were pruned in #71 and #73, and the " 

181 ... "version moved to a git tag in #91") 

182 [] 

183 

184 Both spellings of a block, and a supersession named outright: 

185 

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

190 

191 

192def paths(body: str) -> list[str]: 

193 """The backticked tokens in *body* that are shaped like repository paths. 

194 

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: 

197 

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'] 

201 

202 A token carrying a space is a command, whatever else it looks like: 

203 

204 >>> paths("`uv run pytest tests/test_x.py`") 

205 [] 

206 

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: 

209 

210 >>> paths("see `src/jointview/cli.py:67`") 

211 ['src/jointview/cli.py'] 

212 

213 A leading `./` is removed without eating the dot of a dotfile — `.rhiza/tests` 

214 is a directory, not `rhiza/tests`: 

215 

216 >>> paths("`./setup.cfg` and `.rhiza/template.yml`") 

217 ['.rhiza/template.yml', 'setup.cfg'] 

218 

219 A command namespaced with a colon is not a path, however many slashes it has: 

220 

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) 

236 

237 

238def quality_filed(body: str) -> bool: 

239 """Whether *body* has the shape ``/rhiza:quality`` writes. 

240 

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. 

244 

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 

252 

253 

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. 

261 

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. 

266 

267 Work already under way elsewhere is not work to start again: 

268 

269 >>> classify("**done when…** it works.", has_open_request=True) 

270 'blocked' 

271 

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: 

274 

275 >>> classify("**Sequencing:** after #75.", superseded=True) 

276 'stale' 

277 

278 No acceptance criterion at all means nobody has said what done looks like: 

279 

280 >>> classify("Two honest ways out, and the issue is to pick one.") 

281 'decision' 

282 

283 A criterion that admits two outcomes is a choice wearing a criterion's clothes: 

284 

285 >>> classify("**done when…** either it is split or the decision is recorded.") 

286 'optional' 

287 

288 And the case the command exists for: 

289 

290 >>> classify("**done when…** `make test` runs without the warning.") 

291 'mechanical' 

292 

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" 

312 

313 

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. 

323 

324 One call so a caller cannot read half the signals and reach its own verdict. 

325 

326 >>> report = signals("**Subcategory:** x\\n\\n**done when…** the warning is gone.") 

327 >>> report["category"], report["quality_filed"] 

328 ('mechanical', True) 

329 

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: 

332 

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 }