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/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/script/Build.sol b/script/Build.sol index 25997f0..2181640 100644 --- a/script/Build.sol +++ b/script/Build.sol @@ -26,7 +26,8 @@ struct GeneratedContract { /// deploys. /// /// - `run()` rewrites the rolling snapshots under `src/generated/candidate/`, -/// the alias libs pointing at them, and the released-suites libs. +/// the alias libs pointing at them, the released-suites libs and the +/// aggregate over them. /// - `cutRelease()` does the same, freezing the rolling snapshots as /// `src/generated//` in between. /// @@ -35,8 +36,8 @@ struct GeneratedContract { /// The frozen `/` directories are what /// `RegistryDeploySuites.releasedSuites()` enumerates. /// -/// `generatedContracts()` is the only list, read by the regeneration, both lib -/// writers and the freeze. +/// `generatedContracts()` is the only list, read by the regeneration, all three +/// lib writers and the freeze. contract Build is Script, RegistryDeploySuites { /// Every contract this repo generates deploy pins for. /// @return contracts The generated contracts. @@ -52,8 +53,19 @@ contract Build is Script, RegistryDeploySuites { }); } - /// @notice Regenerate the rolling snapshots, their alias libs and the - /// released-suites libs. + /// Every generated contract's name, in declaration order — the order the + /// aggregate emits its entries in. Read by the freeze and the aggregate. + /// @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 Regenerate the rolling snapshots, their alias libs, the + /// released-suites libs and the aggregate over them. function run() external { regenerateCandidates(); regenerateLibs(); @@ -63,16 +75,14 @@ contract Build is Script, RegistryDeploySuites { /// `src/generated//`, then rewrite the libs from the record, so the /// release being cut is in them. 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, LibRainDeploySnapshot.LIB_FS_ROOT, regenerateCandidates, contractNames); + LibRainDeploySnapshot.freeze( + vm, LibRainDeploySnapshot.LIB_FS_ROOT, regenerateCandidates, generatedContractNames() + ); regenerateLibs(); } - /// @notice Rewrite every alias lib and every released-suites lib. + /// @notice Rewrite every alias lib, every released-suites lib and the + /// aggregate over them. function regenerateLibs() internal { GeneratedContract[] memory contracts = generatedContracts(); for (uint256 i = 0; i < contracts.length; i++) { @@ -83,6 +93,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 85f0e46..81a929f 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. @@ -48,8 +47,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 @@ -59,31 +59,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 function 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 f3b4749..6f3c607 100644 --- a/src/lib/LibRainDeploySnapshot.sol +++ b/src/lib/LibRainDeploySnapshot.sol @@ -275,6 +275,41 @@ library LibRainDeploySnapshot { return LibFs.pathForTaggedContract(dir, contractName); } + /// 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`. /// @@ -558,7 +593,7 @@ library LibRainDeploySnapshot { string memory copyrightText ) internal 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( @@ -744,6 +779,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 aliased import one record file contributes: all four consensus /// fields and the frozen dependency list, under that release's prefix. /// @@ -931,8 +979,8 @@ library LibRainDeploySnapshot { string memory copyrightText, 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) @@ -968,6 +1016,185 @@ 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`, 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) { + 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 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, + /// 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) { + // 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++) { + reads = string.concat( + reads, + " released[", + vm.toString(i), + "] = ", + releasedLibraryName(contractNames[i]), + ".releasedSuites();\n" + ); + } + + 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( + " 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" + ); + } + + /// 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, 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. + /// @param contractNames The contracts whose released libs to aggregate. + /// @return The path written. + function writeReleasedSuitesAggregate( + Vm vm, + string memory libDir, + string memory spdxLicenseIdentifier, + string memory copyrightText, + 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(spdxLicenseIdentifier, copyrightText), + "\n", + aggregateImportBlock(contractNames), + aggregateLibraryBlock(vm, contractNames) + ) + ); + return path; + } + + /// `writeReleasedSuitesAggregate` applied to + /// `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 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) + internal + returns (string memory) + { + return writeReleasedSuitesAggregate( + vm, libDir, RAIN_SPDX_LICENSE_IDENTIFIER, RAIN_COPYRIGHT_TEXT, contractNames + ); + } + /// The newest release in a record: the greatest tag any of its files sits /// under, compared as a version. /// diff --git a/src/lib/LibReleasedSuites.sol b/src/lib/LibReleasedSuites.sol new file mode 100644 index 0000000..dc665ed --- /dev/null +++ b/src/lib/LibReleasedSuites.sol @@ -0,0 +1,49 @@ +// 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 released = new DeploySuite[][](2); + released[0] = LibAddressRegistryReleased.releasedSuites(); + released[1] = LibMigrationRegistryReleased.releasedSuites(); + + uint256 total = 0; + for (uint256 i = 0; i < released.length; i++) { + total += released[i].length; + } + + 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/lib/LibReleasedSuitesAggregate.sol b/test/lib/LibReleasedSuitesAggregate.sol new file mode 100644 index 0000000..25aab09 --- /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.2/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/script/Build.t.sol b/test/script/Build.t.sol index b0a2072..63de863 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/GeneratedSnapshotShape.t.sol b/test/src/lib/GeneratedSnapshotShape.t.sol index ebf0b85..d179db1 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.2/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"; import {LibStringSet} from "../../lib/LibStringSet.sol"; /// @title GeneratedSnapshotShapeTest @@ -236,6 +237,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( + LibStringSet.holds(declared, snapshots[i]), + string.concat("rolling snapshot is not in the released aggregate: ", snapshots[i]) + ); + } + + for (uint256 i = 0; i < declared.length; i++) { + assertTrue( + LibStringSet.holds(snapshots, declared[i]), + string.concat("released aggregate names a contract with no rolling snapshot: ", declared[i]) + ); + } + } + /// PROPERTY: every snapshot declares exactly the five constants a deploy /// record is for, of these types, in this order. A rename, a retype, a /// reorder or a sixth 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 99ec771..df45bcb 100644 --- a/test/src/lib/LibRainDeploySnapshot.t.sol +++ b/test/src/lib/LibRainDeploySnapshot.t.sol @@ -15,6 +15,7 @@ import { UnreleasableVersion } from "../../../src/lib/LibRainDeploySnapshot.sol"; import {MockDeployable} from "../../concrete/MockDeployable.sol"; +import {LibReleasedSuitesAggregate} from "../../lib/LibReleasedSuitesAggregate.sol"; import {LibStringSet} from "../../lib/LibStringSet.sol"; /// @title LibRainDeploySnapshotTest @@ -580,6 +581,27 @@ contract LibRainDeploySnapshotTest is Test { /// it too. string constant FIXTURE_CONTRACT_SECOND = "MockDeployableV2"; + /// The header every generated file carries under the org's licence and + /// copyright, 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: ", + RAIN_SPDX_LICENSE_IDENTIFIER, + "\n", + "// SPDX-FileCopyrightText: ", + RAIN_COPYRIGHT_TEXT, + "\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"; @@ -1097,15 +1119,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: ", - RAIN_SPDX_LICENSE_IDENTIFIER, - "\n", - "// SPDX-FileCopyrightText: ", - RAIN_COPYRIGHT_TEXT, - "\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()) ); @@ -1144,15 +1158,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: ", - RAIN_SPDX_LICENSE_IDENTIFIER, - "\n", - "// SPDX-FileCopyrightText: ", - RAIN_COPYRIGHT_TEXT, - "\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()) ); @@ -1164,6 +1170,245 @@ 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'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. + /// + /// 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, 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 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 released = new DeploySuite[][](1);\n", + " released[0] = LibAddressRegistryReleased.releasedSuites();\n", + EXPECTED_AGGREGATE_CONCATENATION, + " }\n}\n" + ) + ); + + assertEq( + LibRainDeploySnapshot.aggregateLibraryBlock(vm, aggregateNames(2)), + string.concat( + EXPECTED_AGGREGATE_HEADER, + " DeploySuite[][] memory released = new DeploySuite[][](2);\n", + " released[0] = LibAddressRegistryReleased.releasedSuites();\n", + " released[1] = LibMigrationRegistryReleased.releasedSuites();\n", + EXPECTED_AGGREGATE_CONCATENATION, + " }\n}\n" + ) + ); + + assertEq( + LibRainDeploySnapshot.aggregateLibraryBlock(vm, aggregateNames(3)), + string.concat( + EXPECTED_AGGREGATE_HEADER, + " 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" + ) + ); + } + + /// 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. + /// + /// 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 + /// 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_PATH_FIXTURE_DIR, true); + string[] memory names = aggregateNames(3); + + 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_PATH_FIXTURE_DIR, true); + + assertEq(written, string.concat(AGGREGATE_PATH_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 @@ -1752,4 +1997,26 @@ contract LibRainDeploySnapshotTest is Test { assertEq(defaulted, explicitly); } + + /// As `testWriteSnapshotDefaultsToTheOrgHeader`, for the aggregate. The + /// defaulting arity keeps `libDir`, and is pointed at + /// `AGGREGATE_DEFAULTS_FIXTURE_DIR` for the reason that constant gives. + function testWriteReleasedSuitesAggregateDefaultsToTheOrgHeader() external { + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.createDir(AGGREGATE_DEFAULTS_FIXTURE_DIR, true); + string[] memory names = aggregateNames(3); + + string memory defaulted = + vm.readFile(LibRainDeploySnapshot.writeReleasedSuitesAggregate(vm, AGGREGATE_DEFAULTS_FIXTURE_DIR, names)); + string memory explicitly = vm.readFile( + LibRainDeploySnapshot.writeReleasedSuitesAggregate( + vm, AGGREGATE_DEFAULTS_FIXTURE_DIR, RAIN_SPDX_LICENSE_IDENTIFIER, RAIN_COPYRIGHT_TEXT, names + ) + ); + + //forge-lint: disable-next-line(unsafe-cheatcode) + vm.removeDir(AGGREGATE_DEFAULTS_FIXTURE_DIR, true); + + assertEq(defaulted, explicitly); + } }