diff --git a/.agents/docs/2026-07-19-issue-243-feature-forwarding-design.md b/.agents/docs/2026-07-19-issue-243-feature-forwarding-design.md new file mode 100644 index 00000000..7d58e87b --- /dev/null +++ b/.agents/docs/2026-07-19-issue-243-feature-forwarding-design.md @@ -0,0 +1,269 @@ +# Issue #243 —— feature 依赖转发(`dep/feat`)设计 + +> 日期:2026-07-19 +> 基线:mcpp **0.0.98**(HEAD;`MCPP_VERSION = "0.0.98"` @ `src/toolchain/fingerprint.cppm:21`) +> 范围:GitHub issue **#243** —— manifest `[features]` 的 (1) feature 条件依赖 与 (2) 依赖 feature **转发**(Cargo `dep/feat` 语法)。阻塞用例:opencv 模块包的 `opencv.dnn` 接口(一个 feature 既要拉入一个依赖、又要打开该依赖的某个 feature)。 +> 所有 file:line 锚点均在 0.0.98 源码上核实。 +> 上游总账:[2026-07-19-issues-230-243-batch-ledger-and-architecture-assessment.md](2026-07-19-issues-230-243-batch-ledger-and-architecture-assessment.md) §1(#243 行)/§3;架构主线沿用 [2026-07-18-issue-triage-215plus-architectural-remediation.md](2026-07-18-issue-triage-215plus-architectural-remediation.md) 与 [2026-07-18-v0.0.97-architectural-remediation-implementation-plan.md](2026-07-18-v0.0.97-architectural-remediation-implementation-plan.md):**把结构化意图保留到唯一收敛点(single funnel),不另开旁路**。 + +--- + +## 0. 结论先行 + +**#243 的口子比标题窄。** 核实现状后: + +- **(1) feature 条件依赖 —— 已存在。** 两种文法都落到同一数据模型 `Manifest::featureDeps`(`src/manifest/types.cppm:386`,`map>`): + - TOML 面:专用 `[feature-deps.]` 段(`src/manifest/toml.cppm:699-710`)。 + - xpkg 面:`features..deps = { ... }`(`src/manifest/xpkg.cppm:1135-1161`)。 + - 消费点:`prepare.cppm:2101-2109` 的 `mergeActiveFeatureDeps`,在 root(`:2113-2124`)与每个 dep(`:2596-2600`)两处把**激活了的** feature 的 feature-deps 折进该包的 `dependencies`。 + - 单测已锁:`tests/unit/test_manifest.cpp:422`(`FeatureDepsTomlSection`)、`:445`(`SynthesizeFromXpkgLua, FeatureDepsAndImplies`)。 + - **故第 (1) 半基本无需新增代码**;唯一可议的是"要不要在 `[features]` 里内联 `deps=` 以与 xpkg 文法对齐"——见 §3 的裁决:**不加,`[feature-deps]` 保持 canonical**。 + +- **(2) feature 转发 `dep/feat` —— 完全不存在,是 #243 的真正工作量。** 当前 `[features]` 解析只读 `implies/defines/sources/requires/provides`(`toml.cppm:211-236`),xpkg 同理(`xpkg.cppm:1167-1173`);没有任何地方表达"本包 feature F 激活时,顺带激活依赖 D 的 feature `feat`"。而 dep 的请求 feature 集在**两处**各自独立推导且不自洽: + - 解析期 `mergeActiveFeatureDeps` 用 `spec.features`(dep)/`rootReq`(root)——决定**拉哪些可选依赖**; + - 激活期 `apply()`(`prepare.cppm:2673-2777`)对 dep 只从 `m->dependencies[pkg].features`**直接边**重新推导 `req`(`:2801-2806`),传递性 dep→dep 请求被丢弃(源码 `:2653` 注释明确:"Transitive dep→dep feature requests are not yet propagated")。 + +**因此 #243 的转发是"强制把 per-package 请求 feature 集收敛成唯一漏斗"的契机。** 本设计不在既有两处特判旁再加第三处,而是引入**一个** per-package 请求 feature 累加器 `requestedFeaturesByPkg`,由解析期填充(消费边 `spec.features` ∪ 转发 ∪ #242 的 default 策略),被 `mergeActiveFeatureDeps` 与 `apply()` **共同消费**——顺带把 `:2653` 的传递性缺口一并补上(不是本 issue 硬要求,但同一漏斗自然收敛)。 + +--- + +## 1. 目标语义与用例 + +opencv 模块包(源码直编形态)大致: + +```toml +# compat.opencv 的 mcpp.toml(片段) +[features] +# dnn 接口:既要拉入 protobuf 依赖(条件依赖,已支持), +# 又要打开 opencv 内部的 dnn 源集(本地 feature), +# 还要打开 protobuf 的 "lite" feature(转发,#243 新增) +dnn = ["dnn-sources", "compat.protobuf/lite"] + +[feature-deps.dnn] +"compat.protobuf" = "3.21.x" # 条件依赖:dnn 激活才拉 protobuf(已支持) +``` + +期望:`mcpp build --features dnn`(或消费者 `opencv = { version="…", features=["dnn"] }`)时—— + +1. `dnn-sources`(本地 feature)按 implies 展开; +2. `compat.protobuf` 被拉入依赖图(feature-deps,已支持); +3. **`compat.protobuf` 以其 `lite` feature 激活参与构建**(转发,新增)——即 protobuf 的 `lite` 定义/源集在 protobuf 的 TU 里生效,而**不带** `--features dnn` 时 protobuf 根本不进图(或即便进图也不带 `lite`)。 + +转发必须**传递**(protobuf 的 `lite` 若又转发到 abseil 的某 feature,链条继续)且在 **`mcpp build` 与 `mcpp test` 双路径**上都成立(0.0.97 确立的 per-package 双路径不变量,见 batch ledger §0 "单一漏斗不变量")。 + +--- + +## 2. 面向用户的语法(surface syntax)裁决 + +### 2.1 候选与选型 + +| 方案 | 拼写 | 评价 | +|---|---|---| +| **A(推荐)** Cargo 平价,复用 implies 数组 | `[features]`\n`F = ["local-feat", "dep/feat"]` | 零新键、零新段;`dep/feat`(含 `/`)是转发,裸名是本地 implies。与 Cargo `[features] F = ["dep/feat", ...]` 完全一致,用户零学习成本。表格形亦可:`F = { implies=[...] }`,`/` token 混在 implies 里同样识别。 | +| B 专用段 | `[feature-forward.F]`\n`"dep" = ["feat"]` | 又开一段,与已有 `[feature-deps]` 割裂;`[features]` 依然要跨段读才知道 F 全貌。否决。 | +| C 表格形显式键 | `F = { implies=[...], forward=["dep/feat"] }` | 更"自解释",但与 Cargo 分叉,且 `forward` 值仍是 `dep/feat` 串——只是把 A 的 token 从 `implies` 挪到新键。作为 A 的**可选补充**保留(见 §2.2),不作主推。 | + +**决策:采用 A。** `[features]` 数组(及表格形的 `implies`)里,**含 `/` 的 token = 转发 `/`,不含 `/` = 本地 implied feature**。理由: + +1. **Cargo 平价、非 workaround**(batch ledger §1 对 #243 的方案栏原话:"Feature 记录加 `forward`/`deps-features`,active 时把列出的 feature 注入对应依赖请求集后再跑 `feature_closure`")。 +2. **closed syntax, open vocabulary**(0.0.97 全局约束):只加一个固定形状的解析分流(按 `/` 切分),不加开放语义键。 +3. 与 `[feature-deps]` 分工清晰:`[feature-deps]` 说"F 激活拉哪个包"(需要完整 DependencySpec:version/path/git),`dep/feat` 说"F 激活给那个包开哪个 feature"(只需两个字符串)。二者正交、可组合(§1 用例即二者同用)。 + +### 2.2 `deps` 内联问题(第 (1) 半的语法统一) + +xpkg 文法在 `features..deps` 内联条件依赖(`xpkg.cppm:1135`),TOML 文法用独立 `[feature-deps.]`。**是否要在 TOML `[features]` 表格形也加 `deps=`?** + +**裁决:不加。** 一个 feature-dep 需要完整 DependencySpec(version/path/git/rev/features/visibility),内联进 `[features]` 数组会撑爆"数组=轻量列表"的直觉;`[feature-deps.]` 是一个正经的 dep 段,复用 `load_deps`(`toml.cppm:705`),表达力完整。二者**已经是"一数据模型(`featureDeps`)两文法"**,满足 batch ledger §5.2 的一致性检验点。文档层把 `[feature-deps.]` 记为 canonical TOML 面,`features.x.deps` 记为其 xpkg 文法等价物即可。**#243 不动这一半。** + +--- + +## 3. 数据模型 + +### 3.1 新增字段(唯一) + +`src/manifest/types.cppm`(紧邻 `featuresMap:370` / `featureDeps:386`,同族)新增: + +```cpp +// Feature System v2 —— 依赖 feature 转发(Cargo `dep/feat` 平价)。 +// 本包 feature F 激活时,把 注入依赖 的请求 feature 集, +// 在该 dep 跑 feature_closure 之前。depKey 与 dependencies / featureDeps 同键 +// (resolve_dependency_selector 的 stableMapKey)。转发是"加法":只增开 +// 依赖的 feature,不改依赖是否被拉入(那是 featureDeps 的职责)。 +std::map>> + featureForwards; // featureName → [(depKey, depFeature)] +``` + +**不新增** `DependencySpec` 字段、**不新增**段。转发目标 depKey 复用 `dependencies` / `featureDeps` 的键空间(`resolve_dependency_selector(...).stableMapKey`,见 `xpkg.cppm:1150-1157` 的既有用法),使"转发到某 dep"与"依赖某 dep"天然对齐。 + +### 3.2 与既有 feature 数据的关系(single funnel) + +``` +[features] / xpkg features → featuresMap(implies) +[features].defines / xpkg defines → buildConfig.featureDefines +[features].sources / xpkg sources → buildConfig.featureSources +[features].requires/provides → featureRequires / featureProvides +[feature-deps.X] / xpkg features.X.deps → featureDeps ← 已有(条件依赖) +[features] 数组内 "dep/feat" / xpkg 同 → featureForwards ← 新增(转发) +``` + +六张表都 keyed by featureName,由**同一个** `feature_closure`(`prepare.cppm:407-423`)判定"哪些 feature 激活",再各自消费。转发不引入平行的 feature 判定,只是**多一张激活后要消费的表**。 + +--- + +## 4. 解析改动(两文法,共用切分) + +### 4.1 一个共享 helper + +在 `prepare.cppm` 附近或 manifest 层加一个纯函数(供两文法与校验共用): + +```cpp +// "compat.protobuf/lite" → {depKey:"compat.protobuf", depFeature:"lite"} +// 无 '/' → nullopt(调用方按本地 implied 处理)。depKey 走 +// resolve_dependency_selector 归一到 stableMapKey,与 dependencies 同键。 +std::optional> split_forward_token(std::string_view); +``` + +按**首个** `/` 切分(feature 名不含 `/`;depKey 可含 `.` 命名空间但不含 `/`)。depKey 侧过 `resolve_dependency_selector(..., OmittedMcpplibsPriority)`(同 `xpkg.cppm:1150`)归一,保证 `protobuf` 与 `compat.protobuf` 指向同一键。 + +### 4.2 TOML(`src/manifest/toml.cppm:211-236`) + +`[features]` 循环里,无论数组形(`:213-215`)还是表格形的 `implies`(`:218`),把收进 `implied` 的每个 token 过 `split_forward_token`:命中 `/` → `m.featureForwards[fname].push_back(...)`,否则 → 原样进 `featuresMap[fname]`(implies)。**其余键(defines/sources/requires/provides)不变。** + +### 4.3 xpkg(`src/manifest/xpkg.cppm:1167-1173`) + +`sub == "implies"` 分支(`:1168`)当前直接把字符串塞进 `&m.featuresMap[fname]`。改为逐 token 过 `split_forward_token`,同 §4.2 分流。**`deps` 分支(`:1135-1161`)不变**(那是 featureDeps)。 + +> 两文法只共享 `split_forward_token` 一个切分点,零重复逻辑——满足 batch ledger §5.2"一数据模型多文法"。 + +--- + +## 5. 传播算法与注入点 + +### 5.1 现状的两处"各自推导"(问题根) + +| 阶段 | 位置 | 用什么当 dep 的请求集 | 缺陷 | +|---|---|---|---| +| 解析期 拉可选依赖 | `mergeActiveFeatureDeps` @ `prepare.cppm:2600` | `spec.features`(该 dep 的消费边) | 只认单条消费边;转发无处注入 | +| 激活期 发 `-D`/源集 | `apply()` @ `prepare.cppm:2801-2806` | 从 `m->dependencies` **直接边**重推 | 传递 dep→dep 丢失(`:2653`);转发无从体现 | + +两处对"dep 的请求 feature 集"各算一遍且不一致——这正是要消除的"散在多处特判"。 + +### 5.2 唯一漏斗:`requestedFeaturesByPkg` + +引入一个 per-resolved-package 累加器(keyed by `packages[]` 下标,与 `dependencyEdges` 的 `consumer/dependencyPackageIndex` 同坐标,见 `:2864`): + +```cpp +std::map> requestedFeaturesByPkg; +``` + +**填充规则(加法、单调增):** + +1. **root**(`packages[0]`):`requestedFeaturesByPkg[0] = rootReq`(`parse_feature_request(overrides.features)`,`:2779`;#242 的 `default-features` 只影响 default seed,不影响这里)。 +2. **每条消费边 P→D**(`recordDependencyEdge` @ `:2617`):把该边 `spec.features` 并入 `requestedFeaturesByPkg[depIdx]`(去重)。**这一步顺带补上 `:2653` 的传递性缺口**——D 不再只认 root 直接边。 +3. **转发**:对每个包 P,`active(P) = feature_closure(P.manifest, requestedFeaturesByPkg[Pidx])`;对每个激活 feature F、每条 `P.featureForwards[F] = (Dkey, feat)`、每条 P→D 边(D 的 canonical 名匹配 Dkey),把 `feat` 并入 `requestedFeaturesByPkg[Didx]`。 + +规则 3 依赖 P 的 active 集,而 P 的 active 集又可能因**上游对 P 的转发**而增长 → **不动点迭代**:重复规则 2+3 直到无累加器增长。终止性:feature 集单调增且有限(全集 = 所有包所有 feature),故收敛;环(A/f→B/g→A/f)不会死循环——集合饱和即停,`feature_closure` 自身的 `seen`(`:414/417`)另挡 implies 环。 + +### 5.3 与解析期"拉可选依赖"的次序耦合 + +难点:转发的 `feat` 可能触发 **D 自身的 feature-deps**(D 的 `[feature-deps.feat]` 再拉一个包 E),而累加器的不动点若在**整图建完后**才跑,E 就漏拉了。 + +**方案:把转发注入**并进**既有 worklist 的增量展开**,而非事后独立一遍。具体在 dep 处理块(`prepare.cppm:2596-2643`): + +1. `:2600` `mergeActiveFeatureDeps(*dep_manifest, requestedFeaturesByPkg[depIdx])` —— 把入参从 `spec.features` 换成**累加器**(已并入所有已知消费边 + 已知转发)。D 的 feature-deps 据此折进 `dep_manifest->dependencies`。 +2. 紧接在把 D 的子依赖 push 进 worklist(`:2640-2643`)**之前**,插入转发注入: + `active(D) = feature_closure(*dep_manifest, requestedFeaturesByPkg[depIdx])`;对每个激活 feature F、每条 `dep_manifest->featureForwards[F] = (childKey, feat)`,把 `feat` 追加到 `dep_manifest->dependencies[childKey].features`(即将 push 的 `child_spec`)。 +3. 于是 child 被 push 时其 `spec.features` 已含转发的 `feat`;child 处理时其 `mergeActiveFeatureDeps` 用到的累加器(由规则 2 从这条边并入)也含 `feat` → child 的 feature-deps / 再转发递归展开。**传递性天然成立(树形 BFS 前向即够)。** + +root 侧同构:在 `:2113-2124` 现有块内,`mergeActiveFeatureDeps(*m, rootReq)` 之后、seed worklist(`:2129`)之前,按 `m->featureForwards` 对 root 的直接依赖 `m->dependencies[childKey].features` 注入转发。 + +**菱形(D 有 ≥2 消费者,第二消费者晚于 D 首次 resolve 才加 feature)**:与既有 feature-deps / 传递请求**同一历史局限**。补法:当某条**晚到的**消费边或转发使 `requestedFeaturesByPkg[Didx]` **增长**且 D 已 resolve,则把 D **重新入队**做一次 feature-deps + 转发再展开(去重守卫:仅当累加器真增长才重入队 → 单调有限 → 终止)。菱形非 opencv.dnn 的 MVP 必需(dnn 是 root→opencv→protobuf 的树链),但漏斗设计天然容纳,建议实现时一并做以彻底消除 `:2653` 局限。 + +### 5.4 激活期消费累加器(消除第二处推导) + +`apply()` 的调用点改为读累加器,而非重推: + +- root:`apply(packages[0], requestedFeaturesByPkg[0])`(替 `:2798` 的 `rootReq`,值相同)。 +- dep:`apply(packages[i], requestedFeaturesByPkg[i])`(替 `:2801-2806` 从 `m->dependencies` 重推的 `req`)。 + +`apply()` 内部(`:2673-2777`)一字不改:它对每个激活 feature 发 `-DMCPP_FEATURE_`、并入 `featureDefines`(`:2698-2717`,含 Public/Interface 传播)、按 `featureSources` drop/add(`:2740-2776`)。转发的 `feat` 此刻已在 D 的累加器里 → D 的 `apply()` 自然激活 `feat`,其 defines/源集照常生效。**转发不需要在 `apply()` 里加任何转发专属分支**——它只是让 D 的请求集"多了 feat",复用全部既有激活机制。 + +> 双路径不变量:`apply()` 的 drop 是 `!includeDevDeps` 门(`:2742`),add 在两模式都跑(`:2731-2739` 的 eigen_blas 教训)。累加器与注入点都在 `apply()` 之前、与 `includeDevDeps` 无关,故 `mcpp build` 与 `mcpp test` 走同一累加器 → 转发在双路径一致(满足 batch ledger §0 单一漏斗不变量)。build.mcpp 环境契约(`:2852` `bpEnv.features = feature_closure(pkg.manifest, req)`)同样应改读累加器,使 dep 的 build.mcpp 看到转发进来的 feature。 + +--- + +## 6. 与 #242(consumer `default-features = false`)的组合 + +#242(batch ledger §1 / 待实施)加 `DependencySpec.defaultFeatures`(默认 `true`),穿进 `feature_closure`:为 `false` 时**跳过 default seed**(`prepare.cppm:411-412` 的 `pm.featuresMap["default"]` 注入)。 + +**组合规则(转发是加法,default-features 是减法,二者正交):** + +- `feature_closure(pm, requested)` 的输入 = `(default seed if defaultFeatures) ∪ requested`。 +- 转发注入到的是 **`requested`**(累加器 / `spec.features`),属于"显式请求",**不受** default-features 开关影响。 +- 故 `dep = { version="…", default-features = false, features=[] }` 且上游转发 `dep/lite`:dep 的 `requested = {lite}`,default seed 被跳过 → **dep 只带 lite(+lite 的 implies),不带 dep 的默认 feature 集**。这正是 opencv"精简依赖"想要的:关掉 protobuf 默认,只按 dnn 需要打开 `lite`。 +- 反之 default-features 默认 `true` 时:`requested(含转发) ∪ default`,转发的 feature 叠加在默认集之上。 + +一句话:**转发决定"额外开哪些 dep feature",default-features 决定"要不要 dep 的默认 feature 集";前者进 `requested`,后者门控 `default` seed,在 `feature_closure` 里并集,互不覆盖。** 实现次序上建议 #242 先落(它定义 `DependencySpec.defaultFeatures` 与 `feature_closure` 的 seed 开关),#243 在其上叠转发;若并行,两者都只在 `feature_closure` 入口与 DependencySpec 交汇,冲突面小。 + +--- + +## 7. 边界情形 + +| 情形 | 处理 | +|---|---| +| 转发到**未声明**的 dep(`F=["ghost/x"]` 但 `ghost` 不在 dependencies∪featureDeps) | F 激活时校验:depKey 在图中无匹配包 → **strict 报错 / 非 strict warning**,复用 `:2792-2797` / `:2807-2815` 既有 strict/warn 模式。F 未激活则不校验(与 `unknown_requested` 同惰性)。 | +| 转发到**可选/feature 激活**的 dep(dep 仅由某 feature-dep 拉入) | 允许。次序:`mergeActiveFeatureDeps`(`:2600`)先把 feature-dep 折进 `dependencies`,转发注入(§5.3 步骤 2)在其后 → dep 在场即注入。若拉入该 dep 的 feature-dep **未激活**(dep 缺席)而转发仍指向它 → 归入"转发到未声明 dep",warn。 | +| 转发一个 dep **未定义**的 feature(`protobuf/nonesuch`) | 转发的 `feat` 经累加器进 `spec.features`,被**既有**的 "dependency does not declare requested feature" 门(`:2807-2815`)校验 → strict 报错 / warn。**无需新增校验路径**(复用漏斗的红利:转发 feature 与显式 `features=[...]` 走同一校验)。 | +| 转发目标 feature 自身再转发(传递) | §5.3 步骤 3 递归覆盖;累加器不动点(§5.2)兜菱形。 | +| 环(A/f→B/g→A/f) | 累加器单调饱和即停(§5.2 终止性);`feature_closure` 的 `seen` 挡 implies 环。 | +| 同一 dep 被多条转发/消费边给不同 feature(菱形并集) | 累加器按包去重并集(§5.2 规则 2/3);增长则重入队(§5.3 菱形补法)。 | +| depKey 命名空间歧义(`protobuf` vs `compat.protobuf`) | `split_forward_token` 走 `resolve_dependency_selector` 归一到 stableMapKey,与 `dependencies` 同键匹配(同 `xpkg.cppm:1150-1157`)。 | + +--- + +## 8. 测试计划 + +**单测(解析,加进现有 `tests/unit/test_manifest.cpp`,勿新建工程):** + +- `TEST(Manifest, FeatureForwardTomlParse)`:`[features]\nF = ["local", "compat.protobuf/lite"]` → `m.featuresMap["F"] == {"local"}` 且 `m.featureForwards["F"] == {{"compat.protobuf","lite"}}`(校验 `/` 分流、命名空间归一)。 +- `TEST(Manifest, FeatureForwardTableForm)`:表格形 `F = { implies = ["a", "dep/x"] }` 同样分流。 +- `TEST(SynthesizeFromXpkgLua, FeatureForwardParse)`:xpkg `features.F.implies={"dep/x"}` → `featureForwards`(与 `FeatureDepsAndImplies:445` 并列)。 +- 负例(可 e2e 承接):转发未声明 dep → strict 报错串。 + +**e2e(host-aware,编号续 `tests/e2e/` 现有末号 123 → 从 124 起,并入 `run_all.sh`):** + +- 夹具:项目 `P` 依赖 `D`;`D` 有 feature `extra`,其 `[features].extra.defines = ["D_EXTRA=1"]`(或 `featureSources` 一个仅 extra 才编、导出符号的 `.cpp`)。`P` 的 `[features]`:`F = ["D/extra"]`,`P` 的 TU `#ifdef` 探测 `D_EXTRA`(或链接 D 的 extra 符号)。 + - `mcpp build`(不带 `--features F`)→ `D_EXTRA` 缺席 / 符号未定义(断言其**不出现**)。 + - `mcpp build --features F` → D 带 extra 编译,`D_EXTRA` 在场 / 符号解析,构建+运行通过。 + - **双路径**:同一断言在 `mcpp test` 复跑一遍(锁 0.0.97 双路径不变量)。 +- 传递变体(可选,验证链条 + 菱形补法):`D/extra` 再转发 `E/y`,断言 `E` 的 y 定义仅在 `--features F` 下在场。 +- 组合 #242 变体(若 #242 已落):`D = { …, default-features=false, features=[] }` + `F=["D/extra"]` → 仅 extra 在场,D 默认 feature 集**不**在场。 + +--- + +## 9. Commit / PR 计划 + +**这是独立后续 PR,非 0.0.98(HEAD)内容。** 建议目标 **0.0.99**(或 #237/#241/#242 机械修的同一后续批次,见 batch ledger §3)。 + +- **依赖次序**:建议 **#242 先落**(定义 `DependencySpec.defaultFeatures` 与 `feature_closure` 的 default-seed 开关),#243 叠其上;二者若并行,交汇点仅 `feature_closure` 入口 + DependencySpec,冲突面小,但 §6 的组合语义须在 #243 的 e2e 显式覆盖。 +- **commit 拆分(单 PR,逐节点绿)**: + 1. 数据模型 + 两文法解析 + `split_forward_token`(`types.cppm` / `toml.cppm` / `xpkg.cppm`)+ 解析单测。 + 2. 漏斗重构:`requestedFeaturesByPkg` 累加器 + `mergeActiveFeatureDeps` / `apply()` / build.mcpp env 三个消费点改读累加器 + 转发注入(§5.3/5.4)+ 菱形重入队(顺带闭 `:2653`)+ 校验复用。 + 3. e2e 124(+ 传递/组合变体)并入 `run_all.sh`;host-aware。 + 4. 版本 commit `0.0.99`(`fingerprint.cppm:21` + `mcpp.toml` + CHANGELOG,**仅末尾改版本号**)。 +- **每 commit 不变量**:`mcpp build`(self-host)+ `mcpp test` 全绿;涉行为 commit 附 e2e;单测就近加进 `test_manifest.cpp`。 +- **架构验收(batch ledger §5.2)**:交付后 manifest 文法 / xpkg 描述符 / feature 模型三处对"转发"须收敛为**一数据模型(`featureForwards`)多文法**,per-package 请求 feature 集收敛为**唯一漏斗** `requestedFeaturesByPkg`,不得再分叉出平行实现;`prepare.cppm:2653` 的传递性 TODO 应随本 PR 消除。 + +--- + +## 附:核实锚点清单(0.0.98) + +- 版本真源:`src/toolchain/fingerprint.cppm:21`(`MCPP_VERSION = "0.0.98"`)。 +- 条件依赖已存在:`toml.cppm:699-710`(`[feature-deps.]`)、`xpkg.cppm:1135-1161`(`features.X.deps`)、`types.cppm:386`(`featureDeps`)、`prepare.cppm:2101-2109/2113-2124/2596-2600`(`mergeActiveFeatureDeps` + 两调用点)、`tests/unit/test_manifest.cpp:422/445`。 +- `[features]` 解析(转发要挂入):`toml.cppm:196-236`(数组/表格形,implies/defines/sources/requires/provides)、`xpkg.cppm:1108-1195`(同,implies 分支 `:1167-1173`)。 +- feature 判定唯一实现:`prepare.cppm:407-423`(`feature_closure`)、`:426-436`(`parse_feature_request`)。 +- 激活期两处独立推导(要收敛):`prepare.cppm:2673-2777`(`apply`)、`:2798`(root)、`:2801-2806`(dep,直接边重推)、`:2653`(传递性 TODO 注释)、`:2807-2815`(dep feature 未声明校验,转发复用)。 +- DependencySpec:`src/pm/dep_spec.cppm:28-51`(`features` @ `:42`;#242 将加 `defaultFeatures`)。 +- dep-spec 键白名单(#242 将加 `default-features`):`toml.cppm:423-428`;inline 填充 `:437-477`(features @ `:455-458`,backend 糖 `:461-462`)。 +- 消费边/依赖边坐标(累加器 keyed by):`prepare.cppm:2615-2617`(`packages.push_back` / `recordDependencyEdge`)、`:2864`(`dependencyEdges` 遍历)。 +- build.mcpp 环境契约(应读累加器):`prepare.cppm:2852`。 diff --git a/.agents/docs/2026-07-19-issues-230-243-batch-ledger-and-architecture-assessment.md b/.agents/docs/2026-07-19-issues-230-243-batch-ledger-and-architecture-assessment.md new file mode 100644 index 00000000..600e55b4 --- /dev/null +++ b/.agents/docs/2026-07-19-issues-230-243-batch-ledger-and-architecture-assessment.md @@ -0,0 +1,119 @@ +# #230–#243 批次总账 + 架构评估(治理文档) + +> 日期:2026-07-19 · 基线:mcpp **0.0.98**(HEAD,branch `fix/object-path-239-240`) +> 目的:本文件是 issue **#230–#243** 这一批次的**唯一权威总账**——每个 issue 的分类 / 状态 / 根因子系统 / 已核实 file:line 锚点 / **根因级(非 workaround)修复方案** / 对应 commit·PR / 验证方式,一格不漏。 +> **交付纪律(用户指令,强约束)**: +> 1. **不做 workaround 级别的修复**——每个修复改的是**根因收敛点**,不加旁路开关 / 特判豁免。凡是只能在 mcpp 仓外(xlings)根治的,必须**如实标注**,不得用 mcpp 侧 band-aid 冒充"已修"。 +> 2. 批次**全部完成后**,必须补一份**整体架构评估**(§4:设计合理性 / 稳定性 / 是否留下架构债),记录在本文件。 +> 3. **所有相关修复 / 实现都必须清楚对应到 issue**(§1 总账即此纪律的落地)。 + +--- + +## 0. 建立在 0.0.97 已确立的架构主线之上(不重复推导) + +本批次全部沿用 0.0.97 两份设计文档确立的架构原则(见 +`.agents/docs/2026-07-18-issue-triage-215plus-architectural-remediation.md` / +`.agents/docs/2026-07-18-v0.0.97-architectural-remediation-implementation-plan.md`): + +- **主线诊断**:mcpp 反复"过早把结构化意图压平成字符串 / 把特判散落各处",再在下游补救。**每个修复都要把意图保留到单一汇聚点(choke point / funnel),而不是再加一个特判。** +- **单一漏斗不变量**:per-package 的有效 source / feature / cfg 求值必须在**所有**构建路径(root + version-dep + path/git-dep)且 **`mcpp build` 与 `mcpp test` 双路径**上评估(#218/#229 的教训)。#242/#243 必须**扩展**这个已有漏斗(`prepare.cppm` 的 per-package feature loop),不能另开旁路。 +- **封闭文法要"响亮失败"**:0.0.97 的 `[[x]]`-非白名单硬报错立了先例;#237 是它到 xpkg `mcpp` 段构建路径的直接延伸。 +- **工具 / 资源供给走单一同步"边前供给"门**(`Fetcher::resolve_xpkg_path(..., autoInstall=true)`:先刷索引 → 阻塞安装 → 校验 payload → 硬报错)。#238(xlings install 路径)、#241(依赖 payload 可用性)在此框架内。 +- **命名空间路由是表查找,不是硬编码短路**(R6 移除默认命名空间→内建索引的两处硬编码)。#238 恰恰在"root `[indices]` 继承(#224)× 多仓"这个**0.0.97 预期终态**上暴露。 +- **标准警示**:0.0.97 自身的 C3 `shell_quote_arg` 过度引号回归,只在合并后全量 e2e + 多平台 CI 才现形(逐 commit 单测没抓到)。**跨构建 / 共享库运行期破坏会躲过 per-commit 单测**——本批次同样适用(本次 #239 的 ninja `@`-引号 × #235 depfile 就是同类交互 bug)。 + +--- + +## 1. 批次总账(#230–#243,一格不漏) + +图例:状态 ✅已发布 / 🟢已修待发(本地 branch)/ 🔧机械根因修(可实施)/ 📐需设计 / ⛔根因在仓外 / 🔒已修待关闭。 +"根因级"= 改的是根因收敛点;凡标 workaround 者必须给出为何暂无根因路径。 + +| # | 类型 | 状态 | 根因子系统 | 已核实锚点 | 根因级修复方案 | commit / PR | +|---|------|------|-----------|-----------|---------------|-------------| +| **#230** | bug(win) | 🔒 已修(0.0.96) | scanner glob 走 symlink 逃逸 + 窄串转换抛异常→`__fastfail`→裸 127 | `scanner.cppm` symlink 守卫 + `.mcpp` prune;`src/main.cpp` 兜底 catch 返回 70 | 已根治(`df985df` / #231)。**动作:mcpp-index windows CI pin ≥0.0.96 复验后关闭** | 已发布 0.0.96 | +| **#237** | 诊断缺口 | ✅ 已修待发 | xpkg 描述符 `mcpp` 段未知键在 **build 路径**静默丢弃(`xpkg parse` 才硬报错) | `xpkg.cppm:1324-1333`(else 分支入 `xpkgUnknownKeys`,build 不消费);消费点仅 `cmd_xpkg.cppm:122/186`;字段 `types.cppm:351` | build 时消费 `xpkgUnknownKeys` → 响亮告警(前向兼容,非硬错)+ `closest_known_xpkg_key`(封闭词表别名 + Levenshtein 回退)did-you-mean;在 `prepare.cppm` 描述符采纳点(funnel)发。单测 `XpkgUnknownKeys.CollectedAndDidYouMean` | `c20520b`(0.0.98) | +| **#238** | bug | ⛔ **根因在 xlings**(mcpp 诊断✅) | xlings `install_packages` 多 index_repos(≥2)解析静默 exit 1 | mcpp 侧只:写多仓 `xlings.cppm:1109-1117`、shell-out `:1016/1022`、吞失败 `package_fetcher.cppm:343`(NDJSON 无 `error` 事件 `progress.cppm:23`);触发链 = #224 root `[indices]` 继承 | mcpp 侧**诊断已落地**(`00b66c5`):`read_seeded_index_repos`(空格容错解析,修了复用 compact `extract_string` 的假 0 仓 bug)+ `format_install_failure_diagnostic` 点名目标/仓清单/`≥2` 缺口提示,`captured_error()` 保留子进程输出,`MCPP_VERBOSE` 记原始调用。单测 `PmPackageFetcher.*`。**根因仍须落 xlings**:已提交 **openxlings/xlings#374** | mcpp 诊断:`00b66c5`(0.0.98);根因:**openxlings/xlings#374** | +| **#239** | bug | 🟢 已修待发 | #233 消歧对越根/绝对 relPath 逃逸 `obj/` + `@` 撞 ninja 引号×#235 depfile | `plan.cppm` 消歧前缀 | `safe_object_prefix` 逐分量净化(去根/`.`丢/`..`→`__up`/非可移植→`_`),逐分量单射保 #233 唯一性 | `b7f32f6`(本 branch,0.0.98) | +| **#240** | bug | 🟢 已修待发 | #233 消歧后 entry-main link 输入用陈旧扁平 `obj/main.o` | `plan.cppm` entry-main 独立重算 + census 盲区 | 对象路径收敛单一 `object_for`;entry-main 纳入普查 / 复用已扫描单元对象 | `b7f32f6`(本 branch,0.0.98) | +| **#241** | enhancement | ✅ 已修待发 | build.mcpp G3 环境契约缺 per-dep 路径 | `build_program.cppm` contract_env(无 per-dep);sanitizer `sanitize_feature_env`;rerun hash;调用点 `prepare.cppm` 依赖 build.mcpp 站 + `dependencyEdges` 图;struct `BuildProgramEnv` | `BuildProgramEnv.depDirs` + `contract_env` 发 `MCPP_DEP__DIR`(同 feature sanitize,自动进 rerun hash)+ `mcpp::dep_dir()` 模块助手;用权威 consumer→dep 边图注入(覆盖 feature 激活依赖)。**根因**:消除包侧自导航 store 布局。作用域=依赖侧 build.mcpp(root 工程 build.mcpp 早于依赖解析,列后续)。e2e 125;**架构评审补**:canonical+short 双发 + 碰撞守卫(`4449077`) | `df3750b` + `4449077`(0.0.98) | +| **#242** | enhancement | ✅ 已修待发 | 消费端无法关默认 feature 集 | `feature_closure` 无条件 seed default;dep-spec 键白名单缺 `default-features` `toml.cppm`;`DependencySpec`(`dep_spec.cppm`)无 `defaultFeatures` | `default-features` 键→`DependencySpec.defaultFeatures`(默认 true)→穿进 `feature_closure` 单一 `seedDefault` 门(false 时跳过 default seed);root/工程 build.mcpp 仍 seed。**Cargo 平价,单一收敛点**。单测 + e2e 126;**架构评审补**:传递边 opt-out 收敛到 `aggregatedRequest`(e2e 127) | `bba624d` + `4449077`(0.0.98) | +| **#243** | enhancement | 📐 设计✅ / 实现待后续 PR | manifest `[features]` 缺条件依赖(**实为已存在**)+ 依赖 feature 转发(`dep/feat`,真缺口) | 条件依赖已存在:`[feature-deps.]` `toml.cppm:699-710` + xpkg `features.x.deps` `xpkg.cppm:1135-1161` → `featureDeps`;转发无处表达;请求集两处不自洽(`mergeActiveFeatureDeps` vs `apply()` `:2801-2806`,`:2653` 传递性 TODO) | 设计定稿 `.agents/docs/2026-07-19-issue-243-feature-forwarding-design.md`:`[features]` 内 `dep/feat` token 转发;引入**单一** per-package 请求 feature 漏斗 `requestedFeaturesByPkg`(顺带补 `:2653` 传递性),与 #242 加性组合。**实现列 0.0.99+ 独立 PR** | 设计:本 branch;实现:后续 | + +> 上批(#224–#235 + R6)已于 **0.0.97** 发布,总账见 +> `.agents/docs/2026-07-18-v0.0.97-architectural-remediation-implementation-plan.md`;本文件不重复,仅以 §0 继承其框架。 + +--- + +## 2. 已完成工作的架构评估(#239/#240)—— 根因 or workaround? + +**结论:根因级,非 workaround。** 依据: + +- **#240** 的修复不是"link 端把 `obj/main.o` 特判成消歧路径",而是**把对象路径分配收敛成单一来源 `object_for`**——scanner 单元与 synthesized entry-main 走**同一函数 + 同一碰撞普查**,link 输入与编译边"永不背离"是**结构性保证**,不是打补丁。这正是 §0"保留意图到单一汇聚点"主线的落地:#233 的病根就是"对象路径这件事拆散在两处算"。 +- **#239** 的修复不是"发现 `@` 就删掉",而是**前缀构造对任意 relPath 保证'向下且 shell 安全'**——覆盖越根 `..`、绝对根、非可移植字符三类,逐分量单射保住 #233 的唯一性(L1b 断言兜底残余)。 +- **稳定性**:常见单二进制工程(main 唯一)对象路径**字节不变**;干净 relPath 的碰撞项亦字节不变;仅在"真的发生碰撞"时行为改变,且改后才正确(此前直接构建失败)。已验:单测 35/35、e2e 117/118/02/03/07/08/09 + 新增 123/124 全绿、非 glob main 两边界(唯一→扁平 / 碰撞→消歧)真跑通。 +- **留下的已知架构债(如实记录)**:#235 的 depfile 规则 `"$out.d"` 对**任何**含 `@` 的对象路径本就脆(ninja 1.12.1 实测含 `@` 即单引号包裹)。本次靠净化前缀**绕开**了触发面,但**未根治** `"$out.d"` 自身的脆性——若将来对象路径经其它途径合法带 `@`,仍会复现。**候选后续**:ninja depfile 侧改用不受外层引号影响的写法(独立 issue,不在本批次范围;已在此登记以免遗忘)。 + +--- + +## 3. 剩余开口工作的根因计划(#237 / #241 / #242 / #243 / #238) + +- **#237 / #241 / #242**:均为**机械级根因修**,锚点已核实(§1),各自独立小改面,不触碰构建图。可各成一 commit(或并入 0.0.98 后续 PR)。 +- **#243**:**需先设计**(feature 转发是传递性的,且与 #242 的 opt-out、per-package feature 漏斗、xpkg 已有平价点交互)。走 brainstorming → 设计文档 → 实施。 +- **#238**:**根因不在本仓**。mcpp 侧只能做诊断改进(把 `fetch failed (exit 1)` 变成携带真实原因的错误事件),**必须同时在 xlings 仓开根因 issue**(多 index_repos 解析),否则按"不做 workaround"纪律**不能标记为已修**。 + +--- + +## 4. 整体架构评估(批次落地后) + +> 方法:实现全部落地后,派一个**独立对抗性 reviewer**(冷上下文)审全量 batch diff(`05155ff..HEAD`),按"workaround 味 / 单漏斗违背 / 回归 / 一致性债 / 测试是否真门控"五维找问题并给可复现触发。下述结论基于其发现 + 逐条核实 + 实测。 + +### 4.1 设计合理性(单一汇聚点 vs 特判)——逐条 + +| # | 判定 | 依据 | +|---|------|------| +| #239/#240 | ✅ 根因/单漏斗 | `object_for`/`safe_object_prefix` 是真正的单一对象路径分配器,scanner 单元与 synthesized entry-main 同源;残余非单射由 L1b 响亮断言兜底。非特判。 | +| #237 | ✅ 根因 | 单一描述符采纳点 funnel(`warn_unknown_xpkg_keys` 两站同调)+ 封闭词表随 parser else-if 同步。 | +| #238 | ✅ 正确归属 | 根因在 xlings(openxlings/xlings#374);mcpp 侧只从自有上下文重建可操作诊断,纯函数可测,不吞子进程输出。非冒充修复。 | +| #241 | ✅ 根因(修正后) | 用权威 consumer→dep 边图注入(非名字猜测)。review 指出"canonical vs declared 名"矛盾 → 已改为**canonical + short 双发**并加碰撞守卫。 | +| #242 | ⚠️→✅ 根因(修正后) | `seedDefault` 门本身干净,但 review 证实 **feature 请求集在解析与激活两处独立推导且对传递边不自洽**(激活只扫 root 直接依赖)——`default-features=false` 对传递依赖被静默丢弃,甚至编译默认门控源却不解析其依赖。**已收敛**:见 §4.6。 | + +### 4.2 一致性(一数据模型多文法) + +- **feature 条件依赖**:TOML `[feature-deps]` 与 xpkg `features.x.deps` 已收敛到单一 `Manifest::featureDeps`(#243 核实)。✅ +- **feature 请求集**:此前是**反例**——解析用 per-edge `spec.features`,激活用 root 直接边重推,两处漂移。§4.6 已把二者收敛到唯一权威源(`dependencyEdges` 图的 `aggregatedRequest`)。✅(本批次最大的架构收益) +- **残留不一致(如实登记)**:xpkg 描述符 `deps` 仍是**纯版本串**,不支持 per-dep `features`/`default-features`;#242 的 opt-out 只在 TOML 面。属**先于本批**的表面不对称(xpkg deps 从来没有 per-dep features),消费端 opt-out 也只在 root(TOML)面有意义 → 非本批回归,列为一致性债。 + +### 4.3 稳定性 / 回归面 + +- feature 激活是本批风险最高的改动面;已跑**全部 feature e2e**(67/71/72/79/80/81/82/83/100/106/125/126)+ 新增 127 + 单测 35/35 全绿,直接依赖行为逐字节不变(root 边携带 root spec)。 +- 对象路径改动对常见工程逐字节不变(§2)。 +- **未做多平台/跨构建 e2e**(本机仅 Linux)——沿用"合并后全量 e2e + 多平台 CI"堵盲区,故**发版前置条件 = CI 全绿**(见 §5,用户要求)。 + +### 4.4 架构债清算(不静默遗留) + +| 债 | 处置 | +|----|------| +| `"$out.d"`×`@` depfile 脆性(§2) | 本批靠净化前缀绕开触发面;`"$out.d"` 自身脆性**未根治** → 候选独立 issue(登记在案)。 | +| #238 多仓解析根因 | 已开 **openxlings/xlings#374**,mcpp 侧诊断到位。 | +| #241 payload-mount 子项(裸文件 payload 实体在共享 runtimedir) | 未做(fetcher/store 侧改动),issue 中为"顺带"项 → 后续。 | +| #243 转发实现 | 设计定稿,列 0.0.99+ 独立 PR。 | +| **新发现**:传递依赖的 active feature 集变化跨"就地重建"未触发该依赖重编(e2e 127 control 需 `clean` 才对) | fingerprint 未覆盖传递 feature 态 → 登记为候选后续(非本批引入的正确性回归:首次干净构建正确,只是增量重建缓存过旧)。 | + +### 4.5 issue↔实现映射完整性 + +§1 每行均有 commit + 状态如实;#238 明确标 ⛔(根因在 xlings),未冒充已修。✅ + +### 4.6 已执行的架构优化(review 头号建议) + +**把 feature 请求集收敛到唯一权威漏斗。** `DependencyEdge` 现携带 per-edge `requestedFeatures + defaultFeatures`;新 `aggregatedRequest(depPkgIndex)` 对某依赖包的**所有入边**做 union(features)/OR(default-features)(Cargo 菱形语义),feature 激活与依赖 build.mcpp env **共同消费**之。一次改动同时:(a) 修 #242 传递边 opt-out 丢失(commit `4449077`);(b) 退休 `prepare.cppm` 长期的"传递 dep→dep feature 请求未传播"限制;(c) 与 #243 设计的 `requestedFeaturesByPkg` 漏斗同向(#243 实现可直接在此之上做转发注入)。e2e 127 门控。 + +**结论**:本批全部修复均为根因级,无 workaround 冒充;唯一 review 抓到的真隐患(#242 传递边)已按"收敛到单漏斗"原则修掉而非打补丁。剩余为显式登记的后续项(§4.4),无静默遗留。 + +--- + +## 5. 执行与记账纪律 + +- 每落地一个 issue:更新 §1 对应行的状态 + commit,并在 CHANGELOG 追加(带 `#nnn`)。 +- 凡"根因在仓外 / 转后续"的,§1 标 ⛔ 并附外部 issue 链接,**不得**在本仓用 band-aid 标记已修。 +- 本批次的发布仍走既定链路:release 四平台 → 镜像 xlings-res(gh+gtc)→ xim-pkgindex 索引 → bump bootstrap pin;随后 mcpplibs #79 CI pin 升级解阻塞(#240 是 #79 的合并门)。 diff --git a/.agents/docs/2026-07-19-object-path-disambiguation-followups-239-240-design.md b/.agents/docs/2026-07-19-object-path-disambiguation-followups-239-240-design.md new file mode 100644 index 00000000..07a6753d --- /dev/null +++ b/.agents/docs/2026-07-19-object-path-disambiguation-followups-239-240-design.md @@ -0,0 +1,201 @@ +# #233 对象路径消歧的两个后续缺口(#240 / #239)——根因分析与统一修复方案 + +> 日期:2026-07-19 +> 基线:mcpp **0.0.97**(HEAD `05155ff`,`MCPP_VERSION = "0.0.97"` @ `src/toolchain/fingerprint.cppm`) +> 范围:GitHub issue **#240**(阻塞 mcpplibs #79 opencv 收录)+ **#239**(同批同区域回归) +> 全部锚点均在 0.0.97 源码 `src/build/plan.cppm` / `src/modgraph/scanner.cppm` 上核实,并以自托管构建产物做了**两份真实最小复现**。 + +--- + +## 0. 结论先行 + +0.0.97 的 #233 修复(commit `3b4f18e`,"mirror source relative path in object paths")把对象输出路径从 +`obj/_<父目录名>/.o` 改成 `obj///.o`,解决了"同父目录名不同深度"的 +折叠碰撞。但这次改动**只覆盖了 scanner 产出的编译单元**,遗漏了两条路径,构成两个 OPEN bug: + +| # | 症状 | 触发面 | 根因子系统 | +|---|------|--------|-----------| +| **#240** | `ninja: error: 'obj/main.o' … missing and no known rule` | 依赖包与消费者存在**同名源**(双方都有 `main.cpp`) | 链接输入端的 entry-main 对象路径**独立重算**,没跟随消歧 | +| **#239** | `obj//../…/gen.o`(逃逸出 `obj/`,甚至镜像整棵绝对路径树) | 依赖 `build.mcpp` 把生成源写进 **OUT_DIR**(绝对路径,在包根之外) | 消歧前缀直接取 `relPath.parent_path()`,含 `..`/绝对根时 `obj/` 向上逃逸 | + +两者**同源**:`#233` 把"对象路径 = 前缀 + 文件名"这件事拆散在两处计算(scanner 单元一处、link 端 entry-main 一处), +且前缀构造对"源在包根之外"的相对路径不做净化。修复思路统一为一句话: + +> **对象路径的分配收敛成一个函数,entry-main 走同一个函数;前缀构造对任意 relPath 保证"永远向下、绝不逃逸 `obj/`"。** + +修复面小、局部、对常见工程**零字节差异**(非碰撞、relPath 干净时输出与今天完全一致),可一次 PR / 一个版本(**0.0.98**)、多 commit 落地。 + +--- + +## 1. 复现(两份,均已在本机自托管产物上跑通) + +### 1.1 #240 —— 同名 `main.cpp` + +``` +mydep/ (kind=lib) src/main.cpp -> int dep_helper() +consumer/(kind=bin) src/main.cpp -> int main(){…} dep = { path=../mydep } + [modules] sources = ["src/**/*.cpp"] # main.cpp 被 glob 进来 → 被 scan +``` + +`mcpp build` → +``` +ninja: error: 'obj/main.o', needed by 'bin/consumer', missing and no known rule to make it +``` + +build.ninja 实况: +``` +build obj/mydep/src/main.o : cxx_object …/mydep/src/main.cpp # dep 的 main 消歧了 +build obj/consumer/src/main.o : cxx_object …/consumer/src/main.cpp # 消费者的 main 也消歧了 +build bin/consumer : link … obj/main.o obj/mydep/src/main.o # 但链接引用了消歧前的 obj/main.o +``` + +### 1.2 #239 —— 依赖 `build.mcpp` 生成的 OUT_DIR 绝对路径源 + +``` +gdep/ build.mcpp 把 gen.cpp 写进 out_dir()(= /target/.build-mcpp/deps/gdep@0.1.0/out) + 并 mcpp::generated("gen.cpp") +consumer/ src/gen.cpp(与 gdep 生成的 gen.cpp 同 basename → 触发消歧分支) +``` + +`mcpp build` → +``` +cc1plus: fatal error: opening output file + obj/gdep/../consumer/target/.build-mcpp/deps/gdep@0.1.0/out/gen.o: No such file or directory +``` + +对象路径里出现 `..`(relPath = `relative(绝对生成源, dep 包根)`),`obj/gdep/../…` 已经逃出 `obj/`; +若 `..` 更多(生成源与包根分处更远),归一化后会爬到根再下钻,等价于在 CWD 下**镜像整棵绝对路径树**。 + +--- + +## 2. 根因(file:line,0.0.97) + +### 2.1 #240 —— entry-main 对象路径独立重算,link 用了陈旧值 + +`src/build/plan.cppm`: + +- **消歧只发生在 scanner 单元循环**(L448–L484):`basenameCount` 仅统计 `topoOrder`;碰撞时 + `cu.object = obj / / / fname`,否则 `obj/fname`。 +- **entry-main 是在 link 组装时另造的**(L733–L784): + ```cpp + main_cu.object = std::filesystem::path("obj") / object_filename_for(*lu.entryMain, objExt); // L737 恒为 obj/.o + … + bool already = …; // main.cpp 若被 glob 进来则已在 compileUnits 里 + if (!already) plan.compileUnits.push_back(main_cu); + lu.objects.push_back(main_cu.object); // L784 —— 无论 already 与否,都 push 了 L737 的扁平路径 + ``` + 当消费者 `main.cpp` 被 glob 进 sources(几乎所有工程 `sources=["src/**/*.cpp"]`)且与依赖的 `main.cpp` + 同名时:scanner 已把它编到 `obj//src/main.o`,但 L784 仍把 `obj/main.o` 塞进链接输入 → 没有规则产出 → 报错。 + entry-main 也从未进入 `basenameCount` 普查,是消歧体系的一个盲区。 + +### 2.2 #239 —— 消歧前缀对包根外的 relPath 不做净化 + +`src/build/plan.cppm` L469–L474: +```cpp +if (basenameCount[fname] > 1) { + auto relDir = u.relPath.parent_path(); // 可能含 `..` 或为绝对 + auto prefix = u.packageName.empty() ? relDir + : std::filesystem::path(sanitize(u.packageName)) / relDir; + cu.object = std::filesystem::path("obj") / prefix / fname; // `obj/<..>/…` 逃逸 +} +``` +`relPath` 由 `scanner.cppm` L1005 `std::filesystem::relative(f, p.root)` 得到。依赖 `build.mcpp` 的生成源经 +`build_program.cppm` L563–L567 被改写成 **OUT_DIR 下的绝对路径**再进 `modules.sources`;它在包根之外, +`relative()` 于是产出 `../../…` 前缀。碰撞分支把它原样拼进 `obj/`,路径逃逸。 + +--- + +## 3. 修复方案(统一,局部) + +全部改动集中在 `src/build/plan.cppm` 的 `make_plan`,新增两个纯函数式的小工具,无跨文件/无 schema 变更。 + +### 3.1 前缀净化:`safe_object_prefix`(修 #239,并顺带护住 entry-main) + +把"包名 + relPath 目录部分"折叠成一个**永远向下**的子目录,对每个路径分量做单射映射: +- 根名 / 根目录(`/`、盘符)分量:丢弃(去绝对根); +- `.`:丢弃; +- `..`:映射为 `__up`(保留单射 → 不破坏 #233 的唯一性,除非真有目录名叫 `__up`,概率极低且有 §3.4 断言兜底); +- 其余分量:原样保留。 + +```cpp +// relPath 可能是绝对的或含 `..`(源在其包根之外 —— 如依赖 build.mcpp 的 OUT_DIR 生成源,#239)。 +// 直接拼进 obj/ 会让对象路径爬出构建树。把每个分量折叠成"安全且向下"的 token: +// 去根、丢 `.`、`..`→`__up`、其余保留。逐分量单射,保住 #233 的唯一性前提。 +std::filesystem::path safe_object_prefix(const std::string& pkg, + const std::filesystem::path& relDir) { + std::filesystem::path safe; + for (auto const& comp : relDir) { + if (comp.has_root_name() || comp.has_root_directory()) continue; // 去绝对根 + auto s = comp.string(); + if (s.empty() || s == ".") continue; + safe /= (s == ".." ? "__up" : s); + } + return pkg.empty() ? safe : std::filesystem::path(sanitize(pkg)) / safe; +} +``` +非碰撞路径不走此函数(仍是扁平 `obj/fname`);碰撞且 relPath 干净时,输出与今天**逐字节一致**(`__up` 只在有 `..` 时出现)。 + +### 3.2 对象路径分配收敛成一个 lambda:`object_for`(修 #240 的一半 + 复用) + +```cpp +auto object_for = [&](const std::filesystem::path& src, + const std::string& pkg, + const std::filesystem::path& relPath) -> std::filesystem::path { + const auto fname = object_filename_for(src, objExt); + if (basenameCount[fname] > 1) + return std::filesystem::path("obj") / safe_object_prefix(pkg, relPath.parent_path()) / fname; + return std::filesystem::path("obj") / fname; +}; +``` +scanner 单元循环(L459–L483)改用 `cu.object = object_for(u.path, u.packageName, u.relPath);`。 + +### 3.3 entry-main 进入普查、并复用已扫描单元的对象路径(修 #240 的另一半) + +1. **普查纳入 entry-main**:在 `basenameCount` 计算处(L448),把 root manifest 里 **binary/test 且带 `main`**、 + 且**尚未**被 scan(不在 `scannedSources`)的 entry 源也 `++` 计入。这样"消费者 main 未被 glob、但依赖有同名 main" + 的场景也能正确消歧。 +2. **link 端复用**(L777–L784)改为: + ```cpp + std::filesystem::path entryObject; + bool already = false; + for (auto& cu : plan.compileUnits) + if (cu.source == main_cu.source) { already = true; entryObject = cu.object; break; } + if (!already) { + main_cu.object = object_for(main_cu.source, main_cu.packageName, + std::filesystem::relative(main_cu.source, projectRoot)); + plan.compileUnits.push_back(main_cu); + entryObject = main_cu.object; + } + lu.objects.push_back(entryObject); // 永远用编译单元真实产出的对象 + ``` + - `already`(main 被 glob):直接复用 scanner 已消歧的 `cu.object` —— **这正是 #240 的直接修复**。 + - `!already`(main 只在 `main=` 里、未被 glob):走同一 `object_for`,与 dep 的同名 main 一致消歧。 + - L737 那句独立重算(`object_filename_for`)删除,不再是路径事实来源。 + +### 3.4 保留 #233 的唯一性断言(L486–L512) + +`safe_object_prefix` 逐分量单射 + entry-main 纳入同一分配器后,理论上不会再碰撞;L486 的"碰撞即报错"防御断言**保留原样**, +作为任何未预料输入的可诊断兜底(把 ninja 的 "multiple rules generate X" 变成点名两个源文件的 mcpp 错误)。 + +--- + +## 4. 影响面与兼容性 + +- **常见工程零差异**:单 binary、`main.cpp` 唯一 → 仍是扁平 `obj/main.o`,link 复用同一路径。e2e 117 现状(`main=src/main.cpp` 且唯一)不变。 +- **仅在发生 basename 碰撞时**行为改变,且改后才是正确的(今天是构建直接失败)。 +- 无 manifest schema、无 CLI、无跨平台字节差异(`safe_object_prefix` 用 `path` 迭代器,Windows 盘符走 `has_root_name` 分支)。 + +## 5. 提交拆分(单 PR / 0.0.98,多 commit) + +1. `test(e2e): add failing repros for #240 (same-named main) and #239 (OUT_DIR abs gen source)` —— 先落两个红测(TDD)。 +2. `fix(build): route entry-main object path through disambiguation; reuse scanned unit's object (#240)`。 +3. `fix(build): sanitize collision prefix so out-of-root/abs relPaths stay under obj/ (#239)`。 +4. `chore(release): bump mcpp 0.0.97 -> 0.0.98` + CHANGELOG。 + +> 发版后按既定链路:release 四平台 → 镜像 xlings-res(gh+gtc)→ xim-pkgindex 索引 → bump bootstrap pin;随后 mcpplibs #79 CI pin 升到 0.0.98 即可解阻塞。#242(消费端 default-features opt-out)、#243(依赖 feature 转发)为独立非阻塞项,不在本 PR。 + +## 6. 验证清单 + +- `mcpp test`(单测,含 ninja backend)全绿。 +- e2e:新增两测 + `tests/e2e/run_all.sh` host-aware 全过(至少 117/118 + 新两测)。 +- 手工:§1 两份复现均 `mcpp build && mcpp run` 成功,ninja 中 entry-main link 输入 = 其编译边真实对象;`obj/` 下无 `..`/绝对逃逸。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 928ce3c5..19b7cc72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,31 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [0.0.98] — 2026-07-19 + +> #230–#243 批次(单 PR 统一发布,逐 commit):#233 对象路径消歧的两个后续缺口(#239/#240,解阻塞 mcpplibs #79 opencv 收录)+ #237/#241/#242 根因级实现 + #238 mcpp 侧诊断(根因在 openxlings/xlings#374)+ #243 设计。总账 + 架构评估见 `.agents/docs/2026-07-19-issues-230-243-batch-ledger-and-architecture-assessment.md`;各设计文档见 `.agents/docs/2026-07-19-*`。 + +### 新增 + +- **`MCPP_DEP__DIR` build.mcpp 契约**(#241):包的 `build.mcpp` 现可经 `mcpp::dep_dir("")` 拿到依赖的安装目录(verdir/payload 根),不必再逆向 store 布局(实例:compat.opencv 的 `unifont` feature 读数据资产包 compat.opencv-unifont 的字体 blob)。用权威的 consumer→dep 边图注入,覆盖 feature 激活的依赖;canonical + short 双发(`dep_dir("compat.zlib")`/`dep_dir("zlib")` 皆可)、同款 sanitize、碰撞守卫、自动进 rerun hash。作用域为依赖侧 build.mcpp(root 工程 build.mcpp 早于依赖解析运行,列为后续项)。 +- **消费端 `default-features = false`**(#242):依赖 spec 支持关闭该依赖的默认 feature 集(`{ default-features = false, features = ["x"] }`),Cargo 平价。根因收敛在 `feature_closure` 的单一 `seedDefault` 门:关闭时不 seed 该依赖 `[features].default`,仅显式请求 + `implies` 激活;root 包/工程 build.mcpp 仍默认 seed。(挡住 compat.ffmpeg 裁剪档形态。) + +### 修复 + +- **消歧后链接输入未跟随改名,依赖与消费者同名源即挂**(#240):当依赖包与消费者存在同名源(近乎必现——双方都有 `src/main.cpp`,如 OpenCV 自带的 sample `main.cpp` × 消费者的入口)时,#233 已把被扫描的消费者 `main` 编到 `obj//src/main.o`,但链接步骤仍引用消歧前的扁平 `obj/main.o` → `ninja: error: 'obj/main.o' … missing and no known rule`。现将对象路径分配收敛为**单一来源**:被 glob 进来的入口复用其编译边已消歧的对象;未被 glob 的入口也纳入同一碰撞普查后消歧——链接输入与编译边永不背离。常见单二进制工程(`main` 唯一)仍为扁平 `obj/main.o`,字节不变。 +- **绝对/越根路径源的消歧对象逃逸出 `obj/`**(#239):依赖 `build.mcpp` 写进 OUT_DIR(`target/.build-mcpp/deps/@/out/`,在包根之外)的生成源,其 `relPath` 携带 `..`,#233 曾原样拼进 `obj//../…` 致对象路径爬出构建树(甚至在 CWD 镜像整棵绝对路径树);且路径里的 `@` 会被 ninja 单引号包裹,连带压垮 #235 的 `"$out.d"` depfile 重定向。现消歧前缀逐分量净化:去绝对根、`.` 丢弃、`..`→`__up`、非可移植字符(如 `@`)→`_`——对象永远向下且 shell 安全;逐分量单射,保住 #233 的唯一性(L1b 断言兜底残余)。 +- **xpkg 描述符 mcpp 段未知键 build 时静默忽略**(#237):`dependencies` 误写(正确键 `deps`)等未知键此前只有 `mcpp xpkg parse` 报,build 路径静默丢弃致依赖消失无诊断。现描述符被采纳为依赖时按键**响亮告警**并给 did-you-mean(封闭词表别名 + Levenshtein 回退);沿用 0.0.97 封闭文法先例,告警而非硬错以保前向兼容。 +- **feature 请求集收敛到依赖边图(#242 传递边 + #241 命名;架构评估头号优化)**:此前 feature 请求集在解析(`mergeActiveFeatureDeps`,读 per-edge spec)与激活(`apply()`,只扫 root 直接依赖)两处独立推导且对**传递边**不自洽——传递依赖的请求 feature 与其消费者的 `default-features = false` 被静默丢弃(激活仍 seed 该依赖默认 feature,定义其宏/保留默认门控源,而解析已跳过)。现 `DependencyEdge` 携带 per-edge `requestedFeatures + defaultFeatures`,新 `aggregatedRequest` 对某依赖包所有入边做 union/OR(Cargo 菱形语义),激活与依赖 build.mcpp 共享之;直接依赖行为不变,顺带补掉长期的传递 feature 未传播缺口。e2e 127。 +- **多 index repo 下 `install_packages` 失败无诊断**(#238,**根因在 xlings**):裸 `fetch failed (exit 1)` 现重建为可操作诊断——点名目标、已配置 index repos 清单、`≥2` 仓的已知 xlings 解析缺口提示、保留子进程输出、`MCPP_VERBOSE=1` 看原始调用。**仅诊断改进**;多仓解析的根因修复须落在 openxlings/xlings(已开 **openxlings/xlings#374**)。 + +### 设计(未实现,后续 PR) + +- **#243 feature 依赖转发(`dep/feat`)**:核实条件依赖半边已存在(`[feature-deps]`/xpkg `features.x.deps`),真正缺口是转发;设计见 `.agents/docs/2026-07-19-issue-243-feature-forwarding-design.md`,收敛为单一 per-package 请求 feature 漏斗(顺带补 `prepare.cppm:2653` 传递性缺口),与 #242 opt-out 加性组合。 + +### 备注 + +- #230(windows workspace exit 127)已于 0.0.96 修复,待 mcpp-index windows CI pin ≥0.0.98 复验后关闭。 + ## [0.0.97] — 2026-07-18 > 架构级修复批次(单 PR,逐簇 commit):ffmpeg-m/opencv-m 全源码直编暴露的第二层缺口 + workspace 测试基建语法空洞。 diff --git a/mcpp.toml b/mcpp.toml index 06a1bef4..6791fbc4 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "0.0.97" +version = "0.0.98" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/build/build_program.cppm b/src/build/build_program.cppm index b05723a8..6a6a32f5 100644 --- a/src/build/build_program.cppm +++ b/src/build/build_program.cppm @@ -41,6 +41,13 @@ struct BuildProgramEnv { // root-project contract, unchanged). Dependencies point this at OUT_DIR so // a shared package root is never written to. std::filesystem::path genBase; + // mcpp#241: this package's resolved dependencies, as (name → dir) pairs. + // The caller emits each dep under BOTH its canonical package name and its + // short (namespace-stripped) name, so a build.mcpp can locate a dependency's + // payload (e.g. a data-asset package) via either spelling. Emitted as + // MCPP_DEP__DIR (same sanitizer as MCPP_FEATURE_) instead of + // reverse-engineering the store layout. + std::vector> depDirs; }; // Compile + run `/build.mcpp` (if present) with `hostCompiler` (the resolved @@ -248,6 +255,20 @@ inline bool has_feature(const char* name) { buf[o] = 0; return std::getenv(buf) != nullptr; } +// mcpp#241: resolved install dir of a declared dependency (by its package +// name), or "" if not found. Same sanitize as has_feature; wraps +// MCPP_DEP__DIR. +inline const char* dep_dir(const char* name) { + char buf[256] = "MCPP_DEP_"; + unsigned long o = 9; + for (const char* p = name; *p && o + 5 < sizeof buf; ++p, ++o) { + char c = *p; + buf[o] = (c >= 'a' && c <= 'z') ? char(c - 'a' + 'A') + : ((c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) ? c : '_'; + } + buf[o++] = '_'; buf[o++] = 'D'; buf[o++] = 'I'; buf[o++] = 'R'; buf[o] = 0; + return env_or(buf); +} } )CPP"; @@ -350,6 +371,25 @@ contract_env(const fs::path& root, const fs::path& outDir, const BuildProgramEnv e.emplace_back("MCPP_FEATURE_" + sanitize_feature_env(f), "1"); } e.emplace_back("MCPP_FEATURES", csv); + // mcpp#241: per-dependency payload dir, under MCPP_DEP__DIR + // (same sanitizer as MCPP_FEATURE_ — predictable from the manifest, not + // store internals). Two distinct dep names can sanitize to the same var + // (e.g. `foo.bar` vs `foo-bar`, or a bare `zlib` vs another dep's short + // `zlib`); guard so a silent last-wins can't hand one dep another's dir — + // keep the first and warn on a conflicting value. + std::map depVarValue; + for (auto const& [name, dir] : env.depDirs) { + auto var = "MCPP_DEP_" + sanitize_feature_env(name) + "_DIR"; + auto [it, inserted] = depVarValue.try_emplace(var, dir.string()); + if (inserted) { + e.emplace_back(var, dir.string()); + } else if (it->second != dir.string()) { + mcpp::ui::warning(std::format( + "build.mcpp: dependency name collides on {} (kept '{}', ignored " + "'{}') — rename one dependency to disambiguate", var, + it->second, dir.string())); + } + } return e; } diff --git a/src/build/plan.cppm b/src/build/plan.cppm index eb93a76d..bd6c872a 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -445,15 +445,80 @@ make_plan(const mcpp::manifest::Manifest& manifest, // files keep the pre-existing flat `obj/` layout untouched // (back-compat for the overwhelmingly common single-file-per- // basename project). + std::set scannedSources; std::map basenameCount; for (auto idx : topoOrder) { basenameCount[object_filename_for(graph.units[idx].path, objExt)]++; + scannedSources.insert(graph.units[idx].path); + } + // mcpp#240: entry `main` sources are synthesized into compile units later + // (during link assembly), NOT part of topoOrder — but they still occupy an + // object path and must share ONE disambiguation census with everything + // else. Count each root target's entry that isn't already scanned (a globbed + // main IS scanned, so counting it again would falsely disambiguate the + // common single-binary project). This makes "consumer main not globbed + + // dependency ships a same-named main" disambiguate correctly too. + for (auto& t : manifest.targets) { + if (t.main.empty()) continue; + if (t.kind != mcpp::manifest::Target::Binary + && t.kind != mcpp::manifest::Target::TestBinary) continue; + auto entry = projectRoot / t.main; + if (scannedSources.contains(entry)) continue; + basenameCount[object_filename_for(entry, objExt)]++; } auto sanitize = [](const std::string& s) { std::string out; out.reserve(s.size()); for (char c : s) out += (c == '.' || c == '/' ? '_' : c); return out; }; + // mcpp#239: fold a source's package-relative directory into an object + // subdir that is ALWAYS downward AND shell-safe. relPath may be absolute or + // carry `..` when the source lives outside its package root (e.g. a + // dependency build.mcpp's OUT_DIR-generated source under + // `.../@/out/`) — pasting it straight into `obj/` both climbs + // out of the build tree AND drags shell-hostile chars (the `@` in the deps + // dir) into the object path, which ninja then single-quotes, breaking the + // #235 `"$out.d"` depfile redirect. Map each component: drop the root + // (`/`, drive) and `.`, turn `..` into `__up`, and replace any char outside + // the portable set `[A-Za-z0-9._+-]` with `_`. The mapping is injective + // enough to preserve the uniqueness the relPath-mirroring scheme (mcpp#233) + // relies on (the L1b assertion backstops the residual). + auto safe_component = [](std::string s) { + if (s == "..") return std::string("__up"); + for (auto& c : s) { + const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') + || c == '.' || c == '_' || c == '+' || c == '-'; + if (!ok) c = '_'; + } + return s; + }; + auto safe_object_prefix = [&](const std::string& pkg, + const std::filesystem::path& relDir) + -> std::filesystem::path { + std::filesystem::path safe; + for (auto const& comp : relDir) { + if (comp.has_root_name() || comp.has_root_directory()) continue; + auto s = comp.string(); + if (s.empty() || s == ".") continue; + safe /= safe_component(s); + } + return pkg.empty() ? safe + : std::filesystem::path(sanitize(pkg)) / safe; + }; + // mcpp#233/#240: the single source of truth for a compile unit's object + // path — scanned units AND the synthesized entry main go through here, so + // the link input can never diverge from the compile edge. + auto object_for = [&](const std::filesystem::path& src, + const std::string& pkg, + const std::filesystem::path& relPath) + -> std::filesystem::path { + const auto fname = object_filename_for(src, objExt); + if (basenameCount[fname] > 1) + return std::filesystem::path("obj") + / safe_object_prefix(pkg, relPath.parent_path()) / fname; + return std::filesystem::path("obj") / fname; + }; // 1. Compile units in topological order for (auto idx : topoOrder) { @@ -465,16 +530,7 @@ make_plan(const mcpp::manifest::Manifest& manifest, cu.packageCflags = u.packageCflags; cu.packageCxxflags = u.packageCxxflags; cu.packageAsmflags = u.packageAsmflags; - const auto fname = object_filename_for(u.path, objExt); - if (basenameCount[fname] > 1) { - auto relDir = u.relPath.parent_path(); - auto prefix = u.packageName.empty() - ? relDir - : std::filesystem::path(sanitize(u.packageName)) / relDir; - cu.object = std::filesystem::path("obj") / prefix / fname; - } else { - cu.object = std::filesystem::path("obj") / fname; - } + cu.object = object_for(u.path, u.packageName, u.relPath); if (u.provides) { cu.providesModule = u.provides->logicalName; } @@ -731,10 +787,13 @@ make_plan(const mcpp::manifest::Manifest& manifest, bool entryDefinesMain = lu.entryMain && source_defines_main(*lu.entryMain); if ((lu.kind == LinkUnit::Binary || lu.kind == LinkUnit::TestBinary) && lu.entryMain) { - // Add main.cpp -> obj/main.o + // Synthesize the entry main's compile unit. Its object path is + // NOT computed here — it comes from the shared `object_for` + // disambiguator below, so the link input matches the compile edge + // even when the entry collides with a dependency's same-named + // source (mcpp#240). CompileUnit main_cu; main_cu.source = *lu.entryMain; - main_cu.object = std::filesystem::path("obj") / object_filename_for(*lu.entryMain, objExt); main_cu.packageName = qualified_package_name(manifest); if (!packages.empty() && packages[0].usageResolved) { main_cu.localIncludeDirs = packages[0].privateBuild.includeDirs; @@ -773,15 +832,30 @@ make_plan(const mcpp::manifest::Manifest& manifest, } } - // Avoid duplicate insert if main was already scanned + // mcpp#240: the entry main may ALSO have been scanned (globbed into + // [modules].sources — the near-universal `src/**/*.cpp`). When it + // was, reuse THAT compile unit's already-disambiguated object; the + // synthesized main_cu is a duplicate and must not be linked under a + // divergent (flat) path. When it wasn't scanned, route the + // synthesized unit through the same `object_for` census so it, too, + // disambiguates against any same-named dependency source. + std::filesystem::path entryObject; bool already = false; for (auto& cu : plan.compileUnits) { - if (cu.source == main_cu.source) { already = true; break; } + if (cu.source == main_cu.source) { + already = true; + entryObject = cu.object; + break; + } } if (!already) { + main_cu.object = object_for( + main_cu.source, main_cu.packageName, + std::filesystem::relative(main_cu.source, projectRoot)); plan.compileUnits.push_back(main_cu); + entryObject = main_cu.object; } - lu.objects.push_back(main_cu.object); + lu.objects.push_back(entryObject); // Per-target entry-scoped flags (issue #131). Applied to the compile // unit that actually builds this target's entry — which may be the diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 64b2bdd3..e647fbc2 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -49,6 +49,29 @@ import mcpp.project; namespace mcpp::build { +// mcpp#237: surface xpkg-descriptor mcpp-segment keys this mcpp did not +// recognise. The parser collects them into `xpkgUnknownKeys` and skips the +// value; without this a typo like `dependencies = {...}` (correct key: `deps`) +// dropped the dependency with no diagnostic. Called at the descriptor-adoption +// sites (a fetched dep with no mcpp.toml, synthesized from the index `mcpp={}` +// block) — the single place the descriptor becomes a build input. Warning (not +// hard error) keeps forward-compat: an older mcpp building a newer descriptor +// should not fail outright, only tell the user what it ignored. +inline void warn_unknown_xpkg_keys(const mcpp::manifest::Manifest& dm, + std::string_view depLabel) { + for (auto const& key : dm.xpkgUnknownKeys) { + auto suggestion = mcpp::manifest::closest_known_xpkg_key(key); + if (suggestion.empty()) + mcpp::ui::warning(std::format( + "dependency '{}': unknown mcpp-segment key '{}' in its xpkg " + "descriptor — ignored (schema mismatch or typo)", depLabel, key)); + else + mcpp::ui::warning(std::format( + "dependency '{}': unknown mcpp-segment key '{}' in its xpkg " + "descriptor — ignored; did you mean '{}'?", depLabel, key, suggestion)); + } +} + // ── L1 platform-conditional config: cfg() predicate evaluation ────────────── // Context = the RESOLVED target's coordinates. A `[target.'cfg(...)'.build]` // predicate is evaluated against this (target triple for a cross build, host @@ -381,12 +404,21 @@ void merge_conditional_sources_flags(mcpp::manifest::Manifest& m, // contract, Stage 2a feature-deps, and the main feature pass all call this): // seed = [features].default ∪ requested, expanded transitively over implies; // the literal name "default" is never itself a feature. +// +// `seedDefault` is the funnel for consumer-side `default-features = false` +// (#242, Cargo parity): when false the dependency's own `[features].default` +// is NOT seeded, so only the explicitly `requested` features (and their +// transitive `implies`) activate. The root package always seeds its own +// default (seedDefault=true); a dependency passes its dep spec's +// `defaultFeatures` flag. `requested` is applied identically either way. std::vector feature_closure(const mcpp::manifest::Manifest& pm, - const std::vector& requested) + const std::vector& requested, + bool seedDefault = true) { 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()); + if (seedDefault) + 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()) { @@ -1578,6 +1610,7 @@ prepare_build(bool print_fingerprint, return std::unexpected(std::format( "dependency '{}': {}", depName, depManifest.error().format())); } + warn_unknown_xpkg_keys(*depManifest, depName); auto preinstallKey = std::format("{}:{}@{}", ns, shortName, version); if (preinstallStack.contains(preinstallKey)) { @@ -1633,6 +1666,9 @@ prepare_build(bool print_fingerprint, : std::format("{}.{}", ns, shortName); mcpp::ui::info("Downloading", std::format("{} v{}", fqname, version)); + // #238: retain whatever error/warn text the child DID emit so we + // can fold it into a diagnostic if install_packages exits non-zero. + std::string capturedChildError; auto install_one = [&](std::string target) -> std::expected { if (useProjectEnv) { // Project/custom-index deps install into the project-local @@ -1651,12 +1687,15 @@ prepare_build(bool print_fingerprint, mcpp::fetcher::InstallProgressHandler progress; auto r = mcpp::xlings::call( projEnv, "install_packages", argsJson, &progress); + capturedChildError = progress.captured_error(); if (!r) return std::unexpected(mcpp::pm::CallError{r.error()}); return *r; } std::vector targets{ std::move(target) }; mcpp::fetcher::InstallProgressHandler progress; - return fetcher.install(targets, &progress); + auto r = fetcher.install(targets, &progress); + capturedChildError = progress.captured_error(); + return r; }; auto target = std::format("{}@{}", fqname, version); // For custom indices, use indexName:fullPackageName@version so @@ -1678,10 +1717,27 @@ prepare_build(bool print_fingerprint, if (!r) return std::unexpected(std::format( "fetch '{}@{}': {}", depName, version, r.error().message)); if (r->exitCode != 0) { - std::string err = std::format( - "fetch '{}@{}' failed (exit {})", depName, version, r->exitCode); - if (r->error) err += ": " + r->error->message; - return std::unexpected(err); + // #238: the opaque `fetch failed (exit 1)` hid the actionable + // context mcpp actually has. Reconstruct it: the target, the + // configured index repos (read back from the seeded + // .xlings.json — project scope when useProjectEnv, else the + // global xlings home), any child error text we captured, plus + // a hint about the known ≥2-repo xlings resolution gap. The + // real fix lives in openxlings/xlings; this only surfaces WHY. + auto xlingsJson = (useProjectEnv + ? (*root / ".mcpp") + : (*cfg)->xlingsHome()) + / ".xlings.json"; + auto indexRepos = mcpp::pm::read_seeded_index_repos(xlingsJson); + std::string childErr = capturedChildError; + if (r->error) { + if (!childErr.empty()) childErr += "; "; + childErr += r->error->message; + } + auto target = std::format("{}@{}", depName, version); + return std::unexpected( + mcpp::pm::format_install_failure_diagnostic( + target, r->exitCode, indexRepos, childErr)); } // After install, check project data first for custom index packages. installed = findRawInstalled(); @@ -1728,6 +1784,7 @@ prepare_build(bool print_fingerprint, auto dm = mcpp::manifest::synthesize_from_xpkg_lua(*luaContent, depName, version); if (!dm) return std::unexpected(std::format( "dependency '{}': {}", depName, dm.error().format())); + warn_unknown_xpkg_keys(*dm, depName); manifest = std::move(*dm); // effRoot stays as verRoot } else { @@ -1794,6 +1851,14 @@ prepare_build(bool print_fingerprint, std::size_t dependencyPackageIndex = 0; mcpp::modgraph::DependencyVisibility visibility = mcpp::modgraph::DependencyVisibility::Public; + // #242/#243: the per-edge feature request that THIS consumer made of + // THIS dependency. Feature activation must consume these off the edge + // graph (union over all incoming edges) rather than re-scanning only + // the root manifest's direct deps — otherwise a transitive dep's + // requested features and its consumer's `default-features = false` are + // silently dropped (resolution honors them per-edge; activation did not). + std::vector requestedFeatures; + bool defaultFeatures = true; }; std::vector dependencyEdges; @@ -1904,6 +1969,8 @@ prepare_build(bool print_fingerprint, .consumerPackageIndex = consumerPackageIndex, .dependencyPackageIndex = dependencyPackageIndex, .visibility = visibility, + .requestedFeatures = spec.features, + .defaultFeatures = spec.defaultFeatures, }); }; @@ -2066,17 +2133,21 @@ prepare_build(bool print_fingerprint, // 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) { - return feature_closure(pm, requested); // single shared implementation + const std::vector& requested, + bool seedDefault = true) { + return feature_closure(pm, requested, seedDefault); // single shared implementation }; // 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. + // when that feature is active. `seedDefault` carries consumer-side + // `default-features = false` (#242): when a consumer opts out of this dep's + // default set, feature-deps behind the default pseudo-feature stay dormant. auto mergeActiveFeatureDeps = [&](mcpp::manifest::Manifest& pm, - const std::vector& requested) { + const std::vector& requested, + bool seedDefault = true) { if (pm.featureDeps.empty()) return; - for (auto& f : activateFeatures(pm, requested)) { + for (auto& f : activateFeatures(pm, requested, seedDefault)) { auto it = pm.featureDeps.find(f); if (it == pm.featureDeps.end()) continue; for (auto& [k, spec] : it->second) pm.dependencies.try_emplace(k, spec); @@ -2572,7 +2643,7 @@ prepare_build(bool print_fingerprint, // 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); + mergeActiveFeatureDeps(*dep_manifest, spec.features, spec.defaultFeatures); auto linkFlagsAdded = propagateLinkFlags(dep_root, *dep_manifest); @@ -2642,12 +2713,14 @@ prepare_build(bool print_fingerprint, return f; }; auto activate = [](const mcpp::manifest::Manifest& pm, - const std::vector& requested) { - return feature_closure(pm, requested); // single shared implementation + const std::vector& requested, + bool seedDefault = true) { + return feature_closure(pm, requested, seedDefault); // single shared implementation }; auto apply = [&](mcpp::modgraph::PackageRoot& pkg, - const std::vector& requested) { - auto active = activate(pkg.manifest, requested); + const std::vector& requested, + bool seedDefault = true) { + auto active = activate(pkg.manifest, requested, seedDefault); // 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. @@ -2773,12 +2846,32 @@ prepare_build(bool print_fingerprint, apply(packages[0], rootReq); for (auto& f : activate(*m, rootReq)) activeRootFeatures.insert(f); } + // #242/#243: the feature request for a dependency PACKAGE, aggregated + // over ALL its incoming edges (a package may be depended on by several + // consumers — diamond — or reached only transitively). Cargo semantics: + // requested features UNION; default-features stays on unless EVERY + // consumer opted out. Sourcing this from the authoritative edge graph — + // rather than scanning only the root manifest's direct deps — makes + // activation AGREE with resolution (mergeActiveFeatureDeps, which reads + // the true per-edge spec): a transitive dep's requested features and its + // consumer's `default-features = false` are no longer silently dropped. + auto aggregatedRequest = [&](std::size_t depPkgIndex) + -> std::pair, bool> { + std::vector feats; + bool anyEdge = false, anyDefault = false; + for (auto const& edge : dependencyEdges) { + if (edge.dependencyPackageIndex != depPkgIndex) continue; + anyEdge = true; + if (edge.defaultFeatures) anyDefault = true; + for (auto const& f : edge.requestedFeatures) + if (std::find(feats.begin(), feats.end(), f) == feats.end()) + feats.push_back(f); + } + return { std::move(feats), anyEdge ? anyDefault : true }; + }; for (std::size_t i = 1; i < packages.size(); ++i) { auto& pname = packages[i].manifest.package.name; - std::vector req; - for (auto& [dname, dspec] : m->dependencies) { - if (dname == pname || dspec.shortName == pname) { req = dspec.features; break; } - } + auto [req, depDefaultFeatures] = aggregatedRequest(i); if (!req.empty() && !packages[i].manifest.featuresMap.empty()) { for (auto& f : req) { if (packages[i].manifest.featuresMap.contains(f)) continue; @@ -2791,7 +2884,9 @@ prepare_build(bool print_fingerprint, } // Always apply: even with no requested/default feature, a dep with // feature-gated sources must have those sources dropped by default. - apply(packages[i], req); + // depDefaultFeatures carries the consumer's `default-features = false` + // (#242): when opted out, the dep's [features].default is not seeded. + apply(packages[i], req, depDefaultFeatures); } // ── G2: dependency build.mcpp (Cargo build.rs model) ──────────────── @@ -2809,14 +2904,10 @@ prepare_build(bool print_fingerprint, if (!std::filesystem::exists(pkg.root / "build.mcpp", bpEc)) continue; auto host = host_tc_for_build_program(); if (!host) return std::unexpected(host.error()); - std::vector req; - for (auto& [dname, dspec] : m->dependencies) { - if (dname == pkg.manifest.package.name - || dspec.shortName == pkg.manifest.package.name) { - req = dspec.features; - break; - } - } + // Same edge-graph aggregation as feature activation above, so a + // dep build.mcpp sees the SAME active feature set the dep is built + // with (incl. transitive requests / default-features opt-out). + auto [req, depDefaultFeatures] = aggregatedRequest(i); auto dirSafe = [](std::string s) { for (auto& c : s) if (c == '/' || c == '\\' || c == ':') c = '_'; return s; @@ -2824,10 +2915,30 @@ prepare_build(bool print_fingerprint, mcpp::build::BuildProgramEnv bpEnv; bpEnv.targetTriple = resolvedTargetCanonical; bpEnv.profile = effectiveProfile; - bpEnv.features = feature_closure(pkg.manifest, req); + bpEnv.features = feature_closure(pkg.manifest, req, depDefaultFeatures); bpEnv.artifactsDir = *root / "target" / ".build-mcpp" / "deps" / (dirSafe(pkg.manifest.package.name) + "@" + pkg.manifest.package.version); bpEnv.genBase = bpEnv.artifactsDir / "out"; + // mcpp#241: expose this package's resolved dependencies (verdir / + // payload root) as MCPP_DEP__DIR. Uses the authoritative + // consumer→dep edge graph (no name-guessing); covers feature- + // activated deps too (mergeActiveFeatureDeps folded them into + // `dependencies` before the edges were recorded). A dep is emitted + // under BOTH its canonical package name AND its namespace-stripped + // short name, so `mcpp::dep_dir("compat.zlib")` and + // `mcpp::dep_dir("zlib")` both resolve regardless of which spelling + // the author used in `deps`. (The ROOT project's own build.mcpp runs + // before dependency resolution, so it does not yet receive these — + // tracked as a follow-up in the #230-#243 ledger.) + for (auto const& edge : dependencyEdges) { + if (edge.consumerPackageIndex != i) continue; + auto const& depPkg = packages[edge.dependencyPackageIndex]; + const auto& canon = depPkg.manifest.package.name; + bpEnv.depDirs.emplace_back(canon, depPkg.root); + if (auto dot = canon.rfind('.'); dot != std::string::npos + && dot + 1 < canon.size()) + bpEnv.depDirs.emplace_back(canon.substr(dot + 1), depPkg.root); + } auto& bcDep = pkg.manifest.buildConfig; const auto cN = bcDep.cflags.size(), cxN = bcDep.cxxflags.size(), ldN = bcDep.ldflags.size(); diff --git a/src/fetcher/progress.cppm b/src/fetcher/progress.cppm index 71472659..35198174 100644 --- a/src/fetcher/progress.cppm +++ b/src/fetcher/progress.cppm @@ -208,6 +208,24 @@ export mcpp::config::BootstrapProgressCallback make_bootstrap_progress_callback( export struct InstallProgressHandler : mcpp::fetcher::EventHandler { mcpp::ui::DownloadProgress progress_; + // #238: retain any error/warn text the child DID emit so the caller can + // fold it into the install_packages failure diagnostic. The known + // multi-repo failure mode emits {"exitCode":1} with no error event, so + // this is usually empty — but when xlings does say something, we must not + // discard it (the whole point of the diagnostics work). + std::vector capturedDiagnostics_; + + // Join the captured error/warn lines into a single blob (empty if the + // child emitted nothing structured). + std::string captured_error() const { + std::string out; + for (auto& d : capturedDiagnostics_) { + if (!out.empty()) out += "; "; + out += d; + } + return out; + } + void on_data(const mcpp::fetcher::DataEvent& d) override { if (d.dataKind != "download_progress") return; auto files = parse_all_install_files(d.payloadJson); @@ -218,12 +236,15 @@ export struct InstallProgressHandler : mcpp::fetcher::EventHandler { } void on_log(const mcpp::fetcher::LogEvent& e) override { - if (e.level == "error") + if (e.level == "error") { mcpp::log::error("xlings", e.message); - else if (e.level == "warn") + capturedDiagnostics_.push_back(std::format("[error] {}", e.message)); + } else if (e.level == "warn") { mcpp::log::warn("xlings", e.message); - else + capturedDiagnostics_.push_back(std::format("[warn] {}", e.message)); + } else { mcpp::log::info("xlings", e.message); + } mcpp::log::verbose("xlings", std::format("[{}] {}", e.level, e.message)); } @@ -231,6 +252,10 @@ export struct InstallProgressHandler : mcpp::fetcher::EventHandler { mcpp::log::error("xlings", std::format("{}: {}", e.code, e.message)); if (!e.hint.empty()) mcpp::log::info("xlings", std::format("hint: {}", e.hint)); + std::string blob = e.code.empty() ? e.message + : std::format("{}: {}", e.code, e.message); + if (!e.hint.empty()) blob += std::format(" (hint: {})", e.hint); + capturedDiagnostics_.push_back(std::move(blob)); } // progress_'s own destructor finishes the active bar. diff --git a/src/manifest/toml.cppm b/src/manifest/toml.cppm index 6fe2aecd..a24b4dd7 100644 --- a/src/manifest/toml.cppm +++ b/src/manifest/toml.cppm @@ -423,7 +423,8 @@ std::expected parse_string(std::string_view content, auto is_dep_spec_key = [](std::string_view k) { return k == "path" || k == "version" || k == "git" || k == "rev" || k == "tag" || k == "branch" - || k == "features" || k == "workspace" || k == "visibility" + || k == "features" || k == "default-features" + || k == "workspace" || k == "visibility" || k == "backend"; }; auto looks_like_inline_dep_spec = [&](const t::Table& sub) { @@ -456,6 +457,13 @@ std::expected parse_string(std::string_view content, for (auto& fv : it->second.as_array()) if (fv.is_string()) spec.features.push_back(fv.as_string()); } + // `default-features = false` — consumer opts out of the dependency's + // own `[features].default` seed (Cargo parity). Explicitly requested + // `features = [...]` still activate. Threaded into feature_closure so + // the default pseudo-feature is not seeded for this dependency. + if (auto it = sub.find("default-features"); it != sub.end() && it->second.is_bool()) { + spec.defaultFeatures = it->second.as_bool(); + } // `backend = ""` — sugar for requesting the dependency's // `backend-` feature (library-level backend selection knob). if (auto it = sub.find("backend"); it != sub.end() && it->second.is_string()) { @@ -513,7 +521,7 @@ std::expected parse_string(std::string_view content, if (!looks_like_inline_dep_spec(sub)) { return std::unexpected(error(origin, std::format( "[{}.{}] must be a version string or table of " - "(path/version/git/rev/tag/branch/features/visibility)", + "(path/version/git/rev/tag/branch/features/default-features/visibility)", section, key))); } if (auto r = fill_inline_spec(spec, section, key, sub); !r) return r; diff --git a/src/manifest/xpkg.cppm b/src/manifest/xpkg.cppm index d810e579..4eca32c3 100644 --- a/src/manifest/xpkg.cppm +++ b/src/manifest/xpkg.cppm @@ -95,10 +95,76 @@ std::expected synthesize_from_xpkg_lua(std::string_view luaContent, std::string_view packageName, std::string_view packageVersion); + +// mcpp#237: the mcpp-segment key vocabulary is a CLOSED whitelist (the parse +// loop's else-if chain). An unrecognised key is collected into +// `Manifest::xpkgUnknownKeys` and silently skipped — historically only `mcpp +// xpkg parse` surfaced it, so a descriptor written with `dependencies = {...}` +// (the real key is `deps`) built with the dependency silently dropped and NO +// diagnostic. `closest_known_xpkg_key` maps an unknown key back to its most +// likely intended key (well-known confusables like dependencies→deps, plus a +// small edit-distance fallback for typos); returns "" when nothing is close. +// The build-time warning that consumes it lives at the descriptor-adoption +// sites in prepare.cppm (which owns the ui layer) — the loud-failure principle +// (0.0.97 closed-grammar guard) extended to the xpkg build path. +std::string closest_known_xpkg_key(std::string_view unknownKey); } // namespace mcpp::manifest namespace mcpp::manifest { +// mcpp#237: the closed vocabulary of mcpp-segment keys — MUST stay in sync with +// the else-if chain in the parse loop below (any `key == "X"` handled there). +// Kept as one array so the did-you-mean suggester speaks the same vocabulary +// the parser accepts. +inline constexpr std::string_view kKnownXpkgKeys[] = { + "cflags", "c_standard", "cxxflags", "deps", "features", "flags", + "generated_files", "import_std", "include_dirs", "language", "ldflags", + "linux", "macosx", "modules", "provides", "runtime", "scan_overrides", + "schema", "sources", "target_cfg", "targets", "windows", +}; + +// Well-known confusables an edit-distance metric cannot bridge (the intended +// key is spelled very differently from what the user wrote). +inline constexpr std::pair kXpkgKeyAliases[] = { + { "dependencies", "deps" }, + { "dependency", "deps" }, + { "requires", "deps" }, // cargo muscle memory + { "define", "flags" }, + { "defines", "flags" }, + { "feature", "features" }, + { "source", "sources" }, + { "target", "targets" }, + { "include", "include_dirs" }, + { "include_dir", "include_dirs" }, +}; + +std::string closest_known_xpkg_key(std::string_view unknownKey) { + for (auto const& [wrong, right] : kXpkgKeyAliases) + if (unknownKey == wrong) return std::string(right); + + // Small Levenshtein for single/double typos of a real key. + auto edit_distance = [](std::string_view a, std::string_view b) { + std::vector prev(b.size() + 1), cur(b.size() + 1); + for (std::size_t j = 0; j <= b.size(); ++j) prev[j] = j; + for (std::size_t i = 1; i <= a.size(); ++i) { + cur[0] = i; + for (std::size_t j = 1; j <= b.size(); ++j) { + std::size_t sub = prev[j - 1] + (a[i - 1] == b[j - 1] ? 0 : 1); + cur[j] = std::min({ sub, prev[j] + 1, cur[j - 1] + 1 }); + } + std::swap(prev, cur); + } + return prev[b.size()]; + }; + std::string_view best; + std::size_t bestDist = 3; // require distance ≤ 2 to suggest anything + for (auto known : kKnownXpkgKeys) { + auto d = edit_distance(unknownKey, known); + if (d < bestDist) { bestDist = d; best = known; } + } + return std::string(best); +} + // ===================================================================== // synthesize_from_xpkg_lua — parse mcpp = {} segment from an xpkg .lua diff --git a/src/pm/dep_spec.cppm b/src/pm/dep_spec.cppm index e0a21292..b12c0498 100644 --- a/src/pm/dep_spec.cppm +++ b/src/pm/dep_spec.cppm @@ -40,6 +40,9 @@ struct DependencySpec { std::string gitRefKind; // "rev" / "tag" / "branch" (for clarity) std::string visibility = "public"; // public / private / interface std::vector features; // requested feature set (long-form dep spec) + bool defaultFeatures = true; // consumer opt-out: `default-features = false` + // suppresses the dep's own [features].default seed + // (Cargo parity). Explicit `features = [...]` still apply. std::vector candidates; // ordered lookup candidates bool inheritWorkspace = false; // .workspace = true diff --git a/src/pm/package_fetcher.cppm b/src/pm/package_fetcher.cppm index fedbce32..64a47e4f 100644 --- a/src/pm/package_fetcher.cppm +++ b/src/pm/package_fetcher.cppm @@ -178,6 +178,39 @@ private: std::string_view argsJson) const; }; +// ─── install_packages failure diagnostics (#238) ──────────────────── +// +// When `xlings install_packages` exits non-zero, mcpp historically reported +// only `fetch '@' failed (exit 1)`. The known #238 failure mode — +// ≥2 project-level index_repos (root [indices] inheritance + a default-ns +// redirect, per #224) making the xlings resolver silently fail — left the +// user with an opaque exit code and no next step. The child emits +// {"exitCode":1,"kind":"result"} with NO error event (see +// mcpp.fetcher.progress), so mcpp must reconstruct actionable context from +// what IT knows: the target, the configured index repos, and whatever the +// child DID log. The two helpers below are pure/self-contained so the message +// shape is unit-testable without a live xlings. + +// Read the `index_repos` entry names out of a seeded `.xlings.json`. Returns +// the entries as "name" (or "name -> url" when a url is present). Missing or +// unreadable file yields an empty vector — callers treat "unknown" gracefully. +std::vector +read_seeded_index_repos(const std::filesystem::path& xlingsJson); + +// Compose the enriched, actionable error for an install_packages exit!=0. +// target : "@" (or index-scoped "idx:ns.name@ver") +// exitCode : the child's non-zero exit +// indexRepos : names/identities of the configured index repos (see reader) +// childError : any error/warn text the child DID emit (may be empty) +// When ≥2 repos are configured the message flags the known xlings-side gap +// and links the tracking issue; otherwise it still names the target, the repo +// set, and points at MCPP_VERBOSE=1 for the raw xlings output. +std::string +format_install_failure_diagnostic(std::string_view target, + int exitCode, + std::span indexRepos, + std::string_view childError = {}); + } // namespace mcpp::pm namespace mcpp::pm { @@ -203,6 +236,125 @@ std::string Fetcher::build_command(std::string_view capability, return mcpp::xlings::build_interface_command(env, capability, argsJson); } +// ─── install_packages failure diagnostics (#238) ──────────────────── + +std::vector +read_seeded_index_repos(const std::filesystem::path& xlingsJson) +{ + std::vector out; + std::error_code ec; + if (!std::filesystem::exists(xlingsJson, ec)) return out; + std::ifstream in(xlingsJson, std::ios::binary); + if (!in) return out; + std::string text((std::istreambuf_iterator(in)), + std::istreambuf_iterator()); + + // The array is emitted by seed_xlings_json as: + // "index_repos": [ { "name": "..", "url": ".." }, ... ] + // Parse just the slice between "index_repos": [ ... matching ] and pull + // each object's name/url. Cheap hand-roll (matches the module's existing + // manual-JSON style); tolerant of missing url. + auto key = text.find("\"index_repos\""); + if (key == std::string::npos) return out; + auto lb = text.find('[', key); + if (lb == std::string::npos) return out; + // find matching ']' at the same bracket depth. + std::size_t p = lb + 1; + int depth = 1; + std::size_t rb = std::string::npos; + bool in_str = false; + for (; p < text.size(); ++p) { + char c = text[p]; + if (in_str) { + if (c == '\\' && p + 1 < text.size()) { ++p; continue; } + if (c == '"') in_str = false; + continue; + } + if (c == '"') in_str = true; + else if (c == '[') ++depth; + else if (c == ']') { if (--depth == 0) { rb = p; break; } } + } + if (rb == std::string::npos) return out; + std::string_view arr(text.data() + lb + 1, rb - lb - 1); + + // Whitespace-tolerant field reader: seed_xlings_json PRETTY-prints the + // objects (`"name": "value"` — space after the colon), whereas the shared + // extract_string assumes compact NDJSON (`"name":"value"`). Reusing it here + // silently matched nothing. Read `"key"` then skip ws/`:` then the quoted + // value ourselves. (Repo names/urls carry no escaped quotes.) + auto field = [](std::string_view obj, std::string_view key) -> std::string { + std::string needle = std::format("\"{}\"", key); + auto p = obj.find(needle); + if (p == std::string_view::npos) return ""; + p += needle.size(); + while (p < obj.size() && (obj[p] == ' ' || obj[p] == '\t' || obj[p] == ':')) + ++p; + if (p >= obj.size() || obj[p] != '"') return ""; + ++p; + std::string out; + while (p < obj.size() && obj[p] != '"') out.push_back(obj[p++]); + return out; + }; + + // Walk each { ... } object inside the array. + std::size_t q = 0; + while (q < arr.size()) { + auto ob = arr.find('{', q); + if (ob == std::string_view::npos) break; + auto oe = arr.find('}', ob); + if (oe == std::string_view::npos) break; + auto obj = arr.substr(ob, oe - ob + 1); + auto name = field(obj, "name"); + auto url = field(obj, "url"); + if (!name.empty()) { + out.push_back(url.empty() ? name + : std::format("{} -> {}", name, url)); + } + q = oe + 1; + } + return out; +} + +std::string +format_install_failure_diagnostic(std::string_view target, + int exitCode, + std::span indexRepos, + std::string_view childError) +{ + std::string repoList; + for (std::size_t i = 0; i < indexRepos.size(); ++i) { + if (i) repoList += ", "; + repoList += indexRepos[i]; + } + + std::string msg = std::format( + "xlings install_packages failed (exit {}) for '{}'", exitCode, target); + + if (indexRepos.empty()) { + msg += " with an unknown index-repo configuration"; + } else { + msg += std::format(" with {} index repo{} configured [{}]", + indexRepos.size(), + indexRepos.size() == 1 ? "" : "s", + repoList); + } + + if (indexRepos.size() >= 2) { + msg += "; ≥2 project-level index repos is a known xlings resolution " + "gap that can make install_packages silently exit non-zero " + "(mcpp #238; root cause openxlings/xlings#374 — the resolver " + "does not report which repo/package failed)"; + } + + if (!childError.empty()) { + msg += std::format("\n xlings reported: {}", childError); + } else { + msg += "\n xlings emitted no structured error; re-run with " + "MCPP_VERBOSE=1 to see the raw xlings invocation + output."; + } + return msg; +} + std::expected Fetcher::call(std::string_view capability, std::string_view argsJson, diff --git a/src/toolchain/fingerprint.cppm b/src/toolchain/fingerprint.cppm index 27a324bc..02660b3b 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.97"; +inline constexpr std::string_view MCPP_VERSION = "0.0.98"; struct FingerprintInputs { Toolchain toolchain; diff --git a/src/xlings.cppm b/src/xlings.cppm index 92b756a1..7c4ee5eb 100644 --- a/src/xlings.cppm +++ b/src/xlings.cppm @@ -902,6 +902,13 @@ call(const Env& env, std::string_view capability, { auto cmd = build_interface_command(env, capability, argsJson); + // #238: under MCPP_VERBOSE=1 surface the exact xlings invocation so a + // failing install_packages can be reproduced/inspected by hand. The + // NDJSON child's own error/warn lines are surfaced by the caller's + // EventHandler (e.g. InstallProgressHandler). + mcpp::log::verbose("xlings", + std::format("interface {} exec: {}", capability, cmd)); + CallResult result; int rc = mcpp::platform::process::run_streaming(cmd, [&](std::string_view line) { diff --git a/tests/e2e/123_same_named_main_across_dep.sh b/tests/e2e/123_same_named_main_across_dep.sh new file mode 100755 index 00000000..fa2d2de8 --- /dev/null +++ b/tests/e2e/123_same_named_main_across_dep.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# requires: elf gcc +# mcpp#240 (follow-up to #233): when a dependency package and the consumer +# ship a SAME-NAMED source (the near-universal case: both have src/main.cpp — +# e.g. OpenCV's sample main.cpp's vs the consumer's own), #233's object-path +# disambiguation renames the scanned consumer main to obj//src/main.o, +# but the LINK step kept referencing the pre-disambiguation flat obj/main.o: +# ninja: error: 'obj/main.o', needed by 'bin/', missing and no known rule +# The entry-main link input must follow the same disambiguation as its +# compile edge. Member-scoped tests dodge this (distinct filenames), so only +# a real dep+consumer collision exercises it. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +# Dependency (kind=lib) that ships its OWN src/main.cpp (an impl file here, +# same basename as the consumer's entry). +mkdir -p mydep/src +cat > mydep/src/main.cpp <<'EOF' +int dep_helper() { return 41; } +EOF +cat > mydep/mcpp.toml <<'EOF' +[package] +name = "mydep" +version = "0.1.0" +[modules] +sources = ["src/**/*.cpp"] +[targets.mydep] +kind = "lib" +EOF + +# Consumer with its OWN src/main.cpp (globbed into sources, so it is scanned +# and subject to disambiguation). +mkdir -p consumer/src +cat > consumer/src/main.cpp <<'EOF' +import std; +extern int dep_helper(); +int main() { + std::println("val={}", dep_helper()); + return dep_helper() == 41 ? 0 : 1; +} +EOF +cat > consumer/mcpp.toml <<'EOF' +[package] +name = "consumer" +version = "0.1.0" +[modules] +sources = ["src/**/*.cpp"] +[dependencies] +mydep = { path = "../mydep" } +[targets.consumer] +kind = "bin" +main = "src/main.cpp" +EOF + +cd consumer +"$MCPP" build > build.log 2>&1 || { + cat build.log + echo "FAIL: build failed (expected: same-named main across dep must link the disambiguated object)" + exit 1 +} + +ninja_file="$(find target -name build.ninja | head -1)" +[[ -n "$ninja_file" ]] || { echo "no build.ninja generated"; exit 1; } + +# The link edge must NOT reference a bare obj/main.o (the stale, unproduced +# flat path). It must reference the consumer main's real, disambiguated object. +link_line="$(grep -E 'bin/consumer *:' "$ninja_file")" +if echo "$link_line" | grep -qE '(^| )obj/main\.o( |$)'; then + echo "FAIL: link still references stale flat obj/main.o" + echo "$link_line" + cat "$ninja_file" + exit 1 +fi + +out="$("$MCPP" run 2>&1 | tail -1)" +[[ "$out" == "val=41" ]] || { echo "unexpected output: $out"; exit 1; } + +echo "OK" diff --git a/tests/e2e/124_gen_source_out_of_root_object_path.sh b/tests/e2e/124_gen_source_out_of_root_object_path.sh new file mode 100755 index 00000000..0581a013 --- /dev/null +++ b/tests/e2e/124_gen_source_out_of_root_object_path.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# requires: elf gcc +# mcpp#239 (follow-up to #233): a dependency's build.mcpp can emit generated +# sources into OUT_DIR (/target/.build-mcpp/deps/@/out), +# which live OUTSIDE the dependency's package root. Their scanner relPath is +# `relative(, )` and therefore carries `..` +# components. #233's collision-disambiguation pasted that relPath straight +# into the object path: +# cc1plus: fatal error: opening output file +# obj//..//target/.../out/gen.o: No such file or directory +# i.e. `obj//../…` climbs out of obj/ (and with enough `..`, mirrors the +# whole absolute tree under CWD). The collision prefix must be sanitized so +# every object stays under obj/. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +# Dependency whose build.mcpp writes gen.cpp into OUT_DIR (absolute path, +# outside the package root). +mkdir -p gdep/src +cat > gdep/build.mcpp <<'EOF' +#include +#include +import mcpp; +int main() { + std::string p = std::string(mcpp::out_dir()) + "/gen.cpp"; + std::FILE* f = std::fopen(p.c_str(), "w"); + std::fputs("int gen_val() { return 7; }\n", f); + std::fclose(f); + mcpp::generated("gen.cpp"); + return 0; +} +EOF +cat > gdep/src/lib.cpp <<'EOF' +extern int gen_val(); +int gdep_use() { return gen_val(); } +EOF +cat > gdep/mcpp.toml <<'EOF' +[package] +name = "gdep" +version = "0.1.0" +[modules] +sources = ["src/**/*.cpp"] +[targets.gdep] +kind = "lib" +EOF + +# Consumer with its OWN src/gen.cpp — same basename as gdep's generated +# gen.cpp, forcing the disambiguation branch on the out-of-root abs source. +mkdir -p consumer/src +cat > consumer/src/gen.cpp <<'EOF' +int consumer_gen() { return 100; } +EOF +cat > consumer/src/main.cpp <<'EOF' +import std; +extern int gdep_use(); +extern int consumer_gen(); +int main() { + std::println("sum={}", gdep_use() + consumer_gen()); + return (gdep_use() == 7 && consumer_gen() == 100) ? 0 : 1; +} +EOF +cat > consumer/mcpp.toml <<'EOF' +[package] +name = "consumer" +version = "0.1.0" +[modules] +sources = ["src/**/*.cpp"] +[dependencies] +gdep = { path = "../gdep" } +[targets.consumer] +kind = "bin" +main = "src/main.cpp" +EOF + +cd consumer +"$MCPP" build > build.log 2>&1 || { + cat build.log + echo "FAIL: build failed (expected: out-of-root generated source must map under obj/)" + exit 1 +} + +ninja_file="$(find target -name build.ninja | head -1)" +[[ -n "$ninja_file" ]] || { echo "no build.ninja generated"; exit 1; } + +# No object output may escape obj/ via `..` or an absolute root. +bad="$(grep -oE 'build [^ ]+\.o : cxx_object' "$ninja_file" | awk '{print $2}' \ + | grep -E '(^|/)\.\.(/|$)|^/' || true)" +if [[ -n "$bad" ]]; then + echo "FAIL: object path escapes obj/:" + echo "$bad" + exit 1 +fi + +out="$("$MCPP" run 2>&1 | tail -1)" +[[ "$out" == "sum=107" ]] || { echo "unexpected output: $out"; exit 1; } + +echo "OK" diff --git a/tests/e2e/125_build_mcpp_dep_dir.sh b/tests/e2e/125_build_mcpp_dep_dir.sh new file mode 100755 index 00000000..8e24ae84 --- /dev/null +++ b/tests/e2e/125_build_mcpp_dep_dir.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# requires: elf gcc +# mcpp#241: build.mcpp env contract exposes each resolved dependency's install +# dir as MCPP_DEP__DIR (read via mcpp::dep_dir("")), so a +# package's build.mcpp can locate a dependency's payload (e.g. a data-asset +# package) by name instead of reverse-engineering the store layout. Scenario: +# consumer -> pkgp (has build.mcpp) -> datad (data package). pkgp's build.mcpp +# reads dep_dir("datad") and generates a source returning it; consumer prints it. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +# data-asset dependency +mkdir -p datad/src +echo 'int datad_touch() { return 1; }' > datad/src/d.cpp +cat > datad/mcpp.toml <<'EOF' +[package] +name = "datad" +version = "0.1.0" +[modules] +sources = ["src/**/*.cpp"] +[targets.datad] +kind = "lib" +EOF + +# middle package with a build.mcpp that locates datad's dir +mkdir -p pkgp/src +cat > pkgp/src/lib.cpp <<'EOF' +extern const char* dep_datad_dir(); +const char* pkgp_datad_dir() { return dep_datad_dir(); } +EOF +cat > pkgp/build.mcpp <<'EOF' +#include +#include +import mcpp; +int main() { + const char* d = mcpp::dep_dir("datad"); + if (d == nullptr || d[0] == '\0') { + std::fprintf(stderr, "build.mcpp: MCPP_DEP_DATAD_DIR not set\n"); + return 1; // fail loudly if the contract var is missing + } + std::string out = std::string(mcpp::out_dir()) + "/depdir.cpp"; + std::FILE* f = std::fopen(out.c_str(), "w"); + std::fprintf(f, "extern const char* dep_datad_dir();\n"); + std::fprintf(f, "const char* dep_datad_dir() { return \"%s\"; }\n", d); + std::fclose(f); + mcpp::generated("depdir.cpp"); + return 0; +} +EOF +cat > pkgp/mcpp.toml <<'EOF' +[package] +name = "pkgp" +version = "0.1.0" +[modules] +sources = ["src/**/*.cpp"] +[dependencies] +datad = { path = "../datad" } +[targets.pkgp] +kind = "lib" +EOF + +# root consumer +mkdir -p consumer/src +cat > consumer/src/main.cpp <<'EOF' +import std; +extern const char* pkgp_datad_dir(); +int main() { + std::println("DEPDIR={}", pkgp_datad_dir()); + return 0; +} +EOF +cat > consumer/mcpp.toml <<'EOF' +[package] +name = "consumer" +version = "0.1.0" +[modules] +sources = ["src/**/*.cpp"] +[dependencies] +pkgp = { path = "../pkgp" } +[targets.consumer] +kind = "bin" +main = "src/main.cpp" +EOF + +cd consumer +"$MCPP" build > build.log 2>&1 || { cat build.log; echo "FAIL: build failed"; exit 1; } + +out="$("$MCPP" run 2>&1 | grep '^DEPDIR=' | tail -1)" +dir="${out#DEPDIR=}" +[[ -n "$dir" ]] || { echo "FAIL: empty dep dir (contract var missing)"; exit 1; } +[[ -d "$dir" ]] || { echo "FAIL: dep dir does not exist: $dir"; exit 1; } +[[ "$(basename "$dir")" == "datad" ]] || { echo "FAIL: dep dir is not datad's root: $dir"; exit 1; } + +echo "OK" diff --git a/tests/e2e/126_default_features_opt_out.sh b/tests/e2e/126_default_features_opt_out.sh new file mode 100755 index 00000000..c50d81f7 --- /dev/null +++ b/tests/e2e/126_default_features_opt_out.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# 126_default_features_opt_out.sh — consumer-side `default-features = false` +# (#242, Cargo parity). A dependency declares a DEFAULT feature set +# (`[features] default = ["turbo"]`) that carries a package-owned define. A +# consumer must be able to OPT OUT of that default set via +# `default-features = false`, so the dependency's default feature (and its +# define) does NOT activate. Explicitly requesting `features = [...]` still +# activates those, even alongside the opt-out. +# +# Root-cause funnel: the feature-activation closure (prepare.cppm +# feature_closure) no longer unconditionally seeds `[features].default` for a +# dependency — it seeds it only when the consumer's dep spec keeps default +# features (seedDefault). See the #242 commit. +# +# No `requires:` capability → runs on all three CI platforms. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +# Dependency whose DEFAULT feature set enables `turbo`, which carries a +# package-owned define that propagates to consumers (header-only case). +mkdir -p widget/include/widget widget/src +cat > widget/mcpp.toml <<'EOF' +[package] +name = "widget" +version = "0.1.0" + +[features] +default = ["turbo"] +turbo = { defines = ["WIDGET_TURBO=1"] } + +[build] +include_dirs = ["include"] + +[targets.widget] +kind = "lib" +EOF +cat > widget/include/widget/widget.hpp <<'EOF' +#pragma once +inline int widget_mode() { +#ifdef WIDGET_TURBO + return 1; +#else + return 0; +#endif +} +EOF +cat > widget/src/widget.cppm <<'EOF' +export module widget; +export int widget_anchor() { return 0; } +EOF + +# ── Case A: default-features = false → the dep's default `turbo` is SUPPRESSED. +# The consumer TU must NOT see WIDGET_TURBO; if it did, the #error below trips +# and the build fails. +mkdir -p appoff/src +cat > appoff/mcpp.toml <<'EOF' +[package] +name = "appoff" +version = "0.1.0" + +[dependencies] +widget = { path = "../widget", default-features = false } +EOF +cat > appoff/src/main.cpp <<'EOF' +#include +#ifdef WIDGET_TURBO +#error "WIDGET_TURBO leaked despite default-features = false" +#endif +int main() { return widget_mode() == 0 ? 0 : 2; } +EOF + +( cd appoff + "$MCPP" build > b.log 2>&1 || { cat b.log; echo "FAIL(A): opt-out build failed"; exit 1; } + if grep -q 'WIDGET_TURBO' compile_commands.json; then + echo "FAIL(A): WIDGET_TURBO present despite default-features = false"; cat compile_commands.json; exit 1 + fi + BIN=$(find target -type f \( -name appoff -o -name appoff.exe \) | head -1) + [ -n "$BIN" ] || { echo "FAIL(A): built binary not found"; exit 1; } + "$BIN"; rc=$? + [ "$rc" -eq 0 ] || { echo "FAIL(A): binary observed turbo despite opt-out (exit $rc)"; exit 1; } +) + +# ── Case B: default features ON (omitted) → the dep's default `turbo` activates +# and its define reaches the consumer, exactly as before this change. +mkdir -p appon/src +cat > appon/mcpp.toml <<'EOF' +[package] +name = "appon" +version = "0.1.0" + +[dependencies] +widget = { path = "../widget" } +EOF +cat > appon/src/main.cpp <<'EOF' +#include +#ifndef WIDGET_TURBO +#error "WIDGET_TURBO missing though default features are on" +#endif +int main() { return widget_mode() == 1 ? 0 : 2; } +EOF + +( cd appon + "$MCPP" build > b.log 2>&1 || { cat b.log; echo "FAIL(B): default-on build failed"; exit 1; } + grep -q 'WIDGET_TURBO' compile_commands.json || { + echo "FAIL(B): WIDGET_TURBO missing with default features on"; cat compile_commands.json; exit 1; } +) + +# ── Case C: default-features = false BUT explicitly re-request `turbo` → +# the explicit request still activates it (opt-out suppresses only the default +# seed, not explicit `features = [...]`). +mkdir -p appre/src +cat > appre/mcpp.toml <<'EOF' +[package] +name = "appre" +version = "0.1.0" + +[dependencies] +widget = { path = "../widget", default-features = false, features = ["turbo"] } +EOF +cat > appre/src/main.cpp <<'EOF' +#include +#ifndef WIDGET_TURBO +#error "explicit features = [turbo] did not activate alongside default-features = false" +#endif +int main() { return widget_mode() == 1 ? 0 : 2; } +EOF + +( cd appre + "$MCPP" build > b.log 2>&1 || { cat b.log; echo "FAIL(C): opt-out + explicit build failed"; exit 1; } + grep -q 'WIDGET_TURBO' compile_commands.json || { + echo "FAIL(C): explicit feature did not activate alongside opt-out"; cat compile_commands.json; exit 1; } +) + +echo "OK" diff --git a/tests/e2e/127_transitive_default_features_optout.sh b/tests/e2e/127_transitive_default_features_optout.sh new file mode 100755 index 00000000..44421251 --- /dev/null +++ b/tests/e2e/127_transitive_default_features_optout.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# requires: elf gcc +# mcpp#242 (transitive edge, found in the 0.0.99 architecture review): a +# consumer's `default-features = false` must be honored even when the consumer +# is itself a DEPENDENCY (transitive edge), not only for the root's direct deps. +# +# Before the edge-graph convergence, feature *resolution* honored the per-edge +# opt-out (mergeActiveFeatureDeps) but feature *activation* re-derived the flag +# by scanning only the ROOT manifest's direct deps — so a transitive dep's +# opt-out was silently dropped: activation still seeded the dep's default +# feature (defining its macro / keeping its default-gated sources) that +# resolution had already skipped. Now both consume the authoritative +# consumer→dep edge graph, so they agree. +# +# Layout: root(bin) -> A(lib) -> B(lib, `default = ["heavy"]`, heavy defines +# B_HEAVY). A depends on B with `default-features = false`. B's heavy macro must +# therefore be OFF in the transitive build. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +# B: a library with a default feature `heavy` that defines B_HEAVY. +mkdir -p B/src +cat > B/mcpp.toml <<'EOF' +[package] +name = "B" +version = "0.1.0" +[modules] +sources = ["src/**/*.cpp"] +[features] +default = ["heavy"] +heavy = { defines = ["B_HEAVY=1"] } +[targets.B] +kind = "lib" +EOF +cat > B/src/b.cpp <<'EOF' +int b_heavy() { +#ifdef B_HEAVY + return 1; +#else + return 0; +#endif +} +EOF + +# A: depends on B, opting OUT of B's default features. +mkdir -p A/src +cat > A/mcpp.toml <<'EOF' +[package] +name = "A" +version = "0.1.0" +[modules] +sources = ["src/**/*.cpp"] +[dependencies] +B = { path = "../B", default-features = false } +[targets.A] +kind = "lib" +EOF +cat > A/src/a.cpp <<'EOF' +extern int b_heavy(); +int a_val() { return b_heavy(); } +EOF + +# root consumer -> A (which transitively pulls B). +mkdir -p app/src +cat > app/mcpp.toml <<'EOF' +[package] +name = "app" +version = "0.1.0" +[modules] +sources = ["src/**/*.cpp"] +[dependencies] +A = { path = "../A" } +[targets.app] +kind = "bin" +main = "src/main.cpp" +EOF +cat > app/src/main.cpp <<'EOF' +import std; +extern int a_val(); +int main() { std::println("heavy={}", a_val()); return 0; } +EOF + +cd app +"$MCPP" build > build.log 2>&1 || { cat build.log; echo "FAIL: build failed"; exit 1; } +out="$("$MCPP" run 2>&1 | grep '^heavy=' | tail -1)" +[[ "$out" == "heavy=0" ]] || { + echo "FAIL: transitive default-features opt-out not honored (got '$out', want heavy=0)" + exit 1 +} + +# Control: flip A to KEEP B's defaults -> heavy must come back on. Clean first: +# changing a transitive dep's active feature set across an in-place rebuild is a +# separate fingerprint concern; this test isolates the activation logic with a +# fresh build. +cat > ../A/mcpp.toml <<'EOF' +[package] +name = "A" +version = "0.1.0" +[modules] +sources = ["src/**/*.cpp"] +[dependencies] +B = { path = "../B" } +[targets.A] +kind = "lib" +EOF +"$MCPP" clean > /dev/null 2>&1 || true +"$MCPP" build > build2.log 2>&1 || { cat build2.log; echo "FAIL: control build failed"; exit 1; } +out2="$("$MCPP" run 2>&1 | grep '^heavy=' | tail -1)" +[[ "$out2" == "heavy=1" ]] || { + echo "FAIL: control (defaults kept) expected heavy=1, got '$out2'" + exit 1 +} + +echo "OK" diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index 3d4d93d5..68236c97 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -229,6 +229,36 @@ opengl = "2026.05.31" EXPECT_EQ(m->dependencies.at("compat.opengl").visibility, "public"); } +// #242 — consumer-side `default-features = false`: a consumer opts out of a +// dependency's own [features].default set (Cargo parity). The flag parses into +// DependencySpec.defaultFeatures; explicitly requested `features = [...]` still +// come through. Default (omitted) stays true. +TEST(Manifest, ParsesDependencyDefaultFeaturesOptOut) { + auto m = mcpp::manifest::parse_string(R"( +[package] +name = "x" +version = "0.1.0" +[modules] +sources = ["src/**/*.cppm"] +[targets.x] +kind = "bin" +main = "src/main.cpp" +[dependencies.compat] +ffmpeg = { version = "6.1", default-features = false, features = ["avcodec"] } +zlib = "1.3" +)"); + ASSERT_TRUE(m.has_value()) << m.error().format(); + ASSERT_EQ(m->dependencies.size(), 2u); + // Opt-out dependency: flag is false, but the explicit feature still parses. + const auto& ff = m->dependencies.at("compat.ffmpeg"); + EXPECT_FALSE(ff.defaultFeatures); + ASSERT_EQ(ff.features.size(), 1u); + EXPECT_EQ(ff.features[0], "avcodec"); + EXPECT_EQ(ff.version, "6.1"); + // Bare-string dependency: defaultFeatures defaults to true. + EXPECT_TRUE(m->dependencies.at("compat.zlib").defaultFeatures); +} + TEST(Manifest, RejectsInvalidDependencyVisibility) { auto m = mcpp::manifest::parse_string(R"( [package] @@ -470,6 +500,38 @@ package = { EXPECT_TRUE(m->dependencies.empty()); } +// mcpp#237: an unknown mcpp-segment key is collected (so the build path can +// surface it) and the did-you-mean helper maps well-known confusables + +// edit-distance typos back to the closed key vocabulary. +TEST(XpkgUnknownKeys, CollectedAndDidYouMean) { + // `dependencies` is the canonical wrong spelling of `deps` (issue #237). + EXPECT_EQ(mcpp::manifest::closest_known_xpkg_key("dependencies"), "deps"); + EXPECT_EQ(mcpp::manifest::closest_known_xpkg_key("dependency"), "deps"); + // single-typo of a real key resolves via edit distance. + EXPECT_EQ(mcpp::manifest::closest_known_xpkg_key("cxxflag"), "cxxflags"); + EXPECT_EQ(mcpp::manifest::closest_known_xpkg_key("feature"), "features"); + // a genuinely unrelated key has no suggestion. + EXPECT_EQ(mcpp::manifest::closest_known_xpkg_key("wibble"), ""); + + // The build-path synthesizer records the unknown key rather than dropping + // it silently. + constexpr auto lua = R"( +package = { + name = "x", + xpm = { linux = { ["1.0.0"] = { url = "u", sha256 = "h" } } }, + mcpp = { + sources = { "*/a.c" }, + targets = { ["x"] = { kind = "lib" } }, + dependencies = { ["zlib"] = "1.3.x" }, + }, +} +)"; + auto m = mcpp::manifest::synthesize_from_xpkg_lua(lua, "x", "1.0.0"); + ASSERT_TRUE(m.has_value()) << m.error().format(); + ASSERT_EQ(m->xpkgUnknownKeys.size(), 1u); + EXPECT_EQ(m->xpkgUnknownKeys[0], "dependencies"); +} + TEST(Manifest, BuildMacosDeploymentTarget) { constexpr auto src = R"( [package] diff --git a/tests/unit/test_pm_package_fetcher.cpp b/tests/unit/test_pm_package_fetcher.cpp index e28bd1d6..856eadbc 100644 --- a/tests/unit/test_pm_package_fetcher.cpp +++ b/tests/unit/test_pm_package_fetcher.cpp @@ -175,3 +175,81 @@ TEST(PmPackageFetcher, ResolvesCustomNamespaceDescriptorUnderNonCanonicalFilenam std::filesystem::remove_all(project); } + +// ─── #238: install_packages failure diagnostics ───────────────────── +// +// The root cause of #238 is in xlings (its resolver silently exits 1 with ≥2 +// project-level index_repos). mcpp cannot fix that; it can only make the +// failure diagnosable. These tests pin the message-formatting helper and the +// .xlings.json index_repos reader so the enriched, actionable error shape is +// locked without needing a live (failing) multi-repo xlings. + +TEST(PmPackageFetcher, InstallFailureDiagnosticFlagsMultiRepoGap) { + std::vector repos = { + "mcpplibs -> https://example/mcpplibs", + "myindex -> https://example/myindex", + }; + auto msg = mcpp::pm::format_install_failure_diagnostic( + "acme.widget@1.2.3", 1, repos, /*childError=*/""); + + // Names the target + exit code. + EXPECT_NE(msg.find("acme.widget@1.2.3"), std::string::npos); + EXPECT_NE(msg.find("exit 1"), std::string::npos); + // Enumerates the configured repos (count + identities). + EXPECT_NE(msg.find("2 index repos"), std::string::npos); + EXPECT_NE(msg.find("mcpplibs"), std::string::npos); + EXPECT_NE(msg.find("myindex"), std::string::npos); + // Flags the known ≥2-repo xlings gap and links the tracking issue. + EXPECT_NE(msg.find("#238"), std::string::npos); + EXPECT_NE(msg.find("openxlings/xlings"), std::string::npos); + // No child error → points at MCPP_VERBOSE for the raw output. + EXPECT_NE(msg.find("MCPP_VERBOSE"), std::string::npos); +} + +TEST(PmPackageFetcher, InstallFailureDiagnosticSingleRepoNoGapFlag) { + std::vector repos = { "mcpplibs" }; + auto msg = mcpp::pm::format_install_failure_diagnostic( + "acme.widget@1.2.3", 1, repos, /*childError=*/""); + + EXPECT_NE(msg.find("1 index repo"), std::string::npos); + // A single repo is NOT the #238 failure mode; do not misattribute it. + EXPECT_EQ(msg.find("known xlings resolution gap"), std::string::npos); + EXPECT_EQ(msg.find("#238"), std::string::npos); +} + +TEST(PmPackageFetcher, InstallFailureDiagnosticPreservesChildError) { + std::vector repos = { "a", "b" }; + auto msg = mcpp::pm::format_install_failure_diagnostic( + "acme.widget@1.2.3", 1, repos, + /*childError=*/"[error] descriptor not found in index"); + + // Child-emitted text must be surfaced, not swallowed. + EXPECT_NE(msg.find("descriptor not found in index"), std::string::npos); + EXPECT_NE(msg.find("xlings reported"), std::string::npos); + // With a real child error we don't fall back to the MCPP_VERBOSE nudge. + EXPECT_EQ(msg.find("MCPP_VERBOSE"), std::string::npos); +} + +TEST(PmPackageFetcher, ReadSeededIndexReposParsesXlingsJson) { + auto project = make_tempdir("mcpp-238-xlings-json"); + auto xjson = project / ".xlings.json"; + write_file(xjson, R"({ + "index_repos": [ + { "name": "mcpplibs", "url": "https://example/mcpplibs" }, + { "name": "myindex", "url": "https://example/myindex" } + ], + "lang": "en" +})"); + + auto repos = mcpp::pm::read_seeded_index_repos(xjson); + ASSERT_EQ(repos.size(), 2u); + EXPECT_NE(repos[0].find("mcpplibs"), std::string::npos); + EXPECT_NE(repos[0].find("https://example/mcpplibs"), std::string::npos); + EXPECT_NE(repos[1].find("myindex"), std::string::npos); + + // Missing file → empty (caller degrades to "unknown configuration"). + auto none = mcpp::pm::read_seeded_index_repos(project / "does-not-exist.json"); + EXPECT_TRUE(none.empty()); + + std::filesystem::remove_all(project); +}