# mcpp 0.0.97 架构级修复 —— 实施设计与任务拆分(单 PR / 逐簇 commit)

> **给实施者**:本计划配套 [2026-07-18-issue-triage-215plus-architectural-remediation.md](2026-07-18-issue-triage-215plus-architectural-remediation.md)(分类与根因)。本文是**可执行计划**:一个大 PR(分支 `feat/v0.0.97-remediation`),**每一类问题一个修复 commit 节点**,末尾一个版本 commit。每个 commit 落地即树保持可编译 + `mcpp test` 绿 + 相关 e2e 绿。步骤用 `- [ ]` 勾选跟踪。
>
> **目标(Goal)**:以架构级设计(非 workaround)一次性修掉 #224/#225/#226/#227/#228/#229/#232/#233/#234/#235 + R6 + 两条 workspace ergonomics,发布 0.0.97。
>
> **架构主线(Architecture)**:七簇修复都遵循同一原则——**把结构化意图保留到唯一收敛点,消除"过早压平成串 / 散在多处特判"**:源发现在 glob choke point 收窄并复用缓存;flag 作为 token 列表在规范化+发射两 pass 处理;对象路径镜像源相对路径;条件源集在唯一 per-package 漏斗求值;工具供给复用工具链同款同步门;命名空间路由查表而非硬编码短路。
>
> **技术栈**:C++23 modules、自研 mcpp(self-host)、ninja backend、自研 TOML parser、gtest 单测(`mcpp test`)、`tests/e2e/*.sh` 端到端。

## 决策已定(2026-07-18)
1. 簇 G 粒度 → **一个 commit(C7)**。 2. R6 键拼写 → **`default = {path}`**(归一化点 `toml.cppm:924-948`;`""` 亦归一到 `kDefaultNamespace`,但文档/示例只推 `default`)。 3. 簇 A → **token-list 两 pass**(取代 `Arg` 草案)。 4. 执行 → **subagent-driven**(每 commit 一个 fresh subagent + commit 间审查)。

## 实施进度(动态更新)
- 环境:bootstrap `mcpp 0.0.96` @ `/home/speak/.xlings/data/xpkgs/xim-x-mcpp/0.0.96/bin/mcpp`;基线 `mcpp build` 43s、`mcpp test` 35 binaries 全绿。分支 `feat/v0.0.97-remediation`(docs commit 已落)。

| commit | 状态 | 备注 |
|---|---|---|
| C1 簇E #225 | ✅ 完成 `33e6d9a` | scanner 前缀收窄+边界排除;run 复用缓存(try_fast_run);+2 单测(含确定性 glob_literal_prefix)+e2e 114;review 通过 |
| C2 簇B #227 #228 | ✅ 完成 `2230872` | libs/toml AOT + expand_braces;consumer 零改(同 Value 形);+manifest 层 closed-grammar 守卫(拒非白名单 `[[x]]` typo,防静默丢数据)+brace 深度上限;+7 单测+e2e 115;review 通过 |
| C3 簇A #226 #234 | ✅ 完成 `b71d93c` | token-list 两 pass:normalize_include_flags(全 include 族 joined/separated)+shell_quote_arg;修 1 Critical=MSVC include_dirs 需先绝对化再加 dialect 前缀(typed path 通道≠flag-string 通道);+5 单测+e2e 116;review 通过 |
| C4 簇C #233 #235 | ✅ 完成 `3b4f18e`(α) | 对象路径:保留 basename 碰撞门,消歧前缀改 relPath.parent_path()(非碰撞项字节不变,4 个断言扁平路径 e2e 保绿)+唯一性断言;depfile:cxx_module/object 加 -MMD,过滤 GCC -fmodules 反向规则(awk 保留文本 include 记录,POSIX 门控);+2 e2e 117/118+1 单测;review 通过 |
| C5 簇D #229 | ✅ 完成 `d6b3b6c`(β) | 3-site 漏斗(root+version-dep 保留,新增 path/git-dep site=真正 #229 修复);偏离 brief 有据(apply()-loop 会丢 cfg flags:makePackageRoot 早快照 privateBuild);+e2e 119;review 通过(3 风险全清) |
| C6 簇F #232 | ✅ 完成 `482b428`(β) | nasm 走工具链同款 resolve_xpkg_path("xim:nasm@3.02",autoInstall) 同步门 + guard 不再吞 config 错;ensure_nasm 退役→find_usable_nasm/find_sandbox_nasm;+6 单测+e2e 105 冷环境;review 通过 |
| C7 簇G #224 R6 +run-p | ✅ 完成 `c044f8e`(β) | #224 根锚点继承(merge_workspace_deps 传 path+按 wsRoot 解析;[indices] 相对路径按根)+ R6 默认命名空间 `default={path}` 重定向(两处短路查表;url-form 拒绝 loud+dup-key 拒绝)+ `run -p member`;+7 单测+e2e 120/121/122;review 通过(1 Important 已修) |
| 合并 β→α | ✅ 完成 `c7a2b61` | merge --no-ff,无冲突;合并后全量 rebuild+test 35/35 绿;9 个新 e2e(114-122)全绿 |
| C8 版本 0.0.97 | ✅ 完成 `964f9d0` | fingerprint.cppm + mcpp.toml → 0.0.97;CHANGELOG 追加;`mcpp --version`=0.0.97 |
| 全量 e2e 回归 | ✅ 通过(修 1 回归) | 119→123 pass;抓到并修 rpath `$ORIGIN` 双重引号回归(`d3ad223`,C3 shell_quote_arg 误碰 link flags);余 22/54/62 为沙箱环境性(gcc ELF interp 缺失,0.0.96 基线同败,CI 真工具链过) |
| 最终整支 review | ✅ 通过 `adc7c...` | opus,READY,无 Critical/Important;deferred minors 已分诊 |
| rpath 回归修复 | ✅ 完成 `d3ad223` | join_flags 加 shellQuote 参数,link flags 不再二次引号;+单测 LinkFlagsAreNotReQuoted;e2e 55/56/57/64 红→绿 |
| PR / CI / 合入 / release | ⏳ 进行中 | gh 已认证(Sunrisepeak),origin=mcpp-community/mcpp |

## 全局约束(Global Constraints)

- **版本**:`MCPP_VERSION` 唯一真源 `src/toolchain/fingerprint.cppm:21`;必须与 `mcpp.toml:3` 同步。本 PR 目标 `0.0.97`(**仅末尾版本 commit 改**,前七个修复 commit 不动版本号)。
- **基线**:HEAD `42198fb`(0.0.96),分支 `feat/v0.0.97-remediation` 从 main 切出。
- **每 commit 绿**:任一修复 commit 落地后 `mcpp build`(self-host)+ `mcpp test` 必须通过;涉及行为的簇附带新 e2e(编号从 **114** 起)且并入 `tests/e2e/run_all.sh` 顺序。
- **单测就近**:新单测加进现有 `tests/unit/test_*.cpp`(gtest,由 `mcpp test` 发现),不新建测试工程。
- **host-aware**:e2e 断言凡涉及平台差异必须 host-aware(记忆教训:自托管 e2e 会在 win/mac 上跑,勿硬编 Linux 断言)。
- **closed syntax, open vocabulary**:解析层扩展(#227/#228/R6)只加固定形状机制,不加开放语义键。
- **无 workaround**:每个修复改的是根因收敛点(见各 commit 的"设计")而非加旁路开关或特判豁免。

## Commit 序(DAG)与"每步绿"不变量

```
main(42198fb)
  └─ feat/v0.0.97-remediation
       C1 [簇E] scanner glob 收窄+边界排除 + run/build 缓存复用        (#225)   ← 基础:动 expand_glob
       C2 [簇B] TOML 数组表 + glob 花括号交替                          (#227 #228) ← 花括号脱糖挂在 C1 后的 expand_glob 入口
       C3 [簇A] flag token 列表:include 族规范化 + 发射端 shell 引号   (#226 #234)
       C4 [簇C] 对象路径镜像源相对路径 + 编译边 depfile                (#233 #235) ← depfile 依赖对象路径唯一,同 commit 内先做路径
       C5 [簇D] 条件源集统一 per-package 漏斗                          (#229)
       C6 [簇F] nasm 走工具链同款同步供给门                            (#232)
       C7 [簇G] workspace 根锚点继承 + 默认命名空间重定向 + run 选成员  (#224 R6 +run-p)
       C8 [版本] bump 0.0.97 + CHANGELOG + 发布说明
```

**唯一的 commit 间耦合**:C2 的花括号脱糖复用 C1 重构后的 `expand_glob` 入口 → **C1 必须先于 C2**。其余 C3–C7 相互无文件级冲突(C6/C7 都碰 `prepare.cppm` 但分别在 nasm 段 3019-3032 与命名空间/继承段 577/1134/1297,不重叠),理论可乱序;按上表顺序合入最清晰。#230 已由 df985df 修复,不在本 PR;#215 不做。

---

## Commit 1 —— 簇 E:源发现收窄 + run/build 缓存复用(#225)

**Files:**
- Modify: `src/modgraph/scanner.cppm:233-287`(`expand_glob`)、`:289-333`(`expand_dir_glob`)
- Modify: `src/build/execute.cppm:34-41`(`BuildCacheEntry`)、`:43-126`(read/write cache)、`:269-383`(`try_fast_build`)、`:387-431`(`build_run_target`)
- Modify: `src/build/prepare.cppm`(写缓存处 ~`:3100`,补新字段)
- Test: `tests/unit/test_modgraph.cpp`(glob 前缀/边界)、新 `tests/e2e/114_run_scan_scope.sh`

**Interfaces:**
- Produces:`glob_literal_prefix(std::string_view glob) -> std::filesystem::path`(scanner 内部);`BuildCacheEntry` 新增 `std::vector<std::pair<std::string,std::string>> runTargets;`(target 名 → 相对 outputDir 的可执行路径)。
- Consumes:C2 复用 `expand_glob` 的脱糖入口(见 C2)。

### 设计(架构,非 workaround)
三处根因(triage §6.2)在**唯一 choke point** `expand_glob`/`expand_dir_glob` 与 run 快路径修:

1. **前缀收窄遍历**:`expand_glob` 目前恒从 `root` 起 `recursive_directory_iterator`(`scanner.cppm:251-252`),glob 只在遍历后词法过滤。改为先从 glob 提取首个通配符前的字面目录前缀,迭代器从 `root/prefix` 起。
2. **边界排除**:现仅按名剪 `.mcpp`(`scanner.cppm:261-264`)。扩为一个排除判定:目录名 ∈ {`.mcpp`,`.git`,`target`} 或命中 `<root>/.gitmodules` 登记的子模块路径 → `disable_recursion_pending()`。
3. **run 复用缓存**:`build_run_target`(`execute.cppm:391`)无条件 `prepare_build`。给它一条与 `cmd_build` 的 `try_fast_build` 平行的快路径:缓存有效(fingerprint + ninja 比源新)则跑 ninja 后从 `runTargets` 定位 exe,跳过 `prepare_build`。为此 `BuildCacheEntry` 持久化 `runTargets`(在 `prepare_build` 成功后连 fingerprint 一起写)。

**Before**(`scanner.cppm:251-264` 摘要):
```cpp
fs::recursive_directory_iterator it(
    root, fs::directory_options::follow_directory_symlink, ec);
for (...; !ec && it != end; it.increment(ec)) {
    auto& e = *it;
    if (e.is_directory(eec) && !eec) {
        if (e.path().filename() == ".mcpp") { it.disable_recursion_pending(); continue; }
        ...
```
**After**(要点):
```cpp
// 1) 前缀收窄:src/**/*.cppm → 从 root/"src" 起走
fs::path prefix = glob_literal_prefix(glob);        // 首个 * ? { [ 之前的目录段
fs::path start  = prefix.empty() ? root : root / prefix;
if (!fs::exists(start)) return out;                  // 前缀不存在 = 空集(非全树)
fs::recursive_directory_iterator it(
    start, fs::directory_options::follow_directory_symlink, ec);
...
    if (e.is_directory(eec) && !eec) {
        if (is_excluded_walk_dir(e.path(), root)) {  // .mcpp/.git/target/submodule
            it.disable_recursion_pending(); continue;
        }
        ...
```
> 注:`path_matches_glob` 仍对全 glob 做词法匹配(前缀只收窄**起点**,不改语义);`!` 排除 glob 与 `expand_dir_glob` 同款处理。`glob_literal_prefix` 须对花括号(C2)保守——见 C2 的脱糖先行。

### Tasks
- [ ] **Step 1**:`test_modgraph.cpp` 加失败单测 `ExpandGlobStartsAtLiteralPrefix`:构造 `root/{src/a.cppm, junk/big/... }`,断言 `expand_glob(root,"src/**/*.cppm")` 只含 `src/a.cppm` 且**不访问** `junk`(用一个哨兵:在 `junk` 放一个会让旧代码变慢/命中的文件,断言结果集不含它)。再加 `ExcludesGitAndTargetAndMcpp`:`root/{.git/x.cppm, target/y.cppm, .mcpp/z.cppm, src/ok.cppm}` → 只 `src/ok.cppm`。
- [ ] **Step 2**:`mcpp test`,确认新单测 FAIL(旧代码会把 junk/.git/target 命中或遍历)。
- [ ] **Step 3**:实现 `glob_literal_prefix` + `is_excluded_walk_dir`(读一次 root `.gitmodules` 缓存子模块路径集),改 `expand_glob` 与 `expand_dir_glob` 两处起点+排除。
- [ ] **Step 4**:`mcpp test` 绿。
- [ ] **Step 5**:`BuildCacheEntry` 加 `runTargets`;`write_build_cache`/`read_build_cache` 序列化该字段(向后兼容:旧缓存无此行 → 视为无效,重跑一次);`prepare_build` 成功后填充(从 `ctx.plan.linkUnits` 取每个 bin target 的名与相对 exe 路径)。
- [ ] **Step 6**:`build_run_target` 加快路径:`read_build_cache` + fingerprint/ninja 新鲜度校验(复用 `try_fast_build` 的校验,并把其硬编码 `src/` 遍历统一到 `expand_glob`)→ 命中则 ninja + 从 `runTargets` 定位 exe;未命中回落 `prepare_build`。
- [ ] **Step 7**:新增 `tests/e2e/114_run_scan_scope.sh`:建含大无关目录(伪 `compat/blob` 千文件 + `src/main.cpp`)的工程,`mcpp build` 后两次 `mcpp run`,断言第二次 `real` 与缓存 `mcpp build` 同量级(设阈值,如 < 2s;避免绝对时间脆弱,用"run 不重新扫描"标志:`MCPP_VERBOSE` 下断言无 scan 日志行)。并入 `run_all.sh`。
- [ ] **Step 8**:`mcpp test` + `bash tests/e2e/114_run_scan_scope.sh` 绿。
- [ ] **Step 9**:commit
```bash
git add src/modgraph/scanner.cppm src/build/execute.cppm src/build/prepare.cppm \
        tests/unit/test_modgraph.cpp tests/e2e/114_run_scan_scope.sh tests/e2e/run_all.sh
git commit -m "perf(scanner): bound glob walk to literal prefix + exclude vcs/build dirs; reuse resolved cache in \`mcpp run\` (#225)"
```

---

## Commit 2 —— 簇 B:清单语法表达力(#227 数组表 + #228 花括号)

**Files:**
- Modify: `src/libs/toml.cppm:460-499`(解析主循环,`[` 分支 471-476)——加 `[[table]]` 累积
- Modify: `src/manifest/toml.cppm:692-730`(`build.flags` 消费,接受数组表形式)
- Modify: `src/modgraph/scanner.cppm`(`expand_glob` 入口加花括号脱糖;`scan_one_into` glob 消费处)
- Test: `tests/unit/test_toml.cpp`(AOT)、`tests/unit/test_modgraph.cpp`(brace)、新 `tests/e2e/115_manifest_syntax_aot_brace.sh`

**Interfaces:**
- Consumes:C1 的 `expand_glob`(脱糖插在其最前)。
- Produces:`expand_braces(std::string_view glob) -> std::vector<std::string>`(scanner 内部,笛卡尔展开 `{a,b}`)。

### 设计
两处纯语法补齐(triage §3.3),零新语义:
1. **#227**:`libs/toml.cppm:474-476` 现见 `[[` 直接报错。改为:识别 `[[path]]` → 在文档里对 `path` 维护一个**表数组**,每遇一次追加一个新子表、后续键值填入该子表。这是给自研 parser 补一个**通用 TOML 能力**。随后 `manifest/toml.cppm:692-730` 让 `build.flags` **同时**接受内联表数组(现状)与数组表(新),声明顺序=应用顺序不变。
2. **#228**:`expand_braces` 在 `expand_glob` 入口把 `a/{x,y}/**` 展成 `["a/x/**","a/y/**"]`(支持嵌套/多组,笛卡尔积),每分支走既有匹配;`glob_literal_prefix`(C1)对含 `{` 的原始 glob 返回 `{` 前的前缀,脱糖后各分支再各自取更长前缀。

### Tasks
- [ ] **Step 1**:`test_toml.cpp` 加 `ParsesArrayOfTables`:解析
```toml
[[build.flags]]
glob = "a/**"
cxxflags = ["-DX=1"]
[[build.flags]]
glob = "b/**"
```
断言得到 2 个条目、键值正确、顺序保持。
- [ ] **Step 2**:`mcpp test` → FAIL(现 `array-of-tables not supported`)。
- [ ] **Step 3**:实现 libs/toml.cppm 的 AOT 累积 + manifest/toml.cppm 消费两形式。
- [ ] **Step 4**:`mcpp test` 绿。
- [ ] **Step 5**:`test_modgraph.cpp` 加 `ExpandBraceAlternation`:`root/{p/aac/x.c, p/bsf/y.c, p/opus/z.c}`,断言 `expand_glob(root,"p/{aac,bsf}/**")` 含 aac/bsf 不含 opus。→ FAIL。
- [ ] **Step 6**:实现 `expand_braces` 并接到 `expand_glob` 入口 + `scan_one_into` glob 消费点;`glob_literal_prefix` 遇 `{` 保守回退。
- [ ] **Step 7**:`mcpp test` 绿。
- [ ] **Step 8**:`tests/e2e/115_manifest_syntax_aot_brace.sh`:一个工程用 `[[build.flags]]` + `{a,b}` glob 编译两目录,断言构建成功、define 生效。并入 `run_all.sh`。
- [ ] **Step 9**:`mcpp test` + e2e 绿。
- [ ] **Step 10**:commit
```bash
git commit -m "feat(manifest): support [[build.flags]] array-of-tables + glob brace alternation {a,b} (#227 #228)"
```

---

## Commit 3 —— 簇 A:flag token 列表规范化 + 发射端引号(#226 #234)

**Files:**
- Modify: `src/modgraph/scanner.cppm:338-350`(`absolutize_include_flags` → 泛化为 include 族)
- Modify: `src/build/ninja_backend.cppm:99-106`(`join_flags` 加逐 token 引号)、`src/build/flags.cppm:312-331`(全局 blob 装配加同款引号)
- Test: `tests/unit/test_build_flags.cpp`、`tests/unit/test_ninja_backend.cpp`、新 `tests/e2e/116_include_family_and_define_spaces.sh`

**Interfaces:**
- Produces:`normalize_include_flags(root, std::vector<std::string>&)`(取代 `absolutize_include_flags`);`shell_quote_arg(std::string_view) -> std::string`(平台感知:POSIX 单引号 / Windows 双引号,叠加 ninja `$` 转义)。

### 设计(修正 triage §2.3 的 `Arg` 草案 → 更贴合源码的最小忠实设计)
关键澄清:**flag vector 的每个元素本就 = 一个 argv token**——`apply_glob_flags`(`scanner.cppm:578-580`)push 的 `"-D"+d`(即 `-DT=long long`)是**一个**元素、边界并未丢。#234 的 bug **纯在发射端** `join_flags`(`ninja_backend.cppm:99-106`)裸空格拼接、不引号。故不需要引入新 `Arg` 结构(那是过度设计);正确架构 = **把 vector<string> 当 token 列表对待**,补两个 pass:

1. **include 族规范化**(#226):`absolutize_include_flags` 只认 `-I`(`scanner.cppm:342` 字面 `starts_with("-I")`)。泛化:一张前缀表 `{-I,-iquote,-isystem,-idirafter,-iprefix,-L}`,每 flag:
   - **joined**(`-iquotehdr`):`starts_with(prefix) && len>prefix` → 重写尾部路径;
   - **separated**(`-isystem` 后跟独立元素 `hdr`):元素恰等于裸前缀 → 下一元素为待重写路径;
   - 重写 = 相对(`!has_root_path()`)则 `root/p`。`-I` 拼写仍走 `Dialect::includePrefix`。把 `flags.cppm:161-165` 那条**另一路** `includeDirs` 绝对化并入同一函数,消除双实现。
2. **发射端 shell 引号**(#234):`shell_quote_arg` 对含空格/shell 特殊字符的 token 加引号,在 `join_flags`(per-unit)与 `flags.cppm:312-331`(全局 blob)两个 choke point 逐 token 施加,叠加既有 ninja `$`/`:` 转义(`escape_flag_path`)。

**Before**(`scanner.cppm:341-348`):
```cpp
for (auto& f : flags) {
    if (f.size() > 2 && f.starts_with("-I")) {
        std::filesystem::path p(f.substr(2));
        if (!p.has_root_path()) f = "-I" + (root / p).string();
    }
}
```
**After**(结构):
```cpp
static constexpr std::string_view kIncPrefixes[] =
    {"-I","-iquote","-isystem","-idirafter","-iprefix","-L"};
for (std::size_t i = 0; i < flags.size(); ++i) {
    for (auto pre : kIncPrefixes) {
        if (flags[i] == pre && i+1 < flags.size()) {        // separated
            rewrite_rel(flags[i+1], root); ++i; break;
        }
        if (flags[i].size() > pre.size() && flags[i].starts_with(pre)) { // joined
            std::string tail = flags[i].substr(pre.size());
            std::string abs  = rewrite_rel_copy(tail, root);
            if (abs != tail) flags[i] = std::string(pre) + abs;
            break;
        }
    }
}
```

### Tasks
- [ ] **Step 1**:`test_build_flags.cpp` 加 `RewritesFullIncludeFamily`:输入 `["-Ihdr","-iquotehdr","-isystem","hdr","-idirafterhdr"]` + root `/proj`,断言四者路径都变 `/proj/hdr`(joined 拼回前缀、separated 改下一元素)。
- [ ] **Step 2**:`mcpp test` → FAIL(现只 `-I` 变)。
- [ ] **Step 3**:实现 `normalize_include_flags`,替换调用点(`scanner.cppm:618-620/633-635`、`plan.cppm:707-708`),并入 `flags.cppm:161-165` includeDirs 绝对化。
- [ ] **Step 4**:`mcpp test` 绿。
- [ ] **Step 5**:`test_ninja_backend.cpp` 加 `QuotesFlagValueWithSpace`:token `-DT=long long` 经 `join_flags` 后在命令行是**单参数**(断言输出含 `'-DT=long long'` 或等效引号形式,不含裸 ` long ` 断裂)。
- [ ] **Step 6**:`mcpp test` → FAIL。
- [ ] **Step 7**:实现 `shell_quote_arg`,施加于 `join_flags` + `flags.cppm:312-331`。
- [ ] **Step 8**:`mcpp test` 绿。
- [ ] **Step 9**:`tests/e2e/116_include_family_and_define_spaces.sh`:`hdr/magic.h` + `-iquotehdr` include 成功;`defines=["T=long long"]` 编译成功且 `typedef T x` 生效。并入 `run_all.sh`。
- [ ] **Step 10**:`mcpp test` + e2e 绿。
- [ ] **Step 11**:commit
```bash
git commit -m "fix(flags): rewrite full -I/-iquote/-isystem include family to project root + shell-quote flag values with spaces (#226 #234)"
```

---

## Commit 4 —— 簇 C:对象路径镜像 + 编译边 depfile(#233 #235)

**Files:**
- Modify: `src/modgraph/graph.cppm:15-29`(`SourceUnit` 加 `relPath`)、`src/modgraph/scanner.cppm`(scan 时填 `relPath`)
- Modify: `src/build/plan.cppm:426-462`(对象路径方案 + 唯一性断言)
- Modify: `src/build/ninja_backend.cppm:402-435`(cxx_module/cxx_object 加 depfile)
- Test: `tests/unit/test_dyndep.cpp`/`test_ninja_backend.cpp`、新 `tests/e2e/117_object_path_collision.sh`、`118_purview_include_rebuild.sh`

**Interfaces:**
- Produces:`SourceUnit.relPath`(源相对包根路径,scanner 填);对象路径 `obj/<pkg?>/<relDir>/<name>`。
- 顺序:**先对象路径唯一(#233)后 depfile(#235)**——`$out.d` 依赖 `$out` 唯一,否则 depfile 也撞。

### 设计
1. **#233 对象路径镜像源相对路径**(triage §4.3):`plan.cppm:450-462` 现按 `parent_path().filename()` 折叠必撞。scanner 掌握 root,故在 `SourceUnit` 存 `relPath = fs::relative(path, root)`;plan 镜像目录层级:
   **Before**(`plan.cppm:451-462`):
   ```cpp
   if (basenameCount[fname] > 1) {
       auto parentDir = u.path.parent_path().filename().string();   // 只取父目录名 → 撞
       auto prefix = ... sanitize(pkg) + "_" + parentDir;
       cu.object = "obj" / prefix / fname;
   } else cu.object = "obj" / fname;
   ```
   **After**:
   ```cpp
   // 镜像源相对路径,天然唯一;去掉 basenameCount 启发式
   auto relDir = u.relPath.parent_path();                            // a/src, b/src ...
   auto prefix = u.packageName.empty() ? fs::path{} : sanitize(u.packageName);
   cu.object = fs::path("obj") / prefix / relDir / fname;            // obj/<pkg>/a/src/util.o
   ```
   循环后加**唯一性断言**:`std::set` 收集所有 `cu.object`,冲突则返回可诊断错误(列出 colliders),把 ninja "multiple rules" 前移成 mcpp 层错误。
2. **#235 编译边 depfile**(triage §4.3 方案 2,根治整类 stale):`cxx_module`/`cxx_object` 在非 MSVC 下无任何 depfile(`ninja_backend.cppm:402-435`,`append_deps` 仅 msvc)。仿 nasm 规则(`:463-468`)给二者的 `$cxx` 命令加 `-MMD -MF $out.d`,规则加 `depfile = $out.d` + `deps = gcc`(非 msvc 分支)。这捕获**编译期全部** header/purview/GMF include,不止 #235 的 purview `.inc`,连"改普通头不重编"的潜伏 bug 一并根治。
   **After**(`cxx_object` 非 msvc):
   ```cpp
   append(std::format(
       "  command = $cxx $local_includes $cxxflags $unit_cxxflags -MMD -MF $out.d {}\n",
       compile_tail));
   append("  description = OBJ $out\n");
   if (!msvcDeps) { append("  deps = gcc\n"); append("  depfile = $out.d\n"); }
   else append_deps();
   ```
   `cxx_module` 同理(把 `-MMD -MF $out.d` 插进非 windows 的 BMI-restat `$cxx ...` 调用与 windows 分支命令,规则加 depfile/deps)。

### Tasks
- [ ] **Step 1**:`tests/e2e/117_object_path_collision.sh`:`a/src/util.cpp`+`b/src/util.cpp`+`src/main.cpp` 引用两者,`mcpp build` 成功(现 ninja "multiple rules generate")。
- [ ] **Step 2**:`bash tests/e2e/117...` → FAIL。
- [ ] **Step 3**:`SourceUnit` 加 `relPath`,scanner 填;plan.cppm 改对象路径为镜像 + 唯一性断言;确认 `.ddi/.dd`(派生自 `cu.object`)自动跟随。
- [ ] **Step 4**:`bash tests/e2e/117...` 绿 + `mcpp test` 绿(注意 `test_dyndep`/既有 obj 路径断言若硬编旧格式需同步更新)。
- [ ] **Step 5**:`tests/e2e/118_purview_include_rebuild.sh`:`m.cppm` purview `#include "vals.inc"`;`mcpp run` 得 41 → 改 vals.inc 41→42 → `mcpp run` 得 **42**(现 stale 得 41)。附加一条:改普通 `.h` 也重编。
- [ ] **Step 6**:`bash tests/e2e/118...` → FAIL(stale)。
- [ ] **Step 7**:给 cxx_module/cxx_object 加 `-MMD -MF $out.d` + `depfile`/`deps=gcc`(非 msvc)。
- [ ] **Step 8**:`bash tests/e2e/118...` 绿 + `mcpp test` 绿。两 e2e 并入 `run_all.sh`。
- [ ] **Step 9**:commit
```bash
git commit -m "fix(build): mirror source relative path in object paths + track compile-edge depfiles for purview/header includes (#233 #235)"
```

---

## Commit 5 —— 簇 D:条件源集统一 per-package 漏斗(#229)

**Files:**
- Modify: `src/build/prepare.cppm`:`merge_conditional_build`(`:345-362`)拆分;调用点重排(退役 `:792` 的 sources/flags 半 + `:1714`;新增 per-package 循环 `:2669-2711` 内调用;保留 `:800-802` 条件依赖 root-only)
- Test: 扩 `tests/unit/test_manifest.cpp` 或直接 e2e;新 `tests/e2e/119_dep_cfg_sources.sh`

**Interfaces:**
- Produces:`merge_conditional_sources_flags(Manifest&, ctx, triple)`(每包调用,只并 cfg 的 sources/flags);条件**依赖**合并留在 root 专属函数。

### 设计(triage §5.3)
建立"每包有效源集 = base + cfg + feature、按已解析 target 求值"的**唯一漏斗**,消除 path/git dep 漏评(#229 根因:`merge_conditional_build` 只在 root `:792` 与 version-dep `:1714` 调,path/git 分支 `:2447-2459` 不调)。做法:把 cfg 的 sources/flags 求值折进 feature sources 已有的 per-package 循环(root `:2689` + 每 dep `:2710`),与 `apply` 同点、扫描前;退役两个散点调用;条件依赖(`:800-802`,须在依赖解析前)留 root。

### Tasks
- [ ] **Step 1**:`tests/e2e/119_dep_cfg_sources.sh`:`mylib` 带 `[target.'cfg(linux)'.build] sources=["src/impl.cpp"]`,`app` 以 path dep `import mylib;` 调 `impl_value()`;`mcpp build`(现 undefined reference)+ `mcpp test` 双路径。
- [ ] **Step 2**:`bash tests/e2e/119...` → FAIL(undefined reference)。
- [ ] **Step 3**:拆 `merge_conditional_build` → `merge_conditional_sources_flags`(per-package)+ 条件依赖 root 函数;per-package 循环内对 `packages[0..n]` 调用;删 `:792` sources/flags 半与 `:1714`;保留 `:800-802`。
- [ ] **Step 4**:`bash tests/e2e/119...` 绿(build+test 双路径)+ 既有 `108_cfg_conditional_sources.sh`/`85_target_cfg_build_flags.sh`/`100_feature_sources_test_mode.sh` 回归绿(确认无双重合并)。并入 `run_all.sh`。
- [ ] **Step 5**:commit
```bash
git commit -m "fix(build): evaluate cfg-conditional sources for every package via one per-package funnel (path/git deps + test mode) (#229)"
```

---

## Commit 6 —— 簇 F:nasm 走工具链同款同步供给门(#232)

**Files:**
- Modify: `src/build/prepare.cppm:3019-3032`(nasm 消费边:换用 fetcher 门 + 修 guard 吞错)
- Modify: `src/xlings.cppm:1276-1295`(`ensure_nasm` 退役或委托)
- Test: 扩 `tests/unit/test_xlings.cpp`/`test_pm_package_fetcher.cpp`;e2e 复用 `105_asm_sources_nasm.sh`(冷环境变体)

### 设计(triage §7.3)
nasm 现走 bespoke 弱机制(无索引刷新前置、失败降 warning、`if(cfgNasm)` guard 吞真错)。改为走工具链同款同步门 `Fetcher::resolve_xpkg_path("xim:nasm", autoInstall=true, &progress)`(范式见 `prepare.cppm:851/958-959`,impl `package_fetcher.cppm:644-762`:先刷索引→阻塞 install→校验 payload→XLINGS_HOME 兜底),失败**硬错**;并修 guard:不静默丢 `get_cfg()` 的 bootstrap 错、config 失败时不跳过供给。`xlings::ensure_nasm` 保留为 PATH→sandbox 探测的薄封装或删。

**Before**(`prepare.cppm:3019-3032`):
```cpp
auto cfgNasm = get_cfg();
if (cfgNasm) nasmBin = mcpp::xlings::ensure_nasm(make_xlings_env(**cfgNasm), false, {});
if (!nasmBin) return std::unexpected("...no usable nasm...");
```
**After**(结构):
```cpp
auto cfgNasm = get_cfg();
if (!cfgNasm) return std::unexpected(cfgNasm.error());          // 不再吞错
if (auto sys = xlings::find_usable_nasm(**cfgNasm)) nasmBin = *sys;  // PATH/sandbox 快路
else {
    Fetcher f(...);                                             // 同工具链构造
    auto p = f.resolve_xpkg_path(nasm_pkg.target(), /*autoInstall=*/true, &progress);
    if (!p) return std::unexpected(std::format("nasm provisioning failed: {}", p.error()));
    nasmBin = xlings::find_sandbox_nasm(**cfgNasm);
}
if (!nasmBin) return std::unexpected("...no usable nasm...");
```

### Tasks
- [ ] **Step 1**:`test_xlings.cpp`/`test_pm_package_fetcher.cpp` 加断言:nasm 供给路径**调用** `resolve_xpkg_path`(可用一个可注入的 fetcher stub 或验证"索引刷新前置被触发");并断言 `get_cfg()` 错误被**透传**而非吞掉。
- [ ] **Step 2**:`mcpp test` → FAIL。
- [ ] **Step 3**:实现 fetcher 门替换 + guard 修复;抽 `find_usable_nasm`(PATH+sandbox)复用。
- [ ] **Step 4**:`mcpp test` 绿。
- [ ] **Step 5**:`105_asm_sources_nasm.sh` 加/改冷环境断言:临时空 `MCPP_HOME` + PATH 无 nasm → `mcpp build` 一次成功(供给同步阻塞完成),错误信息若失败须指明真实根因(非"no nasm")。
- [ ] **Step 6**:`bash tests/e2e/105...` 绿(有 nasm 沙箱的环境)。
- [ ] **Step 7**:commit
```bash
git commit -m "fix(nasm): provision nasm through the toolchain's synchronous resolve_xpkg_path gate (index refresh + hard error), stop swallowing config bootstrap errors (#232)"
```

---

## Commit 7 —— 簇 G:workspace 根锚点继承 + 默认命名空间重定向 + run 选成员(#224 R6 + run-p)

**Files:**
- Modify: `src/project.cppm:52-75`(`merge_workspace_deps` 传播 `path` + 根锚点)
- Modify: `src/build/prepare.cppm:577-579/600-602`(索引继承相对根)、`:1134-1145` + `:1297-1310`(默认命名空间查表)
- Modify: `src/manifest/toml.cppm:924-948`(`default`/`""` 索引键归一化到 `kDefaultNamespace`)
- Modify: `src/cli/cmd_build.cppm:99-107`(`cmd_run` 加 `--package`)、`src/build/execute.cppm:387-431`(`build_run_target` 加 `package_filter`)
- Test: `tests/unit/test_manifest.cpp`;新 `tests/e2e/120_ws_root_indices.sh`、`121_default_ns_redirect.sh`、`122_run_member.sh`

### 设计(triage §8)
同子系统三洞一并补(可选按 G1/G2/G3 拆三 commit,见文末):
1. **#224 根锚点继承**:`merge_workspace_deps`(`project.cppm:52-75`)现只抄 `version`。改为传播 `path`(记录"相对 workspace 根");`prepare.cppm:577/600` 继承索引时相对**workspace 根**解析 `path`(线程 wsRoot),而非成员目录。
2. **R6 默认命名空间重定向**:两处硬短路让 `[indices]` 对默认命名空间失效——
   **Before**:`prepare.cppm:1140` `if (ns == kDefaultNamespace) return true;`;`:1300` `if (ns.empty() || ns == kDefaultNamespace) return nullptr;`
   **After**:两处在返回 builtin/nullptr **前先查** `m->indices`——
   ```cpp
   // usesBuiltinIndex (1140):
   if (ns == kDefaultNamespace) {
       auto it = m->indices.find(std::string(kDefaultNamespace));
       return it == m->indices.end() || it->second.is_builtin();
   }
   // findIndexForNs (1300):
   if (ns.empty() || ns == kDefaultNamespace) {
       auto it = m->indices.find(std::string(kDefaultNamespace));
       return it == m->indices.end() ? nullptr : &it->second;
   }
   ```
   `toml.cppm:924-948` 把索引键 `default`(建议规范拼写)或 `""` 归一化为 `kDefaultNamespace` 存入 `m.indices`。
3. **run 选成员**:`cmd_run`(`cmd_build.cppm:99-107`)接入 `--package` + `resolve_member_dir`(与 build/test 同规则);`build_run_target` 加 `package_filter` 形参,在成员目录 scope 内解析并启动该成员二进制。

### Tasks
- [ ] **Step 1**:`test_manifest.cpp` 加 `DefaultNsIndexRedirect`:manifest 带 `[indices] default = {path="x"}` + 默认命名空间 dep,断言 `findIndexForNs("")` 返回该 spec(非 nullptr)。→ FAIL。
- [ ] **Step 2**:`mcpp test` → FAIL。
- [ ] **Step 3**:实现 toml 键归一化 + 两处短路查表。
- [ ] **Step 4**:`mcpp test` 绿。
- [ ] **Step 5**:`project.cppm` 传播 path + `prepare.cppm` 索引继承相对 wsRoot;`tests/e2e/120_ws_root_indices.sh`:root `[indices] x={path="mcpp"}` 一次声明,成员不重复声明即可解析;path dep 经 `workspace.dependencies` 可用。
- [ ] **Step 6**:`bash tests/e2e/120...` 绿。
- [ ] **Step 7**:`tests/e2e/121_default_ns_redirect.sh`:一个"模块包"风格工程(默认命名空间)+ `default={path=本地包}`,`mcpp build && mcpp run` 用本地 checkout 解析(不碰线上 index)。
- [ ] **Step 8**:`bash tests/e2e/121...` 绿。
- [ ] **Step 9**:`cmd_run` + `build_run_target` 加成员选择;`tests/e2e/122_run_member.sh`:workspace 两成员各一 bin,`mcpp run -p memberB` 跑 B 的二进制。
- [ ] **Step 10**:`bash tests/e2e/122...` 绿 + 三 e2e 并入 `run_all.sh` + `90_workspace_test.sh` 回归绿。
- [ ] **Step 11**:commit
```bash
git commit -m "feat(workspace): root-anchored path/index inheritance + default-namespace index redirect + \`mcpp run -p <member>\` (#224, R6)"
```

---

## Commit 8 —— 版本 bump 0.0.97 + CHANGELOG + 发布说明

**Files:**
- Modify: `src/toolchain/fingerprint.cppm:21`、`mcpp.toml:3`、`CHANGELOG`(若有)、`.agents/docs` 发布说明

### Tasks
- [ ] **Step 1**:`MCPP_VERSION` `0.0.96`→`0.0.97`;`mcpp.toml` version 同步。
- [ ] **Step 2**:`mcpp build`(self-host)成功,`mcpp --version` 打 0.0.97。
- [ ] **Step 3**:写 CHANGELOG:七簇一句话 + issue 号;注明 #230 已于 0.0.96 修、#215 不做。
- [ ] **Step 4**:全量 `mcpp test` + `bash tests/e2e/run_all.sh` 绿(host-aware)。
- [ ] **Step 5**:commit
```bash
git commit -m "chore(release): bump mcpp 0.0.96 -> 0.0.97 (scanner scope, flag model, build-graph identity, cfg funnel, nasm gate, workspace)"
```
> 发布运维(镜像/索引/pin bump)按 [记忆:release-publish-pipeline] 在 PR 合入后单独走,不在本 PR。**坑复盘**:publish-ecosystem 30min timeout(大件 gtc 补传)、squash `--delete-branch` 会 close 子 PR——本 PR 单分支单 PR 无叠栈,规避该坑。

---

## PR 描述模板(单 PR)

```
title: feat: 0.0.97 架构级修复 —— scanner 范围/flag 模型/构建图身份/条件源漏斗/nasm 供给门/workspace

本 PR 以架构级设计(非 workaround)一次修掉 #224/#225/#226/#227/#228/#229/#232/#233/#234/#235 + R6,
按"每一类问题一个 commit 修复节点"组织,末尾一个版本 commit。设计见
.agents/docs/2026-07-18-v0.0.97-architectural-remediation-implementation-plan.md。

Commits(逐簇):
- 簇E #225  scanner glob 收窄 + 边界排除 + run 复用缓存
- 簇B #227 #228  TOML 数组表 + glob 花括号
- 簇A #226 #234  include 族规范化 + 发射端 shell 引号
- 簇C #233 #235  对象路径镜像 + 编译边 depfile
- 簇D #229  条件源集统一 per-package 漏斗
- 簇F #232  nasm 走工具链同款同步供给门
- 簇G #224 R6  workspace 根锚点继承 + 默认命名空间重定向 + run 选成员
- release  bump 0.0.97

Closes #224 #225 #226 #227 #228 #229 #232 #233 #234 #235
Note: #230 已于 0.0.96(df985df)修复,windows CI 0.0.97 pin 复验后一并关闭;#215 不做(挂 upstream P2996)。
```

---

## 自审:spec 覆盖对照

| triage 簇/issue | 本计划 commit | 覆盖点 |
|---|---|---|
| E #225 | C1 | 前缀收窄 + 边界排除 + run 缓存快路径 ✅ |
| B #227 | C2 | libs/toml AOT + build.flags 消费 ✅ |
| B #228 | C2 | expand_braces 脱糖 ✅ |
| A #226 | C3 | include 族(joined/separated)统一重写 ✅ |
| A #234 | C3 | 发射端逐 token shell 引号 ✅ |
| C #233 | C4 | 对象路径镜像相对路径 + 唯一性断言 ✅ |
| C #235 | C4 | 编译边 -MMD depfile(含普通头额外收益)✅ |
| D #229 | C5 | per-package 条件源漏斗(build+test+path/git dep)✅ |
| F #232 | C6 | fetcher 同步门 + guard 修复 ✅ |
| G #224 | C7 | 根锚点继承 + path 传播 ✅ |
| G R6 | C7 | 默认命名空间查表重定向 ✅ |
| G run-p | C7 | cmd_run 成员选择 ✅ |
| #230 | — | 已 0.0.96 修,仅复验 ✅ |
| #215 | — | 不做 ✅ |

**类型一致性核对**:`glob_literal_prefix`/`expand_braces`(C1/C2 同在 scanner)、`normalize_include_flags`/`shell_quote_arg`(C3)、`SourceUnit.relPath`(C4 定义、plan 消费)、`merge_conditional_sources_flags`(C5)、`find_usable_nasm`(C6)、`build_run_target(package_filter)`(C7)命名前后一致。

## 待实施者/maintainer 决策(继承 triage §10)
1. **簇 G 粒度**:一个 commit(C7,推荐,同子系统)还是拆 G1/G2/G3 三 commit?本计划默认合一。
2. **R6 索引键拼写**:`default = {path}`(推荐)vs `"" = {path}` vs 两者都接受(归一化点在 `toml.cppm:924-948`)。
3. **簇 A**:确认采用"token 列表 + 两 pass"的忠实设计(本计划,取代 triage 的 `Arg` 结构草案)。
4. **执行方式**:subagent-driven(每 commit 一个 fresh subagent + 审查)vs 本会话 inline 批执行。
