Pairs that must not collapse #
Every correctness bug this tool has had is one of a small set of pairs being treated as one thing. They are listed here because the list is short, it has not changed in a long time, and reading it before touching a read path is cheaper than rediscovering an entry:
- absence and ignorance. "No record says so" and "no record I could see
says so" are different facts.
pr closeread the author's PDS and not the repo owner's, concluded a merged pull was open, and wroteclosedover the merge. A pull somebody else had stacked on read as "not stacked". - decided and unknown. A knot that refuses has done nothing; a knot that dies may have merged. Both were reported as "retry later".
- stale and current.
stack createcompared a pull's patch shas against branch commits that--add-change-idshad just rewritten. - identity and incidental context. Two reports of the same chain damage differed only by which member the walk started from, and the writer read that as new damage.
- one authority for two questions.
stack mergeinherited an ownership check from the verbs that write member records, and refused the repo owner a merge it writes no member for.
The tool already has the abstraction for the first of these — Evidence,
Reach, State::Unknown, Listing — and every instance of it has been in
code that did not use one. A value derived from a partial read should carry
its reach; where a function returns a bare Vec or a bare enum from a
listing, that is the thing to check.
The best fix is not to have the partial view #
understack_patches in cmd/pr/review.rs is the one place that sidesteps
"absence and ignorance" entirely rather than reporting it: it walks
dependentOn record by record with direct getRecord reads, so there is no
listing, no page cap and no index to be behind. A cycle is refused, a
malformed link is refused, and an unreadable member stops the checkout
rather than producing a working tree missing a patch.
Carrying Evidence is the right answer when a listing is genuinely needed —
"every pull aimed at this repo" cannot be walked link by link. Where the
question is "what does this record point at", the links are the answer and
no listing should be involved. Ask which kind of question is being asked
before reaching for a listing.
The rule about the rules #
Every entry above was one rule with several copies, or one rule in the wrong place. So the fix for an instance is not finished when the instance stops happening — it is finished when there is one copy of the rule, somewhere that owns it.
Two of these were written twice on the same day. "Absence and ignorance" was
fixed in pr close and then found again in stack view's "not stacked".
"Decided and unknown" was fixed in stack merge and then implemented a
second time, by hand, in repo delete. Both second copies were written by
somebody who had just written the first, which is the strongest evidence
available that noticing a pattern is not the same as removing it.
knot::decided is the one definition now, and it takes a tag and a status
rather than an error, because its two callers hold different things and the
rule is about neither.
Fixing one copy is not fixing the bug #
Every fix starts by grepping for the other implementations. Not after — before. This was learned the expensive way, seven times:
target_branch_ofwas made strict on the stack path whilepr mergekeptunwrap_or("main"), feeding the sameMergePlanto the same knot call. The doc comment on the strict one even named the bug it was fixing.- "Newest state wins" had five orderings; three decided state and all three differed. Two carried doc comments asserting they agreed with each other.
- "No PDS endpoint in the DID document" is one sentence with an
Exit::NotFoundowner and six hand-rolled copies that exit1. - The
@handle-or-DID rule was centralised, and copies kept turning up for days afterward.
A subagent sweep found three of these in one pass, and grepping properly
found two orderings the sweep had missed. So: name the rule, grep -rn for
every place that decides it, fix them together or write down why one of them
is deliberately different. A fix applied to one copy leaves the codebase
less consistent than before, because now the copies disagree.
Then ask why there were copies. Grepping is the fix for the instance;
it is not the fix for the pattern, and it had been written down here for a
while before the pattern kept happening anyway. Every rule on that list is a
rule about what a record means, and src/ was organised by verb, so none
of them had anywhere to live: cmd/ is forty thousand lines, and a rule you
cannot find is a rule you rewrite. model/ is where they go now — see
docs/module-layout.md. Duplication that keeps coming back is usually a
missing module, not a missing habit, and a rule that has an obvious home
gets found instead of rewritten. cmd/stack's chain reader is the evidence:
it became that home by accident and its rules stopped diverging.
A mock that ignores who is asking cannot catch asking the wrong one #
The mock PDS served every blob to every did: getBlob looked the CID up in
one global map and never read the did parameter. Everything about that is
convenient and it silently voided a whole class of test.
stack merge exists so a repo owner can land a contributor's stack, and it
read every member's patch blob from the merging account's PDS. A real PDS
answers 404 — the blob is in the contributor's repository — so the command
could not do the one thing it is for.
the_owner_merges_a_contributors_stack_from_their_own_pds covered exactly
this flow and passed, because in the harness there was no wrong PDS to ask.
The mock now records who uploaded each blob and 404s anyone else, and with the fix reverted that test fails with the message a maintainer would really have seen. The rule generalises past blobs: when a mock collapses an axis the real service distinguishes — which account, which host, which repo — no test can fail along that axis, and a green suite says nothing about it. A service that answers every question the same way is the empty-service mistake in a costume: see the next section.
Three accounts, three hosts, and a repo none of them is #
Tangled is multi-account by construction: a record lives in its author's own PDS, a repo belongs to one account and is contributed to by others, and no account can read or write another's repository. A fixture that does not model that cannot fail along it.
The harness used to have two accounts sharing one PDS endpoint, so:
- "ask the right account's PDS" was a question with one answer, and
stack mergehad been asking its own host for a contributor's patch blob for as long as the command existed; - "write only into your own repository" was unenforced — the mock checked that a token was present, never that it belonged to the repo being written to.
Now every account is served by its own mock on its own port, answering only
for itself; a request naming anyone else gets a 404, and a write whose bearer
is not the repo's owner gets a 403. Both are what the real services do. The
axes are the point: Scenario::new gives Alice, Bob and Carol, and
Scenario::upstream moves the repo to Carol so the two contributors are
aiming at a repository neither of them owns — the case where "the owner's
PDS" and "my PDS" stop being the same host, and every rule that tells them
apart finally has to be right.
Prefer Scenario::upstream for anything about ownership, standing, merging
somebody else's work, or which host holds a record. A test written against a
fixture where the acting account owns everything is a test that cannot tell a
correct answer from a lucky one.
Practices these came out of #
- Mutate before believing a test. Three tests in this suite passed for the wrong reason and were caught by deliberately breaking the code they covered. A green test is evidence only once it has been seen to fail.
- Model a service that fails, not only one that is empty. An empty answer
is evidence about the world; a refused request is evidence about nothing.
World::bobbin_fails,web_fails,knot_failsandCheckout::refuse_pushesexist for this and each found untested handling. - Agreement is not correctness.
assert_stack_reads_alikechecks that atgc and the appview read the same chain; two readers agree perfectly well on a chain that is wrong. Name the shape as well.
Testing #
cargo test runs everything and takes about eight seconds. No spindle is
attached to this repo, so prek run --all-files plus cargo test locally is
the actual gate, whatever .tangled/workflows/ci.yml claims.
What earns a test. Three things.
Parsers of other people's data. Every sh.tangled.* record, DID document,
Bobbin response and knot redirect is written by software this project does not
control, and the code is permissive about them on purpose, which is the kind of
parsing that fails quietly.
Things that are fiddly out of proportion to their size, like a base64url decoder or character-safe truncation. Short enough to look obviously right, and with no natural manual check.
Anything a commit message had to justify, because the paragraph will not survive the next refactor but the test will.
Glue does not earn one: main.rs dispatch, the layout of a println!, the
four lines around a request whose parser is already tested. That kind of
coverage costs the same to maintain as a real test, goes red for unrelated
reasons, and creates the impression that a green suite means the tool works.
Hermetic, non-negotiable. No network, no OAuth, no session, no reading or
writing the real ~/.config/atgc, no touching the user's git config, no
assuming a particular Tangled repo exists.
Two traps are specific to this crate. HOME cannot be redirected inside the
test process, because that needs std::env::set_var, which is unsafe in
edition 2024 and the crate forbids unsafe code. So a function that reads HOME
itself is not unit-testable; take the directory as a parameter and leave one
untested wrapper that names $HOME. And the working directory is process-wide
while cargo runs tests on threads, so testutil::TempRepo takes a module-level
mutex and must stay the only thing in the tree that moves the process.
Network access is allowed in exactly one place: a test marked #[ignore] whose
message says why. If an #[ignore] ever pins a known bug rather than a slow
socket, its message must say so, because "ignored" reads as "unimportant" and a
green suite standing over a broken function is worse than a red one.
Fixtures for reads, mocks for writes. Test a parser against real bytes captured off the real service, never against a mock. A mock encodes what we believe the service returns, so a test of a reader against it passes exactly when our belief is self-consistent, which is the thing already known.
A write command asks the opposite question: what atgc sent, in what order, as
whom. That is atgc's own behaviour, which no fixture can observe.
tests/support/ is the environment for it, with mock services on loopback
ports, a session that restores with no network, and the real binary spawned as
a child. Spawning a child is what makes an alternate HOME possible at all,
since Command::env is not unsafe. Reach for it whenever the bug is about
how two commands interact rather than about one function's output: a round
appended to the wrong record, a chain linked to something outside its own
batch, a write made with the wrong account's credentials.
A model of somebody else's service is a third thing. Fixtures and mocks
both answer "what did atgc do?". tests/support/appview.rs answers "what does
the service that reads these records make of them?", which is a question no
capture and no mock of our own can be asked — a stack can satisfy every rule a
PDS enforces and still be one tangled.org draws wrongly. A model like that is
only worth what its reading of the upstream source is worth, so it names the
file and the date it was read from, and it is written to look like the code it
models rather than like code we would have written.
Keep this page this short. Do not add per-feature notes, a list of what is verified by hand, or an inventory of what is not covered. That kind of writing goes stale the week after it lands and nothing checks it. This page once ran to 535 lines and named six source files that no longer existed. A fact about one test belongs in that test's doc comment; how a fixture was captured belongs beside the code that loads it; a claim that something is unproven belongs at the claim, in the code, where a reader is standing when it matters.