# v0.0.99 批次设计 —— #243 feature 转发 + #238 xlings 升级 + #230 windows 复验

> 日期:2026-07-19
> 基线:mcpp **0.0.98**(HEAD;`MCPP_VERSION` @ `src/toolchain/fingerprint.cppm:21`)
> 目标版本:**0.0.99**
> 覆盖 issue:**#243**(manifest `[features]` 的 dep/feat 转发)、**#238**(≥2 项目级 index_repo 时 xlings `install_packages` 静默失败)、**#230**([windows] `mcpp test --workspace` 退出 127)。
> 前序设计:[2026-07-19-issue-243-feature-forwarding-design.md](2026-07-19-issue-243-feature-forwarding-design.md)(#243 语义与用例分析)。本文 **§1.3 校正**其 §5 的实现路径:0.0.98 已通过 `0cca9e0` 落地 `DependencyEdge` + `aggregatedRequest` 单一漏斗,原设计假想的 `requestedFeaturesByPkg` 累加器**已经存在**,#243 只需注入这个既有漏斗,不再新建平行结构。
> 上游总账:[2026-07-19-issues-230-243-batch-ledger-and-architecture-assessment.md](2026-07-19-issues-230-243-batch-ledger-and-architecture-assessment.md)。
> 所有 file:line 锚点均在 0.0.98 源码上核实。

---

## 0. 三个 issue 的现状核实(结论先行)

| issue | 类型 | 0.0.98 现状(核实) | 0.0.99 需做 |
|---|---|---|---|
| **#243** | enhancement | 转发(`dep/feat`)**完全不存在**;条件依赖(`[feature-deps]` / xpkg `features.x.deps`)已存在 | **新增转发**:数据模型 `featureForwards` + 两文法解析 + 注入 `dependencyEdges` 漏斗 |
| **#238** | bug | 根因在 xlings(`install_packages` 多仓解析),mcpp 侧诊断已落地(`00b66c5`);**xlings#374 已在上游修复并发布 0.4.67**(`cf9b60d56`) | **升级 vendored xlings 0.4.62 → 0.4.67**,生态真实验证多仓安装 |
| **#230** | bug(win) | scanner symlink 逃逸 + 窄串转换抛异常→`__fastfail`→裸 127,**代码已修**(`df985df`/#231,在 HEAD)+ `main.cpp` 兜底 catch | **复验** + 补 `build.mcpp` 产物 windows `.exe` 后缀(逃逸修复后 workspace build-mcpp 成员在 windows 才会被走到的次生面) |

三者共版本 0.0.99 一次 PR/release,是 #230–#243 批次的收尾(参见 batch ledger §3 "剩余开口工作")。

---

## 1. #243 —— feature 依赖转发(Cargo `dep/feat` 平价)

### 1.1 语义与用例

opencv 模块包(源码直编形态)要提供 `import opencv.dnn;`:一个 feature `dnn` 既要拉入 `compat.opencv`(条件依赖,已支持),又要**打开该依赖的 `dnn` feature**(转发,新增),否则 `dnn` 的 +309 TU 要么对所有消费者永远全量拉、要么无法提供。

期望:`opencv = { version="…", features=["dnn"] }`(消费端)→ 模块包启用 `dnn.cppm` 源 + 依赖的 `compat.opencv` **以 `features=["dnn"]` 参与构建**。转发必须**传递**(链式转发继续)且在 **`mcpp build` 与 `mcpp test` 双路径**上都成立。

### 1.2 面向用户语法(Cargo 平价)

`[features]` 数组(及表格形 `implies`)里,**含 `/` 的 token = 转发 `<depKey>/<depFeature>`**,不含 `/` = 本地 implied feature。与 Cargo `[features] F = ["dep/feat", ...]` 完全一致:

```toml
[features]
# dnn 激活时:本地拉 dnn.cppm 源集,并把 compat.opencv 的 dnn feature 打开
dnn = { sources = ["src/dnn.cppm"], implies = ["compat.opencv/dnn"] }
# 数组简写等价:dnn = ["compat.opencv/dnn"]
# 表格形亦可用专用键(自解释、等价):dnn = { forward = ["compat.opencv/dnn"] }

[dependencies]
compat.opencv = { version = "0.0.3", default-features = false }
```

`depKey` 与 `[dependencies]` / `[feature-deps]` **同键空间**(`resolve_dependency_selector(...).stableMapKey`,实为原始 selector 串;`compat.opencv` 保持 `compat.opencv`)。转发是**加法**:只增开依赖的 feature,不改依赖是否被拉入(那是 `[feature-deps]` 的职责),不受 `default-features` 开关影响(§1.5)。

> **不加 `deps` 内联**:条件依赖的 canonical TOML 面仍是独立 `[feature-deps.<name>]` 段(需要完整 DependencySpec);转发只需两个字符串,内联进 `[features]` 是自然的。二者正交、可组合(§1.1 用例即二者同用)。

### 1.3 数据模型(唯一新增)

`src/manifest/types.cppm`(紧邻 `featuresMap` / `featureDeps`,同族)新增:

```cpp
// #243: dep/feat 转发(Cargo 平价)。featureName → [(depStableKey, depFeature)]。
// 本包 feature F 激活时,把 depFeature 注入依赖 depStableKey 的请求集。
// depStableKey 与 dependencies / featureDeps 同键空间(stableMapKey == 原始 selector 串)。
std::map<std::string, std::vector<std::pair<std::string, std::string>>> featureForwards;
```

**不新增** `DependencySpec` 字段、**不新增** TOML 段。

### 1.4 共享切分 helper(两文法唯一切分点)

`src/pm/dependency_selector.cppm`(两文法均已 `import` 之)加一个纯 inline 函数:

```cpp
// "compat.opencv/dnn" → {depKey:"compat.opencv", depFeature:"dnn"}。无 '/' 或空段 → nullopt(按本地 implied 处理)。
inline std::optional<std::pair<std::string,std::string>>
split_feature_forward_token(std::string_view token);
```

按**首个** `/` 切分(feature 名不含 `/`)。两解析器把 implies token 逐个过它:命中→ `featureForwards`,否则→ `featuresMap`(implies)。

- **TOML**(`toml.cppm:196-237`):array 简写与表格 `implies` 收进 `implied` 后统一 partition;新增表格键 `forward`(值为 `dep/feat` token 数组)也走同一 helper。
- **xpkg**(`xpkg.cppm:1108-1195`):feature 表 `}` 收尾后对 `featuresMap[fname]` 做同一 partition(与 TOML 对称)。`deps` 分支不变。

### 1.5 传播算法 —— 注入既有 `aggregatedRequest` 漏斗(核心)

0.0.98 的 `prepare.cppm` 已把 per-package 请求 feature 集收敛为唯一漏斗:

- `DependencyEdge{ consumerPackageIndex, dependencyPackageIndex, requestedFeatures, defaultFeatures }`(`:1849-1863`),由 `recordDependencyEdge(consumerDepIndex, depIdx, spec)` 从 `spec.features` 填充(`:1948-1975`)。
- `aggregatedRequest(depPkgIndex)`(`:2858-2871`):对某 dep,union 所有入边的 `requestedFeatures`(菱形安全,Cargo 语义)。
- 两个消费点都读它:激活 `apply(packages[i], req, depDefaultFeatures)`(`:2872-2890`)与 dep build.mcpp 的 `bpEnv.features`(`:2910-2918`)。
- 解析期 `mergeActiveFeatureDeps(pm, spec.features, spec.defaultFeatures)`(`:2646`)用**同一** `spec.features` 拉条件依赖。

**转发的唯一注入点:在把某包 P 的子依赖 push 进 worklist 之前,按 P 的激活 feature 集,把 P 转发给该子依赖的 feature 追加进子依赖的 `spec.features`。** 一处注入同时覆盖两个消费点:

1. **解析期**:子依赖被处理时 `mergeActiveFeatureDeps(*dep_manifest, spec.features, …)` 用到的 `spec.features` 已含转发 feature → 该子依赖的 `[feature-deps.<forwarded>]` 也随之展开(转发能触发被转发包自身的条件依赖)。
2. **激活期**:`recordDependencyEdge(…, spec)` 把含转发 feature 的 `spec.features` 记到 P→D 边上 → `aggregatedRequest(D)` union 到它 → `apply(D)` 激活该 feature(发 `-DMCPP_FEATURE_<F>`、`featureDefines`、`featureSources` drop/add)。**`apply()` 内部一字不改。**

**注入 helper**(`prepare.cppm`,近其它 feature lambda):

```cpp
// P 的激活 feature 集 parentActive 决定哪些转发生效;把 P 转发给 childKey 的 feature
// 并入 childSpec.features(去重)。在 child push 进 worklist 前调用。
auto injectForwards = [](const Manifest& parent, const vector<string>& parentActive,
                         const string& childKey, DependencySpec& childSpec) {
    if (parent.featureForwards.empty()) return;
    for (auto& f : parentActive)
        if (auto it = parent.featureForwards.find(f); it != parent.featureForwards.end())
            for (auto& [depKey, depFeat] : it->second)
                if (depKey == childKey &&
                    find(childSpec.features.begin(), childSpec.features.end(), depFeat)
                        == childSpec.features.end())
                    childSpec.features.push_back(depFeat);
};
```

**两处 push 点各加一次注入(push 的是增广后的 spec 拷贝,不改存储的 manifest):**

- **root**(`:2172-2183`):`rootActive = feature_closure(*m, rootReq, true)`;对 `m->dependencies` 每个子依赖 `injectForwards(*m, rootActive, n, reqCopy)` 后 push。
- **dep**(`:2686-2689`):`depActive = feature_closure(*dep_manifests.back(), spec.features, spec.defaultFeatures)`(与 `:2646` 的 `mergeActiveFeatureDeps` 同闭包);对其子依赖同样注入后 push。

**传递性**:root→mid→leaf 的链,mid 处理时其 `spec.features`(来自 root 对 mid 的边,已含 root 的转发)决定 `depActive`,再把 mid 对 leaf 的转发注入 leaf 的边 → 沿 BFS 前向边天然递归。**终止性**:feature 集单调增且有限;`feature_closure` 的 `seen` 挡 implies 环。

**菱形局限(已知,与既有一致)**:若 D 有多个消费者且**晚到**的转发边在 D 已 resolve 之后才出现,晚到的转发不会重新触发 D 的条件依赖展开——这与 0.0.98 `aggregatedRequest` 注释所述"解析期按边、激活期 union"的同一历史边界一致(激活期仍会 union 到,`apply` 正确;只有"晚到转发再触发 D 的 `[feature-deps]`"这一步受限)。opencv.dnn 是 root→opencv→protobuf 的树链,不触及此边界。

### 1.6 与 #242(`default-features = false`)组合

转发是加法、default-features 是减法,二者在 `feature_closure` 入口并集、正交:

- 转发注入到的是 `spec.features`(显式请求),**不受** `default-features` 开关影响。
- `compat.opencv = { default-features = false }` + 转发 `compat.opencv/dnn` → opencv 的 `requested = {dnn}`,default seed 被跳过 → **只带 dnn(+其 implies),不带 opencv 默认 feature 集**。正是"精简依赖"想要的。

### 1.7 校验(复用漏斗红利)

- **转发未定义的 dep feature**(`opencv/nonesuch`):转发 feature 经边进 `aggregatedRequest`,被**既有**"dependency does not declare requested feature"门(`:2875-2883`)校验 → strict 报错 / warn。无需新增校验路径。
- **转发到未声明的 dep**(depKey 不在 `dependencies`∪`featureDeps`):新增轻量校验——在注入处,若某激活 feature 的转发 depKey 不在该包(已折入 feature-deps 的)`dependencies` 中,strict 报错 / 非 strict warn(与既有 feature 校验同模式)。feature 未激活则不校验(惰性)。

### 1.8 测试

**单测**(`tests/unit/test_manifest.cpp`,并列既有 `FeatureDepsTomlSection` / `FeatureDepsAndImplies`):

- `TEST(Manifest, FeatureForwardTomlParse)`:`F = ["local", "compat.opencv/dnn"]` → `featuresMap["F"]=={"local"}` 且 `featureForwards["F"]=={{"compat.opencv","dnn"}}`。
- `TEST(Manifest, FeatureForwardTableForms)`:表格 `implies` 混入 + 专用 `forward` 键都分流到 `featureForwards`。
- `TEST(SynthesizeFromXpkgLua, FeatureForwardParse)`:xpkg `implies={"dep/x"}` → `featureForwards`。

**e2e**(host-aware,`tests/e2e/128_feature_forwarding.sh`,并入 `run_all.sh`;模板同 `126_default_features_opt_out.sh`):

- 夹具:`leaf`(feature `extra` 带 `defines=["LEAF_EXTRA=1"]`,不在 default);`mid` 依赖 `leaf`(`default-features=false`)且 `[features] withextra = ["leaf/extra"]`;`app` 依赖 `mid`。
  - `app` 请求 `mid` 的 `withextra` → leaf 以 extra 编译,`LEAF_EXTRA` / `MCPP_FEATURE_EXTRA` 在 leaf 的 `compile_commands.json` 在场(转发经 root→mid→leaf 传递)。
  - `app` 不请求 withextra → leaf 无 extra,`LEAF_EXTRA` **不**在场。
  - **双路径**:同断言在 `mcpp test` 复跑(锁 0.0.97 双路径不变量)。

---

## 2. #238 —— 升级 vendored xlings 0.4.62 → 0.4.67

### 2.1 根因归属(核实)

`install_packages` 在 ≥2 项目级 `index_repos` 时静默 exit 1,**根因在 xlings**(不在 mcpp)。已开 **openxlings/xlings#374**,该 issue **已 closed**:上游修复 `cf9b60d56` "fix(xim): surface multi-repo install failures + best-effort catalog (#374)",发布于 **xlings 0.4.67**(`v0.4.66...v0.4.67` 恰好一条该 commit)。mcpp 侧诊断(`00b66c5`,把裸 `exit 1` 变成可操作错误)已在 0.0.98。

### 2.2 改动

把 release/构建/e2e 三处 workflow 的 vendored xlings pin 从 `0.4.62` 升到 `0.4.67`:

- `.github/workflows/release.yml`(多处 `XLINGS_VERSION: '0.4.62'`、aarch64 bundle URL `xlings-0.4.62-linux-aarch64`)。
- `.github/workflows/cross-build-test.yml`(2 处)。
- `.github/workflows/ci-linux-e2e.yml`(`quick_install.sh v0.4.62`)。

`src/xlings.cppm:36 kXlingsVersion` 是 xlings **self-install** 的最低 pin,与多仓解析无关,可一并同步到 `0.4.67`(保守:核对其消费点后再定;不影响 #238 修复,因修复来自 bundle 的 xlings 二进制行为)。

### 2.3 生态真实验证(§4)

release 后按发布闭环:mirror xlings-res → xim-pkgindex → **构造 ≥2 index_repo 的沙箱真装一个未缓存包**(如 `compat:compat.imgui`),断言 exit 0(0.4.62 下为静默 exit 1);再 bump bootstrap pin。

---

## 3. #230 —— windows workspace 退出 127

### 3.1 根因与既有修复(核实)

`mcpp test --workspace` 在 windows 于成员 glob 扫描期,`recursive_directory_iterator(follow_directory_symlink)`(#220 `d440a0b` 引入)顺 `#224` 建的 `.mcpp/.xlings/data/<index>` symlink 逃逸进 vendored `xim-pkgindex`,其中一个 **CJK 文件名**触发 MSVC `lexically_relative().generic_string()` 宽→窄转换抛 `std::system_error`;0.0.95 未捕获 → `std::terminate` → `__fastfail(0xC0000409)` → git-bash 报裸 **127**。

**代码已修**(在 HEAD):
- `scanner.cppm`:`is_excluded_walk_dir` prune `.mcpp`(`:291-300`,两处 walk 调用 `:438-441`/`:531-532`)+ 窄串转换 try/catch never-throw(`:135-146`)。
- `src/main.cpp`:顶层 catch,未捕获异常→`error: internal: unhandled exception` + exit 70(不再裸 127)。
来源 `df985df`(#231),0.0.98 已含。

### 3.2 0.0.99 补:`build.mcpp` 产物 windows `.exe` 后缀

scanner 逃逸修复后,workspace 的 `build-mcpp` 成员(带 `build.mcpp`)在 windows 才会被真正走到——此时暴露一个次生 windows 面:

`src/build/build_program.cppm:539` 硬编码产物名 `build.mcpp.bin`(无 `.exe`),`:591` 经 `capture_exec` 运行;windows 上 `capture_exec` 走 cmd.exe(`_popen`,argv[0] 原样不加引号),**cmd 无法按 `.bin` 扩展名执行 PE**(不在 PATHEXT)。

**修复**:产物名按平台取后缀(与 `plan.cppm:201` 用 `mcpp::platform::exe_suffix` 同惯例):

```cpp
fs::path bin = bdir / (mcpp::platform::is_windows ? "build.mcpp.exe" : "build.mcpp.bin");
```

非 windows 字节不变(仍 `build.mcpp.bin`);windows 产出并按 `.exe` 名执行。此改动零 schema、零跨平台行为回归(`is_windows` 是 constexpr)。既有 e2e `89_build_mcpp.sh` / `92_build_mcpp_import.sh` 在 linux 覆盖(exe_suffix 为空,无行为变化);windows 侧由 CI(ci-windows)+ mcpp-index workspace-windows 复验。

### 3.3 复验与关闭

0.0.99 release 后,mcpp-index 的 workspace(windows)CI pin 升到 0.0.99(从临时钉回的 0.0.94),全绿则关闭 #230。

---

## 4. 版本 / PR / 发布 / 生态验证计划

**单 PR,逐 commit 绿(每 commit `mcpp build` self-host + `mcpp test` 全绿):**

1. `feat(manifest)`:`featureForwards` 数据模型 + `split_feature_forward_token` + 两文法解析 + 解析单测。
2. `feat(build)`:转发注入 `dependencyEdges` 漏斗(root/dep 两 push 点)+ 未声明-dep 校验 + e2e 128 并入 `run_all.sh`。
3. `fix(build.mcpp)`:windows `.exe` 后缀(#230 次生面)。
4. `chore(deps)`:vendored xlings 0.4.62 → 0.4.67(#238);workflow pins。
5. `chore(release)`:版本 `0.0.99`(`fingerprint.cppm` + `mcpp.toml` + CHANGELOG,仅末尾改版本号)。

**发布闭环**(memory `release-publish-pipeline`):release→四平台产物→mirror xlings-res(gh+gtc 双端)→xim-pkgindex PR→**xlings install 真装 0.0.99**→bump bootstrap pin(`.xlings.json` / workspace)。

**生态真实验证(必做,三 issue 各一条闭环):**
- #243:在真实索引拉一个带转发的包(或本地夹具),`import` 被转发 feature 提供的模块接口,编译+运行通过。
- #238:≥2 index_repo 沙箱真装未缓存包,exit 0。
- #230:mcpp-index workspace(windows)CI 在 0.0.99 全绿。

**架构验收**:manifest 文法 / xpkg 描述符 / feature 模型三处对"转发"收敛为**一数据模型 `featureForwards` 多文法**;per-package 请求 feature 集仍是唯一漏斗 `aggregatedRequest`(转发只注入,不新建平行结构)。
