From bf14bb77e05cbb3a08ae5942af4df3ad1b31305c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 12:06:25 +0000 Subject: [PATCH 1/4] test: drive freeze's write path and its append-only guard 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 / 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//. Closes #46 Co-Authored-By: Claude Opus 5 (1M context) --- script/Build.sol | 2 +- src/lib/LibRainDeploySnapshot.sol | 57 ++++- test/src/lib/LibRainDeploySnapshot.t.sol | 272 ++++++++++++++++++++++- 3 files changed, 320 insertions(+), 11 deletions(-) diff --git a/script/Build.sol b/script/Build.sol index 45a1dc3..ab6fc90 100644 --- a/script/Build.sol +++ b/script/Build.sol @@ -117,7 +117,7 @@ contract Build is Script, RegistryDeploySuites { for (uint256 i = 0; i < contracts.length; i++) { contractNames[i] = contracts[i].contractName; } - LibRainDeploySnapshot.freeze(vm, regenerateCandidates, contractNames); + LibRainDeploySnapshot.freeze(vm, LibRainDeploySnapshot.LIB_FS_ROOT, regenerateCandidates, contractNames); regenerateLibs(); } diff --git a/src/lib/LibRainDeploySnapshot.sol b/src/lib/LibRainDeploySnapshot.sol index c8c684f..682d48b 100644 --- a/src/lib/LibRainDeploySnapshot.sol +++ b/src/lib/LibRainDeploySnapshot.sol @@ -160,11 +160,20 @@ library LibRainDeploySnapshot { return string(tagBytes); } - /// The directory holding a snapshot, rolling or frozen. + /// The directory holding a snapshot, rolling or frozen, under a record + /// root. + /// @param root The record root — `LIB_FS_ROOT` for a repo's real record. + /// @param dir The snapshot directory name — a release tag, or `CANDIDATE`. + /// @return The directory path. + function dirForSnapshot(string memory root, string memory dir) internal pure returns (string memory) { + return string.concat(root, "/", dir); + } + + /// The directory holding a snapshot in a repo's REAL record. /// @param dir The snapshot directory name — a release tag, or `CANDIDATE`. /// @return The directory path. function dirForSnapshot(string memory dir) internal pure returns (string memory) { - return string.concat("src/generated/", dir); + return dirForSnapshot(LIB_FS_ROOT, dir); } /// The contract name that places a generated file inside a snapshot @@ -177,7 +186,29 @@ library LibRainDeploySnapshot { return string.concat(dir, "/", contractName); } - /// The path of a contract's generated file within a snapshot. + /// The path of a contract's generated file within a snapshot, under a + /// record root. + /// + /// `LibFs` writes under `LIB_FS_ROOT` and takes no root, so it cannot spell + /// this one — but the two MUST be one path where the root is the real one, + /// and `testSnapshotPathsAgreeWithTheWriter` is where that is held. This is + /// the only other spelling of a snapshot path there is, so a reader + /// pointed at a record root and a writer pointed at the real one cannot + /// drift by more than that one assertion. + /// @param root The record root — `LIB_FS_ROOT` for a repo's real record. + /// @param dir The snapshot directory name — a release tag, or `CANDIDATE`. + /// @param contractName The name of the contract. + /// @return The file path. + function pathForSnapshot(string memory root, string memory dir, string memory contractName) + internal + pure + returns (string memory) + { + return string.concat(dirForSnapshot(root, dir), "/", contractName, ".sol"); + } + + /// The path of a contract's generated file within a snapshot in a repo's + /// REAL record. /// /// Delegated to `LibFs` rather than concatenated here, so the path this /// library freezes FROM is the same definition `LibFs` writes TO. Two @@ -736,12 +767,24 @@ library LibRainDeploySnapshot { /// "the record matches the candidate" is true by construction rather than /// by a comparison afterwards. /// @param vm The Vm instance for file operations. + /// @param root The record root to freeze into — `LIB_FS_ROOT` for a repo's + /// real record. A parameter for the same reason `frozenSnapshotPaths` and + /// `writeReleasedSuitesLib` take one, and required rather than defaulted + /// for the same reason they do not default it: a freeze 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. + /// + /// It is the root of a whole record tree rather than an output directory + /// to choose: the rolling snapshot is read from under it and the frozen + /// copy written under it, so a freeze reads and writes ONE tree and there + /// is no arrangement in which a release is cut from another repo's + /// candidate. /// @param regenerate Rewrites the rolling snapshot. Run first, always. /// @param contractNames The contracts whose generated files form this /// release's record. - function freeze(Vm vm, function() internal regenerate, string[] memory contractNames) internal { + function freeze(Vm vm, string memory root, function() internal regenerate, string[] memory contractNames) internal { string memory tag = deployTag(vm); - string memory frozenDir = dirForSnapshot(tag); + string memory frozenDir = dirForSnapshot(root, tag); if (vm.exists(frozenDir)) { revert SnapshotAlreadyFrozen(tag, frozenDir); } @@ -755,7 +798,7 @@ library LibRainDeploySnapshot { // call can fail is now behind it, so what follows is writes only. string[] memory records = new string[](contractNames.length); for (uint256 i = 0; i < contractNames.length; i++) { - string memory rollingPath = pathForSnapshot(CANDIDATE, contractNames[i]); + string memory rollingPath = pathForSnapshot(root, CANDIDATE, contractNames[i]); if (!vm.exists(rollingPath)) { revert NothingToFreeze(rollingPath); } @@ -766,7 +809,7 @@ library LibRainDeploySnapshot { vm.createDir(frozenDir, true); for (uint256 i = 0; i < contractNames.length; i++) { //forge-lint: disable-next-line(unsafe-cheatcode) - vm.writeFile(pathForSnapshot(tag, contractNames[i]), records[i]); + vm.writeFile(pathForSnapshot(root, tag, contractNames[i]), records[i]); } } } diff --git a/test/src/lib/LibRainDeploySnapshot.t.sol b/test/src/lib/LibRainDeploySnapshot.t.sol index b917ea4..18e1055 100644 --- a/test/src/lib/LibRainDeploySnapshot.t.sol +++ b/test/src/lib/LibRainDeploySnapshot.t.sol @@ -9,6 +9,7 @@ import { EmptyRelease, LibRainDeploySnapshot, NothingToFreeze, + SnapshotAlreadyFrozen, UnreleasableVersion } from "../../../src/lib/LibRainDeploySnapshot.sol"; import {MockDeployable} from "../../concrete/MockDeployable.sol"; @@ -30,13 +31,23 @@ contract LibRainDeploySnapshotTest is Test { /// A regeneration that does nothing, for driving `freeze`'s guards. Every /// one of them either fires before this runs or is about what it left /// behind, so a no-op is what makes "the guard fired" and "the guard fired - /// FIRST" the same observation. + /// FIRST" the same observation — which is why the ordering itself is + /// driven by `regenerateFreezeFixture` instead. function noRegeneration() internal {} - /// External wrapper so `vm.expectRevert` lands at the right call depth. + /// External wrapper so `vm.expectRevert` lands at the right call depth, for + /// the guards that are about this repo's REAL record. /// @param contractNames The contracts to freeze. function externalFreeze(string[] memory contractNames) external { - LibRainDeploySnapshot.freeze(vm, noRegeneration, contractNames); + LibRainDeploySnapshot.freeze(vm, LibRainDeploySnapshot.LIB_FS_ROOT, noRegeneration, contractNames); + } + + /// External wrapper so `vm.expectRevert` lands at the right call depth, for + /// the guards driven against a fixture record. + /// @param root The record root to freeze into. + /// @param contractNames The contracts to freeze. + function externalFreezeAt(string memory root, string[] memory contractNames) external { + LibRainDeploySnapshot.freeze(vm, root, noRegeneration, contractNames); } /// Where the record fixture is built. NOT `src/generated`: the inherited @@ -199,6 +210,40 @@ contract LibRainDeploySnapshotTest is Test { assertEq(LibRainDeploySnapshot.dirForSnapshot("0_1_7"), "src/generated/0_1_7"); assertEq(LibRainDeploySnapshot.snapshotName("0_1_7", "Foo"), "0_1_7/Foo"); assertEq(LibRainDeploySnapshot.pathForSnapshot("0_1_7", "Foo"), "src/generated/0_1_7/Foo.sol"); + + // The same two paths under a record root that is not the real one, so + // the shape a fixture record is built and read at is pinned rather than + // only implied by the real root's. + assertEq(LibRainDeploySnapshot.dirForSnapshot("test/generated-x", "0_1_7"), "test/generated-x/0_1_7"); + assertEq( + LibRainDeploySnapshot.pathForSnapshot("test/generated-x", "0_1_7", "Foo"), "test/generated-x/0_1_7/Foo.sol" + ); + } + + /// The root-aware snapshot path and the one `LibFs` writes MUST be ONE path + /// wherever both can spell it. + /// + /// `LibFs` takes no root, so a fixture record is the one thing it cannot + /// spell — and `freeze` reads and writes through the root-aware spelling + /// for the REAL record too, so this is the whole of what keeps the path a + /// release is frozen to the path the regeneration wrote it at. Two + /// spellings of one path is how a freeze silently reads nothing, and this + /// is where they are held to being one. + /// + /// Fuzzed over the name rather than pinned to a literal because the + /// property is about every path either could produce, not about a chosen + /// one: a divergence that only appears for some names is exactly the + /// silence this is here to remove. + /// @param dir The snapshot directory name. + /// @param contractName The name of the contract. + function testRootAwareSnapshotPathIsTheWritersAtTheRealRoot(string memory dir, string memory contractName) + external + pure + { + assertEq( + LibRainDeploySnapshot.pathForSnapshot(LibRainDeploySnapshot.LIB_FS_ROOT, dir, contractName), + LibRainDeploySnapshot.pathForSnapshot(dir, contractName) + ); } /// A snapshot MUST land at the path this library says it does, and writing @@ -672,4 +717,225 @@ contract LibRainDeploySnapshotTest is Test { assertFalse(vm.exists(LibRainDeploySnapshot.dirForSnapshot(tag))); } + + /// Where the freeze fixture's record is built. Its own tree, for the same + /// reason `RELEASED_FIXTURE_ROOT` is not `FIXTURE_ROOT`, and NOT + /// `src/generated`: every freeze here cuts a release under THIS repo's + /// tag, and a transient `/` in the real record is a release the + /// inherited record check has to fail on, from contracts forge runs in + /// parallel with this one. + string constant FREEZE_FIXTURE_ROOT = "test/generated-freeze"; + + /// Where the multi-contract freeze fixture's record is built. Its own tree + /// again, and for a sharper reason than the others: every freeze test cuts + /// the SAME tag, so two of them sharing a root would have whichever ran + /// second refused as a re-cut. + string constant FREEZE_MULTI_FIXTURE_ROOT = "test/generated-freeze-multi"; + + /// Where the re-cut fixture's record is built. Its own tree: this one is + /// deliberately left frozen between the two calls, so no other test may + /// share it. + string constant RECUT_FIXTURE_ROOT = "test/generated-recut"; + + /// Where the empty-tag-directory fixture's record is built. Its own tree, + /// for the same reason: it is a frozen tag from the moment it is created. + string constant RECUT_EMPTY_FIXTURE_ROOT = "test/generated-recut-empty"; + + /// What the freeze fixture's regeneration writes, and what a freeze that + /// ran the regeneration first therefore copies. + /// + /// The SPDX identifier is split for the reason `writeFixture` splits it: a + /// run that fails midway leaves this content on disk, and an unlicensed + /// file in the tree is a second failure on top of the first. + /// @return The regenerated rolling snapshot. + function freshRolling() internal pure returns (string memory) { + return string.concat( + "// SPDX-License", + "-Identifier: LicenseRef-DCL-1.0\n", + "address constant DEPLOYED_ADDRESS = address(0xFEED000000000000000000000000000000000000);\n" + ); + } + + /// What is on disk before the call, and what a freeze that read the rolling + /// snapshot before regenerating it would copy instead. + /// @return The stale rolling snapshot. + function staleRolling() internal pure returns (string memory) { + return string.concat( + "// SPDX-License", + "-Identifier: LicenseRef-DCL-1.0\n", + "address constant DEPLOYED_ADDRESS = address(0x57A1E00000000000000000000000000000000000);\n" + ); + } + + /// A regeneration that really regenerates, so "the guard fired FIRST" is no + /// longer the only thing a freeze test can observe. + function regenerateFreezeFixture() internal { + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.writeFile( + LibRainDeploySnapshot.pathForSnapshot( + FREEZE_FIXTURE_ROOT, LibRainDeploySnapshot.CANDIDATE, FIXTURE_CONTRACT + ), + freshRolling() + ); + } + + /// A freeze MUST copy the bytes the regeneration wrote, not the bytes that + /// were on disk when it was called, and the copy MUST land where the record + /// walk finds it. + /// + /// Freezing a stale candidate is silent: the immutability check only fires + /// on a re-cut, which is too late, so nothing downstream ever learns that a + /// release records bytes its own deploy did not produce. The ordering is + /// therefore the whole of what makes a release describe itself, and with a + /// no-op regeneration it is unobservable — a `freeze` that read before it + /// regenerated would pass every other test in this file. + function testFreezeCopiesTheRegeneratedRollingSnapshot() external { + string memory tag = LibRainDeploySnapshot.deployTag(vm); + string memory rollingPath = LibRainDeploySnapshot.pathForSnapshot( + FREEZE_FIXTURE_ROOT, LibRainDeploySnapshot.CANDIDATE, FIXTURE_CONTRACT + ); + string memory frozenPath = LibRainDeploySnapshot.pathForSnapshot(FREEZE_FIXTURE_ROOT, tag, FIXTURE_CONTRACT); + + writeFixture(rollingPath); + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.writeFile(rollingPath, staleRolling()); + + string[] memory contractNames = new string[](1); + contractNames[0] = FIXTURE_CONTRACT; + LibRainDeploySnapshot.freeze(vm, FREEZE_FIXTURE_ROOT, regenerateFreezeFixture, contractNames); + + // Read while the fixture is still there, asserted once it is gone. + bool frozenExists = vm.exists(frozenPath); + string memory frozen = frozenExists ? vm.readFile(frozenPath) : ""; + // The cut is a release the record walk finds, under the tag the version + // maps to and nowhere else. + string[] memory record = LibRainDeploySnapshot.frozenSnapshotPaths(vm, FREEZE_FIXTURE_ROOT); + // And the rolling snapshot is the regenerated one, still in place: a + // freeze MOVES nothing, it copies. + string memory rolling = vm.readFile(rollingPath); + + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(FREEZE_FIXTURE_ROOT, true); + + assertTrue(frozenExists); + assertEq(frozen, freshRolling()); + assertNotEq(frozen, staleRolling()); + assertEq(rolling, freshRolling()); + assertEq(record.length, 1); + assertEq(record[0], frozenPath); + } + + /// A release naming SEVERAL contracts MUST freeze every one of them. + /// + /// A contract regenerated but absent from the record is a contract silently + /// missing from the release, and a tag that never held it has nothing + /// missing from it for anything downstream to notice. + function testFreezeCutsEveryNamedContract() external { + string memory tag = LibRainDeploySnapshot.deployTag(vm); + string[] memory contractNames = new string[](2); + contractNames[0] = FIXTURE_CONTRACT; + contractNames[1] = FIXTURE_CONTRACT_SECOND; + for (uint256 i = 0; i < contractNames.length; i++) { + writeFixture( + LibRainDeploySnapshot.pathForSnapshot( + FREEZE_MULTI_FIXTURE_ROOT, LibRainDeploySnapshot.CANDIDATE, contractNames[i] + ) + ); + } + + LibRainDeploySnapshot.freeze(vm, FREEZE_MULTI_FIXTURE_ROOT, noRegeneration, contractNames); + + string[] memory record = LibRainDeploySnapshot.frozenSnapshotPaths(vm, FREEZE_MULTI_FIXTURE_ROOT); + bool first = + holdsPath(record, LibRainDeploySnapshot.pathForSnapshot(FREEZE_MULTI_FIXTURE_ROOT, tag, contractNames[0])); + bool second = + holdsPath(record, LibRainDeploySnapshot.pathForSnapshot(FREEZE_MULTI_FIXTURE_ROOT, tag, contractNames[1])); + + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(FREEZE_MULTI_FIXTURE_ROOT, true); + + assertTrue(first); + assertTrue(second); + assertEq(record.length, 2); + } + + /// A release is cut ONCE. Re-cutting a tag that already has a record MUST + /// be refused, naming the tag and the directory, and MUST leave the + /// original record exactly as it was. + /// + /// That record is what consumers of that release pin their bytecode + /// against, and a second cut would replace it with whatever the candidate + /// currently is — which, between releases, is ordinarily something else. + /// This is the only protection there is on the immutability of + /// `src/generated//`. + function testFreezeRefusesARecutRelease() external { + string memory tag = LibRainDeploySnapshot.deployTag(vm); + string memory frozenDir = LibRainDeploySnapshot.dirForSnapshot(RECUT_FIXTURE_ROOT, tag); + string memory frozenPath = LibRainDeploySnapshot.pathForSnapshot(RECUT_FIXTURE_ROOT, tag, FIXTURE_CONTRACT); + string memory rollingPath = LibRainDeploySnapshot.pathForSnapshot( + RECUT_FIXTURE_ROOT, LibRainDeploySnapshot.CANDIDATE, FIXTURE_CONTRACT + ); + + writeFixture(rollingPath); + string[] memory contractNames = new string[](1); + contractNames[0] = FIXTURE_CONTRACT; + + LibRainDeploySnapshot.freeze(vm, RECUT_FIXTURE_ROOT, noRegeneration, contractNames); + string memory firstCut = vm.readFile(frozenPath); + + // The candidate moves on, exactly as source does between releases, so + // an accepted re-cut would be seen writing something else. + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.writeFile(rollingPath, freshRolling()); + + vm.expectRevert(abi.encodeWithSelector(SnapshotAlreadyFrozen.selector, tag, frozenDir)); + this.externalFreezeAt(RECUT_FIXTURE_ROOT, contractNames); + + // Read while the fixture is still there, asserted once it is gone. + string memory afterRefusal = vm.readFile(frozenPath); + string[] memory record = LibRainDeploySnapshot.frozenSnapshotPaths(vm, RECUT_FIXTURE_ROOT); + + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(RECUT_FIXTURE_ROOT, true); + + assertEq(afterRefusal, firstCut); + assertNotEq(afterRefusal, freshRolling()); + assertEq(record.length, 1); + } + + /// The refusal is about the DIRECTORY existing, not about what is in it, so + /// an empty `/` left behind by anything refuses the real cut too. + /// + /// That is not a wrinkle, it is why `EmptyRelease` and the write ordering + /// exist: the exit from a wedged tag is deleting a directory this design + /// calls append-only, so everything that could leave one behind is refused + /// up front instead. + function testFreezeRefusesATagDirectoryThatIsEmpty() external { + string memory tag = LibRainDeploySnapshot.deployTag(vm); + string memory frozenDir = LibRainDeploySnapshot.dirForSnapshot(RECUT_EMPTY_FIXTURE_ROOT, tag); + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.createDir(frozenDir, true); + // Everything else a cut needs is ready, so the refusal can only be + // about the directory. + writeFixture( + LibRainDeploySnapshot.pathForSnapshot( + RECUT_EMPTY_FIXTURE_ROOT, LibRainDeploySnapshot.CANDIDATE, FIXTURE_CONTRACT + ) + ); + + string[] memory contractNames = new string[](1); + contractNames[0] = FIXTURE_CONTRACT; + + vm.expectRevert(abi.encodeWithSelector(SnapshotAlreadyFrozen.selector, tag, frozenDir)); + this.externalFreezeAt(RECUT_EMPTY_FIXTURE_ROOT, contractNames); + + // Read while the fixture is still there, asserted once it is gone: the + // tag holds no record at all and the cut is refused anyway. + string[] memory record = LibRainDeploySnapshot.frozenSnapshotPaths(vm, RECUT_EMPTY_FIXTURE_ROOT); + + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(RECUT_EMPTY_FIXTURE_ROOT, true); + + assertEq(record.length, 0); + } } From e88790f2f484a9f2c13a57d104f6697f49ab7880 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 12:08:13 +0000 Subject: [PATCH 2/4] test: assert each frozen contract holds its OWN rolling snapshot 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) --- test/src/lib/LibRainDeploySnapshot.t.sol | 61 ++++++++++++++++-------- 1 file changed, 42 insertions(+), 19 deletions(-) diff --git a/test/src/lib/LibRainDeploySnapshot.t.sol b/test/src/lib/LibRainDeploySnapshot.t.sol index 18e1055..8f66089 100644 --- a/test/src/lib/LibRainDeploySnapshot.t.sol +++ b/test/src/lib/LibRainDeploySnapshot.t.sol @@ -767,6 +767,29 @@ contract LibRainDeploySnapshotTest is Test { ); } + /// One contract's rolling snapshot, carrying the contract's own name. + /// + /// Distinct per contract, so a release of several is asserted to hold each + /// one's OWN bytes rather than merely to hold a file per name. A freeze + /// that copied one contract's snapshot into every file, or that read fewer + /// snapshots than it wrote, produces exactly the right set of paths. + /// @param contractName The contract the snapshot describes. + /// @return That contract's rolling snapshot. + function rollingFor(string memory contractName) internal pure returns (string memory) { + return + string.concat("// SPDX-License", "-Identifier: LicenseRef-DCL-1.0\n", "// snapshot of ", contractName, "\n"); + } + + /// Writes one contract's rolling snapshot into a fixture record. + /// @param root The record root to write under. + /// @param contractName The contract the snapshot describes. + function writeRollingFixture(string memory root, string memory contractName) internal { + string memory path = LibRainDeploySnapshot.pathForSnapshot(root, LibRainDeploySnapshot.CANDIDATE, contractName); + writeFixture(path); + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.writeFile(path, rollingFor(contractName)); + } + /// A regeneration that really regenerates, so "the guard fired FIRST" is no /// longer the only thing a freeze test can observe. function regenerateFreezeFixture() internal { @@ -825,37 +848,41 @@ contract LibRainDeploySnapshotTest is Test { assertEq(record[0], frozenPath); } - /// A release naming SEVERAL contracts MUST freeze every one of them. + /// A release naming SEVERAL contracts MUST freeze every one of them, each + /// from its OWN rolling snapshot. /// /// A contract regenerated but absent from the record is a contract silently /// missing from the release, and a tag that never held it has nothing - /// missing from it for anything downstream to notice. + /// missing from it for anything downstream to notice. A contract present + /// under another one's bytes is worse: the record then says that release + /// deployed something it did not, at an address nothing derives. function testFreezeCutsEveryNamedContract() external { string memory tag = LibRainDeploySnapshot.deployTag(vm); string[] memory contractNames = new string[](2); contractNames[0] = FIXTURE_CONTRACT; contractNames[1] = FIXTURE_CONTRACT_SECOND; for (uint256 i = 0; i < contractNames.length; i++) { - writeFixture( - LibRainDeploySnapshot.pathForSnapshot( - FREEZE_MULTI_FIXTURE_ROOT, LibRainDeploySnapshot.CANDIDATE, contractNames[i] - ) - ); + writeRollingFixture(FREEZE_MULTI_FIXTURE_ROOT, contractNames[i]); } LibRainDeploySnapshot.freeze(vm, FREEZE_MULTI_FIXTURE_ROOT, noRegeneration, contractNames); + // Read while the fixture is still there, asserted once it is gone. string[] memory record = LibRainDeploySnapshot.frozenSnapshotPaths(vm, FREEZE_MULTI_FIXTURE_ROOT); - bool first = - holdsPath(record, LibRainDeploySnapshot.pathForSnapshot(FREEZE_MULTI_FIXTURE_ROOT, tag, contractNames[0])); - bool second = - holdsPath(record, LibRainDeploySnapshot.pathForSnapshot(FREEZE_MULTI_FIXTURE_ROOT, tag, contractNames[1])); + string[] memory frozenPaths = new string[](contractNames.length); + string[] memory frozen = new string[](contractNames.length); + for (uint256 i = 0; i < contractNames.length; i++) { + frozenPaths[i] = LibRainDeploySnapshot.pathForSnapshot(FREEZE_MULTI_FIXTURE_ROOT, tag, contractNames[i]); + frozen[i] = vm.exists(frozenPaths[i]) ? vm.readFile(frozenPaths[i]) : ""; + } //forge-lint: disable-next-line(unsafe-cheatcode) vm.removeDir(FREEZE_MULTI_FIXTURE_ROOT, true); - assertTrue(first); - assertTrue(second); + for (uint256 i = 0; i < contractNames.length; i++) { + assertTrue(holdsPath(record, frozenPaths[i])); + assertEq(frozen[i], rollingFor(contractNames[i])); + } assertEq(record.length, 2); } @@ -876,7 +903,7 @@ contract LibRainDeploySnapshotTest is Test { RECUT_FIXTURE_ROOT, LibRainDeploySnapshot.CANDIDATE, FIXTURE_CONTRACT ); - writeFixture(rollingPath); + writeRollingFixture(RECUT_FIXTURE_ROOT, FIXTURE_CONTRACT); string[] memory contractNames = new string[](1); contractNames[0] = FIXTURE_CONTRACT; @@ -917,11 +944,7 @@ contract LibRainDeploySnapshotTest is Test { vm.createDir(frozenDir, true); // Everything else a cut needs is ready, so the refusal can only be // about the directory. - writeFixture( - LibRainDeploySnapshot.pathForSnapshot( - RECUT_EMPTY_FIXTURE_ROOT, LibRainDeploySnapshot.CANDIDATE, FIXTURE_CONTRACT - ) - ); + writeRollingFixture(RECUT_EMPTY_FIXTURE_ROOT, FIXTURE_CONTRACT); string[] memory contractNames = new string[](1); contractNames[0] = FIXTURE_CONTRACT; From 9f5327e947b6e01b3aac4f97133462adf579c3de Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 12:11:45 +0000 Subject: [PATCH 3/4] test: fixture snapshots are comments, so a red test cannot wedge the 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) --- src/lib/LibRainDeploySnapshot.sol | 15 ++++++--- test/src/lib/LibRainDeploySnapshot.t.sol | 43 +++++++++++++++--------- 2 files changed, 38 insertions(+), 20 deletions(-) diff --git a/src/lib/LibRainDeploySnapshot.sol b/src/lib/LibRainDeploySnapshot.sol index 682d48b..9c3cd36 100644 --- a/src/lib/LibRainDeploySnapshot.sol +++ b/src/lib/LibRainDeploySnapshot.sol @@ -286,10 +286,17 @@ library LibRainDeploySnapshot { /// There is no output root to choose. `LibFs.pathForContract` hardcodes /// `LIB_FS_ROOT` and takes a contract name rather than a path, and this is /// the repo's real deploy record, which belongs under that root and nowhere - /// else. A test that wants a record tree of its own writes one with - /// `vm.writeFile` and reads it with `frozenSnapshotPaths`, which does take a - /// root, because reading somebody else's tree is a thing a walk genuinely - /// does and writing this repo's record somewhere else is not. + /// else. This is the one place a snapshot's bytes come into existence, and + /// they come from the compiler rather than from another tree, so there is + /// nothing for a root to select between. + /// + /// `freeze` does take a root and that is not the same freedom: it COPIES, + /// within one record tree, reading a rolling snapshot under the root it is + /// handed and writing the frozen copy under that same root. Pointing a + /// copier at a tree of its own is a thing a test genuinely needs, exactly + /// as pointing `frozenSnapshotPaths` at one is; GENERATING this repo's + /// record anywhere but under `LIB_FS_ROOT` remains something nothing here + /// can express. /// @param vm The Vm instance for file operations. /// @param dir The snapshot directory name — a release tag, or `CANDIDATE`. /// @param contractName The contract the snapshot describes. diff --git a/test/src/lib/LibRainDeploySnapshot.t.sol b/test/src/lib/LibRainDeploySnapshot.t.sol index 8f66089..469c397 100644 --- a/test/src/lib/LibRainDeploySnapshot.t.sol +++ b/test/src/lib/LibRainDeploySnapshot.t.sol @@ -741,30 +741,42 @@ contract LibRainDeploySnapshotTest is Test { /// for the same reason: it is a frozen tag from the moment it is created. string constant RECUT_EMPTY_FIXTURE_ROOT = "test/generated-recut-empty"; + /// A fixture snapshot's content: a licence header and a marker, and + /// deliberately NOTHING a compiler would look at twice. + /// + /// Two properties, and both are about a run that fails rather than one that + /// passes, because a passing run removes its fixtures and a failing one + /// cannot. + /// + /// The licence header is here for the reason `writeFixture` puts one there: + /// a failure leaves this on disk, and an unlicensed file in the tree is a + /// second failure on top of the first. + /// + /// It is a COMMENT for a sharper reason. `forge test` compiles everything + /// under `test/`, so a fixture a failing test left behind is source the + /// NEXT run has to compile — and content that does not compile turns one + /// red test into a repo that cannot build until somebody deletes a + /// directory by hand. A real snapshot's content is declarations, so writing + /// something that looks like one is the tempting thing to do here and is + /// precisely what wedges the build. + /// @param marker What distinguishes this snapshot from the others. + /// @return The fixture snapshot. + function fixtureSnapshot(string memory marker) internal pure returns (string memory) { + return string.concat("// SPDX-License", "-Identifier: LicenseRef-DCL-1.0\n", "// ", marker, "\n"); + } + /// What the freeze fixture's regeneration writes, and what a freeze that /// ran the regeneration first therefore copies. - /// - /// The SPDX identifier is split for the reason `writeFixture` splits it: a - /// run that fails midway leaves this content on disk, and an unlicensed - /// file in the tree is a second failure on top of the first. /// @return The regenerated rolling snapshot. function freshRolling() internal pure returns (string memory) { - return string.concat( - "// SPDX-License", - "-Identifier: LicenseRef-DCL-1.0\n", - "address constant DEPLOYED_ADDRESS = address(0xFEED000000000000000000000000000000000000);\n" - ); + return fixtureSnapshot("regenerated"); } /// What is on disk before the call, and what a freeze that read the rolling /// snapshot before regenerating it would copy instead. /// @return The stale rolling snapshot. function staleRolling() internal pure returns (string memory) { - return string.concat( - "// SPDX-License", - "-Identifier: LicenseRef-DCL-1.0\n", - "address constant DEPLOYED_ADDRESS = address(0x57A1E00000000000000000000000000000000000);\n" - ); + return fixtureSnapshot("stale"); } /// One contract's rolling snapshot, carrying the contract's own name. @@ -776,8 +788,7 @@ contract LibRainDeploySnapshotTest is Test { /// @param contractName The contract the snapshot describes. /// @return That contract's rolling snapshot. function rollingFor(string memory contractName) internal pure returns (string memory) { - return - string.concat("// SPDX-License", "-Identifier: LicenseRef-DCL-1.0\n", "// snapshot of ", contractName, "\n"); + return fixtureSnapshot(string.concat("snapshot of ", contractName)); } /// Writes one contract's rolling snapshot into a fixture record. From 026ad155f7fa15738d422998e9fccd7e7e699b3c Mon Sep 17 00:00:00 2001 From: David Meister Date: Sun, 16 Aug 2026 15:54:21 +0000 Subject: [PATCH 4/4] Route the new freeze assertion through LibStringSet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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) --- test/src/lib/LibRainDeploySnapshot.t.sol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/src/lib/LibRainDeploySnapshot.t.sol b/test/src/lib/LibRainDeploySnapshot.t.sol index e15d83c..393dabb 100644 --- a/test/src/lib/LibRainDeploySnapshot.t.sol +++ b/test/src/lib/LibRainDeploySnapshot.t.sol @@ -1228,7 +1228,7 @@ contract LibRainDeploySnapshotTest is Test { vm.removeDir(FREEZE_MULTI_FIXTURE_ROOT, true); for (uint256 i = 0; i < contractNames.length; i++) { - assertTrue(holdsPath(record, frozenPaths[i])); + assertTrue(LibStringSet.holds(record, frozenPaths[i])); assertEq(frozen[i], rollingFor(contractNames[i])); } assertEq(record.length, 2);