From 664713de36bccb77cc0fb8a1ee31cb4786d9d3d6 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 19 Jul 2026 04:06:55 +0800 Subject: [PATCH 1/4] test(e2e): repros for object-path #240 (same-named main) + #239 (out-of-root gen source) Two follow-up gaps in #233's object-path disambiguation, each reproduced as a failing e2e test (red on 0.0.97) plus a decomposed design doc: - 123 (#240): a dependency and the consumer ship a same-named source (the near-universal both-have-src/main.cpp). #233 renames the scanned consumer main to obj//src/main.o, but the link still references the stale flat obj/main.o -> 'obj/main.o missing and no known rule'. Blocks mcpplibs #79. - 124 (#239): a dependency build.mcpp emits a source into OUT_DIR (outside the package root); its relPath carries '..', so #233 pastes obj//../.. and the object escapes obj/ (and the '@' in @ gets ninja-quoted, breaking the #235 "$out.d" depfile redirect). Design: .agents/docs/2026-07-19-object-path-disambiguation-followups-239-240-design.md --- ...disambiguation-followups-239-240-design.md | 201 ++++++++++++++++++ tests/e2e/123_same_named_main_across_dep.sh | 81 +++++++ .../124_gen_source_out_of_root_object_path.sh | 100 +++++++++ 3 files changed, 382 insertions(+) create mode 100644 .agents/docs/2026-07-19-object-path-disambiguation-followups-239-240-design.md create mode 100755 tests/e2e/123_same_named_main_across_dep.sh create mode 100755 tests/e2e/124_gen_source_out_of_root_object_path.sh 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 0000000..07a6753 --- /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/tests/e2e/123_same_named_main_across_dep.sh b/tests/e2e/123_same_named_main_across_dep.sh new file mode 100755 index 0000000..fa2d2de --- /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 0000000..0581a01 --- /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" From b7f32f6517f5ca4dca9bed3a66293cbdf788fac8 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 19 Jul 2026 04:07:12 +0800 Subject: [PATCH 2/4] fix(build): unify object-path assignment so entry-main follows disambiguation (#240, #239) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #233 disambiguated object paths only for scanner-produced units, leaving two gaps that both surface as broken/invalid ninja edges. Converge object-path assignment into one source of truth (object_for) and make the collision prefix always downward + shell-safe (safe_object_prefix). #240 — the entry main's object path was recomputed independently as flat obj/.o (plan.cppm) and pushed to the link inputs even when the source had already been scanned into a disambiguated compile unit. Fix: - the synthesized main no longer computes its own object path; - if the entry was globbed (scanned), the link reuses THAT unit's already- disambiguated object; - if not scanned, it is added to the basename census and routed through the same object_for, so it disambiguates against a dependency's same-named source too. Common single-binary projects (unique main) stay flat obj/main.o. #239 — the collision prefix was u.relPath.parent_path() pasted raw. For sources outside their package root (dependency build.mcpp OUT_DIR-generated sources) the relPath carries '..'/absolute roots, so obj/ climbed out of the build tree, and the '@' in @ got ninja-quoted (breaking the #235 "$out.d" redirect). safe_object_prefix folds each component downward: drop root, '.' skipped, '..'->'__up', non-portable chars (e.g. '@')->'_'. Per-component injective, so #233's uniqueness (and its L1b assertion backstop) holds. Non-colliding paths are untouched (flat obj/.o); colliding paths with a clean relPath are byte-identical to before. --- src/build/plan.cppm | 104 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 89 insertions(+), 15 deletions(-) diff --git a/src/build/plan.cppm b/src/build/plan.cppm index eb93a76..bd6c872 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 From 971ad4ba689a3632d0c4cfcb9639507c8e36df2c Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 19 Jul 2026 04:07:21 +0800 Subject: [PATCH 3/4] chore(release): bump mcpp 0.0.97 -> 0.0.98 (#240 #239 object-path fixes) CHANGELOG entry for the two #233 follow-up fixes. Bootstrap pin bump + release mirror + index are the usual post-merge operational steps (not in this PR). --- CHANGELOG.md | 11 ++++++++++- mcpp.toml | 2 +- src/toolchain/fingerprint.cppm | 2 +- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 928ce3c..e6afdbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,16 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 -## [0.0.97] — 2026-07-18 +## [0.0.98] — 2026-07-19 + +> #233 对象路径消歧的两个后续缺口修复(单 PR,逐 commit)。解阻塞 mcpplibs #79 opencv 收录。设计见 `.agents/docs/2026-07-19-object-path-disambiguation-followups-239-240-design.md`。 + +### 修复 + +- **消歧后链接输入未跟随改名,依赖与消费者同名源即挂**(#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 断言兜底残余)。 + + > 架构级修复批次(单 PR,逐簇 commit):ffmpeg-m/opencv-m 全源码直编暴露的第二层缺口 + workspace 测试基建语法空洞。 diff --git a/mcpp.toml b/mcpp.toml index 06a1bef..6791fbc 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/toolchain/fingerprint.cppm b/src/toolchain/fingerprint.cppm index 27a324b..02660b3 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; From a1777357f4564116053a0086890433826fa0ac59 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 19 Jul 2026 04:17:25 +0800 Subject: [PATCH 4/4] docs(governance): #230-#243 batch ledger + no-workaround policy + reserved architecture assessment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Authoritative per-issue ledger (classification / status / root-cause subsystem / verified file:line anchor / root-cause-level fix / commit) for the whole #230-#243 wave, plus the standing no-workaround discipline and the criteria for the final holistic architecture assessment (§4, filled once all land). Records the current state: #230 already fixed (0.0.96); #239/#240 fixed on this branch (0.0.98); #237/#241/#242 mechanical root-cause fixes pending; #243 needs design; #238 root cause lives in xlings (not fixable in this repo, must not be band-aided as done). --- ...atch-ledger-and-architecture-assessment.md | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 .agents/docs/2026-07-19-issues-230-243-batch-ledger-and-architecture-assessment.md 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 0000000..2c7372e --- /dev/null +++ b/.agents/docs/2026-07-19-issues-230-243-batch-ledger-and-architecture-assessment.md @@ -0,0 +1,85 @@ +# #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` → 响亮失败(warn/hard-err,沿 0.0.97 封闭文法先例)+ `dependencies`→`deps` did-you-mean。**非 workaround**:键集本就是封闭白名单(else-if 链) | 待实施 | +| **#238** | bug | ⛔ **根因在 xlings** | 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]` 继承 `prepare.cppm:577-602` | **根因修必须落在 xlings 仓**(多仓解析)。mcpp 侧只能做**诊断改进**(把裸 exit 1 变成真错误)——这**不是**根因修,须如实标注 + 另开 xlings issue | mcpp 侧诊断:待实施;根因:**xlings** | +| **#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:337-354` contract_env(无 per-dep);sanitizer `:329-334`;rerun hash `:359-363`;调用点 `prepare.cppm:1151/2824`;struct `:32` | `BuildProgramEnv` 加已解析依赖 verdir/payload 根,按声明依赖发 `MCPP_DEP__DIR`(同 feature sanitizer)。**根因**:消除包侧自导航 store 布局的 hack。自动进 rerun hash | 待实施 | +| **#242** | enhancement | 🔧 | 消费端无法关默认 feature 集 | `feature_closure` 无条件 seed default `prepare.cppm:384-400`(388-389);dep-spec 键白名单缺 `default-features` `toml.cppm:423-428`;filler `:437-477`;`DependencySpec` 无 `defaultFeatures` | 加 `default-features` 键→`DependencySpec.defaultFeatures`(默认 true)→穿进 `feature_closure`(false 时跳过 default seed)。**Cargo 平价,非 workaround** | 待实施 | +| **#243** | enhancement | 📐 **需设计** | manifest `[features]` 缺条件依赖 + 依赖 feature 转发(`dep/feat`) | `[features]` parse 只读 implies/defines/sources/requires/provides `toml.cppm:211-236`;xpkg 已有 `features.x.deps` 平价点 `xpkg.cppm:1069`;转发注入点 `prepare.cppm:2573`(`mergeActiveFeatureDeps`)+闭包 `:2780/2816/2827` | (1)`toml.cppm` features 表读 `deps={}`,并入 xpkg 同款 `featureActivatedDeps` 数据模型(一模型两文法);(2)Feature 记录加 `forward`/`deps-features`,active 时把列出的 feature 注入对应依赖请求集后再跑 `feature_closure`。**必须复用 per-package feature 漏斗,不另开旁路**;build+test 双路径 | 待设计→实施 | + +> 上批(#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. 【待补】批次全部完成后的整体架构评估 + +> 本节在 #230–#243 **全部落地**后填写,评估维度(现在先锁定标准,避免事后放水): + +1. **设计合理性**:本批全部修复是否都落在**单一汇聚点**,而非新增特判 / 旁路?逐条对照 §0 主线打分。 +2. **一致性**:manifest 文法 / xpkg 描述符 / feature 模型三处是否收敛为**一数据模型多文法**(#243 是关键检验点),还是又分叉出平行实现? +3. **稳定性 / 回归面**:是否复用了"build+test 双路径""合并后全量 e2e + 多平台 CI"来堵 per-commit 单测的盲区?有无跨构建 / 共享库运行期未验的假绿? +4. **架构债清算**:§2 登记的 `"$out.d"`×`@` 脆性、#238 的 xlings 侧根因、#241 的 payload-mount 子项——是清了、还是显式转为后续 issue?**不允许静默遗留。** +5. **issue↔实现映射完整性**:§1 总账是否每条都有 commit/PR 且状态如实(尤其 #238 不得冒充已修)。 + +**评估触发条件**:§1 表中不再有 🔧/📐/⛔/🟢(全部 ✅ 或显式转 issue 的 ⛔)。 + +--- + +## 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 的合并门)。