Files
ersatztv/scripts/tests/tracked_files.py
T
timothyandtimothy 29d7a06e65
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 27s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m53s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 5m57s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 5m56s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m20s
fix(806): guard populations over FILES derive from the git index, not a filesystem walk (#818)
`testing.guard-derives-population-from-source` (#774) was silent on the commonest
population in our own guards — files in a directory — and every one answered with a
filesystem walk. A walk is not authoritative: it reports build output, generated
shims and editor droppings, and differs per machine. #778 measured the cost by
getting the same population wrong three times in one PR.

CONVERTED (a completeness claim over tracked files): `test_guard_inventory.py`,
`test_hook_fire_log.py`, `test_ci_image_pin_population.py` (which also gained
`*.yaml`), `test_remote_state_inventory.py` (folded onto the shared derivation), and
`test_pr_changed_files.py` (not on the issue's list — found by sweeping the whole
repo).

ASSESSED AND RECORDED, not silently skipped: `_repo_copy` takes its file list from
the index for hermeticity though it makes no completeness claim;
`test_ci_dropped_step_guard.py` has no filesystem population at all; the decisions
corpus is recorded as unexamined rather than cleared; and the SPA page-size guard is
deferred to #819 with its obstacle documented. This is not "replace every glob".

`scripts/tests/tracked_files.py` is the single derivation.
`test_guard_populations_derive_from_git.py` proves it in two measured complements:
exhaustive removal catches a hardcoded `.exists()` admit and memoisation; the call
log catches an append-only source that yields nothing on this machine — #778's
shape — which removal cannot see because it has nothing to remove.

Twelve rounds of independent cold review, alternating model families in isolated
worktrees. The production derivations were confirmed sound every round; every
blocking finding after the first was in the proofs or in prose claims about them.
Counts over growing populations were removed rather than corrected, after three
drifted.

fixes #806

Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-22 16:07:58 +00: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