Skip to content

Close three fail-open gaps in the fixture-layout controls - #720

Merged
willhea merged 6 commits into
developfrom
fixture-layout-audit-and-gap-repairs
Sep 11, 2026
Merged

willhea merged 6 commits into
developfrom
fixture-layout-audit-and-gap-repairs

Conversation

@willhea

@willhea willhea commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Repairs the three places tests/test_fixture_layout.py fails open — where a real defect is planted and the suite stays green. One file changed, test-only, no production code.

Found by mutation-testing the module's 28 controls. The evidence is below rather than in a committed report: 37 mutations were applied to the checkout, run, and reverted, and every number here is a measurement, not a reading.


The three gaps

Each verified red-then-green against the mutation that previously passed silently.

Gap Mutation Before After
G1 the committed-fixture oracle could go vacuous committed_fixture_refs() returns set() + a real stale path planted in a scanned module whole suite green 1 failed
G2 the exemption sets had no ceiling exempt tests/test_diff_bill.py, then address a committed fixture through bills/ in it 30 passed 1 failed
G2 (same hole, other side) exempt a new non-fetch tools/verify_manifest.py, same reference 30 passed 1 failed
G3 the gitignore control could not fail append *.pdf to .gitignore 30 passed 1 failed, names all 53 PDFs
G3 (the original probe's format) append tests/corpus/**/*.xml 28 passed 1 failed, names all 58 XML

G1 — the oracle nothing could see fail

committed_fixture_refs() decides what counts as an offence, and the gate consulting it cannot tell an empty answer from "no offences". Planting a genuine stale fixture path alongside a vacuous oracle left the entire non-browser suite green.

The new control asserts non-emptiness and cross-checks git ls-files tests/corpus — the index, against the working tree the oracle walks. That is a second oracle rather than the same one twice, which is precisely why the test that previously claimed this guarantee could not deliver it (below).

G2 — bounded by enumeration, not by prefix

A tools//tests/ prefix bound was written first and was itself fail-open: both directories hold ordinary product and test code. Membership of _DOWNLOAD_TIER_FILES switches off every bill rule for a file, so the prefix form let any test or tool address a committed fixture through bills/ forever.

Membership is now enumerated. There is no rule that separates a fetcher from its neighbours — "is acquisition code" is a judgement, not a path shape — so a list is the only honest bound. A genuinely new fetcher costs a line in the test as well as in the set; that friction is the point, because exempting a file is the cheapest way to defeat the whole module.

G3 — three defects, not one

  1. Missing --no-index. Without it git check-ignore reports what git would do with a path, and it does nothing to a tracked file whatever .gitignore says. The control returned "not ignored" on a fixture tree that was fully ignored.
  2. The probe crossed bills/, which this repo's own .gitignore says is commonly a symlink. Git refuses a pathspec "beyond a symbolic link" with 128, and the old code inferred work-tree-ness from that exit code — so it skipped, claiming "not a git work tree" while standing in one (measured with bills/ symlinked: 27 passed, 1 skipped). Any code that is neither 0 nor 1 is now a failure, not a verdict.
  3. It probed one XML file out of 111 fixtures (58 XML, 53 PDF) — under 1% of the tree, and one of two formats. It now derives the set from git ls-files tests/corpus and asks git about all of it in a single check-ignore --no-index --stdin call, so a third format is covered the day one is committed, with no edit here.

One control deleted

test_download_only_versions_are_genuinely_uncommitted is removed, taking the module from 30 controls to 29. No meta-test was added to replace it.

Its detection set is test_no_source_reaches_into_bills_for_a_committed_fixture's minus the tmp_path skip — and that difference is a false positive: a synthetic tree naming a committed fixture is legal, per the module's own docstring. The guarantee it claimed (that the committed set has not quietly shrunk) is exactly what it could not deliver, because it consulted the same committed_fixture_refs() that had gone vacuous. G1's control holds it now, against git.


What the audit found and this PR deliberately does not do

A prior pass proposed deleting two controls as redundant. Attacking those findings before acting on them withdrew both — each is the sole detector of a failure nothing else in the suite sees.

test_conftest_refuses_a_foreign_engine was said to be dominated by its sibling, which asserts the same two things on a harder input. They actually differ in how their stand-in engine dies. Demoting the foreignness check into the except ModuleNotFoundError handler reddens it alone across the whole suite, because the sibling's partial engine raises ModuleNotFoundError (still caught) while this one's fuller engine dies on a plain ImportError (not caught). Against a complete engine copied out of another checkout:

baseline with the check demoted
child --collect-only rc=4, RuntimeError: … DIFFERENT tree's source rc=0, 191 tests collected

That is a silent green about another checkout's source.

test_the_known_bad_source_is_reported_end_to_end was said to add nothing over the discovery and rule tests. Giving find_stale_fixture_paths a directory roster reddens it alone: discovery still returns everything, and the rule still fires on every input the can-fire tests feed it. Every other input to that rule is keyed under a real top-level directory, so any roster written from today's tree spares them by construction.

Net: the cluster has no measured removal candidates. The count moves 28 → 29 only because of the G1/G2 additions and the G3-sibling deletion.

A limitation worth stating

Mutation testing measures detection, never worth. A sole detector of an irrelevant behaviour is still deletable, and nothing here establishes relevance. Roughly half these controls exist only to make the static text scan trustworthy; their value is inherited from that scan, so they cannot be judged one at a time.

Relatedly, the audit also asked whether the scan could shrink to the files the suite never executes. That question is unresolved and out of scope here. The coverage measurement behind it does not carry it: file-level execution does not establish that a file's fixture-path lines ran, and a skip ceiling sees only skips that actually occur. Answering it would need line- or branch-level coverage of the path-bearing code plus mutations proving a replacement boundary catches stale paths after the scan is removed. The scan's shape is untouched by this PR.


Verification

tests/test_fixture_layout.py 29 passed
neighbouring fixture/corpus modules 434 passed, 20 skipped
full non-browser suite 3824 passed, rc=0
ruff check / ruff format --check clean, module and repository
git diff --stat vs develop 1 file changed

Every mutation applied, run, and reverted; the worktree carries no mutation or generated artifact. The mutation harness is deliberately uncommitted — its bodies contain bills/<id>/<file> literals that the very gates under audit would flag.

The audit ran on a machine with the bill corpus fetched (32 bills, 17 shared with tests/corpus/), which an earlier CI-shaped pass did not have. Two findings changed as a result.

Follow-up in #721, stacked on this branch: the runtime skip ceiling widened suite-wide, five live CWD-dependent defects, and a cwd-independence CI job. Also code-only.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NwVE7osYo39KaUe7CEitFJ

willhea and others added 5 commits September 11, 2026 11:08
committed_fixture_refs() is the oracle both bill gates consult, and
neither can see it fail. An empty answer leaves
test_no_source_reaches_into_bills_for_a_committed_fixture with no
offenders and test_download_only_versions_are_genuinely_uncommitted with
nothing still-committed, so both go green over a tree nothing polices.

Measured before this commit: returning set() and planting a real stale
fixture path in a scanned product module left the entire non-browser
suite at 3818 passed. With this control the same mutation reddens.

The sibling test claims to close this and structurally cannot, because it
consults the same function -- the same oracle, so it fails the same way.
The cross-check here is against git ls-files, which reads the index while
committed_fixture_refs() walks the working tree, so the two can disagree.

Only the index-knows-it/oracle-lost-it direction is asserted. The reverse
is test_every_fixture_file_is_tracked_by_git's subject and is not
restated, to keep one mutation from reddening two controls.

Verified: 29 passed in the module, 3819 in the full non-browser suite,
and 1 failed under the C1 mutation that previously passed silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NwVE7osYo39KaUe7CEitFJ
test_every_exemption_names_a_file_the_scan_reaches checks that a key
still RESOLVES, never that it is DESERVED. Those failures point opposite
ways: a dead key has stopped applying, an over-broad one applies
perfectly to a file that should never have had it.

Measured before this commit: adding src/deltatrack/similarity.py to
_DOWNLOAD_TIER_FILES and planting a real committed-fixture path in that
module left this module at 29 passed and the suite green. Exempting
product code was a one-line, silent, permanent hole, and the cheapest way
to defeat every bill rule in the file.

The two bounds differ in shape because the sets carry different risk.
_DOWNLOAD_TIER_FILES switches off every bill rule, so it is bounded by
tier (tools/, tests/): a new fetcher test gets in without ceremony while
product code is unrepresentable. _DOWNLOAD_ROOT_NAMERS is enumerated,
because one legitimate member IS product code (diff_bill.py's --bills-dir
default, ADR 0013 / #152) and no prefix admits it without admitting the
whole engine.

Both bounds are literals in the assertion rather than constants beside
the sets, per the reasoning in test_exclusions_stay_within_the_research_tree:
a second configuration value lets the policy certify itself by widening
both together.

Verified: 30 passed in the module, and both C2 and C3 redden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NwVE7osYo39KaUe7CEitFJ
test_fixture_tree_is_not_gitignored gave false assurance two ways.

1. Missing --no-index. Without it, git check-ignore reports what git
   would DO with the path, and it does nothing to a TRACKED file whatever
   .gitignore says. Measured: appending tests/corpus/**/*.xml to
   .gitignore left the control at rc=1 ("not ignored") and the module at
   28 passed. With the flag the same mutation reports rc=0 and the
   control reddens.

2. The probe crossed bills/. That tree is commonly a SYMLINK -- the
   .gitignore comment on /bills exists to support exactly that setup --
   and git refuses a pathspec "beyond a symbolic link" with 128. The old
   code inferred work-tree-ness from the check-ignore exit code, so 128
   became a skip claiming "not a git work tree" while standing in one.
   Measured with bills/ symlinked: 27 passed, 1 skipped. The control
   silently did not run on the setup it most needed to cover.

Work-tree-ness is now asked directly with rev-parse --is-inside-work-tree
and is the only thing that may skip, so any other git failure is a
failure rather than a silent pass. The ignored-probe moved to
tests/data/extract_cache/, which is inside tests/ and crosses no symlink.

Verified: 30 passed; 1 failed under A5; 30 passed with bills/ symlinked
(no skip); 1 failed under A5 with bills/ symlinked; 3820 in the full
non-browser suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NwVE7osYo39KaUe7CEitFJ
The G3 repair moved the ignored-probe off the download tree, which left
DOWNLOADS_DIR imported and unused (F401). Every remaining mention in the
module is a string, a regex, a comment or a monkeypatch target, so the
import goes. Also wraps the probe's argument list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NwVE7osYo39KaUe7CEitFJ
Review found both new ceilings were themselves fail-open. Each mutation
below was applied, run, and reverted.

G2 -- the bound admitted all of tests/ and tools/, which hold ordinary
product and test code. Exempting tests/test_diff_bill.py, or a new
non-fetch tools/verify_manifest.py, and then addressing a committed
fixture through bills/ in it, left this module at 30 passed both times.
_DOWNLOAD_TIER_FILES switches off EVERY bill rule for a file, so the
prefix form let any test or tool bypass the lot. Membership is now
enumerated: there is no rule separating a fetcher from its neighbours --
"is acquisition code" is a judgement, not a path shape -- so a list is the
only honest bound. Both mutations now fail.

G3 -- the control probed ONE xml file and the corpus holds 111 fixtures,
58 xml and 53 pdf. Appending *.pdf left it at 30 passed while git
reported every committed PDF as ignored. It now derives the set from
`git ls-files tests/corpus` and asks git about all of it in one
check-ignore --no-index --stdin call, so a third format is covered the day
it is committed. *.pdf now names all 53; tests/corpus/**/*.xml names all
58. An exit code that is neither 0 nor 1 is a failure rather than a
verdict, instead of being inferred as "not ignored". The positive control
keeps its purpose and still probes tests/data/extract_cache/, which
crosses no symlink.

test_download_only_versions_are_genuinely_uncommitted is DELETED. Its
detection set is test_no_source_reaches_into_bills_for_a_committed_fixture's
minus the tmp_path skip, and that difference is exactly the false positive
the audit documented -- a synthetic tree naming a committed fixture is
legal per the module's own docstring. The guarantee it claimed is held by
test_the_committed_fixture_set_is_not_vacuous against a second oracle. No
meta-test was added to replace it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NwVE7osYo39KaUe7CEitFJ
@willhea
willhea force-pushed the fixture-layout-audit-and-gap-repairs branch from 43e0b7b to 3b3b366 Compare September 11, 2026 15:12
@willhea willhea changed the title Audit the fixture-layout cluster by mutation, and close its three fail-open gaps Close three fail-open gaps in the fixture-layout controls Sep 11, 2026
The three added controls documented their own discovery: mutation counts,
a prefix bound that was tried and rejected, corpus snapshots, the deleted
sibling's reasoning. That is review history, and it belongs in the pull
request rather than in the source.

Kept: why the exemption allowlists are literals independent of the sets
they bound; that _DOWNLOAD_ROOT_NAMERS waives one rule rather than all of
them; that git's index is a second oracle for the filesystem-derived
fixture set; that the reverse comparison is owned by
test_every_fixture_file_is_tracked_by_git; why --no-index is required for
tracked paths; and git check-ignore's exit codes with the purpose of the
positive control.

Removed: mutation histories and pass counts, the rejected prefix design,
"58 XML, 53 PDF", the deleted test's justification, and the group labels
above lists whose filenames already say what they are.

Behaviour unchanged -- assertion logic is identical, only messages and
prose are shorter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NwVE7osYo39KaUe7CEitFJ
@willhea
willhea added this pull request to the merge queue Sep 11, 2026
Merged via the queue into develop with commit a03bfcc Sep 11, 2026
27 checks passed
@willhea
willhea deleted the fixture-layout-audit-and-gap-repairs branch September 11, 2026 18:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant