Files
ersatztv/scripts/tests/tracked_files.py
T
timothyandClaude Opus 5 10dba0892e
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 19s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 20s
PR Gates / decisions lifecycle (pull_request) Successful in 22s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 15s
PR Gates / Docs update reminder (pull_request) Successful in 31s
review-verdict/h10 Awaiting review verdict for 10dba08
Review verdict / Set review-verdict status (pull_request_target) Successful in 39s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 4m38s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m32s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m58s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m52s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Canceled after 1s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Canceled after 0s
fix(806): make the drifting counts count-free instead of correcting them again
Cold review of the previous commit found that IT had made
`docs/guard-inventory.md`'s "load-bearing for the six modules that import it"
false, by adding a seventh importer — in the file it was editing, inside the sweep
its own message claims to have done by subject. That is the third hand-maintained
count in this change to go stale, after `mutation-claims-are-executed.md`'s three
and `guard-inventory.md`'s "three of the thirteen".

Correcting a number that tracks a growing population buys one commit of accuracy.
So the counts that track populations are gone rather than updated:

  * "load-bearing for the six modules that import it" → "for every module that
    imports it"
  * "this file is not four near-copies" → "not one near-copy per derivation"
  * "adding a fifth derivation / a fifth index-derived guard" (twice) → "another"
  * "five guard modules would otherwise die" → "every module that imports this one"
  * "Exhaustive removal is cheap (~1s for 60 members)" → "about a second at the
    population sizes here"

The sweep was then done MECHANICALLY — a regex over every spelled-out and numeric
count in all five artifacts this change touches — rather than by eye for a fourth
time. What survives is verified true rather than assumed: "three of the fourteen"
and "36 guards … 14/6/16" (the latter machine-asserted by
`test_the_summary_counts_match_the_table`), "unions four contributors",
"five modules each derived a file population" (`DERIVATIONS` holds exactly five),
and the re-measured 61/39/48/58. `guard-inventory.md`'s "three of these four rows"
is #790's text about a different row set and is untouched.

The figures that remain are the ones that describe a FIXED structure or carry a
reproduction command; the ones that counted a set expected to grow are the ones
that kept breaking, which is the distinction the sweep now encodes.

Correction to the previous commit message: it called the second converted call site
"the delimiter ban over every workflow". That guard is
`test_every_workflow_expression_names_a_REAL_context_or_function`, an
expression-context check; the delimiter ban does not use `_workflow_files()`. Both
call sites are completeness claims over every workflow and both were demonstrated
red against an untracked `.yaml` at the predecessor sha — only the label was wrong.

refs #806

Decisions-Edit: yes
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 16:44:26 +02:00

121 lines
6.4 KiB
Python

"""The authoritative population for a guard whose members are FILES: the git index (ersatztv#806).
`testing.guard-derives-population-from-source` (#774) says a completeness guard derives its
population from a machine-readable authoritative source, and its worked examples are an enum and the
generated OpenAPI document. It does not say what to do when the population is *files*, and every
guard in this repo answered that with a filesystem walk. **A filesystem walk is not an authoritative
source.** It reports build output, editor droppings and whatever else happens to be on disk, and it
differs per machine, so a guard derived from it asserts a different population in CI than on the
laptop of the person it is supposed to stop.
#778 got that wrong three times in one PR, each time with an argument for why the traversal
sufficed, and the third shape is the one that motivates this module:
* a **content filter** on outbound-network tokens that omitted `git fetch`, making a hook that
fetches `origin/main` and derives a push decision structurally invisible;
* a **non-recursive `Path.glob`**, missing four nested files, one of which calls a live ErsatzTV
API and acts on the reply;
* **`Path.rglob`**, which then enumerated `.husky/_/` — 17 husky shims generated by `npm ci` via
`web/package.json`'s `prepare` script, gitignored (`.husky/_/.gitignore` is `*`) and untracked.
That made the guard **RED on every developer checkout and GREEN in CI**, whose `script-tests`
job checks out and pip-installs but never runs `npm ci`. A guard that fails everywhere except
where it runs teaches its readers to ignore it.
The index holds the same set of files every checkout receives from a clone, and excludes untracked
generated files **by construction** rather than by an exclusion list somebody has to maintain and
keep correct. It is not immutable and it is per-worktree; the claim is not that it never changes,
but that it changes only through a deliberate git operation — staging, a checkout, a reset, a
merge — whereas the disk changes whenever a build runs.
Note what that buys over `.gitignore`-awareness: `.husky/_/` happens to carry its own `.gitignore`,
but a stray untracked `foo.sh` in `.claude/hooks/` carries nothing, and only the index knows it is
not part of the repo.
**This is not "replace every glob".** The question per guard is whether it makes a COMPLETENESS
claim over tracked files. If it does, the index is the authoritative source. If it does not — a
fixture copying files into a tmp tree, a walk selecting the SUBJECT of a per-member property — say
so in the guard and leave it, per the boundary #774 already draws between scope and population.
`git ls-files` lists INDEX entries, so a file deleted in the working tree but not yet staged is
still reported. That is deliberate: callers that read member contents assert existence with their
own message rather than filtering, because filtering is what makes a missing member unrepresentable.
"""
from __future__ import annotations
import fnmatch
import subprocess
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
def _git_ls_files() -> list[str]:
"""Every path git tracks, as repo-relative posix strings.
The single place this package shells out to git, so a regression proof can narrow the tracked
set once and have every derivation built on it react.
Fails LOUDLY on an empty result rather than returning it: an empty population makes every
completeness assertion downstream pass vacuously, which is the exact failure these guards exist
to prevent.
"""
proc = subprocess.run(
["git", "-C", str(REPO_ROOT), "ls-files", "-z"],
capture_output=True,
check=False,
)
# `check=True` would raise "returned non-zero exit status 128" and leave git's own diagnostic
# trapped in `e.stderr`. The two real triggers — a tree that is not a repository, and CI's
# `detected dubious ownership` — are both diagnosable ONLY from that text, and every module
# that imports this one would otherwise die with the same inscrutable line.
assert proc.returncode == 0, (
f"`git ls-files` failed in {REPO_ROOT} (exit {proc.returncode}). Every file population here "
f"comes from the index, so this is fatal rather than empty. git said:\n"
f"{proc.stderr.decode(errors='replace').strip() or '(no stderr)'}"
)
paths = [p for p in proc.stdout.decode().split("\0") if p]
assert paths, (
f"`git ls-files` reported nothing under {REPO_ROOT} — the derivation is broken, not the "
"repo. Every file population built on it would be empty and every completeness assertion "
"would pass vacuously."
)
return paths
def tracked_children(directory: str, patterns: tuple[str, ...]) -> set[str]:
"""Tracked files that are DIRECT children of `directory` and match one of `patterns`.
Direct children only, and that is the point rather than a limitation: every population here is
a flat directory (`.claude/hooks/*.sh`, `.husky/*`, `.gitea/workflows/*.yml`,
`scripts/tests/test_*.py`), and recursing is what dragged `.husky/_/` in. A guard that genuinely
needs a nested population should say so and ask for it explicitly.
Returns repo-relative posix paths, matching what the guards' inventories and error messages use.
"""
found: set[str] = set()
for path in _git_ls_files():
parent, _, name = path.rpartition("/")
if parent != directory:
continue
if any(fnmatch.fnmatch(name, pattern) for pattern in patterns):
found.add(path)
return found
def tracked_paths(directory: str, patterns: tuple[str, ...]) -> list[Path]:
"""`tracked_children` as absolute `Path`s, sorted — for guards that read member contents.
Existence is ASSERTED, never filtered: a path in the index with no file on disk means the tree
is mid-edit, and reporting that is strictly better than silently shrinking the population, which
is the defect this module exists to remove.
"""
paths = []
for rel in sorted(tracked_children(directory, patterns)):
absolute = REPO_ROOT / rel
assert absolute.is_file(), (
f"git tracks {rel} but there is no file there. The population comes from the index, so "
"a working tree mid-delete is reported rather than silently shrinking the population."
)
paths.append(absolute)
return paths