From 29a8c6e7ef4b3cfddfcb1bfcca51c4d5560d0655 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:51:23 +0800 Subject: [PATCH 1/5] The compile side closes the host's C library the link side already dropped (#662) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Target-side header isolation had a half. The link side has read `plan.targetSide.cAbi.prebuilt()` since #511 and dropped `-nostdlib` whenever a package supplies the target's C library; the compile side never got the same treatment, so clang's own driver kept searching the host's system directories. On a machine that happens to have the matching host headers (mingw-w64, in the issue's repro) the two C libraries' declarations collide and the error names neither the host header nor why it was read; on a machine without them the failure is a bare "file not found" with no explanation. There is a same-shaped C++ twin: the existing `-nostdinc++` condition asked `!graphSuppliesTarget && !cxxFromPayload`, which is false exactly when BOTH layers come from the graph — the openkal shape — so the driver kept finding the host's libstdc++ beside itself. The issue's own minimal repro only `import std`s and never text-includes a header, so it exposed neither half. - `mcpp.toolchain.hostflags` appends `-nostdlibinc` when the C library comes from the graph, and fixes the `-nostdinc++` condition to read only whether the C++ layer comes from the graph. Both tokens live in the global `cflags`/`cxxflags`/`asmflags` producer, so every consumer (C, C++, asm, `clang-scan-deps`, the std module precompile) gets them identically. - GCC has no one-token equivalent. `can_isolate_graph_c_library` states the capability in the toolchain model (Clang only); `prepare.cppm` refuses the combination at resolution when it would otherwise reach an unisolated build, rather than silently skipping isolation. No target row needs the GCC form today. - `mcpp::build::graph_c_library_isolation_advice`, the compile-side sibling of `link_failure_advice`: when a failed build's raw ninja output carries both the isolating token and a "file not found", it appends a note naming the C library and two remedies (adapt on `cfg(c-abi = "...")`, or bring the platform headers into the graph as a private dependency of the package that needs them) without rewriting the compiler's own line. - M5: verified rather than assumed that a `[feature-deps.]` entry can already stay private to its declaring package (`visibility = "private"`, folding only into `privateBuild`, never into `publicUsage`) — measured both directions with local path packages before writing e2e 739. The capability already existed and was undocumented; docs/06 now states the pattern and docs/24 gains a "boundary" section (kernel-abi vs c-abi, one C/C++ runtime per image, adapt on the differing layer, no source-level implementation detection) — both in English and zh. - Unit tests: the option matrix (`cAbiPrebuilt` x `cxxFromGraph` x {clang, gcc}), a byte-identical regression guard for payload-served builds, and `graph_c_library_isolation_advice`'s own matching rules. - e2e 738: openkal's `x86_64-windows-gnu` build, each C/C++ unit's actual command from the build database re-run with `-v -fsyntax-only`, asserting clang's own header search list names only store paths. Red before this fix (confirmed locally, both the C and the C++ twin), green after. Gated on a `mingw-host-headers` capability (the criterion has no discriminating power without a host mingw to have leaked in); `openkal-cross.yml`'s `ecosystem-e2e` job installs `mingw-w64` so it holds in CI, since `llvm` — which 738 also requires — is never in `run_all.sh`'s detected CAPS on any sharded runner. - e2e 739: the private-feature-dep guarantee, in both directions on the same two packages so the harness cannot pass vacuously. - Version 2026.9.17.3. --- ...-662-graph-target-header-isolation-plan.md | 224 ++++++++++++++++++ .github/workflows/openkal-cross.yml | 18 +- CHANGELOG.md | 39 +++ docs/06-features-and-capabilities.md | 35 +++ docs/22-target-side.md | 12 + docs/24-openkal-cross.md | 66 +++++- docs/zh/06-features-and-capabilities.md | 30 +++ docs/zh/22-target-side.md | 9 + docs/zh/24-openkal-cross.md | 49 ++++ mcpp.toml | 2 +- modules/toolchain-model/src/model.cppm | 25 ++ modules/versioning/src/version.cppm | 2 +- src/build/execute.cppm | 7 + src/build/ninja_backend.cppm | 74 ++++++ src/build/prepare.cppm | 29 +++ src/toolchain/hostflags.cppm | 48 +++- ...lied_target_closes_the_hosts_own_search.sh | 150 ++++++++++++ ...feature_dep_does_not_reach_the_consumer.sh | 126 ++++++++++ tests/e2e/run_all.sh | 12 +- tests/unit/test_hostflags.cpp | 133 +++++++++++ tests/unit/test_ninja_backend.cpp | 69 ++++++ 21 files changed, 1150 insertions(+), 9 deletions(-) create mode 100644 .agents/docs/2026-09-17-issue-662-graph-target-header-isolation-plan.md create mode 100755 tests/e2e/738_a_graph_supplied_target_closes_the_hosts_own_search.sh create mode 100755 tests/e2e/739_a_private_feature_dep_does_not_reach_the_consumer.sh diff --git a/.agents/docs/2026-09-17-issue-662-graph-target-header-isolation-plan.md b/.agents/docs/2026-09-17-issue-662-graph-target-header-isolation-plan.md new file mode 100644 index 00000000..7ed603c3 --- /dev/null +++ b/.agents/docs/2026-09-17-issue-662-graph-target-header-isolation-plan.md @@ -0,0 +1,224 @@ +--- +subject: triage +status: active +--- + +# #662:目标侧由依赖图提供时,编译器的隐式头文件搜索仍指向宿主 + +- Issue: mcpp-community/mcpp#662(Sunrisepeak,2026-09-17) +- 依据:`origin/main` 9bc00f80(mcpp 2026.9.17.2);本机 Linux x86_64,已安装 `mingw-w64-x86-64-dev`;llvm 22.1.8 +- 状态:方案待 review,未实现 + +--- + +## 0. 结论 + +1. **缺陷在引擎,而且范围比 issue 描述的宽。** 目标侧的 C 库由依赖图提供(`c-abi musl (openkal-musl, graph)`)时, + mcpp 会把该 C 库的头文件目录以 `-I` 广播给图中每个编译单元,但**没有关掉编译器 driver 自带的系统头文件搜索**。 + 链接侧对同一事实早已写成 `-nostdlib`,编译侧缺少对应的一半。受影响的是**所有**文本包含 C 头文件的单元 + (C、C++、汇编、依赖扫描),不只是 compat 包的 C 源文件。 +2. **C++ 有一个同形的孪生缺陷。** 同一目标下,clang++ 的默认搜索列表里还有宿主 mingw 的 **libstdc++** 目录 + (`/usr/lib/gcc/x86_64-w64-mingw32/13-win32/include/c++`),而这个图的 C++ 层是 libc++。issue 的最小复现里 + 根项目只 `import std`、没有文本包含头文件,所以没有暴露。 +3. **issue 中有两处判断与实测不符。** + - 「C++ 模块单元没有这个问题」:全局 `cxxflags` 同样没有隔离;C++ 单元没出错只是因为没有文本包含 C 头文件。 + - 「宿主没有 mingw 时同一个项目能构建」:去掉宿主头文件后,zlib 的 `gzlib.c` 报 + `fatal error: 'io.h' file not found`。`io.h` 只存在于宿主 mingw 中,所以没有 mingw 的机器上这个组合同样会失败, + 只是报错不同。这是依据头文件位置作出的推断,没有在无 mingw 的机器上单独测量。 +4. **修好隔离之后,暴露出一个真实的包级不兼容,它属于生态,不属于引擎。** `x86_64-windows-gnu` 目标预定义 `_WIN32`, + zlib 把 `_WIN32` 当作「存在 Windows CRT」(``、`_lseeki64`、`_wopen`),而这个图的 C ABI 是 musl。 + 一直以来,宿主 mingw 恰好替它补上了 CRT 头文件。 +5. **方案分两层,各自单 PR。** + - mcpp:编译侧按目标侧各层的来源关闭对应的隐式搜索,与链接侧的 `-nostdlib` 由同一个值决定。 + - mcpp-index:compat 包在 `cfg(all(windows, c-abi = "musl"))` 下走 POSIX 分支(先做 zlib,并给出 ABI 判据)。 + +--- + +## 1. 实测 + +### 1.1 复现(mcpp 2026.9.17.2,与 issue 相同的清单) + +``` +Target x86_64-windows-gnu → x86_64-w64-windows-gnu + kernel-abi openkal (openkal-windows@0.7.4, graph) + c-abi musl (openkal-musl@0.13.5, graph) +/usr/x86_64-w64-mingw32/include/corecrt.h:98:24: error: typedef redefinition ... +.../openkal-musl-0.13.5/musl/include/sys/stat.h:84:5: error: conflicting types for 'chmod' +``` + +### 1.2 命令行对比(build.ninja) + +| 位置 | 与头文件隔离相关的 token | +|---|---| +| 全局 `cflags` | `-std=c11 -O0 -g --target=x86_64-w64-windows-gnu -fdwarf-exceptions -femulated-tls --no-default-config`,**没有隔离** | +| 全局 `cxxflags` | 同上加 `-std=c++23`,**没有隔离** | +| 全局 `ldflags` | `... -nostdlib -static ...`,**链接侧已隔离** | +| openkal-musl 自己的 C 单元 `unit_cflags` | `-ffreestanding -nostdinc ...`,由包自己声明 | +| compat.zlib 的 C 单元 `unit_cflags` | `-D_XOPEN_SOURCE=700 -DOKW_STANDALONE`,**只有宏定义** | +| zlib 单元的 `local_includes` | zlib 自身目录,以及 openkal-llvm-runtime、openkal-musl 的广播目录 | + +openkal 系列的包自己带着 `-nostdinc`,所以它们不受影响;受影响的是**不知道自己在为 openkal 构建**的普通包。 + +### 1.3 判据:clang 的头文件搜索列表 + +``` +$ clang -xc -v -fsyntax-only --target=x86_64-w64-windows-gnu --no-default-config /dev/null + /lib/clang/22/include + /usr/x86_64-w64-mingw32/include <- 宿主 + +$ ... -nostdlibinc + /lib/clang/22/include <- 只剩编译器自带头文件 + +$ clang++ -xc++ -v ... + /usr/lib/gcc/x86_64-w64-mingw32/13-win32/include/c++ <- 宿主 libstdc++(孪生缺陷) + /usr/lib/gcc/x86_64-w64-mingw32/13-win32/include/c++/x86_64-w64-mingw32 + /usr/lib/gcc/x86_64-w64-mingw32/13-win32/include/c++/backward + /lib/clang/22/include + /usr/x86_64-w64-mingw32/include +``` + +判据是**搜索列表本身**,不是构建是否成功,也不是 `-I` 有没有出现(同一形状见记忆 +`an-implicit-include-search-is-not-on-the-command-line`)。 + +### 1.4 逐单元验证(用 `ninja -t commands` 取出原命令,只增加 token) + +| 单元 | 原命令 | + `-nostdlibinc` | + `-nostdlibinc -U_WIN32 -include mcpp_zlib_config.h` | +|---|---|---|---| +| gzlib.c | 8 个错误(宿主 CRT 与 musl 冲突) | 1 个错误:`'io.h' file not found` | 通过 | +| gzread / gzwrite / zutil / deflate | 失败 | — | 通过 | + +--- + +## 2. 设计原则(与已有架构对齐) + +- **一层由谁提供,就由谁提供它的头文件和库。** 目标侧模型已经按层记录来源(`targetside_model`: + `cAbi.fromGraph()`、`cxx.fromGraph()`、`cAbi.prebuilt()`)。链接侧据此替换了 `-nostdlib`,`hostflags.cppm` + 据此撤掉了载荷的 `-isystem`。编译侧还差「撤掉 driver 的隐式搜索」这一步。三者读同一个值,不另作推导 + (`hostflags.cppm` 的注释已经把「READ, NOT DERIVED」写成规则)。 +- **引擎做通用的事,包做自己的事。** 「图提供的层不应被宿主补齐」对所有目标、所有包成立,放在引擎; + 「zlib 在 musl 上应走 POSIX 分支」只对 zlib 成立,放在 mcpp-index 的描述符里,使用已有的 + `target_cfg = { ["cfg(...)"] = ... }` 机制(`modules/manifest/src/xpkg.cppm`),引擎不感知任何包名。 +- **同一个 token 必须出现在编译器实际收到的每条命令里。** C、C++、汇编、`clang-scan-deps`、std 模块预编译 + 都读同一组 flags;`compile_commands.json` 与 `mcpp emit build-database` 自然随之变化(SPEC-005 R3.7)。 + +--- + +## 3. 方案 + +### 3.1 mcpp(一个 PR) + +**M1 编译侧按层关闭隐式搜索。** 位置在 `src/toolchain/hostflags.cppm`,即生成 `cflags`/`cxxflags`/`asmflags` +公共 token 的同一处,与 `graphSuppliesTarget`、`cxxFromPayload` 的判断相邻: + +| 条件(读 `HostFlagOptions`) | 追加的 token(clang 家族) | 作用于 | +|---|---|---| +| `!cAbiPrebuilt`(C 库由图提供) | `-nostdlibinc` | C、C++、汇编、扫描、std 模块 | +| `cxxFromGraph`(C++ 层由图提供) | `-nostdinc++` | C++、扫描、std 模块 | + +- 用 `-nostdlibinc` 而不是 `-nostdinc`:前者保留编译器自带头文件(`stddef.h`、`stdarg.h`、intrinsics), + 只去掉系统和 C 库目录。编译器自带头文件属于编译器层,不属于 C 库层;openkal 包需要更强隔离时,仍由包自己声明 `-nostdinc`。 +- 修正现有条件:`-nostdinc++` 目前只在 `!graphSuppliesTarget && !cxxFromPayload` 时追加,恰好在 openkal + 这种两层都来自图的情形下不追加。1.3 节显示这正是 libstdc++ 泄漏的来源。条件改为只看 `cxxFromGraph`。 +- GCC 家族没有 `-nostdlibinc`。等价形式是 `-nostdinc` 加上 `-isystem ` + 与 `include-fixed`。这个差异应当放进工具链模型(`modules/toolchain-model`),由模型提供 + 「关闭系统 C 库搜索」的 token,hostflags 不按编译器名分支。实现前需要先确认当前是否存在「图提供 C 库 + GCC」的 + 目标行:如果不存在,模型对 GCC 返回拒绝并给出原因,而不是静默不隔离。 +- 不改变 C 库由载荷提供(`cAbiPrebuilt` 为真)的情形。宿主 `/usr/include` 补齐载荷 glibc 的问题是另一个已记录的 + 缺陷形状,不并入本 PR。 + +**M2 诊断。** 隔离之后,原先靠宿主补齐的包会报 `'
' file not found`,而这个报错不会说明原因。 +当一次构建失败、目标侧 `c-abi` 来自图、且编译器输出含 `file not found` 时,追加一行说明: + +``` +note: this target's C library is musl (openkal-musl@0.13.5, from the dependency graph); the host's + headers are not searched. A package that needs '' has to adapt to this C library, + for example with target_cfg = { ["cfg(c-abi = \"musl\")"] = ... }. +``` + +实现复用已有的构建失败提示通道(与 `BuildProgramCompatHint` 同类),只匹配这一种形状,不改写编译器输出。 + +**M3 测试。** +- 单测(`test_hostflags*`):选项矩阵 `cAbiPrebuilt × cxxFromGraph × {clang, gcc}` 下的 token 集合, + 以及「载荷提供两层时 token 不变」这一回归守卫。 +- e2e(新增):openkal 的 `x86_64-windows-gnu` 构建。对每个 C 和 C++ 单元,从 `mcpp emit build-database` + 取出实际命令,附加 `-v -fsyntax-only` 执行,断言搜索列表中只有 store 路径和编译器 resource 目录。 + **这个判据只在宿主存在 mingw 头文件时有区分力**,所以: + - 用一个 capability(例如 `mingw-host-headers`,探测 `/usr/x86_64-w64-mingw32/include`)作为前置条件; + - 在 `ci-linux-e2e` 的一个 shard 中安装 `mingw-w64`,保证这个 capability 在 CI 上确实成立(`# requires:` + 只在本机跳过而 CI 从不具备,这个坑记录在 `requires-gcc-is-a-linux-only-gate`); + - 修复前后各跑一次,确认修复前为红。 +- 构建数据库:SPEC-005 R3.7 已要求列出编译器实际收到的词,e2e 736 覆盖了渲染规则;本 PR 只需确认新 token 出现在 + 数据库条目中。 + +**M4 文档。** `docs/22-target-side.md`(中英)在「Adaptation To The Resolved Target Side」一节陈述: +一层由图提供时,编译器不再搜索该层的宿主位置;需要适配的包用层谓词适配。CHANGELOG 写明升级影响。 + +**兼容性与升级。** +- 只影响目标侧由图提供的构建(openkal 各目标、图提供 libc++ 的 iOS 行)。原生构建和载荷提供 C 库的构建,命令行逐字节不变 + (单测守卫)。 +- 行为变化:原先依靠宿主头文件才能编译的包会确定地失败。这些构建原本就只在装了对应宿主包的机器上成功,而且 + 产物混用了两个 C 库的声明。M2 的说明使这个失败可以诊断。 +- flags 变化使这类构建的指纹变化,升级后全量重建一次。 + +**跨平台。** +- Windows 宿主上的 openkal 目标同样读这组 flags;MSVC 不参与图提供 C 库的目标。 +- macOS 宿主上的 iOS 行(图提供 libc++、SDK 提供 C 库)只受 `-nostdinc++` 条件修正的影响,而那一路原本已经发出 + `-nostdinc++`,需要单测确认 token 不重复。 +- 裸机目标(`riscv64-none-elf` 等)的 clang driver 没有系统目录,`-nostdlibinc` 不改变搜索结果; + 需要确认已有裸机 e2e 保持为绿。 + +### 3.2 mcpp-index(一个 PR) + +**I1 compat.zlib 在图提供 musl 的 Windows 目标上走 POSIX 分支。** + +```lua +target_cfg = { + ["cfg(all(windows, c-abi = \"musl\"))"] = { + cflags = { "-U_WIN32", "-include", "mcpp_zlib_config.h" }, + }, +}, +``` + +同时把 `mcpp_zlib_config.h` 中的 `#if !defined(_WIN32)` 保持原样:`-U_WIN32` 之后它会定义 `Z_HAVE_UNISTD_H`。 + +**ABI 判据(必须有,否则只能证明「编译通过」)。** `-U_WIN32` 只作用于 zlib 自己的单元,消费者看到的 +`zconf.h` 仍带着 `_WIN32`。实测读 `zconf.h`,两种视角下的差异有三处: +1. `z_off_t`:zlib 一侧是 `off_t`(经 `Z_HAVE_UNISTD_H`),消费者一侧是 `long long`。 + 在 musl 的 x86_64-windows 配置下 `off_t` 也是 `long long`,但这需要测量,不能靠推断; +2. `gzopen_w`:消费者看得到声明,但 zlib 对象中没有定义(`WIDECHAR` 未定义)。调用它会在链接期失败,不会静默出错; +3. `WIN32` 宏:只影响 16 位路径,对 64 位目标没有作用。 + +判据:mcpp-index 的 zlib 测试成员在该目标下断言 `(zlibCompileFlags() >> 6) & 3` 对应的 `z_off_t` 字节数, +等于消费者一侧的 `sizeof(z_off_t)`。这个值由 zlib 对象自己报告,是跨越两种视角的唯一直接证据。 + +**I2 其他 compat 包。** issue 的实际目标 `compat:libarchive` 的 Windows 分支远多于 zlib,需要单独评估,不在本 PR 中 +承诺。I1 给出的是模式,并在 `docs/repository-and-schema.md` 记录:在 `c-abi = "musl"` 的 Windows 目标上, +`_WIN32` 不代表 Windows CRT。 + +**I3 CI。** 如果 mcpp-index 的矩阵中没有 openkal Windows 目标,zlib 成员增加一条交叉构建腿。它依赖 mcpp M1 发布, +因此 pin 先移动,再启用这条腿。 + +--- + +## 4. 顺序 + +``` +mcpp PR(M1-M4)→ 发布 → 索引 bump +mcpp-index PR(I1-I3,pin 到新版)→ 全量验证 +沙箱:xlings subos --sandbox + CN mirror,宿主装有 mingw 与没有 mingw 两种形态,对照上一版 +``` + +I1 本身不依赖 M1:在未修复的 mcpp 上,宿主有 mingw 时依然会因为冲突而失败(冲突来自隐式搜索,与 `_WIN32` 无关)。 +所以两个 PR 的判据只能在 M1 发布之后一起成立。 + +--- + +## 5. 需要决定的问题 + +- **D1 隔离 token 放在全局 flags,还是只加给非 openkal 包的单元?** 建议放在全局。openkal 包自带的 `-nostdinc` + 更强,两者叠加没有副作用;按包区分会引入「这个包是不是 openkal」的判断,而引擎不应该感知包名。 +- **D2 `-nostdlibinc` 还是 `-nostdinc`?** 建议 `-nostdlibinc`。编译器自带头文件属于编译器层;musl 自带 + `stddef.h` 等,在 `-I` 顺序上本来就排在 resource 目录之前。 +- **D3 M2 的诊断提示是否纳入同一个 PR?** 建议纳入。没有它,升级后出现的 `file not found` 读起来像 mcpp 回归。 +- **D4 GCC 家族:实现等价隔离,还是明确拒绝?** 取决于是否存在「图提供 C 库 + GCC」的目标行,实现前先穷举 + 目标行表。 diff --git a/.github/workflows/openkal-cross.yml b/.github/workflows/openkal-cross.yml index 323dbdef..21d7cefa 100644 --- a/.github/workflows/openkal-cross.yml +++ b/.github/workflows/openkal-cross.yml @@ -386,7 +386,7 @@ jobs: # the shim on PATH dispatches against whichever home owns it, so an # emulator installed only in the ambient one answers "not installed" when # mcpp asks. - - name: Install the emulators the last two scripts need + - name: Install the emulators, and this host's mingw-w64 (#662) run: | set -euo pipefail # The runner image carries third-party apt lists (Google Chrome @@ -402,7 +402,16 @@ jobs: # name was a guess and the guess was wrong. sudo grep -rlE 'dl[.]google[.]com|packages[.]microsoft[.]com' \ /etc/apt/sources.list.d/ 2>/dev/null | xargs -r sudo rm -f - sudo apt-get update -qq && sudo apt-get install -y -qq qemu-user + # `mingw-w64` (the distro package, NOT `mingw-cross-gcc`) is what + # 738's `mingw-host-headers` capability probes for + # (`/usr/x86_64-w64-mingw32/include`). The assertion it gates -- + # clang's own driver no longer searches the host once a graph + # package supplies the target's C library -- has no discriminating + # power without a host copy to have leaked in in the first place; + # this is the one runner in the fleet that installs it for that + # reason (run_all.sh's capability probe, and the design doc's note + # on `# requires:` gates CI never satisfies on its own). + sudo apt-get update -qq && sudo apt-get install -y -qq qemu-user mingw-w64 "$XLINGS_BIN" install xim:qemu-riscv -y XLINGS_HOME="${MCPP_HOME:-$HOME/.mcpp}/registry" \ "$XLINGS_BIN" install xim:qemu-riscv -y @@ -423,7 +432,8 @@ jobs: fail=0 for t in tests/e2e/285_*.sh tests/e2e/286_*.sh tests/e2e/287_*.sh \ tests/e2e/288_*.sh tests/e2e/289_*.sh tests/e2e/291_*.sh \ - tests/e2e/292_*.sh tests/e2e/293_*.sh tests/e2e/294_*.sh; do + tests/e2e/292_*.sh tests/e2e/293_*.sh tests/e2e/294_*.sh \ + tests/e2e/738_*.sh; do echo "=== $t ===" bash "$t" 2>&1 | tee "$(basename "$t").log" || true rc=${PIPESTATUS[0]} @@ -475,4 +485,6 @@ jobs: "OK: the requested target and the resolved one name one operating system" || fail=1 check 294_the_list_answers_what_can_be_built_not_what_has_a_payload.sh \ "OK: the list answers what can be built, not what has a payload" || fail=1 + check 738_a_graph_supplied_target_closes_the_hosts_own_search.sh \ + "PASS: 738 a graph-supplied target closes the host's own search" || fail=1 [ "$fail" = 0 ] || exit 1 diff --git a/CHANGELOG.md b/CHANGELOG.md index bf56acc0..bfd738c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,45 @@ ## [Unreleased] +### 目标侧由依赖图供给时,编译侧关掉对应的隐式搜索:#662(2026.9.17.3) + +链接侧早在 #511 就已经按 `plan.targetSide.cAbi.prebuilt()` 撤掉 `-nostdlib`,编译侧一直 +缺对应的一半:图供给 C 库的目标(openkal 各目标)上,clang driver 自己的头文件搜索仍然打开, +宿主机装了什么就可能补进构建里。宿主没有对应头文件时报一个不知所云的类型冲突;宿主有的 +话,产物混用两个 C 库的声明却仍然链接成功。C++ 层有同形的孪生缺陷:C 库和 C++ 运行时两层都 +来自图时(openkal 的实际形状),旧的 `-nostdinc++` 追加条件恰好把这一种排除在外,issue 的最小 +复现(只 `import std`)没有文本包含头文件,所以没暴露。 +分析:`.agents/docs/2026-09-17-issue-662-graph-target-header-isolation-plan.md`。 + +- **编译侧按层来源关闭隐式搜索,读与链接侧相同的值。** `c-abi` 来自图时追加 `-nostdlibinc` + (保留编译器自带头文件,只去掉系统与 C 库目录);`c++-abi` 来自图时追加 `-nostdinc++`, + 条件改为只看这一层自己的来源,不再看 `c-abi` 是否也来自图。两个 token 出现在 `cflags`/ + `cxxflags`/`asmflags` 这组全局 flags 里,C、C++、汇编、依赖扫描、std 模块预编译同时受益。 + (`src/toolchain/hostflags.cppm`,单测 `test_hostflags.cpp`) +- **GCC 家族没有等价的单一 flag。** 穷举现有目标行后没有「图供给 C 库 + GCC」的组合,模型 + 给出 `can_isolate_graph_c_library`(只对 Clang 家族为真),`prepare.cppm` 在这一组合出现时 + 据此拒绝并给出原因,而不是静默地不隔离。(`modules/toolchain-model/src/model.cppm`) +- **隔离后原本靠宿主补齐的包会确定地失败,失败信息现在可读。** 构建失败、目标侧 `c-abi` + 来自图、且编译器输出含 `file not found` 时,追加一条说明,点名 C 库(名字与 + `包名@版本`)并给出两条路:按 `cfg(c-abi = "...")` 适配,或把平台依赖以私有可见性带进 + 依赖图。复用既有的构建失败提示通道(与 `link_failure_advice` 同类),不改写编译器自己的 + 输出。(`mcpp::build::graph_c_library_isolation_advice`) +- **M5:平台依赖保持私有,这个能力已经存在,补了测试和文档。** `[feature-deps.]` + 下的依赖项本就可以写 `visibility = "private"`,让其头文件只到达声明它的包自己的翻译单元、 + 不广播给消费方——这条路径此前完全没有被测过、也没有被文档提到。e2e 739 用「私有时消费方 + 找不到头文件、公开时能找到」两个方向锁定这个行为;`docs/06`(及 zh)记录这个模式,`docs/24` + (及 zh)新增"边界"一节陈述 kernel-abi/c-abi 各自的保证范围、平台依赖必须私有、一个镜像一个 + C/C++ 运行时(R1)、头文件差异按 `c-abi` 适配、设施差异优先用包自己的 feature(R3)。 +- **升级影响。** 只影响目标侧由图供给的构建(openkal 各目标、图供给 libc++ 的 iOS 行);原生 + 构建与载荷提供 C 库的构建命令行逐字节不变(单测守卫)。这类构建的 flags 指纹变化,升级后 + 会重建一次。原先只因为宿主头文件补上缺口才能编译的包,现在会确定地失败——这类构建此前只在 + 装了对应宿主包的机器上成功,产物还混用了两个 C 库的声明;新增的说明使这类失败可以诊断。 +- 单测:`test_hostflags.cpp`(选项矩阵 `cAbiPrebuilt × cxxFromGraph × {clang, gcc}`,以及 + 载荷提供两层时命令行不变的回归守卫)、`test_ninja_backend.cpp`。e2e:738(openkal + `x86_64-windows-gnu` 构建,逐单元核对 clang 自己报告的头文件搜索列表,门在 + `mingw-host-headers` capability 上,`openkal-cross.yml` 的 `ecosystem-e2e` job 安装 + `mingw-w64` 使其在 CI 上成立)、739(私有 feature-dep 的双向锁定)。 + ### 声明的 C 运行时由 mcpp 安装,查找只做精确匹配:#660(2026.9.17.2) xim-pkgindex#852 发布 glibc 2.44.3 之后,CI 缓存只恢复 `registry/data/xpkgs` 的环境报 diff --git a/docs/06-features-and-capabilities.md b/docs/06-features-and-capabilities.md index 6871b01c..49f6e5e1 100644 --- a/docs/06-features-and-capabilities.md +++ b/docs/06-features-and-capabilities.md @@ -488,6 +488,41 @@ that let a C++ standard library offer a replaceable `operator new` do not apply to a package dependency. Keeping the implementation behind a switch means the two never coexist. +### A platform SDK dependency stays private + +A package bound to one platform — it needs that platform's headers to +implement a facility, not to state its own interface — depends on the SDK +under `[feature-deps.]` with `visibility = "private"`: + +```toml +[features] +windows-crt = {} + +# Resolved only on the row that activates it, and its headers reach ONLY +# this package's own translation units. +[feature-deps.windows-crt] +some.windows-headers = { version = "1.0", visibility = "private" } +``` + +`visibility` is a field of any dependency spec (`public` by default, +`private`, or `interface` — see [05 — Dependencies](05-dependencies.md)). +`private` is what keeps the SDK from crossing the package boundary: the +dependency's include directories, defines and flags fold into this package's +own build and stop there, exactly as `privateIncludeDirs` keeps a package's +*own* internal headers from reaching its consumers. A consumer that depends on +the platform-bound package and activates `windows-crt` gets the facility; it +does not get `some.windows-headers`'s directory on its own `-I` list, and +cannot `#include` its headers even by name. + +This is the pattern [24 — openkal and the Graph-Supplied Target](24-openkal-cross.md) +points to for a package that needs platform headers beyond what its declared +layers (`kernel-abi`, `c-abi`, `c++-abi`) supply: the dependency is legal, and +it must not become every consumer's problem. Declaring it `public` (or leaving +`visibility` unstated, which is the same thing) is the mistake this section +exists to name — it works for the declaring package and then hands the SDK's +headers, unasked, to a consumer that may be built for a target where that SDK +does not belong at all. + ## Current limitations **A default feature is turned off in the manifest, not on the command line.** diff --git a/docs/22-target-side.md b/docs/22-target-side.md index 0f9a3d62..a313b37e 100644 --- a/docs/22-target-side.md +++ b/docs/22-target-side.md @@ -320,6 +320,18 @@ Requiring a feature selection for this would oblige a project to restate what the target triple or its dependency graph has already established, and permit the two statements to disagree. +**When a layer is supplied by the graph, the compiler no longer searches that +layer's host locations.** `mcpp.toolchain.hostflags` reads each layer's origin +and closes the corresponding driver search: a graph-supplied `c-abi` drops the +compiler's own C-library search path (`-nostdlibinc`), and a graph-supplied +`c++-abi` drops its C++ search path (`-nostdinc++`) — both independently of +whether the *other* layer is also the graph's. Before this, a package that +happened to compile only because a host header filled a gap the graph did not +would build on one machine and fail, differently, on another; the two +determined outcomes now are "found in the graph" and "not found", never "found +on this machine's copy of the SDK". A package adapts to this by the layer +predicate below, never by relying on what a host happens to have installed. + The predicate keys are the five layer names, and their values are the interface names in the table at the top of this chapter — the same strings the `Target` report prints. They combine with the triple keys under `all`/`any`/`not`: diff --git a/docs/24-openkal-cross.md b/docs/24-openkal-cross.md index 9049984e..63030dfb 100644 --- a/docs/24-openkal-cross.md +++ b/docs/24-openkal-cross.md @@ -447,9 +447,71 @@ does not. structure prevents the combination on the default path, and a diagnostic covers the paths where a project overrides the contract explicitly. +## The Boundary + +Each of the five layers guarantees only itself, and the boundary between them +is where a package's own adaptation belongs. + +**`kernel-abi = openkal` guarantees behaviour that crosses `kal_*` and nothing +past it.** The specification's own interface is platform-independent; a +missing capability is exposed at link time, and a missing property is +answered through the props query. It says nothing about which C library sits +above it, whether a platform SDK is reachable, or whether the rest of a +package's own source is portable. + +**`c-abi = musl` is a separate layer with a separate guarantee.** A build over +openkal's `kernel-abi` is not thereby a build over any particular C library — +musl is one implementation of that layer, resolved by the same dependency +graph as `kernel-abi`, and a header or CRT difference (``, `_WIN32`'s +Windows-CRT assumptions, `TargetConditionals.h`) is the `c-abi` layer's +question, never the `kernel-abi` layer's. A package that adapts to "openkal" +when the actual disagreement is with musl has adapted to the wrong axis — +openkal's own headers `#include` nothing and conflict with no platform SDK; +musl's headers are what a host SDK's declarations collide with (#662). + +**Platform dependencies are legal, and they must come from the graph and stay +private to the package that declares them.** A package bound to one platform +— it needs that platform's headers or import libraries to implement a +facility — depends on the platform SDK under `[feature-deps.]` with +`visibility = "private"`, so the dependency reaches only its own translation +units and never a consumer's. [06 — Features and Capabilities](06-features-and-capabilities.md#a-platform-sdk-dependency-stays-private) +states the pattern and the manifest form. What is not legal is reaching for +the HOST's copy instead of a graph one: that is exactly the header isolation +gap #662 closed, and the isolation exists so a platform dependency's absence +from the graph is a build failure, not a silent substitution. + +**One C runtime and one C++ runtime per image.** "Platform-bound" names the +platform's OS API surface, not its C library. A platform-bound package may +call Win32, WinSock or Cocoa — system libraries with a C interface — provided +only handles and plain values cross the boundary. It may not link a static +library compiled against ucrt, msvcrt, libSystem or glibc, and it may not let +an object the CRT owns cross the boundary: a `FILE*`, a `malloc` freed on the +other side, `errno`, locale state. A vendor SDK distributed only as a static +library against a platform CRT is `n/a` on an openkal target by design, not +by omission. + +**Adapt on the layer that actually differs.** musl running under Linux and +musl running over openkal share headers and CRT shape, so a header or CRT +difference is a `c-abi` question: `cfg(c-abi = "musl")`. The two do NOT share +the same *facilities* — openkal has no epoll, no signal handlers, and `chmod` +can only change the execute bit — so a facility difference is answered first +by the package's OWN feature switch where one exists (an event-loop backend +selection, say), and only falls back to a combined predicate, +`cfg(all(kernel-abi = "openkal", c-abi = "musl"))`, when a descriptor needs to +choose automatically. Neither form reaches the package's source: a `cfg` +predicate is a dependency-resolution-time choice among descriptor entries, not +a macro a translation unit can test. + +**Source code must not detect which implementation is present.** A `kal_*` +call site does not ask whether it is running over `openkal-linux` or +`openkal-macos`; a musl call site does not ask whether the platform beneath it +is real Linux or openkal. The implementation is chosen once, by dependency +resolution, and everything above that choice reads one interface. + ## Reference [docs/22 — The Target Side](22-target-side.md) for the five layers, the four -origins and the rules. [SPEC-002](specs/target-side.md) for the normative -statement of the capability grammar. +origins and the rules. [docs/06 — Features and Capabilities](06-features-and-capabilities.md) +for `[feature-deps.]` and private dependency visibility. [SPEC-002](specs/target-side.md) +for the normative statement of the capability grammar. diff --git a/docs/zh/06-features-and-capabilities.md b/docs/zh/06-features-and-capabilities.md index 862a87d3..b92cf963 100644 --- a/docs/zh/06-features-and-capabilities.md +++ b/docs/zh/06-features-and-capabilities.md @@ -417,6 +417,36 @@ std-freestanding-alloc-kal = "0.1.x" 而非替换 —— 让 C++ 标准库能提供可替换 `operator new` 的那套归档语义,对包依赖并不适用。 把实现放在开关之后,意味着两者**从不共存**。 +### 平台 SDK 依赖保持私有 + +一个绑定到某个平台的包 —— 它需要那个平台的头文件才能实现某个功能,而不是 +为了陈述自己的接口 —— 在 `[feature-deps.]` 下用 `visibility = "private"` +依赖该 SDK: + +```toml +[features] +windows-crt = {} + +# 仅在激活该行时解析,其头文件只到达本包自己的翻译单元。 +[feature-deps.windows-crt] +some.windows-headers = { version = "1.0", visibility = "private" } +``` + +`visibility` 是任意依赖项 spec 的一个字段(默认 `public`,还可以是 `private` +或 `interface` —— 见[05 — 依赖](05-dependencies.md))。让 SDK 不跨越包边界的正是 +`private`:该依赖的头文件目录、宏定义与 flag 只并入本包自己的构建,到此为止, +正如 `privateIncludeDirs` 让一个包**自己的**内部头文件不到达它的消费方。 +一个依赖该平台绑定包并激活 `windows-crt` 的消费方,会得到这个功能;它不会在 +自己的 `-I` 列表里得到 `some.windows-headers` 的目录,甚至无法按名字 +`#include` 它的头文件。 + +这正是[24 —— openkal 与由依赖图供给的目标](24-openkal-cross.md)所指向的模式: +一个包若需要它声明的层(`kernel-abi`、`c-abi`、`c++-abi`)之外的平台头文件, +这个依赖是合法的,但不能变成每一个消费方的问题。把它写成 `public` +(或者不写 `visibility`,二者等价)正是本节要指出的错误 —— 对声明它的包而言这样 +可以工作,却会把 SDK 的头文件不由分说地交给消费方,而消费方构建的目标上很可能 +根本不该出现这个 SDK。 + ## 当前边界 **默认 feature 在 manifest 里关掉,不在命令行上关掉。** 没有 `--no-default-features`。 diff --git a/docs/zh/22-target-side.md b/docs/zh/22-target-side.md index 549d23f7..0a7ef3bd 100644 --- a/docs/zh/22-target-side.md +++ b/docs/zh/22-target-side.md @@ -267,6 +267,15 @@ include_dirs = ["config/picolibc"] 若此处要求一次特性选择,将迫使工程重述目标三元组或其依赖图已经确立的事实, 并允许两处陈述互相矛盾。 +**当某一层由依赖图供给时,编译器不再搜索该层的宿主位置。** +`mcpp.toolchain.hostflags` 读取每一层的来源并关掉对应的编译器隐式搜索: +图供给 `c-abi` 时去掉编译器自带的 C 库搜索路径(`-nostdlibinc`), +图供给 `c++-abi` 时去掉其 C++ 搜索路径(`-nostdinc++`)—— 两者各自独立判断, +不取决于另一层是否也来自图。在此之前,一个只因为宿主头文件恰好补上了某个缺口 +才能编译的包,会在一台机器上构建成功、在另一台机器上以不同方式失败;现在确定的 +结果只有两种 ——「在图里找到」与「没找到」,不再有「用了这台机器上恰好装着的 +那份 SDK」。包应当通过下面的层谓词来适配,而不是依赖宿主机器恰好装了什么。 + 谓词的键就是五个层名,值就是本章开头那张表里的接口名 —— 与 `Target` 报告打印的是 同一批字符串。它们可以与三元组键在 `all`/`any`/`not` 下组合: diff --git a/docs/zh/24-openkal-cross.md b/docs/zh/24-openkal-cross.md index f82f7c0d..0b3ac489 100644 --- a/docs/zh/24-openkal-cross.md +++ b/docs/zh/24-openkal-cross.md @@ -378,8 +378,57 @@ g++: error: unrecognized command-line option '-fuse-ld=…/ld.lld' 它被构建时的配置。解析器的结构在默认路径上阻止该组合, 而一条诊断覆盖工程显式覆写该契约的那些路径。 +## 边界 + +五个层各自只保证自己那一层,层与层之间的边界正是包自身适配工作的所在。 + +**`kernel-abi = openkal` 只保证经过 `kal_*` 的行为,不保证再远的事。** 规范本身的 +接口与平台无关;缺失的能力在链接期暴露,缺失的属性由 props 查询回答。它不说明 +上面架着哪个 C 库、平台 SDK 是否可达,也不说明包其余源码是否可移植。 + +**`c-abi = musl` 是独立的一层,有独立的保证。** 建在 openkal 的 `kernel-abi` 之上 +不等于建在某个特定的 C 库之上 —— musl 只是这一层的一种实现,由与 `kernel-abi` +相同的依赖图解析得到;头文件或 CRT 差异(``、`_WIN32` 所假定的 Windows +CRT、`TargetConditionals.h`)永远是 `c-abi` 这一层的问题,从来不是 `kernel-abi` +的问题。一个把实际分歧记到「openkal」头上、而分歧其实出在 musl 上的包,是适配错了 +轴 —— openkal 自己的头文件什么都不 `#include`,与任何平台 SDK 都不冲突; +会和宿主 SDK 的声明冲突的是 musl 的头文件(#662)。 + +**平台依赖是合法的,而且必须来自依赖图,并且只对声明它的包私有。** 一个绑定到 +某个平台的包 —— 它需要该平台的头文件或导入库才能实现某个功能 —— 在 +`[feature-deps.]` 下用 `visibility = "private"` 依赖该平台 SDK, +使这个依赖只到达它自己的翻译单元,永远不会到达消费方。 +[06 —— Feature 与能力](06-features-and-capabilities.md#平台-sdk-依赖保持私有) +陈述了这个模式与清单写法。不合法的是转而依赖**宿主**恰好装着的那一份 —— 这正是 +#662 关掉的头文件隔离缺口所要堵上的:平台依赖不在图中就应当是构建失败, +而不是被静默地换了一份。 + +**一个镜像只有一个 C 运行时和一个 C++ 运行时。** "platform-bound" 指的是平台的 +操作系统 API 表面,不是它的 C 库。一个 platform-bound 的包可以调用 Win32、 +WinSock 或 Cocoa —— 这些是带有 C 接口的系统库 —— 前提是跨越边界的只有句柄和 +普通值。它不能链接按 ucrt、msvcrt、libSystem 或 glibc 编译的静态库,也不能让 +CRT 拥有的对象跨越边界:`FILE*`、在一侧 `malloc` 在另一侧 `free`、`errno`、 +locale 状态。一个只以对着平台 CRT 编译的静态库形式分发的厂商 SDK,在 openkal +目标上按设计就是 `n/a`,而不是遗漏。 + +**在真正有差异的那一层上适配。** 跑在 Linux 上的 musl 和跑在 openkal 上的 musl +共享头文件与 CRT 形状,所以头文件或 CRT 差异是 `c-abi` 的问题: +`cfg(c-abi = "musl")`。但二者**不**共享同一套设施 —— openkal 上没有 epoll、 +没有信号处理器,`chmod` 只能改动可执行位 —— 所以设施差异首先由包**自己的** +feature 开关回答(比如事件循环后端的选择),只有在描述文件需要自动选择时, +才退回到组合谓词 `cfg(all(kernel-abi = "openkal", c-abi = "musl"))`。两种形式 +都不会进入包的源码:`cfg` 谓词是依赖解析时在描述文件条目之间做的选择,不是翻译 +单元能测试的宏。 + +**源码不得识别具体是哪一个实现。** 一处 `kal_*` 调用点不会去问自己跑在 +`openkal-linux` 还是 `openkal-macos` 之上;一处 musl 调用点不会去问自己下面是 +真正的 Linux 还是 openkal。实现只在依赖解析时选择一次,这一选择之上的一切都只 +读一个接口。 + ## 参考 [docs/22 — 目标侧](22-target-side.md) 给出五个层、四种来源与规则。 +[docs/06 — Feature 与能力](06-features-and-capabilities.md) 给出 +`[feature-deps.]` 与依赖私有可见性。 [SPEC-002](../specs/target-side.md) 给出能力语法的规范性陈述。 diff --git a/mcpp.toml b/mcpp.toml index ad1b2108..a23471d8 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.9.17.2" +version = "2026.9.17.3" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/modules/toolchain-model/src/model.cppm b/modules/toolchain-model/src/model.cppm index a717f7bf..043f5144 100644 --- a/modules/toolchain-model/src/model.cppm +++ b/modules/toolchain-model/src/model.cppm @@ -293,6 +293,27 @@ bool is_musl_target(const Toolchain& tc); bool is_msvc_target(const Toolchain& tc); bool is_mingw_target(const Toolchain& tc); +// CAN THIS COMPILER FAMILY STOP ITS OWN DRIVER SEARCHING THE HOST'S C +// LIBRARY WHEN A PACKAGE SUPPLIES THE TARGET'S INSTEAD? (mcpp#662) +// +// Clang answers yes, with `-nostdlibinc` (mcpp.toolchain.hostflags). GCC has +// no single equivalent flag — the shape would be `-nostdinc` plus `-isystem +// ` and `<…/include-fixed>`, re-adding exactly +// the two directories GCC's own C-library search contributes beside the +// sysroot. +// +// NOT IMPLEMENTED, BECAUSE NO TARGET ROW NEEDS IT YET. Enumerated at #662: +// every existing combination where a package supplies the target's C +// library resolves a Clang-family compiler — the one openkal package that +// requires nothing of the compiler layer (openkal-musl) is still only ever +// built under the toolchain its `provides` siblings (openkal-llvm-runtime, +// which DOES `require mcpp:compiler=llvm`) pull in. `prepare.cppm` refuses a +// row that pairs GCC with a graph-supplied C library, reading this +// predicate, rather than silently leaving it unisolated — the defect this +// module exists to fix was exactly a silent gap between what the link side +// assumed and what the compile side did. +bool can_isolate_graph_c_library(const Toolchain& tc); + // THE FLAGS A WHOLE GRAPH HAS TO AGREE ON WHEN THE RUNTIME COMES FROM IT. // // An ordinary flag is a package's business. These two are not: they change what @@ -476,6 +497,10 @@ bool is_clang(const Toolchain& tc) { return tc.compiler == CompilerId::Clang; } +bool can_isolate_graph_c_library(const Toolchain& tc) { + return is_clang(tc); +} + // Target-shape predicates read the parsed canonical Triple (triple.cppm is // the single triple parser), with the old substring heuristics kept only as // a fallback for triples outside the language. This makes them spelling- diff --git a/modules/versioning/src/version.cppm b/modules/versioning/src/version.cppm index aa8b74ef..33d0111b 100644 --- a/modules/versioning/src/version.cppm +++ b/modules/versioning/src/version.cppm @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.9.17.2"; +inline constexpr std::string_view MCPP_VERSION = "2026.9.17.3"; } // namespace mcpp diff --git a/src/build/execute.cppm b/src/build/execute.cppm index fb3ddb90..767326c8 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -1251,6 +1251,13 @@ std::optional run_ninja_fast(const std::string& ninjaProgram, // silently remove the advice along with them. if (auto advice = mcpp::build::link_failure_advice(out); !advice.empty()) std::fputs(advice.c_str(), stderr); + // mcpp#662, the fast-path form: no `BuildPlan` here to name the C + // library from (the whole point of this path is skipping `prepare`), + // so both name arguments are empty — the note still fires (it reads + // the isolation token in `out` itself) but names no package. + if (auto advice = mcpp::build::graph_c_library_isolation_advice(out); + !advice.empty()) + std::fputs(advice.c_str(), stderr); return 1; } if (verbose && !out.empty()) diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index ff7c25f1..f07338c1 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -97,6 +97,42 @@ std::optional check_rule_commands_name_a_program( // referenced from `__libcpp_allocate` in `__new/allocate.h`. std::string link_failure_advice(std::string_view output); +// mcpp#662: the compile-side sibling of `link_failure_advice`, same shape — +// text-matched against RAW ninja output (command lines included; the caller +// must not pass the filtered form), returning advice to APPEND, never +// rewriting what the compiler said. +// +// The case this exists for: a graph-supplied C library closes the compiler's +// own search of the host's copy (`-nostdlibinc`, mcpp.toolchain.hostflags), +// and a package that only ever compiled because a host header filled a gap +// the graph did not now fails determinstically, naming a header the reader +// has never asked for and no decision mcpp made: +// +// fatal error: 'io.h' file not found +// +// Read on its own this looks like a broken build — the header used to be +// found. The advice names WHY it no longer is and what changed, without +// touching the compiler's own line. +// +// Two conditions, both read from `output` so this needs no BuildPlan: the +// compile command mcpp itself assembled carries `-nostdlibinc` (the token +// that says THIS unit's C library came from the graph — see hostflags.cppm), +// and the compiler's own report contains `file not found`. Neither alone is +// enough: `-nostdlibinc` with no such error is an unrelated failure, and +// `file not found` with no `-nostdlibinc` is an ordinary missing header that +// has nothing to do with the target side. +// +// `cAbiName` / `cAbiCoordinate` (interfaceName / impl of `TargetSide::cAbi`, +// e.g. "musl" / "openkal-musl@0.3.5") name the library precisely when the +// caller has resolved a `BuildPlan` to read them from (the ordinary build +// path). Both empty is a degraded but still correct note — the fast +// re-ninja path (execute.cppm's `run_ninja_fast`) skips `prepare` entirely +// and has no `TargetSide` to read; the two conditions above still hold, so +// it still knows it IS this shape, only not the library's name. +std::string graph_c_library_isolation_advice(std::string_view output, + std::string_view cAbiName = {}, + std::string_view cAbiCoordinate = {}); + } // namespace mcpp::build namespace mcpp::build { @@ -636,6 +672,39 @@ std::string link_failure_advice(std::string_view output) { " `std::align_val_t`, which are the ones most often forgotten.\n"; } +std::string graph_c_library_isolation_advice(std::string_view output, + std::string_view cAbiName, + std::string_view cAbiCoordinate) { + if (output.find("-nostdlibinc") == std::string_view::npos) return {}; + if (output.find("file not found") == std::string_view::npos) return {}; + + std::string library = !cAbiName.empty() + ? (!cAbiCoordinate.empty() ? std::format("{} ({})", cAbiName, cAbiCoordinate) + : std::string(cAbiName)) + : std::string("this target's C library"); + std::string predicateAbi = cAbiName.empty() ? std::string("") + : std::string(cAbiName); + + return std::format( + "\n" + "note: {} comes from the dependency graph, and the host's own C " + "library\n" + " headers are not searched (docs/22 'Adaptation To The Resolved " + "Target Side').\n" + " A package that needs the header above has to adapt, either:\n" + "\n" + " (a) to this C library, in the package's own descriptor:\n" + " [target.'cfg(c-abi = \"{}\")'.build]\n" + " (b) by bringing the platform headers it needs into the " + "dependency graph as\n" + " its OWN dependency, private to itself (docs/06 'A " + "platform SDK\n" + " dependency stays private') — rather than relying on a " + "header this\n" + " machine happens to have installed.\n", + library, predicateAbi); +} + std::string filter_ninja_output(std::string_view output, std::span commandPrefixes) { std::string filtered; @@ -3324,6 +3393,11 @@ std::expected NinjaBackend::build(const BuildPlan& plan // build.ninja happened to be up to date — which is exactly the kind of // "same decision in two places" this codebase keeps paying for. diagnostics += link_failure_advice(out); + // mcpp#662: named precisely here, where `plan.targetSide` is resolved + // — the fast path (execute.cppm) calls the same function with no name + // and gets the degraded-but-still-correct form. + diagnostics += graph_c_library_isolation_advice( + out, plan.targetSide.cAbi.interfaceName, plan.targetSide.cAbi.impl); return std::unexpected(BuildError{"build failed", plan.outputDir / "build.ninja", std::move(diagnostics)}); } diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 7694d184..93731660 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -10902,6 +10902,35 @@ prepare_build(bool print_fingerprint, refusal::record(refusal::Code::LayerOrdering); return std::unexpected(*why); } + // THE COMPILER MUST BE ABLE TO STOP ITS OWN DRIVER SEARCHING THE + // HOST'S C LIBRARY WHEN A PACKAGE SUPPLIES THE TARGET'S INSTEAD + // (#662). `mcpp.toolchain.hostflags` emits `-nostdlibinc` for that — + // a Clang-only flag — and GCC has no one-token equivalent + // (`mcpp::toolchain::can_isolate_graph_c_library`). No target row + // needs the GCC form today (every graph-supplied C library resolves + // a Clang-family compiler in the ecosystem this checks against), so + // the combination is refused here rather than built unisolated: the + // alternative is the exact defect #662 reports, one layer later and + // reported as a package's header conflict rather than as what it is. + if (tc && !tc->cAbiPrebuilt + && !mcpp::toolchain::can_isolate_graph_c_library(*tc)) { + refusal::record(refusal::Code::LayerRequirement); + return std::unexpected(std::format( + "the target's C library ('{}') comes from the dependency " + "graph, and the resolved compiler ('{}') has no way to stop " + "its own driver searching the host's C library headers " + "alongside it.\n" + " Every target row this engine isolates today resolves " + "a Clang-family ('llvm') compiler; add\n" + " [toolchain]\n" + " default = \"llvm@\"\n" + " or, for one target only:\n" + " [target.{}]\n" + " toolchain = \"llvm@\"", + resolvedTargetSide.cAbi.interfaceName, tc->compiler_family(), + overrides.target_triple.empty() ? std::string("") + : overrides.target_triple)); + } // REQUIREMENTS ARE CHECKED BEFORE ANYTHING IS COMPILED, WHICH IS THE // WHOLE POINT OF DECLARING THEM. The combination this rejects — a C++ // runtime configured for one compiler family being handed to another — diff --git a/src/toolchain/hostflags.cppm b/src/toolchain/hostflags.cppm index d7d8f45f..89412b34 100644 --- a/src/toolchain/hostflags.cppm +++ b/src/toolchain/hostflags.cppm @@ -427,7 +427,53 @@ std::vector host_compile_tokens(const Toolchain& tc, // Nothing that used to be emitted moves; this path emitted nothing. out.push_back("--no-default-config"); } - if (bypassCfg && !graphSuppliesTarget && !cxxFromPayload) { + + // THE C LIBRARY'S OWN HOST LOCATIONS, WHEN A GRAPH PACKAGE SUPPLIES THE + // TARGET'S C LIBRARY. The link side has read this exact value since + // #511 (`plan.targetSide.cAbi.prebuilt()`, by way of `graphSuppliesTarget` + // above) and dropped `-nostdlib` accordingly; this was the missing + // compile-side half (#662). + // + // `-nostdlibinc` rather than `-nostdinc`: the latter also drops the + // COMPILER's OWN bundled headers (stddef.h, stdarg.h, the builtin + // intrinsics), which are the compiler's layer and not the C library's — + // a graph-supplied musl still expects them ahead of its own copies on + // the search path. A package that needs the stronger form states + // `-nostdinc` itself; openkal-musl already does, in its own unit flags. + // + // Measured (x86_64-windows-gnu, openkal-musl over openkal-windows, + // clang 22.1.8, `-xc -v -fsyntax-only`): without this token the driver's + // header search list still ends in `/usr/x86_64-w64-mingw32/include` — + // the HOST's mingw, which happened to satisfy every text `#include` the + // graph's own headers did not, until one of its declarations disagreed + // with musl's (`typedef redefinition`, `conflicting types for 'chmod'`). + // With it, the list ends at the compiler's own resource directory. + // + // GCC has no equivalent single flag — see + // `mcpp::toolchain::can_isolate_graph_c_library`. The combination is + // refused at resolution (prepare.cppm) rather than reaching here with + // nothing to emit; `dm.hasCfg` is false for GCC in any case, so + // `bypassCfg` already withholds this block from that family today. + if (bypassCfg && graphSuppliesTarget) out.push_back("-nostdlibinc"); + + // THE CONDITION USED TO BE `!graphSuppliesTarget && !cxxFromPayload`, + // WHICH IS RIGHT ABOUT `cxxFromPayload` AND WRONG TO ASK ABOUT THE C + // LIBRARY AT ALL — this token is the C++ LAYER's question, exactly as + // the comment on `cxxFromPayload` above already says. Asking about the + // C library too meant the one case where BOTH layers come from the graph + // (openkal: `graphSuppliesTarget` true, `cxxFromGraph` true) answered + // `!graphSuppliesTarget` false and never got here — so clang kept + // searching beside itself for the payload's libc++, found the HOST's + // libstdc++ instead (`/usr/lib/gcc/x86_64-w64-mingw32/…/include/c++`), + // and every unit that `#include`s a header transitively reaching it saw + // two C++ standard libraries at once (#662, the C++ twin of the C defect + // above — unreached by the issue's own repro, which only `import std`s). + // + // `!cxxFromPayload` is exactly "the payload is not the one supplying + // these headers", which is true for both `cxxFromGraph` (a package does) + // and `appleSdkCxxHeaders` (the SDK does) — the two cases this branch + // already told apart below by whether `opt.cxxFromGraph` is set. + if (bypassCfg && !cxxFromPayload) { // The driver's own C++ search contributes nothing: beside the compiler // it finds the payload's libc++, and clang's Darwin driver prefers that // copy to the SDK's whenever it exists. What replaces it is either the diff --git a/tests/e2e/738_a_graph_supplied_target_closes_the_hosts_own_search.sh b/tests/e2e/738_a_graph_supplied_target_closes_the_hosts_own_search.sh new file mode 100755 index 00000000..839e5fcd --- /dev/null +++ b/tests/e2e/738_a_graph_supplied_target_closes_the_hosts_own_search.sh @@ -0,0 +1,150 @@ +#!/usr/bin/env bash +# requires: llvm mingw-host-headers python3 +# 738 -- when the target's C library and C++ runtime both come from the +# dependency graph, clang's own driver stops searching the HOST's copies of +# either (mcpp#662). +# +# THE DEFECT. The link side has read `plan.targetSide.cAbi.prebuilt()` since +# #511 and dropped `-nostdlib` when a package supplies the C library. The +# compile side asked the same question of a DIFFERENT, narrower predicate — +# and for the one arrangement openkal actually ships (C library AND C++ +# runtime both from the graph) that predicate answered false, so clang kept +# its own header search active. A C unit that text-includes a host mingw +# header no synced package intended (`io.h`, wanted by a package written for +# an ordinary MinGW Windows CRT) found the HOST's copy instead of failing -- +# and where that copy's declarations disagreed with the graph's C library +# (musl), a compile that should not have found `io.h` at all failed on a +# TYPE CONFLICT instead, naming neither the host header nor why it was read. +# +# THE CRITERION IS THE SEARCH LIST ITSELF, NOT WHETHER THE BUILD SUCCEEDS. +# A build can succeed while quietly mixing two C libraries' declarations, and +# it can fail on an unrelated, in-graph gap (a package not yet ported to +# musl) that this fix does nothing about and must not be blamed for. So this +# test takes each unit's OWN command line out of the build database, adds +# `-v -fsyntax-only`, and inspects clang's own report of where it looked — +# the same judgement call `an-implicit-include-search-is-not-on-the-command- +# line` records: an implicit search is invisible on the command line and +# visible only in the driver's verbose report. +# +# GATED ON `mingw-host-headers`, NOT ON THE BUILD SUCCEEDING. The assertion +# "the host's mingw does not appear" has no discriminating power on a host +# that never had it -- the search list would end at the compiler's own +# resource directory before AND after this fix, for an unrelated reason. +# +# `# requires: llvm` is why this test does not run through the ordinary +# sharded suite at all -- `llvm` is never in run_all.sh's detected CAPS on +# any shard (nothing there installs a toolchain), so every script declaring +# it is invoked DIRECTLY, with the capability installed first and its PASS +# line demanded, the same way openkal-cross.yml's `ecosystem-e2e` job +# already runs 285-294: it installs the distro `mingw-w64` package there +# specifically so `mingw-host-headers` holds and this test is not a +# guaranteed skip. +set -e + +MCPP="${MCPP:-mcpp}" +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && cat "$2"; exit 1; } + +mkdir -p "$TMP/app/src" +cd "$TMP/app" + +# Same stack as e2e 286 (the ecosystem's own arrangement, not a synthetic +# manifest) plus a plain C unit: the defect's C-library half is invisible +# from `import std`-only sources, which is exactly why the issue's own +# minimal repro (root project only `import std`) did not reach it. +cat > mcpp.toml <<'TOML' +[package] +name = "hdriso" +version = "0.1.0" + +[toolchain] +default = "llvm@22.1.8" + +[dependencies] +openkal-musl = "0.3.5" +openkal-windows = "0.1.5" +openkal-llvm-runtime = "0.1.3" +TOML + +cat > src/main.cpp <<'CPP' +#include +int main() { std::puts("hi"); return 0; } +CPP +cat > src/probe.c <<'C' +int probe(void) { return 0; } +C + +if ! out="$("$MCPP" build --target x86_64-windows-gnu --cache off 2>&1)"; then + case "$out" in + *"not found in the synced index"*|*"install_packages failed"*) + echo "SKIP: the openkal packages are not reachable from here" + exit 0 ;; + esac + echo "FAIL: the openkal windows-gnu stack did not build" + printf '%s\n' "$out" | grep -iE 'error' | head -5 + exit 1 +fi +printf '%s\n' "$out" > build.log + +cdb="compile_commands.json" +[ -f "$cdb" ] || cdb="$(find target -name compile_commands.json | head -1)" +[ -n "$cdb" ] && [ -f "$cdb" ] || fail "no compile_commands.json produced" + +home="${MCPP_HOME:-$HOME/.mcpp}" +if ! python3 - "$cdb" "$home" <<'PY' +import json, subprocess, sys, os + +cdb_path, home = sys.argv[1], os.path.realpath(sys.argv[2]) +entries = json.load(open(cdb_path)) +units = [e for e in entries + if e["file"].endswith("main.cpp") or e["file"].endswith("probe.c")] +if len(units) != 2: + print(f"FAIL: expected 2 units (main.cpp, probe.c), found {len(units)}") + print(json.dumps([e["file"] for e in entries], indent=1)) + sys.exit(1) + +bad = False +for e in units: + args = e.get("arguments") or e.get("command") + r = subprocess.run(list(args) + ["-v", "-fsyntax-only"], + capture_output=True, text=True, cwd=e.get("directory")) + lines, capture, searched = r.stderr.splitlines(), False, [] + for l in lines: + if "search starts here" in l: + capture = True + continue + if "End of search list" in l: + capture = False + continue + if capture: + searched.append(l.strip()) + + if not searched: + print(f"FAIL: {e['file']}: clang printed no header search list " + "(the assertion below would be vacuous)") + print(r.stderr) + bad = True + continue + + # THE CRITERION: every directory clang looked in is under the mcpp store + # (the packages' own directories, OR the compiler payload's resource + # directory, which lives in the same store) -- nothing from the host. + outside = [p for p in searched + if os.path.realpath(p).startswith(os.sep) + and not os.path.realpath(p).startswith(home)] + print(f"{e['file']}: {len(searched)} directories searched, " + f"{len(outside)} outside the store") + if outside: + print(" outside the store:") + for p in outside: + print(f" {p}") + bad = True + +sys.exit(1 if bad else 0) +PY +then + fail "the search list names a directory outside the store" build.log +fi + +echo "PASS: 738 a graph-supplied target closes the host's own search" diff --git a/tests/e2e/739_a_private_feature_dep_does_not_reach_the_consumer.sh b/tests/e2e/739_a_private_feature_dep_does_not_reach_the_consumer.sh new file mode 100755 index 00000000..3d692dc1 --- /dev/null +++ b/tests/e2e/739_a_private_feature_dep_does_not_reach_the_consumer.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# 739 -- a platform SDK pulled under [feature-deps.] with +# `visibility = "private"` reaches only the declaring package's own +# translation units, never a consumer's (mcpp#662, M5). +# +# WHY THIS TEST EXISTS. #662's plan required VERIFYING, not assuming, that a +# platform-bound package can keep a platform dependency to itself before +# documenting the pattern (docs/06, docs/24). Reading `computeUsageRequirements` +# in prepare.cppm shows a `DependencyVisibility` axis already exists and that +# `Private` folds a dependency's usage into `consumer.privateBuild` only, never +# into `consumer.publicUsage` — so it should already hold for ANY dependency +# table, `[feature-deps.]` included, since both route through the same +# per-entry parser. This test is the measurement that confirms it, and it +# fails on any future change that lets a private edge's headers leak. +# +# THE SHAPE, deliberately proving both directions on the SAME two packages so +# a change to the harness itself cannot make this pass vacuously: +# +# sdk (a path package) -- a header defining SDK_PRESENT +# lib_platform (a path pkg) -- [feature-deps.plat] depends on `sdk` +# app (this test's root) -- depends on lib_platform with features=["plat"] +# +# A. `sdk` declared PRIVATE -> lib_platform's own TU sees it (must build); +# app's TU including "sdk.h" must NOT build. +# B. `sdk` declared PUBLIC -> app's TU including "sdk.h" DOES build -- +# establishing that the harness would have +# caught A being wrong, not merely different. +set -e + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && cat "$2"; exit 1; } + +mkdir -p "$TMP/sdk/src" "$TMP/sdk/include" +cat > "$TMP/sdk/mcpp.toml" <<'EOF' +[package] +name = "sdk" +version = "0.1.0" + +[targets.sdk] +kind = "lib" +EOF +cat > "$TMP/sdk/include/sdk.h" <<'EOF' +#pragma once +#define SDK_PRESENT 1 +inline int sdk_value(void) { return 42; } +EOF +cat > "$TMP/sdk/src/empty.cpp" <<'EOF' +// no compiled sources of its own beyond the header +EOF + +mkdir -p "$TMP/lib_platform/src" +write_lib_platform_manifest() { # $1 = visibility + cat > "$TMP/lib_platform/mcpp.toml" < "$TMP/lib_platform/src/lib_platform.cpp" <<'EOF' +#include "sdk.h" +#ifndef SDK_PRESENT +#error "lib_platform's own translation unit must see sdk's headers" +#endif +int lib_platform_uses_sdk(void) { return sdk_value(); } +EOF + +mkdir -p "$TMP/app/src" +cat > "$TMP/app/mcpp.toml" <<'EOF' +[package] +name = "app" +version = "0.1.0" + +[dependencies] +lib_platform = { path = "../lib_platform", features = ["plat"] } +EOF +cat > "$TMP/app/src/main.cpp" <<'EOF' +#include "sdk.h" +int main() { return 0; } +EOF + +# A. private — lib_platform's own build must still succeed (it needs sdk.h), +# and app's must fail to find sdk.h at all: not merely "SDK_PRESENT +# undefined" (which private defines/cflags would also produce) but the +# header itself absent from app's search path. +write_lib_platform_manifest private +cd "$TMP/app" +if out="$("$MCPP" build --cache off 2>&1)"; then + printf '%s\n' "$out" > "$TMP/a-build.log" + fail "A: app built despite depending (via a private feature-dep) on a package whose headers it must not see" "$TMP/a-build.log" +fi +printf '%s\n' "$out" > "$TMP/a-build.log" +case "$out" in + *"sdk.h"*"No such file"*|*"sdk.h"*"not found"*|*"sdk.h"*"cannot open"*) + echo " ok A: app cannot find sdk.h (private feature-dep did not cross the boundary)" ;; + *) + fail "A: app's build failed, but not on a missing sdk.h — cannot tell privacy from an unrelated break" "$TMP/a-build.log" ;; +esac + +# lib_platform's OWN build (its private dependency reaches its own TUs). +# `--features plat`: `plat` is not a default feature, and building +# lib_platform AS THE ROOT package activates only `default` unless told +# otherwise — the same rule app's own dependency edge states explicitly. +cd "$TMP/lib_platform" +"$MCPP" build --features plat --cache off > "$TMP/lp-build.log" 2>&1 \ + || fail "A: lib_platform (the declaring package) could not build against its own private feature-dep" "$TMP/lp-build.log" +echo " ok A: lib_platform's own translation unit does see sdk's headers" + +# B. public — the same app now builds, proving the assertion above would +# have failed had the engine actually broadcast a private edge. +write_lib_platform_manifest public +cd "$TMP/app" +"$MCPP" build --cache off > "$TMP/b-build.log" 2>&1 \ + || fail "B: app failed to build once sdk is a PUBLIC feature-dep of lib_platform — the harness cannot distinguish private from broken" "$TMP/b-build.log" +echo " ok B: with visibility=public the same app DOES see sdk.h (the harness is not vacuous)" + +echo "PASS: 739 a private feature-dep does not reach the consumer" diff --git a/tests/e2e/run_all.sh b/tests/e2e/run_all.sh index 8fe31e47..a928404c 100755 --- a/tests/e2e/run_all.sh +++ b/tests/e2e/run_all.sh @@ -79,6 +79,16 @@ case "$OS" in || [[ -x "${MCPP_HOME}/registry/data/xpkgs/xim-x-mingw-cross-gcc/16.1.0/bin/x86_64-w64-mingw32-g++" ]]; then CAPS+=(mingw-cross) fi + # mingw-host-headers: this Linux HOST's own mingw-w64 headers + # (`apt install mingw-w64`, distro package). Distinct from both + # `mingw-cross` above (an xim-managed cross GCC) and `mingw` below (a + # Windows-hosted payload) — this is a plain probe for + # `/usr/x86_64-w64-mingw32/include`, the exact directory #662's + # isolation criterion has to prove clang no longer searches once a + # graph package supplies the target's C library. The criterion has NO + # discriminating power without it: a host that never had these headers + # would pass the same assertion before the fix and after it. + [[ -d /usr/x86_64-w64-mingw32/include ]] && CAPS+=(mingw-host-headers) # wine: run cross-built Windows PE artifacts on the Linux host. command -v wine &>/dev/null && CAPS+=(wine) # qemu-riscv: the emulator a bare-metal riscv artifact runs in @@ -251,7 +261,7 @@ echo "Detected capabilities: ${CAPS[*]:-}" # CAPS+=() calls above by tests/e2e/README or by reading them -- keep it in # sync when adding a capability. KNOWN_CAPS=(android-device android-ndk elf fresh-sandbox gcc import-std-libcxx jq llvm macos - mingw mingw-cross msvc musl nasm no-msvc pack patchelf python3 + mingw mingw-cross mingw-host-headers msvc musl nasm no-msvc pack patchelf python3 qemu-arm qemu-riscv scan-deps symlink unix-shell windows wine xlings-msvc) diff --git a/tests/unit/test_hostflags.cpp b/tests/unit/test_hostflags.cpp index f08d0284..bac1eef8 100644 --- a/tests/unit/test_hostflags.cpp +++ b/tests/unit/test_hostflags.cpp @@ -744,3 +744,136 @@ TEST(HostFlags, AppleFloatMacrosAreStatedForClangOnAppleTargetsOnly) { EXPECT_TRUE(mcpp::toolchain::apple_float_macro_words(tc(CompilerId::GCC, "x86_64-linux-gnu")).empty()); EXPECT_TRUE(mcpp::toolchain::apple_float_macro_words(tc(CompilerId::MSVC, "x86_64-pc-windows-msvc")).empty()); } + +// ── #662: the C library's own host locations, and its C++ twin ───────────── +// +// Same fixture as the two suites above — a sibling `.cfg` beside two +// empty files is a complete driver model for `resolve_clang_driver`, and the +// tests below need only that `bypassCfg` is true. +namespace { + +struct ClangCfgFixture { + std::filesystem::path root; + ClangCfgFixture(std::string_view name) { + namespace fs = std::filesystem; + root = fs::temp_directory_path() / name; + fs::remove_all(root); + fs::create_directories(root / "bin"); + fs::create_directories(root / "include" / "c++" / "v1"); + { std::ofstream(root / "bin" / "clang++"); } + { std::ofstream(root / "bin" / "clang++.cfg"); } + } + ~ClangCfgFixture() { std::error_code ec; std::filesystem::remove_all(root, ec); } + + mcpp::toolchain::Toolchain toolchain() const { + mcpp::toolchain::Toolchain tc; + tc.compiler = CompilerId::Clang; + tc.targetTriple = "x86_64-windows-gnu"; + tc.binaryPath = root / "bin" / "clang++"; + return tc; + } +}; + +bool contains(const std::vector& v, std::string_view f) { + return std::ranges::find(v, f) != v.end(); +} + +std::size_t count_of(const std::vector& v, std::string_view f) { + return std::ranges::count(v, f); +} + +} // namespace + +// A graph-supplied C library closes the driver's own C-library search — +// the compile-side half of the `-nostdlib` the link side has read since +// #511. Measured absent before this fix: the openkal windows-gnu row's +// C units kept finding `/usr/x86_64-w64-mingw32/include` (the host's mingw) +// ahead of musl's own headers. +TEST(HostFlags, GraphSuppliedCLibraryGetsNostdlibinc) { + ClangCfgFixture fx("mcpp_hostflags_662_clib_fixture"); + auto tc = fx.toolchain(); + ASSERT_TRUE(mcpp::toolchain::resolve_clang_driver(tc).hasCfg); + + HostFlagOptions payload; + payload.cfgBypass = HostFlagOptions::CfgBypass::Always; + payload.cAbiPrebuilt = true; + EXPECT_FALSE(contains(mcpp::toolchain::host_compile_tokens( + tc, payload, mcpp::toolchain::no_escape), "-nostdlibinc")); + + HostFlagOptions graph = payload; + graph.cAbiPrebuilt = false; + EXPECT_TRUE(contains(mcpp::toolchain::host_compile_tokens( + tc, graph, mcpp::toolchain::no_escape), "-nostdlibinc")); +} + +// THE OPENKAL SHAPE ITSELF (#662): both layers come from the graph at once. +// Before this fix `-nostdinc++` was conditioned on `!graphSuppliesTarget`, +// which is false exactly here, so it never fired — clang kept searching +// beside itself for the payload's libc++, found the HOST's libstdc++ +// instead, and any unit reaching it transitively (not just `import std`) +// carried two C++ standard libraries. +TEST(HostFlags, BothLayersFromGraphGetBothIsolationTokens) { + ClangCfgFixture fx("mcpp_hostflags_662_both_fixture"); + auto tc = fx.toolchain(); + ASSERT_TRUE(mcpp::toolchain::resolve_clang_driver(tc).hasCfg); + + HostFlagOptions opt; + opt.cfgBypass = HostFlagOptions::CfgBypass::Always; + opt.cAbiPrebuilt = false; // c-abi: musl, from the graph + opt.cxxFromGraph = true; // c++-abi: libc++, from the graph + const auto toks = mcpp::toolchain::host_compile_tokens( + tc, opt, mcpp::toolchain::no_escape); + + EXPECT_TRUE(contains(toks, "-nostdlibinc")); + EXPECT_TRUE(contains(toks, "-nostdinc++")); + // Exactly once each — the two branches that can add `-nostdinc++` + // (graph C++, SDK C++) must not both fire for the same build. + EXPECT_EQ(count_of(toks, "-nostdinc++"), 1u); + EXPECT_EQ(count_of(toks, "-nostdlibinc"), 1u); +} + +// THE REGRESSION GUARD: a build where both layers stay the payload's — every +// native build, and every build over a prebuilt/payload C library — emits +// the identical token sequence this fix touched nothing for. Compared as a +// whole vector rather than by presence, so an accidental REORDERING (which +// would still change the rendered command line) fails this test too. +TEST(HostFlags, PayloadServedCommandLineIsUnchanged) { + ClangCfgFixture fx("mcpp_hostflags_662_guard_fixture"); + auto tc = fx.toolchain(); + ASSERT_TRUE(mcpp::toolchain::resolve_clang_driver(tc).hasCfg); + + HostFlagOptions opt; + opt.cfgBypass = HostFlagOptions::CfgBypass::Always; + opt.cAbiPrebuilt = true; // default in every caller with no graph target + opt.cxxFromGraph = false; + + const auto toks = mcpp::toolchain::host_compile_tokens( + tc, opt, mcpp::toolchain::no_escape); + const std::vector expected{ + "--no-default-config", "-nostdinc++", + "-isystem" + (fx.root / "include" / "c++" / "v1").string(), + }; + EXPECT_EQ(toks, expected); + EXPECT_FALSE(contains(toks, "-nostdlibinc")); +} + +// GCC's fixture carries no `.cfg` (resolve_clang_driver only looks +// for one beside a Clang binary), so `bypassCfg` is false and neither +// isolation branch runs — GCC's own equivalent is not implemented (see +// `mcpp::toolchain::can_isolate_graph_c_library`), and a target row that +// needs it is refused before reaching here (prepare.cppm), not silently +// left unisolated. +TEST(HostFlags, GccEmitsNeitherIsolationTokenAndCannotIsolate) { + auto tc = tc_for(CompilerId::GCC); + EXPECT_FALSE(mcpp::toolchain::can_isolate_graph_c_library(tc)); + EXPECT_TRUE(mcpp::toolchain::can_isolate_graph_c_library(tc_for(CompilerId::Clang))); + + HostFlagOptions opt; + opt.cfgBypass = HostFlagOptions::CfgBypass::Always; + opt.cAbiPrebuilt = false; + opt.cxxFromGraph = true; + const auto toks = mcpp::toolchain::host_compile_tokens( + tc, opt, mcpp::toolchain::no_escape); + EXPECT_FALSE(contains(toks, "-nostdlibinc")); + EXPECT_FALSE(contains(toks, "-nostdinc++")); +} diff --git a/tests/unit/test_ninja_backend.cpp b/tests/unit/test_ninja_backend.cpp index bdb890ba..cb659bec 100644 --- a/tests/unit/test_ninja_backend.cpp +++ b/tests/unit/test_ninja_backend.cpp @@ -1633,6 +1633,75 @@ TEST(LinkFailureAdvice, CarriesNoVersionLiteral) { EXPECT_EQ(advice.find("0."), std::string::npos) << advice; } +// ── graph_c_library_isolation_advice (mcpp#662) ───────────────────────────── +// +// A graph-supplied C library closes the compiler's own search of the host's +// copy (`-nostdlibinc`); a package that only compiled before because a host +// header filled a gap the graph did not now fails naming a header the reader +// has never asked for. The advice fires on the SAME EVIDENCE a human would +// use to diagnose it: the isolating token on the command line, together with +// the compiler's own "file not found". + +TEST(GraphCLibraryIsolationAdvice, FiresOnlyWhenBothTheTokenAndTheErrorAreThere) { + // Neither alone is enough. + EXPECT_TRUE(mcpp::build::graph_c_library_isolation_advice( + "clang: error: something else entirely\n").empty()); + EXPECT_TRUE(mcpp::build::graph_c_library_isolation_advice( + "-nostdlibinc -std=c11 -c probe.c\n" + "probe.c:1:1: error: unknown type name 'nonsense'\n").empty()); + EXPECT_TRUE(mcpp::build::graph_c_library_isolation_advice( + "clang -xc -std=c11 -c probe.c\n" + "probe.c:1:10: fatal error: 'io.h' file not found\n").empty()); + // Both together: an ordinary compile failure the fix's own token is on. + const std::string out = + "FAILED: obj/probe.c.o\n" + "clang -std=c11 --target=x86_64-w64-windows-gnu -nostdlibinc " + "-nostdinc++ -c probe.c -o obj/probe.c.o\n" + "probe.c:1:10: fatal error: 'io.h' file not found\n" + " 1 | #include \n"; + EXPECT_FALSE(mcpp::build::graph_c_library_isolation_advice(out).empty()); +} + +TEST(GraphCLibraryIsolationAdvice, NamesTheLibraryWhenGiven) { + const std::string out = + "clang -nostdlibinc -c probe.c\n" + "probe.c:1:10: fatal error: 'io.h' file not found\n"; + auto advice = mcpp::build::graph_c_library_isolation_advice( + out, "musl", "openkal-musl@0.3.5"); + ASSERT_FALSE(advice.empty()); + EXPECT_NE(advice.find("musl"), std::string::npos); + EXPECT_NE(advice.find("openkal-musl@0.3.5"), std::string::npos); + // Both remedies from the plan: adapting to the C library, and bringing + // the platform dependency into the graph privately. + EXPECT_NE(advice.find("cfg(c-abi = \"musl\")"), std::string::npos); + EXPECT_NE(advice.find("private"), std::string::npos); +} + +TEST(GraphCLibraryIsolationAdvice, DegradesWithoutNamingAnyPackage) { + // The fast path (execute.cppm) has no BuildPlan to read a name from. The + // note still fires — it does not stay silent just because the caller + // could not name the library — and it does not fabricate a name either. + const std::string out = + "clang -nostdlibinc -c probe.c\n" + "probe.c:1:10: fatal error: 'io.h' file not found\n"; + auto advice = mcpp::build::graph_c_library_isolation_advice(out); + EXPECT_FALSE(advice.empty()); + EXPECT_NE(advice.find("this target's C library"), std::string::npos); +} + +TEST(GraphCLibraryIsolationAdvice, DoesNotRewriteTheCompilersOwnLine) { + // "Reuse the channel, don't rewrite compiler output": the advice is + // APPENDED text, and the original diagnostic line survives byte for byte + // wherever a caller concatenates it (as both call sites do). + const std::string compilerLine = + "probe.c:1:10: fatal error: 'io.h' file not found\n"; + const std::string out = "clang -nostdlibinc -c probe.c\n" + compilerLine; + auto advice = mcpp::build::graph_c_library_isolation_advice(out); + ASSERT_FALSE(advice.empty()); + std::string combined = out + advice; + EXPECT_NE(combined.find(compilerLine), std::string::npos); +} + // ═══ mcpp#533 / mcpp#534: emitter self-checks ══════════════════════════════ // ── A rule's command must begin with a program (mcpp#533) ────────────────── From 765bd1e0d92f366c03ce65a8f50056dce6665fba Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:56:25 +0800 Subject: [PATCH 2/5] regenerate .agents/docs/README.md index for the #662 plan doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's check_docs_structure.sh caught this — python3 .github/tools/gen_agents_index.py. --- .agents/docs/README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.agents/docs/README.md b/.agents/docs/README.md index 2026b401..2b337a8c 100644 --- a/.agents/docs/README.md +++ b/.agents/docs/README.md @@ -18,7 +18,7 @@ superseded_by: 2026-09-07-....md # when status is superseded --- ``` -295 records. +296 records. ## By subject @@ -63,6 +63,7 @@ Records that declare one. Everything else is listed by date below. ### triage - [运行时绑定方案 v3:让 mcpp 真正安装它所声明的运行时](2026-09-17-runtime-binding-multi-repo-plan.md) — landed +- [#662:目标侧由依赖图提供时,编译器的隐式头文件搜索仍指向宿主](2026-09-17-issue-662-graph-target-header-isolation-plan.md) — active - [Issue #660 分析:`glibc@2.44` 绑定在 2.44.3 发布后解析失败](2026-09-17-issue-660-glibc-line-binding-analysis.md) — landed - [#655 implemented: one reading of a compile-flag element, the plan, and the ledger](2026-09-17-655-implementation-plan.md) — landed - [#655: the `arguments` array is not the command the build runs](2026-09-17-655-arguments-are-not-the-command-the-build-runs.md) — landed @@ -81,6 +82,7 @@ Records that declare one. Everything else is listed by date below. ### 2026-09 - [运行时绑定方案 v3:让 mcpp 真正安装它所声明的运行时](2026-09-17-runtime-binding-multi-repo-plan.md) — landed +- [#662:目标侧由依赖图提供时,编译器的隐式头文件搜索仍指向宿主](2026-09-17-issue-662-graph-target-header-isolation-plan.md) — active - [Issue #660 分析:`glibc@2.44` 绑定在 2.44.3 发布后解析失败](2026-09-17-issue-660-glibc-line-binding-analysis.md) — landed - [#655 implemented: one reading of a compile-flag element, the plan, and the ledger](2026-09-17-655-implementation-plan.md) — landed - [#655: the `arguments` array is not the command the build runs](2026-09-17-655-arguments-are-not-the-command-the-build-runs.md) — landed From d82137b61dcd0ee22597b6228eb4e720012a7036 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 17 Sep 2026 16:57:54 +0800 Subject: [PATCH 3/5] document the visibility field docs/06 (#662) already pointed to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/06's new 'A platform SDK dependency stays private' section cited [05 — Dependencies] for the visibility field, and that field (public / private / interface, defaulting to public) was not documented anywhere. Adds it, in English and zh, at the point in docs/05 where the other per-edge dependency-spec fields are introduced. --- docs/05-dependencies.md | 34 ++++++++++++++++++++++++++++++++++ docs/zh/05-dependencies.md | 29 +++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/docs/05-dependencies.md b/docs/05-dependencies.md index 4283257b..f015be61 100644 --- a/docs/05-dependencies.md +++ b/docs/05-dependencies.md @@ -112,6 +112,40 @@ baz = "=1.2.3" # Exact match qux = ">=1.0, <2.0" # Range combination ``` +### `visibility` — whether a dependency's usage crosses this package's own boundary + +```toml +[dependencies] +sdk = { version = "1.0", visibility = "private" } +``` + +Every dependency edge carries a `visibility`, defaulting to `public`. It +decides whether the dependency's include directories, defines and flags — +what the dependency asks of a consumer, `provides`/`requires` aside — reach +only this package's own translation units, or reach this package's +*consumers* as well. + +| Value | This package's own units | This package's consumers | +|---|---|---| +| `public` (default) | yes | yes | +| `private` | yes | **no** | +| `interface` | no | yes | + +`private` is the ordinary case for an implementation detail: a vendored +library, a platform SDK a package needs to implement a facility but does not +expose in its own interface. `interface` is the rarer, opposite case — a +header-only dependency this package's own headers `#include` but whose +objects it never compiles against. `public` is what most dependencies want: +a type from the dependency appears in this package's own public headers, so +a consumer needs the same include path to use them. + +Getting this wrong in one direction is silent (an unused `public` broadcasts +headers nobody asked for) and in the other is a build failure at the first +consumer that needed what `private` withheld — which is the safer failure +mode, and why `[feature-deps.]` (§06, "A platform SDK dependency +stays private") states `private` explicitly rather than relying on a default +that happens to work until a consumer is added. + ### When two declarations of one dependency disagree Two edges in the dependency graph can name the same identity — the same diff --git a/docs/zh/05-dependencies.md b/docs/zh/05-dependencies.md index 8090f86e..6b7111de 100644 --- a/docs/zh/05-dependencies.md +++ b/docs/zh/05-dependencies.md @@ -103,6 +103,35 @@ baz = "=1.2.3" # 精确匹配 qux = ">=1.0, <2.0" # 范围组合 ``` +### `visibility` —— 一个依赖的用法是否跨越本包自己的边界 + +```toml +[dependencies] +sdk = { version = "1.0", visibility = "private" } +``` + +每条依赖边都带一个 `visibility`,默认 `public`。它决定该依赖对消费方提出的要求 +——`provides`/`requires` 之外,头文件目录、宏定义与 flag——只到达本包自己的 +翻译单元,还是同时到达本包的**消费方**。 + +| 取值 | 本包自己的翻译单元 | 本包的消费方 | +|---|---|---| +| `public`(默认) | 是 | 是 | +| `private` | 是 | **否** | +| `interface` | 否 | 是 | + +`private` 是实现细节的常规情形:一个被 vendor 进来的库,或者一个包为实现某个 +功能而需要、但自己的接口并不暴露的平台 SDK。`interface` 是更少见的反向情形—— +本包自己的头文件会 `#include` 这个依赖,但本包从不编译链接它的目标文件。 +`public` 是大多数依赖想要的:依赖的某个类型出现在本包自己的公开头文件里, +消费方就需要同一条头文件搜索路径才能用到它们。 + +在某一个方向上写错是静默的(不必要的 `public` 把没人要的头文件广播出去), +在另一个方向上则是构建失败——在第一个需要 `private` 所隐藏之物的消费方那里 +——这是更安全的失败方式,也是为什么 `[feature-deps.]` +(见 06 —— "平台 SDK 依赖保持私有")要显式写出 `private`,而不是依赖一个 +恰好在没有消费方之前都能用的默认值。 + ### 同一依赖两条声明冲突的处理 依赖图里的两条边可能指向同一个身份 —— 同一个 `(namespace, name)` 二元组 —— From bcbb3e0440fbd0eef9c072eb7df812baabd7ffa1 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:16:45 +0800 Subject: [PATCH 4/5] Revert the GCC refusal: it breaks the "report before compiling" contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's "invariants (linux-x86_64)" job turned up what local testing had not: 268, 282 and 303 all went red. The common shape is a `provides = ["mcpp:c-abi=..."]` fixture package with no real headers, used to exercise something the target-side report says — none of them about isolation — under this host's ambient default toolchain, which is gcc. The refusal fired before the target-side report line ever printed, which is exactly what 268's own comment says must not happen ("the resolution is reported during planning ... before a single object is compiled ... complete whether or not the link afterwards succeeds"): a hard refusal at that point is not a stricter report, it is the report's replacement. GCC's behaviour on this combination reverts to precisely what it was before #662: `hostflags.cppm` never emitted `-nostdlibinc` for GCC in the first place (`bypassCfg` is already false there — no `.cfg` beside a GCC binary), so nothing about GCC's own compile command changes. What is removed is the extra, invented gate in `prepare_build` and the `can_isolate_graph_c_library` predicate it read, both added preemptively and unsupported by anything the plan actually required — measured against the real test suite rather than against a hypothesis about it. - `prepare.cppm`: the `LayerRequirement` refusal for "graph-supplied C library + a compiler that cannot isolate" is removed. - `modules/toolchain-model`: `can_isolate_graph_c_library` is removed — unused once nothing enforces it, and its comment's claim about `prepare.cppm` would otherwise go stale silently. - `hostflags.cppm`, `test_hostflags.cpp`: comments corrected to state GCC's actual, unchanged behaviour instead of a removed enforcement. - Re-verified: 268, 282, 303 (the three CI caught), plus 292, 304, 328, 619, 281 (every other e2e using a synthetic c-abi/kernel-abi provider), 286 (the real openkal stack), 295-302 (the native-target and toolchain-origin invariants), 130/133 (freestanding), 736, and 738/739 (this change's own new tests) — all still green. Full unit suite: 121/121 files, 0 failed. --- CHANGELOG.md | 12 ++++++++--- modules/toolchain-model/src/model.cppm | 25 ---------------------- src/build/prepare.cppm | 29 -------------------------- src/toolchain/hostflags.cppm | 15 ++++++++----- tests/unit/test_hostflags.cpp | 15 ++++++------- 5 files changed, 27 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfd738c3..60b618f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,9 +20,15 @@ 条件改为只看这一层自己的来源,不再看 `c-abi` 是否也来自图。两个 token 出现在 `cflags`/ `cxxflags`/`asmflags` 这组全局 flags 里,C、C++、汇编、依赖扫描、std 模块预编译同时受益。 (`src/toolchain/hostflags.cppm`,单测 `test_hostflags.cpp`) -- **GCC 家族没有等价的单一 flag。** 穷举现有目标行后没有「图供给 C 库 + GCC」的组合,模型 - 给出 `can_isolate_graph_c_library`(只对 Clang 家族为真),`prepare.cppm` 在这一组合出现时 - 据此拒绝并给出原因,而不是静默地不隔离。(`modules/toolchain-model/src/model.cppm`) +- **GCC 家族没有等价的单一 flag,保持原样不隔离。** `-nostdinc` 加 `-isystem `、`<…/include-fixed>` 才能拼出等价物,且 GCC 本就没有 + `.cfg` 可供 `bypassCfg` 打开这整块——`hostflags.cppm` 因此对 GCC 不做任何改动。 + 最初的草案在 `prepare.cppm` 里加了一条「图供给 C 库 + GCC」的硬拒绝,想着比静默不隔离更 + 诚实;实测(268、282、303 三个既有 e2e)显示这个组合是这几个文件的常规夹具——它们用一个 + 只声明 `provides = ["mcpp:c-abi=..."]`、不含真实头文件的假包在**原生**目标上验证与隔离 + 完全无关的其它事实,且都依赖 268 自己注释所陈述的设计:目标侧的解析报告先于编译打印, + 失败与否不影响这条报告——而这条硬拒绝恰好抢在报告打印之前退出,三个文件全部转红。撤回 + 该拒绝;GCC 在这一组合上的行为与 #662 之前逐字节相同。 - **隔离后原本靠宿主补齐的包会确定地失败,失败信息现在可读。** 构建失败、目标侧 `c-abi` 来自图、且编译器输出含 `file not found` 时,追加一条说明,点名 C 库(名字与 `包名@版本`)并给出两条路:按 `cfg(c-abi = "...")` 适配,或把平台依赖以私有可见性带进 diff --git a/modules/toolchain-model/src/model.cppm b/modules/toolchain-model/src/model.cppm index 043f5144..a717f7bf 100644 --- a/modules/toolchain-model/src/model.cppm +++ b/modules/toolchain-model/src/model.cppm @@ -293,27 +293,6 @@ bool is_musl_target(const Toolchain& tc); bool is_msvc_target(const Toolchain& tc); bool is_mingw_target(const Toolchain& tc); -// CAN THIS COMPILER FAMILY STOP ITS OWN DRIVER SEARCHING THE HOST'S C -// LIBRARY WHEN A PACKAGE SUPPLIES THE TARGET'S INSTEAD? (mcpp#662) -// -// Clang answers yes, with `-nostdlibinc` (mcpp.toolchain.hostflags). GCC has -// no single equivalent flag — the shape would be `-nostdinc` plus `-isystem -// ` and `<…/include-fixed>`, re-adding exactly -// the two directories GCC's own C-library search contributes beside the -// sysroot. -// -// NOT IMPLEMENTED, BECAUSE NO TARGET ROW NEEDS IT YET. Enumerated at #662: -// every existing combination where a package supplies the target's C -// library resolves a Clang-family compiler — the one openkal package that -// requires nothing of the compiler layer (openkal-musl) is still only ever -// built under the toolchain its `provides` siblings (openkal-llvm-runtime, -// which DOES `require mcpp:compiler=llvm`) pull in. `prepare.cppm` refuses a -// row that pairs GCC with a graph-supplied C library, reading this -// predicate, rather than silently leaving it unisolated — the defect this -// module exists to fix was exactly a silent gap between what the link side -// assumed and what the compile side did. -bool can_isolate_graph_c_library(const Toolchain& tc); - // THE FLAGS A WHOLE GRAPH HAS TO AGREE ON WHEN THE RUNTIME COMES FROM IT. // // An ordinary flag is a package's business. These two are not: they change what @@ -497,10 +476,6 @@ bool is_clang(const Toolchain& tc) { return tc.compiler == CompilerId::Clang; } -bool can_isolate_graph_c_library(const Toolchain& tc) { - return is_clang(tc); -} - // Target-shape predicates read the parsed canonical Triple (triple.cppm is // the single triple parser), with the old substring heuristics kept only as // a fallback for triples outside the language. This makes them spelling- diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 93731660..7694d184 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -10902,35 +10902,6 @@ prepare_build(bool print_fingerprint, refusal::record(refusal::Code::LayerOrdering); return std::unexpected(*why); } - // THE COMPILER MUST BE ABLE TO STOP ITS OWN DRIVER SEARCHING THE - // HOST'S C LIBRARY WHEN A PACKAGE SUPPLIES THE TARGET'S INSTEAD - // (#662). `mcpp.toolchain.hostflags` emits `-nostdlibinc` for that — - // a Clang-only flag — and GCC has no one-token equivalent - // (`mcpp::toolchain::can_isolate_graph_c_library`). No target row - // needs the GCC form today (every graph-supplied C library resolves - // a Clang-family compiler in the ecosystem this checks against), so - // the combination is refused here rather than built unisolated: the - // alternative is the exact defect #662 reports, one layer later and - // reported as a package's header conflict rather than as what it is. - if (tc && !tc->cAbiPrebuilt - && !mcpp::toolchain::can_isolate_graph_c_library(*tc)) { - refusal::record(refusal::Code::LayerRequirement); - return std::unexpected(std::format( - "the target's C library ('{}') comes from the dependency " - "graph, and the resolved compiler ('{}') has no way to stop " - "its own driver searching the host's C library headers " - "alongside it.\n" - " Every target row this engine isolates today resolves " - "a Clang-family ('llvm') compiler; add\n" - " [toolchain]\n" - " default = \"llvm@\"\n" - " or, for one target only:\n" - " [target.{}]\n" - " toolchain = \"llvm@\"", - resolvedTargetSide.cAbi.interfaceName, tc->compiler_family(), - overrides.target_triple.empty() ? std::string("") - : overrides.target_triple)); - } // REQUIREMENTS ARE CHECKED BEFORE ANYTHING IS COMPILED, WHICH IS THE // WHOLE POINT OF DECLARING THEM. The combination this rejects — a C++ // runtime configured for one compiler family being handed to another — diff --git a/src/toolchain/hostflags.cppm b/src/toolchain/hostflags.cppm index 89412b34..1d6de266 100644 --- a/src/toolchain/hostflags.cppm +++ b/src/toolchain/hostflags.cppm @@ -449,11 +449,16 @@ std::vector host_compile_tokens(const Toolchain& tc, // with musl's (`typedef redefinition`, `conflicting types for 'chmod'`). // With it, the list ends at the compiler's own resource directory. // - // GCC has no equivalent single flag — see - // `mcpp::toolchain::can_isolate_graph_c_library`. The combination is - // refused at resolution (prepare.cppm) rather than reaching here with - // nothing to emit; `dm.hasCfg` is false for GCC in any case, so - // `bypassCfg` already withholds this block from that family today. + // GCC has no equivalent single flag — the shape would be `-nostdinc` + // plus `-isystem ` and `<…/include-fixed>`, + // re-adding exactly the two directories GCC's own C-library search + // already contributes beside the sysroot. Not implemented: `dm.hasCfg` + // is false for GCC (resolve_clang_driver only looks for a `.cfg` + // beside a Clang binary), so `bypassCfg` already withholds this whole + // block from that family — GCC stays exactly as it was before #662, + // unisolated, on every target row this codebase's own table pairs it + // with today (test_hostflags.cpp, + // GccEmitsNeitherIsolationTokenRegardlessOfGraphOrigin). if (bypassCfg && graphSuppliesTarget) out.push_back("-nostdlibinc"); // THE CONDITION USED TO BE `!graphSuppliesTarget && !cxxFromPayload`, diff --git a/tests/unit/test_hostflags.cpp b/tests/unit/test_hostflags.cpp index bac1eef8..78577cd7 100644 --- a/tests/unit/test_hostflags.cpp +++ b/tests/unit/test_hostflags.cpp @@ -859,14 +859,15 @@ TEST(HostFlags, PayloadServedCommandLineIsUnchanged) { // GCC's fixture carries no `.cfg` (resolve_clang_driver only looks // for one beside a Clang binary), so `bypassCfg` is false and neither -// isolation branch runs — GCC's own equivalent is not implemented (see -// `mcpp::toolchain::can_isolate_graph_c_library`), and a target row that -// needs it is refused before reaching here (prepare.cppm), not silently -// left unisolated. -TEST(HostFlags, GccEmitsNeitherIsolationTokenAndCannotIsolate) { +// isolation branch runs. `-nostdlibinc` is Clang's own flag; GCC has no +// single-token equivalent (the shape would be `-nostdinc` plus `-isystem +// ` and `<…/include-fixed>`, re-adding +// exactly the two directories GCC's own C-library search already +// contributes beside the sysroot) — so GCC stays exactly as it was before +// #662: unisolated. No target row in this codebase's own target table pairs +// a graph-supplied C library with GCC today, so nothing currently needs it. +TEST(HostFlags, GccEmitsNeitherIsolationTokenRegardlessOfGraphOrigin) { auto tc = tc_for(CompilerId::GCC); - EXPECT_FALSE(mcpp::toolchain::can_isolate_graph_c_library(tc)); - EXPECT_TRUE(mcpp::toolchain::can_isolate_graph_c_library(tc_for(CompilerId::Clang))); HostFlagOptions opt; opt.cfgBypass = HostFlagOptions::CfgBypass::Always; From 356c77ec8e2a76f1ac9d67c250fa2dda240783b4 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:27:29 +0800 Subject: [PATCH 5/5] D4: a compiler that cannot isolate a graph-supplied C library is degraded The revert (bcbb3e04) removed the hard refusal for "graph-supplied C library + a compiler that cannot isolate" because it broke the "report before refusing" contract. That left GCC silent again on this combination -- which is what D4 actually ruled out. This adds the third option: a degradation, not a refusal and not silence. `mcpp::diag::degraded("target/c-abi-isolation", ...)` fires once resolution knows the C library came from the graph and the resolved compiler is not Clang-family. It names the C library (interface and package@version) and the compiler, points at a Clang-family toolchain as the one that isolates, and changes nothing about the command line -- `hostflags.cppm` was already not emitting an isolation token for GCC, and still is not. `--strict` promotes it to an error through the existing `diag::flush` policy, which is the one place that decision already lives. - `prepare.cppm`: the check sits right where `resolvedTargetSide` becomes available, beside the existing `target/compiler-runtime` degradation, and before `format_report` prints -- so the report is unaffected either way, which is what the earlier refusal got wrong. - e2e 740: names the library and the compiler in the warning, asserts the target-side report still prints (the exact thing the reverted refusal broke), and controls against a Clang-family toolchain over the same graph getting no such note. 268, 282, 303 re-verified green. - CHANGELOG's D4 bullet rewritten to describe what this branch actually does, not the removed refusal. --- CHANGELOG.md | 28 +++-- src/build/prepare.cppm | 33 ++++++ ...graph_c_library_is_degraded_not_refused.sh | 100 ++++++++++++++++++ 3 files changed, 151 insertions(+), 10 deletions(-) create mode 100755 tests/e2e/740_gcc_over_a_graph_c_library_is_degraded_not_refused.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 60b618f3..d7563371 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,15 +20,21 @@ 条件改为只看这一层自己的来源,不再看 `c-abi` 是否也来自图。两个 token 出现在 `cflags`/ `cxxflags`/`asmflags` 这组全局 flags 里,C、C++、汇编、依赖扫描、std 模块预编译同时受益。 (`src/toolchain/hostflags.cppm`,单测 `test_hostflags.cpp`) -- **GCC 家族没有等价的单一 flag,保持原样不隔离。** `-nostdinc` 加 `-isystem `、`<…/include-fixed>` 才能拼出等价物,且 GCC 本就没有 - `.cfg` 可供 `bypassCfg` 打开这整块——`hostflags.cppm` 因此对 GCC 不做任何改动。 - 最初的草案在 `prepare.cppm` 里加了一条「图供给 C 库 + GCC」的硬拒绝,想着比静默不隔离更 - 诚实;实测(268、282、303 三个既有 e2e)显示这个组合是这几个文件的常规夹具——它们用一个 - 只声明 `provides = ["mcpp:c-abi=..."]`、不含真实头文件的假包在**原生**目标上验证与隔离 - 完全无关的其它事实,且都依赖 268 自己注释所陈述的设计:目标侧的解析报告先于编译打印, - 失败与否不影响这条报告——而这条硬拒绝恰好抢在报告打印之前退出,三个文件全部转红。撤回 - 该拒绝;GCC 在这一组合上的行为与 #662 之前逐字节相同。 +- **GCC 家族没有等价的单一 flag,命令行不变,但不再静默。** `-nostdinc` 加 + `-isystem `、`<…/include-fixed>` 才能拼出等价物,且 + GCC 本就没有 `.cfg` 可供 `bypassCfg` 打开这整块——`hostflags.cppm` 因此对 GCC + 不做任何改动,这一组合上产出的编译命令与 #662 之前逐字节相同。 + 中途试过两种更强的处理,都被实测推翻:静默不隔离(#662 之前的状态,`GCC 家族没有等价的 + 单一 flag` 的最初读法)什么也不说;`prepare.cppm` 里一条「图供给 C 库 + GCC」的硬拒绝 + (想着比静默更诚实)在既有 e2e 268、282、303 上转红——它们用一个只声明 + `provides = ["mcpp:c-abi=..."]`、不含真实头文件的假包在**原生**目标上验证与隔离完全无关 + 的其它事实,且都依赖 268 自己注释所陈述的设计:目标侧的解析报告先于编译打印,失败与否 + 不影响这条报告——而硬拒绝恰好抢在报告打印之前退出。第三种形状落地:`mcpp::diag::degraded` + 在目标侧解析出「c-abi 来自图 且 编译器不是 Clang 家族」时报一条 warning,点名 C 库 + (名字与 `包名@版本`)和编译器,并指向 Clang 家族能做到隔离——不拒绝构建,不改动任何命令行 + (`hostflags.cppm` 本就没有为这一组合追加过 token),也不再沉默。`--strict` 下 + `mcpp::diag::flush` 按其既有策略把它提升为错误,复用的是这一整块已经存在的策略,不是新写 + 的一条。(`src/build/prepare.cppm`,e2e 740;268、282、303 仍然全绿) - **隔离后原本靠宿主补齐的包会确定地失败,失败信息现在可读。** 构建失败、目标侧 `c-abi` 来自图、且编译器输出含 `file not found` 时,追加一条说明,点名 C 库(名字与 `包名@版本`)并给出两条路:按 `cfg(c-abi = "...")` 适配,或把平台依赖以私有可见性带进 @@ -44,11 +50,13 @@ 构建与载荷提供 C 库的构建命令行逐字节不变(单测守卫)。这类构建的 flags 指纹变化,升级后 会重建一次。原先只因为宿主头文件补上缺口才能编译的包,现在会确定地失败——这类构建此前只在 装了对应宿主包的机器上成功,产物还混用了两个 C 库的声明;新增的说明使这类失败可以诊断。 + C 库来自图、编译器却是 GCC 家族的构建会多打印一条 warning(命令行不变,构建照常继续)。 - 单测:`test_hostflags.cpp`(选项矩阵 `cAbiPrebuilt × cxxFromGraph × {clang, gcc}`,以及 载荷提供两层时命令行不变的回归守卫)、`test_ninja_backend.cpp`。e2e:738(openkal `x86_64-windows-gnu` 构建,逐单元核对 clang 自己报告的头文件搜索列表,门在 `mingw-host-headers` capability 上,`openkal-cross.yml` 的 `ecosystem-e2e` job 安装 - `mingw-w64` 使其在 CI 上成立)、739(私有 feature-dep 的双向锁定)。 + `mingw-w64` 使其在 CI 上成立)、739(私有 feature-dep 的双向锁定)、740(GCC 家族在 + 图供给 C 库上得到警告而非拒绝,且既有的 268/282/303 仍然全绿)。 ### 声明的 C 运行时由 mcpp 安装,查找只做精确匹配:#660(2026.9.17.2) diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 7694d184..e809f709 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -10769,6 +10769,39 @@ prepare_build(bool print_fingerprint, resolvedTargetSide = tsd::resolve(in); targetSideResolved = true; + // REPORTED, NOT REFUSED (mcpp#662, D4). `mcpp.toolchain.hostflags` + // closes the compiler's own C-library search with `-nostdlibinc` + // when a package supplies the target's C library (M1) — but only on + // a Clang-family driver; GCC has no one-token equivalent + // (`hostflags.cppm`'s own note on the shape it would need). Silently + // building unisolated was ruled out once already: the resolver + // refusing the combination outright was ALSO tried and reverted — + // it fired before `format_report` below and broke three existing + // e2e fixtures (268, 282, 303) that use a synthetic C-library + // provider on this host's native, GCC-default target to assert + // something else entirely, none of them about isolation. A + // degradation is the third option: it changes no command line + // (this branch decides nothing `hostflags.cppm` does not already + // decide on its own), and it does not stop a build the previous + // release would have allowed — it names, once, the gap the previous + // release left silent. + if (tc && resolvedTargetSide.cAbi.fromGraph() + && !mcpp::toolchain::is_clang(*tc)) { + mcpp::diag::degraded("target/c-abi-isolation", std::format( + "the target's C library ('{}', {}) comes from the " + "dependency graph, and the resolved compiler ('{}') has no " + "way to stop its own driver from also searching the host's " + "C library headers", + resolvedTargetSide.cAbi.interfaceName, + resolvedTargetSide.cAbi.impl, tc->compiler_family()), + "a host header can still satisfy an #include the graph's " + "own headers do not, silently — a Clang-family toolchain " + "closes that search entirely (docs/22 'Adaptation To The " + "Resolved Target Side')", + "add [toolchain] default = \"llvm@\", or for one " + "target only [target.] toolchain = \"llvm@\""); + } + // REPORTED ONCE, NOT REFUSED. A program that never reaches an // availability check links and runs without the archive; refusing it // would trade a diagnosed hazard for a regression. The degradation diff --git a/tests/e2e/740_gcc_over_a_graph_c_library_is_degraded_not_refused.sh b/tests/e2e/740_gcc_over_a_graph_c_library_is_degraded_not_refused.sh new file mode 100755 index 00000000..ee2aee75 --- /dev/null +++ b/tests/e2e/740_gcc_over_a_graph_c_library_is_degraded_not_refused.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# requires: gcc +# 740 -- when the target's C library comes from the dependency graph and the +# resolved compiler cannot isolate it from the host's own (GCC: no single +# flag equivalent to Clang's `-nostdlibinc`), mcpp reports a degradation and +# proceeds. It neither refuses the build nor stays silent. +# +# THE HISTORY, BECAUSE BOTH OTHER SHAPES WERE TRIED AND MEASURED WRONG. +# +# Silent was the state before mcpp#662's M1: GCC never emitted the isolation +# token (hostflags.cppm's `bypassCfg` is unconditionally false for GCC, no +# `.cfg` beside it), and nothing said so. +# +# A hard refusal at `prepare_build` was tried next, reasoning "never +# silently unisolated" all the way to a build error. Measured against the +# existing suite it broke three files (268, 282, 303) that use a synthetic +# `provides = ["mcpp:c-abi=..."]` package with the AMBIENT default toolchain +# (gcc on a fresh Linux runner) to assert something about the target-side +# REPORT, none of them about isolation -- and the refusal fired before that +# report printed, which is exactly what 268's own comment says must never +# happen: "the resolution is reported during planning ... before a single +# object is compiled ... complete whether or not the link afterwards +# succeeds". +# +# `mcpp::diag::degraded` is the third option this test locks in: the build +# is not refused (268/282/303 stay green -- this file's own half two +# reproduces 303's shape and checks the report line still prints), and the +# gap is not silent either (half one). +set -e + +MCPP="${MCPP:-mcpp}" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +cd "$work" + +mkdir -p libc/src +printf '[package]\nname = "fake-libc"\nversion = "0.1.0"\nprovides = ["mcpp:c-abi=musl"]\n\n[build]\nsources = []\n' \ + > libc/mcpp.toml +printf '[package]\nname = "clibdeg"\nversion = "0.1.0"\n\n[dependencies]\nfake-libc = { path = "libc" }\n' \ + > mcpp.toml + +# `why toolchain`, NOT `build`: this file's subject is what mcpp REPORTS +# during resolution, and a real link additionally needs a working sandbox +# C-runtime setup that has nothing to do with the claim here (`build`'s own +# behaviour on this exact fixture is 303's territory). +out="$("$MCPP" why toolchain 2>&1)" + +# ── Half one: the degradation names the library and the compiler ─────────── +echo "$out" | grep -q '^warning: ' || { + echo "FAIL: no warning at all on a GCC toolchain over a graph-supplied C library:" + echo "$out"; exit 1 +} +echo "$out" | grep -qF "C library ('musl'" || { + echo "FAIL: the warning does not name the C library:"; echo "$out"; exit 1 +} +echo "$out" | grep -qF "resolved compiler ('gcc')" || { + echo "FAIL: the warning does not name the compiler that cannot isolate:" + echo "$out"; exit 1 +} +echo "$out" | grep -qi 'clang' || { + echo "FAIL: the warning does not point at the toolchain that does isolate:" + echo "$out"; exit 1 +} +echo " ok a GCC toolchain over a graph-supplied C library is named, not silent" + +# ── Half two: it is a note, not a refusal -- the report still reaches print ─ +# +# THE EXACT FAILURE MODE THE HARD REFUSAL HAD: firing before `format_report` +# printed anything at all. Asserting the report line is present is what +# distinguishes "advisory" from "the build's replacement". +echo "$out" | grep -qE 'c-abi *musl' || { + echo "FAIL: the target-side report did not print at all -- looks like the" + echo " refusal this file exists to keep reverted:" + echo "$out"; exit 1 +} +echo " ok the target-side report still prints (this is advisory, not a refusal)" + +# ── Half three: the control -- a Clang-family toolchain gets no such note ── +# +# THE HARNESS'S OWN VALIDITY. Without this, half one could be passing because +# the message ALWAYS prints, on every toolchain, which would say nothing +# about GCC specifically. +llvmspec="$("$MCPP" toolchain list --format json 2>/dev/null \ + | jq -r '[.data.toolchains[] | select(.family=="llvm") | "llvm@"+.version] + | unique | .[0] // empty' | tr -d '\r')" +if [ -n "$llvmspec" ]; then + printf '[package]\nname = "clibdeg"\nversion = "0.1.0"\n\n[toolchain]\ndefault = "%s"\n\n[dependencies]\nfake-libc = { path = "libc" }\n' \ + "$llvmspec" > mcpp.toml + out2="$("$MCPP" why toolchain 2>&1)" + if echo "$out2" | grep -q "has no way to stop"; then + echo "FAIL: the same degradation fired on a Clang-family toolchain, which" + echo " DOES isolate the graph's C library:" + echo "$out2"; exit 1 + fi + echo " ok a Clang-family toolchain over the same graph gets no such note" +else + echo "SKIP: no llvm installed here to run the control against" +fi + +echo "OK: 740 a compiler that cannot isolate a graph-supplied C library is degraded, not refused"