Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 61 additions & 6 deletions scripts/check-doc-count.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,21 @@
only tracked content); in a checkout the tracked path always wins, so a broken
repository fails loud rather than quietly scanning something else.

The verdict states its coverage, and a scan that read nothing is a refusal. `OK`
names how many files were read (and how many were skipped as unreadable), so *no
claim in the whole scope* and *no claim in none* stop being the same sentence; a
tree this rule read no file from - every file under `tests/` or `scripts/`, or an
export holding none of them - exits `2` with `could not measure`, because `0`
means *measured and clean* and "I could not check" reported as healthy is how a
broken tree reaches master.

Exit codes
----------
0 no stored count in the tree it read (the count is named in the output)
1 `FAIL` - a tracked file states the count; delete it and name the command
2 `could not measure` - no file was read at all, the file listing failed, or
`--measure` could not collect a count (a missing pytest counts here)

Run it with the project interpreter: `--measure` shells out to pytest, so a bare
`python3` that cannot import pytest fails loud with that reason rather than
reporting a bogus count.
Expand Down Expand Up @@ -376,20 +391,42 @@ def scanned_files() -> list[str]:
return _exported_files()


def offenders() -> list[tuple[str, int, str, str]]:
"""(file, line number, shape, line) for every stored count in the tree."""
def scan() -> tuple[list[str], list[str], list[tuple[str, int, str, str]]]:
"""(files read, files skipped as unreadable, stored counts found).

The coverage is returned beside the findings because a rule that reports
"nothing wrong" owes the reader the size of what it looked at: *no claim in
415 files* and *no claim in none* are the same sentence otherwise, and this
module's own Scope paragraph says "which files did you scan" is the one thing
it must not answer vaguely.
"""
read: list[str] = []
skipped: list[str] = []
found: list[tuple[str, int, str, str]] = []
for name in scanned_files():
try:
text = (REPO_ROOT / name).read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
# Binary or unreadable: a claim cannot live in a file this rule
# cannot read, and failing the whole scan over, say, a PNG would
# make the guard unusable rather than strict.
# make the guard unusable rather than strict. Skipped, not hidden:
# the count goes in the verdict below.
skipped.append(name)
continue
read.append(name)
for lineno, shape, line in claims_in(text):
found.append((name, lineno, shape, line))
return found
return read, skipped, found


def offenders() -> list[tuple[str, int, str, str]]:
"""(file, line number, shape, line) for every stored count in the tree.

The findings alone, for callers that want only those (the pytest guard loads
this module and asks for them). The scan's own coverage - how many files were
read and how many skipped - comes from `scan()`.
"""
return scan()[2]


def resolve_conflict(text: str) -> str:
Expand Down Expand Up @@ -551,15 +588,33 @@ def main(argv: list[str] | None = None) -> int:
return _resolve_conflict_mode()

try:
found = offenders()
read, skipped, found = scan()
except DocCountError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2

if not read:
# The same defect as a failed listing, one step later: an empty scan and a
# clean tree printed the same sentence (the test beside this rule already
# names that as the thing not to allow). A tree this rule read nothing
# from is one it cannot judge, and `0` means *measured and clean*.
print(
f"could not measure: no file was read under {REPO_ROOT}, so this tree "
"carries nothing to judge. The rule scans every tracked file except "
f"{', '.join(EXCLUDED_PREFIXES)}, so a tree whose files are all in "
"those two directories (or an export holding none of them) is an empty "
"subject, and a clean verdict here would be a reading of nothing.",
file=sys.stderr,
)
return 2

if not found:
coverage = f"{len(read)} file(s) read"
if skipped:
coverage += f", {len(skipped)} unreadable file(s) skipped"
print(
"OK: no tracked file states the Python test count "
"(it is measured, not stored)"
f"({coverage}; it is measured, not stored)"
)
return 0

Expand Down
56 changes: 55 additions & 1 deletion tests/test_check_doc_count.py
Original file line number Diff line number Diff line change
Expand Up @@ -621,12 +621,66 @@ def test_the_scanned_tree_is_named_in_the_output(mod, monkeypatch, tmp_path, cap
fake = _fake_checkout(tmp_path / "checkout", 1307)
monkeypatch.chdir(fake)
monkeypatch.setattr(mod, "REPO_ROOT", mod._resolve_root())
monkeypatch.setattr(mod, "scanned_files", lambda: [])
# A stub that read *something*: an empty scan is its own refusal now
# (`test_an_empty_scan_is_a_refusal_not_a_clean_tree` below), and this test is
# about which tree gets named, so it must not be driven through that branch.
monkeypatch.setattr(mod, "scan", lambda: (["Agent.md"], [], []))
assert mod.main([]) == 0
out = capsys.readouterr().out
assert f"tree: {fake.resolve()}" in out, out


def test_an_empty_scan_is_a_refusal_not_a_clean_tree(mod, monkeypatch, tmp_path, capsys):
"""A tree this rule read no file from is not a tree it found nothing wrong in.

The sibling test above (`test_unlistable_files_fail_loud`) already states the
rule - "an empty scan and a clean tree are indistinguishable in the output" -
and only the *listing failure* had a branch for it. An empty listing is the
same defect one step later: every file under `tests/`/`scripts/`, or an export
holding none of them, printed a green verdict over a subject of nothing, and
`0` is the code that means *measured and clean*.

Both directions: empty refuses and names the tree, non-empty still passes -
a refusal that fired on a real tree would make the tool unusable.
"""
fake = _fake_checkout(tmp_path / "checkout", 1307)
monkeypatch.chdir(fake)
monkeypatch.setattr(mod, "REPO_ROOT", mod._resolve_root())
monkeypatch.setattr(mod, "scan", lambda: ([], [], []))
assert mod.main([]) == 2
captured = capsys.readouterr()
assert "could not measure" in captured.err, captured.err
assert f"tree: {fake.resolve()}" in captured.out, captured.out
# The refusal says which directories the scope is, so the reader can tell a
# tree that is genuinely out of scope from one whose listing broke.
assert "tests/" in captured.err and "scripts/" in captured.err, captured.err

monkeypatch.setattr(mod, "scan", lambda: (["Agent.md", "emrg/x.py"], [], []))
assert mod.main([]) == 0
assert "OK: no tracked file states the Python test count" in capsys.readouterr().out


def test_the_clean_verdict_names_how_much_it_read(mod, monkeypatch, tmp_path, capsys):
"""`no claim in the whole scope` and `no claim in none` must not be one sentence.

The number is the point: a reader who cannot see the coverage cannot tell a
narrowed scan from a clean tree, which is the defect the sibling `.md` witness
guards on the other side (`test_the_scan_scope_covers_every_tracked_doc`).
A skipped file is stated too, so a partial read is not read as a whole one.
"""
monkeypatch.setattr(mod, "REPO_ROOT", tmp_path)
monkeypatch.setattr(mod, "scan", lambda: (["Agent.md", "emrg/x.py"], [], []))
assert mod.main([]) == 0
out = capsys.readouterr().out
assert "2 file(s) read" in out, out

monkeypatch.setattr(mod, "scan", lambda: (["Agent.md"], ["img.png"], []))
assert mod.main([]) == 0
out = capsys.readouterr().out
assert "1 file(s) read" in out, out
assert "1 unreadable file(s) skipped" in out, out


def _git_stdout(*argv: str) -> str:
"""One `git` read in this checkout, on the test's own account."""
proc = subprocess.run(
Expand Down
Loading