Fix silent stale-build corruption on module interface changes #28

Merged
catbot merged 3 commits from claude/issue-27 into master 2026-07-30 17:45:19 +00:00
17 changed files with 778 additions and 14 deletions

View file

@ -125,6 +125,12 @@ Per-import precise tracking for both within-project and cross-project module dep
Diamond deps (`A → {B, C}; B → X; C → X`) build `X` exactly once via a `std::shared_future<BuildResult>` cache. Diamond deps (`A → {B, C}; B → X; C → X`) build `X` exactly once via a `std::shared_future<BuildResult>` cache.
Tracking is derived from each translation unit's `import` statements, which are scanned when the sources are declared. `cfg.dependencies` is often assigned *afterwards*`AddTest` works that way — so `Build()` re-resolves any import that matched nothing at scan time before it compares mtimes. Without that, a consumer of a dependency's module carried no edge to it at all: adding a data member to that dependency's interface rebuilt the library, relinked the consumer, and left the consumer's object compiled against the old class layout. Nothing fails to link when a member is added, so the result was a working build and a crash later.
Everything that changes what gets built belongs in the variant id, since it names the `bin/` and `build/` directory. That includes project args crafter-build itself doesn't interpret: `crafter-build` and `crafter-build -- --no-webgpu` get separate directories rather than interleaving their outputs in one. The cached host PCMs under `<cache>/crafter.build/<target>-<march>/` are shared by every crafter-build on the machine, so they are invalidated by a hash of the module sources rather than by mtime — an mtime can't tell a newer PCM from one built by a different install.
`crafter-build clean` removes the project's `bin/` and `build/` trees. It doesn't load `project.cpp`, so it still works when the project no longer compiles.
## Tests ## Tests
Tests live under `tests/<Name>/`. The simplest case is a single C++ file: Tests live under `tests/<Name>/`. The simplest case is a single C++ file:

View file

@ -25,9 +25,14 @@ namespace fs = std::filesystem;
using namespace Crafter; using namespace Crafter;
void Configuration::GetInterfacesAndImplementations(std::span<fs::path> interfaces, std::span<fs::path> implementations) { namespace {
auto resolveImport = [this](const std::string& importName, std::vector<Module*>& localDeps, std::vector<std::pair<Module*, fs::path>>& externalDeps) -> bool { // Map one `import X;` name onto either a module this Configuration owns or
for(const std::unique_ptr<Module>& interface : this->interfaces) { // one exported by a Configuration reachable through its dependency DAG,
// appending the matching staleness edge. False means nothing in reach
// provides X — either it's supplied from outside the graph (`std`) or the
// dependency isn't wired up *yet*, which is why callers remember the name.
bool ResolveImportName(Configuration& cfg, const std::string& importName, std::vector<Module*>& localDeps, std::vector<std::pair<Module*, fs::path>>& externalDeps) {
for(const std::unique_ptr<Module>& interface : cfg.interfaces) {
if(interface->name == importName) { if(interface->name == importName) {
localDeps.push_back(interface.get()); localDeps.push_back(interface.get());
return true; return true;
@ -50,10 +55,38 @@ void Configuration::GetInterfacesAndImplementations(std::span<fs::path> interfac
return false; return false;
}; };
for(Configuration* depCfg : this->dependencies) { for(Configuration* depCfg : cfg.dependencies) {
if (walk(depCfg)) return true; if (walk(depCfg)) return true;
} }
return false; return false;
}
}
void Configuration::ResolvePendingImports() {
// Same resolution the scan used, and against both dependency kinds — a
// second GetInterfacesAndImplementations call can add interfaces that an
// earlier batch's import was looking for, so a pending name may land on a
// local module and not just an external one.
auto sweep = [this](std::vector<std::string>& pending, std::vector<Module*>& localDeps, std::vector<std::pair<Module*, fs::path>>& externalDeps) {
std::erase_if(pending, [&](const std::string& name) {
return ResolveImportName(*this, name, localDeps, externalDeps);
});
};
for(const std::unique_ptr<Module>& interface : interfaces) {
for(const std::unique_ptr<ModulePartition>& partition : interface->partitions) {
sweep(partition->pendingImports, partition->moduleDependencies, partition->externalModuleDependencies);
}
}
for(Implementation& implementation : implementations) {
sweep(implementation.pendingImports, implementation.moduleDependencies, implementation.externalModuleDependencies);
}
}
void Configuration::GetInterfacesAndImplementations(std::span<fs::path> interfaces, std::span<fs::path> implementations) {
auto resolveImport = [this](const std::string& importName, std::vector<Module*>& localDeps, std::vector<std::pair<Module*, fs::path>>& externalDeps, std::vector<std::string>& pending) {
if (!ResolveImportName(*this, importName, localDeps, externalDeps)) {
pending.push_back(importName);
}
}; };
std::vector<std::tuple<fs::path, std::string, ModulePartition*, Module*>> tempModulePaths = std::vector<std::tuple<fs::path, std::string, ModulePartition*, Module*>>(interfaces.size()); std::vector<std::tuple<fs::path, std::string, ModulePartition*, Module*>> tempModulePaths = std::vector<std::tuple<fs::path, std::string, ModulePartition*, Module*>>(interfaces.size());
@ -128,7 +161,7 @@ void Configuration::GetInterfacesAndImplementations(std::span<fs::path> interfac
std::sregex_iterator modCurrent(fileContent.begin(), fileContent.end(), modulePattern); std::sregex_iterator modCurrent(fileContent.begin(), fileContent.end(), modulePattern);
while (modCurrent != lastMatch) { while (modCurrent != lastMatch) {
std::smatch match = *modCurrent; std::smatch match = *modCurrent;
resolveImport(match[1].str(), partition->moduleDependencies, partition->externalModuleDependencies); resolveImport(match[1].str(), partition->moduleDependencies, partition->externalModuleDependencies, partition->pendingImports);
++modCurrent; ++modCurrent;
} }
} }
@ -173,7 +206,7 @@ void Configuration::GetInterfacesAndImplementations(std::span<fs::path> interfac
while (modCurrent != lastMatch) { while (modCurrent != lastMatch) {
std::smatch match2 = *modCurrent; std::smatch match2 = *modCurrent;
if (match2[1] != match[1]) { if (match2[1] != match[1]) {
resolveImport(match2[1].str(), implementation.moduleDependencies, implementation.externalModuleDependencies); resolveImport(match2[1].str(), implementation.moduleDependencies, implementation.externalModuleDependencies, implementation.pendingImports);
} }
++modCurrent; ++modCurrent;
} }
@ -188,7 +221,7 @@ void Configuration::GetInterfacesAndImplementations(std::span<fs::path> interfac
std::sregex_iterator lastMatch; std::sregex_iterator lastMatch;
while (currentMatch != lastMatch) { while (currentMatch != lastMatch) {
std::smatch match2 = *currentMatch; std::smatch match2 = *currentMatch;
resolveImport(match2[1].str(), implementation.moduleDependencies, implementation.externalModuleDependencies); resolveImport(match2[1].str(), implementation.moduleDependencies, implementation.externalModuleDependencies, implementation.pendingImports);
++currentMatch; ++currentMatch;
} }
} }
@ -224,6 +257,18 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map<fs::path, s
} }
} }
// Sources were scanned when they were declared, which for most callers is
// before `dependencies` exists — AddTest resolves tests/<name>/main.cpp and
// only then hands back a builder whose .Dependencies() supplies the library.
// Any `import <DepModule>;` in such a TU resolved to nothing and so carried
// no staleness edge, which meant a member added to a dependency's interface
// rebuilt the library, relinked the consumer, and silently kept the
// consumer's object compiled against the *old* class layout (issue #27).
// Re-resolving here — the last point before mtimes are compared, with the
// DAG fully wired — closes that window for every caller rather than for the
// ones that remember to declare in the right order.
config.ResolvePendingImports();
// Auto-detect the WASI sysroot before any compile step runs so BuildStdPcm // Auto-detect the WASI sysroot before any compile step runs so BuildStdPcm
// and the main compile command see the same value. Linux-only — Windows // and the main compile command see the same value. Linux-only — Windows
// users supply cfg.sysroot pointing at their wasi-sdk install. Covers all // users supply cfg.sysroot pointing at their wasi-sdk install. Covers all
@ -1401,7 +1446,15 @@ ArgQuery Crafter::ApplyStandardArgs(Configuration& cfg, std::span<const std::str
else if (a.starts_with("--march=")) cfg.march = std::string(a.substr(std::string_view("--march=").size())); else if (a.starts_with("--march=")) cfg.march = std::string(a.substr(std::string_view("--march=").size()));
else if (a.starts_with("--mtune=")) cfg.mtune = std::string(a.substr(std::string_view("--mtune=").size())); else if (a.starts_with("--mtune=")) cfg.mtune = std::string(a.substr(std::string_view("--mtune=").size()));
else if (a.starts_with("--sysroot=")) cfg.sysroot = std::string(a.substr(std::string_view("--sysroot=").size())); else if (a.starts_with("--sysroot=")) cfg.sysroot = std::string(a.substr(std::string_view("--sysroot=").size()));
// Anything else is the project's own flag. Its effect on the output is
// opaque to the framework, so it has to key into VariantId or two flag
// settings share a bin dir and leave a bundle matching neither.
else cfg.projectArgs.emplace_back(a);
} }
// Sorted and deduplicated so the identity depends on the set of flags, not
// on how they were ordered or repeated.
std::ranges::sort(cfg.projectArgs);
cfg.projectArgs.erase(std::ranges::unique(cfg.projectArgs).begin(), cfg.projectArgs.end());
if (sawLib && cfg.type == ConfigurationType::Executable) cfg.type = ConfigurationType::LibraryStatic; if (sawLib && cfg.type == ConfigurationType::Executable) cfg.type = ConfigurationType::LibraryStatic;
if (sawShared && cfg.type == ConfigurationType::LibraryStatic) cfg.type = ConfigurationType::LibraryDynamic; if (sawShared && cfg.type == ConfigurationType::LibraryStatic) cfg.type = ConfigurationType::LibraryDynamic;
// WASI sysroot autodetect, applied at config-load time so the VariantId // WASI sysroot autodetect, applied at config-load time so the VariantId
@ -1416,6 +1469,21 @@ ArgQuery Crafter::ApplyStandardArgs(Configuration& cfg, std::span<const std::str
return ArgQuery{args}; return ArgQuery{args};
} }
std::vector<fs::path> Crafter::CleanProject(const fs::path& projectFile) {
fs::path projectDir = fs::absolute(projectFile).lexically_normal().parent_path();
std::vector<fs::path> removed;
for (std::string_view name : { "bin", "build" }) {
fs::path dir = projectDir / name;
std::error_code ec;
if (!fs::is_directory(dir, ec)) continue;
if (fs::remove_all(dir, ec) == static_cast<std::uintmax_t>(-1) || ec) {
throw std::runtime_error(std::format("could not remove {}: {}", dir.string(), ec.message()));
}
removed.push_back(std::move(dir));
}
return removed;
}
static void PrintHelp(std::string_view argv0) { static void PrintHelp(std::string_view argv0) {
std::println( std::println(
R"(Usage: R"(Usage:
@ -1423,6 +1491,7 @@ R"(Usage:
{0} test [test-options] [globs...] Build and run the project's tests {0} test [test-options] [globs...] Build and run the project's tests
{0} lint [lint-options] [globs...] Run the project's lint rules over its sources {0} lint [lint-options] [globs...] Run the project's lint rules over its sources
{0} format [format-options] [globs...] Apply the project's transform rules (rewrites files) {0} format [format-options] [globs...] Apply the project's transform rules (rewrites files)
{0} clean Delete the project's bin/ and build/ trees
{0} help | -h | --help Show this help {0} help | -h | --help Show this help
Loads ./project.cpp (override with --project=<path>), compiles it to a shared Loads ./project.cpp (override with --project=<path>), compiles it to a shared
@ -1465,10 +1534,17 @@ Format options (after the `format` subcommand):
transform. `format` rewrites changed files in place; `lint` reports the transform. `format` rewrites changed files in place; `lint` reports the
same transforms as would-reformat findings without writing. same transforms as would-reformat findings without writing.
Clean (after the `clean` subcommand):
Removes bin/ and build/ next to the project file. Does not load project.cpp,
so it works when the project itself no longer compiles. Every target, variant
and dependency artifact under those trees goes with it.
Project args: Project args:
Any flag not consumed above is forwarded verbatim to CrafterBuildProject as Any flag not consumed above is forwarded verbatim to CrafterBuildProject as
part of its `args` span. Project-specific flags (e.g. --target=, custom part of its `args` span. Project-specific flags (e.g. --target=, custom
feature toggles) live there. feature toggles) live there. Flags ApplyStandardArgs does not itself
interpret are folded into the variant hash, so switching one lands in its own
bin/ and build/ directory instead of overwriting the other setting's.
Environment: Environment:
CRAFTER_BUILD_MARCH Override -march (default: native). CRAFTER_BUILD_MARCH Override -march (default: native).
@ -1496,6 +1572,7 @@ int Crafter::Run(int argc, char** argv) {
bool runTests = false; bool runTests = false;
bool runLint = false; bool runLint = false;
bool runFormat = false; bool runFormat = false;
bool runClean = false;
bool runAfterBuild = false; bool runAfterBuild = false;
RunTestsOptions testOpts; RunTestsOptions testOpts;
RunLintOptions lintOpts; RunLintOptions lintOpts;
@ -1506,11 +1583,13 @@ int Crafter::Run(int argc, char** argv) {
if (arg == "-h" || arg == "--help" || (!runTests && !runLint && !runFormat && arg == "help")) { if (arg == "-h" || arg == "--help" || (!runTests && !runLint && !runFormat && arg == "help")) {
PrintHelp(argv0); PrintHelp(argv0);
return 0; return 0;
} else if (!runLint && !runFormat && arg == "test") { } else if (!runLint && !runFormat && !runClean && arg == "test") {
runTests = true; runTests = true;
} else if (!runTests && !runFormat && arg == "lint") { } else if (!runTests && !runFormat && !runClean && arg == "lint") {
runLint = true; runLint = true;
} else if (!runTests && !runLint && arg == "format") { } else if (!runTests && !runLint && !runClean && arg == "clean") {
runClean = true;
} else if (!runTests && !runLint && !runClean && arg == "format") {
runFormat = true; runFormat = true;
lintOpts.mode = LintMode::Apply; lintOpts.mode = LintMode::Apply;
} else if (runFormat && arg == "--check") { } else if (runFormat && arg == "--check") {
@ -1558,6 +1637,17 @@ int Crafter::Run(int argc, char** argv) {
return 1; return 1;
} }
// Ahead of LoadProject on purpose — see CleanProject.
if (runClean) {
std::vector<fs::path> removed = CleanProject(projectFile);
if (removed.empty()) {
std::println("Nothing to clean");
} else {
for (const fs::path& dir : removed) std::println("Removed {}", dir.string());
}
return 0;
}
Configuration config = LoadProject(projectFile, projectArgs); Configuration config = LoadProject(projectFile, projectArgs);
SetParentProject(&config); SetParentProject(&config);

View file

@ -74,6 +74,54 @@ namespace {
CacheLock(const CacheLock&) = delete; CacheLock(const CacheLock&) = delete;
CacheLock& operator=(const CacheLock&) = delete; CacheLock& operator=(const CacheLock&) = delete;
}; };
// The cached Crafter.Build PCMs are keyed by `<target>-<march>` alone, so
// every crafter-build on the machine writes to the same files regardless of
// which share/crafter-build its module sources came from. Freshness used to
// be a per-file mtime comparison, which cannot distinguish "this PCM is
// newer than my source" from "this PCM was built from *different* sources
// that happen to be newer" — so a second install or checkout silently
// compiled its project against the other's declarations. Same failure shape
// as issue #27: no error, a binary built against a layout nobody linked.
//
// A stamp over the bytes of every module source answers what the mtime
// can't. It covers the whole set rather than one file at a time because the
// PCMs import each other: a change to :Interface invalidates :Clang's PCM
// even though Crafter.Build-Clang.cppm is untouched.
std::string CrafterBuildSourceStamp(const fs::path& sourceDir, std::span<const std::string_view> moduleNames) {
std::string all;
auto append = [&all, &sourceDir](const fs::path& relative) {
std::ifstream in(sourceDir / relative, std::ios::binary);
std::ostringstream buffer;
buffer << in.rdbuf();
all += relative.string();
all += '\0';
all += buffer.str();
all += '\0';
};
for (std::string_view name : moduleNames) {
append(fs::path(std::format("{}.cppm", name)));
}
// Exported through `module;` preambles, so its contents land in the PCMs
// too.
append("Crafter.Build-Api.h");
return std::format("{:016x}", std::hash<std::string>{}(all));
}
fs::path CacheStampPath(const fs::path& cacheDir) {
return cacheDir / "crafter-build-sources.stamp";
}
std::string ReadCacheStamp(const fs::path& cacheDir) {
std::ifstream in(CacheStampPath(cacheDir), std::ios::binary);
std::string stamp;
std::getline(in, stamp);
return stamp;
}
void WriteCacheStamp(const fs::path& cacheDir, const std::string& stamp) {
std::ofstream(CacheStampPath(cacheDir), std::ios::binary | std::ios::trunc) << stamp << '\n';
}
} }
fs::path Crafter::GetCrafterBuildHome() { fs::path Crafter::GetCrafterBuildHome() {
@ -328,13 +376,17 @@ std::string Crafter::GetBaseCommand(const Configuration& config) {
namespace { namespace {
void EnsureCrafterBuildPcms(const fs::path& sourceDir, const fs::path& cacheDir) { void EnsureCrafterBuildPcms(const fs::path& sourceDir, const fs::path& cacheDir) {
CacheLock lock(cacheDir); CacheLock lock(cacheDir);
// Re-derived under the lock: another builder may have refreshed the
// cache from its own sources while we waited.
std::string stamp = CrafterBuildSourceStamp(sourceDir, CrafterBuildModules);
bool upToDate = ReadCacheStamp(cacheDir) == stamp;
for (std::string_view name : CrafterBuildModules) { for (std::string_view name : CrafterBuildModules) {
fs::path cppmPath = sourceDir / std::format("{}.cppm", name); fs::path cppmPath = sourceDir / std::format("{}.cppm", name);
fs::path pcmPath = cacheDir / std::format("{}.pcm", name); fs::path pcmPath = cacheDir / std::format("{}.pcm", name);
if (!fs::exists(cppmPath)) { if (!fs::exists(cppmPath)) {
throw std::runtime_error(std::format("module source {} not found in {} (set CRAFTER_BUILD_HOME)", name, sourceDir.string())); throw std::runtime_error(std::format("module source {} not found in {} (set CRAFTER_BUILD_HOME)", name, sourceDir.string()));
} }
if (fs::exists(pcmPath) && fs::last_write_time(cppmPath) < fs::last_write_time(pcmPath)) { if (upToDate && fs::exists(pcmPath)) {
continue; continue;
} }
std::string cmd = std::format( std::string cmd = std::format(
@ -350,6 +402,10 @@ namespace {
throw std::runtime_error(std::format("Failed to precompile {} (exit {}): {}", name, r.exitCode, r.output)); throw std::runtime_error(std::format("Failed to precompile {} (exit {}): {}", name, r.exitCode, r.output));
} }
} }
// Last, so an interrupted rebuild leaves the stamp disagreeing with the
// sources and the next run starts over rather than trusting a half-built
// set of PCMs.
if (!upToDate) WriteCacheStamp(cacheDir, stamp);
} }
} }
@ -523,6 +579,10 @@ std::string Crafter::GetBaseCommand(const Configuration& config) {
namespace { namespace {
void EnsureCrafterBuildPcms(const fs::path& sourceDir, const fs::path& cacheDir) { void EnsureCrafterBuildPcms(const fs::path& sourceDir, const fs::path& cacheDir) {
CacheLock lock(cacheDir); CacheLock lock(cacheDir);
// Re-derived under the lock: another builder may have refreshed the
// cache from its own sources while we waited.
std::string stamp = CrafterBuildSourceStamp(sourceDir, CrafterBuildModules);
bool upToDate = ReadCacheStamp(cacheDir) == stamp;
fs::path prefix = MingwPrefix(); fs::path prefix = MingwPrefix();
for (std::string_view name : CrafterBuildModules) { for (std::string_view name : CrafterBuildModules) {
fs::path cppmPath = sourceDir / std::format("{}.cppm", name); fs::path cppmPath = sourceDir / std::format("{}.cppm", name);
@ -530,7 +590,7 @@ namespace {
if (!fs::exists(cppmPath)) { if (!fs::exists(cppmPath)) {
throw std::runtime_error(std::format("module source {} not found in {} (set CRAFTER_BUILD_HOME)", name, sourceDir.string())); throw std::runtime_error(std::format("module source {} not found in {} (set CRAFTER_BUILD_HOME)", name, sourceDir.string()));
} }
if (fs::exists(pcmPath) && fs::last_write_time(cppmPath) < fs::last_write_time(pcmPath)) { if (upToDate && fs::exists(pcmPath)) {
continue; continue;
} }
std::string cmd = std::format( std::string cmd = std::format(
@ -546,6 +606,10 @@ namespace {
throw std::runtime_error(std::format("Failed to precompile {} (exit {}): {}", name, r.exitCode, r.output)); throw std::runtime_error(std::format("Failed to precompile {} (exit {}): {}", name, r.exitCode, r.output));
} }
} }
// Last, so an interrupted rebuild leaves the stamp disagreeing with the
// sources and the next run starts over rather than trusting a half-built
// set of PCMs.
if (!upToDate) WriteCacheStamp(cacheDir, stamp);
} }
} }
@ -839,13 +903,17 @@ namespace {
void EnsureCrafterBuildPcms(const fs::path& sourceDir, const fs::path& cacheDir) { void EnsureCrafterBuildPcms(const fs::path& sourceDir, const fs::path& cacheDir) {
CacheLock lock(cacheDir); CacheLock lock(cacheDir);
// Re-derived under the lock: another builder may have refreshed the
// cache from its own sources while we waited.
std::string stamp = CrafterBuildSourceStamp(sourceDir, CrafterBuildModules);
bool upToDate = ReadCacheStamp(cacheDir) == stamp;
for (std::string_view name : CrafterBuildModules) { for (std::string_view name : CrafterBuildModules) {
fs::path cppmPath = sourceDir / std::format("{}.cppm", name); fs::path cppmPath = sourceDir / std::format("{}.cppm", name);
fs::path pcmPath = cacheDir / std::format("{}.pcm", name); fs::path pcmPath = cacheDir / std::format("{}.pcm", name);
if (!fs::exists(cppmPath)) { if (!fs::exists(cppmPath)) {
throw std::runtime_error(std::format("module source {} not found in {} (set CRAFTER_BUILD_HOME)", name, sourceDir.string())); throw std::runtime_error(std::format("module source {} not found in {} (set CRAFTER_BUILD_HOME)", name, sourceDir.string()));
} }
if (fs::exists(pcmPath) && fs::last_write_time(cppmPath) < fs::last_write_time(pcmPath)) { if (upToDate && fs::exists(pcmPath)) {
continue; continue;
} }
std::string cmd = std::format( std::string cmd = std::format(
@ -860,6 +928,10 @@ namespace {
throw std::runtime_error(std::format("Failed to precompile {} (exit {}): {}", name, r.exitCode, r.output)); throw std::runtime_error(std::format("Failed to precompile {} (exit {}): {}", name, r.exitCode, r.output));
} }
} }
// Last, so an interrupted rebuild leaves the stamp disagreeing with the
// sources and the next run starts over rather than trusting a half-built
// set of PCMs.
if (!upToDate) WriteCacheStamp(cacheDir, stamp);
} }
} }

View file

@ -429,6 +429,10 @@ TestBuilder Configuration::AddTest(std::string_view name, std::span<fs::path> in
t.config.mtune = this->mtune; t.config.mtune = this->mtune;
t.config.sysroot = this->sysroot; t.config.sysroot = this->sysroot;
t.config.debug = this->debug; t.config.debug = this->debug;
// Inherited so a project flag that changes what the library contains also
// moves the test's own outputs, rather than letting two flag settings share
// one test bin dir.
t.config.projectArgs = this->projectArgs;
t.config.type = ConfigurationType::Executable; t.config.type = ConfigurationType::Executable;
// Default source layout: tests/<name>/main.cpp resolved against the // Default source layout: tests/<name>/main.cpp resolved against the
@ -453,6 +457,7 @@ void Configuration::AddMarchVariants(std::string_view name, std::span<fs::path>
t.config.mtune = tier.mtune; t.config.mtune = tier.mtune;
t.config.sysroot = this->sysroot; t.config.sysroot = this->sysroot;
t.config.debug = this->debug; t.config.debug = this->debug;
t.config.projectArgs = this->projectArgs;
t.config.type = ConfigurationType::Executable; t.config.type = ConfigurationType::Executable;
fs::path mainSource = fs::path("tests") / std::string(name) / "main"; fs::path mainSource = fs::path("tests") / std::string(name) / "main";
@ -483,6 +488,14 @@ TestBuilder& TestBuilder::Args(std::vector<std::string> a) { Ref().args = std::
TestBuilder& TestBuilder::Requires(std::string r) { Ref().requires_.push_back(std::move(r)); return *this; } TestBuilder& TestBuilder::Requires(std::string r) { Ref().requires_.push_back(std::move(r)); return *this; }
TestBuilder& TestBuilder::Dependencies(std::vector<Configuration*> d) { TestBuilder& TestBuilder::Dependencies(std::vector<Configuration*> d) {
Ref().config.dependencies = std::move(d); Ref().config.dependencies = std::move(d);
// AddTest already scanned tests/<name>/main.cpp, at which point this test
// had no dependencies, so every `import <DepModule>;` in it came back
// unresolved. Place them now that the libraries are known — without this
// the test's object carries no staleness edge to the interfaces it consumes
// and survives a layout change to them (issue #27). Build() re-runs the
// same sweep as a backstop; doing it here keeps the Configuration coherent
// for anyone inspecting it before the build.
Ref().config.ResolvePendingImports();
return *this; return *this;
} }
TestBuilder& TestBuilder::LinkFlag(std::string f) { Ref().config.linkFlags.push_back(std::move(f)); return *this; } TestBuilder& TestBuilder::LinkFlag(std::string f) { Ref().config.linkFlags.push_back(std::move(f)); return *this; }

View file

@ -250,10 +250,28 @@ export namespace Crafter {
// clobber the baseline's. Not for direct project use — declare // clobber the baseline's. Not for direct project use — declare
// wasmVariants instead. // wasmVariants instead.
std::vector<std::string> wasmVariantFlags; std::vector<std::string> wasmVariantFlags;
// The project args ApplyStandardArgs did not interpret — i.e. the
// project's own flags, whose effect on the build the framework cannot
// see. Hashed into VariantId because such a flag typically decides what
// gets compiled or bundled (`--no-webgpu` dropping entries from
// 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.
std::vector<std::string> projectArgs;
std::vector<Test> tests; std::vector<Test> tests;
// Lint rules for `crafter-build lint`. Populate via AddLintRule. // Lint rules for `crafter-build lint`. Populate via AddLintRule.
std::vector<LintRule> lintRules; std::vector<LintRule> lintRules;
CRAFTER_API void GetInterfacesAndImplementations(std::span<fs::path> interfaces, std::span<fs::path> implementations); CRAFTER_API void GetInterfacesAndImplementations(std::span<fs::path> interfaces, std::span<fs::path> implementations);
// Retry the `import X;` names GetInterfacesAndImplementations could not
// place, against the dependency DAG as it stands now. Sources are
// scanned when they're declared, but `dependencies` is often assigned
// afterwards (AddTest does exactly this), and an import that resolved to
// nothing leaves no staleness edge — so a dependency's interface could
// change and this Configuration's objects would be silently reused
// against the new layout. Build() calls this before checking mtimes;
// calling it again is harmless.
CRAFTER_API void ResolvePendingImports();
// Declare a test. Sources default to `tests/<name>/main.cpp` resolved // Declare a test. Sources default to `tests/<name>/main.cpp` resolved
// against this Configuration's path; target/march/mtune/sysroot/debug // against this Configuration's path; target/march/mtune/sysroot/debug
// are inherited from this Configuration so cross-arch projects don't // are inherited from this Configuration so cross-arch projects don't
@ -306,6 +324,12 @@ export namespace Crafter {
compileKey += "|W:"; compileKey += "|W:";
compileKey += f; compileKey += f;
} }
// Sorted by ApplyStandardArgs, so flag order on the command line
// doesn't split the cache.
for (const std::string& a : projectArgs) {
compileKey += "|A:";
compileKey += a;
}
std::size_t configHash = std::hash<std::string>{}(compileKey); std::size_t configHash = std::hash<std::string>{}(compileKey);
return std::format("{}-{}-{}-{}-{:08x}", name, target, march, mtune, configHash); return std::format("{}-{}-{}-{}-{:08x}", name, target, march, mtune, configHash);
} }
@ -336,6 +360,16 @@ export namespace Crafter {
CRAFTER_API int Run(int argc, char** argv); CRAFTER_API int Run(int argc, char** argv);
// Delete the bin/ and build/ trees beside `projectFile`, returning the paths
// that existed and were removed. Backs `crafter-build clean`.
//
// Deliberately does not load the project: cleaning is most often reached
// when something is already wrong, and a clean that first needs project.cpp
// to compile (and its git dependencies to be present) is useless exactly
// then. That means variant directories are not enumerated — the whole tree
// goes, which is what the by-hand `rm -rf bin build` did anyway.
CRAFTER_API std::vector<fs::path> CleanProject(const fs::path& projectFile);
// Add a small index.html + runtime.js pair next to the .wasm output so the // Add a small index.html + runtime.js pair next to the .wasm output so the
// build can be loaded directly in a browser (just `serve` the bin dir and // build can be loaded directly in a browser (just `serve` the bin dir and
// open it). Opt-in: WASI builds destined for wasmtime/wasmer don't need // open it). Opt-in: WASI builds destined for wasmtime/wasmer don't need

View file

@ -15,6 +15,9 @@ namespace Crafter {
std::vector<Module*> moduleDependencies; std::vector<Module*> moduleDependencies;
std::vector<ModulePartition*> partitionDependencies; std::vector<ModulePartition*> partitionDependencies;
std::vector<std::pair<Module*, fs::path>> externalModuleDependencies; std::vector<std::pair<Module*, fs::path>> externalModuleDependencies;
// See ModulePartition::pendingImports — imports this TU declared that
// no reachable Configuration provided when the source was scanned.
std::vector<std::string> pendingImports;
fs::path path; fs::path path;
CRAFTER_API Implementation(fs::path&& path); CRAFTER_API Implementation(fs::path&& path);
CRAFTER_API bool Check(const fs::path& buildDir, const fs::path& pcmDir, fs::file_time_type sourceFloor = fs::file_time_type::min()) const; CRAFTER_API bool Check(const fs::path& buildDir, const fs::path& pcmDir, fs::file_time_type sourceFloor = fs::file_time_type::min()) const;

View file

@ -14,6 +14,13 @@ namespace Crafter {
std::vector<Module*> moduleDependencies; std::vector<Module*> moduleDependencies;
std::vector<ModulePartition*> partitionDependencies; std::vector<ModulePartition*> partitionDependencies;
std::vector<std::pair<Module*, fs::path>> externalModuleDependencies; std::vector<std::pair<Module*, fs::path>> externalModuleDependencies;
// Names from `import X;` that matched neither a module in this
// Configuration nor one reachable through its dependencies at the time
// the source was scanned. Retried by ResolvePendingImports (see
// Crafter.Build:Clang) so a dependency wired up after
// GetInterfacesAndImplementations still produces a staleness edge.
// Names that never resolve (`std`, module-mapped externals) stay here.
std::vector<std::string> pendingImports;
std::atomic<bool> compiled; std::atomic<bool> compiled;
bool needsRecompiling; bool needsRecompiling;
bool checked = false; bool checked = false;

View file

@ -107,6 +107,8 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
cfg.AddTest("StaticLib").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("StaticLib").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("ModuleInterface").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("ModuleInterface").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("DependencyLink").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("DependencyLink").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("IncrementalInterfaceChange").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("CleanProject").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("ShaderCompile").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("ShaderCompile").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("StandardArgs").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("StandardArgs").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("TestRunnerSpec").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("TestRunnerSpec").Dependencies({ CrafterBuildLib.get() });
@ -120,6 +122,9 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
// for project.so. // for project.so.
cfg.AddTest("ConcurrentCacheRace").Dependencies({ CrafterBuildLib.get() }) cfg.AddTest("ConcurrentCacheRace").Dependencies({ CrafterBuildLib.get() })
.LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl"); .LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl");
// Same LoadProject wiring as ConcurrentCacheRace above.
cfg.AddTest("HostCacheSourceStamp").Dependencies({ CrafterBuildLib.get() })
.LinkFlag("-Wl,--export-dynamic").LinkFlag("-ldl");
cfg.AddTest("ConcurrentDependencyReset").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("ConcurrentDependencyReset").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("Lint").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("Lint").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("HouseRules").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("HouseRules").Dependencies({ CrafterBuildLib.get() });

View file

@ -0,0 +1,79 @@
// 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;
// `crafter-build clean` exists so the fix for a stale build is discoverable
// rather than folklore. It must not need the project to load: the state you most
// want to clear is one where the build is already broken.
namespace {
std::int32_t Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
fs::path StageProject(std::string_view leaf) {
fs::path root = fs::temp_directory_path() / std::format("crafter-build-clean-{}", leaf);
fs::remove_all(root);
fs::create_directories(root / "bin" / "app-x86_64-pc-linux-gnu-native-native-0badf00d");
fs::create_directories(root / "build" / "app-x86_64-pc-linux-gnu-native-native-0badf00d");
std::ofstream(root / "bin" / "app-x86_64-pc-linux-gnu-native-native-0badf00d" / "app") << "binary";
std::ofstream(root / "build" / "app-x86_64-pc-linux-gnu-native-native-0badf00d" / "main_impl.o") << "object";
std::ofstream(root / "keep-me.txt") << "untouched";
return root;
}
}
int main() {
{
// A project file that could never compile: clean has to work anyway.
fs::path root = StageProject("broken");
fs::path projectFile = root / "project.cpp";
std::ofstream(projectFile) << "this is not valid C++ at all\n";
std::vector<fs::path> removed = CleanProject(projectFile);
Check(removed.size() == 2, std::format("both trees reported removed, got {}", removed.size()));
Check(!fs::exists(root / "bin"), "bin/ is gone");
Check(!fs::exists(root / "build"), "build/ is gone");
Check(fs::exists(root / "keep-me.txt"), "unrelated files in the project root are left alone");
Check(fs::exists(projectFile), "the project file itself is left alone");
}
{
// Idempotent, and quiet about it — nothing to remove is not an error.
fs::path root = StageProject("twice");
fs::path projectFile = root / "project.cpp";
std::ofstream(projectFile) << "\n";
CleanProject(projectFile);
std::vector<fs::path> second = CleanProject(projectFile);
Check(second.empty(), "a second clean reports nothing removed");
}
{
// Resolved relative to the project file, not the cwd, so --project=
// cleans the project it names.
fs::path root = StageProject("elsewhere");
fs::path projectFile = root / "project.cpp";
std::ofstream(projectFile) << "\n";
fs::path cwdBin = fs::current_path() / "bin";
bool cwdBinExisted = fs::exists(cwdBin);
CleanProject(projectFile);
Check(!fs::exists(root / "bin"), "the named project's bin/ is gone");
Check(fs::exists(cwdBin) == cwdBinExisted, "the cwd's bin/ is untouched");
}
if (Failures > 0) {
std::println(std::cerr, "{} assertions failed", Failures);
return 1;
}
return 0;
}

View file

@ -0,0 +1,150 @@
// 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;
// The host PCM cache is keyed by `<target>-<march>` alone, so every
// crafter-build on a machine — a package install and a working checkout, two
// checkouts of different versions — writes the same `Crafter.Build-*.pcm`
// files. Freshness was a per-file mtime comparison, which says "the PCM is
// newer than my source, reuse it" whether or not it was built from *my*
// source. The loser then compiled its project.cpp against the other install's
// declarations: no error, and a project.so built against a layout that doesn't
// match the library it's loaded into — the same silent shape as issue #27.
//
// So the invalidation has to key on the source bytes, and this test asserts it
// notices a change the mtime rule provably cannot see: different content with a
// deliberately *older* timestamp.
namespace {
std::int32_t Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
// The cache subdirectory is named for the host target and march; glob for
// it rather than reconstructing the naming rule here.
fs::path FindModuleCacheDir(const fs::path& cacheRoot) {
for (const fs::directory_entry& entry : fs::directory_iterator(cacheRoot / "crafter.build")) {
if (entry.is_directory()) return entry.path();
}
return {};
}
std::string ReadStamp(const fs::path& moduleCacheDir) {
std::ifstream in(moduleCacheDir / "crafter-build-sources.stamp");
std::string stamp;
std::getline(in, stamp);
return stamp;
}
}
int main() {
fs::path installedHome = GetCrafterBuildHome();
if (!fs::exists(installedHome / "Crafter.Build.cppm")) {
std::println(std::cerr, "SKIP: no module sources at {}", installedHome.string());
return 77;
}
fs::path scratch = fs::temp_directory_path() / "crafter-build-host-cache-source-stamp";
std::error_code ec;
fs::remove_all(scratch, ec);
fs::create_directories(scratch);
// A private copy of the module sources, so the mutation below can't touch
// the real install, and a cold private cache so the first LoadProject has
// to populate it.
fs::path home = scratch / "share" / "crafter-build";
fs::create_directories(home.parent_path());
fs::copy(installedHome, home, fs::copy_options::recursive);
setenv("CRAFTER_BUILD_HOME", home.string().c_str(), 1);
fs::path cacheRoot = scratch / "cache";
fs::create_directories(cacheRoot);
setenv("XDG_CACHE_HOME", cacheRoot.string().c_str(), 1);
fs::path projectDir = scratch / "project";
fs::create_directories(projectDir);
fs::path projectFile = projectDir / "project.cpp";
std::ofstream(projectFile) <<
"import std;\n"
"import Crafter.Build;\n"
"using namespace Crafter;\n"
"extern \"C\" Configuration CrafterBuildProject(std::span<const std::string_view>) {\n"
" Configuration cfg;\n"
" cfg.path = \"./\";\n"
" cfg.name = \"stamped\";\n"
" cfg.outputName = \"stamped\";\n"
" cfg.target = HostTarget();\n"
" cfg.type = ConfigurationType::Executable;\n"
" return cfg;\n"
"}\n";
std::array<std::string_view, 0> noArgs = {};
try {
(void)LoadProject(projectFile, noArgs);
} catch (const std::exception& e) {
std::println(std::cerr, "FAIL: first LoadProject threw: {}", e.what());
return 1;
}
fs::path moduleCacheDir = FindModuleCacheDir(cacheRoot);
Check(!moduleCacheDir.empty(), "a module cache directory was created");
if (moduleCacheDir.empty()) return 1;
fs::path probePcm = moduleCacheDir / "Crafter.Build-Clang.pcm";
Check(fs::exists(probePcm), "the cold run populated the cache");
if (!fs::exists(probePcm)) return 1;
std::string firstStamp = ReadStamp(moduleCacheDir);
Check(!firstStamp.empty(), "the cold run recorded a source stamp");
fs::file_time_type afterCold = fs::last_write_time(probePcm);
// Unchanged sources must be a no-op — the stamp is an invalidation key, not
// an excuse to precompile eleven modules on every invocation.
try {
(void)LoadProject(projectFile, noArgs);
} catch (const std::exception& e) {
std::println(std::cerr, "FAIL: warm LoadProject threw: {}", e.what());
return 1;
}
Check(fs::last_write_time(probePcm) == afterCold, "unchanged sources do not rebuild the cache");
Check(ReadStamp(moduleCacheDir) == firstStamp, "unchanged sources keep the same stamp");
// Now the case the mtime rule cannot see. The edit lands in a module the
// probe does not name, because the cached PCMs import each other: a change
// anywhere in the set invalidates all of them.
fs::path edited = home / "Crafter.Build-Progress.cppm";
{
std::ofstream append(edited, std::ios::app);
append << "\n// Content change with a backdated timestamp.\n";
}
fs::last_write_time(edited, afterCold - std::chrono::hours(24));
Check(fs::last_write_time(edited) < fs::last_write_time(probePcm), "the edited source really is older than the cached PCM");
try {
(void)LoadProject(projectFile, noArgs);
} catch (const std::exception& e) {
std::println(std::cerr, "FAIL: LoadProject after the edit threw: {}", e.what());
return 1;
}
Check(fs::last_write_time(probePcm) > afterCold, "a content change rebuilds the cache even when the source's mtime is older");
Check(ReadStamp(moduleCacheDir) != firstStamp, "the recorded stamp follows the sources");
fs::remove_all(scratch, ec);
if (Failures > 0) {
std::println(std::cerr, "{} assertions failed", Failures);
return 1;
}
return 0;
}

View file

@ -0,0 +1,19 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Copied over lib/Widget.cppm mid-test to stand in for the interface edit from
// issue #27: one extra data member, every signature and mangled name unchanged.
// Kept as a .cppm.in so the module scanner never treats it as a source of its
// own — and so the text does not have to live in a string literal inside the
// test, where `export module Widget;` would make the test itself look like an
// implementation unit of Widget.
export module Widget;
import std;
export struct Widget {
std::string a;
std::string b;
};
export std::size_t WidgetSizeInLibrary();

View file

@ -0,0 +1,7 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module Widget;
import std;
std::size_t WidgetSizeInLibrary() { return sizeof(Widget); }

View file

@ -0,0 +1,19 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
export module Widget;
import std;
// The test rewrites this struct to add a member. Adding one changes the class
// layout while leaving every signature and mangled name untouched, so nothing
// downstream fails to link — a consumer object left over from before the change
// keeps the old sizeof and quietly disagrees with the library.
export struct Widget {
std::string a;
};
// Deliberately out-of-line, in the library's own implementation unit, so the
// value reflects the layout the *library* was compiled against rather than the
// caller's. An inline body would be instantiated from the (rebuilt) BMI in the
// consumer and would agree with it by construction.
export std::size_t WidgetSizeInLibrary();

View file

@ -0,0 +1,17 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
import std;
import Widget;
// Prints "<size the consumer was compiled against> <size the library was
// compiled against>" and exits 1 when they disagree. A mismatch is the
// observable form of the mixed-layout binary a stale consumer object produces —
// this reports it instead of waiting for the SIGSEGV that the real-world case
// (issue #27) produced in a destructor.
int main() {
std::size_t here = sizeof(Widget);
std::size_t inLibrary = WidgetSizeInLibrary();
std::print("{} {}", here, inLibrary);
return here == inLibrary ? 0 : 1;
}

View file

@ -0,0 +1,199 @@
// 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;
// Adding a data member to a class in a module interface must rebuild every
// object compiled against the old layout. The dangerous shape (issue #27) is a
// consumer whose sources were scanned *before* its `dependencies` were assigned:
// its `import <DepModule>;` matched nothing, so its object carried no staleness
// edge to the interface, and a layout change rebuilt the library, relinked the
// consumer, and produced a binary mixing both layouts with no error or warning.
//
// `AddTest` is exactly that shape — it scans tests/<name>/main.cpp and only then
// returns a builder whose .Dependencies() supplies the library — which is why
// the original report saw the corruption in test executables specifically.
namespace {
std::int32_t Failures = 0;
void Check(bool cond, std::string_view msg) {
if (!cond) {
std::println(std::cerr, "FAIL: {}", msg);
++Failures;
}
}
// The fixture is mutated during the run, so work on a copy outside the repo.
fs::path StageFixture() {
fs::path source = fs::current_path() / "tests" / "IncrementalInterfaceChange" / "fixture";
fs::path staged = fs::temp_directory_path() / "crafter-build-incremental-interface-change";
fs::remove_all(staged);
fs::copy(source, staged, fs::copy_options::recursive);
return staged;
}
// Swap in the variant carrying the extra member. Comes from a file rather
// than a string literal here: the module scanner reads raw source, so an
// `export module Widget;` spelled inside this test would make the test look
// like an implementation unit of Widget.
void GrowWidget(const fs::path& staged) {
fs::copy_file(staged / "lib" / "Widget-grown.cppm.in", staged / "lib" / "Widget.cppm", fs::copy_options::overwrite_existing);
// copy_file carries the source's mtime across, which would leave the
// rewritten interface looking older than the BMI built from it.
fs::last_write_time(staged / "lib" / "Widget.cppm", fs::file_time_type::clock::now());
}
std::unique_ptr<Configuration> MakeLib(const fs::path& staged) {
auto lib = std::make_unique<Configuration>();
lib->path = staged / "lib";
lib->name = "widget";
lib->outputName = "widget";
lib->target = HostTarget();
lib->type = ConfigurationType::LibraryStatic;
std::array<fs::path, 1> ifaces = { "Widget" };
std::array<fs::path, 1> impls = { "Widget" };
lib->GetInterfacesAndImplementations(ifaces, impls);
return lib;
}
// Scan first, wire the dependency up afterwards — the ordering that used to
// silently drop the staleness edge.
Configuration MakeConsumerScannedBeforeDependencies(const fs::path& staged, Configuration* lib) {
Configuration app;
app.path = staged;
app.name = "widget-app";
app.outputName = "widget-app";
app.target = HostTarget();
app.type = ConfigurationType::Executable;
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "main" };
app.GetInterfacesAndImplementations(ifaces, impls);
app.dependencies = { lib };
return app;
}
bool BuildOk(Configuration& app, std::string_view label) {
// A fresh depResults per pass: the map memoizes each Configuration's
// build for the duration of one pass, so reusing it would skip the
// library's second build entirely.
std::unordered_map<fs::path, std::shared_future<BuildResult>> depResults;
std::mutex depMutex;
BuildResult r = Build(app, depResults, depMutex);
if (!r.result.empty()) {
std::println(std::cerr, "FAIL: {} build failed: {}", label, r.result);
++Failures;
return false;
}
return true;
}
}
int main() {
{
// The scan leaves the unmatched import recorded rather than forgotten,
// and ResolvePendingImports places it once the library is reachable.
fs::path staged = StageFixture();
std::unique_ptr<Configuration> lib = MakeLib(staged);
Configuration app;
app.path = staged;
app.name = "widget-app";
app.outputName = "widget-app";
app.target = HostTarget();
app.type = ConfigurationType::Executable;
std::array<fs::path, 0> ifaces = {};
std::array<fs::path, 1> impls = { "main" };
app.GetInterfacesAndImplementations(ifaces, impls);
Check(app.implementations.size() == 1, "consumer has one implementation");
if (app.implementations.size() == 1) {
const Implementation& impl = app.implementations[0];
Check(impl.externalModuleDependencies.empty(), "no external dep resolvable before dependencies are assigned");
Check(std::ranges::find(impl.pendingImports, "Widget") != impl.pendingImports.end(), "unresolved 'import Widget;' is recorded as pending");
app.dependencies = { lib.get() };
app.ResolvePendingImports();
Check(impl.externalModuleDependencies.size() == 1, "ResolvePendingImports adds the external module dep");
if (impl.externalModuleDependencies.size() == 1) {
Check(impl.externalModuleDependencies[0].first->name == "Widget", "external dep is the Widget module");
Check(impl.externalModuleDependencies[0].second == lib->PcmDir() / "Widget.pcm", "external dep points at the library's BMI");
}
Check(std::ranges::find(impl.pendingImports, "Widget") == impl.pendingImports.end(), "resolved import is no longer pending");
// Idempotent: a second sweep must not duplicate the edge (Build
// runs one unconditionally, on top of whatever callers already did).
app.ResolvePendingImports();
Check(impl.externalModuleDependencies.size() == 1, "ResolvePendingImports is idempotent");
}
}
{
// AddTest is the reported path: it scans the test source, then hands
// back a builder whose .Dependencies() names the library.
fs::path staged = StageFixture();
fs::create_directories(staged / "tests" / "Consumer");
fs::copy_file(staged / "main.cpp", staged / "tests" / "Consumer" / "main.cpp");
std::unique_ptr<Configuration> lib = MakeLib(staged);
Configuration app;
app.path = staged;
app.name = "host";
app.outputName = "host";
app.target = HostTarget();
app.type = ConfigurationType::Executable;
// AddTest resolves tests/<name>/main against the cwd, which for a real
// run is the directory holding project.cpp. Stand in the staged project
// for the declaration so the fixture's test source is the one scanned.
fs::path restore = fs::current_path();
fs::current_path(staged);
app.AddTest("Consumer").Dependencies({ lib.get() });
fs::current_path(restore);
Check(app.tests.size() == 1, "one test declared");
if (app.tests.size() == 1 && app.tests[0].config.implementations.size() == 1) {
const Implementation& impl = app.tests[0].config.implementations[0];
Check(impl.externalModuleDependencies.size() == 1, "AddTest(...).Dependencies() resolves the test's import of the library module");
Check(impl.pendingImports.empty() || std::ranges::find(impl.pendingImports, "Widget") == impl.pendingImports.end(), "test's 'import Widget;' is no longer pending");
}
}
{
// End to end, and deliberately without calling ResolvePendingImports:
// the guarantee under test is that Build() closes the window on its own,
// for consumers that never knew they had to ask.
fs::path staged = StageFixture();
std::unique_ptr<Configuration> lib = MakeLib(staged);
Configuration app = MakeConsumerScannedBeforeDependencies(staged, lib.get());
fs::path binary = app.BinDir() / "widget-app";
fs::path consumerObject = app.BuildDir() / "main_impl.o";
if (BuildOk(app, "first pass")) {
auto first = RunCommandWithTimeout(binary.string(), std::chrono::seconds(30));
Check(first.exitCode == 0 && !first.crashed && !first.timedOut, std::format("first pass agrees on the layout (exit={} output='{}')", first.exitCode, first.output));
fs::file_time_type objectBefore = fs::last_write_time(consumerObject);
// Same edit as the original report: one more member on a class in a
// module interface. Every signature and mangled name is unchanged,
// so a missed rebuild produces no diagnostic of any kind.
GrowWidget(staged);
if (BuildOk(app, "second pass")) {
Check(fs::last_write_time(consumerObject) > objectBefore, "consumer object is recompiled after the interface gains a member");
auto second = RunCommandWithTimeout(binary.string(), std::chrono::seconds(30));
Check(second.exitCode == 0 && !second.crashed && !second.timedOut, std::format("second pass agrees on the layout (exit={} output='{}')", second.exitCode, second.output));
}
}
}
if (Failures > 0) {
std::println(std::cerr, "{} assertions failed", Failures);
return 1;
}
return 0;
}

View file

@ -83,6 +83,31 @@ int main() {
Check(!q.Get("--other=").has_value(), "ArgQuery::Get returns nullopt for absent prefix"); Check(!q.Get("--other=").has_value(), "ArgQuery::Get returns nullopt for absent prefix");
} }
// Flags ApplyStandardArgs doesn't itself interpret are collected into
// cfg.projectArgs, which feeds VariantId — a project flag that changes what
// gets compiled has to move the output directory with it.
{
Configuration cfg;
std::array<std::string_view, 5> raw = {
"--debug", "--no-webgpu", "--target=aarch64-linux-gnu", "--feature=fancy", "bare",
};
ApplyStandardArgs(cfg, raw);
std::vector<std::string> expected = { "--feature=fancy", "--no-webgpu", "bare" };
Check(cfg.projectArgs == expected, std::format("only unrecognised args are collected, sorted; got [{}]", std::format("{}", std::views::join_with(cfg.projectArgs, std::string(", ")) | std::ranges::to<std::string>())));
}
// Order and repetition must not perturb the set — otherwise the same build
// spelled two ways would land in two directories.
{
Configuration a;
Configuration b;
std::array<std::string_view, 2> forward = { "--alpha", "--beta" };
std::array<std::string_view, 3> reversed = { "--beta", "--alpha", "--beta" };
ApplyStandardArgs(a, forward);
ApplyStandardArgs(b, reversed);
Check(a.projectArgs == b.projectArgs, "projectArgs is order- and duplicate-insensitive");
}
if (Failures > 0) { if (Failures > 0) {
std::println(std::cerr, "{} assertions failed", Failures); std::println(std::cerr, "{} assertions failed", Failures);
return 1; return 1;

View file

@ -96,6 +96,25 @@ int main() {
b.march = "x86-64-v3"; b.march = "x86-64-v3";
Check(a.VariantId() != b.VariantId(), "march change perturbs VariantId"); Check(a.VariantId() != b.VariantId(), "march change perturbs VariantId");
} }
{
// A project flag the framework can't interpret still decides what gets
// compiled and bundled, so it has to key into VariantId. Otherwise both
// settings share one bin dir and interleave their outputs there — an
// index.html from one alongside a script from the other.
Configuration a = MakeBase();
Configuration b = MakeBase();
b.projectArgs.push_back("--no-webgpu");
Check(a.VariantId() != b.VariantId(), "project arg perturbs VariantId");
Check(a.BinDir() != b.BinDir(), "project arg yields a distinct BinDir");
}
{
// Distinct project args must not collide with each other either.
Configuration a = MakeBase();
a.projectArgs.push_back("--no-webgpu");
Configuration b = MakeBase();
b.projectArgs.push_back("--no-audio");
Check(a.VariantId() != b.VariantId(), "different project args yield different VariantId");
}
{ {
// PcmDir() differs between Executable (in BuildDir) and Library // PcmDir() differs between Executable (in BuildDir) and Library
// (in BinDir) — Library PCMs land in the installable bin dir so // (in BinDir) — Library PCMs land in the installable bin dir so