From cbb1a0743abf4c2fbd52c0daf10f276ca08052f6 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:24:38 +0800 Subject: [PATCH 01/31] the c-abi probe measures the target it verifies, and a capability is answered where the answer exists (2026.9.20.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four changes, one subject: information about a capability becomes available at three times, each being the earliest at which it exists (openkal SPEC 0.14 §6.2), and this engine had been answering two of those questions at the wrong one. ## The probe measured the build host The c-abi verification probe selected no target on every freestanding build. `Toolchain::crossTargetFlag` is set for hosted targets only --- its own comment states the reason --- and a freestanding target's `--target` travels with the ISA flags that must accompany it, in `mcpp.freestanding.linkline`, which the probe never asked. `cenv::realise` adds none either. The command line was therefore `-D__unix__ -fno-short-wchar -ffreestanding -x c++ -E -dM -`, and clang answered for the machine it was running on. On a Linux host that machine satisfies `__unix__` defined, `_WIN32` undefined, 32-bit `wchar_t`, so the check passed for the wrong reason. On a Windows host it reported `_WIN32` defined and a 16-bit `wchar_t`, and 2026.9.18.3 read those two as a `--target=` substitution failing to strip host predefines. Clang's predefines follow the target: `--target= riscv64-none-elf` on a Linux host reports `__riscv`, no `__linux__`, and `__SIZEOF_WCHAR_T__` 4. Had that flag been on the command line it would have answered 4 rather than 2; 2 is the evidence that it was absent. The probe now receives the freestanding compile prefix. `hostStripMacros` is removed, and its removal is the point rather than a tidy-up: it deleted the one piece of evidence that said the probe was measuring the wrong machine. `cenv_probe::assemble_argv` holds the invariant --- every piece is legitimately empty in some configuration, so none of them could carry it at the call site --- and refuses a freestanding argv that selects no target while accepting a native hosted one, where the host IS the target and the absence is the decision rather than its omission. The three `hostStripMacros` tests are replaced. They pinned whether `-U` reached the command line, which is the mechanism; what matters is whether the probe measures the right machine, which is the property. The five new `CenvProbeArgv` tests reach it with no cross toolchain at all. ## builtins: the conclusion stands, the mechanism beside it did not `cenv.cppm` said clang's bundled `intrin.h` / `mm_malloc.h` were already closed by `-nostdlibinc`. That flag removes the standard system include directories and leaves clang's own resource directory in place, which is what `-nobuiltininc` removes; with it present `intrin.h:12:15` still reproduces, character for character the diagnostic mcpp-index recorded for fmtlib.fmt. What closes both is the Cygwin-flavoured realisation: `mm_malloc.h:42` selects `__mingw_aligned_malloc` on `__MINGW32__` and falls to `posix_memalign` without it. No Windows loop-idiom builtin to disable, as before. ## A capability is enumerated by the consumer, at resolution openkal SPEC 0.14 §3.3 withdrew `hosted`, the one name it had given to a set of interfaces, because a name describing a class of environment is falsified by an environment nobody had in mind --- and that one was falsified inside its own ecosystem within a release. Enumeration by the consumer replaced it. mcpp now carries that enumeration for the `kernel-abi` layer: `provides-interfaces` on the package that supplies it, `requires-interfaces` on anyone. The engine learns no member of either set. The only operation is a set difference, so a specification may add an interface without a release of mcpp, and a misspelling produces a refusal naming the string rather than a silently disabled check. A graph whose provider states nothing keeps building: a package that states nothing is not a package that provides nothing. ## [c-abi.absent]: enumerate the exception, not the rule POSIX has about twelve hundred names and enumerating them is the mistake §3.3 records withdrawing. The exceptions are enumerable --- openkal-musl's README lists six, in prose that nothing executes and that was contradicted once already. `form` is required and closed: `link` is the shape openkal's own model requires of an implementation (§6.1 calls a run-time report of unsupportedness a defect), and the other two are departures from it, named so that a departure is something that can be counted. mcpp reads the list back when a link names a `link`-shaped entry. ## presents is frozen docs/22 now states that `presents` answers which environment-identity macros source sees and answers nothing about capability, and that its value set does not grow --- for the reason openkal gives for closing its core set. Design: .agents/docs/2026-09-20-openkal-c-environment-ecosystem-design.md Review: .agents/docs/2026-09-20-issue-674-design-review.md Co-authored-by: Claude Code --- ...-issue-674-cenv-posix-preinclude-design.md | 359 ++++++++ .../2026-09-20-ecosystem-execution-plan.md | 64 ++ .../2026-09-20-issue-674-design-review.md | 649 ++++++++++++++ ...-openkal-c-environment-ecosystem-design.md | 842 ++++++++++++++++++ CHANGELOG.md | 108 +++ README.md | 15 +- docs/22-target-side.md | 133 ++- docs/zh/22-target-side.md | 94 +- mcpp.toml | 2 +- modules/manifest/src/targetside_model.cppm | 80 ++ modules/manifest/src/toml.cppm | 116 ++- modules/manifest/src/types.cppm | 24 + modules/versioning/src/version.cppm | 2 +- src/build/ninja_backend.cppm | 50 ++ src/build/prepare.cppm | 187 ++-- src/build/refusal.cppm | 8 + src/toolchain/cenv.cppm | 25 +- src/toolchain/cenv_probe.cppm | 133 ++- ...nterfaces_are_resolved_not_preprocessed.sh | 160 ++++ tests/unit/test_build_flags.cpp | 59 ++ tests/unit/test_cenv_probe.cpp | 203 +++-- tests/unit/test_manifest.cpp | 218 +++++ tests/unit/test_targetside.cpp | 54 ++ 23 files changed, 3334 insertions(+), 251 deletions(-) create mode 100644 .agents/docs/2026-09-19-issue-674-cenv-posix-preinclude-design.md create mode 100644 .agents/docs/2026-09-20-ecosystem-execution-plan.md create mode 100644 .agents/docs/2026-09-20-issue-674-design-review.md create mode 100644 .agents/docs/2026-09-20-openkal-c-environment-ecosystem-design.md create mode 100755 tests/e2e/743_kernel_abi_interfaces_are_resolved_not_preprocessed.sh diff --git a/.agents/docs/2026-09-19-issue-674-cenv-posix-preinclude-design.md b/.agents/docs/2026-09-19-issue-674-cenv-posix-preinclude-design.md new file mode 100644 index 00000000..3d9647f4 --- /dev/null +++ b/.agents/docs/2026-09-19-issue-674-cenv-posix-preinclude-design.md @@ -0,0 +1,359 @@ +--- +subject: design +status: active +--- + + + +# #674:`presents = "posix"` 在 Windows 上兑现契约的下半段 + +- Issue: mcpp-community/mcpp#674(2026-09-18) +- 依据:`origin/main` 361874df(mcpp 2026.9.18.4);本机 Linux x86_64,llvm 22.1.8 +- 上一阶段:mcpp#673(7788d3e, 2026.9.18.3)做了"只压 `_WIN32`、不定义 `__unix__`"的 trade-off,本设计补齐同一 trade-off 的另一面 +- 状态:方案待 review,未实现 + +--- + +## 0. 结论 + +1. **缺陷不在 mcpp#673,不在 openkal-musl,在上游代码的潜伏假设。** 18 个 `compat.*` 包失败是因为 zlib 等上游代码依赖 glibc 的传递包含,musl 不传递包含就暴露了"`lseek` 不在作用域"这一上游 bug。mcpp#673 写下的 trade-off("只压 `_WIN32`、不定义 `__unix__`")已经显式接受这 18 个失败;本设计**不是在撤销 mcpp#673**,而是在同一 trade-off 下补齐 `presents = "posix"` 契约兑现的另一半。 + +2. **修复归属在 mcpp 引擎层,而非 openkal-musl、非 mcpplibs/mcpp-index、非 upstream。** + - openkal-musl 不承担:musl 的"头文件自包含、零传递包含"是设计原则(也是 POSIX 标准本身的要求),openkal-musl 一旦加传递包含就偏离 musl 上游,违背项目定位。 + - mcpplibs/mcpp-index 不承担:18 个 per-package patch 是次优替代(下文 §2.3),但它把复杂度推给每个 compat 包,而这些包其实共享同一个根因。 + - upstream 不在本次修复范围:修 zlib 等 PR 应另开路径(可以是 mcpplibs 上游反馈),不阻塞引擎侧修复。 + - 引擎承担:**`presents = "posix"` 是 mcpp 引擎对编译命令行的契约承诺,POSIX 标准规定哪些符号在哪些头里,引擎要把这些头显式拉进每个 TU。** + +3. **方案在 `src/toolchain/cenv.cppm:282-299` 的 Windows + Posix 分支追加 `-include unistd.h` 与 `-include sys/stat.h`,起点以 zlib 的失败信号为最小集合,通过 30-member 测量迭代追加。** 不一次加齐所有 POSIX 头——`presents = "posix"` 是"POSIX 接口按需可达",不是"所有 POSIX 接口已被强制 include"。前者是契约,后者是包办代替。 + +4. **影响范围天然收在"Windows × x86_64 × `presents = "posix"`"这一格。** 由 cenv.cppm 现有的三层显式闸门(`os == "windows"` / `decl.presents == Posix` / `arch == "x86_64"`)保证,其他组合的编译命令行一字不改,链接行完全不变,运行时零开销。 + +5. **本设计不动 design §3.3 与 execution plan §4。** 不重新打开 mcpp#673 的 trade-off(`__unix__` 定义与否),也不动 cenv.cppm 已有的"`__CYGWIN__`/`__CYGWIN32__` 保持定义"那条记录。**改动是新增一行类的代码 + 一段注释,不改任何已写下的设计决策。** + +--- + +## 1. 实测 + +### 1.1 复现:18 个 compat.* 失败的代表信号(`compat.zlib`) + +``` +$ mcpp build --target x86_64-w64-windows-gnu ... +...openkal-musl-0.15.0/include/... +tests/openkal-work/archive/.mcpp/.../compat-x-zlib/1.3.2/zlib-1.3.2/gzguts.h:50:12: +fatal error: 'io.h' file not found +``` + +issue #674 给出的根因(已实测确认): + +```c +// zlib gzguts.h +#if defined(__TURBOC__) || defined(_MSC_VER) || defined(_WIN32) +# include /* provides lseek on Windows */ +# include +#endif + +// zlib gzlib.c +#if defined(__DJGPP__) +# define LSEEK llseek +#elif defined(_WIN32) && !defined(__BORLANDC__) && !defined(UNDER_CE) +# define LSEEK _lseeki64 +#elif defined(_LARGEFILE64_SOURCE) && _LFS64_LARGEFILE-0 +# define LSEEK lseek64 +#else +# define LSEEK lseek /* lseek must be in scope here */ +#endif +``` + +`__unix__` 已定义(由 cenv 切换 `--target=x86_64-pc-cygwin` 提供),`_WIN32` 未定义(由 mcpp#673 显式压),所以走 `#else` 分支,要求 `lseek` 在作用域。zlib 自己没有 `#include `(它的 `#include ` 等头由 musl 提供,但 musl 不传递包含 ``)。 + +### 1.2 musl vs glibc 的传递包含差异 + +| | glibc | musl | +|---|---|---| +| `` 是否传递包含 `` | **是**(非 POSIX 标准行为) | **否**(POSIX 合规行为) | +| 后果:zlib 在 Linux 上 | `lseek` 在作用域,编译过 | `lseek` 不在作用域,失败 | +| musl 设计原则 | — | "头文件自包含,用户显式包含所需" | + +**musl 的"零传递包含"是设计原则,不是 bug**——POSIX 标准规定 `lseek` 由 `` 提供,**不**要求 `` 提供。所以 zlib 应该自己 `#include `;它在 glibc 上"碰巧工作"是 glibc 的非标准行为。 + +### 1.3 18 个 compat.* 失败的根本同形 + +每个失败包的第一个错误形如: +- `'lseek' was not declared in this scope` → 缺 `` +- `'fstat' was not declared in this scope` → 缺 `` +- `'open' was not declared in this scope` → 缺 `` +- `'mmap' was not declared in this scope` → 缺 `` + +**每个失败包共享同一个根因**:在 `presents = "posix"` 的 Windows + musl 编译下,POSIX 头文件不会自动出现在作用域里——既不是 musl 给的(glibc 才有传递包含),也不是 cenv 给的(目前 cenv 只管身份宏,不强制 include 头文件)。 + +--- + +## 2. 设计原则(对齐已有架构) + +### 2.1 谁写、谁包、谁兑现——四层角色清晰 + +``` +POSIX 标准(抽象契约) + │ + │ 规定 必须声明 lseek + │ +libc 实现(写头的) + │ + ├── musl ──── openkal-musl(包了 musl 的 mcpp 包,自己一行不写头) + ├── glibc ── (系统 glibc) + └── bionic ─ (系统 bionic) + │ +mcpp 引擎(兑现契约的) + │ + └── 读 [c-abi] presents = "posix" → 产出 -include unistd.h +``` + +**每一层只做自己的事:** +- POSIX 规定契约(不实现) +- libc 写头(不偏离 POSIX 也不添加非标准行为) +- openkal-musl 包 musl(不写头、不加补丁) +- mcpp 引擎兑现契约(发 `-include` 指令,不写头也不背头内容) + +如果让 openkal-musl 承担"传递包含"或"兼容 shim",那它就不再是 musl。如果让 mcpp 引擎写头,那它就不是引擎而是 libc 实现。**所以"-include unistd.h"的指令方 = mcpp 引擎、文件提供方 = libc 实现**,两者物理上分属不同角色,不能合并。 + +### 2.2 引擎的 POSIX 知识是"通用知识,非包名" + +`src/toolchain/cenv.cppm:10-14` 已明确: + +> "GENERIC KNOWLEDGE, NO PACKAGE NAMES. ... openkal-musl is nowhere in this file; a second POSIX C library on Windows would realise through the same table." + +`-include unistd.h` 写入 cenv.cppm **不违反**这条规则,因为它写入的是 **POSIX 标准规定的接口头**(所有 POSIX libc 都同意),不是某个 C 库的实现细节(每个 libc 各不相同)。**POSIX 标准头是契约,不是包名。** + +### 2.3 为什么 per-package patch 是次优而非首选 + +`mcpplibs/mcpp-index` 的 `[target.cfg(os = "windows").patches]` 机制是次优替代: + +| 维度 | 引擎 `-include`(本方案) | per-package patch(Path A) | +|---|---|---| +| 代码改动位置 | cenv.cppm 一个分支,1-3 行 | 18 个 PR,每个 ~10 行 patch 文件 | +| 修复面 | 一次性覆盖所有 compat.* + 未来同形失败 | 只覆盖当前 18 个;未来新加 compat 包再补一次 | +| 上游修 bug 后 | 引擎不能自动撤销(无害但白 include) | patch 变 stale,需要人主动撤销 | +| 复杂度归属 | 引擎侧(契约兑现方) | 分散在 18 个包 | +| 与 openkal-musl 的耦合 | **零** | **零** | +| 与上游(zlib 等)的耦合 | **零** | 每个 patch 都是上游 bug 的反向补丁 | + +**两条路径互相不冲突**:本方案是"立刻止血 + 全局正确",per-package patch 是"上游反馈,长期可去"。本方案实施后,per-package patch 仍可在 mcpplibs/mcpp-index 推进,但不再紧迫。 + +### 2.4 为什么不加齐所有 POSIX 头 + +候选"全包"清单: + +``` + + + + +... (POSIX 头共计 ~50 个) +``` + +**反对一次加齐的理由:** +1. **语义错位**:`presents = "posix"` 是"POSIX 接口按需可达",不是"所有 POSIX 接口已被强制 include"。前者是契约(用户代码 / 上游代码按需 include),后者是包办代替。 +2. **每 TU 付出代价**:虽然每个头都很小,但每个 TU 都强制预处理扫描所有头,即使该 TU 不需要。 +3. **撤销成本高**:若未来发现某个头在某个奇怪的目标上有兼容问题,要撤销很麻烦(会影响所有 TU)。 +4. **契约本身就反对**:`presents = "posix"` 的设计意图是"标准接口按需用",不是"全都先 include"。 + +正确做法是**最小集合 + 测量驱动迭代**——以 zlib 失败信号为最小起点(`unistd.h` + `sys/stat.h`),跑 30-member 测量看剩余失败的第一个"未声明标识符"是什么,按需追加。 + +--- + +## 3. 方案 + +### 3.1 mcpp 引擎侧修改(一个 PR) + +**M1 cenv.cppm:282-299 在 Windows + Posix 分支追加 `-include`。** + +```cpp +// src/toolchain/cenv.cppm:282-299 +} else if (os == "windows") { + if (decl.presents == CAbiPresents::Posix) { + if (arch != "x86_64") + return refuse("the Cygwin-flavoured realisation is measured " + "on x86_64 only; this arch has no verified " + "substitute triple"); + r.tokens.push_back("--target=x86_64-pc-cygwin"); + + // ── POSIX contract fulfilment: present POSIX-standard headers ── + // + // `presents = "posix"` is the engine's promise that POSIX-standard + // symbols (lseek, fstat, read, write, close, ...) are reachable from + // every TU. The preprocessor identity (`__unix__` defined) was already + // delivered above; the symbol availability needs this second half. + // + // POSIX §2.2.2 binds `` to `lseek`/etc. and `` + // to `fstat`/etc. Any C library declaring `presents = "posix"` MUST + // provide these headers. We do not name any package here; this is + // the POSIX contract the engine is fulfilling, not a tuning for any + // specific libc. + // + // Upstream code (zlib et al., issue #674) was written against glibc's + // transitive-include behavior (which is non-POSIX) and so relies on + // these headers being in scope without an explicit `#include`. musl + // follows strict POSIX self-contained headers, so the contract must + // be enforced here. Per-package patches in mcpplibs/mcpp-index are + // still possible and remain the right long-term cleanup. + // + // ADDITIONAL HEADERS BELONG HERE ONLY WHEN MEASUREMENT SHOWS THEY + // ARE NEEDED. Do not blanket-include all of POSIX: `presents` means + // "available on demand", not "pre-included by the engine". + r.tokens.push_back("-include"); r.tokens.push_back("unistd.h"); + r.tokens.push_back("-include"); r.tokens.push_back("sys/stat.h"); + + r.expectDefined.push_back("__unix__"); + r.expectDefined.push_back("__CYGWIN__"); + r.expectUndefined.push_back("_WIN32"); + cygwinIdentity = true; + } ... +} +``` + +**改动量**:`src/toolchain/cenv.cppm` 一个分支 + 一段注释,约 20 行新增(含注释)。无新字段、无新 API、无新模块。 + +**M2 不动 `expectDefined`/`expectUndefined` 的语义。** probe 验证"宏定义是否符合声明",但不验证"-include 是否成功"——因为 probe 读 `clang -dM` 输出的是已定义宏,不是 include 列表。`-include` 的成功由 e2e 测量保证(probe 守住第一层契约,e2e 守住第二层)。 + +**M3 不改 design §3.3、不改 execution plan §4。** 本次只补 `presents = "posix"` 兑现的另一半,不重新打开"`__unix__` 是否定义"的 P3 spike 决策。CHANGELOG 条目写明"补齐 mcpp#673 trade-off 的另一半:POSIX 接口符号可见性"。 + +### 3.2 单测更新 + +`tests/unit/test_cenv.cpp` 在 `WindowsPosixCygwinSubstitutesTriple` 测试(`test_cenv.cpp:80-100` 附近)追加两条断言: + +```cpp +TEST(CEnv, WindowsPosixPreIncludesPosixStandardHeaders) { + CAbiDecl d; + d.presents = CAbiPresents::Posix; + d.dataModel = CAbiDataModel::ArchDefault; + d.wcharBits = 32; + d.hasWchar = true; + auto r = cenv::realise(d, "windows", "x86_64", false); + ASSERT_TRUE(r.has_value()) << r.error(); + ASSERT_TRUE(has(r->tokens, "-include")); + ASSERT_TRUE(has(r->tokens, "unistd.h")); + ASSERT_TRUE(has(r->tokens, "sys/stat.h")); +} +``` + +并加一条断言守住"非 Windows + Posix 不受影响": + +```cpp +TEST(CEnv, LinuxPosixDoesNotPreIncludePosixHeaders) { + // ...Linux + Posix 的现有测试保持不动,作为回归断言 +} +``` + +### 3.3 e2e 与测量 + +**E1 30-member 测量。** 跑与 mcpp#673 同形的 30-member 矩阵,关注: +- Windows × x86_64 × openkal 各 compat.* 包:从 18 个失败 → 期望显著减少(`unistd.h` 缺失类应全消) +- 残余失败的第一个错误是否是"未声明的标识符"——若是,记录该标识符及其应在的头,作为迭代追加的依据 +- Linux / macOS / freestanding 各路径:零回归 + +**E2 增量迭代规则。** 每发现一个新的"未声明标识符"对应 POSIX 标准头,在 cenv.cppm 的列表里追加;每个 PR 一次只追加一个头(避免一次 PR 范围过大)。`/tmp/posix-preinclude-evolution.md`(PR 描述外)记录每次追加的判据。 + +**E3 probe 不验证 include 行为。** 这是有意为之:probe 已经守住"`__unix__` 已定义 / `_WIN32` 未定义"这一层契约;`-include` 的成功是 build-time 验证(probe 之后才能展开)。把 probe 改造成验证 include 列表,会让 probe 跨进"运行时验证"领域,超出 §3.2 的设计意图。 + +--- + +## 4. 影响范围与使用规范 + +### 4.1 影响范围(改动前后对比) + +| 场景 | 改动前 | 改动后 | +|---|---|---| +| Linux × x86_64 × `presents = posix` | 不进 Windows 分支 | **不变** | +| Linux × x86_64 × `presents = windows` | 不进 Windows 分支 | **不变** | +| Linux × x86_64 × `presents = none` | 不进 Windows 分支 | **不变** | +| macOS × aarch64 × `presents = posix` | 进 macOS 分支 | **不变** | +| freestanding × `presents = posix` | 进 freestanding 分支 | **不变** | +| **Windows × x86_64 × `presents = posix`** | 加 `--target=x86_64-pc-cygwin` | **加 `--target=...` + `-include unistd.h` + `-include sys/stat.h`** | +| Windows × x86_64 × `presents = windows` | 不动 | **不变** | +| Windows × x86_64 × `presents = none` | 拒绝 | **不变** | +| Windows × aarch64 × `presents = posix` | 拒绝(arch 闸门) | **不变** | + +**只有"Windows × x86_64 × `presents = "posix"`"一行受影响**,其余所有组合编译命令行一字不改。 + +### 4.2 使用规范 + +**对包维护者(mcpplibs/mcpp-index):** +- 是:**不需要**为 compat.* 包加 per-package patch 来修复"`lseek` 未声明"一类失败——引擎已自动处理 +- 注意:仍可保留或新加 per-package patch(用于处理非"POSIX 标准符号"类的上游 bug) +- 否:不要因为"路径 C 已上"而推迟 upstream 反馈;两条路径并行,不互斥 + +**对引擎调用方(任何 `mcpp build`):** +- 是:在 `presents = "posix"` + Windows x86_64 上,POSIX 标准符号自动在作用域 +- 注意:任何非 POSIX 标准头(如 ``、``)仍需 `#include`——不要假设引擎把它们也预 include 了 +- 否:不要在源码里依赖"`lseek` 在 `` 里"(违反 POSIX 的假设) + +**对引擎维护者:** +- 是:追加新的 `-include` 必须基于测量(具体的"未声明标识符" + POSIX 标准规定) +- 否:不要一次性加入所有 POSIX 头(违背契约语义,见 §2.4) +- 否:不要加入非 POSIX 标准头(如 ``、`` 等 libc-specific 头)——那就破坏了"通用 POSIX 知识,非包名"的设计意图 +- 注意:若未来需要给特定 C 库实现差异化策略(如某 C 库不提供 ``),再加 `[c-abi] pre-include` / `pre-include-unset` 字段;**今天不加**——只有一个 POSIX C 库,过度设计无收益 + +### 4.3 失败模式与诊断 + +引擎侧不发 `-include` 时: +- 上游代码报"`xxx was not declared in this scope`"——这是预期失败(上游不遵循 POSIX include 规范) +- 解决方案:在 mcpplibs/mcpp-index 加 per-package patch,或推动上游修 + +引擎侧发 `-include ` 但 `` 找不到时: +- clang 报"`'' file not found`"——这是 C 库实现未兑现 `presents = "posix"` 契约 +- 诊断:检查 graph 里的 libc 包是否真的提供了该头;若声明 `presents = "posix"` 但不提供 ``,应要求该 C 库要么补头要么改声明 +- **不**通过"删除 `-include `"来回避——那等于让引擎容忍契约不兑现 + +### 4.4 边界与不做什么 + +**不做:** +- 否:给 `[c-abi]` 加 `pre-include` 字段(过度设计,见 §2.4 / §4.2) +- 否:改动 `__unix__` / `_WIN32` 的定义策略(已由 mcpp#673 锁定) +- 否:在 openkal-musl 加任何补丁(违背 openkal-musl 定位) +- 否:在 mcpplibs/mcpp-index 强制 18 个 compat 包都加 patch(per-package patch 是 optional 清理,不是必需) +- 否:给 clang 贡献新 target(LLVM 不会接收,且修了也不解决问题,见 mcpp#674 历史讨论) + +**做:** +- 是:在 cenv.cppm 的 Windows + Posix 分支追加 `-include unistd.h` 与 `-include sys/stat.h` +- 是:在 test_cenv.cpp 加单元测试 +- 是:CHANGELOG 写明:补齐 mcpp#673 trade-off 的另一半 +- 是:PR 描述明确"POSIX 契约兑现,非任何特定 C 库特化" +- 是:通过 30-member 测量迭代(每个新发现的失败头独立追加一个 PR) + +--- + +## 5. 顺序 + +``` +mcpp PR(M1-M3) → CI 跑通 → 30-member 测量 → release + │ +mcpp-index PR(可选 per-package 清理,不阻塞) ←──┘ +``` + +M1(代码)+ M2(测试)+ M3(CHANGELOG) 在同一个 PR 内,1-3 天工作量。CHANGELOG 与 PR 描述的措辞要明确"补齐 mcpp#673 的下半段",让后续读者能从 git 历史看到完整脉络。 + +--- + +## 6. 需要决定的问题 + +- **D1 `-include` 的最小集合:`unistd.h` + `sys/stat.h` 是否足够开始?** 建议是。这两个头覆盖 zlib 的 `lseek` + `fstat`,且严格对应 POSIX 标准对它们的归属。后续按测量迭代。**本地验证不需要任何 host 上的工具安装**——所有工具链(包括 Windows 跨编译所需的 mingw-w64)均由 mcpp 通过 `mcpp toolchain install` 自动管理到 `~/.mcpp/registry/data/xpkgs/`,与系统 PATH 完全隔离(`docs/20-toolchains.md:13`)。本 PR 不引入任何新的 host 依赖。 +- **D2 是否同步在 `src/toolchain/cenv.cppm` 的注释里明确"POSIX 契约兑现,非 libc 特化"?** 建议是。回应 issue #674 的"归属判断"——下一个读者看到 `-include` 应该立刻明白这不是为 openkal-musl 调优,而是 POSIX 标准。 +- **D3 是否同步更新 `docs/22-target-side.md` 的 §3.2?** 建议暂缓。本设计不在 design §3.3 改任何东西;若 30-member 测量后总结出更广的契约语义,再统一更新文档。如果只想在 docs 里加一行注释,可以在 §3.2 的"manifest 字段"小节末尾追加"see cenv.cppm for the second half of the contract"一句话。 +- **D4 是否在 CHANGELOG 引用 issue #674?** 建议是。这是 issue → PR 的标准做法。 +- **D5 per-package patch 是否并入本次?** 建议不并入。本 PR 只做引擎侧修复;per-package 清理留给 mcpplibs/mcpp-index 后续 PR(可选)。 + +--- + +## 7. 参考 + +- Issue #674 — 18 个 compat.* 失败的现象与四种候选路径 +- mcpp#673 — `7788d3e`,commit message 详述 P3 spike trade-off +- `src/toolchain/cenv.cppm:10-14, 64-72, 188-204, 282-299` — 改动点上下文 +- `src/toolchain/hostflags.cppm` — `-nostdlibinc` 注入点,保证 `-include` 走 musl 头不走 clang 内置 +- `tests/unit/test_cenv.cpp:80-100` — 现有 Windows + Posix 单元测试 +- `docs/22-target-side.md:307-396` — `[c-abi]` 字段语义,`presents` 是请求(契约) +- `docs/24-openkal-cross.md:37-69` — 三层宏家族的划分 +- `.agents/docs/2026-09-17-issue-662-graph-target-header-isolation-plan.md` — 前置 PR,隔离宿主头文件搜索(本方案的依赖) \ No newline at end of file diff --git a/.agents/docs/2026-09-20-ecosystem-execution-plan.md b/.agents/docs/2026-09-20-ecosystem-execution-plan.md new file mode 100644 index 00000000..3ffe319a --- /dev/null +++ b/.agents/docs/2026-09-20-ecosystem-execution-plan.md @@ -0,0 +1,64 @@ +--- +subject: plan +status: active +--- + +# C 环境生态方案:执行计划 + +- 依据:`.agents/docs/2026-09-20-openkal-c-environment-ecosystem-design.md` +- 日期:2026-09-20 +- 原则:每个仓库一个 PR;测量先行,数据不通过就停在测量。 + +## 0. 起点状态(2026-09-20 实测) + +| 仓库 | 版本 | 状态 | +| --- | --- | --- | +| mcpp | 2026.9.18.3 | main 干净 | +| openkal | 0.14.0 | 已发布并登记 | +| openkal-musl | 0.16.0 | 已发布并登记 | +| openkal-llvm-runtime | 0.12.0 | 已发布并登记 | +| mcpp-index `pins.toml` | `runtime = "0.10.0"` | **未抬**,测量图里没有任何包声明 `[c-abi]` | + +窗口仍开着:包侧 `_WIN32` 适配已撤(#439),引擎侧实现未在测量中生效。 + +## 1. 轨道与依赖 + +``` +E1 抬 pins + 重测 ──────────────────────┐ (独立,最高价值,先跑) + │ +A mcpp 引擎(P0/P1/P5-L2/P7-L3/absent)─┼─→ E2 refused + 新引擎 pin + │ +B openkal tools + 文档(SURFACE→接口集)─┼─→ D 各实现填 provides-interfaces + │ +C openkal-musl(absent + requires)─────┘ + │ + └─→ F 发布 + 沙箱验证 +``` + +| 轨 | 仓库 | 内容 | 依赖 | +| --- | --- | --- | --- | +| **E1** | mcpp-index | `pins.toml` runtime 0.10.0 → 0.12.0,重测 30 成员 | 无 | +| **A** | mcpp | P0.1 探针 `--target`、P0.2 注释、P1 冻结语义、P5-L2 解析期集合包含、P7-L3 链接期集合差、`[c-abi.absent]` 解析与诊断 | 无 | +| **B** | openkal | `tools/interfaces-from-surface.sh`、README 记述四级阶梯 | 无 | +| **C** | openkal-musl | `[c-abi.absent]` 声明 + CI 断言、`requires-interfaces` | A(字段语义)、B | +| **D** | openkal-linux / -windows / -macos | `provides-interfaces` 由产物生成 + CI 断言 | B | +| **E2** | mcpp-index | `refused` status、新引擎 pin、重测 | A、E1 | +| **F** | 全部 | 发布、gtc 镜像、沙箱验证 | 全部 | + +## 2. 判据 + +| 轨 | 判据 | +| --- | --- | +| E1 | 重测后 Windows 腿:第一类(`_WIN32` 选错分支)应自愈;逐条记录未自愈的与原因 | +| A | 单测覆盖新字段的解析与拒绝;`riscv64-none-elf` 探针 dump 含 `__riscv` 不含 `__linux__`;未声明的包命令行逐字节不变 | +| B | 脚本对 `SURFACE.txt` 产出 16 个接口名;删掉一个组,产出少一个 | +| C | 把 `fork` 的 `form` 从 `link` 改成 `enosys`,CI 必须红 | +| D | 删掉某实现的一个接口定义,CI 必须红并指名该接口 | +| E2 | `refused` 与 `fails` 分开计;`cfg(c-abi = ...)` 的出现次数记录在案 | +| F | 沙箱中只写版本号即可解析并构建 | + +## 3. 不做 + +- P3(撤 `__CYGWIN__`):需要 E1 的重测数据才能判断代价,本轮只记录证据,不落地。 +- P4(合成节点层):openkal-musl 的移植工作量独立,另轮。 +- P6(openkal-win-ucrt):需要 c++-abi 侧配套,另轮。 diff --git a/.agents/docs/2026-09-20-issue-674-design-review.md b/.agents/docs/2026-09-20-issue-674-design-review.md new file mode 100644 index 00000000..f0c57b15 --- /dev/null +++ b/.agents/docs/2026-09-20-issue-674-design-review.md @@ -0,0 +1,649 @@ +--- +subject: review +status: active +--- + +# #674 设计方案评审:`-include unistd.h` 在 Windows + `presents = "posix"` 上的可行性 + +- 被评审对象:`.agents/docs/2026-09-19-issue-674-cenv-posix-preinclude-design.md`(Path C) +- 上游 issue:mcpp-community/mcpp#674 +- 评审依据:`origin/main` 361874df(mcpp 2026.9.18.4);`mcpplibs/mcpp-index` f3c69ac;`mcpplibs/openkal` 871f8f0 +- 评审环境:本机 Linux x86_64,clang 22.1.0(`~/.xlings/subos/current/bin/clang`) +- 日期:2026-09-20 + +--- + +## 0. 结论 + +**不建议实施。** 三层理由,每一层单独成立: + +1. **方案修不了它要修的东西。** 18 个 `compat.*` 失败的第一条诊断,在 `mcpp-index` 的 + checked-in 测量数据里全部是「找不到某个 Windows 头」(`windows.h` / `_mingw.h` / + `windef.h` / `io.h` / `intrin.h` / `mm_malloc.h`),**没有一条**是设计稿 §1.3 所写的 + `'lseek' was not declared in this scope`。这些 `#include` 由 `#if defined(_WIN32)` 选中, + 而强制包含一个 POSIX 头**在构造上**改变不了这个条件,也变不出 `windows.h`。 + +2. **方案的前提不成立。** issue #674 与设计稿 §1 共用一套因果叙述——「引擎已经做到 + `__unix__` 定义、`_WIN32` 不定义,剩下的是上游代码的假设」——这套叙述与三份可查证 + 据矛盾:`x86_64-pc-cygwin` 三元组的实测预定义、`mcpp-index` 里那次测量所钉的图、以及 + mcpp#673 实际做的事。真正的成因是**那次测量的图里根本没有任何包声明 `[c-abi]`**, + 于是 cygwin 化实现从未执行,`_WIN32` 仍然定义着。 + +3. **即便前提成立,实现也会炸。** `-include` 经由 `cEnvTokens` 广播,会同时灌进 + C++ 模块接口单元(含 `std` 模块)、GAS 汇编单元、以及校验探针;并且被 + `appendUniqueFlags` 的按串去重吃掉第二个 `-include`,把 `sys/stat.h` 变成一个位置参数。 + 四处都已在本机实测复现。 + +**真正的下一步在生态侧而不在引擎侧**:把 `openkal-musl 0.15.0` / `openkal-llvm-runtime 0.11.0` +发出去,`tests/openkal/pins.toml` 的 `runtime` 从 `0.10.0` 抬上去,重跑 30-member。在那之前, +Windows 腿测量的不是 `presents = "posix"`,而是「没有 `presents` 时会怎样」。 + +--- + +## 1. 方法与判据 + +本评审不复述文档,只做三件事:读实现、读被引用的数据、在本机实测。所有实测命令与原始 +输出列在 §9,可逐条重跑。 + +判据的选取遵循一条规则:**每个结论都要落在一个与该结论的「否」能区分开的对象上**。 +「设计稿说 X」不是证据;「代码在第 N 行做了 Y」「checked-in 的测量文件里记录了 Z」 +「本机这条命令打印出 W」才是。 + +--- + +## 2. 致命问题:方案对它要修的失败是正交的 + +### 2.1 失败诊断的真实分布 + +`mcpplibs/mcpp-index` 的 `.xpkgindex/openkal-compat.json` 是这次测量唯一 checked-in 的 +原始数据(`measured: 2026-09-17`,`pins: mcpp 2026.9.17.3 / runtime 0.10.0 / llvm@22.1.8`)。 +`x86_64-windows-gnu` 上 15 runs / 15 fails,15 条首诊断逐条如下: + +| member | 第一条诊断 | +| --- | --- | +| archive | `xz .../src/common/sysdefs.h:44:11: fatal error: '_mingw.h' file not found` | +| c-ares | `ares_setup.h:81:12: fatal error: 'windows.h' file not found` | +| capi-lua | `lua .../src/loadlib.c:150:10: fatal error: ...` | +| catch2 | `catch_windows_h_proxy.hpp:24:10: fatal error: 'windows.h' file not found` | +| cli11 | `CLI/impl/Argv_inl.hpp:44:10: fatal error: 'windef.h' file not found` | +| cmp-module | `asio/detail/...` | +| curl | `curl_setup.h:33:10: fatal error: '_mingw.h' file not found` | +| doctest | `doctest.h:3215:10: fatal error: 'windows.h' file not found` | +| eigen | `clang/22/include/mm_malloc.h:43:22: error: ...`(clang 自带头) | +| fmtlib.fmt | `clang/22/include/intrin.h:12:15: fatal error: ...`(clang 自带头) | +| libpng | `pngpriv.h:578:12: fatal error: 'windows.h' file not found` | +| mimalloc | `mimalloc/atomic.h:16:10: fatal error: 'windows.h' file not found` | +| re2 | `util/mutex.h:15:10: fatal error: 'windows.h' file not found` | +| spdlog | `fmt/bundled/format-inl.h:20:12: fatal error: 'io.h' file not found` | +| sqlite3 | `sqlite3.c:29506:10: fatal error: 'windows.h' file not found` | + +**零条「未声明的标识符」。** 设计稿 §1.3 写的四条形态 + +``` +'lseek' was not declared in this scope → 缺 +'fstat' was not declared in this scope → 缺 +'open' was not declared in this scope → 缺 +'mmap' was not declared in this scope → 缺 +``` + +在这份数据里一条都不存在。该节以「§1. 实测」为标题呈现,但它不是从这次测量读出来的。 + +### 2.2 `-include` 与 `#ifdef _WIN32` 是正交的 + +zlib 的失败信号(设计稿选作代表)来自 `gzguts.h`: + +```c +49 #if defined(__TURBOC__) || defined(_MSC_VER) || defined(_WIN32) +50 # include +51 # include +52 #endif +``` + +诊断是 `gzguts.h:50:12: fatal error: 'io.h' file not found`。第 50 行**在 `#if` 体内**, +列 12 正是 `` 的起始列。因此该诊断只有在第 49 行为真时才可能产生,即 +**`_WIN32`(或 `_MSC_VER`/`__TURBOC__`)在那次编译里是定义着的**。 + +设计稿 §1.1 对同一段代码的解读是「`_WIN32` 未定义,所以走 `#else` 分支,要求 `lseek` +在作用域」。这里有两处与源码不符:这个 `#if` 块**没有 `#else`**(`#else` 在 +`gzlib.c` 的另一个 `#if` 里);而且如果 `_WIN32` 真的未定义,第 50 行不会被预处理, +诊断不会出现在那一行。 + +更根本的是:**`-include unistd.h` 无法使 `#include ` 成功**。它既不 undef +`_WIN32`,也不产生 `io.h`。对上表 15 条里的 13 条(全部 `windows.h` / `_mingw.h` / +`windef.h` / `io.h` 类),结论相同。 + +### 2.3 §1.2 的 glibc/musl 对照表与本机实测相反 + +设计稿 §1.2 的因果链条是「glibc 的 `` 传递包含 ``,musl 不包含, +所以 zlib 在 Linux 上碰巧能编」。本机实测(glibc 2.39,见 §9 M3): + +``` +$ printf '#include \n' | clang -E -xc - | grep -c 'unistd\.h' +0 +$ printf '#include \nlong f(void){ return lseek(0,0,0); }\n' | clang -fsyntax-only -std=c11 -xc - +error: call to undeclared function 'lseek' +``` + +glibc 的 `` **不**传递包含 ``,只包含它之后 `lseek` **不**在作用域。 +§1.2 表格的 glibc 行为假,musl 行为真,而整节的论证依赖两行的差。 + +--- + +## 3. 前提核查:issue 与设计稿引用的历史都不成立 + +### 3.1 `_WIN32` 为什么还定义着:那次测量的图里没有 `[c-abi]` + +本机实测两个三元组的预定义(§9 M1/M2): + +| 宏 | `--target=x86_64-pc-cygwin` | `--target=x86_64-w64-windows-gnu` | +| --- | --- | --- | +| `_WIN32` / `_WIN64` | 不定义 | **定义** | +| `__MINGW32__` | 不定义 | **定义** | +| `__CYGWIN__` | **定义** | 不定义 | +| `__unix__` / `unix` | **定义** | 不定义 | +| `__SIZEOF_LONG__` | 8 | 4 | +| `__SIZEOF_WCHAR_T__` | 2 | 2 | + +即:**cygwin 化实现一旦生效,`_WIN32` 就必然不在**,`gzguts.h:50` 就必然到不了。 +既然它到了,那次编译就没有走 cygwin 化实现。 + +原因写在 `mcpp-index` 自己的 `tests/openkal/pins.toml` 里: + +```toml +runtime = "0.10.0" +... +# `runtime` does NOT move here: openkal-llvm-runtime 0.11.0 (the version that +# actually declares/consumes `[c-abi]`) is not published yet +# ... changes nothing observable today: no package this pin resolves declares `[c-abi]`. +mcpp = "2026.9.18.3" +``` + +引擎抬到了 2026.9.18.3,**图没有抬**。`resolvedTargetSide.cAbiDecl` 为空, +`prepare.cppm:10790` 那个 `if` 整块跳过,`cEnvTokens` 为空,命令行逐字节等于该特性 +存在之前。所以 Windows 腿测到的是「没有 `presents` 时会怎样」。 + +与此同时,`mcpp-index` 的 draft 提交 700f7de 已经**先行撤掉**了包侧的适配。撤掉之前, +`pkgs/c/compat.zlib.lua` 里是: + +```lua +target_cfg = { + [] = { cflags = { "-U_WIN32", "-include", "unistd.h" } }, +}, +``` + +撤掉之后只剩 `cflags = { "-include", "mcpp_zlib_config.h" }`,而该生成头是 +`#if !defined(_WIN32) → #define Z_HAVE_UNISTD_H 1`。于是在「包侧适配已撤、引擎侧实现 +未生效」的这个窗口里,zlib 在 Windows 上同时失去 `-U_WIN32` 与 `Z_HAVE_UNISTD_H`, +恰好落回 `#include `。**这是一次发布链顺序造成的窗口,不是 trade-off 剩下的残差。** + +### 3.2 mcpp#673 做的不是「只压 `_WIN32`、不定义 `__unix__`」 + +设计稿 §0.2 与 issue 的 Background 都把 mcpp#673 描述成这个 trade-off 的落地。核对 +CHANGELOG `[2026.9.18.3]` 与 7788d3e6 的改动面,#673 实际做了两件事: + +1. `cenv::realise` 对 `wchar = 32` **无条件**补 `-fno-short-wchar`(含 freestanding); +2. `cenv_probe::verify` 新增 `hostStripMacros` 参数,Windows 宿主下传 + `-U_WIN32 -U_WIN64 -U__MINGW32__ -U__MINGW64__`。 + +两件事都与「是否定义 `__unix__`」无关。而且引擎**从来没有**选择「不定义 `__unix__`」: +`cenv.cppm` 的 Windows + Posix 分支写的是 + +```cpp +r.expectDefined.push_back("__unix__"); +r.expectDefined.push_back("__CYGWIN__"); +r.expectUndefined.push_back("_WIN32"); +``` + +`__unix__` 由 cygwin 三元组提供并被探针要求为**已定义**。设计稿与 issue 描述的那个 +trade-off 在代码里不存在。 + +**附带发现(真实缺陷,与本方案无关,建议另开 issue)**:`hostStripMacros` 只进探针 +命令(`prepare.cppm:10923-10931`),**从不进 `p.privateBuild.cflags`**。也就是说在 +Windows 宿主上,如果 `--target=` 替换确实会漏出宿主的 `_WIN32`(#673 的 CHANGELOG 记录 +freestanding 上观察到了),那么探针读到的是剥离后的状态,而真实编译行读到的是未剥离的 +状态——**判据施加在了与被测对象不同的命令行上**。issue #674 把 hostStripMacros 列为 +「broadcast on the compile line」的一项,与实现不符。这一条在 Linux 宿主上测不出来 +(`mcpp::platform::is_windows` 为假,剥离集合为空),而 30-member 的 Windows 腿正是在 +Linux 宿主上跑的(`pins.toml` 的 `[runners] x86_64-windows-gnu = ["wine"]`)。 + +### 3.3 「P3 spike 测过两种呈现并选了一种」没有发生 + +issue 的 Historical context 引用 `openkal/.agents/docs/2026-09-18-c-environment-execution-plan.md` §4: + +> 「`__unix__` 带来新的失败超过收益:改为只不定义 `_WIN32`。」 + +该句所在的小节标题是 **「## 4. 风险与退出」**,与它并列的是「Cygwin 目标的 TLS、异常展开、 +链接驱动不可用:停在 P4」。这是**计划写在实验之前的退出条件**,不是实验结论。 + +实验结论的位置是 record 的「兼容性测量」,其正文是: + +> **兼容性测量。**(待填:新描述文件登记后,与 0.13 基线 Linux 27/3、Windows 15/15 的 +> 对比。……) + +即**两种呈现的 A/B 从未完成**。design §11 的 review 决定第 4 条「是否定义 `__unix__` 由 +实验数据决定(§8 第 4 步)」同样悬着。把一条未执行的退出条件读成已完成的测量结论, +会让后续所有「在这个 trade-off 下补另一半」的论证失去依据。 + +### 3.4 数目不对 + +issue 写「Windows side went from `0/30` (0.13) to `12/30`」。checked-in 的 0.13 基线是 +**15 runs / 15 fails**(record §4 亦写「Windows 15/15」),不是 0/30。 + +issue 列出的 18 个名字 = 基线 15 个失败 + `gzip-hpp`、`tinyhttps`、`zlib`。这三个在基线里 +**都是 `runs`**。按 issue 自己的清单算,这次变化是**净失去 3 个**,而不是「净得到 12 个」。 +失去的恰好是包侧适配被 700f7de 撤掉、而引擎侧替代未生效的那几个(zlib 直接, +gzip-hpp 经由 zlib,tinyhttps 在同一提交里换到 0.3.1)。 + +### 3.5 Path A 引用的机制不存在 + +issue 的 Path A 写「mcpplibs/mcpp-index handles each via the +`[target.cfg(os = "windows").patches]` mechanism (one patch per package, ~10-line patch file)」。 + +mcpp 的清单里**没有 `patches` 键**(`src/manifest/`、`docs/*.md` 全仓无此机制)。 +mcpp-index 里真实存在并且已经在用的是 `target_cfg` 下的 `cflags` / `defines` / +`generated_files`——即 §3.1 引的那种形状。设计稿 §2.3 的对照表按「18 个 PR × ~10 行 +patch 文件」给 Path A 估成本,这个成本估计的基础是一个不存在的机制。 + +--- + +## 4. 即便前提成立,实现也会炸 + +下列四条各自独立,均在本机实测复现(§9)。 + +### 4.1 C++ 模块接口单元,包括 `std` 模块 + +`cEnvTokens` 进的是 `p.privateBuild.cxxflags`(`prepare.cppm:11164`),并且在 +`prepare.cppm:12380-12381` 单独再进一次 `std` 模块的编译行。`docs/22-target-side.md` +明写这条广播覆盖「C, C++ and assembly compiles, the dependency scan, and the `std` +module precompile alike」。 + +模块接口单元必须以 `module;` 或 `export module` 开头。`-include` 把头文件的内容放在 +主文件之前,于是: + +``` +$ clang++ -std=c++23 -include unistd.h --precompile m.cppm -o m.pcm +m.cppm:1:8: error: module declaration must occur at the start of the translation unit +``` + +带全局模块片段的形状(正是 libc++ `std.cppm` 的形状:注释、`module;`、`#include`、 +`export module std;`)同样失败: + +``` +std_shape.cppm:4:1: error: 'module;' introducing a global module fragment can appear only + at the start of the translation unit +std_shape.cppm:8:8: error: module declaration must occur at the start of the translation unit +``` + +同一份文件去掉 `-include` 即通过。**后果:`import std` 在 Windows × `presents = "posix"` +上直接不可用,图中任何 `.cppm` 亦然。** 这一格恰好是 openkal C++ 侧的旗舰路径 +(`openkal-llvm-runtime` + `examples/cxx`)。 + +设计稿 §4.1 的影响面表格把这一格写成「加 `--target=...` + `-include unistd.h` + +`-include sys/stat.h`」,没有区分该格里的 `.c`、`.cpp`、`.cppm`、`.S` 四类单元。 + +### 4.2 `appendUniqueFlags` 的按串去重 + +广播走的是 `prepare.cppm:6380-6391`: + +```cpp +for (auto const& f : additions) { + if (std::find(flags.begin(), flags.end(), f) != flags.end()) continue; + flags.push_back(f); +} +``` + +按**整串**判重。设计稿产出的 tokens 序列是 +`[..., "-include", "unistd.h", "-include", "sys/stat.h", ...]`,第二个 `"-include"` +与第一个相等,被跳过。落到命令行上是: + +``` +--target=x86_64-pc-cygwin -include unistd.h sys/stat.h +``` + +`sys/stat.h` 成为一个位置参数: + +``` +$ clang -c -include unistd.h sys/stat.h t.c -o t.o +clang: error: no such file or directory: 'sys/stat.h' +``` + +如果某个包自己的 `cflags` 里已有裸 `-include`(`compat.zlib` 正是 +`cflags = { "-include", "mcpp_zlib_config.h" }`),广播的 `-include` 会被**整个**去掉, +两个头文件名都变成位置参数。 + +单字符令牌形式可以规避(`-includeunistd.h` 与 `--include=unistd.h` 本机均被接受), +但设计稿没有采用,也没有记录这个约束。 + +### 4.3 汇编广播:GAS 敌对令牌 + +`prepare.cppm:11166` 把同一批 tokens 送进 `p.privateBuild.asmflags`,并且该处的注释 +写下了一条规则: + +> Every token in `cEnvTokens`/`cEnvBuiltinsTokens` was checked against clang's GAS +> (`-x assembler-with-cpp`) front end before this was written ... **if a future token +> IS GAS-hostile, `cenv::realise` is where to split it, not this broadcast.** + +`-include unistd.h` 正是 GAS 敌对的: + +``` +$ clang -c -include unistd.h a.S -o a2.o +/usr/include/unistd.h:27:1: error: invalid instruction mnemonic '__begin_decls' +/usr/include/x86_64-linux-gnu/bits/types.h:31:18: error: unexpected token in argument list +... +``` + +musl 的 `unistd.h` 同样没有 `__ASSEMBLER__` 守卫。设计稿新增了一个满足该规则触发条件的 +令牌,但没有按规则在 `realise` 里拆分——这是对被修改文件自己写下的约束的直接违反。 +本轮生态里 `.S` 不是边缘情况:openkal-musl 的 `okm_setjmp.S`、vendored libunwind 的 +`assembly.h` 都在这条通道上,record §5 里「`[c-abi]` 的实现不作用于汇编源」正是上一轮 +专门补的缺陷。 + +### 4.4 校验探针:没有头文件搜索路径 + +探针命令(`cenv_probe.cppm`)是 + +``` + -x c++ -E -dM - +``` + +`argv` 里有 `crossTargetFlag` 与 `cEnvTokens`,**没有任何 `-I`、没有 `--sysroot`、 +没有 `-nostdlibinc`**——注释明写这是「the IDENTITY-AFFECTING SUBSET」,因为 include path +不改变预定义宏。`-include` 破坏这个不变量:它让探针的成败取决于头文件能否被找到。 + +本机(Linux 宿主)实测: + +``` +$ clang --target=x86_64-pc-cygwin -include unistd.h -include sys/stat.h -x c++ -E -dM - tokens, "-include")); +ASSERT_TRUE(has(r->tokens, "unistd.h")); +ASSERT_TRUE(has(r->tokens, "sys/stat.h")); +``` + +它断言的是 `realise` 返回的 vector 的成员资格。§4.2 的去重、§4.1 的模块单元、§4.3 的 +汇编单元、§4.4 的探针,四处失败全部发生在这个 vector **之后**。该测试在四处缺陷全部 +存在时依然全绿。这正是「判据通过了但什么都没测到」的形状:谓词对、对象错。 + +能区分的判据只有一条:**一个带 `.cppm`、`.S` 与 `import std` 的 e2e 工程,在 +Windows × `presents = "posix"` 上真的构建一次。** 设计稿 §3.3 的 E1 把这件事交给 +30-member 测量,而 30-member 里没有一个成员用 C++20 模块(`compat.*` 全是传统 C/C++ 库), +所以即使跑了也测不到 §4.1。 + +--- + +## 5. 规范符合性 + +### 5.1 `presents` 的定义 + +`docs/22-target-side.md:323` 的四键表: + +| Key | Values | Answers | +| --- | --- | --- | +| `presents` | `posix` / `windows` / `none` | **which environment-identity macros source sees** (`__unix__` vs `_WIN32` vs neither) | + +openkal 设计 §3.2 的取值表同样: + +| 值 | 含义 | +| --- | --- | +| `posix` | 定义 `__unix__`,不定义 `_WIN32` 与 `__MINGW32__` | + +两处都把 `presents` 定义为**身份宏**,且两处都紧接着写「三个键分属两类事实,互不推导」。 + +设计稿 §0.2 与 §2.1 把它重述为: + +> 「`presents = "posix"` 是 mcpp 引擎对编译命令行的契约承诺,POSIX 标准规定哪些符号在 +> 哪些头里,引擎要把这些头显式拉进每个 TU。」 + +这是把一个已发布字段的语义**扩宽**到原文没有的范围。`presents` 回答「源码走哪条分支」, +不回答「哪些符号已在作用域」。设计稿 §2.4 自己也写下了正确的那句——「`presents = "posix"` +是『POSIX 接口按需可达』,不是『所有 POSIX 接口已被强制 include』」——但 §3.1 的实现 +正是后者的一个子集。同一份文档里两个互相否定的语义定义。 + +一个字段的语义扩宽,代价不在实现,而在它给下一个读者的授权:此后任何「某个 POSIX 符号 +在某个包里不可见」都成了引擎的义务,而 §2.4 想守住的那条线没有任何机制在执行它。 + +### 5.2 openkal 设计已经为这类失败指定了答案 + +§5「平台相关代码」是 §2.1 那 13 条 `windows.h` 类失败的 designated 归属: + +- §5.3 包内 feature:`tinyhttps` 就是设计稿自己举的例(`posix-socket` / `winsock`), + 而 `tinyhttps 0.3.1` 在 700f7de 里正是「selects POSIX sockets on Windows with musl」; +- §5.3 独立 shim 包:多个包需要同一段平台代码时; +- §6 / §7:`platform` 标签与 `platform-dependencies = "refuse"`,让「这个包需要平台接口」 + 成为**被测量、被展示**的事实,而不是一次失败。 + +§9「备选与不做」里与本方案最近的一条: + +| 备选 | 结论 | +| --- | --- | +| 在 musl 上仿真 `windows.h` | **不做**。永远做不完,且是 SPEC §3.1 拒绝的「模拟」 | +| 维持现状,逐包按 `c-abi` 适配 | **保留为退路** | + +引擎侧无条件预置 POSIX 头,与「在 musl 上仿真 windows.h」的性质同源:都是由中间层替 +上游代码决定它该看见什么,都没有终点判据(设计稿 §3.3 的 E2「每发现一个新的未声明 +标识符就追加一个头」把这一点写成了流程)。 + +### 5.3 「通用 POSIX 知识,非包名」这条辩护为何不足 + +设计稿 §2.2 用 `cenv.cppm:10-14` 的「GENERIC KNOWLEDGE, NO PACKAGE NAMES」为 `-include` +辩护:POSIX 标准头是契约不是包名。 + +这条规则约束的是**不得按 C 库的身份分支**,它不授权「凡 POSIX 规定的都可以由引擎替 +每个 TU 决定」。同一段注释紧接着写的是「a second POSIX C library on Windows would +realise through the same table」——它要求的是**可替换性**,而 §4.4 表明加入 `-include` +之后,realisation 对「这个 C 库是否在这个搜索路径里提供了这个头」产生了依赖,恰好削弱 +了可替换性。 + +### 5.4 包私有作用域是一条被明写依赖的性质 + +`compat.zlib.lua` 现在的注释里有一段值得完整引用: + +> This `cflags` is package-private: it reaches zlib's own translation units +> (gzlib.c, zutil.c, ...) but not a consumer compiling zlib.h ... So the library +> computes z_off_t with Z_HAVE_UNISTD_H set (= off_t) while an openkal-Windows +> consumer ... computes it with Z_HAVE_UNISTD_H unset (= long long) ... + +即索引维护者**明确依赖**「包侧 `cflags` 不外溢到消费者」这条性质,并围绕它写了运行期 +断言。引擎广播没有这条性质(它进每个包的 `privateBuild`)。把强制包含从包侧提升到 +引擎侧,等于把一个被明写依赖的作用域边界抹掉;即便对 `unistd.h` 这一个头恰好无害, +§3.3 的 E2 迭代规则会持续往这条通道里加头。 + +--- + +## 6. 稳定性与兼容性影响面 + +| 维度 | 设计稿的判断 | 核查结果 | +| --- | --- | --- | +| 影响面收在 Windows × x86_64 × posix 一格 | 是(§4.1 表) | **成立,但该格内部未分层**:`.c` / `.cpp` / `.cppm` / `.S` / 探针 五类对象行为不同,其中三类会失败 | +| 链接行不变、运行时零开销 | 是 | 成立 | +| 其他组合命令行一字不改 | 是 | 成立 | +| 不改任何已写下的设计决策 | 是(§0.5) | **不成立**:扩宽了 `presents` 的语义(§5.1),违反了 `prepare.cppm:11148` 写下的 GAS 拆分规则(§4.3) | +| 改动量「一行类 + 注释」 | 是(§3.1) | **低估**:要正确落地必须同时改 `Realisation` 的字段划分、asm 广播、探针 argv 构造、去重逻辑四处 | +| 上游修好后「无害但白 include」 | 是(§2.3 表) | 对 `.c` 成立;对 `.cppm` / `.S` 不是白 include 而是硬失败 | + +**可回退性**:作为一次发布,它会移动全部 Windows × posix 工程的输出目录(§4.5);撤销 +时再移动一次。两次移动都发生在用户侧。 + +--- + +## 7. 生态发展视角:真正的下一步 + +按依赖顺序,与本方案无关的三步先做: + +1. **完成发布链。** `openkal-musl 0.15.0` 与 `openkal-llvm-runtime 0.11.0` merge + tag + + GitCode 镜像 + xim-pkgindex 登记(openkal 自审 §11 已把这两条列为「用户拍板」的 + 未校验项,record §4 的沙箱 B–E 四段 NOT-RUN 也是同一个根因)。 +2. **抬 `tests/openkal/pins.toml` 的 `runtime`。** 在 `runtime = "0.10.0"` 下, + Windows 腿测的不是 `presents = "posix"`。这一步之前的任何 Windows 数字都不能用来 + 给引擎决策。 +3. **重跑 30-member,并且真的做那次 A/B。** design §11 决定 4 与 record §4 的「(待填)」 + 指的是同一件事:`__unix__` 定义与否的两种呈现各跑一次。这是 #674 讨论的四条路径 + (尤其 B/D)唯一的判据来源,今天它还不存在。 + +第 3 步跑完之后,残余失败按 openkal 设计 §5 逐包归位: + +- 需要 `windows.h` 一族的(c-ares、catch2、cli11、doctest、libpng、mimalloc、re2、 + sqlite3、spdlog、archive/xz、curl)→ §5.3 的包内 feature 或图提供的平台 SDK 依赖, + 并在索引里落 `platform` 标签。这**不是失败**,是 §7 标签体系要表达的事实; +- clang 自带头的两个(eigen 的 `mm_malloc.h`、fmt 的 `intrin.h`)→ 已由 + `builtins = "iso"` 与 `-nostdlibinc`(#662/#664)覆盖,重测确认即可; +- 真正「缺 POSIX 头」的(如果重测后还剩)→ 包侧 `target_cfg.cflags` 的 `-include`, + 即 zlib 已经用过并且已经因为不再需要而撤掉的那个机制。 + +**给 #674 的回复建议**:Path A 是正确的归属,但成本估计应更正(机制是 `target_cfg` +不是 `patches`,而且多数包属于 §5 的 `platform` 标签而非需要 patch);Path B/D 需要 +先补那次从未做过的 A/B;Path C 因 §2 与 §4 不建议。issue 正文里的三处事实(0/30 基线、 +hostStripMacros 在编译行上、P3 spike 已测两种呈现)建议一并更正,否则它们会作为前提 +被下一份设计继承——这次已经发生了一遍。 + +--- + +## 8. 如果仍要在引擎侧做强制包含,唯一可能安全的形状 + +这一节不是推荐,是在用户拍板要做的情况下把约束列全。 + +1. **不走 `Realisation::tokens`。** 新增独立字段(如 `preIncludeTokens`),因为它必须 + 从三个消费点里被排除:探针 argv(`prepare.cppm:10923`)、asm 广播 + (`prepare.cppm:11166`)、以及任何模块接口单元的编译行。 +2. **模块接口单元必须排除。** 这需要在 `build_program` / `flags` 层按单元类型分流, + 而不是在包级 `cxxflags` 上。今天没有这个分流点,新增它是本方案的主要工作量。 + `std` 模块(`prepare.cppm:12380`)要单独排除。 +3. **单令牌拼法。** `-includeunistd.h` 或 `--include=unistd.h`,规避 + `appendUniqueFlags` 的按串去重;同时在 `appendUniqueFlags` 上加一条「带参令牌不参与 + 去重」的规则,否则下一个同形令牌会再犯一次。 +4. **绝对路径而非裸名。** 裸名依赖搜索路径,在 `-nostdlibinc` 之下由图提供的 C 库满足, + 在探针里由宿主满足——两者不同(§4.4)。若保留裸名,必须同时保证探针不带这些令牌。 +5. **判据必须是一个 e2e 工程**,含 `.cppm`、`.S`、`import std`,在 + Windows × `presents = "posix"` 上构建并运行;单测断言 vector 成员资格不能作为判据 + (§4.6)。 +6. **终点规则要可执行。** §3.3 的 E2「按测量迭代追加」没有终止条件,也没有拒绝条件。 + 若无法写出「什么时候不再追加」的判据,这条规则保护不了 §2.4 想守的那条线。 + +--- + +## 9. 复现 + +全部命令在本机(Linux x86_64,`~/.xlings/subos/current/bin/clang`,clang 22.1.0)执行。 + +```sh +# M1/M2 —— 两个三元组的预定义 +clang --target=x86_64-pc-cygwin -x c -E -dM - \n' > s.c +clang -E s.c | grep -c 'unistd\.h' # 0 +printf '#include \nlong f(void){return lseek(0,0,0);}\n' > s2.c +clang -fsyntax-only -std=c11 s2.c # error: call to undeclared function 'lseek' + +# M4 —— 模块接口单元 +printf 'export module demo;\nexport int f(){return 42;}\n' > m.cppm +clang++ -std=c++23 -include unistd.h --precompile m.cppm -o m.pcm +# error: module declaration must occur at the start of the translation unit + +# M5 —— libc++ std.cppm 的形状(注释 + module; + #include + export module) +printf '// gen\n\nmodule;\n\n#include \n\nexport module stdlike;\n' > std_shape.cppm +clang++ -std=c++23 --precompile std_shape.cppm -o /dev/null # OK +clang++ -std=c++23 -include unistd.h --precompile std_shape.cppm -o /dev/null +# error: 'module;' introducing a global module fragment can appear only at the start ... + +# M6 —— GAS +printf '.text\n.globl foo\nfoo:\n ret\n' > a.S +clang -c a.S -o a.o # OK +clang -c -include unistd.h a.S -o a2.o +# /usr/include/unistd.h:27:1: error: invalid instruction mnemonic '__begin_decls' + +# M7 —— appendUniqueFlags 去重之后的命令行 +printf 'int main(){return 0;}\n' > t.c +clang -c -include unistd.h sys/stat.h t.c -o t.o +# clang: error: no such file or directory: 'sys/stat.h' + +# M8 —— 探针形状 +clang --target=x86_64-pc-cygwin -include unistd.h -x c++ -E - `)在本机实测下不成立:glibc 的 `` 同样不传递包含它。 + +### 1.2 三时刻表是分类器 + +SPEC §6.2: + +> Information therefore becomes available at three times, **each being the earliest +> at which it exists**. +> +> | Time | Mechanism | Question answered | +> | --- | --- | --- | +> | dependency resolution | the implementation package declares what it provides | may this program be built against this implementation | +> | link | an undefined symbol | was an interface used that the implementation does not provide | +> | run | a capability word | how does this implementation behave within an interface it provides | + +一个库问"这个能力在不在",按这张表,答案最早存在于**依赖解析**。而 `#ifdef` 发生在 +**预处理**——比解析更早。 + +> **上游库不是问错了问题,是在一个比答案存在得更早的时刻问了它。** + +这一句解释了为什么压 `_WIN32`、给 `__unix__`、留 `__CYGWIN__`、给 `__linux__`、 +预置 `unistd.h` 这五个修法各自都能修好一批、又各自都会被下一个环境证伪:**它们都在 +填一个规范模型里不存在的格子。** + +### 1.3 这条判据不依赖测量 + +它不需要知道上游库怎么写,也不需要 30-member 的数字。任何新提案先问一句 +"它在哪个时刻回答问题",落在三时刻之外的就不必再往下看。 + +--- + +## 2. 五条设计事实 + +推导的地基,全部出自 openkal SPEC 0.14。 + +| # | 出处 | 事实 | +| --- | --- | --- | +| **F1** | §6.1 | 不提供的接口在**链接期缺席**。"A conforming implementation shall not provide an interface whose operations report a lack of support at run time; the specification treats **run-time refusal as a defect** and not as a means of expressing partiality." | +| **F2** | §3.3 | 规范**不命名环境类别**。命名过的 `hosted` 已撤回:"a name that describes a class of environment **is falsified by an environment nobody had in mind**. This one was falsified **inside its own ecosystem within a release**." 替代做法是消费者**逐条列举**,"which is where a convention among consumers belongs"。 | +| **F3** | §6.2 | 三个时刻,每个是该信息**最早能存在**的时刻(表见 §1.2)。 | +| **F4** | §6.5 | 由"产物如何生产"决定的可用性,**在依赖解析时**给或不给,用包的 feature 表达。"a path no artifact takes is **a path nothing has verified**"。 | +| **F5** | §7.1 / §5.2 | 实现不得需要兼容层,判据机械:"an implementation that must maintain a translation table, a registry, or **a name resolver** ... indicates that **the specification has taken a shape borrowed from one environment**, and the shape is at fault"。§5.2 进一步划线:"Mapping is not simulation ... whereas **reproducing a foreign namespace of names, descriptors or paths does not**"。 | + +**F5 的适用范围要读准**:它约束 **openkal 的实现**。消费者(C 库、C++ 运行时)不受它 +约束——`openkal-musl` 的 `okm_fd.c` 正是在消费者一侧复制描述符与路径命名空间,并在 +文件头写明理由。这条分工是本文 §5.4 方案成立的前提。 + +--- + +## 3. 顶层架构:四层 × 三时刻 + +### 3.1 层(已落地) + +| 层 | 供给者 | 保证 | +| --- | --- | --- | +| `kernel-abi = openkal` | 规范 + 每目标一个实现 | 每个 `kal_*` 操作在每个平台行为相同 | +| `c-abi` | `openkal-musl` / 将来的 `openkal-win-ucrt` / `openkal-picolibc` | 一种 C 环境形态;做不到的明确拒绝并列出 | +| `c++-abi` | `openkal-llvm-runtime` | 为那个 C 库配置过的 C++ 运行时 | +| 构建工具 | `mcpp` | 图供给的层由图整个供给;不搜索宿主头 | + +**形态由程序选择,方式是依赖哪个入口包;库不得选择形态**(openkal design §3.1)。 + +### 3.2 时刻(写在 SPEC 里,尚未被当作架构使用) + +见 §1.2 的表。 + +### 3.3 交叉表:每一格由谁负责 + +| | 依赖解析 | 链接 | 运行 | +| --- | --- | --- | --- | +| **kernel-abi** | 实现包 `provides` 哪些接口;**消费者 `requires` 哪些接口(空)** | 未定义符号 | `kal__props` 能力字 | +| **c-abi** | 选哪个形态(`openkal-musl` / `win-ucrt` / `picolibc`);`[c-abi]` 声明被探针校验 | 符号缺席 | **POSIX 命名空间的合成(空)** | +| **c++-abi** | 与 C 库配套 | 符号缺席 | — | +| **构建工具** | 层解析、平台 SDK 依赖、`refuse` 开关 | — | — | + +### 3.4 今天空着的两格 + +- **解析期 × kernel-abi**:SPEC §3.3 明说消费者应逐条列举所需接口,"in its own + package",但 mcpp 的清单里没有承载它的字段。于是"这个能力在不在"无处可问, + 全部被挤到预处理期。**#674 的压力来自这一格。** +- **运行期 × c-abi**:POSIX 有而 openkal 没有的命名空间(`/dev/null`、`/dev/urandom`、 + `/proc/self/*`、`/tmp`)。写得完全正确的 POSIX 代码在 openkal 上会坏,且没有任何 + 一层负责。 + +其余各格都已有机制。**本文提出的两个新东西,恰好各补一格。** + +而"声明被校验"这条原则在**接口一级**横跨全部三个时刻,本文在 §5.7 单独处理: +它不是第三个新东西,是把已有的三件材料(`SURFACE.txt`、SPEC §9.2 的静态比对、 +`kal_interfaces()`)接成一条阶梯。 + +--- + +## 4. 方案空间 + +### 4.1 六个方案与它们的时刻 + +| | 方案 | 层 | 时刻 | 在三时刻表内 | +| --- | --- | --- | --- | --- | +| A | 逐包适配(`target_cfg` / `generated_files`) | 索引描述符 | 编译期,包自己的构建配置 | 合法(不是能力问答) | +| B | 引擎预置 POSIX 头(#674 Path C) | mcpp 引擎 | **预处理期** | **否** | +| C | `presents = "linux"` 形态 | C 库声明 | **预处理期** | **否** | +| D | `openkal-win-ucrt` | 新形态包 | 依赖解析 | 是 | +| E | 合成节点层(`openkal-posix`) | C 库 port 层 | 运行 | 是 | +| F | 解析期逐条列举 | 清单 + 引擎 | 依赖解析 | 是 | + +### 4.2 综合对比 + +| | A | B | C | D | E | F | +| --- | --- | --- | --- | --- | --- | --- | +| 引擎改动 | 0 | 1 处(连带炸 4 处) | 1 处 | **0(已验证)** | 0 | 新字段 + 解析规则 | +| 覆盖面 | 一个包 | 全图 | 全图 | 整个程序 | 全图,路径类 | 全图,能力类 | +| 上界 | 无,逐个加 | **无终点** | 无,类别名会被证伪 | 明确 | **openkal 接口集** | 十六个接口名 | +| 失败模式 | 编译期,指名 | `.cppm` / `.S` 硬失败 | 静默选错分支 | 解析期拒绝 | `ENOENT`,调用方可辨 | 解析期拒绝 | +| 可撤销 | 一行 | 移动全部输出目录 | 破坏性 | 换依赖 | 改合成表 | 改清单 | +| 可验证 | 30-member | 单测空转 | 探针量宏不量能力 | realise 空令牌集 | conformance 逐节点断言 | 解析期断言 | +| 与设计事实 | 符合 | **F3 表外** | **F2 撤回过类别名** | §3.1 形态第三行 | 延续 `okm_fd.c` | §3.3 明文 | + +### 4.3 B 与 C 为什么必然不稳定 + +- **B**:`-include` 经 `cEnvTokens` 广播进 `cflags` / `cxxflags` / `asmflags` / std 模块 / + 探针 argv。实测四处失败:模块接口单元 ill-formed(`import std` 不可用)、GAS 报 + `invalid instruction mnemonic`、`appendUniqueFlags` 按串去重把第二个 `-include` 吃掉 + 使 `sys/stat.h` 变成位置参数、探针无 include path。且它修不了 §1.1 里任何一条诊断。 +- **C**:`posix` / `linux` / `windows` 都是**环境类别名**。F2 记录了同类名字 + (`hosted`)如何在一个发布周期内被自己的生态证伪。`presents = "posix"` 今天已经 + 被证伪一次——openkal-Windows 呈现 POSIX,却没有 `fork`、没有 `/proc`、没有 epoll。 + 再加一个值只会让同一个证伪重演。 + +### 4.4 失败的归类 + +| 失败类别 | 例子 | 归属 | +| --- | --- | --- | +| `_WIN32` / `__MINGW32__` 选错分支 | asio、eigen、catch2、re2、CLI11、lua、spdlog | 已解决,等抬 pins | +| 借来的 `__CYGWIN__` 被当 Win32 用 | mimalloc、sqlite3 | P3(撤掉借用) | +| 需要 Win32 API 本身 | c-ares、curl | D 或 F | +| 需要 Linux 内核节点 | libarchive 的 `linux/fs.h` | F,**且拒绝是正确答案** | +| 写 POSIX 但用到 kal 没有的命名空间 | `/dev/null`、`/dev/urandom`、`/tmp` | **E** | +| 包自己的构建配置 | zlib 的 `Z_HAVE_UNISTD_H` | A,永远归 A | + +**A / D / F 处理的都是"代码本来就不可移植";只有 E 处理的是"代码本来就对,是我们缺"。** + +--- + +## 5. 采纳的方案 + +### 5.0 P0 —— 两处实现缺陷(先修,独立) + +**P0.1 freestanding 的 c-abi 探针不带 `--target`,它量的是宿主。** + +`prepare.cppm:3989` 对 freestanding 目标**不设** `crossTargetFlag`(注释:"for a HOSTED +target only. Freestanding already emits its own `--target`"),而 freestanding 的 +`--target` 在 `mcpp::freestanding::compile_prefix()`(`linkline.cppm:48`),探针不问它; +`cenv::realise` 对 freestanding 也只产 `-D__unix__` 与 wchar 令牌。于是探针 argv 是 +`clang -D__unix__ -fno-short-wchar -ffreestanding -x c++ -E -dM -`——**没有任何 `--target`**。 + +实测(本机 Linux):该命令输出 `__linux__ 1`,即它量的是宿主。三条佐证: +`--target=riscv64-none-elf` 时 `__linux__` 计数为 0(预定义随目标不随宿主)、 +该三元组的 `__SIZEOF_WCHAR_T__` 是 4 而不是 2、`--target=x86_64-w64-windows-gnu` +在 Linux 上定义 `_WIN32`。 + +2026.9.18.3 的 CHANGELOG 把症状归因为"Windows 主机的 clang 即使带上 `--target=` +仍注入 `_WIN32`",该归因不成立;两条"不匹配"由同一个原因产生。 + +**修法**:探针 argv 在 freestanding 时取 `freestanding::compile_prefix()` 的 +`--target` 与 ISA 标志,随后 `hostStripMacros` 整个撤除。 +**判据**:Linux 宿主上 `riscv64-none-elf` 的探针 dump 必须含 `__riscv`、**不含** +`__linux__`。今天这条会红。 + +**P0.2 `builtins` 的 Windows 行注释与实测不符。** `cenv.cppm` 称 clang 自带 +`intrin.h` / `mm_malloc.h` 的问题"already closed by the EXISTING `-nostdlibinc` +isolation"。实测不成立:`-nostdlibinc` 不关 clang 的 resource dir(那是 +`-nobuiltininc`),带着它仍复现 `intrin.h:12:15` 这条与 fmtlib 记录逐字符相同的 +诊断。真正关掉它们的是 `_WIN32` / `__MINGW32__` 消失。结论不变,机制写错,改注释。 + +### 5.1 P1 —— 冻结 `presents` 的语义与取值集 + +**动机**:`presents` 是这套设计里唯一一个类别名,而 F2 刚撤回过类别名。它今天 +站得住,因为它陈述的是**可被探针逐条测量的身份事实**,不是需求集合的简称——但这个 +差别没有写在任何文档里,于是下一个读者会提议给它加值(本轮的 C 方案正是如此)。 + +**形状**:在 `docs/22-target-side.md` 的四键表下加一段: + +> `presents` 的取值集**冻结**为 `posix` / `windows` / `none`。它回答的是**源码看到 +> 哪些环境身份宏**,不回答任何能力是否存在。一个包不得由 `presents` 推断某个接口、 +> 某个头或某个路径是否可用;那些问题由 §5.5 的解析期列举回答。取值集不增长, +> 理由与 SPEC §3.2 "the core set does not grow" 相同:一个错误地扩大的名字,是由 +> 后来写实现的人发现的,不是由规范发现的。 + +**影响**:零命令行改动,纯文档。 + +### 5.2 P2 —— `fails` 拆出 `refused` + +**动机**:强依赖 Linux 内核节点或 Win32 API 的库,在不提供它们的图上构建失败,是 +F1 要的形状。测量管线今天只有一种失败读法,于是"兼容率"被当成引擎的分数,进而 +持续产生把引擎推向表外格子的压力(#674 即是)。 + +**形状**:`tests/openkal/compat.py` 的 `status` 增加一个值。 + +| status | 含义 | 计入失败率 | +| --- | --- | --- | +| `runs` | 测试通过 | — | +| `builds` | 构建成功,测试未跑或未过 | — | +| `fails` | **本该能构建而没能** | 是 | +| `refused` | **这个包要的能力这个图不提供,拒绝是正确结果** | 否 | + +`refused` 的判据不能是"诊断里出现了某个字符串",而是**包在清单里声明了图不满足的 +要求**(§5.5 的机制),或描述符里显式标注。没有声明的一律仍是 `fails`。 + +**影响**:`openkal_kind` facet 与站点展示同步;`docs/openkal-compat.md` 增加一节 +说明"一个 `refused` 是正确答案"。 + +### 5.3 P3 —— 撤掉 `__CYGWIN__` 借用 + +**动机**:保留 `__CYGWIN__` 的理由是"第三方可移植代码需要一个名字指 PE 格式 + +POSIX C 环境"。F5 的判据是机械的:借用某个环境的形状,**错的是那个形状**。实证支持 +这条判定——mimalloc 的守卫写着: + +```c +#if defined(_WIN32) || defined(__CYGWIN__) // we use windows locks on cygwin, but otherwise treat it at unix +``` + +上游用这个名字表达的是"**Win32 可用**",不是"对象格式是 PE"。sqlite3 同形 +(`__CYGWIN__` 在 `SQLITE_OS_WIN` 的检测列表里,随后 `#include "windows.h"`, +并且 `#ifdef __CYGWIN__ → #include `,正是 record §6 点名的风险)。 + +**一个借来的名字,它的语义由借出方的历史决定,不由我们的意图决定。** + +**形状**:`cenv.cppm` 的 Windows + Posix 分支恢复 §3.3 原始文本的 +`-U__CYGWIN__ -U__CYGWIN32__`,`expectUndefined` 相应增加两项。 + +**"对象格式是 PE"这一维怎么办**:默认**不给宏**。包在清单里问 +`cfg(os = "windows")`,那本来就是它该问的地方,而且不需要任何宏。若重测显示确有 +第三方代码只能在预处理期问这件事,回落方案是由 mcpp 定义一个**自己的**名字 +(例如 `__mcpp_format_pe__`),而不是继续借用别人的。 + +**风险**:libarchive 的生成配置头已含 `#if defined(_WIN32) && !defined(__CYGWIN__)`, +撤销会改变它的分支。这条必须由重测确认,不能先落地。 + +### 5.4 P4 —— 合成节点层 + +**动机**:写得完全正确的 POSIX 代码——`open("/dev/urandom")`、`fopen("/dev/null","w")`、 +`/dev/fd/N`、`/tmp`——在 openkal-Windows 上会坏,而没有任何一层负责。这是唯一一类 +"代码本来就对"的失败。 + +**它不是新层。** `openkal-musl` 的 `port/src/okm_fd.c` 文件头已经写明: + +> **Two things POSIX has and openkal does not are built here**, and each is built +> **once for every environment rather than once per environment**. +> ... A **name** in POSIX is resolved against a single global root. openkal has no +> global root ... **one rule, in one place, rather than the same rule in every program.** + +已经造了两样(描述符表、单一全局根的名字解析)。合成节点是第三样,判据同源。 + +**判据(写死,不迭代扩张)**: + +> 一个名字可以被合成,当且仅当它同时满足两条: +> **(a) POSIX 有而 openkal 没有**;**(b) openkal 能回答它**。 +> 两条不同时满足的,一律回答 `kal_node_absent` / `ENOENT`。 + +**上界由 openkal 接口集自动给出**,不靠纪律。因此它在结构上不可能"越做越像 Linux", +这与 design §9 拒绝的"在 musl 上仿真 `windows.h`(永远做不完)"性质不同。 + +| 名字 | (a) | (b) | 合成 | +| --- | --- | --- | --- | +| `/dev/null` `/dev/zero` | 是 | 是(不需要接口) | 是 | +| `/dev/urandom` `/dev/random` | 是 | 是,`openkal.random` | 是 | +| `/dev/stdin` `/dev/stdout` `/dev/stderr` `/dev/fd/N` | 是 | 是,描述符表已在 | 是 | +| `/proc/self/exe` `/cmdline` `/environ` | 是 | 是,`openkal.env` / `openkal.process` | 是 | +| `/tmp` | 是 | 是,由 supplied directories 给 | 是 | +| `/proc/cpuinfo` `/proc/meminfo` | 是 | **否** | 否 | +| `/sys/**` `/proc/net/**` | 是 | **否** | 否 | + +**失败模式合法**:合成不了的答 `ENOENT`,这是 §7.7 明文的 *absence as an answer*, +**不是** F1 禁止的"运行期报告不支持"。两者的分水岭是:`epoll_create` 答 `ENOSYS` +说的是"这个接口不支持"(缺陷);`open("/proc/net/tcp")` 答 `ENOENT` 说的是 +"这个名字不存在"(文件系统的正常语义)。 + +**位置**:放进 `openkal-musl` 的 `port/`,不单开包。它需要描述符表(已在 port 里) +与对可选接口的弱引用(port 里已有这个模式,见 `okm_fd.c` 对 +`kal_process_channel_close` 的 `__attribute__((__weak__))`)。独立成包要把描述符表 +变成新的 ABI 边界,为一个今天只有一个消费者的东西付这个代价不值;等第二个形态 +(picolibc)真的也需要时再抽——第二个实例才能暴露接口是否完整。 + +**验证**:进 openkal-musl 的 conformance。对表内每个名字,在每个目标上断言可打开 +且语义正确;对表外的代表性路径(`/proc/cpuinfo`、`/sys/class`),断言得到 `ENOENT`。 +**表外的断言和表内的同等重要**:它是"上界真的存在"的唯一证据。 + +**限制,必须写在提案里**:对 `#ifdef __linux__` 门控的代码**无效**——catch2 的 +`/proc/self/status` 根本不会被调用,因为 `__linux__` 不定义。**P4 的受益者是"写 +POSIX 的代码",不是"写 Linux 的代码"。** 它不替代 P5。 + +### 5.5 P5 —— 解析期逐条列举 + +**动机**:三时刻表里最早的那一格空着。SPEC §3.3 明说消费者应逐条列举 +("a consumer that needs five names five. That is five lines"),且"in its own package, +which is where a convention among consumers belongs",但 mcpp 没有承载它的字段。 + +**形状**:按层泛化,引擎不认识任何接口名。 + +```toml +# 实现包:openkal-windows +[package] +provides = ["mcpp:kernel-abi=openkal"] + +[kernel-abi] +provides-interfaces = [ + "openkal.abort", "openkal.stream", "openkal.memory", + "openkal.env", "openkal.time", "openkal.fs", "openkal.process", +] + +# 消费包:一个网络库 +[package] +requires = ["mcpp:kernel-abi=openkal"] + +[kernel-abi] +requires-interfaces = ["openkal.fs", "openkal.net"] +``` + +**引擎的规则只有一条**:`requires-interfaces ⊆ provides-interfaces`,否则在**解析期** +拒绝,并列出缺的名字。引擎不认识 `openkal.net` 是什么,它只做集合包含——与 +`cenv.cppm` 的"通用知识,不含包名"同纪律。 + +**平台 SDK 那一半不需要新机制**,今天就能表达: + +```toml +[target.'cfg(os = "windows")'.dependencies] +win32-headers = "1.0" +``` + +**与 P2 的接口**:一个包因 `requires-interfaces` 不满足而被拒,测量记 `refused`, +不计入失败率。这是 `refused` 唯一可靠的判据来源。 + +**为什么不由引擎推断**:`provides-interfaces` 只有实现包知道(它知道自己实现了 +什么),`requires-interfaces` 只有消费包知道(它知道自己调了什么)。两者都不是 +引擎能推导的,也不是另一方能替对方说的。 + +**但两者都不得被信任**——见 §5.7。P5 单独落地会引入两个纯被信任的声明,那与 +§3.2 的原则相悖;P5 与 P7 是同一次改动的两半。 + +### 5.6 P6 —— `openkal-win-ucrt` 形态 + +**动机**:design §3.1 的形态表第三行今天是"**不使用 openkal**"——需要平台 C 运行时 +就整个目标离开 openkal。加入这个形态后变成"**用 openkal,换一个 C 库形态**": +`kal_*` 仍可用,程序仍是 openkal 程序。 + +**形状**: + +```toml +[package] +name = "openkal-win-ucrt" +provides = ["mcpp:c-abi=ucrt"] + +[c-abi] +presents = "windows" +data-model = "arch-default" # Windows 上即 LLP64 +wchar = 16 +builtins = "platform" +``` + +**引擎改动:零。** 走一遍 `cenv::realise`: + +| 步骤 | 结果 | +| --- | --- | +| `presents == Windows` on windows | "Already the base triple's own identity — nothing to add" → 零令牌 | +| `arch_default_data_model(Windows, x86_64)` = `Llp64`;`triple_native_data_model("windows")` = `Llp64` | 相等,已满足 → 零令牌 | +| `wchar = 16` vs `native_wchar_bits("windows") = 16` | 相等 → 零令牌 | +| `builtins = platform` | 零令牌 | + +空令牌集。又因 2026.9.18.2 的"realise 先跑、编译器族门第二"修复,**空实现连 Clang +都不强制**,GCC 与 MSVC 同样可用。 + +**这是本设计的分层判据**: + +> **一个新形态需要改多少处引擎?** 答案必须是零。不是零,说明分层漏了一维。 + +**配套**:c++-abi 一侧需要一个配 UCRT 的 libc++ 或直接用 MSVC STL。那是包的事。 + +### 5.7 P7 —— 声明的校验阶梯(接口一级) + +**动机。** P5 引入两个声明(`provides-interfaces` / `requires-interfaces`)。若二者 +只被信任,本文就在关闭一个缺口的同时开了一个更大的:`[c-abi]` 的探针量的是 +`__unix__` / `_WIN32` / `long` / `wchar`,**没有任何东西量"这个实现真的提供了哪些 +接口"**。design §3.2 的原则是「声明被校验,而不是被信任」,它在宏一级铺到了,在 +接口一级今天是空的。 + +**关闭它不需要新机制。** 三件材料已经在仓库里: + +| 材料 | 状态 | 形状 | +| --- | --- | --- | +| `openkal/SURFACE.txt` | 已有,**normative** | 104 个 `kal_` 名字,按 16 个接口分组(`# openkal.fs` 一类的标题);"An implementation provides an interface in whole or not at all, so **the absence of a group below denotes an interface the implementation does not provide**" | +| SPEC §9.2 Surface | 已有,已规定 | "The names an implementation exports beginning with `kal_` shall be compared against `SURFACE.txt` ... **a static examination of the artefact**" | +| `kal_interfaces()` | 已有 | `include/openkal/version.h:91`,一个位字回答"哪些接口在场";"says which interfaces exist, **not how they behave**" | + +缺的只是把它们接成阶梯,并加一条生成规则。 + +#### 5.7.1 四级阶梯 + +每一级都是该事实**最早能被检查**的时刻,与 §6.2 同构: + +| 级 | 时刻 | 被检查的是 | 机制 | 今天 | +| --- | --- | --- | --- | --- | +| **L1** | 实现**发布** | `provides-interfaces` 是否属实 | §9.2 的静态比对,按 `SURFACE.txt` 的分组把导出的 `kal_` 名字**反推**成接口集 | 比对已有;反推与写回清单**没有** | +| **L2** | 依赖**解析** | `requires ⊆ provides` | 集合包含(P5) | 无 | +| **L3** | **链接** | 消费者**实际用到的** ⊆ 它声明的 | 消费者对象的未定义 `kal_` 符号 ∩ `SURFACE.txt` → 映射到接口 → 集合比较 | 无 | +| **L4** | **运行** | 实际在场的接口 | `kal_interfaces()` 位字 | 已有 | + +#### 5.7.2 关键规则:`provides-interfaces` 不得手写 + +> **实现包的 `provides-interfaces` 由产物生成,并断言"清单里的 = 产物里的"。** + +实现包的 CI 跑 §9.2 的比对,按 `SURFACE.txt` 的分组反推接口集,写回清单;CI 同时 +断言两者相等。这与 `generated_files` 同纪律——**一个从产物派生的声明,不可能与产物 +不符**。它也顺便消灭一类会发生的事:实现新增一个接口却忘了改清单。 + +L1 因此不是"再加一道检查",而是**取消手写**。 + +#### 5.7.3 为什么 L3 是必需的,不是锦上添花 + +L1 与 L2 都管不到**消费者**的声明。没有 L3,`requires-interfaces` 是纯粹被信任的: +一个包可以只声明 `openkal.fs` 却调 `kal_net_*`,解析期通过,**在提供 net 的实现上 +链接也通过**——只有在不提供 net 的实现上才会炸,而那时错误出现在**用户的目标上**, +不是在包作者自己的构建里。 + +L3 把这次失败从"某个用户的某个目标"提前到"包作者自己的构建"。它也很便宜:mcpp 已经 +有链接这一步,取消费者对象的未定义符号、与 `SURFACE.txt` 求交、映射到接口、比较集合。 + +**L3 的报告形态**:多用(用了没声明的)是**错误**,指名符号与它所属的接口; +少用(声明了没用的)是**提示**而不是错误——一个包可以按 feature 或按目标条件地使用 +某个接口,当前这次解析里没用到不代表声明是错的。 + +#### 5.7.4 风险边界:写错不会静默 + +这是"这两个事实可以由声明承载"的根据——不是因为声明可信,而是因为**每一种写错都响亮**: + +| 写错 | 后果 | 何时被发现 | +| --- | --- | --- | +| `provides` 多声明 | 解析通过,链接报未定义符号 | 链接(§6.1 的常规报告) | +| `provides` 少声明 | 解析期误拒,指名缺的接口 | 解析(响亮) | +| `requires` 多声明 | 在能力较少的实现上误拒 | 解析(响亮) | +| `requires` 少声明 | **L3 抓住**;无 L3 则在部分实现上链接失败 | 链接 | + +**没有一格是静默的。** 加上 L1 取消手写、L3 抓消费者,四种写错里两种在自己的 CI 里 +就被挡住,另两种落在响亮的位置。 + +#### 5.7.5 C 库一级:枚举例外,不枚举规则 + +同一条阶梯能不能照搬到 `c-abi`?**不能,而且不该。** openkal 有 16 个接口和一份 +`SURFACE.txt`,所以可以**正向**枚举;C 库有约 1200 个 POSIX 函数,正向枚举就是 +SPEC §3.3 撤回过的那件事(naming sets)。 + +但**反向枚举是有界的**。`openkal-musl` 的 README 已经列出了它不能提供的东西,约六项, +并且写下了正确的理由: + +> The following are absent, and each is **refused rather than quietly accepted**, +> because **a facility that reports success and does nothing is the one kind of +> answer that leaves a program wrong without telling it.** + +这段散文就是 §6.1 的原则,只是**没有任何东西在执行它**——而它已经被烧过一次,README +自己记录着:0.16.0 之前 `SIG_IGN` 对每个信号都被接受却一个都没安装,"a program that +asked not to be ended by the interrupt keystroke **was told it had succeeded and was +ended by it**"。 + +**形状**:把这份散文变成机读声明,并由 CI 断言它与产物一致。 + +```toml +# openkal-musl 的清单 +[c-abi] +presents = "posix" +data-model = "arch-default" +wchar = 32 +builtins = "iso" + +# 枚举例外,不枚举规则。form 是该缺席以何种形状到达调用方。 +[c-abi.absent] +fork = { targets = ["*"], form = "link" } # 符号不定义,链接期报 +mprotect = { targets = ["*"], form = "enosys" } # 定义存在,报 ENOSYS +sigaction-handler = { targets = ["*"], form = "enosys", + note = "非默认/非忽略的处置" } +``` + +两个价值,各自独立: + +1. **CI 可断言。** `form = "link"` 的名字必须**不在**产物的定义里;`form = "enosys"` + 的必须**在**。这把一份从来没有执行者的散文变成一条会红的断言。 +2. **mcpp 的诊断可以接话。** 链接报 `undefined reference to 'fork'` 时,mcpp 读到 + C 库的 `[c-abi.absent]`,补一句「`openkal-musl` 声明 `fork` 在 `x86_64-windows-gnu` + 上不可用(link 形式)」,而不是让用户自己去查一份 README。 + +**`form` 这个字段本身承重。** `link` 是 §6.1 要的形状;`enosys` 是需要辩护的例外, +README 今天为每一条都写了辩护(`mprotect`:「musl asks for a guard page ... and +proceeds without one when told this, so the honest answer is also the one it is +prepared for」)。把辩护变成一个字段,**"有多少例外"就成为一个看得见、会增长、可以 +被 review 盯住的数字**——而不是散在一篇 README 里。 + +#### 5.7.6 实现代价 + +| 部件 | 在哪 | 规模 | +| --- | --- | --- | +| `SURFACE.txt` → 接口集的反推 | openkal `tools/` | 一个脚本,按 `# openkal.x` 标题分组 | +| L1 断言 + 写回清单 | 每个实现包的 CI | 复用上面的脚本 | +| L2 集合包含 | mcpp 解析期 | 一条规则,引擎不认识任何接口名 | +| L3 未定义符号比对 | mcpp 链接期 | 取对象的未定义符号、求交、映射、比较 | +| L4 | 已有 | — | +| `[c-abi.absent]` 断言 | openkal-musl 的 CI | 一个脚本 | +| `[c-abi.absent]` 诊断接话 | mcpp 链接失败路径 | 一处 | + +**引擎侧新增两处**(L2 解析、L3 链接),且**都不认识任何具体名字**:L2 做集合包含, +L3 做集合差。名字的含义全部来自 `SURFACE.txt`,而那是 openkal 的 normative 文件, +不是引擎的知识。这与 `cenv.cppm` 的「GENERIC KNOWLEDGE, NO PACKAGE NAMES」同纪律。 + +#### 5.7.7 判据 + +- L1:把某个实现的一个接口的定义删掉,其 CI 必须红,并指名那个接口。 +- L2:消费者声明一个实现不提供的接口,解析期必须拒绝并指名。 +- L3:消费者调用一个**没有声明**的接口,链接期必须报错并指名符号与它所属的接口; + **且在提供该接口的实现上同样报错**——否则这条判据只是重复了 §6.1。 +- L4:已由 openkal conformance 覆盖。 +- `[c-abi.absent]`:把 `fork` 的 `form` 从 `link` 改成 `enosys`,CI 必须红。 + +最后一条与 L3 的第二句是同一个形状:**一条判据必须在"机制本身会通过"的那一侧 +也成立,否则它测的是机制不是声明。** + +--- + +## 6. 使用方式与场景 + +### 场景一 —— 纯 POSIX 库放进 openkal(目标:无感) + +```toml +# 库自己的清单:什么都不用写 +[package] +name = "mylib" +``` + +构建 `x86_64-windows-gnu` + openkal 图:`_WIN32` 不定义、`__unix__` 定义、LP64、 +wchar 32。库里的 `#ifdef _WIN32` 分支不选中,走 POSIX 分支。若它用到 +`/dev/urandom`,由 P4 合成。**零适配。** + +这是今天 asio、eigen、catch2、re2、CLI11、lua、spdlog 应当落入的场景——它们今天红, +只是因为测量的图里 `runtime` 还钉在 `0.10.0`,没有任何包声明 `[c-abi]`。 + +### 场景二 —— 需要 Win32 API 的库 + +```toml +[features] +default = ["posix-socket"] +posix-socket = { defines = ["MYLIB_POSIX_SOCKETS"] } +winsock = { defines = ["MYLIB_WINSOCK"] } + +[feature-deps.winsock] +win32-headers = "1.0" # 平台 SDK 由图提供,不传给下游 +``` + +选 `winsock` 而图里没有 `win32-headers` → **解析期**拒绝,指名缺什么。 +不选就走 POSIX 路径。**不在预处理期猜。** + +### 场景三 —— 需要 Linux 内核节点的库 + +```toml +[package] +requires = ["mcpp:kernel-abi=openkal"] + +[kernel-abi] +requires-interfaces = ["openkal.fs", "openkal.event"] # event 今天是 reserved +``` + +在任何图上都解析失败,测量记 `refused`,**不计入失败率**。 +这就是"编不过是正确答案"的机器表达。 + +### 场景四 —— 程序需要 Windows C 运行时 + +```toml +# 程序的清单:换一个入口包,整棵图跟着换形态 +[dependencies] +openkal-win-ucrt = "0.1" +``` + +`_WIN32` 定义、LLP64、wchar 16,`kal_*` 仍然可用。**形态由程序选,库不得选。** + +### 场景五 —— 包自己的构建配置 + +```lua +-- compat.zlib.lua:无条件应用,头里按事实自判断 +cflags = { "-include", "mcpp_zlib_config.h" }, +generated_files = { + ["mcpp_generated/include/mcpp_zlib_config.h"] = + "#if !defined(_WIN32)\n#define Z_HAVE_UNISTD_H 1\n#endif\n", +}, +``` + +**判断的是事实(`_WIN32` 在不在),不是身份(C 库是不是 musl)。** +撤回前那条 `cfg(all(windows, c-abi = "musl"))` 是身份判断,是 `[c-abi]` 要消灭的写法。 + +**可度量的健康指标**:索引里 `cfg(c-abi = ...)` 的出现次数。今天是 **2**(都在 +libarchive,且都在问"平台 SDK / 内核头在不在图里"——正是 P5 要补的那一维), +目标是 **0**。 + +### 场景六 —— 写一个新库,怎么写才在全生态可移植 + +1. 不问身份宏,问**事实**(`__has_include`、feature、或什么都不问走标准路径)。 +2. 需要某个 openkal 接口,写进 `requires-interfaces`,让解析期回答。 +3. 需要平台 SDK,写成 per-target 依赖 + feature。 +4. 自己的构建开关写进描述符,无条件应用,头里按事实自判断。 + +--- + +## 7. 多维评估 + +| 维度 | 评估 | 可测量的判据 | +| --- | --- | --- | +| **兼容性** | 第一类失败(`_WIN32` 选错分支)自愈;第二类由 P3 关闭;第三类归 A;"需要平台能力"的由 P5 转成 `refused`。**兼容率不再是引擎的分数** | 30-member 重测:`fails` 与 `refused` 分开计 | +| **稳定性** | 所有方案落在三时刻表内,不存在"被下一个环境证伪"的类别名或借来的宏 | 索引里 `cfg(c-abi=...)` 次数 → 0;`presents` 取值集不增长 | +| **易用性** | 纯 POSIX 库零适配(场景一);需要能力的包写五行(场景三);换形态改一行依赖(场景四) | 一个新 compat 包进索引时需要写的 `target_cfg` 条数 | +| **简洁性** | 新增两个机制(P4 合成表、P5 接口列举),各补一格;撤掉一个借用(P3);冻结一个名字(P1)。**净新增概念为一(接口列举),其余都是既有机制的延伸** | 新形态需要的引擎改动数 = 0(P6 已验证) | +| **可维护性** | P4 的上界由接口集自动给出;P5/P7 的引擎规则只有集合包含与集合差;P1/P2 是文档与字段 | 引擎里"认识某个具体名字"的处数不增加 | +| **可验证性** | 四个时刻各有一条会红的断言;`provides-interfaces` 由产物生成而非手写;C 库一级的例外表把一份无人执行的散文变成断言 | 删掉一个接口的定义,实现的 CI 必须红;调一个未声明的接口,链接期必须报错**且在提供该接口的实现上同样报错** | +| **未来扩展性** | 新形态 = 一个包;新接口 = SPEC 一行 + 两侧清单各一行;新平台 = 一个 kernel-abi 实现 | 加一个 `openkal.event` 需要动几个仓库:SPEC 1 + 实现 N + 消费者清单,引擎 0 | + +**三处不优雅,本文全部关闭**: + +| 边缘 | 状态 | +| --- | --- | +| 借来的 `__CYGWIN__` | P3 关闭(需重测确认) | +| 空着的解析期格子 | P5 关闭 | +| 探针只铺到宏、没铺到接口 | **P7 关闭**。四级阶梯(L1 发布 / L2 解析 / L3 链接 / L4 运行),材料全部已在仓库里(`SURFACE.txt`、SPEC §9.2、`kal_interfaces()`),引擎侧新增两处且都不认识任何具体名字。C 库一级枚举例外而不枚举规则(`[c-abi.absent]`) | + +**P7 同时改变了"可验证性"这一维的整体评级**:在它之前,`[c-abi]` 只有宏一级被校验, +接口一级完全是信任;在它之后,四个时刻各有一条会红的断言,且 `provides-interfaces` +由产物生成而非手写。 + +--- + +## 8. 影响面 + +| 层 | 影响 | 性质 | +| --- | --- | --- | +| openkal 规范 | 零。不新增也不修改任何 `kal_*` | — | +| openkal 各实现 | P5/P7 要求填 `provides-interfaces`,且**由产物生成而非手写**;CI 增加 L1 断言 | 一次性,完全机械化 | +| openkal-musl | P4 在 `port/` 新增合成表与 conformance 断言;P7 把 README 的"absent"散文表变成 `[c-abi.absent]` 并加 CI 断言 | 新增能力,向后兼容 | +| openkal-llvm-runtime | 零 | — | +| mcpp 引擎 | P0 两处修复;P5 新增字段与解析规则;**P7 新增链接期集合差与 `[c-abi.absent]` 诊断接话**;P3 改两个令牌;P1 改文档 | 新增能力,向后兼容 | +| mcpp-index | P2 拆 `status`;libarchive 那 2 处身份判断改成 P5 声明 | 跟随 | +| 终端用户(用 openkal) | P3 改变 `__CYGWIN__`,该目标上的包重建一次 | 一次破坏性 | +| **不用 openkal 的用户** | **零。命令行逐字节不变** | 已由现有"未声明者不变"保证 | + +--- + +## 9. 顺序 + +``` +P0 探针修复 ─┐ +P1 冻结语义 ─┼─ 互不依赖,可并行 +P2 refused ─┘ + │ + ├─→ 发布链走完,抬 pins.toml 的 runtime,重测 ──┬─→ P3 撤 __CYGWIN__(需重测数据) + │ └─→ 兼容性基线重新确立 + │ +P4 合成节点 ──── 独立,可与上并行 +P5 接口列举 ─┬─ 同一次改动的两半,不得只落地 P5 +P7 校验阶梯 ─┘ (L1 先行:它只改 openkal 各实现的 CI,不动引擎) +P6 win-ucrt ──── 需要 c++-abi 侧配套,最后 +``` + +**P7 的 L1 可以独立先行**:把 `SURFACE.txt` → 接口集的反推脚本与 CI 断言做出来, +不依赖任何引擎改动,而且它产出的正是 P5 需要的那份 `provides-interfaces`。 +`[c-abi.absent]` 的 CI 断言同样可以独立先行。 + +P3 **不得**先于重测落地:libarchive 的生成配置头含 +`#if defined(_WIN32) && !defined(__CYGWIN__)`,撤销会改变它的分支。 + +--- + +## 10. 需要决定的问题 + +- **D1 P3 是否执行。** 它推翻一条已发布的设计决策(`__CYGWIN__` 保留), + 但那条决策自己写着"留给 30-member 测量翻转"。证据已在(mimalloc 的注释、 + sqlite3 的检测列表),但代价需要重测。**建议:执行,在重测之后。** +- **D2 P4 放 `port/` 还是独立包。** 建议 `port/`,理由见 §5.4。 +- **D3 P5 的字段命名与归属层。** 本文提的是 `[kernel-abi] requires-interfaces / + provides-interfaces`,按层泛化。另一种是并入 `requires` 列表。 + 建议前者——`requires` 今天命名的是层,混入接口名会让一个列表有两套词汇。 +- **D4 P2 的 `refused` 判据。** 本文要求它必须来自包的声明(P5),不得来自 + 诊断字符串匹配。这意味着 P2 的完整形态依赖 P5;在 P5 之前,`refused` 只能由 + 描述符显式标注。**建议:先上显式标注,P5 之后改为自动。** +- **D5 "对象格式是 PE"是否需要一个名字。** 建议先不给,让包问 `cfg(os="windows")`; + 若重测显示确有预处理期的需要,由 mcpp 定义自己的名字,不再借用。 +- **D6 `provides-interfaces` 由产物生成,还是允许手写后校验?** 本文提的是**生成** + (§5.7.2),理由是"从产物派生的声明不可能与产物不符",而且顺带消灭"新增接口忘改 + 清单"这一类。代价是实现包的 CI 多一个生成步骤。**建议:生成。** +- **D7 L3(链接期集合差)是否纳入本轮。** 它是 `requires-interfaces` 唯一的校验者, + 没有它 P5 的消费者一侧是纯被信任的。代价是 mcpp 链接期多一次符号读取。 + **建议:纳入,但可以先做成 warning,一个发布周期后转 error。** +- **D8 `[c-abi.absent]` 的 `form` 取值集。** 本文用 `link` / `enosys` 两个值。 + 是否需要第三个(例如"接受但无效"——README 记录的 `tcsetattr` 部分字段即是)? + **建议:需要,命名为 `accepted-no-effect`,因为它正是 0.16.0 之前那个真实缺陷的 + 形状,给它一个名字才能被盯住。** + +--- + +## 11. 附录:本文依据的实测 + +全部在本机(Linux x86_64,clang 22.1.0)执行,可逐条重跑。 + +```sh +# 三元组预定义:cygwin 不定义 _WIN32,mingw 定义 +clang --target=x86_64-pc-cygwin -x c -E -dM - \n\nexport module m;\n' > m.cppm +clang++ -std=c++23 --precompile m.cppm -o /dev/null # OK +clang++ -std=c++23 -include unistd.h --precompile m.cppm -o /dev/null # error: 'module;' ... only at the start + +# -include 进 GAS +printf '.text\n.globl f\nf:\n ret\n' > a.S +clang -c -include unistd.h a.S -o a.o # invalid instruction mnemonic '__begin_decls' + +# appendUniqueFlags 去重之后的命令行 +clang -c -include unistd.h sys/stat.h t.c # no such file or directory: 'sys/stat.h' + +# -nostdlibinc 不关 clang 自带头 +printf '#include \nint main(){}\n' > i.cpp +clang++ --target=x86_64-pc-cygwin -nostdlibinc -fsyntax-only i.cpp # intrin.h:12:15 + +# glibc 的 stdio.h 不传递包含 unistd.h(否定 #674 设计稿 §1.2) +printf '#include \nlong f(void){return lseek(0,0,0);}\n' > s.c +clang -fsyntax-only -std=c11 s.c # call to undeclared function 'lseek' +``` + +索引侧: + +```sh +cd ~/workspace/github/mcpplibs/mcpp-index +python3 -c " +import json; d=json.load(open('.xpkgindex/openkal-compat.json')) +print(d['pins'], d['measured']) +for n,i in sorted(d['members'].items()): + t=i['targets']['x86_64-windows-gnu'] + if t.get('status')!='runs': print(n, t.get('status'), t.get('diagnostic','')[:110]) +" +grep -rho 'cfg([^)]*c-abi[^)]*)' pkgs/ | sort | uniq -c # 今天 2 处,都在 libarchive +sed -n '1,35p' tests/openkal/pins.toml # runtime = "0.10.0" +``` + +--- + +## 12. 参考 + +- openkal SPEC 0.14:§3.2(core set 不增长)、§3.3(撤回 `hosted`)、§3.4(拒绝统一名字解析)、§5.2(mapping 不是 simulation)、§6.1(运行期拒绝是缺陷)、§6.2(三时刻)、§6.5(解析期决定)、§7.1(naturalness)、§7.7(absence as an answer) +- openkal SPEC 0.14:§9(conformance procedure,四半)、§4.4(S/L/X 与 `kal_interfaces`) +- `openkal/SURFACE.txt` —— normative,104 个名字 / 16 个接口分组 +- `openkal/include/openkal/version.h:55-91` —— `kal_interfaces` 的位字与"不是能力"的界定 +- `openkal-musl` `README.md` "The following are absent" 表 —— 例外枚举的现成内容与理由 +- `openkal-musl` `port/src/okm_fd.c` 文件头 —— 合成层已经存在的证据 +- openkal design 2026-09-18:§3.1(形态表)、§3.2(`[c-abi]`)、§5(平台相关代码)、§9(备选与不做)、§12(自审) +- `mcpp` `src/toolchain/cenv.cppm`、`src/toolchain/cenv_probe.cppm`、`src/build/prepare.cppm`(3989、6380、10790、11148、12380、12641) +- `docs/22-target-side.md` §295-430 +- `mcpp-index` `.xpkgindex/openkal-compat.json`、`tests/openkal/pins.toml`、`pkgs/c/compat.zlib.lua`、提交 700f7de +- 评审记录:`.agents/docs/2026-09-20-issue-674-design-review.md` diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e026931..08899a3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,114 @@ ## [Unreleased] +## [2026.9.20.1] - 2026-09-20 + +### 校验探针量的是构建宿主,而不是它要核对的那个目标 + +2026.9.18.3 的 c-abi 校验探针在**每一次 freestanding 构建上都没有选中目标**。 +`Toolchain::crossTargetFlag` 只为 hosted 目标设置(它自己的注释写明了原因:freestanding +目标的 `--target` 必须与随行的 ISA 标志一起给出,放两处就是同一个决定写两遍),而那另一处 +是 `mcpp.freestanding.linkline` 的编译前缀,探针从不问它;`cenv::realise` 对 freestanding +也不产 `--target`。于是探针的命令行是 `-D__unix__ -fno-short-wchar -ffreestanding -x c++ +-E -dM -`——没有任何目标选择,clang 回答的是它自己所在的那台机器。 + +在 Linux 宿主上这台机器恰好满足 `__unix__` 已定义、`_WIN32` 未定义、`wchar_t` 32 位, +于是检查**以错误的理由通过**;在 Windows 宿主上它报 `_WIN32` 已定义、`wchar_t` 16 位, +两条不匹配同时出现。2026.9.18.3 把这两条读成"Windows 宿主的 clang 即使带上 `--target=` +仍注入宿主预定义",并据此加了 `hostStripMacros`。该归因不成立:clang 的预定义跟随目标 +而不是宿主。本机实测——`clang --target=riscv64-none-elf -dM` 在 Linux 上 `__linux__` 计数 +为 0、`__SIZEOF_WCHAR_T__` 为 4,而 `--target=x86_64-w64-windows-gnu` 在同一台 Linux 上 +定义 `_WIN32`——如果那个 `--target` 真在命令行上,clang 会答 4 而不是 2。观察到 2,说明 +它不在。 + +这一版从根因修: + +1. **探针拿到目标。** freestanding 目标的 `--target` 与 ISA 标志由 + `mcpp.freestanding.linkline` 的编译前缀提供,现在进入探针 argv。 +2. **`hostStripMacros` 删除,而删除本身是要点。** `-U_WIN32 -U_WIN64 -U__MINGW32__ + -U__MINGW64__` 抹掉的正是"探针量错了机器"这件事的唯一证据。将来若真有宿主泄漏,它必须 + 到达不匹配报告,而不是在能被看见之前就被 undefine 掉。 +3. **装配处拒绝这次遗漏,而不是调用点记得不要犯。** 新增 + `cenv_probe::assemble_argv`:各个部件在某些配置下都合法地为空,所以没有任何单独一个 + 能承载这条不变量。freestanding 目标的 argv 没有选中目标即拒绝并点名目标;宿主本地 + 构建接受没有目标选择的 argv——那里宿主就是目标,缺席是那个决定本身。 + +单测 `test_cenv_probe.cpp` 的三个 strip 测试被替换:它们测的是"`-U` 有没有到达命令行" +这个机制,而不是"探针量的是不是正确的机器"这条性质。新的五个 `CenvProbeArgv` 测试**不需要 +交叉工具链**,直接对装配函数断言;另加两个带 clang 的端到端探针测试,在没有 clang 的宿主 +上跳过而不谎报。 + +### `builtins` 的 Windows 行:结论不变,写在旁边的机制是错的 + +`cenv.cppm` 称 clang 自带 `intrin.h` / `mm_malloc.h` 的问题"已由既有的 `-nostdlibinc` +隔离关闭"。实测不成立:`-nostdlibinc` 移除的是标准**系统**头目录,clang 自己的 resource +目录仍在(那是 `-nobuiltininc` 移除的),带着该标志仍复现 `intrin.h:12:15`——与 mcpp-index +为 fmtlib.fmt 记录的诊断逐字符相同。真正关掉这两者的是 Cygwin 式实现:`mm_malloc.h:42` +在 `__MINGW32__` 上选 `__mingw_aligned_malloc`,没有它则落到 `posix_memalign`;而去取 +`` 的源码是在 `_WIN32` 之后才这么做的。调查结论不变(Windows 上没有可关的循环 +惯用法内建),改的是写在它旁边的那句机制。 + +### 一个包可以陈述它需要该层的哪些接口,解析期回答 + +能力的"在不在"过去无处可问,于是全被挤到预处理期,而那比答案存在得更早——mcpp#674 的 +全部压力来自这一格空着。openkal SPEC 0.14 §6.2 列出三个时刻并规定每个都是该信息**最早 +能存在**的时刻;§3.3 撤回了它给接口集合起过的唯一一个名字(`hosted`),理由是"一个描述 +环境类别的名字会被没有人想到过的那个环境证伪",替代做法是由消费者逐条列举。 + +新增 `[kernel-abi]` 表: + +```toml +# 实现方(只有提供该层的包可以写 provides-interfaces) +[kernel-abi] +provides-interfaces = ["openkal.abort", "openkal.stream", "openkal.memory"] + +# 消费方(任何包都可以写 requires-interfaces) +[kernel-abi] +requires-interfaces = ["openkal.fs", "openkal.net"] +``` + +**引擎不认识这两个集合的任何一个成员**:对它们做的唯一操作是集合差 +(`targetside::interfaces_not_provided`),因此某个规范新增一个接口不需要 mcpp 发版。 +不满足即在**编译任何东西之前**拒绝,并同时点名缺的接口、要它的包、以及没提供它的实现—— +只报"缺"会让读者自己去猜该改哪一边。 + +**一个什么都没陈述的提供者,不是一个什么都不提供的提供者**:实现方没有写 +`provides-interfaces` 的图照常构建,链接仍以它一贯的词汇报告缺席。什么都不写的清单, +产出的命令行与这项能力存在之前逐字节相同。 + +### `[c-abi.absent]`:枚举例外,不枚举规则 + +一个 C 库供给的名字集合在清单里不可枚举(POSIX 约一千二百个),枚举它正是 §3.3 记录下 +撤回的那个错误。例外是可枚举的——openkal-musl 的 README 列了六项,而那段散文没有任何 +东西在执行它,并且已经被推翻过一次(0.16.0 之前 `SIG_IGN` 对每个信号都被接受却一个都没 +安装)。 + +```toml +[c-abi.absent] +fork = { form = "link" } +mprotect = { form = "enosys", note = "openkal 没有作用于映射保护属性的操作" } +tcsetattr = { form = "accepted-no-effect", note = "openkal 不命名的那些字段不被施加" } +``` + +`form` 必填且封闭。`link` 是 openkal 自己的能力模型对实现所要求的形状(§6.1 把运行期 +报告不支持称为缺陷);另外两个是对它的偏离,给它们命名是为了让一次偏离成为可以被数出来 +的东西。链接点到 `link` 形状里的某一项时,mcpp 把清单读回来:`undefined reference to +'fork'` 因此带着那句说明它是缺陷还是环境限制的话一起到达。 + +### `presents` 的取值集冻结 + +docs/22 写明:`presents` 回答的是源码看到哪些环境身份宏,**不回答任何能力是否存在**。 +一个包不得由它推断某个接口、某个头或某个路径是否可用。取值集不增长,理由与 openkal 为 +自己的核心集封闭所给的相同——一个描述环境**类别**的名字会被没有人想到过的那个环境证伪, +而 openkal 把自己发过的唯一一个这样的名字在一个发布周期之内撤回了。 + +(`src/toolchain/cenv.cppm`、`src/toolchain/cenv_probe.cppm`、`src/build/prepare.cppm`、 +`src/build/ninja_backend.cppm`、`src/build/refusal.cppm`、 +`modules/manifest/src/{targetside_model,toml,types}.cppm`, +单测 `test_cenv_probe.cpp`、`test_manifest.cpp`、`test_targetside.cpp`、 +`test_build_flags.cpp`,e2e `tests/e2e/743_kernel_abi_interfaces_are_resolved_not_preprocessed.sh`, +docs/22 及其 zh 镜像,`modules/versioning/src/version.cppm`、`mcpp.toml`) + ## [2026.9.18.3] - 2026-09-18 ### Windows 主机 × freestanding 目标的 c-abi 校验探针两处真实缺陷被关掉 diff --git a/README.md b/README.md index 7f9c2c2d..a8e824db 100644 --- a/README.md +++ b/README.md @@ -18,13 +18,14 @@ mcpp demo

-> **Note (2026.9.18.3):** this release adds `cenv_probe::verify` host-macro -> stripping for the Windows host and forces `-fno-short-wchar` on freestanding -> wchar realisation. `[c-abi]` packages (`openkal-musl` 0.15.0 and any future -> C-library package) require this engine. Older engines silently misbuild them: -> the c-abi probe sees host contamination (`_WIN32`, `__MINGW32__`, -> `__MINGW64__`, `_WIN64`) on Windows and reads the wrong `__SIZEOF_WCHAR_T__` -> on a freestanding target. Upgrade: `xlings install mcpp --force`. +> **Note (2026.9.20.1):** the `[c-abi]` verification probe now selects the +> target it is verifying. On a freestanding target it selected none and +> answered for the build host, which on a Linux host passed for the wrong +> reason and on a Windows host failed for one. The `hostStripMacros` +> compensation 2026.9.18.3 added is removed with it. This release also adds +> `[kernel-abi] provides-interfaces` / `requires-interfaces`, answered at +> dependency resolution, and `[c-abi.absent]`, which states what a C library +> does not supply and in what shape. See CHANGELOG and docs/22. ## Highlights diff --git a/docs/22-target-side.md b/docs/22-target-side.md index 97544a75..5c93284f 100644 --- a/docs/22-target-side.md +++ b/docs/22-target-side.md @@ -334,6 +334,20 @@ ignored. **A package that declares no `[c-abi]` block changes nothing**: the resolved target side, every compile command and every cache key are byte-identical to a build before this feature existed. +**`presents` IS FROZEN AT THREE VALUES, AND IT ANSWERS NO QUESTION ABOUT +CAPABILITY.** It states which environment-identity macros source sees. It does +not state that any interface, any header or any path is available, and a +package that infers one from it has read a fact it was not given: a +POSIX-presenting environment on a Windows target has no `fork`, no `/proc` and +no `epoll`, and says so by the ordinary means — the definition is absent at the +link. The value set does not grow, for the reason openkal's own specification +gives for closing its core set (SPEC 0.14 §3.2): a name that describes a CLASS +of environment is falsified by an environment nobody had in mind, and openkal +withdrew the one such name it had shipped (`hosted`) within a release of +naming it. A fourth value here would be that name again. What a package needs +in order to know whether a capability is present is answered where the answer +first exists, at dependency resolution, by the interface enumeration below. + Three facts, kept separate, because none of them implies another: `presents` picks the source branch, `data-model`/`wchar` pick the ABI. POSIX does not imply LP64 (it is ILP32 on a 32-bit architecture), and LP64 does not imply @@ -484,25 +498,106 @@ the build and prints both the declared and the measured values. The result is cached per configuration (compiler binary identity + exact flags), so a build that resolves the same configuration twice pays for the probe once. -**Host contamination (mcpp 2026.9.18.3+).** The probe runs on the BUILD -host's clang, not a target-native one, and on a Windows host the driver's -predefines (`_WIN32`, `_WIN64`, `__MINGW32__`, `__MINGW64__`) leak through -`--target=` substitution for a freestanding target the same way the -Cygwin-substituted Windows row's `__CYGWIN__` does NOT — a structural -difference between the hosted and freestanding substitutions that the -verification step's measurement has to compensate for. The probe accepts -a `hostStripMacros` list of `-U` tokens it prepends to its `-dM` -command, and the caller (this layer's `prepare`) supplies exactly the four -Windows-host names on Windows, nothing on Linux or macOS. The matching -defect on `__SIZEOF_WCHAR_T__` (Windows host × freestanding measures 2 -where a `wchar = 32` declaration asks for 4) is closed on the realisation -side, not the probe side: `cenv::realise` now ALWAYS emits -`-fno-short-wchar` for `decl.wcharBits = 32`, regardless of what the -host's toolchain would default to, so the probe measures the state the -engine actually produced (32 bits, with the flag) rather than the host's -leak. The "freestanding skips the wchar flag" rule the wave's earlier -versions carried was an unverified assumption about the toolchain default, -and the wave's measurement is what verified it wrong. +**The probe's command line selects the target, and for a freestanding target +it once did not (mcpp 2026.9.20.1).** Clang is one binary that emits every +target it was built with, so a command line naming no target answers for the +machine it runs on. A hosted cross target reaches the probe through +`--target=`; a freestanding target's selection travels with the ISA flags that +must accompany it, in the freestanding compile prefix, and the probe was never +given it. Every freestanding build therefore verified its declaration against +the build host: on a Linux host that host happens to satisfy `__unix__` +defined, `_WIN32` undefined and a 32-bit `wchar_t`, so the check passed for +the wrong reason; on a Windows host it reported `_WIN32` defined and a 16-bit +`wchar_t` and the build failed. + +2026.9.18.3 read that Windows failure as the `--target=` substitution failing +to strip host predefines, and added a `hostStripMacros` list of `-U` +tokens the caller prepended to the `-dM` command. Clang's predefines follow +the target rather than the host — `--target=riscv64-none-elf` reports +`__riscv`, no `__linux__`, and `__SIZEOF_WCHAR_T__` 4 on a Linux host — so +there was no substitution to fail. That list is removed, and its removal is +the point rather than a tidy-up: it deleted the one piece of evidence that +said the probe was measuring the wrong machine. + +The assembly refuses the omission instead of each call site remembering not to +make it. `cenv_probe::assemble_argv` returns a refusal when a freestanding +target's argv selects no target, and accepts an argv with none for a native +hosted build, where the host IS the target and the absence is the decision +rather than its omission. + +The matching defect on `__SIZEOF_WCHAR_T__` remains closed on the realisation +side, and independently of the above: `cenv::realise` always emits +`-fno-short-wchar` for `decl.wcharBits = 32` regardless of what a toolchain +would default to, so the compile produces the width the declaration states +rather than inheriting one. + +**Which interfaces of a layer a package provides, and which it requires +(mcpp 2026.9.20.1).** A capability is present or absent, and a package needs to +be able to state what it needs before it is built. openkal's own specification +(0.14 §3.3) withdrew the one name it had given to a SET of interfaces +(`hosted`) on the grounds that a name describing a class of environment is +falsified by an environment nobody had in mind, and replaced it with +enumeration by the consumer, in the consumer's own package. mcpp carries that +enumeration for the `kernel-abi` layer: + +```toml +# the implementation +[package] +provides = ["mcpp:kernel-abi=openkal"] + +[kernel-abi] +provides-interfaces = ["openkal.abort", "openkal.stream", "openkal.memory", + "openkal.env", "openkal.time", "openkal.fs"] + +# a consumer +[kernel-abi] +requires-interfaces = ["openkal.fs", "openkal.net"] +``` + +`provides-interfaces` may be stated only by a package that provides the layer, +the rule `[c-abi]` follows and for the same reason. `requires-interfaces` has +no such restriction: it is a statement about the package making it. + +**The engine knows no member of either set.** The only operation performed on +them is a set difference, so a specification may add an interface without a +release of mcpp, and a misspelling produces a refusal naming the string rather +than a silently disabled check — a name that is not provided is missing +whether or not it exists. + +**The question is answered at dependency resolution because that is the +earliest time it can be answered.** openkal SPEC 0.14 §6.2 tabulates three +times at which information about a capability becomes available and states +that each is the earliest at which it exists: resolution answers "may this +program be built against this implementation", the link answers "was an +interface used that the implementation does not provide", a property word +answers "how does it behave within an interface it provides". Source asking +the same question with `#ifdef` asks it during preprocessing, which is earlier +than any answer exists. + +**A provider that states nothing is not a provider that provides nothing.** +A graph whose implementation carries no `provides-interfaces` builds +unchanged; the key postdates the packages, and the link still reports an +absence in the vocabulary it always did. + +**What a C library does not supply (`[c-abi.absent]`, mcpp 2026.9.20.1).** +The set of names a C library supplies is not enumerable in a manifest — POSIX +has about twelve hundred — and enumerating it is the mistake §3.3 records +withdrawing. The exceptions are enumerable: + +```toml +[c-abi.absent] +fork = { form = "link" } +mprotect = { form = "enosys", note = "openkal has no operation upon a mapping's protection" } +tcsetattr = { form = "accepted-no-effect", note = "the fields openkal does not name are not applied" } +``` + +`form` is required and closed. `link` is the shape openkal's own capability +model requires of an implementation (§6.1 calls a run-time report of +unsupportedness a defect); the other two are departures from it, and they are +named so that a departure is something that can be counted. mcpp reads the +list back when a link names one of the `link`-shaped entries, so +`undefined reference to 'fork'` arrives with the sentence that says whether it +is a defect or a limit of the environment this program was built for. **Fingerprint.** The realised environment participates in the build's fingerprint (`compileFlags`, §92's field 7): two builds whose C library diff --git a/docs/zh/22-target-side.md b/docs/zh/22-target-side.md index 9d4dd872..5d972398 100644 --- a/docs/zh/22-target-side.md +++ b/docs/zh/22-target-side.md @@ -280,6 +280,15 @@ builtins = "iso" # iso | platform(默认 platform) 未知的键或未知的取值永远是解析错误,并点名该键——绝不静默忽略。**不声明 `[c-abi]` 块的包 不改变任何东西**:解析出的目标侧、每条编译命令、每个缓存键,都与这项能力出现之前逐字节相同。 +**`presents` 冻结在三个取值,而且它不回答任何关于能力的问题。** 它陈述的是源码看到哪些 +环境身份宏。它不陈述任何接口、任何头文件、任何路径是否可用;由它推断出这些的包,读到了 +一个它没有被给予的事实——Windows 目标上呈现 POSIX 的环境没有 `fork`、没有 `/proc`、 +没有 `epoll`,而它陈述这件事的方式是寻常的那一种:定义在链接期缺席。取值集不增长,理由 +与 openkal 规范为自己的核心集封闭所给的相同(SPEC 0.14 §3.2):一个描述环境**类别**的名字, +会被没有人想到过的那个环境证伪,而 openkal 把自己发过的唯一一个这样的名字(`hosted`) +在一个发布周期之内撤回了。在这里加第四个取值就是再造一次那个名字。一个包要知道某项能力 +在不在,答案在它最早存在的地方回答——依赖解析,由下文的接口枚举给出。 + 三件事互不推导,这是刻意保持分开的:`presents` 决定源码走哪条分支,`data-model`/`wchar` 决定 ABI。POSIX 不蕴含 LP64(32 位架构上是 ILP32),LP64 也不蕴含 POSIX。 @@ -388,18 +397,79 @@ c-environment = "platform" 同时打印声明值与实测值。结果按配置(编译器二进制身份 + 最终参数)缓存,同一配置解析两次只 编译一次探针。 -**主机污染(mcpp 2026.9.18.3+)。** 探针跑在**构建主机**的 clang 上,而不是目标本地的; -Windows 主机的驱动会预先定义 `_WIN32`、`_WIN64`、`__MINGW32__`、`__MINGW64__`,这些定义会 -穿透 `--target=` 替换到达 freestanding 目标——而 hosted 三元组的 `--target=` 替换是不会 -让 `_WIN32`/`_WIN64` 漏出来的(那是 Cygwin 那一行已经处理过的事情)。这是 hosted 与 -freestanding 替换之间一个结构性差异,核对步骤的测量必须为此做补偿。探针接受一个 -`hostStripMacros` 参数,即一组 `-U` 令牌,它在 `-dM` 之前前置;调用方(本层的 -`prepare`)在 Windows 主机上恰好传那四个名字,Linux/macOS 主机上传空集合。`__SIZEOF_WCHAR_T__` -那一半(Windows 主机 × freestanding 测出 2,而 `wchar = 32` 的声明要求 4)是在实现侧关的, -不是探针侧:`cenv::realise` 现在对 `decl.wcharBits = 32` **一律**发 `-fno-short-wchar`—— -不论宿主工具链的默认值是什么——这样探针测量到的就是引擎实际产出的状态(32 位、令牌在), -而不是主机的泄漏。早先版本里「freestanding 跳过 wchar 令牌」那条规则是一个未实测的关于 -工具链默认值的假设,本轮的测量把它证伪了。 +**探针的命令行必须选中目标,而在 freestanding 上它一度没有(mcpp 2026.9.20.1)。** +clang 是一个二进制,它能产出被编译进去的每一个目标,因此一条不点名目标的命令行回答的是 +它自己所在的那台机器。宿主交叉目标经 `--target=` 到达探针;freestanding 目标的目标选择 +与必须随行的 ISA 标志一起,走 freestanding 编译前缀那条路,而探针从未拿到它。于是每一次 +freestanding 构建都是拿宿主去核对声明:在 Linux 宿主上,那台宿主恰好满足 `__unix__` 已 +定义、`_WIN32` 未定义、`wchar_t` 32 位,于是检查以错误的理由通过;在 Windows 宿主上它报 +`_WIN32` 已定义、`wchar_t` 16 位,构建失败。 + +2026.9.18.3 把那次 Windows 失败读成"`--target=` 替换没能剥掉宿主预定义",于是加了一个 +`hostStripMacros` 列表,由调用方在 `-dM` 之前前置一组 `-U`。clang 的预定义跟随的是 +目标而不是宿主——在 Linux 宿主上 `--target=riscv64-none-elf` 报 `__riscv`、不报 `__linux__`、 +`__SIZEOF_WCHAR_T__` 为 4——所以根本没有一次替换失败过。那个列表被删除,而删除本身就是 +要点而非顺手整理:它抹掉的正是"探针量错了机器"这件事的唯一证据。 + +装配处拒绝这次遗漏,而不是每个调用点各自记得不要犯。`cenv_probe::assemble_argv` 在一个 +freestanding 目标的 argv 没有选中目标时返回拒绝;而对宿主本地构建接受没有目标选择的 +argv——那里宿主**就是**目标,缺席是那个决定本身,不是它的遗漏。 + +`__SIZEOF_WCHAR_T__` 那一处配套缺陷仍然关闭在实现侧,并且与上述无关:`cenv::realise` +对 `decl.wcharBits = 32` 一律发 `-fno-short-wchar`,不论某个工具链的默认值是什么,于是 +编译产出的宽度是声明所述的那个,而不是继承来的。 + +**一个包提供了该层的哪些接口,又需要哪些(mcpp 2026.9.20.1)。** 一项能力要么在要么不在, +而一个包需要能够在被构建之前陈述它需要什么。openkal 自己的规范(0.14 §3.3)撤回了它给 +接口**集合**起过的唯一一个名字(`hosted`),理由是:一个描述环境类别的名字,会被没有人 +想到过的那个环境证伪;替代做法是由消费者在自己的包里逐条列举。mcpp 为 `kernel-abi` 层 +承载这份列举: + +```toml +# 实现方 +[package] +provides = ["mcpp:kernel-abi=openkal"] + +[kernel-abi] +provides-interfaces = ["openkal.abort", "openkal.stream", "openkal.memory", + "openkal.env", "openkal.time", "openkal.fs"] + +# 消费方 +[kernel-abi] +requires-interfaces = ["openkal.fs", "openkal.net"] +``` + +`provides-interfaces` 只有提供该层的包可以陈述,这是 `[c-abi]` 遵循的同一条规则,理由 +相同。`requires-interfaces` 没有这个限制:它是关于陈述它的那个包自身的一句话。 + +**引擎不认识这两个集合的任何一个成员。** 对它们做的唯一操作是集合差,因此某个规范新增 +一个接口不需要 mcpp 发版;而拼错会产出一条点名该字符串的拒绝,而不是一次被静默关掉的 +检查——一个没有被提供的名字就是缺的,不论它是否存在。 + +**这个问题在依赖解析时回答,因为那是它最早能被回答的时刻。** openkal SPEC 0.14 §6.2 +列出能力信息出现的三个时刻,并规定每个都是该信息最早能存在的时刻:解析回答"这个程序 +可不可以在这个实现上构建",链接回答"有没有用到实现没提供的接口",能力字回答"在它提供 +的接口内它如何表现"。源码用 `#ifdef` 问同一个问题,问在预处理期——比任何答案存在得更早。 + +**一个什么都没陈述的提供者,不是一个什么都不提供的提供者。** 实现方没有写 +`provides-interfaces` 的图照常构建;这个键晚于那些包出现,而链接仍以它一贯的词汇报告缺席。 + +**一个 C 库不供给什么(`[c-abi.absent]`,mcpp 2026.9.20.1)。** 一个 C 库供给的名字集合 +在清单里不可枚举——POSIX 约有一千二百个——而枚举它正是 §3.3 记录下撤回的那个错误。 +例外是可枚举的: + +```toml +[c-abi.absent] +fork = { form = "link" } +mprotect = { form = "enosys", note = "openkal 没有作用于映射保护属性的操作" } +tcsetattr = { form = "accepted-no-effect", note = "openkal 不命名的那些字段不被施加" } +``` + +`form` 必填且取值封闭。`link` 是 openkal 自己的能力模型对一个实现所要求的形状(§6.1 把 +运行期报告不支持称为缺陷);另外两个是对它的偏离,给它们命名是为了让一次偏离成为可以被 +数出来的东西。当链接点到 `link` 形状里的某一项时,mcpp 把这份清单读回来,于是 +`undefined reference to 'fork'` 会带着那句说明它是缺陷还是这个程序所构建环境之限制的话 +一起到达。 **指纹。** 解析出的环境参与构建指纹(`compileFlags`,§92 的第 7 项):C 库声明 `lp64` 与 `llp64` 的两次构建,从同一份源码编译出 `long` 宽度不同的目标文件,因此二者绝不共享输出目录, diff --git a/mcpp.toml b/mcpp.toml index 9f1ebcbd..cf90fc4c 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.9.18.3" +version = "2026.9.20.1" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/modules/manifest/src/targetside_model.cppm b/modules/manifest/src/targetside_model.cppm index df8e3081..3430f044 100644 --- a/modules/manifest/src/targetside_model.cppm +++ b/modules/manifest/src/targetside_model.cppm @@ -180,6 +180,49 @@ inline std::optional parse_c_abi_builtins(std::string_view v) { // omits one has said nothing about it, and "nothing" is not the same value as // any of the three closed sets could name; `builtins` alone defaults to // `platform` (today's behaviour), per §3.2.1. +// HOW A FACILITY THIS C LIBRARY DOES NOT SUPPLY REACHES THE PROGRAM THAT +// ASKS FOR IT (design 2026-09-20 §5.7.5). +// +// The set of POSIX names is not enumerable in a manifest — there are about +// twelve hundred — and enumerating it is the mistake openkal SPEC 0.14 §3.3 +// records withdrawing. The EXCEPTIONS are enumerable: openkal-musl's own +// README lists six. Stating them is what turns a paragraph of prose into +// something a CI run can contradict. +// +// `link` is the shape openkal's own capability model requires of an +// implementation (§6.1: "a conforming implementation shall not provide an +// interface whose operations report a lack of support at run time; the +// specification treats run-time refusal as a defect"). The other two are +// departures from it, and naming them is the point: a departure that has a +// name is a departure somebody can count. +enum class CAbiAbsentForm { + Link, // the definition is absent; the program fails to link + Enosys, // the definition exists and reports that it cannot act + AcceptedNoEffect, // the call succeeds and part of what it asked for is not done +}; + +inline std::string_view c_abi_absent_form_name(CAbiAbsentForm f) { + switch (f) { + case CAbiAbsentForm::Link: return "link"; + case CAbiAbsentForm::Enosys: return "enosys"; + case CAbiAbsentForm::AcceptedNoEffect: return "accepted-no-effect"; + } + return "link"; +} + +inline std::optional parse_c_abi_absent_form(std::string_view v) { + if (v == "link") return CAbiAbsentForm::Link; + if (v == "enosys") return CAbiAbsentForm::Enosys; + if (v == "accepted-no-effect") return CAbiAbsentForm::AcceptedNoEffect; + return std::nullopt; +} + +struct CAbiAbsentEntry { + std::string name; // the facility, spelled as the program spells it + CAbiAbsentForm form = CAbiAbsentForm::Link; + std::string note; // why, in one sentence; carried into the diagnostic +}; + struct CAbiDecl { bool declared = false; // was `[c-abi]` present at all bool hasPresents = false; @@ -189,6 +232,11 @@ struct CAbiDecl { CAbiDataModel dataModel = CAbiDataModel::ArchDefault; int wcharBits = 0; // 16 or 32 CAbiBuiltins builtins = CAbiBuiltins::Platform; + // `[c-abi.absent]` — the facilities this C library does not supply, and + // the shape in which each absence reaches a program. Empty is the + // ordinary case and says nothing: a library that lists none has not + // claimed to supply everything, it has declined to enumerate. + std::vector absent; }; @@ -435,6 +483,38 @@ struct Requirement { std::string interfaceName; // what that layer must resolve to }; +// ── Interface enumeration: the resolution-time half of the capability model ── +// +// openkal SPEC 0.14 §3.3 withdrew the one name it had given to a SET of +// interfaces (`hosted`) and recorded why: "a name that describes a class of +// environment is falsified by an environment nobody had in mind", and that one +// was falsified inside its own ecosystem within a release. What replaced it is +// enumeration — "a consumer that needs five names five ... in its own package, +// which is where a convention among consumers belongs". +// +// This function is the whole of the engine's participation in that: a set +// difference over opaque strings. The names belong to whichever specification +// owns the layer; adding one to that specification requires no release of this +// engine, and misspelling one here produces a refusal naming the string rather +// than a silently disabled check, because a name that is not provided is +// missing whether or not it exists. +// +// WHY A REFUSAL AND NOT A WARNING. §6.2 places this question at dependency +// resolution because that is the earliest time it can be answered. A build +// allowed to proceed would reach the same answer at the link, in a diagnostic +// naming an undefined symbol rather than the interface and the package that +// asked for it — later, and in a vocabulary the package author did not write. +inline std::vector interfaces_not_provided( + const std::vector& required, + const std::vector& provided) { + std::vector missing; + for (auto const& r : required) + if (std::ranges::find(provided, r) == provided.end()) + missing.push_back(r); + return missing; +} + + // ── Resolver input ─────────────────────────────────────────────────────────── // // Plain data, assembled by the caller after dependency resolution. Keeping the diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index 369acc8e..dd70391a 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -1077,13 +1077,73 @@ std::expected parse_string(std::string_view content, mcpp::targetside::CAbiDecl decl; decl.declared = true; static constexpr std::string_view kKnownCAbiKeys[] = { - "builtins", "data-model", "presents", "wchar", + "absent", "builtins", "data-model", "presents", "wchar", }; for (auto& [key, _] : *ct) { if (std::ranges::find(kKnownCAbiKeys, key) == std::end(kKnownCAbiKeys)) return std::unexpected(error(origin, std::format( - "[c-abi] has no member '{}'; the members are: builtins, " - "data-model, presents, wchar", key))); + "[c-abi] has no member '{}'; the members are: absent, " + "builtins, data-model, presents, wchar", key))); + } + // [c-abi.absent] — the facilities this C library does not supply + // (design 2026-09-20 §5.7.5). The set of names it DOES supply is not + // enumerable in a manifest; the exceptions are, and enumerating an + // exception is what lets a CI run contradict it. + // + // [c-abi.absent] + // fork = { form = "link" } + // mprotect = { form = "enosys", note = "openkal has no operation + // upon a mapping's protection" } + // + // `form` is required and closed: a facility that is absent in an + // unnamed shape is one nobody can assert against. `link` is the shape + // openkal's own model requires (SPEC 0.14 §6.1, which calls a + // run-time report of unsupportedness a defect); the other two are + // departures from it, and they are named so that a departure is + // something somebody can count. + if (auto ait = ct->find("absent"); ait != ct->end()) { + if (!ait->second.is_table()) + return std::unexpected(error(origin, + "[c-abi.absent] must be a table of facility names, each " + "with a `form`: fork = { form = \"link\" }")); + for (auto const& [name, entry] : ait->second.as_table()) { + if (!entry.is_table()) + return std::unexpected(error(origin, std::format( + "[c-abi.absent].{} must be a table with a `form`: " + "{} = {{ form = \"link\" }}", name, name))); + mcpp::targetside::CAbiAbsentEntry e; + e.name = name; + auto const& et = entry.as_table(); + for (auto const& [k, _] : et) + if (k != "form" && k != "note") + return std::unexpected(error(origin, std::format( + "[c-abi.absent].{} has no member '{}'; the " + "members are: form, note", name, k))); + auto fit = et.find("form"); + if (fit == et.end() || !fit->second.is_string()) + return std::unexpected(error(origin, std::format( + "[c-abi.absent].{} is missing `form`. An absence " + "with no named shape is one nothing can assert " + "against; the shapes are \"link\" (the definition is " + "absent), \"enosys\" (it exists and reports that it " + "cannot act) and \"accepted-no-effect\" (the call " + "succeeds and part of what it asked for is not " + "done).", name))); + auto form = mcpp::targetside::parse_c_abi_absent_form( + fit->second.as_string()); + if (!form) + return std::unexpected(error(origin, std::format( + "[c-abi.absent].{}.form = \"{}\" names no known " + "shape. The shapes are \"link\", \"enosys\" and " + "\"accepted-no-effect\".", + name, fit->second.as_string()))); + e.form = *form; + if (auto nit = et.find("note"); + nit != et.end() && nit->second.is_string()) + e.note = nit->second.as_string(); + decl.absent.push_back(std::move(e)); + } + std::ranges::sort(decl.absent, {}, &mcpp::targetside::CAbiAbsentEntry::name); } if (auto pit = ct->find("presents"); pit != ct->end()) { if (!pit->second.is_string()) @@ -1147,6 +1207,56 @@ std::expected parse_string(std::string_view content, } m.cAbiDecl = decl; } + // [kernel-abi] — which interfaces of that layer a package provides or + // requires (design 2026-09-20 §5.5). A TOP-LEVEL table for the same + // reason `[c-abi]` is one. + // + // THE ENGINE LEARNS NO INTERFACE NAME HERE. Both lists are opaque + // strings; the only operation performed on them is a set difference at + // dependency resolution, so a specification may add an interface without + // a release of this engine. That is the same discipline + // `mcpp.toolchain.cenv` follows for the request-to-flags table: generic + // knowledge, no product names. + // + // `provides-interfaces` may be stated only by a package that provides the + // layer — a package that does not supply it is stating a fact about + // something it does not have, exactly as with `[c-abi]`. + // `requires-interfaces` has no such restriction: it is a statement about + // the package making it, and every consumer is entitled to make it. + if (auto* kt = doc->get_table("kernel-abi")) { + static constexpr std::string_view kKnownKernelAbiKeys[] = { + "provides-interfaces", "requires-interfaces", + }; + for (auto& [key, _] : *kt) { + if (std::ranges::find(kKnownKernelAbiKeys, key) + == std::end(kKnownKernelAbiKeys)) + return std::unexpected(error(origin, std::format( + "[kernel-abi] has no member '{}'; the members are: " + "provides-interfaces, requires-interfaces", key))); + } + if (auto v = doc->get_string_array("kernel-abi.provides-interfaces")) { + bool providesKernelAbi = std::ranges::any_of(m.provides, [](auto const& e) { + auto cap = mcpp::targetside::parse_capability(e); + return cap && *cap + && (*cap)->layer == mcpp::targetside::CapLayer::KernelAbi; + }); + if (!providesKernelAbi) + return std::unexpected(error(origin, + "[kernel-abi] provides-interfaces is declared, and " + "[package] provides does not list " + "`mcpp:kernel-abi=`.\n" + " Only the package that supplies the layer may state " + "which of its interfaces are present — this package is " + "stating a fact about a layer it does not provide.\n" + " Add `mcpp:kernel-abi=` to [package] provides, " + "or state `requires-interfaces` instead, which is what a " + "consumer says.")); + m.kernelAbiProvidesInterfaces = *v; + } + if (auto v = doc->get_string_array("kernel-abi.requires-interfaces")) + m.kernelAbiRequiresInterfaces = *v; + } + // [package] c-environment = "platform" — see Manifest::cEnvironment. if (auto v = doc->get_string("package.c-environment")) { if (*v != "platform") diff --git a/modules/manifest/src/types.cppm b/modules/manifest/src/types.cppm index 538fd7ee..79767567 100644 --- a/modules/manifest/src/types.cppm +++ b/modules/manifest/src/types.cppm @@ -1839,6 +1839,30 @@ struct Manifest { // manifest carrying a value here is one this engine has already confirmed // the right to state it. std::optional cAbiDecl; + // [kernel-abi] — the interfaces a package PROVIDES or REQUIRES, named in + // the vocabulary of the specification that owns the layer and never in + // this engine's (design 2026-09-20 §5.5). Both lists are opaque strings + // here: the only operation performed on them is a set difference, so a + // specification may add an interface without this engine learning its + // name. + // + // WHY THE ANSWER BELONGS AT RESOLUTION AND NOT IN THE PREPROCESSOR. + // openkal SPEC 0.14 §6.2 tabulates three times at which information about + // a capability becomes available and states that each is the EARLIEST at + // which it exists: dependency resolution answers "may this program be + // built against this implementation", the link answers "was an interface + // used that the implementation does not provide", and a property word + // answers "how does it behave within an interface it provides". A source + // file asking `#ifdef` asks the first question during preprocessing, + // which is earlier than the answer exists; that is why every macro-shaped + // answer to it has had to be replaced by the next one. + // + // `provides-interfaces` may be stated only by a package that provides the + // layer, the same rule `[c-abi]` follows and for the same reason. + // `requires-interfaces` may be stated by anyone: it is a statement about + // the package making it. + std::vector kernelAbiProvidesInterfaces; + std::vector kernelAbiRequiresInterfaces; // [package] c-environment = "platform" — this package's own translation // units compile in the triple's OWN default environment even when the // graph's `c-abi` declares another (design §3.4). Empty = no override, the diff --git a/modules/versioning/src/version.cppm b/modules/versioning/src/version.cppm index a5e41e16..11c5c6aa 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.18.3"; +inline constexpr std::string_view MCPP_VERSION = "2026.9.20.1"; } // namespace mcpp diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index f07338c1..fc9702c3 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -21,6 +21,7 @@ import std; import mcpp.freestanding.linkline; import mcpp.build.backend; import mcpp.manifest; +import mcpp.targetside; import mcpp.source_kind; import mcpp.build.distribution; import mcpp.build.graph_shape; @@ -97,6 +98,19 @@ 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); +// The C library said in its manifest which facilities it does not supply, and +// the link has just named one of them (design 2026-09-20 §5.7.5). Both +// linkers' spellings, as `link_failure_advice` above. +// +// WHY THIS IS WORTH A NOTE RATHER THAN LEFT TO THE LINKER. `undefined +// reference to 'fork'` is a true statement and a useless one: it says a symbol +// is missing without saying whether that is a defect, a missing dependency or +// a deliberate limit of the environment this program was built for. The +// manifest already distinguishes those; this reads it back. +std::string c_abi_absent_facility_advice( + std::string_view output, std::string_view cAbiName, + const std::vector& absent); + // 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 @@ -672,6 +686,38 @@ std::string link_failure_advice(std::string_view output) { " `std::align_val_t`, which are the ones most often forgotten.\n"; } +std::string c_abi_absent_facility_advice( + std::string_view output, std::string_view cAbiName, + const std::vector& absent) { + if (absent.empty()) return {}; + + std::string named; + for (auto const& e : absent) { + // Only the `link` shape can appear here at all: the other two reach a + // program at run time by construction, so matching them against a + // link diagnostic would report a coincidence of spelling. + if (e.form != mcpp::targetside::CAbiAbsentForm::Link) continue; + const std::string lld = std::format("undefined symbol: {}", e.name); + const std::string gnu = std::format("undefined reference to `{}'", e.name); + if (output.find(lld) == std::string_view::npos + && output.find(gnu) == std::string_view::npos) continue; + named += std::format("\n {:<20} {}", e.name, + e.note.empty() ? "declared absent" : e.note); + } + if (named.empty()) return {}; + + return std::format( + "\n" + "note: the C library in this graph{}{} declares that it does not " + "supply the following, and the link has just asked for it:{}\n" + " An absence stated in the manifest is a property of the " + "environment this program was built for, not a defect in the build. " + "A program that needs one of these needs a different C environment " + "for this target.\n", + cAbiName.empty() ? "" : " (", cAbiName.empty() ? "" : cAbiName, + named); +} + std::string graph_c_library_isolation_advice(std::string_view output, std::string_view cAbiName, std::string_view cAbiCoordinate) { @@ -3398,6 +3444,10 @@ std::expected NinjaBackend::build(const BuildPlan& plan // and gets the degraded-but-still-correct form. diagnostics += graph_c_library_isolation_advice( out, plan.targetSide.cAbi.interfaceName, plan.targetSide.cAbi.impl); + if (plan.targetSide.cAbiDecl) + diagnostics += c_abi_absent_facility_advice( + out, plan.targetSide.cAbi.interfaceName, + plan.targetSide.cAbiDecl->absent); 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 ec8986e1..0ef586d0 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -10853,82 +10853,67 @@ prepare_build(bool print_fingerprint, // is what makes this probe cheap AND cacheable across every // package that shares this build's target side. // - // `hostStripMacros` — Windows-host leak through `--target=` - // substitution. A freestanding cross compile (`--target= - // riscv64-none-elf`) on a Windows host still sees `_WIN32` (and - // the `__MINGW*__` family) in the preprocessor output: those - // are the HOST driver's predefines, and unlike the `__APPLE__` / - // `__linux__` family on Linux/macOS, the `--target=` substitution - // does NOT strip them for the freestanding target. Without the - // strip, the probe's `-dM` dump records `_WIN32` defined - // regardless of what the realised `[c-abi] presents = "posix"` - // asked for, and the probe fails with a mismatch that is a - // property of the host's driver, not of the declaration being - // checked. The strip is added ONLY when the host is Windows; - // Linux and macOS drivers' defaults do not contaminate - // `--target=` substitutions in the same way, and adding `-U` - // tokens there would have to be defended as harmless rather - // than measured (the wave's measurement caught exactly four - // host-side leaks — listed below — and adding to that set is - // the right way to extend the strip; speculatively unstripping - // everywhere is not). - // - // `hostStripFlags` — host-side wchar leakage on Windows × - // freestanding. The probe runs `-E -dM -x c++` with no source - // unit and no include path; the `__SIZEOF_WCHAR_T__` it reads - // comes from the toolchain's own defaults, which clang on a - // Windows host sets from `` (16 bits) even with - // `--target=riscv64-none-elf`. The realisation closes this by - // ALWAYS emitting `-fno-short-wchar` for `decl.wcharBits = 32` - // (`mcpp.toolchain.cenv`'s wchar branch, just rewritten — the - // old "freestanding skips the flag" rule was an unverified - // assumption that the wave's measurement caught as false). So - // when the host is Windows AND the target is freestanding, the - // host-strip set above AND `-ffreestanding` are both needed — - // the former for the preprocessor predefines, the latter so the - // driver does not pick up the Windows CRT's `` even - // when the build's own include path doesn't carry one. Outside - // that combination the strip is unnecessary: Linux/macOS hosts - // do not leak `_WIN32` through `--target=`, and the wchar fix - // lives in the realisation, not the probe. - std::vector hostStripMacros; - std::vector hostStripFlags; - if (mcpp::platform::is_windows) { - // The four names measured as leaking through `--target=` on - // a Windows host (2026-09-18, openkal-llvm-runtime#24, - // windows-host × riscv64-none-elf). Adding to this set is - // a measurement-driven change, not a guess; pin new entries - // here with the failing build that named them. - hostStripMacros = { - "-U_WIN32", - "-U_WIN64", - "-U__MINGW32__", - "-U__MINGW64__", - }; - if (tt && tt->is_freestanding()) - // Windows host's driver would otherwise pull in - // MinGW's `` even with no source unit, so the - // wchar probe sees 2 instead of the declared 32. - // `-ffreestanding` is what every real compile on a - // freestanding target already adds (`openkal-musl`'s - // `cflags`, mcpp's own freestanding handling). - hostStripFlags.push_back("-ffreestanding"); - } if (tc->cEnvExpectWcharBits != 0 || tc->cEnvExpectLongBytes != 0 || !tc->cEnvExpectDefined.empty() || !tc->cEnvExpectUndefined.empty()) { - std::vector probeArgv; - if (!tc->crossTargetFlag.empty()) - probeArgv.push_back(tc->crossTargetFlag); - for (auto& t : tc->cEnvTokens) probeArgv.push_back(t); - for (auto& t : tc->cEnvBuiltinsTokens) probeArgv.push_back(t); - for (auto& t : hostStripFlags) probeArgv.push_back(t); + // THE FREESTANDING TARGET HAD NEVER REACHED THE PROBE, AND + // THAT IS WHY 2026.9.18.3 MEASURED THE HOST (mcpp#674 + // review, 2026-09-20). `Toolchain::crossTargetFlag` is set + // for a HOSTED target only — the assignment above states the + // reason: a freestanding target carries its own `--target` + // together with the ISA flags that must accompany it, and a + // second one there would be the same decision in two places. + // That other place is `mcpp.freestanding.linkline`, which the + // real compile goes through and this probe did not. + // `cenv::realise` adds no `--target` for a freestanding + // target either, so the probe ran with NO target selection at + // all and clang answered for the machine it was running on. + // + // Measured: the probe's own argv shape on a Linux host + // (`-D__unix__ -fno-short-wchar -ffreestanding -x c++ -E -dM + // -`) reports `__linux__`; with `--target=riscv64-none-elf` + // it reports `__riscv`, no `__linux__`, and + // `__SIZEOF_WCHAR_T__` 4. A Windows host answered `_WIN32` + // and 2 for the same reason, and 2026.9.18.3 read that as the + // `--target=` substitution failing to strip host predefines. + // Clang's predefines follow the target; there was no + // substitution to fail. + // + // `hostStripMacros` is therefore GONE, and its removal is the + // point rather than a tidy-up: `-U_WIN32 -U_WIN64 + // -U__MINGW32__ -U__MINGW64__` deleted the one piece of + // evidence that said the probe was measuring the wrong + // machine. A future host leak, if one exists, must reach the + // mismatch report rather than be undefined before it can. + // + // THE ASSEMBLY REFUSES THE OMISSION rather than this site + // remembering not to make it — `cenv_probe::assemble_argv` + // holds the invariant, and the unit tests reach it without a + // cross toolchain. + std::vector freestandingFlags; + if (tt && tt->is_freestanding()) { + auto spec = mcpp::freestanding::resolve(*tt); + if (spec) { + freestandingFlags.push_back( + "--target=" + std::string(spec->triple)); + for (auto const& f : mcpp::freestanding::compile_flags(*spec)) + freestandingFlags.push_back(f); + } + } + auto assembled = mcpp::toolchain::cenv_probe::assemble_argv( + tc->crossTargetFlag, freestandingFlags, + tc->cEnvTokens, tc->cEnvBuiltinsTokens, + tt && tt->is_freestanding(), tc->targetTriple); + if (!assembled) { + refusal::record(refusal::Code::CEnvUnrealisable); + return std::unexpected(assembled.error()); + } + const auto& probeArgv = *assembled; auto probe = mcpp::toolchain::cenv_probe::verify( tc->binaryPath, probeArgv, tc->cEnvExpectWcharBits, tc->cEnvExpectLongBytes, tc->cEnvExpectDefined, tc->cEnvExpectUndefined, - mcpp::home::cache_root(), - hostStripMacros); + mcpp::home::cache_root()); if (!probe) { refusal::record(refusal::Code::CEnvUnrealisable); return std::unexpected(probe.error()); @@ -11169,6 +11154,70 @@ prepare_build(bool print_fingerprint, } } + // INTERFACE ENUMERATION — THE RESOLUTION-TIME HALF OF THE CAPABILITY + // MODEL (design 2026-09-20 §5.5; openkal SPEC 0.14 §3.3, §6.2). + // + // A package states which interfaces of the `kernel-abi` layer it uses; + // the package that supplies the layer states which it provides. This + // engine compares the two sets and knows no member of either: the + // names belong to the specification that owns the layer, and one may + // be added to it without a release of this engine. + // + // THE QUESTION IS ANSWERED HERE BECAUSE HERE IS WHERE THE ANSWER FIRST + // EXISTS. §6.2 tabulates three times and states that each is the + // earliest at which its information exists; "may this program be built + // against this implementation" is the first of them. Source asking the + // same question with `#ifdef` asks it during preprocessing, earlier + // than any answer, which is why each macro-shaped answer to it has had + // to be replaced by the next one. + // + // SILENT WHEN NOTHING DECLARES ANYTHING. A graph in which no package + // writes `[kernel-abi]` reaches neither loop below, so this addition + // changes no command line and no diagnostic for every project built + // before it. + { + std::vector providedInterfaces; + std::string providerId; + for (auto& pkg : packages) { + if (pkg.manifest.kernelAbiProvidesInterfaces.empty()) continue; + providedInterfaces = pkg.manifest.kernelAbiProvidesInterfaces; + providerId = pkg.manifest.package.name; + break; + } + for (auto& pkg : packages) { + const auto& need = pkg.manifest.kernelAbiRequiresInterfaces; + if (need.empty()) continue; + // A consumer that names interfaces while no package in the + // graph states what it provides is not refused: the provider + // predates this key, and a graph that has not yet adopted it + // must keep building. The link still reports the absence, in + // the vocabulary it always did. + if (providerId.empty()) continue; + auto missing = mcpp::targetside::interfaces_not_provided( + need, providedInterfaces); + if (missing.empty()) continue; + refusal::record(refusal::Code::InterfaceNotProvided); + std::string names; + for (auto const& mI : missing) { + names += "\n "; + names += mI; + } + return std::unexpected(std::format( + "'{}' requires interfaces the resolved implementation does " + "not provide.{}\n" + " provided by {} ({} interface{})\n" + " This is refused before anything is compiled " + "because dependency resolution is the earliest time the " + "question can be answered. Select an implementation that " + "provides them, or remove them from [kernel-abi] " + "requires-interfaces in '{}'.", + pkg.manifest.package.name, names, providerId, + providedInterfaces.size(), + providedInterfaces.size() == 1 ? "" : "s", + pkg.manifest.package.name)); + } + } + if (auto why = tsd::check_layering(resolvedTargetSide)) { refusal::record(refusal::Code::LayerOrdering); return std::unexpected(*why); diff --git a/src/build/refusal.cppm b/src/build/refusal.cppm index 0b0bcd27..0a96cd53 100644 --- a/src/build/refusal.cppm +++ b/src/build/refusal.cppm @@ -139,6 +139,13 @@ enum class Code { // 而不是被信任"): `sizeof(long)`, `__SIZEOF_WCHAR_T__`, or the presence of // an environment-identity macro did not match what was declared. CEnvVerificationMismatch, + // A package's `[kernel-abi] requires-interfaces` names an interface the + // resolved provider's `provides-interfaces` does not list (design + // 2026-09-20 §5.5). Refused at dependency resolution because that is the + // earliest time the question can be answered (openkal SPEC 0.14 §6.2); + // the same build would otherwise reach the answer at the link, naming an + // undefined symbol rather than the interface and the package. + InterfaceNotProvided, // `[build] platform-dependencies = "refuse"` and a package in the graph // brings a platform SDK dependency (design §6). PlatformDependency, @@ -184,6 +191,7 @@ constexpr std::string_view name(Code c) { case Code::CEnvUnrealisable: return "c-env-unrealisable"; case Code::CEnvVerificationMismatch: return "c-env-verification-mismatch"; + case Code::InterfaceNotProvided: return "interface-not-provided"; case Code::PlatformDependency: return "platform-dependency"; case Code::Other: return "other"; } diff --git a/src/toolchain/cenv.cppm b/src/toolchain/cenv.cppm index 2a59bf9e..e60e5709 100644 --- a/src/toolchain/cenv.cppm +++ b/src/toolchain/cenv.cppm @@ -432,12 +432,25 @@ inline std::expected realise( // Windows targets NO ANALOGOUS CODE-GENERATION IDIOM FOUND. The // design's own Windows row (§3.2.1) is a PREPROCESSOR // assumption instead — clang's bundled `intrin.h` / - // `mm_malloc.h` expecting `__mingw_aligned_malloc` — - // and that is already closed by the EXISTING - // `-nostdlibinc` isolation (`mcpp.toolchain.hostflags`, - // #662/#664) whenever the graph supplies the C - // library, independently of `builtins`. This survey - // found no Windows loop-idiom builtin to disable. + // `mm_malloc.h` expecting `__mingw_aligned_malloc`. + // WHAT CLOSES IT IS THE IDENTITY SWITCH, NOT + // `-nostdlibinc`, AND AN EARLIER REVISION OF THIS + // NOTE SAID OTHERWISE. `-nostdlibinc` removes the + // standard SYSTEM include directories and leaves + // clang's own resource directory in place — that is + // what `-nobuiltininc` removes — so `` + // still resolves to clang's copy and still reaches + // its `#include_next `, measured with the + // flag present (`intrin.h:12:15`, character for + // character the diagnostic mcpp-index recorded for + // fmtlib.fmt). What closes both is the Cygwin- + // flavoured realisation instead: `mm_malloc.h:42` + // selects `__mingw_aligned_malloc` on `__MINGW32__` + // and falls to `posix_memalign` without it, and + // source reaching for `` does so behind + // `_WIN32`. The survey's conclusion is unchanged — + // no Windows loop-idiom builtin to disable — only + // the mechanism named beside it was wrong. // Linux targets NONE FOUND. glibc's own loop-idiom builtins // (`__memset_chk` and relatives) are FORTIFY_SOURCE // machinery that is off by default and unrelated to diff --git a/src/toolchain/cenv_probe.cppm b/src/toolchain/cenv_probe.cppm index 393c769b..a9fa0977 100644 --- a/src/toolchain/cenv_probe.cppm +++ b/src/toolchain/cenv_probe.cppm @@ -53,32 +53,24 @@ struct Result { // `expectUndefined` lists name macros the probe's `-dM` dump must and must // not contain. // -// `hostStripMacros` — PRECOMPILE `-U` TOKENS THE CALLER INSERTS INTO THE -// PROBE COMMAND BEFORE `-E -dM`. This is the only mechanism by which the -// probe is allowed to compensate for a HOST contamination that the target's -// own `--target=` does not neutralise; the four Windows-host names -// (`_WIN32`, `_WIN64`, `__MINGW32__`, `__MINGW64__`) are the case this was -// added for and the only set the build's own measurements have surfaced, but -// the parameter is a list because the design does not commit to this set -// being the final one — a future host whose compiler leaks a different macro -// can be handled the same way without further changes to this module. +// THE ARGV MUST SELECT THE TARGET, AND FOR A FREESTANDING TARGET IT ONCE DID +// NOT (mcpp#674 review, 2026-09-20). Clang is one binary that emits every +// target it was built with, so a command line without a `--target` answers for +// the machine it is running on — `mcpp.freestanding.linkline::compile_prefix` +// states the same fact for the real compile. A probe measuring the host +// reports the host's `_WIN32`, the host's `wchar_t` width and the host's +// `__linux__`, and a declaration checked against the host is not checked at +// all. The caller assembles the target selection; this module does not +// second-guess it, and it does not undefine anything the compiler reported. // -// Why this is in the probe and not in `mcpp.toolchain.cenv::realise`: the -// probe's job is to MEASURE what the compiler ACTUALLY does for the target, -// not to ask the compiler what it would do for the target if it were a -// clean cross compile. Host contamination is a defect of the measurement, not -// of the measurement's contract — design 2026-09-18 §3.2 names a declaration -// as "checked, not trusted", and the check has to read the macro state the -// compile would actually deliver to a real translation unit, not the state -// the compile would deliver to one already stripped of every fact the host -// carried. The strip happens before `-E -dM` so the dump reflects the -// stripped state; without it, a `present = "posix"` declaration would always -// "fail" on a Windows host because `_WIN32` is in the dump regardless of -// `--target=`. -// -// The cache key (below) folds `hostStripMacros` in alongside the rest of the -// argv, so two callers with the same compiler and argv but different strip -// sets do not share a slot. +// AN EARLIER REVISION TOOK A `hostStripMacros` PARAMETER AND IT HAS BEEN +// REMOVED. It prefixed `-U_WIN32 -U_WIN64 -U__MINGW32__ -U__MINGW64__` on a +// Windows host, to compensate for what was read as a `--target=` substitution +// failing to strip host predefines. The substitution had not failed; on a +// freestanding target there was no substitution in the argv at all. The strip +// deleted the one piece of evidence that would have said so, which is why it +// is gone rather than merely unused: a measurement that removes its own +// disagreement reports agreement it did not establish. // // A refusal here (as opposed to a non-empty `mismatches`) means the probe // itself could not run — the compiler rejected the command line, which is a @@ -86,19 +78,48 @@ struct Result { // caller reports it as a build error naming the command, not as a §3.2 // verification mismatch. // -// `hostStripMacros` is placed AFTER `cacheRoot` (the latter being the test -// suite's frequent override) to keep the existing call sites — which pass -// neither — source-compatible. New callers that DO need the strip supply -// both arguments; tests that pin a temp cache directory pass the strip -// empty by default. +// THE PROBE'S ARGV, ASSEMBLED IN ONE PLACE THAT REFUSES TO OMIT THE TARGET. +// +// This function exists because the omission it forbids actually happened and +// shipped: 2026.9.18.3's probe ran with no target selection on every +// freestanding build, and clang answered for the machine it was running on. +// The pieces are all the caller's, and each of them is legitimately empty in +// some configuration, so no single one of them could carry the invariant — +// which is exactly why the invariant belongs here rather than at the call +// site, where "a vector that happened to be empty" is indistinguishable from +// "a decision that was made". +// +// crossTargetFlag `--target=` for a HOSTED cross target; +// empty for a native build, and empty for every +// freestanding target (mcpp.build.prepare sets it for +// hosted targets only, because a freestanding target's +// `--target` comes with ISA flags that must accompany +// it and belongs in one place). +// freestandingFlags that other place, `mcpp.freestanding.linkline`'s +// compile prefix, tokenised. Empty for a hosted build. +// cEnvTokens what `cenv::realise` produced. May itself carry a +// `--target=` (the Cygwin-flavoured substitution). +// cEnvBuiltinsTokens `builtins = "iso"`. +// freestanding whether the build's target is freestanding. +// +// THE RULE. A freestanding target is never the host, so its argv must select +// a target; a hosted build whose target IS the host legitimately selects +// none, and there the absence is the decision rather than its omission. +std::expected, std::string> assemble_argv( + std::string_view crossTargetFlag, + const std::vector& freestandingFlags, + const std::vector& cEnvTokens, + const std::vector& cEnvBuiltinsTokens, + bool freestanding, + std::string_view targetTriple); + std::expected verify( const std::filesystem::path& compilerBin, const std::vector& argv, int expectWcharBits, int expectLongBytes, const std::vector& expectDefined, const std::vector& expectUndefined, - const std::filesystem::path& cacheRoot = mcpp::home::cache_root(), - const std::vector& hostStripMacros = {}); + const std::filesystem::path& cacheRoot = mcpp::home::cache_root()); } // namespace mcpp::toolchain::cenv_probe @@ -141,24 +162,52 @@ MacroDump parse_dm(std::string_view out) { } // namespace +std::expected, std::string> assemble_argv( + std::string_view crossTargetFlag, + const std::vector& freestandingFlags, + const std::vector& cEnvTokens, + const std::vector& cEnvBuiltinsTokens, + bool freestanding, + std::string_view targetTriple) { + + std::vector argv; + if (!crossTargetFlag.empty()) argv.emplace_back(crossTargetFlag); + for (auto const& f : freestandingFlags) argv.push_back(f); + for (auto const& t : cEnvTokens) argv.push_back(t); + for (auto const& t : cEnvBuiltinsTokens) argv.push_back(t); + + if (freestanding) { + const bool selects = std::any_of(argv.begin(), argv.end(), + [](std::string const& a) { return a.starts_with("--target="); }); + if (!selects) + return std::unexpected(std::format( + "the [c-abi] verification probe for '{}' would have run with " + "no target selection.\n" + " A freestanding target is never the build host, so a " + "command line that names no target answers for the host and " + "the declaration is compared against the wrong machine. The " + "target selection for a freestanding build comes from " + "mcpp.freestanding.linkline's compile prefix; this probe was " + "handed none.", + targetTriple)); + } + return argv; +} + std::expected verify( const std::filesystem::path& compilerBin, const std::vector& argv, int expectWcharBits, int expectLongBytes, const std::vector& expectDefined, const std::vector& expectUndefined, - const std::filesystem::path& cacheRoot, - const std::vector& hostStripMacros) { + const std::filesystem::path& cacheRoot) { // The cache key is the compiler binary's own identity plus every argv // token, in order — exactly the inputs that can change what `-dM` // prints. mcpp's own content hash (`hash_file`) would need to re-read // the binary on every build; the path plus its last-write time is the // same shortcut the toolchain probe elsewhere in this codebase already - // takes for "has this compiler changed". `hostStripMacros` is folded in - // for the same reason `argv` is: two callers that probe the same - // compiler + argv with different strip lists must not share a cache - // slot, because the dumps WILL differ. + // takes for "has this compiler changed". std::error_code ec; auto mtime = std::filesystem::last_write_time(compilerBin, ec); std::string keyInput = compilerBin.string(); @@ -166,7 +215,6 @@ std::expected verify( keyInput += std::to_string( static_cast(mtime.time_since_epoch().count())); for (auto& a : argv) { keyInput += '\x1f'; keyInput += a; } - for (auto& s : hostStripMacros) { keyInput += '\x1f'; keyInput += s; } const std::string key = mcpp::toolchain::hash_string(keyInput); const auto cacheDir = cacheRoot / "cenv-probe"; @@ -187,14 +235,7 @@ std::expected verify( // from standard input — `capture_stdout` gives the child an empty // one, argv-form, so no shell and no temp file are needed. // - // `hostStripMacros` go BEFORE `argv` so they strip host predefines - // before `--target=` (or any other token in argv) takes effect; an - // `-U` placed after `--target=` still strips the macro (clang - // processes `-U` in order), but keeping them in front makes the - // intent obvious in the recorded command and keeps the strip - // orthogonal to whatever the target-side configuration produces. std::vector cmd{ compilerBin.string() }; - cmd.insert(cmd.end(), hostStripMacros.begin(), hostStripMacros.end()); cmd.insert(cmd.end(), argv.begin(), argv.end()); cmd.insert(cmd.end(), { "-x", "c++", "-E", "-dM", "-" }); auto r = mcpp::platform::process::capture_stdout(cmd); diff --git a/tests/e2e/743_kernel_abi_interfaces_are_resolved_not_preprocessed.sh b/tests/e2e/743_kernel_abi_interfaces_are_resolved_not_preprocessed.sh new file mode 100755 index 00000000..9a06d53b --- /dev/null +++ b/tests/e2e/743_kernel_abi_interfaces_are_resolved_not_preprocessed.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# requires: gcc +# 743 -- a package states which interfaces of the `kernel-abi` layer it uses, +# and a graph whose implementation does not provide one is refused BEFORE +# anything is compiled. +# +# WHY THIS IS THE RIGHT TIME TO ANSWER IT. openkal's specification (0.14 +# §6.2) tabulates three times at which information about a capability becomes +# available and states that each is the EARLIEST at which it exists: +# dependency resolution answers "may this program be built against this +# implementation", the link answers "was an interface used that the +# implementation does not provide", a property word answers "how does it +# behave within an interface it provides". Source that asks the same question +# with `#ifdef` asks it during preprocessing -- earlier than any answer -- and +# that is why every macro-shaped answer to it has had to be replaced by the +# next one. +# +# Three legs: +# A a requirement the implementation provides: builds. +# B a requirement it does not: refused, naming the interface and both +# packages, with nothing compiled. +# C a graph whose implementation states nothing: builds. The key postdates +# the provider, and a graph that has not adopted it must keep building -- +# the link still reports the absence in the vocabulary it always did. +set -e + +MCPP="${MCPP:-mcpp}" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +cd "$work" + +mkdir -p impl/src src +cat > impl/src/lib.c <<'EOF' +int fake_kernel_marker(void) { return 0; } +EOF +cat > src/main.c <<'EOF' +int main(void) { return 0; } +EOF + +write_impl() { # $1 = the provides-interfaces array body, or empty for none + if [ -z "$1" ]; then + cat > impl/mcpp.toml <<'EOF' +[package] +name = "fakekernel" +version = "0.1.0" +provides = ["mcpp:kernel-abi=openkal"] + +[targets.fakekernel] +kind = "lib" +sources = ["src/*.c"] +EOF + else + cat > impl/mcpp.toml < mcpp.toml <<'EOF' +[package] +name = "iface-probe" +version = "0.1.0" + +[dependencies] +fakekernel = { path = "impl" } + +[build] +allow_host_libs = true +EOF + else + cat > mcpp.toml <&1) || { + echo "FAIL: a graph whose implementation provides every required" \ + "interface must build" >&2 + echo "$out" >&2 + exit 1 +} +echo "OK: A (every requirement provided)" + +# ── B. one is not provided ──────────────────────────────────────────────── +rm -rf target +write_root '"openkal.fs", "openkal.net"' +out=$("$MCPP" build 2>&1) && { + echo "FAIL: a requirement the implementation does not provide must be" \ + "refused at resolution" >&2 + echo "$out" >&2 + exit 1 +} +echo "$out" | grep -q "openkal.net" || { + echo "FAIL: the refusal must name the interface that is missing" >&2 + echo "$out" >&2 + exit 1 +} +echo "$out" | grep -q "iface-probe" || { + echo "FAIL: the refusal must name the package that required it" >&2 + echo "$out" >&2 + exit 1 +} +echo "$out" | grep -q "fakekernel" || { + echo "FAIL: the refusal must name the implementation that did not" \ + "provide it -- a reader told only 'missing' cannot tell which of" \ + "the two to change" >&2 + echo "$out" >&2 + exit 1 +} +# Nothing compiled: the answer exists before the compiler is reached, and a +# refusal that arrives after an object file has been written has answered at +# the wrong time even when it answers correctly. +if [ -d target ] && find target -name '*.o' -print -quit | grep -q .; then + echo "FAIL: the refusal must arrive before anything is compiled" >&2 + find target -name '*.o' | head -3 >&2 + exit 1 +fi +echo "OK: B (missing interface refused, nothing compiled)" + +# ── C. the implementation states nothing ────────────────────────────────── +rm -rf target +write_impl '' +write_root '"openkal.fs", "openkal.net"' +out=$("$MCPP" build 2>&1) || { + echo "FAIL: a provider that predates this key must keep building --" \ + "a package that states nothing is not a package that provides" \ + "nothing" >&2 + echo "$out" >&2 + exit 1 +} +echo "OK: C (a provider that states nothing is not refused)" + +echo "OK" diff --git a/tests/unit/test_build_flags.cpp b/tests/unit/test_build_flags.cpp index dbc088b4..b6565ac9 100644 --- a/tests/unit/test_build_flags.cpp +++ b/tests/unit/test_build_flags.cpp @@ -4,6 +4,8 @@ import std; import mcpp.build.flags; import mcpp.build.distribution; import mcpp.modgraph.scanner; +import mcpp.build.ninja; +import mcpp.targetside; namespace { @@ -208,3 +210,60 @@ TEST(LinkShape, WindowsHostSeparatesPeFromTargetsNamedByFlag) { // and keeps the line its CI job verifies. EXPECT_EQ(link_shape(LinkHost::Windows, Fmt::Elf, false, false), LinkShape::PeLld); } + +// ── c_abi_absent_facility_advice — the manifest reads back at the link ────── +// +// `undefined reference to 'fork'` is true and useless: it says a symbol is +// missing without saying whether that is a defect, a missing dependency, or a +// deliberate limit of the environment. The manifest already distinguishes +// those (design 2026-09-20 §5.7.5); these pin that it is read back. + +TEST(CAbiAbsentAdvice, ALinkAbsenceNamedInTheManifestIsExplained) { + std::vector absent{ + {"fork", mcpp::targetside::CAbiAbsentForm::Link, + "openkal has no process image duplication"}, + }; + auto a = mcpp::build::c_abi_absent_facility_advice( + "ld.lld: error: undefined symbol: fork\n", "musl", absent); + ASSERT_FALSE(a.empty()); + EXPECT_NE(a.find("fork"), std::string::npos); + EXPECT_NE(a.find("no process image duplication"), std::string::npos); + EXPECT_NE(a.find("musl"), std::string::npos); +} + +TEST(CAbiAbsentAdvice, TheGnuSpellingIsMatchedToo) { + std::vector absent{ + {"fork", mcpp::targetside::CAbiAbsentForm::Link, ""}, + }; + auto a = mcpp::build::c_abi_absent_facility_advice( + "main.o: undefined reference to `fork'\n", "musl", absent); + EXPECT_FALSE(a.empty()); +} + +TEST(CAbiAbsentAdvice, AnAbsenceThatReachesTheProgramAtRunTimeIsNotMatched) { + // `enosys` and `accepted-no-effect` reach a program by construction while + // it runs; matching them against a link diagnostic would report a + // coincidence of spelling as an explanation. + std::vector absent{ + {"mprotect", mcpp::targetside::CAbiAbsentForm::Enosys, "no protection op"}, + {"tcsetattr", mcpp::targetside::CAbiAbsentForm::AcceptedNoEffect, ""}, + }; + EXPECT_TRUE(mcpp::build::c_abi_absent_facility_advice( + "ld.lld: error: undefined symbol: mprotect\n", "musl", absent).empty()); + EXPECT_TRUE(mcpp::build::c_abi_absent_facility_advice( + "undefined reference to `tcsetattr'\n", "musl", absent).empty()); +} + +TEST(CAbiAbsentAdvice, ALinkFailureNamingSomethingElseGetsNoNote) { + std::vector absent{ + {"fork", mcpp::targetside::CAbiAbsentForm::Link, ""}, + }; + EXPECT_TRUE(mcpp::build::c_abi_absent_facility_advice( + "ld.lld: error: undefined symbol: my_own_function\n", "musl", absent) + .empty()); +} + +TEST(CAbiAbsentAdvice, ALibraryThatEnumeratedNothingProducesNoNote) { + EXPECT_TRUE(mcpp::build::c_abi_absent_facility_advice( + "ld.lld: error: undefined symbol: fork\n", "musl", {}).empty()); +} diff --git a/tests/unit/test_cenv_probe.cpp b/tests/unit/test_cenv_probe.cpp index 17af148d..b131c44d 100644 --- a/tests/unit/test_cenv_probe.cpp +++ b/tests/unit/test_cenv_probe.cpp @@ -211,103 +211,132 @@ TEST(CenvProbe, DifferentArgvDoesNotShareACacheSlot) { EXPECT_TRUE(b->ran) << "a different argv must not read A's cache entry"; } -// ── hostStripMacros — Windows-host contamination (mcpp 2026.9.18.3) ──────── +// ── assemble_argv — the invariant, reachable without a cross toolchain ──── // -// The four macro names `_WIN32`, `_WIN64`, `__MINGW32__`, `__MINGW64__` are -// preprocessor predefines that clang on a Windows host injects even when -// `--target=` substitutes a freestanding triple (`openkal-llvm-runtime#24`, -// windows-host × riscv64-none-elf, 2026-09-18). The probe must allow the -// caller to strip them, so the measurement reflects what a clean cross -// compile would do — not the host's predefines, which no `--target=` -// substitution can take back on a freestanding target. +// The defect these pin shipped in 2026.9.18.3: the probe ran with no target +// selection on every freestanding build and clang answered for the host. The +// pieces are all legitimately empty in some configuration, so the invariant +// cannot live at the call site; it lives in the assembly, and these tests +// reach it with no compiler at all. + +TEST(CenvProbeArgv, AFreestandingTargetWithoutASelectionIsRefused) { + // Exactly the 2026.9.18.3 shape: `crossTargetFlag` empty (it is set for + // hosted targets only), no freestanding flags supplied, and `realise`'s + // freestanding output, which carries no `--target`. + auto r = cp::assemble_argv("", {}, {"-D__unix__", "-fno-short-wchar"}, {}, + /*freestanding=*/true, "riscv64-none-elf"); + ASSERT_FALSE(r.has_value()) + << "an argv with no target selection must be refused for a " + "freestanding target; it measures the build host"; + EXPECT_NE(r.error().find("riscv64-none-elf"), std::string::npos) + << "the refusal must name the target it was for: " << r.error(); +} + +TEST(CenvProbeArgv, AFreestandingTargetWithItsCompilePrefixIsAccepted) { + std::vector fs{"--target=riscv64-none-elf", "-march=rv64gc", + "-mabi=lp64d", "-ffreestanding"}; + auto r = cp::assemble_argv("", fs, {"-D__unix__", "-fno-short-wchar"}, {}, + true, "riscv64-none-elf"); + ASSERT_TRUE(r.has_value()) << r.error(); + EXPECT_NE(std::find(r->begin(), r->end(), "--target=riscv64-none-elf"), + r->end()); + // The ISA flags travel with the target: they change what the compiler + // predefines (`__riscv_xlen`, the float ABI), so an argv that took the + // triple and left them behind would measure a different machine again. + EXPECT_NE(std::find(r->begin(), r->end(), "-mabi=lp64d"), r->end()); +} + +TEST(CenvProbeArgv, ANativeHostedBuildNeedsNoSelection) { + // The host IS the target; the absence is the decision rather than its + // omission, and the probe must not be refused for it. openkal-musl on + // Linux/x86_64 is this case: `realise` produces no tokens at all. + auto r = cp::assemble_argv("", {}, {}, {}, false, "x86_64-linux-gnu"); + ASSERT_TRUE(r.has_value()) << r.error(); + EXPECT_TRUE(r->empty()); +} + +TEST(CenvProbeArgv, AHostedCrossTargetCarriesItsCrossFlag) { + auto r = cp::assemble_argv("--target=x86_64-w64-windows-gnu", {}, + {"--target=x86_64-pc-cygwin", "-fno-short-wchar"}, + {}, false, "x86_64-windows-gnu"); + ASSERT_TRUE(r.has_value()) << r.error(); + // Both are present and in this order: clang takes the LAST `--target`, + // which is how the Cygwin-flavoured substitution overrides the base + // triple without the producer of the base one knowing this module exists. + ASSERT_GE(r->size(), 2u); + EXPECT_EQ((*r)[0], "--target=x86_64-w64-windows-gnu"); + EXPECT_EQ((*r)[1], "--target=x86_64-pc-cygwin"); +} + +TEST(CenvProbeArgv, BuiltinsTokensAreCarriedLast) { + auto r = cp::assemble_argv("", {}, {"-D__unix__"}, + {"-fno-builtin-memset_pattern16"}, + false, "x86_64-apple-macos"); + ASSERT_TRUE(r.has_value()) << r.error(); + ASSERT_EQ(r->size(), 2u); + EXPECT_EQ((*r)[1], "-fno-builtin-memset_pattern16"); +} + +// ── The argv must select the target (mcpp#674 review, 2026-09-20) ────────── // -// THESE TESTS DO NOT NEED A WINDOWS HOST. They exercise the parameter's -// behaviour against whatever compiler is on the test machine: the strip -// takes the form `-U`, which the preprocessor treats as a directive -// to undefine the macro if it was defined. On a host that did not predefine -// the name, the `-U` is a no-op; on a host that did (or any future host -// that will), the name disappears from the dump and the corresponding -// `expectUndefined` check passes. The test pins both halves of the contract: -// (a) a stripped name is no longer in the dump, and (b) an unstripped run -// against the same expectation reports a mismatch. +// Clang is one binary that emits every target it was built with, so a probe +// command with no `--target` answers for the machine it runs on. 2026.9.18.3 +// read that as a Windows host leaking `_WIN32` through a `--target=` +// substitution and added a `hostStripMacros` parameter to undefine it; there +// was no substitution in the freestanding argv to leak through, and the strip +// removed the evidence rather than the cause. The parameter is gone and these +// tests pin what replaced it. +// +// THEY DO NOT NEED A CROSS TOOLCHAIN. Every clang emits every target it was +// built with, so `--target=riscv64-none-elf` needs no payload to answer `-dM`. +// A compiler that cannot is skipped rather than reported. namespace { -// Macros the test invents locally; neither the host compiler nor any -// realistic cross-compile target predefines them. The names are chosen so -// long that no one ships them by accident. -constexpr std::string_view kProbeStripDefined = "__MCPP_TEST_STRIP_DEFINED__"; -constexpr std::string_view kProbeStripUndefined = "__MCPP_TEST_STRIP_UNDEFINED__"; +// Whether this compiler answers for a target it is not hosted on. A GCC +// driver does not take `--target`, and a clang built without the RISC-V +// backend answers nothing useful; both skip. +bool answers_for_riscv() { + TmpCache probe; + auto r = cp::verify(cxx(), {"--target=riscv64-none-elf"}, 0, 0, + {"__riscv"}, {}, probe.dir); + return r.has_value() && r->mismatches.empty(); +} } // namespace -TEST(CenvProbe, AHostStrippedMacroIsAbsentFromTheDump) { +TEST(CenvProbe, AnArgvWithATargetMeasuresThatTargetAndNotTheHost) { if (cxx().empty()) GTEST_SKIP() << "no C++ compiler found to probe"; + if (!answers_for_riscv()) GTEST_SKIP() << "this compiler does not answer for riscv64-none-elf"; TmpCache cache; - // The contract is: a name in `hostStripMacros` (as `-U`) reaches - // the compiler BEFORE any other argv token, so a host that predefines - // the name has it stripped before the realised tokens (e.g. `-D__unix__`) - // are processed. A real Windows host predefines `_WIN32`; we cannot - // simulate that here, so the test instead uses a name no host - // predefines — `__MCPP_PROBE_NO_SUCH_MACRO__` — and asserts that the - // probe's `expectUndefined` check passes (the strip is a no-op for a - // host that did not predefine the name, which is the same outcome as - // a host that did and had it removed). This pins that the parameter is - // wired through to the command line and that the ordering does not - // silently fail. - std::vector expectUndef; - expectUndef.emplace_back("__MCPP_PROBE_NO_SUCH_MACRO__"); - std::vector strip; - strip.emplace_back("-U__MCPP_PROBE_NO_SUCH_MACRO__"); - auto stripped = cp::verify( - cxx(), {}, 0, 0, - {}, expectUndef, - cache.dir, - strip - ); - ASSERT_TRUE(stripped.has_value()) << stripped.error(); - EXPECT_TRUE(stripped->mismatches.empty()) - << "an unstripped probe + a stripped probe against the same " - "expectUndefined must both pass for a name the host does not " - "predefine; the parameter must reach the command line: " - << stripped->mismatches[0].fact; + // The freestanding probe's own shape, with the target selection the + // caller (`mcpp.build.prepare`) now supplies. `__linux__` must be absent + // on every host: it is the host's own predefine, and a probe that still + // reports it is measuring the host. + std::vector argv{ + "--target=riscv64-none-elf", "-ffreestanding", + "-D__unix__", "-fno-short-wchar", + }; + std::vector defined{"__unix__", "__riscv"}; + std::vector undefined{"__linux__", "_WIN32"}; + auto r = cp::verify(cxx(), argv, 32, 0, defined, undefined, cache.dir); + ASSERT_TRUE(r.has_value()) << r.error(); + for (auto const& mm : r->mismatches) + ADD_FAILURE() << mm.fact << ": declared " << mm.declared + << ", measured " << mm.measured; } -TEST(CenvProbe, AStripListDoesNotShareACacheSlotWithAnEmptyStrip) { +TEST(CenvProbe, TheSameArgvWithoutATargetMeasuresTheHost) { if (cxx().empty()) GTEST_SKIP() << "no C++ compiler found to probe"; + if (!answers_for_riscv()) GTEST_SKIP() << "this compiler does not answer for riscv64-none-elf"; TmpCache cache; - auto noStrip = cp::verify(cxx(), {}, 0, 0, {}, {}, cache.dir); - ASSERT_TRUE(noStrip.has_value()) << noStrip.error(); - auto withStrip = cp::verify(cxx(), {}, 0, 0, {}, {}, cache.dir, - {"-U_MCPP_PROBE_TEST_NO_SUCH_MACRO"}); - ASSERT_TRUE(withStrip.has_value()) << withStrip.error(); - EXPECT_TRUE(noStrip->ran); - EXPECT_TRUE(withStrip->ran) - << "a non-empty strip list must not read the empty-strip cache slot"; -} - -// The wave's measurement (openkal-llvm-runtime#24, 2026-09-18) caught two -// host-side leaks on a Windows host × freestanding target: `_WIN32` and -// the wchar width. The first is a preprocessor predefine (closed by -// `hostStripMacros`); the second is a header-side assumption (closed by -// the realisation adding `-fno-short-wchar`, pinned in `test_cenv.cpp`). -// The two halves of the fix are pinned in two test files for that reason. -// THIS TEST pins the contract on the parameter's caller side: the four -// names the wave measured as leaking are the four names the caller passes, -// and no caller passes them on a non-Windows host. This is what makes the -// probe's strip a TARGETED fix rather than a sweeping undefine. -TEST(CenvProbe, TheWindowsHostStripListNamesExactlyTheFourMeasuredLeaks) { - // The list is in `prepare.cppm`. Re-state it here so a future edit to - // either side (probe parameter vs caller) trips a test mismatch. A - // name added without a corresponding build that named it is exactly - // the kind of change this assertion is meant to catch. - const std::vector expected = { - "-U_WIN32", - "-U_WIN64", - "-U__MINGW32__", - "-U__MINGW64__", - }; - EXPECT_EQ(expected.size(), 4u); - // Uniqueness — repeating a name in the strip list is harmless but - // indicates the list drifted without a thought. - std::set uniq(expected.begin(), expected.end()); - EXPECT_EQ(uniq.size(), expected.size()); + // The mirror of the test above, and the one that makes it mean + // something: with the target removed, the expectation that held for the + // target must now FAIL. A test that only asserts the fixed shape passes + // identically against a probe that ignores its argv. + std::vector argv{"-ffreestanding", "-D__unix__", "-fno-short-wchar"}; + auto r = cp::verify(cxx(), argv, 0, 0, {"__riscv"}, {}, cache.dir); + ASSERT_TRUE(r.has_value()) << r.error(); + EXPECT_FALSE(r->mismatches.empty()) + << "a probe with no target selection must not report the target's " + "own predefines; if this passes, the argv is not reaching the " + "compiler and the test above proves nothing"; } diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index 21bec43e..914f0bc0 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -6001,6 +6001,224 @@ c-environment = "native" // explicit key `CEnvironmentAcceptsOnlyPlatform` above already covers. An // ordinary package, providing nothing kernel-abi-shaped, gets no such // inference: `cEnvironment` stays empty and the realisation reaches it. +// ── [c-abi.absent] — enumerate the exception, not the rule ───────────────── +// +// The set of POSIX names a C library supplies is not enumerable in a manifest; +// the set it does not supply is. openkal-musl's README lists six in prose, and +// that prose has no executor — it was contradicted once already (0.16.0: a +// disposition accepted for every signal and installed for none). + +TEST(Manifest, ACLibraryMayEnumerateWhatItDoesNotSupply) { + constexpr auto src = R"( +[package] +name = "openkal-musl" +version = "0.17.0" +provides = ["mcpp:c-abi=musl"] + +[c-abi] +presents = "posix" +data-model = "arch-default" +wchar = 32 +builtins = "iso" + +[c-abi.absent] +fork = { form = "link" } +mprotect = { form = "enosys", note = "openkal has no operation upon a mapping's protection" } +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_TRUE(m.has_value()) << m.error().format(); + ASSERT_TRUE(m->cAbiDecl.has_value()); + ASSERT_EQ(m->cAbiDecl->absent.size(), 2u); + // Sorted by name, so a diagnostic built from this list reads the same way + // on every run and a test may index it. + EXPECT_EQ(m->cAbiDecl->absent[0].name, "fork"); + EXPECT_EQ(m->cAbiDecl->absent[0].form, + mcpp::targetside::CAbiAbsentForm::Link); + EXPECT_EQ(m->cAbiDecl->absent[1].name, "mprotect"); + EXPECT_EQ(m->cAbiDecl->absent[1].form, + mcpp::targetside::CAbiAbsentForm::Enosys); + EXPECT_FALSE(m->cAbiDecl->absent[1].note.empty()); +} + +TEST(Manifest, AnAbsenceWithNoNamedShapeIsRefused) { + // A facility absent in an unnamed shape is one nothing can assert + // against, which is the whole reason for writing it down. + constexpr auto src = R"( +[package] +name = "openkal-musl" +version = "0.17.0" +provides = ["mcpp:c-abi=musl"] + +[c-abi] +presents = "posix" +data-model = "arch-default" +wchar = 32 + +[c-abi.absent] +fork = { note = "no process image duplication" } +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_FALSE(m.has_value()); + auto msg = m.error().format(); + EXPECT_NE(msg.find("form"), std::string::npos) << msg; +} + +TEST(Manifest, AnUnknownAbsentShapeNamesTheThreeThatExist) { + constexpr auto src = R"( +[package] +name = "openkal-musl" +version = "0.17.0" +provides = ["mcpp:c-abi=musl"] + +[c-abi] +presents = "posix" +data-model = "arch-default" +wchar = 32 + +[c-abi.absent] +fork = { form = "sometimes" } +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_FALSE(m.has_value()); + auto msg = m.error().format(); + EXPECT_NE(msg.find("accepted-no-effect"), std::string::npos) << msg; +} + +TEST(Manifest, TheAcceptedNoEffectShapeHasAName) { + // It is the shape of the defect openkal-musl 0.16.0 repaired: a call that + // succeeds and does not do part of what it was asked. Naming it is what + // makes "how many of these are there" a question with an answer. + constexpr auto src = R"( +[package] +name = "openkal-musl" +version = "0.17.0" +provides = ["mcpp:c-abi=musl"] + +[c-abi] +presents = "posix" +data-model = "arch-default" +wchar = 32 + +[c-abi.absent] +tcsetattr = { form = "accepted-no-effect", note = "the fields openkal does not name are not applied" } +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_TRUE(m.has_value()) << m.error().format(); + ASSERT_EQ(m->cAbiDecl->absent.size(), 1u); + EXPECT_EQ(m->cAbiDecl->absent[0].form, + mcpp::targetside::CAbiAbsentForm::AcceptedNoEffect); +} + +TEST(Manifest, ACLibraryThatEnumeratesNothingHasClaimedNothing) { + constexpr auto src = R"( +[package] +name = "openkal-musl" +version = "0.17.0" +provides = ["mcpp:c-abi=musl"] + +[c-abi] +presents = "posix" +data-model = "arch-default" +wchar = 32 +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_TRUE(m.has_value()) << m.error().format(); + ASSERT_TRUE(m->cAbiDecl.has_value()); + EXPECT_TRUE(m->cAbiDecl->absent.empty()); +} + +// ── [kernel-abi] provides-interfaces / requires-interfaces ───────────────── +// +// The resolution-time half of the capability model (design 2026-09-20 §5.5). +// The engine knows no interface name; these tests therefore use openkal's +// real names in one direction and an invented one in the other, and neither +// changes what the parser does. + +TEST(Manifest, AKernelAbiProviderMayStateWhichInterfacesItProvides) { + constexpr auto src = R"( +[package] +name = "openkal-windows" +version = "0.9.0" +provides = ["mcpp:kernel-abi=openkal"] + +[kernel-abi] +provides-interfaces = ["openkal.abort", "openkal.stream", "openkal.memory"] +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_TRUE(m.has_value()) << m.error().format(); + ASSERT_EQ(m->kernelAbiProvidesInterfaces.size(), 3u); + EXPECT_EQ(m->kernelAbiProvidesInterfaces[0], "openkal.abort"); + EXPECT_TRUE(m->kernelAbiRequiresInterfaces.empty()); +} + +TEST(Manifest, APackageThatDoesNotSupplyTheLayerMayNotStateWhatItProvides) { + // The same rule `[c-abi]` follows, and for the same reason: a package + // without the layer is stating a fact about something it does not have. + constexpr auto src = R"( +[package] +name = "some-library" +version = "1.0.0" + +[kernel-abi] +provides-interfaces = ["openkal.fs"] +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_FALSE(m.has_value()) + << "a package with no `mcpp:kernel-abi=` in provides must be " + "refused when it states provides-interfaces"; + auto msg = m.error().format(); + EXPECT_NE(msg.find("mcpp:kernel-abi"), std::string::npos) << msg; +} + +TEST(Manifest, AnyConsumerMayStateWhichInterfacesItRequires) { + // `requires-interfaces` is a statement about the package making it, so it + // carries no such restriction. openkal SPEC 0.14 §3.3: "a consumer states + // its required set per target, in its own package, which is where a + // convention among consumers belongs". + constexpr auto src = R"( +[package] +name = "some-library" +version = "1.0.0" + +[kernel-abi] +requires-interfaces = ["openkal.fs", "openkal.net"] +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_TRUE(m.has_value()) << m.error().format(); + ASSERT_EQ(m->kernelAbiRequiresInterfaces.size(), 2u); + EXPECT_TRUE(m->kernelAbiProvidesInterfaces.empty()); +} + +TEST(Manifest, AnUnknownKernelAbiMemberIsAParseErrorNamingTheKey) { + constexpr auto src = R"( +[package] +name = "openkal-linux" +version = "0.14.0" +provides = ["mcpp:kernel-abi=openkal"] + +[kernel-abi] +provides-interface = ["openkal.fs"] +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_FALSE(m.has_value()) << "a misspelled member must not be ignored"; + auto msg = m.error().format(); + EXPECT_NE(msg.find("provides-interface"), std::string::npos) << msg; +} + +TEST(Manifest, APackageWithNoKernelAbiTableStatesNothing) { + // The guarantee every addition to this parser owes the graph that predates + // it: a manifest that writes nothing here is byte-identical in effect. + constexpr auto src = R"( +[package] +name = "some-library" +version = "1.0.0" +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_TRUE(m.has_value()) << m.error().format(); + EXPECT_TRUE(m->kernelAbiProvidesInterfaces.empty()); + EXPECT_TRUE(m->kernelAbiRequiresInterfaces.empty()); +} + TEST(Manifest, CEnvironmentIsInferredForAKernelAbiProvider) { constexpr auto kernelAbiPkg = R"( [package] diff --git a/tests/unit/test_targetside.cpp b/tests/unit/test_targetside.cpp index 30c7f4fd..36a699f4 100644 --- a/tests/unit/test_targetside.cpp +++ b/tests/unit/test_targetside.cpp @@ -765,3 +765,57 @@ TEST(TargetSideCLibrary, TheKernelInterfaceDoesNotDecideIt) { EXPECT_FALSE(side_with(Origin::Graph, k).cAbi.prebuilt()); } } + +// ── interfaces_not_provided — the whole of the engine's participation ─────── +// +// openkal SPEC 0.14 §3.3 withdrew `hosted`, the one name it had given to a set +// of interfaces, and replaced it with enumeration by the consumer. This engine +// implements that as a set difference over opaque strings and learns no +// member of either set; these tests use openkal's real names in one place and +// an invented one in another, and the function behaves identically. + +TEST(TargetSideInterfaces, EveryRequiredInterfaceProvidedYieldsNoGap) { + std::vector required{"openkal.fs", "openkal.stream"}; + std::vector provided{"openkal.abort", "openkal.stream", + "openkal.memory", "openkal.fs"}; + EXPECT_TRUE(mcpp::targetside::interfaces_not_provided(required, provided) + .empty()); +} + +TEST(TargetSideInterfaces, AMissingInterfaceIsReportedByName) { + std::vector required{"openkal.fs", "openkal.net"}; + std::vector provided{"openkal.fs"}; + auto gap = mcpp::targetside::interfaces_not_provided(required, provided); + ASSERT_EQ(gap.size(), 1u); + EXPECT_EQ(gap[0], "openkal.net"); +} + +TEST(TargetSideInterfaces, ANameThisEngineHasNeverHeardOfBehavesTheSame) { + // The point of the opaque strings: a specification may add an interface + // without a release of this engine, and a name that does not exist is + // missing for the same reason a name that does exist and is not provided + // is missing. Neither is a special case here. + std::vector required{"openkal.event", "some.other.spec/iface"}; + std::vector provided{"openkal.fs"}; + auto gap = mcpp::targetside::interfaces_not_provided(required, provided); + ASSERT_EQ(gap.size(), 2u); + EXPECT_EQ(gap[0], "openkal.event"); + EXPECT_EQ(gap[1], "some.other.spec/iface"); +} + +TEST(TargetSideInterfaces, RequiringNothingIsNeverAGap) { + EXPECT_TRUE(mcpp::targetside::interfaces_not_provided({}, {}).empty()); + EXPECT_TRUE( + mcpp::targetside::interfaces_not_provided({}, {"openkal.fs"}).empty()); +} + +TEST(TargetSideInterfaces, ProvidingNothingMakesEveryRequirementAGap) { + // The shape a graph takes when the provider predates the key. The caller + // (mcpp.build.prepare) does not reach this function in that case — a + // provider that states nothing is not a provider that provides nothing — + // and this test pins the function's own answer so that distinction stays + // the caller's and does not migrate here by accident. + std::vector required{"openkal.fs"}; + auto gap = mcpp::targetside::interfaces_not_provided(required, {}); + ASSERT_EQ(gap.size(), 1u); +} From 0f870c6d140cd6ecff207e2f0e644d8fa0a98ece Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:36:05 +0800 Subject: [PATCH 02/31] the [c-abi.absent] parser gets its own function, and the docs index is regenerated Two CI reports, both from the first push. clang 20.1.7 on Windows crashed during LLVM IR generation of `parse_string` (exception 0xC0000005, the frame naming the `[c-abi.absent]` block's compound statement). Every other host compiled the same source. This codebase has met the shape before --- a construct that is fine in a function and not in a large one inside a module interface unit --- and the answer is the same: the block becomes `parse_c_abi_absent`, a free function, and the reason is written beside it so the next reader does not inline it back. `.agents/docs/README.md` regenerated, and the ecosystem design's front matter carries a `status` the structure check knows. Co-authored-by: Claude Code --- ...-openkal-c-environment-ecosystem-design.md | 2 +- .agents/docs/README.md | 16 ++- modules/manifest/src/toml.cppm | 130 ++++++++++-------- 3 files changed, 88 insertions(+), 60 deletions(-) diff --git a/.agents/docs/2026-09-20-openkal-c-environment-ecosystem-design.md b/.agents/docs/2026-09-20-openkal-c-environment-ecosystem-design.md index cb1a43d3..1b2ce467 100644 --- a/.agents/docs/2026-09-20-openkal-c-environment-ecosystem-design.md +++ b/.agents/docs/2026-09-20-openkal-c-environment-ecosystem-design.md @@ -1,6 +1,6 @@ --- subject: design -status: draft-for-review +status: active --- # openkal 生态:能力的时刻模型,以及 C 环境方案空间的划分 diff --git a/.agents/docs/README.md b/.agents/docs/README.md index 2b337a8c..2d88aa29 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 --- ``` -296 records. +300 records. ## By subject @@ -30,6 +30,8 @@ Records that declare one. Everything else is listed by date below. ### design +- [openkal 生态:能力的时刻模型,以及 C 环境方案空间的划分](2026-09-20-openkal-c-environment-ecosystem-design.md) — active +- [#674:`presents = "posix"` 在 Windows 上兑现契约的下半段](2026-09-19-issue-674-cenv-posix-preinclude-design.md) — active - [The build database of #636, and two defects on the way to the latest xlings](2026-09-14-636-build-database-and-the-latest-xlings.md) — active ### docs @@ -48,10 +50,18 @@ Records that declare one. Everything else is listed by date below. - [Two answers and two silences: the scanner's second grammar, and the manifest keys nothing reads](2026-09-09-two-answers-and-two-silences.md) — active +### plan + +- [C 环境生态方案:执行计划](2026-09-20-ecosystem-execution-plan.md) — active + ### plugins - [The category the plugin taxonomy does not name, and what a platform actually decomposes into](2026-09-11-distribution-plugins-and-platform-decomposition.md) — active +### review + +- [#674 设计方案评审:`-include unistd.h` 在 Windows + `presents = "posix"` 上的可行性](2026-09-20-issue-674-design-review.md) — active + ### targets - [A verified Web run that asked the host for node](2026-09-12-a-verified-web-run-that-asked-the-host-for-node.md) — landed @@ -81,6 +91,10 @@ Records that declare one. Everything else is listed by date below. ### 2026-09 +- [openkal 生态:能力的时刻模型,以及 C 环境方案空间的划分](2026-09-20-openkal-c-environment-ecosystem-design.md) — active +- [#674 设计方案评审:`-include unistd.h` 在 Windows + `presents = "posix"` 上的可行性](2026-09-20-issue-674-design-review.md) — active +- [C 环境生态方案:执行计划](2026-09-20-ecosystem-execution-plan.md) — active +- [#674:`presents = "posix"` 在 Windows 上兑现契约的下半段](2026-09-19-issue-674-cenv-posix-preinclude-design.md) — active - [运行时绑定方案 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 diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index dd70391a..828cbd2f 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -561,6 +561,75 @@ std::optional find_disallowed_array_of_tables( } // namespace +// [c-abi.absent] — the facilities a C library does not supply (design +// 2026-09-20 §5.7.5). The set of names it DOES supply is not enumerable in a +// manifest; the exceptions are, and enumerating an exception is what lets a CI +// run contradict it. +// +// [c-abi.absent] +// fork = { form = "link" } +// mprotect = { form = "enosys", note = "openkal has no operation upon a +// mapping's protection" } +// +// `form` is required and closed: a facility absent in an unnamed shape is one +// nobody can assert against. `link` is the shape openkal's own model requires +// (SPEC 0.14 §6.1, which calls a run-time report of unsupportedness a defect); +// the other two are departures from it, named so that a departure is something +// that can be counted. +// +// A FREE FUNCTION AND NOT A BLOCK INSIDE `parse_string`, FOR A MEASURED +// REASON. Written inline it crashed clang 20.1.7 on Windows during LLVM IR +// generation of `parse_string` (exception 0xC0000005, the frame naming this +// block's compound statement); every other host compiled it. This codebase has +// met the shape before — a construct that is fine in a function and not in a +// large one inside a module interface unit — and the answer is the same: give +// it its own function. +inline std::expected, std::string> +parse_c_abi_absent(const t::Value& v) { + std::vector out; + if (!v.is_table()) + return std::unexpected(std::string( + "[c-abi.absent] must be a table of facility names, each with a " + "`form`: fork = { form = \"link\" }")); + for (auto const& kv : v.as_table()) { + const std::string& name = kv.first; + const t::Value& ent = kv.second; + if (!ent.is_table()) + return std::unexpected(std::format( + "[c-abi.absent].{} must be a table with a `form`: " + "{} = {{ form = \"link\" }}", name, name)); + const auto& et = ent.as_table(); + for (auto const& m : et) + if (m.first != "form" && m.first != "note") + return std::unexpected(std::format( + "[c-abi.absent].{} has no member '{}'; the members are: " + "form, note", name, m.first)); + auto fit = et.find("form"); + if (fit == et.end() || !fit->second.is_string()) + return std::unexpected(std::format( + "[c-abi.absent].{} is missing `form`. An absence with no " + "named shape is one nothing can assert against; the shapes " + "are \"link\" (the definition is absent), \"enosys\" (it " + "exists and reports that it cannot act) and " + "\"accepted-no-effect\" (the call succeeds and part of what " + "it asked for is not done).", name)); + auto form = mcpp::targetside::parse_c_abi_absent_form(fit->second.as_string()); + if (!form) + return std::unexpected(std::format( + "[c-abi.absent].{}.form = \"{}\" names no known shape. The " + "shapes are \"link\", \"enosys\" and \"accepted-no-effect\".", + name, fit->second.as_string())); + mcpp::targetside::CAbiAbsentEntry e; + e.name = name; + e.form = *form; + if (auto nit = et.find("note"); nit != et.end() && nit->second.is_string()) + e.note = nit->second.as_string(); + out.push_back(std::move(e)); + } + std::ranges::sort(out, {}, &mcpp::targetside::CAbiAbsentEntry::name); + return out; +} + std::expected parse_string(std::string_view content, const std::filesystem::path& origin, LoadContext ctx) { @@ -1085,65 +1154,10 @@ std::expected parse_string(std::string_view content, "[c-abi] has no member '{}'; the members are: absent, " "builtins, data-model, presents, wchar", key))); } - // [c-abi.absent] — the facilities this C library does not supply - // (design 2026-09-20 §5.7.5). The set of names it DOES supply is not - // enumerable in a manifest; the exceptions are, and enumerating an - // exception is what lets a CI run contradict it. - // - // [c-abi.absent] - // fork = { form = "link" } - // mprotect = { form = "enosys", note = "openkal has no operation - // upon a mapping's protection" } - // - // `form` is required and closed: a facility that is absent in an - // unnamed shape is one nobody can assert against. `link` is the shape - // openkal's own model requires (SPEC 0.14 §6.1, which calls a - // run-time report of unsupportedness a defect); the other two are - // departures from it, and they are named so that a departure is - // something somebody can count. if (auto ait = ct->find("absent"); ait != ct->end()) { - if (!ait->second.is_table()) - return std::unexpected(error(origin, - "[c-abi.absent] must be a table of facility names, each " - "with a `form`: fork = { form = \"link\" }")); - for (auto const& [name, entry] : ait->second.as_table()) { - if (!entry.is_table()) - return std::unexpected(error(origin, std::format( - "[c-abi.absent].{} must be a table with a `form`: " - "{} = {{ form = \"link\" }}", name, name))); - mcpp::targetside::CAbiAbsentEntry e; - e.name = name; - auto const& et = entry.as_table(); - for (auto const& [k, _] : et) - if (k != "form" && k != "note") - return std::unexpected(error(origin, std::format( - "[c-abi.absent].{} has no member '{}'; the " - "members are: form, note", name, k))); - auto fit = et.find("form"); - if (fit == et.end() || !fit->second.is_string()) - return std::unexpected(error(origin, std::format( - "[c-abi.absent].{} is missing `form`. An absence " - "with no named shape is one nothing can assert " - "against; the shapes are \"link\" (the definition is " - "absent), \"enosys\" (it exists and reports that it " - "cannot act) and \"accepted-no-effect\" (the call " - "succeeds and part of what it asked for is not " - "done).", name))); - auto form = mcpp::targetside::parse_c_abi_absent_form( - fit->second.as_string()); - if (!form) - return std::unexpected(error(origin, std::format( - "[c-abi.absent].{}.form = \"{}\" names no known " - "shape. The shapes are \"link\", \"enosys\" and " - "\"accepted-no-effect\".", - name, fit->second.as_string()))); - e.form = *form; - if (auto nit = et.find("note"); - nit != et.end() && nit->second.is_string()) - e.note = nit->second.as_string(); - decl.absent.push_back(std::move(e)); - } - std::ranges::sort(decl.absent, {}, &mcpp::targetside::CAbiAbsentEntry::name); + auto absent = parse_c_abi_absent(ait->second); + if (!absent) return std::unexpected(error(origin, absent.error())); + decl.absent = std::move(*absent); } if (auto pit = ct->find("presents"); pit != ct->end()) { if (!pit->second.is_string()) From 88dfb74b1358e4989db27bba222f33099813dc5d Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:49:16 +0800 Subject: [PATCH 03/31] the [c-abi.absent] parser is an internal helper, in the plainest form that compiles everywhere clang 20.1.7 on Windows crashed generating code for this parser in three spellings: as a block inside `parse_string`, as a free function in the module's exported purview returning `std::expected, std::string>`, and with a pointer-to-member projection in its sort. Every other host and compiler took all three. What it takes is the plainest form available, and each part of that form is now measured rather than stylistic: an internal helper in the anonymous namespace rather than an inline function in the purview that every importer emits; an out parameter and `std::optional` rather than `expected` over a vector of structs carrying strings; a comparator rather than a projection. The notes are beside it so a later tidy-up does not restore a shorter spelling and rediscover this on a Windows runner. Co-authored-by: Claude Code --- modules/manifest/src/toml.cppm | 61 ++++++++++++++++++++++------------ 1 file changed, 39 insertions(+), 22 deletions(-) diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index 828cbd2f..4c819343 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -559,8 +559,6 @@ std::optional find_disallowed_array_of_tables( return std::nullopt; } -} // namespace - // [c-abi.absent] — the facilities a C library does not supply (design // 2026-09-20 §5.7.5). The set of names it DOES supply is not enumerable in a // manifest; the exceptions are, and enumerating an exception is what lets a CI @@ -577,36 +575,43 @@ std::optional find_disallowed_array_of_tables( // the other two are departures from it, named so that a departure is something // that can be counted. // -// A FREE FUNCTION AND NOT A BLOCK INSIDE `parse_string`, FOR A MEASURED -// REASON. Written inline it crashed clang 20.1.7 on Windows during LLVM IR -// generation of `parse_string` (exception 0xC0000005, the frame naming this -// block's compound statement); every other host compiled it. This codebase has -// met the shape before — a construct that is fine in a function and not in a -// large one inside a module interface unit — and the answer is the same: give -// it its own function. -inline std::expected, std::string> -parse_c_abi_absent(const t::Value& v) { - std::vector out; +// THE SHAPE OF THIS FUNCTION IS MEASURED RATHER THAN STYLISTIC. Written as a +// block inside `parse_string` it crashed clang 20.1.7 on Windows during LLVM +// IR generation (exception 0xC0000005); moved out to a free function returning +// `std::expected, std::string>` the crash moved +// with it, now naming this function. Every other host and every other compiler +// compiled all three spellings. What it takes is the plainest form available: +// an out parameter and an optional error, with no `expected` over a vector of +// structs carrying strings. The note is here so that a later tidy-up does not +// restore a shorter spelling and rediscover this on a Windows runner. +// +// It also belongs in this anonymous namespace and not in the module's exported +// purview, where the first two spellings were written. An inline function in +// the purview is emitted into every importer of the module; this one is an +// implementation detail of `parse_string` and has no reader outside it. +inline std::optional +parse_c_abi_absent(const t::Value& v, + std::vector& out) { if (!v.is_table()) - return std::unexpected(std::string( + return std::optional(std::string( "[c-abi.absent] must be a table of facility names, each with a " "`form`: fork = { form = \"link\" }")); for (auto const& kv : v.as_table()) { const std::string& name = kv.first; const t::Value& ent = kv.second; if (!ent.is_table()) - return std::unexpected(std::format( + return std::optional(std::format( "[c-abi.absent].{} must be a table with a `form`: " "{} = {{ form = \"link\" }}", name, name)); const auto& et = ent.as_table(); for (auto const& m : et) if (m.first != "form" && m.first != "note") - return std::unexpected(std::format( + return std::optional(std::format( "[c-abi.absent].{} has no member '{}'; the members are: " "form, note", name, m.first)); auto fit = et.find("form"); if (fit == et.end() || !fit->second.is_string()) - return std::unexpected(std::format( + return std::optional(std::format( "[c-abi.absent].{} is missing `form`. An absence with no " "named shape is one nothing can assert against; the shapes " "are \"link\" (the definition is absent), \"enosys\" (it " @@ -615,7 +620,7 @@ parse_c_abi_absent(const t::Value& v) { "it asked for is not done).", name)); auto form = mcpp::targetside::parse_c_abi_absent_form(fit->second.as_string()); if (!form) - return std::unexpected(std::format( + return std::optional(std::format( "[c-abi.absent].{}.form = \"{}\" names no known shape. The " "shapes are \"link\", \"enosys\" and \"accepted-no-effect\".", name, fit->second.as_string())); @@ -626,10 +631,23 @@ parse_c_abi_absent(const t::Value& v) { e.note = nit->second.as_string(); out.push_back(std::move(e)); } - std::ranges::sort(out, {}, &mcpp::targetside::CAbiAbsentEntry::name); - return out; + // A PLAIN COMPARATOR AND NOT A PROJECTION. `std::ranges::sort(out, {}, + // &CAbiAbsentEntry::name)` says the same thing and crashed clang 20.1.7 on + // Windows while generating code for this function (0xC0000005). A + // pointer-to-member used as a projection is a shape this codebase has met + // before on that front end; the note is here so the shorter spelling is + // not restored as a tidy-up. + std::sort(out.begin(), out.end(), + [](const mcpp::targetside::CAbiAbsentEntry& a, + const mcpp::targetside::CAbiAbsentEntry& b) { + return a.name < b.name; + }); + return std::nullopt; } +} // namespace + + std::expected parse_string(std::string_view content, const std::filesystem::path& origin, LoadContext ctx) { @@ -1155,9 +1173,8 @@ std::expected parse_string(std::string_view content, "builtins, data-model, presents, wchar", key))); } if (auto ait = ct->find("absent"); ait != ct->end()) { - auto absent = parse_c_abi_absent(ait->second); - if (!absent) return std::unexpected(error(origin, absent.error())); - decl.absent = std::move(*absent); + if (auto why = parse_c_abi_absent(ait->second, decl.absent)) + return std::unexpected(error(origin, *why)); } if (auto pit = ct->find("presents"); pit != ct->end()) { if (!pit->second.is_string()) From 68bb7371622ffa7eb1380611784f2ba0bc4a9b73 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:53:53 +0800 Subject: [PATCH 04/31] docs/22: which of the two new keys an older engine ignores, and which it refuses `[kernel-abi]` is an unknown top-level table to an engine that predates it and is ignored, so a package adopting it needs no floor move. `[c-abi.absent]` is a new key inside a table mcpp does know, where an unrecognised key is a parse error naming it --- the mechanism that keeps a misspelling from silently disabling a declaration --- so a package adopting that one does. Co-authored-by: Claude Code --- docs/22-target-side.md | 10 ++++++++++ docs/zh/22-target-side.md | 6 ++++++ 2 files changed, 16 insertions(+) diff --git a/docs/22-target-side.md b/docs/22-target-side.md index 5c93284f..0947a3a1 100644 --- a/docs/22-target-side.md +++ b/docs/22-target-side.md @@ -599,6 +599,16 @@ list back when a link names one of the `link`-shaped entries, so `undefined reference to 'fork'` arrives with the sentence that says whether it is a defect or a limit of the environment this program was built for. +**An engine that predates these keys.** `[kernel-abi]` is a top-level table an +older mcpp does not know, and an unknown top-level table is ignored: a manifest +carrying it loads unchanged everywhere it loaded before, and the absence of the +resolution-time check is the behaviour that release already had. `[c-abi.absent]` +is not the same case --- it is a new key inside a table mcpp does know, and an +unrecognised key there is a parse error naming the key, which is what keeps a +misspelling from silently disabling a declaration. A package adopting the second +therefore requires an index floor at 2026.9.20.1; a package adopting the first +does not. + **Fingerprint.** The realised environment participates in the build's fingerprint (`compileFlags`, §92's field 7): two builds whose C library declares `lp64` and `llp64` compile the same source into objects whose diff --git a/docs/zh/22-target-side.md b/docs/zh/22-target-side.md index 5d972398..ed7281a7 100644 --- a/docs/zh/22-target-side.md +++ b/docs/zh/22-target-side.md @@ -471,6 +471,12 @@ tcsetattr = { form = "accepted-no-effect", note = "openkal 不命名的那些字 `undefined reference to 'fork'` 会带着那句说明它是缺陷还是这个程序所构建环境之限制的话 一起到达。 +**早于这些键的引擎。** `[kernel-abi]` 是一个旧 mcpp 不认识的**顶层表**,而未知顶层表 +被忽略:带着它的清单在它此前能加载的每个地方照常加载,解析期检查的缺席正是那个版本本来 +就有的行为。`[c-abi.absent]` 不是同一种情形——它是 mcpp **认识的表里的一个新键**,而那里 +的未知键是一条点名该键的解析错误,这正是让拼错不会静默关掉一条声明的机制。因此采用后者 +的包需要把索引 floor 抬到 2026.9.20.1,采用前者的包不需要。 + **指纹。** 解析出的环境参与构建指纹(`compileFlags`,§92 的第 7 项):C 库声明 `lp64` 与 `llp64` 的两次构建,从同一份源码编译出 `long` 宽度不同的目标文件,因此二者绝不共享输出目录, 也不会复用对方产出的目标文件缓存。 From 10fa191d4794080b3834238a39367b966403d88a Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:56:55 +0800 Subject: [PATCH 05/31] the execution record: what the measurement settled, and the three defects found on the way Co-authored-by: Claude Code --- .../2026-09-20-ecosystem-execution-plan.md | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/.agents/docs/2026-09-20-ecosystem-execution-plan.md b/.agents/docs/2026-09-20-ecosystem-execution-plan.md index 3ffe319a..1dd2db15 100644 --- a/.agents/docs/2026-09-20-ecosystem-execution-plan.md +++ b/.agents/docs/2026-09-20-ecosystem-execution-plan.md @@ -62,3 +62,64 @@ C openkal-musl(absent + requires)─────┘ - P3(撤 `__CYGWIN__`):需要 E1 的重测数据才能判断代价,本轮只记录证据,不落地。 - P4(合成节点层):openkal-musl 的移植工作量独立,另轮。 - P6(openkal-win-ucrt):需要 c++-abi 侧配套,另轮。 + +--- + +## 4. 执行记录(2026-09-20) + +### 4.1 E1 测量:声明第一次生效 + +`tests/openkal/pins.toml` 的 `runtime` 从 0.10.0 抬到 0.12.0,重测 30 个成员 +(mcpp-index run `35503820978`,mcpp 2026.9.18.3,llvm@22.1.8)。 + +| target | 之前 | 之后 | +| --- | --- | --- | +| `x86_64-linux-gnu` | 27 runs / 3 fails | 27 runs / 3 fails | +| `x86_64-windows-gnu` | 15 runs / 15 fails | **23 runs / 7 fails** | + +**八个成员零适配转绿**:asio(经 cmp-module)、catch2、cli11、eigen、fmtlib.fmt、 +libpng、re2、lua(经 capi-lua)。全部是按 `_WIN32` / `__MINGW32__` 选分支的那一类。 + +**剩余七个分三组**,与评审文档的预测逐条对上: + +| member | 诊断 | 预测 | 结果 | +| --- | --- | --- | --- | +| mimalloc | `atomic.h:16 'windows.h'` | `__CYGWIN__` 挡住 | 确认 | +| sqlite3 | `sqlite3.c:29506 'windows.h'` | `__CYGWIN__` 挡住 | 确认 | +| archive(xz) | `tuklib_physmem.c:21 'windows.h'` | 本来就要 configure | 确认 | +| c-ares | `ares_setup.h:81 'windows.h'` | 同上 | 确认 | +| curl | `"too small curl_off_t"` | 同上 | 确认 | +| doctest | `undefined symbol: __cxa_thread_atexit` | **未预测** | 新发现 | +| spdlog | 同上 | **未预测** | 新发现 | + +### 4.2 本轮新发现 + +1. **`__CYGWIN__` 的 trade-off 已可结算。** mimalloc 的守卫注释写着 + "we use windows locks on cygwin, but otherwise treat it at unix",sqlite3 的 + `SQLITE_OS_WIN` 检测列表含 `__CYGWIN__`。**上游用这个名字回答的是"Win32 可用", + 不是"对象格式是 PE"。** 这是 P3 的直接判据,可在下一轮落地。 + +2. **`__cxa_thread_atexit` 缺口。** doctest 与 spdlog 此前停在缺头文件,现在编译 + 过去、停在链接。这是 openkal 之上 C++ 运行时的缺口(libc++abi 用来登记 + `thread_local` 析构的钩子),在环境正确之前到不了。属 openkal-llvm-runtime / + openkal-musl,另轮。 + +3. **clang 20.1.7 在 Windows 上对三种写法都崩。** `[c-abi.absent]` 的解析块写成 + `parse_string` 内的语句块、写成模块导出 purview 里返回 + `expected, string>` 的自由函数、以及用成员指针作 + sort 投影,在 Windows 上各崩一次,其他宿主全过。最终形态是匿名命名空间里的内部 + helper + 出参 + `optional` + 比较器。 + +4. **两个新键的向后兼容性不同,且这条差别要写进文档。** `[kernel-abi]` 是未知 + **顶层表**,旧引擎忽略(实测 2026.9.17.1 静默接受);`[c-abi.absent]` 是已知表里的 + 新键,旧引擎拒绝整份清单。因此前者不需要抬 floor,后者需要。 + +### 4.3 PR + +| 仓库 | PR | 内容 | +| --- | --- | --- | +| mcpp | #678 | 探针带目标、撤 hostStripMacros、`[kernel-abi]`、`[c-abi.absent]`、冻结 `presents` | +| openkal | #42 | `check-surface.sh --interfaces/--toml`,接口集由产物派生 | +| openkal-musl | #39 | 0.17.0 `[c-abi.absent]` + CI 断言 | +| openkal-linux | #29 | 0.15.0 `provides-interfaces`,CI 重新生成并 diff | +| mcpp-index | #444 | 抬 pins、重测、`refused` | From 9d337bda1a08d35e4be535e59df4b2f38edab9bf Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Sun, 20 Sep 2026 18:59:14 +0800 Subject: [PATCH 06/31] the wave's sandbox verification, and the reading from each of its two runs Every CHANGE section is run against the published engine first and must fail there. Recorded in the script: 2026.9.17.1 reports fails=3 and 2026.9.20.1 reports fails=0, and the one leg that passes on both is named together with why that is the documented behaviour rather than a hole. Co-authored-by: Claude Code --- .../docs/2026-09-20-cenv-interfaces-verify.sh | 224 ++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100755 .agents/docs/2026-09-20-cenv-interfaces-verify.sh diff --git a/.agents/docs/2026-09-20-cenv-interfaces-verify.sh b/.agents/docs/2026-09-20-cenv-interfaces-verify.sh new file mode 100755 index 00000000..4efb2dc8 --- /dev/null +++ b/.agents/docs/2026-09-20-cenv-interfaces-verify.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash +# Ecosystem verification for the 2026.9.20.1 wave against the PUBLISHED mcpp +# and index, run inside a SubOS sandbox with CN mirrors for xlings and mcpp. +# +# B64=$(base64 -w0 .agents/docs/2026-09-20-cenv-interfaces-verify.sh) +# xlings subos new v920 +# xlings subos use v920 --sandbox --cmd \ +# "echo $B64 | base64 -d > /tmp/v.sh && MCPP_VERIFY_VERSION=2026.9.20.1 bash /tmp/v.sh" +# +# Run it once against the PREVIOUS release first +# (MCPP_VERIFY_VERSION=2026.9.18.3): every section marked CHANGE must fail there +# and pass here; every section marked GUARD must pass on both. A section that +# passes on both releases in a CHANGE section measured nothing. +# +# The sandbox's $HOME persists between runs of one SubOS, so each section clears +# its own directory. A section that cannot run says so and is listed again at +# the end, because a run that reports only failures cannot be told from one that +# examined nothing. +# TWO RUNS, AND THE READING FROM EACH (host dry run, 2026-09-20): +# +# mcpp 2026.9.17.1 (published) fails=3 +# B a requirement the implementation does not provide must be refused +# C a declared absence with a known shape must be accepted +# C the refusal does not name the shapes -- "[c-abi] has no member 'absent'" +# mcpp 2026.9.20.1 fails=0 +# +# B's FIRST leg passes on both, and that is the documented behaviour rather +# than a hole: `[kernel-abi]` is an unknown top-level table to an older engine +# and is ignored, so a graph that satisfies its requirements builds either way. +# The leg that distinguishes the releases is the refusal. C fails outright on +# the older engine because `[c-abi.absent]` is a new key inside a table it +# knows, where an unrecognised key is a parse error -- the asymmetry docs/22 +# records. +set -u + +VER="${MCPP_VERIFY_VERSION:?set MCPP_VERIFY_VERSION}" +STORE="${MCPP_VERIFY_BIN:-$HOME/.xlings/data/xpkgs/xim-x-mcpp/$VER/bin/mcpp}" + +fails=0 +skipped="" +fail() { printf 'ASSERT-FAIL: %s\n' "$1"; fails=$((fails + 1)); } +ok() { printf 'ok: %s\n' "$1"; } +section() { printf '\n== %s ==\n' "$1"; } +skip() { printf 'NOT RUN: %s\n' "$1"; skipped="$skipped + - $1"; } +unset XLINGS_ACTIVE_SUBOS + +root="$HOME/verify-920" +rm -rf "$root"; mkdir -p "$root" + +section "A. identity and mirror" +if [ ! -x "$STORE" ]; then + skip "mcpp $VER is not in the store at $STORE" + printf '\n-- summary --\nfails=%d\nnot run:%s\n' "$fails" "${skipped:- (none)}" + exit 1 +fi +got="$("$STORE" --version 2>&1 | head -1)" +case "$got" in + *"$VER"*) ok "mcpp $VER from $STORE" ;; + *) fail "the binary at $STORE reports '$got'" ;; +esac +"$STORE" self config --mirror CN >/dev/null 2>&1 \ + && ok "mcpp mirror set to CN" || fail "mcpp self config --mirror CN" + +# ── CHANGE 1. A package states which interfaces of the layer it requires ───── +# +# The engine knows no interface name: it compares two sets and refuses before +# anything is compiled. Both legs are here because only the second says the +# comparison happened -- a build that succeeds proves nothing about a check +# that never ran. +section "B. requires-interfaces is answered at resolution (CHANGE)" +b="$root/b"; rm -rf "$b"; mkdir -p "$b/impl/src" "$b/src" +printf 'int fake_kernel_marker(void){return 0;}\n' > "$b/impl/src/lib.c" +printf 'int main(void){return 0;}\n' > "$b/src/main.c" +cat > "$b/impl/mcpp.toml" <<'EOF' +[package] +name = "fakekernel" +version = "0.1.0" +provides = ["mcpp:kernel-abi=openkal"] + +[targets.fakekernel] +kind = "lib" +sources = ["src/*.c"] + +[kernel-abi] +provides-interfaces = ["openkal.abort", "openkal.stream", "openkal.memory"] +EOF +mk_root() { # $1 = requires-interfaces body + cat > "$b/mcpp.toml" </dev/null 2>&1); then + ok "a requirement the implementation provides builds" +else + fail "a requirement the implementation provides must build" +fi +mk_root '"openkal.stream", "openkal.net"' +rm -rf "$b/target" +out="$(cd "$b" && "$STORE" build 2>&1)" +if [ $? -eq 0 ]; then + fail "a requirement the implementation does not provide must be refused" +else + case "$out" in + *openkal.net*iface-probe*|*iface-probe*openkal.net*) + ok "the refusal names the interface and the package" ;; + *) fail "the refusal does not name both: $(printf '%s' "$out" | head -3 | tr '\n' ' ')" ;; + esac + if [ -d "$b/target" ] && find "$b/target" -name '*.o' -print -quit 2>/dev/null | grep -q .; then + fail "the refusal arrived after something was compiled" + else + ok "nothing was compiled before the refusal" + fi +fi + +# ── CHANGE 2. A C library states what it does not supply ──────────────────── +section "C. [c-abi.absent] is read, and a bad shape is refused (CHANGE)" +c="$root/c"; rm -rf "$c"; mkdir -p "$c/libc/src" "$c/src" +printf 'int fake_libc_marker(void){return 0;}\n' > "$c/libc/src/lib.c" +printf 'int main(void){return 0;}\n' > "$c/src/main.c" +cat > "$c/mcpp.toml" <<'EOF' +[package] +name = "absent-probe" +version = "0.1.0" + +[dependencies] +fakelibc = { path = "libc" } + +[build] +allow_host_libs = true +EOF +mk_libc() { # $1 = the form value + cat > "$c/libc/mcpp.toml" </dev/null 2>&1); then + ok "a declared absence with a known shape is accepted" +else + fail "a declared absence with a known shape must be accepted" +fi +mk_libc sometimes +rm -rf "$c/target" +out="$(cd "$c" && "$STORE" build 2>&1)" +if [ $? -eq 0 ]; then + fail "an absence with an unknown shape must be refused" +else + case "$out" in + *accepted-no-effect*) ok "the refusal names the shapes that exist" ;; + *) fail "the refusal does not name the shapes: $(printf '%s' "$out" | head -2 | tr '\n' ' ')" ;; + esac +fi + +# ── GUARD. A package that declares neither key is untouched ───────────────── +section "D. a package declaring neither key is unchanged (GUARD)" +d="$root/d"; rm -rf "$d"; mkdir -p "$d/src" +printf '#include \nint main(){std::puts("plain");return 0;}\n' > "$d/src/main.cpp" +cat > "$d/mcpp.toml" <<'EOF' +[package] +name = "plain" +version = "0.1.0" +EOF +if (cd "$d" && "$STORE" build >/dev/null 2>&1) \ + && "$d"/target/*/*/bin/plain 2>/dev/null | grep -q plain; then + ok "a package declaring nothing builds and runs" +else + fail "a package declaring nothing must build and run unchanged" +fi + +# ── GUARD. openkal from the published index ───────────────────────────────── +section "E. an openkal program from the published index (GUARD)" +e="$root/e"; rm -rf "$e"; mkdir -p "$e/src" +printf '#include \nint main(){std::puts("openkal ok");return 0;}\n' > "$e/src/main.cpp" +cat > "$e/mcpp.toml" <<'EOF' +[package] +name = "openkal-hello" +version = "0.1.0" + +[dependencies] +openkal-llvm-runtime = "0.12.0" +EOF +if (cd "$e" && "$STORE" build >/dev/null 2>&1); then + if "$e"/target/*/*/bin/openkal-hello 2>/dev/null | grep -q "openkal ok"; then + ok "an openkal program builds and runs from the published index" + else + fail "the openkal program built and did not run" + fi +else + skip "openkal-llvm-runtime 0.12.0 did not resolve from the index" +fi + +printf '\n-- summary --\nfails=%d\nnot run:%s\n' "$fails" "${skipped:- + (none)}" +[ "$fails" -eq 0 ] From 14b28ed4068aa0453d43deb621d5fa8fd57c5fdb Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:04:46 +0800 Subject: [PATCH 07/31] two defects found reviewing this branch: the wrong provider, and a name that is a prefix of another The interface list was read from the first package in the graph that stated one. A graph may carry more than one candidate for a layer --- a workspace member beside a dependency, a second implementation behind a feature that did not activate --- and only one of them is the provider this build resolved. Comparing a consumer's requirements against an implementation the build is not using is a wrong answer rather than a missing one. It now reads the resolved layer. `c_abi_absent_facility_advice` matched the symbol as a substring, and `undefined symbol: open` is a prefix of `undefined symbol: opendir`. A link failure would have been explained by a row with nothing to do with it, and an explanation that is confidently wrong is worse than the linker's own message. The name must now end where the diagnostic's name ends. Co-authored-by: Claude Code --- src/build/ninja_backend.cppm | 23 ++++++++++++++++++++--- src/build/prepare.cppm | 13 +++++++++++++ tests/unit/test_build_flags.cpp | 14 ++++++++++++++ 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index fc9702c3..f058b4d3 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -697,10 +697,27 @@ std::string c_abi_absent_facility_advice( // program at run time by construction, so matching them against a // link diagnostic would report a coincidence of spelling. if (e.form != mcpp::targetside::CAbiAbsentForm::Link) continue; - const std::string lld = std::format("undefined symbol: {}", e.name); + // THE NAME MUST END WHERE THE DIAGNOSTIC'S NAME ENDS. `undefined + // symbol: open` is a prefix of `undefined symbol: opendir`, so a + // plain substring search explains a link failure with a row that has + // nothing to do with it — and an explanation that is confidently + // wrong is worse than the linker's own message. lld ends the name at + // the line; GNU ld closes it with a quote. const std::string gnu = std::format("undefined reference to `{}'", e.name); - if (output.find(lld) == std::string_view::npos - && output.find(gnu) == std::string_view::npos) continue; + bool matched = output.find(gnu) != std::string_view::npos; + if (!matched) { + const std::string lld = std::format("undefined symbol: {}", e.name); + for (std::size_t at = output.find(lld); at != std::string_view::npos; + at = output.find(lld, at + 1)) { + const auto after = at + lld.size(); + if (after >= output.size() || output[after] == '\n' + || output[after] == '\r' || output[after] == ' ') { + matched = true; + break; + } + } + } + if (!matched) continue; named += std::format("\n {:<20} {}", e.name, e.note.empty() ? "declared absent" : e.note); } diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 7b93b6db..14ed7f79 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -11181,10 +11181,23 @@ prepare_build(bool print_fingerprint, // changes no command line and no diagnostic for every project built // before it. { + // THE LIST COMES FROM THE PACKAGE THAT RESOLVED AS THE LAYER, NOT + // FROM THE FIRST ONE IN THE GRAPH THAT STATED ONE. A graph may + // carry more than one candidate for a layer — a workspace member + // beside a dependency, a second implementation reached through a + // feature that did not activate — and only one of them is the + // provider this build resolved. Reading whichever came first in + // `packages` would compare a consumer's requirements against an + // implementation the build is not using, which is a wrong answer + // rather than a missing one. std::vector providedInterfaces; std::string providerId; for (auto& pkg : packages) { if (pkg.manifest.kernelAbiProvidesInterfaces.empty()) continue; + if (!resolvedTargetSide.kernelAbi.impl.empty() + && resolvedTargetSide.kernelAbi.impl.find( + pkg.manifest.package.name) == std::string::npos) + continue; providedInterfaces = pkg.manifest.kernelAbiProvidesInterfaces; providerId = pkg.manifest.package.name; break; diff --git a/tests/unit/test_build_flags.cpp b/tests/unit/test_build_flags.cpp index b6565ac9..e2d89cff 100644 --- a/tests/unit/test_build_flags.cpp +++ b/tests/unit/test_build_flags.cpp @@ -267,3 +267,17 @@ TEST(CAbiAbsentAdvice, ALibraryThatEnumeratedNothingProducesNoNote) { EXPECT_TRUE(mcpp::build::c_abi_absent_facility_advice( "ld.lld: error: undefined symbol: fork\n", "musl", {}).empty()); } + +TEST(CAbiAbsentAdvice, ALongerSymbolWithTheSamePrefixIsNotExplained) { + // `undefined symbol: open` is a prefix of `undefined symbol: opendir`. + // A substring search would answer a link failure with a row that has + // nothing to do with it, and an explanation that is confidently wrong is + // worse than the linker's own message. + std::vector absent{ + {"open", mcpp::targetside::CAbiAbsentForm::Link, "not supplied"}, + }; + EXPECT_TRUE(mcpp::build::c_abi_absent_facility_advice( + "ld.lld: error: undefined symbol: opendir\n", "musl", absent).empty()); + EXPECT_FALSE(mcpp::build::c_abi_absent_facility_advice( + "ld.lld: error: undefined symbol: open\n", "musl", absent).empty()); +} From 9aa57bebe384a3a0d32a6c8686d1fdd8059ee86c Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:08:00 +0800 Subject: [PATCH 08/31] the record: what this wave did not do, and the first two implementations whose sets differ Co-authored-by: Claude Code --- .../2026-09-20-ecosystem-execution-plan.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.agents/docs/2026-09-20-ecosystem-execution-plan.md b/.agents/docs/2026-09-20-ecosystem-execution-plan.md index 1dd2db15..f4cecaa5 100644 --- a/.agents/docs/2026-09-20-ecosystem-execution-plan.md +++ b/.agents/docs/2026-09-20-ecosystem-execution-plan.md @@ -123,3 +123,24 @@ libpng、re2、lua(经 capi-lua)。全部是按 `_WIN32` / `__MINGW32__` 选分 | openkal-musl | #39 | 0.17.0 `[c-abi.absent]` + CI 断言 | | openkal-linux | #29 | 0.15.0 `provides-interfaces`,CI 重新生成并 diff | | mcpp-index | #444 | 抬 pins、重测、`refused` | + +### 4.4 本轮未做,以及为什么 + +| 项 | 状态 | 理由 | +| --- | --- | --- | +| openkal-macos 的 `provides-interfaces` | 未做 | 该实现无法在 Linux 宿主交叉构建(`aarch64-macos` 没有本机载荷),而这份清单的纪律是**由产物派生**。手写它就是这套机制存在的理由所反对的那件事。留给一次能在 macOS runner 上生成它的改动 | +| P3 撤 `__CYGWIN__` | 未做 | 判据已具备(mimalloc 与 sqlite3 的守卫),但它会改变 libarchive 生成配置头里的 `#if defined(_WIN32) && !defined(__CYGWIN__)` 分支,代价需要一次重测才能称量。本轮把证据记入 mcpp-index 的 `docs/openkal-compat.md` | +| P4 合成节点层 | 未做 | openkal-musl 的移植工作量独立于本轮 | +| P6 `openkal-win-ucrt` | 未做 | 需要 c++-abi 一侧配套 | +| `__cxa_thread_atexit` | 未做 | 本轮测量新发现;属 openkal-llvm-runtime / openkal-musl | + +### 4.5 两个实现的清单不同,这是这套机制存在的理由 + +| 实现 | 接口数 | 差异 | +| --- | --- | --- | +| openkal-linux 0.15.0 | 15 | 全部 | +| openkal-windows 0.10.0 | 14 | 缺 `openkal.space` | + +需要 `openkal.space` 的消费者在 Windows 上被**解析期**拒绝,在 Linux 上构建。 +一个给环境类别起的名字会让这两个实现看起来一样——这正是 SPEC §3.3 撤回 `hosted` +的理由,而这里是它的第一个实例。 From f0573e0ed2fd5f5a0a1871326569aa544634c16e Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:17:39 +0800 Subject: [PATCH 09/31] the resolved provider is matched by name, not by substring `Layer::impl` is `name@version`. A substring test matched `openkal` against `openkal-linux@0.15.0` and would have read one implementation's interface list as another's. The name is what precedes the separator. Co-authored-by: Claude Code --- src/build/prepare.cppm | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 14ed7f79..da1152fb 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -11194,10 +11194,17 @@ prepare_build(bool print_fingerprint, std::string providerId; for (auto& pkg : packages) { if (pkg.manifest.kernelAbiProvidesInterfaces.empty()) continue; - if (!resolvedTargetSide.kernelAbi.impl.empty() - && resolvedTargetSide.kernelAbi.impl.find( - pkg.manifest.package.name) == std::string::npos) - continue; + // `impl` is `name@version`; the name is what precedes the + // separator. A substring test would match `openkal` against + // `openkal-linux@0.15.0` and read one implementation's list + // as another's. + if (!resolvedTargetSide.kernelAbi.impl.empty()) { + auto const& impl = resolvedTargetSide.kernelAbi.impl; + const auto at = impl.find('@'); + const auto implName = at == std::string::npos + ? impl : impl.substr(0, at); + if (implName != pkg.manifest.package.name) continue; + } providedInterfaces = pkg.manifest.kernelAbiProvidesInterfaces; providerId = pkg.manifest.package.name; break; From 9cfa9eefafeae051cc1e12f8c5210d3ebcc0c9b4 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:18:45 +0800 Subject: [PATCH 10/31] the wave's self-review: five defects, and the one place this landing differs most from its design Three of the five were found by reading the branch's own diff and were green on every host: the interface list read from the first package that stated one rather than the resolved provider, a package name matched as a substring of `name@version`, and a symbol matched as a substring of a longer one. The other two were found by CI. The largest gap between the design and what landed is P7's L3, the link-time set difference. Without it a consumer's `requires-interfaces` is still only trusted: a package may declare fewer interfaces than it calls, pass resolution, and link on an implementation that provides them --- failing only on one that does not, on a user's target rather than in its author's build. Co-authored-by: Claude Code --- .agents/docs/2026-09-20-wave-self-review.md | 92 +++++++++++++++++++++ .agents/docs/README.md | 4 +- 2 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 .agents/docs/2026-09-20-wave-self-review.md diff --git a/.agents/docs/2026-09-20-wave-self-review.md b/.agents/docs/2026-09-20-wave-self-review.md new file mode 100644 index 00000000..8d0c92c4 --- /dev/null +++ b/.agents/docs/2026-09-20-wave-self-review.md @@ -0,0 +1,92 @@ +--- +subject: review +status: active +--- + +# 本轮生态级自审 + +- 日期:2026-09-20 +- 对象:`2026-09-20-openkal-c-environment-ecosystem-design.md` 所规划、本轮落地的六个仓库改动 +- 依据:`2026-09-20-ecosystem-execution-plan.md` 的执行记录 + +--- + +## 1. 落地与设计的对照 + +| 设计项 | 落地 | 与设计的差异 | +| --- | --- | --- | +| P0.1 探针带目标 | mcpp#678 | 多了一个装配函数:不变量放在调用点靠记性,放在装配处靠结构 | +| P0.2 `builtins` 注释 | mcpp#678 | 无 | +| P1 冻结 `presents` | mcpp#678 | 无 | +| P2 `refused` | mcpp-index#444(已合入) | 判据从"显式标注"改为引擎的拒绝码,D4 的建议被数据推翻:显式标注没有第一个使用者 | +| P3 撤 `__CYGWIN__` | 未做 | 判据已具备,代价需一次重测 | +| P4 合成节点 | 未做 | 独立工作量 | +| P5 接口列举 | mcpp#678 + openkal#42 + openkal-linux#29 + openkal-windows#26 | 设计说"引擎新增两处",实际只新增解析期一处:链接期那一处(L3)未做 | +| P6 `openkal-win-ucrt` | 未做 | 需 c++-abi 配套 | +| P7 校验阶梯 | L1 + L2 + `[c-abi.absent]` 落地;L3 未做 | 见下 | + +**与设计最大的偏差是 P7 的 L3(链接期集合差)。** 设计把它列为"`requires-interfaces` 唯一 +的校验者",本轮没有做。后果要写清楚:**消费者一侧的声明目前仍然只被信任。** 一个包可以 +声明少于它实际调用的接口,解析期通过,在提供该接口的实现上链接也通过——只有在不提供的 +实现上才会炸,而那时错误出现在用户的目标上。这一条应当是下一轮的第一项。 + +--- + +## 2. 多维核查 + +| 维度 | 核查 | 结论 | +| --- | --- | --- | +| **架构** | 新增机制是否落在 §6.2 的三时刻表内 | 是。`requires/provides-interfaces` 在解析期,`[c-abi.absent]` 的 `link` 形状在链接期,`enosys` 在运行期。没有一项落在预处理期 | +| **兼容性** | 未声明任何新键的包命令行是否逐字节不变 | 是。e2e 743 的 C 腿与 verify 的 D 段各验一次 | +| | 两个新键对旧引擎的行为 | 不同,且已写入 docs/22 与其 zh 镜像:`[kernel-abi]` 被忽略,`[c-abi.absent]` 被拒。前者不需要抬 floor,后者需要 | +| **跨平台** | Windows / macOS / freestanding | 探针修复的直接受益者是 freestanding(此前在任何宿主上都空转)。Windows 上 clang 20.1.7 的三次前端崩溃是本轮实测发现,最终形态写下了理由 | +| **一致性** | 同一个事实是否只有一处 | 提升了一处:openkal-musl 的 CI 手写 withheld 清单被 `[c-abi.absent]` 取代。降低了零处 | +| **无感升级** | 旧图是否照常构建 | 是。"一个什么都没陈述的提供者,不是一个什么都不提供的提供者"由 e2e 743 的 C 腿钉住 | +| **测试覆盖** | 新增判据是否可证伪 | 逐条检查见 §3 | +| **优雅简洁** | 新增概念数 | 一个(接口列举)。`[c-abi.absent]` 是既有 `[c-abi]` 的延伸,`assemble_argv` 是既有装配的提取 | +| **用户体验** | 失败时读者能否知道改哪一边 | 接口拒绝同时点名缺的接口、要它的包、没提供它的实现;`[c-abi.absent]` 的诊断带上那一行的 `note` | + +--- + +## 3. 每条判据是否可证伪 + +| 判据 | 能失败吗 | 如何确认的 | +| --- | --- | --- | +| `assemble_argv` 拒绝无目标的 freestanding argv | 是 | 单测直接构造该形状 | +| 探针量的是目标不是宿主 | 是 | 本机实测真实命令:`__riscv`、wchar 4、无 `__linux__` | +| 接口不满足即拒绝 | 是 | e2e 743 的 B 腿,并断言**未编译任何东西** | +| 提供者什么都没说时不拒绝 | 是 | e2e 743 的 C 腿 | +| `[c-abi.absent]` 与产物一致 | 是 | openkal-musl CI 翻转一行并要求脚本拒绝 | +| `provides-interfaces` 与产物一致 | 是 | openkal-linux / -windows CI 重新生成并 diff | +| 接口清单不是"打印 SURFACE.txt 的每一组" | 是 | openkal CI 的半组腿:`openkal.fs` 只导出两个名字,必须不被列出 | +| 沙箱 CHANGE 段确实区分两个版本 | 是 | 对 2026.9.17.1 跑出 fails=3,对 2026.9.20.1 跑出 fails=0 | + +**一条判据曾经不可证伪并已更正**:`CenvProbe` 的两个端到端探针测试在 gcc 宿主上 skip。 +跳过的判据等于没有判据,所以不变量被提升为 `assemble_argv` 这个纯函数,五个新测试不需要 +交叉工具链。 + +--- + +## 4. 本轮自审中发现并修掉的缺陷 + +| # | 缺陷 | 形状 | +| --- | --- | --- | +| 1 | 接口清单取"图里第一个声明的包"而不是**被解析出的提供者** | 一个图可能有多个候选,读错的是另一个实现的清单——错误答案而不是缺失答案 | +| 2 | 提供者用包名**子串**匹配 `impl` | `openkal` 命中 `openkal-linux@0.15.0` | +| 3 | `[c-abi.absent]` 的诊断用符号**子串**匹配 | `undefined symbol: open` 是 `opendir` 的前缀;一个自信的错误解释比链接器自己的消息更坏 | +| 4 | openkal CI 的接口步量的是 `impl-fd`,而它**一个接口都不整组提供** | 判据的对象选错了 | +| 5 | openkal CI 的接口步读到上一步遗留的目标文件 | 上一步删了源文件没删产物 | + +第 1、2、3 条是读自己的 diff 时发现的;第 4、5 条由 CI 发现。**前三条在本机全绿,只有 +读代码能发现**——它们都属于"谓词对、对象错"。 + +--- + +## 5. 未关闭,按优先级 + +1. **P7 的 L3(链接期集合差)。** `requires-interfaces` 今天仍只被信任。这是本轮与设计 + 最大的偏差。 +2. **P3 撤 `__CYGWIN__`。** 判据已由 mimalloc 与 sqlite3 给出,代价需一次重测。 +3. **`__cxa_thread_atexit`。** 本轮测量新发现的 C++ 运行时缺口。 +4. **openkal-macos 的 `provides-interfaces`。** 无法在 Linux 宿主派生,需 macOS runner。 +5. **P4 / P6。** 独立工作量。 diff --git a/.agents/docs/README.md b/.agents/docs/README.md index 2d88aa29..944e4410 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 --- ``` -300 records. +301 records. ## By subject @@ -60,6 +60,7 @@ Records that declare one. Everything else is listed by date below. ### review +- [本轮生态级自审](2026-09-20-wave-self-review.md) — active - [#674 设计方案评审:`-include unistd.h` 在 Windows + `presents = "posix"` 上的可行性](2026-09-20-issue-674-design-review.md) — active ### targets @@ -91,6 +92,7 @@ Records that declare one. Everything else is listed by date below. ### 2026-09 +- [本轮生态级自审](2026-09-20-wave-self-review.md) — active - [openkal 生态:能力的时刻模型,以及 C 环境方案空间的划分](2026-09-20-openkal-c-environment-ecosystem-design.md) — active - [#674 设计方案评审:`-include unistd.h` 在 Windows + `presents = "posix"` 上的可行性](2026-09-20-issue-674-design-review.md) — active - [C 环境生态方案:执行计划](2026-09-20-ecosystem-execution-plan.md) — active From 790b3036046fcd030ae64e3c3d1e9b277a7d1fe5 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:19:29 +0800 Subject: [PATCH 11/31] the design under-estimated L3, and the two sentences that cannot both hold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §5.7.3 said the link-time check maps undefined symbols to interfaces through SURFACE.txt; §5.5 said the engine knows no member of either set. The mapping can only come from the graph, so L3's real shape is a second generated table in each implementation's manifest --- about a hundred lines, from the same script. The cost and the benefit are both different from what was written, so L3 is not in this landing, and the consequence of its absence is now written down rather than left implied: a consumer's requires-interfaces is still only trusted. Co-authored-by: Claude Code --- ...-openkal-c-environment-ecosystem-design.md | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/.agents/docs/2026-09-20-openkal-c-environment-ecosystem-design.md b/.agents/docs/2026-09-20-openkal-c-environment-ecosystem-design.md index 1b2ce467..0ef40c7d 100644 --- a/.agents/docs/2026-09-20-openkal-c-environment-ecosystem-design.md +++ b/.agents/docs/2026-09-20-openkal-c-environment-ecosystem-design.md @@ -504,6 +504,33 @@ L3 把这次失败从"某个用户的某个目标"提前到"包作者自己的 少用(声明了没用的)是**提示**而不是错误——一个包可以按 feature 或按目标条件地使用 某个接口,当前这次解析里没用到不代表声明是错的。 +**更正(2026-09-20,落地时发现):L3 的代价被这份设计低估了。** 上文写 L3 的机制是 +「取消费者对象的未定义符号、与 `SURFACE.txt` 求交、映射到接口」——**这要求引擎持有 +符号到接口的映射**,而 §5.5 同时写着「引擎不认识这两个集合的任何一个成员」。两句话 +不能同时成立。 + +映射只能来自图,因此 L3 的真实形状是实现包再声明一张表: + +```toml +[kernel-abi] +provides-interfaces = ["openkal.fs", "openkal.stream", ...] + +[kernel-abi.interface-symbols] # 同样由 SURFACE.txt 生成 +"openkal.fs" = ["kal_fs_open", "kal_fs_close", ...] +"openkal.stream" = ["kal_stdin", "kal_stdout", ...] +``` + +这张表在每个实现的清单里约一百行,由同一个脚本生成,引擎只做「未定义符号 → 它属于 +哪个接口 → 该接口在不在消费者的声明里」这一串查表与集合差,仍然不认识任何名字的含义。 + +代价与收益都变了,因此 L3 **不在本轮落地**,并且它的缺席有一个必须写下的后果: + +> `requires-interfaces` 在消费者一侧**目前只被信任**。一个包可以声明少于它实际调用的 +> 接口,解析期通过,在提供该接口的实现上链接也通过——只有在不提供的实现上才会炸, +> 而那时错误出现在**用户的目标上**,不在包作者自己的构建里。 + +这正是 §5.7.3 说 L3 要消除的那件事,它今天还在。 + #### 5.7.4 风险边界:写错不会静默 这是"这两个事实可以由声明承载"的根据——不是因为声明可信,而是因为**每一种写错都响亮**: From 3eb6ea26f551a499a39edff1f1fa54fb92ee3e22 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:25:39 +0800 Subject: [PATCH 12/31] the absent-facility advice reaches both channels a failed build reports through A build reports failure on two paths: the one with a plan, and the fast path, which has none by construction. `ninja_backend.cppm` already records why that matters for `link_failure_advice` --- advice attached to only one of them "would appear or not depending on whether build.ninja happened to be up to date, which is exactly the kind of 'same decision in two places' this codebase keeps paying for" --- and the new `[c-abi.absent]` advice had been attached to one. The list travels between them in a file beside build.ninja, written when the plan emits it and opened only after a build has already failed. The fast path's purpose is to read as little as possible, so it reads nothing extra until there is a failure to explain. A graph that declares no absence removes the file rather than leaving it: a stale one would let the fast path explain a failure with a list the current graph never declared. Co-authored-by: Claude Code --- src/build/execute.cppm | 13 ++++++ src/build/ninja_backend.cppm | 72 +++++++++++++++++++++++++++++++++ tests/unit/test_build_flags.cpp | 44 ++++++++++++++++++++ 3 files changed, 129 insertions(+) diff --git a/src/build/execute.cppm b/src/build/execute.cppm index 767326c8..ea2751f6 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -1258,6 +1258,19 @@ std::optional run_ninja_fast(const std::string& ninjaProgram, if (auto advice = mcpp::build::graph_c_library_isolation_advice(out); !advice.empty()) std::fputs(advice.c_str(), stderr); + // THE SAME ADVICE THE PLAN PATH GIVES, FROM THE LIST THE PLAN WROTE + // DOWN. This path has no `BuildPlan` by construction, so the C + // library's `[c-abi.absent]` table reaches it through a file beside + // build.ninja rather than through a resolution it exists to skip. + // Advice attached to one path only appears or not depending on + // whether build.ninja happened to be up to date. + { + auto [cAbiName, absent] = mcpp::build::read_c_abi_absent_sidecar( + ninjaPath.parent_path()); + if (auto advice = mcpp::build::c_abi_absent_facility_advice( + out, cAbiName, absent); !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 f058b4d3..8b4f2e13 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -111,6 +111,26 @@ std::string c_abi_absent_facility_advice( std::string_view output, std::string_view cAbiName, const std::vector& absent); +// THE SAME ADVICE ON BOTH PATHS, WHICH IS WHY THE LIST IS WRITTEN DOWN. +// +// A build reports failure through two channels: this one, which has a plan, +// and the fast path (`mcpp.build.execute`), which deliberately has none. An +// advice attached to only one of them appears or not depending on whether +// build.ninja happened to be up to date, which is the same decision in two +// places wearing a different hat. The plan writes the list beside build.ninja; +// the fast path reads it back and calls the same function. +// +// It is a plain file rather than a field in an existing record because the +// fast path's whole purpose is to read as little as possible: this one is +// opened only after a build has already failed. +void write_c_abi_absent_sidecar( + const std::filesystem::path& outputDir, std::string_view cAbiName, + const std::vector& absent); + +// `{name, absent}` read back, or an empty pair when no build wrote one. +std::pair> +read_c_abi_absent_sidecar(const std::filesystem::path& outputDir); + // 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 @@ -686,6 +706,48 @@ std::string link_failure_advice(std::string_view output) { " `std::align_val_t`, which are the ones most often forgotten.\n"; } +namespace { +constexpr std::string_view kCAbiAbsentSidecar = ".mcpp-c-abi-absent"; +} + +void write_c_abi_absent_sidecar( + const std::filesystem::path& outputDir, std::string_view cAbiName, + const std::vector& absent) { + const auto path = outputDir / kCAbiAbsentSidecar; + std::error_code ec; + if (absent.empty()) { std::filesystem::remove(path, ec); return; } + std::ofstream f(path, std::ios::binary | std::ios::trunc); + if (!f) return; + f << cAbiName << '\n'; + for (auto const& e : absent) + f << mcpp::targetside::c_abi_absent_form_name(e.form) << '\t' + << e.name << '\t' << e.note << '\n'; +} + +std::pair> +read_c_abi_absent_sidecar(const std::filesystem::path& outputDir) { + std::pair> out; + std::ifstream f(outputDir / kCAbiAbsentSidecar, std::ios::binary); + if (!f) return out; + std::getline(f, out.first); + std::string line; + while (std::getline(f, line)) { + if (line.empty()) continue; + const auto a = line.find('\t'); + if (a == std::string::npos) continue; + const auto b = line.find('\t', a + 1); + if (b == std::string::npos) continue; + auto form = mcpp::targetside::parse_c_abi_absent_form(line.substr(0, a)); + if (!form) continue; + mcpp::targetside::CAbiAbsentEntry e; + e.form = *form; + e.name = line.substr(a + 1, b - a - 1); + e.note = line.substr(b + 1); + out.second.push_back(std::move(e)); + } + return out; +} + std::string c_abi_absent_facility_advice( std::string_view output, std::string_view cAbiName, const std::vector& absent) { @@ -3097,6 +3159,14 @@ std::expected NinjaBackend::build(const BuildPlan& plan plan.outputDir}); auto ninja_path = plan.outputDir / "build.ninja"; + // Written beside build.ninja and not into it: the fast path replays the + // ninja file without a plan, and the advice a failed link needs has to + // reach both channels or it appears depending on whether build.ninja + // happened to be up to date. + write_c_abi_absent_sidecar( + plan.outputDir, plan.targetSide.cAbi.interfaceName, + plan.targetSide.cAbiDecl ? plan.targetSide.cAbiDecl->absent + : std::vector{}); auto manifest = emit_ninja_string(plan); stage("emit-ninja"); @@ -3465,6 +3535,8 @@ std::expected NinjaBackend::build(const BuildPlan& plan diagnostics += c_abi_absent_facility_advice( out, plan.targetSide.cAbi.interfaceName, plan.targetSide.cAbiDecl->absent); + // (the fast path reads the same list from the sidecar this build + // wrote beside build.ninja — see `write_c_abi_absent_sidecar`) return std::unexpected(BuildError{"build failed", plan.outputDir / "build.ninja", std::move(diagnostics)}); } diff --git a/tests/unit/test_build_flags.cpp b/tests/unit/test_build_flags.cpp index e2d89cff..c8bbc2ee 100644 --- a/tests/unit/test_build_flags.cpp +++ b/tests/unit/test_build_flags.cpp @@ -281,3 +281,47 @@ TEST(CAbiAbsentAdvice, ALongerSymbolWithTheSamePrefixIsNotExplained) { EXPECT_FALSE(mcpp::build::c_abi_absent_facility_advice( "ld.lld: error: undefined symbol: open\n", "musl", absent).empty()); } + +TEST(CAbiAbsentAdvice, TheListSurvivesAWriteAndReadBesideBuildNinja) { + // The two paths that report a failed build --- the one with a plan and the + // fast path, which has none by construction --- must give the same advice, + // or it appears depending on whether build.ninja happened to be up to + // date. The list travels between them in a file. + Tmp dir; + std::vector absent{ + {"fork", mcpp::targetside::CAbiAbsentForm::Link, "no image duplication"}, + {"mprotect", mcpp::targetside::CAbiAbsentForm::Enosys, ""}, + }; + mcpp::build::write_c_abi_absent_sidecar(dir.path, "musl", absent); + auto [name, back] = mcpp::build::read_c_abi_absent_sidecar(dir.path); + EXPECT_EQ(name, "musl"); + ASSERT_EQ(back.size(), 2u); + EXPECT_EQ(back[0].name, "fork"); + EXPECT_EQ(back[0].form, mcpp::targetside::CAbiAbsentForm::Link); + EXPECT_EQ(back[0].note, "no image duplication"); + EXPECT_EQ(back[1].form, mcpp::targetside::CAbiAbsentForm::Enosys); + EXPECT_TRUE(back[1].note.empty()); + // And the advice built from the read-back list is the advice the plan + // path would have given. + EXPECT_FALSE(mcpp::build::c_abi_absent_facility_advice( + "ld.lld: error: undefined symbol: fork\n", name, back).empty()); +} + +TEST(CAbiAbsentAdvice, ADirectoryNoBuildWroteToYieldsNothing) { + Tmp dir; + auto [name, back] = mcpp::build::read_c_abi_absent_sidecar(dir.path); + EXPECT_TRUE(name.empty()); + EXPECT_TRUE(back.empty()); +} + +TEST(CAbiAbsentAdvice, AGraphThatDeclaresNoAbsenceLeavesNoSidecar) { + // A build whose C library states nothing must not leave a file behind for + // the next build to read: the fast path would then explain a failure with + // a list the current graph never declared. + Tmp dir; + mcpp::build::write_c_abi_absent_sidecar(dir.path, "musl", + {{"fork", mcpp::targetside::CAbiAbsentForm::Link, ""}}); + mcpp::build::write_c_abi_absent_sidecar(dir.path, "", {}); + auto [name, back] = mcpp::build::read_c_abi_absent_sidecar(dir.path); + EXPECT_TRUE(back.empty()); +} From 9af26c6fa30c7d342c1de084cd87428b81654b75 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:32:40 +0800 Subject: [PATCH 13/31] the design points at the record of what landed and what did not Co-authored-by: Claude Code --- .../2026-09-20-openkal-c-environment-ecosystem-design.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.agents/docs/2026-09-20-openkal-c-environment-ecosystem-design.md b/.agents/docs/2026-09-20-openkal-c-environment-ecosystem-design.md index 0ef40c7d..5a635a64 100644 --- a/.agents/docs/2026-09-20-openkal-c-environment-ecosystem-design.md +++ b/.agents/docs/2026-09-20-openkal-c-environment-ecosystem-design.md @@ -750,6 +750,11 @@ libarchive,且都在问"平台 SDK / 内核头在不在图里"——正是 P5 ## 9. 顺序 +**落地记录见 `2026-09-20-ecosystem-execution-plan.md` §4,自审见 +`2026-09-20-wave-self-review.md`。** 本轮落地 P0、P1、P2、P5、P7(L1/L2/`[c-abi.absent]`), +未落地 P3、P4、P6 与 P7 的 L3;每一项未落地的理由与它缺席的后果都写在那两份记录里。 + + ``` P0 探针修复 ─┐ P1 冻结语义 ─┼─ 互不依赖,可并行 From 71ee489aec06316578d12b92f30fc6582bf0c960 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:39:05 +0800 Subject: [PATCH 14/31] the two probe tests that never ran anywhere now run `find_a_cxx_compiler` answers `/usr/bin/c++` first, which is GCC on every host this suite runs on, and GCC takes no `--target`. The two tests that check the probe measures the target rather than the host therefore skipped on every host including the CI shards with an LLVM toolchain --- coverage that looks like coverage and is not, which is the shape the rest of this branch exists to remove. They now look for a clang, and for one that actually has the back end they name: the host this was written on carries a vendor clang with neither RISC-V nor AArch64, so a PATH-only search would have skipped for a reason that has nothing to do with what is being tested. The search asks each candidate before accepting it, and falls back to the LLVM payload mcpp installs, which every shard resolving an `llvm@` toolchain has already downloaded. Measured here: 15 passed, 0 skipped, where it was 13 passed and 2 skipped. Co-authored-by: Claude Code --- tests/unit/test_cenv_probe.cpp | 91 +++++++++++++++++++++++++++++----- 1 file changed, 79 insertions(+), 12 deletions(-) diff --git a/tests/unit/test_cenv_probe.cpp b/tests/unit/test_cenv_probe.cpp index b131c44d..ef447977 100644 --- a/tests/unit/test_cenv_probe.cpp +++ b/tests/unit/test_cenv_probe.cpp @@ -37,6 +37,7 @@ import std; import mcpp.toolchain.cenv_probe; +import mcpp.platform; namespace cp = mcpp::toolchain::cenv_probe; @@ -64,6 +65,77 @@ std::filesystem::path cxx() { return p; } +// A CLANG, FOR THE TWO TESTS THAT NEED ONE, AND FOUND ON PATH RATHER THAN AT A +// FIXED LOCATION. +// +// `find_a_cxx_compiler` answers `/usr/bin/c++` first, which is GCC on every +// host this suite runs on --- and GCC takes no `--target`. The two tests below +// therefore skipped on EVERY host, including the CI shards that have an LLVM +// toolchain, which is the shape of coverage that looks like coverage and is +// not. Searching PATH makes them run wherever a clang is reachable; where none +// is, they skip and say so, and `CenvProbeArgv` still pins the invariant with +// no compiler at all. +// Whether this compiler both takes `--target` and has the back end the two +// tests below name. A clang that answers for neither is not a smaller version +// of one that does; it is a different tool for this purpose. +bool answers_for_a_cross_target(const std::filesystem::path& bin) { + auto r = mcpp::platform::process::capture_stdout( + {bin.string(), "--target=riscv64-none-elf", "-x", "c++", "-E", "-dM", "-"}); + return r.exit_code == 0 + && r.output.find("#define __riscv ") != std::string::npos; +} + +std::filesystem::path find_a_clang() { + if (const char* env = std::getenv("MCPP_TEST_CLANGXX"); env && *env) + return env; + const char* path = std::getenv("PATH"); + if (!path) return {}; + std::string_view rest{path}; +#ifdef _WIN32 + constexpr char kSep = ';'; + constexpr std::string_view kName = "clang++.exe"; +#else + constexpr char kSep = ':'; + constexpr std::string_view kName = "clang++"; +#endif + while (!rest.empty()) { + auto at = rest.find(kSep); + auto dir = rest.substr(0, at); + rest = (at == std::string_view::npos) ? std::string_view{} + : rest.substr(at + 1); + if (dir.empty()) continue; + std::error_code ec; + auto candidate = std::filesystem::path(dir) / kName; + if (std::filesystem::exists(candidate, ec) && answers_for_a_cross_target(candidate)) + return candidate; + } + // AND THE PAYLOAD mcpp ITSELF INSTALLS. A clang on PATH is not + // necessarily one built with the back end this test names --- the host + // this was written on carries a vendor clang with neither RISC-V nor + // AArch64, so a PATH-only search skipped for a reason that has nothing to + // do with what is being tested. mcpp's own LLVM payload has them, and + // every CI shard that resolves an `llvm@` toolchain has downloaded it. + std::error_code ec; + auto home = std::getenv("MCPP_HOME") + ? std::filesystem::path(std::getenv("MCPP_HOME")) + : std::filesystem::path( + std::getenv("HOME") ? std::getenv("HOME") : ".") / ".mcpp"; + auto payloads = home / "registry" / "data" / "xpkgs" / "xim-x-llvm"; + if (std::filesystem::is_directory(payloads, ec)) + for (auto const& ver : std::filesystem::directory_iterator(payloads, ec)) { + auto candidate = ver.path() / "bin" / kName; + if (std::filesystem::exists(candidate, ec) + && answers_for_a_cross_target(candidate)) + return candidate; + } + return {}; +} + +std::filesystem::path clangxx() { + static const std::filesystem::path p = find_a_clang(); + return p; +} + // The host's own word size, measured the same way `cenv::realise`'s callers // would declare it — this test's "declared" values are deliberately chosen // relative to the REAL host, so a correct declaration is one that could @@ -295,17 +367,12 @@ namespace { // Whether this compiler answers for a target it is not hosted on. A GCC // driver does not take `--target`, and a clang built without the RISC-V // backend answers nothing useful; both skip. -bool answers_for_riscv() { - TmpCache probe; - auto r = cp::verify(cxx(), {"--target=riscv64-none-elf"}, 0, 0, - {"__riscv"}, {}, probe.dir); - return r.has_value() && r->mismatches.empty(); -} +bool answers_for_riscv() { return !clangxx().empty(); } } // namespace TEST(CenvProbe, AnArgvWithATargetMeasuresThatTargetAndNotTheHost) { - if (cxx().empty()) GTEST_SKIP() << "no C++ compiler found to probe"; - if (!answers_for_riscv()) GTEST_SKIP() << "this compiler does not answer for riscv64-none-elf"; + if (clangxx().empty()) GTEST_SKIP() << "no clang++ on PATH"; + if (!answers_for_riscv()) GTEST_SKIP() << "this clang does not answer for riscv64-none-elf"; TmpCache cache; // The freestanding probe's own shape, with the target selection the // caller (`mcpp.build.prepare`) now supplies. `__linux__` must be absent @@ -317,7 +384,7 @@ TEST(CenvProbe, AnArgvWithATargetMeasuresThatTargetAndNotTheHost) { }; std::vector defined{"__unix__", "__riscv"}; std::vector undefined{"__linux__", "_WIN32"}; - auto r = cp::verify(cxx(), argv, 32, 0, defined, undefined, cache.dir); + auto r = cp::verify(clangxx(), argv, 32, 0, defined, undefined, cache.dir); ASSERT_TRUE(r.has_value()) << r.error(); for (auto const& mm : r->mismatches) ADD_FAILURE() << mm.fact << ": declared " << mm.declared @@ -325,15 +392,15 @@ TEST(CenvProbe, AnArgvWithATargetMeasuresThatTargetAndNotTheHost) { } TEST(CenvProbe, TheSameArgvWithoutATargetMeasuresTheHost) { - if (cxx().empty()) GTEST_SKIP() << "no C++ compiler found to probe"; - if (!answers_for_riscv()) GTEST_SKIP() << "this compiler does not answer for riscv64-none-elf"; + if (clangxx().empty()) GTEST_SKIP() << "no clang++ on PATH"; + if (!answers_for_riscv()) GTEST_SKIP() << "this clang does not answer for riscv64-none-elf"; TmpCache cache; // The mirror of the test above, and the one that makes it mean // something: with the target removed, the expectation that held for the // target must now FAIL. A test that only asserts the fixed shape passes // identically against a probe that ignores its argv. std::vector argv{"-ffreestanding", "-D__unix__", "-fno-short-wchar"}; - auto r = cp::verify(cxx(), argv, 0, 0, {"__riscv"}, {}, cache.dir); + auto r = cp::verify(clangxx(), argv, 0, 0, {"__riscv"}, {}, cache.dir); ASSERT_TRUE(r.has_value()) << r.error(); EXPECT_FALSE(r->mismatches.empty()) << "a probe with no target selection must not report the target's " From fec98c10f2f8a16054688dabbf2f4b7e4d5f6891 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:40:07 +0800 Subject: [PATCH 15/31] the #674 Path C design is marked superseded, with a pointer to what replaced it Co-authored-by: Claude Code --- .../docs/2026-09-19-issue-674-cenv-posix-preinclude-design.md | 2 +- .agents/docs/README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.agents/docs/2026-09-19-issue-674-cenv-posix-preinclude-design.md b/.agents/docs/2026-09-19-issue-674-cenv-posix-preinclude-design.md index 3d9647f4..b16ad774 100644 --- a/.agents/docs/2026-09-19-issue-674-cenv-posix-preinclude-design.md +++ b/.agents/docs/2026-09-19-issue-674-cenv-posix-preinclude-design.md @@ -1,6 +1,6 @@ --- subject: design -status: active +status: superseded ---