Guidelines for AI coding agents working on this repository.
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.
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.
This repo follows the workflow in CONTRIBUTING.md. The load-bearing parts for an agent:
- Pick work from the
Readycolumn of the project board —Readymeans 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(nevermain), commit in small focused steps, and open the PR againstdevelop. 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 offdevelopitself 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
develop→mainpromotion is the maintainer's to initiate. Releasing is a judgment call about timing, not a step an agent infers. A runbook line in aplans/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_grouptrigger (#416). The merge queue waits on every required context before it will merge, so a required check whose workflow never fires onmerge_groupis never reported and the queue stalls for the whole repository, not just the pull request that added it. Today that is bothci.yml(test) andsecurity.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 toREQUIRED_CHECK_WORKFLOWSas 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 checkis 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), addCORPUS_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.
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
Sprintiteration field is the two-week container (14-day, Wednesday-aligned blocks). The current iteration's title carries the theme (renameSprint N→ e.g.Demo out), so it rides on every card and is API-readable. - "Committed this sprint" =
Sprintset to the current iteration + StatusReady. There is no separate commit flag. - Work the
Current sprintview (iteration:@current) first;Readyitems with no iteration are the bonus pool.
- 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 reintroducebug/enhancementlabels. - Grooming makes an issue pickup-ready (the
Backlog → Readymove): 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
Sprintare project fields — read viagh project item-listor the project GraphQL query, write withupdateProjectV2ItemFieldValue. Priority and Effort live on the issue, not the project item — read them fromgh api /repos/AgoraDMV/DeltaTrack/issues/<n>/issue-field-values(or GraphQLissue.issueFieldValues) and write withupdateIssueFieldValue(input:{issueId, issueField:{fieldId, singleSelectOptionId}})— a singular nestedissueField, using the option's node id (not the oldersetIssueFieldValue/plural form, which no longer matches the preview schema — introspectUpdateIssueFieldValueInputif it errors). List the field/option node ids viaorganization(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.fieldsas a single-select withoptions: [], while a real project field likeStatusshows 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 isorganization(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 keyissue_field_name, not a nestedissue_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.
updateProjectV2FieldwithsingleSelectOptionsreplaces the entire option set. Any option you resubmit without its originalidis 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 itsid. Snapshot first (gh project item-list --format json), and note thatitem-listis 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 thesecuritylabel and a hard look — that's the one outward-facing, abusable part of the project. - Epics carry the
epiclabel and stay untyped (no issue type) until the org-levelEpictype 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.
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 thisabove 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 0006does 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.
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.
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.
- 1. Diff a structured model of the bill, not document text
- 2. Use pypdfium2 (PDFium) as the single PDF text engine
- 3. Client-side PDF.js extraction is viable for published bills
- 4. Fetch bill discovery and text from govinfo bulk data, not the Congress.gov API
- 5. Keep DeltaTrack a contained two-version tool
- 6. Make a versioned JSON document the contract between the diff engine and its consumers
- 7. Render every diff with one renderer, whatever source pipeline produced it
- 8. Keep the diff engine deterministic; a language model may read a diff, never compute one
- 9. Validate the parser against independently-authored committee reports
- 10. Support a PDF pipeline for pre-publication bills; prefer XML once published
- 11. Process user-provided bill content only on the user's machine; no channel may send it off-device
- 12. Recover PDF heading levels from deterministic geometry; accept the prose-leading agency gap
- 13. Bill identity is the slug; version is a per-bill ordinal, not a universal one
- 14. Ship the recoverable heading levels as a conservation-checked tree; defer semantic rollup
- 15. Commit a curated corpus fixture set and collect the gates from a manifest
- 16. Separate the product, the acquisition tooling, and the delivery channel in the layout
- 17. Ship the diff engine as an installable
src/deltatrackpackage - 18. Read appropriations phrases for money, never for structure
- 19. Identify a parsed observation by its source, its parser revision and its ordinal; never by its text
- 20. Separate retrieval, correspondence evidence, correspondence assignment and change classification
- 21. Name things in the vocabulary an outside reader already speaks, scoped to the boundary being named
- 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()insrc/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_labelstores the division context (e.g., "Division A: Military Construction").normalize_division_title()strips the letter prefix for matching.match_nodes()insrc/deltatrack/diff_bill.pyresolves every two-sidedmatch_pathgroup 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 (samematch_pathin 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 — seetest_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 theCandidateSetand the round-1GroupAssignments;match_nodes()is the pairing-only projection. The similarity cutoff is deliberately not here —apply_similarity_assignment_ruleis 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 bytests/test_round1_stages.py, and its correspondence bytests/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_changedcompares base amounts (annotations stripped). Thehas_amendment_annotationsfield onFinancialChangeflags 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;--sourcedefaults to keyless govinfo,--source apifor the Congress.gov API — plussearchfor keyless title discovery over the local BILLSTATUS index, andfetch-indexfor the lightweight scoped BILLSTATUS fetch that givessearchits ZIPs without the full bulk download),tools/fetch_bill_archives.py(bulk bill metadata from govinfo BILLSTATUS archives;fetch-indexreuses its download phase for one scoped(congress, type)), andtools/fetch_bill_text_archives.py(bulk per-(congress, session, type)bill text download intobills/).tools/fetch_bills.pyandtools/fetch_bill_archives.pysharetools/shared/(http.pyAPI client + retry,bill_types.pythe bill-type vocab) andtools/bill_index/(a CSV-backedBillIndexkeyed by{congress}-{type}-{number}slug;parse_bill_id/make_bill_id).download-all --file <csv>reads a slug list throughBillIndex. Bills are stored asbills/{congress}-{type}-{number}/{n}_{label}.{ext}(folder = bill, file = version).
- All test files live in
tests/. The engine is the installeddeltatrackpackage, not source on disk:srcis deliberately absent frompythonpathso that pytest, the root command wrappers and the tools all resolvedeltatrackthe same one way (ADR 0017 / #398). That is an import-resolution guarantee, not a packaging one —uv syncinstalls the engine editable, pointed straight atsrc/, so an ordinary run reports on the working tree and a module missing from the wheel passes it. Packaging fidelity is covered only bytests/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 barepytestin an environment whereuv syncnever ran fails onNo module named 'deltatrack', and the fix is to sync, not to add a path.pythonpath = [".", "tools"]covers the fetch tooling and, through the root, thetestsandscriptsnamespace 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 fromtests/corpus_paths.pyand is absolute, so pytest runs from any directory:FIXTURES_DIRfor bill documents undertests/corpus/,DATA_DIRfor everything else the suite reads from disk undertests/data/. Import them rather than respelling either path, which is whattests/test_fixture_layout.pyenforces (#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_MODULESintests/conftest.py) parametrize over the committed manifest (tests/corpus_manifest.toml), so they collect the same set everywhere and run in CI; each carries atest_manifest_fixtures_committedfloor that fails closed if a manifested bill is uncommitted (ADR 0015 / #217).CORPUS_SWEEP=1widens 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 throughbills/— resolve paths throughtests/corpus_paths.py(fixture_path,resolve_bill_file,sweep_bill_dirs), whichtests/test_fixture_layout.pyenforces. Adding a fixture needs no.gitignoreedit. - Every skip in a watched module must be allowlisted, or the session fails. Two ceilings share one mechanism (
_SKIP_WATCH_GROUPSintests/conftest.py):ALLOWED_CORPUS_SKIPSforCORPUS_GATE_MODULES(#220), andALLOWED_CI_SLOW_SKIPSforCI_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-rsand 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_nodesandtest_pdf_subsection_recallparametrize over it; their fixtures are committed and manifested, and each carries atest_manifest_fixtures_committedfloor. Don't reintroducerequire_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.networkand is skipped unless--run-networkis passed. Don't reintroduce an env var to express "this test needs something extra"; a marker is discoverable inpyproject.tomland in-mexpressions, an undocumented variable is not. History:REQUIRE_CORPUScame 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 intodevelopand the tree is clean, and it discards nothing, because the commits are indevelop. Nevergit checkouta 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 namedfix-438-439-import-storycame to holdchore/442-pin-python-version, andgit worktree liststops 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 arelocked, andgit worktree pruneskips a locked worktree by design. One writer per worktree, ever. - A new git worktree needs its own
source ./initbefore anything runs (#398). The engine is resolved from an editable install pointed at that checkout'ssrc/, and.venv/is gitignored, so a fresh worktree has no environment and no installeddeltatrack. It costs about a second:uv synchardlinks 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.pyrefuses any run whosedeltatrackresolves outside this checkout's ownsrc/, 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'spythonat a worktree does not —pythonpath = ["."]collects the worktree'stests/whiledeltatrackstill 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-levelraise RuntimeErrorin a worktree'ssrc/deltatrack/bill_tree.pylefttests/test_bill_tree.pyat 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 intest_corpus_manifest.py(rootdir =tmp_path) from tripping it. And it anchors onsrc/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.venvat 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 tosrc/.PYTHONPATH=$PWD/srcis 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/andbills_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 intests/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_recallsweep rather than parametrizing over the manifest, but they sweeptests/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 tobills/only underCORPUS_SWEEP=1, which also disables the skip ceiling.is_watched_casestill 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.pycache parsed bill trees and diffs to avoid redundant XML parsing tools/fetch_bills.pytests userespx.mockdecorator and monkeypatchtime.sleepsrc/deltatrack/bill_tree.pytests use inline XML snippets; integration tests use session fixturestests/test_diff_validation.pyholds the hand-curated cross-version correctness assertions plusTestCorpusDiffSmoke, which runs invariant checks across every adjacent committed-manifest version pairtests/test_corpus_properties.pyparametrizes over the committed corpus manifest (tests/corpus_manifest.toml), broadened to every local XML file underCORPUS_SWEEP=1; uses_KNOWN_DUPLICATE_COUNTSand_KNOWN_MISSING_APPROdicts for per-file baselines. Every baseline key must name a manifested fixture (test_known_duplicate_counts_names_manifest_fixtures): a key onlyCORPUS_SWEEP=1can 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/smalltags (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