fix: key the host PCM cache on source content, add clean, hash project args

Three follow-ons to the stale-build report, all cases of an identity not
capturing something that changes the output.

The host PCM cache under <cache>/crafter.build/<target>-<march>/ is shared by
every crafter-build on the machine, and freshness was a per-file mtime
comparison. That cannot tell "this PCM is newer than my source" from "this PCM
was built from different sources that happen to be newer", so a package install
and a working checkout — or two checkouts of different versions — silently
compiled their project.cpp against each other's declarations. Invalidation now
keys on a stamp over the bytes of every module source, which also covers the
case one file's mtime never could: the cached PCMs import each other, so a
change to :Interface invalidates :Clang's PCM with Crafter.Build-Clang.cppm
untouched.

Project args ApplyStandardArgs does not itself interpret are now folded into
VariantId. Such a flag typically decides what gets compiled or bundled — the
report's example is --no-webgpu dropping entries from cfg.files — and without it
both settings shared one bin dir and interleaved their outputs there, leaving a
bundle matching neither. Sorted and deduplicated so flag order doesn't split the
cache, and inherited by test Configurations.

`crafter-build clean` removes the project's bin/ and build/ trees. It
deliberately does not load project.cpp: cleaning is most often reached when
something is already wrong, and a clean that first needs the project to compile
is useless exactly then.
This commit is contained in:
catbot 2026-07-30 17:43:09 +00:00
commit e8fde57582
9 changed files with 439 additions and 15 deletions

View file

@ -63,22 +63,22 @@ namespace {
}
void Configuration::ResolvePendingImports() {
// Discard the local-module hits: a name recorded as pending already failed
// to match this Configuration's own interfaces, and nothing adds interfaces
// between the scan and here. Only the external edge can newly appear.
std::vector<Module*> ignored;
auto sweep = [this, &ignored](std::vector<std::string>& pending, std::vector<std::pair<Module*, fs::path>>& externalDeps) {
// 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, ignored, externalDeps);
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->externalModuleDependencies);
sweep(partition->pendingImports, partition->moduleDependencies, partition->externalModuleDependencies);
}
}
for(Implementation& implementation : implementations) {
sweep(implementation.pendingImports, implementation.externalModuleDependencies);
sweep(implementation.pendingImports, implementation.moduleDependencies, implementation.externalModuleDependencies);
}
}
@ -1446,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("--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()));
// 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 (sawShared && cfg.type == ConfigurationType::LibraryStatic) cfg.type = ConfigurationType::LibraryDynamic;
// WASI sysroot autodetect, applied at config-load time so the VariantId
@ -1461,6 +1469,21 @@ ArgQuery Crafter::ApplyStandardArgs(Configuration& cfg, std::span<const std::str
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) {
std::println(
R"(Usage:
@ -1468,6 +1491,7 @@ R"(Usage:
{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} 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
Loads ./project.cpp (override with --project=<path>), compiles it to a shared
@ -1510,10 +1534,17 @@ Format options (after the `format` subcommand):
transform. `format` rewrites changed files in place; `lint` reports the
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:
Any flag not consumed above is forwarded verbatim to CrafterBuildProject as
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:
CRAFTER_BUILD_MARCH Override -march (default: native).
@ -1541,6 +1572,7 @@ int Crafter::Run(int argc, char** argv) {
bool runTests = false;
bool runLint = false;
bool runFormat = false;
bool runClean = false;
bool runAfterBuild = false;
RunTestsOptions testOpts;
RunLintOptions lintOpts;
@ -1551,11 +1583,13 @@ int Crafter::Run(int argc, char** argv) {
if (arg == "-h" || arg == "--help" || (!runTests && !runLint && !runFormat && arg == "help")) {
PrintHelp(argv0);
return 0;
} else if (!runLint && !runFormat && arg == "test") {
} else if (!runLint && !runFormat && !runClean && arg == "test") {
runTests = true;
} else if (!runTests && !runFormat && arg == "lint") {
} else if (!runTests && !runFormat && !runClean && arg == "lint") {
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;
lintOpts.mode = LintMode::Apply;
} else if (runFormat && arg == "--check") {
@ -1603,6 +1637,17 @@ int Crafter::Run(int argc, char** argv) {
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);
SetParentProject(&config);

View file

@ -74,6 +74,54 @@ namespace {
CacheLock(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() {
@ -328,13 +376,17 @@ std::string Crafter::GetBaseCommand(const Configuration& config) {
namespace {
void EnsureCrafterBuildPcms(const fs::path& sourceDir, const fs::path& 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) {
fs::path cppmPath = sourceDir / std::format("{}.cppm", name);
fs::path pcmPath = cacheDir / std::format("{}.pcm", name);
if (!fs::exists(cppmPath)) {
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;
}
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));
}
}
// 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 {
void EnsureCrafterBuildPcms(const fs::path& sourceDir, const fs::path& 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();
for (std::string_view name : CrafterBuildModules) {
fs::path cppmPath = sourceDir / std::format("{}.cppm", name);
@ -530,7 +590,7 @@ namespace {
if (!fs::exists(cppmPath)) {
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;
}
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));
}
}
// 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) {
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) {
fs::path cppmPath = sourceDir / std::format("{}.cppm", name);
fs::path pcmPath = cacheDir / std::format("{}.pcm", name);
if (!fs::exists(cppmPath)) {
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;
}
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));
}
}
// 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.sysroot = this->sysroot;
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;
// 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.sysroot = this->sysroot;
t.config.debug = this->debug;
t.config.projectArgs = this->projectArgs;
t.config.type = ConfigurationType::Executable;
fs::path mainSource = fs::path("tests") / std::string(name) / "main";

View file

@ -250,6 +250,15 @@ export namespace Crafter {
// clobber the baseline's. Not for direct project use — declare
// wasmVariants instead.
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;
// Lint rules for `crafter-build lint`. Populate via AddLintRule.
std::vector<LintRule> lintRules;
@ -315,6 +324,12 @@ export namespace Crafter {
compileKey += "|W:";
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);
return std::format("{}-{}-{}-{}-{:08x}", name, target, march, mtune, configHash);
}
@ -345,6 +360,16 @@ export namespace Crafter {
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
// 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

View file

@ -108,6 +108,7 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
cfg.AddTest("ModuleInterface").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("StandardArgs").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("TestRunnerSpec").Dependencies({ CrafterBuildLib.get() });
@ -121,6 +122,9 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
// for project.so.
cfg.AddTest("ConcurrentCacheRace").Dependencies({ CrafterBuildLib.get() })
.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("Lint").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

@ -83,6 +83,31 @@ int main() {
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) {
std::println(std::cerr, "{} assertions failed", Failures);
return 1;

View file

@ -96,6 +96,25 @@ int main() {
b.march = "x86-64-v3";
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
// (in BinDir) — Library PCMs land in the installable bin dir so