test: drive freeze's write path and its append-only guard - #97
Conversation
Opens the record-root seam `freeze` was missing and adds the tests it made possible. `freeze` now takes the record root the rest of the library already takes, required rather than defaulted, exactly as `frozenSnapshotPaths`, `recordPathsForContract` and `writeReleasedSuitesLib` take it: a writer that can only be pointed at the real record can only be tested against it, and the real record is one a test must not leave a release in. The root is threaded through root-aware `dirForSnapshot`/`pathForSnapshot` variants rather than concatenated inside `freeze`, so a snapshot path still has one spelling. `testRootAwareSnapshotPathIsTheWritersAtTheRealRoot` holds that spelling to `LibFs`'s at the real root. Four tests the seam makes reachable: - the freeze copies the bytes the REGENERATION wrote, not the bytes on disk when it was called. With `noRegeneration` as the only regeneration any test passed, a freeze that read before it regenerated passed the whole suite. - a release naming several contracts freezes every one of them. - a re-cut is refused, naming the tag and the directory, and leaves the first cut byte for byte. - an EMPTY <tag>/ refuses the cut too: the refusal is about the directory, not its contents. `SnapshotAlreadyFrozen` had no test at all and is the only protection on the immutability of src/generated/<tag>/. Closes #46 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A release of several contracts asserted only that a file per name landed, which a read loop truncated to the first contract, or a write loop that wrote records[0] for every i, both satisfy exactly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…build forge test compiles everything under test/, and a fixture a FAILING test left behind is source the next run has to compile. The freeze fixtures held an address declaration, so one red test left a tree that would not build until the directory was deleted by hand -- found by deleting the SnapshotAlreadyFrozen guard and watching the next run fail to compile rather than fail its assertions. Also reconciles writeSnapshot's docstring, which gave 'writing this repo's record somewhere else is not a thing' as the reason no writer takes a root. freeze now takes one: it COPIES within one tree, which is not the same freedom as choosing where a snapshot is GENERATED. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
Included review availability: Your plan includes up to 1 review per rolling hour; 0 remain after this review. Walkthrough
ChangesSnapshot freeze flow
Estimated code review effort: 3 (Moderate) | ~25 minutes Merge Risk: ⚪ Minimal · up to This change adds root-aware snapshot freezing and append-only protection with focused coverage; no actionable merge-blocking risk remains after normal checks and review. Possibly related issues
Possibly related PRs
Sequence Diagram(s)sequenceDiagram
participant cutRelease
participant LibRainDeploySnapshot
participant regenerate
participant RecordRoot
cutRelease->>LibRainDeploySnapshot: Call freeze with LIB_FS_ROOT
LibRainDeploySnapshot->>regenerate: Regenerate candidate snapshots
LibRainDeploySnapshot->>RecordRoot: Read candidates and validate release order
LibRainDeploySnapshot->>RecordRoot: Write frozen contract snapshots
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
#95 froze each release's dependency list into its snapshot, in the same code this branch gives `freeze` a record root. One textual conflict, in `writeSnapshot`'s docstring, where both sides rewrote the paragraph about why that writer takes no root. Resolved by keeping both, in that order. This branch's paragraphs replace main's "a test that wants a record tree of its own writes one with `vm.writeFile`": that sentence described the workaround a root-less `freeze` forced on a test, and a `freeze` that takes a root is precisely what removes the need for it — so keeping it would have left the file recommending a detour around a parameter that now exists. Main's dependency-freezing paragraphs follow verbatim; they are about WHAT the writer emits, not about which tree it writes to, so neither side's reasoning is weakened by the other. Everything else merged clean. `script/Build.sol` took both call-site changes: `freeze` gains `LIB_FS_ROOT`, and `writeSnapshot` gains the candidate's declared dependency list. The merged tree differs from main by exactly this branch's change and nothing else — 72 lines in the library, 306 in its test, 2 in `Build.sol`, the same diffstat the branch had against the merge base. Full suite on this commit: 222 tests, 175 passed, 47 failed. Every one of the 47 is `vm.createSelectFork: environment variable *_RPC_URL not found` — the absent fork endpoints in this environment, not a result. Zero non-environmental failures, which is what both sides reported separately. Main's four dependency tests and all five freeze tests this branch adds pass. Test-name accounting, because an auto-merged test file is where a silent drop hides: 195 at the merge base, +5 from this branch, +4 -2 from main, 202 here. The two absent are main's own replacements — `testSnapshotDeclaresTheFourDeployConstantsInOrder` by its FIVE-constant successor, and `testReleasedLibraryBlockCarriesTheTemplateDependencies` by `testReleasedEntriesTakeDependenciesFromTheFrozenRecord` and `testReleasedEntriesIgnoreTheTemplateDependencies`. No test this branch added is missing, and none appeared that neither side wrote. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Resolved LibRainDeploySnapshot around #114 and #96: - kept #114's single spelling of the record root (LIB_FS_ROOT, declared above its first use) and layered this branch's root-taking dirForSnapshot overload on top, so the no-root overload reads LIB_FS_ROOT rather than a literal. - freeze now takes a record root, so the monotonicity guard #96 added reads that same root instead of a hardcoded LIB_FS_ROOT: the record a release is appended to is the record it is checked against. testFreezeChecksTheRecordItIsAppendingTo pins it. - both sides' additions to LibRainDeploySnapshot.t.sol kept in full. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`testFreezeMultipleContracts` asserted membership with `holdsPath`, a local helper this branch was written against. #113 landed on main meanwhile, moving the shared test helpers into `test/lib/LibStringSet.sol` and deleting the local definitions. Neither side conflicts textually — one deletes a definition, the other adds a call — so the merge is clean and the tree does not compile. Both branches were green on their own CI. `LibStringSet.holds` is where the other membership assertions in this file already point, so the import was already present. 256 tests pass on the merge commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Reviewed 026ad15: ready — Closes #46. Drives Two conflicts with #96's guard read the wrong record. It added #113 deleted the helper this branch calls. It moved the shared test helpers into #114's invariant survives: exactly one occurrence of the literal All three rainix jobs green — including |
Closes #46 — audit findings
cov-02andcov-03.freezecould only ever be pointed at the realsrc/generated/record, so neither its write path nor itsSnapshotAlreadyFrozenappend-only guard could be driven from a test.grep -rn SnapshotAlreadyFrozen test/returned nothing: the only protection on the immutability ofsrc/generated/<tag>/— the record consumers pin their bytecode against — had never been seen to fire.The seam
freezenow takes the record root the rest of the library already takes, threaded through new root-awaredirForSnapshot/pathForSnapshotvariants.Four choices worth stating, because each has a cheaper alternative that is wrong:
The root is a REQUIRED parameter, not an overload with a defaulting wrapper. The issue proposed the overload. A
freezethat can still be called without a root can still be tested only against the real record, and the real record is one a test must not leave a release in.frozenSnapshotPaths,recordPathsForContractandwriteReleasedSuitesLibdo not default their root either.Paths go through the root-aware helpers, not hand-concatenation inside
freeze. The issue's proposed patch concatenatedstring.concat(root, "/", CANDIDATE, ...)directly — which the issue's own verification block flagged as a defect, againstpathForSnapshot's docstring: "Two spellings of one path is how a freeze silently reads nothing." The two spellings are equivalent only whileLibFs.pathForContractstayssrc/generated/<name>.sol, so any later drift would be silent.testRootAwareSnapshotPathIsTheWritersAtTheRealRootfuzzes the root-aware spelling againstLibFs's at the real root, so there is one path and not two.Fixture snapshot content is comment-only.
forge testcompiles everything undertest/, and a fixture left behind by a failing test is source the next run has to compile. A realistic fixture — declarations, as a real snapshot holds — turns one red test into a repo that will not build until somebody deletes a directory by hand. Found by deleting theSnapshotAlreadyFrozenguard and watching the next run fail to compile rather than fail its assertions.testFreezeCutsEveryNamedContractasserts each frozen file holds its OWN contract's bytes. Asserting only that a file per name exists is satisfied exactly by a write loop that storesrecords[0]for everyi, and by a read loop truncated to the first contract. Mutants m6, m7 and m8 below are those three, and the weaker assertion killed none of them.writeSnapshot's docstring gave "writing this repo's record somewhere else is not [a thing]" as the reason no writer takes a root; it is reconciled here.freezeCOPIES within one tree — reading a rolling snapshot under the root it is handed and writing the frozen copy under that same root — which is not the same freedom as choosing where a snapshot is GENERATED.Tests added
testFreezeCopiesTheRegeneratedRollingSnapshot— the freeze copies the bytes the REGENERATION wrote, not the bytes on disk when it was called, and the copy lands where the record walk finds it.testFreezeCutsEveryNamedContract— a release naming several contracts freezes every one, each from its own rolling snapshot.testFreezeRefusesARecutRelease— a re-cut is refused naming the tag and the directory, and leaves the first cut byte for byte.testFreezeRefusesATagDirectoryThatIsEmpty— the refusal is about the directory existing, not its contents.testRootAwareSnapshotPathIsTheWritersAtTheRealRoot— the root-aware path andLibFs's are one path wherever both can spell it.Suite
86f8d969f5327eThe 47 are identical on both sides and every one is
vm.createSelectFork: environment variable *_RPC_URL not found— 38 inLibRainDeployTest, 7 inRainDeployVerifyChainTest, 2 inRainDeployVerifyChainCandidateTest. They are absent fork endpoints in the local environment, not results. Zero non-environmental failures on either side, and the 5 tests this branch adds all pass.Mutation ledger
Nine mutants, one per behaviour the diff adds or changes. Run twice through independent harnesses — a per-mutant driver over the whole suite, and the org's
mutation-probeover a suite narrowed to exclude the three fork contracts (their env failures make a baseline red, and the probe refuses to run on one). Both agree: 9 killed, 0 survived, 0 no-run, 0 harness errors.dirForSnapshot(root, dir)ignores the root it was handedtestSnapshotPathsAgreeWithTheWriter, and all five freeze testspathForSnapshot(root, …)drops the.solextensiontestRootAwareSnapshotPathIsTheWritersAtTheRealRoot,testSnapshotPathsAgreeWithTheWriter,testFreezeLeavesNothingBehindWhenThereIsNothingToFreezepathForSnapshot(root, …)ignores the root, so reader and writer drifttestSnapshotPathsAgreeWithTheWriter,testFreezeCopiesTheRegeneratedRollingSnapshot,testFreezeCutsEveryNamedContract,testFreezeRefusesARecutReleaseSnapshotAlreadyFrozenguard is deleted outrighttestFreezeRefusesARecutRelease,testFreezeRefusesATagDirectoryThatIsEmptyregenerate()runs AFTER the reads, freezing a stale candidatetestFreezeCopiesTheRegeneratedRollingSnapshottestFreezeCutsEveryNamedContracttestFreezeCutsEveryNamedContracttestFreezeCutsEveryNamedContracttestFreezeCopiesTheRegeneratedRollingSnapshot,testFreezeCutsEveryNamedContract,testFreezeRefusesARecutReleasem4 and m5 are the two the issue was filed about. m4 — the append-only guard — had no test at all before this branch and survived the entire suite. m5 is the ordering: with
noRegenerationas the only regeneration any test passed, afreezethat read before it regenerated passed every existing test.Evidence each suite actually ran
A killed mutant and a mutant that never compiled both make
forge testexit non-zero, and this repo's suite is red at baseline from the missing RPC endpoints, so neither exit code nor "it went red" is evidence. Per mutant,LibRainDeploySnapshotTestran all 25 of its tests (25 pass / 0 fail at baseline) and the compile loggedCompiler run successful:Every whole-suite figure is
47 environmental + N real, and everyNis accounted for by the named tests above.Two harness traps were hit and fixed rather than reported through:
test/generated-recut/<tag>/makes the NEXT mutant revertSnapshotAlreadyFrozenfor a reason that has nothing to do with that mutant. Clearing residue is part of the suite command, before each run.src/generatedas well.QA
testFreezeCopiesTheRegeneratedRollingSnapshot,testFreezeCutsEveryNamedContract,testFreezeRefusesARecutRelease,testFreezeRefusesATagDirectoryThatIsEmpty,testRootAwareSnapshotPathIsTheWritersAtTheRealRoot— none can run against base at all: on86f8d96freezetakes no record root, so a success-path test could only cut into the realsrc/generated/, whichtestEveryFrozenSnapshotIsReleasedfails on from contracts forge runs in parallel. That untestability IS findingcov-02, so "fails on base" is verified the only way it can be — by restoring each base behaviour as a mutation and confirming the test fails: m4 restores base's absent append-only guard (base had no test for it; it survived the whole suite), and m5 restores the read-before-regenerate ordering that base'snoRegeneration-only tests could not observe. Both are killed below.dirForSnapshotstring.concat(root, …)->string.concat(LIB_FS_ROOT, …)->testSnapshotPathsAgreeWithTheWriter+ all five freeze testspathForSnapshotdrop".sol"->testRootAwareSnapshotPathIsTheWritersAtTheRealRoot,testSnapshotPathsAgreeWithTheWriterpathForSnapshotdirForSnapshot(root, dir)->dirForSnapshot(LIB_FS_ROOT, dir)->testSnapshotPathsAgreeWithTheWriter,testFreezeCopiesTheRegeneratedRollingSnapshot,testFreezeCutsEveryNamedContract,testFreezeRefusesARecutReleasefreezedeleteif (vm.exists(frozenDir)) revert SnapshotAlreadyFrozen(...)->testFreezeRefusesARecutRelease,testFreezeRefusesATagDirectoryThatIsEmptyfreezemoveregenerate();below the read loop ->testFreezeCopiesTheRegeneratedRollingSnapshotfreezewriterecords[0]for everyi->testFreezeCutsEveryNamedContractfreezeread loopi < contractNames.length->i < 1->testFreezeCutsEveryNamedContractfreezewrite loopi < contractNames.length->i < 1->testFreezeCutsEveryNamedContractfreezewrite topathForSnapshot(root, tag, …)->pathForSnapshot(root, CANDIDATE, …)->testFreezeCopiesTheRegeneratedRollingSnapshot,testFreezeCutsEveryNamedContract,testFreezeRefusesARecutReleaserollingFor(contractName), distinct per contract), and the freshness assertion compares againstfreshRolling()while assertingassertNotEqagainststaleRolling(), so afreezethat copied the wrong source cannot satisfy it.testRootAwareSnapshotPathIsTheWritersAtTheRealRoot's oracle is the independentLibFswriter, fuzzed rather than pinned to a chosen name.cov-02(freeze's write path: the regeneration ordering, the copied bytes, the cut being a release the record walk finds, and a multi-contract release) andcov-03(theSnapshotAlreadyFrozenappend-only guard, including the empty-<tag>/case). Covered: orderingtestFreezeCopiesTheRegeneratedRollingSnapshot; multi-contracttestFreezeCutsEveryNamedContract; re-cut refusal and the original record left intacttestFreezeRefusesARecutRelease; empty tag directorytestFreezeRefusesATagDirectoryThatIsEmpty. The issue's examples are not treated as exhaustive: the seam they both need is covered as a property in its own right bytestRootAwareSnapshotPathIsTheWritersAtTheRealRoot, and the read/write loop truncations (m7, m8) are covered though the issue names neither.Checks run locally
forge fmt --checkclean;pre-commit run --all-filesall green (deadnix, denofmt, nil, nixfmt, prettier-rainix, statix, taplo, yamlfmt).Summary by CodeRabbit
Bug Fixes
Enhancements
Tests