diff --git a/README.md b/README.md index 4b8b6de..f5347b2 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ 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` 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. +Tracking is derived from each translation unit's `import` statements, which are scanned when the sources are declared. Every kind of unit is scanned — primary module interfaces, partitions and implementation units alike — and an interface that imports a sibling module in the same `Configuration` also gets its compile ordered behind it. `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 all of 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 `/crafter.build/-/` 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. diff --git a/implementations/Crafter.Build-Clang.cpp b/implementations/Crafter.Build-Clang.cpp index 6dc681c..28491f8 100644 --- a/implementations/Crafter.Build-Clang.cpp +++ b/implementations/Crafter.Build-Clang.cpp @@ -73,6 +73,7 @@ void Configuration::ResolvePendingImports() { }); }; for(const std::unique_ptr& interface : interfaces) { + sweep(interface->pendingImports, interface->moduleDependencies, interface->externalModuleDependencies); for(const std::unique_ptr& partition : interface->partitions) { sweep(partition->pendingImports, partition->moduleDependencies, partition->externalModuleDependencies); } @@ -104,19 +105,26 @@ void Configuration::GetInterfacesAndImplementations(std::span interfac tempModulePaths[i] = {file, fileContent, nullptr, nullptr}; } - std::erase_if(tempModulePaths, [this](std::tuple& file) { + // Primary interface units first, so the partition pass below can find their + // parent Module. They stay in tempModulePaths — a primary unit's own + // `import X;` lines are layout dependencies exactly like a partition's, and + // dropping the entry here is what left them unrecorded (issue #26). Marked + // by a null partition slot with the Module slot filled in. + for(std::tuple& file : tempModulePaths) { std::smatch match; if (std::regex_search(std::get<1>(file), match, std::regex(R"(export module ([a-zA-Z0-9_\.\-]+);)"))) { - std::get<0>(file).replace_extension(""); - this->interfaces.push_back(std::make_unique(std::move(match[1].str()), std::move(std::get<0>(file)))); - return true; - } else { - return false; + fs::path pthCpy = std::get<0>(file); + pthCpy.replace_extension(""); + this->interfaces.push_back(std::make_unique(std::move(match[1].str()), std::move(pthCpy))); + std::get<3>(file) = this->interfaces.back().get(); } - }); + } for(std::uint16_t i = 0; i < tempModulePaths.size(); i++) { std::smatch match; + if (std::get<3>(tempModulePaths[i]) != nullptr) { + continue; + } if (std::regex_search(std::get<1>(tempModulePaths[i]), match, std::regex(R"(export module ([a-zA-Z_0-9\.\-]+):([a-zA-Z_0-9\.\-]+);)"))) { for(const std::unique_ptr& modulee : this->interfaces) { if(modulee->name == match[1]) { @@ -142,6 +150,22 @@ void Configuration::GetInterfacesAndImplementations(std::span interfac Module* parentModule = std::get<3>(file); const std::string& fileContent = std::get<1>(file); + // Primary interface unit: record only its module imports. Its + // `[export] import :Part;` lines need no edge — Module::Check walks + // every partition it owns and Module::Compile builds them all before + // precompiling itself, so partitions are covered wholesale. + if (partition == nullptr) { + std::regex primaryPattern(R"(import ([a-zA-Z_0-9\.\-]+);)"); + std::sregex_iterator primaryCurrent(fileContent.begin(), fileContent.end(), primaryPattern); + std::sregex_iterator primaryLast; + while (primaryCurrent != primaryLast) { + std::smatch match = *primaryCurrent; + resolveImport(match[1].str(), parentModule->moduleDependencies, parentModule->externalModuleDependencies, parentModule->pendingImports); + ++primaryCurrent; + } + continue; + } + std::regex partitionPattern(R"(import :([a-zA-Z_\-0-9\.]+);)"); std::sregex_iterator currentMatch(fileContent.begin(), fileContent.end(), partitionPattern); std::sregex_iterator lastMatch; @@ -882,20 +906,15 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_map externalFloor) externalFloor = ext.latestArtifact; } + // Check every interface before compiling any of them. Module::Compile waits + // on the `compiled` flag of each sibling module it imports, and that flag is + // set either by a Compile that runs or by the Check that decides none is + // needed — so a Check still pending while another module's thread is already + // waiting would block on a flag nothing goes on to raise. + std::vector staleInterfaces; for(std::unique_ptr& interface : config.interfaces) { if(interface->Check(pcmDir, externalFloor)) { - Module* mod = interface.get(); - threads.emplace_back([mod, &command, &pcmDir, &buildDir, &buildCancelled, &buildError]() { - Progress::Task task(std::format("Compiling interface {}", mod->path.filename().string())); - try { - mod->Compile(command, pcmDir, buildDir, buildCancelled, buildError); - } catch (const std::exception& e) { - bool expected = false; - if (buildCancelled.compare_exchange_strong(expected, true)) { - buildError = std::format("Module::Compile threw: {}", e.what()); - } - } - }); + staleInterfaces.push_back(interface.get()); buildResult.repack = true; } files += std::format(" {}/{}.o", buildDir.string(), interface->path.filename().string()); @@ -904,6 +923,20 @@ BuildResult Crafter::Build(Configuration& config, std::unordered_mappath.filename().string())); + try { + mod->Compile(command, pcmDir, buildDir, buildCancelled, buildError); + } catch (const std::exception& e) { + bool expected = false; + if (buildCancelled.compare_exchange_strong(expected, true)) { + buildError = std::format("Module::Compile threw: {}", e.what()); + } + } + }); + } + for(Implementation& implementation : config.implementations) { if(implementation.Check(buildDir, pcmDir, externalFloor)) { buildResult.repack = true; diff --git a/implementations/Crafter.Build-Interface.cpp b/implementations/Crafter.Build-Interface.cpp index 91400ca..b92e9c6 100644 --- a/implementations/Crafter.Build-Interface.cpp +++ b/implementations/Crafter.Build-Interface.cpp @@ -39,6 +39,10 @@ namespace Crafter { } needsRecompiling = false; compiled.store(true); + // Nothing waits on `compiled` until the compile threads start, + // which is after every Check has run — but notify anyway so the + // flag is never left set without a wake-up behind it. + compiled.notify_all(); return false; } else { needsRecompiling = true; @@ -100,24 +104,55 @@ namespace Crafter { std::string pcmPath = std::format("{}.pcm", (pcmDir/path.filename()).generic_string()); std::string cppmPath = std::format("{}.cppm", path.generic_string()); if(fs::exists(pcmPath) && std::max(fs::last_write_time(cppmPath), sourceFloor) < fs::last_write_time(pcmPath)) { + fs::file_time_type pcmTime = fs::last_write_time(pcmPath); + // Every partition gets Check()ed even once one is known stale: + // Compile() drives partition rebuilds off their own + // needsRecompiling flags, so short-circuiting here would leave + // the later ones unevaluated and silently unbuilt. bool depCheck = false; for(std::unique_ptr& partition : partitions) { if(partition->Check(pcmDir, sourceFloor)) { depCheck = true; } } + // Modules this interface unit imports directly. Local ones share + // our pcmDir and are resolved recursively; external ones are + // compared by PCM mtime, their owning Configuration having + // already finished building by the time we run. + for(Module* dependency : moduleDependencies) { + if(dependency->Check(pcmDir, sourceFloor)) { + depCheck = true; + } + } + if(!depCheck) { + for(const auto& [externalMod, externalPcmPath] : externalModuleDependencies) { + std::error_code ec; + fs::file_time_type t = fs::last_write_time(externalPcmPath, ec); + if (!ec && t >= pcmTime) { + depCheck = true; + break; + } + } + } if(depCheck) { needsRecompiling = true; return true; } else { needsRecompiling = false; compiled.store(true); + compiled.notify_all(); return false; } } else { + // Already known stale, but the dependencies still need + // evaluating: an unchecked local module would never get built, + // and Compile() below waits on its `compiled` flag. for(std::unique_ptr& partition : partitions) { partition->Check(pcmDir, sourceFloor); } + for(Module* dependency : moduleDependencies) { + dependency->Check(pcmDir, sourceFloor); + } needsRecompiling = true; return true; } @@ -127,6 +162,17 @@ namespace Crafter { } void Module::Compile(const std::string_view clang, const fs::path& pcmDir, const fs::path& buildDir, std::atomic& buildCancelled, std::string& buildError) { + // A sibling module in the same Configuration that this interface unit + // imports has to have its PCM on disk before we precompile against it. + // Safe to block here: Build() Checks every interface before it spawns + // any compile thread, so a dependency that needs no rebuild already has + // `compiled` set and this returns immediately. + for(Module* dependency : moduleDependencies) { + if(!dependency->compiled.load()) { + dependency->compiled.wait(false); + } + } + std::vector threads; threads.reserve(partitions.size()); for(std::unique_ptr& part : partitions) { diff --git a/interfaces/Crafter.Build-Interface.cppm b/interfaces/Crafter.Build-Interface.cppm index b1c2d0c..3991c24 100644 --- a/interfaces/Crafter.Build-Interface.cppm +++ b/interfaces/Crafter.Build-Interface.cppm @@ -22,7 +22,7 @@ namespace Crafter { // Names that never resolve (`std`, module-mapped externals) stay here. std::vector pendingImports; std::atomic compiled; - bool needsRecompiling; + bool needsRecompiling = false; bool checked = false; std::string name; fs::path path; @@ -33,8 +33,17 @@ namespace Crafter { export class Module { public: + // A primary module interface unit imports things too — `import Base;` + // in `export module Widget;` is as much a layout dependency as the + // same line in a partition. Recorded on the same three vectors, with + // the same meanings, so Check() can see through them and Compile() + // can order itself behind a sibling module in the same Configuration + // (issue #26). + std::vector moduleDependencies; + std::vector> externalModuleDependencies; + std::vector pendingImports; std::atomic compiled; - bool needsRecompiling; + bool needsRecompiling = false; bool checked = false; std::vector> partitions; std::string name; diff --git a/project.cpp b/project.cpp index 0def7cd..33d04a7 100644 --- a/project.cpp +++ b/project.cpp @@ -108,6 +108,7 @@ extern "C" Configuration CrafterBuildProject(std::span a cfg.AddTest("ModuleInterface").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("DependencyLink").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("IncrementalInterfaceChange").Dependencies({ CrafterBuildLib.get() }); + cfg.AddTest("TransitiveInterfaceChange").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("CleanProject").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("ShaderCompile").Dependencies({ CrafterBuildLib.get() }); cfg.AddTest("StandardArgs").Dependencies({ CrafterBuildLib.get() }); diff --git a/tests/TransitiveInterfaceChange/fixture/base/Base-grown.cppm.in b/tests/TransitiveInterfaceChange/fixture/base/Base-grown.cppm.in new file mode 100644 index 0000000..3e0b305 --- /dev/null +++ b/tests/TransitiveInterfaceChange/fixture/base/Base-grown.cppm.in @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +export module Base; +import std; + +export namespace Demo { + class Payload { + public: + // The whole point of the fixture: `first` moves, and nothing about any + // signature or mangled name changes with it. + std::array padding{}; + std::int32_t first = 1; + void Stamp(); + }; +} diff --git a/tests/TransitiveInterfaceChange/fixture/base/Base.cpp b/tests/TransitiveInterfaceChange/fixture/base/Base.cpp new file mode 100644 index 0000000..3307f52 --- /dev/null +++ b/tests/TransitiveInterfaceChange/fixture/base/Base.cpp @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +module Base; +import std; + +namespace Demo { + void Payload::Stamp() { + first = 11; + } +} diff --git a/tests/TransitiveInterfaceChange/fixture/base/Base.cppm b/tests/TransitiveInterfaceChange/fixture/base/Base.cppm new file mode 100644 index 0000000..eed5adb --- /dev/null +++ b/tests/TransitiveInterfaceChange/fixture/base/Base.cppm @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +export module Base; +import std; + +export namespace Demo { + class Payload { + public: + std::int32_t first = 1; + void Stamp(); + }; +} diff --git a/tests/TransitiveInterfaceChange/fixture/main.cpp b/tests/TransitiveInterfaceChange/fixture/main.cpp new file mode 100644 index 0000000..7fc3c58 --- /dev/null +++ b/tests/TransitiveInterfaceChange/fixture/main.cpp @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// Consumer of the layout under test. `Thing` lives on the stack here, so a +// stale object file that still believes the old `sizeof(Payload)` lets the +// library's Fill() write past what this frame reserved. Reports the disagreement +// as a non-zero exit rather than relying on the corruption to fault. +import Widget; +import std; + +int main() { + Demo::Thing thing; + thing.Fill(); + std::println("first={} tail={} sizeof={}", thing.payload.first, thing.tail, sizeof(Demo::Thing)); + if (thing.payload.first != 11 || thing.tail != 7) { + return 1; + } + return 0; +} diff --git a/tests/TransitiveInterfaceChange/fixture/widget/Widget.cpp b/tests/TransitiveInterfaceChange/fixture/widget/Widget.cpp new file mode 100644 index 0000000..e1170bc --- /dev/null +++ b/tests/TransitiveInterfaceChange/fixture/widget/Widget.cpp @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +module Widget; +import std; + +namespace Demo { + void Thing::Fill() { + payload.Stamp(); + tail = 7; + } +} diff --git a/tests/TransitiveInterfaceChange/fixture/widget/Widget.cppm b/tests/TransitiveInterfaceChange/fixture/widget/Widget.cppm new file mode 100644 index 0000000..fc2fec6 --- /dev/null +++ b/tests/TransitiveInterfaceChange/fixture/widget/Widget.cppm @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® + +// A *primary* module interface unit — no partitions — that imports another +// module and embeds one of its types by value. The import lives here rather +// than in a partition on purpose: that is the edge issue #26 was missing. +export module Widget; +import std; +export import Base; + +export namespace Demo { + class Thing { + public: + Payload payload; + std::int32_t tail = 0; + void Fill(); + }; +} diff --git a/tests/TransitiveInterfaceChange/main.cpp b/tests/TransitiveInterfaceChange/main.cpp new file mode 100644 index 0000000..845dcd7 --- /dev/null +++ b/tests/TransitiveInterfaceChange/main.cpp @@ -0,0 +1,237 @@ +// 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; + +// Issue #26. A *primary* module interface unit — `export module Widget;`, no +// partitions — records nothing about what it imports. The scanner registered +// the Module and then dropped the file before the import pass ran, and Module +// itself had nowhere to put the edge, so: +// +// * `Module::Check` looked only at its own .cppm and its partitions. A layout +// change in an imported module left Widget.pcm and Widget.o untouched, the +// consumer's object untouched, and the executable relinked — mixing two +// layouts with no diagnostic. That is the reported failure: rebuild the +// dependency, relink the test, crash somewhere unrelated. +// +// * `Module::Compile` waited on nothing. Two modules in one Configuration +// compile on concurrent threads, so importing a sibling was a coin flip +// between working and "module 'Base' not found". +// +// Partitions never had either problem — they carry the same three vectors and +// Check/Compile honour them — which is why the gap only shows up on a module +// whose interface is one flat unit. tests/IncrementalInterfaceChange covers the +// neighbouring consumer-side edge (issue #27). + +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" / "TransitiveInterfaceChange" / "fixture"; + fs::path staged = fs::temp_directory_path() / "crafter-build-transitive-interface-change"; + fs::remove_all(staged); + fs::copy(source, staged, fs::copy_options::recursive); + return staged; + } + + // Swap in the Base variant carrying the extra member. From a file rather + // than a string literal: the module scanner reads raw source, so a module + // declaration spelled inside this test would make the test itself look like + // an implementation unit of Base. + void GrowBase(const fs::path& staged) { + fs::copy_file(staged / "base" / "Base-grown.cppm.in", staged / "base" / "Base.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 / "base" / "Base.cppm", fs::file_time_type::clock::now()); + } + + std::unique_ptr MakeBaseLib(const fs::path& staged) { + auto base = std::make_unique(); + base->path = staged / "base"; + base->name = "base"; + base->outputName = "base"; + base->target = HostTarget(); + base->type = ConfigurationType::LibraryStatic; + std::array ifaces = { "Base" }; + std::array impls = { "Base" }; + base->GetInterfacesAndImplementations(ifaces, impls); + return base; + } + + std::unique_ptr MakeWidgetLib(const fs::path& staged) { + auto widget = std::make_unique(); + widget->path = staged / "widget"; + widget->name = "widget"; + widget->outputName = "widget"; + widget->target = HostTarget(); + widget->type = ConfigurationType::LibraryStatic; + std::array ifaces = { "Widget" }; + std::array impls = { "Widget" }; + widget->GetInterfacesAndImplementations(ifaces, impls); + return widget; + } + + Module* FindModule(const Configuration& cfg, std::string_view name) { + for (const std::unique_ptr& mod : cfg.interfaces) { + if (mod->name == name) return mod.get(); + } + return nullptr; + } + + bool BuildOk(Configuration& cfg, 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 + // dependencies' second build entirely. + std::unordered_map> depResults; + std::mutex depMutex; + BuildResult r = Build(cfg, depResults, depMutex); + if (!r.result.empty()) { + std::println(std::cerr, "FAIL: {} build failed: {}", label, r.result); + ++Failures; + return false; + } + return true; + } + + // Non-zero exit here means the two ends of the link disagree about the + // layout — see the fixture's main.cpp. + void CheckAgrees(const fs::path& binary, std::string_view label) { + auto run = RunCommandWithTimeout(binary.string(), std::chrono::seconds(30)); + Check(run.exitCode == 0 && !run.crashed && !run.timedOut, std::format("{}: consumer and library agree on the layout (exit={} output='{}')", label, run.exitCode, run.output)); + } +} + +int main() { + { + // The scan records a primary interface unit's imports, and the + // pending/resolve machinery reaches them the same way it reaches a + // partition's. + fs::path staged = StageFixture(); + std::unique_ptr base = MakeBaseLib(staged); + std::unique_ptr widget = MakeWidgetLib(staged); + + Module* mod = FindModule(*widget, "Widget"); + Check(mod != nullptr, "the primary interface unit registered a module named Widget"); + if (mod != nullptr) { + Check(mod->partitions.empty(), "Widget has no partitions, so nothing else could carry the edge"); + Check(mod->externalModuleDependencies.empty(), "nothing resolvable before dependencies are assigned"); + Check(std::ranges::find(mod->pendingImports, "Base") != mod->pendingImports.end(), "the unresolved import of Base is remembered as pending"); + Check(std::ranges::find(mod->pendingImports, "std") != mod->pendingImports.end(), "std stays pending — it is supplied from outside the graph"); + + widget->dependencies = { base.get() }; + widget->ResolvePendingImports(); + + Check(mod->externalModuleDependencies.size() == 1, "ResolvePendingImports places the external module edge"); + if (mod->externalModuleDependencies.size() == 1) { + Check(mod->externalModuleDependencies[0].first->name == "Base", "the edge names the Base module"); + Check(mod->externalModuleDependencies[0].second == base->PcmDir() / "Base.pcm", "the edge points at the dependency's BMI"); + } + Check(std::ranges::find(mod->pendingImports, "Base") == mod->pendingImports.end(), "the resolved name is no longer pending"); + Check(std::ranges::find(mod->pendingImports, "std") != mod->pendingImports.end(), "std is still pending after the sweep"); + + // Build() runs a sweep unconditionally on top of whatever callers + // already did, so a repeat must not duplicate the edge. + widget->ResolvePendingImports(); + Check(mod->externalModuleDependencies.size() == 1, "ResolvePendingImports is idempotent"); + } + } + + { + // Two modules in one Configuration, declared in the order that used to + // hide the problem: Widget — which imports Base — is registered first. + // Every primary unit is registered before any import is resolved, so + // declaration order must not matter. + fs::path staged = StageFixture(); + Configuration cfg; + cfg.path = staged; + cfg.name = "sibling"; + cfg.outputName = "sibling"; + cfg.target = HostTarget(); + cfg.type = ConfigurationType::Executable; + std::array ifaces = { "widget/Widget", "base/Base" }; + std::array impls = { "widget/Widget", "base/Base", "main" }; + cfg.GetInterfacesAndImplementations(ifaces, impls); + + Module* widgetMod = FindModule(cfg, "Widget"); + Module* baseMod = FindModule(cfg, "Base"); + Check(widgetMod != nullptr && baseMod != nullptr, "both sibling modules registered"); + if (widgetMod != nullptr && baseMod != nullptr) { + Check(std::ranges::find(widgetMod->moduleDependencies, baseMod) != widgetMod->moduleDependencies.end(), "Widget's import of its sibling resolved locally despite being declared first"); + Check(widgetMod->externalModuleDependencies.empty(), "a sibling in the same Configuration is not an external edge"); + Check(baseMod->moduleDependencies.empty(), "Base imports no sibling"); + } + + // That edge is also the compile-ordering guarantee: Widget's precompile + // needs Base.pcm on disk. Repeated from scratch because the failure was + // a thread race, and one lucky pass proves nothing. + for (std::int32_t attempt = 0; attempt < 4; ++attempt) { + fs::remove_all(cfg.BuildDir()); + fs::remove_all(cfg.BinDir()); + if (!BuildOk(cfg, std::format("sibling pass {}", attempt))) break; + CheckAgrees(cfg.BinDir() / "sibling", std::format("sibling pass {}", attempt)); + } + } + + { + // End to end, the reported shape: a consumer executable, a library whose + // primary interface unit imports a second library's module, and a member + // added to a class in that second module. + fs::path staged = StageFixture(); + std::unique_ptr base = MakeBaseLib(staged); + std::unique_ptr widget = MakeWidgetLib(staged); + widget->dependencies = { base.get() }; + + Configuration app; + app.path = staged; + app.name = "consumer"; + app.outputName = "consumer"; + app.target = HostTarget(); + app.type = ConfigurationType::Executable; + std::array appIfaces = {}; + std::array appImpls = { "main" }; + app.GetInterfacesAndImplementations(appIfaces, appImpls); + app.dependencies = { widget.get() }; + + fs::path binary = app.BinDir() / "consumer"; + fs::path widgetInterfaceObject = widget->BuildDir() / "Widget.o"; + fs::path widgetImplObject = widget->BuildDir() / "Widget_impl.o"; + fs::path consumerObject = app.BuildDir() / "main_impl.o"; + + if (BuildOk(app, "first pass")) { + CheckAgrees(binary, "first pass"); + + fs::file_time_type interfaceBefore = fs::last_write_time(widgetInterfaceObject); + fs::file_time_type implBefore = fs::last_write_time(widgetImplObject); + fs::file_time_type consumerBefore = fs::last_write_time(consumerObject); + + GrowBase(staged); + + if (BuildOk(app, "second pass")) { + // All three sat on the old layout before the fix, and all three + // were left alone while base rebuilt and both binaries relinked. + Check(fs::last_write_time(widgetInterfaceObject) > interfaceBefore, "the importing interface unit is recompiled"); + Check(fs::last_write_time(widgetImplObject) > implBefore, "that module's implementation unit is recompiled"); + Check(fs::last_write_time(consumerObject) > consumerBefore, "the consumer's translation unit is recompiled"); + + CheckAgrees(binary, "second pass"); + } + } + } + + if (Failures > 0) { + std::println(std::cerr, "{} assertions failed", Failures); + return 1; + } + return 0; +}