From 060ab4380fd18b1798244a8efe61a90082e2c7b9 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sat, 31 Oct 2026 05:09:51 +0100 Subject: [PATCH 1/7] fix: give a project file its args only for the CrafterBuildProject call 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 --- implementations/Crafter.Build-Platform.cpp | 44 ++++++++++++---------- interfaces/Crafter.Build-External.cppm | 25 +++++++----- 2 files changed, 40 insertions(+), 29 deletions(-) diff --git a/implementations/Crafter.Build-Platform.cpp b/implementations/Crafter.Build-Platform.cpp index 1d21c9c..f93e9f2 100644 --- a/implementations/Crafter.Build-Platform.cpp +++ b/implementations/Crafter.Build-Platform.cpp @@ -413,9 +413,10 @@ namespace { struct ProjectFileFrame { fs::path project; - // What LoadProject was given, for ProjectArgs(). A view is enough: - // the caller's storage outlives LoadProject, and the frame is popped - // before LoadProject returns. + // What LoadProject was given, for ProjectArgs(), and only once + // CrafterBuildProject is about to be called (see LoadProject). A view + // is enough: the caller's storage outlives LoadProject, and the frame + // is popped before LoadProject returns. std::span args; std::vector dependencies; }; @@ -430,10 +431,7 @@ namespace { // already on the stack is a cycle, which used to recurse forever. class ProjectFileGuard { public: - // The args are recorded with the frame rather than just before - // CrafterBuildProject runs, so a spec built while dlopen runs the - // project file's static initialisers sees them too. - ProjectFileGuard(const fs::path& absProject, std::span args) { + explicit ProjectFileGuard(const fs::path& absProject) { for (const ProjectFileFrame& frame : LoadingFrames) { if (frame.project != absProject) continue; std::string chain; @@ -441,7 +439,7 @@ namespace { throw std::runtime_error(std::format("circular project dependency: {}{}", chain, absProject.string())); } index_ = LoadingFrames.size(); - LoadingFrames.push_back({ absProject, args, {} }); + LoadingFrames.push_back({ absProject, {}, {} }); } ~ProjectFileGuard() { LoadingFrames.pop_back(); } ProjectFileGuard(const ProjectFileGuard&) = delete; @@ -503,16 +501,15 @@ namespace { std::set libs; for (const PragmaDependency& pragma : pragmas) { - // Host tooling is loaded with none of the consumer's args, so both - // specs pass `.args = {}` instead of defaulting to them. The tool - // is built for the host whatever the consumer targets: given the - // consumer's --target=, its project.cpp would pick sources and - // flags for the wrong triple before ForceHostTooling resets the - // fields, and the consumer's own flags would hash it apart from - // the build every other consumer of the tool shares. + // Neither spec sets args, and the tool still gets none of the + // consumer's: this pre-pass runs before LoadProject hands the + // frame its args, so ProjectArgs() is empty. Given the consumer's + // --target=, the tool's project.cpp would pick sources and flags + // for the wrong triple, and its flags would split the tool's + // variant from the one every other consumer shares. Configuration* cfg = nullptr; if (IsGitSource(pragma.source)) { - GitProjectSpec spec{ .source = { pragma.source, pragma.branch, pragma.commit }, .args = {} }; + GitProjectSpec spec{ .source = { pragma.source, pragma.branch, pragma.commit } }; if (!pragma.project.empty()) spec.projectFile = pragma.project; cfg = GitProject(spec); } else { @@ -526,7 +523,7 @@ namespace { PragmaError(absProject, pragma.line, "'project' only applies to a directory source"); } if (!fs::exists(local)) PragmaError(absProject, pragma.line, std::format("no such project file: {}", local.string())); - cfg = LocalProject({ .projectFile = local, .args = {} }); + cfg = LocalProject({ .projectFile = local }); } if (std::ranges::contains(out.configs, cfg)) continue; @@ -902,7 +899,7 @@ namespace { Configuration Crafter::LoadProject(const fs::path& projectFile, std::span args) { RequireToolchain(); fs::path absProject = fs::canonical(projectFile); - ProjectFileGuard guard(absProject, args); + ProjectFileGuard guard(absProject); fs::path buildDir = absProject.parent_path() / "build"; if (!fs::exists(buildDir)) { fs::create_directories(buildDir); @@ -974,6 +971,9 @@ Configuration Crafter::LoadProject(const fs::path& projectFile, std::span ProjectArgs(); // Specification for a sibling crafter-build project to fetch and depend on. From e6363b95fed613b57d3a0c30b0b99ba51271bfbd Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sat, 31 Oct 2026 05:10:02 +0100 Subject: [PATCH 2/7] test: specs and ProjectDependency() in a project file's static initialisers ProjectArgs gets a sibling that makes its own sibling's spec at namespace scope. Before the fix that spec took the args the file was first loaded with; now it takes none on every load. The checks go through one CheckLoaded helper, which prints what each sibling was given, so a failure says which args a wrong frame handed over. ProjectDependency loads a fixture whose static initialiser asks for its pragma dependency, in a child, because a throw there terminates the process: before the fix it found none. The check that the dependency gets none of the consumer's args moves into the first load, which now passes the app an arg it ignores; the separate load could not fail its own check, and its early return skipped every later one. Co-Authored-By: Claude Opus 5.5 --- tests/ProjectArgs/fixture/app/project.cpp | 8 +-- tests/ProjectArgs/fixture/static/project.cpp | 26 ++++++++++ tests/ProjectArgs/main.cpp | 49 ++++++++++++------- .../fixture/static-dep/project.cpp | 22 +++++++++ tests/ProjectDependency/main.cpp | 33 +++++++------ 5 files changed, 102 insertions(+), 36 deletions(-) create mode 100644 tests/ProjectArgs/fixture/static/project.cpp create mode 100644 tests/ProjectDependency/fixture/static-dep/project.cpp diff --git a/tests/ProjectArgs/fixture/app/project.cpp b/tests/ProjectArgs/fixture/app/project.cpp index 353c592..7b51816 100644 --- a/tests/ProjectArgs/fixture/app/project.cpp +++ b/tests/ProjectArgs/fixture/app/project.cpp @@ -6,12 +6,14 @@ import Crafter.Build; using namespace Crafter; // Loads the same sibling three ways: with its args left at their default, -// with nothing forwarded, and with a list of its own. The test reads what -// each copy received back out of cfg.dependencies. +// with nothing forwarded, and with a list of its own. Then a sibling that +// makes its own sibling's spec at namespace scope. The test reads what each +// copy received back out of cfg.dependencies. extern "C" Configuration CrafterBuildProject(std::span args) { Configuration* inherited = LocalProject({ .projectFile = "../lib/project.cpp" }); Configuration* none = LocalProject({ .projectFile = "../lib/project.cpp", .args = {} }); Configuration* replaced = LocalProject({ .projectFile = "../lib/project.cpp", .args = { "--only" } }); + Configuration* fromStatic = LocalProject({ .projectFile = "../static/project.cpp" }); Configuration cfg; cfg.path = "./"; @@ -19,7 +21,7 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.outputName = "app"; cfg.type = ConfigurationType::Executable; ApplyStandardArgs(cfg, args); - cfg.dependencies = { inherited, none, replaced }; + cfg.dependencies = { inherited, none, replaced, fromStatic }; // GitProjectSpec takes the same default. Checked on the spec itself, // since a GitProject would need a repository to clone. const GitProjectSpec git{ .source = { .url = "unused" } }; diff --git a/tests/ProjectArgs/fixture/static/project.cpp b/tests/ProjectArgs/fixture/static/project.cpp new file mode 100644 index 0000000..84f75bd --- /dev/null +++ b/tests/ProjectArgs/fixture/static/project.cpp @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +import std; +import Crafter.Build; +using namespace Crafter; + +// Makes its sibling's spec at namespace scope, so the spec's args default +// while LoadProject dlopens this file: before CrafterBuildProject is called, +// and on the first load of the file only. +namespace { + const LocalProjectSpec LeafSpec{ .projectFile = "../leaf/project.cpp" }; +} + +extern "C" Configuration CrafterBuildProject(std::span args) { + Configuration* leaf = LocalProject(LeafSpec); + + Configuration cfg; + cfg.path = "./"; + cfg.name = "static"; + cfg.outputName = "static"; + cfg.type = ConfigurationType::LibraryStatic; + ApplyStandardArgs(cfg, args); + cfg.dependencies = { leaf }; + return cfg; +} diff --git a/tests/ProjectArgs/main.cpp b/tests/ProjectArgs/main.cpp index 4b47938..6bf4005 100644 --- a/tests/ProjectArgs/main.cpp +++ b/tests/ProjectArgs/main.cpp @@ -15,7 +15,10 @@ using namespace Crafter; // release while the project built --debug. The fixture is a chain // app -> lib -> leaf of LocalProjects; app also loads lib with an empty and // with a replaced list, and checks the GitProjectSpec default itself, which -// a clone would need a repository for. +// a clone would need a repository for. A spec made at namespace scope gets +// no args: made while dlopen runs the file's static initialisers, which +// happens on its first load only, it would otherwise keep that load's args +// for every later one. namespace { std::int32_t Failures = 0; @@ -26,10 +29,21 @@ namespace { } } - // The single sibling lib and leaf declare, or nullptr. + // The single sibling lib, static and leaf declare, or nullptr. const Configuration* OnlyDependency(const Configuration& cfg) { return cfg.dependencies.size() == 1 ? cfg.dependencies[0] : nullptr; } + + // What a sibling was loaded with, as ApplyStandardArgs split it up: + // --debug into cfg.debug, the uninterpreted rest into cfg.projectArgs. + // Printed either way, so a failure says which args the sibling read. + void CheckLoaded(const Configuration* cfg, bool debug, const std::vector& rest, std::string_view what) { + if (!cfg) { + Check(false, std::format("{} (no such configuration)", what)); + return; + } + Check(cfg->debug == debug && cfg->projectArgs == rest, std::format("{} (debug {}, projectArgs {})", what, cfg->debug, cfg->projectArgs)); + } } int main() { @@ -60,24 +74,25 @@ int main() { try { Configuration cfg = LoadProject("project.cpp", args); Check(std::ranges::contains(cfg.compileFlags, "-DGIT_DEFAULT=1"), "a GitProjectSpec's args default to the project's own"); - Check(cfg.dependencies.size() == 3, std::format("the project declared its three siblings (got {})", cfg.dependencies.size())); - if (cfg.dependencies.size() == 3) { - const Configuration& inherited = *cfg.dependencies[0]; - const Configuration& none = *cfg.dependencies[1]; - const Configuration& replaced = *cfg.dependencies[2]; - Check(inherited.debug && inherited.projectArgs == flavor, std::format("a LocalProject without .args gets the project's args (debug {}, projectArgs {})", inherited.debug, inherited.projectArgs)); - Check(!none.debug && none.projectArgs.empty(), std::format("`.args = {{}}` forwards nothing (debug {}, projectArgs {})", none.debug, none.projectArgs)); - Check(!replaced.debug && replaced.projectArgs == only, std::format("an explicit list replaces the args rather than adding to them (debug {}, projectArgs {})", replaced.debug, replaced.projectArgs)); - Check(&inherited != &none && &inherited != &replaced && &none != &replaced, "each list loads its own configuration"); + Check(cfg.dependencies.size() == 4, std::format("the project declared its four siblings (got {})", cfg.dependencies.size())); + if (cfg.dependencies.size() == 4) { + const Configuration* inherited = cfg.dependencies[0]; + const Configuration* none = cfg.dependencies[1]; + const Configuration* replaced = cfg.dependencies[2]; + const Configuration* fromStatic = cfg.dependencies[3]; + CheckLoaded(inherited, true, flavor, "a LocalProject without .args gets the project's args"); + CheckLoaded(none, false, {}, "`.args = {}` forwards nothing"); + CheckLoaded(replaced, false, only, "an explicit list replaces the args rather than adding to them"); + Check(inherited != none && inherited != replaced && none != replaced, "each list loads its own configuration"); // One sibling further down, the same default passes on what the // middle project was given, not what the top one was. - const Configuration* inheritedLeaf = OnlyDependency(inherited); - Check(inheritedLeaf && inheritedLeaf->debug && inheritedLeaf->projectArgs == flavor, "the project's args reach its sibling's sibling"); - const Configuration* noneLeaf = OnlyDependency(none); - Check(noneLeaf && !noneLeaf->debug && noneLeaf->projectArgs.empty(), "a sibling loaded with nothing passes nothing on"); - const Configuration* replacedLeaf = OnlyDependency(replaced); - Check(replacedLeaf && !replacedLeaf->debug && replacedLeaf->projectArgs == only, "a sibling passes on the list it was given"); + CheckLoaded(OnlyDependency(*inherited), true, flavor, "the project's args reach its sibling's sibling"); + CheckLoaded(OnlyDependency(*none), false, {}, "a sibling loaded with nothing passes nothing on"); + CheckLoaded(OnlyDependency(*replaced), false, only, "a sibling passes on the list it was given"); + + CheckLoaded(fromStatic, true, flavor, "the sibling with a namespace-scope spec is itself loaded with the args"); + CheckLoaded(OnlyDependency(*fromStatic), false, {}, "a spec made at namespace scope forwards nothing"); } } catch (const std::exception& e) { fs::current_path(cwd); diff --git a/tests/ProjectDependency/fixture/static-dep/project.cpp b/tests/ProjectDependency/fixture/static-dep/project.cpp new file mode 100644 index 0000000..2090db5 --- /dev/null +++ b/tests/ProjectDependency/fixture/static-dep/project.cpp @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +import std; +import Crafter.Build; +#pragma crafter dependency "../greeter" +using namespace Crafter; + +// Asks for its dependency at namespace scope, so the call runs while +// LoadProject dlopens this file, before CrafterBuildProject is called. +namespace { + Configuration* const Greeter = ProjectDependency("greeter"); +} + +extern "C" Configuration CrafterBuildProject(std::span) { + Configuration cfg; + cfg.path = "./"; + cfg.name = std::format("static {}", Greeter->name); + cfg.outputName = "static-dep"; + cfg.type = ConfigurationType::Executable; + return cfg; +} diff --git a/tests/ProjectDependency/main.cpp b/tests/ProjectDependency/main.cpp index de01130..595d17c 100644 --- a/tests/ProjectDependency/main.cpp +++ b/tests/ProjectDependency/main.cpp @@ -90,14 +90,19 @@ int main(int argc, char** argv) { const fs::path app = scratch / "app" / "project.cpp"; const fs::path appSo = scratch / "app" / "build" / "project.so"; - std::array noArgs = {}; Check(IsTopLevelProject(), "outside any project file counts as top level"); // The dependency is fetched, built and linked before project.cpp runs, - // and ProjectDependency() hands it back by either name. + // and ProjectDependency() hands it back by either name. The app ignores + // the arg it is loaded with, and the dependency must not get it: host + // tooling gets none of the consumer's args, although a LocalProject spec + // defaults to them. The consumer's flags would split the tool's variant + // from every other consumer's, and its --target= would have the tool's + // project.cpp choose for the wrong triple. try { - Configuration cfg = LoadProject(app, noArgs); + std::array flavored = { "--flavor=x" }; + Configuration cfg = LoadProject(app, flavored); Check(cfg.name == "hello from greeter", std::format("project.cpp imported and called the dependency's module (got '{}')", cfg.name)); Check(std::ranges::contains(cfg.compileFlags, "-DTOP_LEVEL=1"), "the project LoadProject was pointed at sees itself as top level"); Check(cfg.dependencies.size() == 2, "ProjectDependency resolved by module name and by configuration name"); @@ -109,6 +114,7 @@ int main(int argc, char** argv) { Check(dep.march == "native", "the environment's march was overridden for host tooling"); Check(std::ranges::contains(dep.compileFlags, "-fPIC"), "the dependency archive was built position-independent"); Check(std::ranges::contains(dep.compileFlags, "-DTOP_LEVEL=0"), "a dependency's project file does not see itself as top level"); + Check(dep.projectArgs.empty(), std::format("the dependency got none of the consumer's args (got {})", dep.projectArgs)); Check(fs::exists(ExpectedOutput(dep)), "the dependency archive exists"); } } catch (const std::exception& e) { @@ -116,20 +122,15 @@ int main(int argc, char** argv) { return 1; } - // Host tooling gets none of the consumer's args, although a LocalProject - // spec defaults to them: the consumer's flags would split the tool's - // variant from every other consumer's, and its --target= would have the - // tool's project.cpp choose for the wrong triple. - try { - std::array flavored = { "--flavor=x" }; - Configuration cfg = LoadProject(app, flavored); - const bool found = !cfg.dependencies.empty(); - Check(found, "a consumer loaded with args still finds its pragma dependency"); - if (found) Check(cfg.dependencies[0]->projectArgs.empty(), std::format("a pragma dependency is loaded without the consumer's args (got {})", cfg.dependencies[0]->projectArgs)); - } catch (const std::exception& e) { - std::println(std::cerr, "FAIL: LoadProject with args threw: {}", e.what()); - return 1; + // A static initialiser of the project file runs while LoadProject + // dlopens it, before CrafterBuildProject is called, and can already ask + // for a dependency. In a child, because a static initialiser that + // throws terminates the process. + { + const std::string loaded = LoadInChild(scratch / "static-dep" / "project.cpp"); + Check(loaded == "static greeter", std::format("ProjectDependency() works in a static initialiser (got '{}')", loaded)); } + Check(fs::exists(appSo), "project.so was produced"); if (!fs::exists(appSo)) return 1; const fs::file_time_type firstSo = fs::last_write_time(appSo); From e420c9f95861de563ef4cd263527f4e0d5f90603 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sat, 31 Oct 2026 05:10:21 +0100 Subject: [PATCH 3/7] fix: keep host tooling out of the project cache the target uses ForceHostTooling rewrites a `#pragma crafter dependency` and everything it loads in place: host target, march native, release, -fPIC. Those are entries of the project cache like any other, keyed by project file and args alone. A project that loaded the same sibling as its tool, with the same args, got the rewritten entry back. That happens whenever the project gets no args, since host tooling gets none either: a consumer built for x86-64-v2 then imported a sibling's BMI compiled with -march=native, and clang refused it. Any arg on the command line split the keys and hid it. A thread-local count of pragma loads now marks the tool's load and every load inside it, its project file's siblings included, and GitProject and LocalProject put that mark in the key. The clone stays shared; only the Configuration is separate, as for two arg lists. A module-internal function rather than a spec field, because the siblings of a tool are loaded by the tool's own project.cpp, which cannot know it is host tooling. Co-Authored-By: Claude Opus 5.5 --- implementations/Crafter.Build-External.cpp | 7 +-- implementations/Crafter.Build-Platform.cpp | 58 ++++++++++++++++------ interfaces/Crafter.Build-Platform.cppm | 6 +++ 3 files changed, 52 insertions(+), 19 deletions(-) diff --git a/implementations/Crafter.Build-External.cpp b/implementations/Crafter.Build-External.cpp index 6e12988..ff0c766 100644 --- a/implementations/Crafter.Build-External.cpp +++ b/implementations/Crafter.Build-External.cpp @@ -360,11 +360,12 @@ Configuration* Crafter::GitProject(const GitProjectSpec& spec) { // source revision (forwarded args don't change what gets cloned, just // how the dep's project.cpp interprets them at runtime), while the // in-memory Configuration is keyed by the full spec so each (args) - // permutation gets its own Configuration object. + // permutation gets its own Configuration object, and host tooling one + // apart from the target's even for the same args. std::string srcKey = std::format("git|{}|{}|{}|{}", spec.source.url, spec.source.branch, spec.source.commit, spec.projectFile.string()); std::string srcHash = std::format("{:016x}", std::hash{}(srcKey)); - std::string fullKey = srcKey; + std::string fullKey = std::format("{}{}", LoadingHostTooling() ? "host|" : "", srcKey); for (const std::string& a : spec.args) { fullKey += '|'; fullKey += a; @@ -449,7 +450,7 @@ Configuration* Crafter::LocalProject(const LocalProjectSpec& spec) { } fs::path canonical = fs::canonical(projectPath); - std::string fullKey = std::format("local|{}", canonical.string()); + std::string fullKey = std::format("{}local|{}", LoadingHostTooling() ? "host|" : "", canonical.string()); for (const std::string& a : spec.args) { fullKey += '|'; fullKey += a; diff --git a/implementations/Crafter.Build-Platform.cpp b/implementations/Crafter.Build-Platform.cpp index f93e9f2..b727cbb 100644 --- a/implementations/Crafter.Build-Platform.cpp +++ b/implementations/Crafter.Build-Platform.cpp @@ -451,6 +451,21 @@ namespace { std::size_t index_ = 0; }; + // How many `#pragma crafter dependency` loads this thread is inside. The + // tool's project file runs within its load, and so does every sibling it + // loads in turn, which is what LoadingHostTooling() reports to GitProject + // and LocalProject. Per thread like LoadingFrames: a sibling loaded on a + // thread of the tool's own goes uncounted, as it gets no ProjectArgs(). + thread_local std::int32_t HostToolingLoads = 0; + + class HostToolingScope { + public: + HostToolingScope() { ++HostToolingLoads; } + ~HostToolingScope() { --HostToolingLoads; } + HostToolingScope(const HostToolingScope&) = delete; + HostToolingScope& operator=(const HostToolingScope&) = delete; + }; + // A pre-dependency is host tooling: project.so is loaded into the running // crafter-build, so the dependency is built for the host and against the // same PCM cache LoadProject populates — the hostConfig LoadProject itself @@ -506,24 +521,31 @@ namespace { // frame its args, so ProjectArgs() is empty. Given the consumer's // --target=, the tool's project.cpp would pick sources and flags // for the wrong triple, and its flags would split the tool's - // variant from the one every other consumer shares. + // variant from the one every other consumer shares. The scope + // keeps the tool and everything it loads in a project cache of + // their own: ForceHostTooling rewrites them in place, and a + // consumer loading the same sibling for itself got that host + // build back whenever the two cache keys matched. Configuration* cfg = nullptr; - if (IsGitSource(pragma.source)) { - GitProjectSpec spec{ .source = { pragma.source, pragma.branch, pragma.commit } }; - if (!pragma.project.empty()) spec.projectFile = pragma.project; - cfg = GitProject(spec); - } else { - if (!pragma.branch.empty() || !pragma.commit.empty()) PragmaError(absProject, pragma.line, "'branch' and 'commit' only apply to a git source"); - // Relative to the project file, not the cwd: LoadProject runs - // from wherever the consumer was invoked. - fs::path local = (absProject.parent_path() / pragma.source).lexically_normal(); - if (fs::is_directory(local)) { - local /= pragma.project.empty() ? fs::path("project.cpp") : fs::path(pragma.project); - } else if (!pragma.project.empty()) { - PragmaError(absProject, pragma.line, "'project' only applies to a directory source"); + { + HostToolingScope hostTooling; + if (IsGitSource(pragma.source)) { + GitProjectSpec spec{ .source = { pragma.source, pragma.branch, pragma.commit } }; + if (!pragma.project.empty()) spec.projectFile = pragma.project; + cfg = GitProject(spec); + } else { + if (!pragma.branch.empty() || !pragma.commit.empty()) PragmaError(absProject, pragma.line, "'branch' and 'commit' only apply to a git source"); + // Relative to the project file, not the cwd: LoadProject runs + // from wherever the consumer was invoked. + fs::path local = (absProject.parent_path() / pragma.source).lexically_normal(); + if (fs::is_directory(local)) { + local /= pragma.project.empty() ? fs::path("project.cpp") : fs::path(pragma.project); + } else if (!pragma.project.empty()) { + PragmaError(absProject, pragma.line, "'project' only applies to a directory source"); + } + if (!fs::exists(local)) PragmaError(absProject, pragma.line, std::format("no such project file: {}", local.string())); + cfg = LocalProject({ .projectFile = local }); } - if (!fs::exists(local)) PragmaError(absProject, pragma.line, std::format("no such project file: {}", local.string())); - cfg = LocalProject({ .projectFile = local }); } if (std::ranges::contains(out.configs, cfg)) continue; @@ -597,6 +619,10 @@ std::vector Crafter::ProjectArgs() { return std::vector(args.begin(), args.end()); } +bool Crafter::LoadingHostTooling() { + return HostToolingLoads > 0; +} + namespace { // The one reading of a wait status (pclose, system) for every helper // that runs a command: a signal death is reported the way a shell diff --git a/interfaces/Crafter.Build-Platform.cppm b/interfaces/Crafter.Build-Platform.cppm index 2601275..eb9110c 100644 --- a/interfaces/Crafter.Build-Platform.cppm +++ b/interfaces/Crafter.Build-Platform.cppm @@ -75,6 +75,12 @@ namespace Crafter { // that library as a dependency, the test's GitProject loads crafter-build's // project file in turn and the two recurse until the cycle guard throws. export bool IsTopLevelProject(); + // True while this thread loads a `#pragma crafter dependency`: the tool's + // project file and every sibling it loads in turn. GitProject and + // LocalProject key the project cache on it, so host tooling, which + // ForceHostTooling rewrites in place, never shares a Configuration with a + // load for the target. + bool LoadingHostTooling(); // Resolves the directory holding distributed runtime assets (Crafter.Build // module sources, wasi-runtime/, etc). Honors CRAFTER_BUILD_HOME; otherwise // derives /share/crafter-build from the running executable's path. From 95896a7ddd94266048783a4816f1575dad9a7a94 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sat, 31 Oct 2026 05:10:28 +0100 Subject: [PATCH 4/7] test: a project and its pragma dependency load a shared sibling apart The fixture app has `#pragma crafter dependency "../tool"` and loads ../shared for itself, which the tool loads too; the app is loaded with no args, the case where the two cache keys used to match. With CRAFTER_BUILD_MARCH=x86-64-v2, the app's copy of shared has to keep that march and no -fPIC, and the tool's copy has to be host tooling. Before the fix the app got the tool's copy back: one Configuration, with march native and -fPIC. Co-Authored-By: Claude Opus 5.5 --- project.cpp | 2 + .../fixture/app/project.cpp | 22 ++++++ .../fixture/shared/interfaces/Shared.cppm | 5 ++ .../fixture/shared/project.cpp | 23 ++++++ .../fixture/tool/interfaces/Tool.cppm | 6 ++ .../fixture/tool/project.cpp | 24 ++++++ tests/HostToolingSibling/main.cpp | 79 +++++++++++++++++++ 7 files changed, 161 insertions(+) create mode 100644 tests/HostToolingSibling/fixture/app/project.cpp create mode 100644 tests/HostToolingSibling/fixture/shared/interfaces/Shared.cppm create mode 100644 tests/HostToolingSibling/fixture/shared/project.cpp create mode 100644 tests/HostToolingSibling/fixture/tool/interfaces/Tool.cppm create mode 100644 tests/HostToolingSibling/fixture/tool/project.cpp create mode 100644 tests/HostToolingSibling/main.cpp diff --git a/project.cpp b/project.cpp index 4ea9679..bfd3e9b 100644 --- a/project.cpp +++ b/project.cpp @@ -282,6 +282,8 @@ extern "C" Configuration CrafterBuildProject(std::span a .LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl"); cfg.AddTest("ProjectArgs").Dependencies({ CrafterBuildLib.get() }) .LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl"); + cfg.AddTest("HostToolingSibling").Dependencies({ CrafterBuildLib.get() }) + .LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl"); cfg.AddTest("Script").Dependencies({ CrafterBuildLib.get() }) .LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl"); cfg.AddTest("ConcurrentDependencyReset").Dependencies({ CrafterBuildLib.get() }); diff --git a/tests/HostToolingSibling/fixture/app/project.cpp b/tests/HostToolingSibling/fixture/app/project.cpp new file mode 100644 index 0000000..d9f107a --- /dev/null +++ b/tests/HostToolingSibling/fixture/app/project.cpp @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +import std; +import Crafter.Build; +#pragma crafter dependency "../tool" +using namespace Crafter; + +// Loads for itself the sibling its tool loads, by the same path and, when +// the app gets no args, with the same args. +extern "C" Configuration CrafterBuildProject(std::span args) { + Configuration* shared = LocalProject({ .projectFile = "../shared/project.cpp" }); + + Configuration cfg; + cfg.path = "./"; + cfg.name = "app"; + cfg.outputName = "app"; + cfg.type = ConfigurationType::Executable; + ApplyStandardArgs(cfg, args); + cfg.dependencies = { shared, ProjectDependency("tool") }; + return cfg; +} diff --git a/tests/HostToolingSibling/fixture/shared/interfaces/Shared.cppm b/tests/HostToolingSibling/fixture/shared/interfaces/Shared.cppm new file mode 100644 index 0000000..af40390 --- /dev/null +++ b/tests/HostToolingSibling/fixture/shared/interfaces/Shared.cppm @@ -0,0 +1,5 @@ +export module Shared; +import std; +export std::int32_t SharedValue() { + return 7; +} diff --git a/tests/HostToolingSibling/fixture/shared/project.cpp b/tests/HostToolingSibling/fixture/shared/project.cpp new file mode 100644 index 0000000..3cd5bdc --- /dev/null +++ b/tests/HostToolingSibling/fixture/shared/project.cpp @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +// The sibling both the tool and the app load. ApplyStandardArgs on purpose: +// it reads CRAFTER_BUILD_MARCH from the environment, which is how the test +// tells the app's copy from the one built as host tooling. +extern "C" Configuration CrafterBuildProject(std::span args) { + Configuration cfg; + cfg.path = "./"; + cfg.name = "shared"; + cfg.outputName = "shared"; + cfg.type = ConfigurationType::LibraryStatic; + ApplyStandardArgs(cfg, args); + std::array ifaces = { "interfaces/Shared" }; + std::array impls = {}; + cfg.GetInterfacesAndImplementations(ifaces, impls); + return cfg; +} diff --git a/tests/HostToolingSibling/fixture/tool/interfaces/Tool.cppm b/tests/HostToolingSibling/fixture/tool/interfaces/Tool.cppm new file mode 100644 index 0000000..c6d5490 --- /dev/null +++ b/tests/HostToolingSibling/fixture/tool/interfaces/Tool.cppm @@ -0,0 +1,6 @@ +export module Tool; +import std; +import Shared; +export std::int32_t ToolValue() { + return SharedValue(); +} diff --git a/tests/HostToolingSibling/fixture/tool/project.cpp b/tests/HostToolingSibling/fixture/tool/project.cpp new file mode 100644 index 0000000..51ad781 --- /dev/null +++ b/tests/HostToolingSibling/fixture/tool/project.cpp @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +// The app's `#pragma crafter dependency`, with a sibling of its own. +extern "C" Configuration CrafterBuildProject(std::span args) { + Configuration* shared = LocalProject({ .projectFile = "../shared/project.cpp" }); + + Configuration cfg; + cfg.path = "./"; + cfg.name = "tool"; + cfg.outputName = "tool"; + cfg.type = ConfigurationType::LibraryStatic; + ApplyStandardArgs(cfg, args); + cfg.dependencies = { shared }; + std::array ifaces = { "interfaces/Tool" }; + std::array impls = {}; + cfg.GetInterfacesAndImplementations(ifaces, impls); + return cfg; +} diff --git a/tests/HostToolingSibling/main.cpp b/tests/HostToolingSibling/main.cpp new file mode 100644 index 0000000..f6ad160 --- /dev/null +++ b/tests/HostToolingSibling/main.cpp @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +#include +import std; +import Crafter.Build; +namespace fs = std::filesystem; +using namespace Crafter; + +// A sibling that a project and its `#pragma crafter dependency` both load +// is two configurations: the tool's copy is host tooling, built for the +// host and position-independent, and the project's is built the way the +// project builds. ForceHostTooling rewrites the tool's graph in place, and +// the project cache used to hand the project that same rewritten +// Configuration whenever the two loads had the same key, as they do when +// the project gets no args. The project then built against a sibling +// compiled with -march=native while itself building for x86-64-v2, and +// clang refused the sibling's BMI. +namespace { + std::int32_t Failures = 0; + + void Check(bool cond, std::string_view msg) { + if (!cond) { + std::println(std::cerr, "FAIL: {}", msg); + ++Failures; + } + } +} + +int main() { + const fs::path installedHome = GetCrafterBuildHome(); + if (!fs::exists(installedHome / "Crafter.Build.cppm")) { + std::println(std::cerr, "SKIP: no module sources at {}", installedHome.string()); + return 77; + } + + // Staged because LoadProject drops a build/ tree next to every project + // file it compiles, and the pre-pass builds the tool and its sibling. + const fs::path fixture = fs::current_path() / "tests" / "HostToolingSibling" / "fixture"; + const fs::path scratch = fs::temp_directory_path() / "crafter-build-host-tooling-sibling"; + std::error_code ec; + fs::remove_all(scratch, ec); + fs::create_directories(scratch); + fs::copy(fixture, scratch, fs::copy_options::recursive); + + // The march the project builds for. Host tooling overrides it to + // native, so a sibling that comes back with native was rewritten. + setenv("CRAFTER_BUILD_MARCH", "x86-64-v2", 1); + + // From the app's own directory, as crafter-build runs it: the fixture's + // LocalProject paths are relative to the working directory. + const fs::path cwd = fs::current_path(); + fs::current_path(scratch / "app"); + std::array noArgs = {}; + try { + Configuration cfg = LoadProject("project.cpp", noArgs); + Check(cfg.dependencies.size() == 2, std::format("the app declared its sibling and its tool (got {})", cfg.dependencies.size())); + if (cfg.dependencies.size() == 2) { + const Configuration* shared = cfg.dependencies[0]; + const Configuration* tool = cfg.dependencies[1]; + const Configuration* toolShared = tool->dependencies.size() == 1 ? tool->dependencies[0] : nullptr; + Check(toolShared != nullptr, "the tool declared its sibling"); + Check(shared != toolShared, "the app's sibling is not the tool's copy of it"); + Check(shared->march == "x86-64-v2", std::format("the app's sibling keeps the app's march (got {})", shared->march)); + Check(!std::ranges::contains(shared->compileFlags, "-fPIC"), "the app's sibling is not built as host tooling"); + if (toolShared) { + Check(toolShared->march == "native", std::format("the tool's copy is built for the host (got {})", toolShared->march)); + Check(std::ranges::contains(toolShared->compileFlags, "-fPIC"), "the tool's copy is position-independent"); + } + } + } catch (const std::exception& e) { + fs::current_path(cwd); + std::println(std::cerr, "FAIL: LoadProject threw: {}", e.what()); + return 1; + } + fs::current_path(cwd); + + return Failures > 0 ? 1 : 0; +} From 368fd2c527177d019fd81f2e330472c7203c85b2 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sat, 31 Oct 2026 05:10:38 +0100 Subject: [PATCH 5/7] fix(cli): end the options at `--` instead of forwarding it The help has advertised `[options] [-- project-args...]`, but Run never looked for `--`. It fell through to projectArgs as a word of its own, and the words after it were still read as this tool's options and verbs: `crafter-build -- -v` turned verbose output on. In projectArgs `--` changed the project's variant hash, and now that siblings inherit the args, the hash of every sibling down the chain, each building into directories of its own that the project's clean never removes. `--` now ends this tool's options and goes nowhere itself. Every later word is an operand of the verb, as POSIX has it: a project arg for a build, a glob for test, lint and format, the script's name and then its args for run. A glob rather than a project arg after `test` because `crafter-build test -- 'House*'` is how such a filter gets typed; a project arg goes before the `--`. Co-Authored-By: Claude Opus 5.5 --- implementations/Crafter.Build-Clang.cpp | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/implementations/Crafter.Build-Clang.cpp b/implementations/Crafter.Build-Clang.cpp index 550023e..f93451a 100644 --- a/implementations/Crafter.Build-Clang.cpp +++ b/implementations/Crafter.Build-Clang.cpp @@ -1651,6 +1651,10 @@ Build options: -v, --verbose Verbose progress output. -q, --quiet Suppress progress output. --project= Path to the project file (default: ./project.cpp). + -- Ends this tool's options. Every later word is a + project arg, even one that looks like an option or + a verb; after test, lint or format, a glob; after + run, the script's name and then its args. Test options (after the `test` subcommand): --list Enumerate matching tests without running them. @@ -1762,6 +1766,9 @@ std::int32_t Crafter::Run(std::int32_t argc, char** argv) { RunLintOptions lintOpts; RunScriptOptions scriptOpts; Progress::Verbosity verbosity = Progress::Verbosity::Default; + // Set by `--`: every later word is an operand of the verb, never an + // option or a verb of this tool. + bool operandsOnly = false; for (std::int32_t i = 1; i < argc; ++i) { std::string_view arg = argv[i]; @@ -1773,11 +1780,25 @@ std::int32_t Crafter::Run(std::int32_t argc, char** argv) { // variant hash and so the directory the project builds into. if (scriptOpts.named) { scriptOpts.args.emplace_back(arg); - } else if (runScript && !arg.starts_with("-")) { + } else if (runScript && (operandsOnly || !arg.starts_with("-"))) { // The first word after `run`, even one that is a verb // elsewhere. Options before it keep their usual meaning. scriptOpts.name = arg; scriptOpts.named = true; + } else if (operandsOnly) { + if (runTests) { + testOpts.globs.emplace_back(arg); + } else if (runLint || runFormat) { + lintOpts.globs.emplace_back(arg); + } else { + projectArgs.push_back(arg); + } + } else if (arg == "--") { + // Ends this tool's options and is never forwarded itself. It + // used to land in projectArgs as a word of its own, giving the + // project a variant hash of its own, and every sibling project + // that inherits the args one as well. + operandsOnly = true; } else if (arg == "--version") { // Answered here, before the project file is looked for, so // it works in any directory and is never forwarded to From 70b68b98a5b1e584e50c29687ca5e3c4b47d8bde Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sat, 31 Oct 2026 05:10:38 +0100 Subject: [PATCH 6/7] test: `--` reaches neither the project args nor the script `run -- record -- a` has to run the record script with the args `--` and `a`: the first `--` ends the options, the second comes after the script's name and is the script's. The project args keep only what stood before `run`. And `run -- -x` has to look for a script called -x, since after `--` an option-like word is an operand. Before the fix the first `--` showed up in the project args, and `-x` joined it there while the bare `run` listed the scripts and succeeded. Co-Authored-By: Claude Opus 5.5 --- tests/Script/main.cpp | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/Script/main.cpp b/tests/Script/main.cpp index 8e200a8..472682d 100644 --- a/tests/Script/main.cpp +++ b/tests/Script/main.cpp @@ -218,6 +218,29 @@ int main() { Check(fs::exists(binSentinel) && fs::exists(buildSentinel), "`clean` as a script argument deleted nothing"); } + // `--` ends this tool's options and goes nowhere itself. In the + // project's args it gave the project a variant of its own, and every + // sibling that inherits the args one too. The word after it is an + // operand even when it looks like an option, here the script's name; + // after the name, a `--` is the script's like any other word. + { + fs::remove(scratch / "record.txt"); + const std::int32_t code = RunWith({"crafter-build", project, "--flavour=x", "run", "--", "record", "--", "a"}); + Check(code == 42, std::format("the script after `--` ran (got {})", code)); + const std::vector expected = { + "arg:--", "arg:a", + "projectArg:--flavour=x", + "debug:false", + "project:script-fixture", + }; + const std::vector record = ReadLines(scratch / "record.txt"); + Check(record == expected, "`--` reached neither the project args nor the script"); + if (record != expected) { + for (const std::string& line : record) std::println(std::cerr, " recorded: {}", line); + } + Check(RunWith({"crafter-build", project, "run", "--", "-x"}) == 1, "after `--` a word that looks like an option names the script"); + } + // The first word after `run` is the name even when it is a verb. { fs::remove(scratch / "record.txt"); From 84d9d666490e2d7044f470b0e50f84171eb9b427 Mon Sep 17 00:00:00 2001 From: Jorijn van der Graaf Date: Sat, 31 Oct 2026 05:10:48 +0100 Subject: [PATCH 7/7] docs: correct what the sibling-args docs promise The README suggested giving one sibling a list without a flag only the project reads, so that the sibling would not get a build directory of its own. When another path loads the same sibling with the full list, that filter is what gives it the second directory: two builds of one project, both in the program (#41). It now says to build such a list once and give it to every sibling. It also says the default is read when the spec is made, so a spec made at namespace scope, on another thread or in a script forwards nothing, and that cfg.projectArgs, the part ApplyStandardArgs did not interpret, is no substitute: it drops the target and the build type. The comments on ProjectArgs() and on Configuration::projectArgs say the same. The spec comment named --local as a flag the default carries down a chain of siblings. Through a GitProject hop it breaks: the clone has no checkouts beside it. The GitProject comment said the clone is keyed by the args; only the Configuration is, and every arg list builds in the one clone. AGENTS.md now notes that a stale harness gives no symptom for a changed default member initialiser: project.cpp silently gets the old default. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- README.md | 4 +++- interfaces/Crafter.Build-Clang.cppm | 4 +++- interfaces/Crafter.Build-External.cppm | 23 ++++++++++++++--------- 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ffd0057..d5227e8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,7 @@ Prerequisites on Linux: `clang` (with `libclang`, which the AST lint rules load Three things to know about this loop: - `bin/crafter-build` is the bootstrap binary and the harness for every command below. The self-host build writes to `bin/crafter.build-exe-/crafter-build` and does not replace it. -- Changes under `implementations/` are picked up by the self-host build and by the tests, which link the local library. Changes under `interfaces/` are not: the harness compiles against the copies in `share/crafter-build/` and the PCM cache built from them, so a new or changed declaration is invisible until you run `./build.sh` again. The symptom is `use of undeclared identifier` for the symbol you just added. +- Changes under `implementations/` are picked up by the self-host build and by the tests, which link the local library. Changes under `interfaces/` are not: the harness compiles against the copies in `share/crafter-build/` and the PCM cache built from them, so a new or changed declaration is invisible until you run `./build.sh` again. The symptom is `use of undeclared identifier` for the symbol you just added. A changed default member initialiser has no symptom at all: `project.cpp` compiles against the old one and quietly gets the old default, as `project.cpp`'s sibling loads do with a harness that predates `GitProjectSpec::args = ProjectArgs()`. - A new exported symbol that a `project.cpp` reaches on its default path also needs `./build.sh`: the old binary does not export it, and `LoadProject` fails with an undefined symbol. `./build.sh` clones and builds glslang on first run and takes several minutes; later runs are faster. `./bin/crafter-build clean` deletes `bin/` and `build/` without loading `project.cpp`. That takes `bin/crafter-build` itself with it, and `build/flatpak`, the state of the local Flatpak build (see "CI and publishing"): about 2 GB, and the next `run flatpak` rebuilds LLVM. Set `CRAFTER_BUILD_HOME` only when running a binary from outside this tree; `CRAFTER_BUILD_MARCH` and `CRAFTER_BUILD_MTUNE` default to `native`. diff --git a/README.md b/README.md index 89fc5de..2620f3f 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,9 @@ Configuration* sibling = LocalProject({ .projectFile = "../Crafter.Math/project. cfg.dependencies = { math }; ``` -The sibling's `CrafterBuildProject` gets the args this `project.cpp` was loaded with (`ProjectArgs()`), so `--target=`, `--march=`, `--debug` and the project's own flags reach every sibling down the chain. Set `.args` to send something else: `{}` for nothing, or a list built from the project's args, say one without a flag only this project reads (it would otherwise give the sibling a build directory of its own) or with an extra `--target=`. Releases up to 2.2.0 forward nothing when `.args` is left out. +The sibling's `CrafterBuildProject` gets the args this `project.cpp` was loaded with (`ProjectArgs()`), so `--target=`, `--march=`, `--debug` and the project's own flags reach every sibling down the chain. That is all of them: `cfg.projectArgs` holds only what `ApplyStandardArgs` did not interpret, and passing it on would drop the target and the build type. Set `.args` to send something else: `{}` for nothing, or a list built from the project's args, such as one without a flag only this project reads. Build such a list once and give it to every sibling. A project that two paths load with different lists is built twice, and the program ends up with both builds. Releases up to 2.2.0 forward nothing when `.args` is left out. + +The default is read when the spec is made, so make it inside `CrafterBuildProject`, on the thread that calls it. A spec made at namespace scope, on a thread of your own or in a script forwards nothing. Clones share the external cache and identical specs share one `Configuration`. An unpinned source is `git pull`ed on every invocation, set `.commit` for reproducible, offline-after-first-fetch builds. diff --git a/interfaces/Crafter.Build-Clang.cppm b/interfaces/Crafter.Build-Clang.cppm index 12be520..07a787a 100644 --- a/interfaces/Crafter.Build-Clang.cppm +++ b/interfaces/Crafter.Build-Clang.cppm @@ -483,7 +483,9 @@ export namespace Crafter { // cfg.files, say), and without it both flag settings share one bin dir // and interleave their outputs there. Populated by ApplyStandardArgs; // the args it does recognise are excluded, since their effect already - // shows up in target/march/mtune/debug/sysroot/type. + // shows up in target/march/mtune/debug/sysroot/type. For the same + // reason this is not what to hand a sibling project: ProjectArgs() is + // every arg, the recognised ones included. std::vector projectArgs; std::vector tests; // Lint rules for `crafter-build lint`. Populate via AddLintRule. diff --git a/interfaces/Crafter.Build-External.cppm b/interfaces/Crafter.Build-External.cppm index 317879c..23ebe6e 100644 --- a/interfaces/Crafter.Build-External.cppm +++ b/interfaces/Crafter.Build-External.cppm @@ -93,21 +93,26 @@ export namespace Crafter { // remote's default branch HEAD (sloppy but convenient), set branch to // track a branch tip, or set commit to pin to an immutable SHA. args are // what the dep's CrafterBuildProject receives. They default to this - // project's own, so --target=, --march=, --debug and flags of its own - // such as --local reach a whole chain of sibling projects without being - // passed. Set them to forward something else: a copy without one of this - // project's flags, one with an extra --target=, or {} for nothing. + // project's own, read when the spec is made, so --target=, --march=, + // --debug and the project's own flags reach every sibling down a chain + // without being passed. Set them to forward something else: {} for + // nothing, or a list built from this project's args, such as one without + // a flag only this project reads. Give such a list to every sibling: a + // project that two paths load with two different lists is built twice, + // and the program ends up with both builds. struct GitProjectSpec { GitSource source; fs::path projectFile = "project.cpp"; std::vector args = ProjectArgs(); }; - // Clones the spec's git URL into the per-project external cache (keyed by - // url+branch+commit+args+projectFile), recursively LoadProject's the - // remote's project.cpp, and returns a stable Configuration* you can drop - // into cfg.dependencies. The Configuration is owned by an internal cache - // for the lifetime of the build; identical specs share one Configuration. + // Clones the spec's git URL into the external cache, recursively + // LoadProject's the remote's project.cpp, and returns a stable + // Configuration* you can drop into cfg.dependencies. The clone is keyed by + // url+branch+commit+projectFile alone, so every arg list builds in the + // same working tree, each into variant directories of its own. The + // Configuration is owned by an internal cache for the lifetime of the + // build, keyed by the clone and the args: identical specs share one. Configuration* GitProject(const GitProjectSpec& spec); // Specification for a local sibling crafter-build project (e.g. an