Files
ersatztv/scripts/tests/test_docs_only_detector_clone_depth.py
T
timothyandClaude Fable 5.1 a7d91bf15a
Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 35s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 57s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 37s
PR Gates / Docs update reminder (pull_request) Successful in 1m0s
PR Gates / decisions lifecycle (pull_request) Successful in 20s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 17s
review-verdict/h10 Review-verdict: MERGEABLE @ a7d91bf (base: main)
Review verdict / Set review-verdict status (pull_request_target) Successful in 45s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m27s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m4s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
fix(876): sweep session narrative out of hooks, workflows, scripts, tests and code comments; grow the detector to the process corpus
`docs.no-session-narrative` reaches every durable artifact, but its detector scanned only
`docs/**/*.md` and root markdown, and nothing had ever swept the rest. The issue named four sites
from one grep and called them a floor. Deriving the population instead — a whitespace-joined sweep
over every tracked file outside the detector, for the detector's own phrasings plus the attribution
and review-round class #812 found — gave 453 sites in 108 files at `fb5592971`, and a second pass
for phrasings the first list missed (hyphenated `round-N`, "an earlier version", "the reviewer
proved") added residuals in the same files. Every site was classified with #812's three
dispositions (CUT / SEVER / KEEP with its sub-kind) under the who-benefits test; the per-site
manifests are on the PR. The rejected designs, tested-and-rejected fixtures, measurements and
traps stay; the attribution of who found them and the round in which they were found go.

The detector's population grows to `.claude/`, `.gitea/`, `.husky/` and `scripts/` regardless
of extension, minus the detector and its own test (whose fixtures ARE the phrasings) and minus
`scripts/tests/fixtures/` (test data, including decision-record copies — the same reasoning as
the records' own exemption, and what keeps the record's depth measurement true), and `--all`
lists tracked REGULAR files only — a symlink's content is its target and a gitlink has none. The #812
argument for leaving `docs/superpowers/**` in the population runs the other way here: `--diff`
sees only ADDED lines, and 287 of the 453 sites were under 30 days old — this corpus is where
narrative is being added, so the advisory nudge has reach. Density agrees: 56 line-mode hits over
the 113 regular files the predicate admits, against 9 over 66 docs files before #812. `web/` and C# stay out on the same
measurement (3 of 74 PATTERNS-matching sites, ~4,600 files). The predicate did not grow: PATTERNS
matched 74 of 453 sites, and widening the word list to the attribution class is the treadmill
the withdrawn parity test ran on. The population oracle is restated over segments with the new
arms, the synthetic cross product gains the process heads and non-markdown extensions, a fixture
witnesses that a tracked symlink is neither scanned nor counted, a `.py.bak` axis separates a
by-name exemption from a `startswith` over the same tuple, and eight mutants (drop the process
arm, drop the by-name exemption, exempt by `startswith`, drop or add a prefix, drop the fixtures
exemption, list only markdown, drop the symlink filter, test the mode per row instead of per
path) each
redden it. A pre-existing silent drop in `--diff` goes with it: git tab-terminates a `+++`
filename that contains a space, and the kept tab made `is_scanned_path` refuse the file with no
notice — fixed, with a positive control and its own mutant.

Code is unchanged by construction, measured per file type against `origin/main`: Python modules
are AST-equal with docstrings stripped, except `#` lines inside the embedded fixture programs
(string literals) of three test modules; workflows differ only in `#` lines inside `run:` block
scalars; shell, C#, TypeScript and jq are equal with comment lines stripped. The stated
exceptions: the detector and its test, 26 vitest titles that carried review-round or severity
labels or a reviewer attribution (call sites whose title changed — every changed title line
walked back to its `it(` / `it.each(...)(` anchor, so a `' + '` concatenation counts once), two
registry note strings and the mutation manifest's prose fields. scripts/tests: 1565 passed.
Web: lint, typecheck, 1319 tests green. Closes #876.

Decisions-Edit: yes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PEcBoFw7ctrf3Nb7R7x7wk
2026-09-03 20:51:39 +02:00

367 lines
19 KiB
Python

"""`scripts/ci-detect-docs-only.sh` must never graft a COMPLETE clone shallow (ersatztv#836).
`git fetch --depth=N` writes `.git/shallow` and cuts history at N even when every object is already
present. Exactly four `docker-build.yml` jobs run the detector — `test`, `migrations` and
`functional-e2e` at `fetch-depth: 2`, and `build` at `fetch-depth: 0` — so `build` is the only
complete-clone consumer, and the detector asks the repository which shape it is in rather than
assuming. (`api-docs` and `format` have a step with `id: detect` too, but it runs their own inline
diff, not this script. Do not add them here.)
WHAT WENT WRONG WITHOUT THIS. `build`'s next step after the detector is `Compute version and tags`,
whose `git describe --tags --abbrev=0` needs a reachable tag. The graft cut it off, `describe`
failed, and a `|| echo v0.0.0` fallback that cannot fail turned that into a version string: every
`:latest` image published from `main` carried `InformationalVersion 0.0.0-<sha>` instead of
`26.x.y-<sha>`, from 2026-07-17 (#416) until #836, with nothing red anywhere. It was found by
reading the string out of
a running container. That is why the assertion here is on the RESULTING VERSION and on the shallow
flag — not on the detector's exit status, which was `0` throughout the defect.
WHY THE FIXTURE USES `file://`. `git clone /path` and `git fetch /path` use the local transport,
which IGNORES `--depth` outright. A fixture built on a plain path would therefore never graft, every
assertion below would hold, and the whole file would pass while testing nothing —
`testing.eight-ways-a-test-passes-for-the-wrong-reason`. `test_the_fixture_can_actually_graft` is the
negative control that keeps that honest: it performs the raw depth fetch the detector is forbidden to
perform and REQUIRES the graft to happen. If git or the transport ever stops grafting, that control
goes red and says so, instead of the other tests quietly becoming vacuous.
"""
from __future__ import annotations
import os
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
DETECTOR = REPO_ROOT / "scripts" / "ci-detect-docs-only.sh"
TAG = "v26.3.1"
def _env(**extra: str) -> dict[str, str]:
"""Ambient `GIT_*` removed, everything else kept.
Exported `GIT_DIR`/`GIT_WORK_TREE`/`GIT_INDEX_FILE` override `cwd` and would aim these git calls
at the real repository. The rest of `os.environ` is carried deliberately rather than built from
scratch: `conftest.py`'s isolation puts `ETV_HOOK_FIRE_LOG_DIR` there, and a from-scratch
environment would drop it and write into the production hook-fire log.
`GITHUB_` is dropped alongside `GIT_` because it does NOT match the `GIT_` prefix
(`"GITHUB_OUTPUT".startswith("GIT_")` is False), and every `GITHUB_*` the detector reads decides
which arm it takes. Pinning only the one variable that bites today would leave the class open for
the next arm that starts reading a sibling — the whole prefix goes, and `_run_detector` puts back
exactly what each test means to set.
THE MACHINE'S GIT CONFIG IS NEUTRALISED TOO, and that is not belt-and-braces — it is what keeps
this file from passing here and failing in CI. A developer checkout has a global `user.email`;
the CI container has none, and git refuses to commit without one ("Author identity unknown").
The fixtures below commit, so without this the file is green locally and red in CI on
exactly that. Pointing the global and system config at `os.devnull` makes the ambient
configuration UNAVAILABLE rather than merely overridden, so a missing `-c` flag in `_git` fails
everywhere instead of only where the machine is unhelpful — the local run reproduces CI by
construction. It also disarms a global `core.hooksPath` (which would fire this repo's husky
hooks against throwaway fixtures) and a global `commit.gpgsign` (which would block on a pinentry
prompt no pytest timeout is watching).
"""
env = {k: v for k, v in os.environ.items() if not k.startswith(("GIT_", "GITHUB_"))}
env["GIT_CONFIG_GLOBAL"] = os.devnull
env["GIT_CONFIG_SYSTEM"] = os.devnull
env.update(extra)
return env
# Passed to every git invocation, so no repository here can inherit an identity or a hook path from
# the machine. `_env()` makes the ambient config unavailable; these supply what the commits need.
_GIT_CONF = [
"-c",
"user.email=test@example.com",
"-c",
"user.name=Test",
"-c",
"commit.gpgsign=false",
"-c",
"core.hooksPath=",
# Forbids git from INVENTING an identity from the OS user and hostname. Without it the two
# settings above are not enough to reproduce CI: a Mac happily auto-detects one, so dropping the
# identity flags stays green locally and fails in the container. With it, a missing flag fails
# everywhere — verified by removing the identity pair and watching this file go red.
"-c",
"user.useConfigOnly=true",
]
def _git(args: list[str], cwd: Path) -> str:
r = subprocess.run(["git", *_GIT_CONF, *args], cwd=str(cwd), env=_env(), capture_output=True, text=True)
assert r.returncode == 0, f"git {' '.join(args)} failed: {r.stderr}"
return r.stdout.strip()
def _git_rc(args: list[str], cwd: Path) -> subprocess.CompletedProcess:
return subprocess.run(["git", *_GIT_CONF, *args], cwd=str(cwd), env=_env(), capture_output=True, text=True)
def _is_shallow(repo: Path) -> str:
return _git(["rev-parse", "--is-shallow-repository"], repo)
@pytest.fixture(scope="module")
def origin_url(tmp_path_factory) -> str:
"""An origin shaped like ours: the newest tag is well back, and the tip is a merge commit.
Both properties matter. The tag has to be further back than the fetch depth or the graft cannot
cut it off, and the tip has to be a merge commit or the detector's push arm reports
`non-merge push` and returns before reaching anything this file is about.
"""
root = tmp_path_factory.mktemp("detector-depth")
origin = root / "origin"
origin.mkdir()
_git(["init", "-q", "-b", "main", "."], origin)
for i in range(1, 13):
(origin / f"f{i}.txt").write_text(f"{i}\n")
_git(["add", "-A"], origin)
_git(["commit", "-q", "-m", f"c{i}"], origin)
_git(["tag", TAG], origin)
for i in range(13, 31):
(origin / f"f{i}.txt").write_text(f"{i}\n")
_git(["add", "-A"], origin)
_git(["commit", "-q", "-m", f"c{i}"], origin)
# A docs-only branch merged with --no-ff, i.e. exactly how an update to `main` lands here.
_git(["checkout", "-q", "-b", "feature"], origin)
(origin / "docs").mkdir()
(origin / "docs" / "a.md").write_text("doc\n")
_git(["add", "-A"], origin)
_git(["commit", "-q", "-m", "docs change"], origin)
_git(["checkout", "-q", "main"], origin)
_git(["merge", "-q", "--no-ff", "feature", "-m", "merge feature (#1)"], origin)
return f"file://{origin}"
@pytest.fixture(scope="module")
def deep_origin_url(tmp_path_factory) -> str:
"""An origin with MORE than 200 commits after its newest tag, for the PR arm's `--depth=200`.
Built separately, and the extra ~3s is the price of a test that is not vacuous. The other
fixture's 31 commits all fit inside a depth of 200, so a `--depth=200` fetch there has nothing
to cut: no `.git/shallow` is written, `describe` keeps resolving, and a PR-arm test built on it
passes against the UNFIXED script — measured, which is how this fixture came to exist. A guard
whose positive case cannot occur is the defect it is meant to catch, one level up.
"""
root = tmp_path_factory.mktemp("detector-depth-deep")
origin = root / "origin"
origin.mkdir()
_git(["init", "-q", "-b", "main", "."], origin)
(origin / "a.txt").write_text("a\n")
_git(["add", "-A"], origin)
_git(["commit", "-q", "-m", "c0"], origin)
_git(["tag", TAG], origin)
# Empty commits: the depth boundary counts commits, and 210 real ones would only be slower.
for i in range(1, 211):
_git(["commit", "-q", "--allow-empty", "-m", f"c{i}"], origin)
return f"file://{origin}"
def _clone(origin_url: str, dest: Path, depth: int | None = None) -> Path:
args = ["clone", "-q"]
if depth is not None:
args += [f"--depth={depth}"]
args += [origin_url, str(dest)]
_git(args, dest.parent)
return dest
def _run_detector(repo: Path, tmp_path: Path, **github_env: str) -> tuple[subprocess.CompletedProcess, str]:
"""Drive the real detector.
`GITHUB_REF_TYPE` is pinned to `branch` rather than left to the ambient environment. `_env()`
deliberately carries the rest of `os.environ` (for `ETV_HOOK_FIRE_LOG_DIR`), and the detector's
FIRST branch returns early on `GITHUB_REF_TYPE=tag` — before any fetch — so an exported `tag`
turns every test here red for a reason that has nothing to do with what they assert.
Not live today, stated precisely rather than dramatised: the `scan` job DOES run pytest on tag
pushes, but over two named files, and this is not one of them; `script-tests`, which does run
this file, is `on: pull_request`. Adding this file to `scan`'s argument list is the one edit that
would make it live. It is a default (`env.update(github_env)` follows), so a future test can
still drive the tag arm deliberately.
"""
out_file = tmp_path / f"gh-output-{repo.name}"
out_file.write_text("")
env = {"GITHUB_REF_TYPE": "branch"}
env.update(github_env)
r = subprocess.run(
["bash", str(DETECTOR)],
cwd=str(repo),
env=_env(GITHUB_OUTPUT=str(out_file), **env),
capture_output=True,
text=True,
)
return r, out_file.read_text()
# --------------------------------------------------------------------------------------------
# The negative control. Everything else here asserts that a graft does NOT happen, so it is all
# vacuous if the fixture cannot graft in the first place.
# --------------------------------------------------------------------------------------------
@pytest.mark.parametrize(
"fixture_name, depth",
[("origin_url", 2), ("deep_origin_url", 200)],
ids=["push-arm-depth-2", "pr-arm-depth-200"],
)
def test_the_fixture_can_actually_graft(request, fixture_name, depth, tmp_path):
"""The raw `--depth=N` fetch the detector must not perform DOES graft this fixture.
Parametrised over BOTH fixtures and BOTH depths, because a control that covers only one of
them leaves the other free to go vacuous unreported — and that is MEASURED: the PR arm against
the 31-commit origin, where a depth of 200 has nothing to cut, passed against the unfixed
script. One control per (fixture, depth) pair the file relies on.
"""
origin = request.getfixturevalue(fixture_name)
repo = _clone(origin, tmp_path / f"control-{depth}")
assert _is_shallow(repo) == "false", "a plain clone should be complete"
assert _git(["describe", "--tags", "--abbrev=0"], repo) == TAG
_git(["fetch", "--no-tags", f"--depth={depth}", "origin", "main"], repo)
assert _is_shallow(repo) == "true", (
f"a --depth={depth} fetch did not graft this fixture, so the test that relies on it is "
"vacuous — most likely the clone/fetch fell back to the local transport, which ignores "
"--depth (use file://), or the fixture has fewer commits than the depth"
)
described = _git_rc(["describe", "--tags", "--abbrev=0"], repo)
assert described.returncode != 0, (
"the graft happened but `git describe` still resolved, so the version this file protects "
"is not actually reachable from the shallow boundary — the fixture's tag is too close to "
f"the tip. stdout={described.stdout!r}"
)
# --------------------------------------------------------------------------------------------
# The guard proper.
# --------------------------------------------------------------------------------------------
def test_the_push_arm_leaves_a_COMPLETE_clone_complete(origin_url, tmp_path):
"""`build`'s shape: `fetch-depth: 0`, push to main. The version must survive the detector.
This is the proof named by `scripts/ci-detect-docs-only.sh`'s `MUTATION` row in
`docs/guard-inventory.md`; the declared mutation in `mutation_manifest.py` disarms the shallow
condition, which puts the `--depth=2` fetch back and reddens the two assertions below.
"""
repo = _clone(origin_url, tmp_path / "build-shape")
assert _is_shallow(repo) == "false"
r, output = _run_detector(repo, tmp_path, GITHUB_EVENT_NAME="push", GITHUB_REF_NAME="main")
assert r.returncode == 0, f"detector failed: {r.stdout}{r.stderr}"
assert _is_shallow(repo) == "false", (
"the detector GRAFTED the complete clone shallow — this is ersatztv#836: the next step in "
"`build` is `Compute version and tags`, whose `git describe` then finds no reachable tag "
f"and the image ships InformationalVersion 0.0.0-<sha>.\ndetector said:\n{r.stdout}"
)
described = _git_rc(["describe", "--tags", "--abbrev=0"], repo)
assert described.returncode == 0 and described.stdout.strip() == TAG, (
"after the detector, `git describe --tags --abbrev=0` no longer resolves, so `build` would "
f"stamp this image 0.0.0. git said: {described.stdout.strip()!r} {described.stderr.strip()!r}"
)
# The detector still has to do its actual job on this shape.
assert "docs_only=true" in output, (
f"the merge tip is docs-only but the detector did not say so:\n{r.stdout}\n{output}"
)
def test_the_pull_request_arm_leaves_a_COMPLETE_clone_complete(deep_origin_url, tmp_path):
"""A complete checkout on the PR arm, N=200. NO PRODUCTION CONSUMER TODAY — deliberately.
MEASURED: on `pull_request` the only
jobs running this script are the three `fetch-depth: 2` ones, so the PR arm's `--depth=200` is
inert rather than latently firing. What this pins is the SHARED CLAUSE — the next `fetch-depth: 0`
consumer added to this arm must not silently inherit the graft that cost #836. It is a guard
against a future shape, not a reproduction of a shipped one, and grading it as the latter would
overstate what the file proves.
The deep fixture is what makes the case real at all: with the 32-commit origin a depth of 200 has
nothing to cut, and this test passed against the unfixed script.
"""
repo = _clone(deep_origin_url, tmp_path / "pr-shape")
assert _is_shallow(repo) == "false"
# A PR head: one docs commit on top of the base branch.
_git(["checkout", "-q", "-b", "pr-branch"], repo)
(repo / "docs").mkdir(exist_ok=True)
(repo / "docs" / "b.md").write_text("doc\n")
_git(["add", "-A"], repo)
_git(["commit", "-q", "-m", "docs on the PR head"], repo)
r, output = _run_detector(repo, tmp_path, GITHUB_EVENT_NAME="pull_request", GITHUB_BASE_REF="main")
assert r.returncode == 0, f"detector failed: {r.stdout}{r.stderr}"
assert _is_shallow(repo) == "false", (
"the PR arm GRAFTED a complete clone shallow (ersatztv#836). No job runs this arm against a "
f"complete checkout today, so this is the shared clause regressing.\n{r.stdout}"
)
described = _git_rc(["describe", "--tags", "--abbrev=0"], repo)
assert described.returncode == 0 and described.stdout.strip() == TAG, (
"after the PR arm, `git describe --tags --abbrev=0` no longer resolves: "
f"{described.stdout.strip()!r} {described.stderr.strip()!r}"
)
# FETCH_HEAD is what the PR arm diffs against, so the depth-less fetch must still write it.
assert _git_rc(["rev-parse", "--verify", "-q", "FETCH_HEAD"], repo).returncode == 0, (
"the PR arm no longer writes FETCH_HEAD, so its base revision is unresolvable"
)
assert "docs_only=true" in output, (
f"the PR head is docs-only but the detector did not say so:\n{r.stdout}\n{output}"
)
def test_a_SHALLOW_checkout_still_gets_its_depth_and_still_decides(origin_url, tmp_path):
"""The other half: `test`/`migrations`/`functional-e2e` at `fetch-depth: 2` must be unchanged.
Removing the graft must not turn into removing the depth. A shallow checkout stays shallow, and
the detector still resolves `HEAD^1` and classifies the docs-only merge — the behaviour the
`--depth=2` was added for in the first place.
"""
repo = _clone(origin_url, tmp_path / "shallow-shape", depth=2)
assert _is_shallow(repo) == "true", "the fixture clone was meant to be shallow"
r, output = _run_detector(repo, tmp_path, GITHUB_EVENT_NAME="push", GITHUB_REF_NAME="main")
assert r.returncode == 0, f"detector failed: {r.stdout}{r.stderr}"
assert _is_shallow(repo) == "true", "a shallow checkout was silently unshallowed"
assert "docs_only=true" in output, (
"the shallow checkout no longer classifies the docs-only merge — the depth stopped being "
f"applied where it IS needed:\n{r.stdout}\n{output}"
)
assert "fetching with --depth=2" in r.stdout, f"the shallow checkout did not take the depth branch:\n{r.stdout}"
assert "fetching without --depth" not in r.stdout, (
f"the shallow checkout took the complete-clone branch:\n{r.stdout}"
)
def test_an_UNKNOWN_shallow_answer_passes_no_depth(tmp_path):
"""Fail-direction, stated and executed: `--is-shallow-repository` unanswerable => no depth.
Defaulting the other way would re-graft on exactly the paths nobody can observe, so the branch
is pinned rather than argued. Running outside a repository is one way to make the query
unanswerable and the one this drives; it is NOT the only one — on a git older than 2.15 the flag
is unrecognised and rev-parse echoes it back verbatim with status 0. That shape is not covered
here (it needs an ancient git), and it reaches the same no-depth branch.
WHAT THIS DELIBERATELY DOES NOT ASSERT. Outside a repository the detector exits 128 with an
empty `$GITHUB_OUTPUT`, because `set -o pipefail` makes the `git rev-list ... | wc -w` assignment
below the fetch abort under `set -e`. That is PRE-EXISTING and unchanged here — measured against
`8aeacd534`, the commit this fix branched from, which exits 128 the same way. It is also not a
fail-open: the step goes red, so the job does, and no `docs_only=true` is ever emitted. Asserting
a clean exit here would be asserting a behaviour the script has never had.
"""
notarepo = tmp_path / "notarepo"
notarepo.mkdir()
r, _output = _run_detector(notarepo, tmp_path, GITHUB_EVENT_NAME="push", GITHUB_REF_NAME="main")
assert "is-shallow=unknown" in r.stdout, f"the unknown answer was not reached or not reported:\n{r.stdout}"
assert "fetching without --depth" in r.stdout, f"an unknown shallow answer must not ask for a depth:\n{r.stdout}"