Refuse to generate beside another artifact for the same contract - #128
Conversation
`LibFs.pathForContract` names `src/generated/<Contract>.sol`. Consumers commit that file and import it by path from `src/**`, so the path is a cross repo contract. A repo holding an artifact for the same contract under any other name got a fresh `<Contract>.sol` written beside it and a green build, while its imports kept resolving to the file nothing regenerates. `buildFileForContract` now calls `requireNoOrphanedArtifact` before it creates or removes anything, and reverts `OrphanedGeneratedArtifact` when `src/generated` holds a direct child named for the contract in full, followed by a `.` and anything other than the current artifact's name. The current name is read from `pathForContract` rather than respelled, so the check follows that function wherever it goes. Only direct children are read, so per release snapshot subdirectories are untouched. `[package].version` takes the minor step by hand: the autopublish bump is always a patch step and this change breaks consumers holding an artifact at the old path. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`lastPathSegment` is what turns a directory entry into the name the check compares, so its result is the whole basis of that comparison. Its own tests cover the shapes a directory read never produces and so never reached it: a path with no separator at all, an empty path, a path ending in a separator, and repeated separators. The property over arbitrary bytes pins it to the LAST separator rather than any earlier one. `buildFileForContract` creates `GENERATED_DIR` before the refusal, so the refusal precedes the removal and the write rather than everything. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Warning Review limit reached
Next review available in: 20 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 (6)
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 |
…ated-path-contract # Conflicts: # foundry.toml # src/lib/LibFs.sol
The orphan tests wrote a fixture, asserted, then removed it. A revert and a failed assertion both abort the test body where they happen, so the removal only ran on the runs that passed. Every fixture these tests write is a file `requireNoOrphanedArtifact` refuses on, and all of them share the one `src/generated`, so a run that failed handed the next one a directory it had to refuse. That is what the mutation matrix leaves behind: under any mutant killed by `testRequireNoOrphanedArtifactIgnoresOtherContracts`, that test reverts before its cleanup and leaks `LibFsOrphanPrefixExtra.pointers.sol` and `LibFsOrphanUnrelated.pointers.sol` into a committed directory. Each test now catches the outcome, reads whatever it needs off disk, removes its fixtures, and only then asserts. `vm.expectRevert` cannot express that ordering, so the refusal is compared as returned revert data instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comparing raw revert data reports a mismatch as two hex strings. The message now names the error and the path expected, so the assertion that replaced `vm.expectRevert` reads as well as it did. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
`vm.readDir` does not revert on a read it cannot perform: it returns one entry naming the directory itself and carrying an `errorMessage`. Measured, not assumed — reading a missing directory under this repo's `fs_permissions` returns `len=1`, `path` the absolute directory path, and `errorMessage` the `os error 2`. No artifact name matches that entry, so a directory that cannot be read is accepted. That is the answer wanted for a repo with no generated directory yet, and it is what makes creating the directory first the honest ordering rather than a load-bearing one. The docstring said `dir` "must exist", which reads as a precondition something enforces; nothing does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`vm.readDir` does not revert on a directory that is not there: it returns one entry whose `path` is that directory and whose `errorMessage` says why. The check placed ahead of `vm.createDir` therefore reads that one entry, and the two orderings disagree only where the directory's own final segment reads as an artifact for the contract — so that is the shape this drives through. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ated-path-contract # Conflicts: # src/lib/LibFs.sol # test/concrete/LibFsExternal.sol
…gainst #137 landed `buildFileForTaggedContract`, which writes into exactly the per release snapshot directories this section called untouched. They are not: that write enters the same shared body, so it reads the directory it writes into and checks it against its own contents. Only a generation into `src/generated/` itself leaves them unread, and it never refuses one of them because a tag carries no `.`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
From the whole-repo audit at
7aa85a4. There is no issue for it, so there is noClosesline.What was wrong
LibFs.pathForContractnamessrc/generated/<Contract>.sol. Consumers committhat file and import it by path from
src/**, which is what the library's owndocstring means by "a cross repo contract rather than an internal detail".
It has not always named that file.
7cf0992("fix: generated files are notpointers") moved it from
src/generated/<Contract>.pointers.sol, and theearliest tag containing that commit is
sol-v0.1.4— a patch step, because themerge-driven autopublish lifecycle only takes patch steps.
Nothing in the library noticed the old file. A consumer that bumps
rain-sol-codegenand re-runs its build script gets a freshsrc/generated/<Contract>.solwritten beside its committed<Contract>.pointers.sol, a success exit, andsrc/**still importing the oldfile — whose
BYTECODE_HASHand function-pointer constants are frozen atwhatever the contract was the last time the old library generated it. Nothing
regenerates that file and nothing fails while it sits there.
What it does now
buildFileForContractruns the orphan check before it unlinks or writesanything at the path. That reverts
OrphanedGeneratedArtifact(path)when thegenerated directory holds a direct child named for the contract in full, then a
., then anything other than the namepathForContractcurrently produces.pathForContract, not respelled. Thecheck follows that function wherever it goes: the next time the generated path
moves, the previous one is refused with nothing here to edit. That is the half
of this that is structural rather than about one extension.
.is the boundary, and it is load-bearing.rainlangholdsRainlang.pointers.solandRainlangInterpreter.pointers.solin onedirectory;
raindexholdsRaindexV6.pointers.solandRaindexV6SubParser.pointers.sol. A prefix match without the.refuses eachof those for the other.
pathForContractnever names anythingdeeper, so a generation into
src/generated/never reads inside theper-release snapshot directories that
raindex,rain.deploy,rain.factory.deployandst0x.deploykeep there, and never refuses one ofthose directories either, because a tag carries no
.. Those directories dohold artifacts this library wrote — through the
diroverload — and ageneration into one of them reads that directory instead, against its own
contents.
Where it sits in the write
buildFileForContractis now the six-argument call applied toGENERATED_DIRover a seven-argument
diroverload (#112), and the shared body builds thewhole file content before touching disk (#100) and unlinks in a
whileloop(#127). Since #137 landed,
buildFileForTaggedContractis a third entry pointinto the same body, applied to
dirForTag(tag). The check goes into that sharedbody, between the create and the unlink, so all three inherit it:
back by the revert that follows them. A refusal that landed after the unlink
would leave a consumer with neither artifact — the current one deleted and the
orphan still there.
testBuildFileForContractRefusesToOrphanAnotherArtifactputs a file at the generated path and asserts it is still there, byte for
byte, after the refusal.
requireNoOrphanedArtifact(vm, contractName)is that check applied toGENERATED_DIR, over a privaterequireNoOrphanedArtifactIn(vm, dir, contractName)— the same pairingpathForContract/pathForContractInalready uses, and for the same reason: the only directory a consumer of this
library writes to is
GENERATED_DIR. The overload reads the directory it writesinto rather than always
GENERATED_DIR, so the snapshot directories a consumergenerates into are checked against their own contents and not against the live
one.
What it does not reach: renaming a contract (
Foo→Bar) orphansFoo.solwhile
Bargenerates cleanly, and no per-contract API can see that. CI'sartifact-currency step is the right place for that one.
The decision, and the ledger
The finding named two routes. Neither was taken; the third one below was.
Route 1 — restore the
.pointers.solsuffix. Build cost: one line. Butevery artifact generated since 0.1.4 is already flat —
rain.deploypins 0.1.6and generates flat artifacts into per-release directories — so the revert
orphans those by the identical mechanism, in the other direction. It therefore
still needs an orphan check to be safe, which is this PR's work plus a second
break. It also re-entrenches a name
7cf0992argues is wrong: the generatedfile is a set of constants, not a function-pointer table.
Route 2 — hardcode the legacy
.pointers.solpath as a special case. Buildcost: three lines. Carrying cost: a dead convention named forever inside a live
library, and never provably removable, because soldeer consumers outside these
orgs cannot be enumerated. That is a stopgap by the org's own definition, so it
was not built.
Taken — refuse on any orphaned sibling, keyed to the current name. Build
cost: ~110 lines of library plus the tests below. Carrying cost: one directory
read per generated contract, and no name anywhere that has to be kept updated as
the convention moves. Removal cost, if it is ever wrong: delete the two
functions and the one call site — nothing else depends on them, and while the
check passes it changes nothing about what gets written.
[package].versionis not touchedAn earlier revision of this branch set
[package].versionto0.2.0by handand documented that in the README, on the argument that a consumer-breaking
change needs a minor step the patch-only autopublish lifecycle cannot take.
Both are dropped. Autopublish owns the version in this repo; it is never set by
hand here. This branch leaves
foundry.tomluntouched at whatevermainsays.Consumer migration — reported, not performed
Surveyed: every repo in the eight in-scope orgs of the org registry that has a
commit at all — 272 of 275, the other three empty — read at its default branch
through the GitHub trees API, with each repo's
foundry.tomlfetched and theaffected repos cloned for their contents.
Fourteen repos keep a
src/generated/, holding 42 direct children betweenthem: 26 files and 16 directories.
<Contract>.pointers.sol, across eleven repos. Every one is thelegacy name for a contract that repo still generates: per repo, the artifact
count and the number of
LibFs.buildFileForContractcall sites in its buildscript match one for one, and at each repo's pinned version the library itself
produced that name. Each is refused on that repo's next generation of that
contract, once it is on a version carrying this check.
S01-Issuer/st0x.deploy'sLibProdDeployCurrent.solandLibProdDeployV4.sol— and are accepted.candidate,0_1_1, and0_1_3through0_1_13. No tag carries a., sonone can match a
<Contract>.prefix and none is ever refused.Fourteen repos pin
rain-sol-codegenthrough soldeer: twelve at0.1.0,S01-Issuer/st0x.deployat0.1.3,rainlanguage/rain.deployat0.1.6. Theearliest tag containing
7cf0992issol-v0.1.4, so the thirteen pinned belowit still generate
.pointers.soltoday — their committed artifacts agree withtheir pins, and the split only opens when they bump.
rain.deploy, above it,already generates flat:
src/generated/candidate/AddressRegistry.solandMigrationRegistry.sol.rainlanguage/rain.uniswapis the one consumer with no pin. It reaches thislibrary through a nested git submodule, remapping
rain.sol.codegen/=lib/rain.interpreter/lib/rain.sol.codegen/src/infoundry.tomland generating fromscript/BuildPointers.sol. That chainresolves to
d0fbb76(2024-08-03), which predates7cf0992and writes.pointers.sol, so its committed artifact agrees with it too. Its upgrade is asubmodule bump rather than a soldeer one.
The import surface to repoint is 58
.solfiles carrying 158 references toa
.pointers.solartifact across those eleven repos. None of the 58 is anartifact referring to itself.
.solfiles referencingrainlanguage/raindex0.1.0rainlanguage/rainlang0.1.0rainlanguage/dvin.deploy0.1.0rainlanguage/rain.verify0.1.0rainlanguage/rain.dia0.1.0rainlanguage/rain.erc4626.words0.1.0rainlanguage/rain.flare0.1.0rainlanguage/rain.math.float0.1.0rainlanguage/rain.merkle0.1.0rainlanguage/rain.pyth0.1.0rainlanguage/rain.uniswapWhere the check does not reach. A further 85
.pointers.solsit one leveldown, inside snapshot directories:
raindex60,S01-Issuer/st0x.deploy22,rain.factory.deploy3. A generation intosrc/generated/never reads them. Ageneration into one of those directories reads that directory instead and
applies the same rule to it, so a snapshot directory regenerated in place is
refused on its own contents. Frozen release snapshots are not regenerated; the
live case is
st0x.deploy'scandidate/, which holds 12.Migrating a repo is: delete the orphan, repoint the imports that named it,
regenerate. None of that is done here. This PR is what makes the build say so
instead of passing.
QA
mainmoved three times during this branch — tob422d97(#124),1c81613(#135 and the
0.1.32bump), andb9e7ac7(#137's tag layout —requireTag,dirForTag,pathForTaggedContract,buildFileForTaggedContract— and the0.1.33bump). Each was merged in, never rebased over, and the suite, theformatter and the whole mutation matrix were re-run after each. #137 conflicted
in
src/lib/LibFs.solandtest/concrete/LibFsExternal.sol; both conflicts aretwo additions at the same point and both sides are kept.
The semantic half of that merge is that
buildFileForTaggedContractdelegatesto #112's
diroverload, so it enters the same shared body and inherits thischeck without a line here: a tagged generation is checked against its own tag
directory. That inheritance is structural, not incidental: the delegation is a single
line, so there is no second code path to keep in step.
testRequireNoOrphanedArtifactRejectsTheLegacyName, which puts<Name>.pointers.sol— the exact shape 24 artifacts across eleven repos arein — in the generated directory and asserts
OrphanedGeneratedArtifact("src/generated/<Name>.pointers.sol")..pointers.solis one row of a table rather thanthe subject.
testRequireNoOrphanedArtifactRejectsEveryOtherSuffixwalks tensuffixes —
pointers.sol,so,soll,SOL,asol,sol0,sol.sol,json,pointers, and the empty one — and each must be refused, so a checkthat hardcoded the legacy name would fail nine of them. The accepted side is
asked of the library rather than spelled out:
testRequireNoOrphanedArtifactAcceptsTheFileItWriteswrites toLibFs.pathForContract(name), so whatever that function returns is what mustsurvive.
testRequireNoOrphanedArtifactRejectsEveryNonIdentifierName(bytes)fuzzes the name.
survives a bump, unregenerated and still imported. Covered on all three of its
parts — the refusal itself, the ordering that makes the refusal safe (nothing
is unlinked before it), and the boundary that keeps
FooandFooBarapart.nix develop -c forge test→Ran 29 test suites in 4.31s (58.91s CPU time): 262 tests passed, 0 failed, 0 skipped (262 total tests).nix develop -c forge fmt --check→ exit 0.src/libfile.LibFs.sol: 100% lines (67/67),statements (82/82), branches (9/9), funcs (12/12). Repo total 100% lines
(153/153), statements (188/188), branches (20/20), funcs (28/28).
mutation-probe, which reads the verdict from thesuite's own tally rather than from an exit code, and re-checks each file
byte-pristine before the next mutant. Baseline green at 262, then 18/18
KILLED, 0 survived, 0 no-run, 0 harness errors.
Four mutants cover where the call sits, and the rest cover what it does.
mutation-probecaps each killer list at five names, andforgeruns suites inparallel, so which five it records varies between runs — two passes over the
same tree reported different families for several rows. The names below are the
ones this run recorded, not the complete set of tests that kill each mutant.
testBuildFileForContractChecksTheDirectoryItWritesTo,testBuildFileForContractRefusesToOrphanAnotherArtifacttestBuildFileForContractRefusesToOrphanAnotherArtifactvm.createDirtestBuildFileForContractReadsTheDirectoryNotItsOwnNameGENERATED_DIRinstead of the directory written totestBuildFileForContractChecksTheDirectoryItWritesTo,testBuildFileForContractReadsTheDirectoryNotItsOwnNamelastPathSegmentkeeps the separator / stops recognising ittestLastPathSegment*, plustestRequireNoOrphanedArtifactRejectsADirectorylastPathSegmentcopies from the front / stops at the first separator rather than the lasttestRequireNoOrphanedArtifactRejects*, plustestLastPathSegmentAbsolutePathtestRequireNoOrphanedArtifactAcceptsTheFileItWrites,testBuildFileForContractIdempotent,testBuildFileForTaggedContractIdempotent., so a longer name matchestestRequireNoOrphanedArtifactIgnoresOtherContracts,testRequireNoOrphanedArtifactIgnoresTheBareName<Name>.is skippedtestRequireNoOrphanedArtifactRejectsEveryOtherSuffixtestRequireNoOrphanedArtifactIgnoresTheBareNametestRequireNoOrphanedArtifactAcceptsTheFileItWrites,testBuildFileForContractIdempotent,testBuildFileForTaggedContractIdempotenttestRequireNoOrphanedArtifactRejectsTheLegacyNameand three siblingstestRequireNoOrphanedArtifactIgnoresOtherContracts,testRequireNoOrphanedArtifactIgnoresShorterContracts.is never readtestRequireNoOrphanedArtifactIgnoresOtherContracts,testBuildFileForContractGeneratesWhenNoOtherArtifactExiststestRequireNoOrphanedArtifactRejectsADirectoryand three siblingstestRequireNoOrphanedArtifactIgnoresSubdirectoriesM03 is the one that needed a new test.
vm.readDirdoes not revert on adirectory that is not there — it returns one entry whose
pathis thatdirectory and whose
errorMessagesays why — so the two orderings only disagreewhere the directory's own final segment reads as an artifact for the contract.
testBuildFileForContractReadsTheDirectoryNotItsOwnNamegeneratesLibFsBuildDirNameinto a directory calledLibFsBuildDirName.snapshot: runbefore the create, the check refuses the directory it was about to make; run
after it, the read is of an empty directory and the file is written.