From 3e09bcf21aa161373a2d05a1706080069b628698 Mon Sep 17 00:00:00 2001 From: David Meister Date: Sun, 16 Aug 2026 18:55:51 +0000 Subject: [PATCH 1/5] Hold .soldeerignore against the repo root instead of reading it as documentation `.vscode` and `/docs` matched nothing and never have; `.audit/` was at the root and unexcluded, so its run stamps shipped in the package. Closes #91 Co-Authored-By: Claude Opus 5 (1M context) --- .soldeerignore | 3 +- foundry.toml | 3 + test/package/SoldeerIgnore.t.sol | 174 +++++++++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 test/package/SoldeerIgnore.t.sol diff --git a/.soldeerignore b/.soldeerignore index 1db26d5..f8197fa 100644 --- a/.soldeerignore +++ b/.soldeerignore @@ -5,11 +5,10 @@ .gitignore .pre-commit-config.yaml .soldeerignore -.vscode +/.audit /audit /cache /dependencies -/docs /flake.lock /flake.nix /foundry.toml diff --git a/foundry.toml b/foundry.toml index f2958e5..3f9ceca 100644 --- a/foundry.toml +++ b/foundry.toml @@ -32,6 +32,9 @@ evm_version = "cancun" ffi = true fs_permissions = [ + # `.soldeerignore` is a claim about what is at the repo root, so the test that + # holds it against the root has to be able to list the root. + { access = "read", path = "./" }, { access = "read", path = "foundry.toml" }, { access = "read-write", path = "src/generated" }, { access = "read-write", path = "meta" }, diff --git a/test/package/SoldeerIgnore.t.sol b/test/package/SoldeerIgnore.t.sol new file mode 100644 index 0000000..aa67369 --- /dev/null +++ b/test/package/SoldeerIgnore.t.sol @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: LicenseRef-DCL-1.0 +// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd +pragma solidity =0.8.25; + +import {Test} from "forge-std-1.16.1/src/Test.sol"; +import {VmSafe} from "forge-std-1.16.1/src/Vm.sol"; + +/// @title SoldeerIgnoreTest +/// @notice `.soldeerignore` is the entire definition of what the published +/// package contains: soldeer zips the working tree at publish time and drops +/// what this file names. So the list is a claim about the repo root, and it is +/// held against the repo root rather than read as documentation of it. +/// +/// Three sets meet at the root, and the two containments below are what make +/// the file honest in both directions: +/// +/// - every root entry is either excluded here or named in `shipped()`, so a new +/// top level path cannot start shipping without someone saying it should; +/// - every entry here either names a root entry or names something `.gitignore` +/// keeps out of the repo, so an entry that excludes nothing cannot sit in the +/// list looking like it was derived from the package contents. +/// +/// The `.gitignore` half of that is what admits the defensive entries. A path +/// git is told never to hold is a path a working tree can still hold when the +/// package is built, so excluding it is live even though it is absent from a +/// fresh checkout. +contract SoldeerIgnoreTest is Test { + /// The top level paths the package is for. Everything else at the root is + /// tooling, and `.soldeerignore` has to name it. + function shipped() internal pure returns (string[] memory) { + string[] memory paths = new string[](6); + paths[0] = "LICENSE"; + paths[1] = "LICENSES"; + paths[2] = "README.md"; + paths[3] = "REUSE.toml"; + paths[4] = "script"; + paths[5] = "src"; + return paths; + } + + /// `.DS_Store` is the one exclusion that answers to neither the repo nor + /// `.gitignore`: nothing in this repo produces it and git is not told about + /// it, but macOS writes it into any directory it browses, including one + /// about to be published. + function ephemeral() internal view returns (string[] memory) { + string[] memory gitignored = entriesOf(".gitignore"); + string[] memory paths = new string[](gitignored.length + 1); + for (uint256 i = 0; i < gitignored.length; i++) { + paths[i] = gitignored[i]; + } + paths[gitignored.length] = ".DS_Store"; + return paths; + } + + /// The paths named by an ignore file, one per line, with the leading `/` + /// that anchors an entry to the root stripped so that anchored and + /// unanchored spellings of the same root path compare equal. Blank lines + /// and `#` comments name nothing and are dropped. + function entriesOf(string memory path) internal view returns (string[] memory) { + string[] memory lines = vm.split(vm.readFile(path), "\n"); + string[] memory paths = new string[](lines.length); + uint256 count = 0; + for (uint256 i = 0; i < lines.length; i++) { + string memory line = vm.trim(lines[i]); + bytes memory data = bytes(line); + if (data.length == 0 || data[0] == "#") { + continue; + } + paths[count++] = data[0] == "/" ? drop(data, 1) : line; + } + assembly ("memory-safe") { + mstore(paths, count) + } + return paths; + } + + /// The names directly under the repo root. `readDir` resolves what it is + /// asked to walk, so it reports each entry as an absolute path and the name + /// is the last segment of it. + function rootEntries() internal view returns (string[] memory) { + VmSafe.DirEntry[] memory dir = vm.readDir("./"); + string[] memory paths = new string[](dir.length); + for (uint256 i = 0; i < dir.length; i++) { + bytes memory data = bytes(dir[i].path); + uint256 start = 0; + for (uint256 j = 0; j < data.length; j++) { + if (data[j] == "/") { + start = j + 1; + } + } + paths[i] = drop(data, start); + } + return paths; + } + + /// `data` without its first `prefixLength` bytes. + function drop(bytes memory data, uint256 prefixLength) internal pure returns (string memory) { + bytes memory out = new bytes(data.length - prefixLength); + for (uint256 i = 0; i < out.length; i++) { + out[i] = data[i + prefixLength]; + } + return string(out); + } + + /// Whether `paths` holds `path`. + function has(string[] memory paths, string memory path) internal pure returns (bool) { + bytes32 wanted = keccak256(bytes(path)); + for (uint256 i = 0; i < paths.length; i++) { + if (keccak256(bytes(paths[i])) == wanted) { + return true; + } + } + return false; + } + + /// Every exclusion excludes something. An entry that matches neither a path + /// in the repo nor a path git is told to keep out of it is inherited from + /// somewhere else and makes the list read as derived from the package when + /// it was copied. + function testEveryIgnoreEntryExcludesSomething() external view { + string[] memory root = rootEntries(); + string[] memory defensive = ephemeral(); + string[] memory ignored = entriesOf(".soldeerignore"); + for (uint256 i = 0; i < ignored.length; i++) { + assertTrue( + has(root, ignored[i]) || has(defensive, ignored[i]), + string.concat( + "`.soldeerignore` excludes `", + ignored[i], + "`, which is neither in the repo nor in `.gitignore`, so it excludes nothing" + ) + ); + } + } + + /// Nothing reaches the package by omission. A root path that is not shipped + /// on purpose has to be excluded on purpose. + function testEveryRootEntryIsShippedOrIgnored() external view { + string[] memory ignored = entriesOf(".soldeerignore"); + string[] memory published = shipped(); + string[] memory root = rootEntries(); + for (uint256 i = 0; i < root.length; i++) { + assertTrue( + has(ignored, root[i]) || has(published, root[i]), + string.concat( + "`", root[i], "` is at the repo root and `.soldeerignore` does not exclude it, so it ships" + ) + ); + } + } + + /// The shipped set is a statement about this repo, so each of its paths is + /// one this repo has. + function testEveryShippedPathIsInTheRepo() external view { + string[] memory root = rootEntries(); + string[] memory published = shipped(); + for (uint256 i = 0; i < published.length; i++) { + assertTrue(has(root, published[i]), string.concat("`", published[i], "` is not at the repo root")); + } + } + + /// The package is these paths, so excluding one empties the package of the + /// thing it is for. + function testNoShippedPathIsIgnored() external view { + string[] memory ignored = entriesOf(".soldeerignore"); + string[] memory published = shipped(); + for (uint256 i = 0; i < published.length; i++) { + assertFalse( + has(ignored, published[i]), + string.concat("`.soldeerignore` excludes `", published[i], "`, which the package is for") + ); + } + } +} From 2bd581dcbb3e1c089bfd60778e73944aa1f0aa10 Mon Sep 17 00:00:00 2001 From: David Meister Date: Sun, 16 Aug 2026 18:58:27 +0000 Subject: [PATCH 2/5] Exclude the meta directory the suite creates at the repo root Running the suite leaves an empty `meta/` at the root, which nothing excluded, so it would ship in any package built after a test run. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 1 + .soldeerignore | 1 + 2 files changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 0f9f079..d1f4c0e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ cache dependencies +meta out .pre-commit-config.yaml diff --git a/.soldeerignore b/.soldeerignore index f8197fa..c7de5b8 100644 --- a/.soldeerignore +++ b/.soldeerignore @@ -12,6 +12,7 @@ /flake.lock /flake.nix /foundry.toml +/meta /out /remappings.txt /slither.config.json From a3f90fe3f3166f0bae00916e280fbf17033f8541 Mon Sep 17 00:00:00 2001 From: David Meister Date: Sun, 16 Aug 2026 19:20:55 +0000 Subject: [PATCH 3/5] Move the .soldeerignore root check to rainix#317 Delete `test/package/SoldeerIgnore.t.sol` and revert the repo-root `{ access = "read", path = "./" }` fs_permissions grant, returning `fs_permissions` to its base three entries. The `.soldeerignore` and `.gitignore` changes stay: `.audit/` stops shipping and `meta/` can no longer leak. Co-Authored-By: Claude Opus 5 (1M context) --- foundry.toml | 3 - test/package/SoldeerIgnore.t.sol | 174 ------------------------------- 2 files changed, 177 deletions(-) delete mode 100644 test/package/SoldeerIgnore.t.sol diff --git a/foundry.toml b/foundry.toml index 3f9ceca..f2958e5 100644 --- a/foundry.toml +++ b/foundry.toml @@ -32,9 +32,6 @@ evm_version = "cancun" ffi = true fs_permissions = [ - # `.soldeerignore` is a claim about what is at the repo root, so the test that - # holds it against the root has to be able to list the root. - { access = "read", path = "./" }, { access = "read", path = "foundry.toml" }, { access = "read-write", path = "src/generated" }, { access = "read-write", path = "meta" }, diff --git a/test/package/SoldeerIgnore.t.sol b/test/package/SoldeerIgnore.t.sol deleted file mode 100644 index aa67369..0000000 --- a/test/package/SoldeerIgnore.t.sol +++ /dev/null @@ -1,174 +0,0 @@ -// SPDX-License-Identifier: LicenseRef-DCL-1.0 -// SPDX-FileCopyrightText: Copyright (c) 2020 Rain Open Source Software Ltd -pragma solidity =0.8.25; - -import {Test} from "forge-std-1.16.1/src/Test.sol"; -import {VmSafe} from "forge-std-1.16.1/src/Vm.sol"; - -/// @title SoldeerIgnoreTest -/// @notice `.soldeerignore` is the entire definition of what the published -/// package contains: soldeer zips the working tree at publish time and drops -/// what this file names. So the list is a claim about the repo root, and it is -/// held against the repo root rather than read as documentation of it. -/// -/// Three sets meet at the root, and the two containments below are what make -/// the file honest in both directions: -/// -/// - every root entry is either excluded here or named in `shipped()`, so a new -/// top level path cannot start shipping without someone saying it should; -/// - every entry here either names a root entry or names something `.gitignore` -/// keeps out of the repo, so an entry that excludes nothing cannot sit in the -/// list looking like it was derived from the package contents. -/// -/// The `.gitignore` half of that is what admits the defensive entries. A path -/// git is told never to hold is a path a working tree can still hold when the -/// package is built, so excluding it is live even though it is absent from a -/// fresh checkout. -contract SoldeerIgnoreTest is Test { - /// The top level paths the package is for. Everything else at the root is - /// tooling, and `.soldeerignore` has to name it. - function shipped() internal pure returns (string[] memory) { - string[] memory paths = new string[](6); - paths[0] = "LICENSE"; - paths[1] = "LICENSES"; - paths[2] = "README.md"; - paths[3] = "REUSE.toml"; - paths[4] = "script"; - paths[5] = "src"; - return paths; - } - - /// `.DS_Store` is the one exclusion that answers to neither the repo nor - /// `.gitignore`: nothing in this repo produces it and git is not told about - /// it, but macOS writes it into any directory it browses, including one - /// about to be published. - function ephemeral() internal view returns (string[] memory) { - string[] memory gitignored = entriesOf(".gitignore"); - string[] memory paths = new string[](gitignored.length + 1); - for (uint256 i = 0; i < gitignored.length; i++) { - paths[i] = gitignored[i]; - } - paths[gitignored.length] = ".DS_Store"; - return paths; - } - - /// The paths named by an ignore file, one per line, with the leading `/` - /// that anchors an entry to the root stripped so that anchored and - /// unanchored spellings of the same root path compare equal. Blank lines - /// and `#` comments name nothing and are dropped. - function entriesOf(string memory path) internal view returns (string[] memory) { - string[] memory lines = vm.split(vm.readFile(path), "\n"); - string[] memory paths = new string[](lines.length); - uint256 count = 0; - for (uint256 i = 0; i < lines.length; i++) { - string memory line = vm.trim(lines[i]); - bytes memory data = bytes(line); - if (data.length == 0 || data[0] == "#") { - continue; - } - paths[count++] = data[0] == "/" ? drop(data, 1) : line; - } - assembly ("memory-safe") { - mstore(paths, count) - } - return paths; - } - - /// The names directly under the repo root. `readDir` resolves what it is - /// asked to walk, so it reports each entry as an absolute path and the name - /// is the last segment of it. - function rootEntries() internal view returns (string[] memory) { - VmSafe.DirEntry[] memory dir = vm.readDir("./"); - string[] memory paths = new string[](dir.length); - for (uint256 i = 0; i < dir.length; i++) { - bytes memory data = bytes(dir[i].path); - uint256 start = 0; - for (uint256 j = 0; j < data.length; j++) { - if (data[j] == "/") { - start = j + 1; - } - } - paths[i] = drop(data, start); - } - return paths; - } - - /// `data` without its first `prefixLength` bytes. - function drop(bytes memory data, uint256 prefixLength) internal pure returns (string memory) { - bytes memory out = new bytes(data.length - prefixLength); - for (uint256 i = 0; i < out.length; i++) { - out[i] = data[i + prefixLength]; - } - return string(out); - } - - /// Whether `paths` holds `path`. - function has(string[] memory paths, string memory path) internal pure returns (bool) { - bytes32 wanted = keccak256(bytes(path)); - for (uint256 i = 0; i < paths.length; i++) { - if (keccak256(bytes(paths[i])) == wanted) { - return true; - } - } - return false; - } - - /// Every exclusion excludes something. An entry that matches neither a path - /// in the repo nor a path git is told to keep out of it is inherited from - /// somewhere else and makes the list read as derived from the package when - /// it was copied. - function testEveryIgnoreEntryExcludesSomething() external view { - string[] memory root = rootEntries(); - string[] memory defensive = ephemeral(); - string[] memory ignored = entriesOf(".soldeerignore"); - for (uint256 i = 0; i < ignored.length; i++) { - assertTrue( - has(root, ignored[i]) || has(defensive, ignored[i]), - string.concat( - "`.soldeerignore` excludes `", - ignored[i], - "`, which is neither in the repo nor in `.gitignore`, so it excludes nothing" - ) - ); - } - } - - /// Nothing reaches the package by omission. A root path that is not shipped - /// on purpose has to be excluded on purpose. - function testEveryRootEntryIsShippedOrIgnored() external view { - string[] memory ignored = entriesOf(".soldeerignore"); - string[] memory published = shipped(); - string[] memory root = rootEntries(); - for (uint256 i = 0; i < root.length; i++) { - assertTrue( - has(ignored, root[i]) || has(published, root[i]), - string.concat( - "`", root[i], "` is at the repo root and `.soldeerignore` does not exclude it, so it ships" - ) - ); - } - } - - /// The shipped set is a statement about this repo, so each of its paths is - /// one this repo has. - function testEveryShippedPathIsInTheRepo() external view { - string[] memory root = rootEntries(); - string[] memory published = shipped(); - for (uint256 i = 0; i < published.length; i++) { - assertTrue(has(root, published[i]), string.concat("`", published[i], "` is not at the repo root")); - } - } - - /// The package is these paths, so excluding one empties the package of the - /// thing it is for. - function testNoShippedPathIsIgnored() external view { - string[] memory ignored = entriesOf(".soldeerignore"); - string[] memory published = shipped(); - for (uint256 i = 0; i < published.length; i++) { - assertFalse( - has(ignored, published[i]), - string.concat("`.soldeerignore` excludes `", published[i], "`, which the package is for") - ); - } - } -} From c3c3c80f7555eef2e229010a62f21952c0085504 Mon Sep 17 00:00:00 2001 From: David Meister Date: Mon, 17 Aug 2026 03:55:10 +0000 Subject: [PATCH 4/5] meta/ is committed input, not scratch: it does not belong in .gitignore meta/.rain.meta is what LibCodeGen.describedByMetaHashConstantString reads. Those files are committed inputs to codegen, so gitignoring the directory makes committing them impossible. There are none committed today; that is a data gap to fill by adding files, not one to enshrine by ignoring the path. Costs nothing: LibCodeGenDescribedByMetaHashConstantStringTest removes every fixture it writes, so the directory the suite leaves at the root is empty and git does not see it. Verified: git status is clean after a full run with the line gone. /meta stays in .soldeerignore. What git tracks and what the published package ships are different questions. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index d1f4c0e..0f9f079 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ cache dependencies -meta out .pre-commit-config.yaml From edfb864ddd73cf22f846390b00bc89f3c60521fb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 17 Aug 2026 04:53:25 +0000 Subject: [PATCH 5/5] meta/ ships in the published soldeer package Ruled 2026-08-17: "for 133 meta needs to be in soldeer published packages". .soldeerignore lists what is stripped from the package, so `/meta` is removed. meta/ is absent from that list on main, so this restores main's behaviour for that path and the published package carries whatever meta/ holds. This is the second half of the pair. The first ruling put meta/ in git ("in 133 meta should be committed, so it can't be in gitignore"); this one puts it in the package. Committed and published, both. Co-Authored-By: Claude Opus 5 (1M context) --- .soldeerignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.soldeerignore b/.soldeerignore index 52f45a5..a939169 100644 --- a/.soldeerignore +++ b/.soldeerignore @@ -11,7 +11,6 @@ /flake.lock /flake.nix /foundry.toml -/meta /out /remappings.txt /slither.config.json