Coverage for plugin/scripts/check_doc_examples.py: 100%
44 statements
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 14:46 +0000
« prev ^ index » next coverage.py v7.16.1, created at 2026-09-15 14:46 +0000
1#!/usr/bin/env python3
2"""Check a repo's *examples* — the doctests in its docstrings and the fences in its README.
4`/rhiza:quality` has always measured docstring **coverage** and never docstring **truth**.
5`interrogate` answers "is there a docstring?"; nothing answered "does the example inside it
6still evaluate to what it claims?". The same hole sits under the README: `markdownlint`
7checks that a fence is well-formed markdown and says nothing about whether the shell inside
8it parses or the Python inside it runs. Both are documentation that fails silently — it
9keeps rendering long after it stopped being true, and the person it misleads is a newcomer
10running the quickstart.
12The rhiza template already closes this in a *managed* repo: `.rhiza/tests/` ships
13`test_docstrings.py`, `test_readme.py` and `test_readme_validation.py`, all of which
14`make rhiza-test` runs. This script is that same trio for **any** repo — the degraded-mode
15case above all, where there is no `.rhiza/` and so nothing checks any of it. Its
16conventions mirror the template's, `+RHIZA_SKIP` included, so a repo that adopts rhiza
17later keeps the verdict it had here.
19The two halves live in `_doc_examples_source.py` and `_doc_examples_readme.py`; this file
20is the dispatcher, the combined verdict and the CLI. Each half degrades on its own: a repo
21with no source root still gets its README checked, and vice versa.
23`--run` adds execution, and is opt-in for a reason: running examples means importing the
24repo's modules and executing its README's Python, which runs whatever module-level code
25they carry. That is the same trust boundary `make test` already crosses, but `/quality` is
26an *assessment* command, so it is never crossed unasked. Shell fences are never executed
27under any flag.
29Usage:
30 uv run --python 3.12 --no-project python \
31 scripts/check_doc_examples.py [--target-dir DIR] [--source-root DIR] \
32 [--readme FILE] [--run] [--json]
34Exit codes:
35 0 every example that could be checked holds
36 1 an example is broken (bad syntax, a failing doctest, output that doesn't match)
37 2 nothing was checkable — no source root and no README
38"""
40from __future__ import annotations
42import argparse
43import json
44import sys
45from pathlib import Path
46from typing import Any
48sys.path.insert(0, str(Path(__file__).resolve().parent))
49from _doc_examples_readme import print_report as print_readme # noqa: E402
50from _doc_examples_readme import readme_report # noqa: E402
51from _doc_examples_source import docstring_report # noqa: E402
52from _doc_examples_source import print_report as print_docstrings # noqa: E402
54EXIT_OK = 0
55EXIT_VIOLATION = 1
56EXIT_NOTHING = 2
59def report(target: Path, source_root: str, readme: str, *, run: bool) -> dict[str, Any]:
60 """Check both halves and return the combined summary, exit code included.
62 The distinction between exit 1 and exit 2 is the one that matters to a caller that
63 scores: a broken example is a finding, while *nothing to check* is out-of-scope — the
64 same rule an unavailable `make` target already follows in `/quality`.
65 """
66 docs = docstring_report(target / source_root, run=run)
67 readme_part = readme_report(target / readme, run=run)
68 violations = list(docs["violations"]) + list(readme_part["violations"])
70 if violations:
71 exit_code = EXIT_VIOLATION
72 elif not docs["present"] and not readme_part["present"]:
73 exit_code = EXIT_NOTHING
74 else:
75 exit_code = EXIT_OK
76 return {
77 "docstrings": docs,
78 "readme": readme_part,
79 "violations": violations,
80 "notes": list(docs["notes"]) + list(readme_part["notes"]),
81 "exit_code": exit_code,
82 }
85def _parser() -> argparse.ArgumentParser:
86 """Build the CLI parser."""
87 parser = argparse.ArgumentParser(
88 description="Check a repo's doctest examples and README code fences.",
89 )
90 parser.add_argument("--target-dir", default=".", help="Repository root (default: cwd).")
91 parser.add_argument(
92 "--source-root",
93 default="src",
94 help="Source root holding the docstrings (default: src; ask language_profile.py).",
95 )
96 parser.add_argument(
97 "--readme", default="README.md", help="README to check (default: README.md)."
98 )
99 parser.add_argument(
100 "--run",
101 action="store_true",
102 help="Execute the examples, not just parse them (imports the repo's modules).",
103 )
104 parser.add_argument(
105 "--json", dest="json_output", action="store_true", help="Emit the summary as JSON."
106 )
107 return parser
110def main(argv: list[str] | None = None) -> int:
111 """Entry point: check the repo's examples and return an exit code."""
112 args = _parser().parse_args(argv)
113 # Deliberately not resolved: every path in the report is quoted back as it was given,
114 # so a run from the repo root prints `README.md:59` rather than an absolute path
115 # nobody can paste into an editor.
116 summary = report(Path(args.target_dir), args.source_root, args.readme, run=args.run)
118 if args.json_output:
119 print(json.dumps(summary, indent=2))
120 else:
121 print_docstrings(summary["docstrings"])
122 print_readme(summary["readme"])
123 for violation in summary["violations"]:
124 print(f"violation {violation}", file=sys.stderr)
125 for note in summary["notes"]:
126 print(f"note {note}", file=sys.stderr)
127 return int(summary["exit_code"])
130if __name__ == "__main__":
131 raise SystemExit(main())