fix: track what a primary module interface imports
Some checks failed
CI / build-test-release (pull_request) Failing after 6m5s

A primary module interface unit — `export module Widget;`, no partitions —
recorded nothing about what it imported. GetInterfacesAndImplementations
registered the Module and then erased the file from the scan list, so the
import pass only ever saw partitions, and Module had no vectors to hold an
edge anyway.

Two consequences, both reported as issue #26:

  Module::Check consulted only its own .cppm and its partitions. A data
  member added to an imported module left Widget.pcm, Widget.o and every
  consumer object untouched while the imported library rebuilt and both
  binaries relinked — one executable holding two class layouts, no
  diagnostic, and a crash somewhere unrelated. Wiping build/ was the only
  cure, so `crafter-build test` could not be trusted straight after an
  interface edit.

  Module::Compile waited on nothing. Two modules in one Configuration
  compile on concurrent threads, so a primary interface 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 surfaced on a module
whose interface is one flat unit.

Module now carries moduleDependencies, externalModuleDependencies and
pendingImports with the same meanings as on ModulePartition; primary units
stay in the scan list so their imports land there; Check sees through them;
Compile orders itself behind a local sibling; and ResolvePendingImports
sweeps them so an edge survives dependencies being wired up afterwards.
Build() now Checks every interface before spawning any compile thread — the
`compiled` flag a waiter blocks on is raised 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 waits would have hung the build.

Resolves #26

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
catbot 2026-07-30 18:19:04 +00:00
commit f38521298b
12 changed files with 437 additions and 22 deletions

View file

@ -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<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.
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 `<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.

View file

@ -73,6 +73,7 @@ void Configuration::ResolvePendingImports() {
});
};
for(const std::unique_ptr<Module>& interface : interfaces) {
sweep(interface->pendingImports, interface->moduleDependencies, interface->externalModuleDependencies);
for(const std::unique_ptr<ModulePartition>& partition : interface->partitions) {
sweep(partition->pendingImports, partition->moduleDependencies, partition->externalModuleDependencies);
}
@ -104,19 +105,26 @@ void Configuration::GetInterfacesAndImplementations(std::span<fs::path> interfac
tempModulePaths[i] = {file, fileContent, nullptr, nullptr};
}
std::erase_if(tempModulePaths, [this](std::tuple<fs::path, std::string, ModulePartition*, Module*>& 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<fs::path, std::string, ModulePartition*, Module*>& 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<Module>(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<Module>(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<Module>& modulee : this->interfaces) {
if(modulee->name == match[1]) {
@ -142,6 +150,22 @@ void Configuration::GetInterfacesAndImplementations(std::span<fs::path> 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<fs::path, s
if (ext.latestArtifact > 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<Module*> staleInterfaces;
for(std::unique_ptr<Module>& 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_map<fs::path, s
}
}
for(Module* mod : staleInterfaces) {
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());
}
}
});
}
for(Implementation& implementation : config.implementations) {
if(implementation.Check(buildDir, pcmDir, externalFloor)) {
buildResult.repack = true;

View file

@ -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<ModulePartition>& 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<ModulePartition>& 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<bool>& 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<std::thread> threads;
threads.reserve(partitions.size());
for(std::unique_ptr<ModulePartition>& part : partitions) {

View file

@ -22,7 +22,7 @@ namespace Crafter {
// Names that never resolve (`std`, module-mapped externals) stay here.
std::vector<std::string> pendingImports;
std::atomic<bool> 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<Module*> moduleDependencies;
std::vector<std::pair<Module*, fs::path>> externalModuleDependencies;
std::vector<std::string> pendingImports;
std::atomic<bool> compiled;
bool needsRecompiling;
bool needsRecompiling = false;
bool checked = false;
std::vector<std::unique_ptr<ModulePartition>> partitions;
std::string name;

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("TransitiveInterfaceChange").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("CleanProject").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("ShaderCompile").Dependencies({ CrafterBuildLib.get() });
cfg.AddTest("StandardArgs").Dependencies({ CrafterBuildLib.get() });

View file

@ -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<char, 4096> padding{};
std::int32_t first = 1;
void Stamp();
};
}

View file

@ -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;
}
}

View file

@ -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();
};
}

View file

@ -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;
}

View file

@ -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;
}
}

View file

@ -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();
};
}

View file

@ -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<Configuration> MakeBaseLib(const fs::path& staged) {
auto base = std::make_unique<Configuration>();
base->path = staged / "base";
base->name = "base";
base->outputName = "base";
base->target = HostTarget();
base->type = ConfigurationType::LibraryStatic;
std::array<fs::path, 1> ifaces = { "Base" };
std::array<fs::path, 1> impls = { "Base" };
base->GetInterfacesAndImplementations(ifaces, impls);
return base;
}
std::unique_ptr<Configuration> MakeWidgetLib(const fs::path& staged) {
auto widget = std::make_unique<Configuration>();
widget->path = staged / "widget";
widget->name = "widget";
widget->outputName = "widget";
widget->target = HostTarget();
widget->type = ConfigurationType::LibraryStatic;
std::array<fs::path, 1> ifaces = { "Widget" };
std::array<fs::path, 1> impls = { "Widget" };
widget->GetInterfacesAndImplementations(ifaces, impls);
return widget;
}
Module* FindModule(const Configuration& cfg, std::string_view name) {
for (const std::unique_ptr<Module>& 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<fs::path, std::shared_future<BuildResult>> 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<Configuration> base = MakeBaseLib(staged);
std::unique_ptr<Configuration> 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<fs::path, 2> ifaces = { "widget/Widget", "base/Base" };
std::array<fs::path, 3> 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<Configuration> base = MakeBaseLib(staged);
std::unique_ptr<Configuration> 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<fs::path, 0> appIfaces = {};
std::array<fs::path, 1> 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;
}