199 lines
9.4 KiB
C++
199 lines
9.4 KiB
C++
|
|
// 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 <DepModule>;` 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/<name>/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<Configuration> MakeLib(const fs::path& staged) {
|
||
|
|
auto lib = std::make_unique<Configuration>();
|
||
|
|
lib->path = staged / "lib";
|
||
|
|
lib->name = "widget";
|
||
|
|
lib->outputName = "widget";
|
||
|
|
lib->target = HostTarget();
|
||
|
|
lib->type = ConfigurationType::LibraryStatic;
|
||
|
|
std::array<fs::path, 1> ifaces = { "Widget" };
|
||
|
|
std::array<fs::path, 1> 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<fs::path, 0> ifaces = {};
|
||
|
|
std::array<fs::path, 1> 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<fs::path, std::shared_future<BuildResult>> 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<Configuration> 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<fs::path, 0> ifaces = {};
|
||
|
|
std::array<fs::path, 1> 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<Configuration> lib = MakeLib(staged);
|
||
|
|
|
||
|
|
Configuration app;
|
||
|
|
app.path = staged;
|
||
|
|
app.name = "host";
|
||
|
|
app.outputName = "host";
|
||
|
|
app.target = HostTarget();
|
||
|
|
app.type = ConfigurationType::Executable;
|
||
|
|
// AddTest resolves tests/<name>/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<Configuration> 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;
|
||
|
|
}
|