// SPDX-License-Identifier: LGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® #include import std; import Crafter.Build; namespace fs = std::filesystem; using namespace Crafter; // The host PCM cache is keyed by `-` 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) {\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 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; }