Skip to content

LibFs: build the generated file content before touching disk - #100

Merged
thedavidmeister merged 5 commits into
mainfrom
2026-08-16-issue-61
Aug 17, 2026
Merged

LibFs: build the generated file content before touching disk#100
thedavidmeister merged 5 commits into
mainfrom
2026-08-16-issue-61

Conversation

@thedavidmeister

@thedavidmeister thedavidmeister commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Closes #61

What was wrong

LibFs.buildFileForContract unlinked whatever was at
pathForContract(contractName) as its own statement, before the content it was
going to write existed. The content was only built as an argument to
vm.writeFile, and building it calls LibCodeGen.bytecodeHashConstantString,
which reverts with CodelessInstance for an instance that holds no code.
Cheatcode filesystem effects are not rolled back by the EVM revert, so a build
that failed that way deleted the previously generated file and wrote nothing
back. The visible symptom was a missing or drifted committed artifact rather
than the CodelessInstance that caused it.

The change

src/lib/LibFs.sol: the whole file content is built into a local before the
first cheatcode, so createDir, the unlink and the write are all downstream of
every revert the build can produce. Nothing is created, unlinked or written
unless there is content to write. The docstring states that ordering and why it
is load bearing.

Since #112 landed, the statements this reorders live in the five-arg
buildFileForContract(vm, instance, dir, contractName, body) overload, and the
four-arg function is that overload applied to GENERATED_DIR. The hoist is
therefore made in the overload, which is where the unlink and the write are, and
both entry points get the ordering. The docstring paragraph sits on the four-arg
function, where the behavioural narrative lives and which the overload's
docstring points at as identical in every other respect.

The issue's proposed fix is what landed, adapted only to that re-siting: the
issue's snippet names GENERATED_DIR and vm.exists(path) where the current
tree has dir and isPresent(vm, path), both of which are #112's and #127's
changes to the surrounding statements rather than anything this PR touches.

Two things were added on top of the issue's proposed test: an explicit
codeless.code.length == 0 precondition, so the test proves it is exercising
the codeless path rather than assuming 0xdead is empty, and mutant M04 below,
which is what makes the byte-identical assertion load bearing rather than
decorative. The test lives at test/src/lib/LibFs.buildFileForContract.t.sol,
the post-#56 mirror path for a test whose subject is src/lib/LibFs.sol.

QA

  • Discriminating tests: testBuildFileForContractFailedBuildKeepsExistingFile - fails with the fix's ordering reverted (M01 below, trace attached), passes with it in place
  • Mutations applied: src/lib/LibFs.sol content-before-cheatcodes ordering -> restore the pre-fix ordering (content built inline at vm.writeFile) -> killed by testBuildFileForContractFailedBuildKeepsExistingFile; vm.writeFile(path, content) -> vm.writeFile(path, "") -> killed by testBuildFileForContractExactContent + 4 more; if (isPresent(vm, path)) guard -> unconditional vm.removeFile(path) -> killed by testBuildFileForContractReplacesDanglingSymlink + 4 more; content assignment -> preceded by vm.writeFile(path, LibCodeGen.filePrefix()) -> killed by testBuildFileForContractFailedBuildKeepsExistingFile and testBuildFileForContractReplacesDanglingSymlink. 4/4 killed, 0 survived, 0 no-run, 0 harness errors, baseline green at 156 passed before each
  • Oracle: the issue's own measured claim, restated as literals independent of the library - the pre-existing bytes "PRE-EXISTING" are written by the test itself and asserted back verbatim, and the expected revert is abi.encodeWithSelector(CodelessInstance.selector, address(uint160(0xdead))) built from the error declaration rather than from what buildFileForContract happens to throw. The codeless precondition is asserted (codeless.code.length == 0) rather than assumed
  • Category check: issue LibFs.buildFileForContract unlinks the existing file before the content is computed, so a failed build destroys the previously generated file #61 asks for one thing - a failed build must not destroy the previously generated file - and names both halves of it (the file still exists, and its content is unchanged); both are covered, and M04 is what proves the content half is not riding on the existence half

nix develop -c forge test, nix develop -c forge fmt --check, nix develop -c forge coverage and the mutation pass were all run in a fresh clone of this
branch merged with main, forge 1.7.2-nightly (43923a4) from the flake.

The fix's ordering reverted

nix develop -c forge test --match-test testBuildFileForContractFailedBuildKeepsExistingFile -vvv
with M01 applied to the merge commit — the content built inline at
vm.writeFile and the hoisted local deleted, which is exactly the ordering
main has. The trace shows the unlink landing and then the revert, exactly as
the issue describes. The VM: address is shortened.

[FAIL: a failed build destroyed the existing file] testBuildFileForContractFailedBuildKeepsExistingFile() (gas: 43227)
Traces:
  [43227] LibFsBuildFileForContractTest::testBuildFileForContractFailedBuildKeepsExistingFile()
    ├─ [0] VM::exists("src/generated/LibFsBuildFailedKeeps.sol") [staticcall]
    │   └─ ← [Return] false
    ├─ [0] VM::writeFile("src/generated/LibFsBuildFailedKeeps.sol", "PRE-EXISTING")
    │   └─ ← [Return]
    ├─ [0] VM::expectRevert(CodelessInstance(0x000000000000000000000000000000000000dEaD))
    │   └─ ← [Return]
    ├─ [12875] LibFsExternal::buildFileForContract(VM: [...], 0x...dEaD, "LibFsBuildFailedKeeps", "\n// body\n")
    │   ├─ [0] VM::createDir("src/generated", true)
    │   │   └─ ← [Return]
    │   ├─ [0] VM::exists("src/generated/LibFsBuildFailedKeeps.sol") [staticcall]
    │   │   └─ ← [Return] true
    │   ├─ [0] VM::removeFile("src/generated/LibFsBuildFailedKeeps.sol")
    │   │   └─ ← [Return]
    │   └─ ← [Revert] CodelessInstance(0x000000000000000000000000000000000000dEaD)
    ├─ [0] VM::exists("src/generated/LibFsBuildFailedKeeps.sol") [staticcall]
    │   └─ ← [Return] false
    ├─ [0] VM::assertTrue(false, "a failed build destroyed the existing file") [staticcall]
    │   └─ ← [Revert] a failed build destroyed the existing file
    └─ ← [Revert] a failed build destroyed the existing file

Suite result: FAILED. 0 passed; 1 failed; 0 skipped

src/lib/LibFs.sol was restored from git afterwards and the restore verified
byte-exact by sha256
(c0ed3f0ec736a7a9d4d88483476ca7ece571ffe55a1e440f16e363658b30271e before and
after).

Passing with the fix

[PASS] testBuildFileForContractFailedBuildKeepsExistingFile() (gas: 53280)
Suite result: ok. 1 passed; 0 failed; 0 skipped

Full suite, formatting and coverage

On the merge commit of this branch with main:

Ran 23 test suites in 2.91s (31.63s CPU time): 156 tests passed, 0 failed, 0 skipped (156 total tests)

main alone at that same point runs 155 tests across the same 23 suites, so the
delta is the one test this PR adds and nothing else.

nix develop -c forge fmt --check exits 0 with no diff reported.

nix develop -c forge coverage holds src/lib at 100% on every column, and the
hoisted local is covered rather than merely compiled — LibFs.sol goes from
20/20 lines and 17/17 statements on main to 22/22 and 19/19 here:

| src/lib/LibCodeGen.sol   | 100.00% (49/49) | 100.00% (62/62) | 100.00% (3/3)   | 100.00% (14/14) |
| src/lib/LibFs.sol        | 100.00% (22/22) | 100.00% (19/19) | 100.00% (4/4)   | 100.00% (5/5)   |
| src/lib/LibHexString.sol | 100.00% (14/14) | 100.00% (16/16) | 100.00% (3/3)   | 100.00% (1/1)   |

Mutation matrix

Run with mutation-probe (rainlanguage/adversarial-mutation-test), which
proves the suite ran from its own tally rather than from an exit code. Baseline
green at 156 passed / 0 failed before every mutant, and every mutated file is
restored byte-identical afterwards. Every mutant targets the five-arg overload,
which is where the unlink and the write live.

# mutant to src/lib/LibFs.sol verdict killed by
M01 content built after the unlink (the pre-fix ordering) KILLED testBuildFileForContractFailedBuildKeepsExistingFile
M02 vm.writeFile(path, content)vm.writeFile(path, "") KILLED testBuildFileForContractBodyVerbatim, …CreatesTheDirectory, …EmptyBody, …ExactContent, …FreshPath
M03 if (isPresent(vm, path)) guard dropped in front of the unlink KILLED testBuildFileForContractReplacesDanglingSymlink, …BodyVerbatim, …CreatesTheDirectory, …EmptyBody, …ExactContent
M04 the existing file rewritten with just the prefix before the build can fail KILLED testBuildFileForContractFailedBuildKeepsExistingFile, testBuildFileForContractReplacesDanglingSymlink
baseline: green (156 passed)
== 4/4 killed; survived: 0; no-run: 0; harness errors: 0

M01 is the fix's own behaviour, and it is discriminating: it is killed by the
new test and by nothing else in the suite. M02 and M03 are there because
hoisting the content into a local is a silent place to drop it, and because the
existence guard sits between the two statements that moved. M04 leaves a file at
the path but not the bytes that were there, so assertTrue(vm.exists(path))
still passes and only the assertEq on the content can see it — that is what
makes the byte-identical half of the new test load bearing rather than
decorative.

The suite provably ran on every one of these: each verdict carries the test
names that failed, and the run reports no-run: 0; harness errors: 0, so no
verdict here is a compile error or a zero-match filter reading as a pass.

`buildFileForContract` unlinked whatever was at the path before it computed
the content to write. Building the content reverts for a codeless instance,
and cheatcode filesystem effects are not rolled back by the revert, so a
failed build deleted the previously generated file and wrote nothing back.

Closes #61

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thedavidmeister thedavidmeister self-assigned this Aug 16, 2026
@coderabbitai

coderabbitai Bot commented Aug 16, 2026

Copy link
Copy Markdown

Warning

Review limit reached

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

Next review available in: 13 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: ac5e1577-55b0-4c2c-b63a-9ce4b99f9e89

📥 Commits

Reviewing files that changed from the base of the PR and between 37a8dcf and c9fcaee.

📒 Files selected for processing (2)
  • src/lib/LibFs.sol
  • test/src/lib/LibFs.buildFileForContract.t.sol

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.

@thedavidmeister

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Aug 16, 2026

Copy link
Copy Markdown
⚠️ Action not completed

Review rate limited.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

… the run

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
thedavidmeister and others added 3 commits August 17, 2026 04:49
Re-sites the content hoist into #112's five-arg overload, where the unlink and
the write now live, and takes #56's move of the test file into the test/src
mirror tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thedavidmeister
thedavidmeister merged commit e31d902 into main Aug 17, 2026
4 checks passed
thedavidmeister added a commit that referenced this pull request Aug 17, 2026
#100 rebuilt buildFileForContract so the whole file content is computed into
a local before any disk mutation. Resolved onto that ordering rather than
restoring compute-at-write: the local is built from
filePrefix(spdxLicenseIdentifier, copyrightText), so a refused licence or
copyright now reverts before createDir, the unlink and the write, exactly as
a codeless instance does. Its docstring paragraph is widened to say so, since
the build step this branch adds a revert path to is the one that paragraph is
about.

#100's testBuildFileForContractFailedBuildKeepsExistingFile is kept and
passes the two values like every other call site.
thedavidmeister added a commit that referenced this pull request Aug 17, 2026
Comment-only. The clause added when resolving onto #100's ordering left the
paragraph wrapping mid-sentence at a short line.
thedavidmeister added a commit that referenced this pull request Aug 17, 2026
Re-sites the orphaned-artifact refusal onto main's current `LibFs`:

- `buildFileForContract` is now the six-arg call applied to `GENERATED_DIR`
  over a seven-arg `dir` overload (#112), builds the whole file content
  before touching disk (#100), and unlinks in a `while` loop (#127). The
  check goes into the shared body, after `vm.createDir` because it is a read
  of that directory, and before the unlink so a refusal leaves the existing
  artifact where it found it.
- `requireNoOrphanedArtifact(vm, contractName)` is that check applied to
  `GENERATED_DIR`, over a private `requireNoOrphanedArtifactIn`, mirroring
  `pathForContract` / `pathForContractIn`. The overload reads the directory
  it writes into rather than always `GENERATED_DIR`.
- The test moves from `test/lib/` to `test/src/lib/` (#56), and its calls
  carry the licence and copyright `filePrefix` now takes (#135).
- `InvalidContractName` / `isContractNameSlow` are `InvalidIdentifier` /
  `isIdentifierSlow`, and forge-std is 1.16.2.
- The README's "Generated paths" section anchors ahead of "Formatter
  requirements": the worked-example section it sat under is gone (#138) and
  the publish section it appended to was rewritten (#140).

Drops the hand-set `[package].version = "0.2.0"` and the README paragraph
that justified it. Autopublish owns the version.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to 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.

LibFs.buildFileForContract unlinks the existing file before the content is computed, so a failed build destroys the previously generated file

1 participant