Compare commits

...
Author SHA1 Message Date
f60ba809b5
docs: describe IsTopLevelProject for test-only dependencies
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 22:02:40 +02:00
be88a194f4
fix: let a project file ask whether it is the top-level project
Crafter.LintRules declares a test that links crafter-build, fetched
with GitProject. crafter-build's own project.cpp loads Crafter.LintRules
as a `#pragma crafter dependency`, so the chain was: crafter-build loads
LintRules, LintRules' test wiring loads crafter-build's project.cpp from
the external cache, which loads LintRules again, and the cycle guard
threw. Every command that loads project.cpp in this repository has
failed since that test was added, including the last CI run on master.

A project file had no way to tell whether it was the one the user
pointed crafter-build at or a dependency being loaded for somebody
else, so it could not skip wiring that only its own build needs.
IsTopLevelProject() answers that from the loading-frame stack
ProjectDependency() already reads: one frame is the top-level project
file, a second is only ever pushed for a dependency. Outside any project
file it is true as well, which is what a test harness constructing
Configurations expects.

The ProjectDependency test records the answer from both the app and the
greeter project files and checks each.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 22:02:40 +02:00
7 changed files with 27 additions and 1 deletions

View file

@ -149,7 +149,7 @@ These were decided deliberately. Do not undo them without the maintainer's say-s
## Gotchas
- A `#pragma crafter dependency` target is rebuilt on every load on purpose: clang crashes rather than erroring when it imports a BMI whose own imported PCM was rebuilt underneath it, and the depfile check is what catches a regenerated host cache. Do not optimise the rebuild away.
- A host-tooling library such as Crafter.LintRules may `import Crafter.Build;` without declaring it as a dependency, because `LoadProject` fills the cache it resolves from. Listing Crafter.Build in its `cfg.dependencies` would clone and build all of crafter-build for every consumer.
- A host-tooling library such as Crafter.LintRules may `import Crafter.Build;` without declaring it as a dependency, because `LoadProject` fills the cache it resolves from. Listing Crafter.Build in its `cfg.dependencies` would clone and build all of crafter-build for every consumer. Its test does link crafter-build, so that test is wired behind `IsTopLevelProject()`: without the guard, crafter-build loading the library as a dependency would load its own `project.cpp` again through the test's `GitProject` and hit the cycle guard.
- `crafter-build clean` leaves a pragma dependency's build alone; it lives with the clone in the cache.
- The bootstrap binary is compiled without `CRAFTER_BUILD_HAS_ASSET`, so `cfg.assets` only works in the self-hosted binary. Changing asset extensions means updating two maps in `Crafter.Build-Clang.cpp`: `assetOutput` and the transitive copy loop.
- A warning is printed only when its unit actually recompiles. A build with nothing to do is silent, which is not evidence that a warning is gone.

View file

@ -83,6 +83,8 @@ cfg.dependencies = { math };
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.
A project loaded this way runs its `CrafterBuildProject` as a dependency, and `IsTopLevelProject()` is `false` while it does (`true` in the project crafter-build was pointed at, and outside any project file). Wire test-only dependencies behind it: a library whose test links crafter-build itself would otherwise load crafter-build's `project.cpp` from inside crafter-build and recurse.
### Dependencies of project.cpp itself
`project.cpp` can import modules from another crafter-build project. The file has to compile before it can say anything, so the dependency is a directive next to the `import` it enables:

View file

@ -554,6 +554,12 @@ Configuration* Crafter::ProjectDependency(std::string_view name) {
throw std::runtime_error(std::format("ProjectDependency('{}'): not declared by {} (declared: {})", name, frame.project.string(), declared.empty() ? "none" : declared));
}
bool Crafter::IsTopLevelProject() {
// One frame is the project file crafter-build was pointed at; a second
// is only ever pushed for a dependency's project file.
return LoadingFrames.size() <= 1;
}
#if defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_pc_windows_msvc) || defined(CRAFTER_BUILD_CONFIGURATION_TARGET_x86_64_w64_mingw32)
CommandResult Crafter::RunCommandChecked(std::string_view cmd) {
std::array<char, 128> buffer;

View file

@ -41,6 +41,15 @@ namespace Crafter {
// Throws when no such dependency was declared, or when called from
// anywhere other than inside LoadProject.
export CRAFTER_API Configuration* ProjectDependency(std::string_view name);
// True while the project file crafter-build was pointed at runs its
// CrafterBuildProject, and when no project file is running at all; false
// inside a dependency's project file (a `#pragma crafter dependency`,
// GitProject or LocalProject target). Wire anything only the project's
// own build or test run needs behind it. The case that forced it: a
// library's test links crafter-build itself, so when crafter-build loads
// 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 CRAFTER_API bool IsTopLevelProject();
// Resolves the directory holding distributed runtime assets (Crafter.Build
// module sources, wasi-runtime/, etc). Honors CRAFTER_BUILD_HOME; otherwise
// derives <prefix>/share/crafter-build from the running executable's path.

View file

@ -16,5 +16,8 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view>)
// Once by the module it exports, once by its configuration name; the
// test expects the same object back for both.
cfg.dependencies = { ProjectDependency("Greeter"), ProjectDependency("greeter") };
// Reported through a flag the test can read back: this file is the one
// LoadProject was pointed at, the greeter's is not.
cfg.compileFlags.push_back(IsTopLevelProject() ? "-DTOP_LEVEL=1" : "-DTOP_LEVEL=0");
return cfg;
}

View file

@ -19,5 +19,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
std::array<fs::path, 1> ifaces = { "interfaces/Greeter" };
std::array<fs::path, 0> impls = {};
cfg.GetInterfacesAndImplementations(ifaces, impls);
// Loaded as a dependency, so this must come out false (see app/).
cfg.compileFlags.push_back(IsTopLevelProject() ? "-DTOP_LEVEL=1" : "-DTOP_LEVEL=0");
return cfg;
}

View file

@ -92,11 +92,14 @@ int main(int argc, char** argv) {
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.
try {
Configuration cfg = LoadProject(app, noArgs);
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");
if (cfg.dependencies.size() == 2) {
Check(cfg.dependencies[0] == cfg.dependencies[1], "both names resolve to the same dependency");
@ -105,6 +108,7 @@ int main(int argc, char** argv) {
Check(dep.target == HostTarget(), "the dependency was built for the host triple LoadProject uses");
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(fs::exists(ExpectedOutput(dep)), "the dependency archive exists");
}
} catch (const std::exception& e) {