Unlink the generated path until it holds nothing, so a live symlink is replaced too - #127
Conversation
…s replaced Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Warning Review limit reached
Next review available in: 42 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 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 configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
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. Comment |
Re-sites the unlink loop into #112's five-arg overload, which is where the unlink and the write now live, and keeps main's `vm.createDir(dir, true)`. The docstring change stays on the four-arg NatSpec, which is where the unlink paragraph lives and which the overload defers to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Takes #126's forge-std 1.16.2 import prefixes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ite-through # Conflicts: # src/lib/LibFs.sol
#127 turned the unlink into `while (isPresent(...))` in the same function these parameters run through. Taken as main has it: how many times the path is unlinked is independent of what the header says. #127's new testBuildFileForContractReplacesLiveSymlink merged with no conflict marker and the wrong arity, because neither side touched those two lines. It is threaded here, with the same comment the file's other generating test carries about this repo's own values standing in where nothing reads the header. The compiler is what caught it, not the merge. #123 renamed requireContractName to requireIdentifier and InvalidContractName to InvalidIdentifier, conflicting with the import block this branch had grown. Both sides taken.
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>
buildFileForContractwrites through a live symlink at the generated path, whileLibFs.solclaims it does not. This finishes the unlink and makes the claim true.What defect this fixes, and why there is no issue
This PR closes no issue, so here is the defect it is for.
#110 fixed the
dangling symlink half of the unlink and named the other half as what it was
leaving behind: "the write's behaviour for a symlink that does resolve
(covered here only for
isPresent's own answer, not throughbuildFileForContract)". The issue that owned the removal block's coverage,#66, was closed with
#110. This is that remainder, and it is a behaviour defect rather than a
coverage gap: the live case is not merely untested, it is wrong, and the NatSpec
asserts the opposite.
What is wrong
Foundry's fs cheatcodes resolve a path before they act on it.
vm.removeFileona live symlink therefore removes the link's TARGET and leaves the link in
place; only on a dangling symlink, where resolution fails and the unresolved
path is used, does it remove the link itself.
A single
if (isPresent(vm, path))removal is enough for the dangling case andnot for the live one. It removes the target, which leaves the link dangling, and
then
vm.writeFilefollows the link and re-creates the target. The generatedsource lands at the link's target and the generated path is still a symlink.
Measured on
mainatc816251, with the tree read back through the shell ratherthan through the cheatcodes under test. A live link
src/generated/ProbeELive.sol -> ProbeELiveTarget.txtwhose target heldSENTINEL, then onebuildFileForContract:The path is still a symlink,
SENTINELis gone, and the 379 byte generated fileis at the link's target. The write left the path it was given, which is exactly
what the docstring said could not happen.
How far it reaches
Bounded by
fs_permissions, not byGENERATED_DIR. A link whose target resolvesoutside the grant reverts inside
isPresent, atvm.exists, before anything isremoved or written:
with the outside file's contents intact afterwards. So nothing escapes the grant.
metais granted read-write alongsidesrc/generated, though, and that is wherethe write does land on
main. Same probe, link pointed atmetainstead:So the generated source leaves
src/generatedentirely. With the loop, the sameprobe leaves
meta/empty andsrc/generated/ProbeMeta.sola regular 378 bytefile.
The change
ifbecomeswhile. The first pass removes what the link resolves to, whichleaves the link dangling; the second removes the link. Every pass removes
something the next one no longer finds, so it terminates, and the path holds
nothing when the write happens.
The docstring is corrected to state the two consequences that are true today
rather than the one that was not: taking a live symlink off the path takes what
it resolves to with it, and a directory, or a symlink onto a directory, cannot be
unlinked at all and reverts.
Termination, measured rather than argued
Turning a bounded
ifinto an unboundedwhilehas exactly one new failuremode, so every shape that could spin was run on the merge commit. Probes are
throwaway and are not part of the diff; each was run on its own under a shell
timeoutso a non-terminating loop would show as the timeout rather than as ahung suite.
X.sol -> X.sol(self loop)A.sol -> B.txt -> A.sol(2-cycle)B.txtleft pointing at itpath -> mid -> file(chain)filegone,midleft dangling beside itBoth reverts are
vm.removeFile: failed to remove file "…": Is a directory (os error 21). Neither writes anywhere, so nothing escapes the path. Both predatethis change and #110, and are out of scope here; they are stated so they are on
the record, and the docstring now names them.
QA
Merged current
mainin; the change is re-sited into the five-arg overload#112 introduced, which is
where the unlink and the write now live. The docstring correction stays on the
four-arg NatSpec, which is where the unlink paragraph lives and which the
overload defers to.
#100 has since landed in
the same block and hoists the file's whole content into a local before any disk
mutation, so a build that reverts cannot destroy the file that is already there.
The loop is resolved onto that ordering rather than over it: content is
computed first, then
vm.createDir, then the loop, then the write. #100'stestBuildFileForContractFailedBuildKeepsExistingFilepasses on the mergecommit, which is what says the ordering survived.
testBuildFileForContractReplacesLiveSymlink. Run againstcurrent
main'sifwith nothing else changed, where it fails[FAIL: the path is still a symlink],Suite result: FAILED. 0 passed; 1 failed, and passes only with the loop.ln/readlink/rmover ffi, neverthrough the cheatcodes under test, plus a control contract generated at a path
that held nothing — the claim is that the symlink case produces the same file
as the empty-path case, not that particular bytes appear.
written through while
LibFs.solclaims it is not. Covered: the writebehaviour (test above) and the claim itself (docstring corrected to what is
measured, including the directory cases it cannot unlink).
nix develop -c forge test→Ran 23 test suites: 166 tests passed, 0 failed, 0 skipped (166 total tests),against 156 on this branch immediately before the merge, with no existing test
changed. The +10 is entirely
main's own:mainwent from 155 test functionsat
89cb0a2to 165 atff69c10(+1 inLibFs.buildFileForContract.t.sol, +9in
LibHexString.bytesToHex.t.sol). This PR's one added test,testBuildFileForContractReplacesLiveSymlink, is the +1 on top of each.mainmoved five times during this:c816251→37a8dcf(version bump anda
.soldeerignoreedit) →89cb0a2(chore: bump forge-std 1.16.1 -> 1.16.2, including the prefix rewrite the pin forces #126's forge-std 1.16.2 prefix rewrite,which touches both of this PR's files) →
e31d902(LibFs: build the generated file content before touching disk #100, which reorders theblock this PR changes) →
ff69c10(docs: state the fixed-point bound and how a non-converging build reports #136 and a version bump). It was merged inand the suite, formatter, coverage and mutation matrix were re-run after each.
nix develop -c forge fmt --check→ exit 0.src/libfile:LibFs.sol100% lines (22/22), statements (19/19), branches (3/3), funcs (5/5). The line
and statement denominators rose from 20 and 17 because LibFs: build the generated file content before touching disk #100's content hoist is
now in the function; branches and funcs are unchanged.
mutation-probe, which provesthe suite ran from its own tally rather than from an exit code. Baseline green
at 166, then 3/3 KILLED, 0 survived, 0 no-run, 0 harness errors:
while (isPresent(vm, path))→if (isPresent(vm, path)), i.e. thebehaviour on
main— KILLED bytestBuildFileForContractReplacesLiveSymlink.while (isPresent(vm, path))→while (vm.exists(path)), i.e. looping onthe resolved path instead of the path — KILLED by
testBuildFileForContractReplacesLiveSymlinkandtestBuildFileForContractReplacesDanglingSymlink.while (isPresent(vm, path))→while (false), i.e. no unlink at all —KILLED by the same two.