Skip to content

Commit dea776e

Browse files
authored
feat(features): Feature System v2 — package-owned defines + capabilities (v0.0.69) (#181)
* feat(features): Stage 1 — a [features] entry can contribute package-owned defines A feature table entry may now be written in table form carrying `defines` (and `implies`); when the feature is active each bare define desugars to -D<x> on the package compile flags, alongside the automatic MCPP_FEATURE_<NAME>. The array shorthand keeps meaning implied-features. Restricts feature compile contributions to package-owned macros (no free-form cflags/ldflags) per the capability-model design. Tests: e2e/80_feature_defines.sh, unit Manifest.FeatureTableFormDefinesAndImplies. Design: .agents/docs/2026-06-29-feature-capability-model-design.md * feat(features): Stage 3 — capabilities (provides/requires) with single-provider binding A feature/package may `requires` an abstract capability instead of a concrete package; providers declare `provides`. The resolver binds exactly one provider from the dependency graph, deterministically: - explicit [capabilities] pin (or --cap cap=provider) wins; - 0 providers / >=2 unpinned providers are hard errors (never a silent guess); - a single provider binds with no config. Link/include requirements still flow through normal dependency mechanics; this is the selection-and-validation layer that turns a silently-wrong or missing backend into a loud configure-time error. Parsed on both surfaces (TOML [features]/[package].provides/[capabilities] and the Lua descriptor). CLI: `--cap` on build/test. Tests: e2e/81_capability_binding.sh (6 cases), unit Manifest.CapabilitiesProvidesRequiresAndPins + SynthesizeFromXpkgLua.CapabilitiesAndFeatureDefines. Design: .agents/docs/2026-06-29-feature-capability-model-design.md * docs+release: feature/capability user docs, CHANGELOG, v0.0.69 bump - docs/05-mcpp-toml.md (+ zh mirror): document the [features] table form (package-owned defines) and the new provides/requires capabilities section ([capabilities] pins, --cap, deterministic 0/1/many binding table). - CHANGELOG: 0.0.69 entry (Feature System v2 — S1 defines + S3 capabilities). - Bump mcpp.toml + MCPP_VERSION to 0.0.69. - Design doc: record Implementation Status (S1+S3 shipped, S2 next).
1 parent b72265b commit dea776e

13 files changed

Lines changed: 919 additions & 8 deletions

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

Lines changed: 318 additions & 0 deletions
Large diffs are not rendered by default.

CHANGELOG.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,31 @@
33
> 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。
44
> 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)
55
6+
## [0.0.69] — 2026-06-29
7+
8+
### 新增
9+
10+
- **Feature 系统 v2 — feature 可贡献「包自有 defines」+ capability(provides/requires)能力绑定**:
11+
解决「`compat.eigen` 启用 `blas` 特性后,`compile_commands.json` 里只有 `-DMCPP_FEATURE_BLAS`
12+
没有上游真正读的 `-DEIGEN_USE_BLAS`,特性形同未启用」这一类根因——旧版 feature 激活**只能**产出
13+
`-DMCPP_FEATURE_<NAME>` 宏 + 门控源文件,无法表达任意宏、更无法做 backend 选择。本次按
14+
「功能全覆盖 + 少即是多」收敛为**两个原语**(详见
15+
`.agents/docs/2026-06-29-feature-capability-model-design.md`):
16+
17+
- **Stage 1 — feature `defines`**:`[features]` 条目可写成**表形式**
18+
`name = { defines = ["EIGEN_USE_BLAS"], implies = [...] }`(TOML 与 Lua 描述符两面均支持);
19+
激活时每个**裸名** define 脱糖为 `-D<x>` 加到该包编译标志,与自动的 `-DMCPP_FEATURE_<NAME>`
20+
并存。按行业经验(vcpkg)**刻意限制**为「包自有命名空间宏」,feature ****注入自由
21+
`cflags`/`ldflags`,以保持 feature union 组合性。
22+
- **Stage 3 — capabilities**:包/特性可 `provides`/`requires` 一个**抽象能力字符串**(如 `blas`),
23+
解析器从依赖图中**绑定唯一 provider**——确定性:`[capabilities]` pin / `--cap` 指定者胜出;
24+
图中**恰好一个** provider 自动绑定;**零个****多个未指定****硬报错**(绝不静默猜测)。
25+
这把「静默用错/缺失后端」变成配置期显式报错。link/include 仍走既有依赖机制流动。
26+
27+
> Stage 2(feature 触发的可选依赖自动拉取 + 全图 feature union 统一)作为下一阶段:它需要把
28+
> 特性计算提前到依赖解析之前(解析阶段重排),风险更高,且 capability/Eigen 用例并不依赖它
29+
> (provider 以显式依赖声明)。本次先发坚实的 S1+S3,符合设计文档「各阶段独立可发」原则。
30+
631
## [0.0.67] — 2026-06-26
732

833
### 修复

docs/05-mcpp-toml.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,75 @@ extra = []
292292
requesting an undeclared feature produces a warning; an error under `--strict`. A
293293
package that does not declare `[features]` accepts any request (pure macro usage).
294294

295+
#### Table form — a feature that contributes more than implied features
296+
297+
A `[features]` entry may be written as a **table** instead of an array, letting the
298+
feature carry package-owned preprocessor `defines` and/or capability `requires` /
299+
`provides` (see §2.8.1) alongside its implied features:
300+
301+
```toml
302+
[features]
303+
default = []
304+
# Array shorthand: just implied features.
305+
docking = ["extra"]
306+
extra = []
307+
# Table form: contribute a package-owned define when active.
308+
mpl2only = { defines = ["EIGEN_MPL2_ONLY"] }
309+
# Table form: a define + an implied feature.
310+
fast_math = { defines = ["APP_FAST=1"], implies = ["extra"] }
311+
```
312+
313+
- `defines` are **bare** macro names (no `-D`); each desugars to `-D<x>` on the
314+
package's own compile when the feature is active — exactly like `[targets.*]
315+
defines`. They are restricted by convention to the package's **own** namespaced
316+
macros: a feature does **not** inject free-form `cflags`/`ldflags`, which would
317+
break the additive feature-union model. Link flags come from a provider
318+
dependency (§2.8.1), not from a feature.
319+
- The automatic `-DMCPP_FEATURE_<NAME>` is still defined for every active feature,
320+
so `defines` are additive to it.
321+
322+
### 2.8.1 `provides` / `requires` — Capabilities (backend selection)
323+
324+
A **capability** is a shared abstract name (e.g. `blas`). A package can *provide*
325+
one; a feature can *require* one instead of naming a concrete package, and the
326+
resolver binds exactly one provider from the dependency graph. This is how you pick
327+
one of several interchangeable backends (OpenBLAS / MKL / …) without baking a choice
328+
into the library.
329+
330+
```toml
331+
# A provider package satisfies a capability for any dependent that requires it.
332+
[package]
333+
name = "compat.openblas"
334+
version = "0.3.0"
335+
provides = ["blas", "lapack"]
336+
```
337+
338+
```toml
339+
# A consumer requires the abstract capability via one of its features.
340+
[features]
341+
use_blas = { defines = ["EIGEN_USE_BLAS"], requires = ["blas"] }
342+
343+
# When >1 provider is in the graph, pick one (else the build errors and lists them).
344+
[capabilities]
345+
blas = "compat.openblas" # equivalently: mcpp build --cap blas=compat.openblas
346+
347+
[dependencies]
348+
compat.openblas = "0.3.0" # the provider must be a real dependency in the graph
349+
```
350+
351+
Binding is **deterministic**:
352+
353+
| Providers of a required capability in the graph | Result |
354+
|---|---|
355+
| exactly one | bound automatically (no config needed) |
356+
| a `[capabilities]` pin / `--cap` names one | the pin wins |
357+
| zero | **error**: no package provides `<cap>` |
358+
| two or more, unpinned | **error**, listing the candidates — never a silent guess |
359+
360+
The bound provider's link/include flags reach the consumer through normal
361+
dependency mechanics; the capability layer is the *selection-and-validation* step
362+
that turns a silently-wrong or missing backend into a loud configure-time error.
363+
295364
### 2.9 `[profile.<name>]` — Build Profiles
296365

297366
```toml

docs/zh/05-mcpp-toml.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,70 @@ extra = []
277277
- **strict 校验**:目标包声明了 `[features]` 表时,请求未声明的 feature 给出
278278
warning;`--strict` 下报错。未声明 `[features]` 的包接受任意请求(纯宏用法)。
279279

280+
#### 表形式 —— 让 feature 贡献的不止是隐含 feature
281+
282+
`[features]` 的条目除了写成数组,还可写成****,从而让该 feature 在隐含 feature
283+
之外,携带包自有的预处理 `defines`,以及 capability 的 `requires` / `provides`
284+
(见 §2.8.1):
285+
286+
```toml
287+
[features]
288+
default = []
289+
# 数组简写:仅隐含 feature。
290+
docking = ["extra"]
291+
extra = []
292+
# 表形式:激活时贡献一个包自有的宏。
293+
mpl2only = { defines = ["EIGEN_MPL2_ONLY"] }
294+
# 表形式:宏 + 一个隐含 feature。
295+
fast_math = { defines = ["APP_FAST=1"], implies = ["extra"] }
296+
```
297+
298+
- `defines`****宏名(不带 `-D`);feature 激活时每个脱糖为 `-D<x>`,加到该包
299+
自己的编译上——与 `[targets.*] defines` 完全一致。按约定仅限包**自有**的带命名
300+
空间宏:feature ****注入自由的 `cflags`/`ldflags`,否则会破坏加性的 feature
301+
并集模型。链接旗标来自 provider 依赖(§2.8.1),而非 feature。
302+
- 每个激活的 feature 仍会得到自动的 `-DMCPP_FEATURE_<NAME>`,`defines` 与之叠加。
303+
304+
### 2.8.1 `provides` / `requires` —— 能力(后端选择)
305+
306+
**capability(能力)** 是一个共享的抽象名字(如 `blas`)。包可以 *provide*(提供)
307+
一种能力;feature 可以 *require*(需要)一种能力而非点名某个具体包,解析器会从依赖
308+
图中绑定**恰好一个** provider。这样就能在多个可互换后端(OpenBLAS / MKL / …)中选其
309+
一,而不必把选择写死进库里。
310+
311+
```toml
312+
# provider 包为任何 require 它的依赖方满足某能力。
313+
[package]
314+
name = "compat.openblas"
315+
version = "0.3.0"
316+
provides = ["blas", "lapack"]
317+
```
318+
319+
```toml
320+
# 消费方经由自己的某个 feature 来 require 这个抽象能力。
321+
[features]
322+
use_blas = { defines = ["EIGEN_USE_BLAS"], requires = ["blas"] }
323+
324+
# 图中有 >1 个 provider 时,选其一(否则构建报错并列出候选)。
325+
[capabilities]
326+
blas = "compat.openblas" # 等价于:mcpp build --cap blas=compat.openblas
327+
328+
[dependencies]
329+
compat.openblas = "0.3.0" # provider 必须是图中真实存在的依赖
330+
```
331+
332+
绑定是**确定性**的:
333+
334+
| 图中某被需要能力的 provider 数量 | 结果 |
335+
|---|---|
336+
| 恰好一个 | 自动绑定(无需配置) |
337+
| `[capabilities]` pin / `--cap` 指定了一个 | 以 pin 为准 |
338+
| 零个 | **报错**:没有包提供 `<cap>` |
339+
| 两个及以上且未 pin | **报错**并列出候选——绝不静默猜测 |
340+
341+
被绑定 provider 的链接/头文件旗标经由常规依赖机制流到消费方;capability 层是那道
342+
*选择与校验* 步骤,把"静默选错后端 / 缺后端"变成构建期的显式报错。
343+
280344
### 2.9 `[profile.<name>]` — 构建档案
281345

282346
```toml

mcpp.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "mcpp"
3-
version = "0.0.68"
3+
version = "0.0.69"
44
description = "Modern C++ build & package management tool"
55
license = "Apache-2.0"
66
authors = ["mcpp-community"]

src/build/prepare.cppm

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,7 @@ export struct BuildOverrides {
286286
std::string profile; // --profile <name> (default "release")
287287
std::string features; // --features a,b,c (root package activation)
288288
bool strict = false; // --strict: schema warnings become errors
289+
std::string capabilities; // --cap blas=openblas,lapack=mkl (provider pins)
289290
};
290291

291292
// `prepare_build` builds the BuildContext for any verb that compiles.
@@ -2054,6 +2055,11 @@ prepare_build(bool print_fingerprint,
20542055
// Also captured here: the root package's active feature set, reused below
20552056
// for the [targets.*] required_features gate.
20562057
std::set<std::string> activeRootFeatures;
2058+
// Capability accumulation (Stage 3): which packages provide each capability,
2059+
// and which (capability, requiring-package) pairs need binding. Filled by
2060+
// apply() as each package's features activate; bound after the loops below.
2061+
std::map<std::string, std::vector<std::string>> capProviders;
2062+
std::vector<std::pair<std::string, std::string>> capRequires;
20572063
{
20582064
auto sanitize = [](std::string f) {
20592065
for (auto& c : f)
@@ -2080,12 +2086,37 @@ prepare_build(bool print_fingerprint,
20802086
auto apply = [&](mcpp::modgraph::PackageRoot& pkg,
20812087
const std::vector<std::string>& requested) {
20822088
auto active = activate(pkg.manifest, requested);
2089+
// Capability accumulation: package-level provides always count;
2090+
// feature-scoped provides/requires count only when the feature is
2091+
// active. Requirements are bound after all packages are processed.
2092+
const auto& pcap = pkg.manifest.package.name;
2093+
for (auto& cap : pkg.manifest.provides) capProviders[cap].push_back(pcap);
2094+
for (auto& f : active) {
2095+
if (auto it = pkg.manifest.featureProvides.find(f);
2096+
it != pkg.manifest.featureProvides.end())
2097+
for (auto& cap : it->second) capProviders[cap].push_back(pcap);
2098+
if (auto it = pkg.manifest.featureRequires.find(f);
2099+
it != pkg.manifest.featureRequires.end())
2100+
for (auto& cap : it->second) capRequires.emplace_back(cap, pcap);
2101+
}
20832102
for (auto& f : active) {
20842103
auto def = "-DMCPP_FEATURE_" + sanitize(f);
20852104
pkg.manifest.buildConfig.cflags.push_back(def);
20862105
pkg.manifest.buildConfig.cxxflags.push_back(def);
20872106
pkg.privateBuild.cflags.push_back(def);
20882107
pkg.privateBuild.cxxflags.push_back(def);
2108+
// Feature System v2 Stage 1: package-owned `defines` declared on
2109+
// this feature ride alongside the automatic MCPP_FEATURE_ macro.
2110+
// Bare names desugar to -D<x>, matching [targets.*] `defines`.
2111+
if (auto it = pkg.manifest.buildConfig.featureDefines.find(f);
2112+
it != pkg.manifest.buildConfig.featureDefines.end())
2113+
for (auto& d : it->second) {
2114+
auto fdef = "-D" + d;
2115+
pkg.manifest.buildConfig.cflags.push_back(fdef);
2116+
pkg.manifest.buildConfig.cxxflags.push_back(fdef);
2117+
pkg.privateBuild.cflags.push_back(fdef);
2118+
pkg.privateBuild.cxxflags.push_back(fdef);
2119+
}
20892120
}
20902121
// Feature-gated sources (e.g. gtest's gtest_main.cc behind "main"):
20912122
// drop EVERY feature-listed glob from the default build, then re-add
@@ -2169,6 +2200,62 @@ prepare_build(bool print_fingerprint,
21692200
// feature-gated sources must have those sources dropped by default.
21702201
apply(packages[i], req);
21712202
}
2203+
2204+
// ─── Capability binding (Stage 3) ──────────────────────────────────
2205+
// For each required capability, bind exactly one provider from the
2206+
// graph. Deterministic: an explicit [capabilities] pin wins; otherwise
2207+
// 0 providers / ≥2 providers are hard errors (never a silent guess); a
2208+
// single provider binds with no config. The provider's link/include
2209+
// requirements already flow through normal dependency mechanics — this
2210+
// pass is the selection-and-validation layer. See the capability-model
2211+
// design doc.
2212+
// --cap cap=provider[,cap=provider] overrides [capabilities] pins.
2213+
for (std::size_t p = 0; p < overrides.capabilities.size();) {
2214+
auto c = overrides.capabilities.find_first_of(", ", p);
2215+
auto tok = overrides.capabilities.substr(
2216+
p, c == std::string::npos ? std::string::npos : c - p);
2217+
if (auto eq = tok.find('='); eq != std::string::npos)
2218+
m->capabilityPins[tok.substr(0, eq)] = tok.substr(eq + 1);
2219+
if (c == std::string::npos) break;
2220+
p = c + 1;
2221+
}
2222+
2223+
std::set<std::string> boundCaps;
2224+
for (auto& [cap, requirer] : capRequires) {
2225+
if (!boundCaps.insert(cap).second) continue; // one diagnosis per cap
2226+
auto& pins = m->capabilityPins;
2227+
// Dedup candidates, preserve first-seen order.
2228+
std::vector<std::string> cands;
2229+
if (auto it = capProviders.find(cap); it != capProviders.end())
2230+
for (auto& p : it->second)
2231+
if (std::find(cands.begin(), cands.end(), p) == cands.end())
2232+
cands.push_back(p);
2233+
if (auto pit = pins.find(cap); pit != pins.end()) {
2234+
const auto& pin = pit->second;
2235+
if (std::find(cands.begin(), cands.end(), pin) == cands.end()) {
2236+
std::string list;
2237+
for (auto& c : cands) list += (list.empty() ? "" : ", ") + c;
2238+
return std::unexpected(std::format(
2239+
"capability '{}' pinned to provider '{}' (via [capabilities]), "
2240+
"but no such provider is in the graph; candidates: [{}]",
2241+
cap, pin, list));
2242+
}
2243+
continue; // pin satisfied
2244+
}
2245+
if (cands.empty())
2246+
return std::unexpected(std::format(
2247+
"no package provides capability '{}' required by '{}'; add a "
2248+
"dependency that declares `provides = [\"{}\"]`", cap, requirer, cap));
2249+
if (cands.size() > 1) {
2250+
std::string list;
2251+
for (auto& c : cands) list += (list.empty() ? "" : ", ") + c;
2252+
return std::unexpected(std::format(
2253+
"capability '{}' has multiple providers in the graph: [{}]; select "
2254+
"one with [capabilities] {} = \"<provider>\" or --cap {}=<provider>",
2255+
cap, list, cap, cap));
2256+
}
2257+
// exactly one → bound implicitly.
2258+
}
21722259
}
21732260

21742261
// [targets.*] required_features gate: a target is emitted only when ALL its

src/cli.cppm

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,8 @@ int run(int argc, char** argv) {
220220
.help("Build profile: release (default) | dev | dist | <[profile.*] name>"))
221221
.option(cl::Option("features").takes_value().value_name("LIST")
222222
.help("Activate root-package features (comma-separated)"))
223+
.option(cl::Option("cap").takes_value().value_name("LIST")
224+
.help("Pin capability providers (e.g. blas=openblas,lapack=mkl)"))
223225
.option(cl::Option("strict")
224226
.help("Treat manifest schema warnings (unknown feature/platform) as errors"))
225227
.action(wrap_rc(cmd_build)))
@@ -235,6 +237,8 @@ int run(int argc, char** argv) {
235237
.help("Build profile for the test build: release (default) | dev | dist | <[profile.*] name>"))
236238
.option(cl::Option("features").takes_value().value_name("LIST")
237239
.help("Activate root-package features for the test build (comma-separated)"))
240+
.option(cl::Option("cap").takes_value().value_name("LIST")
241+
.help("Pin capability providers (e.g. blas=openblas,lapack=mkl)"))
238242
.option(cl::Option("strict")
239243
.help("Treat manifest schema warnings (unknown feature/platform) as errors"))
240244
.action(wrap_rc([&passthrough](const cl::ParsedArgs& p) {

src/cli/cmd_build.cppm

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export int cmd_build(const mcpplibs::cmdline::ParsedArgs& parsed) {
2828
if (auto p = parsed.value("package")) ov.package_filter = *p;
2929
if (auto pr = parsed.value("profile")) ov.profile = *pr;
3030
if (auto fs = parsed.value("features")) ov.features = *fs;
31+
if (auto cp = parsed.value("cap")) ov.capabilities = *cp;
3132
ov.strict = parsed.is_flag_set("strict");
3233
ov.force_static = parsed.is_flag_set("static");
3334

@@ -37,7 +38,7 @@ export int cmd_build(const mcpplibs::cmdline::ParsedArgs& parsed) {
3738
// the fast path would silently ignore the flags.
3839
if (!print_fp && ov.target_triple.empty() && !ov.force_static
3940
&& ov.profile.empty() && ov.features.empty() && !ov.strict
40-
&& ov.package_filter.empty()) {
41+
&& ov.capabilities.empty() && ov.package_filter.empty()) {
4142
auto root = mcpp::project::find_manifest_root(std::filesystem::current_path());
4243
if (root) {
4344
if (auto rc = mcpp::build::try_fast_build(*root, verbose, no_cache)) {
@@ -73,6 +74,7 @@ export int cmd_test(const mcpplibs::cmdline::ParsedArgs& parsed,
7374
mcpp::build::BuildOverrides ov;
7475
if (auto pr = parsed.value("profile")) ov.profile = *pr;
7576
if (auto fs = parsed.value("features")) ov.features = *fs;
77+
if (auto cp = parsed.value("cap")) ov.capabilities = *cp;
7678
ov.strict = parsed.is_flag_set("strict");
7779
return mcpp::build::run_tests(passthrough, ov);
7880
}

0 commit comments

Comments
 (0)