PR Gates / CI image pin matches docker/ci (pull_request) Successful in 10s
PR Gates / Docs update reminder (pull_request) Successful in 13s
PR Gates / decisions lifecycle (pull_request) Successful in 18s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 11s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 25s
review-verdict/h10 Awaiting review verdict for b0f42f1
Review verdict / Set review-verdict status (pull_request_target) Successful in 10s
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 5m38s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 8m35s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m11s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 5m50s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 7s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 5s
`testing.guard-derives-population-from-source` (#774) says a completeness guard derives its population from an authoritative source, and its worked examples are an enum and the generated OpenAPI document. It was silent on the commonest population in our own guards — files in a directory — and every one of them answered with a filesystem walk. A walk is not an authoritative source: it reports build output, generated shims and editor droppings, and it differs per machine. #778 measured the cost by getting the same population wrong three times in one PR while implementing the milestone that exists to prevent it. The worst shape was `Path.rglob` enumerating `.husky/_/` — 17 shims `npm ci` writes, gitignored and untracked — which made that guard RED on every developer checkout and GREEN in CI, whose `script-tests` job never runs `npm ci`. Only that one guard was fixed then. This audits the rest. CONVERTED (a completeness claim over tracked files): * `test_guard_inventory.py` — its `.husky/` walk excluded `.husky/_/` only because `_` is a directory, so the obvious "make it recursive" edit would have reintroduced #778's defect in the repo's own model guard. Both halves are gated on the index: the callers, and the `scripts/…` paths they name — the second was left on `Path.exists()` in the first cut and found by cold review. * `test_hook_fire_log.py` — an untracked scratch `.sh` in `.claude/hooks/` was demanded to carry instrumentation. * `test_ci_image_pin_population.py` — and `*.yaml` added: Gitea accepts both spellings, so a `.yaml` workflow adopting the toolchain image was structurally invisible while the test read as covering every workflow. * `test_remote_state_inventory.py` — folded onto the shared derivation, so the rule has one implementation rather than two. ASSESSED AND RECORDED, not silently skipped: * `test_ci_release_path_scan_job.py::_repo_copy` — not a completeness claim, but it takes its file LIST from the index anyway for hermeticity, since `copytree` copied untracked files and `__pycache__` into a tree whose behaviour the probes measure. Content still comes from the working tree, and the copy is NOT a git repo, so neither file that step runs may use the helper — written down because `MARKED_JOBS` is left open as a residual gap, which invites editing exactly that file. * `test_ci_dropped_step_guard.py` — no filesystem population at all; its members come from the parsed workflow. * The decisions corpus keeps its walks and is recorded as unexamined rather than cleared. 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 directions, which are complements rather than a second opinion — measured both ways: * REMOVAL, exhaustively: every member of every registered derivation is dropped from the index in turn and must disappear while still on disk. One victim was not enough — `derived_guard_files` unions four contributors, so a mutant putting only one back on a walk passed. This catches a hardcoded `.exists()` admit and memoisation, which the other direction cannot. * The CALL LOG: a derivation may READ a file but must not LIST a directory. This catches an append-only source that yields nothing on this machine — #778's shape — which removal cannot see, because it has nothing to remove. Its limits are stated in one place and are true in both directions. Both the population and the proofs are registered against a hand-written scope mirror that carries its own equality check, and the import matcher's 16 forms are pinned as a table so the next edit cannot silently re-open one. The mutation this guard declares in `mutation_manifest.py` (#790) is the real defect cold review found in its own first round. Docs: `testing.guard-derives-population-from-source` gains the file-population case and why the disk is not authoritative; `docs/guard-inventory.md` carries the per-guard audit table, including the two no-change verdicts and the decisions corpus recorded as unexamined. Seven rounds of independent cold review (Codex GPT-5.6 and Opus, alternating) — the per-round findings and their measurements are in the PR. fixes #806 Decisions-Edit: yes Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
121 lines
6.4 KiB
Python
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 five guard
|
|
# modules 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
|