Skip to content

Generate the rolling snapshots under the record root they are frozen from - #226

Merged
thedavidmeister merged 8 commits into
mainfrom
fix-206
Sep 16, 2026
Merged

thedavidmeister merged 8 commits into
mainfrom
fix-206

Conversation

@thedavidmeister

@thedavidmeister thedavidmeister commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Closes #206.

What was wrong

cutRelease() regenerates and then freezes from recordRoot(), but
Build.regenerateSnapshots called the writeSnapshot whose only output root
was LIB_FS_ROOT — its path helper was the two-argument pathForSnapshot, by
way of LibFs.buildFileForTaggedContract. Under an overridden root the
regeneration wrote one tree and freeze read another, so the seam
BuildScript.recordRoot advertises could not work for any Build written the
way this repo's own is: NothingToFreeze(<fixture root>/candidate/<Name>.sol),
after src/generated/candidate/ — the append-only tree the override exists to
keep a caller's hands off — had already been rewritten.

The code was wrong, not the documentation. writeSnapshot's NatSpec argued the
capability did not exist ("GENERATING this repo's record anywhere but under
LIB_FS_ROOT remains something nothing here can express"), but LibFs exposes
buildFileForContract, which takes the directory it writes into, so the root
was always expressible; and the asymmetry was already visible inside Build,
where regenerateLibs passes recordRoot() through to
writeReleasedSuitesLib and regenerateSnapshots had no way to.

What changed

writeSnapshot takes the record root, on both arities, required rather than
defaulted for the reason freeze and writeReleasedSuitesLib require theirs.
It writes through LibFs.buildFileForContract at dirForSnapshot(root, dir)
and returns pathForSnapshot(root, dir, contractName);
Build.regenerateSnapshots passes recordRoot(), the way regenerateLibs
already 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 place
the root becomes a path, and it already held dir to LibFs's tag alphabet, so
before 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 dir held
to 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 reached
the writer, and fs_permissions — a consuming repo's config rather than an
argument this library gets to see, and one that grants ./src — was the only
thing left between a caller's string and a write outside the tree it named.

requireRecordRoot is that check, with InvalidRecordRoot(root) of its own. A
root is a PATH, so LibFs.requireTag cannot be asked it — the separators that
make a path a path are exactly what requireTag refuses — but the alphabet
BETWEEN the separators is requireTag's, widened by exactly one character,
because that is how roots are spelled in this repo: src/generated is tag
segments already, and every fixture root the tests build (test/generated-x,
test/generated-write-snapshot-root, test/fixture-record, and the rest) is
tag segments plus -. So a root is one or more /-separated segments, each
non-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 reason requireTag's own NatSpec
gives 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 path
goes through (pathForSnapshot, writeSnapshot on both arities, freeze), and
frozenSnapshotPaths(vm, root), the record WALK, which is the one root-taking
entry 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) all
reach the walk, so they are covered by it rather than by a check restated at
each.

At LIB_FS_ROOT the directory is byte-for-byte the one LibFs.dirForTag names,
so this repo's own run()/cutRelease() write exactly where they did: the
committed 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 form

LibRainDeploySnapshot.writeSnapshot(vm, CANDIDATE, name, creationCode, dependencies);

becomes

LibRainDeploySnapshot.writeSnapshot(vm, recordRoot(), CANDIDATE, name, creationCode, dependencies);

Passing LibRainDeploySnapshot.LIB_FS_ROOT instead of recordRoot() keeps the
old 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_ROOT is one, so a repo that never
overrode the root is unaffected. A repo that overrode it to something with a
., a leading or trailing /, or a character outside the alphabet now gets
InvalidRecordRoot where it previously got a path built out of it.

Relation to the other open branch on this file

#232 (fix-190) adds a setUp that clears the fixture roots
LibRainDeploySnapshotTest writes, from a single fixtureRoots() list, and is
being reworked to one fixture root. It conflicts with this PR in
test/src/lib/LibRainDeploySnapshot.t.sol — both add constants and tests to
that 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's ESCAPE_FIXTURE_CLIMB_DIR and
ESCAPE_FIXTURE_DIR) belong in that PR's clear list. There is no source
conflict: #232 changes no source.

QA

  • Discriminating tests: testWriteSnapshotRefusesARootThatClimbsOutOfTheTreeItNames,
    testRecordRootRefusesEveryRootThatIsNotOne,
    testRecordRootSegmentIsTheTagAlphabetPlusHyphen,
    testFrozenSnapshotPathsRefusesARootThatIsNotOne, all in
    test/src/lib/LibRainDeploySnapshot.t.sol, alongside
    testRegenerateSnapshotsWritesUnderTheRecordRoot
    (test/script/BuildRecordRoot.t.sol, over test/concrete/BuildRecordRootHarness.sol)
    and testWriteSnapshotWritesUnderTheRootItIsHanded for the root parameter
    itself. testWriteSnapshotRefusesARootThatClimbsOutOfTheTreeItNames names no
    new symbol, so it RUNS on the base commit: applied alone to fix-206 at
    324c57b it is
    [FAIL: the write landed in the real record, outside the root it was handed]
    — the root test/generated-escape-root/../../src/generated walked the write
    out of the tree it named and into the append-only record. The other three name
    InvalidRecordRoot or requireRecordRoot and cannot compile on base, so they
    are verified by mutation below.
    testRegenerateSnapshotsWritesUnderTheRecordRoot fails on main at baa1a9c
    with only its test files applied:
    [FAIL: regeneration wrote nothing under the record root: AddressRegistry],
    and that base run advanced src/generated/candidate/{AddressRegistry,MigrationRegistry}.sol
    from 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 run
    as the whole suite minus LibRainDeployTest — the fork-dependent contract
    whose 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.

    # Mutation Verdict
    M01 dirForSnapshot drops requireRecordRoot(root), keeping LibFs.requireTag(dir) — the pre-PR state, a checked dir beside a verbatim root KILLED
    M02 frozenSnapshotPaths drops requireRecordRoot(root) — the walk left as the one root-taking entry point with no check KILLED
    M03 requireRecordRoot stops refusing an empty segment before a /, admitting the leading separator and the doubled one KILLED
    M04 requireRecordRoot stops refusing an empty final segment, admitting the trailing separator and the empty root KILLED
    M05 - is dropped from the root alphabet, narrowing it to LibFs.requireTag's exactly KILLED
    M06 . is admitted to the root alphabet, so . and .. become legal segments KILLED
  • Oracle: for the root parameter, BuildScript.recordRoot's NatSpec and
    freeze, which reads each rolling snapshot at
    pathForSnapshot(root, CANDIDATE, name) — the expected path is the freeze's
    own 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>.sol
    for the Build test and the same snapshot written at LIB_FS_ROOT for the
    library test, both independent of the new writer. For the root RULE, the
    alphabet's oracle is LibFs.requireTag itself: the widened copy is compared
    against it over all 256 byte values by
    testRecordRootSegmentIsTheTagAlphabetPlusHyphen, so what is asserted is the
    original's answer and not a restatement of the copy's, and - is asserted as
    the one byte they must DISAGREE about. The shape rule's oracle is
    requireTag's own stated reason for its alphabet (no separator, no ., so no
    segment is . or ..), applied to a path instead of a segment; the escape
    test's expected-absent path is pathForSnapshot(dir, contractName) — the real
    record'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's
    NatSpec, or give writeSnapshot a root and pass recordRoot() through
    Build.regenerateSnapshots. The second is taken, because the capability the
    NatSpec called inexpressible is expressible (LibFs.buildFileForContract), so
    narrowing 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 Build
    test's bytes) and the side effect (nothing lands in the real record — asserted
    directly by the library test's assertFalse, and by the suite leaving
    src/generated/ clean on the branch where the base run did not). The root
    check 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/fuzz before every run,
.env.example with drpc/publicnode endpoints substituted for arbitrum, base,
bsc and hyperevm.

  • Base fix-206 at 324c57b: 512 passed, 2 failed of 514.
  • This branch: 516 passed, 2 failed of 518 — the four new tests, and the same
    two failures.
  • The two are testFindDeployBlockZoltuFactory and testIsStartBlockAtDeployBlock
    in LibRainDeployTest, both
    403 ... Archive requests require a personal token from the public base
    mainnet endpoint. Per CLAUDE.md a fork instantiation failure is an endpoint
    fault, 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 the
    two. Every other suite is green on both sides.

🤖 Generated with Claude Code

https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN

…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
@coderabbitai

coderabbitai Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 41 minutes.

Check out review usage here.

View limit details

Limit 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.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 82aae784-ac69-453f-8b48-257c44bd78a4

📥 Commits

Reviewing files that changed from the base of the PR and between d7ac706 and e9e182a.

📒 Files selected for processing (5)
  • script/Build.sol
  • src/lib/LibRainDeploySnapshot.sol
  • test/concrete/BuildRecordRootHarness.sol
  • test/script/BuildRecordRoot.t.sol
  • test/src/lib/LibRainDeploySnapshot.t.sol

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

baku-ccron and others added 5 commits September 15, 2026 16:02
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
@thedavidmeister

Copy link
Copy Markdown
Contributor Author

rainix / static / static is pre-existing, not this PR

Reproduced on a clean main. Tip feefc95, untouched working tree, in the shell CI pins:

$ nix develop github:rainlanguage/rainix/8657b83b68f41957ab85da91132c3f652c1f32c0#sol-shell -c forge lint -D warnings
Error: Compiler run failed:
Warning (2018): Function state mutability can be restricted to pure
   --> test/src/lib/LibRainDeploySnapshot.t.sol:667:5
Warning (2018): Function state mutability can be restricted to pure
   --> test/src/lib/LibRainDeploySnapshot.t.sol:679:5

These are solc diagnostics, not forge-lint rules. -D is a compiler flag, so
forge lint -D warnings denies solc's own warnings and aborts at compile before
reaching a single lint rule.

Why it reads as new. main's last run (35015487248, feefc95,
2026-09-15T19:45:08Z) is green and its static job has no forge lint step at
all — it ran soldeer install, slither ., forge fmt --check,
rainix-sol-single-contract. The step arrived upstream in
rainlanguage/rainix@55c8198e ("Gate every sol consumer on forge lint and the
pre-commit hook bundle") at 2026-09-15T20:59:35Z, 74 minutes after that run.
.github/workflows/rainix.yaml consumes rainix-sol.yaml@main, floating, so
every push from then on picks the gate up. main has not been pushed since, so
main is stale-green over red code.

This branch. merge-base baa1a9ca5821c6b41bd148fecc4fd87bd0f3666c. This PR does touch LibRainDeploySnapshot.t.sol (+247/-3), but both functions are byte-identical to the merge-base; only their line numbers move, 599/611 to 825/837. Both functions came in with
a201ec8 (2026-09-14), which is on main.

Clearing the two warnings will not be enough. With both flipped to pure the
compile succeeds and forge lint reports four findings CI has never printed,
every one on code no restore branch touches:

  • missing-zero-check x2 — test/concrete/MockChainDependentOwner.sol:26, both constructor address params
  • boolean-cst — the trailing : false in the semver precedes ternary, test/src/lib/LibRainDeploySnapshot.t.sol:1601
  • block-timestamp — test/src/concrete/MigrationRegistryApplyMigration.t.sol:924

Error: aborting due to 4 linter warning(s). Behind forge lint, the same
upstream commit added a pre-commit run --all-files step that has never executed
on this repo because lint fails first, so what that step does here is unknown.

All six restore branches (#219, #226, #228, #229, #232, #234) fail identically on
code none of them touches. The fix belongs on main once, not six times.

@thedavidmeister
thedavidmeister merged commit 2fed7dc into main Sep 16, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

cutRelease under an overridden recordRoot freezes from a record the regeneration never wrote, after writing the real one

1 participant