diff --git a/.github/workflows/ci-macos-e2e.yml b/.github/workflows/ci-macos-e2e.yml index ac6f72e4..d602424d 100644 --- a/.github/workflows/ci-macos-e2e.yml +++ b/.github/workflows/ci-macos-e2e.yml @@ -24,6 +24,10 @@ jobs: e2e: name: e2e suite (macOS ARM64, self-host, ${{ matrix.image }}) # The same two images as ci-macos.yml; `xcode-27` is macOS 27 (see there). + # KNOWN RED on xcode-27, along with ci-macos.yml's own job: the image's + # Command Line Tools SDK ships an `arm64e.x1` .tbd stub ld64.lld 22.1.8 + # cannot parse (fixed upstream, llvm-project#222721, not yet in a + # release). See mcpp-community/mcpp#669. strategy: fail-fast: false matrix: diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index cc8bde2a..e901d6a0 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -81,15 +81,15 @@ jobs: - name: The floating macros mcpp states for an Apple target run: echo 'APPLE_FLOAT_MACROS=-DINFINITY=HUGE_VALF -DNAN=__builtin_nanf("0x7fc00000")' >> "$GITHUB_ENV" - # THE SDK mcpp SELECTS, NOT THE ONE THE PACKAGE'S clang++.cfg NAMES. The - # xlings LLVM package writes `--sysroot=/Library/Developer/CommandLineTools/ - # SDKs/MacOSX.sdk` into its configuration; mcpp passes --no-default-config - # and uses `xcrun --show-sdk-path` (modules/platform/src/macos/macos.cppm). - # On xcode-27-arm64 20260912 the Command Line Tools SDK lists `arm64e.x1` - # in its .tbd stubs, which ld64.lld 22.1.8 does not parse (support landed - # upstream in llvm-project#222721, after 22.1.8), while the SDK xcrun - # selects links. These raw compiles state the SDK the way mcpp does, so the - # job measures what mcpp builds rather than the package's default. + # THE SDK mcpp'S OWN RESOLUTION NAMES: `xcrun --show-sdk-path` + # (modules/platform/src/macos/macos.cppm). These raw compiles state the + # SDK the same way, on purpose. + # + # KNOWN RED on the xcode-27 image, this job and "e2e suite (macOS ARM64, + # self-host, xcode-27)" both: the Command Line Tools SDK there ships an + # `arm64e.x1` .tbd stub ld64.lld 22.1.8 cannot parse — fixed upstream + # (llvm-project#222721) after 22.1.8 was tagged, not yet in any release. + # See mcpp-community/mcpp#669 for the evidence and status. - name: The SDK mcpp selects for an Apple target run: echo "APPLE_SYSROOT=-isysroot $(xcrun --show-sdk-path)" >> "$GITHUB_ENV" diff --git a/CHANGELOG.md b/CHANGELOG.md index d7563371..f94ac69a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,106 @@ ## [Unreleased] +### C 库层可以声明它呈现的 C 环境,引擎实现并校验:设计 2026-09-18(2026.9.18.1) + +三元组的 OS 段过去总是同时回答两件事:目标机器长什么样,以及源码面对的 C 环境是什么。 +一旦某个包接管了 C 库(`mcpp:c-abi=`),这两个问题就可能有不同答案——openkal-musl +在 `x86_64-windows-gnu` 上生成 PE/Win64 代码,却是一个呈现 POSIX 环境的 musl 移植版,而 +`_WIN32` 在这种图里同时冒充了「机器是 Windows」与「C 运行时是 Windows CRT」两件事,后者 +是假的。这一版本让 C 库层把它实际呈现的环境说出来,由引擎实现并校验,不再由三元组或某个 +库自己猜。 + +- **`[c-abi]` 块**:只有提供 `mcpp:c-abi=` 层的包可以声明,其余情况在清单解析阶段 + 即被拒绝。`presents`(`posix`/`windows`/`none`)、`data-model` + (`arch-default`/`lp64`/`llp64`/`ilp32`)、`wchar`(`16`/`32`)三个键没有默认值, + `builtins`(`iso`/`platform`)默认 `platform`;拼错的键或取值都是解析错误而不是静默 + 忽略。不声明该块的包,产出的命令行与这项能力之前逐字节相同。 + (`modules/manifest/src/{targetside_model,toml,types}.cppm`,单测 `test_manifest.cpp`) +- **实现(realisation)**:新模块 `mcpp.toolchain.cenv` 保存「请求 → 三元组与开关」的映射 + ——通用知识,不含包名。Windows 上 `presents = "posix", data-model = "arch-default"` + 采用 Cygwin 式语义,仅在编译行把 `--target=` 换成 `x86_64-pc-cygwin`;链接行保持图解析出 + 的三元组不变,因为两个三元组生成的机器码实测完全一致(PE、Win64 调用约定、SEH)。 + 无法满足的请求明确拒绝,点名目标、请求与缺什么。`[package] c-environment = "platform"` + 让一个包的自身单元退出这项实现,继续按三元组自身的默认环境编译。 + (`src/toolchain/cenv.cppm`,单测 `test_cenv.cpp`) +- **`c-environment = "platform"` 对 `mcpp:kernel-abi=` 提供者是推导出来的,不需要 + 自己声明,经 PR 进行中 openkal-musl 尖峰实验的修订。** 这样的包定义上就是说平台自身 ABI + 的边界,绝不会是想要图里呈现的 `[c-abi]` 环境的那个包——openkal-windows 在 POSIX 替换下 + 编译,`-fno-short-wchar` 给了它 32 位 `wchar_t`,而它调的 Win32 接口回传真正的 16 位 + UTF-16,`wchar_t*` 循环于是把两个 UTF-16 码元读成一个码点,这是实测出来的失败,不是假设。 + 推导让 openkal-windows 0.8.0、openkal-macos 0.10.0、openkal-linux 0.13.0 等每一个已发布 + 实现都不需要新发版本、不需要跨仓库协调版本号就能把边界做对,这一类失败因此**无法被表达**。 + 包自身清单里显式写的 `c-environment` 仍然优先于推导——该键仍是设计 §5.3 另一类情形(不是 + kernel-abi 边界、但自身确有平台绑定单元的普通包)唯一的表达手段。`mcpp.manifest.xpkg` 与 + `mcpp.toml` 两条清单解析路径都实现了同一条推导。(`modules/manifest/src/{toml,xpkg}.cppm`, + 单测 `test_manifest.cpp` 的 `CEnvironmentIsInferredForAKernelAbiProvider`(两条解析路径各一 + 个),e2e `tests/e2e/741_...sh`) +- **升级到这个版本后,第一次构建会是一次冷构建——这是故意的,原因如下。** 全局构建缓存 + (`~/.mcpp/build-cache/v1`)的键原先没有覆盖解析出的 [c-abi] 环境(经 openkal-musl 尖峰 + 实验发现):`mcpp.build.cache_key::fill_package_config` 只读包自己清单里声明的 + `cflags`/`cxxflags`,而解析出的环境是引擎的广播,只写进 `PackageRoot::privateBuild`, + 从不写回前者——两次解析出不同环境的构建因此拿到同一把键。原地升级 `mcpp` 而不清理缓存 + 目录,会把按**旧**环境编译的目标文件喂给按**新**环境构建的镜像,一个镜像混两种 C 环境且 + 没有任何诊断,这正是本设计要防止的那个不变量本身。键的推导已经修好(见下),但已经写下的 + 条目没法用它来判断自己还能不能信——所以 `kCacheEpoch` 从 2 提到了 3,`~/.mcpp/ + build-cache/v1` 下已有的条目整体作废,不再逐条判断。这不是求稳的富余动作:键改对了以后, + 恰恰是那些*不再*触发替换的包最危险——比如同一个 PR 里被推导进 `c-environment = + "platform"` 的 kernel-abi 包,它广播前后 `privateBuild.cflags` 都是空的,新键和旧键因此 + 照样相同,而旧键当初对应的目标文件,正是带着替换令牌编译出来的那一份。 + `fill_package_config` 现在把 `privateBuild.cflags`/`cxxflags`/新增的 `asmflags`(广播后的 + 值)与包自身声明的标志一起折进键里,和它原本处理 include 目录的方式一致。 + (`src/build/cache_key.cppm`,单测 `test_cache_key.cpp` 的 + `TwoDifferentRealisedCEnvironmentsDoNotShareASlot` 与 + `EveryPrivateBuildBroadcastFieldReachesTheKey`——后者是给这一类缺陷立的长期防线: + `privateBuild`(`UsageRequirements`)每加一个新的广播字段,都要在这个测试和 + `fill_package_config` 里同时补上一行,否则历史会重演,`-D__openkal__` 和 + `targetSideUsage` 自己的广播在这次修订之前就已经有过同样的缺口) +- **`__CYGWIN__`/`__CYGWIN32__` 保持定义,经 openkal-musl 尖峰实验修订。** 最初的实现 + 取消定义它们(理由是图里没有真正的 Cygwin 用户态)。第三方可移植代码里需要知道**目标文件 + 格式**——不是 C 环境,也不是平台 API——的那部分,没有别的名字能指代「PE 格式 + 呈现 + POSIX 的 C 环境」这一组合,只有 `__CYGWIN__`;这样的代码不像本生态自己的包那样可以打 + 补丁。这是一项留给 30 个成员那轮实测去判定的权衡,不是已经定论的事实:一个库伸手去够 + `__CYGWIN__`,也可能伸手去够一个这里并不存在的真正 Cygwin 接口——如果定义它带来的新 + 失败比修好的还多,结论就会翻过来。(`src/toolchain/cenv.cppm`) +- **声明被校验,不被信任**:新模块 `mcpp.toolchain.cenv_probe` 用最终参数编译一次纯预处理 + 探针(`-E -dM`,不执行、不需要目标可在本机运行),核对 `__SIZEOF_LONG__`、 + `__SIZEOF_WCHAR_T__` 与环境身份宏是否与声明相符,不符即失败并同时打印声明值与实测值; + 结果按配置缓存。(`src/toolchain/cenv_probe.cppm`,协调者复核后补的单测 + `tests/unit/test_cenv_probe.cpp`——针对真实编译器直接调用 + `cenv_probe::verify`,不经过 `cenv::realise`,专门核实测出的不符会被正确识别并渲染成 + 声明值/实测值两列;此前这一模块只在 e2e 里被间接跑过成功路径,不符路径完全没有测试覆盖) +- **`c-abi` 环境放进共享 store 的那道口子,讲清楚失败模式是什么(而不是缺口表里一行字)。** + 对照真正写入 store 的代码路径核实过:安装钩子确实能编译目标侧代码,而它运行在工具链解析 + **之前**——`install_hook_env` 拿到的工具链字段一律为空,这是顺序上的硬约束,不是漏传。 + 钩子因此没有办法知道这次构建解析出的环境,而 store 也不记录装的是按哪种环境编译的,所以 + 一次环境不一致的构建会拿到宽度错配的目标文件,**没有任何东西核对它**。这与 + `mcpp:c++-abi=` 那条 `requires` 检查已经接受下来的同一种限制同形,不是这个 PR + 新引入的;真正补上需要两阶段安装,或者把同样的 `requires` 检查方式推广到 + `c-abi`/`c-environment`——都不在这个 PR 范围内。(`docs/22` 及其 zh 镜像) +- **`__openkal__`**:`kernel-abi` 解析为 `openkal` 时,引擎为目标侧全部单元定义它——取自层 + 的取值,不取自包名。只能用于决定是否调用 `kal_*`,不得用于选择头文件或推断平台 + (`docs/24`)。 +- **闭包可见性**:`provides = ["platform-sdk"]` 是包对自己的陈述;构建报告新增一行列出 + 图中所有这样的包(没有则为空),`[build] platform-dependencies = "refuse"` + 让它们的出现直接失败构建。(`src/build/prepare.cppm`,`docs/06`) +- **指纹**:解析出的环境与 `__openkal__` 参与构建指纹,LP64 与 LLP64 两次构建绝不共享输出 + 目录。安装钩子的存储键尚未补上同一个缺口,已在 `docs/22` 记录为已知差距。 +- **实现同样到达汇编单元,经 openkal-musl 尖峰实验发现并修订。** 最初的广播只写入每个包的 + `privateBuild.cflags`/`cxxflags`,`.S` 单元走独立组装的 `f.as`,只从 `packageCflags` 里 + 继承 `-D`/`-U`/`-I` 子集(`unit_asm_flags`,本就如此,为了不让 `-std=`/`-O` 这类对汇编 + 无意义的标志混进去)——`--target=`/`-fno-short-wchar` 因此从未到达汇编器,同一个包里 + `.c` 单元看到 `_WIN32` 未定义而 `.S` 单元仍看到它已定义(openkal-musl 自己的 + `okm_setjmp.S`、上游 libunwind 的 `assembly.h` 都按这个宏选目标文件格式分支与寄存器保存 + 集,后果是用 SysV 保存集写、按 Win64 头部量的 `jmp_buf` 悄悄错位)。新增 + `UsageRequirements::asmflags`——`privateBuild` 内与 `cflags`/`cxxflags` 平行、但只供引擎 + 自己广播用的第三条通道(没有对应的 `[build] asmflags = [...]` 清单键)——把同一份令牌 + 原样送进 `packageAsmflags`,绕开 D/U/I 过滤。实测 clang 对 `-x assembler-with-cpp` 接受 + 这些令牌全集,故未作裁剪。(`src/modgraph/scanner.cppm`、`src/build/prepare.cppm`,e2e + `tests/e2e/741_...sh` 新增 `.S` 单元与其上的断言) +- 文档:`docs/22`(`[c-abi]`、校验、指纹)、`docs/21`(声明的环境如何移动编译三元组而不 + 移动链接三元组)、`docs/24`(三组宏、`__openkal__` 的规则、平台单元)、`docs/06` + (`platform-sdk` 标记)及对应 zh 镜像。 + ### 目标侧由依赖图供给时,编译侧关掉对应的隐式搜索:#662(2026.9.17.3) 链接侧早在 #511 就已经按 `plan.targetSide.cAbi.prebuilt()` 撤掉 `-nostdlib`,编译侧一直 diff --git a/docs/06-features-and-capabilities.md b/docs/06-features-and-capabilities.md index 49f6e5e1..ee081a99 100644 --- a/docs/06-features-and-capabilities.md +++ b/docs/06-features-and-capabilities.md @@ -523,6 +523,32 @@ exists to name — it works for the declaring package and then hands the SDK's headers, unasked, to a consumer that may be built for a target where that SDK does not belong at all. +#### Making it visible to the closure, not only private (mcpp 2026.9.18+) + +`visibility = "private"` answers "does this dependency leak to a consumer's +`-I` list" and says nothing about "is this dependency in the graph at all" — +a question [22 — The Target Side](22-target-side.md#closure-visibility) +answers for the WHOLE build. The SDK package itself states the fact a report +or a refusal needs: + +```toml +[package] +name = "some.windows-headers" +version = "1.0.0" +provides = ["platform-sdk"] +``` + +`platform-sdk` is an ordinary, UNPREFIXED capability — like `blas` above, not +like `mcpp:c-abi=` — because it names no layer this engine resolves, +only a fact a package states about itself. A build's `Target` report lists +every package in the graph that declares it (empty when none), and `[build] +platform-dependencies = "refuse"` fails the build outright when one is +present — the machine-checkable form of "this build is a closure entirely on +its kernel-abi implementation and nothing else." Declaring `provides = +["platform-sdk"]` and `visibility = "private"` together is the complete +statement: private keeps the headers off a consumer's search path, and +`platform-sdk` keeps the fact off nobody's report. + ## Current limitations **A default feature is turned off in the manifest, not on the command line.** diff --git a/docs/21-the-target-triple.md b/docs/21-the-target-triple.md index cc95922b..1d8bd264 100644 --- a/docs/21-the-target-triple.md +++ b/docs/21-the-target-triple.md @@ -468,6 +468,21 @@ is in use. A payload for it does not exist on any host; its system can only come from a dependency graph, which is what `toolchain list` reports as `via dependency graph`. +### A declared environment can move the compiled triple, never the linked one (mcpp 2026.9.18+) + +A `c-abi` package's `[c-abi]` block ([22 — The C Environment](22-target-side.md#the-c-environment-a-c-abi-package-presents-mcpp-2026918)) +can change what `--target=` a COMPILE receives — `x86_64-windows-gnu` compiles +as `x86_64-pc-cygwin` when the graph's C library declares `presents = +"posix"` — without changing the triple this chapter is otherwise about. The +resolved triple (`mcpp toolchain list`'s spelling, the output directory name, +the `Target` report's head line, the LINK line) stays exactly what the graph +and the toolchain resolved; only the compiler's own `--target=` token, on +compile commands, is substituted, because that is the one place the +declaration's environment-identity macros and data model come from. A reader +who greps a `compile_commands.json` for `--target=` and finds a spelling this +chapter never lists is looking at exactly this — see docs/22 for what +triggered it and why. + ## The Build Host Is A Third Axis The two axes above — which compiler, and where the C library comes from — are diff --git a/docs/22-target-side.md b/docs/22-target-side.md index a313b37e..ed5882a0 100644 --- a/docs/22-target-side.md +++ b/docs/22-target-side.md @@ -188,6 +188,49 @@ Interface and implementation are separate columns. `openkal` is an interface and `openkal-windows` an implementation of it; collapsing the two would conceal why one source reaches several machines. +## Closure Visibility + +The five-layer report above answers "where does each layer come from"; it +says nothing about the OTHER packages in the graph — a dependency bound to +one platform's SDK is exactly as invisible to it as an ordinary one. Two +mechanisms close that gap (design 2026-09-18 §6). + +**What counts as a platform dependency, precisely.** A package brings one if +and only if it says so — `provides = ["platform-sdk"]`, an ordinary, +unprefixed capability. Nothing here is inferred from header paths, link +flags or a dependency's `visibility`: inference would have exactly the +silent-typo failure mode the reserved `mcpp:` prefix exists to avoid for the +five layers, applied to a fact this engine cannot otherwise observe. [06 — +A platform SDK dependency stays private](06-features-and-capabilities.md#a-platform-sdk-dependency-stays-private) +is the pattern such a package's own manifest follows. + +**The report.** A build's `Target` report gains a line naming every package +in the graph that declares `platform-sdk`, empty when none: + +``` + Target platform-deps — +``` + +``` + Target platform-deps some.windows-headers@1.0.0 +``` + +Printed under the same visibility rule as the five layers: only when there +is something to report, or always under `MCPP_VERBOSE`. + +**The refusal.** `[build] platform-dependencies = "refuse"` fails the build +outright when the graph contains one — the machine-checkable form of "this +build is a closure entirely on its kernel-abi implementation and nothing +else": + +```toml +[build] +platform-dependencies = "refuse" +``` + +The only accepted value is `"refuse"`; absent (the default) allows platform +dependencies, today's behaviour. + ## What A Package Declares ### provides @@ -245,6 +288,255 @@ toolchain values ([32 — Authoring a Payload](32-authoring-a-payload.md)). It must not build a different variant into the same store directory, because the first consumer would then decide the variant for every later one. +### The C Environment A `c-abi` Package Presents (mcpp 2026.9.18+) + +A traditional stack never has to say this: the compiler payload's target +triple already implies the environment a C program compiles against. The +moment a package supplies the C library instead, that stops being true — +openkal-musl on `x86_64-windows-gnu` generates PE/Win64 code while +presenting a POSIX environment to source, because it is a musl port and +every `#ifdef _WIN32` in the libraries above it is asking the wrong layer. +The `[c-abi]` block is how the C library states, once, what it actually +presents — and only the package that supplies the layer may state it. + +```toml +# openkal-musl's manifest +[package] +provides = ["mcpp:c-abi=musl"] + +[c-abi] +presents = "posix" # posix | windows | none +data-model = "arch-default" # arch-default | lp64 | llp64 | ilp32 +wchar = 32 # 16 | 32 +builtins = "iso" # iso | platform (default platform) +``` + +**Who may declare it.** A package that writes `[c-abi]` without also listing +`mcpp:c-abi=` in `provides` is stating a fact about a layer it does not +supply, and that is always wrong rather than merely unusual — it is refused +at manifest parse time, naming the missing `provides` entry. + +**The four keys, and their closed value sets.** + +| Key | Values | Answers | +|---|---|---| +| `presents` | `posix` / `windows` / `none` | which environment-identity macros source sees (`__unix__` vs `_WIN32` vs neither) | +| `data-model` | `arch-default` / `lp64` / `llp64` / `ilp32` | how wide `long` is | +| `wchar` | `16` / `32` | how wide `wchar_t` is | +| `builtins` | `iso` / `platform` (default) | whether the compiler may assume the platform C library's own extensions | + +`presents`, `data-model` and `wchar` carry no default: a block that omits one +of them is refused naming the missing key, because "absent" is not the same +statement as any of the three closed values could make. `builtins` alone +defaults to `platform`, today's behaviour. An unrecognised key or an +unrecognised value is always a parse error naming the key — never silently +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. + +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 +POSIX. + +**Realisation.** Once the `c-abi` layer resolves to a package that declares +this block, mcpp turns the request into compiler configuration for every +target-side unit — the C library itself, the C++ runtime, the compiler +runtime's builtins, and every ordinary package in the graph — covering C, +C++ and assembly compiles, the dependency scan, and the `std` module +precompile alike. Assembly (`.S`/`.s`) needs its own broadcast channel to get +this: a `.S` unit's command line is assembled independently of a `.c`/`.cpp` +unit's (`mcpp.build.flags::CompileFlags::as`, not `::cc`/`::cxx`), and only +takes the `-D`/`-U`/`-I` words out of a package's C flags on purpose — a +`-std=` or `-O` token meant for the C compiler is meaningless to GAS — so the +realised environment tokens (`--target=`, `-f[no-]short-wchar`, and anything +`builtins = "iso"` adds) are broadcast a second time, verbatim, into that +narrower channel (found missing, and fixed, by the openkal-musl spike: a +`.c` unit in a package saw `_WIN32` undefined while a `.S` unit in the SAME +package — real code, like `okm_setjmp.S` and upstream libunwind's +`assembly.h`, selects register-save sets on it — still saw it defined). mcpp +holds one mapping table from request to triple and flags, generic knowledge +that names no C library: + +| Target | Request | Realisation | +|---|---|---| +| Linux | `posix` / `arch-default` | the default triple already satisfies it | +| macOS | `posix` / `arch-default` | the default triple already satisfies it | +| Windows | `posix` / `arch-default` | Cygwin-flavoured: `--target=x86_64-pc-cygwin` on the compile line only; `__CYGWIN__`/`__CYGWIN32__` are left defined (see the note below); `data-model` becomes LP64 as a consequence of the triple, not a separate flag | +| any | `builtins = "iso"` | turns off code-generation idioms that assume a platform C library — `-fno-builtin-memset_pattern16` on Apple targets is the one this survey measured; see `src/toolchain/cenv.cppm` for what else was checked and found not to apply | +| anything else | | refused, naming the target, the request and what is missing — never a silent downgrade | + +The Windows row is the flagship case: `x86_64-w64-windows-gnu` and +`x86_64-pc-cygwin` produce IDENTICAL machine code — same PE format, same +Win64 calling convention, same SEH — and differ only in what the +preprocessor sees and how wide `long` is. Realisation therefore touches only +the **compile** line; the **link** line keeps the triple the graph resolved, +because nothing about the object format changed. + +**`__CYGWIN__`/`__CYGWIN32__` are left defined — a revision from the +openkal-musl spike, not the design's original claim.** Undefining them was +tried first, on the reasoning that a real Cygwin userland is not in the +graph. Portable third-party code that needs to know the **object format** — +not the C environment, not the platform API — has no name for "PE format +with a POSIX-presenting C environment" other than `__CYGWIN__`, and such code +cannot be patched the way this ecosystem's own packages can. `presents = +"posix"` answers one question, which environment-identity macros source +sees; it does not get to answer a different one, what object format this is, +by deleting the only macro that names it. This is a **trade-off for the +30-member measurement to settle, not a settled fact**: a library reaching for +`__CYGWIN__` may also reach for a real Cygwin interface (`sys/cygwin.h`, +`cygwin_conv_path`) that does not exist here, and if defining it produces +more new failures than it fixes, the answer flips. + +**A `kernel-abi` provider's own units are INFERRED onto the platform boundary +— it never has to say so (mcpp 2026.9.18+, a mid-PR revision from the +openkal-musl spike).** A package that provides `mcpp:kernel-abi=` +(openkal-windows, say) has to see the platform's own environment — it +includes platform declarations and `_WIN32` must be true for it — and it +always will, by definition: such a package's whole job is to speak the +platform's ABI, so it can never be the package that wants the graph's +*presented* `[c-abi]` environment instead of the triple's own. mcpp does not +wait to be told this. Any package whose `provides` names +`mcpp:kernel-abi=` gets `c-environment = "platform"` as its default, +with no key of its own: + +```toml +[package] +provides = ["mcpp:kernel-abi=openkal"] +# no [package] c-environment line — the boundary is inferred from `provides` +``` + +**Why inference and not just the flag.** The flag alone works; what it +cannot do is retroactively fix a package that has already shipped without +it. openkal-windows 0.8.0, openkal-macos 0.10.0, openkal-linux 0.13.0, and +every future kernel-abi implementation, get the boundary right — with no new +release and no coordinated version bump across repositories — because +`provides = ["mcpp:kernel-abi="]` is the one fact they already state. +The failure this closes was measured, not hypothetical: openkal-windows, +compiled under the POSIX substitution like everything else in its graph, +got a 32-bit `wchar_t` from `-fno-short-wchar` while the Win32 calls it +makes hand back genuine 16-bit UTF-16 — a `wchar_t*` loop then read two +UTF-16 units as one code point. Making the boundary a default rather than a +manifest key a package must remember turns that failure class +unrepresentable rather than merely documented. + +**Precedence: an explicit `c-environment` in the package's own manifest +always wins over the inference.** The inference only fills `cEnvironment` +when the package wrote nothing — a package that, after all, needs the +presented environment can still say so explicitly (there is no way today to +write "not platform" back, because `"platform"` remains the only value this +key accepts). The explicit key also stays the ONLY mechanism for §5.3's +other category — an ordinary package that is not the kernel-abi boundary but +still has platform-bound units of its own — where the author really is +making a choice mcpp cannot infer: + +```toml +[package] +# an ORDINARY package, not a kernel-abi provider — the engine cannot infer +# this one; the author states it because platform-bound units are a real +# minority of what this package builds (design §5.3) +c-environment = "platform" +``` + +This is a boundary rule, documented rather than enforced by the engine +beyond the flag itself: the interface such a package exposes to the rest of +the graph must still cross in fixed-width types only (SPEC §5.4). + +**Verification, not trust.** A declaration is checked, never trusted — the +same rule openkal applies to its own conformance claims. Once the tokens +above are known, mcpp compiles one syntax-only probe (`-E -dM`, a predefined- +macro dump — cheap, and it needs no execution, which matters because the +realised environment is routinely a cross target) with them and reads back +`__SIZEOF_LONG__`, `__SIZEOF_WCHAR_T__` and which environment-identity +macros are defined, comparing them against the declaration. A mismatch fails +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. + +**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 +`long` disagrees in width, so they never share an output directory, and +neither can reuse a cached object the other produced. + +**The global build cache's key also covers it (mcpp 2026.9.18+, a mid-PR +fix, not the design's original text).** `~/.mcpp/build-cache/v1` — the +cache an ordinary dependency compile reuses across projects and across an +`mcpp` upgrade — is a SEPARATE mechanism from the build fingerprint above, +keyed per package from exactly the axes that reach that package's own +compile command line (`mcpp.build.cache_key`). The realised environment +reaches a package's command line entirely through an engine BROADCAST (the +same channel `targetSideUsage` and `-D__openkal__` use, never the package's +own declared `[build] cflags`/`cxxflags`), so the key's own derivation had +to be told to read the broadcast, not only the declaration — found exactly +that way (coordinator report, openkal-musl spike): upgrading `mcpp` in +place, with the cache directory left in place, served objects compiled +under the OLD realised environment into an image built under the new one, +two C environments in one image, with no diagnosis at all. `fill_package_ +config` now folds in `PackageRoot::privateBuild.cflags`/`cxxflags`/ +`asmflags` — the post-broadcast values — alongside the package's own +declared flags, exactly as it already did for include directories. +`--cache=off`, or clearing the cache directory, was never a sign the key +was RIGHT; both routes bypass it entirely. + +**This release also bumps the cache's epoch, orphaning every existing +entry — the first build after upgrading is cold.** A corrected key does not +by itself make an entry written under the old, wrong derivation safe to +keep: an entry is poisoned exactly when its recorded key and its actual +compiled inputs already disagreed, and the package MOST likely to still +show an unchanged key after the fix is the one this same revision newly +exempts from the realisation (a `kernel-abi` provider inferred into +`c-environment = "platform"`, above) — its `privateBuild.cflags` is now +empty, so the new key is computed from nothing, matching the OLD key, which +was also computed from nothing, while the object on disk was compiled WITH +the substitution. No cheaper check tells a pre-fix entry from a post-fix +one, so `mcpp.build.cache_key::kCacheEpoch` moves (2 → 3), which orphans +the whole cache unconditionally rather than trust a key equality that +cannot be trusted for exactly the entries that matter most. + +**Store key — not yet closed, and here is exactly what that means (checked +against the code path that populates it, mcpp 2026.9.18+).** A package's +*install hook* CAN and does compile target-side code — object code, a +static library — and the shared store it installs into is keyed by package +and version only, the same gap [requires](#requires) already documents for +a C++ runtime selection. What makes this different from the build-cache key +above, and NOT something this PR could close the same way: an install hook +runs BEFORE the toolchain resolves, by a real ordering constraint, not an +oversight. `install_hook_env`'s toolchain fields are unconditionally empty +on the ordinary path — `prepare.cppm` resolves `tc` only *after* the +dependency graph installs, because resolving the target side can itself +depend on which package the graph turns out to supply a layer from (the +`c-abi` provider is a member of that same graph). A hook has no realised +environment to consult because, at the moment it runs, none has been +computed yet — there is nothing to pass it, not merely something mcpp +forgot to pass. + +**The failure mode, plainly, not as a line in a gap table:** a hook that +compiles environment-sensitive C code (anything whose correctness depends +on `wchar_t` width, the data model, or which environment-identity macros +are defined) has no way to ask what this build realised, so it can only +compile against ONE assumption and hope every consumer shares it. A project +whose graph declares a `[c-abi]` that disagrees produces objects sized for +one `wchar_t` linked against headers sized for another, with **nothing +checking it** — the store records no environment for what it holds, so +there is no mismatch to detect, only a silently wrong link. This is the +identical shape the C++-runtime `requires` check above already accepts as a +documented limit, not a new one this PR introduces; `[c-abi]` inherits it +because it inherits the same store. + +**What closing it for real would take:** either (a) a two-phase install — +defer any target-side compilation an install hook performs until after +target-side resolution, re-invoking the hook (or a second, later hook) once +an environment is known, which changes the install/resolve ordering this +whole codebase currently treats as fixed; or (b) extend the `c++-abi` +`requires`-shaped check's pattern to `c-abi`/`c-environment` — a package +states the environment its store artifact was built for, checked once the +toolchain resolves, refused on mismatch — which is designed (this section) +but not implemented in this PR. Until one of them lands, the interim +discipline is the same the C++-runtime case already requires: such a +package's install hook must not build more than one environment's variant +into one store directory. + ### Standard Library Module Sources A package that is a standard library states where its `std` module source is diff --git a/docs/24-openkal-cross.md b/docs/24-openkal-cross.md index 63030dfb..247f8da5 100644 --- a/docs/24-openkal-cross.md +++ b/docs/24-openkal-cross.md @@ -34,6 +34,74 @@ reaches every architecture the compiler supports. The claim is verified by a matrix of three hosts and three targets, each cell building one source and running the result. +## Three Layers, Three Macro Families (mcpp 2026.9.18+) + +A build over openkal answers three different questions, and until this +release one macro (`_WIN32`) answered two of them at once — the root cause +of every openkal-Windows failure whose diagnosis named a missing platform +header: the code was asking "is this openkal" through a macro that actually +meant "is the Windows CRT present." + +| Family | States | Defined by | Example | +|---|---|---|---| +| kernel ABI | `kal_*` is callable, and behaves the same on every platform | the layer providing `mcpp:kernel-abi=openkal` | `__openkal__` | +| C environment | the shape of the C environment source sees | the layer providing `mcpp:c-abi=`, via [`[c-abi]`](22-target-side.md#the-c-environment-a-c-abi-package-presents-mcpp-2026918) | `__unix__`, `_WIN32`, `__MINGW32__` | +| system & architecture | the underlying OS and processor | the target triple | `__linux__`, `__APPLE__`, `__x86_64__` | + +A FOURTH fact rides along with the C-environment row without being the same +question: the **object format** the linker produces — PE, ELF, Mach-O — is +a property of the *target triple*, not of the C environment `presents` +selects, and the two can disagree. `presents = "posix"` on Windows still +links PE; the only name portable third-party code has for that specific +combination, "PE format with a POSIX-presenting C environment," is +`__CYGWIN__`/`__CYGWIN32__`, which the Cygwin-flavoured realisation +therefore leaves DEFINED rather than folding into the three rows above — +see [22's own note](22-target-side.md#the-c-environment-a-c-abi-package-presents-mcpp-2026918) +for the full trade-off (it is not a settled fact, and may flip). Reading it +as a fourth C-environment macro, rather than as what it actually is — an +object-format fact `__CYGWIN__` happens to be the only name for — is +exactly the shape of confusion this whole section exists to head off. + +**`__openkal__` — the rule.** The engine defines it, for every target-side +unit, whenever the resolved `kernel-abi` layer's interface name is +`openkal` — read from the LAYER's value, never from a package name, so a +second implementation (`openkal-macos`, `openkal-opensbi`, …) needs no +engine change. + +*Allowed:* gating whether a call site invokes `kal_*` at all. Its meaning is +identical on every target, so using it this way never smuggles platform +information into source that is supposed to be implementation-agnostic. + +*Forbidden:* selecting a header, inferring whether `_WIN32` is real, +working around a missing SDK, or telling `linux`/`windows`/`macos` apart. +Those are the C-environment layer's or the platform layer's questions — +write `cfg(c-abi = "…")` or `cfg(kernel-abi = "…")` in the manifest instead +(and see [22 — Adaptation To The Resolved Target Side](22-target-side.md#adaptation-to-the-resolved-target-side) +for the predicate grammar). + +**Platform units.** A package that itself needs the platform's own +environment never reads `__openkal__` or any other macro to work that out — +the boundary is stated in the manifest, not inferred from source, and +everything crossing it is still fixed-width (SPEC §5.4). Two different +packages reach `[package] c-environment = "platform"` (docs/22) by two +different routes: + +- A `mcpp:kernel-abi=` provider (openkal-windows, say) gets it + **inferred**, from `provides` alone — such a package IS the platform + boundary by definition, so it never has to write the key itself, and + every already-released implementation is covered with no version bump. +- An ordinary package that is not a kernel-abi provider but still has + platform-bound units of its own — a shim under [06's private dependency + pattern](06-features-and-capabilities.md#a-platform-sdk-dependency-stays-private), + say — states the key EXPLICITLY, because the engine has no `provides` + entry to infer it from (design §5.3). + +An explicit key always wins over the inference where both could apply +(docs/22's own precedence note) — but there is no way today to write "not +platform" back, so a kernel-abi provider that, unusually, needs the graph's +presented C environment after all is the only case where this matters in +practice. + ## What A Project Writes ```toml diff --git a/docs/zh/06-features-and-capabilities.md b/docs/zh/06-features-and-capabilities.md index b92cf963..7e50e4b9 100644 --- a/docs/zh/06-features-and-capabilities.md +++ b/docs/zh/06-features-and-capabilities.md @@ -447,6 +447,28 @@ some.windows-headers = { version = "1.0", visibility = "private" } 可以工作,却会把 SDK 的头文件不由分说地交给消费方,而消费方构建的目标上很可能 根本不该出现这个 SDK。 +#### 让闭包也能看见它,而不只是私有(mcpp 2026.9.18+) + +`visibility = "private"` 回答的是「这个依赖会不会泄漏到消费方的 `-I` 列表」,不回答 +「这个依赖到底在不在图里」——后者是[22 —— 目标侧](22-target-side.md#闭包可见性) +要为**整个构建**回答的问题。由 SDK 包自己陈述报告或拒绝开关需要的事实: + +```toml +[package] +name = "some.windows-headers" +version = "1.0.0" +provides = ["platform-sdk"] +``` + +`platform-sdk` 是一个普通的、不带命名空间前缀的能力——像上面的 `blas`,不像 +`mcpp:c-abi=`——因为它不指代引擎解析的任何一层,只是包对自己陈述的一个事实。 +构建的 `Target` 报告会列出图中每一个声明了它的包(没有则显示为空); +`[build] platform-dependencies = "refuse"` 则在它出现时直接让构建失败——这是 +「本次构建完全是基于其 kernel-abi 实现的闭包,不多不少」这句话的机器可核验形式。 +同时声明 `provides = ["platform-sdk"]` 与 `visibility = "private"` 才是完整的陈述: +private 让头文件不出现在消费方的搜索路径上,`platform-sdk` 让这个事实不从任何人的 +报告里消失。 + ## 当前边界 **默认 feature 在 manifest 里关掉,不在命令行上关掉。** 没有 `--no-default-features`。 diff --git a/docs/zh/21-the-target-triple.md b/docs/zh/21-the-target-triple.md index a0192a81..686f7d79 100644 --- a/docs/zh/21-the-target-triple.md +++ b/docs/zh/21-the-target-triple.md @@ -416,6 +416,17 @@ CRT;图供给时是 `musl`。一个目标字符串,两个不同的 C 库 —— 全部差别就在于用的是哪个 C 库。任何宿主都没有为它准备的载荷;它的系统只能来自依赖 图,这正是 `toolchain list` 报的 `via dependency graph`。 +### 声明的环境能移动编译三元组,移动不了链接三元组(mcpp 2026.9.18+) + +`c-abi` 包的 `[c-abi]` 块([22 —— C 环境](22-target-side.md#c-abi-包陈述它呈现的-c-环境mcpp-2026918)) +可以改变一次**编译**收到的 `--target=`——图里的 C 库声明 `presents = "posix"` 时, +`x86_64-windows-gnu` 按 `x86_64-pc-cygwin` 编译——而不改变本章讲的那个三元组。 +解析出的三元组(`mcpp toolchain list` 打印的拼法、输出目录名、`Target` 报告的 +标题行、**链接**行)照旧是图与工具链解析出的那个;被替换的只是编译命令自己的 +`--target=` 记号,因为环境身份宏与数据模型正是从这里来的。如果在 +`compile_commands.json` 里 grep `--target=` 找到一个本章列表里没有的拼法,看到的 +正是这件事——原因与触发条件见 docs/22。 + ## 构建机是第三条轴 上面两条轴 —— 用哪个编译器、C 库从哪来 —— 是工程做的选择。第三条不是:它是构建 diff --git a/docs/zh/22-target-side.md b/docs/zh/22-target-side.md index 0a7ef3bd..235fb7d3 100644 --- a/docs/zh/22-target-side.md +++ b/docs/zh/22-target-side.md @@ -160,6 +160,43 @@ openkal-llvm-runtime = "0.1" 接口与实现是两列。`openkal` 是接口,`openkal-windows` 是它的一个实现; 合并二者会掩盖一份源码为何能够到达多台机器。 +## 闭包可见性 + +上面五层的报告回答的是「每一层来自哪里」,不回答图中「还有哪些别的包」——一个绑定到某个 +平台 SDK 的依赖,对这份报告而言和普通依赖一样不可见。两种手段补上这个缺口 +(设计 2026-09-18 §6)。 + +**什么算平台依赖,精确定义。** 一个包带来平台依赖,当且仅当它自己这样说—— +`provides = ["platform-sdk"]`,一个普通的、不带命名空间前缀的能力。这里的一切都不是从 +头文件路径、链接 flag 或某个依赖的 `visibility` 推断出来的:推断会带来与保留前缀 +`mcpp:` 为五层所要避免的完全同一种「拼错即静默失效」的失败模式,只是用在了这个引擎本来 +就无法直接观测的事实上。[06 —— 让平台 SDK 依赖保持私有](06-features-and-capabilities.md#平台-sdk-依赖保持私有) +是这样的包自己清单所遵循的模式。 + +**报告。** 构建的 `Target` 报告新增一行,点名图中每一个声明了 `platform-sdk` 的包, +没有则为空: + +``` + Target platform-deps — +``` + +``` + Target platform-deps some.windows-headers@1.0.0 +``` + +按与五层相同的可见性规则打印:只在有内容可报告时打印,或在 `MCPP_VERBOSE` 下总是打印。 + +**拒绝开关。** `[build] platform-dependencies = "refuse"` 在图中存在这样的包时直接让 +构建失败——这是「本次构建完全是基于其 kernel-abi 实现的闭包,不多不少」这句话的机器 +可核验形式: + +```toml +[build] +platform-dependencies = "refuse" +``` + +唯一接受的取值是 `"refuse"`;不写(默认)则允许平台依赖,即今天的行为。 + ## 包声明的内容 ### provides @@ -206,6 +243,184 @@ requires = ["mcpp:c++-abi=libstdc++"] 目标,但收不到工具链的取值([32 —— 编写载荷](32-authoring-a-payload.md))。钩子不得把另一种变体构建进 同一个存储目录,否则第一个消费者就会替之后所有消费者决定变体。 +### `c-abi` 包陈述它呈现的 C 环境(mcpp 2026.9.18+) + +传统技术栈不需要陈述这件事:编译器载荷的目标三元组已经蕴含了环境。一旦某个包取代载荷供给 +C 库,这一点就不再成立——openkal-musl 在 `x86_64-windows-gnu` 上生成 PE/Win64 代码,却向源码 +呈现 POSIX 环境,因为它是 musl 的移植版,它之上每一处 `#ifdef _WIN32` 问的都是错误的层。 +`[c-abi]` 块就是 C 库一次性陈述它到底呈现什么——且只有提供该层的包可以陈述。 + +```toml +# openkal-musl 的清单 +[package] +provides = ["mcpp:c-abi=musl"] + +[c-abi] +presents = "posix" # posix | windows | none +data-model = "arch-default" # arch-default | lp64 | llp64 | ilp32 +wchar = 32 # 16 | 32 +builtins = "iso" # iso | platform(默认 platform) +``` + +**谁有资格声明。** 写了 `[c-abi]` 却没有在 `provides` 里列出 `mcpp:c-abi=` 的包,是在 +陈述一件自己不提供的层的事实——这总是错的,而不只是不寻常,因此在清单解析阶段即被拒绝, +并指出缺失的 `provides` 条目。 + +**四个键与各自的封闭取值集合。** + +| 键 | 取值 | 回答 | +|---|---|---| +| `presents` | `posix` / `windows` / `none` | 源码看到哪一族环境身份宏(`__unix__` 还是 `_WIN32` 还是都不定义) | +| `data-model` | `arch-default` / `lp64` / `llp64` / `ilp32` | `long` 有多宽 | +| `wchar` | `16` / `32` | `wchar_t` 有多宽 | +| `builtins` | `iso` / `platform`(默认) | 编译器是否可以假定平台 C 库自己的扩展在场 | + +`presents`、`data-model`、`wchar` 没有默认值:块里漏写其中一个即被拒绝,并指出缺失的键—— +「没写」不等于三个封闭取值中的任何一个。只有 `builtins` 有默认值 `platform`,即今天的行为。 +未知的键或未知的取值永远是解析错误,并点名该键——绝不静默忽略。**不声明 `[c-abi]` 块的包 +不改变任何东西**:解析出的目标侧、每条编译命令、每个缓存键,都与这项能力出现之前逐字节相同。 + +三件事互不推导,这是刻意保持分开的:`presents` 决定源码走哪条分支,`data-model`/`wchar` +决定 ABI。POSIX 不蕴含 LP64(32 位架构上是 ILP32),LP64 也不蕴含 POSIX。 + +**实现(realisation)。** 一旦 `c-abi` 层解析到声明了这个块的包,mcpp 就把请求转换成编译器 +配置,作用于目标侧的每一个编译单元——C 库自己、C++ 运行时、编译器运行时的 builtins,以及 +图中所有普通包——覆盖 C、C++、汇编编译,依赖扫描,以及 `std` 模块预编译。汇编(`.S`/`.s`)要 +做到这一点需要自己单独的广播通道:`.S` 单元的命令行是独立组装的(`mcpp.build.flags:: +CompileFlags::as`,不是 `::cc`/`::cxx`),而且它有意只从包的 C 标志里取出 `-D`/`-U`/`-I` +子集——对 C 编译器有意义的 `-std=` 或 `-O` 之类标志对 GAS 毫无意义——所以实现出来的环境 +令牌(`--target=`、`-f[no-]short-wchar`,以及 `builtins = "iso"` 添加的部分)要原样再广播 +一遍进这条更窄的通道(openkal-musl 尖峰实验发现并修好的缺口:同一个包里 `.c` 单元看到 +`_WIN32` 未定义,`.S` 单元却仍看到它已定义——真实代码,比如 `okm_setjmp.S` 与上游 +libunwind 的 `assembly.h`,正是按这个宏来选寄存器保存集的)。mcpp 保存的是一份 +「请求到三元组与开关」的映射表,是不含包名的通用知识: + +| 目标 | 请求 | 实现 | +|---|---|---| +| Linux | `posix` / `arch-default` | 默认三元组已经满足 | +| macOS | `posix` / `arch-default` | 默认三元组已经满足 | +| Windows | `posix` / `arch-default` | 采用 Cygwin 式语义:仅在编译行加 `--target=x86_64-pc-cygwin`;`__CYGWIN__`/`__CYGWIN32__` 保持定义(见下方说明);`data-model` 变为 LP64 是三元组切换的结果,不是另一个开关 | +| 任意目标 | `builtins = "iso"` | 关闭代码生成阶段假定平台 C 库在场的惯用法识别——本轮实测到的唯一一例是 Apple 目标上的 `-fno-builtin-memset_pattern16`;`src/toolchain/cenv.cppm` 记录了还核实过哪些、结论是不适用 | +| 其余情况 | | 明确拒绝,点名目标、请求与缺什么——不静默降级 | + +Windows 一行是旗舰情形:`x86_64-w64-windows-gnu` 与 `x86_64-pc-cygwin` 生成的机器码完全一致—— +同样的 PE 格式、同样的 Win64 调用约定、同样的 SEH——差别只在预处理器看到什么、`long` 有多宽。 +因此实现只触及**编译**行;**链接**行保持图解析出的三元组,因为目标文件格式没有变化。 + +**`__CYGWIN__`/`__CYGWIN32__` 保持定义——这是 openkal-musl 尖峰实验带来的修订,不是设计 +最初的陈述。** 最初试过取消定义它们,理由是图里没有真正的 Cygwin 用户态。第三方可移植 +代码里,需要知道**目标文件格式**——不是 C 环境,也不是平台 API——的那部分,没有别的名字 +能指代「PE 格式加呈现 POSIX 的 C 环境」这个组合,只有 `__CYGWIN__`;这样的代码不像本生态 +自己的包那样可以打补丁。`presents = "posix"` 回答的是一个问题——源码看到哪些环境身份宏; +它不能顺带删掉唯一能回答另一个问题——这是什么目标文件格式——的宏。这是一项**留给 30 个 +成员那轮实测去判定的权衡,不是已经定论的事实**:一个库伸手去够 `__CYGWIN__`,也可能伸手 +去够一个这里并不存在的真正 Cygwin 接口(`sys/cygwin.h`、`cygwin_conv_path`)——如果定义它 +带来的新失败比修好的还多,结论就会翻过来。 + +**`kernel-abi` 提供者的自身单元被推导落到平台边界上——它不必自己说出来** +(mcpp 2026.9.18+,PR 进行中根据 openkal-musl 尖峰实验做的修订)。提供 +`mcpp:kernel-abi=` 的包(比如 openkal-windows)必须看到平台自身的 +环境——它要 include 平台声明,`_WIN32` 对它必须为真——而且永远如此,这是 +由定义决定的:这样的包的全部工作就是说平台自己的 ABI,所以它绝不可能是那个 +想要图里「呈现」的 `[c-abi]` 环境、而不是三元组自身环境的包。mcpp 不等着被 +告知这一点。任何 `provides` 里点名 `mcpp:kernel-abi=` 的包,默认就得到 +`c-environment = "platform"`,不需要自己声明: + +```toml +[package] +provides = ["mcpp:kernel-abi=openkal"] +# 没有 [package] c-environment 这一行——边界是从 provides 推导出来的 +``` + +**为什么要推导,而不只是提供这个开关。** 开关本身没问题;它做不到的是追溯性地 +修好一个已经发布、却没写这个开关的包。openkal-windows 0.8.0、openkal-macos +0.10.0、openkal-linux 0.13.0,以及未来任何 kernel-abi 实现,都能因此把边界 +做对——不需要新发版本,也不需要跨仓库协调版本号——因为 `provides = +["mcpp:kernel-abi="]` 正是它们本就已经声明的那一个事实。这里堵住的 +失败是实测出来的,不是假设:openkal-windows 和图里其余部分一样,在 POSIX +替换下编译,`-fno-short-wchar` 给了它 32 位的 `wchar_t`,而它调用的 Win32 +接口回传的却是真正的 16 位 UTF-16——于是一个 `wchar_t*` 循环把两个 UTF-16 +码元读成了一个码点。把这条边界变成默认值,而不是一个包必须记得去写的清单 +键,让这一类失败变得**无法被表达**,而不只是被记录在文档里。 + +**优先级:包自己清单里显式写的 `c-environment` 永远赢过推导。** +推导只在包什么都没写的时候才填 `cEnvironment`——如果一个包终究还是需要 +呈现的环境,它仍然可以显式这样声明(今天还没有办法反着写「不是 +platform」,因为 `"platform"` 仍是这个键唯一接受的取值)。这个显式键也仍然 +是 §5.3 另一类情形——不是 kernel-abi 边界、但自身确实有平台绑定单元的普通 +包——唯一的手段,那种情形下作者做出的确实是 mcpp 无法推导出来的选择: + +```toml +[package] +# 一个普通包,不是 kernel-abi 提供者——这一个引擎推导不出来;作者要自己 +# 声明它,因为平台绑定单元只是这个包所构建内容里真正的少数(设计 §5.3) +c-environment = "platform" +``` + +这是一条边界规则,由这一个开关记录下来,而不是引擎强制执行:这样的包对图其余部分暴露的 +接口仍然只能用定宽类型(SPEC §5.4)。 + +**声明被校验,而不是被信任。** 声明要经过核对,绝不直接信任——这与 openkal 自己核对一致性声明 +的做法一致。目标侧解析出上述开关之后,mcpp 用它们编译一个纯预处理探针(`-E -dM`,把预定义宏 +全部打印出来——足够便宜,且不需要执行,这一点很重要,因为解析出的环境常常是交叉目标),读回 +`__SIZEOF_LONG__`、`__SIZEOF_WCHAR_T__` 以及哪些环境身份宏被定义,与声明核对。不符即失败,并 +同时打印声明值与实测值。结果按配置(编译器二进制身份 + 最终参数)缓存,同一配置解析两次只 +编译一次探针。 + +**指纹。** 解析出的环境参与构建指纹(`compileFlags`,§92 的第 7 项):C 库声明 `lp64` 与 +`llp64` 的两次构建,从同一份源码编译出 `long` 宽度不同的目标文件,因此二者绝不共享输出目录, +也不会复用对方产出的目标文件缓存。 + +**全局构建缓存的键也覆盖了这一点(mcpp 2026.9.18+,PR 进行中的修订,不是设计原文)。** +`~/.mcpp/build-cache/v1`——一次普通依赖编译跨项目、也跨 `mcpp` 升级复用的缓存——是与上面 +构建指纹分开的另一套机制,按包逐一取键,只取真正到达该包自身编译命令行的那些轴 +(`mcpp.build.cache_key`)。解析出的环境到达一个包的命令行,完全是通过引擎的**广播** +(与 `targetSideUsage`、`-D__openkal__` 同一条通道),从来不经过包自己声明的 +`[build] cflags`/`cxxflags`——所以键的推导本身也得被告知去读广播后的值,而不只是声明的值。 +这个缺口正是这样被发现的(协调者反馈,openkal-musl 尖峰实验):原地升级 `mcpp`、缓存目录 +未清理时,给按新环境构建的镜像喂了按**旧**解析环境编译出的目标文件——一个镜像里混了两种 +C 环境,而且没有任何诊断。`fill_package_config` 现在把 `PackageRoot::privateBuild.cflags`/ +`cxxflags`/`asmflags`——广播之后的值——和包自己声明的标志一起折进键里,做法与它原本处理 +include 目录的方式完全一致。`--cache=off`,或者干脆清空缓存目录,从来都不是键本身正确 +的信号——这两条路径都是绕开了这个键,而不是证明了它。 + +**这次发布还把缓存的 epoch 提了一版,让已有条目全部作废——升级后第一次构建会是冷构建。** +键改对了,不代表用旧的、错误推导方式写下的条目就可以留着继续信:一个条目被污染,恰恰是 +因为它记录的键和它实际编译时的输入从一开始就对不上——而修好之后最可能仍然拿到不变的键 +的那个包,正好是这次修订里新推导进 `c-environment = "platform"` 的那一类(上文的 +kernel-abi 提供者):它的 `privateBuild.cflags` 现在是空的,新键因此是从空内容算出来的, +跟旧键(同样是从空内容算出来的)一样;而磁盘上那份目标文件,却是带着替换令牌编译出来的。 +没有更便宜的办法能把修复前写下的条目和修复后写下的条目分开,所以 +`mcpp.build.cache_key::kCacheEpoch` 往上提了一版(2 → 3),让整个缓存无条件作废,而不是 +去相信一个恰恰在最要紧的那些条目上靠不住的键相等判断。 + +**存储键——尚未补上,这里把它到底意味着什么讲清楚(对照真正写入 store 的那条代码路径核实 +过,mcpp 2026.9.18+)。** 一个包的**安装钩子**能够、也确实会编译目标侧代码——目标文件、 +静态库——而它装进的共享 store 只按包名与版本取键,这与 [requires](#requires) 已经记录的 +C++ 运行时选择缺口同形。它和上面构建缓存的键不同、也不是这一个 PR 能用同样方式补上的地方 +在于:安装钩子运行在工具链解析**之前**,这是真实的顺序约束,不是遗漏。`install_hook_env` +在正常路径上工具链相关的字段一律为空——`prepare.cppm` 要等依赖图装完之后才解析 `tc`,因为 +解析目标侧本身可能要依赖图最终供给了哪个包的哪一层(`c-abi` 提供者正是这个图里的一个成员)。 +钩子没有办法去问这次构建解析出了什么环境,因为在它运行的那一刻,压根还没有算出来——不是 +mcpp 忘了传给它,是那时候真的没有可传的东西。 + +**失败模式,直说,不当成缺口表里的一行:** 一个编译了对环境敏感的 C 代码的钩子(凡是正确性 +依赖 `wchar_t` 宽度、数据模型、或哪些环境身份宏被定义的代码)没有办法问这次构建实际解析出 +什么,所以它只能按一种假设编译,然后指望每一个消费者都跟它假设的一样。一个图里声明的 +`[c-abi]` 与之不符的项目,会拿到按一种 `wchar_t` 宽度编译的目标文件,去链接按另一种宽度 +写的头文件——**没有任何东西会去核对这件事**:store 里不记录它装的是按哪种环境编译的,所以 +根本没有可以核对的对象,只有一次悄无声息的错误链接。这与上面 C++ 运行时那条 `requires` +检查已经接受下来的、同一种形状的已知限制相同,不是这一个 PR 新引入的——`[c-abi]` 继承它, +是因为它继承了同一个 store。 + +**要真正补上它需要什么:** 要么 (a) 改成两阶段安装——把安装钩子做的任何目标侧编译推迟到目标侧 +解析完成之后,解析出环境后再重新调用一次钩子(或者一个更晚的第二个钩子)——这会改动整个 +代码库目前当作固定不变的安装/解析顺序;要么 (b) 把 `c++-abi` 那条 `requires` 形状检查的 +做法,推广到 `c-abi`/`c-environment`——包声明它的 store 产物是按哪种环境构建的,工具链解析 +完之后核对,不符就拒绝——这个方案本节已经设计好,但这个 PR 里没有实现。在其中一个真正落地 +之前,过渡期的纪律和 C++ 运行时那条现有要求一样:这类包的安装钩子不得把一种以上的环境变体 +构建进同一个 store 目录。 + ### 标准库模块源 作为标准库的包陈述它的 `std` 模块源在何处,以及该源需要什么。 diff --git a/docs/zh/24-openkal-cross.md b/docs/zh/24-openkal-cross.md index 0b3ac489..30b8970e 100644 --- a/docs/zh/24-openkal-cross.md +++ b/docs/zh/24-openkal-cross.md @@ -27,6 +27,55 @@ C++ 运行时 —— 成为一组由依赖图解析、并由当前运行的编 该论断由一个三宿主 × 三目标的矩阵验证,每一格构建同一份源码并运行其结果。 +## 三层,三组宏(mcpp 2026.9.18+) + +一次基于 openkal 的构建回答三个不同的问题,而在这项能力之前,一个宏(`_WIN32`)同时 +回答了其中两个——这正是每一次 openkal-Windows 失败的根因,其诊断都指向一份缺失的平台 +头文件:代码本想问「这是不是 openkal」,却用了一个实际意味着「Windows CRT 是否在场」的宏。 + +| 宏族 | 陈述 | 定义者 | 例子 | +|---|---|---|---| +| 内核 ABI | `kal_*` 可调用,且在每个平台上行为一致 | 提供 `mcpp:kernel-abi=openkal` 的层 | `__openkal__` | +| C 环境 | 源码看到的 C 环境形状 | 提供 `mcpp:c-abi=` 的层,通过 [`[c-abi]`](22-target-side.md#c-abi-包陈述它呈现的-c-环境mcpp-2026918) | `__unix__`、`_WIN32`、`__MINGW32__` | +| 系统与架构 | 底层操作系统与处理器 | 目标三元组 | `__linux__`、`__APPLE__`、`__x86_64__` | + +还有第四件事,搭着 C 环境这一行的便车,但并不是同一个问题:链接器产出的**目标文件 +格式**——PE、ELF、Mach-O——是**目标三元组**的属性,不是 `presents` 选出来的那个 C 环境 +的属性,二者可以不一致。Windows 上 `presents = "posix"` 链接出来的仍然是 PE;第三方可移植 +代码里,给这个特定组合——「PE 格式 + 呈现 POSIX 的 C 环境」——起名字的唯一办法,是 +`__CYGWIN__`/`__CYGWIN32__`,所以 Cygwin 式的实现让它们保持**定义**,而不是把它折进上面 +三行里的任何一行——完整的权衡说明见 +[22 自己的说明](22-target-side.md#c-abi-包陈述它呈现的-c-环境mcpp-2026918)(不是已经定论 +的事实,结论可能翻过来)。把它当成第四个 C 环境宏来读,而不是它实际是的样子——一个 +`__CYGWIN__` 恰好是唯一名字的目标文件格式事实——正是这一整节想要提前避免的那种混淆。 + +**`__openkal__` 的规则。** 只要解析出的 `kernel-abi` 层的接口名是 `openkal`,引擎就为目标侧的 +每一个编译单元定义它——取自层的取值,绝不取自包名,因此第二个 `mcpp:kernel-abi=openkal` 的 +实现不需要引擎改动。 + +*允许:* 用它决定某处调用点是否调用 `kal_*`。它在每个目标上含义相同,因此这样用绝不会把 +平台信息夹带进本该与实现无关的源码。 + +*禁止:* 用它选择头文件、推断 `_WIN32` 是否为真、绕开缺失的 SDK,或者区分 +`linux`/`windows`/`macos`。那些是 C 环境层或平台层的问题——在清单里写 +`cfg(c-abi = "…")` 或 `cfg(kernel-abi = "…")`(谓词语法见 +[22 —— 对已解析目标侧的适配](22-target-side.md#对已解析目标侧的适配))。 + +**平台单元。** 自己需要平台原生环境的包,从不读 `__openkal__` 或别的宏去推断这件事——边界 +是清单里写明的,不是从源码推断出来的,跨越它的一切仍然只能是定宽类型(SPEC §5.4)。两类 +不同的包,通过两条不同的路径拿到 `[package] c-environment = "platform"`(docs/22): + +- 提供 `mcpp:kernel-abi=` 的包(比如 openkal-windows),这个值是**推导**出来的, + 单凭 `provides` 就够——这样的包按定义本身就是平台边界,所以它自己完全不需要写这个键, + 每一个已经发布的实现也都覆盖到了,不需要发新版本。 +- 一个普通包,不是 kernel-abi 提供者,但自身确有平台绑定的单元——比如 + [06 私有依赖模式](06-features-and-capabilities.md#平台-sdk-依赖保持私有)下的一个平台 + shim——**显式**声明这个键,因为引擎没有 `provides` 条目可供推导(设计 §5.3)。 + +两者都能用得上的地方,显式声明的键总是优先于推导(docs/22 自己的优先级说明)——但今天还 +没有办法反着写「不是 platform」,所以实践中真正用得上这条优先级的,只有一种少见情形: +一个 kernel-abi 提供者,居然还是需要图里呈现的那个 C 环境。 + ## 工程书写的内容 ```toml diff --git a/mcpp.toml b/mcpp.toml index a23471d8..f63e3787 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.9.17.3" +version = "2026.9.18.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 6f5f7b35..df8e3081 100644 --- a/modules/manifest/src/targetside_model.cppm +++ b/modules/manifest/src/targetside_model.cppm @@ -111,6 +111,87 @@ inline std::string_view env_axis_noun(EnvAxis a, std::string_view segment = {}) // one name. On a traditional stack the two are often the same object — macOS // supplies its kernel interface and its C library as one library — and that // sameness is itself worth showing. +// ── The C environment a `c-abi` package declares it presents ──────────────── +// +// design 2026-09-18 (openkal, "C environment declared by the C library +// layer"), §3.2. A traditional stack never has to say this: the compiler +// payload's target triple already implies the environment. The moment a +// package supplies the C library (`mcpp:c-abi=`), the triple's OS field +// is a fact about the MACHINE and no longer a fact about the ENVIRONMENT the +// C library presents to source — openkal-musl on `x86_64-windows-gnu` +// generates PE/Win64 code while presenting a POSIX environment, because it is +// a musl port and every source file that branches on `_WIN32` is asking the +// wrong question of the wrong layer. +// +// THREE FACTS, KEPT SEPARATE. `presents` picks the SOURCE branch (does +// `#ifdef _WIN32` see itself); `dataModel`/`wcharBits` pick the ABI (how wide +// is `long`, how wide is `wchar_t`). POSIX does not imply LP64 — it is ILP32 +// on a 32-bit architecture — and LP64 does not imply POSIX, so a declaration +// states both rather than letting one stand in for the other. +enum class CAbiPresents { Posix, Windows, None }; +enum class CAbiDataModel { ArchDefault, Lp64, Llp64, Ilp32 }; +enum class CAbiBuiltins { Iso, Platform }; + +constexpr std::string_view c_abi_presents_name(CAbiPresents p) { + switch (p) { + case CAbiPresents::Posix: return "posix"; + case CAbiPresents::Windows: return "windows"; + case CAbiPresents::None: return "none"; + } + return {}; +} +constexpr std::string_view c_abi_data_model_name(CAbiDataModel d) { + switch (d) { + case CAbiDataModel::ArchDefault: return "arch-default"; + case CAbiDataModel::Lp64: return "lp64"; + case CAbiDataModel::Llp64: return "llp64"; + case CAbiDataModel::Ilp32: return "ilp32"; + } + return {}; +} +constexpr std::string_view c_abi_builtins_name(CAbiBuiltins b) { + switch (b) { + case CAbiBuiltins::Iso: return "iso"; + case CAbiBuiltins::Platform: return "platform"; + } + return {}; +} + +inline std::optional parse_c_abi_presents(std::string_view v) { + if (v == "posix") return CAbiPresents::Posix; + if (v == "windows") return CAbiPresents::Windows; + if (v == "none") return CAbiPresents::None; + return std::nullopt; +} +inline std::optional parse_c_abi_data_model(std::string_view v) { + if (v == "arch-default") return CAbiDataModel::ArchDefault; + if (v == "lp64") return CAbiDataModel::Lp64; + if (v == "llp64") return CAbiDataModel::Llp64; + if (v == "ilp32") return CAbiDataModel::Ilp32; + return std::nullopt; +} +inline std::optional parse_c_abi_builtins(std::string_view v) { + if (v == "iso") return CAbiBuiltins::Iso; + if (v == "platform") return CAbiBuiltins::Platform; + return std::nullopt; +} + +// `presents`, `data-model` and `wchar` carry no default — a declaration that +// 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. +struct CAbiDecl { + bool declared = false; // was `[c-abi]` present at all + bool hasPresents = false; + bool hasDataModel = false; + bool hasWchar = false; + CAbiPresents presents = CAbiPresents::None; + CAbiDataModel dataModel = CAbiDataModel::ArchDefault; + int wcharBits = 0; // 16 or 32 + CAbiBuiltins builtins = CAbiBuiltins::Platform; +}; + + struct Layer { Origin origin = Origin::None; std::string interfaceName; // openkal / linux / win32 / darwin / musl / glibc / libc++ @@ -174,6 +255,12 @@ struct TargetSide { Layer cAbi; Layer cxx; + // The resolved `c-abi` provider's own `[c-abi]` block, carried through + // unchanged from `Inputs::cAbi->cAbiDecl` — `resolve` does not interpret + // it, only threads it to callers that turn a declaration into compiler + // configuration (`mcpp.toolchain.cenv`). + std::optional cAbiDecl; + // What the triple asked the C library to be, empty when it did not ask. // Kept beside the resolved value rather than replacing it: the report // states the outcome, and this exists so a mismatch can be named. @@ -362,6 +449,10 @@ struct Provider { // distinction needs no second capability name: the capability says a layer // has a supplier, and this key says how far the supply goes. bool hasStdModule = false; + // The `[c-abi]` block, read only from a `mcpp:c-abi=` provider + // (design 2026-09-18 §3.2). `std::nullopt` here means the package supplies + // no such block and today's behaviour applies unchanged. + std::optional cAbiDecl; std::string id() const { return version.empty() ? name : std::format("{}@{}", name, version); @@ -538,8 +629,10 @@ inline TargetSide resolve(const Inputs& in) { ts.kernelAbi = { Origin::Payload, in.targetOs, in.payloadSystemRef, false }; // c-abi ← the triple's ENV field. - if (in.cAbi) + if (in.cAbi) { ts.cAbi = { Origin::Graph, in.cAbi->display_interface(), in.cAbi->id(), false }; + ts.cAbiDecl = in.cAbi->cAbiDecl; + } else if (in.sysrootDeclaredEmpty) ts.cAbi = { Origin::None, {}, {}, false }; else if (!in.sysrootXpkg.empty()) diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index 49dab0b5..369acc8e 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -1043,6 +1043,149 @@ std::expected parse_string(std::string_view content, else m.requiresAbiExceptions = av.as_bool(); } } + // [c-abi] — the C environment a `mcpp:c-abi=` provider presents + // (design 2026-09-18 §3.2). A TOP-LEVEL table, not `[package.c-abi]`: it + // is a declaration about the target side on a par with `[build]`, not a + // package-identity field. + // + // ONLY THE PACKAGE THAT PROVIDES THE LAYER MAY DECLARE IT (§3.2, gap #3 + // of the design's self-review, §12). A package writing this block without + // `provides = ["mcpp:c-abi="]` is stating a fact about a layer it + // does not supply, which is always wrong rather than merely unusual, so + // it is a hard parse error rather than a warning — the same severity as + // every other internal inconsistency this function refuses. + // + // `presents`, `data-model` and `wchar` carry no default: each is checked + // for presence separately so a declaration that forgets one names that + // key rather than silently taking a value nobody wrote. `builtins` + // alone defaults to `platform` — see `CAbiDecl`. + if (auto* ct = doc->get_table("c-abi")) { + bool providesCAbi = std::ranges::any_of(m.provides, [](auto const& e) { + auto cap = mcpp::targetside::parse_capability(e); + return cap && *cap && (*cap)->layer == mcpp::targetside::CapLayer::CAbi; + }); + if (!providesCAbi) + return std::unexpected(error(origin, + "[c-abi] is declared, and [package] provides does not list " + "`mcpp:c-abi=`.\n" + " Only the package that supplies the C library may state " + "the environment it presents — this package is stating a fact " + "about a layer it does not provide.\n" + " Add `mcpp:c-abi=` to [package] provides, or " + "remove [c-abi].")); + + mcpp::targetside::CAbiDecl decl; + decl.declared = true; + static constexpr std::string_view kKnownCAbiKeys[] = { + "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))); + } + if (auto pit = ct->find("presents"); pit != ct->end()) { + if (!pit->second.is_string()) + return std::unexpected(error(origin, + "[c-abi].presents must be a string: \"posix\", \"windows\" " + "or \"none\"")); + auto parsed = mcpp::targetside::parse_c_abi_presents(pit->second.as_string()); + if (!parsed) + return std::unexpected(error(origin, std::format( + "[c-abi].presents = \"{}\" names no known environment " + "identity. The values are: posix, windows, none", + pit->second.as_string()))); + decl.presents = *parsed; + decl.hasPresents = true; + } else { + return std::unexpected(error(origin, + "[c-abi] is missing `presents`. A declared block states all " + "three: `presents`, `data-model`, `wchar` — an absent key is " + "not the same statement as any of its values.")); + } + if (auto dit = ct->find("data-model"); dit != ct->end()) { + if (!dit->second.is_string()) + return std::unexpected(error(origin, + "[c-abi].data-model must be a string: \"arch-default\", " + "\"lp64\", \"llp64\" or \"ilp32\"")); + auto parsed = mcpp::targetside::parse_c_abi_data_model(dit->second.as_string()); + if (!parsed) + return std::unexpected(error(origin, std::format( + "[c-abi].data-model = \"{}\" names no known data model. " + "The values are: arch-default, lp64, llp64, ilp32", + dit->second.as_string()))); + decl.dataModel = *parsed; + decl.hasDataModel = true; + } else { + return std::unexpected(error(origin, + "[c-abi] is missing `data-model`. A declared block states all " + "three: `presents`, `data-model`, `wchar`.")); + } + if (auto wit = ct->find("wchar"); wit != ct->end()) { + if (!wit->second.is_int() + || (wit->second.as_int() != 16 && wit->second.as_int() != 32)) + return std::unexpected(error(origin, + "[c-abi].wchar must be the integer 16 or 32")); + decl.wcharBits = static_cast(wit->second.as_int()); + decl.hasWchar = true; + } else { + return std::unexpected(error(origin, + "[c-abi] is missing `wchar`. A declared block states all " + "three: `presents`, `data-model`, `wchar`.")); + } + if (auto bit = ct->find("builtins"); bit != ct->end()) { + if (!bit->second.is_string()) + return std::unexpected(error(origin, + "[c-abi].builtins must be a string: \"iso\" or \"platform\"")); + auto parsed = mcpp::targetside::parse_c_abi_builtins(bit->second.as_string()); + if (!parsed) + return std::unexpected(error(origin, std::format( + "[c-abi].builtins = \"{}\" names no known policy. The " + "values are: iso, platform", bit->second.as_string()))); + decl.builtins = *parsed; + } + m.cAbiDecl = decl; + } + // [package] c-environment = "platform" — see Manifest::cEnvironment. + if (auto v = doc->get_string("package.c-environment")) { + if (*v != "platform") + return std::unexpected(error(origin, std::format( + "[package] c-environment = \"{}\" names no known override. " + "The only value is \"platform\" — this package's own units " + "compile in the triple's default environment regardless of " + "what the graph's C library declares.", *v))); + m.cEnvironment = *v; + } + // `c-environment = "platform"` is INFERRED, not merely allowed, for any + // package that PROVIDES `mcpp:kernel-abi=` — design revision from + // the openkal-musl spike (openkal-windows called Win32 with a + // POSIX-substituted `wchar_t` width and silently misread its own UTF-16 + // return values). A `kernel-abi` provider is BY DEFINITION the boundary + // where the platform's own interfaces are called; its whole job is to + // speak the platform's ABI, so it can never be the package that wants + // the graph's presented [c-abi] environment instead of the triple's own. + // Making this the DEFAULT rather than a manifest key every kernel-abi + // package must remember means every already-released implementation + // (openkal-windows, -macos, -linux, and whatever comes next) gets the + // boundary right with no new release and no coordinated version bump, + // and the failure class becomes unrepresentable rather than merely + // documented. + // + // The explicit key above still wins: this only fills `cEnvironment` when + // nothing was written, so a kernel-abi package that, after all, needs + // the presented environment can still say so — there is just no way to + // say "not platform" today, because `"platform"` is the only value this + // key accepts (see the parse error above). Precedence is written down + // here and in docs/22 so it is discovered by reading, not by surprise. + if (m.cEnvironment.empty()) { + 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) m.cEnvironment = "platform"; + } // [package] exclusive — capabilities this package claims sole provision of. // // Not validated against the reserved prefix: exclusivity is a property of @@ -1104,9 +1247,9 @@ std::expected parse_string(std::string_view content, // // MUST stay in sync with the `doc->get_*("package.")` reads above. static constexpr std::string_view kKnownPackageKeys[] = { - "accelerators", "authors", "description", "exclusive", "license", - "metadata", "name", "namespace", "platforms", "provides", "repo", - "requires", "requires_abi", "standard", "std-compat-module", + "accelerators", "authors", "c-environment", "description", "exclusive", + "license", "metadata", "name", "namespace", "platforms", "provides", + "repo", "requires", "requires_abi", "standard", "std-compat-module", "std-module", "std-module-flags", "version", }; if (auto* pt = doc->get_table("package")) { @@ -2077,6 +2220,18 @@ std::expected parse_string(std::string_view content, if (auto v = doc->get_string("build.default-profile")) m.buildConfig.defaultProfile = *v; else if (auto v = doc->get_string("build.profile")) m.buildConfig.defaultProfile = *v; // accepted alias if (auto v = doc->get_string("build.cache")) m.buildConfig.cacheMode = *v; + // [build] platform-dependencies — see Manifest::platformDependencies. + // Validated HERE (unlike `cache`) because the value set is one word and + // there is nothing build-mode-specific about it to keep out of the + // manifest layer. + if (auto v = doc->get_string("build.platform-dependencies")) { + if (*v != "refuse") + return std::unexpected(error(origin, std::format( + "[build] platform-dependencies = \"{}\" names no known " + "policy. The only value is \"refuse\" — absent means " + "platform dependencies are allowed, today's behaviour.", *v))); + m.buildConfig.platformDependencies = *v; + } // #519. Validated HERE rather than in prepare_build because the vocabulary // is closed and owned by mcpp: unlike `cache`, whose values interact with // a build mode resolved much later, "static" and "shared" are the whole @@ -2274,7 +2429,8 @@ std::expected parse_string(std::string_view content, "dialect_cxxflags", "flags", "include_dirs", "include_dirs_after", "private_include_dirs", "ios_deployment_target", - "jobs", "ldflags", "macos_deployment_target", "module_extensions", "profile", + "jobs", "ldflags", "macos_deployment_target", "module_extensions", + "platform-dependencies", "profile", "sources", "static_stdlib", "target", // #540: read a few hundred lines above and, until now, absent here — // the SECOND drift of this list, and the comment below narrates the diff --git a/modules/manifest/src/types.cppm b/modules/manifest/src/types.cppm index c5e23f24..538fd7ee 100644 --- a/modules/manifest/src/types.cppm +++ b/modules/manifest/src/types.cppm @@ -11,6 +11,7 @@ import mcpp.pm.compat; // Legacy dependency-key compatibility helpers import mcpp.pm.index_spec; // IndexSpec for [indices] section import mcpp.platform; import mcpp.manifest.flag_words; +import mcpp.targetside; export namespace mcpp::manifest { @@ -958,6 +959,14 @@ struct BuildConfig : BuildInputs { // error under --strict) rather than here, so parsing a manifest never // depends on the build-mode vocabulary. std::string cacheMode; + // `[build] platform-dependencies` — "" (default, allowed) | "refuse". + // design 2026-09-18 §6: the machine-checkable form of "this build is a + // closure entirely on openkal (or whatever kernel-abi it names) and + // nothing else" — any package in the graph that declares itself a + // platform dependency (`provides = ["platform-sdk"]`) fails the build + // rather than being silently linked in. Root-only, like `target`; + // validated in `prepare_build`, not here. + std::string platformDependencies; }; // Canonical package identity used by runtime requirements/artifacts. A short @@ -1823,6 +1832,19 @@ struct Manifest { std::vector targetRequiresAbiExceptions; std::map> targetFeatureRequiresAbiThreads; std::map> targetFeatureRequiresAbiExceptions; + // [c-abi] — a `mcpp:c-abi=` provider's declaration of the C + // environment it presents (design 2026-09-18 §3.2). `std::nullopt` when + // the table is absent, which is today's behaviour unchanged. Validated at + // parse time against `provides` — see `load()` in toml.cppm — so every + // manifest carrying a value here is one this engine has already confirmed + // the right to state it. + std::optional cAbiDecl; + // [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 + // ordinary case. The only accepted value is "platform"; anything else is a + // parse error naming the one value that exists. + std::string cEnvironment; // [package] exclusive — the capabilities this package claims it is the ONLY // provider of. // diff --git a/modules/manifest/src/xpkg.cppm b/modules/manifest/src/xpkg.cppm index 622e8a37..9c5057f2 100644 --- a/modules/manifest/src/xpkg.cppm +++ b/modules/manifest/src/xpkg.cppm @@ -9,6 +9,7 @@ import mcpp.pm.dep_spec; import mcpp.pm.dependency_selector; import mcpp.platform; import mcpp.platform.axis; +import mcpp.targetside; import mcpp.version_req; export namespace mcpp::manifest { @@ -2343,6 +2344,24 @@ synthesize_from_xpkg_lua(std::string_view luaContent, m.package.standard = m.cppStandard.canonical; m.language.standard = m.cppStandard.canonical; + // `c-environment = "platform"` is INFERRED for a `mcpp:kernel-abi=` + // provider — see the identical block, and its full reasoning, in + // `toml.cppm`'s `[package] c-environment` handling. Repeated here rather + // than only there because this is a SEPARATE parser (an xpkg descriptor + // never touches toml.cppm), and this function does not parse an explicit + // `c-environment` key at all yet (`docs/22`, known gap) — so `m + // .cEnvironment` is always empty on entry here, and the inference is + // unconditional rather than "if empty". An already-released + // implementation (openkal-windows, -macos, -linux, …), installed from + // the index as an xpkg descriptor, is exactly the case this exists to + // cover without a new release. + if (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; + })) + m.cEnvironment = "platform"; + return m; } diff --git a/modules/toolchain-model/src/model.cppm b/modules/toolchain-model/src/model.cppm index a717f7bf..252247b3 100644 --- a/modules/toolchain-model/src/model.cppm +++ b/modules/toolchain-model/src/model.cppm @@ -162,6 +162,33 @@ struct Toolchain { // is the only place that has both the request and the compiler — and read // verbatim everywhere else. std::string crossTargetFlag; + + // [c-abi] REALISATION (design 2026-09-18, "C environment declared by the + // C library layer", §3.2-§3.4). When the resolved `c-abi` provider + // declares a `[c-abi]` block, `mcpp.toolchain.cenv::realise` turns the + // request into these tokens, computed once in `prepare` and read by every + // one of `mcpp.toolchain.hostflags`'s three consumers plus the per-package + // broadcast that reaches the dependency scan (`docs/22`, "Adaptation To + // The Resolved Target Side"). Empty = no `[c-abi]` block, or nothing + // beyond the base triple was requested — every command line this build + // emits is then byte-identical to a release before this existed. + std::vector cEnvTokens; + // `[c-abi].builtins = "iso"` tokens, kept apart from `cEnvTokens` so a + // report can name the two requests separately. + std::vector cEnvBuiltinsTokens; + // What the verification probe (`mcpp.toolchain.cenv_probe`) checks the + // realised configuration against. 0 / empty = the declaration said + // nothing about that fact and it is not checked. + int cEnvExpectWcharBits = 0; + int cEnvExpectLongBytes = 0; + std::vector cEnvExpectDefined; + std::vector cEnvExpectUndefined; + // The resolved `kernel-abi` is `openkal` — the engine defines + // `__openkal__` for every target-side unit (design §2.1, §3.4). Read from + // the LAYER's value, never from a package name, so a second `openkal` + // implementation needs no engine change. + bool kernelAbiIsOpenkal = false; + std::filesystem::path sysroot; // -print-sysroot output (or empty) std::optional payloadPaths; // fine-grained sysroot from xpkgs // The TARGET's C library, for targets whose row in kKnownTargets names one diff --git a/modules/versioning/src/version.cppm b/modules/versioning/src/version.cppm index 33d0111b..57fd0bfd 100644 --- a/modules/versioning/src/version.cppm +++ b/modules/versioning/src/version.cppm @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.9.17.3"; +inline constexpr std::string_view MCPP_VERSION = "2026.9.18.1"; } // namespace mcpp diff --git a/src/build/cache_key.cppm b/src/build/cache_key.cppm index a537a0c6..5fcaadde 100644 --- a/src/build/cache_key.cppm +++ b/src/build/cache_key.cppm @@ -73,7 +73,26 @@ export namespace mcpp::build::cache_key { // not ask for. They would all miss anyway (probe_cached compares the REQUESTED // artifacts), but sharing a directory between two layouts makes `cache gc`'s // size accounting and `cache verify`'s output meaningless. -inline constexpr int kCacheEpoch = 2; +// 3 (design 2026-09-18, coordinator review): `fill_package_config` used to +// read only a package's OWN declared cflags/cxxflags, never the engine +// broadcast channel (`privateBuild`) the realised [c-abi] environment and +// `-D__openkal__` actually travel through — so an entry written before this +// fix can be WRONG in a way probe_cached's normal "did the inputs change" +// check cannot see: the compiled object and the recorded key silently +// disagreed about what was compiled from the moment they were written, not +// from a change since. This is the one case in this axis's whole design +// (see the header comment above `struct BuildAxes`) where a narrower fix +// is not enough — the poisoned entries are not the ones whose inputs +// changed, they are ENTRIES WHOSE KEY NEVER DESCRIBED THEM, and the package +// most likely to still show the identical (wrong) key after the fix is +// exactly the one whose `privateBuild.cflags` just emptied out from under +// it (a `kernel-abi` provider inferred into `c-environment = "platform"` in +// this same PR): its new key is computed from nothing, matching its old +// key, which was ALSO computed from nothing, while the STORED OBJECT was +// compiled WITH the substitution. Bumping this orphans the entire cache — +// one cold rebuild — rather than trust a key equality that cannot tell a +// pre-fix entry from a post-fix one. +inline constexpr int kCacheEpoch = 3; // Axes A/B/C — identical for every package in one build, computed once. struct BuildAxes { @@ -185,6 +204,11 @@ struct PackageAxes { std::vector features; std::vector cflags; std::vector cxxflags; + // Whole-package assembler flags reaching a GAS unit's command line + // (`UsageRequirements::asmflags` — see its own comment). Only ever + // non-empty once `usageResolved`: there is no manifest key for it, only + // an engine broadcast, so an unresolved package has none to report. + std::vector asmflags; std::vector ldflags; std::vector defines; std::vector globFlags; // pre-serialized, ordered @@ -302,6 +326,7 @@ nlohmann::json to_json(const BuildAxes& b, const PackageAxes& p) { {"features", p.features}, {"cflags", p.cflags}, {"cxxflags", p.cxxflags}, + {"asmflags", p.asmflags}, {"ldflags", p.ldflags}, {"defines", p.defines}, {"glob_flags", p.globFlags}, @@ -347,6 +372,7 @@ std::string key_hex(const BuildAxes& b, const PackageAxes& p) { put_list(s, "features", p.features); put_list(s, "cflags", p.cflags); put_list(s, "cxxflags", p.cxxflags); + put_list(s, "asmflags", p.asmflags); put_list(s, "ldflags", p.ldflags); put_list(s, "defines", p.defines); put_list(s, "globflags", p.globFlags); @@ -501,6 +527,35 @@ void fill_package_config(PackageAxes& out, out.sourceGlobs = bc.sources; out.moduleExtensions = bc.moduleExtensions; + // THE RESOLVED, POST-BROADCAST flags — not only what THIS package wrote + // in its own manifest. `bc.cflags`/`cxxflags` above are what the package + // declared; `pkg.privateBuild.cflags`/`cxxflags` (once `usageResolved`) + // are what its compile command line actually carries, because several + // engine broadcasts append to `privateBuild` and never touch `bc` — + // `targetSideUsage` (the resolved target side's own include dirs/flags), + // `-D__openkal__`, and the realised [c-abi] environment (design + // 2026-09-18) chief among them. A cache key built from `bc` alone cannot + // tell two builds of the SAME package apart when only one of them + // realised, say, LP64 instead of LLP64 — found exactly that way + // (coordinator report, openkal-musl spike): upgrading mcpp in place hit + // stale global-cache entries compiled under the OLD realised + // environment, linked into an image built under the new one, with + // nothing diagnosing it. `pkg.privateBuild.cflags` already CONTAINS + // `bc.cflags` (prepare.cppm seeds it from the manifest before any + // broadcast appends), so this widens the axis rather than duplicating a + // disjoint one — the same shape `includeDirs` below already uses for the + // identical reason, just appended into the SAME list here (a + // package that received no broadcast folds in an exact duplicate of + // `bc.cflags`, which changes nothing about the hash's ability to tell + // two DIFFERENT inputs apart). + if (pkg.usageResolved) { + out.cflags.insert(out.cflags.end(), pkg.privateBuild.cflags.begin(), + pkg.privateBuild.cflags.end()); + out.cxxflags.insert(out.cxxflags.end(), pkg.privateBuild.cxxflags.begin(), + pkg.privateBuild.cxxflags.end()); + out.asmflags = pkg.privateBuild.asmflags; + } + if (!bc.cStandard.empty()) { // A package may pin its own C standard; it reaches its own C units. out.cflags.push_back("__c_standard=" + bc.cStandard); diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index e809f709..1bd3f3d6 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -33,6 +33,8 @@ import mcpp.modgraph.scanner; import mcpp.modgraph.validate; import mcpp.toolchain.clang; import mcpp.toolchain.hostflags; // the compile-token producer the package std module reuses +import mcpp.toolchain.cenv; // [c-abi] declaration → compiler configuration (design 2026-09-18) +import mcpp.toolchain.cenv_probe; // [c-abi] declaration is checked, not trusted (design §3.2) import mcpp.toolchain.cppfly; import mcpp.toolchain.detect; import mcpp.toolchain.dialect; @@ -10583,6 +10585,7 @@ prepare_build(bool print_fingerprint, p.version = pkg.manifest.package.version; p.interfaceName = decl->interfaceName; p.hasStdModule = !pkg.manifest.stdModule.empty(); + p.cAbiDecl = pkg.manifest.cAbiDecl; auto& slot = byLayer[static_cast(decl->layer)]; // A package may carry both spellings during the transition, and @@ -10769,6 +10772,96 @@ prepare_build(bool print_fingerprint, resolvedTargetSide = tsd::resolve(in); targetSideResolved = true; + // `__openkal__` — design 2026-09-18 §2.1, §3.4. Read from the + // resolved LAYER's interface name, never from a package name, so a + // second implementation of `mcpp:kernel-abi=openkal` needs no engine + // change. Applies to every target-side unit unconditionally — even + // one that declares `c-environment = "platform"`, because the + // exception in §3.4 is about the C ENVIRONMENT a package sees, not + // about whether `kal_*` may be called from it. + if (tc) tc->kernelAbiIsOpenkal = + resolvedTargetSide.kernelAbi.interfaceName == "openkal"; + + // [c-abi] REALISATION — design §3.2-§3.4. `TargetSide::cAbiDecl` is + // set only when the resolved `c-abi` provider's manifest carried a + // `[c-abi]` block (validated at parse time, toml.cppm); everything + // below is therefore skipped, and every command line unchanged, for + // the graph this engine has always built. + if (tc && resolvedTargetSide.cAbiDecl) { + if (!mcpp::toolchain::is_clang(*tc)) { + refusal::record(refusal::Code::CEnvUnrealisable); + return std::unexpected(std::format( + "the C library ('{}', {}) declares [c-abi], and this " + "build's compiler ('{}') is not one mcpp can realise it " + "through.\n" + " [c-abi] is realised with Clang-specific " + "mechanisms — a `--target=` substitution and " + "`-f[no-]short-wchar` — so a Clang toolchain is required " + "for this target while a [c-abi] block is in the graph.\n" + " Select one: [toolchain] default = \"llvm@\", " + "or [target.] toolchain = \"llvm@\".", + resolvedTargetSide.cAbi.interfaceName, + resolvedTargetSide.cAbi.impl, tc->compiler_family())); + } + auto tt = mcpp::toolchain::triple::parse(tc->targetTriple); + auto realised = mcpp::toolchain::cenv::realise( + *resolvedTargetSide.cAbiDecl, tt ? tt->os : std::string{}, + tt ? tt->arch : std::string{}, tt && tt->is_freestanding()); + if (!realised) { + refusal::record(refusal::Code::CEnvUnrealisable); + return std::unexpected(realised.error()); + } + tc->cEnvTokens = realised->tokens; + tc->cEnvBuiltinsTokens = realised->builtinsTokens; + tc->cEnvExpectWcharBits = realised->expectWcharBits; + tc->cEnvExpectLongBytes = realised->expectLongBytes; + tc->cEnvExpectDefined = realised->expectDefined; + tc->cEnvExpectUndefined = realised->expectUndefined; + + // VERIFICATION, NOT TRUST (design §3.2). The probe's argv is the + // IDENTITY-AFFECTING SUBSET of the real command line — the + // `--target=` substitution and the `-U`/`-f[no-]short-wchar` + // tokens `cenv::realise` just produced — because those are the + // only tokens that change what a compiler predefines; include + // paths and library search flags do not, and leaving them out + // is what makes this probe cheap AND cacheable across every + // package that shares this build's target side. + 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); + auto probe = mcpp::toolchain::cenv_probe::verify( + tc->binaryPath, probeArgv, + tc->cEnvExpectWcharBits, tc->cEnvExpectLongBytes, + tc->cEnvExpectDefined, tc->cEnvExpectUndefined); + if (!probe) { + refusal::record(refusal::Code::CEnvUnrealisable); + return std::unexpected(probe.error()); + } + if (!probe->mismatches.empty()) { + refusal::record(refusal::Code::CEnvVerificationMismatch); + std::string lines; + for (auto& mm : probe->mismatches) + lines += std::format( + "\n {:<24} declared {:<10} measured {}", + mm.fact, mm.declared, mm.measured); + return std::unexpected(std::format( + "the C library's [c-abi] declaration does not match " + "what the compiler actually produced for '{}'.{}\n" + " A declaration is checked, never trusted " + "(design 2026-09-18 §3.2) — the mismatch above was " + "measured from the compiler's own predefined macros, " + "compiled with the exact tokens this build derived " + "from the declaration.", + tc->targetTriple, lines)); + } + } + } + // REPORTED, NOT REFUSED (mcpp#662, D4). `mcpp.toolchain.hostflags` // closes the compiler's own C-library search with `-nostdlibinc` // when a package supplies the target's C library (M1) — but only on @@ -10931,6 +11024,60 @@ prepare_build(bool print_fingerprint, } } + // `__openkal__` AND THE REALISED [c-abi] ENVIRONMENT — design + // 2026-09-18 §2.1, §3.2-§3.4. Broadcast into every package's OWN + // `privateBuild`, the same channel `targetSideUsage` just used above: + // it reaches that package's C/C++ compiles AND its dependency scan + // (`mcpp.modgraph.scanner` reads `privateBuild.cflags`/`cxxflags`), + // and it is APPENDED, so it follows every flag the package wrote for + // itself and the driver's own defaults still come last. + // + // `c-environment = "platform"` (§3.4) opts a package OUT of the + // [c-abi] REALISATION ONLY — `__openkal__` still reaches it, because + // the exception is about the C environment a package's headers see, + // not about whether its own code may call `kal_*`. The base command + // line these tokens are appended to is untouched either way, which is + // what keeps a package that declares neither field byte-identical to + // a build before this feature existed. + // + // ALSO INTO `privateBuild.asmflags` (openkal-musl spike, post-review): + // the environment is a property of the TARGET, so it has to reach + // every translation unit built for that target, assembly (.S/.s) + // included — assembly is preprocessed with the same macros, and real + // code selects on them (openkal-musl's own `okm_setjmp.S`; upstream + // libunwind's `assembly.h`). `cflags`/`cxxflags` do not reach a .S + // file wholesale (`mcpp.build.compile_commands::unit_asm_flags` keeps + // only their -D/-U/-I words, on purpose — a -std= or -O token meant + // for the C compiler has no meaning for GAS), so the object-format + // and wchar-width tokens have to be named again here, into the + // channel `unit_asm_flags` passes through UNFILTERED. `__openkal__` + // needs no second copy: it is a -D, and the -D/-U/-I filter already + // carries it from `cflags` into every assembly unit. + // + // Every token in `cEnvTokens`/`cEnvBuiltinsTokens` was checked against + // clang's GAS (`-x assembler-with-cpp`) front end before this was + // written (`--target=`, `-f[no-]short-wchar`, + // `-fno-builtin-memset_pattern16`) and none is rejected — so nothing + // here is filtered a second time; if a future token IS GAS-hostile, + // `cenv::realise` is where to split it, not this broadcast. + if (tc && (tc->kernelAbiIsOpenkal || !tc->cEnvTokens.empty() + || !tc->cEnvBuiltinsTokens.empty())) { + static const std::vector kOpenkalDefine = {"-D__openkal__"}; + for (auto& p : packages) { + if (tc->kernelAbiIsOpenkal) { + appendUniqueFlags(p.privateBuild.cflags, kOpenkalDefine); + appendUniqueFlags(p.privateBuild.cxxflags, kOpenkalDefine); + } + if (p.manifest.cEnvironment == "platform") continue; + appendUniqueFlags(p.privateBuild.cflags, tc->cEnvTokens); + appendUniqueFlags(p.privateBuild.cxxflags, tc->cEnvTokens); + appendUniqueFlags(p.privateBuild.asmflags, tc->cEnvTokens); + appendUniqueFlags(p.privateBuild.cflags, tc->cEnvBuiltinsTokens); + appendUniqueFlags(p.privateBuild.cxxflags, tc->cEnvBuiltinsTokens); + appendUniqueFlags(p.privateBuild.asmflags, tc->cEnvBuiltinsTokens); + } + } + if (auto why = tsd::check_layering(resolvedTargetSide)) { refusal::record(refusal::Code::LayerOrdering); return std::unexpected(*why); @@ -11146,6 +11293,61 @@ prepare_build(bool print_fingerprint, } mcpp::ui::info("Target", tsd::format_report( resolvedTargetSide, reportedTargetName, mcpp::log::is_verbose())); + + // CLOSURE VISIBILITY — design §6. Distinct from the five-layer + // report above: a platform dependency is not a LAYER (no engine + // vocabulary names it, and `mcpp.targetside` — the pure, layer-only + // module the report above comes from — stays that way), it is an + // ORDINARY package that happens to declare `provides = + // ["platform-sdk"]`. That is the precise, machine-checkable + // definition this build uses: a package brings a platform + // dependency if and only if it says so, the same way a package + // states any other capability (docs/22, "provides"). Nothing infers + // this from header paths or link flags, because inference here would + // have exactly the silent-typo failure mode the reserved `mcpp:` + // prefix exists to avoid for the five layers — except this + // capability is deliberately UNPREFIXED, because it names no layer + // this engine resolves, only a fact a package states about itself. + std::vector platformDeps; + for (auto& pkg : packages) { + if (std::ranges::find(pkg.manifest.provides, "platform-sdk") + == pkg.manifest.provides.end()) + continue; + platformDeps.push_back(pkg.manifest.package.version.empty() + ? pkg.manifest.package.name + : std::format("{}@{}", pkg.manifest.package.name, + pkg.manifest.package.version)); + } + if (!platformDeps.empty() || mcpp::log::is_verbose()) { + std::string joined; + for (auto& d : platformDeps) { + if (!joined.empty()) joined += ", "; + joined += d; + } + mcpp::ui::info("Target", std::format( + " {:<17} {}", "platform-deps", + joined.empty() ? std::string("—") : joined)); + } + if (!platformDeps.empty() + && m->buildConfig.platformDependencies == "refuse") { + refusal::record(refusal::Code::PlatformDependency); + std::string joined; + for (auto& d : platformDeps) { + if (!joined.empty()) joined += ", "; + joined += d; + } + return std::unexpected(std::format( + "[build] platform-dependencies = \"refuse\", and the " + "dependency graph brings {}: {}.\n" + " This build asked to be a closure entirely on its " + "kernel-abi implementation and nothing else (design " + "2026-09-18 §6).\n" + " Remove the dependency, remove the feature that " + "pulled it in, or drop the refusal to allow it.", + platformDeps.size() == 1 ? "a platform dependency" + : "platform dependencies", + joined)); + } } // ── L1b: conditional sections whose predicate names a target-side layer ── @@ -12075,6 +12277,18 @@ prepare_build(bool print_fingerprint, for (auto& f : mcpp::toolchain::graph_runtime_compile_flags(*tc)) flags += " " + f; } + // `__openkal__` AND THE REALISED [c-abi] ENVIRONMENT REACH THE STD + // MODULE TOO (design §3.4: "环境作用于目标侧的全部编译单元... 以及图中 + // 所有普通包"). The std module's own command is assembled here rather + // than through `mcpp.toolchain.hostflags`'s shared string (see the + // comment above), so it needs the same broadcast the ordinary + // per-package loop gives every other unit — this is that same rule, + // stated once more at the one site it cannot reach on its own. + if (tc->kernelAbiIsOpenkal) flags += " -D__openkal__"; + if (pkg.manifest.cEnvironment != "platform") { + for (auto& t : tc->cEnvTokens) flags += " " + t; + for (auto& t : tc->cEnvBuiltinsTokens) flags += " " + t; + } // Everything up to here says which machine the module is for; what // follows says where its headers are. The codegen step needs only the // first — see Toolchain::stdModuleTargetFlags. @@ -12323,6 +12537,44 @@ prepare_build(bool print_fingerprint, fpi.cppStandard = m->package.standard; fpi.compileFlags = canonical_compile_flags(*m) + canonical_package_build_metadata(packages); + // [c-abi] REALISATION AND `__openkal__` PARTICIPATE IN THE FINGERPRINT + // (design 2026-09-18 §3.4, gap #4 of the design's own self-review). Two + // builds whose C library declares `data-model = "lp64"` and `"llp64"` + // compile the SAME source, against the SAME manifest, into objects whose + // `long` disagrees in width — sharing an output directory between them is + // exactly the silent ABI mismatch §3.4 exists to rule out. Appended only + // when non-empty (`tc->cEnvTokens` is empty whenever no `[c-abi]` block + // resolved), so a graph that declares nothing keeps the directory it + // already had. + if (tc->kernelAbiIsOpenkal) fpi.compileFlags += " openkal-kernel-abi"; + for (auto& t : tc->cEnvTokens) fpi.compileFlags += " cenv:" + t; + for (auto& t : tc->cEnvBuiltinsTokens) fpi.compileFlags += " cenv:" + t; + // A package opting OUT via `c-environment = "platform"` (§3.4) still + // changes what ITS OWN objects contain, relative to a graph where it + // did not opt out — so the opt-out is folded in too, named by the + // package rather than by its flags, since the flags it now keeps are + // simply the ones already covered above. + // + // GATED ON THE REALISATION ACTUALLY BEING ACTIVE (`cEnvTokens` or + // `cEnvBuiltinsTokens` non-empty) — NOT unconditional. `cEnvironment == + // "platform"` is true for every `mcpp:kernel-abi=` provider now + // (it is INFERRED, this same revision), in every graph that uses one, + // whether or not that graph's C library declares `[c-abi]` at all. An + // unconditional loop here folded `cenv-platform:` into the + // fingerprint of EVERY project using openkal-windows (say) even when + // nothing about the realised environment was active — moving every + // such project's output directory on upgrade for a string that + // describes an opt-out from a realisation that never ran. There is + // nothing to opt OUT of when there is nothing being realised, so the + // opt-out changes nothing about that package's own objects and must + // not move the fingerprint either — the same "declares nothing, byte + // identical" guarantee the rest of this block already gives, which this + // loop had broken on its own. + if (!tc->cEnvTokens.empty() || !tc->cEnvBuiltinsTokens.empty()) { + for (auto& pkg : packages) + if (pkg.manifest.cEnvironment == "platform") + fpi.compileFlags += " cenv-platform:" + pkg.manifest.package.name; + } // The module-edge schedule changes the SHAPE of build.ninja, and the fast // path replays that file without a plan to compare against. Folding the // switch into the fingerprint puts a differently-scheduled build in a diff --git a/src/build/refusal.cppm b/src/build/refusal.cppm index 46a13c9a..0b0bcd27 100644 --- a/src/build/refusal.cppm +++ b/src/build/refusal.cppm @@ -129,6 +129,19 @@ enum class Code { // ExclusiveCapability, which is about two implementations of an interface: // here it is one package that has no single image to live in. StaticPackageInTwoImages, + // The resolved `c-abi` provider's `[c-abi]` declaration has no known + // realisation for this target (design 2026-09-18 §3.2, mapping table + // §3.3) — `mcpp.toolchain.cenv::realise` returned a refusal, or the + // resolved compiler is not one this engine can realise it through. + CEnvUnrealisable, + // The probe translation unit compiled with the realised `[c-abi]` + // configuration disagrees with the declaration (design §3.2, "声明被校验, + // 而不是被信任"): `sizeof(long)`, `__SIZEOF_WCHAR_T__`, or the presence of + // an environment-identity macro did not match what was declared. + CEnvVerificationMismatch, + // `[build] platform-dependencies = "refuse"` and a package in the graph + // brings a platform SDK dependency (design §6). + PlatformDependency, Other, // a refusal that has not been given a code yet }; @@ -168,6 +181,10 @@ constexpr std::string_view name(Code c) { return "program-cxx-runtime-split"; case Code::StaticPackageInTwoImages: return "static-package-in-two-images"; + case Code::CEnvUnrealisable: return "c-env-unrealisable"; + case Code::CEnvVerificationMismatch: + return "c-env-verification-mismatch"; + case Code::PlatformDependency: return "platform-dependency"; case Code::Other: return "other"; } return "other"; diff --git a/src/modgraph/scanner.cppm b/src/modgraph/scanner.cppm index 2308bde3..02cfa55d 100644 --- a/src/modgraph/scanner.cppm +++ b/src/modgraph/scanner.cppm @@ -121,6 +121,19 @@ struct UsageRequirements { std::vector includeDirsAfter; std::vector cflags; std::vector cxxflags; + // Whole-package assembler flags. There is no `[build] asmflags = [...]` + // manifest key to back this — a per-glob `flags = [{ glob, asmflags }]` + // entry is the author-facing channel (scan_one_into applies it below, via + // apply_glob_flags) — this field exists only for the engine's own + // broadcasts, today the realised [c-abi] environment tokens (design + // 2026-09-18 §3.2). `cflags`/`cxxflags` do NOT reach assembly wholesale: + // `mcpp.build.compile_commands::unit_asm_flags` narrows a unit's C flags + // to their -D/-U/-I words before letting them near a .S file (a -std= or + // -O flag meant for the C compiler is meaningless, or worse, to GAS), so + // a broadcast that must reach assembly UNFILTERED — `--target=`, + // `-f[no-]short-wchar`, the object-format identity itself — has to name + // itself here rather than ride along in cflags and be quietly dropped. + std::vector asmflags; std::vector ldflags; std::vector modules; }; @@ -1175,7 +1188,8 @@ void scan_one_into(ScanResult& result, const std::vector& localIncludeDirs, const std::vector& localIncludeDirsAfter, const std::vector& packageCflags, - const std::vector& packageCxxflags) + const std::vector& packageCxxflags, + const std::vector& packageAsmflags = {}) { // This package's own extension table. Built once per package, not per // file, and taken from THIS manifest — a dependency is classified by its @@ -1286,6 +1300,16 @@ void scan_one_into(ScanResult& result, u.localIncludeDirsAfter = localIncludeDirsAfter; u.packageCflags = packageCflags; u.packageCxxflags = packageCxxflags; + // GAS ONLY. NASM has no `--target=` concept and does not + // recognize clang-family flags at all — `packageAsmflags` here is + // the engine's own broadcast (today: the realised [c-abi] + // environment), and unlike a per-glob `flags = [{asmflags}]` + // entry (applied below by apply_glob_flags, to both kinds, which + // is safe because THAT list is author-written per glob) it is not + // written with any one assembler in mind. A NASM unit still gets + // its own per-glob asmflags untouched. + if (u.kind == mcpp::SourceKind::GasAsm) + u.packageAsmflags = packageAsmflags; apply_glob_flags(u); normalize_include_flags(root, u.packageCflags); normalize_include_flags(root, u.packageCxxflags); @@ -1305,6 +1329,9 @@ void scan_one_into(ScanResult& result, r->localIncludeDirsAfter = localIncludeDirsAfter; r->packageCflags = packageCflags; r->packageCxxflags = packageCxxflags; + // See the identical GAS-only gate in the scan_overrides branch above. + if (r->kind == mcpp::SourceKind::GasAsm) + r->packageAsmflags = packageAsmflags; apply_glob_flags(*r); normalize_include_flags(root, r->packageCflags); normalize_include_flags(root, r->packageCxxflags); @@ -1451,8 +1478,21 @@ ScanResult scan_packages(const std::vector& packages) { auto const& packageCxxflags = p.usageResolved ? p.privateBuild.cxxflags : p.manifest.buildConfig.cxxflags; + // No `!p.usageResolved` fallback here: unlike cflags/cxxflags there is + // no `manifest.buildConfig.asmflags` to fall back to (see + // `UsageRequirements::asmflags`'s own comment) — a package's whole- + // package asm broadcast exists only once usage is resolved. A static + // empty vector (rather than a ternary mixing an lvalue with a + // temporary) keeps the resolved branch a reference, not a copy — the + // same reason packageCflags/packageCxxflags above are two same-typed + // lvalues rather than one lvalue and one temporary. + static const std::vector kNoAsmFlags; + auto const& packageAsmflags = p.usageResolved + ? p.privateBuild.asmflags + : kNoAsmFlags; scan_one_into(result, p.root, p.manifest, localIncludeDirs, - localIncludeDirsAfter, packageCflags, packageCxxflags); + localIncludeDirsAfter, packageCflags, packageCxxflags, + packageAsmflags); } resolve_graph(result); return result; @@ -1497,6 +1537,9 @@ ScanResult scan_packages_p1689(const std::vector& packages, r->packageCxxflags = p.usageResolved ? p.privateBuild.cxxflags : p.manifest.buildConfig.cxxflags; + // GAS only — see the identical gate in scan_one_into above. + if (p.usageResolved && r->kind == mcpp::SourceKind::GasAsm) + r->packageAsmflags = p.privateBuild.asmflags; result.graph.units.push_back(std::move(*r)); } } diff --git a/src/toolchain/cenv.cppm b/src/toolchain/cenv.cppm new file mode 100644 index 00000000..cc9526bd --- /dev/null +++ b/src/toolchain/cenv.cppm @@ -0,0 +1,346 @@ +// mcpp.toolchain.cenv — a `[c-abi]` REQUEST turned into COMPILER CONFIGURATION. +// +// design 2026-09-18 (openkal, "C environment declared by the C library +// layer"), §3.2–§3.4. The C library package states the environment it +// PRESENTS to source (`posix` / `windows` / `none`), its data model and its +// `wchar_t` width; this module holds the engine's map from that request, +// together with the target it is requested for, onto the tokens a compile +// command needs to realise it — or a refusal naming what is missing. +// +// GENERIC KNOWLEDGE, NO PACKAGE NAMES. Exactly as `mcpp.targetside` hardcodes +// the five layer names and never an implementation, this module hardcodes the +// request→triple/flags MAPPING and never a C library's identity. openkal-musl +// is nowhere in this file; a second POSIX C library on Windows would realise +// through the same table. +// +// ONE MAPPING TABLE, THE ONE §3.3 PUBLISHES: +// +// target request realisation +// Linux posix / arch-default the default triple already satisfies it +// macOS posix / arch-default the default triple already satisfies it +// Windows posix / arch-default Cygwin-flavoured: `--target=x86_64-pc-cygwin`, +// `__CYGWIN__`/`__CYGWIN32__` STAY DEFINED (see +// the note below the table — this is a design +// revision, not the original §3.3 text) +// * builtins = iso turn off the platform-C-library idioms the +// code generator assumes (§3.2.1) — Apple's +// `memset_pattern16` is the one measured case +// anything else refused, naming the target, the request and +// what is missing — never a silent downgrade +// +// `wchar` is realised the same way on every target through one clang pair, +// `-fshort-wchar` / `-fno-short-wchar`, relative to the triple's OWN default +// width — that generalisation is this module's, not the design record's, and +// is why the VERIFICATION probe (mcpp.toolchain.cenv_probe) exists: a flag +// this table applies with confidence is still checked against what the +// compiler actually did, per §3.2's own rule that a declaration is checked, +// never trusted. +// +// COMPILE-TIME ONLY, AND DELIBERATELY SO. What changes here is the +// preprocessor identity and the `--target=` used for COMPILING; the LINK line +// keeps the triple the graph resolved (`x86_64-w64-windows-gnu`). §1.4 of the +// design measured that the object format and the calling convention are +// IDENTICAL between `x86_64-w64-windows-gnu` and `x86_64-pc-cygwin` — same PE, +// same Win64 argument placement — so an object compiled under the Cygwin +// identity links exactly like one compiled under the MinGW one. Only the +// preprocessor saw a different environment; the linker never has to know. +// +// `__CYGWIN__`/`__CYGWIN32__` ARE NOT REMOVED, AND THE FIRST VERSION OF THIS +// MODULE GOT THAT WRONG. §3.3's original text called for `-U__CYGWIN__ +// -U__CYGWIN32__` on the reasoning that a real Cygwin userland is not in the +// graph. A first reading of the openkal-musl spike's libunwind build failure +// blamed a missing `__CYGWIN__` branch in libunwind itself; reading the +// vendored source shows that is wrong — upstream libunwind has no such +// branch, so defining it there would have changed nothing (the actual break +// was a downstream package selecting on `_WIN32` and is being fixed there). +// The reason to keep them defined is narrower and still real: third-party +// portable code that has to know the OBJECT FORMAT — as opposed to which C +// environment or which platform API — has no name for "PE format with a +// POSIX-presenting C environment" other than `__CYGWIN__`, and such code +// cannot be patched the way this ecosystem's own packages can. The cost is +// symmetric: a library that reaches for `__CYGWIN__` may also reach for a +// real Cygwin interface (`sys/cygwin.h`, `cygwin_conv_path`) that does not +// exist here. This is a TRADE-OFF for the 30-member measurement to settle — +// if defining it produces more new failures than it fixes, the answer flips +// — not a fact this module is asserting as closed. +export module mcpp.toolchain.cenv; + +import std; +import mcpp.targetside; + +export namespace mcpp::toolchain::cenv { + +// What a realised `[c-abi]` declaration adds to a compile command, and what +// the verification probe must observe to confirm it actually happened. +struct Realisation { + // Tokens appended AFTER the base `--target=` and after every other + // token `mcpp.toolchain.hostflags` already emits. Clang takes the LAST + // `--target=` on a command line, so a triple substitution here overrides + // the base one without that producer having to know this module exists. + std::vector tokens; + + // `[c-abi].builtins = "iso"` tokens, kept apart from `tokens` only so a + // caller that wants to report the two requests separately can. + std::vector builtinsTokens; + + // What the probe (mcpp.toolchain.cenv_probe) checks the FINAL command + // line actually produced, against what was declared. 0 = not checked + // (the declaration said nothing about that fact). + int expectWcharBits = 0; + int expectLongBytes = 0; // 8 = LP64/ILP32-on-64bit is N/A; see below + std::vector expectDefined; + std::vector expectUndefined; + + // NO "REVERSAL" FIELD FOR `c-environment = "platform"` (design §3.4), AND + // DELIBERATELY. `tokens`/`builtinsTokens` are broadcast ADDITIVELY into + // every ordinary package's own compile inputs (`prepare.cppm`, beside the + // existing target-side-usage broadcast) and left OUT of a platform + // package's — the base command line these tokens are appended to is + // therefore untouched either way, so "opting out" needs no counter-flags, + // only the absence of the ones everyone else received. +}; + +// Heuristic bit width from an ARCH SPELLING, not a lookup table of +// architectures — mcpp's own arch vocabulary (docs/21) is `x86_64`, +// `aarch64`, `riscv64`, `wasm32`, and every 64-bit member of it contains +// "64" while every 32-bit one does not. A future arch this heuristic gets +// wrong fails LOUDLY, at the refusal below, rather than silently picking a +// data model nobody asked for — `realise` only trusts this for `arch-default` +// and refuses every explicit request it cannot itself verify by triple +// substitution. +inline bool arch_is_64bit(std::string_view arch) { + return arch.find("64") != std::string_view::npos; +} + +// WHAT `data-model = "arch-default"` MEANS: THE CONVENTION THE DECLARED +// ENVIRONMENT ITSELF USES ON THAT ARCHITECTURE, NOT THE TRIPLE'S. design +// §3.2's own example is explicit about this — "该架构上 musl 自己的模型, +// 64 位即 LP64" (the model musl itself uses on that architecture; 64-bit +// means LP64) — for a `presents = "posix"` C library: POSIX's own `long` +// convention on a 64-bit architecture is LP64, full stop, independent of +// which OS is underneath. A `presents = "windows"` C library's own +// convention is the Windows CRT's, LLP64, for the same reason `env = "gnu"` +// on a Windows triple names UCRT's data model and not glibc's (docs/21). +// `presents = "none"` never reaches this function with a data model to +// decide: the identity check above refuses it on every hosted target this +// table covers, and a freestanding target skips the data-model check +// entirely (there is no OS-conventional `long` to compare against). +// +// COMPILED WRONG ONCE, AND THE BUG IS WORTH NAMING. An earlier revision took +// no `presents` parameter here at all and always answered the POSIX +// convention, which happened to be right for the flagship case +// (`presents = "posix"` on Windows/x86_64: LP64) and wrong for the control +// case beside it in the unit tests (`presents = "windows"`, `arch-default`, +// on Windows/x86_64: this function answered LP64, `realise` then refused +// because native Windows is LLP64). The fix is the `presents` parameter: +// `arch-default` asks what THIS DECLARED PRESENTS value's own convention is, +// `triple_native_data_model` below asks what the BASE TRIPLE already has +// with no declaration at all, and the two are compared to decide whether any +// realisation is needed — never confused for one function. +inline mcpp::targetside::CAbiDataModel arch_default_data_model( + mcpp::targetside::CAbiPresents presents, std::string_view arch) { + using mcpp::targetside::CAbiDataModel; + using mcpp::targetside::CAbiPresents; + if (presents == CAbiPresents::Windows) return CAbiDataModel::Llp64; + return arch_is_64bit(arch) ? CAbiDataModel::Lp64 : CAbiDataModel::Ilp32; +} + +// The data model the BASE, UNMODIFIED triple already has — what deciding +// "is any realisation needed at all" is compared against, and what +// `presents = "none"`'s `arch-default` takes since it states no convention +// of its own. Windows is LLP64 on every architecture it supports, MinGW and +// MSVC alike; every other OS this table covers follows the architecture's +// own convention. +inline mcpp::targetside::CAbiDataModel triple_native_data_model( + std::string_view os, std::string_view arch) { + using mcpp::targetside::CAbiDataModel; + if (os == "windows") return CAbiDataModel::Llp64; + return arch_is_64bit(arch) ? CAbiDataModel::Lp64 : CAbiDataModel::Ilp32; +} + +// The `wchar_t` width a target's default triple already has. Measured +// (design §1.4): 16 on Windows (MinGW and Cygwin alike, absent +// `-fno-short-wchar`), 32 everywhere else clang targets (Linux, macOS, +// freestanding ELF). +inline int native_wchar_bits(std::string_view os) { + return os == "windows" ? 16 : 32; +} + +// `decl.declared` must be true — callers hold `TargetSide::cAbiDecl`, which is +// only ever set when a `[c-abi]` block was read (§3.2). `os`/`arch` are mcpp's +// own triple fields (`docs/21`); `freestanding` is `Triple::is_freestanding()`. +// +// Returns the tokens to append, or a refusal naming the target, the request +// and what is missing (§3.2: "无法满足...明确拒绝并说明缺什么,不静默降级"). +inline std::expected realise( + const mcpp::targetside::CAbiDecl& decl, std::string_view os, + std::string_view arch, bool freestanding) { + using mcpp::targetside::CAbiPresents; + using mcpp::targetside::CAbiDataModel; + + Realisation r; + auto refuse = [&](std::string_view missing) -> std::expected { + return std::unexpected(std::format( + "the C library's [c-abi] declaration cannot be realised for " + "'{}{}'.\n" + " requested presents = {}, data-model = {}, wchar = {}\n" + " missing {}\n" + " mcpp knows one realisation for a POSIX-presenting " + "environment on a non-POSIX target: the Cygwin-flavoured " + "compile on Windows/x86_64 (design 2026-09-18 §3.3). Every " + "other combination this build asked for has no known mapping " + "and is refused rather than silently approximated.", + os, freestanding ? " (freestanding)" : "", + mcpp::targetside::c_abi_presents_name(decl.presents), + mcpp::targetside::c_abi_data_model_name(decl.dataModel), + decl.wcharBits, missing)); + }; + + // Whether the identity branch below chose the Cygwin substitution — the + // one mechanism that also MOVES the data model (native LLP64 to LP64) as + // a side effect. The data-model check right after needs to compare + // against what the compile line ACTUALLY has at that point, not against + // the triple's original default, or a `data-model = "llp64"` requested + // alongside `presents = "posix"` would read as "already satisfied" (the + // triple's own native IS LLP64) while the identity switch already moved + // it to LP64 — a contradiction the mapping should refuse outright rather + // than leave for the verification probe to catch after a real compile. + bool cygwinIdentity = false; + + // ── `presents`: the environment-identity macros ───────────────────────── + if (freestanding) { + // A freestanding target defines none of `_WIN32`/`__unix__` to begin + // with, so only `none` — the value the (deferred) reduced-ISO-C form + // uses — is already satisfied; the other two ask for an identity that + // does not exist here. + if (decl.presents != CAbiPresents::None) + return refuse("a freestanding target has no platform identity " + "macros to become `posix` or `windows`"); + } 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"); + // `--target=x86_64-pc-cygwin`, on the COMPILE line only (module + // header above). `__CYGWIN__`/`__CYGWIN32__` are LEFT AS THE + // TRIPLE SUBSTITUTION DEFINES THEM — not undefined (see the + // module header's note: portable third-party code that needs to + // know the object format has no other name for "PE format, + // POSIX-presenting environment", and this is a trade-off for the + // 30-member measurement, not a settled fact). + r.tokens.push_back("--target=x86_64-pc-cygwin"); + r.expectDefined.push_back("__unix__"); + r.expectDefined.push_back("__CYGWIN__"); + r.expectUndefined.push_back("_WIN32"); + cygwinIdentity = true; + } else if (decl.presents == CAbiPresents::Windows) { + // Already the base triple's own identity — nothing to add. + r.expectDefined.push_back("_WIN32"); + } else { + return refuse("no known way to suppress every environment-" + "identity macro on a hosted Windows triple"); + } + } else if (os == "linux" || os == "macos") { + if (decl.presents == CAbiPresents::Posix) { + // The default triple already presents POSIX. + r.expectDefined.push_back("__unix__"); + r.expectUndefined.push_back("_WIN32"); + } else { + return refuse(std::format( + "no known way to make a {} target present `{}` — that " + "identity belongs to a different object format", + os, mcpp::targetside::c_abi_presents_name(decl.presents))); + } + } else { + return refuse("this OS is outside the mapping table §3.3 publishes"); + } + + // ── `data-model` ───────────────────────────────────────────────────────── + if (!freestanding) { + // `want`: what `arch-default` MEANS — the C LIBRARY's own convention + // for this architecture (`arch_default_data_model`), never the + // triple's. `tripleNative`: what the BASE, unmodified triple already + // has, which decides whether anything needs to change at all. + const auto want = decl.dataModel == CAbiDataModel::ArchDefault + ? arch_default_data_model(decl.presents, arch) : decl.dataModel; + // What the compile line ALREADY has at this point — the triple's own + // native model, unless the identity switch above already moved it + // (the Cygwin mechanism: native LLP64 to LP64, as a side effect of + // the `--target=` substitution, not a second flag). Comparing `want` + // against THIS rather than against the triple's original default is + // what makes `presents = "posix", data-model = "llp64"` a refusal + // instead of a false "already satisfied": the identity switch that + // `presents = "posix"` required already left LLP64 behind. + const auto effectiveNative = cygwinIdentity ? CAbiDataModel::Lp64 + : triple_native_data_model(os, arch); + if (want == effectiveNative) { + // Already satisfied — the common case, and the only one that + // needs no further check: `arch-default` on Linux/macOS/native + // Windows, or on the Cygwin identity once it has already moved + // the model to LP64. + } else { + return refuse(std::format( + "data-model = {} has no realisation on {}/{} once `presents " + "= \"{}\"` is realised (that leaves the model at {})", + mcpp::targetside::c_abi_data_model_name(want), os, arch, + mcpp::targetside::c_abi_presents_name(decl.presents), + mcpp::targetside::c_abi_data_model_name(effectiveNative))); + } + r.expectLongBytes = + (want == CAbiDataModel::Lp64) ? 8 + : (want == CAbiDataModel::Llp64 || want == CAbiDataModel::Ilp32) ? 4 + : 0; + } + + // ── `wchar` ────────────────────────────────────────────────────────────── + if (decl.hasWchar) { + const int native = freestanding ? 32 : native_wchar_bits(os); + if (decl.wcharBits != native) { + // One clang pair, relative to whatever the triple in force + // (possibly already substituted above) would otherwise give: + // `-fshort-wchar` for 16, `-fno-short-wchar` for 32. Measured on + // the Cygwin triple (design §1.4): default 16, `-fno-short-wchar` + // gives 32. + r.tokens.push_back(decl.wcharBits == 32 ? "-fno-short-wchar" + : "-fshort-wchar"); + } + r.expectWcharBits = decl.wcharBits; + } + + // ── `builtins` ─────────────────────────────────────────────────────────── + // + // §3.2.1: the compiler assumes the PLATFORM C library provides certain + // symbols, and that assumption survives the identity switch above because + // it happens at code generation, after preprocessing. Surveyed against + // clang 22's `-fno-builtin-` family (one flag per recognised + // idiom): + // + // Apple targets `memset_pattern16`/`memset_pattern4`/`memset_pattern8` + // — libSystem loop-idiom recognition (measured, §3.2.1). + // 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. + // 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 + // code-generation idiom recognition. + // + // So `iso` realises to one flag, on Apple targets only, and does nothing + // measurable elsewhere today. That is reported rather than silently + // accepted: a caller that wants to know what changed reads `builtinsTokens`. + if (decl.builtins == mcpp::targetside::CAbiBuiltins::Iso) { + if (!freestanding && (os == "macos" || os == "ios")) + r.builtinsTokens.push_back("-fno-builtin-memset_pattern16"); + } + + return r; +} + +} // namespace mcpp::toolchain::cenv diff --git a/src/toolchain/cenv_probe.cppm b/src/toolchain/cenv_probe.cppm new file mode 100644 index 00000000..8ff81352 --- /dev/null +++ b/src/toolchain/cenv_probe.cppm @@ -0,0 +1,209 @@ +// mcpp.toolchain.cenv_probe — the `[c-abi]` declaration is CHECKED, not TRUSTED. +// +// design 2026-09-18 §3.2: "声明被校验,而不是被信任" (a declaration is +// verified, never trusted) — the same rule openkal itself follows for its own +// conformance claims. Once the target side has resolved the tokens +// `mcpp.toolchain.cenv::realise` computed, this module asks the REAL +// compiler, with those REAL final flags, what it actually predefines, and +// compares the answer against what the C library declared. A mismatch fails +// the build and prints both — the declaration and the measurement — because +// a caller that is told only "wrong" has no way to tell which of the two was. +// +// ONE PREPROCESS, NO CODEGEN, NO EXECUTION. `-E -dM` dumps every macro the +// driver would hand the real compile — including `__SIZEOF_LONG__` and +// `__SIZEOF_WCHAR_T__`, which clang predefines from the SAME target-and-flags +// resolution that decides the real `sizeof(long)`/`sizeof(wchar_t)` — so the +// three facts §3.2 names (`sizeof(long)`, `__SIZEOF_WCHAR_T__`, +// `_WIN32`/`__unix__` presence) are all read from one dump. No execution: the +// realised environment is routinely a CROSS target (`x86_64-windows-gnu` +// built on Linux is the design's own motivating case), and a probe that had +// to RUN the result would need the target to be runnable on the build host, +// which defeats a check meant to hold for every build. +// +// CACHED PER CONFIGURATION. The dump depends only on the compiler binary and +// the exact argv handed to it — nothing about the project's own sources — so +// it is hashed and cached beside the std module cache +// (`mcpp::home::cache_root()`), and a build that resolves the same +// configuration twice (two targets in one workspace, or two consecutive +// builds) pays for the compile once. +export module mcpp.toolchain.cenv_probe; + +import std; +import mcpp.home; +import mcpp.platform; +import mcpp.toolchain.fingerprint; // hash_string — the same 64-bit FNV the std cache keys with + +export namespace mcpp::toolchain::cenv_probe { + +struct Mismatch { + std::string fact; // "sizeof(long)" / "wchar_t width" / "_WIN32" / "__unix__" / ... + std::string declared; + std::string measured; +}; + +struct Result { + bool ran = false; // false = cache hit, nothing executed this call + std::vector mismatches; // empty = the declaration held +}; + +// `compilerBin` + `argv` is the EXACT final compile configuration an +// ordinary target-side unit receives — the base host-compile tokens plus +// `Toolchain::cEnvTokens`/`cEnvBuiltinsTokens` (`mcpp.toolchain.cenv`). +// `expectWcharBits`/`expectLongBytes` 0 = not checked; the `expectDefined`/ +// `expectUndefined` lists name macros the probe's `-dM` dump must and must +// not contain. +// +// 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 +// DIFFERENT failure from the declaration disagreeing with what compiled: the +// caller reports it as a build error naming the command, not as a §3.2 +// verification mismatch. +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()); + +} // namespace mcpp::toolchain::cenv_probe + +namespace mcpp::toolchain::cenv_probe { + +namespace { + +// One line per predefined macro's NAME, ignoring its expansion — every fact +// this module checks is "is this name defined at all", which is what +// `_WIN32`/`__unix__`/`__CYGWIN__` answer and what `expectDefined`/ +// `expectUndefined` ask about. `__SIZEOF_LONG__`/`__SIZEOF_WCHAR_T__` are the +// two exceptions and are looked up with their value kept. +struct MacroDump { + std::set names; + std::map values; +}; + +MacroDump parse_dm(std::string_view out) { + MacroDump d; + std::size_t pos = 0; + while (pos < out.size()) { + auto nl = out.find('\n', pos); + std::string_view line = out.substr(pos, nl == std::string_view::npos + ? std::string_view::npos : nl - pos); + pos = (nl == std::string_view::npos) ? out.size() : nl + 1; + // `#define NAME value...` — clang's -dM format, one per predefined + // macro. `NAME` may itself take parameters (`#define FOO(x) ...`); + // this module only ever looks up object-like macro names, so the + // parenthesised form is simply never matched by `names.contains`. + if (!line.starts_with("#define ")) continue; + auto rest = line.substr(8); + auto sp = rest.find(' '); + auto name = std::string(sp == std::string_view::npos ? rest : rest.substr(0, sp)); + d.names.insert(name); + if (sp != std::string_view::npos) + d.values[name] = std::string(rest.substr(sp + 1)); + } + return d; +} + +} // namespace + +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) { + + // 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". + std::error_code ec; + auto mtime = std::filesystem::last_write_time(compilerBin, ec); + std::string keyInput = compilerBin.string(); + keyInput += '\x1f'; + keyInput += std::to_string( + static_cast(mtime.time_since_epoch().count())); + for (auto& a : argv) { keyInput += '\x1f'; keyInput += a; } + const std::string key = mcpp::toolchain::hash_string(keyInput); + + const auto cacheDir = cacheRoot / "cenv-probe"; + const auto cacheFile = cacheDir / (key + ".dm"); + + std::string dump; + bool ran = false; + if (std::ifstream cached(cacheFile, std::ios::binary); cached) { + std::ostringstream ss; ss << cached.rdbuf(); + dump = ss.str(); + } else { + // `-E -dM`: preprocess only, dump every predefined macro. No parse, + // no codegen — the cheapest command that still asks the real driver, + // with the real final flags, what it predefines. `-x c++`: every + // caller of this module probes a C++-capable configuration (the + // realised environment applies to C and C++ alike; C++ is the + // superset for the macros this checks). `-`: read the (empty) source + // from standard input — `capture_stdout` gives the child an empty + // one, argv-form, so no shell and no temp file are needed. + std::vector cmd{ compilerBin.string() }; + cmd.insert(cmd.end(), argv.begin(), argv.end()); + cmd.insert(cmd.end(), { "-x", "c++", "-E", "-dM", "-" }); + auto r = mcpp::platform::process::capture_stdout(cmd); + if (r.exit_code != 0) + return std::unexpected(std::format( + "the [c-abi] verification probe could not be compiled " + "(rc={}). command: {}", r.exit_code, + std::accumulate(cmd.begin(), cmd.end(), std::string(), + [](std::string a, std::string const& b) { + return a.empty() ? b : a + " " + b; + }))); + dump = r.output; + ran = true; + std::error_code mkec; + std::filesystem::create_directories(cacheDir, mkec); + std::ofstream out(cacheFile, std::ios::binary); + if (out) out << dump; + } + + auto macros = parse_dm(dump); + Result res; + res.ran = ran; + + auto sizeof_from = [&](std::string_view macro) -> std::optional { + auto it = macros.values.find(std::string(macro)); + if (it == macros.values.end()) return std::nullopt; + // The value is a decimal integer literal, occasionally with a + // trailing suffix (`4U`, on some targets) — read the leading digits + // only. + int v = 0; + auto s = it->second; + std::size_t i = 0; + while (i < s.size() && s[i] >= '0' && s[i] <= '9') { v = v * 10 + (s[i] - '0'); ++i; } + if (i == 0) return std::nullopt; + return v; + }; + + if (expectLongBytes != 0) { + if (auto got = sizeof_from("__SIZEOF_LONG__"); got && *got != expectLongBytes) + res.mismatches.push_back({"sizeof(long)", + std::to_string(expectLongBytes), std::to_string(*got)}); + } + if (expectWcharBits != 0) { + if (auto got = sizeof_from("__SIZEOF_WCHAR_T__"); got + && *got * 8 != expectWcharBits) + res.mismatches.push_back({"__SIZEOF_WCHAR_T__ (bits)", + std::to_string(expectWcharBits), std::to_string(*got * 8)}); + } + for (auto& name : expectDefined) + if (!macros.names.contains(name)) + res.mismatches.push_back({name, "defined", "undefined"}); + for (auto& name : expectUndefined) + if (macros.names.contains(name)) + res.mismatches.push_back({name, "undefined", "defined"}); + + return res; +} + +} // namespace mcpp::toolchain::cenv_probe diff --git a/tests/e2e/741_c_abi_environment_declared_by_the_c_library.sh b/tests/e2e/741_c_abi_environment_declared_by_the_c_library.sh new file mode 100755 index 00000000..1c85f7ca --- /dev/null +++ b/tests/e2e/741_c_abi_environment_declared_by_the_c_library.sh @@ -0,0 +1,274 @@ +#!/usr/bin/env bash +# requires: llvm unix-shell +# 741 -- the `[c-abi]` block a `mcpp:c-abi=` provider declares (design +# 2026-09-18, "C environment declared by the C library layer") reaches the +# target-side report, realises into the tokens `docs/22` documents, reaches +# `mcpp emit build-database`, is inferred off for a `mcpp:kernel-abi=` +# provider with no manifest change of its own, and its verification step +# refuses an unrealisable request rather than silently approximating it. +# +# WHAT THIS COVERS THAT THE UNIT TESTS CANNOT. `mcpp.toolchain.cenv::realise` +# is a pure function and its mapping table is asserted directly in +# tests/unit/test_cenv.cpp; `[c-abi]`/`c-environment` parsing, validation and +# the kernel-abi inference are asserted in tests/unit/test_manifest.cpp. What +# only a build can show is the WIRING: that a provider's block actually +# reaches the resolved target side, that the realised tokens reach the +# compile database of an ordinary package, that a kernel-abi provider is +# excluded from them WITHOUT declaring `c-environment` itself, +# that a GAS (.S) unit gets the SAME tokens a .c/.cpp unit in the same package +# does (a defect found by the openkal-musl spike after this test's first +# version: the substitution reached `f.cc`/`f.cxx` but not `f.as`, so a .c unit +# saw `_WIN32` undefined while a .S unit in the same package still saw it +# defined), and that a request this engine cannot realise is refused before +# anything compiles -- rather than compiled wrong and shipped. +# +# `x86_64-windows-gnu` is the flagship target (design's own motivating case), +# and this test compiles only -- it never links or runs the artifact, so it +# needs no mingw runtime and no Wine. `python3` reads compile_commands.json +# because grep across a JSON array of command strings is fragile the moment a +# path contains the token being searched for. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +PY=python3 +command -v "$PY" >/dev/null 2>&1 || PY=python + +mkdir -p fakemusl/src +cat > fakemusl/src/lib.c <<'EOF' +int fakemusl_marker(void) { return 0; } +EOF + +mkdir -p openkalwin/src +cat > openkalwin/src/shim.c <<'EOF' +int openkalwin_marker(void) { return 0; } +EOF + +mkdir -p src +cat > src/main.cpp <<'EOF' +int main() { return 0; } +EOF + +# A third-party-style assembly unit, mirroring the class of file the +# coordinator's report named (openkal-musl's `okm_setjmp.S`, upstream +# libunwind's `assembly.h`): real GAS source that is preprocessed and reads +# the SAME environment macros a `.c`/`.cpp` unit does. Compiled only (never +# linked/run), so it needs no runtime -- one label is enough to be valid GAS. +cat > src/probe.S <<'EOF' +.text +.globl cabi_probe_asm_marker +cabi_probe_asm_marker: + ret +EOF + +cat > mcpp.toml <<'EOF' +[package] +name = "cabi-probe" +version = "0.1.0" + +[dependencies] +fakemusl = { path = "fakemusl" } +openkalwin = { path = "openkalwin" } + +[build] +allow_host_libs = true +EOF + +# `fakemusl_declares ` rewrites the provider's manifest with the given +# `wchar` value so the "deliberately wrong" leg below can ask for a value this +# engine has no way to realise, without duplicating the whole manifest. +fakemusl_declares() { + cat > fakemusl/mcpp.toml <` +# provider is inferred into the platform boundary from `provides` alone, +# because it is BY DEFINITION the package that speaks the platform's own +# ABI and can never want the graph's presented [c-abi] environment. Before +# this revision this manifest carried an explicit `c-environment = +# "platform"` line; it is gone on purpose, to prove the DEFAULT, not the +# opt-out key (`tests/unit/test_manifest.cpp`'s +# `CEnvironmentAcceptsOnlyPlatform` already covers the explicit key). +cat > openkalwin/mcpp.toml <<'EOF' +[package] +name = "openkalwin" +version = "1.0.0" +provides = ["mcpp:kernel-abi=openkal"] + +[targets.openkalwin] +kind = "lib" +sources = ["src/*.c"] +EOF + +# ── A. the realisable request reaches the report and the compile database ── +fakemusl_declares 32 +out=$("$MCPP" build --target x86_64-windows-gnu --toolchain llvm@22.1.8 2>&1) || { + echo "FAIL: a realisable [c-abi] request must not fail the build" >&2 + echo "$out" >&2; exit 1 +} + +echo "$out" | grep -q 'c-abi *fakemusl' || { + echo "FAIL: the target-side report must name the c-abi provider" >&2 + echo "$out" >&2; exit 1 +} + +"$MCPP" emit build-database --target x86_64-windows-gnu --toolchain llvm@22.1.8 \ + --format json > db.json 2> db.err || { + echo "FAIL: emit build-database must succeed on the realised graph" >&2 + cat db.err >&2; exit 1 +} + +"$PY" - "$TMP/db.json" <<'PYEOF' +import json, sys +doc = json.load(open(sys.argv[1])) +sets = doc["data"]["database"]["sets"] + +def args_for(source_contains): + for s in sets: + for tu in s.get("translation-units", []): + if source_contains in tu.get("source", ""): + yield tu["arguments"] + +def joined(argv_iter): + return " ".join(" ".join(a) for a in argv_iter) + +# The ordinary consumer (main.cpp, package "cabi-probe") is target-side and +# does NOT declare c-environment: it must carry the Cygwin-flavoured tokens. +# +# `__CYGWIN__`/`__CYGWIN32__` are NOT undefined (design revision from the +# openkal-musl spike: third-party portable code that needs to know the +# object format -- not the C environment, not the platform API -- has no +# other name for "PE format, POSIX-presenting environment", and such code +# cannot be patched the way this ecosystem's own packages can; a trade-off +# for the 30-member measurement to settle, not a settled fact) -- so this +# asserts `-U__CYGWIN__` is ABSENT from the command line, the opposite of an +# earlier version of this test. +consumer = joined(args_for("main.cpp")) +missing = [tok for tok in ("--target=x86_64-pc-cygwin", "-fno-short-wchar") + if tok not in consumer] +if missing: + print(f"FAIL: ordinary package is missing realised tokens {missing}\n args: {consumer}") + sys.exit(1) +present = [tok for tok in ("-U__CYGWIN__", "-U__CYGWIN32__") if tok in consumer] +if present: + print(f"FAIL: __CYGWIN__/__CYGWIN32__ must stay defined, but found {present}\n args: {consumer}") + sys.exit(1) + +# fakemusl's OWN units get them too -- the environment applies to the C +# library itself, not only to its consumers. +libc = joined(args_for("fakemusl/src/lib.c")) +if "--target=x86_64-pc-cygwin" not in libc: + print(f"FAIL: the c-abi provider's own unit is missing the realised triple\n args: {libc}") + sys.exit(1) + +# The GAS (.S) unit must carry the SAME environment tokens as the C/C++ units +# of the SAME package -- the defect this leg pins (coordinator report, +# openkal-musl spike): the substitution used to reach C/C++ compiles only, so +# a `.c` unit in a package saw `_WIN32` undefined while a `.S` unit in the +# SAME package still saw it defined, because `--target=`/`-fno-short-wchar` +# never reached the assembler's command line at all. `--target=` is asserted +# by full-string match (assembly's flag string is independently assembled -- +# `mcpp.build.flags::f.as`, not `f.cc` -- so a match here proves the token +# actually reached that channel, not merely that it exists somewhere in the +# database). `-fno-short-wchar` is meaningless to GAS (no wchar_t in +# assembly) and is asserted too regardless, on the coordinator's own +# instruction: "whatever the C units get for the environment, the assembler +# units should get too, minus anything meaningless to the assembler" -- +# clang accepts the flag for `.S` input (measured, does not error), so +# nothing here justifies dropping it just because assembly has no use for it. +asm_args = list(args_for("probe.S")) +if not asm_args: + print("FAIL: could not find the .S unit's compile command at all") + sys.exit(1) +asm_joined = joined(iter(asm_args)) +missing = [tok for tok in ("--target=x86_64-pc-cygwin", "-fno-short-wchar") + if tok not in asm_joined] +if missing: + print(f"FAIL: the assembly unit is missing realised tokens {missing} " + f"-- the [c-abi] substitution must reach .S the same as .c/.cpp\n" + f" args: {asm_joined}") + sys.exit(1) +present = [tok for tok in ("-U__CYGWIN__", "-U__CYGWIN32__") if tok in asm_joined] +if present: + print(f"FAIL: __CYGWIN__/__CYGWIN32__ must stay defined on assembly too, " + f"but found {present}\n args: {asm_joined}") + sys.exit(1) + +# openkalwin provides `mcpp:kernel-abi=openkal` and declares NO +# `c-environment` of its own -- the platform boundary is INFERRED from that +# alone (coordinator revision), so it must NOT see the substituted triple: +# it needs the real Windows identity to include platform declarations, the +# same as if it had written `c-environment = "platform"` by hand. +shim = joined(args_for("shim.c")) +if not shim: + print("FAIL: could not find openkalwin's compiled unit at all") + sys.exit(1) +if "--target=x86_64-pc-cygwin" in shim: + print(f"FAIL: a kernel-abi provider must be INFERRED into the platform " + f"boundary and must not receive the realised triple, even though " + f"it declares no c-environment itself\n args: {shim}") + sys.exit(1) + +print("OK: A") +PYEOF + +# ── B. an unrealisable request is refused, naming target/request/missing ─── +# +# `data-model = "llp64"` together with `presents = "posix"` has no +# realisation this engine knows (docs/22's table has exactly one Windows row, +# and it delivers LP64): the "deliberately wrong declaration" the design +# calls for. `mcpp.toolchain.cenv::realise` refuses it before anything +# compiles -- this is the request-cannot-be-realised leg; the "declared vs +# measured" probe (§3.2) is exercised by every REALISABLE request above, +# which only builds because the probe found no mismatch. +cat > fakemusl/mcpp.toml <<'EOF' +[package] +name = "fakemusl" +version = "1.0.0" +provides = ["mcpp:c-abi=fakemusl"] + +[targets.fakemusl] +kind = "lib" +sources = ["src/*.c"] + +[c-abi] +presents = "posix" +data-model = "llp64" +wchar = 32 +EOF +rm -rf target + +out=$("$MCPP" build --target x86_64-windows-gnu --toolchain llvm@22.1.8 2>&1) && { + echo "FAIL: an unrealisable [c-abi] request must refuse the build" >&2 + echo "$out" >&2; exit 1 +} +echo "$out" | grep -q 'x86_64-windows-gnu\|windows' || { + echo "FAIL: the refusal must name the target" >&2 + echo "$out" >&2; exit 1 +} +echo "$out" | grep -qi 'llp64' || { + echo "FAIL: the refusal must name the request" >&2 + echo "$out" >&2; exit 1 +} +echo "OK: B" + +echo "OK" diff --git a/tests/unit/test_cache_key.cpp b/tests/unit/test_cache_key.cpp index 696d1352..d2740777 100644 --- a/tests/unit/test_cache_key.cpp +++ b/tests/unit/test_cache_key.cpp @@ -381,6 +381,123 @@ TEST(CacheKey, AHostedTargetHasNoTargetImpliedFlagsEitherWay) { EXPECT_EQ(a.targetImpliedFlags, b.targetImpliedFlags); } +// THE SAME CLASS OF DEFECT AS THE FREESTANDING ONE ABOVE, FOUND THE SAME WAY +// — A CONFIG BIT THAT REACHES THE COMPILE BUT NOT THE KEY. +// +// The realised [c-abi] environment (design 2026-09-18) does not reach a +// package's compile command line through its own declared +// `manifest.buildConfig.cflags`/`cxxflags` at all — it is an ENGINE +// BROADCAST, appended into `PackageRoot::privateBuild.cflags`/`cxxflags`/ +// `asmflags` by `prepare.cppm` once the target side resolves (the same +// channel `targetSideUsage` and `-D__openkal__` use). `fill_package_config` +// used to read only `manifest.buildConfig.cflags`/`cxxflags` — the +// package's OWN declaration — so two builds of the identical package, +// realising two DIFFERENT C environments (LP64 vs LLP64, say), produced the +// identical cache key. +// +// MEASURED, NOT HYPOTHETICAL (coordinator report): upgrading mcpp in place, +// with the global build cache (`~/.mcpp/build-cache/v1`) left in place, +// served an object compiled under the PREVIOUS realised environment into an +// image built under the new one — two C environments in one image, with +// nothing diagnosing it, which is the exact invariant this whole design +// exists to protect. `--cache=off`, or clearing the cache directory, +// produced the correct numbers reproducibly — because both routes bypass +// this key entirely, not because the key was telling the two apart. +TEST(CacheKey, TwoDifferentRealisedCEnvironmentsDoNotShareASlot) { + std::filesystem::path store = "/home/u/.mcpp/registry/data/xpkgs"; + auto build = [&](std::vector cEnvTokens) { + auto pkgRoot = rootAt(store / "musl-x-openkal-musl" / "1.0.0"); + pkgRoot.usageResolved = true; + // What `prepare.cppm`'s broadcast actually does: seed from the + // declared flags, then append the realised tokens — exactly the + // shape `fill_package_config`'s own comment documents. + pkgRoot.privateBuild.cflags = pkgRoot.manifest.buildConfig.cflags; + pkgRoot.privateBuild.cxxflags = pkgRoot.manifest.buildConfig.cxxflags; + pkgRoot.privateBuild.asmflags = cEnvTokens; + for (auto& t : cEnvTokens) { + pkgRoot.privateBuild.cflags.push_back(t); + pkgRoot.privateBuild.cxxflags.push_back(t); + } + ck::PackageAxes p; + p.indexName = "musl"; p.packageName = "openkal-musl"; p.version = "1.0.0"; + ck::fill_package_config(p, pkgRoot, store); + return ck::key_hex(axes(), p); + }; + auto lp64Key = build({"--target=x86_64-pc-cygwin", "-fno-short-wchar"}); + auto llp64Key = build({}); + EXPECT_NE(lp64Key, llp64Key); +} + +// A DURABLE GUARD FOR THE WHOLE CLASS THE PREVIOUS TEST FOUND ONE INSTANCE +// OF — this predates the c-abi wave (`targetSideUsage`'s own broadcast, and +// `-D__openkal__`, had the identical exposure before [c-abi] existed) and +// will recur: `PackageRoot::privateBuild` (`mcpp.modgraph.scanner +// ::UsageRequirements`) is the engine's OWN channel for "this reaches a +// package's compile command line even though the package wrote nothing" — +// today's members are `includeDirs`, `includeDirsAfter`, `cflags`, +// `cxxflags`, `asmflags`, `ldflags`, `modules` — and every one of them has +// to move `fill_package_config`'s output, because the cache key's only job +// is to describe what actually reaches the compiler. +// +// C++ has no reflection this side of the standard that could enumerate +// `UsageRequirements`'s members and fail this test automatically the day a +// new one is added without a matching line below — so this is the nearest +// substitute the language allows: one assertion per CURRENT member, so the +// list itself is the checklist. Adding a member to `UsageRequirements` +// without adding its case here and its read in `fill_package_config` is +// exactly the shape of the defect `TwoDifferentRealisedCEnvironmentsDoNotSh +// areASlot` above caught — if you are adding one, add it in both places in +// the SAME change. +// +// `ldflags` and `modules` are asserted UNCOVERED, on purpose: nothing +// broadcasts into `privateBuild.ldflags` or `.modules` today (checked by +// grep across `src/` and `modules/` when this test was written), so there +// is nothing yet for `fill_package_config` to be missing on those two — the +// day something does broadcast into either, this pair of assertions must +// flip (start reading it) at the same time the broadcast is added, not +// after. +TEST(CacheKey, EveryPrivateBuildBroadcastFieldReachesTheKey) { + std::filesystem::path store = "/home/u/.mcpp/registry/data/xpkgs"; + auto keyFor = [&](auto mutate) { + auto pkgRoot = rootAt(store / "p" / "1"); + pkgRoot.usageResolved = true; + mutate(pkgRoot); + ck::PackageAxes p; + p.indexName = "p"; p.packageName = "p"; p.version = "1"; + ck::fill_package_config(p, pkgRoot, store); + return ck::key_hex(axes(), p); + }; + const auto baseline = keyFor([](auto&) {}); + + EXPECT_NE(baseline, keyFor([](auto& r) { + r.privateBuild.includeDirs = {"/somewhere/broadcast-only-include"}; + })) << "privateBuild.includeDirs"; + EXPECT_NE(baseline, keyFor([](auto& r) { + r.privateBuild.includeDirsAfter = {"/somewhere/broadcast-only-after"}; + })) << "privateBuild.includeDirsAfter"; + EXPECT_NE(baseline, keyFor([](auto& r) { + r.privateBuild.cflags = {"-Dbroadcast_only_c"}; + })) << "privateBuild.cflags"; + EXPECT_NE(baseline, keyFor([](auto& r) { + r.privateBuild.cxxflags = {"-Dbroadcast_only_cxx"}; + })) << "privateBuild.cxxflags"; + EXPECT_NE(baseline, keyFor([](auto& r) { + r.privateBuild.asmflags = {"-Dbroadcast_only_asm"}; + })) << "privateBuild.asmflags"; + + // Documented gap, not an oversight — see the comment above. + EXPECT_EQ(baseline, keyFor([](auto& r) { + r.privateBuild.ldflags = {"-Wl,--broadcast-only"}; + })) << "privateBuild.ldflags is not yet a live broadcast channel; if " + "this starts failing, something now writes it, and " + "fill_package_config must be taught to read it in the SAME change " + "that flips this expectation"; + EXPECT_EQ(baseline, keyFor([](auto& r) { + r.privateBuild.modules = {"broadcast.only.module"}; + })) << "privateBuild.modules is not yet a live broadcast channel; see " + "the ldflags case above for what to do when it becomes one"; +} + // THE HEADER SET THE DRIVER IS POINTED AT IS PART OF THE IDENTITY. // // Everything else on axis A describes the COMPILER. Nothing described the diff --git a/tests/unit/test_cenv.cpp b/tests/unit/test_cenv.cpp new file mode 100644 index 00000000..d841c491 --- /dev/null +++ b/tests/unit/test_cenv.cpp @@ -0,0 +1,173 @@ +// [c-abi] realisation: a request → compiler-configuration mapping. +// +// design 2026-09-18 (openkal, "C environment declared by the C library +// layer"), §3.2-§3.3. `mcpp.toolchain.cenv::realise` is a pure function of +// plain data — the same reason `test_targetside.cpp` exists for +// `mcpp.targetside::resolve` — so the whole mapping table §3.3 publishes can +// be asserted here without compiling anything. + +#include + +import std; +import mcpp.targetside; +import mcpp.toolchain.cenv; + +namespace ts = mcpp::targetside; +namespace cenv = mcpp::toolchain::cenv; + +namespace { + +ts::CAbiDecl decl(ts::CAbiPresents p, ts::CAbiDataModel dm, int wchar, + ts::CAbiBuiltins b = ts::CAbiBuiltins::Platform) { + ts::CAbiDecl d; + d.declared = true; + d.presents = p; d.hasPresents = true; + d.dataModel = dm; d.hasDataModel = true; + d.wcharBits = wchar; d.hasWchar = true; + d.builtins = b; + return d; +} + +bool has(const std::vector& v, std::string_view tok) { + return std::find(v.begin(), v.end(), tok) != v.end(); +} + +} // namespace + +// ── §3.3's own table: the three realisable rows ────────────────────────────── + +TEST(CEnv, LinuxPosixArchDefaultIsANoOp) { + auto d = decl(ts::CAbiPresents::Posix, ts::CAbiDataModel::ArchDefault, 32); + auto r = cenv::realise(d, "linux", "x86_64", false); + ASSERT_TRUE(r.has_value()) << r.error(); + EXPECT_TRUE(r->tokens.empty()); + EXPECT_EQ(r->expectLongBytes, 8); + EXPECT_EQ(r->expectWcharBits, 32); +} + +TEST(CEnv, MacosPosixArchDefaultIsANoOp) { + auto d = decl(ts::CAbiPresents::Posix, ts::CAbiDataModel::ArchDefault, 32); + auto r = cenv::realise(d, "macos", "aarch64", false); + ASSERT_TRUE(r.has_value()) << r.error(); + EXPECT_TRUE(r->tokens.empty()); +} + +// The flagship case: Cygwin-flavoured Windows. +// +// `__CYGWIN__`/`__CYGWIN32__` STAY DEFINED — a design revision from the +// openkal-musl spike, not the original §3.3 text. Third-party portable code +// that needs to know the OBJECT FORMAT (as opposed to the C environment or +// the platform API) has no name for "PE format, POSIX-presenting +// environment" other than `__CYGWIN__`, and such code cannot be patched the +// way this ecosystem's own packages can. This is a trade-off for the +// 30-member measurement to settle, not a settled fact: the cost is that a +// library reaching for `__CYGWIN__` may also reach for a real Cygwin +// interface that does not exist here. +TEST(CEnv, WindowsPosixArchDefaultSubstitutesTheCygwinTriple) { + auto d = decl(ts::CAbiPresents::Posix, ts::CAbiDataModel::ArchDefault, 32); + auto r = cenv::realise(d, "windows", "x86_64", false); + ASSERT_TRUE(r.has_value()) << r.error(); + EXPECT_TRUE(has(r->tokens, "--target=x86_64-pc-cygwin")); + EXPECT_FALSE(has(r->tokens, "-U__CYGWIN__")); + EXPECT_FALSE(has(r->tokens, "-U__CYGWIN32__")); + // wchar 32 differs from Cygwin's own default (16) — the flag is added. + EXPECT_TRUE(has(r->tokens, "-fno-short-wchar")); + EXPECT_EQ(r->expectLongBytes, 8); + EXPECT_EQ(r->expectWcharBits, 32); + ASSERT_TRUE(has(r->expectDefined, "__unix__")); + ASSERT_TRUE(has(r->expectDefined, "__CYGWIN__")); + ASSERT_TRUE(has(r->expectUndefined, "_WIN32")); +} + +// wchar = 16 on the Cygwin substitution matches Cygwin's own default, so no +// `-f[no-]short-wchar` token is needed. +TEST(CEnv, WindowsPosixWchar16NeedsNoWcharFlag) { + auto d = decl(ts::CAbiPresents::Posix, ts::CAbiDataModel::ArchDefault, 16); + auto r = cenv::realise(d, "windows", "x86_64", false); + ASSERT_TRUE(r.has_value()) << r.error(); + EXPECT_FALSE(has(r->tokens, "-fno-short-wchar")); + EXPECT_FALSE(has(r->tokens, "-fshort-wchar")); +} + +// `presents = "windows"` on Windows is the base triple's own identity. +TEST(CEnv, WindowsWindowsArchDefaultIsANoOp) { + auto d = decl(ts::CAbiPresents::Windows, ts::CAbiDataModel::ArchDefault, 16); + auto r = cenv::realise(d, "windows", "x86_64", false); + ASSERT_TRUE(r.has_value()) << r.error(); + EXPECT_TRUE(r->tokens.empty()); +} + +// ── `builtins = "iso"` — the Apple case is the only one this survey found ──── + +TEST(CEnv, BuiltinsIsoOnMacosDisablesMemsetPattern16) { + auto d = decl(ts::CAbiPresents::Posix, ts::CAbiDataModel::ArchDefault, 32, + ts::CAbiBuiltins::Iso); + auto r = cenv::realise(d, "macos", "x86_64", false); + ASSERT_TRUE(r.has_value()) << r.error(); + EXPECT_TRUE(has(r->builtinsTokens, "-fno-builtin-memset_pattern16")); +} + +TEST(CEnv, BuiltinsIsoOnLinuxAddsNothingMeasurable) { + auto d = decl(ts::CAbiPresents::Posix, ts::CAbiDataModel::ArchDefault, 32, + ts::CAbiBuiltins::Iso); + auto r = cenv::realise(d, "linux", "x86_64", false); + ASSERT_TRUE(r.has_value()) << r.error(); + EXPECT_TRUE(r->builtinsTokens.empty()); +} + +TEST(CEnv, BuiltinsPlatformAddsNothingOnAnyTarget) { + auto d = decl(ts::CAbiPresents::Posix, ts::CAbiDataModel::ArchDefault, 32, + ts::CAbiBuiltins::Platform); + auto r = cenv::realise(d, "macos", "x86_64", false); + ASSERT_TRUE(r.has_value()) << r.error(); + EXPECT_TRUE(r->builtinsTokens.empty()); +} + +// ── refusals: everything outside the table §3.3 publishes ─────────────────── + +TEST(CEnv, WindowsPosixOnANonX86_64ArchIsRefused) { + auto d = decl(ts::CAbiPresents::Posix, ts::CAbiDataModel::ArchDefault, 32); + auto r = cenv::realise(d, "windows", "aarch64", false); + ASSERT_FALSE(r.has_value()); + EXPECT_NE(r.error().find("x86_64"), std::string::npos) << r.error(); +} + +TEST(CEnv, LinuxWindowsPresentsIsRefused) { + auto d = decl(ts::CAbiPresents::Windows, ts::CAbiDataModel::ArchDefault, 32); + auto r = cenv::realise(d, "linux", "x86_64", false); + ASSERT_FALSE(r.has_value()); + EXPECT_NE(r.error().find("no known way"), std::string::npos) << r.error(); +} + +TEST(CEnv, WindowsPresentsNoneIsRefused) { + auto d = decl(ts::CAbiPresents::None, ts::CAbiDataModel::ArchDefault, 32); + auto r = cenv::realise(d, "windows", "x86_64", false); + ASSERT_FALSE(r.has_value()); +} + +TEST(CEnv, LlP64RequestedOnLinuxIsRefused) { + auto d = decl(ts::CAbiPresents::Posix, ts::CAbiDataModel::Llp64, 32); + auto r = cenv::realise(d, "linux", "x86_64", false); + ASSERT_FALSE(r.has_value()); + EXPECT_NE(r.error().find("data-model"), std::string::npos) << r.error(); +} + +TEST(CEnv, FreestandingAcceptsOnlyNone) { + auto ok = decl(ts::CAbiPresents::None, ts::CAbiDataModel::ArchDefault, 32); + auto r1 = cenv::realise(ok, "none", "riscv64", true); + EXPECT_TRUE(r1.has_value()) << (r1 ? "" : r1.error()); + + auto bad = decl(ts::CAbiPresents::Posix, ts::CAbiDataModel::ArchDefault, 32); + auto r2 = cenv::realise(bad, "none", "riscv64", true); + ASSERT_FALSE(r2.has_value()); +} + +// A 32-bit architecture's own native data model is ILP32 — the Cygwin +// mechanism is x86_64-only, so `arch-default` there needs no substitution and +// stays a no-op regardless. +TEST(CEnv, Ilp32ArchDefaultOnA32BitArchIsANoOp) { + auto d = decl(ts::CAbiPresents::Posix, ts::CAbiDataModel::ArchDefault, 32); + auto r = cenv::realise(d, "linux", "arm", false); + ASSERT_TRUE(r.has_value()) << r.error(); + EXPECT_EQ(r->expectLongBytes, 4); +} diff --git a/tests/unit/test_cenv_probe.cpp b/tests/unit/test_cenv_probe.cpp new file mode 100644 index 00000000..46ea68f2 --- /dev/null +++ b/tests/unit/test_cenv_probe.cpp @@ -0,0 +1,187 @@ +// mcpp.toolchain.cenv_probe — "a declaration is checked, never trusted" +// (design 2026-09-18 §3.2), exercised against a REAL compiler. +// +// `tests/unit/test_cenv.cpp` covers `mcpp.toolchain.cenv::realise` as a pure +// function; `tests/e2e/741_...sh`'s leg A only proves the probe does not +// reject a MATCHING declaration (the build succeeds), and leg B's refusal +// comes from `cenv::realise` itself refusing an unrealisable REQUEST before +// the probe is ever reached — neither exercises what happens when the probe +// actually MEASURES a disagreement. This file is that missing piece: it +// calls `cenv_probe::verify` directly, bypassing `cenv::realise` entirely, so +// the "declared" side can be deliberately wrong regardless of whether any +// (request, target) pair in `cenv::realise`'s own mapping table could +// produce it — the module under test does not know or care where its +// expectations came from, and neither does this test. +// +// NO `--target=` HERE ON PURPOSE. `cenv_probe::verify` itself is +// compiler-family-agnostic (it just runs `-E -dM`); only `cenv::realise`'s +// OWN Cygwin-substitution tokens are Clang-specific, and prepare.cppm gates +// those behind `is_clang` separately. Probing a plain host compile with no +// extra tokens keeps this test honest against whatever C++ compiler the +// test-running machine has (gcc or clang), rather than requiring the +// project's own resolved llvm payload to exist at a guessable path. +// +// EVERY TEST GETS ITS OWN CACHE DIRECTORY. The dump the probe caches is +// keyed on (compiler, argv) alone — not on what expectations it is compared +// against, since the expectations are applied in memory to whatever dump +// comes back. Two tests that probe the identical (compiler, empty argv) +// against the SAME default cache root would have the second one silently +// read the first one's cached dump (`ran = false`) rather than genuinely +// probe — correct behaviour for the module, but it makes the test suite's +// outcome depend on run order and on what a previous run left on disk. +// Passing a fresh temp directory per test is what `verify`'s own `cacheRoot` +// parameter exists for; using it here is not a workaround, it is the +// intended way to keep two callers' cached dumps apart. + +#include + +import std; +import mcpp.toolchain.cenv_probe; + +namespace cp = mcpp::toolchain::cenv_probe; + +namespace { + +// The narrowest possible "does a working C++ compiler exist on this +// machine" check. `cenv_probe::verify` takes a real filesystem path (it +// calls `last_write_time` on it for the cache key), not a bare command name +// relying on PATH resolution inside the child process — so this resolves +// one, rather than handing the module something it was never asked to +// resolve itself. +std::filesystem::path find_a_cxx_compiler() { + static constexpr std::string_view kCandidates[] = { + "/usr/bin/c++", "/usr/bin/g++", "/usr/bin/clang++", + }; + for (auto c : kCandidates) { + std::error_code ec; + if (std::filesystem::exists(c, ec)) return c; + } + return {}; +} + +std::filesystem::path cxx() { + static const std::filesystem::path p = find_a_cxx_compiler(); + 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 +// plausibly ever be written, not a magic constant. +constexpr int kHostLongBytes = sizeof(long); +constexpr int kHostWcharBits = sizeof(wchar_t) * 8; + +// A fresh, per-test cache directory — see the file header. Removed on +// destruction so a failed run does not leave temp directories behind. +struct TmpCache { + std::filesystem::path dir = std::filesystem::temp_directory_path() + / std::format("mcpp-cenv-probe-test-{}", std::random_device{}()); + ~TmpCache() { std::error_code ec; std::filesystem::remove_all(dir, ec); } +}; + +} // namespace + +TEST(CenvProbe, AMatchingDeclarationProducesNoMismatches) { + if (cxx().empty()) GTEST_SKIP() << "no C++ compiler found to probe"; + TmpCache cache; + auto r = cp::verify(cxx(), {}, kHostWcharBits, kHostLongBytes, {}, {}, + cache.dir); + ASSERT_TRUE(r.has_value()) << r.error(); + EXPECT_TRUE(r->ran) << "a fresh cache directory must actually probe"; + EXPECT_TRUE(r->mismatches.empty()); +} + +// THE CASE THE COORDINATOR ASKED TO SEE: a declared word width the compiler +// disagrees with. This is exactly what a C library's [c-abi] block getting +// `data-model` wrong would produce once it reached the probe, independent +// of whether `cenv::realise`'s own mapping table would ever have accepted +// that request in the first place. +TEST(CenvProbe, ADeclaredLongWidthTheCompilerDisagreesWithIsAMismatch) { + if (cxx().empty()) GTEST_SKIP() << "no C++ compiler found to probe"; + TmpCache cache; + const int wrongLongBytes = kHostLongBytes == 8 ? 4 : 8; + auto r = cp::verify(cxx(), {}, 0, wrongLongBytes, {}, {}, cache.dir); + ASSERT_TRUE(r.has_value()) << r.error(); + EXPECT_TRUE(r->ran); + ASSERT_EQ(r->mismatches.size(), 1u); + EXPECT_EQ(r->mismatches[0].fact, "sizeof(long)"); + EXPECT_EQ(r->mismatches[0].declared, std::to_string(wrongLongBytes)); + EXPECT_EQ(r->mismatches[0].measured, std::to_string(kHostLongBytes)); +} + +TEST(CenvProbe, ADeclaredWcharWidthTheCompilerDisagreesWithIsAMismatch) { + if (cxx().empty()) GTEST_SKIP() << "no C++ compiler found to probe"; + TmpCache cache; + const int wrongWcharBits = kHostWcharBits == 32 ? 16 : 32; + auto r = cp::verify(cxx(), {}, wrongWcharBits, 0, {}, {}, cache.dir); + ASSERT_TRUE(r.has_value()) << r.error(); + ASSERT_EQ(r->mismatches.size(), 1u); + EXPECT_EQ(r->mismatches[0].fact, "__SIZEOF_WCHAR_T__ (bits)"); + EXPECT_EQ(r->mismatches[0].declared, std::to_string(wrongWcharBits)); + EXPECT_EQ(r->mismatches[0].measured, std::to_string(kHostWcharBits)); +} + +// `expectDefined` names a macro the declaration says must be there and +// isn't — the shape a `presents = "posix"` declaration takes when the +// compiler did not actually end up defining `__unix__`. +TEST(CenvProbe, AMacroDeclaredDefinedButAbsentIsAMismatch) { + if (cxx().empty()) GTEST_SKIP() << "no C++ compiler found to probe"; + TmpCache cache; + auto r = cp::verify(cxx(), {}, 0, 0, + {"__MCPP_TEST_MACRO_THAT_DOES_NOT_EXIST__"}, {}, + cache.dir); + ASSERT_TRUE(r.has_value()) << r.error(); + ASSERT_EQ(r->mismatches.size(), 1u); + EXPECT_EQ(r->mismatches[0].fact, + "__MCPP_TEST_MACRO_THAT_DOES_NOT_EXIST__"); + EXPECT_EQ(r->mismatches[0].declared, "defined"); + EXPECT_EQ(r->mismatches[0].measured, "undefined"); +} + +// The reverse: a macro the declaration says must be ABSENT but the compiler +// defines anyway — the shape `presents = "posix"` on a target whose +// compiler still predefines `_WIN32` would take. `__cplusplus` is always +// defined by a working C++ compiler, so this needs no special setup. +TEST(CenvProbe, AMacroDeclaredUndefinedButPresentIsAMismatch) { + if (cxx().empty()) GTEST_SKIP() << "no C++ compiler found to probe"; + TmpCache cache; + auto r = cp::verify(cxx(), {}, 0, 0, {}, {"__cplusplus"}, cache.dir); + ASSERT_TRUE(r.has_value()) << r.error(); + ASSERT_EQ(r->mismatches.size(), 1u); + EXPECT_EQ(r->mismatches[0].fact, "__cplusplus"); + EXPECT_EQ(r->mismatches[0].declared, "undefined"); + EXPECT_EQ(r->mismatches[0].measured, "defined"); +} + +// CACHED PER CONFIGURATION (module header) — a second call with the +// identical compiler + argv must not recompile. Distinguished from the +// first call via `ran`, exactly as `Result::ran`'s own doc comment says: a +// cache hit still answers the mismatch question, it just does no work to. +TEST(CenvProbe, TheSecondCallForTheIdenticalConfigurationIsACacheHit) { + if (cxx().empty()) GTEST_SKIP() << "no C++ compiler found to probe"; + TmpCache cache; + auto first = cp::verify(cxx(), {}, kHostWcharBits, kHostLongBytes, {}, {}, + cache.dir); + ASSERT_TRUE(first.has_value()) << first.error(); + EXPECT_TRUE(first->ran); + + auto second = cp::verify(cxx(), {}, kHostWcharBits, kHostLongBytes, {}, {}, + cache.dir); + ASSERT_TRUE(second.has_value()) << second.error(); + EXPECT_FALSE(second->ran) << "identical compiler + argv must hit the cache"; + EXPECT_TRUE(second->mismatches.empty()); +} + +// TWO different argv strings must NOT share a cache slot — the whole point +// of hashing argv into the key. A wrong hit here would mean this build's +// probe silently answered a DIFFERENT configuration's question. +TEST(CenvProbe, DifferentArgvDoesNotShareACacheSlot) { + if (cxx().empty()) GTEST_SKIP() << "no C++ compiler found to probe"; + TmpCache cache; + auto a = cp::verify(cxx(), {"-DMCPP_PROBE_TEST_A=1"}, 0, 0, {}, {}, cache.dir); + auto b = cp::verify(cxx(), {"-DMCPP_PROBE_TEST_B=1"}, 0, 0, {}, {}, cache.dir); + ASSERT_TRUE(a.has_value()) << a.error(); + ASSERT_TRUE(b.has_value()) << b.error(); + EXPECT_TRUE(a->ran); + EXPECT_TRUE(b->ran) << "a different argv must not read A's cache entry"; +} diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index 86ace869..21bec43e 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -2,6 +2,7 @@ import std; import mcpp.manifest; +import mcpp.targetside; import mcpp.libs.toml; import mcpp.pm.dep_spec; import mcpp.platform.axis; @@ -5783,9 +5784,281 @@ exclusive = ["cap"] std-module = "m" std-compat-module = "c" std-module-flags = ["-x"] +c-environment = "platform" )"; auto m = mcpp::manifest::parse_string(src); ASSERT_TRUE(m.has_value()) << m.error().format(); for (auto const& w : m->schemaWarnings) EXPECT_EQ(w.find("[package] has unsupported key"), std::string::npos) << w; + EXPECT_EQ(m->cEnvironment, "platform"); +} + +// ── `[c-abi]` — the C environment a `mcpp:c-abi=` provider declares +// (design 2026-09-18 §3.2) ────────────────────────────────────────────────── + +TEST(Manifest, CAbiBlockParsesOnAProviderOfTheLayer) { + constexpr auto src = R"( +[package] +name = "openkal-musl" +version = "0.15.0" +provides = ["mcpp:c-abi=musl"] + +[c-abi] +presents = "posix" +data-model = "arch-default" +wchar = 32 +builtins = "iso" +)"; + 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->declared); + EXPECT_EQ(m->cAbiDecl->presents, mcpp::targetside::CAbiPresents::Posix); + EXPECT_EQ(m->cAbiDecl->dataModel, mcpp::targetside::CAbiDataModel::ArchDefault); + EXPECT_EQ(m->cAbiDecl->wcharBits, 32); + EXPECT_EQ(m->cAbiDecl->builtins, mcpp::targetside::CAbiBuiltins::Iso); +} + +// `builtins` alone defaults — the other three carry no default (§3.2). +TEST(Manifest, CAbiBuiltinsDefaultsToPlatform) { + constexpr auto src = R"( +[package] +name = "openkal-musl" +version = "0.15.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_EQ(m->cAbiDecl->builtins, mcpp::targetside::CAbiBuiltins::Platform); +} + +// A package that does not provide the layer has no standing to state what +// environment it presents — an internal contradiction, refused rather than +// silently ignored (§3.2, gap #3 of the design's own self-review). +TEST(Manifest, CAbiBlockWithoutProvidingTheLayerIsRefused) { + constexpr auto src = R"( +[package] +name = "not-a-c-library" +version = "0.1.0" + +[c-abi] +presents = "posix" +data-model = "arch-default" +wchar = 32 +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_FALSE(m.has_value()); + EXPECT_NE(m.error().message.find( + "[package] provides does not list `mcpp:c-abi=`"), + std::string::npos) << m.error().message; +} + +TEST(Manifest, CAbiRequiresAllThreeKeysWithNoDefault) { + const std::pair cases[] = { + {R"([package] +name = "a" +version = "0.1.0" +provides = ["mcpp:c-abi=x"] +[c-abi] +data-model = "arch-default" +wchar = 32 +)", "[c-abi] is missing `presents`"}, + {R"([package] +name = "a" +version = "0.1.0" +provides = ["mcpp:c-abi=x"] +[c-abi] +presents = "posix" +wchar = 32 +)", "[c-abi] is missing `data-model`"}, + {R"([package] +name = "a" +version = "0.1.0" +provides = ["mcpp:c-abi=x"] +[c-abi] +presents = "posix" +data-model = "arch-default" +)", "[c-abi] is missing `wchar`"}, + }; + for (auto [src, expected] : cases) { + auto m = mcpp::manifest::parse_string(src); + ASSERT_FALSE(m.has_value()) << src; + EXPECT_NE(m.error().message.find(expected), std::string::npos) + << src << " -> " << m.error().message; + } +} + +TEST(Manifest, CAbiClosedValueSetsRejectAMisspelling) { + const std::pair cases[] = { + {R"([package] +name = "a" +version = "0.1.0" +provides = ["mcpp:c-abi=x"] +[c-abi] +presents = "posixx" +data-model = "arch-default" +wchar = 32 +)", "presents = \"posixx\" names no known environment identity"}, + {R"([package] +name = "a" +version = "0.1.0" +provides = ["mcpp:c-abi=x"] +[c-abi] +presents = "posix" +data-model = "lp65" +wchar = 32 +)", "data-model = \"lp65\" names no known data model"}, + {R"([package] +name = "a" +version = "0.1.0" +provides = ["mcpp:c-abi=x"] +[c-abi] +presents = "posix" +data-model = "arch-default" +wchar = 17 +)", "[c-abi].wchar must be the integer 16 or 32"}, + {R"([package] +name = "a" +version = "0.1.0" +provides = ["mcpp:c-abi=x"] +[c-abi] +presents = "posix" +data-model = "arch-default" +wchar = 32 +builtins = "gnuish" +)", "builtins = \"gnuish\" names no known policy"}, + {R"([package] +name = "a" +version = "0.1.0" +provides = ["mcpp:c-abi=x"] +[c-abi] +presents = "posix" +data-model = "arch-default" +wchar = 32 +extra = 1 +)", "[c-abi] has no member 'extra'"}, + }; + for (auto [src, expected] : cases) { + auto m = mcpp::manifest::parse_string(src); + ASSERT_FALSE(m.has_value()) << src; + EXPECT_NE(m.error().message.find(expected), std::string::npos) + << src << " -> " << m.error().message; + } +} + +// A package with no [c-abi] block resolves `cAbiDecl` to nullopt — the +// absent-block case §3.2 requires to be byte-identical to today. +TEST(Manifest, NoCAbiBlockLeavesCAbiDeclEmpty) { + constexpr auto src = R"( +[package] +name = "openkal-musl" +version = "0.15.0" +provides = ["mcpp:c-abi=musl"] +)"; + auto m = mcpp::manifest::parse_string(src); + ASSERT_TRUE(m.has_value()) << m.error().format(); + EXPECT_FALSE(m->cAbiDecl.has_value()); +} + +// `[package] c-environment` — design §3.4. The only value is "platform". +TEST(Manifest, CEnvironmentAcceptsOnlyPlatform) { + constexpr auto ok = R"( +[package] +name = "openkal-windows" +version = "0.1.0" +provides = ["mcpp:kernel-abi=openkal"] +c-environment = "platform" +)"; + auto m = mcpp::manifest::parse_string(ok); + ASSERT_TRUE(m.has_value()) << m.error().format(); + EXPECT_EQ(m->cEnvironment, "platform"); + + constexpr auto bad = R"( +[package] +name = "x" +version = "0.1.0" +c-environment = "native" +)"; + auto m2 = mcpp::manifest::parse_string(bad); + ASSERT_FALSE(m2.has_value()); + EXPECT_NE(m2.error().message.find( + "c-environment = \"native\" names no known override"), + std::string::npos) << m2.error().message; +} + +// `c-environment = "platform"` is INFERRED for a `mcpp:kernel-abi=` +// provider (coordinator revision, openkal-musl spike: openkal-windows called +// Win32 with a POSIX-substituted `wchar_t` width and misread its own UTF-16 +// results). A kernel-abi provider IS the platform boundary by definition, so +// it must never receive the presented [c-abi] environment even when its own +// manifest says nothing about it — this pins the default, not merely the +// 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. +TEST(Manifest, CEnvironmentIsInferredForAKernelAbiProvider) { + constexpr auto kernelAbiPkg = R"( +[package] +name = "openkal-windows" +version = "0.8.0" +provides = ["mcpp:kernel-abi=openkal"] +)"; + auto m = mcpp::manifest::parse_string(kernelAbiPkg); + ASSERT_TRUE(m.has_value()) << m.error().format(); + EXPECT_EQ(m->cEnvironment, "platform") + << "a kernel-abi provider must default to the platform boundary " + "without having to declare c-environment itself"; + + constexpr auto ordinaryPkg = R"( +[package] +name = "some-library" +version = "1.0.0" +)"; + auto m2 = mcpp::manifest::parse_string(ordinaryPkg); + ASSERT_TRUE(m2.has_value()) << m2.error().format(); + EXPECT_TRUE(m2->cEnvironment.empty()) + << "an ordinary package must NOT be inferred into the platform " + "boundary -- only a kernel-abi provider is"; + + // A package that provides c-abi (not kernel-abi) is not the boundary + // either, and must not be inferred. + constexpr auto cAbiPkg = R"( +[package] +name = "openkal-musl" +version = "0.15.0" +provides = ["mcpp:c-abi=musl"] +)"; + auto m3 = mcpp::manifest::parse_string(cAbiPkg); + ASSERT_TRUE(m3.has_value()) << m3.error().format(); + EXPECT_TRUE(m3->cEnvironment.empty()); +} + +// The same inference, through the OTHER manifest parser: an xpkg descriptor +// (index packages -- exactly how a released kernel-abi implementation like +// openkal-windows is actually consumed) has no explicit `c-environment` key +// at all (docs/22's own documented gap), so the inference here is +// unconditional rather than "if empty" -- and this is the test that pins +// that it still runs. +TEST(SynthesizeFromXpkgLua, CEnvironmentIsInferredForAKernelAbiProvider) { + constexpr auto lua = R"( +package = { + spec = "1", + name = "openkal-windows", + xpm = { windows = { ["0.8.0"] = { url = "u", sha256 = "h" } } }, + mcpp = { + sources = { "*/anchor.c" }, + provides = { "mcpp:kernel-abi=openkal" }, + targets = { ["openkal-windows"] = { kind = "lib" } }, + }, +} +)"; + auto m = mcpp::manifest::synthesize_from_xpkg_lua( + lua, "openkal-windows", "0.8.0", mcpp::platform::HostPlatform::current()); + ASSERT_TRUE(m.has_value()) << m.error().format(); + EXPECT_EQ(m->cEnvironment, "platform"); } diff --git a/tests/unit/test_modgraph.cpp b/tests/unit/test_modgraph.cpp index de930fad..293bb568 100644 --- a/tests/unit/test_modgraph.cpp +++ b/tests/unit/test_modgraph.cpp @@ -541,6 +541,58 @@ TEST(Scanner, UsesResolvedPackagePrivateBuildIncludeDirs) { std::filesystem::remove_all(dir); } +// The realised [c-abi] environment (design 2026-09-18) broadcasts into +// `privateBuild.asmflags`, and this proves the wiring on the ONE point that +// went wrong first (coordinator report, openkal-musl spike): the broadcast +// must reach a GAS (.S) unit's `packageAsmflags`, exactly as it reaches a +// `.c`/`.cpp` unit's cflags/cxxflags -- and it must NOT reach a NASM (.asm) +// unit's, because NASM has no `--target=` concept and does not recognize a +// single clang-family flag. A `--target=x86_64-pc-cygwin` handed to `nasm` +// is not a degraded build, it is a build that does not assemble at all, so +// this is a refusal-shaped guarantee even though nothing here calls +// `refusal::record`: the wrong list for the wrong assembler. +TEST(Scanner, ResolvedCEnvAsmflagsReachGasUnitsOnlyNotNasm) { + auto dir = make_tempdir("mcpp-scanner-cenv-asm"); + write(dir / "src" / "gas.S", + ".text\n.globl gas_marker\ngas_marker:\n ret\n"); + write(dir / "src" / "nasm.asm", + "section .text\nglobal nasm_marker\nnasm_marker:\n ret\n"); + + mcpp::manifest::Manifest m; + m.package.name = "pkg"; + m.modules.sources = {"src/*.S", "src/*.asm"}; + + PackageRoot p{dir, m}; + p.usageResolved = true; + p.privateBuild.asmflags = {"--target=x86_64-pc-cygwin", "-fno-short-wchar"}; + + auto r = scan_packages({p}); + ASSERT_TRUE(r.errors.empty()); + ASSERT_EQ(r.graph.units.size(), 2u); + + bool sawGas = false, sawNasm = false; + for (auto const& u : r.graph.units) { + if (u.kind == mcpp::SourceKind::GasAsm) { + sawGas = true; + EXPECT_NE(std::find(u.packageAsmflags.begin(), u.packageAsmflags.end(), + "--target=x86_64-pc-cygwin"), + u.packageAsmflags.end()) + << "the realised environment must reach a GAS unit"; + } else if (u.kind == mcpp::SourceKind::NasmAsm) { + sawNasm = true; + EXPECT_EQ(std::find(u.packageAsmflags.begin(), u.packageAsmflags.end(), + "--target=x86_64-pc-cygwin"), + u.packageAsmflags.end()) + << "NASM does not understand a clang --target= flag and must " + "never receive it"; + } + } + EXPECT_TRUE(sawGas); + EXPECT_TRUE(sawNasm); + + std::filesystem::remove_all(dir); +} + TEST(Scanner, PartitionImportFromPrimaryInterface) { // Primary module interface: `export module foo;` → logicalName = "foo". // `import :tls;` resolves to "foo:tls".