Skip to content

Commit 687e154

Browse files
committed
Workspace build graph: member targets gated by features, the workspace package reads its own workspace dependencies, pooled flags kept for units no member owns, the group link line checked for hermeticity, runtime files placed in the product directory
1 parent 796dbda commit 687e154

7 files changed

Lines changed: 78 additions & 15 deletions

File tree

‎.agents/docs/2026-09-29-workspace-build-graph-design.md‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,3 +497,35 @@ version. The other repositories change only where the release requires it.
497497
| T13 | the release canaries (xlings, mcppls), both rooted workspaces, and the plugins' CI | xlings, mcppls, mcpp-plugins | T11 |
498498
| T14 | sandbox verification with the CN mirror; issues commented and closed | all | T12, T13 |
499499
| T15 | acceptance on the validation project's pull request: its Windows CI against 1828 s, the core library compiled once, the Release layout | the validation project | T14 |
500+
501+
## 17. Implementation record (2026-09-29)
502+
503+
Implemented in one change on `feat/workspace-build-graph`, released as
504+
2026.9.29.1. Where the implementation settled a question the sections above
505+
left open, the answer is recorded here.
506+
507+
| Question | Answer | Where |
508+
|---|---|---|
509+
| The root of a rooted workspace | Every workspace plan has a virtual root; the workspace's own package is the member `"."`, whose products keep `bin/`. A command at the root selects it alone. The package is then the same node in every selection. | `select_workspace_members` (prepare/manifest.cpp) |
510+
| Which values separate plans | Every root-position value (`root_position_key`): the toolchain and target rows, the standard, the graph-wide `[build]` keys, the profiles, the indices, the capability and tool pins. The virtual root copies them from the group's first member, so a group and its root cannot disagree. | `mcpp.project` |
511+
| A member's build program | Runs after every dependency's program, where a root's runs, with its own artifacts directory and a graph document of its closure in which it is `root`. A workspace member reached as another member's dependency runs there too, so its program sees one environment in every selection. | `step9_member_build_programs` (prepare/target_side.cpp) |
512+
| A member's identity | A path member that declares no namespace keeps its bare name (a root's identity) instead of the default namespace a path dependency receives. | prepare/graph.cpp |
513+
| Per-member link data | Each package's normalised link flags are read when the plan is made; a member's link group holds the plan's flags followed by its closure's, and its closure's runtime contract, and `compute_flags` renders it from a copy of the plan with the group swapped in. The link edge carries the group's `ldflags` and `c_ldflags`. | `make_plan` step 6, `swap_link_group`, ninja_backend.cppm |
514+
| Graph-built shared libraries | Linked once at `bin/`, placed in each product directory whose units load them. | `LinkGroup::placements` |
515+
| Linked placement | Shared libraries only; other deployed files are copied, since a program may write a file beside itself and a link would carry the write to its source. A linked destination is never written in place. | mcpp.build.stage |
516+
| `-p X` and `--workspace` in one directory | `build.ninja` records a request tag (the plan's members and the requested features), and every fast path compares it; one `.build_cache` entry per selection and configuration group. Alternating selections re-plan (one plan of the selection) and recompile nothing. | graph_shape.cppm, execute.cppm |
517+
| The lock | `<workspace>/mcpp.lock`. A plan of all members writes the whole record; a plan of some keeps the other entries, and `--locked` then reports no entry of another member as drift. A selected member's git dependencies are locked as a root's. | prepare/records.cpp |
518+
| Tests | `mcpp test` keeps one plan per member (`-p X` each), in the shared directory; the members' dev-dependencies are their own. | cmd_build.cppm |
519+
| Concurrency | Groups are planned in turn and built on threads with a static share of the jobs; the `.build_cache` write is one locked step. | cmd_build.cppm |
520+
521+
Readings with the implementation (Linux, llvm 22.1.8):
522+
523+
| Reading | 2026.9.28.3 | 2026.9.29.1 |
524+
|---|---|---|
525+
| chain of five libraries and a program, `--workspace`, nothing built | 36.8 s | 0.51 s |
526+
| the same, nothing changed | not replayed | 3 ms |
527+
| `-p app`, nothing changed | not replayed | 3 ms |
528+
529+
mcpp's own repository, a rooted workspace, builds itself with the new engine
530+
(full build, gcc 16.1.0, 115 s), and its module tests run through member plans
531+
(`mcpp test -p graph`, `-p versioning`).

‎cdb.json‎

Whitespace-only changes.

‎src/build/flags.cppm‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1668,7 +1668,7 @@ CompileFlags compute_flags(const BuildPlan& plan) {
16681668
// same build re-runs edges for no reason.
16691669
std::ranges::sort(sources);
16701670
for (auto const& src : sources)
1671-
in.candidates.push_back({{src}, std::filesystem::path("bin") / src.filename(),
1671+
in.candidates.push_back({{src}, plan.productDir / src.filename(),
16721672
rp::Kind::Toolchain});
16731673
}
16741674
auto decision = rp::resolve(in);

‎src/build/ninja_backend.cppm‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3923,6 +3923,22 @@ std::expected<BuildResult, BuildError> NinjaBackend::build(const BuildPlan& plan
39233923
flags.graphLinkIsolated, plan.packageRoots); !h) {
39243924
return std::unexpected(BuildError{h.error(), {}});
39253925
}
3926+
// A workspace member's units link with their group's line (§15), which
3927+
// is checked the same way.
3928+
if (!plan.linkGroups.empty()) {
3929+
BuildPlan view = plan;
3930+
std::set<std::string> checked{flags.ld};
3931+
for (auto& g : view.linkGroups) {
3932+
swap_link_group(view, g);
3933+
const auto gf = compute_flags(view);
3934+
swap_link_group(view, g);
3935+
if (!checked.insert(gf.ld).second) continue;
3936+
if (auto h = verify_hermetic_link(plan.toolchain, gf.ld, plan.outputDir,
3937+
plan.manifest.buildConfig.allowHostLibs,
3938+
gf.graphLinkIsolated, plan.packageRoots); !h)
3939+
return std::unexpected(BuildError{h.error(), {}});
3940+
}
3941+
}
39263942
stage("hermetic-check");
39273943

39283944
// When the toolchain comes from mcpp's private sandbox, use the

‎src/build/plan.cppm‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,10 @@ struct BuildPlan {
493493
// musl-gcc 15.1 modules failed to emit vector<pair<string,string>>'s
494494
// move-ctor instantiation across the module boundary (release link error).
495495
std::vector<RuntimeCapabilityProvider> runtimeProviders;
496+
// Where the files the runtime needs are placed beside the programs,
497+
// relative to outputDir: `bin`, or a workspace member's product directory
498+
// while its link group is swapped in (§15).
499+
std::filesystem::path productDir = "bin";
496500

497501
// A workspace plan's per-member link data (workspace design 2026-09-29
498502
// §15). A selected member's link units link the member's closure -- the
@@ -540,6 +544,7 @@ void swap_link_group(BuildPlan& plan, BuildPlan::LinkGroup& g) {
540544
std::swap(plan.runtimeDlopenLibs, g.runtimeDlopenLibs);
541545
std::swap(plan.runtimeCapabilities, g.runtimeCapabilities);
542546
std::swap(plan.runtimeProviders, g.runtimeProviders);
547+
std::swap(plan.productDir, g.productDir);
543548
}
544549

545550
// Merge the generic facts exported by the already-selected xlings
@@ -2834,12 +2839,14 @@ make_plan(const mcpp::manifest::Manifest& manifest,
28342839
for (auto i : closureIdx) closure.insert(qualified_package_name(packages[i].manifest));
28352840

28362841
// The member's link group: the closure's link flags after the
2837-
// plan's own, in the order a root build gives them (the member, then
2838-
// its dependencies in discovery order), and the closure's runtime.
2842+
// root's own (`packages[0]` is snapshotted before any dependency is
2843+
// loaded, so it holds the profile's flags and nothing pooled), in the
2844+
// order a root build gives them (the member, then its dependencies in
2845+
// discovery order), and the closure's runtime.
28392846
BuildPlan::LinkGroup group;
28402847
group.member = owner;
28412848
group.productDir = productDir;
2842-
group.ldflags = manifest.buildConfig.ldflags;
2849+
group.ldflags = packages[0].linkUsage.ldflags;
28432850
for (auto i : closureIdx)
28442851
for (auto const& f : packages[i].linkUsage.ldflags)
28452852
group.ldflags.push_back(f);

‎src/build/prepare/features.cpp‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -323,9 +323,12 @@ static std::expected<void, std::string> step6_activate_features(PrepareState& st
323323
// place costs nothing. What the gate exists for is the shape where
324324
// a target's mere presence changes how the package is linked into
325325
// every consumer — and that is exactly a `shared` or `lib` target.
326+
// A selected workspace member builds every target, so every one
327+
// of its targets is gated, as a root's are (§15).
326328
std::erase_if(pkg.manifest.targets,
327329
[&](const mcpp::manifest::Target& t) {
328-
if (t.kind != mcpp::manifest::Target::Library
330+
if (!pkg.memberProducts
331+
&& t.kind != mcpp::manifest::Target::Library
329332
&& t.kind != mcpp::manifest::Target::SharedLibrary)
330333
return false;
331334
for (auto const& rf : t.requiredFeatures)
@@ -2040,11 +2043,8 @@ static std::expected<void, std::string> step6_dependency_build_programs(PrepareS
20402043
// A CLAIM THAT ONLY EVER TIGHTENS.
20412044
if (bcDep.runExclusive && !exclusiveBefore)
20422045
state.m->buildConfig.runExclusive = true;
2043-
// A workspace plan reads each package's own flags when it links
2044-
// a member's closure; nothing is pooled in its root (§15).
2045-
if (!state.workspacePlan())
2046-
state.m->buildConfig.ldflags.insert(state.m->buildConfig.ldflags.end(),
2047-
bcDep.ldflags.begin() + ldN, bcDep.ldflags.end());
2046+
state.m->buildConfig.ldflags.insert(state.m->buildConfig.ldflags.end(),
2047+
bcDep.ldflags.begin() + ldN, bcDep.ldflags.end());
20482048
}
20492049

20502050
// apply() may have added interface defines to packages' publicUsage

‎src/build/prepare/graph.cpp‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -377,13 +377,13 @@ static std::vector<std::string> propagateLinkFlags(
377377
// flags with the same reading its own flags receive, and an element
378378
// that packs several tokens is several words on both sides.
379379
//
380-
// A workspace plan links each member's closure with that closure's own
381-
// flags (workspace design 2026-09-29 §15), read from each package when
382-
// the plan is made, so nothing is pooled in the virtual root.
380+
// In a workspace plan the pooled list serves the units no member owns
381+
// (a dependency's shared library); a member's units link with its own
382+
// closure's flags, which the plan reads from each package (§15).
383383
std::vector<std::string> added;
384384
for (auto const& word : mcpp::manifest::flag_words(depManifest.buildConfig.ldflags)) {
385385
auto normalized = mcpp::manifest::flag_element(normalizeDepLdflag(depRoot, word));
386-
if (!state.workspacePlan()) state.m->buildConfig.ldflags.push_back(normalized);
386+
state.m->buildConfig.ldflags.push_back(normalized);
387387
added.push_back(std::move(normalized));
388388
}
389389
return added;
@@ -1637,7 +1637,15 @@ step4b_acquire_dependency_source(PrepareState& state, WorklistItemCtx& ctx) {
16371637
const std::filesystem::path& wsRoot) {
16381638
return inherit_as_workspace_member(*dep_manifest, ws, wsRoot, dep_root);
16391639
};
1640-
if (depIsMember) {
1640+
// A rooted workspace's own package, the member "." of a workspace
1641+
// plan (§15), is the workspace's manifest: it reads its own
1642+
// `[workspace.dependencies]`, as it did as the root.
1643+
const bool depIsWorkspacePackage = state.workspacePlan() && state.wsManifest
1644+
&& dep_root.lexically_normal() == state.runtimeWorkspaceRoot.lexically_normal();
1645+
if (depIsWorkspacePackage) {
1646+
mcpp::project::merge_workspace_deps(*dep_manifest, *state.wsManifest,
1647+
state.runtimeWorkspaceRoot);
1648+
} else if (depIsMember) {
16411649
if (auto bad = inheritAsMember(*state.wsManifest, state.runtimeWorkspaceRoot))
16421650
return std::unexpected(*bad);
16431651
} else if (!gitMember.empty()) {

0 commit comments

Comments
 (0)