Skip to content

Commit 0e67835

Browse files
committed
feat(features): propagate active-feature defines to consumers (interface defines); v0.0.72
A dependency's active-feature `defines` are interface requirements: a header-only library's feature switch only takes effect in the translation unit that includes its headers. The canonical case is Eigen's `use_blas` feature, whose `EIGEN_USE_BLAS` macro must be defined when the *consumer* compiles `a * b` — not only when Eigen's own anchor TU compiles. Previously feature defines landed only on the owning package's private build flags, so the switch was inert for header-only providers and the consumer silently used the library's built-in path. Feature defines now flow into every consumer's compile flags along Public/Interface dependency edges, mirroring `include_dirs`: they are recorded on `PackageRoot::publicUsage` (cflags/cxxflags) during feature activation, and the `computeUsageRequirements()` fixpoint propagates them into each consumer's `privateBuild`. The fixpoint is re-run after feature activation (the first pass precedes it). The automatic `MCPP_FEATURE_<NAME>` macro stays private to the owning package — it is a build signal, not a public contract. This completes the eigen[backend-openblas] closed loop: the produced binary now links and calls OpenBLAS `dgemm_` instead of Eigen's built-in GEMM. Tests: tests/e2e/83_feature_defines_propagate.sh (consumer observes a dependency's feature define). Regression: 80/81/82, transitive-dep 09/31/55/56, full unit suite.
1 parent ea7e437 commit 0e67835

4 files changed

Lines changed: 173 additions & 11 deletions

File tree

.agents/docs/2026-06-29-feature-capability-model-design.md

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
# Feature System v2 — Capability-Oriented Model (Design)
22

33
Date: 2026-06-29
4-
Status: **S1 + S3 implemented & shipped** (see Implementation Status below);
5-
S2 scoped as the documented next stage.
4+
Status: **S1 + S2a + S3 + interface-define propagation implemented & shipped**
5+
(see Implementation Status below); the full eigen[backend-openblas] ecosystem
6+
closed loop is validated end-to-end.
67
Scope: `src/manifest.cppm` (parse), `src/build/prepare.cppm` (feature activation +
78
resolver), `src/cli.cppm` / `src/cli/cmd_build.cppm` (`--cap`), mcpp-index recipe schema.
89

@@ -18,14 +19,42 @@ resolver), `src/cli.cppm` / `src/cli/cmd_build.cppm` (`--cap`), mcpp-index recip
1819
`[capabilities]` pins and `--cap`. Tests: `e2e/81_capability_binding.sh`
1920
(6 cases), `Manifest.CapabilitiesProvidesRequiresAndPins`,
2021
`SynthesizeFromXpkgLua.CapabilitiesAndFeatureDefines`.
21-
- **Stage 2 — optional-dep activation + feature-union unification: NEXT.**
22-
Deliberately deferred from this release. Rationale: activating a *new*
23-
dependency from a feature requires moving feature computation ahead of
24-
dependency resolution (resolution-phase reordering) — a deeper, higher-risk
25-
change. It is also **not required** for the capability/Eigen use case, which
26-
binds over providers that are explicitly declared as dependencies. Shipping
27-
S1+S3 first matches this doc's "each stage independently shippable" intent and
28-
keeps the release low-risk.
22+
- **Stage 2a — feature-activated optional dependencies: DONE.** A dependency
23+
declared under `[feature-deps.<name>]` (TOML) or a feature's nested `deps`
24+
(Lua) is pulled into the worklist only when that feature is active. Feature
25+
activation (including transitive `implies`) is computed ahead of the
26+
resolution worklist via local lambdas in `prepare.cppm` (kept local to avoid a
27+
GCC-16 modules-BMI bug). Tests: `e2e/82_feature_optional_deps.sh`,
28+
`Manifest.FeatureDepsTomlSection`, `SynthesizeFromXpkgLua.FeatureDepsAndImplies`.
29+
- **Interface-define propagation (header-only providers): DONE.** A dependency's
30+
active-feature `defines` are **interface requirements**: they flow into every
31+
consumer's own compile flags along Public/Interface dependency edges, mirroring
32+
`include_dirs`. This is required for header-only libraries whose feature switch
33+
only takes effect in the TU that includes their headers — the canonical case is
34+
Eigen's `use_blas``EIGEN_USE_BLAS`, which must be defined when the
35+
*consumer* compiles `a * b`, not only when Eigen's own anchor TU compiles. The
36+
automatic `MCPP_FEATURE_<NAME>` macro stays private to the owning package (it
37+
is a build signal, not a public contract). Implemented by routing feature
38+
defines through `PackageRoot::publicUsage` and extending the
39+
`computeUsageRequirements()` fixpoint to propagate `cflags`/`cxxflags`. Test:
40+
`e2e/83_feature_defines_propagate.sh`.
41+
- **Stage 2b — feature-union unification across multiple consumers: NEXT.**
42+
Deliberately deferred. When two consumers request different feature sets on the
43+
same dependency, the activated set should be their union (single resolved
44+
instance). The current model activates per the first-seen consumer's request;
45+
divergent transitive feature requests are not yet unified. Not required for the
46+
validated Eigen/OpenBLAS use case.
47+
48+
### Validated closed loop (eigen[backend-openblas])
49+
50+
`mcpp build` of a consumer declaring
51+
`compat.eigen = { features = ["backend-openblas"] }` exercises every stage:
52+
`backend-openblas` → (implies) `use_blas``-DEIGEN_USE_BLAS` propagated to the
53+
consumer's TUs + `requires "blas"`; `[feature-deps]` pulls `compat.openblas`,
54+
whose xpkg `install()` hook builds `libopenblas.a` from source (BLAS-only,
55+
`TARGET=GENERIC`, no Fortran) via the `xim:make` build-dep; `provides "blas"`
56+
binds the capability; the provider's `-lopenblas` links. Verified: the produced
57+
binary pulls OpenBLAS's `dgemm_` (not Eigen's built-in GEMM) and runs.
2958

3059
---
3160

src/build/prepare.cppm

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1343,6 +1343,19 @@ prepare_build(bool print_fingerprint,
13431343
return changed;
13441344
};
13451345

1346+
auto appendUniqueFlags =
1347+
[](std::vector<std::string>& flags,
1348+
const std::vector<std::string>& additions) -> bool
1349+
{
1350+
bool changed = false;
1351+
for (auto const& f : additions) {
1352+
if (std::find(flags.begin(), flags.end(), f) != flags.end()) continue;
1353+
flags.push_back(f);
1354+
changed = true;
1355+
}
1356+
return changed;
1357+
};
1358+
13461359
auto expandIncludeDirs =
13471360
[&](const std::filesystem::path& packageRoot,
13481361
const mcpp::manifest::Manifest& manifest)
@@ -1424,12 +1437,28 @@ prepare_build(bool print_fingerprint,
14241437
changed = appendUniquePaths(consumer.privateBuild.includeDirs,
14251438
dependency.publicUsage.includeDirs)
14261439
|| changed;
1440+
// Interface defines (a dependency's active-feature `defines`)
1441+
// ride the same edges as include dirs: they must reach the
1442+
// consumer's own TUs so header-only switches like
1443+
// EIGEN_USE_BLAS take effect where the headers are used.
1444+
changed = appendUniqueFlags(consumer.privateBuild.cflags,
1445+
dependency.publicUsage.cflags)
1446+
|| changed;
1447+
changed = appendUniqueFlags(consumer.privateBuild.cxxflags,
1448+
dependency.publicUsage.cxxflags)
1449+
|| changed;
14271450
}
14281451
if (edge.visibility == mcpp::modgraph::DependencyVisibility::Public
14291452
|| edge.visibility == mcpp::modgraph::DependencyVisibility::Interface) {
14301453
changed = appendUniquePaths(consumer.publicUsage.includeDirs,
14311454
dependency.publicUsage.includeDirs)
14321455
|| changed;
1456+
changed = appendUniqueFlags(consumer.publicUsage.cflags,
1457+
dependency.publicUsage.cflags)
1458+
|| changed;
1459+
changed = appendUniqueFlags(consumer.publicUsage.cxxflags,
1460+
dependency.publicUsage.cxxflags)
1461+
|| changed;
14331462
}
14341463
}
14351464
}
@@ -2172,6 +2201,17 @@ prepare_build(bool print_fingerprint,
21722201
pkg.manifest.buildConfig.cxxflags.push_back(fdef);
21732202
pkg.privateBuild.cflags.push_back(fdef);
21742203
pkg.privateBuild.cxxflags.push_back(fdef);
2204+
// Interface-propagate the user-declared feature define:
2205+
// a header-only dependency's switch (e.g. EIGEN_USE_BLAS)
2206+
// only takes effect in the TU that includes its headers,
2207+
// so consumers that enable the feature must see it too.
2208+
// computeUsageRequirements() flows publicUsage flags into
2209+
// each consumer's privateBuild along Public/Interface
2210+
// edges, mirroring include_dirs. The automatic
2211+
// MCPP_FEATURE_<NAME> macro stays private to the owning
2212+
// package (it is a build signal, not a public contract).
2213+
pkg.publicUsage.cflags.push_back(fdef);
2214+
pkg.publicUsage.cxxflags.push_back(fdef);
21752215
}
21762216
}
21772217
// Feature-gated sources (e.g. gtest's gtest_main.cc behind "main"):
@@ -2257,6 +2297,13 @@ prepare_build(bool print_fingerprint,
22572297
apply(packages[i], req);
22582298
}
22592299

2300+
// apply() may have added interface defines to packages' publicUsage
2301+
// flags (a dependency's active-feature `defines`). Re-run the usage
2302+
// fixpoint so those flags flow into each consumer's privateBuild — the
2303+
// first pass (above) ran before features were activated. Idempotent:
2304+
// include-dir/flag propagation is unique-append.
2305+
computeUsageRequirements();
2306+
22602307
// ─── Capability binding (Stage 3) ──────────────────────────────────
22612308
// For each required capability, bind exactly one provider from the
22622309
// graph. Deterministic: an explicit [capabilities] pin wins; otherwise

src/toolchain/fingerprint.cppm

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import mcpp.toolchain.detect;
1818

1919
export namespace mcpp::toolchain {
2020

21-
inline constexpr std::string_view MCPP_VERSION = "0.0.71";
21+
inline constexpr std::string_view MCPP_VERSION = "0.0.72";
2222

2323
struct FingerprintInputs {
2424
Toolchain toolchain;
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
#!/usr/bin/env bash
2+
# 83_feature_defines_propagate.sh — Feature System v2: a dependency's active-
3+
# feature `defines` are INTERFACE requirements. When a consumer enables a feature
4+
# on a (header-only) dependency, that feature's `defines` must reach the
5+
# CONSUMER's own translation units — not only the dependency's own compile. This
6+
# is the header-only-library case (e.g. Eigen's `use_blas` → EIGEN_USE_BLAS,
7+
# which only changes behavior in the TU that includes Eigen's headers). The
8+
# define propagates along Public/Interface dependency edges, mirroring
9+
# include_dirs. See .agents/docs/2026-06-29-feature-capability-model-design.md.
10+
#
11+
# No `requires:` capability → runs on all three CI platforms.
12+
set -e
13+
14+
TMP=$(mktemp -d)
15+
trap "rm -rf $TMP" EXIT
16+
cd "$TMP"
17+
18+
# Header-only dependency: a feature `turbo` carries a package-owned define.
19+
mkdir -p widget/include/widget widget/src
20+
cat > widget/mcpp.toml <<'EOF'
21+
[package]
22+
name = "widget"
23+
version = "0.1.0"
24+
25+
[features]
26+
default = []
27+
turbo = { defines = ["WIDGET_TURBO=1"] }
28+
29+
[build]
30+
include_dirs = ["include"]
31+
32+
[targets.widget]
33+
kind = "lib"
34+
EOF
35+
cat > widget/include/widget/widget.hpp <<'EOF'
36+
#pragma once
37+
// The macro's value is only meaningful in the TU that includes this header —
38+
// exactly the header-only library situation.
39+
inline int widget_mode() {
40+
#ifdef WIDGET_TURBO
41+
return 1;
42+
#else
43+
return 0;
44+
#endif
45+
}
46+
EOF
47+
cat > widget/src/widget.cppm <<'EOF'
48+
export module widget;
49+
export int widget_anchor() { return 0; }
50+
EOF
51+
52+
mkdir -p app/src
53+
cat > app/mcpp.toml <<'EOF'
54+
[package]
55+
name = "app"
56+
version = "0.1.0"
57+
58+
[dependencies]
59+
widget = { path = "../widget", features = ["turbo"] }
60+
EOF
61+
# The consumer's TU asserts the dependency's feature define reached it. If the
62+
# define does NOT propagate, this fails to compile (#error), failing the build.
63+
cat > app/src/main.cpp <<'EOF'
64+
#include <widget/widget.hpp>
65+
#ifndef WIDGET_TURBO
66+
#error "WIDGET_TURBO did not propagate from widget[turbo] to the consumer"
67+
#endif
68+
int main() { return widget_mode() == 1 ? 0 : 2; }
69+
EOF
70+
71+
cd app
72+
73+
# Build: widget[turbo]'s define must reach app/src/main.cpp. A missing
74+
# propagation makes main.cpp hit the #error and the build fails.
75+
"$MCPP" build > b.log 2>&1 || { cat b.log; echo "FAIL: feature define did not propagate to consumer"; exit 1; }
76+
77+
# Double-check the compile database carries the define on the consumer TU.
78+
grep -q 'WIDGET_TURBO' compile_commands.json || {
79+
echo "FAIL: WIDGET_TURBO missing from consumer compile_commands.json"; cat compile_commands.json; exit 1; }
80+
81+
# And the produced binary observes turbo mode at runtime.
82+
BIN=$(find target -name app -type f | head -1)
83+
"$BIN"; rc=$?
84+
[ "$rc" -eq 0 ] || { echo "FAIL: binary did not observe WIDGET_TURBO (exit $rc)"; exit 1; }
85+
86+
echo "OK"

0 commit comments

Comments
 (0)