diff --git a/.agents/docs/2026-06-29-feature-optional-dependencies-s2-design.md b/.agents/docs/2026-06-29-feature-optional-dependencies-s2-design.md new file mode 100644 index 0000000..191a8e8 --- /dev/null +++ b/.agents/docs/2026-06-29-feature-optional-dependencies-s2-design.md @@ -0,0 +1,306 @@ +# Feature System v2 — Stage 2: feature-activated optional dependencies (Design) + +Date: 2026-06-29 +Status: **S2a implemented**; S2b (feature unification) next. +Builds on: `.agents/docs/2026-06-29-feature-capability-model-design.md` +Scope: `src/manifest.cppm` (parse), `src/build/prepare.cppm` (worklist resolution ++ feature activation), `src/pm/dep_spec.cppm` (DepSpec reuse). + +## Implementation status + +- **S2a — DONE.** `Manifest.featureDeps` (`map>`). + Parsed from the TOML `[feature-deps.]` section and from a Lua descriptor + feature's nested `deps = { ["name"] = "ver" }`; Lua feature `implies` is now + parsed too (was TOML-only). In `prepare_build`, two local lambdas + `activateFeatures` / `mergeActiveFeatureDeps` merge a manifest's active + feature-deps into its `dependencies` map — for the root before the worklist is + seeded, and for each dependency right after its manifest loads (before its + children are pushed). The existing worklist BFS then fetches/version-merges + them, and Stage-3 capability binding finds a feature-pulled provider in the + graph. Optional-by-default falls out for free (a dep declared only under a + feature is never seen by the worklist unless the feature is active). + Tests: `e2e/82_feature_optional_deps.sh`, `Manifest.FeatureDepsTomlSection`, + `SynthesizeFromXpkgLua.FeatureDepsAndImplies`. + + > Implementation note: `activateFeatures`/`mergeActiveFeatureDeps` MUST be + > local lambdas, not file-scope functions. As exported (inline) functions in + > this module-interface unit their `std::map` instantiations leak into the + > emitted BMI and trip a GCC-16 modules bug — *another* TU importing `std` + > then fails with `fatal error: failed to load pendings for __normal_iterator`. + > Keeping them local confines the instantiations to the implementation. + +- **S2b — feature unification: NEXT.** Union feature requests per resolved + package identity across the graph (today the first requester's features win). + Needed for correct diamond behavior with feature-deps; called out separately + below because it is the one genuine resolver-semantics change. + +--- + +## 1. Problem + +A feature cannot pull a dependency today. `[features]` entries parse `implies`, +`defines`, `requires`, `provides` — the `deps` key is explicitly reserved +("`requires`/`provides`/`deps` keys are reserved for later stages", +`manifest.cppm`). So: + +- `requires = ["blas"]` (Stage 3) only **binds** a provider that is already in + the dependency graph; it does not bring one in. +- There is no way to say "*activating feature X pulls dependency Y*", which is + the natural way to express optional backends (e.g. Eigen's `use_blas` wanting + an external OpenBLAS), optional codecs, GPU backends, etc. + +This is **Stage 2** of the capability model. Implementing it makes the +provider/consumer story self-contained: a single feature can pull a provider +**and** bind the capability to it. + +## 2. Goals + +1. `features..deps` — a dependency listed under a feature is pulled **only + when that feature is active**. A dependency under `[dependencies]` is always + pulled (unchanged). Optionality is expressed by *where* the dep is declared, + not a separate `optional = true` flag (simpler than Cargo). +2. Works for the **root package** (`mcpp build --features X` pulls X's deps) and + **transitively** (a dependency's active feature pulls that dependency's deps). +3. **Composes with Stage 3**: a feature can `deps` a provider package **and** + `requires` the capability it provides; the resolver then binds the just-pulled + provider. This is the headline (`backend-openblas` below). +4. **Feature unification** (Stage 2b): when the same package is reached with + different feature sets from different consumers, the sets are **unioned**, so + all feature-deps are pulled and all feature effects apply (Cargo's additive + model). Replaces today's first-requester-wins behavior. +5. No regression: packages with no feature-deps resolve exactly as today. + +## 3. Key insight — it rides the existing worklist BFS + +Dependency resolution in `prepare_build` is already a breadth-first worklist +(`prepare.cppm:849` `WorkItem`, `:1547` seed from the root manifest, `:1560` +`while (!worklist.empty())`). Each `WorkItem` carries the `DependencySpec` +(including its requested `features`), the requester, and the consumer slot. +Transitive deps are discovered by pushing a loaded manifest's `[dependencies]` +onto the same worklist. + +So feature-deps need **no new resolution phase** and **no re-entrancy** (the +earlier worry). They are simply *more deps pushed onto the worklist* at the +moment a package's manifest is loaded and its active feature set is known: + +``` +seed worklist: + root [dependencies] (existing) + + root active-feature deps (default ∪ --features) (NEW) + +per worklist item (a dep whose manifest just loaded): + push its [dependencies] (existing) + + push its active-feature deps (NEW) + active = item.spec.features ∪ dep.default ∪ implied(expanded) +``` + +The BFS then fetches/resolves/version-merges the feature-deps exactly like any +other dep. Stage 3 capability binding (already implemented, `prepare.cppm` +`capProviders`/`capRequires`) runs after resolution and now finds the +feature-pulled provider in `packages`, binding the capability to it — no Stage 3 +change required. + +## 4. Data model + +Add to `Manifest` (next to `featuresMap` / `featureRequires`): + +```cpp +// feature name → dependencies activated by that feature. A dep that appears +// ONLY here (not in [dependencies]) is optional: resolved only when the +// feature is active. Each entry is a full DependencySpec (version/path/git + +// its own features/backend), so a feature-dep can itself request features. +std::map> featureDeps; +``` + +A `map` per feature mirrors `Manifest::dependencies`, so +the same parse/merge/fetch code applies unchanged. + +## 5. Syntax + +### Lua descriptor (index packages — the primary surface) + +```lua +features = { + -- consumer capability switch (Stage 1+3, already supported) + ["use_blas"] = { defines = { "EIGEN_USE_BLAS" }, requires = { "blas" } }, + + -- Stage 2: a backend convenience feature pulls a provider AND turns on the + -- consumer switch. `deps` mirrors the top-level `deps` table shape. + ["backend-openblas"] = { + implies = { "use_blas" }, + deps = { ["compat.openblas"] = "0.3.x" }, + }, +} +``` + +### TOML project manifest (a project's own features) + +```toml +[features] +use_blas = { defines = ["EIGEN_USE_BLAS"], requires = ["blas"] } +backend-openblas = { implies = ["use_blas"] } + +# Nested dep tables don't fit cleanly in a feature inline-table, so feature deps +# get their own section, keyed by feature name. (Parser: read [feature-deps.*] +# into Manifest.featureDeps with the existing dependency loader.) +[feature-deps.backend-openblas] +compat.openblas = "0.3.x" +``` + +Rationale: the Lua surface accepts a nested `deps = { ... }` inside the feature +table (the descriptor parser already walks nested tables). The TOML surface uses +a dedicated `[feature-deps.]` section because TOML inline tables nested +inside a feature inline-table are awkward and the existing dependency loader +(`load_deps`) can be pointed at `[feature-deps.]` verbatim. + +## 6. Resolution flow (where it changes in `prepare_build`) + +``` +1. toolchain → workspace (existing) +2. Compute ROOT active features early (NEW, small) + active_root = expand(default ∪ --features) +3. Seed worklist: + root [dependencies] (existing, :1547) + + for f in active_root: root.featureDeps[f] (NEW) +4. Worklist BFS (existing, :1560): + for each item: fetch + load manifest (existing) + compute the dep's active features: (NEW) + active = expand(item.spec.features ∪ dep.default ∪ implied) + push dep [dependencies] (existing) + + push dep.featureDeps[active] (NEW) + SemVer-merge / dedupe by identity (existing) +5. Feature activation (defines/sources/MCPP_FEATURE_, :2049) (existing) +6. Capability binding (0/1/many over in-graph providers) (existing, Stage 3) + — now finds feature-pulled providers +7. modgraph → plan → lockfile (existing) +``` + +`expand(...)` is the existing `activate()` closure (`prepare.cppm:2064`) factored +out so it can run during the worklist, not only at step 5. + +## 7. Feature unification (Stage 2b) + +Today, when a dependency is requested by more than one consumer, only the first +requester's `features` are applied (`prepare.cppm` dep loop uses the first match +then `break`). With feature-deps this is incorrect: consumer A may request +`compat.eigen[backend-openblas]` while consumer B requests `compat.eigen[use_lapacke]`; +both feature-deps must be pulled. + +Fix: accumulate the **union** of feature requests per resolved package identity +across the whole worklist, and: +- seed feature-deps for the union (so all optional deps are pulled), and +- at step 5, activate the union (so all defines/sources/capabilities apply). + +The worklist already dedupes packages by identity and merges versions; unifying +the feature set is the analogous merge on the feature axis. This is the one piece +that is genuinely a resolver-semantics change (everything else is additive), so +it is called out as its own sub-stage with its own tests. + +## 8. Worked example — OpenBLAS + Eigen (the headline) + +```lua +-- compat.openblas (a real provider package) +package = { + name = "compat.openblas", + provides = { "blas", "lapack" }, -- Stage 3 capability + mcpp = { /* build that exposes -lopenblas, headers */ }, +} +``` + +```lua +-- compat.eigen +features = { + eigen_blas = { sources = {"*/blas/*.cpp","*/blas/f2c/*.c"}, provides = {"blas"} }, + use_blas = { defines = {"EIGEN_USE_BLAS"}, requires = {"blas"} }, + use_lapacke = { defines = {"EIGEN_USE_LAPACKE"}, requires = {"lapack"} }, + mpl2only = { defines = {"EIGEN_MPL2_ONLY"} }, + -- Stage 2: one-liner backend that PULLS the provider and turns on the switch. + ["backend-openblas"] = { + implies = { "use_blas", "use_lapacke" }, + deps = { ["compat.openblas"] = "0.3.x" }, + }, +} +``` + +Consumer's `mcpp.toml`: + +```toml +[dependencies] +compat.eigen = { version = "5.0.1", features = ["backend-openblas"] } +``` + +Resolution walk-through: + +1. Worklist seeds `compat.eigen` (with `features=["backend-openblas"]`). +2. `compat.eigen` manifest loads. Active features expand: + `backend-openblas` → `implies` → `use_blas`, `use_lapacke`. +3. `backend-openblas.deps` → **push `compat.openblas@0.3.x`** onto the worklist. +4. Worklist resolves `compat.openblas` → it `provides = ["blas","lapack"]`. +5. Feature activation: `use_blas`/`use_lapacke` contribute `-DEIGEN_USE_BLAS` + / `-DEIGEN_USE_LAPACKE` and `requires = ["blas"]` / `["lapack"]`. +6. Capability binding (Stage 3): `blas`/`lapack` each have exactly one provider + in the graph (compat.openblas) → bound. Its `-lopenblas` link/include flow to + the consumer via usage requirements. + +Result: a single `features = ["backend-openblas"]` pulls OpenBLAS, defines the +Eigen macros, binds the capability, and links the library — the full +provider/consumer loop with no manual `[dependencies]` entry and no +`[capabilities]` pin (one provider ⇒ unambiguous). + +Note the mutual-exclusion rule still holds: `backend-openblas` must NOT also +imply `eigen_blas` (compiling Eigen's own BLAS while defining `EIGEN_USE_BLAS` +is self-contradictory — see the v2 design doc). `backend-openblas` is the +external-provider path; `eigen_blas` is the self-provider path; pick one. + +## 9. Edge cases + +- **Version conflict**: a feature-dep colliding with a top-level dep on a + different version is handled by the existing SemVer merge across the worklist + (no new logic). +- **Optional-only dep absent when feature off**: a package referenced *only* in + `featureDeps` and never activated is never fetched (the worklist never sees + it) — the desired optional behavior, for free. +- **Transitive feature-deps**: a feature-dep can itself carry `features=[...]`, + whose feature-deps are pushed when that package is processed — natural BFS + recursion. +- **Cycles**: the worklist's existing identity seen-set breaks cycles. +- **Dev-deps**: feature-deps are normal (non-dev) deps; they are not propagated + through `[dev-dependencies]` rules. +- **`--strict`**: requesting an undeclared feature already errors under strict; + a feature-dep that fails to resolve surfaces the existing fetch error. + +## 10. Staging + +| Stage | Content | Resolver change | Unlocks | +|---|---|---|---| +| **S2a** | Parse `featureDeps`; seed root + push per-dep feature-deps onto the worklist; factor `activate()` for reuse. | Additive (push more onto the existing worklist). | `--features X` and dep `features=[X]` pull X's deps; composes with Stage 3 to auto-bind a pulled provider. | +| **S2b** | Feature **unification**: union feature requests per package identity across the graph; activate + seed feature-deps for the union. | Semantics change (union vs first-wins). | Correct diamond behavior; multiple consumers' features all apply. | + +S2a alone already delivers the OpenBLAS+Eigen example (single consumer, single +requester). S2b hardens multi-consumer graphs. + +## 11. Testing + +- **Parse** (`test_manifest`): `featureDeps` from the Lua descriptor and from + `[feature-deps.]`; a feature with no deps yields no entry. +- **S2a e2e**: a root feature pulls a path-dep only when active (and not when + inactive — assert the dep is absent from the build/lockfile); a dep's feature + pulls a transitive path-dep. +- **Composition e2e**: a `backend-*` feature that `deps` a provider + `requires` + its capability resolves and binds with no explicit dependency/pin (the + OpenBLAS+Eigen shape, using small synthetic provider/consumer packages like the + existing `81_capability_binding.sh`). +- **S2b e2e**: two consumers request the same package with different features; + both feature-deps are pulled and both defines applied. + +## 12. Deliberately deferred + +- **Mutually-exclusive feature groups / `conflicts`** (e.g. forbidding + `eigen_blas` + `use_blas`): documented in the recipe for now; a declarative + `conflicts` is a separate, later addition (the single-valued capability slot + already covers the backend case). +- **`optional = true` on top-level deps + same-named auto-feature** (Cargo's + other style): the `featureDeps` table covers the same need more directly; + revisit only if a real case wants a top-level dep gated by an unrelated + feature name. +- **Weak features (`dep?/feat`)**: not needed until a concrete case appears. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dd79bc..4c3341e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,26 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [0.0.71] — 2026-06-29 + +### 新增 + +- **Feature 系统 v2 Stage 2a — 由 feature 激活的可选依赖**:声明于 `[feature-deps.]` + 段(或 Lua 描述符中 feature 的嵌套 `deps` 表)的依赖为**可选**依赖,仅当该 feature 处于激活 + 状态(根 `--features` 或依赖 spec 的 `features=[...]`)时才进入解析;声明于 `[dependencies]` 的 + 依赖始终解析。可选性由声明位置表达,无需额外的 `optional=true` 标志。实现上,`prepare_build` + 在为根包播种解析 worklist 之前、以及在每个依赖的 manifest 加载之后,将该 manifest 的活跃 + feature-deps 合并进其 `dependencies` 映射,后续既有的 worklist BFS 与 Stage 3 能力绑定即自动 + 接管——一个 `backend-openblas` feature 可同时**拉取** provider(`compat.openblas`, + `provides=["blas"]`)并**开启**消费开关(`implies=["use_blas"]`,`requires=["blas"]`),图中单一 + provider 时能力自动绑定。Lua 描述符的 feature `implies` 亦补齐解析(此前仅 TOML 支持)。详见 + `.agents/docs/2026-06-29-feature-optional-dependencies-s2-design.md`。 + + > 实现注记:上述两个 helper(`activateFeatures`/`mergeActiveFeatureDeps`)必须为 prepare_build + > 内的局部 lambda,而非文件作用域函数。若作为模块接口单元中的导出(inline)函数,其 `std::map` + > 实例化会泄入发射的 BMI,触发 GCC 16 modules 缺陷——另一导入 `std` 的翻译单元随即报 + > `fatal error: failed to load pendings for __normal_iterator`。局部化可将实例化限制在实现单元内。 + ## [0.0.70] — 2026-06-29 ### 修复 diff --git a/docs/05-mcpp-toml.md b/docs/05-mcpp-toml.md index 12a00cc..396f740 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -361,6 +361,42 @@ 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.8.2 `[feature-deps.]` — dependencies a feature pulls in + +A dependency declared under `[feature-deps.]` is **optional**: it is +resolved only when that feature is active (root `--features`, or a dependency +spec's `features = [...]`). A dependency in `[dependencies]` is always resolved; +optionality is expressed by *where* you declare it, not a flag. + +```toml +[features] +use_blas = { defines = ["EIGEN_USE_BLAS"], requires = ["blas"] } +backend-openblas = { implies = ["use_blas"] } + +# Pulled ONLY when `backend-openblas` is active. Each entry is a full dependency +# spec (version/path/git + its own features). +[feature-deps.backend-openblas] +compat.openblas = "0.3.x" +``` + +This composes with capabilities (§2.8.1): a single `backend-openblas` feature +both **pulls** the provider (`compat.openblas`, which `provides = ["blas"]`) and +**turns on** the consumer switch (`implies = ["use_blas"]`, which +`requires = ["blas"]`). With one provider in the graph the capability binds +automatically — `features = ["backend-openblas"]` is all the consumer writes. + +In an index package's Lua descriptor the same is written inline: + +```lua +features = { + use_blas = { defines = { "EIGEN_USE_BLAS" }, requires = { "blas" } }, + ["backend-openblas"] = { + implies = { "use_blas" }, + deps = { ["compat.openblas"] = "0.3.x" }, + }, +} +``` + ### 2.9 `[profile.]` — Build Profiles ```toml diff --git a/docs/zh/05-mcpp-toml.md b/docs/zh/05-mcpp-toml.md index 2d2dead..bf759e3 100644 --- a/docs/zh/05-mcpp-toml.md +++ b/docs/zh/05-mcpp-toml.md @@ -341,6 +341,40 @@ compat.openblas = "0.3.0" # provider 必须是图中真实存在的依赖 被绑定 provider 的链接/头文件旗标经由常规依赖机制流到消费方;capability 层是那道 *选择与校验* 步骤,把"静默选错后端 / 缺后端"变成构建期的显式报错。 +### 2.8.2 `[feature-deps.]` —— 由 feature 拉取的依赖 + +在 `[feature-deps.]` 下声明的依赖是**可选的**:仅当该 feature 激活时(根 `--features`, +或某依赖 spec 的 `features = [...]`)才会被解析。`[dependencies]` 中的依赖始终被解析; +可选性由声明的*位置*表达,而非某个标志位。 + +```toml +[features] +use_blas = { defines = ["EIGEN_USE_BLAS"], requires = ["blas"] } +backend-openblas = { implies = ["use_blas"] } + +# 仅当 `backend-openblas` 激活时才拉取。每个条目都是完整的依赖 spec +#(version/path/git + 其自身的 features)。 +[feature-deps.backend-openblas] +compat.openblas = "0.3.x" +``` + +该机制与能力(§2.8.1)组合:单个 `backend-openblas` feature 既**拉取** provider +(`compat.openblas`,其 `provides = ["blas"]`),又**开启**消费方开关 +(`implies = ["use_blas"]`,其 `requires = ["blas"]`)。当图中只有一个 provider 时, +能力自动绑定——消费方只需写 `features = ["backend-openblas"]`。 + +在索引包的 Lua 描述符中,等价写法为内联形式: + +```lua +features = { + use_blas = { defines = { "EIGEN_USE_BLAS" }, requires = { "blas" } }, + ["backend-openblas"] = { + implies = { "use_blas" }, + deps = { ["compat.openblas"] = "0.3.x" }, + }, +} +``` + ### 2.9 `[profile.]` — 构建档案 ```toml diff --git a/mcpp.toml b/mcpp.toml index a0218b4..91d0e07 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "0.0.70" +version = "0.0.71" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 407af1e..6225b8f 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -1544,6 +1544,56 @@ prepare_build(bool print_fingerprint, return {}; }; + // Stage 2a — feature-activated optional dependencies. Defined as local + // lambdas (NOT file-scope functions): keeping their std::map instantiations + // inside this implementation unit avoids polluting the exported module BMI, + // which otherwise trips a GCC-16 modules bug ("failed to load pendings for + // __normal_iterator") when other modules import std. + auto activateFeatures = [](const mcpp::manifest::Manifest& pm, + const std::vector& requested) { + std::vector act, q; + if (auto it = pm.featuresMap.find("default"); it != pm.featuresMap.end()) + q.insert(q.end(), it->second.begin(), it->second.end()); + q.insert(q.end(), requested.begin(), requested.end()); + std::set seen; + while (!q.empty()) { + auto f = q.back(); q.pop_back(); + if (f == "default" || !seen.insert(f).second) continue; + act.push_back(f); + if (auto it = pm.featuresMap.find(f); it != pm.featuresMap.end()) + q.insert(q.end(), it->second.begin(), it->second.end()); + } + return act; + }; + // Merge a manifest's active feature-deps into its `dependencies` map so the + // worklist below pulls them like any normal dep. A top-level dep of the same + // key is never overwritten; deps declared only under a feature appear only + // when that feature is active. + auto mergeActiveFeatureDeps = [&](mcpp::manifest::Manifest& pm, + const std::vector& requested) { + if (pm.featureDeps.empty()) return; + for (auto& f : activateFeatures(pm, requested)) { + auto it = pm.featureDeps.find(f); + if (it == pm.featureDeps.end()) continue; + for (auto& [k, spec] : it->second) pm.dependencies.try_emplace(k, spec); + } + }; + + // Pull the root package's active feature-deps into its dependency set before + // seeding, so `mcpp build --features X` resolves X's optional deps. + { + std::vector rootReq; + for (std::size_t p = 0; p < overrides.features.size();) { + auto c = overrides.features.find_first_of(", ", p); + auto tok = overrides.features.substr( + p, c == std::string::npos ? std::string::npos : c - p); + if (!tok.empty()) rootReq.push_back(tok); + if (c == std::string::npos) break; + p = c + 1; + } + mergeActiveFeatureDeps(*m, rootReq); + } + // Seed the worklist from the main manifest. Dev-deps only when the // caller wants them; they're never propagated transitively. const std::string mainPkgLabel = m->package.name; @@ -2000,6 +2050,12 @@ prepare_build(bool print_fingerprint, } } + // Stage 2a: merge this dependency's active feature-deps into its own + // dependency set before its children are pushed, so a dep's feature can + // transitively pull a provider. `spec.features` = features the consumer + // requested for this dep. + mergeActiveFeatureDeps(*dep_manifest, spec.features); + auto linkFlagsAdded = propagateLinkFlags(dep_root, *dep_manifest); // Move the manifest into stable storage so we can later look it up diff --git a/src/manifest.cppm b/src/manifest.cppm index ce711cb..56bf340 100644 --- a/src/manifest.cppm +++ b/src/manifest.cppm @@ -268,6 +268,12 @@ struct Manifest { std::vector provides; // package-level std::map> featureProvides; // feature → caps std::map> featureRequires; // feature → caps + // Feature System v2 Stage 2a — dependencies activated by a feature. A dep + // declared ONLY here is optional: pulled into the resolution worklist only + // when its feature is active (root --features or a dep spec's features=[...]). + // Each value is a full DependencySpec, so a feature-dep may itself request + // features. See .agents/docs/2026-06-29-feature-optional-dependencies-s2-design.md. + std::map> featureDeps; // Root-only: [capabilities] cap = "provider" pins (also fed by --cap). std::map capabilityPins; @@ -1050,6 +1056,19 @@ std::expected parse_string(std::string_view content, if (auto r = load_deps("dev-dependencies", m.devDependencies); !r) return std::unexpected(r.error()); if (auto r = load_deps("build-dependencies", m.buildDependencies); !r) return std::unexpected(r.error()); + // [feature-deps.] — optional dependencies activated by a feature + // (Stage 2a). Each sub-table is loaded with the same dependency loader as + // [dependencies], keyed by the feature name. + if (auto* fdeps = doc->get_table("feature-deps")) { + for (auto& [fname, fval] : *fdeps) { + if (!fval.is_table()) continue; + if (auto r = load_deps("feature-deps." + std::string(fname), + m.featureDeps[fname]); !r) + return std::unexpected(r.error()); + m.featuresMap.try_emplace(fname, std::vector{}); // register + } + } + // [toolchain] — platform → "pkg@version" map (docs/21) if (auto* tt = doc->get_table("toolchain")) { for (auto& [platform, val] : *tt) { @@ -2052,12 +2071,41 @@ synthesize_from_xpkg_lua(std::string_view luaContent, cur.skip_ws_and_comments(); if (!cur.consume('=')) break; cur.skip_ws_and_comments(); + if (sub == "deps" && cur.peek() == '{') { + // Feature-activated optional deps (Stage 2a): + // deps = { ["compat.openblas"] = "0.3.x", ... } + // Same flat/dotted form as the top-level `deps` table. + cur.consume('{'); + cur.skip_ws_and_comments(); + while (!cur.eof() && cur.peek() != '}') { + auto dname = cur.read_key(); + if (dname.empty()) break; + cur.skip_ws_and_comments(); + if (!cur.consume('=')) break; + cur.skip_ws_and_comments(); + auto dver = cur.read_string(); + DependencySpec spec; + spec.version = dver; + auto selector = mcpp::pm::resolve_dependency_selector( + dname, + mcpp::pm::DependencySelectorMode::OmittedMcpplibsPriority); + if (!selector.candidates.empty()) { + spec.namespace_ = selector.candidates.front().namespace_; + spec.shortName = selector.candidates.front().shortName; + spec.candidates = std::move(selector.candidates); + m.featureDeps[fname][selector.stableMapKey] = std::move(spec); + } + cur.skip_ws_and_comments(); + } + cur.consume('}'); + } else { // Feature subfields that carry a string array. `sources` // gates source globs; `defines` carries package-owned macros // (Stage 1); `requires`/`provides` declare capabilities // (Stage 3). All share the `{ "...", ... }` shape. std::vector* arr = - sub == "sources" ? &m.buildConfig.featureSources[fname] + sub == "implies" ? &m.featuresMap[fname] + : sub == "sources" ? &m.buildConfig.featureSources[fname] : sub == "defines" ? &m.buildConfig.featureDefines[fname] : sub == "requires" ? &m.featureRequires[fname] : sub == "provides" ? &m.featureProvides[fname] @@ -2076,6 +2124,7 @@ synthesize_from_xpkg_lua(std::string_view luaContent, if (cur.peek() == '{') cur.skip_table(); else (void)cur.read_bareword(); } + } cur.skip_ws_and_comments(); } cur.consume('}'); diff --git a/src/toolchain/fingerprint.cppm b/src/toolchain/fingerprint.cppm index 3e2c7d0..94ca17b 100644 --- a/src/toolchain/fingerprint.cppm +++ b/src/toolchain/fingerprint.cppm @@ -18,7 +18,7 @@ import mcpp.toolchain.detect; export namespace mcpp::toolchain { -inline constexpr std::string_view MCPP_VERSION = "0.0.70"; +inline constexpr std::string_view MCPP_VERSION = "0.0.71"; struct FingerprintInputs { Toolchain toolchain; diff --git a/tests/e2e/82_feature_optional_deps.sh b/tests/e2e/82_feature_optional_deps.sh new file mode 100755 index 0000000..f027587 --- /dev/null +++ b/tests/e2e/82_feature_optional_deps.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# 82_feature_optional_deps.sh — Feature System v2 Stage 2a: a dependency declared +# under a feature (`[feature-deps.]`) is pulled ONLY when that feature is +# active; otherwise it is never fetched/resolved. When active it is resolved and +# built like any normal dependency. See +# .agents/docs/2026-06-29-feature-optional-dependencies-s2-design.md. +# +# No `requires:` capability → runs on all three CI platforms. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +# A small path library the feature optionally pulls in. +mkdir -p widget/src +cat > widget/mcpp.toml <<'EOF' +[package] +name = "widget" +version = "0.1.0" + +[targets.widget] +kind = "lib" +EOF +cat > widget/src/widget.cppm <<'EOF' +export module widget; +export int widget_answer() { return 42; } +EOF + +mkdir -p app/src +echo 'int main() { return 0; }' > app/src/main.cpp +cat > app/mcpp.toml <<'EOF' +[package] +name = "app" +version = "0.1.0" + +[features] +default = [] +extra = [] + +# `widget` is declared ONLY under the feature, so it is optional: resolved only +# when --features extra is active. +[feature-deps.extra] +widget = { path = "../widget" } +EOF + +cd app + +# 1. Feature inactive → widget is NOT pulled (never resolved/compiled). +"$MCPP" build > b1.log 2>&1 || { cat b1.log; echo "FAIL: baseline build failed"; exit 1; } +if grep -q 'Compiling widget' b1.log; then + cat b1.log; echo "FAIL: widget must NOT be pulled when feature inactive"; exit 1 +fi + +# 2. Feature active → widget IS pulled and compiled like a normal dependency. +rm -rf target +"$MCPP" build --features extra > b2.log 2>&1 || { cat b2.log; echo "FAIL: feature build failed (widget not pulled?)"; exit 1; } +grep -q 'Compiling widget' b2.log || { cat b2.log; echo "FAIL: widget was not pulled/compiled when feature active"; exit 1; } + +echo "OK" diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index c72dbad..efb7e56 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -403,6 +403,58 @@ package = { ASSERT_TRUE(m->buildConfig.featureSources.contains("eigen_blas")); } +// Feature System v2 Stage 2a: optional deps activated by a feature. +// TOML surface uses a dedicated [feature-deps.] section. +TEST(Manifest, FeatureDepsTomlSection) { + constexpr auto src = R"( +[package] +name = "x" +version = "0.1.0" +[targets.x] +kind = "lib" +[features] +default = [] +backend = [] +[feature-deps.backend] +zlib = "1.3.x" +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_TRUE(m.has_value()) << m.error().format(); + ASSERT_TRUE(m->featureDeps.contains("backend")); + EXPECT_EQ(m->featureDeps["backend"].size(), 1u); + // The optional dep is NOT in the always-on dependency set. + EXPECT_FALSE(m->dependencies.contains("zlib")); +} + +// Lua descriptor surface: a feature carries `deps` (nested table) + `implies`. +TEST(SynthesizeFromXpkgLua, FeatureDepsAndImplies) { + constexpr auto lua = R"( +package = { + spec = "1", + name = "compat.eigen", + xpm = { linux = { ["1.0.0"] = { url = "u", sha256 = "h" } } }, + mcpp = { + sources = { "*/anchor.c" }, + targets = { ["eigen"] = { kind = "lib" } }, + features = { + ["use_blas"] = { defines = { "EIGEN_USE_BLAS" }, requires = { "blas" } }, + ["backend-openblas"] = { implies = { "use_blas" }, deps = { ["compat.openblas"] = "0.3.x" } }, + }, + }, +} +)"; + auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "compat.eigen", "1.0.0"); + ASSERT_TRUE(m.has_value()) << m.error().format(); + // implies recorded in featuresMap + ASSERT_TRUE(m->featuresMap.contains("backend-openblas")); + ASSERT_EQ(m->featuresMap["backend-openblas"].size(), 1u); + EXPECT_EQ(m->featuresMap["backend-openblas"][0], "use_blas"); + // deps recorded in featureDeps, NOT in the always-on dependency set + ASSERT_TRUE(m->featureDeps.contains("backend-openblas")); + EXPECT_EQ(m->featureDeps["backend-openblas"].size(), 1u); + EXPECT_TRUE(m->dependencies.empty()); +} + TEST(Manifest, BuildMacosDeploymentTarget) { constexpr auto src = R"( [package]