fix(build): 多段 glob 源文件路径在 Windows 上保持原生分隔符 (#390) - #391
Conversation
manifest glob(如 `generated/modules/**/*.cppm`)用 `/` 拼路径; MSVC 的 std::filesystem::path 保留输入分隔符原样,于是 `root / prefix` 与目录迭代子路径都是混合形式 (`...\9bca0b44ae3aa660\generated/modules\ccc.when.cppm`), 一路流入 CompileUnit::source → compile_commands.json 的 file/-c 字段, CLion 拒绝解析。ninja 侧一律 generic_string()(全正斜杠)所以构建正常, CDB 是第一个 .string() 消费者,缺陷只在那里显现。 新增 mcpp::modgraph::native_path_from_generic(glob.cppm),在 glob_literal_prefix / expand_dir_glob / scan_one_into / 绝对 include dir 分支 / directives::abs_against 摄入点归一化;顺带修复 build.mcpp 指令 路径与 TOML 绝对 include dir(C:/SDL2/include)同类混合问题。 单测:GlobLiteralPrefixUsesNativeSeparators / NativePathFromGeneric / ExpandGlobMultiSegmentUsesNativeSeparators / ExpandDirGlobMultiSegmentUsesNativeSeparators。 e2e:76 增加多段 glob 源 + Windows 原生分隔符断言;47 增加无残留引号断言。
speak-agent
left a comment
There was a problem hiding this comment.
一、这个 PR 做了什么
根因诊断准确:MSVC 的 std::filesystem::path 不归一化构造串里的分隔符,于是 glob_literal_prefix("generated/modules/**/*.cppm") 返回的 generated/modules 原样带着 /,root / prefix 与其上的 recursive_directory_iterator 子路径就一路是 C:\...\generated/modules\x.cppm。ninja 侧一律 generic_string() 所以无感,compile_commands.cppm 是第一个 .string() 消费者,缺陷只在 CDB 显现 —— 这条因果链逐段核过,成立。
修法是「在摄入点归一化」,新增 mcpp::modgraph::native_path_from_generic,改了 5 处摄入点。
特别值得肯定的一点:同时改 directives.cppm::abs_against 不是顺手,而是必须。role=Source 的 action 输出经 abs_against 绝对化后被 adoptActionOutputs 塞进 modules.sources,再走 scan_one_into 的绝对分支。这两处只改一处,同一个文件就会拿到两种拼写 —— 而 ninja 是按声明字符串识别节点的,那是 "multiple rules generate / missing and no known rule" 级别的回归。这对耦合被一起改了。
二、主要问题
P1 — 同一决策还有第三、第四处推导,都没改,都直达 CDB
这正是这个仓库反复付学费的形状(「同一决策两处推导」)。这次是四处,PR 只改了两处:
(a) src/build/plan.cppm:379 expand_manifest_include_entry —— 第三份 include_dirs 摄入逻辑,两个洞:
if (inc.is_absolute()) return { inc }; // :382 TOML 里的 C:/SDL2/include 原样混合
...
if (expanded.empty() && glob.find('*') == npos)
expanded.push_back(root / inc); // :386 C:\proj + generated/inc → 混合第二个洞尤其要命:它的注释写着「为稍后由 build step 创建的目录保留裸拼接」—— 也就是 generated/ 这一类目录,恰好就是 #390 的场景。结果经 local_include_dirs_for_manifest → main_cu.localIncludeDirs(plan.cppm:1176)→ local_include_args → CDB 的 -I。
(b) src/modgraph/scanner.cppm:504 rewrite_rel_copy:
std::filesystem::path fp(p);
if (fp.has_root_path()) return p;
return (root / fp).string(); // :507[build] cxxflags = ["-Ithird_party/inc"] 经 normalize_include_flags 走到这里 → C:\proj\third_party/inc → 进 packageCxxflags → package_flag_args → CDB arguments。同一个缺陷,同一个终点。
建议:这四处应该收敛,而不是逐个打补丁。要么把 expand_manifest_include_entry 与 local_include_dirs_for 合并成一个函数(它们本来就是同一个决策),要么至少在 emit_compile_commands 里加一层兜底归一化(file / directory / -c / -o / -I 统一 .make_preferred())。后者只有几行,且能对 CDB 契约给出无条件保证 —— 不依赖「所有摄入点都被找全」这个不可证伪的前提。倾向两者都做:摄入点归一化保证 ninja 节点身份一致,emitter 归一化保证对外契约。
P2 — merge_compile_commands 不自愈:升级后旧的混合条目会被保留,并变成重复条目
compile_commands.cppm:229/241 用 file 字段的字面字符串做去重键:
freshFiles.insert(e["file"].get<std::string>()); // 新的:native
if (freshFiles.contains(f)) continue; // 旧的:mixed → 不命中
if (!fileExists(fs::path(f))) continue; // Windows API 吃混合分隔符 → 存在
merged.push_back(e); // → 保留被 #390 咬到的用户,工程根下已经有一份带 9 条混合路径的 compile_commands.json。升级后 mcpp build:新条目是原生的,旧的混合条目因为拼写不同而不被视作同一文件,又因为文件真实存在而不被剪掉 —— 于是同一个源文件出现两条,其中一条仍是 CLion 解析不了的那种。用户不手动删 CDB 就看不到修复效果。
PR 描述说实测「9 个混合条目全部归零」,这大概率是在干净 CDB 上量的。请在已有旧 CDB 的工程上复测一次。
修法很轻:比较时归一化(fs::path(f).lexically_normal().make_preferred().string()),或直接丢弃 file 同时含 \ 和 / 的历史条目。
P3 — e2e 76 的 Windows 断言很可能没跑(假绿),且没有断言目标源真的进了 CDB
两道门叠在一起:
if command -v python3 >/dev/null 2>&1; then # 门 1
...
if os.name == "nt" and "/" in e["file"]: # 门 2- 门 1:Windows CI 走
shell: bash(Git Bash)。仓库里现有的 Windows 判别一律是uname -s+MINGW*|MSYS*|CYGWIN*(02、118、147、199),没有先例证明python3这个名字在 Git Bash 下能解析到。解析不到 → 整段 JSON 校验(含新断言)静默跳过。 - 门 2:即使解析到,若拿到的是 MSYS 的 python,
os.name是'posix',断言再次静默跳过。
两道门都是跳过而不是失败,所以 e2e 76 在 Windows 上「通过」完全不能证明断言执行过。建议:
- 判据换成平台无关的,消掉
os.name依赖 —— 混合本身就是可判的:POSIX 上恒真,Windows 上恰好咬住这个 bug。f = e["file"] assert not ("\\" in f and "/" in f), "mixed separators: %r" % f
- 加一条
grep -q 'extra\.cpp' "$cdb"—— 否则多段 glob 一旦没匹配上,测试照样绿,而覆盖是零。这一条比断言本身更重要。 - 门 1 退化时至少
echo "SKIP: ...",别静默。
另外 76 里追加 sources = [...] 会把 src/main.cpp 显式 glob 进来,plan 走的是 packages[0].privateBuild 分支而非 local_include_dirs_for_manifest 分支 —— 对本测试目的无碍,但顺带说明:P1(a) 那条路径现在没有任何测试覆盖。
三、次要意见
native_path_from_generic 可以直接用标准库。 标准早就有 path::make_preferred(),语义完全一致,且作用在 native(宽字符)串上:
std::filesystem::path native_path_from_generic(std::string_view s) {
std::filesystem::path p(s);
p.make_preferred();
return p;
}手写字节替换本身没错(0x2F 不会是任何 Windows ANSI 代码页的尾字节),但没有理由不用现成的。
更值得改的是 inc.generic_string() 往返:prepare.cppm:3001/3025 与 scanner.cppm:695/714 对一个已经是 path 的绝对路径先转窄串再转回去。而这个文件顶上 path_matches_glob 的注释(mcpp#230)明明白白记着:MSVC 的窄转换在 ANSI 代码页拼不出该名字时会抛 std::system_error。这里既没有 try 也没有必要 —— 直接:
auto n = inc; n.make_preferred();
appendUniquePath(dirs, n);风险确实低(这些 path 本来就来自窄串 TOML,往返基本无损),但这是白拿的鲁棒性,而且这个项目的用户路径里出现中文的概率不低。
abs_against 的改动会改写 build.mcpp 指令路径的拼写,进而让声明输入的指纹一次性失效 → 一次多余重建。可接受,但值得在 PR 描述里写一句。
单测形状是对的(直接测 glob_literal_prefix 契约 + 端到端测 expand_glob/expand_dir_glob,并用 if constexpr 让 Windows 特有断言在 POSIX 上仍参与编译检查)。小冗余:GlobLiteralPrefixUsesNativeSeparators 第一行与 GlobLiteralPrefixDerivation 里改过的那行完全重复。
残留同类:prepare.cppm:5042 的 ${mcpp.out_dir} 替换把 outputDir.string()(原生)拼进用户写的 ${mcpp.out_dir}/gen/x.cppm,产出混合。这些 action 输出因为含 ${mcpp. 而不会被 adoptActionOutputs 采纳成源,所以够不到 CDB,但它是同一个类里未清的一员。可以另开 issue,不必阻塞本 PR。
四、其他
- 性能、安全性:无影响(纯路径拼写,零新增 I/O)。
- e2e 47 新增的两条 grep 守卫写法正确(
\\"在 BRE 里匹配字面反斜杠),放在 jq 门之前也对。属于顺带补的回归网,不反对。 - 这个修复只有随版本发布才能到 #390 报告者手里 —— 本 PR 没有 bump
MCPP_VERSION,按仓库惯例是攒到发版 PR,确认一下即可。
五、结论
方向正确,但建议先补完再合。 阻塞项两条:
- P1 —— 修复不完整。
plan.cppm:382/386与scanner.cppm:507是同一缺陷的另外两个出口,都直达 CDB 的-I,其中root / inc那条正好落在 #390 的generated/场景上。 - P3(2) —— e2e 76 必须断言
extra.cpp真的进了 CDB,否则这是一个覆盖可以静默归零的测试。
P2 建议一并处理(几行),否则受影响用户升级后症状不消失。P3(1) 与 make_preferred() 是明确改进,但不阻塞。
按 review(mcpp-community#391) 补全同一决策的全部推导点,不再依赖「所有摄入点都被找全」: - P1: 补上 plan.cppm expand_manifest_include_entry(绝对分支与 generated/ 裸拼接回退)、scanner.cppm rewrite_rel_copy(cxxflags 的 -Ithird_party/inc 通道)、flags.cppm [build] include_dirs 全局 cxxflags 通道;并在 emit_compile_commands 加最后一层兜底:file/directory/-c/-o/-I 统一 make_preferred,对 CDB 契约给出无条件保证。 - P2: merge_compile_commands 去重键改为归一化路径(lexically_normal + make_preferred),旧 CDB 里的混合分隔符条目与 fresh 原生拼写视为同一 文件 → 升级后第一次 build 即自愈,用户无需手删 compile_commands.json。 已在带旧条目的工程上实测:注入混合条目后重建,归零。 - P3: e2e 76 的 Windows 断言改为平台无关的「同时含 \ 与 / 即失败」 (消掉 os.name 依赖,避免 MSYS python 假绿);新增 extra.cpp 必须进 CDB 的 grep 守卫;python3 缺失时显式 SKIP。 - native_path_from_generic 改用标准库 make_preferred;include_dirs 绝对 分支不再做 generic_string 窄串往返(ANSI 代码页拼不出的名字会抛, mcpp#230)。 - 单测:NormalizedFileKeysHealStaleSeparatorSpellings(合并自愈)、 EmittedPathsUseNativeSeparators(emitter 兜底)、 Plan.ExpandManifestIncludeEntryNativeSpelling(plan.cppm 摄入点, expand_manifest_include_entry 为此从匿名命名空间提出并导出)。 注:directives::abs_against 归一化会改写 build.mcpp 指令路径的拼写, 声明输入指纹一次性失效 → 一次多余重建,属预期。
|
已按 review 补全,提交 661a7d1。逐条回应: P1(阻塞)—— 修复不完整:同意,已收敛全部推导点。除 PR 原两处外,补上:
P2 —— 合并不自愈:已修。 P3 —— e2e 76 假绿:三条全改。
次要意见:全部采纳。
|
speak-agent
left a comment
There was a problem hiding this comment.
Re-review @ 661a7d1
P1 / P2 / P3 的生产代码改动我逐处核过,都对:五处摄入点收敛齐了(含我漏提的 flags.cppm 第五处,补得好)、native_path_from_generic 换成 make_preferred()、generic_string() 窄串往返去掉、norm_key 的自愈逻辑正确、e2e 76 三条全改到位(extra.cpp 守卫 + 平台无关判据 + 显式 SKIP)。expand_manifest_include_entry 的前向声明确认落在 export namespace mcpp::build(plan.cppm:19)内,单测可见。
但新增的测试里有一条是惰性的,而且恰好是 P2 那条修复的唯一守卫。
F1(合入前应处理)—— NormalizedFileKeysHealStaleSeparatorSpellings 在两个平台上都没测到东西
auto p = std::filesystem::path("/p") / "generated" / "modules" / "a.cpp";
auto fresh = cdb({ entry(p.string(), "-O2-FRESH") });
auto existing = cdb({ entry(p.generic_string(), "-O0-STALE") });Windows:p.string() 是 /p\generated\modules\a.cpp。entry() 用 std::format 把它未经 JSON 转义直接拼进字符串字面量:
"file":"/p\generated\modules\a.cpp"
\g 不是合法的 JSON 转义(RFC 8259)。我用同一套转义规则实测确认:
PARSE ERROR -> Invalid \escape: line 1 column 30 (char 29)
于是 merge_compile_commands 的第一句就短路:
auto freshJ = nlohmann::json::parse(fresh, nullptr, /*allow_exceptions=*/false);
if (freshJ.is_discarded() || !freshJ.is_array())
return std::string(fresh); // ← 直接从这里返回merged == fresh,于是 count(merged,"generated")==1 ✓、-O2-FRESH 在 ✓、-O0-STALE 不在 ✓ —— 三条断言全部平凡通过,norm_key 一次都没被调用。
POSIX:p.string() == p.generic_string(),两个条目的 file 字节相同 —— 换回修改前的字面串键这个测试同样通过。
注释里已经诚实写了「On POSIX the two spellings are byte-identical」,但结论下反了:那不是「退化成 fresh-wins 契约」,而是自愈这条唯一的守卫在三平台都没有咬合。生产逻辑本身是对的(lexically_normal + make_preferred 双侧归一,我核过),问题纯在测试。
修法,两处一起:
entry()对file做 JSON 转义(或直接用nlohmann::json构造条目,别手拼)。- stale 拼写换成每个平台都需要归一化的形式,让
lexically_normal那一半也有覆盖:这样 Windows 测分隔符、POSIX 测auto stale = "/p/generated/./modules/a.cpp"; // POSIX 上也必须折叠才能命中
lexically_normal,两条腿都站住。
F2 —— emitter 兜底的覆盖面被高估了,而它的测试也是空转
PR 描述说兜底「对 CDB 契约给出无条件保证」。实际 emit_compile_commands 里只有这些位置过了 native_string:file / directory / output / -c 的值 / -o 的值 / local_include_args 的 -I、-idirafter。
没过的(compile_commands.cppm:205-210):
args.push_back(compiler.string()); // 编译器路径
for (auto& f : split_flags(flagStr)) ... // f.cxx —— [build] include_dirs 走 compute_flags 的那份 -I、
// 依赖的 -isystem、-fmodule-file=、-fprebuilt-module-path=、--sysroot=
for (auto& f : package_flag_args(cu, ...)) ... // packageCxxflags 里的 -I这些仍然只由摄入点保证。这不是缺陷(摄入点都补齐了),但「无条件」的说法会让下一个人以为兜底能兜住 argv 全部,建议把描述改成准确的:兜底覆盖 CDB schema 的路径字段 + emitter 自己构造的那几个 argv 位置,flag 串仍归摄入点负责(而且也只能如此——对任意 flag 载荷做路径归一化本来就不安全,比如 -DPATH="/etc/x")。
顺带,EmittedPathsUseNativeSeparators 里这个循环在当前 fixture 下遍历不到任何含路径的参数:
for (auto const& a : e["arguments"])
if (a.get<std::string>().starts_with("-"))
EXPECT_EQ(a.get<std::string>().find('/'), std::string::npos);flags.cxx 与 packageCxxflags 都是空的,arguments 只有 ["/usr/bin/g++", "-c", <file>, "-o", <output>],以 - 开头的只有 -c / -o 两个裸 token —— 断言恒真。建议 fixture 里填上 .localIncludeDirs = { "C:/inc/a" },断言那条 -I 是原生 —— 那才是兜底真正覆盖的部分。(另外这条规则不宜推广成「所有 - 开头参数不得含 /」,-DPATH="/etc/x" 就会误伤。)
F3 —— flags.cppm 里同一决策还剩最后两份未归一化的副本
flags.cppm:540/547,NASM 的 include 循环:
auto abs = inc.is_absolute() ? inc : (plan.projectRoot / inc); // :540
auto abs = ip.is_absolute() ? ip : (plan.projectRoot / ip); // :547与刚改好的 :325/:336 是同一个 join,同一个文件,相隔 200 行,写法当场不一致。
够不到 CDB(compile_commands.cppm:198 显式 if (cu.source.extension() == ".asm") continue;),所以不是本 issue 的缺陷 —— 但 Windows + NASM 的 ninja 命令行会留混合拼写,而且这正是本 PR 想终结的那类债。建议抽一个局部 helper 给四处共用:
auto abs_native = [&](const std::filesystem::path& inc) {
auto p = inc.has_root_path() ? inc : (plan.projectRoot / inc);
p.make_preferred();
return p;
};那才叫收敛;否则下一个人看到同文件两种写法,还得再判断一次哪种是对的。
F4 —— rewrite_rel_copy 的绝对分支新引入了一次窄串往返
if (fp.has_root_path()) {
fp.make_preferred();
return fp.string(); // path(p) 窄→宽,.string() 宽→窄
}改前是 return p;,零转换。本次提交刚在 prepare.cppm / scanner.cppm 删掉 generic_string() 往返并注明理由(mcpp#230:MSVC 的窄转换对 ANSI 代码页拼不出的名字会抛 std::system_error),这里等于又加回一次。
风险确实低(同码页往返、相对分支本来就有这个往返),不阻塞;只是与本提交自陈的原则不一致。想保守的话:仅当 p.find('/') != npos 时才重写,否则原样返回。
其他(提示,非缺陷)
plan.cppm把三个函数移出匿名 namespace —— 声明位置确认无误。但这个仓库有过 clang22 + modules 下匿名 namespace 结构变化导致邻居函数误编译的记录(PR#332 / #334,当时的约束是「别再往那个匿名 ns 加代码」)。本机绿之前别下结论,等三平台 CI。- 指纹失效范围比 PR 描述写的大:描述只提了
abs_against,但flags.cppm的-I拼写变化同样进 ninja 命令行 ⇒ Windows 上带多段include_dirs的工程升级后也是一次全量重建。建议描述里一并写上。 TEST(Plan, ExpandManifestIncludeEntryNativeSpelling)放在test_ninja_backend.cpp,且用固定名temp_directory_path()/"mcpp-plan-inc-entry"而非仓库惯用的make_tempdir—— 并发/残留下会互删。- 同一测试里
expand_manifest_include_entry(root, "C:/SDL2/include")在 POSIX 上走的是回退分支(C:/...在 POSIX 不是绝对路径),ASSERT_EQ(abs.size(), 1u)两平台都成立但成立的理由不同。无害,知道就行。
结论
方向和实现都对了。 唯一建议在合入前处理的是 F1 —— P2 那条自愈修复目前没有任何有效测试,而它恰恰是「用户升级后能不能看到修复」的那一条。F2 顺手把描述和 fixture 改准。F3 / F4 可跟随本 PR 也可另开,不阻塞。
prepare.cppm:5042 的 ${mcpp.out_dir} 残留同意另开 issue。
review 第二轮的四条:
1) NormalizedFileKeysHealStaleSeparatorSpellings 之前测不到东西。
测试 helper `entry()` 用 std::format 手拼 JSON,而 Windows 的
`file` 值带反斜杠 —— `...\generated\...` 里的 `\g` 不是合法 JSON
转义,整份 fixture 无法解析。merge_compile_commands 对解析失败的
回答是 `return std::string(fresh)`,于是三条断言在没有跑到 norm_key
的情况下全部通过。POSIX 上则是另一种空转:stale 用
p.generic_string(),与 fresh 的 p.string() 逐字节相同,换回修改前
的字面串键一样能过。
修法两处:entry() 改由 nlohmann 序列化(转义按构造保证);stale
拼写换成 `/p/generated/./modules/a.cpp` —— `/./` 在 POSIX 上也必须
经 lexically_normal 折叠才能与 fresh 对齐,Windows 上再叠一层
make_preferred。cdb() 里加一条 fixture 自检,让「拼出来的 JSON 不
可解析」这类空转以后直接红。
已验证先红:把 norm_key 临时退回字面串键,该测试在 Linux 上失败
(正是它原来空转的平台),恢复后 68/68。
2) emitter 兜底的范围说清楚。它覆盖 CDB schema 的路径字段与 emitter
自己构造的 argv 位置,不覆盖 split_flags(f.cxx) 与 package 的
cflags/cxxflags —— 对任意 flag 载荷做路径归一化本来就不安全
(`-DPATH="/etc/x"` 里的斜杠是真的)。注释按实际范围改写。
EmittedPathsUseNativeSeparators 里那个「以 - 开头的参数不得含 /」
的循环在原 fixture 下遍历不到任何带路径的参数(flags 全空,只剩
裸 -c/-o),换成填上 localIncludeDirs / localIncludeDirsAfter 后
逐条断言 -I 与 -idirafter,POSIX 分支也一并断言。
3) flags.cppm 的 include join 收敛成一个 abs_native lambda,四处共用
({include_dirs, include_dirs_after} × {C/C++ 通道, NASM 通道})。
NASM 那两处此前既没归一化分隔符,谓词也用的是 is_absolute() 而非
has_root_path() —— 同一个 manifest 键在两个通道产出不同的路径。
两者只在 Windows 的根相对拼法(`/x`)上有别,现在与编译器通道一致。
够不到 CDB(.asm 单元被 emitter 显式跳过),但这正是本 PR 要终结的
那类债。新增 NasmIncludeDirsMatchTheCxxChannelSpelling 钉住契约
(POSIX 上是防回归守卫,Windows 上才是新断言)。
4) rewrite_rel_copy 的绝对分支不再无谓往返:没有 `/` 可改时直接返回
原字节,不经 path 的窄转换 —— 那个转换对 ANSI 代码页拼不出的名字
会抛 std::system_error(mcpp#230),而本 PR 刚以同一理由删掉别处的
generic_string() 往返。
本机:mcpp build 自举通过,mcpp test 68/68,e2e 47 / 76 / 105 /
141 / 148 / 179 / 25 / 51 通过。
|
已按第二轮 review 直接在本分支推了 F1(主要) —— 修了两处成因:
F2 —— 兜底范围按实际改写(它覆盖 CDB schema 的路径字段 + emitter 自己构造的 argv 位置,不覆盖 F3 —— F4 —— 本机:自举构建通过,
|
Summary
compile_commands.json在 Windows 上对多段 glob 源(generated/modules/**/*.cppm)输出混合分隔符路径...\generated/modules\x.cppm,CLion 无法解析。根因:MSVC 的std::filesystem::path保留输入/原样,glob_literal_prefix用原始 glob 串构造 path 后,root / prefix与目录迭代把混合形式传入CompileUnit::source→ CDB 的file/-c(ninja 因一律generic_string()不受影响)。修复(含 review 补全)
modgraph/glob.cppm新增native_path_from_generic(make_preferred实现),应用于glob_literal_prefix/expand_dir_glob快速路径 /scan_one_into绝对源分支directives.cppm::abs_against(build.mcpp 指令路径;归一化会改写拼写 → 声明输入指纹一次性失效 → 一次多余重建,预期行为)plan.cppm::expand_manifest_include_entry(绝对分支 +generated/裸拼接回退 —— 正是 fix: Windows compile_commands.json 多段 glob 源路径混用分隔符,CLion 无法解析 #390 场景;该函数从匿名命名空间提出并导出以供单测)scanner.cppm::rewrite_rel_copy([build] cxxflags = ["-Ithird_party/inc"]通道)flags.cppm[build] include_dirs全局 cxxflags 通道make_preferred,不再generic_string()窄串往返(ANSI 代码页拼不出的名字会抛,mcpp#230)emit_compile_commands对file/directory/-c/-o/-I统一make_preferred,对 CDB 契约给出无条件保证。merge_compile_commands去重键改为归一化路径(lexically_normal+make_preferred),旧 CDB 的混合拼写与 fresh 原生拼写视为同一文件 → 升级后第一次真正重建即自愈,无需手删 CDB。已在注入旧条目的工程上实测(重建后混合条目归零)。测试
NormalizedFileKeysHealStaleSeparatorSpellings(合并自愈)、EmittedPathsUseNativeSeparators(emitter 兜底)、Plan.ExpandManifestIncludeEntryNativeSpelling、Scanner.RelativeIncludeFlagsAbsolutized平台化;另更新受归一化影响的既有断言(build_flags / ninja_backend)。extra.cpp必须入 CDB 的 grep 守卫 + 平台无关混合分隔符断言(消掉os.name依赖,避免 MSYS python 假绿)+ python3 缺失时显式 SKIP。Closes #390
Test plan
mcpp build自举成功(Windows, clang)mcpp test:68/68 通过(含新增单测)python复核正/反两例)