Skip to content

Clear one fixture tree at the start of every run - #232

Merged
thedavidmeister merged 5 commits into
mainfrom
fix-190
Sep 16, 2026
Merged

thedavidmeister merged 5 commits into
mainfrom
fix-190

Conversation

@thedavidmeister

@thedavidmeister thedavidmeister commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Closes #190.

LibRainDeploySnapshotTest had no setUp, so every run started on whatever the
run before it left in the tree. Each test reads before it removes and asserts
after — the discipline the file documents — but a mismatched vm.expectRevert
fires at the guarded call, which is upstream of every vm.removeDir in the
contract, so the one run that leaves a fixture behind is a run that already
failed.

For testFreezeChecksTheRecordItIsAppendingTo that residue is self-poisoning:
the unrefused freeze writes a real <tag>/ cut, and freeze checks
vm.exists(frozenDir) and reverts SnapshotAlreadyFrozen BEFORE it reaches
checkReleaseFollowsRecord. One failing run therefore turns a repeatable test
into a permanently red one reporting a cause that has nothing to do with the
property under test, recoverable only by deleting the directory by hand. In a
mutation pass the first mutant to break it silently kills every mutant after it.

What changed

Every fixture root this contract writes under test/ moves into ONE tree,
test/generated-snapshot/, named once by FIXTURE_ROOT. setUp removes that
tree, so a root added later is covered by being under it — nothing enumerates
the roots, so nothing can be left out of the clear. setUp is the only safe
point to do it: forge runs a contract's tests concurrently, and runs setUp
once, before any of them.

Two clearFixtureTree calls rather than one, because FIXTURE_LIB_ROOT
(fixture-lib/) cannot join the tree. A generated lib imports ../generated/,
../abstract/ and ./Lib<Contract>Released.sol, which resolve from src/lib
and nowhere else, so a copy under any COMPILED root fails the build for every
suite — and the build runs before setUp, which puts it upstream of anything
setUp could do about it. That is why fixture-lib/ is outside src/ and
test/ to begin with.

Deliberately NOT cleared:

  • The seven src/generated/<dir> fixtures. That root is the writer under test,
    and those directory names are deliberately not tag shaped, so what a failure
    leaves there is passed over by every record walk and overwritten by the test
    that wrote it.
  • FROZEN_FIXTURE_ROOT (test/fixture-record/). Being outside the tree is what
    keeps it: it is committed and read only, so a clear that reached it would
    delete it from the repo.

testClearingAFixtureTreeRemovesAStaleCut drives the clear over exactly the
residue a failed freeze-guard run leaves.

No source change: freeze writing that cut is freeze doing its job once the
ordering guard has passed. The defect is that the run inherits it.

What the diff actually is

+170/-58, of which the mechanism is 106 added lines: the FIXTURE_ROOT
constant, setUp, clearFixtureTree and the new test — comments included, and
the comments are most of it.

The remaining +64/-58 has no behaviour in it. 24 existing fixture-root
constants are re-rooted under the tree, one line each (+24/-24,
test/generated-freeze -> test/generated-snapshot/freeze); the walk test's
FIXTURE_ROOT becomes WALK_FIXTURE_ROOT at all 21 of its sites, now that the
bare name belongs to the tree; and forge fmt re-wraps the comments and
assertions the longer name no longer fits on one line. foundry.toml is
+2/-2, the comment naming the root.

The hand-maintained surface is what the extra lines buy. The first version of
this branch cleared the same residue from a written-out fixtureRoots() array
of 33 entries — every fixture directory named twice, once at its constant and
once in the list, and a root silently uncleared if the list was not updated with
it. That was +135/-2: a smaller diff carrying a standing invariant no compiler
checks. One tree costs more lines once and nothing after.

QA

  • Discriminating tests:
    • testFreezeChecksTheRecordItIsAppendingTo — pre-existing, and it
      discriminates from the defect's own precondition, which is the whole of the
      defect. With <root>/0_1_10/MockDeployable.sol planted before the run: on
      base (baa1a9c) it is
      [FAIL: Error != expected error: SnapshotAlreadyFrozen("0_1_10", "test/generated-freeze-guard/0_1_10") != NonMonotonicRelease("0_1_10", "9_9_9")],
      still red on a second run, and leaves ?? test/generated-freeze-guard/
      behind; on this branch, from the identical plant at
      test/generated-snapshot/freeze-guard/0_1_10/, it passes and git status is
      clean afterwards. Both run.
    • testClearingAFixtureTreeRemovesAStaleCut — new, and cannot run on base:
      clearFixtureTree and the guarantee it pins do not exist there. It is
      discriminated by mutation instead, below.
  • Mutations applied — all three run on this branch, each from a clean tree:
    • FIXTURE_ROOT: "test/generated-snapshot" ->
      "test/generated-snapshot/walk" — the clear still runs, but over one test's
      subtree instead of over the tree — with
      test/generated-snapshot/freeze-guard/0_1_10/MockDeployable.sol planted ->
      killed by testFreezeChecksTheRecordItIsAppendingTo, as
      SnapshotAlreadyFrozen("0_1_10", "test/generated-snapshot/freeze-guard/0_1_10") != NonMonotonicRelease("0_1_10", "9_9_9").
      Unmutated, from the same plant, it passes and leaves nothing. This is the
      mutation that pins the one-tree design itself: the umbrella has to be the
      umbrella and not one of the things under it.
    • clearFixtureTree: if (vm.exists(root)) ->
      if (false && vm.exists(root)) (the clear becomes a no-op) -> killed by
      testClearingAFixtureTreeRemovesAStaleCut, as
      SnapshotAlreadyFrozen("0_1_10", "test/generated-snapshot/freeze-stale/0_1_10") != NonMonotonicRelease("0_1_10", "9_9_9")
      — the exact shadowing A failing freeze-guard test leaves its fixture cut behind and then fails permanently with a different error #190 describes.
    • clearFixtureTree: vm.removeDir(root, true) ->
      vm.removeDir(root, false) (non-recursive) -> killed by
      testClearingAFixtureTreeRemovesAStaleCut, as
      vm.removeDir: failed to remove dir ".../test/generated-snapshot/freeze-stale": Directory not empty (os error 39).
  • Oracle: the expected refusal is NonMonotonicRelease(tag, "9_9_9"), the
    ordering guard's own refusal in checkReleaseFollowsRecord, reached only when
    the SnapshotAlreadyFrozen guard above it does not fire — read from
    src/lib/LibRainDeploySnapshot.sol, not from anything the test writes. The
    intent — that a run does not inherit the fixtures of the run before it — is
    the file's own documented read-before-remove, assert-after discipline, stated
    at three of its cleanup sites.
  • Category check: A failing freeze-guard test leaves its fixture cut behind and then fails permanently with a different error #190 asks for (a) a failing run not leaving its fixture cut
    behind and (b) the next run not failing permanently with a different error.
    Both are covered by the same change and verified together: from the planted
    0_1_10/ cut the run passes AND git status is clean when it ends, so the
    residue a failure leaves is gone by the start of the next run rather than
    deciding it. The issue also names the shape as class-wide — every test in the
    file writing under test/generated-* or the fixture lib root — which is why
    the clear is a setUp over a whole tree rather than one root, and why the
    roots moved into a tree rather than into a list.

Verification

git clean -fdq test src && rm -rf fixture-lib cache/fuzz before every run,
.env.example with the hyperliquid, arbitrum and bsc endpoints swapped for
reachable ones.

  • Whole suite, one run each side: base baa1a9c 512 passed / 0 failed; this
    branch 513 passed / 0 failed. git status clean after both.
  • forge fmt --check clean. forge lint reports one boolean-cst warning at
    LibRainDeploySnapshot.t.sol:1596, which is the same code on base and is not
    touched here.

The pre-existing race, measured

Unrelated, pre-existing, and NOT touched here: freezeConsensusFixture() is
shared by testWriteSnapshotRecordsTheZoltuAddress and
testWriteSnapshotHashesTheCodeItRecords, which run concurrently over the one
src/generated/writeConsensusNotATag directory.

Measured rather than assumed: 40 runs of
forge test --match-contract LibRainDeploySnapshotTest per side, alternating
base and branch so both sides see the same machine load. Base baa1a9c 24/40
red; this branch 26/40 red. Every one of those 50 red runs failed on
src/generated/writeConsensusNotATag and on nothing else, in one of those two
tests, as an ENOENT out of vm.writeFile, vm.readFile, vm.removeFile or
vm.removeDir — the one shared-directory race in four of its spellings. The
rate swings hard with load (4/10, 4/10 and 16/20 on base against 5/10, 7/10 and
14/20 on the branch, in that order) and the two sides swing together, which is
the observation worth having: the race is there on base, this change neither
introduces it nor removes it, and 40 runs a side do not separate the two rates.

It is a different defect — two tests sharing one directory — from the one #190
names, so it is left for its own issue.

🤖 Generated with Claude Code

https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN

Summary by CodeRabbit

  • Tests
    • Improved snapshot and fixture test isolation by using dedicated generated-snapshot directories.
    • Added cleanup between test runs to prevent stale fixture data from affecting results.
    • Expanded coverage for frozen cuts, nested and missing records, library generation, directory walking, and ordering behavior.
    • Added validation that stale frozen cuts are removed before freeze checks.

A test that fails reverts where it fails, and a mismatched `vm.expectRevert`
fires at the guarded call, upstream of every `vm.removeDir` in the contract, so
the run that leaves a fixture behind is a run that already failed. The next run
then read that residue as if a test had put it there: a leftover `<tag>/` makes
`freeze` refuse `SnapshotAlreadyFrozen` before it reaches the ordering guard
`testFreezeChecksTheRecordItIsAppendingTo` exists to observe, so one failure
turned a repeatable test into a permanently red one naming a different cause.

`setUp` clears the contract's fixture roots before any of its tests run, which
is the only point where clearing is safe: the roots are split one per test
because forge runs the tests in a contract concurrently, and forge runs `setUp`
once per contract.

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

Review Change StackReview Change Stack

Warning

Review limit reached

Next included review available in 23 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: d41ae640-2f42-44bb-aea9-b5dddea9d3af

📥 Commits

Reviewing files that changed from the base of the PR and between aa7db22 and cb6cbee.

📒 Files selected for processing (2)
  • foundry.toml
  • test/src/lib/LibRainDeploySnapshot.t.sol

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 83f6a148-1b93-4d5d-9adb-a06a502988ce

📥 Commits

Reviewing files that changed from the base of the PR and between de11ac0 and aa7db22.

📒 Files selected for processing (2)
  • foundry.toml
  • test/src/lib/LibRainDeploySnapshot.t.sol

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


Walkthrough

Snapshot tests now use isolated test/generated-snapshot fixture roots. Setup clears fixture trees before each test. A new test verifies stale frozen cuts are removed before freeze validation.

Changes

Snapshot fixture isolation

Layer / File(s) Summary
Fixture cleanup and configuration
foundry.toml, test/src/lib/LibRainDeploySnapshot.t.sol
The test defines an isolated fixture root, clears fixture trees in setUp, and updates the related configuration comment.
Fixture root migration
test/src/lib/LibRainDeploySnapshot.t.sol
Snapshot walking, release, freeze, regeneration, and ordering fixtures now use subtrees under test/generated-snapshot.
Stale-cut cleanup validation
test/src/lib/LibRainDeploySnapshot.t.sol
A new test removes a prior frozen record, verifies that its cut is absent, and checks that a later freeze reaches the NonMonotonicRelease guard.

Priority: ➖ Normal

Estimated code review effort: 2 (Simple) | ~15 minutes

Change: Bug fix · Severity of issue fixed: Medium

Suggested reviewers: claude

Merge Risk: ⚪ Minimal · up to aa7db

The fixture cleanup and migration changes have no identified merge-blocking risk.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Issue #190 requires repeatable snapshot tests and cleanup of generated fixture residue before tests run. LibRainDeploySnapshotTest.setUp() clears test/generated-snapshot recursively and `fixture-l…
Out of Scope Changes check ✅ Passed The changes stay within Issue #190. Fixture path consolidation supports class-wide cleanup, the new cleanup test verifies the required behavior, and the foundry.toml comment updates the documented f…
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check. Docstring coverage is scoped to functions touched by this diff. Analyzed 0 functions across 0…
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title describes the fixture cleanup added at the start of each test run. However, the change clears multiple fixture roots, not one fixture tree, so the title is less precise than the implementati…
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-190

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.

The first pass listed every fixture root by hand in `setUp`, which is the
defect again one level up: a root added to the contract and not added to the
list is a root that still decides the next run. Every record fixture root is
now a subdirectory of `test/generated-snapshot/`, so there is one name to
clear and a new test takes a subdirectory rather than a list entry.

`fixture-lib` is cleared as well, and is already one root with a subdirectory
per test. It cannot move under the fixture tree: a generated lib left under a
compiled root fails the next BUILD, which is upstream of anything `setUp`
could do about it.

`FIXTURE_ROOT` now names that one tree; the record-walk fixture that held the
name is `WALK_FIXTURE_ROOT`. `test/fixture-record/` is committed and stays
outside the tree, so nothing can clear it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN
@thedavidmeister thedavidmeister changed the title Clear the snapshot test fixture roots at the start of every run Clear one fixture tree at the start of every run Sep 15, 2026
baku-ccron and others added 2 commits September 15, 2026 22:45
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V8ViHcKLVk2YoS2joH4HdN
The sweep's message says these blocks are what this branch added. Most of them
are not: the doc blocks on `MISSING_FIXTURE_ROOT`, `NESTED_FIXTURE_ROOT`,
`SELECTED_FIXTURE_ROOT`, `FREEZE_FIXTURE_ROOT`, `NEWEST_FIXTURE_ROOT`,
`AGGREGATE_DEFAULTS_FIXTURE_DIR`, the `FIXTURE_LIB_ROOT` paragraph and the
"Read while the fixture is still there" comment are all on main, and cutting
them also left the comments that cross-reference them — "See
`NEWEST_FIXTURE_ROOT`.", "for the reason `RELEASED_FIXTURE_ROOT` is not
`WALK_FIXTURE_ROOT`" — pointing at nothing.

What each of them carries is a constraint no line of code states: forge runs
the tests in a contract concurrently, so a root shared between two of them is
a test reading another test's fixtures, and a fixture release under
`src/generated` is one the inherited record check fails on from the contracts
running in parallel. The `setUp` block is this PR's whole thesis: why the
START of the run, why a mismatched `vm.expectRevert` is the case that
read-before-remove cannot cover, and why two trees rather than one.

`clearFixtureTree`'s block stays cut: it restates the name and the signature.

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 (+166/-56), but both functions are byte-identical to the merge-base; only their line numbers move, 599/611 to 660/672. 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 73e6dbb 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.

A failing freeze-guard test leaves its fixture cut behind and then fails permanently with a different error

1 participant