Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
318 changes: 318 additions & 0 deletions .agents/docs/2026-06-29-feature-capability-model-design.md

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,31 @@
> 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。
> 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。

## [0.0.69] — 2026-06-29

### 新增

- **Feature 系统 v2 — feature 可贡献「包自有 defines」+ capability(provides/requires)能力绑定**:
解决「`compat.eigen` 启用 `blas` 特性后,`compile_commands.json` 里只有 `-DMCPP_FEATURE_BLAS`、
没有上游真正读的 `-DEIGEN_USE_BLAS`,特性形同未启用」这一类根因——旧版 feature 激活**只能**产出
`-DMCPP_FEATURE_<NAME>` 宏 + 门控源文件,无法表达任意宏、更无法做 backend 选择。本次按
「功能全覆盖 + 少即是多」收敛为**两个原语**(详见
`.agents/docs/2026-06-29-feature-capability-model-design.md`):

- **Stage 1 — feature `defines`**:`[features]` 条目可写成**表形式**
`name = { defines = ["EIGEN_USE_BLAS"], implies = [...] }`(TOML 与 Lua 描述符两面均支持);
激活时每个**裸名** define 脱糖为 `-D<x>` 加到该包编译标志,与自动的 `-DMCPP_FEATURE_<NAME>`
并存。按行业经验(vcpkg)**刻意限制**为「包自有命名空间宏」,feature **不**注入自由
`cflags`/`ldflags`,以保持 feature union 组合性。
- **Stage 3 — capabilities**:包/特性可 `provides`/`requires` 一个**抽象能力字符串**(如 `blas`),
解析器从依赖图中**绑定唯一 provider**——确定性:`[capabilities]` pin / `--cap` 指定者胜出;
图中**恰好一个** provider 自动绑定;**零个**或**多个未指定**均**硬报错**(绝不静默猜测)。
这把「静默用错/缺失后端」变成配置期显式报错。link/include 仍走既有依赖机制流动。

> Stage 2(feature 触发的可选依赖自动拉取 + 全图 feature union 统一)作为下一阶段:它需要把
> 特性计算提前到依赖解析之前(解析阶段重排),风险更高,且 capability/Eigen 用例并不依赖它
> (provider 以显式依赖声明)。本次先发坚实的 S1+S3,符合设计文档「各阶段独立可发」原则。

## [0.0.67] — 2026-06-26

### 修复
Expand Down
69 changes: 69 additions & 0 deletions docs/05-mcpp-toml.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,75 @@ extra = []
requesting an undeclared feature produces a warning; an error under `--strict`. A
package that does not declare `[features]` accepts any request (pure macro usage).

#### Table form — a feature that contributes more than implied features

A `[features]` entry may be written as a **table** instead of an array, letting the
feature carry package-owned preprocessor `defines` and/or capability `requires` /
`provides` (see §2.8.1) alongside its implied features:

```toml
[features]
default = []
# Array shorthand: just implied features.
docking = ["extra"]
extra = []
# Table form: contribute a package-owned define when active.
mpl2only = { defines = ["EIGEN_MPL2_ONLY"] }
# Table form: a define + an implied feature.
fast_math = { defines = ["APP_FAST=1"], implies = ["extra"] }
```

- `defines` are **bare** macro names (no `-D`); each desugars to `-D<x>` on the
package's own compile when the feature is active — exactly like `[targets.*]
defines`. They are restricted by convention to the package's **own** namespaced
macros: a feature does **not** inject free-form `cflags`/`ldflags`, which would
break the additive feature-union model. Link flags come from a provider
dependency (§2.8.1), not from a feature.
- The automatic `-DMCPP_FEATURE_<NAME>` is still defined for every active feature,
so `defines` are additive to it.

### 2.8.1 `provides` / `requires` — Capabilities (backend selection)

A **capability** is a shared abstract name (e.g. `blas`). A package can *provide*
one; a feature can *require* one instead of naming a concrete package, and the
resolver binds exactly one provider from the dependency graph. This is how you pick
one of several interchangeable backends (OpenBLAS / MKL / …) without baking a choice
into the library.

```toml
# A provider package satisfies a capability for any dependent that requires it.
[package]
name = "compat.openblas"
version = "0.3.0"
provides = ["blas", "lapack"]
```

```toml
# A consumer requires the abstract capability via one of its features.
[features]
use_blas = { defines = ["EIGEN_USE_BLAS"], requires = ["blas"] }

# When >1 provider is in the graph, pick one (else the build errors and lists them).
[capabilities]
blas = "compat.openblas" # equivalently: mcpp build --cap blas=compat.openblas

[dependencies]
compat.openblas = "0.3.0" # the provider must be a real dependency in the graph
```

Binding is **deterministic**:

| Providers of a required capability in the graph | Result |
|---|---|
| exactly one | bound automatically (no config needed) |
| a `[capabilities]` pin / `--cap` names one | the pin wins |
| zero | **error**: no package provides `<cap>` |
| two or more, unpinned | **error**, listing the candidates — never a silent guess |

The bound provider's link/include flags reach the consumer through normal
dependency mechanics; the capability layer is the *selection-and-validation* step
that turns a silently-wrong or missing backend into a loud configure-time error.

### 2.9 `[profile.<name>]` — Build Profiles

```toml
Expand Down
64 changes: 64 additions & 0 deletions docs/zh/05-mcpp-toml.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,70 @@ extra = []
- **strict 校验**:目标包声明了 `[features]` 表时,请求未声明的 feature 给出
warning;`--strict` 下报错。未声明 `[features]` 的包接受任意请求(纯宏用法)。

#### 表形式 —— 让 feature 贡献的不止是隐含 feature

`[features]` 的条目除了写成数组,还可写成**表**,从而让该 feature 在隐含 feature
之外,携带包自有的预处理 `defines`,以及 capability 的 `requires` / `provides`
(见 §2.8.1):

```toml
[features]
default = []
# 数组简写:仅隐含 feature。
docking = ["extra"]
extra = []
# 表形式:激活时贡献一个包自有的宏。
mpl2only = { defines = ["EIGEN_MPL2_ONLY"] }
# 表形式:宏 + 一个隐含 feature。
fast_math = { defines = ["APP_FAST=1"], implies = ["extra"] }
```

- `defines` 为**裸**宏名(不带 `-D`);feature 激活时每个脱糖为 `-D<x>`,加到该包
自己的编译上——与 `[targets.*] defines` 完全一致。按约定仅限包**自有**的带命名
空间宏:feature **不**注入自由的 `cflags`/`ldflags`,否则会破坏加性的 feature
并集模型。链接旗标来自 provider 依赖(§2.8.1),而非 feature。
- 每个激活的 feature 仍会得到自动的 `-DMCPP_FEATURE_<NAME>`,`defines` 与之叠加。

### 2.8.1 `provides` / `requires` —— 能力(后端选择)

**capability(能力)** 是一个共享的抽象名字(如 `blas`)。包可以 *provide*(提供)
一种能力;feature 可以 *require*(需要)一种能力而非点名某个具体包,解析器会从依赖
图中绑定**恰好一个** provider。这样就能在多个可互换后端(OpenBLAS / MKL / …)中选其
一,而不必把选择写死进库里。

```toml
# provider 包为任何 require 它的依赖方满足某能力。
[package]
name = "compat.openblas"
version = "0.3.0"
provides = ["blas", "lapack"]
```

```toml
# 消费方经由自己的某个 feature 来 require 这个抽象能力。
[features]
use_blas = { defines = ["EIGEN_USE_BLAS"], requires = ["blas"] }

# 图中有 >1 个 provider 时,选其一(否则构建报错并列出候选)。
[capabilities]
blas = "compat.openblas" # 等价于:mcpp build --cap blas=compat.openblas

[dependencies]
compat.openblas = "0.3.0" # provider 必须是图中真实存在的依赖
```

绑定是**确定性**的:

| 图中某被需要能力的 provider 数量 | 结果 |
|---|---|
| 恰好一个 | 自动绑定(无需配置) |
| `[capabilities]` pin / `--cap` 指定了一个 | 以 pin 为准 |
| 零个 | **报错**:没有包提供 `<cap>` |
| 两个及以上且未 pin | **报错**并列出候选——绝不静默猜测 |

被绑定 provider 的链接/头文件旗标经由常规依赖机制流到消费方;capability 层是那道
*选择与校验* 步骤,把"静默选错后端 / 缺后端"变成构建期的显式报错。

### 2.9 `[profile.<name>]` — 构建档案

```toml
Expand Down
2 changes: 1 addition & 1 deletion mcpp.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "mcpp"
version = "0.0.68"
version = "0.0.69"
description = "Modern C++ build & package management tool"
license = "Apache-2.0"
authors = ["mcpp-community"]
Expand Down
87 changes: 87 additions & 0 deletions src/build/prepare.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ export struct BuildOverrides {
std::string profile; // --profile <name> (default "release")
std::string features; // --features a,b,c (root package activation)
bool strict = false; // --strict: schema warnings become errors
std::string capabilities; // --cap blas=openblas,lapack=mkl (provider pins)
};

// `prepare_build` builds the BuildContext for any verb that compiles.
Expand Down Expand Up @@ -2054,6 +2055,11 @@ prepare_build(bool print_fingerprint,
// Also captured here: the root package's active feature set, reused below
// for the [targets.*] required_features gate.
std::set<std::string> activeRootFeatures;
// Capability accumulation (Stage 3): which packages provide each capability,
// and which (capability, requiring-package) pairs need binding. Filled by
// apply() as each package's features activate; bound after the loops below.
std::map<std::string, std::vector<std::string>> capProviders;
std::vector<std::pair<std::string, std::string>> capRequires;
{
auto sanitize = [](std::string f) {
for (auto& c : f)
Expand All @@ -2080,12 +2086,37 @@ prepare_build(bool print_fingerprint,
auto apply = [&](mcpp::modgraph::PackageRoot& pkg,
const std::vector<std::string>& requested) {
auto active = activate(pkg.manifest, requested);
// Capability accumulation: package-level provides always count;
// feature-scoped provides/requires count only when the feature is
// active. Requirements are bound after all packages are processed.
const auto& pcap = pkg.manifest.package.name;
for (auto& cap : pkg.manifest.provides) capProviders[cap].push_back(pcap);
for (auto& f : active) {
if (auto it = pkg.manifest.featureProvides.find(f);
it != pkg.manifest.featureProvides.end())
for (auto& cap : it->second) capProviders[cap].push_back(pcap);
if (auto it = pkg.manifest.featureRequires.find(f);
it != pkg.manifest.featureRequires.end())
for (auto& cap : it->second) capRequires.emplace_back(cap, pcap);
}
for (auto& f : active) {
auto def = "-DMCPP_FEATURE_" + sanitize(f);
pkg.manifest.buildConfig.cflags.push_back(def);
pkg.manifest.buildConfig.cxxflags.push_back(def);
pkg.privateBuild.cflags.push_back(def);
pkg.privateBuild.cxxflags.push_back(def);
// Feature System v2 Stage 1: package-owned `defines` declared on
// this feature ride alongside the automatic MCPP_FEATURE_ macro.
// Bare names desugar to -D<x>, matching [targets.*] `defines`.
if (auto it = pkg.manifest.buildConfig.featureDefines.find(f);
it != pkg.manifest.buildConfig.featureDefines.end())
for (auto& d : it->second) {
auto fdef = "-D" + d;
pkg.manifest.buildConfig.cflags.push_back(fdef);
pkg.manifest.buildConfig.cxxflags.push_back(fdef);
pkg.privateBuild.cflags.push_back(fdef);
pkg.privateBuild.cxxflags.push_back(fdef);
}
}
// Feature-gated sources (e.g. gtest's gtest_main.cc behind "main"):
// drop EVERY feature-listed glob from the default build, then re-add
Expand Down Expand Up @@ -2169,6 +2200,62 @@ prepare_build(bool print_fingerprint,
// feature-gated sources must have those sources dropped by default.
apply(packages[i], req);
}

// ─── Capability binding (Stage 3) ──────────────────────────────────
// For each required capability, bind exactly one provider from the
// graph. Deterministic: an explicit [capabilities] pin wins; otherwise
// 0 providers / ≥2 providers are hard errors (never a silent guess); a
// single provider binds with no config. The provider's link/include
// requirements already flow through normal dependency mechanics — this
// pass is the selection-and-validation layer. See the capability-model
// design doc.
// --cap cap=provider[,cap=provider] overrides [capabilities] pins.
for (std::size_t p = 0; p < overrides.capabilities.size();) {
auto c = overrides.capabilities.find_first_of(", ", p);
auto tok = overrides.capabilities.substr(
p, c == std::string::npos ? std::string::npos : c - p);
if (auto eq = tok.find('='); eq != std::string::npos)
m->capabilityPins[tok.substr(0, eq)] = tok.substr(eq + 1);
if (c == std::string::npos) break;
p = c + 1;
}

std::set<std::string> boundCaps;
for (auto& [cap, requirer] : capRequires) {
if (!boundCaps.insert(cap).second) continue; // one diagnosis per cap
auto& pins = m->capabilityPins;
// Dedup candidates, preserve first-seen order.
std::vector<std::string> cands;
if (auto it = capProviders.find(cap); it != capProviders.end())
for (auto& p : it->second)
if (std::find(cands.begin(), cands.end(), p) == cands.end())
cands.push_back(p);
if (auto pit = pins.find(cap); pit != pins.end()) {
const auto& pin = pit->second;
if (std::find(cands.begin(), cands.end(), pin) == cands.end()) {
std::string list;
for (auto& c : cands) list += (list.empty() ? "" : ", ") + c;
return std::unexpected(std::format(
"capability '{}' pinned to provider '{}' (via [capabilities]), "
"but no such provider is in the graph; candidates: [{}]",
cap, pin, list));
}
continue; // pin satisfied
}
if (cands.empty())
return std::unexpected(std::format(
"no package provides capability '{}' required by '{}'; add a "
"dependency that declares `provides = [\"{}\"]`", cap, requirer, cap));
if (cands.size() > 1) {
std::string list;
for (auto& c : cands) list += (list.empty() ? "" : ", ") + c;
return std::unexpected(std::format(
"capability '{}' has multiple providers in the graph: [{}]; select "
"one with [capabilities] {} = \"<provider>\" or --cap {}=<provider>",
cap, list, cap, cap));
}
// exactly one → bound implicitly.
}
}

// [targets.*] required_features gate: a target is emitted only when ALL its
Expand Down
4 changes: 4 additions & 0 deletions src/cli.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,8 @@ int run(int argc, char** argv) {
.help("Build profile: release (default) | dev | dist | <[profile.*] name>"))
.option(cl::Option("features").takes_value().value_name("LIST")
.help("Activate root-package features (comma-separated)"))
.option(cl::Option("cap").takes_value().value_name("LIST")
.help("Pin capability providers (e.g. blas=openblas,lapack=mkl)"))
.option(cl::Option("strict")
.help("Treat manifest schema warnings (unknown feature/platform) as errors"))
.action(wrap_rc(cmd_build)))
Expand All @@ -235,6 +237,8 @@ int run(int argc, char** argv) {
.help("Build profile for the test build: release (default) | dev | dist | <[profile.*] name>"))
.option(cl::Option("features").takes_value().value_name("LIST")
.help("Activate root-package features for the test build (comma-separated)"))
.option(cl::Option("cap").takes_value().value_name("LIST")
.help("Pin capability providers (e.g. blas=openblas,lapack=mkl)"))
.option(cl::Option("strict")
.help("Treat manifest schema warnings (unknown feature/platform) as errors"))
.action(wrap_rc([&passthrough](const cl::ParsedArgs& p) {
Expand Down
4 changes: 3 additions & 1 deletion src/cli/cmd_build.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export int cmd_build(const mcpplibs::cmdline::ParsedArgs& parsed) {
if (auto p = parsed.value("package")) ov.package_filter = *p;
if (auto pr = parsed.value("profile")) ov.profile = *pr;
if (auto fs = parsed.value("features")) ov.features = *fs;
if (auto cp = parsed.value("cap")) ov.capabilities = *cp;
ov.strict = parsed.is_flag_set("strict");
ov.force_static = parsed.is_flag_set("static");

Expand All @@ -37,7 +38,7 @@ export int cmd_build(const mcpplibs::cmdline::ParsedArgs& parsed) {
// the fast path would silently ignore the flags.
if (!print_fp && ov.target_triple.empty() && !ov.force_static
&& ov.profile.empty() && ov.features.empty() && !ov.strict
&& ov.package_filter.empty()) {
&& ov.capabilities.empty() && ov.package_filter.empty()) {
auto root = mcpp::project::find_manifest_root(std::filesystem::current_path());
if (root) {
if (auto rc = mcpp::build::try_fast_build(*root, verbose, no_cache)) {
Expand Down Expand Up @@ -73,6 +74,7 @@ export int cmd_test(const mcpplibs::cmdline::ParsedArgs& parsed,
mcpp::build::BuildOverrides ov;
if (auto pr = parsed.value("profile")) ov.profile = *pr;
if (auto fs = parsed.value("features")) ov.features = *fs;
if (auto cp = parsed.value("cap")) ov.capabilities = *cp;
ov.strict = parsed.is_flag_set("strict");
return mcpp::build::run_tests(passthrough, ov);
}
Expand Down
Loading
Loading