Merge branch 'claude/forward-project-args': sibling projects inherit the args, lint with the lite rules

PR #40. GitProject and LocalProject default their args to the loading
project's own (ProjectArgs()), this tree lints with LintRules' lite
set, and the fixes from the branch's review: a project file gets its
args only for the CrafterBuildProject call, host tooling gets project
cache entries of its own, and `--` ends crafter-build's options.

The conflicts with claude/dependency-notes are all places both sides
added to:
- Platform.cppm and Platform.cpp: PragmaDependencies() beside
  ProjectArgs() and LoadingHostTooling(), all three kept.
- LoadProject: the record PragmaDependencies() reads moves up with the
  frame's dependencies, which this branch sets before dlopen. Left
  where it was, it would have copied preDeps.configs after the vector
  was moved into the frame, and recorded nothing.
- README: the pragma section's new last sentence goes before the new
  "Dependency notes for agents" section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-10-31 05:27:37 +01:00
commit 75142276ab
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
23 changed files with 599 additions and 62 deletions

View file

@ -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<const std::string_view> 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;
}

View file

@ -0,0 +1,5 @@
export module Shared;
import std;
export std::int32_t SharedValue() {
return 7;
}

View file

@ -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<const std::string_view> args) {
Configuration cfg;
cfg.path = "./";
cfg.name = "shared";
cfg.outputName = "shared";
cfg.type = ConfigurationType::LibraryStatic;
ApplyStandardArgs(cfg, args);
std::array<fs::path, 1> ifaces = { "interfaces/Shared" };
std::array<fs::path, 0> impls = {};
cfg.GetInterfacesAndImplementations(ifaces, impls);
return cfg;
}

View file

@ -0,0 +1,6 @@
export module Tool;
import std;
import Shared;
export std::int32_t ToolValue() {
return SharedValue();
}

View file

@ -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<const std::string_view> 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<fs::path, 1> ifaces = { "interfaces/Tool" };
std::array<fs::path, 0> impls = {};
cfg.GetInterfacesAndImplementations(ifaces, impls);
return cfg;
}

View file

@ -0,0 +1,79 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
#include <stdlib.h>
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<std::string_view, 0> 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;
}

View file

@ -0,0 +1,31 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std;
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. 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<const std::string_view> 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 = "./";
cfg.name = "app";
cfg.outputName = "app";
cfg.type = ConfigurationType::Executable;
ApplyStandardArgs(cfg, args);
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" } };
const bool gitDefault = git.args == std::vector<std::string>(args.begin(), args.end());
cfg.compileFlags.push_back(gitDefault ? "-DGIT_DEFAULT=1" : "-DGIT_DEFAULT=0");
return cfg;
}

View file

@ -0,0 +1,17 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std;
import Crafter.Build;
using namespace Crafter;
// The end of the chain: records what it was loaded with and nothing else.
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
Configuration cfg;
cfg.path = "./";
cfg.name = "leaf";
cfg.outputName = "leaf";
cfg.type = ConfigurationType::LibraryStatic;
ApplyStandardArgs(cfg, args);
return cfg;
}

View file

@ -0,0 +1,21 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std;
import Crafter.Build;
using namespace Crafter;
// The middle of the chain. Its own sibling takes the default too, so
// whatever this file was loaded with has to reach the leaf unchanged.
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
Configuration* leaf = LocalProject({ .projectFile = "../leaf/project.cpp" });
Configuration cfg;
cfg.path = "./";
cfg.name = "lib";
cfg.outputName = "lib";
cfg.type = ConfigurationType::LibraryStatic;
ApplyStandardArgs(cfg, args);
cfg.dependencies = { leaf };
return cfg;
}

View file

@ -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<const std::string_view> 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;
}

106
tests/ProjectArgs/main.cpp Normal file
View file

@ -0,0 +1,106 @@
// 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;
// A GitProject or LocalProject without `.args` forwards the args of the
// project file that declares it: both specs default their args to
// ProjectArgs(). Before that default, every project.cpp with a sibling
// dependency copied its span into a std::vector<std::string> for each spec,
// and a spec that missed the copy built its sibling with none of the
// project's flags: for the host while the project cross-compiled, and in
// 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 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;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
// 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<std::string>& 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() {
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.
const fs::path fixture = fs::current_path() / "tests" / "ProjectArgs" / "fixture";
const fs::path scratch = fs::temp_directory_path() / "crafter-build-project-args";
std::error_code ec;
fs::remove_all(scratch, ec);
fs::create_directories(scratch);
fs::copy(fixture, scratch, fs::copy_options::recursive);
Check(ProjectArgs().empty(), "no project args outside a project file");
// Loaded from the project'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<std::string_view, 2> args = { "--debug", "--flavor=x" };
const std::vector<std::string> flavor = { "--flavor=x" };
const std::vector<std::string> only = { "--only" };
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() == 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.
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);
std::println(std::cerr, "FAIL: LoadProject threw: {}", e.what());
return 1;
}
fs::current_path(cwd);
Check(ProjectArgs().empty(), "no project args once the project file has returned");
return Failures > 0 ? 1 : 0;
}

View file

@ -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<const std::string_view>) {
Configuration cfg;
cfg.path = "./";
cfg.name = std::format("static {}", Greeter->name);
cfg.outputName = "static-dep";
cfg.type = ConfigurationType::Executable;
return cfg;
}

View file

@ -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<std::string_view, 0> 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<std::string_view, 1> 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,12 +114,23 @@ 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) {
std::println(std::cerr, "FAIL: LoadProject 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);

View file

@ -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<std::string> expected = {
"arg:--", "arg:a",
"projectArg:--flavour=x",
"debug:false",
"project:script-fixture",
};
const std::vector<std::string> 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");