fix: refuse a build that holds two builds of one module #42
No reviewers
Labels
No labels
bug
claude:blocked
claude:done
claude:failed
claude:in-progress
claude:ready
documentation
duplicate
enhancement
good first issue
help wanted
invalid
question
wontfix
bug
duplicate
enhancement
help wanted
invalid
question
wontfix
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
Catcrafts/Crafter.Build!42
Loading…
Reference in a new issue
No description provided.
Delete branch "claude/issue-41"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Fixes #41. Stacked on #40: merge that first. Until then, this PR also shows #40's commits; its own are the last three.
What was wrong
A project that two paths load with different arg lists is two configurations, each with a variant of its own, and
Build()built both. The consumer's compile got both PCM dirs on-fprebuilt-module-path. Clang resolved the module by name to the first dir for every import, including the import inside the other build's consumers, and nothing checked that it was the build they had been compiled against. In #41's repro (P → M,P → A → M, where the two lists change a structMexports), the build exited 0 and the program read garbage or crashed.The fix
Before a configuration makes a directory or builds a dependency,
Build()checks it and its transitive dependencies for two configurations that export a module of the same name. If it finds them, it stops with both paths and what tells the two builds apart:--local.#pragma crafter dependencytool.The README and the
GitProjectSpeccomment, which #40 has saying both builds end up in the program, now say the build stops.Testing
tests/DuplicateModule, with #41's diamond as the fixture:Build()fails, names the module and both paths, and leaves nobin/anywhere.M, and the build goes through.formatandlintare clean. The full suite gives 37 passed and 2 skipped, the same environment skips as #40 (wasmtime missing, noCRAFTER_BUILD_HOME).No current Catcrafts graph hits this: the projects that change their list give that one list to all of their siblings.
🤖 Generated with Claude Code
Every project.cpp with a sibling dependency opened with the same line, std::vector<std::string> depArgs(args.begin(), args.end()); and handed depArgs to each GitProject and LocalProject. CrafterBuildProject receives a span of string_views and the specs wanted owned strings, and without the copy the sibling got none of the project's args: it was built for the host while the project cross-compiled, and in release while it built --debug. GitProject then turned the copy straight back into string_views for LoadProject. Both specs now default their args to ProjectArgs(), the args LoadProject was given for the project file whose CrafterBuildProject is running. It reads the per-thread stack LoadProject already keeps for IsTopLevelProject and ProjectDependency, whose frames now record the args. A default member initializer rather than a new type or an optional: the member stays a std::vector<std::string>, so every existing `.args = depArgs` keeps its exact meaning (each of the ~50 calls across the Catcrafts projects passes one), and `.args = {}` still forwards nothing. It is the shape of Configuration::target = HostTarget(). A project.cpp that drops its .args needs a crafter-build with this change: an older one compiles it and forwards nothing. ProjectArgs() is declared in :External, next to the defaults that call it. :Platform, where it is defined, cannot be imported there: its unexported `struct Configuration;` would make :External's exported one an ill-formed redeclaration, which clang rejects. The #pragma crafter dependency path now passes `.args = {}` explicitly. Host tooling is built for the host whatever the consumer targets and was loaded with no args; the default would otherwise hand it the consumer's --target= and project flags. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>ProjectArgs loads a chain app -> lib -> leaf of LocalProjects with --debug and a project flag. lib, loaded without .args, has to see both, and so does its own sibling, which takes the default one level further down. Copies loaded with `.args = {}` and with an explicit list get nothing and exactly that list. The GitProjectSpec default is checked on the spec itself, since a GitProject would need a repository to clone. Without the defaults, five of its checks fail. ProjectDependency now also loads its consumer with a project flag: the pragma dependency must still come back with no args. Without the explicit `.args = {}` in the pragma path it gets the consumer's flag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>The sibling-project example dropped its `.args = depArgs`, which no longer has to be written, and says what a sibling receives instead: the args the project file was loaded with, or whatever `.args` is set to, with `{}` for nothing. It also says that releases up to 2.2.0 forward nothing without `.args`, which matters for a project.cpp that has to load on both. The pragma section says that a project file's own dependency is built for the host and gets none of the project's args, the one place the default does not apply. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>ProjectArgs() read args that LoadProject recorded when it pushed the frame, so they were there for everything that ran on that frame, not only for CrafterBuildProject. Two things went wrong with that. The pragma pre-pass runs on the frame too, so both of its specs needed a hand-written `.args = {}` to keep the consumer's args from reaching host tooling, and anything else built there would have needed one as well. And dlopen runs a project file's static initialisers only on the first load of that file: a later load gets the same mapping back. A spec made at namespace scope therefore kept the first load's args for every later load of the file, a silently wrong variant. The frame now gets the args just before CrafterBuildProject is called. The pre-pass and the static initialisers see none, on every load, so the two opt-outs go. The dependencies the pre-pass resolved move the other way, to just before dlopen: they are the same on every load, and ProjectDependency() called from a static initialiser used to find none and terminate the process. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>--instead of forwarding it 368fd2c527--reaches neither the project args nor the script 70b68b98a5Merged into master as
eefd1df(Merge branch 'claude/issue-41'), right after #40; it merged cleanly. #41 was closed by the fix commit. Closed by hand because the repository does not allow marking a PR as manually merged.Pull request closed