From ce86b4168997e98f87ff08b17e1f4aa1735ba8bd Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:21:08 +0800 Subject: [PATCH 1/3] test(modgraph): make the code-page defect fail, before fixing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit deliberately does NOT contain the one-line fix. It exists so the next one has something to prove. mcpp#516: on Windows `path::string()` converts the native (wide) name through the process ANSI code page and throws std::system_error for a character that code page cannot spell. `is_excluded_walk_dir` narrows a directory name on the FIRST line of the glob walk loop, so it fires before the guard #231 added to `path_matches_glob` one line later — which is why hardening that one could never cover a directory name. Two things here are supposed to be RED on this commit, and green on the next: * ci-windows Scanner.GlobWalkSurvivesNamesTheCodePageCannotSpell — proves the test can fail, rather than passing by accident. * ci-linux check_narrow_conversions.sh, naming scanner.cppm — proves the gate catches the real defect, not just the negative control I fed it while writing it. A test that has never been red does not prove anything, and neither does a gate that has only ever seen a synthetic violation. What this commit does contain: * mcpp::modgraph::try_narrow() — the ONE place a walk-derived path becomes a narrow string. Leaf-safe: modgraph imports only std, and nothing under src/modgraph or src/manifest imports mcpp.ui or mcpp.diag. * note_unnarrowable_path() / take_unnarrowable_paths() — records a skip, deduplicated to the nearest ancestor that CAN be named (never the offending name, which cannot enter a message without throwing the same exception). * cli::run scope guard draining those into mcpp.diag as `degraded` — silently dropping a file is the half-fix this whole change exists to avoid, and diag already requires an `impact` sentence for exactly this situation. * pack/digest.cppm: interface_set_digest folds in u8string(), not string(). Its input is an UNFILTERED recursive_directory_iterator over a published package, and the old spelling also made the digest depend on the host code page — a tree packed on Linux and verified on Windows could disagree about a non-ASCII name while every byte on disk was identical. ASCII names hash identically, so no published digest changes. * scaffold/template.cppm: same, for a third-party template provider's tree. * docs (EN + zh), the contributing invariant, and the CI gate. Refs #516, #230, #231 --- ...8-27-issue516-windows-acp-glob-walk-fix.md | 480 ++++++++++++++++++ .agents/skills/mcpp-contributing/SKILL.md | 34 ++ .github/tools/check_narrow_conversions.sh | 125 +++++ .github/workflows/ci-linux.yml | 15 + CHANGELOG.md | 64 +++ docs/05-mcpp-toml.md | 24 + docs/zh/05-mcpp-toml.md | 21 + mcpp.toml | 2 +- src/cli.cppm | 37 ++ src/modgraph/glob.cppm | 118 ++++- src/modgraph/p1689.cppm | 9 +- src/modgraph/scanner.cppm | 19 +- src/pack/digest.cppm | 31 +- src/scaffold/template.cppm | 23 +- src/version.cppm | 2 +- tests/unit/test_modgraph.cpp | 134 +++++ 16 files changed, 1116 insertions(+), 22 deletions(-) create mode 100644 .agents/docs/2026-08-27-issue516-windows-acp-glob-walk-fix.md create mode 100755 .github/tools/check_narrow_conversions.sh diff --git a/.agents/docs/2026-08-27-issue516-windows-acp-glob-walk-fix.md b/.agents/docs/2026-08-27-issue516-windows-acp-glob-walk-fix.md new file mode 100644 index 00000000..e95c2918 --- /dev/null +++ b/.agents/docs/2026-08-27-issue516-windows-acp-glob-walk-fix.md @@ -0,0 +1,480 @@ +# #516 解决方案:glob walk 在 Windows 上撞到 ANSI 代码页拼不出的目录名就崩 + +日期:2026-08-27 · 基线:`e187d3f`(origin/main,2026.8.27.1)· 发布版本:**2026.8.27.2** · 状态:**已决策,已实现** + +> **决策记录(2026-08-27,来自 review)** +> 1. 口径 **B** —— 跳过要报出来,不静默(§2) +> 2. **P0 + P1 同一个 PR** 全部实现,不拆 +> 3. §6.2 的独立 issue **保留**(P2 清单 + 项目根含非 ACP 的静默空构建) +> 4. **不做 httplib 端到端**,单元测试覆盖即可(§5.4 相应作废) +> +> 实现过程中有三处偏离本文初稿,都是测量推翻的,记在 §10。 + +> Issue: mcpp-community/mcpp#516 · 复现:mcpplibs/mcpp-index PR #260 +> 完整取证过程见 openxlings/xlings 仓 `.agents/docs/2026-08-27-mcpp516-windows-acp-path-analysis.md` +> xlings 侧的同类隐患(与本 issue 无关,独立修):openxlings/xlings#571 + +--- + +## 0. 三行结论 + +1. **不是解压问题。** 解压是成功的,文件以正确的 UTF-16 名字落盘; + 抛异常的是 **mcpp 自己**,在 `src/modgraph/scanner.cppm:238` 的 `dir.filename().string()`。 +2. **这是 #230 的同一处漏网。** #231 的 "never-throw on unnarrowable names" 加固了 + `path_matches_glob` / `rewrite_rel_copy` / `local_include_dirs_for` 三处, + **漏掉了同一个 walk 循环里比它们早一行执行的 `is_excluded_walk_dir`**。 + #516 看到的这条整洁报错,正是 #231 顺手加的顶层 catch 在正常工作。 +3. **爆炸半径不是一个包。** mcpp-index 128 个 recipe 里 **101 个(79%)**含以 `*` 开头的 glob, + 即"从解压根无界递归遍历"。任何上游 tarball 哪天多一个非 ASCII 文件名,当天 Windows 就红。 + +--- + +## 1. 缺陷 + +### 1.1 抛出点 + +```cpp +// src/modgraph/scanner.cppm:236-239 +bool is_excluded_walk_dir(const std::filesystem::path& dir, + const std::filesystem::path& root) { + auto name = dir.filename().string(); // ← 抛 + if (name == ".mcpp" || name == ".git" || name == "target") return true; +``` + +MSVC STL 的 `path::string()` → `__std_fs_convert_wide_to_narrow(ACP, …)`; +对非 UTF-8 代码页它传 `lpUsedDefaultChar`,一旦用了替换字符就返回 +`__std_win_error::_No_unicode_translation`(Win32 1113), +`_Throw_system_error_from_std_win_error` 抛 `std::system_error`。 +`system_error(ec)` 不带 what_arg 时 `what()` 就是 `ec.message()`,即 + +> `No mapping for the Unicode character exists in the target multi-byte code page.` + +与 issue 里的报错**逐字一致**。 + +该函数是 walk 循环体的**第一行**(`scanner.cppm:388` 和 `:483` 两处调用), +**每个目录条目调用一次** —— 所以它比后面所有已加固的站点都先执行, +加固后面三处对目录名根本无效。 + +### 1.2 怎么走到那棵树的 + +`compat.httplib.lua`(Form B)带 `include_dirs = { "*" }`, +经 `manifest/xpkg.cppm:1242` 落到 `buildConfig.includeDirs`, +由 `scanner.cppm:788` `local_include_dirs_for()` → `expand_dir_glob(verRoot, "*")`: + +``` +"*" 含通配符 → 不走字面量快路径 +glob_literal_prefix("*") → 通配符在第 0 位 → 前缀为空 +start = root → recursive_directory_iterator 遍历整棵 cpp-httplib 源码树 + → test/www/日本語Dir/ 必然被访问 +``` + +`scanner.cppm:796` 自己的注释把 `"*"` 称作 +"the `*` extracted-tarball-root glob convention" —— 这是**约定**,不是 httplib 的怪癖。 + +### 1.3 异常一路无人接手 + +`expand_dir_glob` → `local_include_dirs_for` → scanner → prepare → `cli::run` +全链路无 catch,最终由 `src/main.cpp:36` 的顶层 catch 接住并 exit 70。 + +### 1.4 归属反证(值得记住的一条) + +有人会怀疑是 xlings 解压时按 ANSI 写坏了名字。**恰恰相反**: +`ERROR_NO_UNICODE_TRANSLATION` 的前提是**宽名里存在 ACP 拼不出的字符**。 +若盘上是 mojibake(UTF-8 字节被 CP1252 逐字节打散),那些字符**逐个都在 CP1252 里**, +mcpp 反而不会抛。**mcpp 抛了,正好证明 xlings 写对了。** + +--- + +## 2. 必须先定的口径(A / B) + +收敛成唯一入口这件事,**必然要一次性定下"转不出来时怎么办"**,否则每个调用方还是各答各的。 + +**现状**(#231 留下的):`path_matches_glob` 的 try/catch 返回 `false` +→ 一个 CJK 命名的源文件**既不报错也不编译,它就是不存在**。 + +| | 行为 | 代价 | +|---|---|---| +| **A** | 保持静默丢弃 | 用户加了个 `模块.cppm`,构建"成功"、符号找不到、零提示 | +| **B** | 丢弃并 `ui::warn` 一次,指名 | 要给这条 warning 找一个不刷屏的去重点 | + +**推荐 B。** 理由不是洁癖:这条路径上"没发生"和"成功了"输出完全一样, +而 #230 → #516 之间隔了整整一个大版本才被再次发现,正是因为中间那段时间它只是**静默少编译**。 + +**本方案按 B 写。** 若定 A,§4.2 的 `emit_unnarrowable_warning()` 整块删掉即可,其余不变 —— +**P0 与口径无关**,可以先合。 + +--- + +## 3. P0 — 修掉抛出点 + +### 3.1 补丁 + +`src/modgraph/scanner.cppm:236-245`: + +```cpp +bool is_excluded_walk_dir(const std::filesystem::path& dir, + const std::filesystem::path& root) { + // 按 path 比较,不要窄化。 + // + // `filename().string()` 在 MSVC 上走宽→ANSI 转换,对当前代码页拼不出的名字 + // 抛 std::system_error(#230 是同一个转换,#516 是同一个转换的另一处站点)。 + // 而这个函数是 walk 循环体的第一行 —— 每个目录条目都经过它,所以它比 + // path_matches_glob 的 try/catch 更早执行,加固那里对目录名无效。 + // + // 三个字面量都是 ASCII,转成 native(Windows 上是宽)无损; + // path::operator== 比较的是 native 串,大小写敏感,行为与原来的窄串比较逐字一致。 + // 用静态常量而非每次构造临时 path,保住 #225 的 per-entry 开销预算。 + static const std::filesystem::path kMcpp{".mcpp"}; + static const std::filesystem::path kGit{".git"}; + static const std::filesystem::path kTarget{"target"}; + const auto name = dir.filename(); + if (name == kMcpp || name == kGit || name == kTarget) return true; + + auto const& submodules = submodule_paths(root); + if (submodules.empty()) return false; + std::error_code ec; + auto c = std::filesystem::canonical(dir, ec); + return submodules.contains(ec ? dir : c); +} +``` + +### 3.2 为什么这一处就够(下游已核过) + +修好之后,那条 walk 上**不再有**会碰到坏名字的窄化点: + +| 后续步骤 | 是否窄化 | +|---|---| +| `it.depth()` / `chain.resize` | 否 | +| `fs::canonical(e.path(), eec)` | `error_code` 重载,不抛 | +| `std::find(chain…)` / `std::set` | `path` 比较,原生串 | +| `path_matches_glob` | **已加固**(`glob.cppm:47-57`)→ 返回 `false` | +| `local_include_dirs_for` 收集结果 | 坏目录已被上一行判为不匹配,不在结果里 | + +即:坏名字被**挡在窄化世界之外**,后面拿不到它。 + +### 3.3 修好之后的行为 + +`日本語Dir` **不会**成为 include dir(`path_matches_glob` 判不匹配)。 +这是**可接受的既有语义**:一个 ACP 拼不出的名字,也没法写进 glob 或编译命令行。 +对 cpp-httplib 而言那是 `test/www/` 下的测试数据,不影响头文件消费。 + +--- + +## 4. P1 — 收敛(真正的修复) + +P0 只是第四个点补丁。再打点补丁,下次会有第五个站点。 + +### 4.1 两条规则,不是一条 + +- **规则 1:只与 ASCII 字面量比较的谓词,根本不该窄化** —— 按 `path` 比。 + (`is_excluded_walk_dir` 属于这条,所以 P0 里没用到 `try_narrow`。) +- **规则 2:必须产出窄串的站点,走唯一入口。** + +### 4.2 唯一入口 + +放进 `mcpp.modgraph.glob`(已有的"唯一 glob 匹配器"模块,`glob.cppm` 开头那段 +"两个匹配器'应该'一致是这个代码库反复付账的形状,所以只有一个" 的理由同样适用): + +```cpp +// 走查得到的路径 → 窄串。当前 ACP 拼不出该名字时返回 nullopt,**永不抛**。 +// +// 任何来自 directory_iterator 的 path 都必须经过这里,不得直接 .string()。 +// 非 Windows 上这是一次拷贝,永远有值。 +std::optional try_narrow(const std::filesystem::path& p) { + try { return p.generic_string(); } + catch (const std::exception&) { return std::nullopt; } +} +``` + +口径 B 的 warning(定 A 则删掉此块): + +```cpp +// 每个进程每个"不可拼写的父目录"只报一次,避免一棵树刷屏。 +void emit_unnarrowable_warning(const std::filesystem::path& p); +``` + +warning 文案必须**只用可拼写的部分**定位,否则打印它本身又会抛: + +``` +warning: skipped a path whose name cannot be represented in the active code page (1252) + under: + hint: 这些文件不会参与构建。`chcp` 改的是控制台代码页,不是进程 ACP。 +``` + +> 拼装这条消息时**不要**去窄化那个坏名字。向上找到第一个 `try_narrow` 成功的祖先, +> 报到那一级为止。这条不是洁癖 —— 是本类缺陷最容易复发的地方 +> (诊断代码自己碰同一个转换)。 + +### 4.3 审计范围(逐个追过输入来源) + +初稿我按文件名 grep 出一张表就交了,那不是测量。逐个追输入来源后砍掉两个误报: + +| 站点 | 输入来源 | 结论 | +|---|---|---| +| `modgraph/scanner.cppm:238` | 任意包/项目树的目录名 | **P0,确认触发** | +| `pack/digest.cppm:48` | `pack/prebuilt.cppm:144` 的递归走查 | **真** —— 打包路径不经 glob 过滤 | +| `scaffold/template.cppm:265` | 模板目录名 | **真**,仅当第三方模板目录含非 ACP 名 | +| ~~`build/resources.cppm:66`~~ | `RcTool::path.filename()` | **误报**:那是 `windres`/`rc.exe`/`llvm-rc`,payload 相对定位,全 ASCII | +| ~~`modgraph/p1689.cppm:339`~~ | `source.filename()` | **误报**:CJK 文件名到不了这里(`path_matches_glob` 已先判不匹配) | + +`p1689` 那处值得单说,因为它顺带答了一个会被问到的问题: +**"用户自己的 CJK 源文件今天会怎样?"** —— 不是崩,是**静默不编译**。 +真正能让 p1689 抛的是同一行的 `source.string()`(**整条绝对路径**,第 341 行): +当**项目根目录自身**含非 ACP 字符时(英文版 Windows + 中文项目路径)。 +但那种情况下 `path_matches_glob` 会先把所有源文件判为不匹配, +构建会先以"没有源文件"失败 —— 又一个"没发生和成功了输出一样"。 +**这一条不在本 PR 范围**,建议单独立 issue(见 §6.2)。 + +### 4.4 不变式与 CI 门 + +写进 `AGENTS.md`: + +> **任何来自 `directory_iterator` 的 `path` 都不得直接 `.string()` / `.generic_string()`。** +> 只与 ASCII 字面量比较时按 `path` 比;必须产出窄串时走 `mcpp::modgraph::try_narrow()`。 + +CI 门(只覆盖真正会走查任意树的目录,并**明说它的边界**): + +```bash +# tools/check-narrow-conversions.sh —— 只管 src/modgraph/,因为只有这里走查任意树。 +# 它挡的是"新写的代码直接窄化",挡不住"经由别处传进来的 path"—— +# 后者靠 §4.2 的唯一入口约定,不靠这个 grep。 +if grep -rnE '\.(filename|stem|extension)\(\)\.(generic_)?string\(\)' src/modgraph/ \ + | grep -v 'NARROW-OK:'; then + echo "FAIL: 直接窄化走查路径。用 try_narrow(),或加 // NARROW-OK: <理由>"; exit 1 +fi +``` + +> 这个门**不是**判据本身,只是让复发变贵一点。判据是 §5 的 Windows 测试。 +> 别把它写进验收标准里当"已覆盖"用。 + +--- + +## 5. 回归测试(这一节比补丁重要) + +### 5.1 一个必须先说清的前提 + +**在 Linux/macOS 上写的任何测试都无法证伪这个缺陷。** +那两个平台上 `path::string()` 只是拷贝 native 串,不做编码转换,任何文件名都"能表示"。 +所以:**测试必须跑在 Windows CI 上,且 runner 的 ACP ≠ 65001。** + +`ci-windows.yml` 的 `build + test + package (windows x64, self-host)` job 会跑 +`mcpp test`,`tests/unit/test_modgraph.cpp` 在其中 —— 这就是落点。 +`windows-latest` 默认 ACP=1252,满足。 + +**但不能默认它永远满足**:哪天 runner 镜像默认 UTF-8 ACP,这个 case 会**静默变成永远绿的装饰品**。 +所以测试自己必须探测,并在探测不到条件时 `GTEST_SKIP()` 且**说明原因**。 + +### 5.2 名字选择(一个容易踩的坑) + +用 `日本語Dir` 忠实于 issue,但它在 **CP932(日)/CP936(简中)/CP950(繁中)上是可拼写的** —— +在这些机器上测试会 skip,等于本地没有覆盖。 + +**天城文(Devanagari)不在任何 Windows ANSI 代码页里**,因此在**所有**非 UTF-8 ACP 下都不可拼写。 +两个都造:天城文保证覆盖,日文保证与 issue 同形。 + +用 `\uXXXX` 转义而非直接写字面量 —— 不依赖源文件编码设置(MSVC 需要 `/utf-8`,clang 默认 UTF-8; +这个测试不该因为哪天换了编译器就变味)。 + +### 5.3 测试代码 + +追加到 `tests/unit/test_modgraph.cpp`: + +```cpp +namespace { +// 当前 ACP 能否拼出这个宽名?能 → 说明 ACP 是 UTF-8(或该名字恰好在表内), +// 本用例的前提不成立。用 path 自己的转换来问,问的就是被测代码走的那条路。 +bool acp_can_spell(const std::wstring& w) { + try { (void)std::filesystem::path(w).string(); return true; } + catch (const std::exception&) { return false; } +} +} // namespace + +// #516 / #230:走查任意包树时,一个当前 ANSI 代码页拼不出的目录名 +// 曾让 is_excluded_walk_dir 的 .filename().string() 抛 std::system_error, +// 一路逃到 main() 变成 `error: internal: unhandled exception:` + exit 70。 +// +// 断言的是「不抛」且「walk 没被截断」,不是「能匹配到那个名字」—— +// 一个 ACP 拼不出的名字也没法写进 glob 或编译命令,判它不匹配是既有语义。 +TEST(Scanner, GlobWalkSurvivesUnnarrowableNames) { + // 用 \u 转义,不写字面量:这个测试不该因为哪天换了编译器或改了源文件编码 + // 设置(MSVC 要 /utf-8,clang 默认 UTF-8)就悄悄变味。 + const std::wstring devanagari = L"\u0915\u0916\u0917Dir"; // कखगDir:不在任何 ANSI 代码页 + const std::wstring japanese = L"\u65e5\u672c\u8a9eDir"; // 日本語Dir:与 #516 同形 + + if (acp_can_spell(devanagari)) { + GTEST_SKIP() << "active code page can spell any name (UTF-8 ACP or non-Windows); " + "this defect is unreachable here"; + } + + auto dir = make_tempdir("mcpp-scanner-acp"); + std::filesystem::create_directories(dir / std::filesystem::path(devanagari)); + std::filesystem::create_directories(dir / std::filesystem::path(japanese)); + // 好邻居:证明 walk 没有在坏条目处提前结束。 + write(dir / "zzz_ascii" / "x.h", "#pragma once\n"); + + // include-dir 通道(#516 的实际路径:include_dirs = { "*" }) + std::vector dirs; + ASSERT_NO_THROW({ dirs = expand_dir_glob(dir, "*"); }); + EXPECT_NE(std::find(dirs.begin(), dirs.end(), dir / "zzz_ascii"), dirs.end()) + << "walk 在不可拼写的条目处被截断了"; + + // 文件通道(installedLayoutMatchesIndex 走的那条:前缀为空 → 全树遍历) + std::vector files; + ASSERT_NO_THROW({ files = expand_glob(dir, "**/*.h"); }); + EXPECT_NE(std::find(files.begin(), files.end(), dir / "zzz_ascii" / "x.h"), files.end()); + + std::filesystem::remove_all(dir); +} +``` + +### 5.4 端到端那一层 + +单测锁住机制,但锁不住"真实上游 tarball + `include_dirs = {"*"}` 约定"这个组合。 +唯一能覆盖它的是 **mcpp-index 的 windows 矩阵跑 httplib 三个 example**(PR #260 本身)。 +建议:#260 合入后,把 `httplib` / `httplib-tls` / `httplib-zstd` 留在 windows 矩阵里, +**不要**以"windows 上不消费该 feature"为由把它们门控掉 —— 那会把这个覆盖点关掉。 + +--- + +## 6. 不做什么 + +### 6.1 不在 mcpp 侧"宽容处理"文件名 + +不要试图把不可拼写的名字转义/哈希后当成可用路径。它们最终要交给 +编译器驱动、ninja、link 命令行,而那些都是**独立进程、按各自的 ACP 解释字节**。 +在 mcpp 内部造一个"看起来能用"的名字,只会把失败推到更远、更难归因的地方。 + +### 6.2 P2(嵌 `activeCodePage=UTF-8` 清单)不进本 PR + +给 Windows 可执行文件嵌带 `UTF-8` 的清单, +`__std_fs_code_page()` 返回 CP_UTF8,`path::string()` 从此返回 UTF-8 且**永不抛**, +上面所有站点一次性全对。要求 Windows 10 1903+(CI runner 满足)。 + +**它不危险**:今天能跑通的路径全是 ASCII,而 ASCII 在 UTF-8 和 CP1252/936 下**字节完全相同** —— +build.ninja、CDB、命令行一个字节不变。 + +**真正的未知是"够不够"**:mcpp 写出 build.ninja 之后,是**另一个进程** +(xlings store 里的 `ninja.exe`,`config.cppm:192`)按**它自己的** ACP 去读。 +所以嵌清单只解决 mcpp 这一半;非 ASCII 路径能否端到端跑通,取决于 ninja 和编译器驱动那一侧。 + +**建议独立立 issue,验收写成"mcpp 不再抛这个异常",不要写成"支持 CJK 路径"。** +后者要单独测量,不能顺带宣称。 + +同一个 issue 里一并收 §4.3 提到的"项目根目录含非 ACP 字符 → 先以『没有源文件』失败"。 + +--- + +## 7. 落地顺序与验收 + +| 步 | 内容 | 验收 | +|---|---|---| +| 1 | P0 补丁(§3.1) | `ci-windows` 绿 | +| 2 | §5.3 单测 | **打补丁前必须红**,补丁后绿。CI 日志里能看到它**没有** skip | +| 3 | P1 收敛(§4.2/4.3)+ `AGENTS.md` 不变式 + CI 门 | 门在故意加一行裸 `.filename().string()` 时会红 | +| 4 | 真实复现验证 | mcpp-index PR #260 的 windows 矩阵三个 httplib 用例转绿 | + +**第 2 步的"打补丁前必须红"不能省。** 一个从来没红过的测试不证明任何事 +—— 先在打补丁前跑一次 `ci-windows`(或 workflow_dispatch),把那条 +`error: internal: unhandled exception: No mapping…` 留在日志里,再合补丁。 + +**第 4 步要注意归因**:mcpp-index 的 CI 会随索引发布漂移。 +验证时**重跑同一个未变更的 run id**,不要用一次新的 push 去比对 —— +否则"绿了"可能是索引变了,不是补丁起作用了。 + +### 不算验收的东西 + +- Linux/macOS 全绿:**与本缺陷无关**,那两个平台上它不可能发生。 +- CI 门通过:它只挡新写的直接窄化,不是判据(§4.4)。 +- 单测 skip 掉也算绿:**必须检查它真的跑了**。 + +--- + +## 8. 风险与回滚 + +- **P0 行为变化**:`path::operator==` 大小写敏感,与原来的 `std::string` 比较**逐字一致**; + 三个字面量全 ASCII,native 转换无损。**没有行为变化**,只是不再抛。 +- **性能**:静态 `path` 常量 + 三次 native 串比较,比原来"每条目一次 `std::string` 构造 + 三次比较" + **更便宜**。#225 的 per-entry 预算不受影响。 +- **回滚**:P0 是单函数改动,回滚即恢复原状(重新可崩)。 + P1 若引入问题,可先只回滚 §4.2 的 warning(口径 B → A),保留唯一入口。 + +--- + +## 9. 多角度拆分、依赖关系与跨仓协作 + +### 9.1 各角度落在哪一处改动上 + +| 角度 | 这次的具体决定 | +|---|---| +| **架构** | `src/modgraph/` 与 `src/manifest/` 是 leaf 层 —— **全仓 31 个模块 import `mcpp.ui`,这两层一个都没有**,`mcpp.diag` 亦然。所以 glob 层**记录**(`note_unnarrowable_path`),CLI 层**排空上报**。不为了少写十行而捅穿这条边界。 | +| **稳定性** | 抛出点消失,而不是被 catch 住。`try_narrow` 本身 `noexcept` 语义(内部 try),调用方拿 `nullopt`。 | +| **优雅简洁** | 不是第四个 try/catch,而是**按用途分三档的一条规则**(§4.1)。`is_excluded_walk_dir` 的正解是**根本不窄化**,比加保护更短也更快。 | +| **用户体验** | 口径 B:跳过按目录报告一次,走 `diag::degraded` 并给出 `impact`。`hint` 里点名 `chcp` 改的是控制台代码页 —— 那是用户第一个会试的东西。 | +| **兼容性** | `path::operator==` 与原来的窄串比较**逐字同解**;`interface_set_digest` 改用 `u8string()` 对纯 ASCII 名字**字节不变**,已发布包摘要不变。 | +| **跨平台** | 非 Windows 上 `try_narrow` 永不失败 → 一条记录都不会产生 → 行为与今天完全一致。`digest` 那处顺带修掉了一个**真实的跨平台不一致**(Linux 打包 / Windows 校验对非 ASCII 名字给出不同摘要)。 | +| **一致性** | 窄化只有一个答案者;报告只有一个 sink(`mcpp.diag`,它本就是全仓唯一的用户可见告警通道);排空只有一个点(`cli::run` 的 scope guard,覆盖全部五条 return)。 | +| **无感升级** | 没有配置项、没有新 flag、没有行为开关。ASCII 项目(几乎全部)的输出一个字节都不变。 | + +### 9.2 任务依赖 + +``` +T1 try_narrow + 记录/排空 API (glob.cppm) + ├─→ T2 P0: is_excluded_walk_dir 停止窄化 ← #516 的实际崩溃点 + ├─→ T3 其余站点归并 (path_matches_glob / scan_file 诊断 / digest / template) + ├─→ T4 单元测试 (依赖 T1 的 API 才能断言"记录到了") + └─→ T5 CI 门 (依赖 T2/T3 才可能绿) +T6 文档与规范 (docs/05 EN+zh 双份 —— check_docs_style 强制标题结构对齐) +T7 版本号 2026.8.27.1 → 2026.8.27.2 + CHANGELOG +T8 xlings pin 2026.8.17.2 → 2026.8.27.4 ← 跨仓,见 9.3;放在最后一个 commit +T2..T8 → T9 CI 全绿 → T10 自我 review → T11 release → T12 生态验证 +``` + +T1 是唯一的串行瓶颈;T2/T3/T4/T6/T7 之后可并行。 + +### 9.3 跨仓协作边界 + +| 仓 | 这次做什么 | 依赖 | +|---|---|---| +| `mcpp-community/mcpp` | 全部代码改动 + 发布 2026.8.27.2 | — | +| `openxlings/xlings` | **不改**。#516 不是 xlings 的缺陷(§1.4)。它自己的同类隐患另立 **openxlings/xlings#571** | 独立 | +| `mcpplibs/mcpp-index` | **不改**。按 review 决定不做 httplib 端到端(§5.4 作废),PR #260 在 mcpp 2026.8.27.2 发布后自然转绿 | 依赖 mcpp 发布 | + +**唯一一条真正的跨仓阻塞**:`kXlingsVersion` → `2026.8.27.4` 要求该版本**已发布**, +否则 CI 会去下载一个不存在的 `xlings-2026.8.27.4-` tarball。 +`.github/tools/check_version_pins.sh` 会强制 `.github/` 下全部 pin 点与常量一致, +所以这是一次**原子的多点改动**,放在最后一个 commit,发布确认之后再推。 + +### 9.4 "打补丁前必须红"怎么落地 + +分两个 commit 推,第一个**故意不含 P0**: + +- `ci-windows` 应当在 `Scanner.GlobWalkSurvivesNamesTheCodePageCannotSpell` 失败 + → 证明这个测试**能**失败(而不是恰好通过); +- `ci-linux` 应当在 `check_narrow_conversions.sh` 失败并点名 `scanner.cppm` 那一行 + → 证明这道门**能**抓到真实缺陷,而不只是抓到我构造的负例。 + +第二个 commit 打上 P0,两条腿转绿。一次推送拿到两份证据。 + +--- + +## 10. 实现推翻了初稿的三处 + +1. **`build/resources.cppm` 与 `modgraph/p1689.cppm` 是误报。** 初稿那张审计表是按文件名 + grep 出来的,不算测量。逐个追输入来源后:前者narrow 的是 `windres`/`rc.exe` 的文件名 + (全 ASCII),后者拿到的路径必然已过 glob 过滤。**真正漏网的是 `pack/digest.cppm`** + —— 它吃的是对已发布包的**未经过滤**的 `recursive_directory_iterator`,初稿没提。 +2. **CI 门的作用域必须收窄。** 第一版覆盖 `src/modgraph src/manifest src/pack src/scaffold`, + 一开就是 **22 个命中,其中约 20 个是假阳性** —— `src/pack` 窄化的是 mcpp 自己产生的 + 名字(staging root、built binary、strip artifact),`src/manifest` 只窄化 `.extension()`。 + 一道有二十个假阳性的门,一个月内必然被绕过,而那条豁免会变成"曾经有过规则"的唯一记录。 + 收窄到 `src/modgraph src/scaffold`,并在脚本头写明**通过 ≠ 已审计**。 +3. **口径 B 的实现不能用 `mcpp.log`。** 它的默认 level 是 `off`,`log::warn` 默认什么都不打印 + —— 那样口径 B 会退化成口径 A,而且看起来像做了。正确通道是 `mcpp.diag`, + 它的模块注释本就写着"`log::debug` 和 `log::verbose` 不算用户可见"。 + +### 一个已知的、没有测试覆盖的环节 + +`cli.cppm` 里那个 scope guard 的**接线**只由阅读保证:记录逻辑有单测 +(`Glob.UnnarrowablePathsDedupToTheirSpellableAncestor`),Windows 用例断言了"确实记录到了", +但"CLI 真的把它打出来了"没有自动化验证 —— 那需要一个 Windows e2e,而本次 review +明确把范围限定在单元测试。写在这里,而不是假装它被覆盖了。 diff --git a/.agents/skills/mcpp-contributing/SKILL.md b/.agents/skills/mcpp-contributing/SKILL.md index 9ab7c9d2..017978f7 100644 --- a/.agents/skills/mcpp-contributing/SKILL.md +++ b/.agents/skills/mcpp-contributing/SKILL.md @@ -278,6 +278,40 @@ docs/ ← 用户文档 .agents/skills/ ← Agent 技能文档 ``` +## 路径窄化不变式(走查得到的 path 不得直接 `.string()`) + +Windows 上 `std::filesystem::path::string()` 会把 native(宽)名经**进程 ANSI 代码页** +转换,遇到该代码页拼不出的字符就抛 `std::system_error`。**非 Windows 上同一个调用只是 +一次拷贝,永不失败**——所以这个隐患在 Linux/macOS 上(包括它们的测试里)完全不可见。 + +它已经付过两次代价,每次戴着不同的面具:#230 抛出后逃到 `std::terminate`,git-bash +显示为**裸 exit 127**(看起来像"命令找不到");#516 逃到 `main()` 的 catch,显示为 +`internal: unhandled exception`(看起来像下载器的**解压/编码缺陷**)。#231 加固了三处 +调用点,漏掉了同一个 walk 循环里**早一行**执行的第四处。 + +规则(按用途选,不是三选一的风格问题): + +| 用途 | 写法 | +|---|---| +| 与 ASCII 字面量比较 | **按 `path` 比**,根本不窄化 | +| 需要稳定身份(hash / key / digest) | `p.u8string()` —— 各平台都是 UTF-8,不碰代码页 | +| 需要交给编译器 / ninja / CDB | `mcpp::modgraph::try_narrow(p)`,并处理 `nullopt` | + +`try_narrow` 返回 `nullopt` 表示"这个文件没法出现在任何交给工具链的字符串里"。 +**跳过它,并且必须报出来**——`mcpp.diag` 的批次不变式对此已有规定:因为前提不满足而 +少做事,必须走 `diag::degraded()` 并给出 `impact`。静默丢弃是这类缺陷藏身的地方。 + +`src/modgraph/` 与 `src/manifest/` 是 leaf 层(全仓没有一条到 `mcpp.ui` / `mcpp.diag` +的 import 边),所以它们**记录**(`note_unnarrowable_path`),由 CLI 层排空上报。 + +`.github/tools/check_narrow_conversions.sh` 是硬门,但它只扫 `src/modgraph`、 +`src/scaffold`——**通过不等于已审计**。确有把握的站点用 `// NARROW-OK: <理由>` 标注, +理由必须写出"为什么这个输入不可能带这种名字"。 + +**测试只有跑在 Windows CI 上才有意义**,且必须自己检查 `GetACP()`:runner 镜像哪天默认 +UTF-8 ACP(65001),这类用例会静默变成永远绿的装饰品。参见 +`tests/unit/test_modgraph.cpp` 的 `Scanner.GlobWalkSurvivesNamesTheCodePageCannotSpell`。 + ## 注意事项 - C++23 模块项目,修改模块时注意 import 依赖顺序 diff --git a/.github/tools/check_narrow_conversions.sh b/.github/tools/check_narrow_conversions.sh new file mode 100755 index 00000000..6f4fd034 --- /dev/null +++ b/.github/tools/check_narrow_conversions.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# +# Guard: a path that came out of a directory walk is never narrowed directly. +# +# WHY +# +# On Windows `std::filesystem::path::string()` converts the native (wide) name +# through the process ANSI code page and THROWS std::system_error when a +# character has no spelling there — "No mapping for the Unicode character +# exists in the target multi-byte code page". Off Windows the same call is a +# copy that cannot fail, so nothing on Linux or macOS — including their tests — +# can see the hazard. +# +# It has cost two incidents, each wearing a different mask: +# +# #230 a walked index tree held a CJK-named issue template; the throw +# escaped to std::terminate → __fastfail → git-bash reported a bare +# exit 127, which reads as "command not found". +# #516 cpp-httplib ships test/www/Dir/ and the `include_dirs = { "*" }` +# convention walks the whole extracted tarball; the throw escaped to +# main()'s catch as `internal: unhandled exception`, which reads as an +# extraction/encoding bug in the downloader. +# +# #231 hardened three call sites and missed a fourth — `is_excluded_walk_dir`, +# which runs ONE LINE EARLIER in the same walk loop. A fifth site is what this +# script exists to make expensive. +# +# THE RULE +# +# * Comparing against ASCII literals? Compare as `path`. Do not narrow. +# * Need a stable identity (hash, key)? `u8string()` — UTF-8 everywhere, +# never touches the code page. +# * Need a build-facing string (compiler +# argument, ninja file, CDB)? `mcpp::modgraph::try_narrow()`, +# and handle the nullopt. +# +# WHAT THIS DOES AND DOES NOT CATCH +# +# It greps the leaf layers that walk trees mcpp does not control. It catches a +# NEW direct narrowing written there. It does NOT catch a path narrowed after +# being passed out to another layer — that is what the try_narrow convention is +# for, and no grep can enforce it. Do not read a pass here as "audited". +# +# `.extension()` is deliberately NOT matched: an extension is ASCII in every +# case that reaches these predicates, so matching it would produce only noise — +# and noise is how a gate gets suppressed. +# +# Escape hatch: `// NARROW-OK: ` on the line itself or within the two +# lines above it. Use it when the input provably cannot carry an unspellable +# name, and say why — a bare marker with no argument is worse than no gate, +# because it reads as "someone checked". +# +# Usage: bash .github/tools/check_narrow_conversions.sh [repo_dir] + +set -uo pipefail + +REPO_DIR="${1:-$(pwd)}" +cd "$REPO_DIR" || { echo "FAIL: cannot cd to $REPO_DIR" >&2; exit 1; } + +# SCOPE, and why it is this narrow. +# +# The hazard needs a path from a tree MCPP DOES NOT CONTROL. Two directories +# qualify: src/modgraph walks arbitrary package and project trees, and +# src/scaffold enumerates third-party template providers. +# +# The first draft of this guard also covered src/pack and src/manifest and +# produced 22 hits, ~20 of them false: src/pack narrows names MCPP ITSELF +# produced (staging roots, built binaries, strip artifacts — all derived from +# validated ASCII package/target names), and src/manifest only ever narrows an +# `.extension()`. A gate with twenty false positives is a gate that gets +# suppressed within a month, and the suppression then becomes the only record +# that a rule existed. The real hazards in those two directories were fixed by +# hand instead (pack/digest.cppm, which feeds on an unfiltered +# recursive_directory_iterator over a published package). +# +# So: a pass here does NOT mean "the tree is audited". It means no NEW direct +# narrowing was written where this class originates. +SCAN_DIRS="src/modgraph src/scaffold" + +PATTERN='\.(filename|stem)\(\)\.(generic_)?string\(\)' + +fail=0 +found=0 + +for dir in $SCAN_DIRS; do + [ -d "$dir" ] || { echo "FAIL: $dir does not exist — this guard has gone stale" >&2; exit 1; } + while IFS= read -r file; do + # Strip // line comments before matching: several of these files DESCRIBE + # the forbidden call in prose (that is the point of the comments), and a + # guard that trips on its own documentation gets deleted. + while IFS=: read -r lineno text; do + [ -n "${lineno:-}" ] || continue + found=1 + # NARROW-OK on the line itself, or on either of the two lines above it. + ctx=$(sed -n "$(( lineno > 2 ? lineno - 2 : 1 )),${lineno}p" "$file") + case "$ctx" in + *NARROW-OK:*) continue ;; + esac + echo "FAIL: $file:$lineno narrows a path directly:" >&2 + echo " ${text# }" >&2 + fail=1 + done < <(sed 's://.*::' "$file" | grep -nE "$PATTERN") + done < <(find "$dir" -type f \( -name '*.cppm' -o -name '*.cpp' -o -name '*.hpp' \) | sort) +done + +if [ "$fail" = 1 ]; then + cat >&2 <<'EOF' + + Use one of: + - compare as std::filesystem::path (ASCII literals; no narrowing) + - p.u8string() (stable identity: hashes, keys) + - mcpp::modgraph::try_narrow(p) (build-facing; handle nullopt) + or annotate with `// NARROW-OK: `. + + Background: mcpp#516, mcpp#230, src/modgraph/glob.cppm. +EOF + exit 1 +fi + +if [ "$found" = 0 ]; then + echo "ok: no direct path narrowing in $SCAN_DIRS" +else + echo "ok: every direct narrowing in $SCAN_DIRS carries a NARROW-OK rationale" +fi +exit 0 diff --git a/.github/workflows/ci-linux.yml b/.github/workflows/ci-linux.yml index 9a9d2d02..cbf6fb39 100644 --- a/.github/workflows/ci-linux.yml +++ b/.github/workflows/ci-linux.yml @@ -55,6 +55,21 @@ jobs: - name: Check version / xlings pin consistency run: bash .github/tools/check_version_pins.sh + # Same placement, same reason: pure text, no toolchain, under a second. + # + # This one is a HARD gate (unlike lint-ci-assertions.sh below) because it + # has no false positives left — its scope was cut to the two directories + # that walk trees mcpp does not control, and the one legitimate site + # carries a NARROW-OK rationale. See the script's header for why the + # scope is that narrow, and mcpp#516 for what it costs when it is missed. + # + # It runs on LINUX on purpose even though the bug it guards is + # Windows-only: it is text analysis, and putting it where the fast leg is + # means a violation is reported in seconds rather than after a Windows + # bootstrap. + - name: Check no walk-derived path is narrowed directly + run: bash .github/tools/check_narrow_conversions.sh + # Same placement, same reason: pure text, no toolchain. # # ⚠️ IT PRINTS AND DOES NOT FAIL, DELIBERATELY. The three rules it carries diff --git a/CHANGELOG.md b/CHANGELOG.md index 4819456e..f8d1f85e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,70 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [2026.8.27.2] — 2026-08-27 + +一个文件名把整个 Windows 构建打断了,而报错说的是别的事。完整分析见 +[`.agents/docs/2026-08-27-issue516-windows-acp-glob-walk-fix.md`](.agents/docs/2026-08-27-issue516-windows-acp-glob-walk-fix.md)。 + +⭐ **这是 `#230` 的同一处漏网,不是新缺陷。** `#231` 加固了三个窄化站点, +漏掉了同一个 walk 循环里**早一行**执行的第四处。 + +### 修复 + +- **⭐⭐ 一个当前代码页拼不出的目录名,会让 `mcpp` 在 Windows 上以内部错误退出。**(#516) + + `src/modgraph/scanner.cppm` 的 `is_excluded_walk_dir()` 用 + `dir.filename().string()` 取目录名。MSVC 的 `path::string()` 走 + `WideCharToMultiByte(ACP)`,遇到该代码页拼不出的字符就抛 `std::system_error`: + + ``` + error: internal: unhandled exception: No mapping for the Unicode character + exists in the target multi-byte code page. + ``` + + 该函数是 walk 循环体的**第一行**,每个目录条目过一次 —— 所以它比 `#231` + 加固过的 `path_matches_glob` **更早**执行,加固那里对目录名从来无效。 + + 触发条件比看上去宽:`include_dirs = { "*" }`(mcpp-index 里 128 个 recipe + 有 101 个含以 `*` 开头的 glob)会从解压根**无界递归遍历整棵上游源码树**。 + cpp-httplib 带了 `test/www/日本語Dir/`,于是 `httplib` / `httplib-tls` / + `httplib-zstd` 三个测试在 Windows 上一起挂 —— 而 Linux/macOS 全绿,因为那两个 + 平台上 `path::string()` 不做任何编码转换。 + + **报错指向的方向是错的**:它看起来像下载器的解压/编码缺陷。实际上解压是**对的** + —— `ERROR_NO_UNICODE_TRANSLATION` 的前提正是宽名里有 ACP 拼不出的字符; + 若真落成了 mojibake,反而不会抛。 + +- **窄化收敛成一条规则,而不是第四个 try/catch。**(#516) + + `mcpp::modgraph::try_narrow()` 是走查路径变成窄串的唯一入口。按用途分三档: + 与 ASCII 字面量比较 → **按 `path` 比,不窄化**;需要稳定身份(hash/digest)→ + `u8string()`;需要交给编译器/ninja/CDB → `try_narrow()` 并处理 `nullopt`。 + `.github/tools/check_narrow_conversions.sh` 是硬门(作用域被刻意收窄到 + `src/modgraph`、`src/scaffold` 两处 —— 第一版覆盖四个目录、22 个命中里 20 个是 + 假阳性,那样的门一个月内就会被绕过)。 + +- **跳过的文件不再是静默的。** 一个无法命名的条目会按目录报告一次,走 + `mcpp.diag` 的 `degraded` 通道(它的批次不变式本就要求"因前提不满足而少做事 + 必须给出 `impact`"): + + ``` + warning: '' contains names this system's active code page cannot represent + impact: those files take no part in the build + ``` + +- **`interface_set_digest` 不再依赖宿主代码页。** + + 它用 `.string()` 折入文件名,而输入是对已发布包 interface 目录的**未经过滤**的 + `recursive_directory_iterator` 走查。除了会抛,它还让**在 Linux 上打包、在 + Windows 上校验**的同一棵树对非 ASCII 名字给出不同摘要 —— 表现为 + "does not match what was packaged",本文件能产生的最吓人的诊断。 + 改用 `u8string()`(各平台同一串字节);纯 ASCII 名字字节不变,已发布包的摘要不变。 + +### 其他 + +- 内部依赖的 xlings pin 升至 `2026.8.27.4`。 + ## [2026.8.27.1] — 2026-08-27 目标侧被解析出来了,只发给了一个编译单元。完整分析见 diff --git a/docs/05-mcpp-toml.md b/docs/05-mcpp-toml.md index ff02455a..6d6cd860 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -621,6 +621,30 @@ skipped). Limits: `.asm` targets x86 only (hard error elsewhere — gate the files off other targets), `.S` is unavailable on the MSVC toolchain, and `.asm` means NASM syntax (MASM sources should be `!`-excluded). +### File names outside the host code page + +Globs are narrow strings, and so are compile commands and `build.ninja`. On +Windows those strings are produced in the process's **ANSI code page**, so a +file whose name has no spelling in that code page cannot be matched by a glob, +named on a compile command, or written into a build file. + +Such entries are skipped, and the skip is reported once per directory: + +```text +warning: 'C:\...\pkg\test\www' contains names this system's active code page cannot represent + impact: those files take no part in the build + hint: Windows only: this is the process ANSI code page, which `chcp` does not change. ... +``` + +`chcp` sets the *console* code page and has no effect here. Names that are only +test data or documentation are harmless — an upstream tarball carrying a +Japanese-named fixture directory builds fine on an en-US host. Sources are not: +they need renaming, or a host whose code page covers them. + +Linux and macOS perform no such conversion, so nothing is skipped there. A +package that builds on one and not the other, with an +`internal: unhandled exception` from a code-page message, was mcpp#516. + ### 2.4 `[lib]` — Library Root Module Convention ```toml diff --git a/docs/zh/05-mcpp-toml.md b/docs/zh/05-mcpp-toml.md index cbf38566..c5d60e5a 100644 --- a/docs/zh/05-mcpp-toml.md +++ b/docs/zh/05-mcpp-toml.md @@ -545,6 +545,27 @@ nasm 则**硬失败**(汇编绝不静默跳过)。限制:`.asm` 仅限 x86 目 报错——用条件 sources 门控)、MSVC 工具链不支持 `.S`、`.asm` 即 NASM 语法 (MASM 源请用 `!` 排除)。 +### 宿主代码页之外的文件名 + +glob 是窄字符串,编译命令和 `build.ninja` 也是。在 Windows 上这些字符串由进程的 +**ANSI 代码页**产生,因此一个名字在该代码页里无法拼写的文件,既匹配不了 glob,也 +写不进编译命令或构建文件。 + +这类条目会被跳过,并按目录报告一次: + +```text +warning: 'C:\...\pkg\test\www' contains names this system's active code page cannot represent + impact: those files take no part in the build + hint: Windows only: this is the process ANSI code page, which `chcp` does not change. ... +``` + +`chcp` 改的是**控制台**代码页,对此无效。若这些名字只是测试数据或文档,跳过是无害 +的——上游 tarball 里带一个日文夹具目录,在 en-US 宿主上照样构建。源文件则不然:需要 +改名,或换一台代码页覆盖得了的机器。 + +Linux 与 macOS 不做这种转换,因此那里不会跳过任何名字。一个包在一边能构建、在另一 +边报 `internal: unhandled exception` 并指向代码页,就是 mcpp#516。 + ### 2.4 `[lib]` — 库根模块约定 ```toml diff --git a/mcpp.toml b/mcpp.toml index a592ecf8..edb63bf4 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.8.27.1" +version = "2026.8.27.2" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/cli.cppm b/src/cli.cppm index 2ad20c05..4cbe8320 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -33,6 +33,8 @@ import mcpp.platform.env; // --offline → MCPP_OFFLINE import mcpp.platform.runtime_search; // linker-wrapper path-injection opt-out import mcpp.ui; import mcpp.log; +import mcpp.diag; // the single sink for the report below +import mcpp.modgraph.glob; // take_unnarrowable_paths() export namespace mcpp::cli { @@ -95,8 +97,43 @@ void print_usage() { std::println("Docs: https://github.com/mcpp-community/mcpp/tree/main/docs"); } +// The ONE place this run's "could not be named in the active code page" +// records are reported. +// +// `src/modgraph/` and `src/manifest/` are leaf layers — not one module in +// either imports `mcpp.ui` or `mcpp.diag` — so the glob walk RECORDS +// (mcpp::modgraph::note_unnarrowable_path) and the CLI reports. A scope guard +// rather than a call before `return` because run() has five exits, and the one +// added next year would silently drop the report — which is precisely the +// failure shape this whole change is about. +// +// Reported as `degraded`, not `warning`: mcpp.diag's batch invariant is that a +// branch doing LESS because a precondition was not met owes the user an +// `impact` sentence. Skipping files is doing less. +// +// Known boundary: both `diag::flush(strict)` call sites live inside the build +// path and run before this guard fires, so `--strict` does not promote these +// to errors. Deliberate — the alternative is a second drain point, i.e. a +// second answerer for the same question. +struct ReportUnnarrowablePaths { + ~ReportUnnarrowablePaths() { + for (auto const& anchor : mcpp::modgraph::take_unnarrowable_paths()) { + mcpp::diag::degraded( + "path/codepage", + std::format("'{}' contains names this system's active code " + "page cannot represent", anchor), + "those files take no part in the build", + "Windows only: this is the process ANSI code page, which " + "`chcp` does not change. Harmless when the names are test " + "data or docs; if they are sources, rename them or build on a " + "system whose code page covers them."); + } + } +}; + int run(int argc, char** argv) { namespace cl = mcpplibs::cmdline; + ReportUnnarrowablePaths reportUnnarrowable_; // ─── --quiet / --no-color: pre-scan ───────────────────────────────── // The cmdline lib propagates global options into nested subcommand diff --git a/src/modgraph/glob.cppm b/src/modgraph/glob.cppm index 7d929c54..e9d93947 100644 --- a/src/modgraph/glob.cppm +++ b/src/modgraph/glob.cppm @@ -32,6 +32,64 @@ std::filesystem::path native_path_from_generic(std::string_view s) { return p; } +// ─── narrowing a walk-derived path ──────────────────────────────────────── +// +// THE ONE PLACE a path that came out of a directory walk becomes a narrow +// string. Everything else in the tree calls this; nothing calls +// `.string()` / `.generic_string()` on such a path directly. +// +// Why it needs to exist at all: on Windows `path::string()` converts the +// native (wide) name through the process's ANSI code page and THROWS +// `std::system_error` when a character has no spelling there — +// "No mapping for the Unicode character exists in the target multi-byte code +// page". Off Windows the same call is a copy that cannot fail, so this whole +// hazard is invisible on Linux and macOS — including to their tests. +// +// It has cost two incidents, wearing a different mask each time: +// +// #230 a walked index tree held `bug-report---问题反馈.md`; the throw +// escaped to std::terminate → `__fastfail(0xC0000409)` → git-bash +// reported a bare **exit 127**, which reads as "command not found". +// #516 cpp-httplib ships `test/www/日本語Dir/`, and the `include_dirs = +// { "*" }` convention walks the whole extracted tarball; the throw +// escaped to main()'s catch as `internal: unhandled exception`, +// which reads as an **extraction/encoding bug in the downloader**. +// +// #231 hardened three sites against it and missed a fourth +// (`is_excluded_walk_dir`, which runs one line EARLIER in the same walk) — +// which is why this is now a single function with a CI gate behind it +// (tools/check-narrow-conversions.sh) rather than a fourth try/catch. +// +// nullopt means: this path cannot be named in any string we hand to a +// compiler, a build file, or a glob. Skip it — and record it, because +// "silently not built" is exactly where this class of bug hides. +std::optional try_narrow(const std::filesystem::path& p) { + try { + return p.generic_string(); + } catch (const std::exception&) { + return std::nullopt; + } +} + +// Record that something had to be skipped because `try_narrow` could not name +// it. Deduplicated to the nearest ANCESTOR that CAN be named: one unreadable +// subtree produces one record, not one per file. +// +// What is stored is that ancestor — never the offending name, which by +// definition cannot be put into a message without throwing the very exception +// this module exists to avoid. (Diagnostic code walking into its own trap is +// the most likely way this regresses.) +void note_unnarrowable_path(const std::filesystem::path& p); + +// Take and clear this run's records. +// +// modgraph is a leaf layer — no module under `src/modgraph/` or +// `src/manifest/` imports `mcpp.ui` or `mcpp.diag` — so it RECORDS and the +// CLI reports. Drained in exactly one place (`cli::run`'s scope guard), which +// is what keeps "recorded but never shown" from becoming the next silent +// failure. See docs & AGENTS.md. +std::vector take_unnarrowable_paths(); + // Does `candidate` match `glob`, interpreted relative to `root`? // // Supports "**" (any number of directory levels) and "*" (within one segment). @@ -43,16 +101,15 @@ bool path_matches_glob(const std::filesystem::path& candidate, const std::filesystem::path& root, std::string_view glob) { - std::string rel; - try { - rel = candidate.lexically_relative(root).generic_string(); - } catch (const std::exception&) { - // MSVC's narrow conversion throws std::system_error when the native - // (wide) name has no spelling in the ANSI codepage (e.g. a CJK - // filename on an en-US host — mcpp#230 hit this on an issue template - // inside a walked index tree). Such a name can never be spelled in a - // glob or a compile command either: not a match, and never a reason to - // tear down the whole build. + // lexically_relative is pure path arithmetic and cannot throw; the + // narrowing is the part that can, so it is the part that goes through + // try_narrow. + auto rel = try_narrow(candidate.lexically_relative(root)); + if (!rel) { + // A name the code page cannot spell can never match a glob (a glob is + // a narrow string) and could never reach a compile command either. + // Not a match — and not a reason to tear down the whole build. + note_unnarrowable_path(candidate); return false; } @@ -91,7 +148,46 @@ bool path_matches_glob(const std::filesystem::path& candidate, }; return rec(0, 0); }; - return match(rel, glob); + return match(*rel, glob); } } // namespace mcpp::modgraph + +// ── implementation ────────────────────────────────────────────────────────── + +namespace mcpp::modgraph { +namespace { + +// A run's worth of unnarrowable subtrees, keyed by their nearest spellable +// ancestor. `std::set` so the report comes out in a stable order regardless of +// directory-enumeration order, which the standard leaves unspecified. +std::mutex g_unnarrowableMu; +std::set g_unnarrowable; + +} // namespace + +void note_unnarrowable_path(const std::filesystem::path& p) { + // Climb to the first ancestor this code page CAN spell. `p` itself fails + // by construction; usually exactly one component is at fault, so the + // parent already succeeds. + std::string anchor; + for (auto dir = p.parent_path();; dir = dir.parent_path()) { + if (auto s = try_narrow(dir)) { anchor = std::move(*s); break; } + if (dir.parent_path() == dir) break; // reached the root, still unspellable + } + // Every component was unspellable (or `p` was a bare relative name). Say so + // rather than reporting an empty path, which reads as a bug in the report. + if (anchor.empty()) anchor = "(a path this code page cannot spell)"; + + std::lock_guard lk(g_unnarrowableMu); + g_unnarrowable.insert(std::move(anchor)); +} + +std::vector take_unnarrowable_paths() { + std::lock_guard lk(g_unnarrowableMu); + std::vector out(g_unnarrowable.begin(), g_unnarrowable.end()); + g_unnarrowable.clear(); + return out; +} + +} // namespace mcpp::modgraph diff --git a/src/modgraph/p1689.cppm b/src/modgraph/p1689.cppm index 028a4595..a591c641 100644 --- a/src/modgraph/p1689.cppm +++ b/src/modgraph/p1689.cppm @@ -336,7 +336,14 @@ scan_file(const std::filesystem::path& source, // One unique name per source — flat layout in tmpDir is fine since // the caller gives us a fresh dir per build. - auto stem = source.filename().string(); + // `source` is always a glob-filtered path. path_matches_glob drops any + // candidate try_narrow() cannot spell, so a name this code page has no + // spelling for never reaches a scan — and a PROJECT ROOT that cannot be + // spelled makes every candidate fail that same filter, so the build stops + // at "no sources" well before here. What this feeds is a compiler command + // line anyway: a path that cannot be narrowed cannot be handed to a + // compiler at all. (mcpp#516 audit) + auto stem = source.filename().string(); // NARROW-OK: glob-filtered, see above auto base = tmpDir / std::format("{}_{}", std::hash{}(source.string()) % 1000000, stem); auto ddi = base; ddi += ".ddi"; diff --git a/src/modgraph/scanner.cppm b/src/modgraph/scanner.cppm index 9a983373..90f460db 100644 --- a/src/modgraph/scanner.cppm +++ b/src/modgraph/scanner.cppm @@ -597,6 +597,14 @@ std::expected scan_file(const std::filesystem::path& file // have meant" a module interface would be a second answer to the same // question — the very thing that produced this defect. if (u.kind == mcpp::SourceKind::Other) { + // Narrow through try_narrow even here. Everything that reaches + // scan_file today came through a glob filter, so the conversion cannot + // actually fail — but a DIAGNOSTIC that throws the exception it is + // describing is the single most likely way this class of bug comes + // back, and it costs one `value_or` to make that impossible. + auto nameNarrow = try_narrow(file.filename()).value_or( + "(a name this code page cannot spell)"); + auto extNarrow = try_narrow(file.extension()).value_or(""); return std::unexpected(ScanError{ file, 0, std::format( "'{}' is listed in [build] sources, and mcpp has no role for the " "extension '{}'.\n" @@ -608,9 +616,9 @@ std::expected scan_file(const std::filesystem::path& file " Otherwise remove it from `sources` — headers belong in " "`include_dirs`, and\n" " Windows resource scripts in `[resources]`.", - file.filename().string(), - file.extension().string().empty() ? "(none)" : file.extension().string(), - file.extension().string().empty() ? ".ixx" : file.extension().string()) }); + nameNarrow, + extNarrow.empty() ? "(none)" : extNarrow, + extNarrow.empty() ? ".ixx" : extNarrow) }); } // C-like files are not C++ modules: they cannot legally contain `module` / `import` @@ -1012,7 +1020,10 @@ void scan_one_into(ScanResult& result, if (ext.empty()) continue; bool seen = false; for (auto const& f : all_files) - if (f.extension().string() == ext) { seen = true; break; } + // Compare as paths — `ext` is ASCII, so it converts to the native + // representation losslessly, and no walk-derived name is narrowed + // (see mcpp::modgraph::try_narrow). + if (f.extension() == ext) { seen = true; break; } if (!seen) { result.warnings.push_back(ScanError{root, 0, std::format( "[build] module_extensions declares '{}' but no source file " diff --git a/src/pack/digest.cppm b/src/pack/digest.cppm index 60917d04..13e0c566 100644 --- a/src/pack/digest.cppm +++ b/src/pack/digest.cppm @@ -43,9 +43,38 @@ std::string file_digest(const std::filesystem::path& p) { return "fnv1a:" + mcpp::toolchain::hash_file(p); } +namespace { + +// The file NAME as platform-independent UTF-8 bytes. +// +// NOT `.string()`, for two independent reasons: +// +// 1. On Windows `.string()` converts through the process ANSI code page and +// THROWS std::system_error for a name that code page cannot spell. This +// input is an UNFILTERED `recursive_directory_iterator` walk of a +// published package's interface directory (prebuilt.cppm), so a package +// shipping one non-ASCII filename would abort the build with +// "No mapping for the Unicode character exists…" — mcpp#516's failure +// class, at a site that never went through a glob filter. +// 2. It makes the digest DEPEND ON THE HOST. A package digested on Linux +// (UTF-8 bytes) and verified on Windows (ANSI bytes) would disagree about +// a non-ASCII name while every byte on disk was identical — a false +// "does not match what was packaged", which is the most alarming +// diagnostic this file can produce. +// +// `u8string()` is UTF-8 on every platform and never touches the code page. For +// ASCII names — every package published to date — the bytes are identical, so +// no existing digest changes. +std::string name_utf8_(const std::filesystem::path& p) { + auto u8 = p.filename().u8string(); + return std::string(reinterpret_cast(u8.data()), u8.size()); +} + +} // namespace + std::string interface_set_digest(const std::vector& files) { std::vector> byName; - for (auto const& f : files) byName.emplace_back(f.filename().string(), f); + for (auto const& f : files) byName.emplace_back(name_utf8_(f), f); std::ranges::sort(byName, {}, &std::pair::first); std::string acc; diff --git a/src/scaffold/template.cppm b/src/scaffold/template.cppm index 09bfda2e..51853759 100644 --- a/src/scaffold/template.cppm +++ b/src/scaffold/template.cppm @@ -137,16 +137,25 @@ struct TemplateMeta { std::expected load_meta(const std::filesystem::path& templateDir) { + // u8string(), not string(): `templateDir` comes from a directory walk of a + // third-party template provider, and on Windows `.string()` throws for a + // name the active code page cannot spell (mcpp#516). A diagnostic that + // throws the exception it is trying to report is the worst version of this + // bug, and these two are diagnostics. + auto dirNameU8 = templateDir.filename().u8string(); + auto dirName = std::string(reinterpret_cast(dirNameU8.data()), + dirNameU8.size()); + auto metaPath = templateDir / "template.toml"; if (!std::filesystem::exists(metaPath)) { return std::unexpected(std::format( - "template '{}' has no template.toml", templateDir.filename().string())); + "template '{}' has no template.toml", dirName)); } auto doc = mcpp::libs::toml::parse_file(metaPath); if (!doc) { return std::unexpected(std::format( "template '{}': bad template.toml: {}", - templateDir.filename().string(), doc.error().message)); + dirName, doc.error().message)); } TemplateMeta meta; @@ -262,7 +271,15 @@ list_templates(const std::filesystem::path& packageRoot) { } continue; } - auto templateName = e.path().filename().string(); + // u8string(), not string(): this is an unfiltered directory walk of a + // third-party template provider, and on Windows `.string()` throws for + // any directory name the active code page cannot spell (mcpp#516). + // The name is validated as "one ASCII atom" three lines down, so a + // non-ASCII name still gets refused — it now gets refused with the + // message that explains it, instead of aborting the whole command. + auto nameU8 = e.path().filename().u8string(); + auto templateName = std::string(reinterpret_cast(nameU8.data()), + nameU8.size()); auto parsedName = mcpp::pm::parse_package_selector(templateName); if (!parsedName || parsedName->namespace_) { return std::unexpected(std::format( diff --git a/src/version.cppm b/src/version.cppm index bc5da918..dfc26637 100644 --- a/src/version.cppm +++ b/src/version.cppm @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.8.27.1"; +inline constexpr std::string_view MCPP_VERSION = "2026.8.27.2"; } // namespace mcpp diff --git a/tests/unit/test_modgraph.cpp b/tests/unit/test_modgraph.cpp index 25d36678..23bae543 100644 --- a/tests/unit/test_modgraph.cpp +++ b/tests/unit/test_modgraph.cpp @@ -883,3 +883,137 @@ TEST(Scanner, RelativeIncludeFlagsAbsolutized) { std::filesystem::remove_all(dir); } + +// ─── mcpp#516 / #230: names the active code page cannot spell ────────────── +// +// On Windows `path::string()` converts the native (wide) name through the +// process ANSI code page and throws std::system_error when a character has no +// spelling there. OFF Windows the same call is a copy that cannot fail — so +// the defect is NOT FALSIFIABLE on Linux or macOS, and the Windows leg of CI +// is the only place the last test below means anything. It says so out loud +// (GTEST_SKIP with a reason) rather than passing vacuously. + +namespace { + +// Can the active code page spell this name? Asked through path's own +// conversion, i.e. the exact call the code under test makes. Doubles as the +// guard against a future runner image whose default ACP is UTF-8 (65001), +// which would otherwise quietly turn the regression test into decoration. +[[maybe_unused]] bool acp_can_spell(const std::wstring& w) { + try { (void)std::filesystem::path(w).string(); return true; } + catch (const std::exception&) { return false; } +} + +} // namespace + +// The recorder: one unreadable subtree produces ONE record, keyed by the +// nearest ancestor that CAN be named — never by the offending name, which +// cannot be put into a message without throwing the very exception being +// reported. Platform-independent logic, so this runs everywhere. +TEST(Glob, UnnarrowablePathsDedupToTheirSpellableAncestor) { + (void)take_unnarrowable_paths(); // other tests in this binary walk too + + const std::filesystem::path base = "/pkg/test/www"; + note_unnarrowable_path(base / "bad" / "a.txt"); + note_unnarrowable_path(base / "bad" / "b.txt"); + note_unnarrowable_path(base / "other.txt"); + + auto notes = take_unnarrowable_paths(); + ASSERT_EQ(notes.size(), 2u); // ".../www/bad" and ".../www" — not three files + EXPECT_NE(std::find(notes.begin(), notes.end(), (base / "bad").generic_string()), + notes.end()); + EXPECT_NE(std::find(notes.begin(), notes.end(), base.generic_string()), + notes.end()); + + // take() clears: the next command reports afresh rather than replaying. + EXPECT_TRUE(take_unnarrowable_paths().empty()); +} + +// A non-ASCII directory name must not disturb the walk on ANY platform. This +// one is reproducible everywhere (on Windows the UTF-8 bytes land as whatever +// the ACP makes of them, which still exercises the walk). +TEST(Scanner, GlobWalkHandlesNonAsciiNames) { + auto dir = make_tempdir("mcpp-scanner-nonascii"); + // U+65E5 U+672C U+8A9E in UTF-8, spelled as bytes so the test does not + // depend on the source file's encoding. Split before "Dir" because a C++ + // hex escape is greedy — "\x9ED" would be one (out-of-range) escape. + std::filesystem::create_directories( + dir / "\xE6\x97\xA5\xE6\x9C\xAC\xE8\xAA\x9E" "Dir"); + write(dir / "zzz_ascii" / "x.h", "#pragma once\n"); + + std::vector dirs; + ASSERT_NO_THROW({ dirs = expand_dir_glob(dir, "*"); }); + EXPECT_NE(std::find(dirs.begin(), dirs.end(), dir / "zzz_ascii"), dirs.end()); + + std::vector files; + ASSERT_NO_THROW({ files = expand_glob(dir, "**/*.h"); }); + EXPECT_NE(std::find(files.begin(), files.end(), dir / "zzz_ascii" / "x.h"), + files.end()); + + std::filesystem::remove_all(dir); +} + +// mcpp#516 proper: a directory name the ACTIVE CODE PAGE cannot spell used to +// throw out of `is_excluded_walk_dir`'s `.filename().string()` — the first +// line of the walk loop, which is why #231 hardening `path_matches_glob` (one +// line later) could never cover it. The throw escaped to main() as +// error: internal: unhandled exception: No mapping for the Unicode +// character exists in the target multi-byte code page. +// and exit 70. +// +// Asserts (a) the walk does not throw, (b) it is not TRUNCATED at the bad +// entry, and (c) the skip was RECORDED — silently dropping the file is the +// half-fix this whole change exists to avoid. +TEST(Scanner, GlobWalkSurvivesNamesTheCodePageCannotSpell) { +#ifndef _WIN32 + GTEST_SKIP() << "path::string() performs no encoding conversion off Windows; " + "mcpp#516 is not reproducible here"; +#else + // Devanagari is in NO Windows ANSI code page, so this case stays live on + // every non-UTF-8 ACP. The Japanese name mirrors mcpp#516's real input + // (cpp-httplib's test/www/Dir), but CP932/936/950 CAN spell it, so on + // Japanese and Chinese hosts it alone would silently prove nothing. + // + // Built from explicit code units rather than a literal: the test must not + // depend on the source file's encoding, nor on /utf-8 reaching the + // compiler, nor on a checkout preserving the bytes. + // U+0915 U+0916 U+0917 = DEVANAGARI KA KHA GA + const std::wstring unspellable{wchar_t(0x0915), wchar_t(0x0916), wchar_t(0x0917), + L'D', L'i', L'r'}; + // U+65E5 U+672C U+8A9E = the three han characters in mcpp#516's directory + const std::wstring japanese{wchar_t(0x65E5), wchar_t(0x672C), wchar_t(0x8A9E), + L'D', L'i', L'r'}; + + if (acp_can_spell(unspellable)) { + GTEST_SKIP() << "the active code page can spell any name (UTF-8 ACP); " + "mcpp#516 is unreachable on this host"; + } + + auto dir = make_tempdir("mcpp-scanner-acp"); + std::filesystem::create_directories(dir / std::filesystem::path(unspellable)); + std::filesystem::create_directories(dir / std::filesystem::path(japanese)); + // A good neighbour, to prove the walk continued past the bad entry. + write(dir / "zzz_ascii" / "x.h", "#pragma once\n"); + + (void)take_unnarrowable_paths(); + + // The include-dir channel — mcpp#516's actual path (`include_dirs = {"*"}`, + // whose literal prefix is empty, so the walk starts at the package root). + std::vector dirs; + ASSERT_NO_THROW({ dirs = expand_dir_glob(dir, "*"); }); + EXPECT_NE(std::find(dirs.begin(), dirs.end(), dir / "zzz_ascii"), dirs.end()) + << "the walk was truncated at the unspellable entry"; + + // The file channel — installedLayoutMatchesIndex's + // `expand_glob(verRoot, "mcpp.toml")` walks the whole tree the same way. + std::vector files; + ASSERT_NO_THROW({ files = expand_glob(dir, "**/*.h"); }); + EXPECT_NE(std::find(files.begin(), files.end(), dir / "zzz_ascii" / "x.h"), + files.end()); + + EXPECT_FALSE(take_unnarrowable_paths().empty()) + << "the skipped entries were not recorded, so nothing would be reported"; + + std::filesystem::remove_all(dir); +#endif +} From fade10f4c26d0cc51f44f4217a2f05e65ebed1f5 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:33:41 +0800 Subject: [PATCH 2/3] fix(modgraph): stop narrowing the walk's directory names; pin xlings 2026.8.27.4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fix the previous commit exists to prove. `is_excluded_walk_dir` compared a directory name against three ASCII literals by first converting it to a narrow string. On Windows that conversion goes through the process ANSI code page and throws for anything the code page cannot spell. The comparison never needed a narrow string: `path::operator==` compares native representations, the three literals are ASCII and convert losslessly, and the decision is byte-for-byte the one the narrow comparison made. Static path constants keep the per-entry cost below what it was (#225 bounded this walk on purpose, and this is its hot path). Evidence from the previous commit, both on real runners: ci-linux failed at `Check no walk-derived path is narrowed directly`, naming src/modgraph/scanner.cppm:238. ci-windows Scanner.GlobWalkSurvivesNamesTheCodePageCannotSpell failed with Actual: it throws std::system_error with description "No mapping for the Unicode character exists in the target multi-byte code page." — word for word what #516 reported, reproduced from `expand_dir_glob(dir, "*")`, which is the exact call the `include_dirs = { "*" }` convention makes. The test did NOT skip, so the runner's ACP is not UTF-8 and the guard is live. 92 other tests passed, so nothing else was disturbed. Also here: * The report drain in cli.cppm is a function-try-block. A destructor is implicitly noexcept, and run() can be left by an exception — main() catches one. A change whose whole subject is "an uncaught exception must not end the build" does not get to introduce a second one in its own reporting path. * xlings pin 2026.8.17.2 → 2026.8.27.4 across all 17 live pin sites (check_version_pins.sh enforces they equal `pinned::kXlingsVersion`). The three remaining 2026.8.17.2 strings in bootstrap-mcpp/action.yml are NOT pins — they are recorded measurements ("Measured on the 2026.8.17.2 assets", "Cache hit for: …xl2026.8.17.2…") and rewriting them would falsify a record of what was actually observed. All four platform assets for v2026.8.27.4 were verified to exist before bumping. * Two comments pointed at files that do not exist (`tools/check-narrow- conversions.sh`, `AGENTS.md` — mcpp keeps its conventions in .agents/skills/). Found in self-review; a reference that cannot be followed is the same defect class as a printed command that cannot be run. Closes #516 --- ...8-27-issue516-windows-acp-glob-walk-fix.md | 7 ++++--- .github/actions/bootstrap-mcpp/action.yml | 2 +- .github/actions/setup-macos-llvm/action.yml | 2 +- .github/workflows/bootstrap-macos.yml | 2 +- .github/workflows/ci-fresh-install.yml | 6 +++--- .github/workflows/ci-linux-e2e.yml | 2 +- .github/workflows/cross-build-test.yml | 4 ++-- .github/workflows/release.yml | 14 ++++++------- src/cli.cppm | 9 +++++++- src/modgraph/glob.cppm | 5 +++-- src/modgraph/scanner.cppm | 21 +++++++++++++++++-- src/platform/xlings/xlings.cppm | 2 +- 12 files changed, 51 insertions(+), 25 deletions(-) diff --git a/.agents/docs/2026-08-27-issue516-windows-acp-glob-walk-fix.md b/.agents/docs/2026-08-27-issue516-windows-acp-glob-walk-fix.md index e95c2918..ad8fc9e9 100644 --- a/.agents/docs/2026-08-27-issue516-windows-acp-glob-walk-fix.md +++ b/.agents/docs/2026-08-27-issue516-windows-acp-glob-walk-fix.md @@ -227,7 +227,8 @@ warning: skipped a path whose name cannot be represented in the active code page ### 4.4 不变式与 CI 门 -写进 `AGENTS.md`: +写进 `.agents/skills/mcpp-contributing/SKILL.md`(**初稿写的是 `AGENTS.md` —— mcpp 没有 +这个文件,那是 xlings 的习惯**;贡献规范在 skill 里): > **任何来自 `directory_iterator` 的 `path` 都不得直接 `.string()` / `.generic_string()`。** > 只与 ASCII 字面量比较时按 `path` 比;必须产出窄串时走 `mcpp::modgraph::try_narrow()`。 @@ -235,7 +236,7 @@ warning: skipped a path whose name cannot be represented in the active code page CI 门(只覆盖真正会走查任意树的目录,并**明说它的边界**): ```bash -# tools/check-narrow-conversions.sh —— 只管 src/modgraph/,因为只有这里走查任意树。 +# .github/tools/check_narrow_conversions.sh —— 作用域收窄的理由见脚本头。 # 它挡的是"新写的代码直接窄化",挡不住"经由别处传进来的 path"—— # 后者靠 §4.2 的唯一入口约定,不靠这个 grep。 if grep -rnE '\.(filename|stem|extension)\(\)\.(generic_)?string\(\)' src/modgraph/ \ @@ -370,7 +371,7 @@ build.ninja、CDB、命令行一个字节不变。 |---|---|---| | 1 | P0 补丁(§3.1) | `ci-windows` 绿 | | 2 | §5.3 单测 | **打补丁前必须红**,补丁后绿。CI 日志里能看到它**没有** skip | -| 3 | P1 收敛(§4.2/4.3)+ `AGENTS.md` 不变式 + CI 门 | 门在故意加一行裸 `.filename().string()` 时会红 | +| 3 | P1 收敛(§4.2/4.3)+ 贡献规范不变式 + CI 门 | 门在故意加一行裸 `.filename().string()` 时会红 | | 4 | 真实复现验证 | mcpp-index PR #260 的 windows 矩阵三个 httplib 用例转绿 | **第 2 步的"打补丁前必须红"不能省。** 一个从来没红过的测试不证明任何事 diff --git a/.github/actions/bootstrap-mcpp/action.yml b/.github/actions/bootstrap-mcpp/action.yml index 6b50301b..4b498c4c 100644 --- a/.github/actions/bootstrap-mcpp/action.yml +++ b/.github/actions/bootstrap-mcpp/action.yml @@ -25,7 +25,7 @@ inputs: # `package.name`, so one of the two was simply unreachable — and which one # depended on the machine, which is why CI failed on `compat:lua` on # Windows and `mcpplibs.capi:lua` on Linux. Never pin below that. - default: '2026.8.17.2' + default: '2026.8.27.4' cache-target: description: also restore/save target/ (build artifacts + BMIs) required: false diff --git a/.github/actions/setup-macos-llvm/action.yml b/.github/actions/setup-macos-llvm/action.yml index 3cfdd438..f9aa8a4b 100644 --- a/.github/actions/setup-macos-llvm/action.yml +++ b/.github/actions/setup-macos-llvm/action.yml @@ -15,7 +15,7 @@ inputs: # Floor imposed by the index, not a routine bump — see # .github/actions/bootstrap-mcpp/action.yml for why 0.4.69 is required # (two packages named `lua` in one repo need openxlings/xlings#381). - default: '2026.8.17.2' + default: '2026.8.27.4' runs: using: composite diff --git a/.github/workflows/bootstrap-macos.yml b/.github/workflows/bootstrap-macos.yml index ecbfb38e..c725a236 100644 --- a/.github/workflows/bootstrap-macos.yml +++ b/.github/workflows/bootstrap-macos.yml @@ -17,7 +17,7 @@ jobs: # Dormant (workflow_dispatch only), but kept in step with the rest — # check_version_pins.sh holds it there. Floor: 0.4.69, below which the # index cannot resolve two packages that share a short name. - XLINGS_VERSION: '2026.8.17.2' + XLINGS_VERSION: '2026.8.27.4' steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/ci-fresh-install.yml b/.github/workflows/ci-fresh-install.yml index 2995291c..fd00913c 100644 --- a/.github/workflows/ci-fresh-install.yml +++ b/.github/workflows/ci-fresh-install.yml @@ -152,7 +152,7 @@ jobs: env: XLINGS_NON_INTERACTIVE: '1' run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.17.2 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.27.4 echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - name: Install mcpp and config mirror @@ -293,7 +293,7 @@ jobs: - name: Install xlings + mcpp run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.17.2 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.27.4 # Deliberately NOT writing to $GITHUB_PATH here. On container # images that declare no PATH in their config (opensuse/ # tumbleweed), appending a single dir to GITHUB_PATH makes the @@ -364,7 +364,7 @@ jobs: # (older ones carry minos=15 and refuse to start). # v0.4.51+: in-process sha256 — this image has no sha256sum # binary, so pinned fetches failed before it. - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.17.2 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.27.4 echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - name: Install mcpp and config mirror diff --git a/.github/workflows/ci-linux-e2e.yml b/.github/workflows/ci-linux-e2e.yml index 6b443920..248279a8 100644 --- a/.github/workflows/ci-linux-e2e.yml +++ b/.github/workflows/ci-linux-e2e.yml @@ -237,7 +237,7 @@ jobs: - name: Bootstrap xlings + released mcpp run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.17.2 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.27.4 export PATH="$HOME/.xlings/subos/current/bin:$PATH" xlings update xlings install mcpp -y -g diff --git a/.github/workflows/cross-build-test.yml b/.github/workflows/cross-build-test.yml index 8c2e1517..95bdb29f 100644 --- a/.github/workflows/cross-build-test.yml +++ b/.github/workflows/cross-build-test.yml @@ -122,7 +122,7 @@ jobs: # release assets were uploaded in a broken state (records present, # blobs missing → 404 on GET); re-uploaded clean. The stale-INDEX # half is handled by the marker-clear below. - XLINGS_VERSION: '2026.8.17.2' + XLINGS_VERSION: '2026.8.27.4' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" bash "$GITHUB_WORKSPACE/.github/tools/fetch_release.sh" \ @@ -263,7 +263,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.17.2' + XLINGS_VERSION: '2026.8.27.4' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" bash "$GITHUB_WORKSPACE/.github/tools/fetch_release.sh" \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 780c1ccb..20b493a6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,7 +96,7 @@ jobs: # Pin xlings to a known-good version. The upstream install # script always grabs `latest` (no version override), so we # download + self-install manually to avoid broken releases. - XLINGS_VERSION: '2026.8.17.2' + XLINGS_VERSION: '2026.8.27.4' run: | if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" @@ -289,7 +289,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.17.2' + XLINGS_VERSION: '2026.8.27.4' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" bash "$GITHUB_WORKSPACE/.github/tools/fetch_release.sh" \ @@ -360,7 +360,7 @@ jobs: # below are pinned to the same version as XLINGS_VERSION; they are # NOT interpolated from it, so check_version_pins.sh scans for them # explicitly (they were absent from the old lock-step comment). - XLA="xlings-2026.8.17.2-linux-aarch64.tar.gz" + XLA="xlings-2026.8.27.4-linux-aarch64.tar.gz" # NOT fetch_release.sh: this asset is OPTIONAL and the `if` is the # point — an arch with no prebuilt xlings must fall through quietly, # while the helper retries a 404 five times before giving up. The one @@ -369,9 +369,9 @@ jobs: # cover it. if curl -fsSL --retry 3 --retry-delay 2 --retry-all-errors \ --connect-timeout 20 --max-time 600 -o "/tmp/$XLA" \ - "https://github.com/openxlings/xlings/releases/download/v2026.8.17.2/$XLA"; then + "https://github.com/openxlings/xlings/releases/download/v2026.8.27.4/$XLA"; then tar -xzf "/tmp/$XLA" -C /tmp - XLBIN=$(find /tmp/xlings-2026.8.17.2-linux-aarch64 -path '*/bin/xlings' -type f | head -1) + XLBIN=$(find /tmp/xlings-2026.8.27.4-linux-aarch64 -path '*/bin/xlings' -type f | head -1) if [ -n "$XLBIN" ]; then mkdir -p "$STAGING/$WRAPPER/registry/bin" cp "$XLBIN" "$STAGING/$WRAPPER/registry/bin/xlings" @@ -449,7 +449,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.17.2' + XLINGS_VERSION: '2026.8.27.4' run: | if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then WORK=$(mktemp -d) @@ -632,7 +632,7 @@ jobs: shell: bash env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.17.2' + XLINGS_VERSION: '2026.8.27.4' run: | # Captured before the `cd` below, in POSIX form: this step never # returns to the workspace, and GITHUB_WORKSPACE is a backslash diff --git a/src/cli.cppm b/src/cli.cppm index 4cbe8320..f2d7f96f 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -116,7 +116,12 @@ void print_usage() { // to errors. Deliberate — the alternative is a second drain point, i.e. a // second answerer for the same question. struct ReportUnnarrowablePaths { - ~ReportUnnarrowablePaths() { + // A destructor is implicitly noexcept, so anything escaping this body is + // std::terminate — and run() can be left by an exception (main() catches + // one), which is exactly when this runs during unwinding. A change whose + // entire subject is "an uncaught exception must not end the build" does + // not get to introduce a second one in its own reporting path. + ~ReportUnnarrowablePaths() try { for (auto const& anchor : mcpp::modgraph::take_unnarrowable_paths()) { mcpp::diag::degraded( "path/codepage", @@ -128,6 +133,8 @@ struct ReportUnnarrowablePaths { "data or docs; if they are sources, rename them or build on a " "system whose code page covers them."); } + } catch (...) { + // Losing the report is bad; terminating instead of it is worse. } }; diff --git a/src/modgraph/glob.cppm b/src/modgraph/glob.cppm index e9d93947..27b7ed22 100644 --- a/src/modgraph/glob.cppm +++ b/src/modgraph/glob.cppm @@ -58,7 +58,7 @@ std::filesystem::path native_path_from_generic(std::string_view s) { // #231 hardened three sites against it and missed a fourth // (`is_excluded_walk_dir`, which runs one line EARLIER in the same walk) — // which is why this is now a single function with a CI gate behind it -// (tools/check-narrow-conversions.sh) rather than a fourth try/catch. +// (.github/tools/check_narrow_conversions.sh) rather than a fourth try/catch. // // nullopt means: this path cannot be named in any string we hand to a // compiler, a build file, or a glob. Skip it — and record it, because @@ -87,7 +87,8 @@ void note_unnarrowable_path(const std::filesystem::path& p); // `src/manifest/` imports `mcpp.ui` or `mcpp.diag` — so it RECORDS and the // CLI reports. Drained in exactly one place (`cli::run`'s scope guard), which // is what keeps "recorded but never shown" from becoming the next silent -// failure. See docs & AGENTS.md. +// failure. The rule is written up in .agents/skills/mcpp-contributing/SKILL.md +// ("路径窄化不变式") and the user-facing behaviour in docs/05-mcpp-toml.md. std::vector take_unnarrowable_paths(); // Does `candidate` match `glob`, interpreted relative to `root`? diff --git a/src/modgraph/scanner.cppm b/src/modgraph/scanner.cppm index 90f460db..c4635a27 100644 --- a/src/modgraph/scanner.cppm +++ b/src/modgraph/scanner.cppm @@ -235,8 +235,25 @@ submodule_paths(const std::filesystem::path& root) { // package's source glob. bool is_excluded_walk_dir(const std::filesystem::path& dir, const std::filesystem::path& root) { - auto name = dir.filename().string(); - if (name == ".mcpp" || name == ".git" || name == "target") return true; + // Compare as paths. Do NOT narrow. + // + // #516: `dir.filename().string()` went through MSVC's wide→ANSI + // conversion and threw std::system_error for any directory name the + // active code page cannot spell (`test/www/Dir/` in cpp-httplib). + // This function is the FIRST line of the walk loop and runs once per + // directory entry, so it fires before `path_matches_glob`'s guard — + // hardening that one (#231) could never cover a directory name. + // + // The three literals are ASCII, so their conversion to the native + // representation is lossless, and `path::operator==` compares native + // strings case-sensitively — byte-for-byte the same decision the narrow + // comparison made. Static constants rather than temporaries per entry: + // #225 bounded this walk for a reason, and this is on its hot path. + static const std::filesystem::path kMcppDir{".mcpp"}; + static const std::filesystem::path kGitDir{".git"}; + static const std::filesystem::path kTargetDir{"target"}; + const auto name = dir.filename(); + if (name == kMcppDir || name == kGitDir || name == kTargetDir) return true; auto const& submodules = submodule_paths(root); if (submodules.empty()) return false; std::error_code ec; diff --git a/src/platform/xlings/xlings.cppm b/src/platform/xlings/xlings.cppm index 0f8a5e84..df085e38 100644 --- a/src/platform/xlings/xlings.cppm +++ b/src/platform/xlings/xlings.cppm @@ -45,7 +45,7 @@ namespace pinned { // in lock-step by hand; that list was already missing both composite // actions, which is how CI's sandbox sat on 0.4.30 unnoticed while // everything else had moved on. Don't reintroduce a hand-maintained list. - inline constexpr std::string_view kXlingsVersion = "2026.8.17.2"; + inline constexpr std::string_view kXlingsVersion = "2026.8.27.4"; inline constexpr std::string_view kNasmVersion = "3.02"; } From 00590335d92319f25131db7d92eceddf2a1970aa Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:56:12 +0800 Subject: [PATCH 3/3] docs: six things self-review found after CI went green MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI on fade10f was 36/36 green, including the Windows leg where the new test ran (`[ OK ] … (40 ms)`, not SKIPPED). Reading the whole diff back afterwards turned up six defects anyway — none of them compile errors, all of them the kind that mislead the next reader: 1. A comment claimed "nothing calls .string() on such a path directly". That is false — p1689.cppm does, with a NARROW-OK rationale. An absolute claim that is not true is worse than the convention it describes; reworded to say it is a convention with a gate, not a guarantee. 2. Two comments pointed at files that do not exist: `tools/check-narrow-conversions.sh` (it is .github/tools/, underscores) and `AGENTS.md` (mcpp keeps its conventions in .agents/skills/ — AGENTS.md is an xlings habit). A reference nobody can follow is the same defect class as a printed command nobody can run. 3. "run() has five exits" — a number I could not cheaply verify (most of the `return`s in that function body belong to lambdas). Replaced with the enumeration that is actually checkable. 4. The docs showed the warning as `'C:\...\test\www'`. The anchor is stored via try_narrow, which returns generic_string(), so the real output uses forward slashes. The documentation was showing output the code does not produce. Fixed both the docs and the code comment that should have said so. 5. The blast-radius statistic was stale AND loosely defined. "128 recipes, 101 with a `*`-leading glob" was measured before mcpp-index moved under me (aa8df60 → 891b2f7); it is 130/103 now. A hard-coded count in a CHANGELOG is wrong the next day, so it now names the commit it was measured on and states the counting rule (and records the false-positive check: every `"*…` string in those recipes really is a glob — `*/include`, `*`, `*/src`, `*/mcpp.toml`). Corrected in the CHANGELOG, the design doc, and the PR body. 6. (in fade10f, listed for completeness) the report drain was an ordinary destructor, i.e. implicitly noexcept, on a path that runs during unwinding. No behaviour change: comments, docs and one CHANGELOG paragraph. --- .../2026-08-27-issue516-windows-acp-glob-walk-fix.md | 3 ++- CHANGELOG.md | 8 ++++++-- docs/05-mcpp-toml.md | 6 +++++- docs/zh/05-mcpp-toml.md | 5 ++++- src/cli.cppm | 5 +++-- src/modgraph/glob.cppm | 12 ++++++++++-- 6 files changed, 30 insertions(+), 9 deletions(-) diff --git a/.agents/docs/2026-08-27-issue516-windows-acp-glob-walk-fix.md b/.agents/docs/2026-08-27-issue516-windows-acp-glob-walk-fix.md index ad8fc9e9..7070b0a2 100644 --- a/.agents/docs/2026-08-27-issue516-windows-acp-glob-walk-fix.md +++ b/.agents/docs/2026-08-27-issue516-windows-acp-glob-walk-fix.md @@ -24,7 +24,8 @@ `path_matches_glob` / `rewrite_rel_copy` / `local_include_dirs_for` 三处, **漏掉了同一个 walk 循环里比它们早一行执行的 `is_excluded_walk_dir`**。 #516 看到的这条整洁报错,正是 #231 顺手加的顶层 catch 在正常工作。 -3. **爆炸半径不是一个包。** mcpp-index 128 个 recipe 里 **101 个(79%)**含以 `*` 开头的 glob, +3. **爆炸半径不是一个包。** 在 mcpplibs/mcpp-index `891b2f7` 上量:130 个 recipe 里 + **103 个**含以 `*` 开头的 glob, 即"从解压根无界递归遍历"。任何上游 tarball 哪天多一个非 ASCII 文件名,当天 Windows 就红。 --- diff --git a/CHANGELOG.md b/CHANGELOG.md index f8d1f85e..5795ba6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,8 +27,12 @@ 该函数是 walk 循环体的**第一行**,每个目录条目过一次 —— 所以它比 `#231` 加固过的 `path_matches_glob` **更早**执行,加固那里对目录名从来无效。 - 触发条件比看上去宽:`include_dirs = { "*" }`(mcpp-index 里 128 个 recipe - 有 101 个含以 `*` 开头的 glob)会从解压根**无界递归遍历整棵上游源码树**。 + 触发条件比看上去宽:`include_dirs = { "*" }` 这类以 `*` 开头的 glob, + 字面前缀为空,会从解压根**无界递归遍历整棵上游源码树**。 + 在 mcpplibs/mcpp-index `891b2f7` 上量:130 个 recipe 里有 **103 个**至少含一条 + 这样的 glob(口径:`.lua` 里出现以 `*` 开头的字符串字面量;抽查其分布为 + `*/include` / `*` / `*/src` / `*/mcpp.toml` 等,全部是 glob,无假阳性)。 + 这个比例会随索引增长而变,写下的是当天那个 commit 的数。 cpp-httplib 带了 `test/www/日本語Dir/`,于是 `httplib` / `httplib-tls` / `httplib-zstd` 三个测试在 Windows 上一起挂 —— 而 Linux/macOS 全绿,因为那两个 平台上 `path::string()` 不做任何编码转换。 diff --git a/docs/05-mcpp-toml.md b/docs/05-mcpp-toml.md index 6d6cd860..7c76e886 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -631,11 +631,15 @@ named on a compile command, or written into a build file. Such entries are skipped, and the skip is reported once per directory: ```text -warning: 'C:\...\pkg\test\www' contains names this system's active code page cannot represent +warning: 'C:/.../pkg/test/www' contains names this system's active code page cannot represent impact: those files take no part in the build hint: Windows only: this is the process ANSI code page, which `chcp` does not change. ... ``` +The reported path is the nearest ancestor whose name the code page *can* spell, +in generic (`/`) spelling. The offending name itself is never printed: rendering +it would throw the same exception the message is reporting. + `chcp` sets the *console* code page and has no effect here. Names that are only test data or documentation are harmless — an upstream tarball carrying a Japanese-named fixture directory builds fine on an en-US host. Sources are not: diff --git a/docs/zh/05-mcpp-toml.md b/docs/zh/05-mcpp-toml.md index c5d60e5a..44579cd0 100644 --- a/docs/zh/05-mcpp-toml.md +++ b/docs/zh/05-mcpp-toml.md @@ -554,11 +554,14 @@ glob 是窄字符串,编译命令和 `build.ninja` 也是。在 Windows 上这 这类条目会被跳过,并按目录报告一次: ```text -warning: 'C:\...\pkg\test\www' contains names this system's active code page cannot represent +warning: 'C:/.../pkg/test/www' contains names this system's active code page cannot represent impact: those files take no part in the build hint: Windows only: this is the process ANSI code page, which `chcp` does not change. ... ``` +报告里给的是**最近一个代码页拼得出的祖先目录**,用通用(`/`)写法。拼不出的那个名字本身 +永远不会被打印:渲染它会抛出这条消息正在报告的同一个异常。 + `chcp` 改的是**控制台**代码页,对此无效。若这些名字只是测试数据或文档,跳过是无害 的——上游 tarball 里带一个日文夹具目录,在 en-US 宿主上照样构建。源文件则不然:需要 改名,或换一台代码页覆盖得了的机器。 diff --git a/src/cli.cppm b/src/cli.cppm index f2d7f96f..95a9214c 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -103,8 +103,9 @@ void print_usage() { // `src/modgraph/` and `src/manifest/` are leaf layers — not one module in // either imports `mcpp.ui` or `mcpp.diag` — so the glob walk RECORDS // (mcpp::modgraph::note_unnarrowable_path) and the CLI reports. A scope guard -// rather than a call before `return` because run() has five exits, and the one -// added next year would silently drop the report — which is precisely the +// rather than a call before `return` because run() has several exits — an +// unknown command, a parse error, `--help`, the dispatched action — and the +// one added next year would silently drop the report, which is precisely the // failure shape this whole change is about. // // Reported as `degraded`, not `warning`: mcpp.diag's batch invariant is that a diff --git a/src/modgraph/glob.cppm b/src/modgraph/glob.cppm index 27b7ed22..ca50bae0 100644 --- a/src/modgraph/glob.cppm +++ b/src/modgraph/glob.cppm @@ -35,8 +35,11 @@ std::filesystem::path native_path_from_generic(std::string_view s) { // ─── narrowing a walk-derived path ──────────────────────────────────────── // // THE ONE PLACE a path that came out of a directory walk becomes a narrow -// string. Everything else in the tree calls this; nothing calls -// `.string()` / `.generic_string()` on such a path directly. +// string. A direct `.string()` / `.generic_string()` on such a path needs a +// written reason (`// NARROW-OK: …`) and there is exactly one of those today, +// in p1689.cppm. This is a convention with a gate behind it, not a guarantee +// the compiler enforces — see the gate's header for what it does and does not +// cover. // // Why it needs to exist at all: on Windows `path::string()` converts the // native (wide) name through the process's ANSI code page and THROWS @@ -79,6 +82,11 @@ std::optional try_narrow(const std::filesystem::path& p) { // definition cannot be put into a message without throwing the very exception // this module exists to avoid. (Diagnostic code walking into its own trap is // the most likely way this regresses.) +// +// The stored spelling is GENERIC (`/`), because that is what try_narrow +// produces and there is no second narrowing here to disagree with it. On +// Windows the reported path therefore reads `C:/pkg/test/www`, not +// `C:\pkg\test\www`; docs/05-mcpp-toml.md shows it that way too. void note_unnarrowable_path(const std::filesystem::path& p); // Take and clear this run's records.