feat: project.cpp declares its own dependencies with #pragma crafter dependency
A project.cpp could only import std and Crafter.Build, so anything shared
between build descriptions had to be a header every consumer copies. Now
#pragma crafter dependency "<source>" [branch "<b>" | commit "<sha>"] [project "<file>"]
names a sibling crafter-build project that project.cpp itself imports a
module from. LoadProject scans for the directive, resolves it through
GitProject (a URL) or LocalProject (a path relative to the project file),
forces the result into host-tooling mode (the host triple LoadProject's
own cache is keyed on, native march, no debug, -fPIC so the archive links
into a shared object), builds it, and puts its BMIs and archive on the
project.so command line. The dependency's own project.cpp goes through
the same loader, so its directives resolve recursively; a cycle between
project files is an error instead of unbounded recursion.
The dependency is built on every load, fresh or not: clang crashes rather
than erroring when it imports a BMI whose own imported PCM was rebuilt
underneath it, and the module depfile — which Module::Check already reads —
is what notices the host PCM cache was regenerated. Its artifact mtime is
an input to project.so's staleness check, so editing the dependency
recompiles project.cpp without touching it. The Linux link gains
-fuse-ld=lld because release archives hold ThinLTO bitcode.
ProjectDependency(name) hands the same Configuration* back from inside
CrafterBuildProject, by configuration name or exported module name, so a
test that links the module needn't spell the source twice. It reads a
thread_local frame stack that an RAII guard keeps for the whole of
LoadProject, which also provides the cycle check.
ExpectedOutput() is the link gate's artifact-path lambda hoisted into the
:Clang partition, so the staleness check and the -r runner share the one
rule. Windows branches are wired identically but unverified.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 20:03:22 +02:00
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
# include <stdlib.h>
import std ;
import Crafter . Build ;
namespace fs = std : : filesystem ;
using namespace Crafter ;
// `#pragma crafter dependency`: a project.cpp that imports a module from a
// sibling crafter-build project. LoadProject has to fetch and build that
// project before project.cpp compiles, put its BMI and archive on the
// project.so command line, and treat the archive as an input to the
// staleness check. The fixture is copied to a scratch dir because one case
// edits a source and every build drops bin/ and build/ trees next to it.
//
// A recompiled project.so can't be observed from the process that already
// dlopened the old one — the loader hands back the mapping it has for that
// path — so the "dependency changed" cases load through a child process:
// this binary re-run with `--load <project.cpp>` prints the name the project
// file produced.
namespace {
std : : int32_t Failures = 0 ;
void Check ( bool cond , std : : string_view msg ) {
if ( ! cond ) {
std : : println ( std : : cerr , " FAIL: {} " , msg ) ;
+ + Failures ;
}
}
// Expect LoadProject to throw; the message, or "" when it didn't.
std : : string LoadFails ( const fs : : path & projectFile ) {
std : : array < std : : string_view , 0 > noArgs = { } ;
try {
( void ) LoadProject ( projectFile , noArgs ) ;
} catch ( const std : : exception & e ) {
return e . what ( ) ;
}
return " " ;
}
// LoadProject in a fresh process; returns what it printed, trimmed.
std : : string LoadInChild ( const fs : : path & projectFile ) {
const fs : : path self = fs : : read_symlink ( " /proc/self/exe " ) ;
const auto r = RunCommandWithTimeout ( std : : format ( " {} --load {} " , self . string ( ) , projectFile . string ( ) ) , std : : chrono : : seconds ( 300 ) ) ;
std : : string out = r . output ;
while ( ! out . empty ( ) & & ( out . back ( ) = = ' \n ' | | out . back ( ) = = ' \r ' ) ) out . pop_back ( ) ;
if ( r . exitCode ! = 0 ) return std : : format ( " <exit {}> {} " , r . exitCode , out ) ;
// The dependency build's progress lines share stdout; the name is the
// last line.
const std : : size_t lastBreak = out . rfind ( ' \n ' ) ;
return lastBreak = = std : : string : : npos ? out : out . substr ( lastBreak + 1 ) ;
}
void ChangeGreeting ( const fs : : path & scratch ) {
const fs : : path iface = scratch / " greeter " / " interfaces " / " Greeter.cppm " ;
fs : : copy_file ( scratch / " greeter " / " interfaces " / " Greeter-changed.cppm.in " , iface , 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 ( iface , fs : : file_time_type : : clock : : now ( ) ) ;
}
}
int main ( int argc , char * * argv ) {
if ( argc = = 3 & & std : : string_view ( argv [ 1 ] ) = = " --load " ) {
std : : array < std : : string_view , 0 > noArgs = { } ;
Configuration cfg = LoadProject ( argv [ 2 ] , noArgs ) ;
std : : println ( " {} " , cfg . name ) ;
return 0 ;
}
const fs : : path installedHome = GetCrafterBuildHome ( ) ;
if ( ! fs : : exists ( installedHome / " Crafter.Build.cppm " ) ) {
std : : println ( std : : cerr , " SKIP: no module sources at {} " , installedHome . string ( ) ) ;
return 77 ;
}
const fs : : path fixture = fs : : current_path ( ) / " tests " / " ProjectDependency " / " fixture " ;
const fs : : path scratch = fs : : temp_directory_path ( ) / " crafter-build-project-dependency " ;
std : : error_code ec ;
fs : : remove_all ( scratch , ec ) ;
fs : : create_directories ( scratch ) ;
fs : : copy ( fixture , scratch , fs : : copy_options : : recursive ) ;
// The dependency's project.cpp applies the standard args, which read the
// march from the environment. project.so is compiled for the host, so
// the pre-pass must override it or the dependency's BMIs won't load.
setenv ( " CRAFTER_BUILD_MARCH " , " x86-64-v2 " , 1 ) ;
const fs : : path app = scratch / " app " / " project.cpp " ;
const fs : : path appSo = scratch / " app " / " build " / " project.so " ;
std : : array < std : : string_view , 0 > noArgs = { } ;
2026-10-02 22:02:40 +02:00
Check ( IsTopLevelProject ( ) , " outside any project file counts as top level " ) ;
feat: project.cpp declares its own dependencies with #pragma crafter dependency
A project.cpp could only import std and Crafter.Build, so anything shared
between build descriptions had to be a header every consumer copies. Now
#pragma crafter dependency "<source>" [branch "<b>" | commit "<sha>"] [project "<file>"]
names a sibling crafter-build project that project.cpp itself imports a
module from. LoadProject scans for the directive, resolves it through
GitProject (a URL) or LocalProject (a path relative to the project file),
forces the result into host-tooling mode (the host triple LoadProject's
own cache is keyed on, native march, no debug, -fPIC so the archive links
into a shared object), builds it, and puts its BMIs and archive on the
project.so command line. The dependency's own project.cpp goes through
the same loader, so its directives resolve recursively; a cycle between
project files is an error instead of unbounded recursion.
The dependency is built on every load, fresh or not: clang crashes rather
than erroring when it imports a BMI whose own imported PCM was rebuilt
underneath it, and the module depfile — which Module::Check already reads —
is what notices the host PCM cache was regenerated. Its artifact mtime is
an input to project.so's staleness check, so editing the dependency
recompiles project.cpp without touching it. The Linux link gains
-fuse-ld=lld because release archives hold ThinLTO bitcode.
ProjectDependency(name) hands the same Configuration* back from inside
CrafterBuildProject, by configuration name or exported module name, so a
test that links the module needn't spell the source twice. It reads a
thread_local frame stack that an RAII guard keeps for the whole of
LoadProject, which also provides the cycle check.
ExpectedOutput() is the link gate's artifact-path lambda hoisted into the
:Clang partition, so the staleness check and the -r runner share the one
rule. Windows branches are wired identically but unverified.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 20:03:22 +02:00
// The dependency is fetched, built and linked before project.cpp runs,
// and ProjectDependency() hands it back by either name.
try {
Configuration cfg = LoadProject ( app , noArgs ) ;
Check ( cfg . name = = " hello from greeter " , std : : format ( " project.cpp imported and called the dependency's module (got '{}') " , cfg . name ) ) ;
2026-10-02 22:02:40 +02:00
Check ( std : : ranges : : contains ( cfg . compileFlags , " -DTOP_LEVEL=1 " ) , " the project LoadProject was pointed at sees itself as top level " ) ;
feat: project.cpp declares its own dependencies with #pragma crafter dependency
A project.cpp could only import std and Crafter.Build, so anything shared
between build descriptions had to be a header every consumer copies. Now
#pragma crafter dependency "<source>" [branch "<b>" | commit "<sha>"] [project "<file>"]
names a sibling crafter-build project that project.cpp itself imports a
module from. LoadProject scans for the directive, resolves it through
GitProject (a URL) or LocalProject (a path relative to the project file),
forces the result into host-tooling mode (the host triple LoadProject's
own cache is keyed on, native march, no debug, -fPIC so the archive links
into a shared object), builds it, and puts its BMIs and archive on the
project.so command line. The dependency's own project.cpp goes through
the same loader, so its directives resolve recursively; a cycle between
project files is an error instead of unbounded recursion.
The dependency is built on every load, fresh or not: clang crashes rather
than erroring when it imports a BMI whose own imported PCM was rebuilt
underneath it, and the module depfile — which Module::Check already reads —
is what notices the host PCM cache was regenerated. Its artifact mtime is
an input to project.so's staleness check, so editing the dependency
recompiles project.cpp without touching it. The Linux link gains
-fuse-ld=lld because release archives hold ThinLTO bitcode.
ProjectDependency(name) hands the same Configuration* back from inside
CrafterBuildProject, by configuration name or exported module name, so a
test that links the module needn't spell the source twice. It reads a
thread_local frame stack that an RAII guard keeps for the whole of
LoadProject, which also provides the cycle check.
ExpectedOutput() is the link gate's artifact-path lambda hoisted into the
:Clang partition, so the staleness check and the -r runner share the one
rule. Windows branches are wired identically but unverified.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 20:03:22 +02:00
Check ( cfg . dependencies . size ( ) = = 2 , " ProjectDependency resolved by module name and by configuration name " ) ;
if ( cfg . dependencies . size ( ) = = 2 ) {
Check ( cfg . dependencies [ 0 ] = = cfg . dependencies [ 1 ] , " both names resolve to the same dependency " ) ;
const Configuration & dep = * cfg . dependencies [ 0 ] ;
Check ( dep . name = = " greeter " , " the dependency is the fixture's greeter project " ) ;
Merge branch 'claude/project-dependencies': project.cpp dependencies, AGENTS.md
Brings in `#pragma crafter dependency`, the switch to Crafter.LintRules
for the house style, and the contributor guide in AGENTS.md.
The branch predates the musl host work on master, and both rewrote the
command that compiles project.so, so the merge reconciles them by hand:
- LoadProject compiles project.so for hostConfig.target (HostTarget(),
from master) with the pre-dependency module path and archives from
the branch. -fuse-ld=lld appears once, with both reasons in the
comment.
- ForceHostTooling added -fPIC only for the literal x86_64-pc-linux-gnu
triple. hostConfig now comes from HostTarget(), so on Alpine the host
is x86_64-alpine-linux-musl and the dependency archive would not have
been position-independent. It now matches any *-linux-gnu or
*-linux-musl host, as project.cpp and the cmake flags do since the
musl change, and its comment no longer argues against HostTarget().
- The ProjectDependency test compared the dependency's target against
the glibc triple, which the musl CI job would fail; it compares
against HostTarget().
- README keeps master's paragraph on the bootstrap triple. AGENTS.md
describes the test block as `if (linuxTarget)`, which is what
project.cpp has now.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 20:47:23 +02:00
Check ( dep . target = = HostTarget ( ) , " the dependency was built for the host triple LoadProject uses " ) ;
feat: project.cpp declares its own dependencies with #pragma crafter dependency
A project.cpp could only import std and Crafter.Build, so anything shared
between build descriptions had to be a header every consumer copies. Now
#pragma crafter dependency "<source>" [branch "<b>" | commit "<sha>"] [project "<file>"]
names a sibling crafter-build project that project.cpp itself imports a
module from. LoadProject scans for the directive, resolves it through
GitProject (a URL) or LocalProject (a path relative to the project file),
forces the result into host-tooling mode (the host triple LoadProject's
own cache is keyed on, native march, no debug, -fPIC so the archive links
into a shared object), builds it, and puts its BMIs and archive on the
project.so command line. The dependency's own project.cpp goes through
the same loader, so its directives resolve recursively; a cycle between
project files is an error instead of unbounded recursion.
The dependency is built on every load, fresh or not: clang crashes rather
than erroring when it imports a BMI whose own imported PCM was rebuilt
underneath it, and the module depfile — which Module::Check already reads —
is what notices the host PCM cache was regenerated. Its artifact mtime is
an input to project.so's staleness check, so editing the dependency
recompiles project.cpp without touching it. The Linux link gains
-fuse-ld=lld because release archives hold ThinLTO bitcode.
ProjectDependency(name) hands the same Configuration* back from inside
CrafterBuildProject, by configuration name or exported module name, so a
test that links the module needn't spell the source twice. It reads a
thread_local frame stack that an RAII guard keeps for the whole of
LoadProject, which also provides the cycle check.
ExpectedOutput() is the link gate's artifact-path lambda hoisted into the
:Clang partition, so the staleness check and the -r runner share the one
rule. Windows branches are wired identically but unverified.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 20:03:22 +02:00
Check ( dep . march = = " native " , " the environment's march was overridden for host tooling " ) ;
Check ( std : : ranges : : contains ( dep . compileFlags , " -fPIC " ) , " the dependency archive was built position-independent " ) ;
2026-10-02 22:02:40 +02:00
Check ( std : : ranges : : contains ( dep . compileFlags , " -DTOP_LEVEL=0 " ) , " a dependency's project file does not see itself as top level " ) ;
feat: project.cpp declares its own dependencies with #pragma crafter dependency
A project.cpp could only import std and Crafter.Build, so anything shared
between build descriptions had to be a header every consumer copies. Now
#pragma crafter dependency "<source>" [branch "<b>" | commit "<sha>"] [project "<file>"]
names a sibling crafter-build project that project.cpp itself imports a
module from. LoadProject scans for the directive, resolves it through
GitProject (a URL) or LocalProject (a path relative to the project file),
forces the result into host-tooling mode (the host triple LoadProject's
own cache is keyed on, native march, no debug, -fPIC so the archive links
into a shared object), builds it, and puts its BMIs and archive on the
project.so command line. The dependency's own project.cpp goes through
the same loader, so its directives resolve recursively; a cycle between
project files is an error instead of unbounded recursion.
The dependency is built on every load, fresh or not: clang crashes rather
than erroring when it imports a BMI whose own imported PCM was rebuilt
underneath it, and the module depfile — which Module::Check already reads —
is what notices the host PCM cache was regenerated. Its artifact mtime is
an input to project.so's staleness check, so editing the dependency
recompiles project.cpp without touching it. The Linux link gains
-fuse-ld=lld because release archives hold ThinLTO bitcode.
ProjectDependency(name) hands the same Configuration* back from inside
CrafterBuildProject, by configuration name or exported module name, so a
test that links the module needn't spell the source twice. It reads a
thread_local frame stack that an RAII guard keeps for the whole of
LoadProject, which also provides the cycle check.
ExpectedOutput() is the link gate's artifact-path lambda hoisted into the
:Clang partition, so the staleness check and the -r runner share the one
rule. Windows branches are wired identically but unverified.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-02 20:03:22 +02:00
Check ( fs : : exists ( ExpectedOutput ( dep ) ) , " the dependency archive exists " ) ;
}
} catch ( const std : : exception & e ) {
std : : println ( std : : cerr , " FAIL: LoadProject threw: {} " , e . what ( ) ) ;
return 1 ;
}
Check ( fs : : exists ( appSo ) , " project.so was produced " ) ;
if ( ! fs : : exists ( appSo ) ) return 1 ;
const fs : : file_time_type firstSo = fs : : last_write_time ( appSo ) ;
// Editing the dependency rebuilds it and recompiles project.so against
// it — without touching project.cpp.
ChangeGreeting ( scratch ) ;
const std : : string changed = LoadInChild ( app ) ;
Check ( changed = = " hello again " , std : : format ( " a changed dependency reaches project.cpp (got '{}') " , changed ) ) ;
Check ( fs : : last_write_time ( appSo ) > firstSo , " project.so was recompiled after the dependency changed " ) ;
const fs : : file_time_type secondSo = fs : : last_write_time ( appSo ) ;
// And an unchanged dependency doesn't: "always stale" would be invisible
// to every other case here.
const std : : string unchanged = LoadInChild ( app ) ;
Check ( unchanged = = " hello again " , std : : format ( " an unchanged dependency still loads (got '{}') " , unchanged ) ) ;
Check ( fs : : last_write_time ( appSo ) = = secondSo , " project.so is left alone when nothing changed " ) ;
// Directive errors name the file and line.
const std : : string noSource = LoadFails ( scratch / " bad-nosource " / " project.cpp " ) ;
Check ( noSource . contains ( " project.cpp:3: " ) & & noSource . contains ( " expected a quoted source " ) , std : : format ( " a directive without a source is rejected with its location (got '{}') " , noSource ) ) ;
const std : : string both = LoadFails ( scratch / " bad-both " / " project.cpp " ) ;
Check ( both . contains ( " project.cpp:3: " ) & & both . contains ( " mutually exclusive " ) , std : : format ( " branch and commit together are rejected (got '{}') " , both ) ) ;
// Two project files that depend on each other stop instead of recursing.
const std : : string cycle = LoadFails ( scratch / " cycle-a " / " project.cpp " ) ;
Check ( cycle . contains ( " circular project dependency " ) , std : : format ( " a dependency cycle is reported (got '{}') " , cycle ) ) ;
// The accessor is only meaningful while a project file is running.
try {
( void ) ProjectDependency ( " Greeter " ) ;
Check ( false , " ProjectDependency outside a project file throws " ) ;
} catch ( const std : : exception & e ) {
Check ( std : : string_view ( e . what ( ) ) . contains ( " outside a project file " ) , std : : format ( " ProjectDependency outside a project file explains itself (got '{}') " , e . what ( ) ) ) ;
}
return Failures > 0 ? 1 : 0 ;
}