fix: re-resolve module imports before checking staleness

Adding a data member to a class in a module interface did not rebuild every
object compiled against the old layout. The build succeeded with no error or
warning and the resulting binary mixed both layouts, surfacing later as a
SIGSEGV in a destructor.

GetInterfacesAndImplementations scans a TU's `import X;` statements when the
source is declared. An import that matches neither a module in the
Configuration nor one reachable through `dependencies` was dropped on the
floor, leaving that TU with no staleness edge to the interface it consumes.
`dependencies` is frequently assigned *after* the scan — AddTest does exactly
that, resolving tests/<name>/main.cpp and only then returning a builder whose
.Dependencies() supplies the library — so consumers of a dependency's modules
routinely carried no edge at all. A layout change then rebuilt the library,
relinked the consumer, and kept the consumer's object as it was.

Unresolved names are now remembered on the partition/implementation as
pendingImports, and Configuration::ResolvePendingImports retries them against
the dependency DAG as it stands. Build() calls it immediately before comparing
mtimes, which closes the window for every caller rather than only the ones that
declare in the right order; TestBuilder::Dependencies also calls it so the
Configuration is coherent for anyone inspecting it before the build.

Resolves #27
This commit is contained in:
catbot 2026-07-30 17:12:24 +00:00
commit 13697cd026
11 changed files with 341 additions and 7 deletions

View file

@ -254,6 +254,15 @@ export namespace Crafter {
// Lint rules for `crafter-build lint`. Populate via AddLintRule.
std::vector<LintRule> lintRules;
CRAFTER_API void GetInterfacesAndImplementations(std::span<fs::path> interfaces, std::span<fs::path> implementations);
// Retry the `import X;` names GetInterfacesAndImplementations could not
// place, against the dependency DAG as it stands now. Sources are
// scanned when they're declared, but `dependencies` is often assigned
// afterwards (AddTest does exactly this), and an import that resolved to
// nothing leaves no staleness edge — so a dependency's interface could
// change and this Configuration's objects would be silently reused
// against the new layout. Build() calls this before checking mtimes;
// calling it again is harmless.
CRAFTER_API void ResolvePendingImports();
// Declare a test. Sources default to `tests/<name>/main.cpp` resolved
// against this Configuration's path; target/march/mtune/sysroot/debug
// are inherited from this Configuration so cross-arch projects don't

View file

@ -15,6 +15,9 @@ namespace Crafter {
std::vector<Module*> moduleDependencies;
std::vector<ModulePartition*> partitionDependencies;
std::vector<std::pair<Module*, fs::path>> externalModuleDependencies;
// See ModulePartition::pendingImports — imports this TU declared that
// no reachable Configuration provided when the source was scanned.
std::vector<std::string> pendingImports;
fs::path path;
CRAFTER_API Implementation(fs::path&& path);
CRAFTER_API bool Check(const fs::path& buildDir, const fs::path& pcmDir, fs::file_time_type sourceFloor = fs::file_time_type::min()) const;

View file

@ -14,6 +14,13 @@ namespace Crafter {
std::vector<Module*> moduleDependencies;
std::vector<ModulePartition*> partitionDependencies;
std::vector<std::pair<Module*, fs::path>> externalModuleDependencies;
// Names from `import X;` that matched neither a module in this
// Configuration nor one reachable through its dependencies at the time
// the source was scanned. Retried by ResolvePendingImports (see
// Crafter.Build:Clang) so a dependency wired up after
// GetInterfacesAndImplementations still produces a staleness edge.
// Names that never resolve (`std`, module-mapped externals) stay here.
std::vector<std::string> pendingImports;
std::atomic<bool> compiled;
bool needsRecompiling;
bool checked = false;