Skip to content

Point the generated-lib writers at a directory they are handed - #137

Merged
thedavidmeister merged 4 commits into
mainfrom
2026-08-18-issue-135-lib-writers-take-a-directory
Aug 18, 2026
Merged

Point the generated-lib writers at a directory they are handed#137
thedavidmeister merged 4 commits into
mainfrom
2026-08-18-issue-135-lib-writers-take-a-directory

Conversation

@thedavidmeister

@thedavidmeisterthedavidmeister commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Closes#135

The defect

writeAliasLib and writeReleasedSuitesLib built their output path out of a
hard-coded src/lib/ and the contract name. The only way to drive either was
therefore to overwrite a COMMITTED source file, and four tests in
LibRainDeploySnapshotTest did exactly that — two over
src/lib/LibAddressRegistryDeploy.sol, two over
src/lib/LibAddressRegistryReleased.sol — each reading a before, writing, and
restoring. forge runs the tests in a contract concurrently, so a read landing
between another test's truncate and its write hands one test an EMPTY before,
which it then commits back to the committed file.

Measured on main (ed91bfa, i.e. after #131 and #134 landed)

100 runs per pair, nix develop -c forge test --mt <pair>, git checkout -- src/ between runs so one corruption cannot be counted twice. Every run
compiled and executed both of its tests, so no result here is a filter that
matched nothing.

pairrunsruns that ran both testscompile failuresfailed runstest-level failures
testWriteAliasLib*10010002634
testWriteReleasedSuitesLib{WritesTheLibAtItsPath,DefaultsToTheOrgHeader}100100044

The issue measured the alias pair and stated the released pair was the same
shape but unmeasured. It is the same shape and it does fail — at 4%, not 26%.
Every released-pair failure is the same signature the alias pair produces,
assertion failed: != // SPDX-License-Identifier: ...: an EMPTY string read
where the committed file should be. No run in either 100 left a file zeroed on
disk; the issue's run 2 did, and both pairs can, because whether the corruption
survives depends only on which test restores last.

The fix

Both writers take libDir, exactly as writeReleasedSuitesLib already took
recordRoot and for the same reason: a writer that can only be pointed at the
committed tree can only be tested by overwriting it. LibRainDeploySnapshot.LIB_DIR
is the directory a build passes, and script/Build.sol passes it.

The two things the old tests conflated are now separate:

  • "the committed lib is what the generator emits"
    testTheCommittedAliasLibIsWhatTheGeneratorEmits and
    testTheCommittedReleasedLibIsWhatTheGeneratorEmits read the committed file
    and write nothing. This is the staleness check, and it is stronger without the
    write: the old alias test asserted emitted == before, i.e. compared a file
    against itself around a write, which is green on a stale file as long as the
    writer is deterministic.
  • "the writer lands the lib at its path"testWriteAliasLibWritesTheLibAtItsPath
    and testWriteReleasedSuitesLibReadsTheRecordItIsHanded write into
    fixture-lib/<test>/, one directory per test, against MockDeployable rather
    than this repo's own contract so the derived library name in the path is
    actually asserted to be derived.
  • "the defaulting arity writes what the parameterised one writes" — a
    fixture directory each.

fixture-lib/ is outside src/ and test/ because both are compiled and a
generated lib imports ../generated/ and ../abstract/, which resolve from
src/lib and nowhere else — the copy a failing test deliberately leaves behind
would otherwise break every suite. It is gitignored and .soldeerignored.

testWriteReleasedSuitesLibWritesTheLibAtItsPath is deleted rather than
converted: with the committed-file half split out, what remained was
testWriteReleasedSuitesLibReadsTheRecordItIsHanded a second time.

testWriteReleasedSuitesLibReadsTheRecordItIsHanded was a fifth writer into
src/lib/ (LibMockDeployableReleased.sol, uncommitted, removed before its
assertions). It raced nothing, but it is the same hazard — a generated lib under
a compiled root — so it moved too.

LIB_DIR is only read by script/Build.sol, which nothing may execute, so
testTheCommittedLibsAreInTheLibDir holds it equal to the directory the
committed libs are actually in.

Measured on this branch

Same harness, 100 runs of every test that drives either writer
(testWriteAliasLib|testWriteReleasedSuitesLib|testTheCommitted):

runsruns that ran all 7 testsfailed runsruns leaving the tree dirtyruns leaving a file under fixture-lib/
100100000

The seven are testTheCommittedAliasLibIsWhatTheGeneratorEmits,
testTheCommittedLibsAreInTheLibDir,
testTheCommittedReleasedLibIsWhatTheGeneratorEmits,
testWriteAliasLibDefaultsToTheOrgHeader,
testWriteAliasLibWritesTheLibAtItsPath,
testWriteReleasedSuitesLibDefaultsToTheOrgHeader and
testWriteReleasedSuitesLibReadsTheRecordItIsHanded.

Suite

nix develop -c forge test: 261 passed / 51 failed, every failure
*_RPC_URL not found.
grep '[FAIL' log | grep -vc '_RPC_URL. not found' = 0.
nix develop -c forge fmt --check exit 0.

main at ed91bfa for comparison: 259 passed / 51 failed. Net +2 tests: three
added (testTheCommittedLibsAreInTheLibDir,
testTheCommittedAliasLibIsWhatTheGeneratorEmits,
testTheCommittedReleasedLibIsWhatTheGeneratorEmits), one deleted.

Adversarial mutation pass

Whole suite per mutant, never a filter: a filter that pulls in a bytecode or
CREATE2-address pin reports KILLED for every source mutation and measures
nothing. Each mutant is proven applied (git diff --numstat non-empty) and
proven to have RUN (compileFail=0, 363-365 [PASS]/[FAIL] result lines
against the baseline's 363). KILLED = a failure beyond the 51 *_RPC_URL
baseline. Raw logs: .scratch/mutants/ in the working clone, summary.txt
carries the per-mutant counts.

Baseline (unmutated): compileFail=0, 363 result lines, 0 non-RPC failures.

#mutationverdictkilled by
1LIB_DIR = "src/lib" -> "src/libs"KILLEDtestTheCommittedLibsAreInTheLibDir
2writeAliasLib path uses LIB_DIR instead of the libDir it was handedKILLEDtestWriteAliasLibWritesTheLibAtItsPath
3writeReleasedSuitesLib path uses LIB_DIR instead of the libDir it was handedKILLEDtestWriteReleasedSuitesLibReadsTheRecordItIsHanded
4writeAliasLib 5-arg forwards libDir + "x"KILLEDtestWriteAliasLibDefaultsToTheOrgHeader, testWriteAliasLibWritesTheLibAtItsPath
5writeReleasedSuitesLib 5-arg forwards libDir + "x"KILLEDtestWriteReleasedSuitesLibDefaultsToTheOrgHeader
6released body emits the library block before the importsKILLEDtestWriteReleasedSuitesLibReadsTheRecordItIsHanded
7released writer drops the "\n" between header and bodyKILLEDtestWriteReleasedSuitesLibReadsTheRecordItIsHanded
8writeAliasLib passes filePrefix(copyrightText, spdxLicenseIdentifier)KILLEDtestWriteAliasLibWritesTheLibAtItsPath
9aliasLibraryBlock exports _DEPLOYED_ADDR rather than _DEPLOYED_ADDRESSKILLEDtestAliasLibraryBlockReExportsBothPins, testTheCommittedAliasLibIsWhatTheGeneratorEmits
10writeAliasLib derives Lib<Contract>DeployedKILLEDtestWriteAliasLibWritesTheLibAtItsPath
11the COMMITTED LibAddressRegistryDeploy.sol drifts one word from the emittersKILLEDtestTheCommittedAliasLibIsWhatTheGeneratorEmits
12the COMMITTED LibAddressRegistryReleased.sol drifts one word from the emittersKILLEDtestTheCommittedReleasedLibIsWhatTheGeneratorEmits

12 applied, 12 killed, 0 survived.

Mutants 11 and 12 are the point of the split: they are the staleness property
the four racing tests were carrying, and they are killed by tests that write
nothing. Mutant 8 is killed only by the writer test, not by the committed one —
correct, because the committed test never calls the writer.

Not in this PR

  • src/lib/LibMigrationRegistryDeploy.sol and
    src/lib/LibMigrationRegistryReleased.sol have no committed-is-current check.
    Neither did they before: the deleted tests only ever covered
    AddressRegistry. Pre-existing gap, unrelated to the race.
  • script/Build.sol's regenerateLibs() is still executed by nothing, so the
    LIB_DIR argument it passes is uncovered at that call site. That is the
    pre-existing state BuildTest's NatSpec describes, not something this PR
    introduced.
  • Generate the released-suites aggregate instead of writing it by hand #126 is untouched. It adds its own fixture-lib root and fs_permissions
    entry for the aggregate writer; the two will conflict textually and agree
    semantically, and whichever lands second resolves it.

QA

  • Discriminating tests: testWriteAliasLibWritesTheLibAtItsPath,
    testWriteAliasLibDefaultsToTheOrgHeader,
    testWriteReleasedSuitesLibDefaultsToTheOrgHeader,
    testWriteReleasedSuitesLibReadsTheRecordItIsHanded,
    testTheCommittedAliasLibIsWhatTheGeneratorEmits,
    testTheCommittedReleasedLibIsWhatTheGeneratorEmits,
    testTheCommittedLibsAreInTheLibDir. They cannot be compiled against base —
    base has neither libDir nor LIB_DIR — so "fails on base" is shown two
    other ways: the base tests these replace fail on base at 26/100 and 4/100 runs
    (measured above, both pairs, 100 runs each, every run executing both tests),
    and every one of the seven kills at least one mutant in the table above.
  • Mutations applied: 12, all killed, 0 survived. Table above carries
    line -> mutation -> killing test, the applied-proof (git diff --numstat
    non-empty per mutant) and the ran-proof (compileFail=0 and 363-365 result
    lines per mutant against the unmutated baseline's 363 with 0 non-RPC
    failures). The whole suite runs per mutant rather than a filter, because a
    filter that pulls in a bytecode or CREATE2-address pin reports KILLED for
    every source mutation.
  • Oracle: the emitted text is spelled out literally in the test — SPDX line,
    copyright line, pragma, the autogenerated banner — and only the two block
    emitters are called, each of which has its own test with a fully literal
    oracle (testAliasImportBlockAliasesBothPins,
    testAliasLibraryBlockReExportsBothPins). The path oracle is spelled out
    (<libDir>/LibMockDeployableDeploy.sol), not derived from the writer. The
    race oracle is the process itself: the file on disk after the run, and
    git status over src/.
  • Category check: the issue names four tests over two paths and asks for
    (a) the committed-file assertion split out so it needs no write, (b) a fixture
    directory per writer test, (c) that directory outside src/ and test/, and
    (d) the libDir parameter the writers lack. All four are covered. The
    category is wider than the four named tests —
    testWriteReleasedSuitesLibReadsTheRecordItIsHanded was a fifth writer into
    src/lib/, so it moved too. The issue's unmeasured claim about the released
    pair was measured before being fixed: 4/100, same signature, same shape.

Summary by CodeRabbit

  • Bug Fixes

    • Improved generated library output handling to ensure alias and released-suite libraries are written to the intended directories.
    • Prevented stack-depth issues during released-suite library generation.
    • Reduced the risk of generated files being overwritten during test execution.
  • Tests

    • Expanded coverage for generated library paths, contents, and default configurations.
    • Added isolated test fixtures for safer, more reliable validation.
  • Chores

    • Updated ignore rules and documentation for generated library artifacts.

`writeAliasLib` and `writeReleasedSuitesLib` derived their output path from
`src/lib/` alone, so the only way to drive either was to overwrite a committed
source file. Four tests in `LibRainDeploySnapshotTest` did that, two per path,
and forge runs the tests in a contract concurrently.
Both now take `libDir`, as `writeReleasedSuitesLib` already took `recordRoot`.
`LIB_DIR` is the directory a build passes. The writer tests take a directory
each under `fixture-lib/`, outside both compiled trees. What the committed libs
hold is asserted by reading them and writing nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thedavidmeisterthedavidmeister self-assigned this Aug 18, 2026
@coderabbitai

coderabbitaiBot commented Aug 18, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@thedavidmeister, you've reached your PR review limit, so we couldn't start this review.

Next review available in:55 minutes

Limit details: You’ve used all 1 included review currently available under your plan.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: d442e8b6-0169-4aa8-ac91-1bc100e90622

📥 Commits

Reviewing files that changed from the base of the PR and between f72fadb and 5bcbaaa.

📒 Files selected for processing (2)
  • script/Build.sol
  • test/script/Build.t.sol

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: ce1b7f24-1b86-49e1-86f2-3a0c104e10f3

📥 Commits

Reviewing files that changed from the base of the PR and between cc83fe0 and f72fadb.

📒 Files selected for processing (7)
  • .gitignore
  • .soldeerignore
  • foundry.toml
  • script/Build.sol
  • src/lib/LibRainDeploySnapshot.sol
  • test/script/Build.t.sol
  • test/src/lib/LibRainDeploySnapshot.t.sol

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


Walkthrough

Generated library writers now accept explicit output directories. Build generation passes those directories, and tests write to isolated fixture-lib paths instead of overwriting committed libraries.

Changes

Library output isolation

Layer / File(s)Summary
Explicit writer directories
src/lib/LibRainDeploySnapshot.sol
Alias and released-suite writers accept libDir, use directory-aware paths, and build released-library content within a scoped block.
Build and filesystem wiring
script/Build.sol, foundry.toml, .gitignore, .soldeerignore
Build calls pass the library directory. Filesystem permissions and ignore rules cover fixture-lib output.
Isolated writer tests
test/src/lib/LibRainDeploySnapshot.t.sol, test/script/Build.t.sol
Tests compare committed output without writes and use isolated fixture directories for generated files and cleanup. Test documentation reflects the expanded write behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Merge Risk:⚪ Minimal · up to f72fa

The change redirects generated-library writes to caller-provided directories and separates committed-file checks from write tests; targeted stress and mutation checks pass, and no actionable merge-blocking risk remains beyond normal checks.

Suggested reviewers:claude

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly states that generated-library writers now use a caller-provided directory, which is the main change.
Linked Issues check✅ PassedThe changes satisfy issue #135 by adding libDir parameters, isolating fixture writes, avoiding committed-file writes, and configuring fixture paths.
Out of Scope Changes check✅ PassedThe ignore rules, permission comment, build wiring, documentation, and tests directly support the requested fixture-directory and race-fix objectives.
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches 💡 1
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch 2026-08-18-issue-135-lib-writers-take-a-directory
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 2026-08-18-issue-135-lib-writers-take-a-directory

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

thedavidmeisterand others added 3 commits August 18, 2026 14:38
`#139` and `#140` landed under this branch, and `#126`'s aggregate writer
came with them. Four files conflicted.
- `foundry.toml`: both sides add the SAME `./fixture-lib` fs_permissions
entry, predicted by this PR's "Not in this PR" note. One entry, with a
comment covering all three writers that are pointed there.
- `.soldeerignore`: both sides add `/fixture-lib`, in different
positions. Deduped, keeping the sorted one.
- `src/lib/LibRainDeploySnapshot.sol`: main introduced `LIB_DIR`,
`pathForLib` and `releasedLibraryName` for the aggregate writer, so
this branch's own `LIB_DIR` is a second declaration of main's and its
hand-concatenated paths are main's helper spelled twice. Main's are
kept, and the two writers this PR parameterises now build their path
with `pathForLib(libDir, libraryName)`. The aggregate writer's NatSpec
claimed the other two writers "take no directory and are always
`LIB_DIR`", which this PR makes false; it now says they take the same
`libDir`.
- `test/script/Build.t.sol`: both sides reworded one paragraph. This
branch's reason (both entry points rewrite committed files other
contracts read) with main's conclusion (nothing below writes anything,
which is what main's new reading tests made true).
- `test/src/lib/LibRainDeploySnapshot.t.sol`: main added
`generatedFilePrefix()` and the aggregate emitter tests where this
branch deleted `testWriteReleasedSuitesLibWritesTheLibAtItsPath`. All
of main's is kept and the deletion stands — that test wrote the
committed `src/lib/LibAddressRegistryReleased.sol`, which is the race
this PR removes, and
`testTheCommittedReleasedLibIsWhatTheGeneratorEmits` carries its
staleness half without writing. The three tests this PR added spell the
generated header out literally; they use `generatedFilePrefix()` now.
`testTheCommittedAggregateIsWhatTheGeneratorEmits` cited the deleted
test by name and now cites its replacement.
Return style is main's throughout: unnamed `returns (...)` with an
explicit `return`, per #129 and the #140 sweep. Nothing added here names
a return, and `forge build --force` emits no solc warning at all.
`nix develop -c forge test`: 275 passed / 52 failed, every failure
`*_RPC_URL not found`; `grep '[FAIL' | grep -vc '_RPC_URL. not found'`
= 0. `git status` clean after the run and `fixture-lib/` empty, so no
test writes a committed file. `nix develop -c forge fmt --check` exit 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two notes the merge earned, neither of them behaviour.
`writeReleasedSuitesLib` builds its body into a local inside a block
rather than inlining it into the `writeFile` call the way `writeAliasLib`
and `writeReleasedSuitesAggregate` do. That reads as gratuitous and the
next person to tidy it will inline it: the `libDir` parameter puts the
function one local past its frame, and solc 0.8.25 without `--via-ir`
refuses the flat form as stack too deep. Measured, not guessed — the
inlined form was compiled and is the error quoted.
`testTheCommittedLibsAreInTheLibDir` said "both writers" when there are
now three, and left a reader wondering where the aggregate's committed
path is pinned. It is pinned by
`testTheCommittedAggregateIsWhatTheGeneratorEmits`, which reads the file
through `pathForLib` and asserts the path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#138 landed after the previous merge, replacing `LIB_FS_ROOT` with the
`recordRoot()` hook while this branch adds `LIB_DIR`. The one conflict, in
`script/Build.sol`, passes both: the writer takes the directory this branch
hands it AND the record root #138 made a hook, matching the five-argument
`writeReleasedSuitesLib(Vm, libDir, recordRoot, contractName, template)`
overload.
No `libDir()` hook was added to `BuildScript` — `regenerateLibs()` is fully
abstract, so the concrete repo picks `LIB_DIR`. That would be a design change
rather than a merge resolution.
Verified: build clean, `forge fmt --check` exit 0, 280 passed / 52 failed with
every failure `*_RPC_URL not found`, `git status` clean and `fixture-lib/` empty
before and after.
@thedavidmeister
thedavidmeister merged commit 4c100b7 into mainAug 18, 2026
4 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

LibRainDeploySnapshotTest tests race each other over committed src/lib files, and can zero one

1 participant

@thedavidmeister