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
« 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``.
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.
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.
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"""
20from __future__ import annotations
22import re
23import shutil
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
33from ..config import Config
34from ..spec import Guard, Skip, task
35from ..uv import uv_run, uvx
36from . import fences
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)
57TAG_VERSION_CHECK = "test_latest_tag_matches_pyproject_version"
58"""pytest-rhiza's assertion that the newest tag equals the declared version.
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.
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.
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"""
78CLEAN_ARTIFACTS = ("dist", "build", ".coverage", ".pytest_cache", ".benchmarks", "_tests", "_book")
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.
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.
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.
97 Args:
98 cfg: The resolved config.
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)
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.
112 Args:
113 cfg: The resolved config.
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)
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.
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`.
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.
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.
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.
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".
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}")
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 )
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.
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.
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 )
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.
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.
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.")
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.
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.
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.
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.
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.
276 Args:
277 cfg: The resolved config.
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)
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.
292 ``.env`` files are preserved: they hold local configuration that is expensive to
293 reconstruct and is not an artifact.
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)
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)
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)
321def _provider(cfg: Config) -> tuple[str, ...]:
322 """Return the ``--with`` arguments that provide pytest-rhiza, or nothing at all.
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.
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.
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.
342 Args:
343 cfg: The resolved config.
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 ()
351def _walk(root: Path) -> list[Path]:
352 """Return the files worth scanning for TODO comments.
354 Args:
355 root: Repository root.
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
373def _semver(text: str) -> tuple[int, int, int] | None:
374 """Parse a ``vX.Y.Z`` or ``X.Y.Z`` string into a comparable tuple.
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.
383 Args:
384 text: A tag or version string, with or without the leading ``v``.
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
396def _release_pending(cfg: Config) -> bool:
397 """Report whether the declared version is ahead of every tag in the repository.
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.
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.
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.
415 Args:
416 cfg: The resolved config.
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)
437def _git(git: str, args: list[str], cwd: Path, capture: bool = False) -> str:
438 """Run a git command, tolerating failure.
440 Args:
441 git: The git executable.
442 args: Arguments.
443 cwd: Working directory.
444 capture: Return stdout instead of streaming it.
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 ""
459def install_hooks(cfg: Config) -> None:
460 """Install the prek git hooks unless an external manager owns ``core.hooksPath``.
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.
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.
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)