Coverage for src/rhiza_task/tasks/fences.py: 100%

185 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-25 04:13 +0000

1"""The fenced-example checker behind ``docs-examples``. 

2 

3Extracted from :mod:`rhiza_task.tasks.quality`, which had grown to 904 lines holding this 

4alongside seven unrelated task bodies -- and a module docstring describing only the latter. 

5Nothing here breached the complexity ceiling; the maintainability index falling 44.55 to 

636.52 over the two changes that built this up was the signal, and cohesion was the actual 

7problem. See issue #113. 

8 

9**One name crosses the boundary: :func:`check`.** That is deliberate and not merely tidy. 

10``CLAUDE.md``'s layering invariant forbids an underscore-prefixed name from crossing a 

11module boundary, so keeping the ``@task`` in ``quality.py`` while moving the helpers here 

12would have meant making seven private functions public -- turning an internal decomposition 

13into an API. A single entry point taking the gate's whole body instead leaves the helpers 

14private, where they belong. 

15 

16What the gate is *for* stays on the task in ``quality.py``, because that is where a reader 

17looking for a gate looks. What it *does* is here. 

18 

19**This module is the largest in ``src/`` -- roughly 780 lines, with a maintainability index 

20in the low 30s at the time of writing -- and that is accepted.** Worth stating outright, 

21because the index is *lower* than the 36.52 that got the checker extracted from 

22``quality.py`` in the first place, so the numbers alone read as having made things worse. 

23They did not, and the reason is the one #113 was actually about: ``quality.py`` was 904 lines 

24doing **two** jobs with a docstring describing one of them, and this is one job. Radon's 

25maintainability index falls with size and Halstead volume whether or not a module is 

26coherent, so it cannot tell those apart -- which is why cohesion, not the index, was the 

27argument then and is the argument now. 

28 

29The figures are written loosely and dated on purpose, following ``pyproject.toml``'s note on 

30the coverage floor: they move with every commit, they are incidental to the argument, and a 

31comment stating one exactly is a comment the next edit falsifies. This paragraph proved it -- 

32adding it grew the file and dropped the index, so the precise numbers it first carried were 

33wrong by the time it was saved. ``uvx radon mi src -s`` prints the current figure. What does 

34not move: nothing here exceeds B (10), the average across ``src/`` is A (3.27), and about a 

35seventh of these lines are comments, which is this repository's house style rather than 

36padding. 

37 

38The condition that changes the answer is **a second job arriving, not a line count**. If 

39something lands here that is not fenced-example checking, split on that seam. Splitting on 

40size alone is the move to resist: the obvious cut -- the parser (``Fence``, :func:`_fences`, 

41the language constants) away from the five checkers -- would put a type in one module and 

42its only five consumers in another, which is worse than a long file. 

43 

44Five kinds of fence are checked and the rest are counted: 

45 

46* ``python`` -- :func:`compile`, so a fence that is a *fragment* still passes. Names need not 

47 resolve; only the syntax is asserted. 

48* ``bash``/``sh`` -- ``bash -n``, which parses without executing. Never executed, because a 

49 README's shell is routinely ``rm -rf`` and ``git push``, and an unparseable fence is a 

50 documentation bug without running it. 

51* ``toml`` -- :func:`tomllib.loads`, in this process. Stdlib at this package's Python floor, 

52 so it needs nothing provisioned and is checked on every machine that can run the gate. 

53* ``yaml``/``yml`` -- a real parser, provisioned into a subprocess because adding one to this 

54 package's runtime dependencies to serve two fences would be the wrong trade: it is a 

55 published CLI whose install cost every consumer pays on every ``uvx`` invocation. 

56* ``result`` -- executed and diffed against the python fences above it. 

57 

58Anything else -- ``mermaid``, ``makefile``, and fences carrying no language at all -- is 

59reported as unchecked with a count. Naming the count is the point: silence there would read 

60as "everything was checked". 

61 

62Two of the five can go unavailable on a machine that runs the gate fine otherwise, and both 

63follow the rule the tool guards elsewhere in this package follow -- the fences are counted 

64out of the checked total and named on their own line, never assumed sound. ``bash`` may be 

65absent, and the yaml parser has to be fetched. 

66 

67Parsing is *not* validation, and the distinction is worth keeping: a ``toml`` fence that 

68parses may still name a setting this package does not have, and a ``yaml`` fence that parses 

69may still be an invalid workflow. Schema-checking either would need the schema, and for the 

70workflow snippets actionlint already owns that question over the real files. What this closes 

71is the narrower gap where a fence stopped being the language it claims. 

72""" 

73 

74from __future__ import annotations 

75 

76import re 

77import shutil 

78 

79# The `bash -n` probe below is a fixed argument vector, with no shell -- which is what bandit's 

80# B404 asks about. The reason sits here rather than on the suppression comment itself: bandit 

81# reads everything after that marker as a comma-separated list of test IDs, so a trailing 

82# explanation becomes one `Test in comment:` warning per word. 

83import subprocess # nosec B404 

84import textwrap 

85import tomllib 

86from collections import Counter 

87from dataclasses import dataclass 

88from pathlib import Path 

89 

90from ..config import Config 

91from ..spec import Failed, Skip 

92from ..uv import uv_run 

93 

94 

95@dataclass(frozen=True) 

96class Scan: 

97 """One pass over the documentation, before any checking. 

98 

99 A record for the reason :class:`Tally` is one: :func:`check` reached C (12) once README 

100 joined the sources, which would have made it the third C-ranked block in this package. 

101 ``CLAUDE.md`` asks for an argument per C block and this one had none worth making -- 

102 parsing, checking and reporting are three jobs, and splitting them is the decomposition 

103 the metric was pointing at rather than a contortion to satisfy it. Passing the four 

104 results positionally between those parts is what a record avoids. 

105 

106 Attributes: 

107 per_file: The docs tree's fences, grouped by file, because a ``result`` block's prelude 

108 is the python fences *in its own file* and nowhere else. 

109 fences: The same fences, flat. The docs tree only -- the code checkers' subject. 

110 readme: ``README.md``'s data fences, and none of its code fences. Empty when there is 

111 no README. See :func:`_readme_fences`. 

112 bash: Absolute path to bash, or None when it is absent -- resolved once, because its 

113 absence must leave the shell fences counted rather than fail the gate. 

114 """ 

115 

116 per_file: list[list[Fence]] 

117 fences: list[Fence] 

118 readme: list[Fence] 

119 bash: str | None 

120 

121 @property 

122 def data(self) -> list[Fence]: 

123 """Return the fences the data checkers see: the docs tree's and README's together. 

124 

125 One list rather than a second pass over README, because :func:`_yaml_violations` owns a 

126 single scratch folder -- calling it twice would have the second run clobber the first's 

127 markers -- and one subprocess for every yaml fence is cheaper anyway. 

128 

129 Returns: 

130 Every fence from both sources. 

131 """ 

132 return self.fences + self.readme 

133 

134 

135def check(cfg: Config) -> None: 

136 """Run the gate: parse every checkable fence, diff the executed ones, print the inventory. 

137 

138 The body of the ``docs-examples`` task, and the only public name in this module. The task 

139 itself is registered in :mod:`rhiza_task.tasks.quality` and carries the argument for why 

140 the gate exists. 

141 

142 Three steps, each its own function: :func:`_scan` parses, this gathers the violations, and 

143 :func:`_verdict` prints and decides. See :class:`Scan` for why that split exists. 

144 

145 Args: 

146 cfg: The resolved config. 

147 

148 Raises: 

149 Skip: When nothing was measured. Documentation carrying nothing runnable would 

150 otherwise score a silent pass, which is the failure this gate exists to make 

151 visible. 

152 Failed: When at least one example is broken or stale. 

153 """ 

154 scratch = cfg.root / "_tests" / "docs-examples" 

155 scratch.mkdir(parents=True, exist_ok=True) 

156 scan = _scan(cfg) 

157 

158 # None, not [], when the parser could not be provisioned: an empty list is 

159 # "checked, all sound", and reporting a machine's missing network as a clean bill of 

160 # health is the one thing this gate must never do. `bash` is handled the same way, in 

161 # `_scan`, by the same argument. 

162 yaml_broken = _yaml_violations(scan.data, cfg, scratch) 

163 

164 broken = [ 

165 *_syntax_violations(scan.fences), 

166 *(_shell_violations(scan.fences, scan.bash, scratch) if scan.bash else []), 

167 *_toml_violations(scan.data), 

168 *(yaml_broken or []), 

169 *_result_violations(scan.per_file, cfg, scratch), 

170 ] 

171 _verdict(cfg, scan, broken, yaml_broken is not None) 

172 

173 

174def _scan(cfg: Config) -> Scan: 

175 """Parse every markdown file this gate looks at, and resolve bash once. 

176 

177 Args: 

178 cfg: The resolved config. 

179 

180 Returns: 

181 What was found, as a :class:`Scan`. 

182 """ 

183 per_file = [ 

184 _fences(md.relative_to(cfg.root).as_posix(), md.read_text(errors="replace")) 

185 for md in sorted(cfg.path("docs_folder").rglob("*.md")) 

186 ] 

187 return Scan( 

188 per_file=per_file, 

189 fences=[fence for one_file in per_file for fence in one_file], 

190 readme=_readme_fences(cfg), 

191 # Resolved once: `bash` is absent on a stock Windows runner, and its absence must leave 

192 # the shell fences *unchecked and counted* rather than failing the gate for a fact 

193 # about the machine. Same reasoning as `Guard(tool=...)` raising Skip rather than 

194 # Failed. 

195 bash=shutil.which("bash"), 

196 ) 

197 

198 

199def _verdict(cfg: Config, scan: Scan, broken: list[str], yaml_ran: bool) -> None: 

200 """Print the violations and the inventory, then raise the outcome. 

201 

202 Args: 

203 cfg: The resolved config. 

204 scan: What :func:`_scan` found. 

205 broken: Every violation message, in the order they were gathered. 

206 yaml_ran: Whether the yaml parser could be provisioned. 

207 

208 Raises: 

209 Skip: When nothing was measured, so a pass would mean nothing. 

210 Failed: When at least one example is broken or stale. 

211 """ 

212 for violation in broken: 

213 print(violation) 

214 

215 measured = _report(scan.fences, scan.bash, yaml_ran, len(scan.per_file)) 

216 if scan.readme: 

217 # Its own line, and outside the inventory above, because README contributes only its 

218 # data fences: folding two of its ten into "12 file(s), 64 fence(s)" would read as 

219 # full coverage of a file this gate deliberately only half-looks at. 

220 print(f"[INFO] README.md: {len(scan.readme)} data fence(s) checked; its code fences are pytest-rhiza's") 

221 if not measured and not scan.readme: 

222 raise Skip(f"no checkable fence under {cfg.docs_folder}") 

223 if broken: 

224 # `and README.md` rather than only the docs folder: since data fences are checked in 

225 # both, a summary naming one scope would point a reader at the wrong file for half the 

226 # failures it reports. The per-violation lines above carry the real locations. 

227 scope = f"{cfg.docs_folder} and README.md" if scan.readme else cfg.docs_folder 

228 raise Failed(1, f"{len(broken)} broken example(s) under {scope}") 

229 

230 

231# Deliberately permissive about what follows the language: mkdocs-material accepts 

232# ```python title="x", and an opening fence this pattern failed to match would have its 

233# *closing* fence read as the next opening one, cascading the misparse through the rest of 

234# the file. Matching any fence line and keeping only the first word cannot do that. 

235DOC_FENCE_OPEN = re.compile(r"^(?P<indent>[ \t]*)```(?P<language>[^`\s]*)") 

236 

237# A closing fence carries nothing but backticks. A bare ``` therefore matches both patterns, 

238# which is why :func:`_fences` tracks state instead of classifying lines independently. 

239DOC_FENCE_CLOSE = re.compile(r"^[ \t]*```[ \t]*$") 

240 

241# `bash` and `sh` only. `console` and `shell-session` are excluded on purpose: their content 

242# is a transcript -- prompts, output and all -- so `bash -n` would reject the very thing that 

243# makes them correct. A language this set does not name is counted as unchecked and reported 

244# rather than guessed at. 

245SHELL_FENCE_LANGUAGES = frozenset({"bash", "sh"}) 

246 

247PYTHON_FENCE_LANGUAGE = "python" 

248 

249# The convention README.md already uses and pytest-rhiza's `test_readme_validation` already 

250# checks *there*: a ```result``` block holds the expected stdout of the python fence above it. 

251RESULT_FENCE_LANGUAGE = "result" 

252 

253# `tomllib` is stdlib at the floor this package declares (`requires-python = ">=3.11"`), so 

254# the toml half costs nothing to provision -- and it is the half that matters most here: the 

255# toml fences under `docs/` are this package's own `[tool.rhiza-task]` and `[tool.bumpversion]` 

256# examples, which is precisely the class that goes stale when a setting is renamed or a 

257# default moves. Eleven of them at the time of writing, against two yaml. 

258TOML_FENCE_LANGUAGE = "toml" 

259 

260# `yml` as well as `yaml`: mkdocs-material's own docs spell it both ways, and a fence this set 

261# failed to name would be silently counted as unchecked rather than parsed -- the exact 

262# silence this gate exists to break. 

263YAML_FENCE_LANGUAGES = frozenset({"yaml", "yml"}) 

264 

265# The driver :func:`_yaml_violations` writes out and runs. A module-level constant with its 

266# body at column 0, rather than an indented literal inside that function, and the indentation 

267# is the whole point: `CLAUDE.md` documents 

268# `grep -rnE '^\s+(from|import) ' src/` as the way to check this package for deferred imports, 

269# and tells the reader it returns two lines. An indented `import yaml` inside a string literal 

270# matched that pattern and took it to four -- two false positives in the one check the 

271# layering invariant is verified by, which is worse than the invariant being unchecked because 

272# it teaches the reader to ignore the output. At column 0 nothing matches and the documented 

273# count holds. 

274# 

275# `started.txt` is written before any parsing and `report.txt` after all of it, so the caller 

276# can tell three outcomes apart that would otherwise collapse into one: the parser could not 

277# be provisioned (no marker at all), the checker ran and crashed (marker, no report), and the 

278# checker finished (both). Without the first marker a bug in this script is indistinguishable 

279# from a machine with no network, and the gate passes green either way. 

280YAML_CHECKER_SCRIPT = """\ 

281import pathlib 

282 

283import yaml 

284 

285here = pathlib.Path(__file__).parent / "yaml" 

286(here / "started.txt").write_text("ok") 

287broken = [] 

288for number, where in enumerate((here / "index.txt").read_text().splitlines()): 

289 try: 

290 yaml.safe_load((here / f"{number:04d}.yaml").read_text()) 

291 except yaml.YAMLError as exc: 

292 # Flattened: a YAMLError's str spans four lines with a caret diagram, and the 

293 # report this joins is one line per violation. 

294 detail = " ".join(str(exc).split()) 

295 broken.append(f"{where}: yaml fence does not parse: {detail}") 

296(here / "report.txt").write_text("\\n".join(broken)) 

297""" 

298 

299# The languages this gate checks in ``README.md`` as well as under the docs folder, and the 

300# reason is a gap rather than a preference. ``README.md`` is pytest-rhiza's subject -- its 

301# ``test_readme_validation`` runs under `rhiza-test` -- so this gate has always left the file 

302# alone to keep one verdict per fact. But that module contains no reference to `toml` or 

303# `yaml` at all, so README's data fences were checked by *nothing*: both of this repository's 

304# are `[tool.rhiza-task]` and `rhiza.toml` examples naming real settings, which is exactly the 

305# class that goes stale when a setting is renamed. See issue #112. 

306# 

307# So the two gates divide by *language* rather than by file, which keeps the no-double-verdict 

308# rule intact and statable: pytest-rhiza owns README's code fences, this owns data fences 

309# everywhere. Should pytest-rhiza ever learn toml, this set is the one place to narrow. 

310DATA_FENCE_LANGUAGES = YAML_FENCE_LANGUAGES | {TOML_FENCE_LANGUAGE} 

311CHECKED_FENCE_LANGUAGES = ( 

312 SHELL_FENCE_LANGUAGES | YAML_FENCE_LANGUAGES | {PYTHON_FENCE_LANGUAGE, RESULT_FENCE_LANGUAGE, TOML_FENCE_LANGUAGE} 

313) 

314 

315 

316@dataclass(frozen=True) 

317class Fence: 

318 """One fenced code block, located and dedented. 

319 

320 Attributes: 

321 path: Repository-relative path, posix-separated, because this reaches report output 

322 a reader pastes into an editor. 

323 line: 1-based line number of the opening fence. 

324 language: The info string's first word, lowercased; empty when the fence carries none. 

325 code: The block's content, dedented. 

326 """ 

327 

328 path: str 

329 line: int 

330 language: str 

331 code: str 

332 

333 

334@dataclass(frozen=True) 

335class Tally: 

336 """How many fences of each kind the tree holds. 

337 

338 A record rather than the tuple this used to be. Four counts unpacked positionally were 

339 already at the edge of readable; toml and yaml take it to six, where 

340 ``python, shell, toml, yaml, diffed, unchecked = _tally(fences)`` stops being checkable by 

341 eye and a transposed pair would report shell fences as toml with nothing failing. The 

342 fields carry the meaning instead. 

343 

344 Attributes: 

345 python: ``python`` fences, checked by :func:`compile`. 

346 shell: ``bash``/``sh`` fences, checked by ``bash -n`` when bash is present. 

347 toml: ``toml`` fences, checked in-process by :mod:`tomllib`. 

348 yaml: ``yaml``/``yml`` fences, checked by a provisioned parser in a subprocess. 

349 diffed: ``result`` fences, executed and compared against the python fences above them. 

350 unchecked: Each remaining language paired with its count, commonest first and then 

351 alphabetically so the report line is diffable between runs. A fence with no 

352 language at all is counted under ``(none)``. 

353 """ 

354 

355 python: int 

356 shell: int 

357 toml: int 

358 yaml: int 

359 diffed: int 

360 unchecked: list[tuple[str, int]] 

361 

362 

363def _fences(path: str, text: str) -> list[Fence]: 

364 """Return every fenced code block in one markdown file, in document order. 

365 

366 Indentation is why this is a state machine rather than a regex over the whole file: 

367 mkdocs admonitions and content tabs indent their fences by four spaces, and ``faq.md`` 

368 indents one by three inside a numbered list. ``textwrap.dedent`` on the collected body is 

369 what makes those compile -- without it every fence inside an admonition is an 

370 ``IndentationError``, which would be a finding against this checker rather than the docs. 

371 

372 Nested fences are not handled, and cannot be: distinguishing them needs the four-backtick 

373 form, which no file in this repository uses. If one appears, its inner fence closes the 

374 outer block early and the languages reported go wrong -- visible in the inventory line 

375 rather than silent, which is the reason that line prints a per-language count. 

376 

377 Args: 

378 path: Repository-relative path, stored on each returned fence. 

379 text: The file's content. 

380 

381 Returns: 

382 The fences found, dedented. 

383 """ 

384 fences: list[Fence] = [] 

385 language: str | None = None 

386 start = 0 

387 body: list[str] = [] 

388 for number, line in enumerate(text.splitlines(), start=1): 

389 if language is None: 

390 opening = DOC_FENCE_OPEN.match(line) 

391 if opening: 

392 language, start, body = opening.group("language").lower(), number, [] 

393 continue 

394 if DOC_FENCE_CLOSE.match(line): 

395 fences.append(Fence(path, start, language, textwrap.dedent("\n".join(body)))) 

396 language = None 

397 continue 

398 body.append(line) 

399 return fences 

400 

401 

402def _readme_fences(cfg: Config) -> list[Fence]: 

403 """Return ``README.md``'s data fences, and none of its code fences. 

404 

405 The filter is the whole function. ``README.md`` belongs to pytest-rhiza's 

406 ``test_readme_validation``, which parses its python and shell fences, so taking the whole 

407 file would make two gates report one fact -- the thing this gate has always refused to do. 

408 Taking only the languages that module does not know about closes the gap without creating 

409 the overlap: see :data:`DATA_FENCE_LANGUAGES` for why those are toml and yaml. 

410 

411 Absent rather than required: a repository need not have a README, and this gate's subject 

412 is the docs tree. 

413 

414 Args: 

415 cfg: The resolved config. 

416 

417 Returns: 

418 The toml and yaml fences in ``README.md``, or an empty list when there is no README. 

419 """ 

420 readme = cfg.root / "README.md" 

421 if not readme.is_file(): 

422 return [] 

423 found = _fences("README.md", readme.read_text(errors="replace")) 

424 return [fence for fence in found if fence.language in DATA_FENCE_LANGUAGES] 

425 

426 

427def _syntax_violations(fences: list[Fence]) -> list[str]: 

428 """Return one message per python fence that does not parse. 

429 

430 :func:`compile` rather than execution, so a fence holding a *fragment* -- ``guards = 

431 (Guard("source_folder"),)`` in ``adding_a_task.md``, with ``Guard`` never imported -- 

432 passes. Undefined names are not the question; syntax is. 

433 

434 Args: 

435 fences: Every fence in the tree. 

436 

437 Returns: 

438 Violation messages, one per broken fence. 

439 """ 

440 broken: list[str] = [] 

441 for fence in fences: 

442 if fence.language != PYTHON_FENCE_LANGUAGE: 

443 continue 

444 try: 

445 compile(fence.code, f"{fence.path}:{fence.line}", "exec") 

446 except SyntaxError as exc: 

447 # The *offending* line, not the fence's: `getting_started.md` holds 24 fences, and 

448 # "somewhere in this file" is the part of a report a reader has to redo by hand. 

449 # `fence.line` is the opening backticks, so body line 1 sits one below it, which is 

450 # what makes this sum the absolute line rather than one short of it. 

451 broken.append(f"{fence.path}:{fence.line + (exc.lineno or 0)}: python fence does not parse: {exc.msg}") 

452 return broken 

453 

454 

455def _shell_violations(fences: list[Fence], bash: str, scratch: Path) -> list[str]: 

456 """Return one message per shell fence that does not parse. 

457 

458 ``-n`` is the whole point: bash reads and parses the script and exits without running a 

459 command of it. So this validates ``rm -rf`` and ``git push`` fences without their 

460 consequences. 

461 

462 Captured rather than streamed through :func:`~rhiza_task.uv.tool`, which every other 

463 binary in this package goes through, for two reasons that both come from this being a 

464 checker rather than a gate over one command: the message is wanted *per fence* and lives 

465 on stderr, and echoing ``$ bash -n ...`` once per fence would bury the report it exists to 

466 produce under twenty invocation lines. ``_git`` above captures for the same reason. 

467 

468 Args: 

469 fences: Every fence in the tree. 

470 bash: Absolute path to bash, already resolved by the caller. 

471 scratch: Directory for the throwaway script. 

472 

473 Returns: 

474 Violation messages, one per broken fence. 

475 """ 

476 broken: list[str] = [] 

477 script = scratch / "fence.sh" 

478 for fence in fences: 

479 if fence.language not in SHELL_FENCE_LANGUAGES: 

480 continue 

481 script.write_text(fence.code) 

482 checked = subprocess.run( # noqa: S603 # nosec B603 

483 [bash, "-n", str(script)], 

484 capture_output=True, 

485 text=True, 

486 check=False, 

487 ) 

488 if checked.returncode: 

489 # bash reports against the throwaway path and its own line numbers, neither of 

490 # which the reader can act on. The fence's own location is already the prefix, so 

491 # the script path is stripped to leave the diagnosis. 

492 detail = checked.stderr.strip().splitlines() 

493 message = detail[-1].replace(str(script), "fence") if detail else f"exit {checked.returncode}" 

494 broken.append(f"{fence.path}:{fence.line}: shell fence does not parse: {message}") 

495 return broken 

496 

497 

498def _toml_violations(fences: list[Fence]) -> list[str]: 

499 """Return one message per toml fence that does not parse. 

500 

501 In this process and with no subprocess, unlike every other checker here, because 

502 :mod:`tomllib` is stdlib from 3.11 and this package declares ``requires-python = ">=3.11"``. 

503 So there is nothing to provision and nothing to skip for: a toml fence is checked on every 

504 machine that can run the gate at all, which is not true of the shell or yaml halves. 

505 

506 A *fragment* is accepted the way :func:`_syntax_violations` accepts one -- ``tomllib`` 

507 parses a bare ``key = value`` with no table header perfectly well, which is what most 

508 configuration examples in ``docs/`` are. Only genuine syntax errors are reported. 

509 

510 Args: 

511 fences: Every fence in the tree. 

512 

513 Returns: 

514 Violation messages, one per broken fence. 

515 """ 

516 broken: list[str] = [] 

517 for fence in fences: 

518 if fence.language != TOML_FENCE_LANGUAGE: 

519 continue 

520 try: 

521 tomllib.loads(fence.code) 

522 except tomllib.TOMLDecodeError as exc: 

523 # `exc` already carries "(at line N, column M)", and that N is relative to the 

524 # fence body. Prefixing the fence's own line would produce two numbers meaning 

525 # different things on one line, so the message is taken whole and the fence is 

526 # located by its opening line alone -- the same choice `_shell_violations` makes. 

527 broken.append(f"{fence.path}:{fence.line}: toml fence does not parse: {exc}") 

528 return broken 

529 

530 

531def _yaml_violations(fences: list[Fence], cfg: Config, scratch: Path) -> list[str] | None: 

532 """Return one message per yaml fence that does not parse, or None when unmeasured. 

533 

534 The one checker here that needs a package this project does not depend on. That is the 

535 whole reason it is a subprocess: ``rhiza-task`` is a published CLI, so every runtime 

536 dependency is an install cost paid by every consumer on every ``uvx`` invocation, and 

537 taking one on to parse two fences in this repository's own docs would be a poor trade. 

538 ``uv_run(..., withs=("pyyaml",), no_project=True)`` provisions it for the length of one 

539 call instead -- the same move :func:`~rhiza_task.tasks.book.marimo` makes for marimo, and 

540 the reason :mod:`rhiza_task.uv` grew ``withs`` at all. 

541 

542 The fences are written to files rather than embedded in the generated script. Embedding 

543 would need them escaped into a literal, and a yaml fence is exactly the kind of text -- 

544 quotes, backslashes, indentation that carries meaning -- where an escaping bug would look 

545 like a parse failure in the document. Files move the bytes without reinterpreting them. 

546 

547 Three outcomes, deliberately distinguished by two marker files rather than by an exit 

548 status. ``uv`` exits non-zero both when it cannot resolve ``pyyaml`` and when the script it 

549 provisioned crashes, so the status alone cannot separate a machine's missing network from 

550 this repository's bug -- and collapsing them is the worse error, because "unavailable" is a 

551 pass. ``started.txt`` is written immediately after ``import yaml`` and ``report.txt`` after 

552 the last fence, so their presence answers it: neither means the parser never arrived, the 

553 first alone means the checker died mid-run, and both mean it finished. 

554 

555 Args: 

556 fences: Every fence in the tree. 

557 cfg: The resolved config. 

558 scratch: Directory for the throwaway files. 

559 

560 Returns: 

561 Violation messages, one per broken fence; an empty list when the tree holds no yaml 

562 fence or every one of them parsed; a single-item list naming the script when it started 

563 and did not finish, which fails the gate; or None when the parser could not be 

564 provisioned at all, so the caller can report the fences as unchecked rather than sound. 

565 """ 

566 targets = [fence for fence in fences if fence.language in YAML_FENCE_LANGUAGES] 

567 if not targets: 

568 return [] 

569 

570 folder = scratch / "yaml" 

571 folder.mkdir(parents=True, exist_ok=True) 

572 for number, fence in enumerate(targets): 

573 (folder / f"{number:04d}.yaml").write_text(fence.code) 

574 (folder / "index.txt").write_text("\n".join(f"{fence.path}:{fence.line}" for fence in targets)) 

575 

576 started = folder / "started.txt" 

577 report = folder / "report.txt" 

578 # Unlinked first: each file's *existence* is a verdict, so a stale copy from a previous 

579 # invocation would report last run's outcome as this one's. 

580 started.unlink(missing_ok=True) 

581 report.unlink(missing_ok=True) 

582 script = scratch / "fence_yaml.py" 

583 script.write_text(YAML_CHECKER_SCRIPT) 

584 

585 code = uv_run( 

586 "python", 

587 script.relative_to(cfg.root).as_posix(), 

588 cwd=cfg.root, 

589 withs=("pyyaml",), 

590 no_project=True, 

591 check=False, 

592 ) 

593 if not started.is_file(): 

594 # Never reached the first statement after `import yaml`: no network, no such package, 

595 # no interpreter. A fact about the machine, so the caller counts these fences as 

596 # unchecked rather than failing the gate. 

597 return None 

598 if not report.is_file(): 

599 # Started and did not finish, which is this repository's bug and not the machine's -- 

600 # so it is a violation, and the gate goes red. Before `started.txt` existed this case 

601 # returned None and read as "parser unavailable", meaning a broken checker reported 

602 # itself as a clean skip. 

603 return [f"{script.name}: the yaml checker started and did not finish (exit {code})"] 

604 # An empty report file is "ran, found nothing", which splitlines turns into [] -- distinct 

605 # from the None above, and the distinction is the point of writing the files at all. 

606 return report.read_text(errors="replace").splitlines() 

607 

608 

609def _result_violations(per_file: list[list[Fence]], cfg: Config, scratch: Path) -> list[str]: 

610 """Return one message per ``result`` block that no longer matches what its python prints. 

611 

612 This is the half that catches an example gone *stale* rather than malformed, which is the 

613 failure with the longest half-life: the fence still parses, still renders, and is wrong. 

614 

615 The prelude is every python fence earlier in the same file, concatenated, because that is 

616 what ``README.md``'s pair needs -- the first fence defines the ``audit`` task with 

617 ``@task`` and the second calls ``lookup("audit")``, so running the second alone raises. 

618 The whole captured stdout is then compared against the block. 

619 

620 That comparison is exact but for surrounding whitespace, and it carries one assumption 

621 worth stating: a prelude fence that *prints* would have its output counted as part of the 

622 result. No file here has one. If that changes, the fix is to fence off the prelude's 

623 output rather than to loosen the diff, because a loosened diff is how a stale example 

624 starts passing again. 

625 

626 Args: 

627 per_file: Fences grouped by file, so a prelude cannot reach across files. 

628 cfg: The resolved config. 

629 scratch: Directory for the throwaway script and its captured stdout. 

630 

631 Returns: 

632 Violation messages, one per stale or unrunnable block. 

633 """ 

634 broken: list[str] = [] 

635 for one_file in per_file: 

636 for index, fence in enumerate(one_file): 

637 if fence.language != RESULT_FENCE_LANGUAGE: 

638 continue 

639 prelude = [f.code for f in one_file[:index] if f.language == PYTHON_FENCE_LANGUAGE] 

640 if not prelude: 

641 broken.append(f"{fence.path}:{fence.line}: result block with no python fence above it") 

642 continue 

643 printed = _run_fences(cfg, scratch, prelude) 

644 if printed is None: 

645 broken.append(f"{fence.path}:{fence.line}: the python above this block exited non-zero") 

646 elif printed.strip() != fence.code.strip(): 

647 broken.append( 

648 f"{fence.path}:{fence.line}: result block is stale\n" 

649 f" expected: {fence.code.strip()!r}\n" 

650 f" actual: {printed.strip()!r}" 

651 ) 

652 return broken 

653 

654 

655def _run_fences(cfg: Config, scratch: Path, codes: list[str]) -> str | None: 

656 """Run python fences in the project environment and return their stdout. 

657 

658 A subprocess, and not :func:`exec` in this process, which would be shorter: the fences in 

659 ``adding_a_task.md`` call ``@task``, and ``@task`` registers into the live 

660 :data:`~rhiza_task.spec.REGISTRY`. Running them here would add an ``audit`` task to the 

661 process running the gate, so a later ``list`` in the same ``rhiza-task all`` would print a 

662 task that does not exist. Isolation is a requirement here, not caution. 

663 

664 stdout arrives through a file rather than a pipe for the reason ``complexity`` reads 

665 radon's ``--output-file``: :func:`~rhiza_task.uv.uv_run` streams rather than captures, and 

666 adding a capturing variant to ``uv.py`` for one caller would widen that module's surface 

667 for it. The script redirects its own stdout, so the invocation stays a fixed argument 

668 vector with no shell -- and the fences' tracebacks still reach the terminal on stderr, 

669 which is where a reader wants them. 

670 

671 Args: 

672 cfg: The resolved config. 

673 scratch: Directory for the script and its captured stdout. 

674 codes: The python fences to run, in document order. 

675 

676 Returns: 

677 The captured stdout, or None when the script exited non-zero or wrote nothing. 

678 """ 

679 printed = scratch / "stdout.txt" 

680 # Stale output first, for the reason `complexity` unlinks its report: output left by an 

681 # earlier run would be read as this run's, and a diff that passes against last run's 

682 # stdout is worse than no diff. 

683 printed.unlink(missing_ok=True) 

684 # `result_fences.py`, not `fences.py`: this module is now called fences.py too, and a 

685 # generated scratch file sharing its name reads as the module in a traceback or a grep. 

686 script = scratch / "result_fences.py" 

687 script.write_text( 

688 f"import sys\nsys.stdout = open({str(printed)!r}, 'w', encoding='utf-8')\n" 

689 + "\n".join(codes) 

690 + "\nsys.stdout.flush()\n" 

691 ) 

692 code = uv_run("python", script.relative_to(cfg.root).as_posix(), cwd=cfg.root, check=False) 

693 if code or not printed.is_file(): 

694 return None 

695 return printed.read_text(errors="replace") 

696 

697 

698def _tally(fences: list[Fence]) -> Tally: 

699 """Count the fences by what can be done with them. 

700 

701 Split out of :func:`_report` rather than inlined there, which read more directly: the two 

702 together score C on cyclomatic complexity, and a fifth C block in this package is a 

703 fifth thing a reader has to accept an argument for. Counting and printing are genuinely 

704 separate jobs, so this is the decomposition the metric asks for rather than a contortion 

705 to satisfy it. 

706 

707 Since toml and yaml joined, that argument has stopped being about taste: at B (7) here and 

708 B (10) there, one function doing both would land above ``complexity_max`` and fail 

709 ``rhiza-task complexity`` outright. So this split is now held by the gate rather than by 

710 the reader who remembers why it is here. 

711 

712 Args: 

713 fences: Every fence in the tree. 

714 

715 Returns: 

716 The counts, as a :class:`Tally`. 

717 """ 

718 tally = Counter(fence.language or "(none)" for fence in fences) 

719 unchecked = sorted( 

720 ((language, count) for language, count in tally.items() if language not in CHECKED_FENCE_LANGUAGES), 

721 key=lambda item: (-item[1], item[0]), 

722 ) 

723 # B604 matches the *keyword name* `shell=` below and reads this as a subprocess call being 

724 # handed a shell. It is a field assignment on a frozen dataclass of integers, and the 

725 # nearest subprocess is two hundred lines away. Renaming the field to dodge the pattern 

726 # would cost the symmetry with `python`, `toml`, `yaml` and `diffed` -- which is the whole 

727 # reason the tuple became a record -- so the suppression is the cheaper trade. 

728 # 

729 # The marker sits on this line and not on `shell=` itself, which is where the finding is 

730 # reported and where it was first written. bandit locates a B604 at the keyword but does 

731 # its nosec bookkeeping against the enclosing call node, so a marker on the keyword line 

732 # suppresses the finding *and* then reports itself as unmatched -- one 

733 # `nosec encountered ... but no failed test` line in every `fmt` and `security` run, which 

734 # is noise in the two gates whose value is a clean signal. Here both agree and neither 

735 # fires. The marker carries no prose for the reason this file's header gives: bandit reads 

736 # anything after it as more test IDs. 

737 return Tally( # nosec B604 

738 python=tally[PYTHON_FENCE_LANGUAGE], 

739 shell=sum(tally[language] for language in SHELL_FENCE_LANGUAGES), 

740 toml=tally[TOML_FENCE_LANGUAGE], 

741 yaml=sum(tally[language] for language in YAML_FENCE_LANGUAGES), 

742 diffed=tally[RESULT_FENCE_LANGUAGE], 

743 unchecked=unchecked, 

744 ) 

745 

746 

747def _report(fences: list[Fence], bash: str | None, yaml_ran: bool, files: int) -> bool: 

748 """Print the inventory and report whether anything was checkable. 

749 

750 The unchecked count is printed rather than dropped because "0 examples" and "43 fences 

751 nothing looks at" both pass every other gate in this repository while documenting nothing 

752 verifiable. A reader seeing only a green line would take it for full coverage. 

753 

754 Two of the five kinds can go unchecked on a machine that runs the gate fine otherwise -- 

755 shell without bash, yaml without a provisioned parser -- and each gets its own line saying 

756 so. They are counted out of ``checked`` in that case rather than assumed sound, which is 

757 the same rule the tool guards elsewhere in this package follow. 

758 

759 B (10) after toml and yaml were added, from B (6). The growth is one branch per kind that 

760 can be unavailable, which is open-ended rather than closed, so it gets a ceiling: **a 

761 sixth checkable language that needs provisioning takes this to C (12)**, and at that point 

762 the availability lines want a loop over ``(count, ran, noun)`` triples rather than a 

763 branch each. A kind that cannot go unavailable -- anything stdlib, as toml is -- costs 

764 nothing here and does not count against that. 

765 

766 Args: 

767 fences: Every fence in the tree. 

768 bash: Path to bash, or None when it is absent. 

769 yaml_ran: Whether the yaml parser could be provisioned. 

770 files: How many markdown files were read. 

771 

772 Returns: 

773 True when at least one fence was checked, so the caller can skip rather than pass. 

774 """ 

775 tally = _tally(fences) 

776 checked = tally.python + tally.diffed + tally.toml + (tally.shell if bash else 0) + (tally.yaml if yaml_ran else 0) 

777 print(f"\n[INFO] {files} file(s), {len(fences)} fence(s): {checked} checked") 

778 print( 

779 f"[INFO] {tally.python} python, {tally.shell} shell, " 

780 f"{tally.toml} toml, {tally.yaml} yaml, {tally.diffed} diffed" 

781 ) 

782 if bash is None and tally.shell: 

783 print(f"[INFO] bash not found: {tally.shell} shell fence(s) went unchecked") 

784 if not yaml_ran and tally.yaml: 

785 print(f"[INFO] yaml parser unavailable: {tally.yaml} yaml fence(s) went unchecked") 

786 if tally.unchecked: 

787 listing = ", ".join(f"{count} {language}" for language, count in tally.unchecked) 

788 print(f"[INFO] {sum(count for _, count in tally.unchecked)} fence(s) not checkable: {listing}") 

789 return checked > 0