From 2f4d52537cdbb6c28e882bb2fb19f0ed31da737e Mon Sep 17 00:00:00 2001 From: David Meister Date: Sat, 15 Aug 2026 13:10:59 +0000 Subject: [PATCH 1/4] feat(build): generate the released-suites aggregate instead of writing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `releasedSuites()` was a hand-written concatenation of the per-contract `LibReleased` libs, which made adding a deployed contract touch a fourth place while the `@dev` beside it said three. Three of the four are self-enforcing — a candidate with no snapshot, a snapshot with no candidate and a missing generated file all fail — and the concat is not: a released lib that exists and is concatenated nowhere compiles, is read by nothing, and leaves the suite green until the release that first freezes that contract, at which point the record check fails the release job with the tag already pushed. `writeReleasedSuitesAggregate` emits `src/lib/LibReleasedSuites.sol` from the same `generatedContracts()` list that writes the per-contract libs and drives the freeze, so there is no fourth place to be missing from. `RegistryDeploySuites.releasedSuites()` is now one call to it. It takes `libDir` so the emitter can be tested without overwriting the live file: forge runs suites in parallel and `GeneratedSnapshotShapeTest` reads that file. Coverage is both halves, and neither is worth anything alone. The committed file is what the generator makes of the contracts it names (`testTheCommittedAggregateIsWhatTheGeneratorEmits`), and those are the contracts this repo generates (`GeneratedSnapshotShapeTest.testEverySnapshotIsInTheReleasedAggregate`) — matched against the rolling snapshots rather than a list in the test, because a list in the test is one more place to add a contract to, which is the finding. Closes #83 Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 32 ++- script/Build.sol | 66 ++++-- src/abstract/RegistryDeploySuites.sol | 36 ++-- src/lib/LibRainDeploySnapshot.sol | 195 ++++++++++++++++- src/lib/LibReleasedSuites.sol | 42 ++++ test/lib/LibReleasedSuitesAggregate.sol | 40 ++++ test/src/lib/GeneratedSnapshotShape.t.sol | 56 +++++ test/src/lib/LibRainDeploySnapshot.t.sol | 251 +++++++++++++++++++++- 8 files changed, 660 insertions(+), 58 deletions(-) create mode 100644 src/lib/LibReleasedSuites.sol create mode 100644 test/lib/LibReleasedSuitesAggregate.sol diff --git a/CLAUDE.md b/CLAUDE.md index e04e3be..73d03a6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -204,11 +204,29 @@ per deployed contract — `AddressRegistry.sol` and `MigrationRegistry.sol` — `forge script script/Build.sol`. `script/Build.sol` declares those contracts ONCE, in `generatedContracts()`, and -the regeneration, both lib writers and the freeze all read that list. A contract -added to it is generated, aliased, released and frozen together. That matters -most for the freeze: a contract regenerated but absent from the names `freeze` -is given 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. +the regeneration, all three lib writers and the freeze all read that list. A +contract added to it is generated, aliased, released, declared and frozen +together. That matters most for the freeze: a contract regenerated but absent +from the names `freeze` is given 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. + +It matters for the DECLARATION one step later, and for the same reason. There is +one `src/lib/LibReleased.sol` per deployed contract, because a release +freezes every contract it names into a single tag directory and a lib that took +the record whole would give one contract's snapshot another's suite key. So +something has to concatenate them, and `src/lib/LibReleasedSuites.sol` is that +concatenation — also generated, from that same list, which is why +`RegistryDeploySuites.releasedSuites()` is one call to it rather than a +summation somebody maintains. A hand-written concatenation is a place a +generated contract can be missing from with nothing to say so: its released lib +compiles, is imported by nothing and is read by nothing, and the omission first +shows up as a failed release job the day that contract is frozen. + +So a third deployed contract is a third named candidate in +`RegistryDeploySuites`, a third entry in its `candidateSuites()`, and a third +entry in `generatedContracts()`. The released declaration follows from the third +entry rather than being a fourth place to edit. A compiler or optimiser change is therefore "run the script, commit". Never hand-edit a generated file. @@ -299,7 +317,9 @@ purpose: the suites and the broadcast are both inherited. Run only via the **`src/abstract/RegistryDeploySuites.sol`** — this repo's own declaration, one named candidate per deployed registry, inherited by `script/Deploy.sol`, -`script/Build.sol` and both pins test contracts. +`script/Build.sol` and both pins test contracts. Only the candidates are written +here; `releasedSuites()` returns the generated `LibReleasedSuites` and nothing +about a release is spelled by hand. **`src/abstract/RainDeployVerify*.sol`** — the deploy-pin verification every deploy repo inherits instead of hand-writing. diff --git a/script/Build.sol b/script/Build.sol index 45a1dc3..b5b21af 100644 --- a/script/Build.sol +++ b/script/Build.sol @@ -45,21 +45,29 @@ struct GeneratedContract { /// are the historical record — what each release actually deployed — which is /// what `RegistryDeploySuites.releasedSuites()` enumerates. /// -/// BOTH entry points also regenerate the released-suites libs from the record. -/// `run()` must: they are imported by ordinary source, so a repo before its -/// first release still has to have them, and with nothing frozen they declare -/// an empty set. +/// BOTH entry points also regenerate the released-suites libs from the record, +/// and the aggregate over them. `run()` must: they are imported by ordinary +/// source, so a repo before its first release still has to have them, and with +/// nothing frozen they declare an empty set. /// /// ## One list, three readers /// /// `generatedContracts()` is the whole of what this script declares, and the /// regeneration, the lib writers and the freeze all read it. A contract added -/// to it is generated, aliased, released and frozen; there is no second list to -/// add it to and therefore no way to add it to one and not the other. That -/// matters most for the freeze: a contract regenerated but left out of the -/// names `freeze` is given is a contract silently absent from the release, -/// which nothing downstream can notice, because a tag that never held it has -/// nothing missing from it. +/// to it is generated, aliased, released, declared and frozen; there is no +/// second list to add it to and therefore no way to add it to one and not the +/// other. That matters most for the freeze: a contract regenerated but left out +/// of the names `freeze` is given is a contract silently absent from the +/// release, which nothing downstream can notice, because a tag that never held +/// it has nothing missing from it. +/// +/// It matters for the declaration for the same reason one step later. The +/// released libs are one per contract, so something has to concatenate them, +/// and a hand-written concatenation is a place a generated contract can be +/// missing from with nothing to say so — its lib compiles, is read by nothing, +/// and the omission surfaces as a failed release job the first time that +/// contract is frozen. `writeReleasedSuitesAggregate` emits it from this same +/// list instead, so there is no place to be missing from. /// /// The metadata each released entry carries beyond its frozen snapshot comes /// from the named candidate on the declaration, which is why this inherits the @@ -72,7 +80,7 @@ struct GeneratedContract { /// positional read would silently write another contract's metadata the moment /// the list is reordered. /// -/// The tag, both snapshot paths, the freeze, the snapshot writer and both +/// The tag, both snapshot paths, the freeze, the snapshot writer and all three /// generated-lib writers all come from `LibRainDeploySnapshot`, which in turn /// emits every constant through `LibCodeGen` and writes snapshots through /// `LibFs`. This script is the declaration and the sequencing, nothing else. @@ -91,6 +99,21 @@ contract Build is Script, RegistryDeploySuites { }); } + /// Every generated contract's name, in declaration order. + /// + /// The freeze and the aggregate both need exactly this and nothing else off + /// the declaration, and the ORDER is what the aggregate emits its entries + /// in. Built here rather than at each call site so the two cannot be handed + /// different lists. + /// @return names The contract names. + function generatedContractNames() internal pure returns (string[] memory names) { + GeneratedContract[] memory contracts = generatedContracts(); + names = new string[](contracts.length); + for (uint256 i = 0; i < contracts.length; i++) { + names[i] = contracts[i].contractName; + } + } + /// @notice Every build: regenerate the rolling snapshots, their alias libs /// and the released-suites libs. function run() external { @@ -112,18 +135,20 @@ contract Build is Script, RegistryDeploySuites { /// declares is a release that drops out of every check there is, which is /// exactly what generating the two from one call removes. function cutRelease() external { - GeneratedContract[] memory contracts = generatedContracts(); - string[] memory contractNames = new string[](contracts.length); - for (uint256 i = 0; i < contracts.length; i++) { - contractNames[i] = contracts[i].contractName; - } - LibRainDeploySnapshot.freeze(vm, regenerateCandidates, contractNames); + LibRainDeploySnapshot.freeze(vm, regenerateCandidates, generatedContractNames()); regenerateLibs(); } - /// @notice Rewrite every alias lib and every released-suites lib. Both - /// entry points end here, so there is no entry point that regenerates one - /// and not the other. + /// @notice Rewrite every alias lib, every released-suites lib and the + /// aggregate over them. Both entry points end here, so there is no entry + /// point that regenerates one and not the others. + /// + /// The aggregate is written from the same list the loop above it read, so + /// the declaration of what this repo has released cannot name a different + /// set of contracts from the set that was just generated. That is the whole + /// reason it is emitted at all: it is the one place a contract could be + /// generated, aliased and frozen and still be absent from what + /// `releasedSuites()` returns. function regenerateLibs() internal { GeneratedContract[] memory contracts = generatedContracts(); for (uint256 i = 0; i < contracts.length; i++) { @@ -134,6 +159,7 @@ contract Build is Script, RegistryDeploySuites { vm, LibRainDeploySnapshot.LIB_FS_ROOT, contracts[i].contractName, contracts[i].candidate.snapshot ); } + LibRainDeploySnapshot.writeReleasedSuitesAggregate(vm, LibRainDeploySnapshot.LIB_DIR, generatedContractNames()); } /// @notice Rewrite every `src/generated/candidate/` snapshot from what this diff --git a/src/abstract/RegistryDeploySuites.sol b/src/abstract/RegistryDeploySuites.sol index a5d40ae..4915ca5 100644 --- a/src/abstract/RegistryDeploySuites.sol +++ b/src/abstract/RegistryDeploySuites.sol @@ -14,9 +14,8 @@ import { RUNTIME_CODE as MIGRATION_REGISTRY_RUNTIME_CODE_CANDIDATE } from "../generated/candidate/MigrationRegistry.sol"; import {LibAddressRegistryDeploy} from "../lib/LibAddressRegistryDeploy.sol"; -import {LibAddressRegistryReleased} from "../lib/LibAddressRegistryReleased.sol"; import {LibMigrationRegistryDeploy} from "../lib/LibMigrationRegistryDeploy.sol"; -import {LibMigrationRegistryReleased} from "../lib/LibMigrationRegistryReleased.sol"; +import {LibReleasedSuites} from "../lib/LibReleasedSuites.sol"; /// @title RegistryDeploySuites /// @notice Everything this repo deploys, declared ONCE. @@ -42,8 +41,9 @@ import {LibMigrationRegistryReleased} from "../lib/LibMigrationRegistryReleased. /// entry and nothing per network at all. /// /// Half of it is written by hand and half is generated: the candidates below -/// are the declaration, and the released libs come from `script/Build.sol`, -/// which emits them from the frozen record. +/// are the declaration, and the whole released side — the per-contract libs and +/// the aggregate that concatenates them — comes from `script/Build.sol`, which +/// emits it from the frozen record. Nothing about a release is written here. abstract contract RegistryDeploySuites is RainDeploySuitesBase { /// @inheritdoc RainDeploySuitesBase /// @dev Generated by `script/Build.sol` from the frozen @@ -53,31 +53,29 @@ abstract contract RegistryDeploySuites is RainDeploySuitesBase { /// every check quietly stops asking about, while the whole suite stays /// green. /// - /// One generated lib per deployed contract, concatenated here, because + /// There is one generated lib per deployed contract, because /// `writeReleasedSuitesLib` emits the releases of ONE contract: a release /// freezes every contract it names into a single tag directory, so a lib /// that took the record whole would give another contract's snapshot this /// contract's suite key and collide with its own entry for that tag. + /// `LibReleasedSuites` is the concatenation of them, generated from the + /// same list that wrote them — so this is one call rather than a summation + /// per contract, and adding a contract does not touch this file at all. /// - /// Both are empty until the first release is cut. The rolling `candidate/` - /// snapshots are not releases and do exist. - function releasedSuites() internal pure override returns (DeploySuite[] memory suites) { - DeploySuite[] memory addressRegistry = LibAddressRegistryReleased.releasedSuites(); - DeploySuite[] memory migrationRegistry = LibMigrationRegistryReleased.releasedSuites(); - - suites = new DeploySuite[](addressRegistry.length + migrationRegistry.length); - for (uint256 i = 0; i < addressRegistry.length; i++) { - suites[i] = addressRegistry[i]; - } - for (uint256 i = 0; i < migrationRegistry.length; i++) { - suites[addressRegistry.length + i] = migrationRegistry[i]; - } + /// Empty until the first release is cut. The rolling `candidate/` snapshots + /// are not releases and do exist. + function releasedSuites() internal pure override returns (DeploySuite[] memory) { + return LibReleasedSuites.releasedSuites(); } /// @inheritdoc RainDeploySuitesBase /// @dev One entry per contract this repo deploys. A third deployed contract /// is a third named candidate below, a third entry here, and a third entry - /// in `script/Build.sol`'s generated-contract list — nothing else. + /// in `script/Build.sol`'s generated-contract list — nothing else. The + /// released declaration follows from that third entry rather than being a + /// fourth place to edit: `script/Build.sol` generates the per-contract + /// released lib AND the aggregate `releasedSuites()` reads from that one + /// list. function candidateSuites() internal pure override returns (DeployCandidate[] memory candidates) { candidates = new DeployCandidate[](2); candidates[0] = addressRegistryCandidate(); diff --git a/src/lib/LibRainDeploySnapshot.sol b/src/lib/LibRainDeploySnapshot.sol index c8c684f..2bf38aa 100644 --- a/src/lib/LibRainDeploySnapshot.sol +++ b/src/lib/LibRainDeploySnapshot.sol @@ -193,6 +193,41 @@ library LibRainDeploySnapshot { /// `LibFs.pathForContract` hardcodes it. string constant LIB_FS_ROOT = "src/generated"; + /// Where every generated non-snapshot lib is written — the alias libs, the + /// per-contract released libs and the aggregate over them. + /// + /// ONE constant rather than one per writer, because the aggregate imports + /// the per-contract libs as `./LibReleased.sol` — a sibling path, + /// which is only a sibling path while both writers agree on this directory. + /// Two spellings of it is a generated file that stops compiling the moment + /// one of them moves. + string constant LIB_DIR = "src/lib"; + + /// The generated aggregate's library name, and the file it is written to. + /// + /// Named rather than derived from a contract, because it is the ONE lib per + /// repo that is about no single contract: it is the whole released + /// declaration. Spelled once here because the emitted source, the path and + /// the tests that check the committed file against the emitter all have to + /// mean the same file. + string constant RELEASED_SUITES_LIBRARY = "LibReleasedSuites"; + + /// The path of a generated lib, from the directory it goes in and its + /// library name. + /// @param libDir The directory the generated libs go in. + /// @param libraryName The generated library's name. + /// @return The path. + function pathForLib(string memory libDir, string memory libraryName) internal pure returns (string memory) { + return string.concat(libDir, "/", libraryName, ".sol"); + } + + /// The path of a generated lib in a repo's real `LIB_DIR`. + /// @param libraryName The generated library's name. + /// @return The path. + function pathForLib(string memory libraryName) internal pure returns (string memory) { + return pathForLib(LIB_DIR, libraryName); + } + /// Every file in the FROZEN record: everything inside a release-tag /// directory under `root`. /// @@ -381,7 +416,7 @@ library LibRainDeploySnapshot { returns (string memory) { string memory libraryName = string.concat("Lib", contractName, "Deploy"); - string memory path = string.concat("src/lib/", libraryName, ".sol"); + string memory path = pathForLib(libraryName); //forge-lint: disable-next-line(unsafe-cheatcode) vm.writeFile( @@ -522,6 +557,19 @@ library LibRainDeploySnapshot { return sortedRecordPaths(vm, selected); } + /// The name of the generated lib that declares ONE contract's releases. + /// + /// Spelled once because two emitters have to agree on it: the writer that + /// emits that lib, and the aggregate that imports it. A second spelling is + /// an aggregate importing a file nothing ever wrote — which does not + /// compile, but only for the repo that regenerates, and the point of + /// generating the aggregate is that nobody has to. + /// @param contractName The contract the released record describes. + /// @return The library name, e.g. `LibAddressRegistryReleased`. + function releasedLibraryName(string memory contractName) internal pure returns (string memory) { + return string.concat("Lib", contractName, "Released"); + } + /// The import block of a generated released-suites lib. /// /// One aliased import per record file, carrying all four consensus fields. @@ -694,8 +742,8 @@ library LibRainDeploySnapshot { string memory contractName, DeploySuite memory template ) internal returns (string memory) { - string memory libraryName = string.concat("Lib", contractName, "Released"); - string memory path = string.concat("src/lib/", libraryName, ".sol"); + string memory libraryName = releasedLibraryName(contractName); + string memory path = pathForLib(libraryName); string[] memory paths = recordPathsForContract(vm, recordRoot, contractName); //forge-lint: disable-next-line(unsafe-cheatcode) @@ -711,6 +759,147 @@ library LibRainDeploySnapshot { return path; } + /// The import block of the generated aggregate lib. + /// + /// One import per contract, of the released lib `writeReleasedSuitesLib` + /// emitted for it, by sibling path — both writers emit into `LIB_DIR`, and + /// naming the sibling rather than the directory is what keeps that true of + /// a repo that moves the directory. + /// @param contractNames The contracts whose released libs to aggregate. + /// @return imports The import block. + function aggregateImportBlock(string[] memory contractNames) internal pure returns (string memory imports) { + imports = "import {DeploySuite} from \"../abstract/RainDeploySuitesBase.sol\";\n\n"; + for (uint256 i = 0; i < contractNames.length; i++) { + string memory libraryName = releasedLibraryName(contractNames[i]); + imports = string.concat(imports, "import {", libraryName, "} from \"./", libraryName, ".sol\";\n\n"); + } + } + + /// The library block of the generated aggregate lib: every per-contract + /// released lib read once and copied into one array, in declaration order. + /// + /// It emits the concatenation rather than computing one, because the + /// entries are only known when the emitted source RUNS: a released lib + /// declares its own releases, and how many it has is not a thing this + /// emitter can see or should have to. So the lengths are summed and the + /// offsets are spelled as expressions over them. + /// + /// A repo with no generated contracts emits a lib returning an empty array. + /// That is the state of a deploy repo whose declaration is not written yet, + /// and it MUST compile: the aggregate is imported by ordinary source, so a + /// repo that could not emit one could not build to get to the point of + /// declaring anything. + /// @param vm The Vm instance for string operations. + /// @param contractNames The contracts whose released libs to aggregate. + /// @return The library block. + function aggregateLibraryBlock(Vm vm, string[] memory contractNames) internal pure returns (string memory) { + string memory locals = ""; + string memory copies = ""; + // The running sum of the lengths already emitted: the whole array's + // length once the loop is done, and the OFFSET of the contract the loop + // is on before its own length is added to it. + string memory total = ""; + + for (uint256 i = 0; i < contractNames.length; i++) { + string memory local = string.concat("released", vm.toString(i)); + + locals = string.concat( + locals, + " DeploySuite[] memory ", + local, + " = ", + releasedLibraryName(contractNames[i]), + ".releasedSuites();\n" + ); + + copies = string.concat( + copies, + " for (uint256 i = 0; i < ", + local, + ".length; i++) {\n suites[", + bytes(total).length == 0 ? "" : string.concat(total, " + "), + "i] = ", + local, + "[i];\n }\n" + ); + + total = string.concat(total, bytes(total).length == 0 ? "" : " + ", local, ".length"); + } + + return string.concat( + "/// @title ", + RELEASED_SUITES_LIBRARY, + "\n/// @notice Every frozen release this repo has cut, of every contract it\n", + "/// deploys: the per-contract released libs concatenated, in declaration\n", + "/// order.\n///\n", + "/// There is one released lib per deployed contract because a release freezes\n", + "/// every contract it names into a single tag directory, so a lib that took\n", + "/// the record whole would give another contract's snapshot this contract's\n", + "/// suite key and collide with its own entry for that tag. This is where\n", + "/// those libs meet, and it is emitted from the same list that wrote them --\n", + "/// so a contract that is generated, aliased and frozen cannot be missing\n", + "/// from the declaration, and a release missing from the declaration is a\n", + "/// release every check quietly stops asking about.\nlibrary ", + RELEASED_SUITES_LIBRARY, + " {\n /// Every released suite, in declaration order.\n", + " /// @return suites The released suites.\n", + " function releasedSuites() internal pure returns (DeploySuite[] memory suites) {\n", + contractNames.length == 0 + ? " suites = new DeploySuite[](0);\n" + : string.concat(locals, "\n suites = new DeploySuite[](", total, ");\n\n", copies), + " }\n}\n" + ); + } + + /// Generate the ONE released-suites lib a repo's declaration reads: the + /// per-contract libs concatenated, emitted from the same list that wrote + /// them. + /// + /// The concatenation is the LAST place a contract could be generated, + /// aliased and frozen and still be absent from the declaration. Written by + /// hand it is also the only one of those places nothing enforces: a + /// candidate with no snapshot and a snapshot with no candidate both fail + /// the shape assertions, and a missing generated file fails to compile, + /// while a released lib that exists and is concatenated nowhere compiles + /// cleanly, is read by nothing, and leaves the whole suite green until the + /// release that first freezes that contract — at which point the record + /// check fails the release job with the tag already pushed. + /// + /// So it is emitted rather than written, from the one list everything else + /// reads, and there is no fourth place. + /// + /// Written beside the libs it imports, which is what makes those imports + /// sibling paths. + /// @param vm The Vm instance for file operations. + /// @param libDir The directory the per-contract released libs were written + /// to, which this is written into as well — `LIB_DIR` for a repo's real + /// libs. A parameter for the same reason `writeReleasedSuitesLib` takes a + /// record root: a writer that can only be pointed at the committed + /// declaration can only be tested by overwriting it, and a test that + /// overwrites a file the rest of the suite reads is a test that fails on + /// timing. Sibling imports are what make any directory correct, so the + /// caller's only obligation is to name the one the released libs went to. + /// @param contractNames The contracts whose released libs to aggregate. + /// @return The path written. + function writeReleasedSuitesAggregate(Vm vm, string memory libDir, string[] memory contractNames) + internal + returns (string memory) + { + string memory path = pathForLib(libDir, RELEASED_SUITES_LIBRARY); + + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.writeFile( + path, + string.concat( + LibCodeGen.filePrefix(), + "\n", + aggregateImportBlock(contractNames), + aggregateLibraryBlock(vm, contractNames) + ) + ); + return path; + } + /// Regenerate the rolling snapshot and freeze it as this release's record, /// in that order, in one call. /// diff --git a/src/lib/LibReleasedSuites.sol b/src/lib/LibReleasedSuites.sol new file mode 100644 index 0000000..cc6dc29 --- /dev/null +++ b/src/lib/LibReleasedSuites.sol @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND. + +import {DeploySuite} from "../abstract/RainDeploySuitesBase.sol"; + +import {LibAddressRegistryReleased} from "./LibAddressRegistryReleased.sol"; + +import {LibMigrationRegistryReleased} from "./LibMigrationRegistryReleased.sol"; + +/// @title LibReleasedSuites +/// @notice Every frozen release this repo has cut, of every contract it +/// deploys: the per-contract released libs concatenated, in declaration +/// order. +/// +/// There is one released lib per deployed contract because a release freezes +/// every contract it names into a single tag directory, so a lib that took +/// the record whole would give another contract's snapshot this contract's +/// suite key and collide with its own entry for that tag. This is where +/// those libs meet, and it is emitted from the same list that wrote them -- +/// so a contract that is generated, aliased and frozen cannot be missing +/// from the declaration, and a release missing from the declaration is a +/// release every check quietly stops asking about. +library LibReleasedSuites { + /// Every released suite, in declaration order. + /// @return suites The released suites. + function releasedSuites() internal pure returns (DeploySuite[] memory suites) { + DeploySuite[] memory released0 = LibAddressRegistryReleased.releasedSuites(); + DeploySuite[] memory released1 = LibMigrationRegistryReleased.releasedSuites(); + + suites = new DeploySuite[](released0.length + released1.length); + + for (uint256 i = 0; i < released0.length; i++) { + suites[i] = released0[i]; + } + for (uint256 i = 0; i < released1.length; i++) { + suites[released0.length + i] = released1[i]; + } + } +} diff --git a/test/lib/LibReleasedSuitesAggregate.sol b/test/lib/LibReleasedSuitesAggregate.sol new file mode 100644 index 0000000..309cf8e --- /dev/null +++ b/test/lib/LibReleasedSuitesAggregate.sol @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity ^0.8.25; + +import {Vm} from "forge-std-1.16.1/src/Vm.sol"; + +/// @title LibReleasedSuitesAggregate +/// @notice Reading a generated released-suites aggregate back: which contracts +/// the committed file actually declares. +/// +/// The aggregate is the one generated file whose CONTENT is nothing but a list +/// of contracts — every release it declares comes from the per-contract libs it +/// imports, so the imports are the whole of what it says. Two assertions are +/// about that list and they are about different halves of it: that the file is +/// what the generator makes of the contracts it names, and that those are the +/// contracts this repo generates. Both need the list read off the file rather +/// than restated, because a list restated in a test is another place a contract +/// has to be added to — which is the defect the aggregate exists to remove. +library LibReleasedSuitesAggregate { + /// The contracts an emitted aggregate declares, in the order it imports + /// them. + /// + /// Split on the import statement's own opening rather than parsed, because + /// the text is generated: `aggregateImportBlock` emits exactly + /// `import {LibReleased} from ...` on a line of its own, one per + /// contract, and nothing else in the file opens a line that way. The + /// `DeploySuite` import does not match — it is not a `Lib` — and the + /// library block below the imports never starts a line with `import`. + /// @param vm The Vm instance for string operations. + /// @param source An emitted aggregate. + /// @return names The contract names, in the order imported. + function declaredContractNames(Vm vm, string memory source) internal pure returns (string[] memory names) { + string[] memory chunks = vm.split(source, "\nimport {Lib"); + + names = new string[](chunks.length - 1); + for (uint256 i = 1; i < chunks.length; i++) { + names[i - 1] = vm.split(chunks[i], "Released}")[0]; + } + } +} diff --git a/test/src/lib/GeneratedSnapshotShape.t.sol b/test/src/lib/GeneratedSnapshotShape.t.sol index 0b6ed27..2149636 100644 --- a/test/src/lib/GeneratedSnapshotShape.t.sol +++ b/test/src/lib/GeneratedSnapshotShape.t.sol @@ -6,6 +6,7 @@ import {Test, Vm} from "forge-std-1.16.1/src/Test.sol"; import {DeployCandidate} from "../../../src/abstract/RainDeploySuitesBase.sol"; import {RegistryDeploySuites} from "../../../src/abstract/RegistryDeploySuites.sol"; import {LibRainDeploySnapshot} from "../../../src/lib/LibRainDeploySnapshot.sol"; +import {LibReleasedSuitesAggregate} from "../../lib/LibReleasedSuitesAggregate.sol"; /// @title GeneratedSnapshotShapeTest /// @notice What a generated deploy snapshot must look like, asserted against @@ -181,6 +182,61 @@ contract GeneratedSnapshotShapeTest is RegistryDeploySuites, Test { } } + /// PROPERTY: the released aggregate declares exactly the contracts that + /// have a rolling snapshot. + /// + /// `releasedSuites()` reads `src/lib/LibReleasedSuites.sol` and nothing + /// else, so a contract missing from it has no declared release at all: its + /// own `LibReleased` compiles, is imported by nothing, is read by + /// nothing, and the whole suite stays green for exactly as long as that + /// contract has never been released. The release is when it lands — + /// `cutRelease()` freezes every contract in the generated list, and + /// `testEveryFrozenSnapshotIsReleased` then reverts on the frozen file that + /// no released suite declares, during the release job, with the tag pushed + /// and the deploy already broadcast. + /// + /// Generating the aggregate is what stops that being written. This is what + /// stops it being LEFT — a hand-edited or stale aggregate is the same + /// defect back — and it fails at the moment of the mistake instead of at + /// the release. + /// + /// Matched against the rolling snapshots rather than against a list here, + /// because a list here would be one more place to add a contract to and + /// that is the whole finding. `testEveryCandidateHasASnapshot` above pins + /// that same set to the declaration, so the two together run the chain from + /// the declared candidates through the generator to what `releasedSuites()` + /// returns. + /// + /// Both directions and the count, for the reason the snapshot match gives: + /// an aggregate that imports one contract twice and another not at all has + /// the right number of imports, and a copied import line is how it happens. + function testEverySnapshotIsInTheReleasedAggregate() external view { + string[] memory snapshots = snapshotContractNames(); + string[] memory declared = LibReleasedSuitesAggregate.declaredContractNames( + vm, vm.readFile(LibRainDeploySnapshot.pathForLib(LibRainDeploySnapshot.RELEASED_SUITES_LIBRARY)) + ); + + assertEq( + declared.length, + snapshots.length, + "a rolling snapshot is missing from the released aggregate, or the aggregate names a contract with none" + ); + + for (uint256 i = 0; i < snapshots.length; i++) { + assertTrue( + holdsName(declared, snapshots[i]), + string.concat("rolling snapshot is not in the released aggregate: ", snapshots[i]) + ); + } + + for (uint256 i = 0; i < declared.length; i++) { + assertTrue( + holdsName(snapshots, declared[i]), + string.concat("released aggregate names a contract with no rolling snapshot: ", declared[i]) + ); + } + } + /// PROPERTY: every snapshot declares exactly the four constants a deploy /// record is for, of these types, in this order. A rename, a retype, a /// reorder or a fifth constant each fail here and name what broke. diff --git a/test/src/lib/LibRainDeploySnapshot.t.sol b/test/src/lib/LibRainDeploySnapshot.t.sol index b917ea4..c795f05 100644 --- a/test/src/lib/LibRainDeploySnapshot.t.sol +++ b/test/src/lib/LibRainDeploySnapshot.t.sol @@ -12,6 +12,7 @@ import { UnreleasableVersion } from "../../../src/lib/LibRainDeploySnapshot.sol"; import {MockDeployable} from "../../concrete/MockDeployable.sol"; +import {LibReleasedSuitesAggregate} from "../../lib/LibReleasedSuitesAggregate.sol"; /// @title LibRainDeploySnapshotTest /// @notice The guards on the release machinery every deploy repo inherits. @@ -250,6 +251,23 @@ contract LibRainDeploySnapshotTest is Test { /// it too. string constant FIXTURE_CONTRACT_SECOND = "MockDeployableV2"; + /// The header every generated file carries: `LibCodeGen.filePrefix`, + /// followed by the blank line every writer puts after it. + /// + /// Split so `reuse lint` reads this as an expectation rather than as this + /// file's own license declaration -- an SPDX identifier in a string literal + /// is indistinguishable from a real one to a line scanner. + /// @return The prefix. + function generatedFilePrefix() internal pure returns (string memory) { + return string.concat( + "// SPDX-License", + "-Identifier: LicenseRef-DCL-1.0\n", + "// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd\n", + "pragma solidity ^0.8.25;\n\n", + "// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND.\n\n" + ); + } + /// The contract every emitter test emits a released lib for. string constant EMITTED_CONTRACT = "AddressRegistry"; @@ -578,11 +596,7 @@ contract LibRainDeploySnapshotTest is Test { string memory emitted = vm.readFile(path); // Built while the fixture record is still there: both emitters read it. string memory expected = string.concat( - "// SPDX-License", - "-Identifier: LicenseRef-DCL-1.0\n", - "// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd\n", - "pragma solidity ^0.8.25;\n\n", - "// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND.\n\n", + generatedFilePrefix(), LibRainDeploySnapshot.releasedImportBlock(vm, paths), LibRainDeploySnapshot.releasedLibraryBlock(vm, libraryName, FIXTURE_CONTRACT, paths, emitterTemplate()) ); @@ -621,11 +635,7 @@ contract LibRainDeploySnapshotTest is Test { string[] memory paths = LibRainDeploySnapshot.recordPathsForContract(vm, LibRainDeploySnapshot.LIB_FS_ROOT, EMITTED_CONTRACT); string memory expected = string.concat( - "// SPDX-License", - "-Identifier: LicenseRef-DCL-1.0\n", - "// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd\n", - "pragma solidity ^0.8.25;\n\n", - "// THIS FILE IS AUTOGENERATED BY THE BUILD SCRIPT. DO NOT EDIT BY HAND.\n\n", + generatedFilePrefix(), LibRainDeploySnapshot.releasedImportBlock(vm, paths), LibRainDeploySnapshot.releasedLibraryBlock(vm, EMITTED_LIBRARY, EMITTED_CONTRACT, paths, emitterTemplate()) ); @@ -637,6 +647,227 @@ contract LibRainDeploySnapshotTest is Test { assertEq(emitted, expected); } + /// The contracts the aggregate emitter tests are handed, as a repo's + /// generated-contract list hands them over: `count` of them, in order. + /// + /// This repo's own two and then a third, because a third deployed contract + /// is the scenario the aggregate exists for — the emitted text is asserted + /// literally below, and a reader has to be able to see what adding one + /// does to it. Reverts above three rather than quietly emitting a shorter + /// list, which is the failure a test helper is worst at reporting. + /// @param count How many contracts. + /// @return names The contract names. + function aggregateNames(uint256 count) internal pure returns (string[] memory names) { + string[] memory all = new string[](3); + all[0] = "AddressRegistry"; + all[1] = "MigrationRegistry"; + all[2] = "ThirdRegistry"; + + names = new string[](count); + for (uint256 i = 0; i < count; i++) { + names[i] = all[i]; + } + } + + /// The import every aggregate carries whatever it aggregates. + string constant AGGREGATE_SUITE_IMPORT = "import {DeploySuite} from \"../abstract/RainDeploySuitesBase.sol\";\n\n"; + + /// The aggregate's text from its title down to the line that opens + /// `releasedSuites`. The same whatever it aggregates, so the assertions + /// below are about the body. + string constant EXPECTED_AGGREGATE_HEADER = "/// @title LibReleasedSuites\n" + "/// @notice Every frozen release this repo has cut, of every contract it\n" + "/// deploys: the per-contract released libs concatenated, in declaration\n" "/// order.\n" "///\n" + "/// There is one released lib per deployed contract because a release freezes\n" + "/// every contract it names into a single tag directory, so a lib that took\n" + "/// the record whole would give another contract's snapshot this contract's\n" + "/// suite key and collide with its own entry for that tag. This is where\n" + "/// those libs meet, and it is emitted from the same list that wrote them --\n" + "/// so a contract that is generated, aliased and frozen cannot be missing\n" + "/// from the declaration, and a release missing from the declaration is a\n" + "/// release every check quietly stops asking about.\n" "library LibReleasedSuites {\n" + " /// Every released suite, in declaration order.\n" " /// @return suites The released suites.\n" + " function releasedSuites() internal pure returns (DeploySuite[] memory suites) {\n"; + + /// The aggregate MUST import the released lib of EVERY contract it is + /// handed, by the sibling path the released writer wrote it to, and nothing + /// else. + /// + /// An aggregate that imports one released lib fewer than the list it was + /// emitted from is the hand-written concatenation this replaces: it + /// compiles, it is read by nothing, and the contract left out of it has no + /// declared release at all. + function testAggregateImportBlockImportsEveryReleasedLib() external pure { + assertEq(LibRainDeploySnapshot.aggregateImportBlock(aggregateNames(0)), AGGREGATE_SUITE_IMPORT); + + assertEq( + LibRainDeploySnapshot.aggregateImportBlock(aggregateNames(1)), + string.concat( + AGGREGATE_SUITE_IMPORT, + "import {LibAddressRegistryReleased} from \"./LibAddressRegistryReleased.sol\";\n\n" + ) + ); + + assertEq( + LibRainDeploySnapshot.aggregateImportBlock(aggregateNames(3)), + string.concat( + AGGREGATE_SUITE_IMPORT, + "import {LibAddressRegistryReleased} from \"./LibAddressRegistryReleased.sol\";\n\n", + "import {LibMigrationRegistryReleased} from \"./LibMigrationRegistryReleased.sol\";\n\n", + "import {LibThirdRegistryReleased} from \"./LibThirdRegistryReleased.sol\";\n\n" + ) + ); + } + + /// A repo with no generated contracts MUST still emit an aggregate, and it + /// MUST return an empty array. + /// + /// That is the state of a deploy repo whose declaration is not written yet, + /// and the aggregate is imported by ordinary source — so a repo that could + /// not emit one could not build to the point of declaring anything. It is + /// also the only shape with no locals to sum, which is why it is a case + /// rather than a corner. + function testAggregateLibraryBlockDeclaresNothingForNoContracts() external pure { + assertEq( + LibRainDeploySnapshot.aggregateLibraryBlock(vm, aggregateNames(0)), + string.concat(EXPECTED_AGGREGATE_HEADER, " suites = new DeploySuite[](0);\n", " }\n}\n") + ); + } + + /// The aggregate MUST read every released lib once and copy all of it, at + /// the offset the libs before it end at, in declaration order. + /// + /// The offsets are the whole of what the emitted code does, and they are + /// spelled here literally rather than computed, because a test that + /// derived them the way the emitter does would agree with it about a wrong + /// answer. An offset that repeated would overwrite one contract's releases + /// with another's; one that skipped would leave a zeroed entry the chain + /// group then asserts is deployed. + function testAggregateLibraryBlockConcatenatesEveryReleasedLib() external pure { + assertEq( + LibRainDeploySnapshot.aggregateLibraryBlock(vm, aggregateNames(1)), + string.concat( + EXPECTED_AGGREGATE_HEADER, + " DeploySuite[] memory released0 = LibAddressRegistryReleased.releasedSuites();\n", + "\n suites = new DeploySuite[](released0.length);\n\n", + " for (uint256 i = 0; i < released0.length; i++) {\n", + " suites[i] = released0[i];\n", + " }\n", + " }\n}\n" + ) + ); + + assertEq( + LibRainDeploySnapshot.aggregateLibraryBlock(vm, aggregateNames(2)), + string.concat( + EXPECTED_AGGREGATE_HEADER, + " DeploySuite[] memory released0 = LibAddressRegistryReleased.releasedSuites();\n", + " DeploySuite[] memory released1 = LibMigrationRegistryReleased.releasedSuites();\n", + "\n suites = new DeploySuite[](released0.length + released1.length);\n\n", + " for (uint256 i = 0; i < released0.length; i++) {\n", + " suites[i] = released0[i];\n", + " }\n", + " for (uint256 i = 0; i < released1.length; i++) {\n", + " suites[released0.length + i] = released1[i];\n", + " }\n", + " }\n}\n" + ) + ); + + assertEq( + LibRainDeploySnapshot.aggregateLibraryBlock(vm, aggregateNames(3)), + string.concat( + EXPECTED_AGGREGATE_HEADER, + " DeploySuite[] memory released0 = LibAddressRegistryReleased.releasedSuites();\n", + " DeploySuite[] memory released1 = LibMigrationRegistryReleased.releasedSuites();\n", + " DeploySuite[] memory released2 = LibThirdRegistryReleased.releasedSuites();\n", + "\n suites = new DeploySuite[](released0.length + released1.length + released2.length);\n\n", + " for (uint256 i = 0; i < released0.length; i++) {\n", + " suites[i] = released0[i];\n", + " }\n", + " for (uint256 i = 0; i < released1.length; i++) {\n", + " suites[released0.length + i] = released1[i];\n", + " }\n", + " for (uint256 i = 0; i < released2.length; i++) {\n", + " suites[released0.length + released1.length + i] = released2[i];\n", + " }\n", + " }\n}\n" + ) + ); + } + + /// Where the aggregate writer is pointed. Its own directory rather than the + /// real `LIB_DIR`: the committed aggregate is read by the assertions below + /// and by `GeneratedSnapshotShapeTest`, which forge runs in parallel with + /// this contract, and a writer aimed at that file would be rewriting it + /// underneath both of them. + string constant AGGREGATE_FIXTURE_DIR = "test/generated-aggregate"; + + /// The aggregate MUST land at `/LibReleasedSuites.sol`, holding + /// exactly the prefix, imports and library the emitters produce for the + /// list it was handed. + /// + /// Handed a list that is NOT this repo's, so a writer that emitted the + /// declaration it happened to find rather than the list it was given says + /// so. Removed BEFORE the assertions run, because forge-std assertions + /// revert: removing afterwards removes in every case except a failure, + /// which is the only case where the tree is dirty and the one this test + /// exists to report. + function testWriteReleasedSuitesAggregateWritesTheLibAtItsPath() external { + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.createDir(AGGREGATE_FIXTURE_DIR, true); + string[] memory names = aggregateNames(3); + + string memory written = LibRainDeploySnapshot.writeReleasedSuitesAggregate(vm, AGGREGATE_FIXTURE_DIR, names); + string memory emitted = vm.readFile(written); + + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(AGGREGATE_FIXTURE_DIR, true); + + assertEq(written, string.concat(AGGREGATE_FIXTURE_DIR, "/LibReleasedSuites.sol")); + assertEq( + emitted, + string.concat( + generatedFilePrefix(), + LibRainDeploySnapshot.aggregateImportBlock(names), + LibRainDeploySnapshot.aggregateLibraryBlock(vm, names) + ) + ); + } + + /// This repo's COMMITTED aggregate MUST be what the generator emits for the + /// contracts it names. + /// + /// That is what makes a hand edit to it, and a generator nobody re-ran, a + /// test failure rather than a silent one — the same thing + /// `testWriteReleasedSuitesLibWritesTheLibAtItsPath` does for one released + /// lib, and needed here for the same reason: the aggregate is the only file + /// the whole released declaration is read through. + /// + /// The contract list is the committed file's own, so this says the file is + /// what the generator makes of the contracts it names. That those ARE the + /// contracts this repo generates is + /// `GeneratedSnapshotShapeTest.testEverySnapshotIsInTheReleasedAggregate`. + /// Neither half is worth anything alone: a file consistent with a list that + /// left a contract out is exactly the defect being removed. + /// + /// Writes nothing, so it cannot collide with the writer above. + function testTheCommittedAggregateIsWhatTheGeneratorEmits() external view { + string memory path = LibRainDeploySnapshot.pathForLib(LibRainDeploySnapshot.RELEASED_SUITES_LIBRARY); + string memory committed = vm.readFile(path); + string[] memory names = LibReleasedSuitesAggregate.declaredContractNames(vm, committed); + + assertEq(path, "src/lib/LibReleasedSuites.sol"); + assertEq( + committed, + string.concat( + generatedFilePrefix(), + LibRainDeploySnapshot.aggregateImportBlock(names), + LibRainDeploySnapshot.aggregateLibraryBlock(vm, names) + ) + ); + } + /// A freeze that names no contracts MUST be refused. It would write /// nothing, report success, and leave `/` there — and an empty /// `/` is a frozen tag, so the real cut of that release could never From fbd96105e6924fea25aab32ab10ddc8d4abde65d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 12:05:19 +0000 Subject: [PATCH 2/4] Fix the aggregate fixture race, pin declaration order, correct the libDir doc Two tests in this contract shared one fixture directory and forge runs the tests in a contract concurrently, so they raced: `--mt testWriteReleasedSuitesAggregate` failed 20 of 20 runs. One directory each, and out of `test/` because a copy of the emitted `LibReleasedSuites.sol` left behind by a failure under a compiled root fails the whole build. Nothing pinned the committed aggregate's ORDER to `generatedContracts()`: the committed-file check takes the list from the file itself and the snapshot check is a set match, so the imports could be swapped and the suite stayed green. `testTheCommittedAggregateIsInDeclarationOrder` pins it. `writeReleasedSuitesAggregate`'s `libDir` doc said sibling imports make any directory correct. The emitted file also imports `../abstract/RainDeploySuitesBase.sol`, and both per-contract writers write to `pathForLib(libraryName)`, which is always `LIB_DIR`. `releasedSuites()`'s `@dev` said adding a contract does not touch that FILE, twelve lines above a `@dev` in the same file saying it does. Co-Authored-By: Claude Opus 5 (1M context) --- foundry.toml | 7 ++++ src/abstract/RegistryDeploySuites.sol | 2 +- src/lib/LibRainDeploySnapshot.sol | 31 ++++++++++------ test/script/Build.t.sol | 43 +++++++++++++++++++++- test/src/lib/LibRainDeploySnapshot.t.sol | 47 +++++++++++++++++------- 5 files changed, 102 insertions(+), 28 deletions(-) diff --git a/foundry.toml b/foundry.toml index 7a9c3ef..2022d3f 100644 --- a/foundry.toml +++ b/foundry.toml @@ -43,6 +43,13 @@ fs_permissions = [ # inherited record check reads that root from contracts forge runs in # parallel, so a fixture release there would be one they have to fail on. { access = "read-write", path = "./test" }, + # LibRainDeploySnapshotTest points the aggregate writer here, one directory + # per test. NOT ./src or ./test: the writer names the file it emits + # LibReleasedSuites.sol and that file imports ./LibReleased.sol and + # ../abstract/RainDeploySuitesBase.sol, which resolve from src/lib and nowhere + # else, so a copy left behind by a failure under a compiled root fails the + # whole build until it is deleted by hand. Nothing compiles this root. + { access = "read-write", path = "./fixture-lib" }, # GeneratedSnapshotShapeTest reads the compiler's AST out of the artifact. { access = "read", path = "./out" }, ] diff --git a/src/abstract/RegistryDeploySuites.sol b/src/abstract/RegistryDeploySuites.sol index 134a610..81a929f 100644 --- a/src/abstract/RegistryDeploySuites.sol +++ b/src/abstract/RegistryDeploySuites.sol @@ -66,7 +66,7 @@ abstract contract RegistryDeploySuites is RainDeploySuitesBase { /// contract's suite key and collide with its own entry for that tag. /// `LibReleasedSuites` is the concatenation of them, generated from the /// same list that wrote them — so this is one call rather than a summation - /// per contract, and adding a contract does not touch this file at all. + /// per contract, and adding a contract does not touch this function at all. /// /// Empty until the first release is cut. The rolling `candidate/` snapshots /// are not releases and do exist. diff --git a/src/lib/LibRainDeploySnapshot.sol b/src/lib/LibRainDeploySnapshot.sol index fc646dc..fdce876 100644 --- a/src/lib/LibRainDeploySnapshot.sol +++ b/src/lib/LibRainDeploySnapshot.sol @@ -1019,9 +1019,11 @@ library LibRainDeploySnapshot { /// The import block of the generated aggregate lib. /// /// One import per contract, of the released lib `writeReleasedSuitesLib` - /// emitted for it, by sibling path — both writers emit into `LIB_DIR`, and - /// naming the sibling rather than the directory is what keeps that true of - /// a repo that moves the directory. + /// emitted for it, by sibling path — both writers emit into `LIB_DIR`, so + /// the released libs are siblings of the aggregate wherever `LIB_DIR` is. + /// The `DeploySuite` import above them is parent-relative, so the emitted + /// file also compiles only from a directory whose parent holds + /// `abstract/RainDeploySuitesBase.sol`. /// @param contractNames The contracts whose released libs to aggregate. /// @return imports The import block. function aggregateImportBlock(string[] memory contractNames) internal pure returns (string memory imports) { @@ -1130,12 +1132,18 @@ library LibRainDeploySnapshot { /// @param vm The Vm instance for file operations. /// @param libDir The directory the per-contract released libs were written /// to, which this is written into as well — `LIB_DIR` for a repo's real - /// libs. A parameter for the same reason `writeReleasedSuitesLib` takes a - /// record root: a writer that can only be pointed at the committed - /// declaration can only be tested by overwriting it, and a test that - /// overwrites a file the rest of the suite reads is a test that fails on - /// timing. Sibling imports are what make any directory correct, so the - /// caller's only obligation is to name the one the released libs went to. + /// libs, and the only directory the emitted file COMPILES in. + /// `writeReleasedSuitesLib` and `writeAliasLib` write to + /// `pathForLib(libraryName)`, which takes no directory and is always + /// `LIB_DIR`, so `LIB_DIR` is the only place the emitted sibling + /// `./LibReleased.sol` imports resolve; the emitted + /// `../abstract/RainDeploySuitesBase.sol` import is parent-relative and + /// constrains the directory further still. A parameter anyway, for the same + /// reason `writeReleasedSuitesLib` takes a record root: a writer that can + /// only be pointed at the committed declaration can only be tested by + /// overwriting it, and a test that overwrites a file the rest of the suite + /// reads is a test that fails on timing. Any other directory holds a file + /// no build can compile, so it has to be a directory nothing compiles. /// @param spdxLicenseIdentifier The SPDX licence identifier the written lib /// declares. The calling repo's, for the reason `writeSnapshot` gives. /// @param copyrightText The copyright text the written lib declares. @@ -1167,9 +1175,8 @@ library LibRainDeploySnapshot { /// `RAIN_SPDX_LICENSE_IDENTIFIER` and `RAIN_COPYRIGHT_TEXT`, for a repo /// this org owns. /// @param vm The Vm instance for file operations. - /// @param libDir The directory the per-contract released libs were written - /// to, which this is written into as well — `LIB_DIR` for a repo's real - /// libs. + /// @param libDir The directory to write into, under the constraint the + /// arity above states — `LIB_DIR` for a repo's real libs. /// @param contractNames The contracts whose released libs to aggregate. /// @return The path written. function writeReleasedSuitesAggregate(Vm vm, string memory libDir, string[] memory contractNames) diff --git a/test/script/Build.t.sol b/test/script/Build.t.sol index b0a2072..f4b3915 100644 --- a/test/script/Build.t.sol +++ b/test/script/Build.t.sol @@ -5,6 +5,8 @@ pragma solidity =0.8.25; import {Test} from "forge-std-1.16.2/src/Test.sol"; import {GeneratedContract} from "../../script/Build.sol"; import {DeployCandidate} from "../../src/abstract/RainDeploySuitesBase.sol"; +import {LibRainDeploySnapshot} from "../../src/lib/LibRainDeploySnapshot.sol"; +import {LibReleasedSuitesAggregate} from "../lib/LibReleasedSuitesAggregate.sol"; import {BuildHarness} from "../concrete/BuildHarness.sol"; /// @title BuildTest @@ -36,7 +38,7 @@ import {BuildHarness} from "../concrete/BuildHarness.sol"; /// Deliberately nothing here calls `run()` or `cutRelease()`. Both write /// `src/lib/Lib*Released.sol`, which `LibRainDeploySnapshotTest` also writes, /// and forge runs test contracts in parallel — two contracts writing one file -/// is a race, not a check. Everything below is pure. +/// is a race, not a check. Nothing below writes anything. contract BuildTest is Test { /// The harness the two declarations are read through. BuildHarness internal sBuild; @@ -143,4 +145,43 @@ contract BuildTest is Test { } } } + + /// PROPERTY: the committed aggregate imports the generated contracts in + /// `generatedContracts()`'s ORDER, not merely as a set. + /// + /// Declaration order is claimed twice — the emitted library documents its + /// entries as being "in declaration order" and `generatedContractNames()` + /// documents itself as giving "the order the aggregate emits its entries + /// in" — and nothing else pins it. + /// `testTheCommittedAggregateIsWhatTheGeneratorEmits` takes the contract + /// list from the committed file itself, so it holds for any permutation of + /// it, and `testEverySnapshotIsInTheReleasedAggregate` matches sets. So + /// the two imports and the two `released` locals can be + /// swapped in the committed file — byte-exactly what the generator emits + /// for the reversed list — and the whole suite stays green while the + /// aggregate returns the releases in an order the declaration does not + /// give, which is the order `suiteNames()` reports them in. + /// + /// Positional, because the SET is already matched by the two tests this + /// names and the order is the whole of what is left. + function testTheCommittedAggregateIsInDeclarationOrder() external view { + GeneratedContract[] memory generated = sBuild.externalGeneratedContracts(); + string[] memory imported = LibReleasedSuitesAggregate.declaredContractNames( + vm, vm.readFile(LibRainDeploySnapshot.pathForLib(LibRainDeploySnapshot.RELEASED_SUITES_LIBRARY)) + ); + + assertEq( + imported.length, + generated.length, + "the committed aggregate imports a different number of contracts than the generator names" + ); + + for (uint256 i = 0; i < generated.length; i++) { + assertEq( + imported[i], + generated[i].contractName, + "the committed aggregate is not in the generator's declaration order" + ); + } + } } diff --git a/test/src/lib/LibRainDeploySnapshot.t.sol b/test/src/lib/LibRainDeploySnapshot.t.sol index 5e8421b..a1e51ae 100644 --- a/test/src/lib/LibRainDeploySnapshot.t.sol +++ b/test/src/lib/LibRainDeploySnapshot.t.sol @@ -1319,12 +1319,30 @@ contract LibRainDeploySnapshotTest is Test { ); } - /// Where the aggregate writer is pointed. Its own directory rather than the - /// real `LIB_DIR`: the committed aggregate is read by the assertions below - /// and by `GeneratedSnapshotShapeTest`, which forge runs in parallel with - /// this contract, and a writer aimed at that file would be rewriting it + /// Where `testWriteReleasedSuitesAggregateWritesTheLibAtItsPath` points the + /// aggregate writer. Its own directory rather than the real `LIB_DIR`: the + /// committed aggregate is read by the assertions below and by + /// `GeneratedSnapshotShapeTest`, which forge runs in parallel with this + /// contract, and a writer aimed at that file would be rewriting it /// underneath both of them. - string constant AGGREGATE_FIXTURE_DIR = "test/generated-aggregate"; + /// + /// Not under `src/` or `test/`, which is everything `fs_permissions` + /// otherwise grants, because the writer names the file it emits + /// `LibReleasedSuites.sol` and that file imports + /// `./LibReleased.sol` and + /// `../abstract/RainDeploySuitesBase.sol` -- paths that resolve from + /// `src/lib` and nowhere else. Both roots are compiled, so a copy left + /// behind by a failure there fails the whole build, on every test in the + /// repo, until it is deleted by hand. `foundry.toml` grants `fixture-lib` + /// for these two. + string constant AGGREGATE_PATH_FIXTURE_DIR = "fixture-lib/aggregate-path"; + + /// Where `testWriteReleasedSuitesAggregateDefaultsToTheOrgHeader` points + /// the writer, for the reason `RELEASED_FIXTURE_ROOT` is not + /// `FIXTURE_ROOT`: forge runs the tests in a contract concurrently, and two + /// of them creating, writing, reading and removing one directory see each + /// other's files and each other's removals. + string constant AGGREGATE_DEFAULTS_FIXTURE_DIR = "fixture-lib/aggregate-defaults"; /// The aggregate MUST land at `/LibReleasedSuites.sol`, holding /// exactly the prefix, imports and library the emitters produce for the @@ -1338,16 +1356,17 @@ contract LibRainDeploySnapshotTest is Test { /// exists to report. function testWriteReleasedSuitesAggregateWritesTheLibAtItsPath() external { //forge-lint: disable-next-line(unsafe-cheatcode) - vm.createDir(AGGREGATE_FIXTURE_DIR, true); + vm.createDir(AGGREGATE_PATH_FIXTURE_DIR, true); string[] memory names = aggregateNames(3); - string memory written = LibRainDeploySnapshot.writeReleasedSuitesAggregate(vm, AGGREGATE_FIXTURE_DIR, names); + string memory written = + LibRainDeploySnapshot.writeReleasedSuitesAggregate(vm, AGGREGATE_PATH_FIXTURE_DIR, names); string memory emitted = vm.readFile(written); //forge-lint: disable-next-line(unsafe-cheatcode) - vm.removeDir(AGGREGATE_FIXTURE_DIR, true); + vm.removeDir(AGGREGATE_PATH_FIXTURE_DIR, true); - assertEq(written, string.concat(AGGREGATE_FIXTURE_DIR, "/LibReleasedSuites.sol")); + assertEq(written, string.concat(AGGREGATE_PATH_FIXTURE_DIR, "/LibReleasedSuites.sol")); assertEq( emitted, string.concat( @@ -1982,22 +2001,22 @@ contract LibRainDeploySnapshotTest is Test { /// As `testWriteSnapshotDefaultsToTheOrgHeader`, for the aggregate. The /// defaulting arity keeps `libDir`, and is pointed at - /// `AGGREGATE_FIXTURE_DIR` for the reason that constant gives. + /// `AGGREGATE_DEFAULTS_FIXTURE_DIR` for the reason that constant gives. function testWriteReleasedSuitesAggregateDefaultsToTheOrgHeader() external { //forge-lint: disable-next-line(unsafe-cheatcode) - vm.createDir(AGGREGATE_FIXTURE_DIR, true); + vm.createDir(AGGREGATE_DEFAULTS_FIXTURE_DIR, true); string[] memory names = aggregateNames(3); string memory defaulted = - vm.readFile(LibRainDeploySnapshot.writeReleasedSuitesAggregate(vm, AGGREGATE_FIXTURE_DIR, names)); + vm.readFile(LibRainDeploySnapshot.writeReleasedSuitesAggregate(vm, AGGREGATE_DEFAULTS_FIXTURE_DIR, names)); string memory explicitly = vm.readFile( LibRainDeploySnapshot.writeReleasedSuitesAggregate( - vm, AGGREGATE_FIXTURE_DIR, RAIN_SPDX_LICENSE_IDENTIFIER, RAIN_COPYRIGHT_TEXT, names + vm, AGGREGATE_DEFAULTS_FIXTURE_DIR, RAIN_SPDX_LICENSE_IDENTIFIER, RAIN_COPYRIGHT_TEXT, names ) ); //forge-lint: disable-next-line(unsafe-cheatcode) - vm.removeDir(AGGREGATE_FIXTURE_DIR, true); + vm.removeDir(AGGREGATE_DEFAULTS_FIXTURE_DIR, true); assertEq(defaulted, explicitly); } From cf014a28cc50204564ea15c7966bae8e17c0da4b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 12:14:44 +0000 Subject: [PATCH 3/4] Exclude the fixture-lib root from the published package Every other transient root is already excluded, and `/test` covered the fixture directories while they lived there. `fixture-lib` is a new top-level root and a failure deliberately leaves a file in it, so a publish from a tree that has just failed a test would package it. Co-Authored-By: Claude Opus 5 (1M context) --- .soldeerignore | 1 + test/script/Build.t.sol | 12 ++++++------ test/src/lib/LibRainDeploySnapshot.t.sol | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.soldeerignore b/.soldeerignore index ab433ec..c434ced 100644 --- a/.soldeerignore +++ b/.soldeerignore @@ -21,4 +21,5 @@ CLAUDE.md /remappings.txt /slither.config.json /soldeer.lock +/fixture-lib /test diff --git a/test/script/Build.t.sol b/test/script/Build.t.sol index f4b3915..63de863 100644 --- a/test/script/Build.t.sol +++ b/test/script/Build.t.sol @@ -155,12 +155,12 @@ contract BuildTest is Test { /// in" — and nothing else pins it. /// `testTheCommittedAggregateIsWhatTheGeneratorEmits` takes the contract /// list from the committed file itself, so it holds for any permutation of - /// it, and `testEverySnapshotIsInTheReleasedAggregate` matches sets. So - /// the two imports and the two `released` locals can be - /// swapped in the committed file — byte-exactly what the generator emits - /// for the reversed list — and the whole suite stays green while the - /// aggregate returns the releases in an order the declaration does not - /// give, which is the order `suiteNames()` reports them in. + /// it, and `testEverySnapshotIsInTheReleasedAggregate` matches sets. So the + /// two imports and the two `released` locals can be swapped in the + /// committed file — byte-exactly what the generator emits for the reversed + /// list — and the whole suite stays green while the aggregate returns the + /// releases in an order the declaration does not give, which is the order + /// `suiteNames()` reports them in. /// /// Positional, because the SET is already matched by the two tests this /// names and the order is the whole of what is left. diff --git a/test/src/lib/LibRainDeploySnapshot.t.sol b/test/src/lib/LibRainDeploySnapshot.t.sol index a1e51ae..8455b55 100644 --- a/test/src/lib/LibRainDeploySnapshot.t.sol +++ b/test/src/lib/LibRainDeploySnapshot.t.sol @@ -1330,7 +1330,7 @@ contract LibRainDeploySnapshotTest is Test { /// otherwise grants, because the writer names the file it emits /// `LibReleasedSuites.sol` and that file imports /// `./LibReleased.sol` and - /// `../abstract/RainDeploySuitesBase.sol` -- paths that resolve from + /// `../abstract/RainDeploySuitesBase.sol` — paths that resolve from /// `src/lib` and nowhere else. Both roots are compiled, so a copy left /// behind by a failure there fails the whole build, on every test in the /// repo, until it is deleted by hand. `foundry.toml` grants `fixture-lib` From fec84ce42eee92489cfc11427725ec4a2652b19e Mon Sep 17 00:00:00 2001 From: David Meister Date: Tue, 18 Aug 2026 12:32:07 +0000 Subject: [PATCH 4/4] Emit the aggregate with lines that do not grow with the contract count `aggregateLibraryBlock` spelled the array length as a sum over every released lib, and each copy loop's offset as a sum over the libs before it, so both lines grew by 19 characters per contract: 110 columns at four contracts, 129 at five. `forge fmt` wraps at 120, `rainix-sol-static` runs `forge fmt --check`, and the committed file is compared byte-for-byte against emitter output by `testTheCommittedAggregateIsWhatTheGeneratorEmits` -- so from the fifth contract on neither check could pass, and the file says DO NOT EDIT BY HAND. The emitted code now reads the released libs into one `DeploySuite[][]`, sums their lengths in a loop over it, and copies each one in at a running offset. Every line it emits is the same width whatever the contract count is, and the only per-contract line is one read. It also declares a constant number of locals rather than one array per contract, which is the stack legacy codegen was going to run out of. Co-Authored-By: Claude Opus 5 (1M context) --- src/lib/LibRainDeploySnapshot.sol | 65 ++++++++++++---------- src/lib/LibReleasedSuites.sol | 23 +++++--- test/src/lib/LibRainDeploySnapshot.t.sol | 69 ++++++++++++------------ 3 files changed, 85 insertions(+), 72 deletions(-) diff --git a/src/lib/LibRainDeploySnapshot.sol b/src/lib/LibRainDeploySnapshot.sol index fdce876..6f3c607 100644 --- a/src/lib/LibRainDeploySnapshot.sol +++ b/src/lib/LibRainDeploySnapshot.sol @@ -1040,8 +1040,16 @@ library LibRainDeploySnapshot { /// It emits the concatenation rather than computing one, because the /// entries are only known when the emitted source RUNS: a released lib /// declares its own releases, and how many it has is not a thing this - /// emitter can see or should have to. So the lengths are summed and the - /// offsets are spelled as expressions over them. + /// emitter can see or should have to. So the emitted code reads every + /// released lib into one array of arrays, sums their lengths at runtime, + /// and copies each one in at the offset the libs before it end at. + /// + /// That sum and that copy are loops over the array of arrays rather than + /// expressions naming every contract, so no emitted line is wider for a + /// repo with more contracts in it. The emitted file is committed, and both + /// `forge fmt --check` and a byte-for-byte comparison against what this + /// emits read it: a line wide enough for the formatter to wrap is a file + /// that cannot satisfy them both. /// /// A repo with no generated contracts emits a lib returning an empty array. /// That is the state of a deploy repo whose declaration is not written yet, @@ -1052,37 +1060,19 @@ library LibRainDeploySnapshot { /// @param contractNames The contracts whose released libs to aggregate. /// @return The library block. function aggregateLibraryBlock(Vm vm, string[] memory contractNames) internal pure returns (string memory) { - string memory locals = ""; - string memory copies = ""; - // The running sum of the lengths already emitted: the whole array's - // length once the loop is done, and the OFFSET of the contract the loop - // is on before its own length is added to it. - string memory total = ""; + // One line per contract, reading its released lib into its own slot of + // the array of arrays the sum and the copy walk. + string memory reads = ""; for (uint256 i = 0; i < contractNames.length; i++) { - string memory local = string.concat("released", vm.toString(i)); - - locals = string.concat( - locals, - " DeploySuite[] memory ", - local, - " = ", + reads = string.concat( + reads, + " released[", + vm.toString(i), + "] = ", releasedLibraryName(contractNames[i]), ".releasedSuites();\n" ); - - copies = string.concat( - copies, - " for (uint256 i = 0; i < ", - local, - ".length; i++) {\n suites[", - bytes(total).length == 0 ? "" : string.concat(total, " + "), - "i] = ", - local, - "[i];\n }\n" - ); - - total = string.concat(total, bytes(total).length == 0 ? "" : " + ", local, ".length"); } return string.concat( @@ -1105,7 +1095,24 @@ library LibRainDeploySnapshot { " function releasedSuites() internal pure returns (DeploySuite[] memory suites) {\n", contractNames.length == 0 ? " suites = new DeploySuite[](0);\n" - : string.concat(locals, "\n suites = new DeploySuite[](", total, ");\n\n", copies), + : string.concat( + " DeploySuite[][] memory released = new DeploySuite[][](", + vm.toString(contractNames.length), + ");\n", + reads, + "\n uint256 total = 0;\n", + " for (uint256 i = 0; i < released.length; i++) {\n", + " total += released[i].length;\n", + " }\n\n", + " suites = new DeploySuite[](total);\n\n", + " uint256 offset = 0;\n", + " for (uint256 i = 0; i < released.length; i++) {\n", + " for (uint256 j = 0; j < released[i].length; j++) {\n", + " suites[offset + j] = released[i][j];\n", + " }\n", + " offset += released[i].length;\n", + " }\n" + ), " }\n}\n" ); } diff --git a/src/lib/LibReleasedSuites.sol b/src/lib/LibReleasedSuites.sol index cc6dc29..dc665ed 100644 --- a/src/lib/LibReleasedSuites.sol +++ b/src/lib/LibReleasedSuites.sol @@ -27,16 +27,23 @@ library LibReleasedSuites { /// Every released suite, in declaration order. /// @return suites The released suites. function releasedSuites() internal pure returns (DeploySuite[] memory suites) { - DeploySuite[] memory released0 = LibAddressRegistryReleased.releasedSuites(); - DeploySuite[] memory released1 = LibMigrationRegistryReleased.releasedSuites(); + DeploySuite[][] memory released = new DeploySuite[][](2); + released[0] = LibAddressRegistryReleased.releasedSuites(); + released[1] = LibMigrationRegistryReleased.releasedSuites(); - suites = new DeploySuite[](released0.length + released1.length); - - for (uint256 i = 0; i < released0.length; i++) { - suites[i] = released0[i]; + uint256 total = 0; + for (uint256 i = 0; i < released.length; i++) { + total += released[i].length; } - for (uint256 i = 0; i < released1.length; i++) { - suites[released0.length + i] = released1[i]; + + suites = new DeploySuite[](total); + + uint256 offset = 0; + for (uint256 i = 0; i < released.length; i++) { + for (uint256 j = 0; j < released[i].length; j++) { + suites[offset + j] = released[i][j]; + } + offset += released[i].length; } } } diff --git a/test/src/lib/LibRainDeploySnapshot.t.sol b/test/src/lib/LibRainDeploySnapshot.t.sol index 8455b55..df45bcb 100644 --- a/test/src/lib/LibRainDeploySnapshot.t.sol +++ b/test/src/lib/LibRainDeploySnapshot.t.sol @@ -1212,6 +1212,19 @@ contract LibRainDeploySnapshotTest is Test { " /// Every released suite, in declaration order.\n" " /// @return suites The released suites.\n" " function releasedSuites() internal pure returns (DeploySuite[] memory suites) {\n"; + /// The aggregate's text from below the released libs it reads to the end of + /// `releasedSuites`: the sum of their lengths, the array that sum + /// allocates, and the copy of each lib into it at the offset the ones + /// before it end at. The same whatever it aggregates, which is what keeps + /// every line the same width however many contracts there are. + string constant EXPECTED_AGGREGATE_CONCATENATION = "\n uint256 total = 0;\n" + " for (uint256 i = 0; i < released.length; i++) {\n" " total += released[i].length;\n" + " }\n\n" " suites = new DeploySuite[](total);\n\n" " uint256 offset = 0;\n" + " for (uint256 i = 0; i < released.length; i++) {\n" + " for (uint256 j = 0; j < released[i].length; j++) {\n" + " suites[offset + j] = released[i][j];\n" " }\n" + " offset += released[i].length;\n" " }\n"; + /// The aggregate MUST import the released lib of EVERY contract it is /// handed, by the sibling path the released writer wrote it to, and nothing /// else. @@ -1257,25 +1270,24 @@ contract LibRainDeploySnapshotTest is Test { ); } - /// The aggregate MUST read every released lib once and copy all of it, at - /// the offset the libs before it end at, in declaration order. + /// The aggregate MUST read every released lib once, each into its own slot + /// of the array it walks, and copy all of it at the offset the libs before + /// it end at, in declaration order. /// - /// The offsets are the whole of what the emitted code does, and they are - /// spelled here literally rather than computed, because a test that - /// derived them the way the emitter does would agree with it about a wrong - /// answer. An offset that repeated would overwrite one contract's releases - /// with another's; one that skipped would leave a zeroed entry the chain - /// group then asserts is deployed. + /// The slots are the whole of what varies with the list, and the sum and + /// copy that read them are spelled here literally rather than computed, + /// because a test that derived them the way the emitter does would agree + /// with it about a wrong answer. A slot that repeated would overwrite one + /// contract's releases with another's; an offset that skipped would leave a + /// zeroed entry the chain group then asserts is deployed. function testAggregateLibraryBlockConcatenatesEveryReleasedLib() external pure { assertEq( LibRainDeploySnapshot.aggregateLibraryBlock(vm, aggregateNames(1)), string.concat( EXPECTED_AGGREGATE_HEADER, - " DeploySuite[] memory released0 = LibAddressRegistryReleased.releasedSuites();\n", - "\n suites = new DeploySuite[](released0.length);\n\n", - " for (uint256 i = 0; i < released0.length; i++) {\n", - " suites[i] = released0[i];\n", - " }\n", + " DeploySuite[][] memory released = new DeploySuite[][](1);\n", + " released[0] = LibAddressRegistryReleased.releasedSuites();\n", + EXPECTED_AGGREGATE_CONCATENATION, " }\n}\n" ) ); @@ -1284,15 +1296,10 @@ contract LibRainDeploySnapshotTest is Test { LibRainDeploySnapshot.aggregateLibraryBlock(vm, aggregateNames(2)), string.concat( EXPECTED_AGGREGATE_HEADER, - " DeploySuite[] memory released0 = LibAddressRegistryReleased.releasedSuites();\n", - " DeploySuite[] memory released1 = LibMigrationRegistryReleased.releasedSuites();\n", - "\n suites = new DeploySuite[](released0.length + released1.length);\n\n", - " for (uint256 i = 0; i < released0.length; i++) {\n", - " suites[i] = released0[i];\n", - " }\n", - " for (uint256 i = 0; i < released1.length; i++) {\n", - " suites[released0.length + i] = released1[i];\n", - " }\n", + " DeploySuite[][] memory released = new DeploySuite[][](2);\n", + " released[0] = LibAddressRegistryReleased.releasedSuites();\n", + " released[1] = LibMigrationRegistryReleased.releasedSuites();\n", + EXPECTED_AGGREGATE_CONCATENATION, " }\n}\n" ) ); @@ -1301,19 +1308,11 @@ contract LibRainDeploySnapshotTest is Test { LibRainDeploySnapshot.aggregateLibraryBlock(vm, aggregateNames(3)), string.concat( EXPECTED_AGGREGATE_HEADER, - " DeploySuite[] memory released0 = LibAddressRegistryReleased.releasedSuites();\n", - " DeploySuite[] memory released1 = LibMigrationRegistryReleased.releasedSuites();\n", - " DeploySuite[] memory released2 = LibThirdRegistryReleased.releasedSuites();\n", - "\n suites = new DeploySuite[](released0.length + released1.length + released2.length);\n\n", - " for (uint256 i = 0; i < released0.length; i++) {\n", - " suites[i] = released0[i];\n", - " }\n", - " for (uint256 i = 0; i < released1.length; i++) {\n", - " suites[released0.length + i] = released1[i];\n", - " }\n", - " for (uint256 i = 0; i < released2.length; i++) {\n", - " suites[released0.length + released1.length + i] = released2[i];\n", - " }\n", + " DeploySuite[][] memory released = new DeploySuite[][](3);\n", + " released[0] = LibAddressRegistryReleased.releasedSuites();\n", + " released[1] = LibMigrationRegistryReleased.releasedSuites();\n", + " released[2] = LibThirdRegistryReleased.releasedSuites();\n", + EXPECTED_AGGREGATE_CONCATENATION, " }\n}\n" ) );