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

120 statements  

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

1"""The language-neutral gates: quality.mk and bootstrap.mk's ``clean``. 

2 

3Nothing here needs to know how the project declares its dependencies, which is why the 

4template made ``core`` rather than a language layer own them. 

5 

6The interesting one is ``rhiza-test``. quality.mk runs ``pytest .rhiza/tests`` -- a folder 

7synced from the template -- and prints a WARN and exits 0 when the folder is absent. Since 

8that folder was replaced by the ``pytest-rhiza`` distribution, consumers who excluded it 

9got a green gate measuring nothing, and jointview carries a 60-line Makefile override to 

10fix that for itself. Here the plugin *is* the implementation, so the override is not 

11needed and the silent-pass branch does not exist. 

12 

13``docs-examples`` is registered here and implemented in :mod:`rhiza_task.tasks.fences`. 

14The split is worth knowing rather than discovering: the checker had grown to two thirds of 

15this module and pulled its maintainability index from 62 to 36, at which point the docstring 

16above described half a file. The task keeps the argument for *why* the gate exists, which is 

17what a reader looking for a gate wants; that module holds the argument for how it checks. 

18""" 

19 

20from __future__ import annotations 

21 

22import re 

23import shutil 

24 

25# The hook-path probe below is a fixed argument vector, with no shell -- which is what bandit's 

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

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

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

29import subprocess # nosec B404 

30import tomllib 

31from pathlib import Path 

32 

33from ..config import Config 

34from ..spec import Guard, Skip, task 

35from ..uv import uv_run, uvx 

36from . import fences 

37 

38TODO_PATTERN = re.compile(r"\b(TODO|FIXME|HACK):") 

39TODO_SUFFIXES = frozenset({".py", ".mk", ".sh", ".md", ".yml", ".yaml", ".toml", ".rs", ".go"}) 

40TODO_SKIP_DIRS = frozenset( 

41 { 

42 ".git", 

43 ".venv", 

44 "node_modules", 

45 ".tox", 

46 "build", 

47 "dist", 

48 "_book", 

49 "_tests", 

50 ".mypy_cache", 

51 ".pytest_cache", 

52 ".ruff_cache", 

53 "__pycache__", 

54 } 

55) 

56 

57TAG_VERSION_CHECK = "test_latest_tag_matches_pyproject_version" 

58"""pytest-rhiza's assertion that the newest tag equals the declared version. 

59 

60Correct about a released tree and **false by construction during a release**, which is the 

61window :func:`_release_pending` exists to detect. A repository cannot satisfy it between the 

62version bump and the tag: the bump is what the release PR contains, and the tag is cut from 

63that PR's merge commit, so for the length of the PR the declared version is ahead of every 

64tag that exists. 

65 

66What that costs is a red ``rhiza-task all`` on the releaser's own machine for the length of the 

67release, which is where it was hit while cutting v1.1.0. 

68 

69**It costs nothing in CI, and the note this replaces claimed otherwise.** ci.yml's checkout sets 

70no ``fetch-depth`` and no ``fetch-tags``, so no CI job has any tags at all and this check already 

71skips there -- ``No version tags found in repository``. The required ``gates`` job was therefore 

72never blocked by it, and the assertion that v1.0.0's release PR had been merged red was inferred 

73from a local run rather than read off a CI one. Both claims were wrong, and they were wrong in 

74the direction that made this change look more necessary than it is. Recorded rather than quietly 

75deleted, because the overstatement shipped. See #115. 

76""" 

77 

78CLEAN_ARTIFACTS = ("dist", "build", ".coverage", ".pytest_cache", ".benchmarks", "_tests", "_book") 

79 

80 

81@task("fmt", "run the pre-commit hooks over all files", section="Quality") 

82def fmt(cfg: Config) -> None: 

83 """Run every configured hook via prek. 

84 

85 ``--config`` is not decoration. By default prek treats every directory below the root 

86 holding a ``.pre-commit-config.yaml`` as a separate project and runs each one's hooks 

87 -- useful in a monorepo, wrong in rhiza's own repo where three bundles ship one as 

88 template content. Naming the config disables that discovery, so ``fmt`` means "this 

89 repo's config, once". A consumer wanting the monorepo behaviour drops the flag here and 

90 in the hook install. 

91 

92 prek rather than pre-commit: a Rust reimplementation reading the same config file, 

93 which provisions each hook's toolchain itself. That is what removed the 

94 ``-p ${PYTHON_VERSION}`` this recipe used to need, and with it the coupling that made 

95 the language-neutral half of the template depend on a Python version being resolvable. 

96 

97 Args: 

98 cfg: The resolved config. 

99 

100 Raises: 

101 Skip: When the project has no pre-commit config. 

102 """ 

103 if not (cfg.root / ".pre-commit-config.yaml").is_file(): 

104 raise Skip("no .pre-commit-config.yaml") 

105 uvx("prek", "run", "--all-files", "--config", ".pre-commit-config.yaml", cwd=cfg.root) 

106 

107 

108@task("semgrep", "run the semgrep static analysis rules", section="Quality", guards=(Guard("source_folder"),)) 

109def semgrep(cfg: Config) -> None: 

110 """Run semgrep against the source folder with rhiza's rule set. 

111 

112 Args: 

113 cfg: The resolved config. 

114 

115 Raises: 

116 Skip: When the rule file is absent. 

117 """ 

118 rules = cfg.root / ".rhiza" / "semgrep.yml" 

119 if not rules.is_file(): 

120 raise Skip("no .rhiza/semgrep.yml") 

121 uvx("semgrep", "--config", str(rules), cfg.source_folder, cwd=cfg.root) 

122 

123 

124@task("rhiza-test", "run the rhiza repository checks", section="Quality", needs=("install",)) 

125def rhiza_test(cfg: Config) -> None: 

126 """Run the ``pytest-rhiza`` checks against this repository. 

127 

128 The check modules are enumerated rather than globbed: pytest-rhiza ships the Rust and 

129 Go modules in the same distribution, so ``--pyargs pytest_rhiza.checks`` would collect 

130 checks that cannot pass on a Python project. See 

131 :data:`~rhiza_task.config.DEFAULT_RHIZA_CHECKS`. 

132 

133 ``install`` is a prerequisite because the docstring check imports the project's own 

134 packages to run their doctests, which needs the dependencies present. 

135 

136 ``RHIZA_DOCTEST_FOLDERS`` is what tells ``test_docstrings`` where to look, and it has 

137 to be passed: the check falls back to ``SOURCE_FOLDER`` in ``.rhiza/.env`` and then to 

138 a literal ``src``, so a repo whose Python lives anywhere else got 

139 ``SKIPPED No doctest folder found (looked for: src)`` and a **green** gate -- the 

140 doctests went unchecked with nothing failing to say so. ``.rhiza/.env`` cannot cover 

141 for it either: since rhiza stopped shipping ``.rhiza/.gitignore``, whose only content 

142 was the ``!.env`` negation, that file is gitignored and a CI checkout never has one. 

143 ``quality.mk`` exported the variable from ``DOCSTRING_FOLDERS``; this is that export. 

144 

145 One check is dropped while a release is in flight. :data:`TAG_VERSION_CHECK` asserts that 

146 the newest tag equals the declared version, which a repository cannot satisfy between its 

147 version bump and its tag -- so ``rhiza-task all``, which a developer runs before pushing, 

148 went red for the length of a release. :func:`_release_pending` detects the window from the 

149 repository's own state, so nothing has to be passed in and the check returns by itself once 

150 the tag exists. It is a *local* improvement only: CI has no tags, so this check skips there 

151 regardless -- see that constant's own note. 

152 

153 The pin the checks are provisioned from is :func:`_provider`'s answer rather than the 

154 setting itself, so a repository can spell "resolve them from my own environment". 

155 

156 Args: 

157 cfg: The resolved config. 

158 """ 

159 # `-k`, not `--deselect`: a deselect needs the collected node id, and under `--pyargs` that 

160 # is the *installed* package's file path inside the uv cache -- a string this task would 

161 # have to reconstruct and that changes with the pin. Matching on the test's name needs 

162 # neither. 

163 # 

164 # Announced on stdout rather than passed over silently. A relaxed gate that says nothing is 

165 # how a real mismatch would hide behind this, and the whole argument for relaxing it is 

166 # that a permanently-red required check is worse than a visibly narrower one. 

167 selection: tuple[str, ...] = () 

168 if _release_pending(cfg): 

169 print(f"[INFO] release in flight: the declared version leads every tag, so {TAG_VERSION_CHECK} is deselected") 

170 selection = ("-k", f"not {TAG_VERSION_CHECK}") 

171 

172 uv_run( 

173 "pytest", 

174 "--pyargs", 

175 *cfg.rhiza_checks, 

176 *selection, 

177 cwd=cfg.root, 

178 withs=_provider(cfg), 

179 env={"RHIZA_DOCTEST_FOLDERS": cfg.source_folder}, 

180 ) 

181 

182 

183@task( 

184 "test-pyproject", 

185 "run the pyproject.toml structure checks, verbosely", 

186 section="Quality", 

187 layer="python", 

188 needs=("install",), 

189) 

190def test_pyproject(cfg: Config) -> None: 

191 """Run just the pyproject check, with full reporting. 

192 

193 A narrower, louder view of one module that ``rhiza-test`` also runs -- kept because it 

194 is what you want when that check is the thing you are fixing. The reporting flags are 

195 python.mk's verbatim, and the provider is :func:`_provider`'s, so the two gates agree on 

196 where the checks come from. 

197 

198 Args: 

199 cfg: The resolved config. 

200 """ 

201 uv_run( 

202 "pytest", 

203 "--pyargs", 

204 "pytest_rhiza.checks.test_pyproject", 

205 "-v", 

206 "--tb=long", 

207 "--showlocals", 

208 "-rA", 

209 "--durations=0", 

210 "--no-header", 

211 cwd=cfg.root, 

212 withs=_provider(cfg), 

213 ) 

214 

215 

216@task("todos", "list every TODO, FIXME and HACK comment", section="Quality") 

217def todos(cfg: Config) -> None: 

218 """Report TODO/FIXME/HACK comments with file and line. 

219 

220 quality.mk implements this as ``find -print0 | xargs -0 grep -nHE | grep -v | awk``, 

221 with a ``grep -v "make todos"`` filter to stop the recipe matching itself. Reading the 

222 files directly needs no such filter and no shell. 

223 

224 Args: 

225 cfg: The resolved config. 

226 """ 

227 hits = 0 

228 for path in sorted(_walk(cfg.root)): 

229 try: 

230 lines = path.read_text(errors="replace").splitlines() 

231 except OSError: 

232 # Skip, not fail: a file the gate cannot open is not a TODO, and one unreadable 

233 # path in a tree must not cost the report every hit after it. `errors="replace"` 

234 # already absorbs undecodable *bytes*, so what reaches here is the file that 

235 # could not be opened at all -- a permission bit, a dangling symlink, a name the 

236 # OS accepted and cannot serve. 

237 continue 

238 for number, line in enumerate(lines, start=1): 

239 if TODO_PATTERN.search(line): 

240 # as_posix, not str: this is report output a reader copies into a grep or an 

241 # editor's go-to-file, so the separator must not depend on the OS that ran 

242 # the gate. The paths are repo-relative and never touch the filesystem again. 

243 rel = path.relative_to(cfg.root).as_posix() 

244 print(f"{rel}:{number}: {line.strip()}") 

245 hits += 1 

246 print(f"\n[INFO] {hits} item(s) found.") 

247 

248 

249@task( 

250 "docs-examples", 

251 "check the fenced examples in the docs tree", 

252 section="Quality", 

253 needs=("install",), 

254 guards=(Guard("docs_folder"),), 

255) 

256def docs_examples(cfg: Config) -> None: 

257 """Parse every checkable fence under the docs folder, and diff the executed ones. 

258 

259 The gap this closes: ``docs-coverage`` asks whether a docstring *exists* and 

260 markdownlint asks whether the markdown is *well-formed*. Neither asks whether what the 

261 documentation **claims** is still true, and a stale command keeps rendering perfectly -- 

262 so the reader who finds out is a newcomer, at the worst moment. ``README.md`` was already 

263 covered, by pytest-rhiza's ``test_readme_validation`` under :func:`rhiza_test`; the docs 

264 tree had nothing, and it is the larger half. 

265 

266 Not a second check of ``README.md``, deliberately: that file is pytest-rhiza's subject, 

267 and counting one verdict twice would make two gates report one fact. 

268 

269 Which languages are checked, how, and why two of them can go unavailable on a working 

270 machine all live in :mod:`rhiza_task.tasks.fences`, which holds the checker. This is the 

271 registration and the argument for the gate; that module is the implementation. 

272 

273 ``install`` is a prerequisite because the executed half imports the project's own 

274 packages, exactly as :func:`rhiza_test`'s docstring check does. 

275 

276 Args: 

277 cfg: The resolved config. 

278 

279 Raises: 

280 Skip: When the tree holds no checkable fence, so nothing was measured. A docs tree 

281 documenting nothing runnable would otherwise score a silent pass, which is the 

282 failure this gate exists to make visible. 

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

284 """ 

285 fences.check(cfg) 

286 

287 

288@task("clean", "remove build artifacts and stale local branches", section="Dev") 

289def clean(cfg: Config) -> None: 

290 """Remove ignored files, build artifacts, and local branches whose remote is gone. 

291 

292 ``.env`` files are preserved: they hold local configuration that is expensive to 

293 reconstruct and is not an artifact. 

294 

295 Args: 

296 cfg: The resolved config. 

297 """ 

298 git = shutil.which("git") or "git" 

299 _git(git, ["clean", "-d", "-X", "-f", "-e", "!.env", "-e", "!.env.*"], cfg.root) 

300 

301 for name in CLEAN_ARTIFACTS: 

302 target = cfg.root / name 

303 if target.is_dir(): 

304 shutil.rmtree(target, ignore_errors=True) 

305 elif target.exists(): 

306 target.unlink(missing_ok=True) 

307 for egg in cfg.root.glob("*.egg-info"): 

308 shutil.rmtree(egg, ignore_errors=True) 

309 

310 print("[INFO] removing local branches with no remote counterpart") 

311 _git(git, ["fetch", "--prune"], cfg.root) 

312 listing = _git(git, ["branch", "-vv"], cfg.root, capture=True) 

313 for line in listing.splitlines(): 

314 # A leading * or + marks the current or a worktree-checked-out branch; neither can 

315 # be deleted, and attempting it is how the make recipe's xargs used to fail. 

316 if ": gone]" in line and not line.startswith(("*", "+")): 

317 branch = line.strip().split()[0] 

318 _git(git, ["branch", "-D", branch], cfg.root) 

319 

320 

321def _provider(cfg: Config) -> tuple[str, ...]: 

322 """Return the ``--with`` arguments that provide pytest-rhiza, or nothing at all. 

323 

324 :attr:`~rhiza_task.config.Config.pytest_rhiza` is pinned by default, which is right for 

325 a consumer: a gate should run the assertions of a known release rather than whatever the 

326 checks repository's HEAD says today. **Empty means the opposite** -- omit ``--with`` 

327 entirely and let ``uv run`` resolve pytest-rhiza from the project environment, which is 

328 how a repository tries an unreleased check against a real subject without publishing 

329 something first. It is a real choice rather than an absent one, so it is honoured rather 

330 than defaulted over. 

331 

332 Spelling it needs TOML, for the reason :func:`~rhiza_task.config._from_env_file` gives 

333 about ``mkdocs-extra-packages``: only there does an empty value differ from an absent 

334 key. ``pytest-rhiza = ""`` in ``[tool.rhiza-task]`` says it; ``RHIZA_PYTEST_RHIZA=`` 

335 cannot, and reads as unset. 

336 

337 ``pytest-rhiza = "."`` is the trap to avoid rather than the shorthand to reach for: uv 

338 resolves it to a *cached built copy* which is not rebuilt on edit, so the checks pass 

339 against a stale tree while looking like they ran against the working one. Empty leaves 

340 the project environment to answer, which an editable install actually tracks. 

341 

342 Args: 

343 cfg: The resolved config. 

344 

345 Returns: 

346 The one-element pin, or an empty tuple when the setting is empty. 

347 """ 

348 return (cfg.pytest_rhiza,) if cfg.pytest_rhiza.strip() else () 

349 

350 

351def _walk(root: Path) -> list[Path]: 

352 """Return the files worth scanning for TODO comments. 

353 

354 Args: 

355 root: Repository root. 

356 

357 Returns: 

358 Matching files, with skipped directories pruned. 

359 """ 

360 found: list[Path] = [] 

361 stack = [root] 

362 while stack: 

363 current = stack.pop() 

364 for entry in current.iterdir(): 

365 if entry.is_dir(): 

366 if entry.name not in TODO_SKIP_DIRS: 

367 stack.append(entry) 

368 elif entry.suffix in TODO_SUFFIXES: 

369 found.append(entry) 

370 return found 

371 

372 

373def _semver(text: str) -> tuple[int, int, int] | None: 

374 """Parse a ``vX.Y.Z`` or ``X.Y.Z`` string into a comparable tuple. 

375 

376 Tuples of ints rather than a real version type, because comparing releases is all this 

377 needs and ``packaging`` is not a dependency of this package -- three runtime dependencies 

378 is a deliberate ceiling, each one paid for by every consumer on every ``uvx`` invocation. 

379 A pre-release or build suffix returns None rather than sorting oddly: this is used to 

380 decide whether to relax a gate, so anything it cannot read confidently must leave the gate 

381 alone. 

382 

383 Args: 

384 text: A tag or version string, with or without the leading ``v``. 

385 

386 Returns: 

387 ``(major, minor, patch)``, or None when the string is not exactly that shape. 

388 """ 

389 match = re.fullmatch(r"v?(\d+)\.(\d+)\.(\d+)", text.strip()) 

390 if match is None: 

391 return None 

392 major, minor, patch = (int(part) for part in match.groups()) 

393 return major, minor, patch 

394 

395 

396def _release_pending(cfg: Config) -> bool: 

397 """Report whether the declared version is ahead of every tag in the repository. 

398 

399 That state is a release in flight: the version bump has been made and the tag has not been 

400 cut yet, which is exactly what a release PR contains and what :data:`TAG_VERSION_CHECK` 

401 cannot be satisfied during. 

402 

403 Every uncertain answer is False -- no manifest, an unreadable one, a version or tag shape 

404 this cannot parse, no git, no tags at all. The gate is relaxed only on positive evidence 

405 that a release is underway, because the failure modes are asymmetric: relaxing it wrongly 

406 hides a real mismatch, while leaving it on wrongly costs one red job on a release PR, which 

407 is the situation being fixed and is at least visible. 

408 

409 It also does not distinguish a release in flight from a bump nobody ever tagged. Nothing 

410 local can: the two states are identical on disk. So the trade is stated rather than hidden 

411 -- the *behind* direction stays gated, which is the one that shipped a wrong version before 

412 (v1.0.0's release commit left uv.lock at the previous version), and the *ahead* direction 

413 is announced on stdout by the caller rather than passed over. 

414 

415 Args: 

416 cfg: The resolved config. 

417 

418 Returns: 

419 True when a release looks to be in flight. 

420 """ 

421 manifest = cfg.root / "pyproject.toml" 

422 if not manifest.is_file(): 

423 return False 

424 try: 

425 parsed = tomllib.loads(manifest.read_text(errors="replace")) 

426 except tomllib.TOMLDecodeError: 

427 return False 

428 declared = _semver(str(parsed.get("project", {}).get("version", ""))) 

429 git = shutil.which("git") 

430 if declared is None or git is None: 

431 return False 

432 listing = _git(git, ["tag", "--list", "v*"], cfg.root, capture=True) 

433 tags = [parsed_tag for parsed_tag in (_semver(line) for line in listing.split()) if parsed_tag] 

434 return bool(tags) and declared > max(tags) 

435 

436 

437def _git(git: str, args: list[str], cwd: Path, capture: bool = False) -> str: 

438 """Run a git command, tolerating failure. 

439 

440 Args: 

441 git: The git executable. 

442 args: Arguments. 

443 cwd: Working directory. 

444 capture: Return stdout instead of streaming it. 

445 

446 Returns: 

447 Captured stdout, or an empty string. 

448 """ 

449 result = subprocess.run( # noqa: S603 # nosec B603 

450 [git, *args], 

451 cwd=cwd, 

452 check=False, 

453 text=True, 

454 capture_output=capture, 

455 ) 

456 return result.stdout or "" if capture else "" 

457 

458 

459def install_hooks(cfg: Config) -> None: 

460 """Install the prek git hooks unless an external manager owns ``core.hooksPath``. 

461 

462 Neutral, and here rather than in a language module, because all three ``install`` 

463 recipes carry it verbatim -- python.mk, rust.mk and go.mk each end with the same 

464 twelve lines of shell. prek provisions each hook's own toolchain, so there is nothing 

465 language-specific left in it. 

466 

467 ``-c`` must be passed here *and* in :func:`fmt`: prek bakes the flag into the generated 

468 shim, so without it the commit-time gate rediscovers nested projects and stops meaning 

469 what ``fmt`` means. 

470 

471 Args: 

472 cfg: The resolved config. 

473 """ 

474 if not (cfg.root / ".pre-commit-config.yaml").is_file(): 

475 return 

476 git = shutil.which("git") or "git" 

477 hooks_path = _git(git, ["config", "--get", "core.hooksPath"], cfg.root, capture=True).strip() 

478 if hooks_path: 

479 print("[INFO] skipping hook install: core.hooksPath is set") 

480 return 

481 # A hook-install failure warns rather than fails, as the make recipes do: it does not 

482 # invalidate the environment that was just built. 

483 uvx("prek", "install", "-c", ".pre-commit-config.yaml", cwd=cfg.root, check=False)