Skip to content

Latest commit

 

History

History
168 lines (134 loc) · 39.1 KB

File metadata and controls

168 lines (134 loc) · 39.1 KB

AGENTS.md

Guidelines for AI coding agents working on this repository.

Quick reference

source ./init                    # Install deps + activate the venv (source it; ./init alone runs in a subshell and the activation is lost)
uv sync                          # (what ./init runs to install dependencies)
uv run pytest -m "not slow and not browser"  # Fast tests only (the first run builds the PDF extraction cache, so it is much slower than later ones)
uv run pytest                    # Every tier; bill fixtures are committed, but the browser tier skips unless `playwright install chromium` has been run
uv run pytest tests/test_diff_bill.py::TestMatchNodesIntegration  # Single test
uv run python diff_pdf.py <v1.pdf> <v2.pdf> -o <out.html>              # Render the PDF-derived diff
uv run python diff_bill.py compare <v1.xml> <v2.xml> --format html -o <out.html>  # ...and the XML-derived one (see TESTING.md)

After source ./init, the top-level CLIs run directly (./tools/fetch_bills.py, ./diff_bill.py, ./diff_pdf.py, ./tools/fetch_bill_archives.py, ./tools/fetch_bill_text_archives.py); the uv run python <script>.py form still works.

Both forms are correct, and the docs pick between them deliberately rather than by accident: README is read by users, so it spells a command the way a reader types it (./tools/fetch_bills.py), while CONTRIBUTING and TESTING are read by contributors, so they use uv run python …, which resolves the pinned interpreter without assuming an activated venv. Match the file you are editing; do not unify them.

Adding a CLI command

A command is an executable .py file in the project root or in tools/. That is the whole definition, and it is what the docs gate keys on: a new command is discovered automatically and is then required to carry a README row, spelled with the path a user types (./diff_bill.py, but ./tools/fetch_bills.py). Files that are not commands (tools/fetch_govinfo.py, src/deltatrack/bill_tree.py) simply carry no executable bit, so do not "tidy up" a file mode you did not set, in either direction.

The two roots share one flat module namespace, because tools/ is on pytest's pythonpath rather than being a package. So a tools/x.py may not be added beside a root x.py: one would shadow the other and drop out of the gate. A test asserts this.

The steps for adding one, including the single step the gate cannot check for you, are in CONTRIBUTING. They apply to any contributor, not just an agent, which is why they live there.

Workflow

This repo follows the workflow in CONTRIBUTING.md. The load-bearing parts for an agent:

  • Pick work from the Ready column of the project board — Ready means groomed and safe to start. Pick a discrete issue, not an epic.
  • Own it and keep status current. Before starting, assign yourself and move the card to In progress. Don't work an issue you aren't assigned to without claiming it first. Opening the PR moves it to In review automatically; keep the Status honest as you go so the board reflects reality.
  • Branch from develop (never main), commit in small focused steps, and open the PR against develop. A maintainer merges; do not merge yourself — and the merge goes through a merge queue, so approval enqueues the PR and the actual merge lands minutes later once the queue's own checks pass. Don't read "not merged yet" as "not approved," and confirm a merge actually landed (git log origin/develop) rather than inferring it from the approval. See CONTRIBUTING's "How a merge lands: the merge queue." Branch off develop itself rather than off another feature branch — see CONTRIBUTING's "Branch workflow" for the stacked-PR caveats (noisy review diffs, and a retarget-at-merge mechanic that fails silently if the parent branch survives its merge).
  • The developmain promotion is the maintainer's to initiate. Releasing is a judgment call about timing, not a step an agent infers. A runbook line in a plans/ file saying to open the release PR is not standing authorization to open it: raise the question and let the maintainer decide.
  • Link the issue in the PR body with Closes #<n> so the issue and its board card resolve on merge.
  • A workflow owning a required status check must carry the merge_group trigger (#416). The merge queue waits on every required context before it will merge, so a required check whose workflow never fires on merge_group is never reported and the queue stalls for the whole repository, not just the pull request that added it. Today that is both ci.yml (test) and security.yml (pip-audit (production deps)) — covering only one stalls it just as completely. test_required_checks_report_to_a_merge_queue (tests/test_ci_workflow.py) pins the trigger on the two workflows above, so a reformat or a merge resolution cannot quietly drop it — but the gate parametrizes over a hardcoded list, so making a third check required means adding it to REQUIRED_CHECK_WORKFLOWS as well as adding the trigger, or the new check is unwatched and the gate stays green while the queue stalls. The trigger is inert until the check is actually required, so it is safe to land first. Recovering from a stall needs an admin (untick "Require merge queue", merge, re-tick), because the fixing pull request would otherwise have to clear the queue it is fixing.
  • Before pushing, run the CI gates locally (lint, ruff format --check, fast, browser, external-validation) -- see CONTRIBUTING's "What CI checks." ruff check is not covered by the pre-commit format hook, so run it explicitly.
  • For changes touching the parser, diff engine, or matcher, run every slow gate CI runs: uv run pytest -m slow --deselect tests/test_govinfo_corpus_parity.py (the one deselection is a live-network gate). Selecting by marker rather than by module list is deliberate: the enumerated form drifted every time a module joined a CI step. The corpus gates within it parametrize over the committed manifest (tests/corpus_manifest.toml) and run in CI, so no fetch is needed and counts are reproducible; each fails closed if a manifested bill is uncommitted (ADR 0015 / #217). To sweep every locally-fetched bill (broader, non-CI exploration that has caught bugs a few clean bills didn't), add CORPUS_SWEEP=1.
  • Before opening a PR, review and show evidence unprompted: run a code review on the diff, then present visual before/after examples of the change (e.g. the rendered diff HTML — see TESTING.md's "Comparing the two pipelines by eye") for the maintainer's verification — don't wait to be asked.

Sprints (biweekly, theme-driven)

The team meets in person every two weeks (Wednesdays); that meeting is the only ceremony — it grooms critical issues to Ready, assigns them, and sets a theme for the cycle ("this sprint: get the demo out"). This is Scrumban, not strict Scrum: no frozen commitment, no point capacity. Critical work is committed by judgment; other Ready work is fair to pull as bonus.

  • The Sprint iteration field is the two-week container (14-day, Wednesday-aligned blocks). The current iteration's title carries the theme (rename Sprint N → e.g. Demo out), so it rides on every card and is API-readable.
  • "Committed this sprint" = Sprint set to the current iteration + Status Ready. There is no separate commit flag.
  • Work the Current sprint view (iteration:@current) first; Ready items with no iteration are the bonus pool.

Filing and grooming issues

  • File with a template (bug / feature / task). Keep reporting lean — for a bug, a way to reproduce is the highest-value thing. Don't pre-scope or pre-size; that's the grooming step.
  • Kind is the issue type (Bug / Feature / Task), not a label — the template sets it. Type is single-select (a thing is one kind); labels are for cross-cutting attributes that stack (security, blocked, epic, testing, good first issue). Don't reintroduce bug/enhancement labels.
  • Grooming makes an issue pickup-ready (the Backlog → Ready move): add acceptance criteria, scope, where-to-start, and set Priority. See CONTRIBUTING's "Grooming an issue for pickup."
  • Priority and Effort are org-level issue fields, not labels: Priority = Urgent / High / Medium / Low (single source of truth — don't reintroduce priority labels); Effort = High / Medium / Low, optional and not a current focus.
  • Reading/writing board fields by script. Status and Sprint are project fields — read via gh project item-list or the project GraphQL query, write with updateProjectV2ItemFieldValue. Priority and Effort live on the issue, not the project item — read them from gh api /repos/AgoraDMV/DeltaTrack/issues/<n>/issue-field-values (or GraphQL issue.issueFieldValues) and write with updateIssueFieldValue(input:{issueId, issueField:{fieldId, singleSelectOptionId}}) — a singular nested issueField, using the option's node id (not the older setIssueFieldValue/plural form, which no longer matches the preview schema — introspect UpdateIssueFieldValueInput if it errors). List the field/option node ids via organization(login).issueFields. They do not surface in the project item query even though they group on the board. View filters, grouping, and charts are UI-only — don't try to script them.
  • Three different reads here fail open, so the trigger is the conclusion, not the API. Before writing that Priority or Effort is unset, empty, unconfigured, or unused, re-read it from the authoritative surface — that sentence is the symptom these failures produce, and each renders as a plausible fact about the project rather than as an error. (1) Field-definition read via the project. A linked org issue field appears in projectV2.fields as a single-select with options: [], while a real project field like Status shows its full option list. Zero options reads as "the field exists but nobody configured it", which is a structural claim and so even more convincing than an unset value; the authoritative definition is organization(login:"AgoraDMV"){ issueFields }, where Priority really does carry Urgent/High/Medium/Low. (2) REST value read with the wrong key. The response is a flat list whose field name is the top-level key issue_field_name, not a nested issue_field.name; the wrong key matches nothing on every issue. (3) Project item-value read, which never carries these values even after linking (linking only enables board grouping). Print one raw payload before reading anything into a sweep, and treat an all-unset or no-options result as a broken query until proven otherwise. As of 2026-08-05, 71 of 97 open issues carry a Priority, so a sweep reporting none is measuring itself. Effort is genuinely unset across the board and is not a current focus — that one is a real reading.
  • Editing a project field's options is destructive — prefer the web UI. updateProjectV2Field with singleSelectOptions replaces the entire option set. Any option you resubmit without its original id is deleted and recreated with a new one, which clears that field's stored value on every item in the project, unrecoverably: the values are cleared rather than orphaned, so recreating the options with their old ids does not relink them, and Projects v2 keeps no per-field history to roll back. To remove one option, resubmit the full list with every surviving option carrying its id. Snapshot first (gh project item-list --format json), and note that item-list is eventually consistent, so verify counts after a delay rather than immediately. This is a different failure from the read trap above: that one shows you nothing, this one destroys the board's state.
  • Watch for security-sensitive work. Anything touching the public/deployed surface (e.g. web/, /api/compare) gets the security label and a hard look — that's the one outward-facing, abusable part of the project.
  • Epics carry the epic label and stay untyped (no issue type) until the org-level Epic type exists (#127); they are decomposed into native sub-issues; the parent's progress bar is its status. Pick up the sub-issues, not the epic. An epic stays open until all its sub-issues close, then a maintainer closes it by hand (the parent does not auto-close). Epics live on the Roadmap view and are filtered off the working board.

Comments and rationale

A comment describes the code as it is now. History and rejected alternatives are not deleted — they are labelled, so a reader can tell at a glance which sentences describe live behaviour and which do not. Unlabelled prose about a state that no longer exists is the form that most reliably causes rework: an agent does not detect that a comment contradicts the code, it acts on it.

  • Lead in the present tense. The first sentence describes only what the file does today; a reader who stops there must not come away with a wrong picture. No previously / used to / before this above the fold.
  • Label anything that is not current. History: for a state that existed and no longer does, Why not X: for an alternative considered and rejected. Both are then skippable by construction — unlabelled narrative is not, which is what forces a reader to reconstruct the timeline before they can trust the paragraph.
  • The tail points, it does not narrate. Issue numbers carry the story (History: #365 divergence, #422 fix). The measurement, the rejected design and the argument belong in the issue, which cannot silently contradict the code the way a comment can.
  • A number describing current state needs a gate or a repro. Counts, percentages and corpus measurements decay silently and cannot be checked at read time. Prefer an assertion that fails when the number changes; otherwise name the command that reproduces it. This is the same reasoning as "Gate the decision you had to argue for" under Test conventions, applied to evidence rather than to design. A number inside a History: tail is exempt — a measurement of a past experiment stays true, which is precisely why it belongs there rather than in the lead.
  • Past about six lines, ask whether it is a decision. A defended design choice belongs in an ADR, reached by a self-contained pointer: # Sections process independently (ADR 0006) still carries its claim when the ADR is never opened, # see ADR 0006 does not. The index below names every decision, but only its title; the argument is in the file, and a bare cross-reference still asks a reader to go and get it.

Research artifacts are working material

Probes, audits, spikes and study write-ups are working material, not automatically permanent repository material. At closure, retain an artifact only if it is needed to reproduce a consequential result, enforce an invariant, document a durable decision, or serve as a frozen input. Delete the rest — Git history preserves the investigative record. Retention is the exception that needs a reason, and "it was expensive to produce" is not one.

Apply that strictly: "useful history", "might be interesting later", "shows how we got here" and "was once reviewed" are not retention reasons, because every one of them is satisfied by git log. Judge an artifact by the consequential role it plays now, never by its filename, its age, or how much history it carries. The inverse error is just as real, so decide by tracing consumers — under docs/research/ a generated JSON that looks like a stale run log may be a committed expected-output oracle a gate reads, and a probe that looks superseded may be the only executable negative control of a rule still in force. Before removing one, check imports, direct path reads, subprocess calls, gate inputs, and any doc that names it as a current requirement.

When deleting research, remove or update live references to it, and move any durable conclusion into its authoritative current home: an ADR, the architecture documentation, an executable test, or a frozen fixture. Do not write an archival summary, a tombstone map or a closure document whose only job is to record what was deleted — that is a new artifact with the same problem, and a pointer into history is one more thing that can be wrong.

The failure this prevents is subtler than rot. A probe that no longer runs at least announces itself; a probe that still runs can publish a quantity the project has since disowned, and passing a check makes it look current. So the question at closure is not "does it work?" but "does it still answer a live question?" tests/test_research_probes.py runs the probes it declares runnable against a closed manifest, so a probe added later is either run or the gate fails.

Two rules that decide the ambiguous cases:

  • Prove claimed regenerability rather than assuming it. If an artifact is being deleted because surviving machinery can reproduce it, re-run the producer and confirm that claim. Failure to regenerate is a reason to re-check the four criteria above; it is not, by itself, a retention reason — a one-off diagnostic that nothing needs is just as disposable when its producer is gone.
  • A dangling reference is acceptable only where the referring document can be appended to. If a byte-frozen document (a pre-registration, a sealed manifest) cites the artifact, it can never be given a pointer to git history — so retain the artifact instead. An append-only ledger can simply record where the removed file resolves, which is the one place a history pointer earns its keep.

Architecture decisions

Every accepted ADR, title as written. The titles are claims, so this list is the decision set itself, not a table of contents: reading it tells you what has been settled without opening anything. Open the file for the argument, the alternatives and the consequences. tests/test_adr_index.py selects by Status and regenerates this from docs/decisions/, failing if the two disagree, so do not hand-edit an entry; change the ADR's heading and re-run. Adding a record means adding a line here (#481). Records that are proposed, superseded, deprecated or rejected are deliberately absent: they are listed with their status in docs/decisions/README.md, which is where history stays reachable without being presented as architecture in force.

Key architecture concepts

  • The shared bill data model (the two-tree hierarchy, the glossary, why the XML encodes nesting positionally, and the PDF↔XML parity goal) lives in docs/bill-structure.md. Read it before working on heading/anchor/account detection or DeltaTrack#54.
  • Which signals the source PDFs/XMLs carry — what we use, what's worth adopting, and what's confirmed absent (no PDF struct tree, no XML structured amounts) — is inventoried in docs/source-signal-inventory.md, reproducible via scripts/audit_source_signals.py. Check it before proposing a new extraction signal, to avoid re-investigating a dead end.
  • Bill XML has structural containers nested inside titles: subtitle, part, chapter, subchapter, subpart (the HOLC higher-unit ladder below title). These are handled by _walk_structural_children() in src/deltatrack/bill_tree.py, which recurses through them to reach sections and appropriations elements.
  • _process_section_element() is the shared helper for section handling, called from both the main title walk and structural containers.
  • BillNode.division_label stores the division context (e.g., "Division A: Military Construction"). normalize_division_title() strips the letter prefix for matching.
  • match_nodes() in src/deltatrack/diff_bill.py resolves every two-sided match_path group through the four ADR 0020 stages and holds no fused pairing act of its own. A non-colliding group takes a one-round orchestration (_match_unique_path_group); a collision group (same match_path in multiple divisions) runs a within-division round and then a cross-division fallback (_match_collision_group). That fallback's population has two sources, and the distinction is load-bearing: observations a division present on only one side contributed, which never reached assignment at all, and observations round 1a's assignment declined. On the committed corpus every cross-division participant is the first kind and none is the second, so the assignment-leftover path is real in code and exercised only by a synthetic fixture — see test_assignment_leftovers_reach_the_cross_division_fallback. An implementation that fed forward only the structural half would be byte-identical on all 27 pairs and wrong. match_nodes_with_stage_outputs() is the single implementation and also returns the CandidateSet and the round-1 GroupAssignments; match_nodes() is the pairing-only projection. The similarity cutoff is deliberately not here — apply_similarity_assignment_rule is a separate, later assignment act that owns the only threshold, and folding it into the group competition would delete that composition. Round 1's stage invariants are bound by tests/test_round1_stages.py, and its correspondence by tests/test_round1_pairing_sentinel.py; read both before changing anything in this paragraph.
  • Floor amendment annotations like "(increased by $2,000,000)" reference the budget request baseline, not the previous bill version. The base amount in the text IS the correct appropriation. amounts_changed compares base amounts (annotations stripped). The has_amendment_annotations field on FinancialChange flags their presence for informational display.
  • Preamble sections (Short Title, References, etc.) sit alongside divisions/titles at the body level and are captured by walk_body_sections().
  • Fetch tooling is layered: tools/fetch_bills.py (per-bill text; --source defaults to keyless govinfo, --source api for the Congress.gov API — plus search for keyless title discovery over the local BILLSTATUS index, and fetch-index for the lightweight scoped BILLSTATUS fetch that gives search its ZIPs without the full bulk download), tools/fetch_bill_archives.py (bulk bill metadata from govinfo BILLSTATUS archives; fetch-index reuses its download phase for one scoped (congress, type)), and tools/fetch_bill_text_archives.py (bulk per-(congress, session, type) bill text download into bills/). tools/fetch_bills.py and tools/fetch_bill_archives.py share tools/shared/ (http.py API client + retry, bill_types.py the bill-type vocab) and tools/bill_index/ (a CSV-backed BillIndex keyed by {congress}-{type}-{number} slug; parse_bill_id/make_bill_id). download-all --file <csv> reads a slug list through BillIndex. Bills are stored as bills/{congress}-{type}-{number}/{n}_{label}.{ext} (folder = bill, file = version).

Test conventions

  • All test files live in tests/. The engine is the installed deltatrack package, not source on disk: src is deliberately absent from pythonpath so that pytest, the root command wrappers and the tools all resolve deltatrack the same one way (ADR 0017 / #398). That is an import-resolution guarantee, not a packaging one — uv sync installs the engine editable, pointed straight at src/, so an ordinary run reports on the working tree and a module missing from the wheel passes it. Packaging fidelity is covered only by tests/test_engine_installs.py, which builds a real wheel into a throwaway environment; don't cite the suite at large for it (#438). The editable install also makes all of this invisible day to day — but a bare pytest in an environment where uv sync never ran fails on No module named 'deltatrack', and the fix is to sync, not to add a path. pythonpath = [".", "tools"] covers the fetch tooling and, through the root, the tests and scripts namespace packages — which is how the dev-only modules are imported now that they live beside what they serve rather than at the repository root (#401): tests.corpus_paths, tests.validation_check, tests.validation_sources, scripts.render_examples. The root itself holds only the two command wrappers and configuration. Every fixture path comes from tests/corpus_paths.py and is absolute, so pytest runs from any directory: FIXTURES_DIR for bill documents under tests/corpus/, DATA_DIR for everything else the suite reads from disk under tests/data/. Import them rather than respelling either path, which is what tests/test_fixture_layout.py enforces (#404).
  • Tests requiring real bill XML files are marked @pytest.mark.slow; front-end tests @pytest.mark.browser. The fast suite is -m "not slow and not browser". CI runs more than the fast suite (see CONTRIBUTING's "What CI checks")
  • The corpus correctness gates (CORPUS_GATE_MODULES in tests/conftest.py) parametrize over the committed manifest (tests/corpus_manifest.toml), so they collect the same set everywhere and run in CI; each carries a test_manifest_fixtures_committed floor that fails closed if a manifested bill is uncommitted (ADR 0015 / #217). CORPUS_SWEEP=1 widens them to a sweep of both trees, for opt-in, non-CI exploration. See TESTING.md.
  • Two bill trees, and only one is a test input (#308): tests/corpus/ is the committed fixture set every gate reads; bills/ is the fetchers' gitignored, disposable working directory. Never address a committed fixture through bills/ — resolve paths through tests/corpus_paths.py (fixture_path, resolve_bill_file, sweep_bill_dirs), which tests/test_fixture_layout.py enforces. Adding a fixture needs no .gitignore edit.
  • Every skip in a watched module must be allowlisted, or the session fails. Two ceilings share one mechanism (_SKIP_WATCH_GROUPS in tests/conftest.py): ALLOWED_CORPUS_SKIPS for CORPUS_GATE_MODULES (#220), and ALLOWED_CI_SLOW_SKIPS for CI_SLOW_MODULES (#288). The reason is the same for both: a manifest floor proves fixtures are committed and cases collected, but not that any assertion ran — a regression turning every case into a skip would otherwise keep CI green while asserting nothing. Matching is on nodeid and reason. Add an entry only with a comment saying why; each records a gap, not a neutral fact. TESTING.md has the two categories and which to use.
  • Adding a module to either tuple watches its skips in every pytest session, not just the CI step that names it — the hook is pytest_runtest_logreport, which has no idea which step invoked it. So a non-slow case in that module is watched by the fast run too, and one undeclared skip there reddens a session whose summary line still reads all-passed. Before adding a module, run it alone with -rs and declare its whole skip surface, not the part the slow step collects.
  • Every corpus gate is on the committed manifest and fails closed with no env var (#220). test_node_join_corpus, test_xml_subsection_nodes and test_pdf_subsection_recall parametrize over it; their fixtures are committed and manifested, and each carries a test_manifest_fixtures_committed floor. Don't reintroduce require_corpus_or_skip, REQUIRED_CORPUS_BILLS, or an .exists() filter on a parametrization list, which is the shape that fails open. History: #167 — those three parametrized over fetched (uncommitted) bills, so they ran empty and green on an unfetched checkout, including CI.
  • Each test requirement is stated where it belongs, not in a shared env var (#278). The Legislative Branch validation bills are committed, so test_validate_extraction's completeness floor is an ordinary fail-closed check that runs everywhere; test_govinfo_corpus_parity — which needs a live BILLSTATUS fetch per bill, and is the only check that the README's documented setup path still matches the code (#271) — carries @pytest.mark.network and is skipped unless --run-network is passed. Don't reintroduce an env var to express "this test needs something extra"; a marker is discoverable in pyproject.toml and in -m expressions, an undocumented variable is not. History: REQUIRE_CORPUS came to mean two unrelated things under a name describing neither.
  • A worktree belongs to one branch, and whoever creates it removes it (#448). It is created for a branch and finishes with it: once the pull request merges, remove the worktree (git worktree remove <path>) and delete the local branch. Removal is safe when the branch is merged into develop and the tree is clean, and it discards nothing, because the commits are in develop. Never git checkout a different branch inside an existing worktree to start the next task; create a second worktree instead. The directory name otherwise keeps describing the previous task while holding the next one, which is how a worktree named fix-438-439-import-story came to hold chore/442-pin-python-version, and git worktree list stops being readable at exactly the moment someone is trying to work out what is safe to delete. Nothing reclaims a finished worktree on its own: the ones assistants create are locked, and git worktree prune skips a locked worktree by design. One writer per worktree, ever.
  • A new git worktree needs its own source ./init before anything runs (#398). The engine is resolved from an editable install pointed at that checkout's src/, and .venv/ is gitignored, so a fresh worktree has no environment and no installed deltatrack. It costs about a second: uv sync hardlinks from its cache, so there is no reason to skip it. Never run an editable install from a worktree against a shared .venv — it re-points that environment at the worktree, and deleting the worktree then breaks every session using the checkout that owns it.
  • tests/conftest.py refuses any run whose deltatrack resolves outside this checkout's own src/, naming both paths (#435). This closes the loophole that supplying another checkout's interpreter opens: a worktree with no environment fails loudly, as the bullet above says, but pointing the main checkout's python at a worktree does not — pythonpath = ["."] collects the worktree's tests/ while deltatrack still resolves through the main checkout's editable install, so the run reports on source nobody is editing and red-green is meaningless, since reverting the file under review changes nothing it can see. Measured before the guard existed: a top-level raise RuntimeError in a worktree's src/deltatrack/bill_tree.py left tests/test_bill_tree.py at 133 passed. Two details carry weight. It anchors on the checkout root rather than pytest's rootdir, which is what keeps the child-session tests in test_corpus_manifest.py (rootdir = tmp_path) from tripping it. And it anchors on src/ rather than the root itself, because a worktree lives inside the checkout that owns it (.claude/worktrees/<name>): a root comparison reads a nested worktree's engine as "this checkout" and goes silent on it, which is the same wrong-tree green running the other way — re-point a shared .venv at a nested worktree, then run the suite from the owner. src/ also rejects a non-editable install under .venv/, a snapshot that equally cannot see an edit to src/. PYTHONPATH=$PWD/src is the escape hatch for a deliberate one-off against a shared venv; it satisfies the guard because it makes the import correct, not because it bypasses it.
  • A git worktree is fail-open only for what still needs a fetched corpus or a network: the live parity gate (network only, since #342 derives its completeness floor from the committed manifest rather than a fetched-corpus count). bills/ and bills_corpus/ are gitignored, so they don't propagate to a new worktree even though the main checkout has them fetched. Every committed fixture does propagate (they're all in tests/corpus/, tracked), so every manifest-parametrized gate — including the CI slow-suite step — runs correctly in a worktree. For the ones that don't, the tell is the collected/deselected count: compare it against a main-checkout run before trusting a green.
  • test_pdf_corpus_smoke / test_pdf_xml_amount_recall sweep rather than parametrizing over the manifest, but they sweep tests/corpus/ (#308), so a worktree, a clean clone and CI all collect the same cases — a differing case count there is a fail-open, not an expected difference. They widen to bills/ only under CORPUS_SWEEP=1, which also disables the skip ceiling. is_watched_case still excludes non-manifest cases from that ceiling; with the sweep pinned to the fixture tree it currently exempts nothing, and is kept as insurance for a future sweeping module rather than as a live carve-out.
  • A corpus gate only samples the input classes the corpus happens to contain. Distinct from the fail-open cases above, which collect nothing or assert vacuously: such a gate can run over the full corpus, on real data, assert something true, pass honestly — and still be blind to an entire class. The #244 title-search fold held idempotence across every corpus title while a real ordering bug sat in the code, because no bill title carries a letter with both an inseparable and a combining diacritic. Real data bounds the observed input space, not the space the code claims to accept, and no amount of extra or messier corpus closes that gap. When a change handles a class of inputs (encodings, Unicode, malformed structure, boundary shapes), pair the corpus gate with constructed inputs drawn from the declared domain.
  • Gate the decision you had to argue for. A design choice defended at length in a comment, ADR, or commit message but pinned by no test is ungated, and the prose reads as if it were settled. Swapping NFD for NFKD in the #244 fold left the whole module green despite paragraphs justifying that exact choice. Add a test that fails under the rejected alternative, then prove it by making the swap once and watching it go red — a decision gate that has never fired is not known to be a gate.
  • A regression test earns its place by naming what it protects and what would break it. Before adding one, state the product or methodological behaviour it preserves and the concrete mutation that turns it red — then prove the mutation, per the bullet above. If the only mutation you can name changes private structure (a renamed helper, a reordered internal call, an intermediate value no caller consumes), the test pins the current shape of the engine rather than anything the report, the extracted financial data or a documented method depends on, and it charges every later refactor for churn it cannot justify: don't add it. After adding it, find the older coverage it duplicates and remove the overlap, so one behaviour change produces one red test rather than five. Being open to evidence that part of this suite is overbuilt is part of maintaining it — but the bar does not move for the fail-closed infrastructure above (manifest floors, skip ceilings, corpus gates, the interpreter guard), where a missing check costs nothing visible until the run that needed it.
  • Shared test helpers live in tests/conftest.py: make_bill_node(), make_bill_tree(), make_node_diff(), make_change_dict()
  • Session-scoped fixtures in tests/conftest.py cache parsed bill trees and diffs to avoid redundant XML parsing
  • tools/fetch_bills.py tests use respx.mock decorator and monkeypatch time.sleep
  • src/deltatrack/bill_tree.py tests use inline XML snippets; integration tests use session fixtures
  • tests/test_diff_validation.py holds the hand-curated cross-version correctness assertions plus TestCorpusDiffSmoke, which runs invariant checks across every adjacent committed-manifest version pair
  • tests/test_corpus_properties.py parametrizes over the committed corpus manifest (tests/corpus_manifest.toml), broadened to every local XML file under CORPUS_SWEEP=1; uses _KNOWN_DUPLICATE_COUNTS and _KNOWN_MISSING_APPRO dicts for per-file baselines. Every baseline key must name a manifested fixture (test_known_duplicate_counts_names_manifest_fixtures): a key only CORPUS_SWEEP=1 can reach is a number no run keeps current, and four silently drifted into failing the sweep before the guard existed (#496). A sweep-only file is reported, not asserted — to hold a bill to a baseline, commit and manifest it (#126). Both dicts are <= ceilings, so they only fail upward: a parser change that reduces collisions leaves them loose with nothing red (#474 did), so re-measure rather than trusting a stored number.
  • Bill DTD XML uses flat-sibling appropriations-major/intermediate/small tags (not nested)
  • Dollar amounts are embedded in prose <text> elements, extracted via regex
  • HTML formatter functions (word_diff, build_financial_table, etc.) are individually testable