// 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 ;` 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//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 MakeLib(const fs::path& staged) { auto lib = std::make_unique(); lib->path = staged / "lib"; lib->name = "widget"; lib->outputName = "widget"; lib->target = HostTarget(); lib->type = ConfigurationType::LibraryStatic; std::array ifaces = { "Widget" }; std::array 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 ifaces = {}; std::array 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> 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 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 ifaces = {}; std::array 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 lib = MakeLib(staged); Configuration app; app.path = staged; app.name = "host"; app.outputName = "host"; app.target = HostTarget(); app.type = ConfigurationType::Executable; // AddTest resolves tests//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 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; }