From 2118bfeb34df1875b0b2f7d8c1f802da56bb7b27 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Fri, 18 Sep 2026 06:55:40 +0800 Subject: [PATCH 1/8] The C library layer declares the C environment it presents, the engine realises and checks it Until now the compiler payload's target triple implied the environment a C program compiles against. The moment a package supplies the C library instead (mcpp:c-abi=), that stops being true: openkal-musl on x86_64-windows-gnu generates PE/Win64 code while presenting a POSIX environment, and every #ifdef _WIN32 above it is asking the wrong layer. - [c-abi] manifest block: presents (posix/windows/none), data-model (arch-default/lp64/llp64/ilp32), wchar (16/32), builtins (iso/platform). Only a package that also provides mcpp:c-abi= may declare it. presents/data-model/wchar carry no default; an unknown key or value is always a parse error naming the key. Absent block changes nothing. (modules/manifest/src/{targetside_model,toml,types}.cppm) - Realisation (src/toolchain/cenv.cppm): a pure, table-driven mapping from request to compiler tokens with no package names. The flagship case -- presents=posix on Windows/x86_64 -- realises as a Cygwin-flavoured compile-only identity switch (--target=x86_64-pc-cygwin, -U__CYGWIN__ -U__CYGWIN32__), leaving the link line on the graph's resolved triple, because the two triples measure identical machine code. Reaches C, C++, the dependency scan and the std module precompile. A request this engine cannot realise is refused naming the target, request and what is missing. [package] c-environment = "platform" opts a package out of the realisation entirely. - Verification, not trust (src/toolchain/cenv_probe.cppm): one -E -dM predefined-macro dump, no codegen and no execution, checked against the declaration and cached per configuration. Caught a real mapping bug during development (data-model = "llp64" alongside presents = "posix" on Windows silently read as already-satisfied); the mapping now refuses that combination directly instead of relying on the probe to catch it late. - __openkal__ defined for every target-side unit when the resolved kernel-abi layer's interface is openkal, read from the layer's value. - Closure visibility: provides = ["platform-sdk"] is a package's own statement; the Target report gains a platform-deps line, and [build] platform-dependencies = "refuse" fails the build when one is present. (src/build/prepare.cppm) - The realised environment and __openkal__ fold into the build fingerprint, so LP64 and LLP64 builds of one source/manifest never share a directory. The equivalent store-key gap for install-hook artifacts is designed but not built here -- documented in docs/22 as a known gap. Two pre-existing defects found while verifying this are filed separately, not fixed here: mcpp-community/mcpp#666 (a Clang-built mcpp binary SIGSEGVs in its own ELF runtime inspector; GCC-built does not) and #667 (a GCC self-host ICE importing mcpp.targetside from a new consumer under a parallel build; -j1 avoids it). Docs: docs/22 ([c-abi], verification, fingerprint), docs/21 (a declared environment moves the compiled triple, not the linked one), docs/24 (the three macro families, the __openkal__ rule, platform units), docs/06 (platform-sdk), and their zh mirrors. Tests: test_manifest.cpp, test_cenv.cpp (14 cases covering the whole mapping table), e2e 741. Design: mcpplibs/openkal .agents/docs/2026-09-18-openkal-c-environment-and-personalities-design.md --- CHANGELOG.md | 38 ++ docs/06-features-and-capabilities.md | 26 ++ docs/21-the-target-triple.md | 15 + docs/22-target-side.md | 161 +++++++++ docs/24-openkal-cross.md | 39 +++ docs/zh/06-features-and-capabilities.md | 22 ++ docs/zh/22-target-side.md | 125 +++++++ docs/zh/24-openkal-cross.md | 29 ++ mcpp.toml | 2 +- modules/manifest/src/targetside_model.cppm | 95 ++++- modules/manifest/src/toml.cppm | 135 ++++++- modules/manifest/src/types.cppm | 22 ++ modules/toolchain-model/src/model.cppm | 27 ++ modules/versioning/src/version.cppm | 2 +- src/build/prepare.cppm | 211 +++++++++++ src/build/refusal.cppm | 17 + src/toolchain/cenv.cppm | 328 ++++++++++++++++++ src/toolchain/cenv_probe.cppm | 209 +++++++++++ ...i_environment_declared_by_the_c_library.sh | 200 +++++++++++ tests/unit/test_cenv.cpp | 163 +++++++++ tests/unit/test_manifest.cpp | 202 +++++++++++ 21 files changed, 2061 insertions(+), 7 deletions(-) create mode 100644 src/toolchain/cenv.cppm create mode 100644 src/toolchain/cenv_probe.cppm create mode 100755 tests/e2e/741_c_abi_environment_declared_by_the_c_library.sh create mode 100644 tests/unit/test_cenv.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index d7563371..0fd3fb24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,44 @@ ## [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` 并去掉 + `__CYGWIN__`/`__CYGWIN32__`;链接行保持图解析出的三元组不变,因为两个三元组生成的机器码 + 实测完全一致(PE、Win64 调用约定、SEH)。无法满足的请求明确拒绝,点名目标、请求与缺什么。 + `[package] c-environment = "platform"` 让一个包(如 openkal-windows)的自身单元退出这项 + 实现,继续按三元组自身的默认环境编译。(`src/toolchain/cenv.cppm`,单测 `test_cenv.cpp`) +- **声明被校验,不被信任**:新模块 `mcpp.toolchain.cenv_probe` 用最终参数编译一次纯预处理 + 探针(`-E -dM`,不执行、不需要目标可在本机运行),核对 `__SIZEOF_LONG__`、 + `__SIZEOF_WCHAR_T__` 与环境身份宏是否与声明相符,不符即失败并同时打印声明值与实测值; + 结果按配置缓存。(`src/toolchain/cenv_probe.cppm`) +- **`__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` 记录为已知差距。 +- 文档:`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..41d4808f 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,124 @@ 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. 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, `-U__CYGWIN__ -U__CYGWIN32__` (those interfaces are not in this graph); `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. + +**A package's own units can opt out.** A package that provides +`mcpp:kernel-abi=openkal` (openkal-windows, say) has to see the platform's +own environment — it includes platform declarations and `_WIN32` must be +true for it. Such a package, or a platform shim, states: + +```toml +[package] +provides = ["mcpp:kernel-abi=openkal"] +c-environment = "platform" # this package's own units compile in the + # triple's own default environment, whatever + # the graph's c-abi declares +``` + +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. + +**Store key — not yet closed.** A package whose *install hook* compiles a +static library from source into the shared store is keyed by package and +version, not by which environment it was built against — the same gap +[requires](#requires) already documents for a C++ runtime selection. Closing +it the same way (a `requires`-shaped statement of the environment, checked +at resolution and refused on mismatch) is designed but not yet implemented; +until it is, such a package's install hook must not build more than one +environment's variant into one store directory, exactly as the C++-runtime +case already requires. + ### 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..a067aa84 100644 --- a/docs/24-openkal-cross.md +++ b/docs/24-openkal-cross.md @@ -34,6 +34,45 @@ 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__` | + +**`__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 — openkal-windows, or a platform shim under [06's private +dependency pattern](06-features-and-capabilities.md#a-platform-sdk-dependency-stays-private) +— states `[package] c-environment = "platform"` (docs/22) rather than +reading `__openkal__` or any other macro to work it out: the boundary is +declared, not inferred, and everything crossing it is still fixed-width +(SPEC §5.4). + ## 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..7594b71a 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#closure-visibility) +要为**整个构建**回答的问题。由 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/22-target-side.md b/docs/zh/22-target-side.md index 0a7ef3bd..2ab8ab40 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,94 @@ 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` 模块预编译。mcpp 保存的是一份 +「请求到三元组与开关」的映射表,是不含包名的通用知识: + +| 目标 | 请求 | 实现 | +|---|---|---| +| Linux | `posix` / `arch-default` | 默认三元组已经满足 | +| macOS | `posix` / `arch-default` | 默认三元组已经满足 | +| Windows | `posix` / `arch-default` | 采用 Cygwin 式语义:仅在编译行加 `--target=x86_64-pc-cygwin`,加 `-U__CYGWIN__ -U__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` 有多宽。 +因此实现只触及**编译**行;**链接**行保持图解析出的三元组,因为目标文件格式没有变化。 + +**包可以让自己的单元退出。** 提供 `mcpp:kernel-abi=openkal` 的包(比如 openkal-windows)必须 +看到平台自身的环境——它要 include 平台声明,`_WIN32` 对它必须为真。这样的包,或者一个平台 +shim,声明: + +```toml +[package] +provides = ["mcpp:kernel-abi=openkal"] +c-environment = "platform" # 本包自身的单元按三元组自身的默认环境编译, + # 不论图里的 c-abi 声明了什么 +``` + +这是一条边界规则,由这一个开关记录下来,而不是引擎强制执行:这样的包对图其余部分暴露的 +接口仍然只能用定宽类型(SPEC §5.4)。 + +**声明被校验,而不是被信任。** 声明要经过核对,绝不直接信任——这与 openkal 自己核对一致性声明 +的做法一致。目标侧解析出上述开关之后,mcpp 用它们编译一个纯预处理探针(`-E -dM`,把预定义宏 +全部打印出来——足够便宜,且不需要执行,这一点很重要,因为解析出的环境常常是交叉目标),读回 +`__SIZEOF_LONG__`、`__SIZEOF_WCHAR_T__` 以及哪些环境身份宏被定义,与声明核对。不符即失败,并 +同时打印声明值与实测值。结果按配置(编译器二进制身份 + 最终参数)缓存,同一配置解析两次只 +编译一次探针。 + +**指纹。** 解析出的环境参与构建指纹(`compileFlags`,§92 的第 7 项):C 库声明 `lp64` 与 +`llp64` 的两次构建,从同一份源码编译出 `long` 宽度不同的目标文件,因此二者绝不共享输出目录, +也不会复用对方产出的目标文件缓存。 + +**存储键——尚未补上。** 安装钩子把从源码编译的静态库装进共享 store 时,键只含包名与版本, +不含针对哪种环境编译——这与 [requires](#requires) 已经记录的 C++ 运行时选择缺口同形。用同样 +的方式补上(一条 `requires` 形状的环境声明,在解析时核对、不符即拒绝)已经设计好,但尚未 +实现;在此之前,这类包的安装钩子不得把一种以上的环境变体构建进同一个 store 目录,与 +C++ 运行时那一条现有要求一致。 + ### 标准库模块源 作为标准库的包陈述它的 `std` 模块源在何处,以及该源需要什么。 diff --git a/docs/zh/24-openkal-cross.md b/docs/zh/24-openkal-cross.md index 0b3ac489..6a794716 100644 --- a/docs/zh/24-openkal-cross.md +++ b/docs/zh/24-openkal-cross.md @@ -27,6 +27,35 @@ 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__` | + +**`__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#adaptation-to-the-resolved-target-side))。 + +**平台单元。** 自己需要平台原生环境的包——openkal-windows,或者 +[06 的私有依赖模式](06-features-and-capabilities.md#平台-sdk-依赖保持私有)下的平台 shim—— +声明 `[package] c-environment = "platform"`(docs/22),而不是读 `__openkal__` 或别的宏去 +推断:边界是声明出来的,不是推断出来的,跨越它的一切仍然只能是定宽类型(SPEC §5.4)。 + ## 工程书写的内容 ```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..f47b1817 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -1043,6 +1043,120 @@ 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; + } // [package] exclusive — capabilities this package claims sole provision of. // // Not validated against the reserved prefix: exclusivity is a property of @@ -1104,9 +1218,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 +2191,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 +2400,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/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/prepare.cppm b/src/build/prepare.cppm index e809f709..ad04eb04 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,37 @@ 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. + 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.cflags, tc->cEnvBuiltinsTokens); + appendUniqueFlags(p.privateBuild.cxxflags, tc->cEnvBuiltinsTokens); + } + } + if (auto why = tsd::check_layering(resolvedTargetSide)) { refusal::record(refusal::Code::LayerOrdering); return std::unexpected(*why); @@ -11146,6 +11270,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 +12254,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 +12514,26 @@ 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. + 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/toolchain/cenv.cppm b/src/toolchain/cenv.cppm new file mode 100644 index 00000000..46f7ab57 --- /dev/null +++ b/src/toolchain/cenv.cppm @@ -0,0 +1,328 @@ +// 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`, +// `-U__CYGWIN__ -U__CYGWIN32__` (those interfaces +// are not in the graph) +// * 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. +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). `-U__CYGWIN__`/`-U__CYGWIN32__`: the Cygwin + // triple predefines them, and they are not withheld by the + // identity switch itself — they name a real Cygwin userland + // (`sys/cygwin.h`, `cygwin_conv_path`) that is not in this + // graph, so a library probing for them would be steered toward + // an interface openkal-musl does not implement (design §3.3). + r.tokens.push_back("--target=x86_64-pc-cygwin"); + r.tokens.push_back("-U__CYGWIN__"); + r.tokens.push_back("-U__CYGWIN32__"); + r.expectDefined.push_back("__unix__"); + r.expectUndefined.push_back("_WIN32"); + r.expectUndefined.push_back("__CYGWIN__"); + 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..d107e9b1 --- /dev/null +++ b/tests/e2e/741_c_abi_environment_declared_by_the_c_library.sh @@ -0,0 +1,200 @@ +#!/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`, exempts a `c-environment = "platform"` package, +# 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]` parsing and validation is 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 +# package opting out with `c-environment = "platform"` does not receive them, +# 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 + +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 < openkalwin/mcpp.toml <<'EOF' +[package] +name = "openkalwin" +version = "1.0.0" +provides = ["mcpp:kernel-abi=openkal"] +c-environment = "platform" + +[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. +consumer = joined(args_for("main.cpp")) +missing = [tok for tok in ("--target=x86_64-pc-cygwin", "-U__CYGWIN__", "-U__CYGWIN32__", + "-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) + +# 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) + +# openkalwin declared c-environment = "platform" and must NOT see the +# substituted triple: it needs the real Windows identity to include platform +# declarations. +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 c-environment=\"platform\" package must not receive the realised triple\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_cenv.cpp b/tests/unit/test_cenv.cpp new file mode 100644 index 00000000..8a290be8 --- /dev/null +++ b/tests/unit/test_cenv.cpp @@ -0,0 +1,163 @@ +// [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. +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_TRUE(has(r->tokens, "-U__CYGWIN__")); + EXPECT_TRUE(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->expectUndefined, "_WIN32")); + ASSERT_TRUE(has(r->expectUndefined, "__CYGWIN__")); +} + +// 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_manifest.cpp b/tests/unit/test_manifest.cpp index 86ace869..64463b20 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,210 @@ 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; } From 2a33e7e497d8c8ddc7e3b6e461dac8ac369aa903 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Fri, 18 Sep 2026 06:58:10 +0800 Subject: [PATCH 2/8] Fix docs lint: zh heading parity and an interrogative table header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/zh/21-the-target-triple.md was missing the zh mirror of the new "a declared environment can move the compiled triple" note; heading count parity with the English document now holds (25/25). - docs/zh/24-openkal-cross.md's macro-family table header "由谁定义" reads as a question (contains 谁); renamed to the noun phrase "定义者". - Three cross-file anchors in the zh docs pointed at the ENGLISH heading slug of their target section instead of the zh one (#adaptation-to-the-resolved-target-side, #closure-visibility) — fixed to the zh anchors check_docs_structure.sh actually validates against. Caught by CI on #668 ("build + unit tests" job's doc-lint step); verified locally afterwards with check_docs_style.sh, check_docs_structure.sh, check_version_pins.sh, check_target_tiers.py and check_modules_wiring.sh. --- docs/zh/06-features-and-capabilities.md | 2 +- docs/zh/21-the-target-triple.md | 11 +++++++++++ docs/zh/24-openkal-cross.md | 4 ++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/zh/06-features-and-capabilities.md b/docs/zh/06-features-and-capabilities.md index 7594b71a..7e50e4b9 100644 --- a/docs/zh/06-features-and-capabilities.md +++ b/docs/zh/06-features-and-capabilities.md @@ -450,7 +450,7 @@ some.windows-headers = { version = "1.0", visibility = "private" } #### 让闭包也能看见它,而不只是私有(mcpp 2026.9.18+) `visibility = "private"` 回答的是「这个依赖会不会泄漏到消费方的 `-I` 列表」,不回答 -「这个依赖到底在不在图里」——后者是[22 —— 目标侧](22-target-side.md#closure-visibility) +「这个依赖到底在不在图里」——后者是[22 —— 目标侧](22-target-side.md#闭包可见性) 要为**整个构建**回答的问题。由 SDK 包自己陈述报告或拒绝开关需要的事实: ```toml 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/24-openkal-cross.md b/docs/zh/24-openkal-cross.md index 6a794716..29e43731 100644 --- a/docs/zh/24-openkal-cross.md +++ b/docs/zh/24-openkal-cross.md @@ -33,7 +33,7 @@ C++ 运行时 —— 成为一组由依赖图解析、并由当前运行的编 回答了其中两个——这正是每一次 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__` | @@ -49,7 +49,7 @@ C++ 运行时 —— 成为一组由依赖图解析、并由当前运行的编 *禁止:* 用它选择头文件、推断 `_WIN32` 是否为真、绕开缺失的 SDK,或者区分 `linux`/`windows`/`macos`。那些是 C 环境层或平台层的问题——在清单里写 `cfg(c-abi = "…")` 或 `cfg(kernel-abi = "…")`(谓词语法见 -[22 —— 适配已解析出的目标侧](22-target-side.md#adaptation-to-the-resolved-target-side))。 +[22 —— 对已解析目标侧的适配](22-target-side.md#对已解析目标侧的适配))。 **平台单元。** 自己需要平台原生环境的包——openkal-windows,或者 [06 的私有依赖模式](06-features-and-capabilities.md#平台-sdk-依赖保持私有)下的平台 shim—— From f19911f79a3701af094aa3e88640040bf407bff6 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Fri, 18 Sep 2026 07:17:07 +0800 Subject: [PATCH 3/8] Keep __CYGWIN__/__CYGWIN32__ defined under the posix realisation Design revision from the openkal-musl spike. The Cygwin-flavoured Windows realisation no longer undefines __CYGWIN__/__CYGWIN32__ -- the -U tokens are dropped, everything else (the --target= substitution, LP64, no _WIN32, -fno-short-wchar for wchar=32) is unchanged. The reason, stated once and correctly (an earlier private draft of this change mis-attributed the cause to libunwind's own branch selection; that was wrong on inspection of the vendored source and is not repeated here): third-party portable code that needs to know the OBJECT FORMAT -- distinct from the C environment and from the platform API -- has no other name for "PE format with a POSIX-presenting C environment" 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: a library reaching for __CYGWIN__ may also reach for a real Cygwin interface that does not exist here, and if defining it produces more new failures than it fixes, the answer flips. Updated: the realisation table and its verification expectations (src/toolchain/cenv.cppm), the unit test and e2e assertions (now checking __CYGWIN__ stays DEFINED, the opposite of the first version), and the English + zh docs/CHANGELOG prose. --- CHANGELOG.md | 17 +++++--- docs/22-target-side.md | 17 +++++++- docs/zh/22-target-side.md | 12 +++++- src/toolchain/cenv.cppm | 40 ++++++++++++++----- ...i_environment_declared_by_the_c_library.sh | 16 +++++++- tests/unit/test_cenv.cpp | 16 ++++++-- 6 files changed, 95 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fd3fb24..3d2906d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,11 +22,18 @@ (`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` 并去掉 - `__CYGWIN__`/`__CYGWIN32__`;链接行保持图解析出的三元组不变,因为两个三元组生成的机器码 - 实测完全一致(PE、Win64 调用约定、SEH)。无法满足的请求明确拒绝,点名目标、请求与缺什么。 - `[package] c-environment = "platform"` 让一个包(如 openkal-windows)的自身单元退出这项 - 实现,继续按三元组自身的默认环境编译。(`src/toolchain/cenv.cppm`,单测 `test_cenv.cpp`) + 采用 Cygwin 式语义,仅在编译行把 `--target=` 换成 `x86_64-pc-cygwin`;链接行保持图解析出 + 的三元组不变,因为两个三元组生成的机器码实测完全一致(PE、Win64 调用约定、SEH)。 + 无法满足的请求明确拒绝,点名目标、请求与缺什么。`[package] c-environment = "platform"` + 让一个包(如 openkal-windows)的自身单元退出这项实现,继续按三元组自身的默认环境编译。 + (`src/toolchain/cenv.cppm`,单测 `test_cenv.cpp`) +- **`__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__` 与环境身份宏是否与声明相符,不符即失败并同时打印声明值与实测值; diff --git a/docs/22-target-side.md b/docs/22-target-side.md index 41d4808f..43794a87 100644 --- a/docs/22-target-side.md +++ b/docs/22-target-side.md @@ -351,7 +351,7 @@ flags, generic knowledge that names no C library: |---|---|---| | 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, `-U__CYGWIN__ -U__CYGWIN32__` (those interfaces are not in this graph); `data-model` becomes LP64 as a consequence of the triple, not a separate flag | +| 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 | @@ -362,6 +362,21 @@ 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 package's own units can opt out.** A package that provides `mcpp:kernel-abi=openkal` (openkal-windows, say) has to see the platform's own environment — it includes platform declarations and `_WIN32` must be diff --git a/docs/zh/22-target-side.md b/docs/zh/22-target-side.md index 2ab8ab40..4486d36e 100644 --- a/docs/zh/22-target-side.md +++ b/docs/zh/22-target-side.md @@ -292,7 +292,7 @@ builtins = "iso" # iso | platform(默认 platform) |---|---|---| | Linux | `posix` / `arch-default` | 默认三元组已经满足 | | macOS | `posix` / `arch-default` | 默认三元组已经满足 | -| Windows | `posix` / `arch-default` | 采用 Cygwin 式语义:仅在编译行加 `--target=x86_64-pc-cygwin`,加 `-U__CYGWIN__ -U__CYGWIN32__`(这些接口不在本图里);`data-model` 变为 LP64 是三元组切换的结果,不是另一个开关 | +| 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` 记录了还核实过哪些、结论是不适用 | | 其余情况 | | 明确拒绝,点名目标、请求与缺什么——不静默降级 | @@ -300,6 +300,16 @@ 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`)——如果定义它 +带来的新失败比修好的还多,结论就会翻过来。 + **包可以让自己的单元退出。** 提供 `mcpp:kernel-abi=openkal` 的包(比如 openkal-windows)必须 看到平台自身的环境——它要 include 平台声明,`_WIN32` 对它必须为真。这样的包,或者一个平台 shim,声明: diff --git a/src/toolchain/cenv.cppm b/src/toolchain/cenv.cppm index 46f7ab57..cc9526bd 100644 --- a/src/toolchain/cenv.cppm +++ b/src/toolchain/cenv.cppm @@ -19,8 +19,9 @@ // 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`, -// `-U__CYGWIN__ -U__CYGWIN32__` (those interfaces -// are not in the graph) +// `__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 @@ -43,6 +44,25 @@ // 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; @@ -203,18 +223,16 @@ inline std::expected realise( "on x86_64 only; this arch has no verified " "substitute triple"); // `--target=x86_64-pc-cygwin`, on the COMPILE line only (module - // header above). `-U__CYGWIN__`/`-U__CYGWIN32__`: the Cygwin - // triple predefines them, and they are not withheld by the - // identity switch itself — they name a real Cygwin userland - // (`sys/cygwin.h`, `cygwin_conv_path`) that is not in this - // graph, so a library probing for them would be steered toward - // an interface openkal-musl does not implement (design §3.3). + // 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.tokens.push_back("-U__CYGWIN__"); - r.tokens.push_back("-U__CYGWIN32__"); r.expectDefined.push_back("__unix__"); + r.expectDefined.push_back("__CYGWIN__"); r.expectUndefined.push_back("_WIN32"); - r.expectUndefined.push_back("__CYGWIN__"); cygwinIdentity = true; } else if (decl.presents == CAbiPresents::Windows) { // Already the base triple's own identity — nothing to add. 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 index d107e9b1..55ffcce7 100755 --- 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 @@ -128,13 +128,25 @@ def joined(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", "-U__CYGWIN__", "-U__CYGWIN32__", - "-fno-short-wchar") +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. diff --git a/tests/unit/test_cenv.cpp b/tests/unit/test_cenv.cpp index 8a290be8..d841c491 100644 --- a/tests/unit/test_cenv.cpp +++ b/tests/unit/test_cenv.cpp @@ -53,20 +53,30 @@ TEST(CEnv, MacosPosixArchDefaultIsANoOp) { } // 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_TRUE(has(r->tokens, "-U__CYGWIN__")); - EXPECT_TRUE(has(r->tokens, "-U__CYGWIN32__")); + 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")); - ASSERT_TRUE(has(r->expectUndefined, "__CYGWIN__")); } // wchar = 16 on the Cygwin substitution matches Cygwin's own default, so no From affcb48098c6613d9be4b701f93dbc28c799fb99 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Fri, 18 Sep 2026 07:54:11 +0800 Subject: [PATCH 4/8] The realised [c-abi] environment reaches assembly units too 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 inherits the -D/-U/-I subset of a package's C flags on purpose -- a -std= or -O token meant for the C compiler is meaningless to GAS. The realised environment broadcast landed only in privateBuild.cflags/cxxflags, so it never reached that narrower channel: within one package a .c unit saw _WIN32 undefined while a .S unit still saw it defined, because --target=/-fno-short-wchar never reached the assembler's command line at all. Found by the openkal-musl spike (okm_setjmp.S, and upstream libunwind's assembly.h, both select register-save sets on that macro) -- a jmp_buf written by one save set and sized by the other header is a silent mismatch, the exact failure class this feature exists to remove. Adds UsageRequirements::asmflags, a broadcast-only channel parallel to cflags/cxxflags (no [build] asmflags manifest key backs it -- per-glob `flags = [{ asmflags }]` remains the author-facing one), and routes the realised environment tokens into it for GAS units only: NASM has no --target= concept and accepts none of these flags, so it must never receive them, and a unit test pins that split directly. Every token was checked against clang's assembler-with-cpp front end first; none is rejected, so nothing is filtered a second time. e2e 741 gains a .S unit asserting it carries the same tokens main.cpp does. --- CHANGELOG.md | 12 +++++ docs/22-target-side.md | 15 +++++- docs/zh/22-target-side.md | 9 +++- src/build/prepare.cppm | 23 ++++++++ src/modgraph/scanner.cppm | 47 +++++++++++++++- ...i_environment_declared_by_the_c_library.sh | 53 ++++++++++++++++++- tests/unit/test_modgraph.cpp | 52 ++++++++++++++++++ 7 files changed, 204 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d2906d0..4206a1bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,18 @@ 让它们的出现直接失败构建。(`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 镜像。 diff --git a/docs/22-target-side.md b/docs/22-target-side.md index 43794a87..4bebf8e3 100644 --- a/docs/22-target-side.md +++ b/docs/22-target-side.md @@ -344,8 +344,19 @@ 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. mcpp holds one mapping table from request to triple and -flags, generic knowledge that names no C library: +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 | |---|---|---| diff --git a/docs/zh/22-target-side.md b/docs/zh/22-target-side.md index 4486d36e..b2144ca5 100644 --- a/docs/zh/22-target-side.md +++ b/docs/zh/22-target-side.md @@ -285,7 +285,14 @@ builtins = "iso" # iso | platform(默认 platform) **实现(realisation)。** 一旦 `c-abi` 层解析到声明了这个块的包,mcpp 就把请求转换成编译器 配置,作用于目标侧的每一个编译单元——C 库自己、C++ 运行时、编译器运行时的 builtins,以及 -图中所有普通包——覆盖 C、C++、汇编编译,依赖扫描,以及 `std` 模块预编译。mcpp 保存的是一份 +图中所有普通包——覆盖 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 保存的是一份 「请求到三元组与开关」的映射表,是不含包名的通用知识: | 目标 | 请求 | 实现 | diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index ad04eb04..54a3df58 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -11039,6 +11039,27 @@ prepare_build(bool print_fingerprint, // 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__"}; @@ -11050,8 +11071,10 @@ prepare_build(bool print_fingerprint, 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); } } 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/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 index 55ffcce7..c76bb915 100755 --- 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 @@ -14,8 +14,12 @@ # a provider's block actually reaches the resolved target side, that the # realised tokens reach the compile database of an ordinary package, that a # package opting out with `c-environment = "platform"` does not receive them, -# and that a request this engine cannot realise is refused before anything -# compiles -- rather than compiled wrong and shipped. +# 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 @@ -46,6 +50,18 @@ 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" @@ -155,6 +171,39 @@ 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 declared c-environment = "platform" and must NOT see the # substituted triple: it needs the real Windows identity to include platform # declarations. 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". From 0e6c8e66a3ea170fc4b79230afc1cc60748f742f Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Fri, 18 Sep 2026 08:20:53 +0800 Subject: [PATCH 5/8] A kernel-abi provider infers the platform boundary, and the build cache key now covers the realised environment A package providing mcpp:kernel-abi= is by definition the boundary where the platform's own interfaces are called, so it can never want the graph's presented [c-abi] environment instead of the triple's own. Rather than require every kernel-abi implementation to write c-environment = "platform" (and every already-released one to bump a version to add it), that value is now inferred from provides alone, in both manifest parsers (mcpp.toml and the xpkg index descriptor). The explicit key still wins when present. Measured without it: 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, misreading its own results. Separately: the global build cache's key (~/.mcpp/build-cache/v1, mcpp.build.cache_key) read only a package's OWN declared cflags/cxxflags, never the engine's broadcast channel the realised environment (and __openkal__, and targetSideUsage) actually travels through. Two builds realising different environments for the identical package produced the identical key. Measured: upgrading mcpp in place, with the cache directory left alone, served objects compiled under the old realised environment into an image built under the new one. fill_package_config now folds in privateBuild.cflags/cxxflags/asmflags (the post-broadcast values) alongside the declared ones, exactly as it already did for include directories. --- CHANGELOG.md | 24 ++++++- docs/22-target-side.md | 71 +++++++++++++++++-- docs/zh/22-target-side.md | 52 ++++++++++++-- modules/manifest/src/toml.cppm | 29 ++++++++ modules/manifest/src/xpkg.cppm | 19 +++++ src/build/cache_key.cppm | 36 ++++++++++ ...i_environment_declared_by_the_c_library.sh | 39 ++++++---- tests/unit/test_cache_key.cpp | 47 ++++++++++++ tests/unit/test_manifest.cpp | 71 +++++++++++++++++++ 9 files changed, 362 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4206a1bd..bee167e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,8 +25,30 @@ 采用 Cygwin 式语义,仅在编译行把 `--target=` 换成 `x86_64-pc-cygwin`;链接行保持图解析出 的三元组不变,因为两个三元组生成的机器码实测完全一致(PE、Win64 调用约定、SEH)。 无法满足的请求明确拒绝,点名目标、请求与缺什么。`[package] c-environment = "platform"` - 让一个包(如 openkal-windows)的自身单元退出这项实现,继续按三元组自身的默认环境编译。 + 让一个包的自身单元退出这项实现,继续按三元组自身的默认环境编译。 (`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`)的键补上了解析出的环境,经 openkal-musl + 尖峰实验发现并修订。** `mcpp.build.cache_key::fill_package_config` 原先只读包自己清单里 + 声明的 `cflags`/`cxxflags`(`manifest.buildConfig`),而解析出的 [c-abi] 环境是引擎的 + 广播,只写入 `PackageRoot::privateBuild`,从不写回前者——两次解析出不同环境的构建因此 + 拿到同一把键,原地升级 `mcpp` 时缓存目录若未清理,会把按旧环境编译的目标文件喂给按新 + 环境构建的镜像,一个镜像混两种 C 环境且没有任何诊断,这正是本设计要防止的那个不变量本身。 + 现在 `fill_package_config` 把 `privateBuild.cflags`/`cxxflags`/新增的 `asmflags`(广播后的 + 值)与包自身声明的标志一起折进键里,和它原本处理 include 目录的方式一致。 + (`src/build/cache_key.cppm`,单测 `test_cache_key.cpp` 的 + `TwoDifferentRealisedCEnvironmentsDoNotShareASlot`) - **`__CYGWIN__`/`__CYGWIN32__` 保持定义,经 openkal-musl 尖峰实验修订。** 最初的实现 取消定义它们(理由是图里没有真正的 Cygwin 用户态)。第三方可移植代码里需要知道**目标文件 格式**——不是 C 环境,也不是平台 API——的那部分,没有别的名字能指代「PE 格式 + 呈现 diff --git a/docs/22-target-side.md b/docs/22-target-side.md index 4bebf8e3..37f35507 100644 --- a/docs/22-target-side.md +++ b/docs/22-target-side.md @@ -388,17 +388,54 @@ by deleting the only macro that names it. This is a **trade-off for the `cygwin_conv_path`) that does not exist here, and if defining it produces more new failures than it fixes, the answer flips. -**A package's own units can opt out.** A package that provides -`mcpp:kernel-abi=openkal` (openkal-windows, say) has to see the platform's -own environment — it includes platform declarations and `_WIN32` must be -true for it. Such a package, or a platform shim, states: +**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"] -c-environment = "platform" # this package's own units compile in the - # triple's own default environment, whatever - # the graph's c-abi declares +# 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 @@ -422,6 +459,26 @@ 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. + **Store key — not yet closed.** A package whose *install hook* compiles a static library from source into the shared store is keyed by package and version, not by which environment it was built against — the same gap diff --git a/docs/zh/22-target-side.md b/docs/zh/22-target-side.md index b2144ca5..4a88f52c 100644 --- a/docs/zh/22-target-side.md +++ b/docs/zh/22-target-side.md @@ -317,15 +317,44 @@ Windows 一行是旗舰情形:`x86_64-w64-windows-gnu` 与 `x86_64-pc-cygwin` 去够一个这里并不存在的真正 Cygwin 接口(`sys/cygwin.h`、`cygwin_conv_path`)——如果定义它 带来的新失败比修好的还多,结论就会翻过来。 -**包可以让自己的单元退出。** 提供 `mcpp:kernel-abi=openkal` 的包(比如 openkal-windows)必须 -看到平台自身的环境——它要 include 平台声明,`_WIN32` 对它必须为真。这样的包,或者一个平台 -shim,声明: +**`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"] -c-environment = "platform" # 本包自身的单元按三元组自身的默认环境编译, - # 不论图里的 c-abi 声明了什么 +# 没有 [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" ``` 这是一条边界规则,由这一个开关记录下来,而不是引擎强制执行:这样的包对图其余部分暴露的 @@ -342,6 +371,19 @@ c-environment = "platform" # 本包自身的单元按三元组自身的默认 `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`,或者干脆清空缓存目录,从来都不是键本身正确 +的信号——这两条路径都是绕开了这个键,而不是证明了它。 + **存储键——尚未补上。** 安装钩子把从源码编译的静态库装进共享 store 时,键只含包名与版本, 不含针对哪种环境编译——这与 [requires](#requires) 已经记录的 C++ 运行时选择缺口同形。用同样 的方式补上(一条 `requires` 形状的环境声明,在解析时核对、不符即拒绝)已经设计好,但尚未 diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index f47b1817..369acc8e 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -1157,6 +1157,35 @@ std::expected parse_string(std::string_view content, "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 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/src/build/cache_key.cppm b/src/build/cache_key.cppm index a537a0c6..b66a972d 100644 --- a/src/build/cache_key.cppm +++ b/src/build/cache_key.cppm @@ -185,6 +185,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 +307,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 +353,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 +508,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/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 index c76bb915..1c85f7ca 100755 --- 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 @@ -3,17 +3,18 @@ # 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`, exempts a `c-environment = "platform"` package, -# and its verification step refuses an unrealisable request rather than -# silently approximating it. +# `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]` parsing and validation is 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 -# package opting out with `c-environment = "platform"` does not receive them, +# 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 @@ -98,12 +99,20 @@ EOF rm -rf target } +# NO explicit `c-environment` here -- that is the point of this leg +# (coordinator revision, openkal-musl spike): a `mcpp:kernel-abi=` +# 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"] -c-environment = "platform" [targets.openkalwin] kind = "lib" @@ -204,15 +213,19 @@ if present: f"but found {present}\n args: {asm_joined}") sys.exit(1) -# openkalwin declared c-environment = "platform" and must NOT see the -# substituted triple: it needs the real Windows identity to include platform -# declarations. +# 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 c-environment=\"platform\" package must not receive the realised triple\n args: {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") diff --git a/tests/unit/test_cache_key.cpp b/tests/unit/test_cache_key.cpp index 696d1352..a11f8bf9 100644 --- a/tests/unit/test_cache_key.cpp +++ b/tests/unit/test_cache_key.cpp @@ -381,6 +381,53 @@ 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); +} + // 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_manifest.cpp b/tests/unit/test_manifest.cpp index 64463b20..21bec43e 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -5991,3 +5991,74 @@ c-environment = "native" "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"); +} From 5de0959b28d77341616703c26acfa65beb326bf5 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Fri, 18 Sep 2026 08:28:11 +0800 Subject: [PATCH 6/8] Bump the build-cache epoch: a corrected key cannot tell a poisoned entry from a trustworthy one The previous commit made fill_package_config read the realised environment, but a corrected derivation does not make an entry written under the old, wrong one safe to keep. An entry is poisoned exactly when its recorded key and its actual compiled inputs already disagreed at write time, and the package most likely to still produce an unchanged key after the fix is the one this same PR newly exempts from the realisation: a kernel-abi provider inferred into c-environment = "platform" now has an empty privateBuild.cflags, so its new key is computed from nothing -- matching its old key, which was also computed from nothing, while the object on disk was compiled WITH the substitution. No cheaper mechanism distinguishes a pre-fix entry from a post-fix one, so kCacheEpoch moves (2 -> 3): every existing ~/.mcpp/build-cache/v1 entry is orphaned unconditionally, one cold rebuild for everyone, rather than trust a key equality that is wrong for exactly the entries that matter most. Also adds CacheKey.EveryPrivateBuildBroadcastFieldReachesTheKey, a durable guard for the class of defect rather than only this one instance of it: PackageRoot::privateBuild (UsageRequirements) is the engine's own broadcast channel, and every one of its members -- today includeDirs, includeDirsAfter, cflags, cxxflags, asmflags, ldflags, modules -- has to move the cache key, because the key's only job is to describe what reaches the compiler. This predates the c-abi wave (targetSideUsage's own broadcast, and __openkal__, had the identical exposure before [c-abi] existed); the two fields nothing broadcasts into yet (ldflags, modules) are asserted uncovered on purpose, with an explicit instruction to flip that assertion in the same change that adds their broadcast. --- CHANGELOG.md | 12 +++++- docs/22-target-side.md | 15 ++++++++ docs/zh/22-target-side.md | 10 +++++ src/build/cache_key.cppm | 21 ++++++++++- tests/unit/test_cache_key.cpp | 70 +++++++++++++++++++++++++++++++++++ 5 files changed, 126 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bee167e8..f2e98200 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,8 +47,18 @@ 环境构建的镜像,一个镜像混两种 C 环境且没有任何诊断,这正是本设计要防止的那个不变量本身。 现在 `fill_package_config` 把 `privateBuild.cflags`/`cxxflags`/新增的 `asmflags`(广播后的 值)与包自身声明的标志一起折进键里,和它原本处理 include 目录的方式一致。 + **升级到这个版本后,第一次构建会是一次冷构建**:`kCacheEpoch` 从 2 提到了 3,已有的 + `~/.mcpp/build-cache/v1` 条目会被整体作废,而不是逐条判断哪些还能信。这不是求稳的富余 + 动作——键改对了以后,恰恰是那些*不再*触发替换的包最危险:比如同一个 PR 里被推导进 + `c-environment = "platform"` 的 kernel-abi 包,它 broadcast 前后 `privateBuild.cflags` + 都是空的,新键和旧键因此照样相同,而旧键当初对应的目标文件,正是带着替换令牌编译出来 + 的那一份——键从一开始就没描述对它,普通的输入变化检测看不出来,只有作废整个缓存才够。 (`src/build/cache_key.cppm`,单测 `test_cache_key.cpp` 的 - `TwoDifferentRealisedCEnvironmentsDoNotShareASlot`) + `TwoDifferentRealisedCEnvironmentsDoNotShareASlot` 与 + `EveryPrivateBuildBroadcastFieldReachesTheKey`——后者是给这一类缺陷立的长期防线: + `privateBuild`(`UsageRequirements`)每加一个新的广播字段,都要在这个测试和 + `fill_package_config` 里同时补上一行,否则历史会重演,`-D__openkal__` 和 + `targetSideUsage` 自己的广播在这次修订之前就已经有过同样的缺口) - **`__CYGWIN__`/`__CYGWIN32__` 保持定义,经 openkal-musl 尖峰实验修订。** 最初的实现 取消定义它们(理由是图里没有真正的 Cygwin 用户态)。第三方可移植代码里需要知道**目标文件 格式**——不是 C 环境,也不是平台 API——的那部分,没有别的名字能指代「PE 格式 + 呈现 diff --git a/docs/22-target-side.md b/docs/22-target-side.md index 37f35507..cbd5ffa7 100644 --- a/docs/22-target-side.md +++ b/docs/22-target-side.md @@ -479,6 +479,21 @@ 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.** A package whose *install hook* compiles a static library from source into the shared store is keyed by package and version, not by which environment it was built against — the same gap diff --git a/docs/zh/22-target-side.md b/docs/zh/22-target-side.md index 4a88f52c..6b09c97d 100644 --- a/docs/zh/22-target-side.md +++ b/docs/zh/22-target-side.md @@ -384,6 +384,16 @@ C 环境,而且没有任何诊断。`fill_package_config` 现在把 `PackageRoot include 目录的方式完全一致。`--cache=off`,或者干脆清空缓存目录,从来都不是键本身正确 的信号——这两条路径都是绕开了这个键,而不是证明了它。 +**这次发布还把缓存的 epoch 提了一版,让已有条目全部作废——升级后第一次构建会是冷构建。** +键改对了,不代表用旧的、错误推导方式写下的条目就可以留着继续信:一个条目被污染,恰恰是 +因为它记录的键和它实际编译时的输入从一开始就对不上——而修好之后最可能仍然拿到不变的键 +的那个包,正好是这次修订里新推导进 `c-environment = "platform"` 的那一类(上文的 +kernel-abi 提供者):它的 `privateBuild.cflags` 现在是空的,新键因此是从空内容算出来的, +跟旧键(同样是从空内容算出来的)一样;而磁盘上那份目标文件,却是带着替换令牌编译出来的。 +没有更便宜的办法能把修复前写下的条目和修复后写下的条目分开,所以 +`mcpp.build.cache_key::kCacheEpoch` 往上提了一版(2 → 3),让整个缓存无条件作废,而不是 +去相信一个恰恰在最要紧的那些条目上靠不住的键相等判断。 + **存储键——尚未补上。** 安装钩子把从源码编译的静态库装进共享 store 时,键只含包名与版本, 不含针对哪种环境编译——这与 [requires](#requires) 已经记录的 C++ 运行时选择缺口同形。用同样 的方式补上(一条 `requires` 形状的环境声明,在解析时核对、不符即拒绝)已经设计好,但尚未 diff --git a/src/build/cache_key.cppm b/src/build/cache_key.cppm index b66a972d..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 { diff --git a/tests/unit/test_cache_key.cpp b/tests/unit/test_cache_key.cpp index a11f8bf9..d2740777 100644 --- a/tests/unit/test_cache_key.cpp +++ b/tests/unit/test_cache_key.cpp @@ -428,6 +428,76 @@ TEST(CacheKey, TwoDifferentRealisedCEnvironmentsDoNotShareASlot) { 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 From 026875d7d2ac9d3c7e1ac1fcab2705372e2a4a7f Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Fri, 18 Sep 2026 08:36:43 +0800 Subject: [PATCH 7/8] Verification-probe test coverage, and the install-hook store's failure mode written out plainly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The declaration probe (mcpp.toolchain.cenv_probe, design 2026-09-18 §3.2) landed in an earlier commit and is wired into prepare.cppm, but had zero direct test coverage: e2e 741 only exercises its success path (the build would not otherwise complete) and its one refusal leg comes from cenv::realise refusing an unrealisable request before the probe is ever reached. Adds tests/unit/test_cenv_probe.cpp, calling cenv_probe::verify directly against a real host compiler with deliberately wrong expectations -- a declared long/wchar_t width the compiler disagrees with, a macro declared defined that isn't and vice versa -- confirming each produces the declared-vs-measured Mismatch entry prepare.cppm renders into its refusal text, plus the cache's hit/miss behavior. Each test gets its own temp cache directory: the probe's cache is keyed on (compiler, argv) alone, not on what expectations are being checked, so two tests sharing a default cache root and identical argv would have the second read the first's cached dump rather than genuinely probe. Separately, traced the install-hook store's code path (prepare.cppm, install_hook_env) to answer directly: yes, an install hook can and does compile target-side code into the shared store, and no, there is no way today for it to know the realised [c-abi] environment, because the hook runs before the toolchain resolves at all -- prepare.cppm resolves tc only after the dependency graph installs, since resolving the target side can itself depend on which package the graph supplies a layer from. This is not a gap this PR could close with a local fix; docs/22 (+ zh mirror) now states the failure mode plainly (objects sized for one wchar_t linked against headers sized for another, with nothing checking it) rather than as a line in a gap table, names what closing it for real would take, and says explicitly that this is the same shape as the already-shipped c++-abi store-key gap, not a new one this PR introduces. --- CHANGELOG.md | 13 ++- docs/22-target-side.md | 51 +++++++-- docs/zh/22-target-side.md | 31 +++++- tests/unit/test_cenv_probe.cpp | 187 +++++++++++++++++++++++++++++++++ 4 files changed, 267 insertions(+), 15 deletions(-) create mode 100644 tests/unit/test_cenv_probe.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index f2e98200..cc15de20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,7 +69,18 @@ - **声明被校验,不被信任**:新模块 `mcpp.toolchain.cenv_probe` 用最终参数编译一次纯预处理 探针(`-E -dM`,不执行、不需要目标可在本机运行),核对 `__SIZEOF_LONG__`、 `__SIZEOF_WCHAR_T__` 与环境身份宏是否与声明相符,不符即失败并同时打印声明值与实测值; - 结果按配置缓存。(`src/toolchain/cenv_probe.cppm`) + 结果按配置缓存。(`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`)。 diff --git a/docs/22-target-side.md b/docs/22-target-side.md index cbd5ffa7..ed5882a0 100644 --- a/docs/22-target-side.md +++ b/docs/22-target-side.md @@ -494,15 +494,48 @@ 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.** A package whose *install hook* compiles a -static library from source into the shared store is keyed by package and -version, not by which environment it was built against — the same gap -[requires](#requires) already documents for a C++ runtime selection. Closing -it the same way (a `requires`-shaped statement of the environment, checked -at resolution and refused on mismatch) is designed but not yet implemented; -until it is, such a package's install hook must not build more than one -environment's variant into one store directory, exactly as the C++-runtime -case already requires. +**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 diff --git a/docs/zh/22-target-side.md b/docs/zh/22-target-side.md index 6b09c97d..235fb7d3 100644 --- a/docs/zh/22-target-side.md +++ b/docs/zh/22-target-side.md @@ -394,11 +394,32 @@ kernel-abi 提供者):它的 `privateBuild.cflags` 现在是空的,新键因此 `mcpp.build.cache_key::kCacheEpoch` 往上提了一版(2 → 3),让整个缓存无条件作废,而不是 去相信一个恰恰在最要紧的那些条目上靠不住的键相等判断。 -**存储键——尚未补上。** 安装钩子把从源码编译的静态库装进共享 store 时,键只含包名与版本, -不含针对哪种环境编译——这与 [requires](#requires) 已经记录的 C++ 运行时选择缺口同形。用同样 -的方式补上(一条 `requires` 形状的环境声明,在解析时核对、不符即拒绝)已经设计好,但尚未 -实现;在此之前,这类包的安装钩子不得把一种以上的环境变体构建进同一个 store 目录,与 -C++ 运行时那一条现有要求一致。 +**存储键——尚未补上,这里把它到底意味着什么讲清楚(对照真正写入 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 目录。 ### 标准库模块源 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"; +} From 091095860c158d3117ab042bc3512b7e9b604485 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Fri, 18 Sep 2026 09:29:50 +0800 Subject: [PATCH 8/8] Self-review before merge: a fingerprint bug found, the fourth fact and the inference precedence made findable, macOS CI pointed at #669 Found while verifying the P1 criterion (a package declaring nothing gets a byte-identical command line): the c-abi fingerprint's cenv-platform loop was unconditional, scanning every package for c-environment == "platform" regardless of whether any [c-abi] realisation was active in this build at all. Since c-environment = "platform" is now INFERRED for every mcpp:kernel-abi= provider, this folded "cenv-platform:" into the fingerprint -- moving the output directory -- of every project using one, even when nothing about the realised environment was ever active. Confirmed live: the identical fakekernel-in-graph, no-[c-abi] scenario, same project directory, same manifest, produced two different output-dir hashes before and after gating the loop behind the same "cEnvTokens or cEnvBuiltinsTokens non-empty" condition the rest of the block already uses correctly. Fixed; a package using no c-abi feature at all now keeps the directory it already had, whether or not its graph happens to include a kernel-abi provider. docs/24 gains the fourth fact this cross-repo doc's own table was missing: object format (__CYGWIN__) is a property of the target triple, not a fourth C-environment macro, and rides along with -- not folded into -- the three-family table; and corrects its now-stale "platform units" paragraph, which still described openkal-windows declaring c-environment explicitly, to state the inferred/explicit split and precedence (both en and zh). CHANGELOG's cache-epoch entry now leads with the user-facing consequence (first build after upgrading is cold) rather than burying it inside the technical explanation. ci-macos.yml's SDK-selection comment is corrected per review: it claimed mcpp's own SDK resolution is independent of and overrides the xlings LLVM package's clang++.cfg sysroot, which does not survive checking (both currently resolve to the same path; which wins on the command line is not established). Replaced with the defensible statement plus a pointer to mcpp-community/mcpp#669, which both known-red xcode-27 jobs now name. --- .github/workflows/ci-macos-e2e.yml | 4 +++ .github/workflows/ci-macos.yml | 18 ++++++------- CHANGELOG.md | 26 +++++++++---------- docs/24-openkal-cross.md | 41 +++++++++++++++++++++++++----- docs/zh/24-openkal-cross.md | 28 +++++++++++++++++--- src/build/prepare.cppm | 24 ++++++++++++++--- 6 files changed, 106 insertions(+), 35 deletions(-) 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 cc15de20..f94ac69a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,20 +39,20 @@ `mcpp.toml` 两条清单解析路径都实现了同一条推导。(`modules/manifest/src/{toml,xpkg}.cppm`, 单测 `test_manifest.cpp` 的 `CEnvironmentIsInferredForAKernelAbiProvider`(两条解析路径各一 个),e2e `tests/e2e/741_...sh`) -- **全局构建缓存(`~/.mcpp/build-cache/v1`)的键补上了解析出的环境,经 openkal-musl - 尖峰实验发现并修订。** `mcpp.build.cache_key::fill_package_config` 原先只读包自己清单里 - 声明的 `cflags`/`cxxflags`(`manifest.buildConfig`),而解析出的 [c-abi] 环境是引擎的 - 广播,只写入 `PackageRoot::privateBuild`,从不写回前者——两次解析出不同环境的构建因此 - 拿到同一把键,原地升级 `mcpp` 时缓存目录若未清理,会把按旧环境编译的目标文件喂给按新 - 环境构建的镜像,一个镜像混两种 C 环境且没有任何诊断,这正是本设计要防止的那个不变量本身。 - 现在 `fill_package_config` 把 `privateBuild.cflags`/`cxxflags`/新增的 `asmflags`(广播后的 +- **升级到这个版本后,第一次构建会是一次冷构建——这是故意的,原因如下。** 全局构建缓存 + (`~/.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 目录的方式一致。 - **升级到这个版本后,第一次构建会是一次冷构建**:`kCacheEpoch` 从 2 提到了 3,已有的 - `~/.mcpp/build-cache/v1` 条目会被整体作废,而不是逐条判断哪些还能信。这不是求稳的富余 - 动作——键改对了以后,恰恰是那些*不再*触发替换的包最危险:比如同一个 PR 里被推导进 - `c-environment = "platform"` 的 kernel-abi 包,它 broadcast 前后 `privateBuild.cflags` - 都是空的,新键和旧键因此照样相同,而旧键当初对应的目标文件,正是带着替换令牌编译出来 - 的那一份——键从一开始就没描述对它,普通的输入变化检测看不出来,只有作废整个缓存才够。 (`src/build/cache_key.cppm`,单测 `test_cache_key.cpp` 的 `TwoDifferentRealisedCEnvironmentsDoNotShareASlot` 与 `EveryPrivateBuildBroadcastFieldReachesTheKey`——后者是给这一类缺陷立的长期防线: diff --git a/docs/24-openkal-cross.md b/docs/24-openkal-cross.md index a067aa84..247f8da5 100644 --- a/docs/24-openkal-cross.md +++ b/docs/24-openkal-cross.md @@ -48,6 +48,20 @@ meant "is the Windows CRT present." | 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 @@ -66,12 +80,27 @@ write `cfg(c-abi = "…")` or `cfg(kernel-abi = "…")` in the manifest instead for the predicate grammar). **Platform units.** A package that itself needs the platform's own -environment — openkal-windows, or a platform shim under [06's private -dependency pattern](06-features-and-capabilities.md#a-platform-sdk-dependency-stays-private) -— states `[package] c-environment = "platform"` (docs/22) rather than -reading `__openkal__` or any other macro to work it out: the boundary is -declared, not inferred, and everything crossing it is still fixed-width -(SPEC §5.4). +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 diff --git a/docs/zh/24-openkal-cross.md b/docs/zh/24-openkal-cross.md index 29e43731..30b8970e 100644 --- a/docs/zh/24-openkal-cross.md +++ b/docs/zh/24-openkal-cross.md @@ -39,6 +39,16 @@ C++ 运行时 —— 成为一组由依赖图解析、并由当前运行的编 | 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` 的 实现不需要引擎改动。 @@ -51,10 +61,20 @@ C++ 运行时 —— 成为一组由依赖图解析、并由当前运行的编 `cfg(c-abi = "…")` 或 `cfg(kernel-abi = "…")`(谓词语法见 [22 —— 对已解析目标侧的适配](22-target-side.md#对已解析目标侧的适配))。 -**平台单元。** 自己需要平台原生环境的包——openkal-windows,或者 -[06 的私有依赖模式](06-features-and-capabilities.md#平台-sdk-依赖保持私有)下的平台 shim—— -声明 `[package] c-environment = "platform"`(docs/22),而不是读 `__openkal__` 或别的宏去 -推断:边界是声明出来的,不是推断出来的,跨越它的一切仍然只能是定宽类型(SPEC §5.4)。 +**平台单元。** 自己需要平台原生环境的包,从不读 `__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 环境。 ## 工程书写的内容 diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 54a3df58..1bd3f3d6 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -12554,9 +12554,27 @@ prepare_build(bool print_fingerprint, // 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. - for (auto& pkg : packages) - if (pkg.manifest.cEnvironment == "platform") - fpi.compileFlags += " cenv-platform:" + pkg.manifest.package.name; + // + // 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