Generate the rolling snapshots under the record root they are frozen from - #226
Conversation
…from `cutRelease()` regenerates and then freezes from `recordRoot()`, but `Build.regenerateSnapshots` reached `writeSnapshot`, whose only output root was `LIB_FS_ROOT`. Under an overridden root the freeze read a rolling snapshot the regeneration had never written — `NothingToFreeze` — after rewriting the real `src/generated/candidate/`, which is the tree the override exists to keep a caller's hands off. `writeSnapshot` now takes the record root, required for the same reason `freeze` and `writeReleasedSuitesLib` require theirs, and writes through `LibFs.buildFileForContract` at `dirForSnapshot(root, dir)`. At `LIB_FS_ROOT` that is the directory `LibFs.dirForTag` names, so the real lifecycle is unchanged. Closes #206 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN
|
Warning Review limit reachedNext included review available in 41 minutes. View limit detailsLimit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. Review configuration: ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Advanced Run ID: 📒 Files selected for processing (5)
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 |
Rain convention is one contract per file; static caught the second contract in Build.t.sol. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN
`dirForSnapshot(root, dir)` held `dir` to `LibFs`'s tag alphabet and concatenated `root` as given, so the half of the path that decides where the tree IS was the only half nothing checked. `requireRecordRoot` holds it to that alphabet plus `-`, segment by segment, which is what this repo's own roots are spelled in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN
A raw byte as the assertion message is unreadable for exactly the bytes the test is about. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN
222ee7b took 194 lines off this branch under the heading of restated comments. Most of what went was not restatement: `src/lib/LibRainDeploySnapshot.sol` lost every sentence saying why a record root is checked at all, why it is checked where it is, why `LibFs.requireTag` cannot be asked the question, and why the generator takes a root instead of writing under `LIB_FS_ROOT` — the thesis of this PR. The tests lost the threat model the escape-root fixture is built from, the argument that makes each oracle independent of the writer under test, and the reason each external wrapper exists. Those come back here. The cuts that were restatement stay cut: the constant docstrings that spell the constant's name back, `sRoot`, and the `@param` and `@return` lines on the test-local wrappers, whose signatures already say it. `resetFixture` keeps 222ee7b's shorter form. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN
|
Closes #206.
What was wrong
cutRelease()regenerates and then freezes fromrecordRoot(), butBuild.regenerateSnapshotscalled thewriteSnapshotwhose only output rootwas
LIB_FS_ROOT— its path helper was the two-argumentpathForSnapshot, byway of
LibFs.buildFileForTaggedContract. Under an overridden root theregeneration wrote one tree and
freezeread another, so the seamBuildScript.recordRootadvertises could not work for anyBuildwritten theway this repo's own is:
NothingToFreeze(<fixture root>/candidate/<Name>.sol),after
src/generated/candidate/— the append-only tree the override exists tokeep a caller's hands off — had already been rewritten.
The code was wrong, not the documentation.
writeSnapshot's NatSpec argued thecapability did not exist ("GENERATING this repo's record anywhere but under
LIB_FS_ROOTremains something nothing here can express"), butLibFsexposesbuildFileForContract, which takes the directory it writes into, so the rootwas always expressible; and the asymmetry was already visible inside
Build,where
regenerateLibspassesrecordRoot()through towriteReleasedSuitesLibandregenerateSnapshotshad no way to.What changed
writeSnapshottakes the record root, on both arities, required rather thandefaulted for the reason
freezeandwriteReleasedSuitesLibrequire theirs.It writes through
LibFs.buildFileForContractatdirForSnapshot(root, dir)and returns
pathForSnapshot(root, dir, contractName);Build.regenerateSnapshotspassesrecordRoot(), the wayregenerateLibsalready does. The NatSpec paragraph that described the missing capability is
replaced by why the root is there.
The root is a CHECKED parameter.
dirForSnapshot(root, dir)is the one placethe root becomes a path, and it already held
dirtoLibFs's tag alphabet, sobefore this change it checked the half of the path that names a directory inside
the tree and concatenated the half that decides where the tree IS. A
dirheldto an alphabet beside a root taken verbatim is the shorter half of the path
confined:
../, a leading/, a trailing one and the empty string all reachedthe writer, and
fs_permissions— a consuming repo's config rather than anargument this library gets to see, and one that grants
./src— was the onlything left between a caller's string and a write outside the tree it named.
requireRecordRootis that check, withInvalidRecordRoot(root)of its own. Aroot is a PATH, so
LibFs.requireTagcannot be asked it — the separators thatmake a path a path are exactly what
requireTagrefuses — but the alphabetBETWEEN the separators is
requireTag's, widened by exactly one character,because that is how roots are spelled in this repo:
src/generatedis tagsegments already, and every fixture root the tests build (
test/generated-x,test/generated-write-snapshot-root,test/fixture-record, and the rest) istag segments plus
-. So a root is one or more/-separated segments, eachnon-empty and drawn from that alphabet. The empty segment is what carries the
rule from a segment to a path: refusing it refuses the leading separator of an
absolute path, the trailing one, the doubled one and the empty root. Refusing
.is what refuses.and.., for the reasonrequireTag's own NatSpecgives for a tag.
Two call sites reach it, and between them they are every way a root enters this
library:
dirForSnapshot(root, dir), which every writer and every rooted pathgoes through (
pathForSnapshot,writeSnapshoton both arities,freeze), andfrozenSnapshotPaths(vm, root), the record WALK, which is the one root-takingentry point that does not. The walk needs its own because it answers a missing
root with an empty record — a real state for a repo that has released nothing,
and silence for a root nothing could ever have been written under. The
read-side functions that take a
recordRoot(recordPathsForContract,newestFrozenTag,checkReleaseFollowsRecord,writeReleasedSuitesLib) allreach the walk, so they are covered by it rather than by a check restated at
each.
At
LIB_FS_ROOTthe directory is byte-for-byte the oneLibFs.dirForTagnames,so this repo's own
run()/cutRelease()write exactly where they did: thecommitted record is unchanged by this PR, and the suite's own green run leaves
src/generated/untouched.Migration
Breaking for a downstream
Build: a call of the formLibRainDeploySnapshot.writeSnapshot(vm, CANDIDATE, name, creationCode, dependencies);becomes
Passing
LibRainDeploySnapshot.LIB_FS_ROOTinstead ofrecordRoot()keeps theold behaviour, and is what the tests that build fixtures in the real record pass.
A downstream
recordRoot()override, or any other root handed to this library,must now be a path of those segments.
LIB_FS_ROOTis one, so a repo that neveroverrode the root is unaffected. A repo that overrode it to something with a
., a leading or trailing/, or a character outside the alphabet now getsInvalidRecordRootwhere it previously got a path built out of it.Relation to the other open branch on this file
#232 (
fix-190) adds asetUpthat clears the fixture rootsLibRainDeploySnapshotTestwrites, from a singlefixtureRoots()list, and isbeing reworked to one fixture root. It conflicts with this PR in
test/src/lib/LibRainDeploySnapshot.t.sol— both add constants and tests tothat contract. The resolution is additive in both directions: whichever lands
second takes the other's constants, and the fixture roots this PR adds
(
ROOTED_FIXTURE_ROOT, and the escape fixture'sESCAPE_FIXTURE_CLIMB_DIRandESCAPE_FIXTURE_DIR) belong in that PR's clear list. There is no sourceconflict: #232 changes no source.
QA
Discriminating tests:
testWriteSnapshotRefusesARootThatClimbsOutOfTheTreeItNames,testRecordRootRefusesEveryRootThatIsNotOne,testRecordRootSegmentIsTheTagAlphabetPlusHyphen,testFrozenSnapshotPathsRefusesARootThatIsNotOne, all intest/src/lib/LibRainDeploySnapshot.t.sol, alongsidetestRegenerateSnapshotsWritesUnderTheRecordRoot(
test/script/BuildRecordRoot.t.sol, overtest/concrete/BuildRecordRootHarness.sol)and
testWriteSnapshotWritesUnderTheRootItIsHandedfor the root parameteritself.
testWriteSnapshotRefusesARootThatClimbsOutOfTheTreeItNamesnames nonew symbol, so it RUNS on the base commit: applied alone to
fix-206at324c57bit is[FAIL: the write landed in the real record, outside the root it was handed]— the root
test/generated-escape-root/../../src/generatedwalked the writeout of the tree it named and into the append-only record. The other three name
InvalidRecordRootorrequireRecordRootand cannot compile on base, so theyare verified by mutation below.
testRegenerateSnapshotsWritesUnderTheRecordRootfails onmainatbaa1a9cwith only its test files applied:
[FAIL: regeneration wrote nothing under the record root: AddressRegistry],and that base run advanced
src/generated/candidate/{AddressRegistry,MigrationRegistry}.solfrom 14:44:25 to 15:36:07 with their md5s unchanged, which is the issue's
"writes the real record on the way to the revert" reproduced.
Mutations applied: six, all over
src/lib/LibRainDeploySnapshot.sol, each runas the whole suite minus
LibRainDeployTest— the fork-dependent contractwhose two endpoint failures are about no code in this file — against a
baseline of 439 passed, 0 failed. 6/6 killed, 0 survived, 0 no-run.
dirForSnapshotdropsrequireRecordRoot(root), keepingLibFs.requireTag(dir)— the pre-PR state, a checkeddirbeside a verbatim rootfrozenSnapshotPathsdropsrequireRecordRoot(root)— the walk left as the one root-taking entry point with no checkrequireRecordRootstops refusing an empty segment before a/, admitting the leading separator and the doubled onerequireRecordRootstops refusing an empty final segment, admitting the trailing separator and the empty root-is dropped from the root alphabet, narrowing it toLibFs.requireTag's exactly.is admitted to the root alphabet, so.and..become legal segmentsOracle: for the root parameter,
BuildScript.recordRoot's NatSpec andfreeze, which reads each rolling snapshot atpathForSnapshot(root, CANDIDATE, name)— the expected path is the freeze'sown spelling rather than the writer's, so the two are compared rather than one
restated; the expected BYTES are the committed
src/generated/candidate/<Name>.solfor the
Buildtest and the same snapshot written atLIB_FS_ROOTfor thelibrary test, both independent of the new writer. For the root RULE, the
alphabet's oracle is
LibFs.requireTagitself: the widened copy is comparedagainst it over all 256 byte values by
testRecordRootSegmentIsTheTagAlphabetPlusHyphen, so what is asserted is theoriginal's answer and not a restatement of the copy's, and
-is asserted asthe one byte they must DISAGREE about. The shape rule's oracle is
requireTag's own stated reason for its alphabet (no separator, no., so nosegment is
.or..), applied to a path instead of a segment; the escapetest's expected-absent path is
pathForSnapshot(dir, contractName)— the realrecord's own spelling — rather than a literal.
Category check: cutRelease under an overridden recordRoot freezes from a record the regeneration never wrote, after writing the real one #206 asks for one of two remedies — narrow
recordRoot'sNatSpec, or give
writeSnapshota root and passrecordRoot()throughBuild.regenerateSnapshots. The second is taken, because the capability theNatSpec called inexpressible is expressible (
LibFs.buildFileForContract), sonarrowing the doc would have documented a defect. Both halves of the issue are
covered: the unreachable seam (a release now freezes from the record its own
regeneration wrote, asserted by the library test's path and by the
Buildtest's bytes) and the side effect (nothing lands in the real record — asserted
directly by the library test's
assertFalse, and by the suite leavingsrc/generated/clean on the branch where the base run did not). The rootcheck is not a third remedy: it is what makes the new input an argument rather
than a hole, and it is asserted over the same real record the issue is about.
Verification
git clean -fdq test src && rm -rf fixture-lib cache/fuzzbefore every run,.env.examplewith drpc/publicnode endpoints substituted for arbitrum, base,bsc and hyperevm.
fix-206at324c57b: 512 passed, 2 failed of 514.two failures.
testFindDeployBlockZoltuFactoryandtestIsStartBlockAtDeployBlockin
LibRainDeployTest, both403 ... Archive requests require a personal tokenfrom the public basemainnet endpoint. Per
CLAUDE.mda fork instantiation failure is an endpointfault, not a statement about a deployment; Clear one fixture tree at the start of every run #232 reports the same pair from the
same endpoint. drpc's base endpoint serves archive but rate-limits: it answers
429 and fails 16 of the same suite at
-j 1, so it is not the better of thetwo. Every other suite is green on both sides.
🤖 Generated with Claude Code
https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN