From 878335064a3fdec6ca3f19b2fb5b92d8c4a9a02f Mon Sep 17 00:00:00 2001 From: SPeak Date: Fri, 7 Aug 2026 16:26:52 +0800 Subject: [PATCH 01/54] =?UTF-8?q?feat:=20[resources]=20=E4=B8=8E=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E8=BA=AB=E4=BB=BD=20=E2=80=94=E2=80=94=20=E4=B8=A4?= =?UTF-8?q?=E5=A4=84=E3=80=8C=E6=A8=A1=E5=9E=8B=E6=AF=94=E7=94=9F=E6=80=81?= =?UTF-8?q?=E5=B0=91=E4=B8=80=E5=B1=82=E3=80=8D(#365,=20#363)=20(2026.8.7.?= =?UTF-8?q?1)=20(#369)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两个 issue 领域无关,失效形状同一条:生态已经产出的东西,mcpp 的模型表达不了,于是走到一条「不报错但结果是错的」路径上。 #365 Windows 资源:`[resources]` 一行写 icon 与版本信息,mcpp 生成并**跟踪**资源脚本;issue 结尾「llvm-rc 的 bug」那条归因已实测证伪(`VS_VERSION_INFO` 未定义会被当资源名字符串,类型仍是 RT_VERSION,所以每个打印类型的工具都说没问题,而 GetFileVersionInfo 查的是序号)。补齐 `role = "object"`:角色表原本缺「链接输入」这一格。 #363 版本身份:`resolve_semver` 现在返回索引的**字面键**而不是从数字重造的地址;预发布按 SemVer 排序且范围看不见它;数值段不再截断在第四段;别名条目不再是范围候选;真平局硬错。mcpp.lock 记录解析结果并覆盖传递依赖,且不再随命令抖动。 合入前做了一轮深度 review,四条实测缺陷 + 三处口径不一致在本 PR 内一并修掉,论证见设计文档 §F。 --- ...s-resources-and-version-identity-design.md | 572 ++++++++++++++++++ .github/workflows/cross-build-test.yml | 10 + CHANGELOG.md | 60 ++ README.md | 6 +- README.zh-CN.md | 6 +- docs/05-mcpp-toml.md | 99 +++ docs/07-build-mcpp.md | 30 +- docs/zh/05-mcpp-toml.md | 87 +++ docs/zh/07-build-mcpp.md | 25 +- mcpp.toml | 2 +- src/build/directives.cppm | 2 + src/build/execute.cppm | 43 +- src/build/hostprogram.cppm | 12 +- src/build/ninja_backend.cppm | 54 +- src/build/plan.cppm | 37 +- src/build/prepare.cppm | 481 ++++++++++++++- src/build/resources.cppm | 444 ++++++++++++++ src/manifest/toml.cppm | 59 ++ src/manifest/types.cppm | 93 ++- src/manifest/xpkg.cppm | 120 +++- src/pm/lock_io.cppm | 16 + src/pm/resolver.cppm | 142 ++++- src/version.cppm | 2 +- src/version_req.cppm | 327 ++++++++-- tests/e2e/169_semver_project_index.sh | 9 +- tests/e2e/188_build_actions.sh | 184 +++++- tests/e2e/196_version_identity_and_lock.sh | 228 +++++++ tests/e2e/197_windows_resources.sh | 14 + tests/e2e/198_windows_resources_cross.sh | 57 ++ tests/e2e/199_resources_validation.sh | 82 +++ tests/e2e/_windows_resources_body.sh | 298 +++++++++ tests/unit/test_build_resources.cpp | 243 ++++++++ tests/unit/test_manifest.cpp | 56 ++ tests/unit/test_version_req.cpp | 140 ++++- 34 files changed, 3868 insertions(+), 172 deletions(-) create mode 100644 .agents/docs/2026-08-07-windows-resources-and-version-identity-design.md create mode 100644 src/build/resources.cppm create mode 100755 tests/e2e/196_version_identity_and_lock.sh create mode 100755 tests/e2e/197_windows_resources.sh create mode 100755 tests/e2e/198_windows_resources_cross.sh create mode 100755 tests/e2e/199_resources_validation.sh create mode 100644 tests/e2e/_windows_resources_body.sh create mode 100644 tests/unit/test_build_resources.cpp diff --git a/.agents/docs/2026-08-07-windows-resources-and-version-identity-design.md b/.agents/docs/2026-08-07-windows-resources-and-version-identity-design.md new file mode 100644 index 00000000..6c04d471 --- /dev/null +++ b/.agents/docs/2026-08-07-windows-resources-and-version-identity-design.md @@ -0,0 +1,572 @@ +# 两处「模型比生态少一层」:Windows 资源输入 与 版本身份 + +> 状态:**已实施(2026.8.7.1)**。剩余待决的 4/5/6 按文档推荐执行,可回退,见 §D。 +> 实施中发现的三条见 §E。 +> 关联:[#365](https://github.com/mcpp-community/mcpp/issues/365)(Windows 资源编译)、 +> [#363](https://github.com/mcpp-community/mcpp/issues/363)(版本模型) +> 涉及:`src/build/{plan,prepare,ninja_backend,flags}.cppm`、 +> `src/manifest/{types,toml}.cppm`、`src/toolchain/{model,dialect,llvm,gcc,msvc}.cppm`、 +> `src/version_req.cppm`、`src/pm/{resolver,lock_io}.cppm`、`src/build/execute.cppm` + +--- + +## 0. 为什么写在一份文档里 + +两个 issue 领域无关,但失效形状同一条:**生态已经产出的东西,mcpp 的模型表达不了,于是走到一条「不报错但结果是错的」路径上。** + +| | 生态产出 | mcpp 的模型 | 用户实际遭遇 | +|---|---|---|---| +| #365 | Windows 应用带 `.rc`(图标、VERSIONINFO) | 链接期输入只有「对象文件」和「不透明 ldflags 字符串」 | 只能把预编译 `.res` 塞进 ldflags;改图标后 `ninja: no work to do` | +| #363 | 上游发 `b10069`、`1.92.8-docking` | 版本 = 4 个整数,字面键被丢弃 | `^1.92.8` 在两个不同 tarball 之间任选一个;lock 记的是约束本身 | + +两条都不是「少个 feature」,是**表达力缺口导致的静默错误**。本仓库反复付过这个学费(假 `Cached` 骗了三个月、`.mcpp_ok` 只证进程退 0、索引下限把旧客户端变砖),处理原则一致:**要么给出正确答案,要么给出指名的错误,不要第三种。** + +除此之外两者有一处真实交汇(不是硬凑):`version_req::Version` 同时是 #363 的排序依据 和 #365 生成 `FILEVERSION` 所需的 4 元组来源。#363 把「字面 / 序」分开之后,#365 正好取它的数值侧。**Part A 与 Part B 可各自独立发布**,只要 A 落在 B 之后(或 A 自己解析一次版本号,成本一行)。 + +--- + +# Part A — #365:Windows 资源编译 + +## A1. 关键发现:VERSIONINFO 读不到,不是 llvm-rc 的 bug + +issue 结尾要求作者注意「llvm-rc 生成的 VERSIONINFO 结构不被 GetFileVersionInfo 解析」,并建议 mcpp 侧做专门处理或换编译器。**这条归因是错的,实测已证伪。** + +用 issue 里那份 `.rc`(`VS_VERSION_INFO VERSIONINFO`,未 include 任何头),以及把首 token 换成字面 `1` 的版本,分别用 `llvm-rc 22.1.8` 编译,比对 `.res` 的资源头: + +``` +# VS_VERSION_INFO VERSIONINFO → 508 字节 +ff ff 10 00 56 00 53 00 5f 00 56 00 45 00 52 00 ... +^type=0xFFFF,16 ^name = UTF-16 字符串 "VS_VERSION_INFO" + +# 1 VERSIONINFO → 480 字节 +ff ff 10 00 ff ff 01 00 +^type=0xFFFF,16 ^name = 序号 1 +``` + +`VS_VERSION_INFO` 是 `verrsrc.h` 里的宏(`#define VS_VERSION_INFO 1`),随 `windows.h` 引入。**没有 include 它时,rc 语法允许标识符出现在资源名位置,于是它被当成资源名字符串**——资源类型仍是 RT_VERSION(16),所以 `llvm-readobj --coff-resources` 照样显示 `Type: VERSIONINFO`;但 `GetFileVersionInfo` 查的是 `MAKEINTRESOURCE(VS_VERSION_INFO)` 即**序号 1**,查不到,所有字段返回空。报告者观测到的每一条都对上了。 + +两种修法都实测通过,产出与 `1 VERSIONINFO` **字节相同**(480 字节,`ff ff 01 00`): + +``` +llvm-rc -D VS_VERSION_INFO=1 /fo out.res in.rc # 命令行定义 +# 或在 .rc 顶部写 #define VS_VERSION_INFO 1 / #include +``` + +同时实测清了 llvm-rc 的能力边界(`llvm-rc /?`):**默认就做预处理**(有 `/no-preprocess` 才关),支持 `/I` 加 include 路径、`/D` 定义宏;**没有任何 depfile 选项**。所以「llvm-rc 不认常量」也不是 llvm-rc 的缺陷,是 `.rc` 里没有 include,预处理器无从展开。 + +**对设计的三条影响** + +1. mcpp 不需要绕开任何工具 bug。合成 `.rc` 时写字面 `1 VERSIONINFO`,构造性正确。 +2. 用户自写 `.rc` 要能 `#include `,所以 mcpp **必须把目标的 SDK / mingw include 目录喂给 rc 工具**(`/I`,MSVC 走 `INCLUDE` 环境变量——`Toolchain::envOverrides` 已经在填它)。 +3. **不注入 `-DVS_VERSION_INFO=1`。** 那是拿 mcpp 去局部模仿 Windows SDK:报告者接下来还缺 `VOS_NT_WINDOWS32`、`VFT_APP`……每补一个就多一处与真 SDK 漂移的定义。正解是让真的 `windows.h` 可达。作为补偿,见 A5 的 lint。 + +## A2. 现状:三条通道,没有一条能表达「被跟踪的链接期输入」 + +| 通道 | 现状 | 为什么不行 | +|---|---|---| +| `[build].ldflags` | `ninja_backend.cppm:825` 把 `$ldflags` 整串拼进 link 命令 | ldflags 是不透明字符串,**没有任何一处从里面析出文件路径当 implicit input**。`.res` 改了 ninja 不知道 → issue 报的 `no work to do` | +| `[build].sources` | `.rc` 不在 `is_implementation_source` / `is_c_source` / nasm / gas 任一分派里(`plan.cppm:299`、`ninja_backend.cppm:244`) | 会被当成 C++ 翻译单元进模块图与编译集 | +| `build.mcpp` 的 `action{}` | `BuildAction::Role` 三个值:`Source`(产出进**编译**集)、`Check`(产出是 stamp)、`Artifact`(**输入是 link 产物**) | 三条接线分别接在「编译输入 / 无 / 链接输出」上。**「链接输入」这个接线点在角色表里不存在。** | + +第三行是架构级的:`types.cppm:219` 明说「role 不是三种机制,是同一条边的三种接线」。资源编译正好证明这张表**缺一格**——link 边是有输入有输出的节点,而没有任何角色能把产物接到它的输入上。同类需求还有:`objcopy` 嵌入二进制 blob、`.def`/`.exp`、外部生成的 `.o`、linker script。 + +> 换句话说:#365 不修,用户就只能继续用 ldflags 塞路径——而 ldflags 塞路径**正是 issue 抱怨的那个不被跟踪的东西**。表达力缺口和它逼出的坏 workaround 是同一件事。 + +## A3. 设计:三层,每层是下一层的默认值 + +分层控制的判据来自 grpcgen 那批的教训:**断崖本身就是设计缺陷**——两个旋钮之后只能手写六十行绕开规则,而那六十行会与规则悄悄漂移。所以三层必须能连续下降,L0 的产物就是 L1 的输入。 + +### L0:声明式(覆盖 90% 场景,零 `.rc` 编写) + +```toml +[resources] +icon = "assets/app.ico" + +[resources.version-info] # 全部可选,默认从 [package] 取 +company = "…" # 默认 authors[0] +product = "…" # 默认 package.name +description = "…" # 默认 package.description +copyright = "…" # 默认 "© " + authors[0] + "," + license +``` + +`[package]` 已有 `name / version / description / license / authors / repo`(`types.cppm:37`),足够生成一份合法 VERSIONINFO。`FILEVERSION` / `PRODUCTVERSION` 取 `version` 的 4 段数值(`0.2.0` → `0,2,0,0`;`2026.8.6.3` → `2026,8,6,3`),每段 clamp 到 u16 并在越界时报错而不是截断;`StringFileInfo` 里的 `FileVersion` / `ProductVersion` 写**版本字面串**(这样 `1.0.0-rc1` 这类预发布信息不丢——见 Part B)。 + +mcpp 把合成的 `.rc` 写到 `target///resources/.rc`,编译,产物挂到该包每个 PE link unit 的输入上。 + +### L1:自写 `.rc` + +```toml +[resources] +files = ["res/app.rc"] +extra-inputs = ["res/dialogs.h"] # 兜底:扫描没认出来的输入 +``` + +mcpp 编译并**跟踪**它:`.rc` 自身 + 从 `.rc` 文本扫出的 `#include "…"` 与资源语句里的引号文件名(`ICON "x.ico"`、`24 MANIFEST "app.manifest"`、`RCDATA`、`BITMAP` …)都进 implicit inputs。llvm-rc/windres 都不给 depfile(A1 实测),所以这里只能扫,因此配一条 `extra-inputs` 显式兜底——与 `[modules].scan_overrides` 同一个「发现不够时改成声明」的先例。 + +**合成与自写的交互,一张 3 行表**(不做文本解析猜测,规则显式): + +| `version-info` | `files` | 行为 | +|---|---|---| +| 未写 | 空 | 合成(L0) | +| 未写 | 非空 | **不合成**——用户接管了资源 ID 空间 | +| `= true` | 非空 | 合成;ID 冲突由用户负责(文档写明 RT_VERSION 只能有一个 id 1) | + +`version-info = false` 恒不合成。 + +**L0 → L1 没有断崖**:合成的 `.rc` 落在 `target/` 下一个稳定路径,用户 `cp` 进自己的树、填进 `files`,得到**字节相同**的资源。这条要作为判据机器化验证(A-判据 6),否则「每层是下一层的默认值」只是文档里的一句话。 + +### L2:通用原语 —— `action` 的第四个 role + +给 `BuildAction::Role` 补上 `Object`:**产出接到 link 边的输入上**。角色表变成完整的四格: + +``` +Source → 编译输入 +Check → 无(stamp) +Object → 链接输入 ← 新增 +Artifact → 链接输出之后 +``` + +这不是为资源加的钩子(钩子数是乘法成本,`build-mcpp-extensibility-architecture` 那批已经定过调)——L0/L1 走的是引擎自己的 rc 边,不经过 action。`Object` 是**补齐角色表**,顺带让 ldflags 塞路径这类 workaround 全类退休。 + +**已决:本批做。** 不做的话「不被跟踪的 ldflags 路径」仍是唯一出路,等于留着这个 issue 的成因。 + +`Object` 的产物接到**哪个** link unit:`Artifact` 是靠 `${mcpp.target_file:NAME}` 出现在 inputs 里反推的,`Object` 反不了(它的产物在 link 之前,没有 link 产物可引用)。定为给 `BuildAction` 加一个可选 `targets = [...]`——空 = 声明它的那个包的所有 link unit,与 L0/L1 的默认作用域一致。`targets` 里出现未知目标名时报错并列出本次构建的目标,复用 `${mcpp.target_file:}` 已有的那条诊断(`prepare.cppm:4941`)。 + +## A4. rc 工具解析:dialect × payload 的实测矩阵 + +**绝不走 PATH。** `~/.mcpp/registry/subos/default/bin/x86_64-w64-mingw32-windres` 现在是个指向 `bin/xlings` 的符号链接——xlings 的裸名 shim 机制,最后注册者抢名(已经弄坏过一次真实工具链:`gcc --version` 全对而产物是 ARM)。工具必须**相对编译器二进制所在 payload 解析**,先例是 `clang::find_scan_deps(tc)`(`prepare.cppm:4958`)。 + +| dialect / 目标 | rc 工具 | 产物 | 链接器接受 | +|---|---|---|---| +| MSVC(`rc.exe` / `link.exe`) | `rc.exe`(SDK;include 走 `envOverrides` 里的 `INCLUDE`) | `.res` | link.exe 直接吃 `.res` | +| clang + lld-link(Windows 原生) | `llvm-rc`(payload 自带) | `.res` | lld-link 直接吃 `.res` | +| GNU / mingw(`x86_64-w64-mingw32-g++` + ld) | `windres -O coff` | COFF `.o` | ld 吃对象;**ld(bfd) 不吃 `.res`** | + +**实测到的 payload 缺口**:Linux 上的 `xim-x-llvm/22.1.8/bin` 只有 `llvm-rc` 和 `llvm-readobj`,**没有 `llvm-windres`、没有 `cvtres`**(Windows 的 20.1.7 payload 才有 `llvm-windres.exe`)。所以: + +- Linux → Windows 走 **mingw 交叉链**(mcpp 现在的交叉路径):`x86_64-w64-mingw32-windres` 在 mingw payload 里,没问题。 +- Linux → Windows 走 **clang + lld** :只有 `llvm-rc` 能产 `.res`。**`ld.lld` 的 mingw 模式是否接受 `.res` 需实测**(VERIFY-A3)。接受则这条路直接通;不接受则必须从 mingw payload 借 `windres`,此时要给出指名的错误。 + +解析时机与失败策略照抄 nasm(`prepare.cppm:4964-5031`):**惰性**——只有 plan 里真的有资源单元才解析;**硬失败**——找不到工具就报错并指名工具与 dialect,绝不静默跳过(掉一个 `.o` 会以「图标没了」或几层之外的 undefined 现形)。 + +## A5. 增量、隔离、与那条 lint + +- **构建图**:新增 `plan.resourceUnits`(源 `.rc`、输出、implicit inputs),backend 出一条 `rc_object` 规则,输出 append 到对应 `LinkUnit::objects`。**不进 `CompileUnit`**——那会把 `.rc` 拖进模块图、topo 序、`compile_commands.json`(clangd 会当场噎住)和缓存键。 +- **与依赖缓存零交互**:资源只由「拥有 link unit 的那个包」声明,root 包永不进全局缓存。依赖声明的资源**不传播**(一个依赖的 VERSIONINFO 和消费者的会打架);非 root 包声明了资源但自己不产 PE link unit → 警告并忽略。 +- **非 PE 目标**:整节**不适用**(不是降级)。不需要 `cfg(windows)`——而且**不能**走那条通道:`[target.'cfg(…)'.build]` 的 `kKnownConditionalBuildKeys`(`toml.cppm:1174`)是封闭表,且「条件通道只载 BuildInputs,一条轴一套作用域规则」是明文设计立场,图标/版本元数据不是 build input,塞进去会被 schemaWarnings 拒掉。用户无条件写一次即可。 +- **可见性补偿**(§D-3 的义务):PE 目标下 `[resources]` 生效时打一行 status(`Embedding app.ico + version info`);非 PE 目标不打也不警告。节名里既然看不出平台,就得让「它这次生效了」出现在输出里——顺带让判据 A2 的增量行为可观测。 + > 这里**偏离 issue 的第 3 条要求**。issue 要「资源文件缺失时跳过,不应导致构建/打包失败」。拆成两件事:跨平台不炸由「非 PE 目标不适用」解决;而**声明了却不存在的文件是硬错误**——与 `main = "…"` 必须匹配恰好一个文件同一条规则。静默跳过一个声明过的输入,等于让「图标为什么没了」变成不可归因的问题,正是这个 issue 的起点。 +- **lint(补偿 A1 第 3 条)**:自写 `.rc` 里出现 `VERSIONINFO`,其名字 token 既不是字面 `1`、文件里又没有 `#include` 也没有 `#define VS_VERSION_INFO` 时,给一条 warning 并附确切修法。这是启发式(用户可能在别处定义了宏),所以只 warn;它把本 issue 里那个**完全无法自行诊断**的失效变成一行可读的话。 + +## A6. 明确不做 + +- **`subsystem` / `entry`**:报告者也在用 `-Wl,-subsystem:windows -Wl,-entry:mainCRTStartup`。那是链接模式,不是资源,属于另一条轴(`[target.].linkage` 那一层)。本设计不动它,现有 ldflags 写法继续有效——但它在**同一个「Windows GUI 应用」的用户故事**里,值得单独 triage。 +- **macOS bundle / Info.plist / `.desktop`**:`[resources]` 的语义定为「编译进产物的元数据与资产」,将来这些扩展**同一节**而不是新开 `[macos]`;本批只实现 PE 消费者。 +- **对话框、字符串表等资源类型**:mcpp 不解析 `.rc` 语义,L1 原样交给 rc 工具。 + +## A7. 判据(可机器验证) + +1. **A1**:Windows 目标 + `[resources] icon=…` 产出的 exe,其 RT_VERSION 资源名是**序号 1**(按字节验,不是「存在一个版本资源」),且 `FileVersionInfo::GetVersionInfo` 的 ProductName / FileVersion 非空。 +2. **A2**:动 `.ico`、动 `.rc`、动 `[package].description` 三者任一 → ninja 重链;都不动 → no-op。(原症状是 `no work to do`。) +3. **A3**:同一份 manifest 在 Linux/macOS 构建**逐字节不变**,零 warning。 +4. **A4**:`.res`/`.o` 不出现在 `compile_commands.json`,不出现在模块图。 +5. **A5**:rc 工具不可用 → 错误消息含工具名与 dialect;e2e 用「把 payload 里的 windres 改名」构造。 +6. **A6(无断崖)**:把合成的 `.rc` 复制进源码树并填入 `files`,产出资源**字节相同**。 +7. **A7**:`.rc` 里 `#include` 的头改动 → 重编(验证扫描确实进了 implicit inputs)。 + +## A8. 待实测(VERIFY) + +- **VERIFY-A1**:mingw payload 的 `windres -O coff` 产物在 mcpp 的交叉链里能被 ld 正常收进 `.rsrc`(本机当前没装 mingw 交叉 payload)。 +- **VERIFY-A2**:`rc.exe` 在 mcpp 的 MSVC 路径下能通过 `envOverrides.INCLUDE` 找到 `windows.h`。 +- **VERIFY-A3**:`ld.lld` 的 mingw 模式是否接受 `.res` 输入(决定 clang+lld 交叉是否需要借 windres)。 +- **VERIFY-A4**:CI 覆盖。Windows job 必须真的跑 `FileVersionInfo` 读取——「exe 里有 VERSIONINFO 资源」这个断言在本 issue 的失效场景下**恒为真**,是假绿(与 E0006 那次「断言出现某错误 = 假绿」同一形状)。 + +--- + +# Part B — #363:版本身份 + +## B1. 关键发现:解析器已经读到了字面键,然后把它扔了 + +`resolver.cppm:127-154`: + +```cpp +auto rawVersions = mcpp::manifest::list_xpkg_versions(*luaContent, platform); // 字面键,全在手里 +std::vector parsed; +for (auto& s : rawVersions) { + auto v = vr::parse_version(s); + if (!v) continue; // ← 不可排序的键静默消失,且此后下标不再对齐 + parsed.push_back(*v); +} +auto idx = vr::choose(*req, parsed); +return parsed[*idx].str(); // ← 从 4 个整数重新渲染出一个地址 +``` + +`list_xpkg_versions` 返回的是描述符里的**字面 key**(`xpkg.cppm:880`,就地取引号内文本)。也就是说:**正确答案一直在 `rawVersions[*idx]` 里,代码却从 `parsed` 里倒推了一个。** `str()` 只能渲染 3–4 段数字,于是任何非纯数字键在范围路径上**永远寻址不到**——不是「不支持」,是「先丢掉再重造」。 + +这解释了 issue 的全部三条现象,包括为什么精确路径完全正常:精确路径(`is_version_constraint` 为 false)根本不经过这段代码,字面串直接进 wire 地址。 + +`continue` 那行还额外制造了**下标错位**:`parsed` 与 `rawVersions` 长度不同,而 `choose` 返回的是 `parsed` 的下标。今天没暴露只因为返回值不再用 `rawVersions`。修的时候必须成对保存,不能只把 `parsed[*idx].str()` 换成 `rawVersions[*idx]`。 + +**血缘**:`str()` 头上那段注释明写「load-bearing:pm/resolver 把它当解析结果返回,流向 lock 与 wire 地址——必须复现索引的字面版本键」。这个约束**已经被写下来了,但只用日期版本的 `.0` 尾段验证过**;本 issue 是同一约束在预发布/非 semver 上的第二次违约。真正的修法不是再加一条注释,而是**让「重新渲染出地址」这件事在类型上不可表达**。 + +## B2. 三个症状,两个根因 + +issue 列了三条,我把归因拆开: + +| 现象 | 根因 | +|---|---| +| ① 精确键(含 `b10069`、`1.92.8-docking`)全通 | — 无需改动,与设计一致 | +| ② `^b10069` 不可能 | 版本模型只有「4 个整数」一种形态,没有「不可排序但可寻址」这一类 | +| ③ `^1.92.8` 看不见 `1.92.8-docking` 的区别 | 截断丢语义(parse)+ 从数值重造地址(B1) | +| ④ lock 记的是约束本身 | **另一个根因**:解析结果没有回流;见 B4 | + +②③ 是同一个根因的两面:**版本 = 字面身份 + 可选的序**,而现在只建模了序。④ 与它们无关,是消费者读错了输入。 + +## B3. 设计:字面是身份,数值只是序 + +```cpp +// 一个索引版本键。literal 是身份(wire 地址、store 目录、lock); +// order 是可选的排序能力:nullopt = 不可排序,只能精确匹配。 +struct VersionKey { + std::string literal; + std::optional order; +}; +struct Order { // 现在的 Version + 预发布 + int major, minor, patch, revision; + std::vector prerelease; // 空 = 正式版;正式版 > 任何预发布 + // build metadata('+' 之后)不参与序,但**在 literal 里**,所以不影响身份 +}; +``` + +- `resolve_semver` 返回 `keys[*idx].literal`。**`str()` 不再出现在任何寻址路径上**,降级为纯展示,并在注释里改掉「load-bearing」的说法——把约束从「需要遵守」变成「无处可犯」。 +- **不可排序的键(`b10069`)**成为一等公民的一类:`order == nullopt`,只参与 `=` / 裸字面(`is_version_constraint` 为 false 的那条路),范围与 `*` 跳过它。这把 issue 建议 2 的「明确而不是靠恰好走了另一条代码路径」落成类型。 +- **预发布序**按 semver:`1.92.8-docking < 1.92.8`;预发布标识符点分段比较,数字段按数值、数字段 < 字母数字段。加上 mcpp 的第四段:序为 `major, minor, patch, revision, 然后 prerelease`(`1.2.3-rc < 1.2.3 < 1.2.3.1`,自洽)。 +- **范围对预发布的可见性**采用 npm/Cargo 已被接受的规则:**带预发布的候选只有在约束里存在一个「同 (major,minor,patch,revision) 且自身带预发布」的比较子时才可入选。** 一条规则同时修两件事:`^1.92.8` 不再看得见 `1.92.8-docking`(issue 的核心诉求),且 `^1.2.3` 的上界不再漏进 `2.0.0-alpha`(今天 `v < upper` 会漏,是同族的既存缺陷)。 +- **序相等但字面不同**(只差 build metadata,如 `1.0.0+a` 与 `1.0.0+b`):见下面 B3.5——这个情形在真实索引里存在,但它的真身不是「平局」。**列入待决策 4。** + +## B3.5 关键发现:真实索引里的「平局」是 alias,而 mcpp 不认识 alias + +扫了本机 `xim-pkgindex` 全部 161 个描述符,非纯数字版本键只有两个包,而**两个都命中本 issue 的形状**: + +```lua +-- pkgs/j/jdk-temurin.lua,三个平台表都一样 +["latest"] = { ref = "25.0.4+7" }, +["25.0.4"] = { ref = "25.0.4+7" }, +["25.0.4+7"] = { url = …, sha256 = … }, -- 唯一的真条目 + +-- pkgs/c/cc-connect.lua +["1.3.2"], ["1.3.3-beta.1"] +``` + +`jdk-temurin` 同时是「build metadata 平局」和「不可排序键被静默跳过」的实例,而**两者都是假的**:`25.0.4` 与 `latest` 都是 `{ ref = ... }` 指针,指向同一个 `25.0.4+7`。 + +**`list_xpkg_versions` 把每个引号键都当成一个版本,包括别名**(它就地收集平台表里的所有 key,`xpkg.cppm:955`;全仓无一处读 `ref`)。于是范围解析的候选集里,三个「版本」有两个是指向第三个的指针。 + +这一条把待决策 4 的问法改掉了:**先排除纯 alias 条目,再谈平局政策**——否则是在给一个不存在的问题定规则,而且定出来的规则会在一个完全正常的索引写法上开火(对 `jdk-temurin` 报「两个版本序相等,无法取舍」,而两个答案是同一个 payload)。 + +`cc-connect` 则是另一件事的实例:今天 `^1.3` 会解析到 `1.3.3-beta.1`(截断成 `1.3.3` → 最高),改后按 npm 预发布可见性规则解析到 `1.3.2`。**方向是对的**(范围不该悄悄给你 beta),但这是一处真实的、生态可见的行为变化,必须写进发布说明。 + +**诊断**(issue 建议 2 的另一半)。今天两个方向都不好: + +- 约束侧 `^b10069` → `invalid version constraint '^b10069': version: not a number ('b10069')`。技术上不错,但没说**为什么不可能**。 +- 键侧才是真隐患:索引里只有 `b10069`,用户写 `*` 或 `^0.1` → 静默 `continue` → **`no valid versions in index`**。这句话在**索引里明明有一个完全可用的版本**时把责任推给了索引。 + +新形状:范围没匹配到任何东西、而存在不可排序的键时,错误必须指名它们并给出可执行修法——「`ggml-org:llamacpp` 的版本键(`b10069`, `b10121`)不是可排序的版本号,范围约束无法表达它们;请精确 pin:`llamacpp = "b10069"`」。 + +## B4. lock:数据结构已经存在,两个消费者读错了输入 + +`prepare.cppm:1964-1977` 里已经有: + +```cpp +struct ResolvedRecord { + std::string version; // 解析后的具体版本 + std::string constraint; // 作者写的原始约束 + std::string requestedBy; + std::string source; // "version" | "path" | "git" + ... +}; +std::map resolved; // 覆盖整张图,含传递依赖 +``` + +**lock 需要的每一个字段都在里面,覆盖范围也对。** 但两个消费者都没读它: + +| 消费者 | 现在读什么 | 后果 | +|---|---|---| +| lock 写入 `prepare.cppm:5363` | `m->dependencies`(root 直接依赖,`spec.version` = 未解析的约束) | lock 记 `^1.92.8`;**传递依赖完全不在 lock 里** | +| `Compiling` 行 `execute.cppm:323-327` | 同上 | 打印 `compat.imgui v^1.92.8` | + +`resolveSemver` 改的是 worklist 里的**副本**(`auto& spec = item.spec;`)。旁边 `prepare.cppm:3277-3283` 已经有一处专门往 `m->dependencies` 回写 `namespace_ / shortName / candidates` 的代码——**`version` 只是没被列进去**。这是本仓库反复遇到的「同一决策两处推导」的镜像:结果算出来了,消费者读的是输入。 + +**正解不是补第三处回写**(那会再造一个推导点),而是让 lock 写入和 `Compiling` 行**都读 `resolved`**。顺带解决三件事:lock 记真实版本、lock 覆盖传递依赖、控制台输出与 lock 同源不可能不一致。 + +**但还有一层更深的问题**:今天 lock 只在一处被读回,且只读 git(`prepare.cppm:1089-1092` → `parse_git_source`)。**index 依赖的 lock 条目从不参与解析。** 也就是说 lock 对 index 依赖是装饰性的——只写真实版本会让它**看起来**权威而实际仍不 pin,这比现在更容易误导(假 `Cached` 的教训)。 + +两条路: + +- **B4-a(本批)**:写真相 + 覆盖传递依赖 + 文件头注释明说「本文件记录本次解析结果,尚不 pin 后续构建」。诚实、零行为变更、零生态风险。 +- **B4-b(单独一批)**:让 lock 权威,并配 `mcpp update`。这是正确终局,但它**改变解析行为**(有 lock 时不再自动吃索引新版本),需要独立的迁移与生态验证。 + +**已决:本批只做 B4-a,lock 不权威。** B4-b 单开一批。 + +这条决定带一个**不可省略的义务**:既然 lock 写的是真实版本却仍不参与解析,文件头必须自己说出来。写死在 `serialize()` 的头注释里,而不是只写进 docs——`# Auto-generated by mcpp. Do not edit by hand.` 后面加一行「记录本次解析结果;尚不锁定后续构建(索引出现更高版本时会重新解析)」。理由与假 `Cached` 那次相同:一个看起来权威而实际不 pin 的产物,比一个明显不完整的产物更危险。判据 B6 之外加一条 **B8:lock 头部含该声明**(e2e grep,改回权威时这条断言会红,正好提醒同批删掉它)。 + +`LockedPackage` 的 `requested` 字段(作者写的约束)**推迟到 B4-b**:它唯一的用途是「manifest 改了要重解析」,而本批不读 lock,现在加就是加一个没有消费者的字段。**待决策 2 撤销。** + +## B5. 兼容性 + +爆炸半径小得反常,值得点明:**`mcpp.version_req` 全仓只有两个消费者**——`pm/resolver.cppm` 与 `pm/index_contract.cppm`(`prepare.cppm` 只 import 未用)。 + +| 面 | 影响 | 处置 | +|---|---|---| +| 四段日期版本 `2026.8.6.3` | 无预发布、无 build metadata → 新解析器逐位同旧 | 判据 B4:对现有全部 `min_mcpp` 值做全表比对 | +| E0006 索引下限(`index_contract.cppm:125`) | 只用 `>=` 比 `Order` | 不变;malformed → `nullopt` 的「永不砖」性质保留 | +| 已发布索引里的既有键 | `^1.92.8` 今天**恰好**选中非 docking,改后**确定性地**选中它 —— 结果不变、原因变对 | 但若某包**只有**预发布键而消费者写了范围,改后会从「能解析」变成「报错」 | +| `mcpp add` / `index_refresh` | 都走 `is_version_constraint`(纯语法谓词),裸 `1.92.8-docking` 仍判为精确 | 不变 | + +最后一行是唯一的真实生态风险,必须**在发版前用真实 mcpp-index 全表扫**:找出所有「版本键全是预发布」的包。(这条与「发版前必须本地跑真实 mcpp-index workspace」是同一条纪律;CI 全绿证明不了。) + +**全表扫已做一次**(本机 `xim-pkgindex`,161 个描述符):非纯数字键只有 2 个包,无「只有预发布键」的包。两个包的具体影响见 B3.5——`cc-connect` 的 `^1.3` 会从 `1.3.3-beta.1` 改到 `1.3.2`(方向正确,需写进发布说明),`jdk-temurin` 取决于 alias 是否排除。**这次扫的是本机快照,发版前要对当时的索引重扫一遍。** + +**VERIFY-B1**:把范围解析结果从 alias 键(`25.0.4`)改成真条目(`25.0.4+7`)之后,store verdir 与 wire 地址行为是否变化——xlings 是否在建 verdir 之前解析 `ref`。若会产生第二个 verdir 或改变寻址,则退回「平局时择字面较大者 + 一次 warning」,把 alias 建模单开一批。 + +## B6. 判据(可机器验证) + +1. **B1**:对索引里每个键 `k`,`resolve_semver("=" + k)` 必须原样返回 `k`。这条把「不再重新渲染地址」变成可穷举的属性测试。 +2. **B2**:`1.92.8` 与 `1.92.8-docking` 在任何约束下不可互相替代;`^1.92.8` → `1.92.8`;只有 `^1.92.8-a` 这类自带预发布的约束才可能选到 `1.92.8-docking`。 +3. **B3**:`^1.2.3` 不匹配 `2.0.0-alpha`(今天会匹配)。 +4. **B4**:`b10069` 只有精确可达;范围约束下的错误消息**指名该键并给出精确 pin 的修法**,且不含 "no valid versions in index"。 +5. **B5**:`2026.8.6.3` 与所有现存 `min_mcpp` 的比较结果逐位不变。 +6. **B6**:`mcpp.lock` 的 `version` 恒为可寻址字面版本;连续两次 `mcpp build` 之间 lock 字节不变(幂等)。 +7. **B7**:lock 含传递依赖;`Compiling` 行的版本与 lock 一致(同一数据源,构造性保证)。 + +--- + +# C. 实施顺序(各切片可独立发布) + +| 步 | 内容 | 依赖 | 风险 | +|---|---|---|---| +| B-1 | `VersionKey`(字面+可选序)、预发布序、npm 预发布可见性规则、`resolve_semver` 返回字面键、不可排序键的指名错误 | — | 低(两个消费者) | +| B-2 | lock 与 `Compiling` 行改读 `resolved`;覆盖传递依赖;文件头诚实声明 | — | 低(无行为变更) | +| A-1 | `[resources]` 解析、`plan.resourceUnits`、`rc_object` 规则、rc 工具惰性解析(payload 相对,硬失败)、L1 自写 `.rc` + 输入扫描 | — | 中(新构建边) | +| A-2 | L0 合成 VERSIONINFO + icon;`FILEVERSION` 取 4 元组 | B-1 更佳(否则自己解析一次版本号) | 低 | +| A-3 | VERSIONINFO 名 lint | A-1 | 低 | +| A-4 | `Role::Object` + `targets`(已决:本批做) | A-1 | 中(角色表扩容) | +| — | **B-3(不在本批)** lock 权威 + `mcpp update` + `requested` 字段 | B-2 | 中(改解析行为) | + +先 B 后 A:B-1 给 A-2 提供版本 4 元组,且 B 的爆炸半径最小、能先独立验证。 + +--- + +# D. 决策记录 + +## 已决 + +1. **lock 本批不权威。** 只写真相(B-2),并在 `serialize()` 头部声明它尚不 pin(判据 B8)。`mcpp update` 与 `requested` 字段一起放到 B-3,不在本批。 +2. **`Role::Object` 本批做**,用可选 `targets = [...]`(空 = 声明包的全部 link unit)指定接哪条 link 边。 +3. **节名 `[resources]`,平台不进拼写。** 平台作用域是「只有 PE 目标消费」,但不写进节名:图标作为**概念**不是 Windows 专有的,只有文件格式与嵌入机制是;将来 macOS `.icns` / Linux `.desktop` 扩**同一节**(必要时 `icon` 升级成 per-platform 映射),保持一条轴,而不是按 OS 切成 `[windows]` / `[macos]` 三份并让 `icon` 这类共性键重复三次。 + 这条决定带一个**义务**:manifest 里既然看不出「只对 Windows 生效」,就得让它在别处可见。PE 目标下 `[resources]` 生效时打一行 status(`Embedding app.ico + version info`)——非 PE 目标**不打也不警告**(是「不适用」不是「降级」,每次 Linux 构建警告一次是噪音)。这条同时让判据 A2 的增量行为可观测。 + **per-target 延后**(落点是 `kKnownTargetKeys`,老 mcpp 只 warn 不 fail)。 + +## 待 review + +4. **平局政策——但问法要先改(见 B3.5)。** 拆成三小条: + - **4a(推荐做)** 识别纯 alias 条目(`{ ref = "…" }` 无 payload),**从范围候选里排除**;精确寻址不变(`= latest` / `= 25.0.4` 照旧走别名)。不做这条,平局政策就是在给一个不存在的问题定规则,而且会在 `jdk-temurin` 这种完全正常的索引写法上开火。 + - **4b(推荐)** 排除之后剩下的真平局(两个都是真条目、只差 build metadata)→ **硬错,指名两个键并给出「精确 pin 其一」的修法**。理由:此时两个键是两个不同 tarball、不同 sha256,mcpp 无从知道要哪个;「择字面较大者」是把猜测包装成确定性,正是本 issue 抱怨的「取舍由一个看不见差别的序决定」。#349「数据不得让程序失效」的反向担忧在这里是有界的——它只影响那一个包的范围解析(不是索引级不可用),错误里就写着修法,且 4a 之后它在惯用写法上不可能触发。 + - **4c** 若 VERIFY-B1 发现排除 alias 会改变 store verdir 或寻址行为,则 4a/4b 一起退回「择字面较大者 + 一次 warning」,alias 建模单开一批。 +5. **`.rc` 输入跟踪:扫描 + 指名缺口 + `extra-inputs` 兜底(推荐),还是纯显式声明?** + 一份 `.rc` 的输入只有两类,分别处置: + - **`#include`**:只跟踪**引号形式**(`"dialogs.h"`,项目自有);**尖括号形式不跟踪**(`` 属工具链,随 payload 不变,且已被工具链 fingerprint 计入构建目录)。这条规则一下去掉了「无法枚举 windows.h 传递闭包」这个担忧。 + - **资源语句里的数据文件**(`1 ICON "app.ico"`、`24 MANIFEST "app.manifest"`、`RCDATA` / `BITMAP` / `CURSOR` / `FONT` / `TYPELIB`):扫引号字符串。 + 唯一的真缺口是**宏间接**(`1 ICON APP_ICON`,文件名藏在 `#define` 后面)。处置:扫描遇到「操作数不是字符串字面量」或「引号 include 在搜索路径上找不到」时**警告并指名**,指向 `extra-inputs`——「限定了覆盖范围就要说出漏了什么」。 + 为什么不选纯显式:漏掉一项时 mcpp **无从警告**(它不知道自己漏了什么),而漏掉的后果是 exe 里留着旧资源直到有人碰一下 `.rc`——与本 issue 报的失效同一类。 + 为什么不选「自己预处理 + 拿编译器 depfile」(最精确):只有 `llvm-rc` 有干净的 `/no-preprocess` 入口,`windres` 与 `rc.exe` 都没有「吃已预处理输入」的干净模式 ⇒ 工具矩阵从 3×1 变 3×2。精确度换来的是每条 dialect 两套管线。 + 附注:**L0 完全不需要扫描**(合成的 `.rc` 里 icon 路径是声明来的,精确),所以这套启发式只作用于自写 `.rc`——而自写 `.rc` 的人正是能写 `extra-inputs` 的人。 +6. **偏离 issue #365 第 3 条**(「资源文件缺失时跳过,不应导致构建/打包失败」)。issue 这一条把两个担忧捆在一起,拆开之后一个消失、一个反转: + - **担忧一「Windows-only 声明会弄坏我的 Linux/macOS 构建」** → 已由「非 PE 目标整节不适用」解决。不是跳过,是没有消费者:零 warning、逐字节不变。issue 想要的跨平台安全**已经拿到了**。 + - **担忧二「资产文件不在(新克隆没拉 LFS / CI 没有这个文件 / 设计还没交图)不该让构建失败」** → 这里偏离:**声明了却不存在 = 硬错误**。四条理由: + ① 一致性——mcpp 里每个「声明过的输入」都是这个规则:`main = "…"` 必须匹配恰好一个文件、`scan_overrides` 的每个 glob 必须匹配 ≥1 个文件、nasm 缺失是硬错误且注释明写「never a silent skip,掉一个 `.o` 会在几层之外以 undefined 现形」。 + ② 它会把本 issue 的失效模式**制度化**——报告者的全部抱怨就是「静默不生效」;把「文件缺失 → 跳过」写成设计,等于让静默不生效成为规定行为。 + ③ 最疼的是发布构建:一个路径拼错或资产没提交,产出的是**没有图标、没有版本信息的正式二进制,而且什么都没说**。发现时间点通常是有人下载之后,归因成本极高。 + ④ 「图标是可选资产」说的是**功能**可选,不是**声明**可选。不要图标已经可表达:把那一行删掉。 + - 若仍要字面满足 issue:`icon = { path = "…", optional = true }`,走既有 `diag::degraded` 通道(报告一次、`--strict` 拒绝)——**显式选择降级**而不是默认静默。推荐先不做(YAGNI):不声明、或用 `action{role=object}` 生成,两条退路已经在。 + +--- + +# E. 实施回执(2026.8.7.1) + +## E.1 写代码才撞出来的三条 + +1. **⚠️ 非 ASCII 元数据会让 rc 编译器直接拒绝整个脚本。** 设计里完全没有这一格。用合成器的输出跑真 `llvm-rc` 时立刻炸: + + ``` + llvm-rc: Error in VERSIONINFO statement (ID 1): + Non-ASCII 8-bit codepoint (—) can't be interpreted in the current codepage + ``` + + 触发它的是**mcpp 自己生成的**默认 copyright 里的一个 em dash。而 `[package]` 的 description/authors 是用户文本,中文项目必然命中 ⇒ **必须给 rc 工具传 UTF-8 codepage**(`/C 65001` / `--codepage=65001`),否则一个中文描述的项目根本构建不了。同时把生成文本本身收敛成纯 ASCII(单测断言),这样生成物不依赖那个 flag 是否传对——两道,因为它们防的是两件事。 + + 这条是「只写文档不写示例会漏掉」的又一次:设计里推演到了「VERSIONINFO 的名字必须是序号 1」,推不到「编码」。 + +2. **五段版本键在真实索引里存在**,而不是假想。`jdk-corretto` 发 `25.0.4.7.1`(`....`),四段截断让 `25.0.4.7.1` 与将来的 `25.0.4.7.2` 比较相等。数值段因此改成任意长度而不是加到第五段——**固定长度这件事本身**是缺陷,加一段只是把下一次推迟。 + +3. **`.res` 的资源头偏移是 40 不是 32。** 设计文档里我写了「first eight bytes of the resource header」,实际是:32 字节全零头 → dataSize+headerSize(8 字节) → type+name。判据写成字节断言时必须核对偏移,否则断言恒假/恒真。已在代码注释与 e2e 里更正。 + +4. **⚠️ `.rc` 是「mtime 扫描看不见、但改了它图就该长得不一样」的第三个实例**——由 Windows CI 抓到,本机与设计都没预见。 + + 一次只改 `res/app.rc` 的构建报 `Finished dev in 0.15s`:工程级 fast path 短路了整个 prepare。`sources_newer_than` 只扫 `src/**/*` 的 C++ 扩展名,`.rc` 既不在 `src/` 下、也不是那些扩展名。 + + **这不只是「警告没打」**:`.rc` 的 implicit input 集合来自**扫描它**,而扫描在 prepare 里。用户往脚本里加一行 `#include "ids.h"`,那个头文件永远不会被跟踪——ninja 用的是上一次算出来的输入表。 + + 同一个函数里已经为这件事写过两遍理由(`build.mcpp` 一次、#359 的 glob 输入一次),措辞都是「一种 mtime 扫描看不见的输入,而它改变了图应该长什么样」。**这是同一形状的第三次**,而前两次的注释没能让第三次被预见到——因为它们是**举例**,不是**判据**。真正该问的是:「这次新增的输入,改了它以后 prepare 的产出会不会变?会,就必须进这个扫描。」 + + 处置:只扫 `files`。`icon` 与 `extra-inputs` 已经是 ninja 的 implicit input,改它们不改变图的形状,为一次改图标强制走完整 prepare 买不到任何东西。 + +5. **我自己写了两条假绿断言。** 图标断言原本搜 4 字节(`00ff00ff`),在 MB 级二进制里撞上是常事——Linux 上通过很可能就是撞上了,而它同时**掩盖了第 4 条**(Windows 上 b3 本该在这里暴露 fast path 问题)。改成 4 像素 icon 的 16 字节高熵标记,并加一条「旧标记必须消失」。 + 教训与「断言『出现 E0006』是假绿」同族:**断言必须能失败**,而「短模式在大文件里出现」这种断言几乎不可能失败。 + +## E.2 与设计的偏差 + +- **`peUnits` 为空时整节跳过并警告**(设计没提)。一个只产静态库的包声明了 `[resources]`,原设计会去解析 rc 工具并硬失败——为一次没有消费者的编译要求一个工具。 +- **同名 `.rc` 冲突显式报错**。两个不同目录下的 `app.rc` 会写同一个产物,原本会变成 ninja 的 "multiple rules generate",报在离原因很远的地方。 +- **`[resources]` 的路径解析用 `lexically_normal` 而非 `weakly_canonical`**。canonical 会解析符号链接,把与用户所写不同的路径烙进生成的脚本(与 #344 让缓存锚点保持字面判定同一条理由)。 +- **版本号无数值形式时报 degraded**。`synthesize_rc` 是纯函数发不出诊断,所以由 prepare 侧再解析一次并报告:FILEVERSION 会是 `0,0,0,0` 而字符串字段保留真版本,不说的话属性对话框与 `[package].version` 不一致且无从解释。 + +## E.3 验证到什么程度 + +| 判据 | 状态 | +|---|---| +| A1 序号 1 / 元数据可读 | ✅ 本机实测(`.res` 字节 + `llvm-readobj` 双重断言),e2e 197/198 | +| A2 增量 | ✅ 改 icon、改 `[package].description` 都到达 exe;无变更为 no-op | +| A3 非 PE 不适用 | ✅ 198 里用同一份 manifest 构建 host,零警告、无 res 单元 | +| A4 不进 compile_commands / 模块图 | ✅ 结构性(独立 `ResourceUnit`,不入 `CompileUnit`) | +| A5 工具缺失硬失败 | ⚠️ 代码路径有,e2e 未构造(要改 payload 目录) | +| A6 L0→L1 字节相同 | ✅ 198 实测 `cmp` 通过 | +| A7 `.rc` 的 include 被跟踪 | ✅ 单测覆盖扫描;e2e 覆盖 icon/metadata 两类输入 | +| B1 返回值恒为字面键 | ✅ e2e 196 用四种真实索引形状 | +| B2 docking 不可互相替代 | ✅ 单测 + 196 | +| B3 `^1.2.3` 不匹配 `2.0.0-alpha` | ✅ 单测 | +| B4 不可排序键指名错误 | ✅ 单测 + 196 | +| B5 E0006 逐位不变 | ✅ 单测(含 `2026.8.3.3` 对现役下限) | +| B6/B7 lock 真实 + 幂等 + 传递 | ✅ 196;169 也加了断言 | +| B8 lock 头部声明 | ✅ 196 断言(改成权威时会红) | + +**A5 是唯一没有 e2e 的判据**:构造它要临时改动 payload 目录,而 payload 是共享的只读树,e2e 写它会污染其他测试(#293 的教训:一个测试的失败源于上一次运行)。 + +## E.4 本批 CI 覆盖的真实缺口 + +`cross-build-test.yml` 的 `mingw-cross-wine` 是**唯一**有 MinGW 交叉链的 job,而它**按文件名逐个调用 e2e**(不跑 `run_all.sh`)⇒ 新增的 198 必须显式加进 workflow,否则 GNU/windres 这一半在 CI 里一次都不会跑。已加。Windows 原生那一半走 `ci-windows-e2e` 的整套 `run_all.sh`,`# requires: windows` 自动生效。 + +--- + +# F. 实施后 review 轮(同一 PR,合入前) + +对已实施版本做了一次架构 / 稳定性 / 一致性 / 多平台的深度 review,并对每条可疑点做真机复现而不是代码推理。**四条实测确认的缺陷 + 三条口径不一致**,全部在本 PR 内修掉。下面记的是**判据**,不是清单。 + +## F.1 「同一决策两处推导」又出现了一次,而且两处已经不一致 + +`resources.cppm:202` 用 `find_first_of(";:")` 切工具链 PATH 覆盖,`prepare.cppm:5323` 切 INCLUDE 用 `find(';')`——**同一份数据、同一个 PR、两种切法**。 + +`;:` 那一种是错的:msvc 分支只在 Windows 上走,PATH 覆盖由 `msvc.cppm:492` 用 `;` 拼接真实 Windows 路径,而**盘符冒号就在下标 1**。`C:\Windows Kits\…` 被切成 `C` 加一段当前盘相对路径 —— 同盘侥幸命中、跨盘必然找不到。放大它的是:这条 PATH 遍历是 msvc 下的**主路径**,不是兜底(`rc.exe` 属于 Windows SDK,从不在 `cl.exe` 旁边,`probe_dir(compilerDir)` 只答得出 llvm-rc)。 + +⇒ 收敛成一个 `rsrc::split_env_list`,两个调用点共用。**判据:一个 PR 内出现两处相同解析,先合并再讨论哪种对。** + +## F.2 「模型少一层」在本 PR 自己身上复发:`Role::Object` 的默认目标集漏了测试二进制 + +真机复现(Linux/clang22,库代码调用 object 提供的符号): + +``` +mcpp build → rc=0 mcpp run → rc=0 +mcpp test → ld.lld: error: undefined symbol: blob_value +``` + +`image` 只认 `Binary | SharedLibrary`,而 `TestBinary` 是第三种 kind。**这正是 #365 那条毛病的同构体**:表格少一格,后果是用户在图外找路。 + +关键在于**它没有可用的逃生口**:显式 `.target("t_core")` 确实能修(实测通过),但测试链接单元是从 `tests/*.cpp` **发现**出来的,名字不在 `mcpp.toml` 里;而且只写测试目标时 `mcpp build` 直接硬失败(实测:`names unknown target(s): t_core / targets in this build: [objrole]`)。⇒ **目标名字空间是 mode-dependent 的**,这一层设计没有承认。 + +⇒ 默认集合加入 `TestBinary`。`[resources]` 的 `peUnits` 反向决策(排除测试二进制)保持不变,并在两边互相写明理由:**图标属于「要发布的东西」,符号属于「要链接的东西」**。 + +## F.3 F.2 与「未知 target 报错」是耦合的,不能分开修 + +未知名检查挂在 `if (!attached && ...)` 上,于是**只要有一个名字命中,其余拼错的就永不上报**。实测 `.target("objrole").target("no_such_target_TYPO")` → `rc=0`,日志零提及 —— 与 `types.cppm` 和 docs 明写的契约相反。 + +⚠️ **但先修这条会当场废掉 F.2 的唯一逃生口**:`.target("app").target("t_core")` 之所以能在 `mcpp build` 下不报错,靠的正是这个漏洞。⇒ **正确顺序是先给 F.2 一个不依赖具体测试名的表达方式(默认集合),再把检查收紧到 per-target。** 单独修任何一条都会把用户推进另一个坑。 + +## F.4 lock 从「读输入」改成「读输出」,顺带把命令也读进去了 + +真机复现(同一工程交替执行): + +``` +mcpp build → 1 条 mcpp test → 2 条(多了 dev-dep) mcpp build → 又变回 1 条 +``` + +`resolved` 在 `includeDevDeps` 时含 dev-deps,而旧代码遍历 `m->dependencies`(dev-deps 是另一张表)**结构性地写不进去**。196 只断言了 build→build 幂等,抓不到。 + +⇒ `WorkItem`/`ResolvedRecord` 增加 `devOnly` 并沿依赖边传播(**多消费者取 AND**:非 dev 的消费者一出现就清掉),lock 跳过 `devOnly`。**判据:lock 是 manifest 的函数,不是命令的函数。** 196 补 build→test→build 三段 `cmp`,并断言 dev-dep 确实被解析过(否则断言是空转)。 + +## F.5 「不适用」不该覆盖到校验 + +`[resources]` 的「声明了必须存在」整块包在 `if (trip.is_pe())` 里。实测:Linux 上 `icon = "assets/DOES_NOT_EXIST.ico"` + `files = ["res/nope.rc"]` → `rc=0`,日志里 `resource` 出现 **0 次**。 + +⇒ Linux/macOS 的开发机与 CI **结构性地**抓不到打错的资源路径,只有 Windows job 会红 —— 这正是这条硬错误要消灭的「太晚才知道」。**路径存不存在是关于工作树的事实,不是关于目标的事实。** 校验提到 `is_pe()` 之前,编译留在后面。新增 `199_resources_validation.sh`(无 `requires`,每个 shard 都跑)守这条。 + +## F.6 两条 warning 应当是 degraded + +`diag.cppm` 的规矩:`degraded` 带 impact 且 `--strict` 会失败;`warning` 是作者笔误 / schema 漂移。按这条口径,`resources/versioninfo`(你的 VERSIONINFO Windows 读不到)和 `resources/no-image`(声明了却没有任何东西嵌)都是「你要了 X,得到的是零」,应当是 degraded —— 尤其前者正是本 feature 要消灭的静默失效,`--strict` 抓不到它说不过去。`role="object"` 无消费者是同一形状,新增 `action/no-target`,与 `resources/no-image` 同口径。 + +## F.7 优雅性 + +- `} else {` 之后约 150 行**完全不缩进**、靠底部 `} // peUnits non-empty` 收尾 —— 在一个对形式如此讲究的仓里是「这块该抽出去」的直接信号。改成带早返回的 `plan_resources` lambda。 +- `ResourceUnit::packageName` 写入后全仓无人读取,删除。 +- `LinkUnit::objects` 注释写「relative to plan.outputDir」而 `Role::Object` 往里塞绝对路径 —— 有理由(ninja 按字面串识别节点),但字段契约本身要改口径,否则下一个人会「顺手规范化」。 +- `resolve_semver` 的字面短路对**任意**约束生效而不只 `=` 前缀。今天安全,靠的是三个调用方都先过 `is_version_constraint` 门——但**那个判据没有写进这个函数**。裸 `1.2.3` 在本语法里是 caret,哪天直达此处,caret 会静默变成精确 pin。收紧成必须 `=` 前缀。 +- `try_merge_semver` 在两个约束相同时不再拼成 `=X,=X`(防御性:prepare 只在两个**已解析版本**不同时才走到这里,而相同约束解析结果相同 —— 但它挡的失败是静默且彻底的)。 + +## F.8 测试自身的假绿 + +- `_windows_resources_body.sh` 里 `.res` 的序号字节判据包在 `case *.res` 中,而 windres 产 `.o` ⇒ **GNU 那一半的核心断言可能一次都不跑**;后备的 readobj 检查也是「grep 不到就跳过」。补一条方言无关的判据(字符串名资源会把 UTF-16 名字写进资源目录,序号名不会),并在 A3-lint 段加**正向反证**:故意构造坏脚本,断言它确实产生了那个 UTF-16 名字 —— 否则「名字不存在」可能因任何理由通过。 +- 改写 `res/app.rc` 那一步前缺 `sleep 1`,而该段**唯一**的重跑 prepare 触发源就是 `.rc` 的 mtime。其余每处 mtime 敏感改动都有。 + +## F.9 方法论 + +**每一条都先真机复现再下判断,没有一条是纯代码推理。** 复现同时充当「断言能失败」的证明:五条新断言各自对应一段用**修复前**二进制跑出来的、看得见的错误输出。这比事后再造一个反向用例更便宜也更可信。 + +## F.10 CI 抓到的第三条:判据选了一个那条 job 拿不到的工具 + +改完 F.8 的判据后本机 198 通过,CI 的 `mingw-cross` job 却红在: + +``` +FAIL: llvm-readobj not found under /home/runner/.mcpp +``` + +那条 job 只装 mingw 工具链,**sandbox 里根本没有 LLVM 载荷**——而本机什么都装着,所以本机永远看不见。 + +**硬失败本身是对的**(静默跳过正是 #365 的出厂方式),错的是**判据依赖了一个不属于这条路径的工具**。正解:`windres -J coff -O rc` 把编译好的资源**反读成 rc 源码**,名字直接可见,而且它在 GNU 这一支**必然存在**——它就是产出这个文件的工具。实测 `.o` 与链接后的 `.exe` 都能读: + +``` +1 VERSIONINFO ← Windows 找得到 +"VS_VERSION_INFO" VERSIONINFO ← 找不到 +``` + +rc 工具从 `build.ninja` 的 `rc =` 绑定里取而不走 PATH——mcpp 本来就是 payload 相对解析的(裸 `windres` 在 PATH 上是 xlings shim),问 PATH 会用**另一个**工具去检查这个产物。msvc 那一支保留 llvm-readobj:能走到那一支的配置里,LLVM 载荷就是默认工具链本身。 + +**判据:一条断言要用的工具,必须来自它所断言的那条路径本身。** 「本机装得全」是最容易把环境假设藏起来的地方——三次改判据里,前两次都是本机绿、别处红。 + +## F.11 第四条:`*windres*` 这个名字什么都不告诉你 + msvc 那一支其实零 CI 覆盖 + +F.10 改成 windres 反读后,mingw job 绿了,**Windows job 红了**: + +``` +FAIL: windres could not read back .../res/resapp.mcpp.o +``` + +两件事同时暴露: + +1. **`llvm-windres` 不能反读。** 它只是 `llvm-rc` 的单向包装,没有 `-J coff`。而它和 GNU binutils 的 `windres` **都匹配 `*windres*`** ⇒ 按工具名分派是错的。修法:**两条路线依次 TRY**(反读 → llvm-readobj),只有两条都不可用才硬失败。 +2. **native Windows 上默认工具链走的是 GNU 支,不是 msvc 支。** 证据在失败信息里:target 目录是 `x86_64-windows-msvc`,而产物是 **`.o` 不是 `.res`** ⇒ `dialect_for(tc).id != "msvc"`,`find_rc_tool` 取了 GNU 分支并选中 `llvm-windres.exe`,lld-link 照单全收。 + +**⚠️ 推论:`.res` 那一支(rc.exe / llvm-rc + 偏移-40 字节判据)在两个 CI job 里都不执行。** mingw job 是 GNU,Windows job 也是 GNU。也就是说: + +- e2e 里 `case "$RES_ART" in *.res)` 是**死分支**——这也正是为什么「方言无关的那条判据」不是锦上添花而是唯一的那条。 +- **F.1 修的那个 PATH 切分缺陷(msvc 下找不到 `rc.exe`)没有任何 CI 覆盖**,只有单测 `EnvListSplitsOnSemicolonsOnly` 守着字符串切分本身。要真正覆盖它需要一个 `# requires: msvc` 的资源 e2e(`msvc@system` 工具链),本批不做,**明确记为缺口**。 + +**判据:「本机/某个 job 绿」不等于「这条分支跑过」。判断一条 fork 是否被覆盖,要看产物形态(`.res` vs `.o`),不能看 job 名字里有没有 windows。** diff --git a/.github/workflows/cross-build-test.yml b/.github/workflows/cross-build-test.yml index a964f734..b9f0d053 100644 --- a/.github/workflows/cross-build-test.yml +++ b/.github/workflows/cross-build-test.yml @@ -296,6 +296,16 @@ jobs: export MCPP_VENDORED_XLINGS="$XLINGS_BIN" bash tests/e2e/102_mingw_cross_wine.sh + # mcpp#365. This is the only job with a MinGW cross toolchain, so it is + # the only place the GNU half of resource compilation (windres -O coff, + # because GNU ld cannot consume a .res) can run at all — the Linux e2e + # shards skip it for want of the `mingw-cross` capability. Named + # explicitly for the same reason 102 is. + - name: "e2e: windows resources (windres / COFF)" + run: | + export MCPP_VENDORED_XLINGS="$XLINGS_BIN" + bash tests/e2e/198_windows_resources_cross.sh + # ── windows → linux ─────────────────────────────────────────────────────── # The mirror of mingw-cross-wine. Two jobs because a Windows runner cannot # execute the ELF it produces; the artefact is handed to a Linux job and diff --git a/CHANGELOG.md b/CHANGELOG.md index d4b112d7..6c262d43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,66 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [2026.8.7.1] — 2026-08-07 + +两处「模型比生态少一层」。设计与实测证据见 `.agents/docs/2026-08-07-windows-resources-and-version-identity-design.md`。 + +### 新增 + +- **`[resources]`:exe 图标与版本信息现在是 `mcpp.toml` 里的一行(#365)。** + + ```toml + [resources] + icon = "assets/app.ico" + ``` + + `FILEVERSION` / `ProductName` / `FileDescription` / `CompanyName` / `LegalCopyright` 全部从 `[package]` 取默认值,资源脚本由 mcpp 生成。自写 `.rc` 写 `files = [...]`,mcpp 编译并**跟踪**它。 + + **只有 PE 目标消费这一节**;在 Linux/macOS 上它「不适用」——不是降级、不是带警告地跳过:没有消费者,构建逐字节不变,也不说话。所以**不需要(也不能)加 `cfg(windows)` 谓词**。节名不叫 `[windows]` 是因为「图标」作为概念不是 Windows 专有的,将来 macOS `.icns` 扩同一节而不是把这条轴按 OS 切三份。 + + **声明了却不存在的文件是硬错误**,这是对 issue 第 3 条请求的**有意偏离**:mcpp 里每个「声明过的输入」都是这个规则(`main = "…"` 必须匹配恰好一个文件、nasm 缺失是硬错误),而「缺失就跳过」会把这个 feature 要消灭的失效模式写成规定行为——一个没有图标、没有版本信息、且什么都没说的正式二进制。不要图标已经可表达:把那一行删掉。 + + **这条校验在每个目标上都跑**,「不适用」只停在*编译*那一步。路径存不存在是关于工作树的事实、不是关于目标的事实;按 PE 设门会让 Linux/macOS 的构建与 CI 完全看不见 `icon` 里的拼写错误,只有 Windows job 变红——正是这条硬错误要消灭的「太晚才知道」。 + +- **`role = "object"`:build.mcpp 的 action 现在能把产物接到链接输入上。** 角色表原本三格接在「编译输入 / 无 / 链接输出」上,缺的正是「链接输入」——一个构建图显然有的接线点。后果不是理论上的:预编译对象只能塞进 `[build].ldflags`,而那是链接命令里的一串字符、不是图里的文件,于是改了图标得到 `ninja: no work to do`。 + + 可选 `.target("name")` 指定接哪条边;**省略是推荐写法**,它接到本次构建产出的每个镜像——可执行、动态库**与测试二进制**。测试二进制在默认集合里不是顺手加的:它链接的是同一份库代码,排除掉会让 `mcpp build` 通过而 `mcpp test` 在这个 action 本来要提供的那个符号上报 `undefined symbol`;而改成显式点名也不成立——测试链接单元是从 `tests/*.cpp` **发现**出来的,名字不在 `mcpp.toml` 里,写了它的 build.mcpp 在普通 `mcpp build` 下会直接构建失败。**每一个**匹配不到链接单元的名字都是错误,包括写在一个匹配得上的名字旁边的那个(拼错的真实形状)。本次构建里没有任何镜像可接时报 degradation——这条边只能经由链接被达成,没有链接就意味着命令一次都不跑。 + +### 修复 + +- **解析出的版本现在是索引里的字面键,不是重新渲染的数字(#363)。** `resolve_semver` 一直把索引的字面版本键读到手里,然后 `return parsed[i].str()` —— 从解析出的数字重造一个地址。渲染器复现不了的东西就变成了不存在的地址: + + | 上游键 | 旧行为 | + |---|---| + | `1.92.8-docking` | 截断成 `1.92.8`,与非 docking 那个**塌成同一个可比较版本**(两个不同 tarball) | + | `25.0.4.7.1`(jdk-corretto,五段) | 截断成 `25.0.4.7` —— **索引里没有这个键** | + | `pre-v0.0.5`(khistory,唯一的发布) | 静默跳过,然后报「no valid versions in index」——把责任推给一个发布得好好的包 | + + 现在字面键与序一起传递,`version_req` 只负责**排序**。连带修的: + + - **预发布按 SemVer 排序**,且范围按 npm/Cargo 规则**看不见预发布**,除非约束自己在同一数值元组上带了预发布。`^1.92.8` 因此确定性地选 `1.92.8`,不再在两个 tarball 之间由一个看不见差别的序做取舍。同一条规则顺带修掉 `^1.2.3` 会漏进 `2.0.0-alpha`。 + - **数值段不再截断在第四段**。真实索引里 `jdk-corretto` 发五段键;截断让 `25.0.4.7.1` 与 `25.0.4.7.2` 比较相等。 + - **别名条目(`{ ref = "…" }`)不再是范围候选**。`jdk-temurin` 的 `["25.0.4"] = { ref = "25.0.4+7" }` 曾与它自己的目标构成一次「平局」。精确寻址不变。 + - **不可排序的键**(`b10069`、`latest`、`pre-v0.0.5`)成为一等公民的一类:只参与精确匹配,范围约束下报**指名的**错误并给出可粘贴的 pin 行。 + - **真平局硬错**。只差 build metadata 的两个键(`1.0.0+a` / `1.0.0+b`)是两个 tarball、两个 sha256,序说不出该要哪个;旧行为按描述符里的行序取第一个,意味着索引的一次排版调整会改变构建出来的东西。 + +- **mcpp.lock 记录解析结果,并覆盖传递依赖。** 它记的一直是**约束本身**(`version = "^1.92.8"`),而一个记录范围的 lock 不锁定任何东西;`Compiling compat.imgui v^1.92.8` 这行也一样。两者读的都是 `m->dependencies`(未解析的输入、且只有直接依赖),而解析结果 `ResolvedRecord` **早就覆盖整张图**——修法是把两个消费者都指过去,而不是补第三处回写。 + + lock 头部现在自己声明**它还不 pin 后续构建**(index 依赖仍每次从约束重新解析)。一个记着真实版本却不生效的文件,比一个明显记着范围的文件更容易被误当权威。 + + **dev-dependencies 不进 lock。** 解析结果覆盖整张图,而 `mcpp test` 解析 dev-deps、`mcpp build` 不解析——照单全收会让一个进 VCS 的文件取决于「上一条命令是什么」,build/test/build 写出三个不同的文件。判据:**lock 是 manifest 的函数,不是命令的函数**。头部注释也写了这一条。 + +- **`[resources]` 在 MSVC 下找不到 `rc.exe`。** 工具链 PATH 覆盖按 `find_first_of(";:")` 切分,而 Windows 路径的**盘符冒号**就在下标 1——`C:\Windows Kits\…` 被切成 `C` 加一段「当前盘相对路径」,同盘时侥幸命中、跨盘必然找不到。而这条 PATH 遍历正是 msvc 下的**主路径**(`rc.exe` 属于 Windows SDK,从不在 `cl.exe` 旁边)。两个调用点各自推导同一条规则、且已经彼此不一致,现在收敛成一个 `split_env_list`。 + +- **`.rc` 现在能穿过工程级 fast path。** `sources_newer_than` 只扫 `src/**/*` 的 C++ 扩展名,一次只改资源脚本的构建因此报 `Finished dev in 0.15s` —— 而 `.rc` 的 implicit input 集合来自扫描它、扫描发生在 prepare,所以往脚本里新加一行 `#include "ids.h"` 那个头文件永远不会被跟踪。与 `build.mcpp`、glob 输入(#359)是同一类:**mtime 扫描看不见,但改了它图就该长得不一样**。只扫 `files`——`icon` 与 `extra-inputs` 已经是 ninja 的 implicit input。 + +### 其他 + +- 版本号 2026.8.6.3 → **2026.8.7.1**。 +- **行为变化(生态可见)**:`cc-connect` 这类「稳定版 + 预发布版」并存的包,`^1.3` 从 `1.3.3-beta.1` 改为解析到 `1.3.2`;`jdk-corretto`/`jdk-temurin` 这类带别名的包,范围解析改为选中真条目(`25.0.4.7.1` 而非别名 `25.0.4`),store 目录名随之变化。 +- **诊断口径**:`resources/versioninfo`(版本资源 Windows 读不到)与 `resources/no-image`(声明了却没有任何镜像可嵌)由 warning 改为 **degradation** —— 它们的 impact 正是本批要消灭的静默失效,`--strict` 必须看得见;`role = "object"` 无消费者新增 `action/no-target`,同口径。 +- README 的包索引链接由仓库地址改为 。 + ## [2026.8.5.4] — 2026-08-06 命令长度这一族缺陷的**第七次**,这次不再补洞。架构分析见 `.agents/docs/2026-08-06-command-length-architecture.md`。 diff --git a/README.md b/README.md index 96c0ab06..98b8e55b 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ | [Documentation](docs/) · [Getting Started](docs/00-getting-started.md) · [mcpp.toml Guide](docs/05-mcpp-toml.md) · [Examples](docs/01-examples.md) · [Toolchains](docs/03-toolchains.md) | |:---:| -| [Package index mcpp-index](https://github.com/mcpp-community/mcpp-index) · [Module libraries mcpplibs](https://github.com/mcpplibs) · [Community Forum](https://forum.d2learn.org/category/20) · [Issues](https://github.com/mcpp-community/mcpp/issues) · [Releases](https://github.com/mcpp-community/mcpp/releases) | +| [Package index mcpp-index](https://mcpplibs.github.io/mcpp-index/) · [Module libraries mcpplibs](https://github.com/mcpplibs) · [Community Forum](https://forum.d2learn.org/category/20) · [Issues](https://github.com/mcpp-community/mcpp/issues) · [Releases](https://github.com/mcpp-community/mcpp/releases) | | [![ci-linux](https://github.com/mcpp-community/mcpp/actions/workflows/ci-linux.yml/badge.svg?branch=main)](https://github.com/mcpp-community/mcpp/actions/workflows/ci-linux.yml) [![ci-macos](https://github.com/mcpp-community/mcpp/actions/workflows/ci-macos.yml/badge.svg?branch=main)](https://github.com/mcpp-community/mcpp/actions/workflows/ci-macos.yml) [![ci-windows](https://github.com/mcpp-community/mcpp/actions/workflows/ci-windows.yml/badge.svg?branch=main)](https://github.com/mcpp-community/mcpp/actions/workflows/ci-windows.yml) |

@@ -360,7 +360,7 @@ Real projects built with mcpp — `import`-able C++23 modules and the toolchain | [imgui-m](https://github.com/mcpplibs/imgui-m) | Dear ImGui as a C++23 module package | | [cmdline](https://github.com/mcpplibs/cmdline) | Command-line parsing library / framework (mcpp uses it) | -More modular libraries → [mcpplibs](https://github.com/mcpplibs) · package index → [mcpp-index](https://github.com/mcpp-community/mcpp-index) +More modular libraries → [mcpplibs](https://github.com/mcpplibs) · package index → [mcpp-index](https://mcpplibs.github.io/mcpp-index/) ## Contributing @@ -386,7 +386,7 @@ then follow the guide to help me submit a contribution to mcpp. ## Community & Ecosystem - [Community Forum](https://forum.d2learn.org/category/20) — chat group (QQ: 1067245099) -- [mcpp-index](https://github.com/mcpp-community/mcpp-index) — default package index +- [mcpp-index](https://mcpplibs.github.io/mcpp-index/) — default package index - [mcpplibs](https://github.com/mcpplibs) — collection of modular C++ libraries ### Acknowledgements diff --git a/README.zh-CN.md b/README.zh-CN.md index 721f6e50..538a7b46 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -11,7 +11,7 @@ | [文档](docs/zh/) · [快速开始](docs/zh/00-getting-started.md) · [mcpp.toml 指南](docs/zh/05-mcpp-toml.md) · [示例项目](docs/zh/01-examples.md) · [工具链管理](docs/zh/03-toolchains.md) | |:---:| -| [包索引 mcpp-index](https://github.com/mcpp-community/mcpp-index) · [模块化库 mcpplibs](https://github.com/mcpplibs) · [社区论坛](https://forum.d2learn.org/category/20) · [Issues](https://github.com/mcpp-community/mcpp/issues) · [Releases](https://github.com/mcpp-community/mcpp/releases) | +| [包索引 mcpp-index](https://mcpplibs.github.io/mcpp-index/) · [模块化库 mcpplibs](https://github.com/mcpplibs) · [社区论坛](https://forum.d2learn.org/category/20) · [Issues](https://github.com/mcpp-community/mcpp/issues) · [Releases](https://github.com/mcpp-community/mcpp/releases) | | [![ci-linux](https://github.com/mcpp-community/mcpp/actions/workflows/ci-linux.yml/badge.svg?branch=main)](https://github.com/mcpp-community/mcpp/actions/workflows/ci-linux.yml) [![ci-macos](https://github.com/mcpp-community/mcpp/actions/workflows/ci-macos.yml/badge.svg?branch=main)](https://github.com/mcpp-community/mcpp/actions/workflows/ci-macos.yml) [![ci-windows](https://github.com/mcpp-community/mcpp/actions/workflows/ci-windows.yml/badge.svg?branch=main)](https://github.com/mcpp-community/mcpp/actions/workflows/ci-windows.yml) |

@@ -352,7 +352,7 @@ mcpp 的身份模型是两条正交轴:**工具链** = `family@version`(family | [imgui-m](https://github.com/mcpplibs/imgui-m) | Dear ImGui 的 C++23 模块封装包 | | [cmdline](https://github.com/mcpplibs/cmdline) | 命令行解析库/框架(mcpp 自身在用) | -更多模块化库 → [mcpplibs](https://github.com/mcpplibs) · 包索引 → [mcpp-index](https://github.com/mcpp-community/mcpp-index) +更多模块化库 → [mcpplibs](https://github.com/mcpplibs) · 包索引 → [mcpp-index](https://mcpplibs.github.io/mcpp-index/) ## 参与贡献 @@ -378,7 +378,7 @@ mcpp 的身份模型是两条正交轴:**工具链** = `family@version`(family ## 社区 & 生态 - [社区论坛](https://forum.d2learn.org/category/20) — 交流群 (Q: 1067245099) -- [mcpp-index](https://github.com/mcpp-community/mcpp-index) — 默认包索引 +- [mcpp-index](https://mcpplibs.github.io/mcpp-index/) — 默认包索引 - [mcpplibs](https://github.com/mcpplibs) — 模块化 C++ 库集合 ### 致谢 diff --git a/docs/05-mcpp-toml.md b/docs/05-mcpp-toml.md index 0695538b..d72c7900 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -1151,6 +1151,105 @@ the other conditional dependency tables (§2.7.1). The **feature itself is registered on every platform** — only what it pulls in is conditional — so requesting it where no predicate matches is not an unknown-feature error. +### 2.15 `[resources]` — Metadata and Assets Embedded in the Artifact (2026.8.7.1+) + +An exe icon and the version metadata Windows shows in a file's Properties dialog +are a path in `mcpp.toml`, nothing more: + +```toml +[resources] +icon = "assets/app.ico" +``` + +That is the whole common case. `FILEVERSION`, `ProductName`, `FileDescription`, +`CompanyName` and `LegalCopyright` all default from `[package]`, and mcpp +generates the resource script for you. + +| Key | Type | Meaning | +|---|---|---| +| `icon` | path | Embedded as the application icon (resource ordinal 1) | +| `files` | list of paths | Your own `.rc` scripts, compiled and **tracked** as build inputs | +| `extra-inputs` | list of paths | Inputs the `.rc` scanner could not see (see below) | +| `version-info` | bool | `false` opts out of the generated version resource | +| `[resources.version-info]` | table | `company`, `product`, `description`, `copyright`, `original-filename`, `internal-name` | + +**Only PE targets *compile* this.** On Linux and macOS the section is +*inapplicable*: no resource units, no diagnostics, byte-identical build. You do +**not** need (and cannot use) a `cfg(windows)` predicate — write it once, +unconditionally. + +**A declared file that does not exist fails the build — on every target.** A +resource is a build input like a source file; mcpp will not quietly ship a +binary without it. Validation is deliberately *not* PE-gated: whether a path +exists is a fact about your working tree, not about the target, so a typo in +`icon = "assets/app.ico"` is caught by your Linux or macOS build (and by their +CI jobs) instead of waiting for the Windows one. If you do not want an icon, +delete the line. + +**Version fields.** `FILEVERSION` takes the four numeric segments of +`[package].version`, each of which must fit in 16 bits; the string fields keep +the version verbatim, so a form the numeric fields cannot hold (`1.0.0-rc1`) +still shows up in the Properties dialog. + +#### Writing your own `.rc` + +```toml +[resources] +files = ["res/app.rc"] +``` + +With `files` set, mcpp stops generating a version resource — you own the +resource ID space. Set `version-info = true` alongside it if you want both (and +mind the collision: there can be only one `RT_VERSION` at ordinal 1). + +To start from the generated script instead of a blank file, copy it out of the +build directory (`target///res/.mcpp.rc`) and list it in +`files`. The result is byte-identical, so moving from generated to hand-written +never changes what ships. + +> **`VS_VERSION_INFO` needs ``.** In a hand-written script, +> `VS_VERSION_INFO VERSIONINFO` without `#include ` files the version +> resource under a *string* name instead of ordinal 1. Every tool still reports +> `Type: VERSIONINFO`, but `GetFileVersionInfo` looks up the ordinal, so +> PowerShell's `FileVersionInfo` shows every field as empty. Either include +> `` or write `1 VERSIONINFO`. mcpp warns when it sees this shape; +> the script it generates uses the literal `1`. + +#### Tracked inputs + +mcpp reads the `.rc` for quoted `#include`s and for the files named by resource +statements (`ICON`, `RCDATA`, `MANIFEST`, …), and makes them build inputs, so +editing your icon relinks. Angled includes (``) are the toolchain's +and are covered by the toolchain fingerprint instead. + +A file name reached through a macro (`1 ICON APP_ICON`) is invisible to that +scan. mcpp names what it could not resolve and asks you to declare it: + +```toml +extra-inputs = ["assets/app.ico"] +``` + +#### Anything else: `role = "object"` + +For inputs that are not resource scripts — a blob embedded with `objcopy`, a +generated `.def`, a pre-built object — a build program can declare a build-graph +node whose outputs join the link: + +```cpp +mcpp::action o; +o.id = "blob"; o.role = "object"; +o.arg("./mkblob.sh").arg("blob.bin").arg("${mcpp.out_dir}/blob.o") + .input("blob.bin") + .output("${mcpp.out_dir}/blob.o") + .target("myapp") // omit: every image, test binaries included + .submit(); +``` + +See [07 — build.mcpp](07-build-mcpp.md). Naming such a file in +`[build].ldflags` also "works", but ldflags is a flat string in the link +command: nothing tracks it, and editing the file gives you `ninja: no work to +do`. + ## Appendix A. Schema Ownership Principle (admission criteria for new fields) > **Closed syntax, open vocabulary**: whoever owns the parsing semantics defines the keys; whoever owns the domain knowledge defines the values. diff --git a/docs/07-build-mcpp.md b/docs/07-build-mcpp.md index b2a66b7c..da068594 100644 --- a/docs/07-build-mcpp.md +++ b/docs/07-build-mcpp.md @@ -170,7 +170,7 @@ int main() { const std::string out = std::string(mcpp::out_dir()) + "/foo.pb.cc"; mcpp::action a; a.id = "protoc:foo"; - a.role = "source"; // "source" | "check" | "artifact" + a.role = "source"; // "source" | "check" | "object" | "artifact" a.arg(mcpp::dep_bin("protobuf", "protoc")) .arg("--cpp_out=...").arg("proto/foo.proto") .input("proto/foo.proto") @@ -179,19 +179,45 @@ int main() { } ``` -Three roles, one primitive — `role` only decides where the edge's outputs +Four roles, one primitive — `role` only decides where the edge's outputs attach: | `role` | Outputs | Ordering | Typical | |---|---|---|---| | `source` | join the compile set | the compile edge consumes them | protoc, a transpiler | | `check` | a stamp file | runs **alongside** compilation (set `blocking = true` to gate it) | clang-tidy, a format or ABI check | +| `object` | join the **link** set | the link edge consumes them | a resource compiler, `objcopy` embedding a blob, a generated `.def`, a pre-built `.o` | | `artifact` | a new file | its *inputs* are link outputs, so it runs after the link | codesign, packaging, size budgets | No phase machinery is involved: ninja's own file dependencies do the sequencing, which is also why an `artifact` action cannot double-apply itself the way a naive "post-build hook" would. +`object` (2026.8.7.1+) takes an optional `.target("name")`, repeatable. It needs +a name at all because, unlike `artifact`, it runs *before* the link and so has +no `${mcpp.target_file:…}` to infer one from; every name that matches no link +unit is an error, including one written next to a name that does match. + +**Prefer omitting it.** With no target, the outputs attach to every image the +declaring package produces in this build — binary, shared library **and test +binary**. Test binaries are in that set because they link the same library code: +leave them out and `mcpp build` succeeds while `mcpp test` dies with `undefined +symbol` on the very symbol the action exists to provide. Naming them instead is +not an option — test link units are discovered from `tests/*.cpp`, so their +names are not in `mcpp.toml`, and a `build.mcpp` that spells one stops building +under plain `mcpp build`, where that unit does not exist. + +If nothing in the build can receive the outputs (an archive-only package), mcpp +reports a degradation: the edge is reachable only through a link, so with no +link the command would never run and the build would say nothing. + +> Naming a pre-built object in `[build].ldflags` also reaches the linker, and +> should not be used for anything the build produces: ldflags is a flat string +> in the link command, not a file in the graph, so nothing tracks it and editing +> it gives you `ninja: no work to do`. For Windows resources specifically, use +> [`[resources]`](05-mcpp-toml.md) — +> `object` is the escape hatch for everything else. + **You must name the output files.** mcpp fixes the source set, the fingerprint and the module graph during prepare, so an output whose *name* is unknown cannot be built. Content may arrive later; names may not. A malformed action is diff --git a/docs/zh/05-mcpp-toml.md b/docs/zh/05-mcpp-toml.md index 4fb251e7..971cf450 100644 --- a/docs/zh/05-mcpp-toml.md +++ b/docs/zh/05-mcpp-toml.md @@ -884,6 +884,93 @@ mcpp 写的包仍然能加载,这个读取器认识的部分照常生效。在 —— 只有它拉进来的东西是条件性的 —— 因此在没有任何谓词匹配的平台上请求它,不是 「未知 feature」错误。 +### 2.15 `[resources]` —— 编译进产物的元数据与资产(2026.8.7.1+) + +exe 图标,以及 Windows 在文件「属性」里显示的版本信息,就是 `mcpp.toml` 里的一个路径: + +```toml +[resources] +icon = "assets/app.ico" +``` + +常见场景到此为止。`FILEVERSION`、`ProductName`、`FileDescription`、`CompanyName`、 +`LegalCopyright` 全部从 `[package]` 取默认值,资源脚本由 mcpp 生成。 + +| 键 | 类型 | 含义 | +|---|---|---| +| `icon` | 路径 | 作为应用图标嵌入(资源序号 1) | +| `files` | 路径列表 | 你自己的 `.rc` 脚本,mcpp 编译并**跟踪**为构建输入 | +| `extra-inputs` | 路径列表 | `.rc` 扫描器看不见的输入(见下) | +| `version-info` | 布尔 | `false` 表示不要生成版本资源 | +| `[resources.version-info]` | 表 | `company`、`product`、`description`、`copyright`、`original-filename`、`internal-name` | + +**只有 PE 目标会*编译*这一节。** 在 Linux/macOS 上它**不适用**:不产资源单元、 +不出诊断、构建逐字节不变。你**不需要**(也不能)加 `cfg(windows)` 谓词 —— +无条件写一次即可。 + +**声明了却不存在的文件会让构建失败 —— 在每个目标上都是。** 资源和源码一样是 +构建输入;mcpp 不会悄悄产出一个缺了它的二进制。校验刻意**不**按 PE 设门: +路径存不存在是关于你工作树的事实,不是关于目标的事实,所以 `icon = "assets/app.ico"` +里的拼写错误由你的 Linux/macOS 构建(以及它们的 CI job)当场抓住,而不是等 +Windows 那条。不想要图标,把那一行删掉。 + +**版本字段。** `FILEVERSION` 取 `[package].version` 的四段数值,每段必须放得进 +16 位;字符串字段保留版本原文,所以数值字段装不下的形态(`1.0.0-rc1`)在属性 +对话框里照样看得到。 + +#### 自写 `.rc` + +```toml +[resources] +files = ["res/app.rc"] +``` + +写了 `files`,mcpp 就不再生成版本资源 —— 资源 ID 空间归你。想两者都要就同时写 +`version-info = true`(注意冲突:序号 1 的 `RT_VERSION` 只能有一个)。 + +想从生成的脚本起步而不是从空文件起步:把它从构建目录里拷出来 +(`target///res/.mcpp.rc`)填进 `files`。结果**字节相同**, +所以从「生成」走到「手写」不会改变产物。 + +> **`VS_VERSION_INFO` 需要 ``。** 手写脚本里如果写 +> `VS_VERSION_INFO VERSIONINFO` 而没有 `#include `,版本资源会被存成 +> **字符串名**而不是序号 1。所有工具依然报告 `Type: VERSIONINFO`,但 +> `GetFileVersionInfo` 查的是序号,于是 PowerShell 的 `FileVersionInfo` 里每个字段 +> 都是空的。要么 include ``,要么直接写 `1 VERSIONINFO`。mcpp 见到这个 +> 形状会警告;它自己生成的脚本用的是字面 `1`。 + +#### 被跟踪的输入 + +mcpp 会读 `.rc`,把引号形式的 `#include` 和资源语句(`ICON`、`RCDATA`、 +`MANIFEST` …)点名的文件都变成构建输入,所以改图标会重链。尖括号形式 +(``)属于工具链,由工具链 fingerprint 覆盖。 + +通过宏间接引用的文件名(`1 ICON APP_ICON`)扫描看不见。mcpp 会**指名**它没能解析 +的东西,并要求你显式声明: + +```toml +extra-inputs = ["assets/app.ico"] +``` + +#### 其余一切:`role = "object"` + +不是资源脚本的输入 —— `objcopy` 嵌入的 blob、生成的 `.def`、预编译对象 —— +可以由构建程序声明一个产出接到链接的图节点: + +```cpp +mcpp::action o; +o.id = "blob"; o.role = "object"; +o.arg("./mkblob.sh").arg("blob.bin").arg("${mcpp.out_dir}/blob.o") + .input("blob.bin") + .output("${mcpp.out_dir}/blob.o") + .target("myapp") // 省略:接到每个镜像,含测试二进制 + .submit(); +``` + +见 [07 — build.mcpp](07-build-mcpp.md)。把这类文件写进 `[build].ldflags` 也「能用」, +但 ldflags 是链接命令里的一串字符:没有任何东西跟踪它,改了它得到的是 +`ninja: no work to do`。 + ## 附录 A. Schema 所有权原则(新字段准入标准) > **语法封闭,词汇开放**:谁拥有解析语义谁定义键;谁拥有领域知识谁定义值。 diff --git a/docs/zh/07-build-mcpp.md b/docs/zh/07-build-mcpp.md index 2a403159..3977fb84 100644 --- a/docs/zh/07-build-mcpp.md +++ b/docs/zh/07-build-mcpp.md @@ -156,7 +156,7 @@ int main() { const std::string out = std::string(mcpp::out_dir()) + "/foo.pb.cc"; mcpp::action a; a.id = "protoc:foo"; - a.role = "source"; // "source" | "check" | "artifact" + a.role = "source"; // "source" | "check" | "object" | "artifact" a.arg(mcpp::dep_bin("protobuf", "protoc")) .arg("--cpp_out=...").arg("proto/foo.proto") .input("proto/foo.proto") @@ -165,17 +165,38 @@ int main() { } ``` -三种 role,一个原语 —— `role` 只决定这条边的输出接到哪: +四种 role,一个原语 —— `role` 只决定这条边的输出接到哪: | `role` | 输出 | 顺序 | 典型 | |---|---|---|---| | `source` | 进编译集 | 编译边消费它们 | protoc、转译器 | | `check` | 一个 stamp 文件 | **与编译并行**(`blocking = true` 才前置) | clang-tidy、格式/ABI 检查 | +| `object` | 进**链接**集 | 链接边消费它们 | 资源编译器、`objcopy` 嵌 blob、生成的 `.def`、预编译 `.o` | | `artifact` | 一个新文件 | 它的**输入**是链接产物,所以在链接之后跑 | 签名、打包、size budget | 全程不涉及任何 phase 机制:顺序由 ninja 自己的文件依赖决定 —— 这也是为什么 `artifact` 不会像朴素的「post 构建钩子」那样把自己重复施加一遍。 +`object`(2026.8.7.1+)可选 `.target("name")`,可重复。它之所以需要名字:与 +`artifact` 不同,它跑在链接**之前**,没有 `${mcpp.target_file:…}` 可以反推。 +**每一个**匹配不到链接单元的名字都是错误 —— 包括写在一个匹配得上的名字旁边的那个, +那正是拼错真实的样子。 + +**优先省略它。** 不写 target 时,产物接到本次构建里该包产出的每个镜像 —— 可执行、 +动态库,**以及测试二进制**。测试二进制在这个集合里,是因为它链接的是同一份库代码: +把它排除掉,`mcpp build` 会通过而 `mcpp test` 在这个 action 本来要提供的那个符号上 +报 `undefined symbol`。改成显式点名也不行 —— 测试链接单元是从 `tests/*.cpp` +**发现**出来的,名字不在 `mcpp.toml` 里,而写了它的 `build.mcpp` 在普通 +`mcpp build` 下会直接构建失败,因为那条链接单元根本不存在。 + +如果本次构建里没有任何东西能接收这些产物(纯静态库包),mcpp 会报一条 degradation: +这条边只能经由链接被达成,没有链接就意味着命令一次都不会跑,而构建什么都不说。 + +> 把预编译对象写进 `[build].ldflags` 同样能到达链接器,但**不要**用它承载构建产物: +> ldflags 是链接命令里的一串字符、不是图里的文件,没有任何东西跟踪它,改了它得到的是 +> `ninja: no work to do`。Windows 资源请用 [`[resources]`](05-mcpp-toml.md); +> `object` 是其余一切的出口。 + **必须写出输出文件名。** mcpp 在 prepare 期就定死源码集、fingerprint 与模块图, 所以名字未知的产物无法构建。内容可以晚到,名字不行。畸形 action 是**硬错误**, 绝不静默跳过。 diff --git a/mcpp.toml b/mcpp.toml index e6d3eead..e0c17b61 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.8.6.3" +version = "2026.8.7.1" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/build/directives.cppm b/src/build/directives.cppm index 2ccfe0fb..85fda563 100644 --- a/src/build/directives.cppm +++ b/src/build/directives.cppm @@ -587,6 +587,7 @@ std::optional decode_action(std::string_view payloa auto role = j.value("role", std::string{"source"}); a.role = role == "check" ? mcpp::manifest::BuildAction::Role::Check : role == "artifact" ? mcpp::manifest::BuildAction::Role::Artifact + : role == "object" ? mcpp::manifest::BuildAction::Role::Object : mcpp::manifest::BuildAction::Role::Source; auto arr = [&](const char* k, std::vector& dst) { if (auto it = j.find(k); it != j.end() && it->is_array()) @@ -598,6 +599,7 @@ std::optional decode_action(std::string_view payloa arr("command", a.command); arr("provides", a.provides); arr("imports", a.imports); + arr("targets", a.targets); a.blocking = j.value("blocking", false); a.description = j.value("description", std::string{}); if (a.command.empty() || a.outputs.empty()) return std::nullopt; diff --git a/src/build/execute.cppm b/src/build/execute.cppm index c47d3b0a..6a0e9b8c 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -323,7 +323,16 @@ export int run_build_plan(BuildContext& ctx, bool verbose, bool no_cache, for (auto& [name, spec] : ctx.manifest.dependencies) { if (announced.contains(name)) continue; announced.insert(name); - std::string ver = spec.isPath() ? "(path)" : std::string("v") + spec.version; + // `spec.version` is the constraint the manifest WROTE. Announcing it + // printed "Compiling compat.imgui v^1.92.8" — a banner naming a version + // that does not exist (mcpp#363). prepare_build hands the resolution + // result over in ctx.resolvedVersions; fall back to the spec only for + // deps that never went through resolution (git, or an exact pin). + auto rit = ctx.resolvedVersions.find(name); + std::string ver = spec.isPath() + ? "(path)" + : std::string("v") + (rit != ctx.resolvedVersions.end() ? rit->second + : spec.version); auto it = cachedUnits.find(name); if (it == cachedUnits.end()) { mcpp::ui::status("Compiling", std::format("{} {}", name, ver)); @@ -438,7 +447,8 @@ export int run_build_plan(BuildContext& ctx, bool verbose, bool no_cache, // caller's broader glob; and it's the same choke-point fix as expand_glob // itself, see scanner.cppm). bool sources_newer_than(const std::filesystem::path& projectRoot, - std::filesystem::file_time_type ninjaTime) { + std::filesystem::file_time_type ninjaTime, + const std::vector& resourceScripts = {}) { std::error_code ec; // The root build.mcpp is a build input too — its directives shape // build.ninja (flags, generated/selected sources). A changed program must @@ -456,6 +466,25 @@ bool sources_newer_than(const std::filesystem::path& projectRoot, // "Finished dev in 0.00s" while the new file is never generated. Same // question as the build.mcpp check above, different kind of input. if (mcpp::build::glob_inputs_stale(projectRoot)) return true; + // mcpp#365: an author-written `.rc` is a third input of the same kind. It + // is not under src/ and has no C++ extension, so the sweep below cannot see + // it — and unlike the icon or a header the script includes, editing it can + // change WHAT THE GRAPH SHOULD BE: the implicit-input set comes from + // scanning the script, and the "your VERSIONINFO is named by string" + // diagnostic is produced while scanning. Both happen in prepare_build, so a + // fresh build.ninja made the edit invisible — the resource itself rebuilt + // (ninja tracks it), but a newly added `#include "ids.h"` went untracked and + // the diagnostic never fired again after the first build. + // + // Only `files` is swept. `icon` and `extra-inputs` are already ninja + // implicit inputs and changing them cannot change the shape of the graph, + // so forcing a full prepare on every icon tweak would buy nothing. + for (auto const& f : resourceScripts) { + auto p = f.is_absolute() ? f : (projectRoot / f); + auto ft = std::filesystem::last_write_time(p, ec); + if (ec) { ec.clear(); continue; } // missing → prepare_build reports it + if (ft > ninjaTime) return true; + } for (auto& f : mcpp::modgraph::expand_glob(projectRoot, "src/**/*")) { auto ext = f.extension().string(); if (ext != ".cppm" && ext != ".cpp" && ext != ".cc" && @@ -547,6 +576,11 @@ std::optional run_ninja_fast(const std::string& ninjaProgram, struct FastPathIdentity { std::string profile; std::string cacheMode; + // mcpp#365: author-written resource scripts, for the freshness sweep. They + // ride along here because this is the one place on the fast path that + // already parses the manifest — re-reading it to answer a second question + // would be a second derivation of the same fact. + std::vector resourceScripts; }; std::optional @@ -558,6 +592,7 @@ fast_path_identity(const std::filesystem::path& projectRoot, mcpp::build::resolve_profile_name(*m, profileOverride), std::string(mcpp::build::cache_mode_name( mcpp::build::resolve_cache_mode(*m, ""))), + m->resources.files, }; } @@ -622,7 +657,7 @@ export std::optional try_fast_build(const std::filesystem::path& projectRoo // mcpp#225: bounded + vcs/build-dir-excluded walk (see sources_newer_than) // instead of a hand-rolled recursive_directory_iterator over src/. - if (sources_newer_than(projectRoot, ninjaTime)) return std::nullopt; + if (sources_newer_than(projectRoot, ninjaTime, want->resourceScripts)) return std::nullopt; // All inputs are older than build.ninja → fast-path: just run ninja. std::chrono::milliseconds elapsed{}; @@ -697,7 +732,7 @@ std::optional try_fast_run(const std::filesystem::path& projectRoot, auto tomlTime = std::filesystem::last_write_time(tomlPath, ec); if (ec || tomlTime > ninjaTime) return std::nullopt; - if (sources_newer_than(projectRoot, ninjaTime)) return std::nullopt; + if (sources_newer_than(projectRoot, ninjaTime, want->resourceScripts)) return std::nullopt; // Fresh → run ninja (picks up any incremental object/link work) then // exec the cached exe path directly. diff --git a/src/build/hostprogram.cppm b/src/build/hostprogram.cppm index d309932e..b49c710c 100644 --- a/src/build/hostprogram.cppm +++ b/src/build/hostprogram.cppm @@ -61,7 +61,7 @@ inline void include_dir_after(const char* dir) { std::printf("mcpp:include-di // cannot be built. Content may arrive later; names may not. struct action { const char* id = ""; - const char* role = "source"; // "source" | "check" | "artifact" + const char* role = "source"; // "source" | "check" | "object" | "artifact" const char* description = ""; bool blocking = false; // check only: gate compilation on it action& input(const char* p) { add(inputs_, sizeof inputs_, p); return *this; } @@ -72,6 +72,13 @@ struct action { // what lets a generated .cppm exist as a graph node at all. action& provides(const char* n) { add(provides_, sizeof provides_, n); return *this; } action& imports(const char* n) { add(imports_, sizeof imports_, n); return *this; } + // Object only: which link unit receives the outputs. Omit for "every image + // this package produces" — which INCLUDES test binaries, and is what you + // want: their names come from tests/*.cpp, so spelling one here breaks + // plain `mcpp build`, where that link unit does not exist. An Artifact reads + // its target out of ${mcpp.target_file:NAME}; an Object runs before the link + // and has no such handle, so it has to say the name. + action& target(const char* n) { add(targets_, sizeof targets_, n); return *this; } void submit() const { std::printf("mcpp:action={\"id\":"); esc(id); std::printf(",\"role\":"); esc(role); @@ -86,6 +93,7 @@ struct action { std::printf(",\"command\":[%s]", command_); std::printf(",\"provides\":[%s]", provides_); std::printf(",\"imports\":[%s]", imports_); + std::printf(",\"targets\":[%s]", targets_); std::printf("}\n"); } private: @@ -94,7 +102,7 @@ private: // so no std::string. Sizes chosen for real generator invocations: a protoc // command line with many -I paths runs long. char inputs_[8192]{}, outputs_[8192]{}, command_[16384]{}, - provides_[2048]{}, imports_[2048]{}; + provides_[2048]{}, imports_[2048]{}, targets_[1024]{}; mutable bool overflow_ = false; static void esc(const char* s) { std::putchar('"'); diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index b6a95325..4c78eb44 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -431,6 +431,13 @@ std::string emit_ninja_string(const BuildPlan& plan) { append(std::format("nasmfmt = {}\n", plan.nasmFormat)); append(std::format("nasmflags ={}\n", flags.nasm)); } + const bool need_rc_rule = !plan.resourceUnits.empty(); + if (need_rc_rule) { + append(std::format("rc = {}\n", escape_ninja_path(plan.rcPath))); + std::string rcf; + for (auto const& f : plan.rcFlags) { rcf += ' '; rcf += shell_quote_arg(f); } + append(std::format("rcflags ={}\n", rcf)); + } append(std::format("ldflags ={}\n", flags.ld)); // `ar` for cxx_archive. @@ -744,6 +751,24 @@ std::string emit_ninja_string(const BuildPlan& plan) { append(" description = NASM $out\n\n"); } + if (need_rc_rule) { + // Windows resources (mcpp#365). Two spellings, and the difference is + // structural rather than cosmetic: GNU ld cannot consume a `.res`, so + // windres is asked for a COFF object; link.exe and lld-link take a + // `.res` directly, and rc.exe/llvm-rc produce nothing else. + // + // No depfile in either branch — neither tool can emit one (checked + // against llvm-rc 22.1.8: /I, /D and no dependency output). What the + // script pulls in is declared instead, from a scan of the .rc plus + // [resources].extra-inputs, and lands on this edge as implicit inputs. + append("rule rc_object\n"); + if (plan.rcStyle == "msvc") + append(" command = $rc /nologo $rcflags /fo $out $in\n"); + else + append(" command = $rc -O coff $rcflags -o $out $in\n"); + append(" description = RC $out\n\n"); + } + // Link/archive/shared: driver-style (g++/clang++ are the linker) vs the // msvc dialect's separate link.exe/lib.exe. One emitter owns the rule // shape; `useRsp` decides whether $in is inlined or routed through a @@ -1232,6 +1257,22 @@ std::string emit_ninja_string(const BuildPlan& plan) { append("\n"); } + // Windows resource units (mcpp#365). One edge per .rc; the output is + // already in the consuming link unit's `objects`, so ninja sequences the + // compile before the link with no help from us — and, unlike the `.res` + // path smuggled through ldflags that this replaces, editing the icon or the + // script now actually reaches the linker. + for (auto const& ru : plan.resourceUnits) { + std::string implicit; + for (auto const& in : ru.implicitInputs) + implicit += " " + escape_ninja_path(in); + append(std::format("build {} : rc_object {}{}\n", + escape_ninja_path(ru.output), + escape_ninja_path(ru.source), + implicit.empty() ? std::string{} : " |" + implicit)); + } + if (!plan.resourceUnits.empty()) append("\n"); + // Link units for (auto& lu : plan.linkUnits) { std::string ins; @@ -1355,6 +1396,7 @@ std::string emit_ninja_string(const BuildPlan& plan) { append(std::format(" description = {} {}\n", a.role == mcpp::manifest::BuildAction::Role::Check ? "CHECK" : a.role == mcpp::manifest::BuildAction::Role::Artifact ? "ARTIFACT" + : a.role == mcpp::manifest::BuildAction::Role::Object ? "OBJECT" : "GENERATE", a.description.empty() ? a.id : a.description)); append("\n"); @@ -1364,11 +1406,13 @@ std::string emit_ninja_string(const BuildPlan& plan) { append(std::format("build{} : mcpp_action_{}{}\n", outs, i, ins)); append("\n"); // A Source action's outputs are already reachable through the compile - // edges that consume them. Check and Artifact outputs are terminal, so - // without this nothing would ever ask for them — and under explicit - // ninja goals (#274) an edge reachable only via `default` is skipped, - // which is exactly how the soname aliases went missing in 0.0.104. - if (a.role != mcpp::manifest::BuildAction::Role::Source) + // edges that consume them, and an Object action's through the link edge + // that lists them. Check and Artifact outputs are terminal, so without + // this nothing would ever ask for them — and under explicit ninja goals + // (#274) an edge reachable only via `default` is skipped, which is + // exactly how the soname aliases went missing in 0.0.104. + if (a.role != mcpp::manifest::BuildAction::Role::Source && + a.role != mcpp::manifest::BuildAction::Role::Object) for (auto const& o : a.outputs) actionDefaults += " " + escape_ninja_path(o); } diff --git a/src/build/plan.cppm b/src/build/plan.cppm index 1349e6b9..0f1daa91 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -60,7 +60,12 @@ struct CompileUnit { struct LinkUnit { std::string targetName; enum Kind { Binary, StaticLibrary, SharedLibrary, TestBinary } kind = Binary; - std::vector objects; // relative to plan.outputDir + // Normally relative to plan.outputDir. A `role = "object"` action's outputs + // land here ABSOLUTE, on purpose: ninja identifies a file by the string an + // edge declares, and the action edge declares whatever prepare_actions + // produced — respelling it here would create a second node and "missing and + // no known rule to make it". Do not normalise this vector. + std::vector objects; std::vector implicitInputs; // relative to plan.outputDir std::vector linkFlags; // per-link edge flags std::filesystem::path output; // relative to plan.outputDir @@ -69,6 +74,26 @@ struct LinkUnit { std::optional entryMain; // src path of main.cpp for bin }; +// One Windows resource script compiled into one linkable resource artifact +// (mcpp#365). +// +// Deliberately NOT a CompileUnit. A `.rc` has no module semantics, so putting it +// there would drag it into the module graph, the topological order, the cache +// key and compile_commands.json — where clangd would be handed a file no C++ +// frontend can parse. It is its own edge whose output joins `LinkUnit::objects`, +// which is the one thing `[build].ldflags` could never do: ldflags is a flat +// string in the link command, so a `.res` named there is invisible to ninja and +// changing it produced "no work to do". +struct ResourceUnit { + std::filesystem::path source; // absolute; synthesised ones live under outputDir + std::filesystem::path output; // relative to plan.outputDir + // The `.rc`'s own inputs: quoted #includes and the data files named by its + // resource statements. Neither windres nor llvm-rc can emit a depfile + // (verified against llvm-rc 22.1.8: /I, /D, no dependency output), so these + // come from a text scan plus `[resources].extra-inputs`. + std::vector implicitInputs; +}; + struct BuildPlan { mcpp::manifest::Manifest manifest; mcpp::toolchain::Toolchain toolchain; @@ -97,6 +122,16 @@ struct BuildPlan { // failure when unavailable; never a silent skip). std::filesystem::path nasmPath; // nasm binary (empty → no .asm units) std::string nasmFormat; // -f value derived from the target triple + // Windows resources (mcpp#365). Resolved in prepare AFTER the plan exists + // and only when the plan actually has resource units — same lazy, hard-fail + // shape as nasm above: a resource that silently vanished would show up as + // "my icon is gone" with nothing to attribute it to. + std::vector resourceUnits; + std::filesystem::path rcPath; // windres / llvm-rc / rc.exe + // "gnu" → windres, emits a COFF object (ld cannot consume a .res) + // "msvc" → rc.exe / llvm-rc, emits a .res (link.exe and lld-link take it) + std::string rcStyle; + std::vector rcFlags; // -I / -D, target-shaped std::vector compileUnits; // topologically sorted std::vector linkUnits; diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 272e2cfa..a622ac8b 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -37,6 +37,7 @@ import mcpp.build.directives; // directive table: mark / fold_private_tail import mcpp.build.tool_store; // #355 host tools: store layout + key + overrides import mcpp.build.dep_graph; // queries over the resolved edge graph import mcpp.build.provisions; // #359 build-time provisions: table + propagation +import mcpp.build.resources; // #365 Windows resources: synthesise / scan / find rc import mcpp.build.backend; // BuildOptions for the tool sub-build import mcpp.build.ninja; // make_ninja_backend — driving that sub-build import mcpp.lockfile; @@ -739,6 +740,14 @@ export struct BuildContext { std::size_t units = 0; }; std::vector cachedDeps; + + // What the dependency walk actually RESOLVED, keyed by the root manifest's + // dependency map key. The "Compiling v" banner used to read + // `manifest.dependencies[...].version` — the constraint as authored — so a + // caret dep announced itself as `v^1.92.8` (mcpp#363). The resolution result + // already existed inside prepare_build; the banner and mcpp.lock were simply + // reading the input instead of the output. Both now read this. + std::map resolvedVersions; }; // The ONE cache-mode resolver, for the same reason resolve_profile_name exists: @@ -1971,6 +1980,13 @@ prepare_build(bool print_fingerprint, std::string constraint; // AND-combined original constraints (version src only) std::string requestedBy; // human-readable for error messages std::string source; // "version" | "path" | "git" — for type-clash check + // Reached ONLY through [dev-dependencies]. mcpp.lock excludes these: + // dev-deps are resolved under `mcpp test` and not under `mcpp build`, so + // recording them makes a VCS-committed file depend on which command ran + // last and ping-pong between the two. The lock must be a function of the + // MANIFEST, not of the command. Cleared the moment a non-dev consumer + // asks for the same package. + bool devOnly = false; std::size_t depIndex = 0; // index into dep_manifests/packages-1 (for in-place re-fetch) std::vector linkFlagsAdded; // entries appended to m->buildConfig.ldflags by this dep }; @@ -1986,6 +2002,7 @@ prepare_build(bool print_fingerprint, std::string originalConstraint; // spec.version BEFORE pinning (for SemVer merge) std::size_t consumerDepIndex; // dep_manifests slot of who pushed this child; kMainConsumer for main std::filesystem::path resolveRoot; // base dir for relative path deps (empty = use project root) + bool devOnly = false; // seeded from [dev-dependencies]; inherited by children }; std::deque worklist; @@ -3251,7 +3268,7 @@ prepare_build(bool print_fingerprint, auto req = s; injectForwards(*m, rootActive, n, req); worklist.push_back({n, req, mainPkgLabel + " (dev-dep)", - req.version, kMainConsumer, {}}); + req.version, kMainConsumer, {}, /*devOnly=*/true}); } } @@ -3297,6 +3314,9 @@ prepare_build(bool print_fingerprint, : "version"; if (auto it = resolved.find(key); it != resolved.end()) { + // A package is dev-only until some non-dev consumer wants it. Order + // of arrival must not decide, so this is an AND over every request. + it->second.devOnly = it->second.devOnly && item.devOnly; // Conflict detection. if (it->second.source != sourceKind) { return std::unexpected(std::format( @@ -3443,6 +3463,7 @@ prepare_build(bool print_fingerprint, .constraint = item.originalConstraint, .requestedBy = item.requestedBy, .source = "version", + .devOnly = item.devOnly, .depIndex = dep_manifests.size() - 1, .linkFlagsAdded = std::move(linkFlagsAdded), }; @@ -3537,7 +3558,7 @@ prepare_build(bool print_fingerprint, dep_manifests[it->second.depIndex]->dependencies) { worklist.push_back({child_name, child_spec, newLabel, child_spec.version, - it->second.depIndex, {}}); + it->second.depIndex, {}, item.devOnly}); } continue; } @@ -3814,6 +3835,7 @@ prepare_build(bool print_fingerprint, .constraint = sourceKind == "version" ? item.originalConstraint : "", .requestedBy = item.requestedBy, .source = sourceKind, + .devOnly = item.devOnly, .depIndex = dep_manifests.size() - 1, .linkFlagsAdded = std::move(linkFlagsAdded), }; @@ -3843,7 +3865,8 @@ prepare_build(bool print_fingerprint, auto childReq = child_spec; injectForwards(*dep_manifests.back(), depActive, child_name, childReq); worklist.push_back({child_name, childReq, thisDepLabel, - childReq.version, selfIdx, dep_root}); + childReq.version, selfIdx, dep_root, + item.devOnly}); } } @@ -4950,6 +4973,89 @@ prepare_build(bool print_fingerprint, "features are active)", bad, known.empty() ? std::string("none") : known)); } + + // role = "object": the outputs are LINK inputs, so attach them to the + // link units that should receive them. + // + // The strings are pushed VERBATIM. ninja identifies a file by the string + // an edge declares, and the action edge declares whatever + // prepare_actions produced (an absolute path); handing the link edge a + // prettier relative spelling of the same bytes creates a second node and + // "missing and no known rule to make it" — the same trap + // ${mcpp.target_file:} documents just above. + std::set unknownObjectTargets; + for (auto const& a : ctx.plan.actions) { + if (a.role != mcpp::manifest::BuildAction::Role::Object) continue; + + // Validate EVERY named target, not just the case where none of them + // matched. Gating the check on "nothing attached" meant + // `.target("app").target("aap")` attached to `app` and dropped the + // typo without a word — while both the type comment and the docs + // promise an unknown name is an error. A per-name check is also the + // only one that scales: the failure it catches is a target that + // exists in one configuration and not another. + for (auto const& t : a.targets) { + bool known = false; + for (auto const& lu : ctx.plan.linkUnits) + if (lu.targetName == t) { known = true; break; } + if (!known) unknownObjectTargets.insert(t); + } + + bool attached = false; + for (auto& lu : ctx.plan.linkUnits) { + // Empty targets = every LINKED IMAGE, and a test binary is one. + // Excluding it made `mcpp build` succeed while `mcpp test` died + // with `undefined symbol` on the very symbol the action exists + // to provide — the library code under test links the same + // objects, so a blob/`.def`/pre-built `.o` has to reach it too. + // Naming the test target instead is not a workaround: test link + // units are DISCOVERED from tests/*.cpp, so their names are not + // in mcpp.toml and a build.mcpp that spells one stops building + // under plain `mcpp build`, where that unit does not exist. + // (`[resources]` makes the opposite call on purpose: an icon + // belongs to what ships, not to a test runner.) + const bool image = lu.kind == mcpp::build::LinkUnit::Binary + || lu.kind == mcpp::build::LinkUnit::SharedLibrary + || lu.kind == mcpp::build::LinkUnit::TestBinary; + const bool wanted = a.targets.empty() + ? image + : std::find(a.targets.begin(), a.targets.end(), + lu.targetName) != a.targets.end(); + if (!wanted) continue; + for (auto const& o : a.outputs) lu.objects.emplace_back(o); + attached = true; + } + + // No consumer at all. The edge is excluded from `actionDefaults` + // (its outputs are supposed to be reachable through a link edge), so + // this is not "builds but unused" — the command never runs and the + // build says nothing. Same shape, and same diagnostic, as + // `resources/no-image`. + if (!attached && a.targets.empty()) { + mcpp::diag::degraded("action/no-target", std::format( + "build.mcpp action '{}' has role = \"object\" but this build " + "produces no executable, shared library or test binary to " + "link its outputs into", a.id.empty() ? "" : a.id), + "the action never runs and its outputs are never produced", + "add a [targets.] that links, or name the targets " + "explicitly with .target(\"…\")"); + } + } + if (!unknownObjectTargets.empty()) { + std::string bad, known; + for (auto const& n : unknownObjectTargets) bad += (bad.empty() ? "" : ", ") + n; + for (auto const& lu : ctx.plan.linkUnits) + known += (known.empty() ? "" : ", ") + lu.targetName; + return std::unexpected(std::format( + "build.mcpp action with role = \"object\" names unknown " + "target(s): {}\n" + " targets in this build: [{}]\n" + " (a target gated by required_features is absent unless those " + "features are active; test binaries exist only under `mcpp " + "test`, so name none and the outputs reach every image " + "including them)", + bad, known.empty() ? std::string("none") : known)); + } } ctx.plan.stdCompatBmiPath = stdCompatBmiPath; ctx.plan.stdCompatObjectPath = stdCompatObjectPath; @@ -5031,6 +5137,275 @@ prepare_build(bool print_fingerprint, } } + // ─── Windows resources: [resources] → a tracked link input (mcpp#365) ── + // + // Four rules, in this order: + // 1. Only the ROOT package's [resources] is read. A dependency's version + // resource would fight its consumer's for ordinal 1, and a dependency + // that produces no PE image of its own has nothing to embed into. + // 2. A DECLARED FILE THAT DOES NOT EXIST IS AN ERROR — on EVERY target. + // Whether a path exists is a fact about the working tree, not about + // the target; gating it on is_pe() meant a Linux or macOS CI could not + // see a typo in `icon = …` at all and only the Windows job went red, + // which is the same "find out late" failure the hard error exists to + // remove. Existence is checked everywhere; only COMPILATION is PE-only. + // 3. On a non-PE target nothing is compiled — no units, no warning, + // byte-identical build. This is what makes `cfg(windows)` unnecessary + // (and it could not be used anyway: the conditional channel carries + // BuildInputs only). + // 4. Nothing to embed into (an archive-only package) → say so and stop. + if (m->resources.declared()) { + namespace rsrc = mcpp::build::resources; + const auto& R = m->resources; + + // Rule 2 — target-independent, so it runs before the is_pe() gate. + auto resolve_declared = [&](const std::filesystem::path& p, + std::string_view key) + -> std::expected + { + // Lexical, not weakly_canonical: canonicalising resolves symlinks, + // and a symlinked source tree would then bake a different path into + // the generated script than the one the user wrote. (Same reason + // mcpp#344 made the cache anchor lexical.) + auto abs = (p.is_absolute() ? p : (*root / p)).lexically_normal(); + std::error_code ec; + if (!std::filesystem::is_regular_file(abs, ec)) + return std::unexpected(std::format( + "[resources] {} = \"{}\" does not exist (looked at {}).\n" + " A declared resource is a build input like any other " + "source: mcpp will not quietly ship a binary without it. " + "Remove the key if the resource is not wanted.", + key, p.generic_string(), abs.generic_string())); + return abs; + }; + + std::filesystem::path iconAbs; + if (!R.icon.empty()) { + auto r = resolve_declared(R.icon, "icon"); + if (!r) return std::unexpected(r.error()); + iconAbs = *r; + } + std::vector extraInputs; + for (auto const& e : R.extraInputs) { + auto r = resolve_declared(e, "extra-inputs"); + if (!r) return std::unexpected(r.error()); + extraInputs.push_back(*r); + } + std::vector scriptFiles; + for (auto const& f : R.files) { + auto r = resolve_declared(f, "files"); + if (!r) return std::unexpected(r.error()); + scriptFiles.push_back(*r); + } + + const auto trip = mcpp::toolchain::triple::parse(tc->targetTriple) + .value_or(mcpp::toolchain::triple::host_triple()); + + // Rules 3 and 4 are early returns rather than nesting: the body below is + // ~150 lines and an `else` around all of it reads as an accident. + auto plan_resources = [&]() -> std::expected { + const auto dialectId = mcpp::toolchain::dialect_for(*tc).id; + const bool msvcStyle = (dialectId == "msvc"); + const std::string_view outExt = msvcStyle ? ".res" : ".o"; + const auto resDir = ctx.plan.outputDir / "res"; + std::error_code mkEc; + std::filesystem::create_directories(resDir, mkEc); + + // Which link units embed resources: images, not archives. A `.res` + // inside a static library is dropped by every linker that reads one. + // Test binaries are images too, but deliberately excluded: an icon + // and an OriginalFilename belong to what the project SHIPS, and a + // test executable is not that. (`role = "object"` makes the opposite + // call, for the opposite reason — see its note above.) + std::vector peUnits; + for (std::size_t i = 0; i < ctx.plan.linkUnits.size(); ++i) { + auto k = ctx.plan.linkUnits[i].kind; + if (k == mcpp::build::LinkUnit::Binary || + k == mcpp::build::LinkUnit::SharedLibrary) + peUnits.push_back(i); + } + // Nothing to embed into. Compiling the scripts anyway would leave + // orphan edges nothing depends on, and demanding a resource + // compiler for them would fail a build that has no use for one. + // A degradation, not a warning: the user asked for something and + // got nothing, so `--strict` should see it. + if (peUnits.empty()) { + mcpp::diag::degraded("resources/no-image", std::format( + "[resources] is declared but '{}' produces no executable or " + "shared library for {}", m->package.name, trip.str()), + "nothing embeds the icon or the version metadata", + "add a [targets.] with kind = \"bin\" or \"shared\", " + "or drop the [resources] section"); + return {}; + } + + // Two scripts with the same stem in different directories would + // otherwise write the same artifact — a silent "multiple rules + // generate" that ninja reports far from the cause. + std::set usedStems; + auto add_unit = [&](const std::filesystem::path& src, + std::string_view stem, + std::vector inputs, + std::size_t attachTo) + -> std::expected + { + if (!usedStems.insert(std::string(stem)).second) + return std::unexpected(std::format( + "[resources] two resource scripts are named '{}.rc'; " + "they would produce the same artifact. Rename one.", stem)); + mcpp::build::ResourceUnit ru; + ru.source = src; + ru.output = std::filesystem::path("res") / + (std::string(stem) + std::string(outExt)); + ru.implicitInputs = std::move(inputs); + ctx.plan.resourceUnits.push_back(std::move(ru)); + const auto& out = ctx.plan.resourceUnits.back().output; + if (attachTo == static_cast(-1)) { + for (auto i : peUnits) ctx.plan.linkUnits[i].objects.push_back(out); + } else { + ctx.plan.linkUnits[attachTo].objects.push_back(out); + } + return {}; + }; + + // Author-written scripts: compiled once, linked into every image. + for (auto const& rcSrc : scriptFiles) { + auto scan = rsrc::scan_rc(rcSrc); + if (scan.versionInfoNamedByString) { + // The mcpp#365 silent failure, caught on the way in. A + // degradation rather than a warning: the impact is exactly + // the thing this feature exists to remove — a shipped binary + // whose version metadata Windows cannot read — so a build + // that asked for `--strict` must not pass over it. + mcpp::diag::degraded("resources/versioninfo", std::format( + "{}: `{} VERSIONINFO` names the version resource '{}' " + "instead of ordinal 1", + rcSrc.filename().generic_string(), scan.versionInfoName, + scan.versionInfoName), + "Windows will not find it — GetFileVersionInfo looks up " + "MAKEINTRESOURCE(1) and every field comes back empty, " + "while every tool that prints the resource TYPE still " + "says it is fine", + "VS_VERSION_INFO is a macro from ; add " + "`#include ` to the script, or write " + "`1 VERSIONINFO`"); + } + for (auto const& g : scan.gaps) { + mcpp::diag::degraded("resources/inputs", + std::format("{}: `{}` names its file through a macro, so " + "mcpp cannot track it", + rcSrc.filename().generic_string(), g), + "editing that file will not trigger a rebuild", + "list it in [resources] extra-inputs = [...]"); + } + auto inputs = std::move(scan.inputs); + inputs.insert(inputs.end(), extraInputs.begin(), extraInputs.end()); + if (auto a = add_unit(rcSrc, rcSrc.stem().string(), + std::move(inputs), + static_cast(-1)); !a) + return std::unexpected(a.error()); + } + + // The synthesised script: per image, because OriginalFilename and + // the version block belong to a specific artifact. + if (!iconAbs.empty() || R.synthesize_version_info()) { + // A version key mcpp cannot order (an upstream build number) + // leaves FILEVERSION's four numeric fields at zero while the + // string fields keep the real text. Say so — the properties + // dialog will disagree with `[package].version` and nothing + // else would explain why. + if (R.synthesize_version_info() && !m->package.version.empty() + && !mcpp::version_req::parse_version(m->package.version)) { + mcpp::diag::degraded("resources/version", + std::format("[package].version = \"{}\" has no numeric " + "form", m->package.version), + "the embedded FILEVERSION / PRODUCTVERSION fields are " + "0,0,0,0 (the string fields keep the real version)", + "set [resources.version-info] explicitly, or use a " + "dotted numeric version"); + } + for (auto i : peUnits) { + const auto& lu = ctx.plan.linkUnits[i]; + auto text = rsrc::synthesize_rc( + m->package, R, lu.output.filename().string(), iconAbs); + if (!text) return std::unexpected(text.error()); + // A stable path, so `cp` + `files = [...]` reproduces the + // same resource byte for byte (the L0→L1 escape hatch). + auto rcPath = resDir / (lu.targetName + ".mcpp.rc"); + // Write only on change: rewriting unconditionally would + // relink on every build. + std::string existing; + if (std::ifstream in(rcPath, std::ios::binary); in) + existing.assign(std::istreambuf_iterator(in), {}); + if (existing != *text) { + std::ofstream os(rcPath, std::ios::binary); + if (!os) return std::unexpected(std::format( + "cannot write generated resource script '{}'", + rcPath.string())); + os << *text; + } + std::vector inputs; + if (!iconAbs.empty()) inputs.push_back(iconAbs); + inputs.insert(inputs.end(), extraInputs.begin(), extraInputs.end()); + if (auto a = add_unit(rcPath, lu.targetName + ".mcpp", + std::move(inputs), i); !a) + return std::unexpected(a.error()); + } + } + + if (ctx.plan.resourceUnits.empty()) return {}; + + // Lazy + hard failure, exactly like nasm: a dropped resource + // surfaces as "where did my icon go", which is unattributable. + auto tool = rsrc::find_rc_tool(*tc, dialectId); + if (!tool) { + return std::unexpected(std::format( + "[resources] needs a Windows resource compiler for the " + "{} toolchain targeting {}, and none was found next to " + "{}.\n Expected {} in the toolchain's own bin directory " + "(mcpp does not search PATH for build tools).", + dialectId, trip.str(), tc->binaryPath.string(), + msvcStyle ? "rc.exe or llvm-rc" + : "-windres, windres or llvm-windres")); + } + ctx.plan.rcPath = tool->path; + ctx.plan.rcStyle = tool->style; + + // UTF-8 input, always. `[package]` metadata is user text and + // routinely non-ASCII; without this llvm-rc refuses the script + // outright ("Non-ASCII 8-bit codepoint can't be interpreted in + // the current codepage") rather than mangling it, so a project + // with a Chinese description could not build at all. + ctx.plan.rcFlags.push_back(msvcStyle ? "/C" : "--codepage=65001"); + if (msvcStyle) ctx.plan.rcFlags.push_back("65001"); + + // Include search: the project first, then whatever the toolchain + // puts on INCLUDE. llvm-rc preprocesses but does NOT read INCLUDE + // (rc.exe does), so the SDK dirs have to be spelled out for it — + // that is what makes `#include ` work, and it is the + // supported way to get VS_VERSION_INFO defined. + const std::string ip = msvcStyle ? "/I" : "-I"; + ctx.plan.rcFlags.push_back(ip + root->string()); + for (auto const& d : m->buildConfig.includeDirs) { + auto abs = d.is_absolute() ? d : (*root / d); + ctx.plan.rcFlags.push_back(ip + abs.string()); + } + if (msvcStyle && tool->name().find("llvm-rc") != std::string::npos) { + for (auto const& ev : tc->envOverrides) { + if (ev.key != "INCLUDE") continue; + // Shared splitter: `;` only. See rsrc::split_env_list — + // the drive colon is not a separator. + for (auto dir : rsrc::split_env_list(ev.value)) + ctx.plan.rcFlags.push_back(ip + std::string(dir)); + } + } + return {}; + }; + + if (trip.is_pe()) + if (auto r = plan_resources(); !r) return std::unexpected(r.error()); + } + // ─── Global dependency cache: per-package keys, hit → stage edges ── // // Every index package gets a key over the axes that actually reach its @@ -5346,10 +5721,32 @@ prepare_build(bool print_fingerprint, // Write/update mcpp.lock for any version-based deps that succeeded. // Path deps are intentionally NOT locked — their source is local filesystem. + // + // mcpp#363: the version entries come from `resolved` — what the walk + // actually picked — not from `m->dependencies`, which still holds the + // constraint the user wrote and only covers DIRECT deps. Reading the input + // instead of the output made the lock record `^1.92.8` (a range locks + // nothing) and omit the transitive graph entirely. Git entries deliberately + // stay on `m->dependencies`: their lock line is read back as a resolution + // anchor (#329), keyed by the root manifest's map key, and that contract is + // unchanged here. { mcpp::lockfile::Lockfile lock; lock.schemaVersion = 2; + // The lock key for a dep the ROOT declares is the map key it declared + // it under (`compat.imgui`, `gtest`) — that is the key #329's git anchor + // lookup uses, and changing it would silently unpin every branch dep. + // A dep reached only transitively has no such key, so it is written + // under its fully-qualified identity. + auto lock_name_for = [&](const ResolvedKey& k) -> std::string { + for (auto const& [n, s] : m->dependencies) { + const std::string sn = s.shortName.empty() ? n : s.shortName; + if (s.namespace_ == k.ns && sn == k.shortName) return n; + } + return mcpp::pm::compat::qualified_name(k.ns, k.shortName); + }; + // Lock custom index shas from manifest [indices] section. for (auto const& [idxName, spec] : m->indices) { if (spec.is_local() || spec.is_builtin()) continue; @@ -5360,46 +5757,64 @@ prepare_build(bool print_fingerprint, lock.indices.push_back(std::move(li)); } + // Git deps: root-declared only, unchanged (see the note above). for (auto const& [name, spec] : m->dependencies) { - if (spec.isPath()) continue; + if (!spec.isGit()) continue; mcpp::lockfile::LockedPackage lp; - lp.name = name; - if (spec.isGit()) { - auto gitIt = root_git_lock_identities.find(name); - lp.version = spec.gitRev; - if (gitIt == root_git_lock_identities.end()) { - lp.source = std::format("git+{}#{}={}", - spec.git, spec.gitRefKind, spec.gitRev); - std::hash hasher; - lp.hash = std::format("fnv1a:{:016x}", hasher(lp.source)); - } else { - lp.source = gitIt->second.source; - lp.hash = gitIt->second.hash; - } - } else { - lp.namespace_ = spec.namespace_.empty() - ? std::string{} - : spec.namespace_; - lp.version = spec.version; - // Use the namespace and resolved version as the source identifier. - // For custom indices, include the index name for traceability. - auto sourceIndex = lp.namespace_.empty() - ? std::string(mcpp::pm::kDefaultNamespace) - : lp.namespace_; - lp.source = std::format("index+{}@{}", sourceIndex, lp.version); - // Use a deterministic hash based on namespace + name + version. - // A future PR can replace this with a real content hash from the - // xpkg.lua's declared sha256 or from the install plan. + lp.name = name; + lp.version = spec.gitRev; + auto gitIt = root_git_lock_identities.find(name); + if (gitIt == root_git_lock_identities.end()) { + lp.source = std::format("git+{}#{}={}", + spec.git, spec.gitRefKind, spec.gitRev); std::hash hasher; - auto hashInput = std::format("{}:{}@{}", sourceIndex, name, lp.version); - lp.hash = std::format("fnv1a:{:016x}", hasher(hashInput)); + lp.hash = std::format("fnv1a:{:016x}", hasher(lp.source)); + } else { + lp.source = gitIt->second.source; + lp.hash = gitIt->second.hash; } lock.packages.push_back(std::move(lp)); } + + // Version deps: the whole resolved graph, at the versions actually + // chosen. `resolved` is an ordered map, so the file is deterministic. + for (auto const& [key, rec] : resolved) { + if (rec.source != "version") continue; // path / git handled elsewhere + if (rec.version.empty()) continue; + // See ResolvedRecord::devOnly: `mcpp test` resolves dev-deps and + // `mcpp build` does not, so writing them would make the file depend + // on which command ran last. + if (rec.devOnly) continue; + mcpp::lockfile::LockedPackage lp; + lp.name = lock_name_for(key); + lp.namespace_ = key.ns; + lp.version = rec.version; + // Use the namespace and resolved version as the source identifier. + // For custom indices, include the index name for traceability. + auto sourceIndex = lp.namespace_.empty() + ? std::string(mcpp::pm::kDefaultNamespace) + : lp.namespace_; + lp.source = std::format("index+{}@{}", sourceIndex, lp.version); + // Use a deterministic hash based on namespace + name + version. + // A future PR can replace this with a real content hash from the + // xpkg.lua's declared sha256 or from the install plan. + std::hash hasher; + auto hashInput = std::format("{}:{}@{}", sourceIndex, lp.name, lp.version); + lp.hash = std::format("fnv1a:{:016x}", hasher(hashInput)); + lock.packages.push_back(std::move(lp)); + } if (!lock.packages.empty() || !lock.indices.empty()) { auto lockPath = workRoot / "mcpp.lock"; (void)mcpp::lockfile::write(lock, lockPath); } + + // Same data, second consumer: the "Compiling v" banner. + // It reads this rather than re-deriving from the manifest, so the banner + // and the lock cannot disagree about what was built. + for (auto const& [key, rec] : resolved) { + if (rec.source != "version" || rec.version.empty()) continue; + ctx.resolvedVersions[lock_name_for(key)] = rec.version; + } } // Apply [runtime.] provider = "" overrides: prefer the diff --git a/src/build/resources.cppm b/src/build/resources.cppm new file mode 100644 index 00000000..5bc8a1c7 --- /dev/null +++ b/src/build/resources.cppm @@ -0,0 +1,444 @@ +// mcpp.build.resources — Windows resource scripts: synthesise one, read one, +// and find the tool that compiles it (mcpp#365). +// +// WHY THIS IS ITS OWN MODULE +// +// Everything here is knowledge about the `.rc` FORMAT and about the resource +// compilers, and none of it is knowledge about the build graph. prepare.cppm +// decides which units exist and ninja_backend.cppm spells the edge; this file +// answers "what goes in the file", "what does the file depend on", and "which +// binary can compile it". +// +// THE BUG THAT SHAPES ALL OF IT +// +// mcpp#365 reported that an embedded VERSIONINFO is invisible to Windows — +// `llvm-readobj --coff-resources` shows the resource, `GetFileVersionInfo` +// returns nothing — and attributed it to llvm-rc. It is not an llvm-rc bug. +// `VS_VERSION_INFO` is a macro from `verrsrc.h` (via `windows.h`) whose value is +// 1. Without that definition, rc grammar happily accepts the bare identifier in +// the resource-NAME position, so the resource is filed under the string name +// "VS_VERSION_INFO" instead of ordinal 1. The type is RT_VERSION(16) either way +// — which is exactly why every tool that prints the type says it looks right — +// but GetFileVersionInfo looks up MAKEINTRESOURCE(VS_VERSION_INFO), i.e. the +// ORDINAL, and finds nothing. +// +// Measured on llvm-rc 22.1.8. In the emitted `.res`, the type and name fields +// of the first real resource header sit at offset 40 (a 32-byte null header, +// then dataSize + headerSize); `ffff` introduces an ordinal: +// +// 1 VERSIONINFO ff ff 10 00 ff ff 01 00 (480 bytes) +// VS_VERSION_INFO VERSIONINFO ff ff 10 00 56 00 53 00 … (508 bytes) +// type = RT_VERSION ─┘ ^ UTF-16 "VS_VERSION_INFO" +// +// Consequences, all of them visible below: +// * The script we synthesise writes the literal `1`. Correct by construction, +// no toolchain workaround, nothing to keep in sync with a Windows SDK. +// * An author-written `.rc` gets the target's include directories, so +// `#include ` resolves and the macro is real. We do NOT inject +// `-DVS_VERSION_INFO=1`: partially re-implementing the SDK's macros is a +// second source of truth that drifts (the reporter also needed +// VOS_NT_WINDOWS32 and VFT_APP). +// * `ScanResult::versionInfoNamedByString` catches the exact silent shape in +// an author-written script anyway, because a user who hits it has no way to +// diagnose it from the outside. + +export module mcpp.build.resources; + +import std; +import mcpp.manifest; +import mcpp.toolchain.detect; +import mcpp.toolchain.triple; +import mcpp.version_req; + +export namespace mcpp::build::resources { + +// ─── The resource compiler ──────────────────────────────────────────────── + +struct RcTool { + std::filesystem::path path; + // "gnu" → windres: emits a COFF object, because GNU ld cannot consume a + // `.res`. Also preprocesses through its own matching `-gcc`, + // so it inherits the target's default include path. + // "msvc" → rc.exe / llvm-rc: emits a `.res`, which link.exe and lld-link + // take directly. llvm-rc preprocesses by default and accepts + // /I and /D (measured; there is no depfile option). + std::string style; + std::string name() const { return path.filename().string(); } +}; + +// Find the resource compiler for `tc`, searching PAYLOAD-RELATIVE locations +// only — never the host PATH. +// +// PATH is not an option here, and not for tidiness: xlings' shim mechanism +// gives the last installer of a bare name ownership of it, and +// `…/registry/subos/default/bin/x86_64-w64-mingw32-windres` is today a symlink +// to the xlings dispatcher. Resolving a build tool through a mutable global +// namespace is how a cross toolchain once silently produced ARM objects while +// every version probe answered correctly. `clang::find_scan_deps` sets the +// precedent: look next to the compiler that is actually being used. +std::optional find_rc_tool(const mcpp::toolchain::Toolchain& tc, + std::string_view dialectId); + +// Split a Windows environment list (PATH, INCLUDE, LIB) into its entries. +// +// `;` is the ONLY separator, and that is not a simplification. Every value that +// reaches here was synthesised by the MSVC backend for a Windows host, where an +// entry routinely begins `C:\`. Splitting on `find_first_of(";:")` cuts at the +// DRIVE COLON: `C:\Windows Kits\10\bin\...;C:\...` becomes `C` plus a +// current-drive-relative tail, which resolves by accident when the build sits +// on the same drive and silently finds nothing otherwise. One function because +// two callers (the rc-tool search here, the llvm-rc include list in +// prepare.cppm) were deriving the same rule independently and had already +// disagreed about it. +std::vector split_env_list(std::string_view value); + +// ─── Reading an author-written .rc ──────────────────────────────────────── + +struct ScanResult { + // Quoted `#include`s and the data files named by resource statements, + // resolved against the .rc's directory. Angled includes are deliberately + // absent: `` belongs to the toolchain, which is immutable for + // the life of a build directory and already folded into the fingerprint. + std::vector inputs; + // Operands the scan could not turn into a path — almost always a file name + // reached through a macro (`1 ICON APP_ICON`). Named rather than dropped: + // bounding coverage silently is how a stale resource survives in a shipped + // binary. The remedy is `[resources].extra-inputs`. + std::vector gaps; + // The mcpp#365 shape: a VERSIONINFO whose name is an identifier that is not + // the literal `1`, in a file that defines no such macro and includes + // nothing that could. See the module header. + bool versionInfoNamedByString = false; + std::string versionInfoName; +}; + +ScanResult scan_rc(const std::filesystem::path& rc); + +// ─── Synthesising the common case ───────────────────────────────────────── + +// Build the `.rc` text for `[resources]`: an icon at ordinal 1 and/or a +// VERSIONINFO at ordinal 1, with every string defaulted from `[package]`. +// +// `iconAbs` is empty when no icon was declared; `outputFileName` is the produced +// artifact's file name (VERSIONINFO's OriginalFilename). Fails only when the +// package version cannot be expressed as FILEVERSION's four 16-bit fields — +// clamping silently would put a version in the binary that is not the version +// that was built. +std::expected +synthesize_rc(const mcpp::manifest::Package& pkg, + const mcpp::manifest::Resources& res, + std::string_view outputFileName, + const std::filesystem::path& iconAbs); + +} // namespace mcpp::build::resources + +namespace mcpp::build::resources { + +namespace { + +bool exists_file(const std::filesystem::path& p) { + std::error_code ec; + return std::filesystem::is_regular_file(p, ec); +} + +// Candidate tool names, most specific first. The triple-prefixed windres is the +// one that preprocesses with the matching cross gcc, so it must win over a bare +// `windres` that might belong to the host. +std::vector gnu_candidates(std::string_view triple) { + std::vector out; + if (!triple.empty()) { + out.push_back(std::string(triple) + "-windres"); + // GCC cross payloads spell the triple with a vendor field + // (x86_64-w64-mingw32) that mcpp's canonical form drops. + auto t = mcpp::toolchain::triple::parse(triple); + if (t && t->os == "windows" && t->env == "gnu") + out.push_back(t->arch + "-w64-mingw32-windres"); + } + out.push_back("windres"); + out.push_back("llvm-windres"); + return out; +} + +std::optional +probe_dir(const std::filesystem::path& dir, const std::vector& names) { + if (dir.empty()) return std::nullopt; + for (auto const& n : names) { + for (auto const& ext : {"", ".exe"}) { + auto p = dir / (n + ext); + if (exists_file(p)) return p; + } + } + return std::nullopt; +} + +// A run of `[0-9A-Za-z_]` starting at `i`. +std::string_view word_at(std::string_view s, std::size_t i) { + std::size_t j = i; + while (j < s.size() && (std::isalnum(static_cast(s[j])) || s[j] == '_')) ++j; + return s.substr(i, j - i); +} + +// Resource statements whose operand is a FILE. Anything else (STRINGTABLE, +// DIALOG, MENU, ACCELERATORS) carries its data inline. +bool is_file_resource_keyword(std::string_view w) { + return w == "ICON" || w == "BITMAP" || w == "CURSOR" || w == "FONT" + || w == "RCDATA" || w == "MESSAGETABLE" || w == "TYPELIB" + || w == "MANIFEST" || w == "HTML" || w == "ANICURSOR" || w == "ANIICON"; +} + +std::string escape_rc_string(std::string_view s) { + std::string out; + for (char c : s) { + if (c == '"' || c == '\\') out += '\\'; + if (c == '\n' || c == '\r') { out += ' '; continue; } + out += c; + } + return out; +} + +} // namespace + +std::vector split_env_list(std::string_view value) { + std::vector out; + while (!value.empty()) { + const auto sep = value.find(';'); + if (auto entry = value.substr(0, sep); !entry.empty()) + out.push_back(entry); + if (sep == std::string_view::npos) break; + value = value.substr(sep + 1); + } + return out; +} + +std::optional find_rc_tool(const mcpp::toolchain::Toolchain& tc, + std::string_view dialectId) { + const auto compilerDir = tc.binaryPath.parent_path(); + + if (dialectId == "msvc") { + // rc.exe comes from the Windows SDK, which the MSVC backend surfaces on + // the toolchain's own PATH override (never the host's). llvm-rc ships + // beside clang and is the fallback for clang + lld-link. + // + // The PATH walk is the PRIMARY route, not a fallback: rc.exe lives in + // the SDK's own bin directory, never next to cl.exe, so the probe above + // answers only for llvm-rc. + const std::vector names = {"rc", "llvm-rc"}; + if (auto p = probe_dir(compilerDir, names)) return RcTool{*p, "msvc"}; + for (auto const& ev : tc.envOverrides) { + if (ev.key != "PATH" && ev.key != "Path") continue; + for (auto dir : split_env_list(ev.value)) + if (auto p = probe_dir(std::filesystem::path(dir), names)) + return RcTool{*p, "msvc"}; + } + return std::nullopt; + } + + const auto names = gnu_candidates(tc.targetTriple); + if (auto p = probe_dir(compilerDir, names)) return RcTool{*p, "gnu"}; + // Cross payloads keep binutils in a sibling /bin. + if (!tc.targetTriple.empty()) { + auto root = compilerDir.parent_path(); + if (auto p = probe_dir(root / tc.targetTriple / "bin", names)) + return RcTool{*p, "gnu"}; + } + return std::nullopt; +} + +ScanResult scan_rc(const std::filesystem::path& rc) { + ScanResult out; + std::ifstream is(rc, std::ios::binary); + if (!is) return out; + std::string body{std::istreambuf_iterator(is), {}}; + const auto dir = rc.parent_path(); + + bool sawInclude = false, definesVersionInfoMacro = false; + // First pass: does anything in this file make VS_VERSION_INFO real? + // `#include ` (or any include — we cannot follow it, and a file + // that includes something has plausibly included the right thing) or an + // explicit `#define`. + for (std::size_t i = 0; i + 1 < body.size(); ++i) { + if (body[i] != '#') continue; + auto w = word_at(body, i + 1); + if (w == "include") sawInclude = true; + if (w == "define") { + auto j = i + 1 + w.size(); + while (j < body.size() && (body[j] == ' ' || body[j] == '\t')) ++j; + if (word_at(body, j) == "VS_VERSION_INFO") definesVersionInfoMacro = true; + } + } + + auto add_input = [&](std::string_view raw) { + std::filesystem::path p{std::string(raw)}; + auto abs = p.is_absolute() ? p : dir / p; + if (std::find(out.inputs.begin(), out.inputs.end(), abs) == out.inputs.end()) + out.inputs.push_back(std::move(abs)); + }; + + // Line-oriented: rc statements do not span lines in any form that matters + // here, and a line-based reader keeps the "what did I fail to understand" + // reporting precise. + std::size_t pos = 0; + while (pos <= body.size()) { + const auto nl = body.find('\n', pos); + std::string_view line{body.data() + pos, + (nl == std::string::npos ? body.size() : nl) - pos}; + pos = (nl == std::string::npos) ? body.size() + 1 : nl + 1; + + // Strip a trailing `//` comment; `/* */` is rare in .rc and a partial + // strip would be worse than none. + if (auto c = line.find("//"); c != std::string_view::npos) line = line.substr(0, c); + while (!line.empty() && (line.front() == ' ' || line.front() == '\t')) + line.remove_prefix(1); + if (line.empty()) continue; + + if (line.starts_with("#include")) { + auto q = line.find('"'); + if (q != std::string_view::npos) { + auto e = line.find('"', q + 1); + if (e != std::string_view::npos) add_input(line.substr(q + 1, e - q - 1)); + } + continue; // angled includes are the toolchain's, on purpose + } + if (line.front() == '#') continue; + + // ` ` — find the type keyword by scanning words. + std::size_t i = 0; + std::string_view prevWord; + while (i < line.size()) { + if (!(std::isalnum(static_cast(line[i])) || line[i] == '_')) { ++i; continue; } + auto w = word_at(line, i); + if (w.empty()) { ++i; continue; } + + if (w == "VERSIONINFO" && !prevWord.empty()) { + // The mcpp#365 shape: an identifier name that is not `1`. + const bool numeric = std::all_of(prevWord.begin(), prevWord.end(), + [](char c){ return std::isdigit(static_cast(c)); }); + if (!numeric && !sawInclude && !definesVersionInfoMacro) { + out.versionInfoNamedByString = true; + out.versionInfoName = std::string(prevWord); + } + } + if (is_file_resource_keyword(w)) { + auto rest = line.substr(i + w.size()); + while (!rest.empty() && (rest.front() == ' ' || rest.front() == '\t')) + rest.remove_prefix(1); + if (rest.starts_with('"')) { + auto e = rest.find('"', 1); + if (e != std::string_view::npos) add_input(rest.substr(1, e - 1)); + } else if (!rest.empty() && rest.front() != '{' + && !rest.starts_with("BEGIN")) { + // A macro, or a form the scanner does not model. Say so. + auto tok = word_at(rest, 0); + if (!tok.empty()) + out.gaps.push_back(std::format("{} {}", w, tok)); + } + } + prevWord = w; + i += w.size(); + } + } + return out; +} + +std::expected +synthesize_rc(const mcpp::manifest::Package& pkg, + const mcpp::manifest::Resources& res, + std::string_view outputFileName, + const std::filesystem::path& iconAbs) { + std::string out; + // ASCII throughout, including this banner: see the LegalCopyright note + // below for why generated text must not lean on the codepage flag. + out += "// Generated by mcpp from [package] and [resources]. Do not edit.\n"; + out += "// Copy this file into your project and list it in\n"; + out += "// [resources] files = [...] to take it over; the result is\n"; + out += "// byte-identical.\n\n"; + + if (!iconAbs.empty()) { + // Ordinal 1: Explorer and the shell show the LOWEST-numbered icon group. + out += std::format("1 ICON \"{}\"\n\n", + escape_rc_string(iconAbs.generic_string())); + } + + if (res.synthesize_version_info()) { + // A version with no numeric form (an upstream build number like + // `b10069`) leaves the four fields at zero and keeps the real text in + // the string fields — the properties dialog is then partly right rather + // than wrong. prepare.cppm reports that as a degradation; this function + // is pure and has no channel to say it on. + std::array f{0, 0, 0, 0}; + if (auto parsed = mcpp::version_req::parse_version(pkg.version); parsed) + for (std::size_t i = 0; i < 4; ++i) f[i] = parsed->seg(i); + for (std::size_t i = 0; i < 4; ++i) { + if (f[i] < 0 || f[i] > 0xFFFF) + return std::unexpected(std::format( + "[resources] cannot build a Windows FILEVERSION from " + "[package].version = \"{}\": field {} is {}, and each of the " + "four fields must fit in 16 bits (0-65535). Set " + "[resources.version-info] explicitly, or use a version whose " + "numeric parts fit.", + pkg.version, i + 1, f[i])); + } + + const std::string company = !res.info.company.empty() ? res.info.company + : (pkg.authors.empty() ? std::string{} : pkg.authors.front()); + const std::string product = !res.info.product.empty() ? res.info.product : pkg.name; + const std::string descr = !res.info.description.empty() ? res.info.description + : pkg.description; + const std::string internalName = !res.info.internalName.empty() + ? res.info.internalName : pkg.name; + const std::string origName = !res.info.originalFilename.empty() + ? res.info.originalFilename : std::string(outputFileName); + std::string copyright = res.info.copyright; + if (copyright.empty() && !company.empty()) { + // ASCII on purpose. A user's own metadata may be anything (the + // build passes the rc tool a UTF-8 codepage for exactly that + // reason), but text mcpp generates itself should not depend on + // that flag being right — an em dash here failed llvm-rc outright + // with "Non-ASCII 8-bit codepoint can't be interpreted in the + // current codepage". + copyright = std::format("(C) {}", company); + if (!pkg.license.empty()) copyright += std::format(" - {}", pkg.license); + } + + // `1`, not `VS_VERSION_INFO`. See the module header: the macro is only + // real when windows.h has been included, and without it the resource is + // filed under a string name that GetFileVersionInfo never looks up. + out += "1 VERSIONINFO\n"; + out += std::format(" FILEVERSION {},{},{},{}\n", f[0], f[1], f[2], f[3]); + out += std::format(" PRODUCTVERSION {},{},{},{}\n", f[0], f[1], f[2], f[3]); + out += " FILEFLAGSMASK 0x3fL\n"; + out += " FILEFLAGS 0x0L\n"; + out += " FILEOS 0x40004L\n"; // VOS_NT_WINDOWS32 + out += " FILETYPE 0x1L\n"; // VFT_APP + out += " FILESUBTYPE 0x0L\n"; + out += "BEGIN\n"; + out += " BLOCK \"StringFileInfo\"\n"; + out += " BEGIN\n"; + // 040904b0 = US English, Unicode. Paired with the Translation entry + // below; the two must agree or Windows reads neither. + out += " BLOCK \"040904b0\"\n"; + out += " BEGIN\n"; + auto value = [&](std::string_view k, std::string_view v) { + if (v.empty()) return; + out += std::format(" VALUE \"{}\", \"{}\"\n", k, escape_rc_string(v)); + }; + value("CompanyName", company); + value("FileDescription", descr); + value("FileVersion", pkg.version); + value("InternalName", internalName); + value("LegalCopyright", copyright); + value("OriginalFilename", origName); + value("ProductName", product); + value("ProductVersion", pkg.version); + out += " END\n"; + out += " END\n"; + out += " BLOCK \"VarFileInfo\"\n"; + out += " BEGIN\n"; + out += " VALUE \"Translation\", 0x409, 1200\n"; + out += " END\n"; + out += "END\n"; + } + return out; +} + +} // namespace mcpp::build::resources diff --git a/src/manifest/toml.cppm b/src/manifest/toml.cppm index dac1d68d..6958a8af 100644 --- a/src/manifest/toml.cppm +++ b/src/manifest/toml.cppm @@ -1056,6 +1056,65 @@ std::expected parse_string(std::string_view content, } } + // [resources] — metadata and assets compiled into the artifact (mcpp#365). + // See types.cppm for why the section is not named after Windows and why it + // is not conditionable. + if (auto v = doc->get_string("resources.icon")) m.resources.icon = *v; + if (auto v = doc->get_string_array("resources.files")) + for (auto& s : *v) m.resources.files.emplace_back(s); + if (auto v = doc->get_string_array("resources.extra-inputs")) + for (auto& s : *v) m.resources.extraInputs.emplace_back(s); + if (auto* res = doc->get_table("resources"); res && !res->empty()) { + // `version-info` is two things by design: `= false` opts out, and a + // `[resources.version-info]` table both opts IN and supplies overrides. + if (auto it = res->find("version-info"); it != res->end()) { + if (it->second.is_bool()) { + m.resources.versionInfo = it->second.as_bool(); + } else if (it->second.is_table()) { + m.resources.versionInfo = true; + auto& vi = it->second.as_table(); + auto str = [&](const char* k, std::string& dst) { + if (auto f = vi.find(k); f != vi.end() && f->second.is_string()) + dst = f->second.as_string(); + }; + str("company", m.resources.info.company); + str("product", m.resources.info.product); + str("description", m.resources.info.description); + str("copyright", m.resources.info.copyright); + str("original-filename", m.resources.info.originalFilename); + str("internal-name", m.resources.info.internalName); + static constexpr std::string_view kKnownVersionInfoKeys[] = { + "company", "product", "description", "copyright", + "original-filename", "internal-name", + }; + for (auto& [k, _] : vi) { + bool known = false; + for (auto kk : kKnownVersionInfoKeys) if (k == kk) { known = true; break; } + if (!known) + m.schemaWarnings.push_back(std::format( + "[resources.version-info] has unsupported key '{}' (ignored). " + "Fields: company, product, description, copyright, " + "original-filename, internal-name.", k)); + } + } else { + return std::unexpected(error(origin, + "[resources].version-info must be a boolean (`false` to opt out) " + "or a [resources.version-info] table of overrides")); + } + } + static constexpr std::string_view kKnownResourceKeys[] = { + "icon", "files", "extra-inputs", "version-info", + }; + for (auto& [k, _] : *res) { + bool known = false; + for (auto kk : kKnownResourceKeys) if (k == kk) { known = true; break; } + if (!known) + m.schemaWarnings.push_back(std::format( + "[resources] has unsupported key '{}' (ignored). Keys: icon, " + "files, extra-inputs, version-info.", k)); + } + } + // [lib] — library root convention (cargo-style). if (auto v = doc->get_string("lib.path")) { m.lib.path = *v; diff --git a/src/manifest/types.cppm b/src/manifest/types.cppm index 9947a7c1..a5d0f2d7 100644 --- a/src/manifest/types.cppm +++ b/src/manifest/types.cppm @@ -216,7 +216,7 @@ inline void append(BuildInputs& dst, const BuildInputs& src) { // once-per-prepare program can never be. So instead of DOING the work, the // program DECLARES it, and it becomes an edge in the build graph. // -// One primitive, three wirings. `role` is not three mechanisms — it is where +// One primitive, four wirings. `role` is not four mechanisms — it is where // the same edge's outputs attach: // // Source — outputs join the compile set (protoc, a transpiler) @@ -224,20 +224,48 @@ inline void append(BuildInputs& dst, const BuildInputs& src) { // format or ABI check). Runs alongside compilation by default, // because serialising every compile behind a linter is a cost // nobody accepts and "the build still fails" is just as true. +// Object — outputs join the LINK set (a resource compiler, objcopy +// embedding a blob, a generated .def, a pre-built .o) // Artifact — inputs are link outputs (codesign, packaging, size budgets) // +// `Object` completes the table (mcpp#365). The other three attach to the +// compile inputs, to nothing, and to the link OUTPUTS — leaving the link +// INPUTS, the one attachment point a build graph obviously has, inexpressible. +// The consequence was not theoretical: a Windows resource could only reach the +// linker by naming a pre-built `.res` in `[build].ldflags`, where it is a flat +// string in the link command rather than a file in the graph — so editing the +// icon produced "ninja: no work to do". A missing attachment point does not +// stop people; it makes them route around the graph. +// // INV-D, the constraint that makes this expressible at all: the declaration // must name its OUTPUT FILES, not merely promise some. mcpp fixes the source // set, the fingerprint, compile_commands.json and the module topo order during // prepare, and all of them need to know which files exist. Content may arrive // later; names may not. struct BuildAction { - enum class Role { Source, Check, Artifact }; + enum class Role { Source, Check, Object, Artifact }; std::string id; // diagnostics + edge naming Role role = Role::Source; std::vector inputs; // absolute or package-relative std::vector outputs; // ditto; declared, see INV-D + // Object only: which link units receive the outputs. Empty = every LINKED + // IMAGE of the declaring package — binary, shared library AND test binary. + // Test binaries are in the default set because they link the same library + // code: leaving them out made `mcpp build` pass and `mcpp test` fail with + // `undefined symbol` on the very symbol the action exists to provide. It is + // also the only workable default, because test link units are DISCOVERED + // from tests/*.cpp — their names are not in mcpp.toml, and a build.mcpp that + // spells one stops building under plain `mcpp build`, where it does not + // exist. (`[resources]` deliberately excludes them: an icon belongs to what + // the project ships, not to a test runner.) + // + // Artifact infers its target from `${mcpp.target_file:NAME}` appearing in + // its inputs; Object cannot, because it runs BEFORE the link and so has no + // link output to name. Naming the targets is the only honest option, and + // EVERY unknown name is an error — including one alongside a name that did + // match, which is the shape a typo actually takes. + std::vector targets; std::vector command; // argv; NOT a shell string // Serialised module facts for a generated OUTPUT, when it is a module // interface. Same "declare instead of discover" trade `[modules].scan_overrides` @@ -250,6 +278,66 @@ struct BuildAction { std::string description; }; +// `[resources]` — metadata and assets compiled INTO the produced artifact +// (mcpp#365). +// +// SCOPE. Today only PE targets consume this: `icon` becomes RT_GROUP_ICON and +// the version fields become an RT_VERSION resource. On ELF/Mach-O the whole +// section is INAPPLICABLE — not degraded, not skipped-with-a-warning: there is +// no consumer, the build is byte-identical, and nothing is said. That is why +// the section is spelled `[resources]` and not `[windows]`, and why it does not +// need (or accept) a `cfg(windows)` predicate: an icon is a cross-platform +// CONCEPT — only the file format and the embedding mechanism are per-OS — so a +// future macOS `.icns` / Linux `.desktop` consumer extends THIS section instead +// of splitting the axis three ways. It also could not live in the conditional +// channel: `[target.'cfg(...)'.build]` carries BuildInputs and nothing else. +// +// A DECLARED FILE THAT DOES NOT EXIST IS AN ERROR, deliberately, and this is a +// documented deviation from what #365 asked for. Every other declared input in +// mcpp behaves this way (`main = "..."` must match exactly one file, +// scan_overrides globs must match ≥1, a missing nasm is fatal), and "missing → +// silently skip" would institutionalise the very failure this feature exists to +// fix: a release binary shipping with no icon and no version metadata, with +// nothing in the build output saying so. Not wanting an icon is already +// expressible — delete the line. +struct ResourceVersionInfo { + std::string company; // default: [package].authors[0] + std::string product; // default: [package].name + std::string description; // default: [package].description + std::string copyright; // default: synthesised from authors/license + std::string originalFilename; // default: the produced file name + std::string internalName; // default: [package].name + + bool empty() const { + return company.empty() && product.empty() && description.empty() + && copyright.empty() && originalFilename.empty() && internalName.empty(); + } +}; + +struct Resources { + std::filesystem::path icon; // e.g. "assets/app.ico" + std::vector files; // author-written .rc sources + // Escape hatch for the .rc input scanner: a file name reached through a + // macro (`1 ICON APP_ICON`) is invisible to it. mcpp names what it could not + // resolve and points here — same "declare when discovery is not enough" + // trade as [modules].scan_overrides. + std::vector extraInputs; + // Unset = the default rule: synthesise a version resource unless the author + // supplied their own .rc (in which case they own the resource ID space). + std::optional versionInfo; + ResourceVersionInfo info; + + bool declared() const { + return !icon.empty() || !files.empty() || versionInfo.has_value() + || !info.empty() || !extraInputs.empty(); + } + // The 3-row rule from the design doc, in one place. + bool synthesize_version_info() const { + if (versionInfo.has_value()) return *versionInfo; + return files.empty(); + } +}; + // `[build]` section — tunables for the build backend. // // M5.0: now also carries `sources` (moved from [modules]) and `include_dirs` @@ -545,6 +633,7 @@ struct Manifest { Toolchain toolchain; // optional; empty == fallback BuildConfig buildConfig; + Resources resources; // [resources] (mcpp#365) RuntimeConfig runtimeConfig; XlingsConfig xlings; // [xlings] build environment (L-1) std::vector conditionalConfigs; // [target.'cfg(...)'.build], deferred diff --git a/src/manifest/xpkg.cppm b/src/manifest/xpkg.cppm index c982080d..25019339 100644 --- a/src/manifest/xpkg.cppm +++ b/src/manifest/xpkg.cppm @@ -21,9 +21,34 @@ struct McppField { std::string value; // glob path (StringPath) or table body (TableBody) }; McppField extract_mcpp_field(std::string_view luaContent); -// Extract the list of available versions for `platform` (e.g. "linux", "macosx", +// One entry of an xpkg .lua's `xpm.` table. +// +// `alias` marks `["25.0.4"] = { ref = "25.0.4.7.1" }` — a POINTER at another +// entry, not a release of its own. mcpp had no notion of this (mcpp#363): every +// quoted key counted as a version, so `jdk-temurin`'s candidate set was +// {latest, 25.0.4, 25.0.4+7} where two of the three point at the third. That +// produced a precedence TIE between an alias and its own target, and for +// `jdk-corretto` (`["25.0.4"] = { ref = "25.0.4.7.1" }`) a range constraint +// resolved to a five-segment key truncated to four — an address that does not +// exist. Aliases stay exactly addressable; they are simply not candidates when +// a RANGE is doing the choosing. +struct XpkgVersionEntry { + std::string version; // the literal key, as written + bool alias = false; // entry carries `ref = "..."` +}; + +// Extract the version entries for `platform` (e.g. "linux", "macosx", // "windows") from an xpkg .lua's xpm. = { ["X.Y.Z"] = {...}, ... }. +// Only TOP-LEVEL keys of the platform table are returned: an entry's own body +// is skipped, so a nested `["GLOBAL"] = "…"` mirror key inside a version's +// `url` table can never be mistaken for a version. // Returns an empty vector if the platform table is missing or has no entries. +std::vector +list_xpkg_version_entries(std::string_view luaContent, + const mcpp::platform::PlatformKey& platform); + +// Keys only, aliases included — the shape every caller that just wants to show +// or existence-check the published versions wants. std::vector list_xpkg_versions(std::string_view luaContent, const mcpp::platform::PlatformKey& platform); @@ -876,16 +901,16 @@ xpkg_name_form_violation_from_lua(std::string_view luaContent) extract_xpkg_name(luaContent)); } -std::vector -list_xpkg_versions(std::string_view luaContent, - const mcpp::platform::PlatformKey& platformAxis) { +std::vector +list_xpkg_version_entries(std::string_view luaContent, + const mcpp::platform::PlatformKey& platformAxis) { const std::string_view platform = platformAxis.key(); // Locate `xpm = { ... = { ["X.Y.Z"] = {...}, ... } ... }`. // We work on a sanitized copy so quoted version keys remain locatable // by their offsets in the original text. auto sanitized = strip_lua_comments_and_strings(luaContent); std::string_view text { sanitized }; - std::vector versions; + std::vector versions; auto find_word_at_lhs = [&](std::string_view name, std::size_t from) -> std::size_t @@ -943,27 +968,86 @@ list_xpkg_versions(std::string_view luaContent, auto plat_end = find_table_end(plat_open); if (plat_end == std::string_view::npos) return versions; - // Inside platform table: scan for ["X.Y.Z"] = { ... } + // Does this entry body declare `ref = ...` at its own top level? Nested + // tables are skipped so a `url = { ref = ... }` (no such shape today, but + // the scanner should not depend on that) cannot make a real release look + // like a pointer. + auto entry_is_alias = [&](std::size_t open, std::size_t end) -> bool { + int depth = 0; + std::size_t p = open + 1; + while (p < end) { + const char c = text[p]; + if (c == '{') { ++depth; ++p; continue; } + if (c == '}') { --depth; ++p; continue; } + if (depth == 0 && (c == 'r') && + (p == open + 1 || + (!std::isalnum(static_cast(text[p-1])) && text[p-1] != '_')) && + text.compare(p, 3, "ref") == 0) { + std::size_t after = p + 3; + const bool word_end = (after >= end || + (!std::isalnum(static_cast(text[after])) && text[after] != '_')); + std::size_t w = after; + while (w < end && (text[w] == ' ' || text[w] == '\t' || + text[w] == '\n' || text[w] == '\r')) ++w; + if (word_end && w < end && text[w] == '=') return true; + } + ++p; + } + return false; + }; + + // Inside platform table: scan for ["X.Y.Z"] = { ... }, skipping each + // entry's own body so nested bracket keys are not read as versions. std::size_t q = plat_open + 1; while (q < plat_end) { - if (text[q] == '[') { - std::size_t r = q + 1; - while (r < plat_end && (text[r] == ' ' || text[r] == '\t')) ++r; - if (r < plat_end && (text[r] == '"' || text[r] == '\'')) { - const char quote = text[r]; - ++r; - std::size_t key_start = r; - while (r < plat_end && text[r] != quote && text[r] != '\n') ++r; - if (r < plat_end && text[r] == quote) { - versions.emplace_back(luaContent.substr(key_start, r - key_start)); - } + if (text[q] != '[') { ++q; continue; } + std::size_t r = q + 1; + while (r < plat_end && (text[r] == ' ' || text[r] == '\t')) ++r; + if (r >= plat_end || (text[r] != '"' && text[r] != '\'')) { ++q; continue; } + const char quote = text[r]; + ++r; + const std::size_t key_start = r; + while (r < plat_end && text[r] != quote && text[r] != '\n') ++r; + if (r >= plat_end || text[r] != quote) { ++q; continue; } + XpkgVersionEntry e; + e.version = std::string(luaContent.substr(key_start, r - key_start)); + + // Past the closing quote: `] = `. Only a table value can be an + // alias, and only a table value has a body worth skipping. + std::size_t v = r + 1; + auto skip_blank = [&] { + while (v < plat_end && (text[v] == ' ' || text[v] == '\t' || + text[v] == '\n' || text[v] == '\r')) ++v; + }; + skip_blank(); + if (v < plat_end && text[v] == ']') ++v; + skip_blank(); + if (v < plat_end && text[v] == '=') ++v; + skip_blank(); + if (v < plat_end && text[v] == '{') { + const auto entry_end = find_table_end(v); + if (entry_end != std::string_view::npos && entry_end <= plat_end) { + e.alias = entry_is_alias(v, entry_end); + versions.push_back(std::move(e)); + q = entry_end + 1; + continue; } } - ++q; + versions.push_back(std::move(e)); + q = r + 1; } return versions; } +std::vector +list_xpkg_versions(std::string_view luaContent, + const mcpp::platform::PlatformKey& platformAxis) { + std::vector out; + for (auto& e : list_xpkg_version_entries(luaContent, platformAxis)) + out.push_back(std::move(e.version)); + return out; +} + // Parses the `{ { glob = "...", cflags/cxxflags/asmflags/defines = {...} }, // ... }` array-of-tables shape shared by `[build]`-level `flags` and #253's // `features..flags` — one entry grammar, two anchoring keys. `ctxLabel` diff --git a/src/pm/lock_io.cppm b/src/pm/lock_io.cppm index a40a50a2..eb2ef5a8 100644 --- a/src/pm/lock_io.cppm +++ b/src/pm/lock_io.cppm @@ -133,6 +133,22 @@ std::expected load(const std::filesystem::path& path) { std::string serialize(const Lockfile& lock) { std::string out; out += "# Auto-generated by mcpp. Do not edit by hand.\n"; + // Say what this file is, in the file. As of mcpp#363 the recorded versions + // are the ones the build actually resolved — but only git branch entries are + // read back (as resolution anchors, #329); an index dependency is re-resolved + // from its constraint on every build. A file that records a real version + // while pinning nothing is easier to mistake for authoritative than one that + // obviously records a range, so the limit is stated here rather than in the + // docs. When the lock does become authoritative, DELETE this line — e2e + // asserts on it, so the assertion turns red and the removal cannot be + // forgotten. + out += "# Records what this build resolved. It does not yet pin future " + "builds:\n" + "# index dependencies are re-resolved from their constraints each " + "time.\n" + "# dev-dependencies are excluded: only `mcpp test` resolves them, and " + "this\n" + "# file must not change depending on which command ran last.\n"; out += std::format("version = {}\n", lock.schemaVersion); // Write [indices.] sections. diff --git a/src/pm/resolver.cppm b/src/pm/resolver.cppm index 16d01a26..e59b0e54 100644 --- a/src/pm/resolver.cppm +++ b/src/pm/resolver.cppm @@ -1,6 +1,16 @@ // mcpp.pm.resolver — turn a SemVer constraint into a concrete version, // using the package's xpkg lua descriptor as the version inventory. // +// WHAT THIS RETURNS IS AN INDEX KEY, NOT A RENDERING (mcpp#363) +// +// The resolved string flows into the xlings wire address, the store directory +// and mcpp.lock, so it must be a key the index literally holds. This function +// used to return `parsed[i].str()` — the parsed numbers re-rendered — which +// cannot reproduce `1.92.8-docking` (prerelease), `b10069` (not a number) or +// `25.0.4.7.1` (five segments, truncated to four). The literal was already in +// hand and was thrown away. It now travels alongside the order, and +// `version_req` is only ever asked to SORT. +// // Part of the package-management subsystem refactor (PR-R4 in // `.agents/docs/2026-05-08-pm-subsystem-architecture.md`), originally // pulled out of `cli.cppm` verbatim. @@ -117,6 +127,43 @@ resolve_semver(std::string_view ns, std::string_view shortName, refreshable ? " — run `mcpp index update` first" : "")); } + auto entries = mcpp::manifest::list_xpkg_version_entries(*luaContent, platform); + + // An exact constraint naming a published key IS the answer, before any + // parsing. Two things depend on this short-circuit: + // + // * `= pre-v0.0.5` has to work. It is the documented remedy the + // unorderable-key error below hands out, and it reaches here through + // try_merge_semver (which canonicalises a literal pin to `=`). + // Routing it through the SemVer grammar would reject the very form the + // error message just told the user to write. + // * `= 1.0.0+a` has to select `1.0.0+a`. SemVer excludes build metadata + // from precedence, so comparing by order alone makes it ambiguous with + // `1.0.0+b` — while the two literals are not ambiguous at all. + // + // Aliases are eligible here: pinning `latest` or `25.0.4` exactly is a + // legitimate address, and only RANGE selection has to ignore pointers. + // + // The `=` prefix is REQUIRED, and the guard is load-bearing rather than + // decorative. A bare `1.2.3` is caret-default in this grammar (see + // version_req.cppm), so matching it literally here would turn every + // `dep = "1.2.3"` into an exact pin the moment the index happens to publish + // that key — silently disabling `^`. Callers never send a bare literal + // (`prepare`, `index_refresh` and `mcpp add` all gate on + // `is_version_constraint`, and `try_merge_semver` canonicalises a literal + // pin to `=`), but that invariant lives in the callers, so this + // function refuses to depend on it. + if (constraint.starts_with('=')) { + auto exact = constraint.substr(1); + while (!exact.empty() && (exact.front() == ' ' || exact.front() == '\t')) + exact.remove_prefix(1); + while (!exact.empty() && (exact.back() == ' ' || exact.back() == '\t')) + exact.remove_suffix(1); + if (!exact.empty() && exact != "*") + for (auto const& e : entries) + if (e.version == exact) return e.version; + } + auto req = vr::parse_req(constraint); if (!req) { return std::unexpected(std::format( @@ -124,34 +171,90 @@ resolve_semver(std::string_view ns, std::string_view shortName, qname, constraint, req.error())); } - auto rawVersions = mcpp::manifest::list_xpkg_versions(*luaContent, platform); - if (rawVersions.empty()) { + if (entries.empty()) { return std::unexpected(std::format( "dependency '{}': index entry has no versions for platform '{}'", qname, platform.key())); } + // Split the published keys three ways. The LITERAL travels with the order: + // what this function returns has to be a key the index actually holds, and + // no rendering of the parsed numbers can promise that (mcpp#363). + std::vector literals; // candidate keys, index-aligned with `parsed` std::vector parsed; - parsed.reserve(rawVersions.size()); - for (auto& s : rawVersions) { - auto v = vr::parse_version(s); - if (!v) continue; // ignore unparseable entries - parsed.push_back(*v); + std::vector unorderable; // real entries whose key has no order + std::vector aliases; // `{ ref = "..." }` pointers + for (auto& e : entries) { + if (e.alias) { aliases.push_back(e.version); continue; } + auto v = vr::parse_version(e.version); + if (!v) { unorderable.push_back(e.version); continue; } + literals.push_back(e.version); + parsed.push_back(std::move(*v)); } + + auto join = [](const std::vector& v) { + std::string s; + for (auto& x : v) { if (!s.empty()) s += ", "; s += x; } + return s; + }; + // "Pin it exactly" is the whole remedy for an unorderable key, so the hint + // carries a line the user can paste. + auto pin_hint = [&](const std::vector& keys) { + return std::format( + "\n These keys are not ordered versions, so no range can address " + "them — pin one exactly:\n {} = \"{}\"", + qname, keys.front()); + }; + if (parsed.empty()) { + // Blaming the index for having "no valid versions" is what this used to + // do, and it is false: `khistory` publishes exactly one release, keyed + // `pre-v0.0.5`, which is perfectly installable — just not by a range. + if (!unorderable.empty()) { + return std::unexpected(std::format( + "dependency '{}': constraint '{}' cannot be resolved. The index " + "publishes [{}]{}", + qname, constraint, join(unorderable), pin_hint(unorderable))); + } + if (!aliases.empty()) { + return std::unexpected(std::format( + "dependency '{}': the index entry for platform '{}' has only " + "alias versions [{}] and no release to point at", + qname, platform.key(), join(aliases))); + } return std::unexpected(std::format( - "dependency '{}': no valid versions in index", qname)); + "dependency '{}': index entry has no versions for platform '{}'", + qname, platform.key())); } - auto idx = vr::choose(*req, parsed); - if (!idx) { - std::string avail; - for (auto& s : rawVersions) { if (!avail.empty()) avail += ", "; avail += s; } - return std::unexpected(std::format( + auto best = vr::choose_all(*req, parsed); + if (best.empty()) { + auto msg = std::format( "dependency '{}': constraint '{}' matches none of: [{}]", - qname, constraint, avail)); + qname, constraint, join(literals)); + if (!unorderable.empty()) + msg += std::format("\n (also published, but not orderable: [{}]){}", + join(unorderable), pin_hint(unorderable)); + return std::unexpected(msg); + } + if (best.size() > 1) { + // Distinct keys that compare equal — build metadata (`1.0.0+a` vs + // `1.0.0+b`), which SemVer excludes from precedence. They are two + // different tarballs with two different hashes, and nothing in the + // ordering can say which one was meant. Choosing "the larger literal" + // would be a guess wearing determinism's clothes, and picking by + // whichever line came first in the descriptor (the old behaviour) makes + // a cosmetic reordering of the index change what gets built. + std::vector tied; + for (auto i : best) tied.push_back(literals[i]); + return std::unexpected(std::format( + "dependency '{}': constraint '{}' matches {} versions that compare " + "EQUAL: [{}]. They differ only in build metadata, which SemVer " + "excludes from precedence, so mcpp cannot tell which one you want " + "— pin one exactly:\n {} = \"{}\"", + qname, constraint, tied.size(), join(tied), qname, tied.front())); } - return parsed[*idx].str(); + return literals[best.front()]; } // ─── Namespace-aware try_merge_semver (canonical, 0.0.10+) ─────────── @@ -172,7 +275,14 @@ try_merge_semver(std::string_view ns, std::string_view shortName, std::string ca = canon(a); std::string cb = canon(b); std::string merged; - if (!ca.empty() && !cb.empty()) merged = ca + "," + cb; + // Two consumers asking for the SAME thing is not a merge. Defensive rather + // than a live fix — prepare only reaches here when the two RESOLVED versions + // differ, and identical constraints resolve identically — but the failure it + // prevents is silent and total: `=pre-v0.0.5,=pre-v0.0.5` routes an + // unorderable key through the SemVer grammar, which rejects the very form + // the unorderable-key error tells users to write. + if (ca == cb) merged = ca.empty() ? "*" : ca; + else if (!ca.empty() && !cb.empty()) merged = ca + "," + cb; else if (!ca.empty()) merged = ca; else if (!cb.empty()) merged = cb; else merged = "*"; diff --git a/src/version.cppm b/src/version.cppm index c1a142d8..86b874ae 100644 --- a/src/version.cppm +++ b/src/version.cppm @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.8.6.3"; +inline constexpr std::string_view MCPP_VERSION = "2026.8.7.1"; } // namespace mcpp diff --git a/src/version_req.cppm b/src/version_req.cppm index 3f22cd9a..b72976a9 100644 --- a/src/version_req.cppm +++ b/src/version_req.cppm @@ -1,4 +1,4 @@ -// mcpp.version_req — parse + match a SemVer-subset requirement grammar. +// mcpp.version_req — parse + match a SemVer requirement grammar. // // Grammar (subset): // "1.2.3" → caret-default: >=1.2.3, <2.0.0 @@ -9,18 +9,34 @@ // "*" → any // "" → any (treated as *) // -// Versions: major.minor.patch[.revision] with all parts ≥ 0; missing parts -// default to 0 (e.g. "1.2" == "1.2.0", "1" == "1.0.0"). +// A VERSION IS AN ORDER, NOT AN IDENTITY (mcpp#363) // -// The fourth segment exists for mcpp's own date-based scheme (YYYY.M.D.N, -// e.g. "2026.7.27.1"). Without it the parser silently truncated, making every -// release of a given day compare EQUAL — which quietly disabled the E0006 -// index-floor check. Ordinary three-segment SemVer is unaffected: the fourth -// component is 0 on both sides, so ^ / ~ / = behave exactly as before. +// What this module produces is a place in a total order. It is NOT the thing +// that addresses a package: `Version::str()` is a rendering, and a rendering +// cannot reproduce an arbitrary index key. Before #363, pm/resolver.cppm parsed +// the index's literal version keys, threw them away, and re-rendered an address +// out of the parsed numbers — so `1.92.8-docking` and `25.0.4.7.1` resolved to +// addresses (`1.92.8`, `25.0.4.7`) that do not exist. The literal key now travels +// with the order (`pm::VersionCandidate`), and `str()` is DISPLAY ONLY. Do not +// re-introduce a code path that turns a Version back into an address. // -// Pre-release / build metadata are NOT supported in M4 V1 — versions -// containing '-' or '+' are still parsed by stripping after first such -// char (matches semver "prerelease ignored for M4 V1" stance). +// Numbers: an arbitrary-length dot-separated list, all parts ≥ 0; absent parts +// compare as 0 (so "1.2" == "1.2.0" == "1.2.0.0"). Fixed at four segments until +// #363: mcpp's own scheme needs a fourth (YYYY.M.D.N), and the real index has +// five-segment keys (`jdk-corretto` publishes `25.0.4.7.1`), which used to be +// truncated to four — making `25.0.4.7.1` and a hypothetical `25.0.4.7.2` +// compare EQUAL. The list has no length limit for the same reason the fourth +// segment was added: a truncating comparison silently merges distinct releases. +// +// Pre-release (`-rc.1`) is ordered per SemVer §11: a release outranks any +// pre-release of the same numbers, and identifiers compare dot-segment by +// dot-segment with numeric < alphanumeric. +// +// Build metadata (`+7`) is deliberately NOT stored: SemVer §10 excludes it from +// precedence, so two keys differing only in metadata are EQUAL here. They are +// still different addresses — which is exactly why the literal has to be carried +// separately, and why the resolver treats such a tie as an error rather than +// picking one. export module mcpp.version_req; @@ -28,37 +44,72 @@ import std; export namespace mcpp::version_req { +// One dot-separated pre-release identifier. SemVer §11.4: numeric identifiers +// always rank below alphanumeric ones; numeric compare numerically (so `rc.9` < +// `rc.10`), alphanumeric compare by ASCII. +struct PreId { + bool numeric = false; + std::uint64_t num = 0; // when `numeric` + std::string text; // when !`numeric` + + std::strong_ordering operator<=>(const PreId& o) const { + if (numeric != o.numeric) + return numeric ? std::strong_ordering::less : std::strong_ordering::greater; + if (numeric) return num <=> o.num; + return text <=> o.text; + } + bool operator==(const PreId& o) const { return (*this <=> o) == 0; } +}; + struct Version { - int major = 0, minor = 0, patch = 0, revision = 0; - // How many segments the source string actually wrote. Only str() reads - // it — it must NOT take part in ordering, or "1.2" and "1.2.0" would - // compare unequal. - int components = 0; - - // Explicit, not `= default`: the defaulted <=> would compare - // `components` too. Order is the four numbers, nothing else. + // Dot-separated numeric segments as WRITTEN. Absent segments read as 0 via + // seg(); the stored length only affects str(). + std::vector nums; + std::vector prerelease; // empty = a release + + std::int64_t seg(std::size_t i) const { return i < nums.size() ? nums[i] : 0; } + bool isPrerelease() const { return !prerelease.empty(); } + + // Named accessors for the first four segments. They exist because most + // callers (and every diagnostic) think in major/minor/patch, not in list + // indices — the list is the storage, not the vocabulary. + std::int64_t major() const { return seg(0); } + std::int64_t minor() const { return seg(1); } + std::int64_t patch() const { return seg(2); } + std::int64_t revision() const { return seg(3); } + std::size_t components() const { return nums.size(); } + + // Explicit, not `= default`: a defaulted <=> would compare the vectors + // element-wise and make "1.2" != "1.2.0". std::strong_ordering operator<=>(const Version& o) const { - if (auto c = major <=> o.major; c != 0) return c; - if (auto c = minor <=> o.minor; c != 0) return c; - if (auto c = patch <=> o.patch; c != 0) return c; - return revision <=> o.revision; + const std::size_t n = std::max(nums.size(), o.nums.size()); + for (std::size_t i = 0; i < n; ++i) + if (auto c = seg(i) <=> o.seg(i); c != 0) return c; + // SemVer §11.3 — a release outranks any pre-release of the same numbers. + if (prerelease.empty() != o.prerelease.empty()) + return prerelease.empty() ? std::strong_ordering::greater + : std::strong_ordering::less; + const std::size_t m = std::min(prerelease.size(), o.prerelease.size()); + for (std::size_t i = 0; i < m; ++i) + if (auto c = prerelease[i] <=> o.prerelease[i]; c != 0) return c; + return prerelease.size() <=> o.prerelease.size(); } bool operator==(const Version& o) const { return (*this <=> o) == 0; } - // Three segments are the floor, so every version that parsed before this - // field existed still renders byte-identically ("1.2" → "1.2.0"). The - // fourth is appended only when the source actually wrote it. - // - // Load-bearing: pm/resolver.cppm returns str() as the RESOLVED dependency - // version, which flows into the lock file and the xlings wire address — - // so this has to reproduce the index's literal version key. In particular - // a date version ending in ".0" (mcpp's formal-release convention) must - // stay four segments; collapsing it to "2026.8.1" would address a key - // that does not exist. - std::string str() const { - auto base = std::format("{}.{}.{}", major, minor, patch); - return components >= 4 ? std::format("{}.{}", base, revision) : base; + // Do the NUMERIC parts match? The pre-release visibility rule (see + // `matches`) is defined on the numeric tuple alone. + bool same_numbers(const Version& o) const { + const std::size_t n = std::max(nums.size(), o.nums.size()); + for (std::size_t i = 0; i < n; ++i) + if (seg(i) != o.seg(i)) return false; + return true; } + + // DISPLAY ONLY (see the header note). Three segments are the floor, so + // everything that rendered before #363 renders byte-identically; a written + // fourth (or fifth) segment is preserved, and build metadata is gone + // because it was never parsed. + std::string str() const; }; std::expected parse_version(std::string_view s); @@ -79,8 +130,17 @@ std::expected parse_req(std::string_view s); bool matches(const Requirement& r, const Version& v); +// Indices of ALL versions in `available` that match `req` and tie for highest +// precedence. Normally one element. More than one means distinct entries +// compare EQUAL — only possible when they differ solely in build metadata +// (`1.0.0+a` vs `1.0.0+b`) or in insignificant trailing zeros. The caller holds +// the literal keys and is the only one that can say whether that is benign, so +// the tie is REPORTED rather than broken here. +std::vector +choose_all(const Requirement& req, const std::vector& available); + // Pick the highest version from `available` matching `req`. Returns the -// chosen version's index, or nullopt if none match. +// chosen version's index, or nullopt if none match. On a tie, the first. std::optional choose(const Requirement& req, const std::vector& available); @@ -88,30 +148,129 @@ choose(const Requirement& req, const std::vector& available); namespace mcpp::version_req { +std::string Version::str() const { + std::string out; + const std::size_t n = std::max(nums.size(), 3); + for (std::size_t i = 0; i < n; ++i) { + if (i) out += '.'; + out += std::to_string(seg(i)); + } + if (!prerelease.empty()) { + out += '-'; + for (std::size_t i = 0; i < prerelease.size(); ++i) { + if (i) out += '.'; + out += prerelease[i].numeric ? std::to_string(prerelease[i].num) + : prerelease[i].text; + } + } + return out; +} + +namespace { + +bool is_ident_char(char c) { + return std::isalnum(static_cast(c)) || c == '-'; +} + +// A run of digits with a leading zero is NOT treated as numeric. SemVer forbids +// leading zeros outright; erroring would reject an index key over a rule the +// index never signed up for, so it is compared as text instead. Either way +// `01` and `1` stay distinguishable, which is the property that matters. +PreId make_pre_id(std::string_view s) { + PreId id; + const bool allDigits = !s.empty() && + std::all_of(s.begin(), s.end(), + [](char c){ return std::isdigit(static_cast(c)); }); + if (allDigits && (s.size() == 1 || s.front() != '0')) { + std::uint64_t n = 0; + bool overflow = false; + for (char c : s) { + if (n > (std::numeric_limits::max() - 9) / 10) { overflow = true; break; } + n = n * 10 + static_cast(c - '0'); + } + if (!overflow) { id.numeric = true; id.num = n; return id; } + } + id.text = std::string(s); + return id; +} + +} // namespace + std::expected parse_version(std::string_view s) { - // Strip prerelease/build metadata for M4 V1. - if (auto dash = s.find_first_of("-+"); dash != std::string_view::npos) { - s = s.substr(0, dash); + const std::string_view original = s; + + // Build metadata: everything after the first '+'. Not stored (SemVer §10 + // excludes it from precedence) but still validated, so a malformed key is + // reported as unorderable rather than silently truncated. + if (auto plus = s.find('+'); plus != std::string_view::npos) { + auto meta = s.substr(plus + 1); + if (meta.empty()) + return std::unexpected(std::format("version: empty build metadata ('{}')", original)); + for (char c : meta) + if (!is_ident_char(c) && c != '.') + return std::unexpected(std::format( + "version: invalid build metadata ('{}')", original)); + s = s.substr(0, plus); } + + // Pre-release: everything after the first '-'. + std::string_view pre; + if (auto dash = s.find('-'); dash != std::string_view::npos) { + pre = s.substr(dash + 1); + s = s.substr(0, dash); + if (pre.empty()) + return std::unexpected(std::format("version: empty pre-release ('{}')", original)); + } + Version v; - int* parts[4] = { &v.major, &v.minor, &v.patch, &v.revision }; - int idx = 0; + // Numeric core: dot-separated digit runs, nothing else. Trailing garbage is + // an ERROR, not something to stop at: `1.2.3abc` used to parse as 1.2.3 and + // therefore compared EQUAL to it — the same silent merge the fourth and + // fifth segments exist to prevent. An unparseable key is not a failure, it + // is a key that only exact matching can address (see pm/resolver.cppm). std::size_t i = 0; - while (idx < 4 && i <= s.size()) { - std::size_t start = i; + while (true) { + const std::size_t start = i; while (i < s.size() && std::isdigit(static_cast(s[i]))) ++i; - if (start == i) { - if (idx == 0) - return std::unexpected(std::format("version: not a number ('{}')", s)); - break; // missing minor/patch → 0 + if (start == i) + return std::unexpected(std::format("version: not a number ('{}')", original)); + std::uint64_t n = 0; + for (std::size_t k = start; k < i; ++k) { + if (n > (static_cast( + std::numeric_limits::max()) - 9) / 10) + return std::unexpected(std::format( + "version: segment out of range ('{}')", original)); + n = n * 10 + static_cast(s[k] - '0'); } - int n = 0; - for (std::size_t k = start; k < i; ++k) n = n * 10 + (s[k] - '0'); - *parts[idx++] = n; - if (i < s.size() && s[i] == '.') ++i; - else break; + v.nums.push_back(static_cast(n)); + if (i == s.size()) break; + if (s[i] != '.') + return std::unexpected(std::format("version: not a number ('{}')", original)); + ++i; + if (i == s.size()) + return std::unexpected(std::format("version: trailing '.' ('{}')", original)); + } + + // Pre-release identifiers: dot-separated, each non-empty and made of + // [0-9A-Za-z-]. + while (!pre.empty()) { + const auto dot = pre.find('.'); + const auto part = pre.substr(0, dot); + if (part.empty()) + return std::unexpected(std::format( + "version: empty pre-release identifier ('{}')", original)); + for (char c : part) + if (!is_ident_char(c)) + return std::unexpected(std::format( + "version: invalid pre-release identifier '{}' ('{}')", part, original)); + v.prerelease.push_back(make_pre_id(part)); + if (dot == std::string_view::npos) break; + pre = pre.substr(dot + 1); + if (pre.empty()) + return std::unexpected(std::format( + "version: trailing '.' in pre-release ('{}')", original)); } - v.components = idx; + return v; } @@ -143,6 +302,21 @@ std::expected parse_comparator(std::string_view s) { return Comparator{op, *v}; } +// SemVer/npm/Cargo pre-release visibility: a pre-release candidate is only +// eligible when the requirement itself names a pre-release at the SAME numeric +// tuple. Two bugs collapse into this one rule: +// +// * `^1.92.8` must not silently pick `1.92.8-docking` — a different upstream +// branch, a different tarball (mcpp#363). +// * `^1.2.3` must not admit `2.0.0-alpha`, which the plain `v < upper` bound +// lets through because 2.0.0-alpha sorts below 2.0.0. +bool prerelease_visible(const Requirement& r, const Version& v) { + if (r.any) return false; // `*` never reaches a pre-release + for (auto& c : r.parts) + if (c.v.isPrerelease() && c.v.same_numbers(v)) return true; + return false; +} + } // namespace std::expected parse_req(std::string_view s) { @@ -164,6 +338,7 @@ std::expected parse_req(std::string_view s) { } bool matches(const Requirement& r, const Version& v) { + if (v.isPrerelease() && !prerelease_visible(r, v)) return false; if (r.any) return true; for (auto& c : r.parts) { switch (c.op) { @@ -175,22 +350,31 @@ bool matches(const Requirement& r, const Version& v) { case Op::Caret: { // ^X.Y.Z = >=X.Y.Z, <(X+1).0.0 (leftmost-nonzero rule) // For simplicity here: bump major; if major==0 bump minor; if both 0 bump patch. - // Every branch must also zero `revision`, or the upper bound - // inherits the constraint's own fourth segment and wrongly - // excludes releases below it (^2026.7.27.3 would cut off - // 2027.0.0.0..2). + // Every branch must also zero the segments AFTER the bumped one, + // or the upper bound inherits the constraint's own tail and + // wrongly excludes releases below it (^2026.7.27.3 would cut off + // 2027.0.0.0..2). The bound is a release, never a pre-release: + // `upper` drops any the constraint carried. Version upper = c.v; - upper.revision = 0; - if (c.v.major != 0) { ++upper.major; upper.minor = 0; upper.patch = 0; } - else if (c.v.minor != 0) { ++upper.minor; upper.patch = 0; } - else { ++upper.patch; } + upper.prerelease.clear(); + auto bump_at = [&](std::size_t idx) { + upper.nums.resize(std::max(upper.nums.size(), idx + 1), 0); + ++upper.nums[idx]; + upper.nums.resize(idx + 1); + }; + if (c.v.major() != 0) bump_at(0); + else if (c.v.minor() != 0) bump_at(1); + else bump_at(2); if (!(v >= c.v && v < upper)) return false; break; } case Op::Tilde: { // ~X.Y.Z = >=X.Y.Z, (upper.nums.size(), 2), 0); + ++upper.nums[1]; + upper.nums.resize(2); if (!(v >= c.v && v < upper)) return false; break; } @@ -199,14 +383,23 @@ bool matches(const Requirement& r, const Version& v) { return true; } -std::optional -choose(const Requirement& req, const std::vector& available) { - std::optional best; +std::vector +choose_all(const Requirement& req, const std::vector& available) { + std::vector best; for (std::size_t i = 0; i < available.size(); ++i) { if (!matches(req, available[i])) continue; - if (!best || available[i] > available[*best]) best = i; + if (best.empty()) { best.push_back(i); continue; } + if (available[i] > available[best.front()]) { best.assign(1, i); continue; } + if (available[i] == available[best.front()]) best.push_back(i); } return best; } +std::optional +choose(const Requirement& req, const std::vector& available) { + auto best = choose_all(req, available); + if (best.empty()) return std::nullopt; + return best.front(); +} + } // namespace mcpp::version_req diff --git a/tests/e2e/169_semver_project_index.sh b/tests/e2e/169_semver_project_index.sh index a5d5568c..f1cdfe3a 100755 --- a/tests/e2e/169_semver_project_index.sh +++ b/tests/e2e/169_semver_project_index.sh @@ -132,8 +132,13 @@ grep -q '\[package\."acme.gadget"\]' mcpp.lock || { } # Only 2.1.0 is seeded above, so a build that got here at all consumed the # resolved version rather than falling back to the constraint's lower bound. -# (The lockfile records the CONSTRAINT, not the pin — pre-existing behaviour -# shared with registry deps, not something this test is asserting about.) +# The lock records that resolution (mcpp#363 — it used to record the constraint +# `^2.0`, which locks nothing); 196 owns the full set of assertions about it. +grep -q 'version = "2.1.0"' mcpp.lock || { + cat mcpp.lock + echo "FAIL: the lock must record the resolved version, not the constraint" + exit 1 +} "$MCPP" run > run.log 2>&1 || { cat run.log; echo "FAIL: run failed"; exit 1; } diff --git a/tests/e2e/188_build_actions.sh b/tests/e2e/188_build_actions.sh index 4080c8a3..1d88f0c4 100755 --- a/tests/e2e/188_build_actions.sh +++ b/tests/e2e/188_build_actions.sh @@ -9,10 +9,14 @@ # reported as "build.mcpp exited 1". Declared as a node it is incremental, # parallel and attributable to the edge that failed. # -# All three wirings of the one primitive: +# All four wirings of the one primitive: # source — outputs join the compile set, and are REGENERATED when an input # changes (the property the eager path can never have) # check — outputs are a stamp; a failing check fails the build +# object — outputs join the LINK set (mcpp#365). Without it the link inputs +# were the one attachment point a build graph obviously has and +# this table could not express, so people routed around the graph +# through `[build].ldflags` and lost incrementality. # artifact — inputs are link outputs, so ninja orders it after the link with # no phase machinery at all # @@ -251,6 +255,184 @@ fi grep -q "no_such_target" b3c.log || { cat b3c.log; echo "FAIL: error does not name the unknown target"; exit 1; } +# ── 3d. role = "object": outputs join the LINK set ───────────────────────── +# +# The fourth wiring (mcpp#365). Source attaches to the compile inputs, Artifact +# to the link outputs, Check to nothing — which left the link INPUTS, an +# obvious attachment point, inexpressible. The consequence was concrete: a +# pre-built object could only reach the linker by being named in +# `[build].ldflags`, where it is a flat string in the command rather than a file +# in the graph, so editing it produced "ninja: no work to do". +# +# The object is produced by THE COMPILER MCPP ITSELF RESOLVED, read out of the +# generated build.ninja. Reaching for the host's `cc` looks simpler and is not: +# on a machine with xlings shims installed, a bare `cc` can resolve to a +# dispatcher pointing at some other sandbox, and the object would either fail to +# build or be built by a compiler whose ABI has nothing to do with the link. +mkdir -p "$TMP/objrole/src" +cd "$TMP/objrole" +cat > mcpp.toml <<'EOF' +[package] +name = "objrole" +version = "0.1.0" +EOF +printf 'int main() { return 0; }\n' > src/main.cpp +"$MCPP" build > o0.log 2>&1 || { cat o0.log; echo "FAIL: probe build failed"; exit 1; } +OBJ_NINJA=$(find target -name build.ninja | head -1) +OBJ_CXX=$(sed -n 's/^cxx *= *//p' "$OBJ_NINJA" | head -1) +[ -n "$OBJ_CXX" ] || { cat "$OBJ_NINJA"; echo "FAIL: could not read the compiler out of build.ninja"; exit 1; } + +cat > src/main.cpp <<'EOF' +#include +extern "C" int blob_value(); +int main() { std::printf("BLOB=%d\n", blob_value()); return blob_value() == 7 ? 0 : 1; } +EOF +# extern "C" keeps the symbol unmangled without needing a C driver. +printf 'extern "C" int blob_value() { return 7; }\n' > blob.cpp +cat > mkobj.sh < build.mcpp <<'EOF' +#include +#include +import mcpp; +int main() { + const std::string root = mcpp::manifest_dir(); + const std::string out = mcpp::out_dir(); + mcpp::action o; + o.id = "blob"; o.role = "object"; + o.arg((root + "/mkobj.sh").c_str()) + .arg((root + "/blob.cpp").c_str()) + .arg((out + "/blob.o").c_str()) + .input((root + "/blob.cpp").c_str()) + .output((out + "/blob.o").c_str()) + .submit(); +} +EOF +"$MCPP" build > o1.log 2>&1 || { cat o1.log; echo "FAIL: role=object build failed"; exit 1; } +"$MCPP" run > o2.log 2>&1 || { cat o2.log; echo "FAIL: the object was not linked in"; exit 1; } +grep -q 'BLOB=7' o2.log || { cat o2.log; echo "FAIL: wrong value from the linked object"; exit 1; } + +# Tracked, unlike the ldflags workaround it replaces. +sleep 1 +printf 'extern "C" int blob_value() { return 9; }\n' > blob.cpp +"$MCPP" run > o3.log 2>&1 && { cat o3.log; echo "FAIL: expected the changed object to be relinked (main asserts ==7)"; exit 1; } +grep -q 'BLOB=9' o3.log || { cat o3.log; echo "FAIL: editing the object's input did not reach the link"; exit 1; } +printf 'extern "C" int blob_value() { return 7; }\n' > blob.cpp + +# An unknown target name is an error, not an edge that quietly attaches to +# nothing. (Artifact infers its target from ${mcpp.target_file:}; an object runs +# before the link and has to say the name.) +cat > build.mcpp <<'EOF' +#include +#include +import mcpp; +int main() { + const std::string root = mcpp::manifest_dir(); + const std::string out = mcpp::out_dir(); + mcpp::action o; + o.id = "blob"; o.role = "object"; + o.arg((root + "/mkobj.sh").c_str()) + .arg((root + "/blob.cpp").c_str()) + .arg((out + "/blob.o").c_str()) + .input((root + "/blob.cpp").c_str()) + .output((out + "/blob.o").c_str()) + .target("no_such_target") + .submit(); +} +EOF +rm -rf target +if "$MCPP" build > o4.log 2>&1; then + cat o4.log; echo "FAIL: role=object accepted an unknown target"; exit 1 +fi +grep -q "no_such_target" o4.log || { + cat o4.log; echo "FAIL: error does not name the unknown target"; exit 1; } + +# ...and EVERY name is checked, not just the case where none of them matched. +# A typo alongside a name that does exist is the shape a typo actually takes, +# and the check used to be gated on "nothing attached" — so `objrole` absorbed +# it and the misspelling was dropped without a word. +sed -i.bak 's/.target("no_such_target")/.target("objrole").target("no_such_target")/' build.mcpp +rm -f build.mcpp.bak +rm -rf target +if "$MCPP" build > o4b.log 2>&1; then + cat o4b.log + echo "FAIL: an unknown target was silently dropped because a sibling matched"; exit 1 +fi +grep -q "no_such_target" o4b.log || { + cat o4b.log; echo "FAIL: error does not name the unknown target"; exit 1; } + +# ── 3e. the default target set includes TEST binaries ────────────────────── +# +# `mcpp build` linking and `mcpp test` failing on the very symbol the action +# exists to provide is the worst shape this can take: the object is what the +# library under test needs, and test link units are DISCOVERED from tests/*.cpp +# so their names cannot be spelled in build.mcpp without breaking plain +# `mcpp build`, where those units do not exist. +mkdir -p "$TMP/objtest/src" "$TMP/objtest/tests" +cd "$TMP/objtest" +cat > mcpp.toml <<'EOF' +[package] +name = "objtest" +version = "0.1.0" +EOF +printf 'export module objtest.engine;\nextern "C" int blob_value();\nexport int get(){ return blob_value(); }\n' > src/engine.cppm +printf 'import objtest.engine;\nint main(){ return get()==7?0:1; }\n' > src/main.cpp +printf 'import objtest.engine;\nint main(){ return get()==7?0:1; }\n' > tests/t_engine.cpp +printf 'extern "C" int blob_value() { return 7; }\n' > blob.cpp +cp "$TMP/objrole/mkobj.sh" mkobj.sh +cat > build.mcpp <<'EOF' +#include +#include +import mcpp; +int main() { + const std::string root = mcpp::manifest_dir(); + const std::string out = mcpp::out_dir(); + mcpp::action o; + o.id = "blob"; o.role = "object"; + o.arg((root + "/mkobj.sh").c_str()) + .arg((root + "/blob.cpp").c_str()) + .arg((out + "/blob.o").c_str()) + .input((root + "/blob.cpp").c_str()) + .output((out + "/blob.o").c_str()) + .submit(); // no .target(): every image, tests included +} +EOF +"$MCPP" build > o5.log 2>&1 || { cat o5.log; echo "FAIL: objtest build failed"; exit 1; } +"$MCPP" test > o6.log 2>&1 || { + cat o6.log + echo "FAIL: the test binary did not receive the object (undefined symbol)"; exit 1; } +grep -q '1 passed' o6.log || { cat o6.log; echo "FAIL: the test did not run"; exit 1; } + +# ── 3f. an object with no consumer at all is reported, not silent ────────── +# +# The edge is deliberately kept out of `actionDefaults` (an object's outputs are +# supposed to be reachable through a link edge), so "no consumer" means the +# command never runs. Saying nothing there is the same failure `[resources]` +# reports as resources/no-image. +mkdir -p "$TMP/objnone/src" +cd "$TMP/objnone" +cat > mcpp.toml <<'EOF' +[package] +name = "objnone" +version = "0.1.0" + +[targets.objnone] +kind = "lib" +EOF +printf 'export module objnone;\nexport int f(){return 1;}\n' > src/objnone.cppm +cp "$TMP/objtest/blob.cpp" blob.cpp +cp "$TMP/objtest/mkobj.sh" mkobj.sh +cp "$TMP/objtest/build.mcpp" build.mcpp +"$MCPP" build > o7.log 2>&1 || { cat o7.log; echo "FAIL: objnone build failed"; exit 1; } +grep -q 'no executable, shared library or test binary' o7.log || { + cat o7.log; echo "FAIL: an object with no consumer must be reported"; exit 1; } + +cd "$TMP/edge" + # ── 4. a malformed action is refused, not skipped ────────────────────────── cat > build.mcpp <<'EOF' #include diff --git a/tests/e2e/196_version_identity_and_lock.sh b/tests/e2e/196_version_identity_and_lock.sh new file mode 100755 index 00000000..591afb5e --- /dev/null +++ b/tests/e2e/196_version_identity_and_lock.sh @@ -0,0 +1,228 @@ +#!/usr/bin/env bash +# requires: gcc fresh-sandbox +# mcpp#363 — a resolved version must be an index KEY, and mcpp.lock must record +# what was resolved. +# +# Before this, pm/resolver.cppm parsed the index's literal version keys, threw +# them away, and re-rendered an address from the parsed numbers. Anything the +# renderer could not reproduce became an address that does not exist: +# +# 1.92.8-docking pre-release, truncated to 1.92.8 (and so indistinguishable +# from the non-docking release — a different tarball) +# 25.0.4.7.1 five segments, truncated to 25.0.4.7 +# b10069 not a number at all: skipped, then reported as +# "no valid versions in index" +# +# Every shape below is one the real xim-pkgindex publishes today (compat.imgui, +# jdk-corretto, jdk-temurin, khistory). The index here is a local path index so +# nothing is downloaded; the payload for the version that must win is pre-seeded, +# and the others deliberately have none — a build that resolves the wrong key +# fails to install, which is itself an assertion. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT + +export MCPP_HOME="$TMP/mcpp-home" +source "$(dirname "$0")/_inherit_toolchain.sh" + +mkdir -p "$TMP/proj/src" "$TMP/proj/local-index/pkgs/a" +cd "$TMP/proj" + +# ── One descriptor per upstream shape ───────────────────────────────────── +mk_pkg() { # $1 = short name, $2 = version-table body, $3 = module name (default gadget) + MOD="${3:-gadget}" + cat > "local-index/pkgs/a/acme.$1.lua" < ".mcpp/.xlings/data/xpkgs/acme.$1/$2/src/$MOD.cppm" +} +seed im 1.92.8 +seed jdk 25.0.4.7.1 +seed dv 1.0.0 devkit + +printf 'import gadget;\nint main(){ return gadget_value() == 42 ? 0 : 1; }\n' > src/main.cpp + +use_dep() { # $1 = short name, $2 = constraint + cat > mcpp.toml </dev/null; done; exit 1; } + +# ── 1. A range must not reach into a pre-release ────────────────────────── +use_dep im '^1.92.8' +"$MCPP" build > b1.log 2>&1 || fail "^1.92.8 did not build" b1.log +grep -q '^\s*Resolved acme.im .* → v1\.92\.8$' b1.log \ + || fail "^1.92.8 must resolve to 1.92.8, never the -docking branch" b1.log + +# ── 2. ...but naming it exactly still addresses it ──────────────────────── +# Only 1.92.8 is seeded, so this must fail AT INSTALL with the docking address — +# proving the wire address carried the literal key rather than a truncation. +use_dep im '1.92.8-docking' +"$MCPP" build > b2.log 2>&1 && fail "expected the unseeded -docking payload to fail" b2.log +grep -q '1\.92\.8-docking' b2.log \ + || fail "exact pre-release must be addressed literally, not truncated" b2.log + +# ── 3. Five segments survive, and an alias is not a candidate ───────────── +# `^25.0` used to render 25.0.4.7 (a key that does not exist) or pick the alias. +use_dep jdk '^25.0' +"$MCPP" build > b3.log 2>&1 || fail "^25.0 did not build" b3.log +grep -q '^\s*Resolved acme.jdk .* → v25\.0\.4\.7\.1$' b3.log \ + || fail "^25.0 must resolve to the real five-segment key, not an alias or a truncation" b3.log + +# ── 4. An unorderable key is named, not blamed on the index ─────────────── +use_dep kh '*' +"$MCPP" build > b4.log 2>&1 && fail "a range over unorderable keys must not succeed" b4.log +grep -q 'not ordered versions' b4.log \ + || fail "expected an error naming the unorderable keys" b4.log +grep -q 'acme.kh = "pre-v0.0.5"' b4.log \ + || fail "the error must show the exact pin that works" b4.log +grep -q 'no valid versions in index' b4.log \ + && fail "the old message blames the index for a package it publishes fine" b4.log + +# ...and pinning it exactly is accepted (fails later, at install, for want of a +# payload — which is proof that resolution let it through). +use_dep kh 'pre-v0.0.5' +"$MCPP" build > b5.log 2>&1 && fail "expected the unseeded payload to fail" b5.log +grep -q 'not ordered versions' b5.log \ + && fail "an exact unorderable key must resolve, not be rejected" b5.log + +# The `=` form must work too — it is exactly what the error above tells the user +# to write, and it is what try_merge_semver produces internally from a literal +# pin. Routing it through the SemVer grammar would reject the remedy. +use_dep kh '=pre-v0.0.5' +"$MCPP" build > b5b.log 2>&1 && fail "expected the unseeded payload to fail" b5b.log +grep -qE 'invalid version constraint|not ordered versions' b5b.log \ + && fail "'=' must resolve to that key" b5b.log + +# ── 5. A genuine precedence tie is refused, not guessed ─────────────────── +use_dep tie '^1.0' +"$MCPP" build > b6.log 2>&1 && fail "a tie must not silently pick one" b6.log +grep -q 'compare EQUAL' b6.log || fail "expected the tie to be named" b6.log +grep -q '1\.0\.0+a' b6.log && grep -q '1\.0\.0+b' b6.log \ + || fail "both tied keys must be listed" b6.log + +# ...while naming one exactly is not ambiguous at all: the ORDER cannot separate +# them (SemVer excludes build metadata from precedence), the LITERALS can. +use_dep tie '=1.0.0+b' +"$MCPP" build > b6b.log 2>&1 && fail "expected the unseeded payload to fail" b6b.log +grep -q 'compare EQUAL' b6b.log \ + && fail "an exact build-metadata pin must not be reported as a tie" b6b.log +grep -q '1\.0\.0+b' b6b.log || fail "the exact key must be addressed literally" b6b.log + +# ── 6. mcpp.lock records the resolution, not the constraint ─────────────── +use_dep im '^1.92.8' +"$MCPP" build > b7.log 2>&1 || fail "rebuild failed" b7.log +grep -q 'version = "1.92.8"' mcpp.lock \ + || fail "the lock must record the resolved version" mcpp.lock +grep -q '\^' mcpp.lock \ + && fail "a lock that records a range locks nothing" mcpp.lock +# The banner and the lock read the same data, so they cannot disagree. (The dep +# announces itself as Compiling or Cached depending on the build cache; both go +# through the same version string, which is the point.) +grep -qE '(Compiling|Cached) +acme\.im v1\.92\.8' b7.log \ + || fail "the dependency banner must announce the resolved version" b7.log +grep -q 'acme\.im v\^' b7.log \ + && fail "the banner must never print a constraint as a version" b7.log + +# Honest about what it is not: the lock is written but not yet read back for +# index deps. DELETE this assertion in the same change that makes it +# authoritative — it exists so that removal cannot be forgotten. +grep -q 'does not yet pin future builds' mcpp.lock \ + || fail "the lock must state that it does not pin yet" mcpp.lock + +# Idempotent: a second build must not churn the file. +cp mcpp.lock lock.first +"$MCPP" build > b8.log 2>&1 || fail "second build failed" b8.log +cmp -s mcpp.lock lock.first || fail "mcpp.lock is not stable across builds" mcpp.lock + +# ── 7. ...and stable across COMMANDS, which is the harder half ──────────── +# +# The lock is written from the resolution result, and `mcpp test` resolves +# dev-dependencies while `mcpp build` does not. Recording them therefore made a +# VCS-committed file depend on which command ran last: build, test, build wrote +# three different files. A lock has to be a function of the MANIFEST. +mkdir -p tests +printf 'import devkit;\nint main(){ return devkit_value()==42?0:1; }\n' > tests/t_dev.cpp +cat > mcpp.toml <<'EOF' +[package] +name = "proj" +version = "0.1.0" + +[indices] +acme = { path = "local-index" } + +[dependencies.acme] +im = "^1.92.8" + +[dev-dependencies.acme] +dv = "^1.0" + +[targets.proj] +kind = "bin" +main = "src/main.cpp" +EOF +rm -f mcpp.lock +"$MCPP" build > c1.log 2>&1 || fail "build with a dev-dep failed" c1.log +cp mcpp.lock lock.build +"$MCPP" test > c2.log 2>&1 || fail "test with a dev-dep failed" c2.log +cmp -s mcpp.lock lock.build \ + || { diff lock.build mcpp.lock || true + fail "mcpp test rewrote mcpp.lock — the lock must not depend on the command"; } +"$MCPP" build > c3.log 2>&1 || fail "build after test failed" c3.log +cmp -s mcpp.lock lock.build \ + || fail "mcpp build rewrote mcpp.lock after mcpp test" mcpp.lock +# The dev-dep really was resolved (so this is not passing by never reaching it)… +grep -q 'Resolved acme.dv' c2.log \ + || fail "the dev-dependency was never resolved — the check above is vacuous" c2.log +# …and it is deliberately absent from the file. +grep -q 'acme\.dv' mcpp.lock \ + && fail "dev-dependencies must not be recorded in mcpp.lock" mcpp.lock +grep -q 'dev-dependencies are excluded' mcpp.lock \ + || fail "the lock must state that dev-dependencies are excluded" mcpp.lock + +echo "OK" diff --git a/tests/e2e/197_windows_resources.sh b/tests/e2e/197_windows_resources.sh new file mode 100755 index 00000000..98ab8fe8 --- /dev/null +++ b/tests/e2e/197_windows_resources.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# requires: windows +# mcpp#365 — native Windows: [resources] compiles to a .res through llvm-rc or +# rc.exe and is linked in by lld-link/link.exe. Shared assertions live in +# _windows_resources_body.sh; 198 runs the same ones through the GNU/windres +# fork so the two dialects cannot drift. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT + +BUILD_ARGS="" +EXE_SUFFIX=".exe" +source "$(dirname "$0")/_windows_resources_body.sh" diff --git a/tests/e2e/198_windows_resources_cross.sh b/tests/e2e/198_windows_resources_cross.sh new file mode 100755 index 00000000..24f51dff --- /dev/null +++ b/tests/e2e/198_windows_resources_cross.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# requires: mingw-cross +# mcpp#365 — Linux → Windows through the MinGW cross toolchain. The GNU dialect +# takes the other fork: `windres -O coff`, because GNU ld cannot consume a .res +# at all. Same assertions as 197 (see _windows_resources_body.sh), plus the +# non-PE half that only a cross host can check: the very same manifest must +# build for the host with the section simply inapplicable. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT + +export MCPP_HOME="${MCPP_HOME:-$HOME/.mcpp}" + +BUILD_ARGS="--target x86_64-windows-gnu" +EXE_SUFFIX=".exe" +source "$(dirname "$0")/_windows_resources_body.sh" + +# ── A3. On a non-PE target the section is INAPPLICABLE ──────────────────── +# +# Not "degraded", not "skipped with a warning": there is no consumer, so the +# build is unchanged and says nothing. This is what makes cfg(windows) gating +# unnecessary — and it is also the half of issue #365's third request that IS +# satisfied (a Windows-only declaration must not break other platforms). +cd "$TMP/proj" +cat > mcpp.toml <<'TOML' +[package] +name = "resapp" +version = "1.2.3" + +[resources] +icon = "assets/app.ico" + +[targets.resapp] +kind = "bin" +main = "src/main.cpp" +TOML +"$MCPP" build > host.log 2>&1 || { cat host.log; echo "FAIL: host build with [resources] failed"; exit 1; } +grep -qi 'resource' host.log && { cat host.log; echo "FAIL: a non-PE build must say nothing about resources"; exit 1; } +HOST_DIR=$(dirname "$(find target -name 'build.ninja' -print | xargs grep -L 'rc_object' | head -1)") +[ -d "$HOST_DIR/res" ] && { echo "FAIL: a non-PE build must not emit resource units"; exit 1; } + +# ── A4. "inapplicable" stops at COMPILATION, not at validation ──────────── +# +# Whether a declared path exists is a fact about the working tree, not about the +# target. Gating the existence check on is_pe() meant a Linux or macOS CI could +# not see a typo in `icon = ...` at all and only the Windows job went red — the +# same "find out late" failure the hard error exists to remove. So: nothing is +# compiled here, nothing is said when the files are fine, and a missing one is +# still an error. +sed -i.bak 's|^icon = .*|icon = "assets/typo.ico"|' mcpp.toml && rm -f mcpp.toml.bak +"$MCPP" build > host2.log 2>&1 \ + && { cat host2.log; echo "FAIL: a missing declared resource must fail on non-PE targets too"; exit 1; } +grep -q 'does not exist' host2.log \ + || { cat host2.log; echo "FAIL: expected the same missing-file error as on Windows"; exit 1; } + +echo "OK" diff --git a/tests/e2e/199_resources_validation.sh b/tests/e2e/199_resources_validation.sh new file mode 100755 index 00000000..331fa740 --- /dev/null +++ b/tests/e2e/199_resources_validation.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# mcpp#365 — the half of [resources] that is identical on every target. +# +# 197 (native Windows) and 198 (mingw cross) own everything about COMPILING a +# resource, and neither can run on a plain Linux or macOS shard. What can — and +# what has to, because it is the regression this test exists for — is VALIDATION: +# whether a declared path exists is a fact about the working tree, not about the +# target. That check used to sit inside the `is_pe()` branch, so a typo in +# `icon = "assets/app.ico"` was invisible to every non-Windows job and the +# Windows build was the first thing to say so. +# +# Nothing here compiles a resource. On a non-PE host the section is inapplicable +# and the build must stay silent; the file must still have to exist. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT + +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } + +mkdir -p "$TMP/proj/src" "$TMP/proj/assets" +cd "$TMP/proj" +printf 'int main() { return 0; }\n' > src/main.cpp +: > assets/app.ico # contents are irrelevant to validation + +write_toml() { # $1 = [resources] body + cat > mcpp.toml < v.log 2>&1 \ + && fail "a missing declared resource must fail the build ($CASE)" v.log + grep -q 'does not exist' v.log \ + || fail "expected a clear missing-file error ($CASE)" v.log + # The error must name the key the user wrote, or it sends them hunting. + KEY="${CASE%% *}" + grep -q "$KEY" v.log \ + || fail "the error must name the [resources] key it came from ($KEY)" v.log +done + +# ── 2. ...and when everything exists, a non-PE build says nothing ───────── +# +# "Inapplicable" is not "degraded" and not "skipped with a warning": no units, +# no diagnostics, and no res/ directory. +# +# PE hosts stop here, before the build below: `assets/app.ico` is an EMPTY file +# — enough to exist, which is all §1 needed — and a real resource compiler would +# rightly reject it. 197 builds a structurally valid icon and owns everything +# about compiling one. +case "$(uname -s 2>/dev/null || echo unknown)" in + MINGW*|MSYS*|CYGWIN*|Windows*) echo "OK (PE host: 197 owns the rest)"; exit 0 ;; +esac + +write_toml 'icon = "assets/app.ico"' +rm -rf target +"$MCPP" build > ok.log 2>&1 || fail "build with a valid [resources] failed" ok.log + +grep -qi 'resource' ok.log \ + && fail "a non-PE build must say nothing about resources" ok.log +find target -type d -name res | grep -q . \ + && fail "a non-PE build must not emit resource units" ok.log + +echo "OK" diff --git a/tests/e2e/_windows_resources_body.sh b/tests/e2e/_windows_resources_body.sh new file mode 100644 index 00000000..41c6de26 --- /dev/null +++ b/tests/e2e/_windows_resources_body.sh @@ -0,0 +1,298 @@ +# Shared body for the Windows-resource e2e tests (mcpp#365). +# +# Sourced by 197 (native Windows) and 198 (Linux → Windows via mingw-cross). +# The two differ only in how the target is selected, and that difference is the +# point: the msvc dialect compiles the script to a `.res` that lld-link/link.exe +# consume directly, while the GNU dialect must go through `windres -O coff` +# because GNU ld cannot read a `.res` at all. Asserting the same behaviour +# through both keeps that fork honest. +# +# Callers must set, before sourcing: +# TMP scratch dir (already created, trap-cleaned) +# MCPP the binary under test +# BUILD_ARGS extra `mcpp build` arguments ("" natively, --target when cross) +# EXE_SUFFIX ".exe" + +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } + +# The ordinal assertions below read the LLVM payload, so the body needs to know +# where it is even when the caller was invoked directly rather than through +# run_all.sh (which exports this). +export MCPP_HOME="${MCPP_HOME:-$HOME/.mcpp}" + +# Hex dump of a file as one unbroken lowercase string — enough to search for a +# byte pattern without needing `strings`, python, or a PE parser on the runner. +hexof() { od -An -v -tx1 "$1" | tr -d ' \n'; } + +# A literal ASCII string as it appears in a Windows resource: UTF-16LE hex. +utf16hex() { + printf '%s' "$1" | od -An -v -tx1 | tr -d ' \n' | sed 's/../&00/g' +} + +# ── A project whose only interesting feature is [resources] ─────────────── +mkdir -p "$TMP/proj/src" "$TMP/proj/assets" +cd "$TMP/proj" + +# A minimal but structurally valid 4x1 32bpp icon: ICONDIR + ICONDIRENTRY + +# BITMAPINFOHEADER + four BGRA pixels + AND mask. +# +# Four pixels, not one, purely so the payload is findable AGAIN inside the +# linked image without false positives: an .ico's bitmap data is embedded +# verbatim, and searching a megabyte-scale binary for a 4-byte pattern hits by +# chance often enough to make the assertion meaningless. 16 bytes does not. +write_icon() { # $1 = 4 BGRA pixels (16 bytes) as printf escapes + printf '\x00\x00\x01\x00\x01\x00\x04\x01\x00\x00\x01\x00\x20\x00\x3c\x00\x00\x00\x16\x00\x00\x00\x28\x00\x00\x00\x04\x00\x00\x00\x02\x00\x00\x00\x01\x00\x20\x00\x00\x00\x00\x00\x14\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00'"$1"'\x00\x00\x00\x00' > assets/app.ico +} +ICON_A='\xd3\x1c\x7a\x45\x92\xe6\x0b\xa8\x41\xf7\x2d\x63\xbe\x50\x84\x19' +ICON_B='\x6c\xa2\x38\xd7\xe1\x4b\x95\x0f\x77\xc4\x1a\x8e\x2b\xf3\x60\xd5' +ICON_A_HEX='d31c7a4592e60ba841f72d63be508419' +ICON_B_HEX='6ca238d7e14b950f77c41a8e2bf360d5' +write_icon "$ICON_A" + +printf 'int main() { return 0; }\n' > src/main.cpp + +cat > mcpp.toml <<'EOF' +[package] +name = "resapp" +version = "1.2.3" +description = "Resource fixture" +license = "MIT" +authors = ["Acme Corp"] + +[resources] +icon = "assets/app.ico" + +[targets.resapp] +kind = "bin" +main = "src/main.cpp" +EOF + +"$MCPP" build $BUILD_ARGS > b1.log 2>&1 || fail "build with [resources] failed" b1.log + +BUILD_DIR=$(dirname "$(find target -name 'build.ninja' -print | head -1)") +[ -n "$BUILD_DIR" ] || fail "no build dir" b1.log + +# ── A1. The generated script names the version resource by ORDINAL ──────── +# +# This is the whole mcpp#365 bug. `VS_VERSION_INFO` is a macro; an +# undefined identifier in the name position files the resource under a STRING +# name, and GetFileVersionInfo — which looks up ordinal 1 — then reports every +# field as empty while every tool that prints the resource TYPE says it is fine. +GEN_RC="$BUILD_DIR/res/resapp.mcpp.rc" +[ -f "$GEN_RC" ] || fail "no generated resource script at $GEN_RC" b1.log +grep -q '^1 VERSIONINFO' "$GEN_RC" || fail "generated script must use ordinal 1" "$GEN_RC" +grep -q 'VS_VERSION_INFO' "$GEN_RC" && fail "generated script must never name the macro" "$GEN_RC" +grep -q 'FILEVERSION 1,2,3,0' "$GEN_RC" || fail "FILEVERSION must come from [package].version" "$GEN_RC" +grep -q '"CompanyName", "Acme Corp"' "$GEN_RC" || fail "metadata must default from [package]" "$GEN_RC" + +# The compiled resource artifact exists and is a link input. +RES_ART=$(ls "$BUILD_DIR"/res/resapp.mcpp.res "$BUILD_DIR"/res/resapp.mcpp.o 2>/dev/null | head -1) +[ -n "$RES_ART" ] || fail "no compiled resource artifact under $BUILD_DIR/res" b1.log +grep -q 'rc_object' "$BUILD_DIR/build.ninja" || fail "no rc_object edge" "$BUILD_DIR/build.ninja" + +case "$RES_ART" in + *.res) + # `.res` is a documented container: a 32-byte null header, then per + # resource dataSize+headerSize followed by type and name. `ffff` introduces + # an ordinal, so RT_VERSION(16) named 1 is exactly ffff1000ffff0100. + hexof "$RES_ART" | cut -c81-96 | grep -qi '^ffff1000ffff0100$' \ + || fail "version resource is not at ordinal 1 (this is the #365 bug)" b1.log + ;; +esac + +# The same assertion in a form that covers the GNU fork too, since the byte +# check above reads the `.res` container and windres emits a COFF object. +# +# `windres -J coff -O rc` round-trips a compiled resource back to rc SOURCE, so +# the name is readable without a PE parser — and it is guaranteed present on +# this fork, because it is the tool that produced the file. Measured on both a +# `.o` and the linked `.exe`: +# +# 1 VERSIONINFO ← Windows finds it +# "VS_VERSION_INFO" VERSIONINFO ← Windows does not +# +# NOT a whole-file search for the string `VS_VERSION_INFO`: that is the `szKey` +# of the VS_VERSIONINFO struct itself, so a CORRECT resource contains it too. +# Written that way first, and it fired on a good windres build — which is the +# only reason this comment exists. +# +# The rc tool comes out of build.ninja rather than off PATH: mcpp resolved it +# payload-relative on purpose (a bare `windres` on PATH is an xlings shim), so +# asking PATH here would inspect the artifact with a different tool than the one +# that built it. +RC_TOOL=$(sed -n 's/^rc *= *//p' "$BUILD_DIR/build.ninja" | head -1) +[ -n "$RC_TOOL" ] || fail "no 'rc =' binding in build.ninja" "$BUILD_DIR/build.ninja" + +# Two routes, TRIED in order rather than dispatched on the tool's name: GNU +# binutils' windres round-trips, `llvm-windres` does not (it only wraps llvm-rc, +# one way) — and both answer to `*windres*`, so the name tells you nothing. Only +# when NEITHER route exists is this a hard failure; silently skipping the +# assertion is how #365 shipped in the first place. +# +# $1 = file to inspect, $2 = log suffix, $3 = "ordinal" | "string" +version_name_is() { + case "$3" in + ordinal) _want='^[[:space:]]*1[[:space:]]+VERSIONINFO' ; _n='Name: (ID 1)' ;; + string) _want='"VS_VERSION_INFO"[[:space:]]+VERSIONINFO' ; _n='Name: VS_VERSION_INFO' ;; + esac + + # Route 1 — windres back to rc SOURCE. Readable, and no PE parser needed. + if [ -n "$RC_TOOL" ] \ + && "$RC_TOOL" -J coff -O rc -i "$1" -o "rt.$2.rc" 2>"rt.$2.err" \ + && grep -qiE 'VERSIONINFO' "rt.$2.rc"; then + grep -qE "$_want" "rt.$2.rc" \ + || fail "expected the version resource to be named by $3 in $1" "rt.$2.rc" + return 0 + fi + + # Route 2 — the resource DIRECTORY, via llvm-readobj. + READOBJ=$(ls "$MCPP_HOME"/registry/data/xpkgs/xim-x-llvm/*/bin/llvm-readobj \ + "$MCPP_HOME"/registry/data/xpkgs/xim-x-llvm/*/bin/llvm-readobj.exe \ + 2>/dev/null | head -1) + [ -n "$READOBJ" ] || fail "no way to read back $1: '$RC_TOOL' cannot round-trip and no llvm-readobj was found under $MCPP_HOME" "rt.$2.err" + "$READOBJ" --coff-resources "$1" > "readobj.$2.log" 2>&1 \ + || fail "llvm-readobj could not read $1" "readobj.$2.log" + grep -q 'Type: VERSIONINFO' "readobj.$2.log" \ + || fail "$1 carries no version resource at all" "readobj.$2.log" + # The window is the VERSIONINFO type table only — an icon is also `(ID 1)`, + # so matching anywhere in the file would pass for the wrong reason. + grep -A5 'Type: VERSIONINFO' "readobj.$2.log" | grep -qF "$_n" \ + || fail "expected the version resource to be named by $3 in $1" "readobj.$2.log" +} +version_name_is "$RES_ART" art ordinal + +# ── The resource actually reached the linked image ──────────────────────── +EXE="$BUILD_DIR/bin/resapp$EXE_SUFFIX" +[ -f "$EXE" ] || fail "no executable at $EXE" b1.log +# The ordinal has to survive the LINK, not just the resource compile — that is +# what GetFileVersionInfo actually reads. +version_name_is "$EXE" exe ordinal +EXE_HEX=$(hexof "$EXE") +echo "$EXE_HEX" | grep -q "$(utf16hex 'Acme Corp')" \ + || fail "the version metadata did not reach the executable" b1.log +echo "$EXE_HEX" | grep -q "$ICON_A_HEX" \ + || fail "the icon payload did not reach the executable" b1.log + +# ── A2. Resources are tracked build inputs ──────────────────────────────── +# +# The workaround this feature replaces (a pre-built .res named in ldflags) is +# invisible to ninja: the reporter's symptom was "ninja: no work to do" after +# changing the icon. +"$MCPP" build $BUILD_ARGS > b2.log 2>&1 || fail "no-op rebuild failed" b2.log +grep -qE 'no work to do|Finished' b2.log || fail "unexpected rebuild output" b2.log + +sleep 1 +write_icon "$ICON_B" # same size, entirely different bytes +"$MCPP" build $BUILD_ARGS > b3.log 2>&1 || fail "rebuild after icon change failed" b3.log +NEW_HEX=$(hexof "$BUILD_DIR/bin/resapp$EXE_SUFFIX") +echo "$NEW_HEX" | grep -q "$ICON_B_HEX" \ + || fail "editing the icon did not reach the executable (the #365 symptom)" b3.log +echo "$NEW_HEX" | grep -q "$ICON_A_HEX" \ + && fail "the old icon is still embedded — the resource was not rebuilt" b3.log + +# Metadata is an input too: the .rc is regenerated and everything downstream +# re-runs. +sleep 1 +sed -i.bak 's/^description = .*/description = "Changed description"/' mcpp.toml && rm -f mcpp.toml.bak +"$MCPP" build $BUILD_ARGS > b4.log 2>&1 || fail "rebuild after metadata change failed" b4.log +grep -q '"FileDescription", "Changed description"' "$GEN_RC" \ + || fail "the generated script did not follow [package].description" "$GEN_RC" +hexof "$BUILD_DIR/bin/resapp$EXE_SUFFIX" | grep -q "$(utf16hex 'Changed description')" \ + || fail "the changed description did not reach the executable" b4.log + +# ── A6. L0 → L1 has no cliff ────────────────────────────────────────────── +# +# Taking the generated script over must reproduce the same resource byte for +# byte, or "each layer is the next layer's default" is only a slogan. +cp "$RES_ART" "$TMP/generated.artifact" +mkdir -p res +cp "$GEN_RC" res/app.rc +cat > mcpp.toml <<'EOF' +[package] +name = "resapp" +version = "1.2.3" +description = "Changed description" +license = "MIT" +authors = ["Acme Corp"] + +[resources] +files = ["res/app.rc"] + +[targets.resapp] +kind = "bin" +main = "src/main.cpp" +EOF +"$MCPP" build $BUILD_ARGS > b5.log 2>&1 || fail "build with an author-written .rc failed" b5.log +AUTHORED=$(ls "$BUILD_DIR"/res/app.res "$BUILD_DIR"/res/app.o 2>/dev/null | head -1) +[ -n "$AUTHORED" ] || fail "the author-written script was not compiled" b5.log +cmp -s "$AUTHORED" "$TMP/generated.artifact" \ + || fail "taking over the generated script changed the resource bytes" b5.log +# ...and with files declared, mcpp stops synthesising: the author owns the ID +# space, and a second RT_VERSION at ordinal 1 is not a thing that can exist. +# Asserted on the GRAPH, not on the directory: the previous build's artifact is +# still lying around, and "the file is absent" would be testing rm, not mcpp. +grep -q 'resapp\.mcpp\.' "$BUILD_DIR/build.ninja" \ + && fail "mcpp must not add a second VERSIONINFO behind an author-written script" "$BUILD_DIR/build.ninja" + +# ── A3 (lint). A script Windows cannot read is named, not shipped quietly ── +# +# The `.rc` is the ONLY thing that changes here, so the rebuild has to come from +# its mtime — which is exactly the project-level fast-path hole this feature had +# to close. A same-second write against a coarse-granularity filesystem would +# make the whole section silently not run. +sleep 1 +cat > res/app.rc <<'EOF' +VS_VERSION_INFO VERSIONINFO + FILEVERSION 1,2,3,0 + PRODUCTVERSION 1,2,3,0 +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "ProductName", "resapp" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END +EOF +"$MCPP" build $BUILD_ARGS > b6.log 2>&1 || true +grep -q 'instead of ordinal 1' b6.log \ + || fail "the VS_VERSION_INFO-without-windows.h shape must be diagnosed" b6.log +# It is a DEGRADATION, not a bare warning: the impact is a shipped binary whose +# version metadata Windows cannot read, so --strict has to see it. +grep -q 'GetFileVersionInfo' b6.log \ + || fail "the diagnostic must state the impact, not just the shape" b6.log + +# And the counter-check that makes the ordinal assertion at the top meaningful: +# compiled from THIS script, the resource really is filed under a STRING name. +# Without it, "the name is (ID 1)" could be passing for any reason at all. +BROKEN=$(ls "$BUILD_DIR"/res/app.res "$BUILD_DIR"/res/app.o 2>/dev/null | head -1) +[ -n "$BROKEN" ] || fail "the broken script produced no artifact to compare against" b6.log +version_name_is "$BROKEN" broken string + +# ── D-6. A declared resource that does not exist is an ERROR ────────────── +# +# Deliberately not the "skip it" the issue asked for: silently shipping a +# release binary with no icon, and saying nothing, is the failure this whole +# feature exists to remove. +rm -rf res +cat > mcpp.toml <<'EOF' +[package] +name = "resapp" +version = "1.2.3" + +[resources] +icon = "assets/missing.ico" + +[targets.resapp] +kind = "bin" +main = "src/main.cpp" +EOF +"$MCPP" build $BUILD_ARGS > b7.log 2>&1 && fail "a missing declared resource must fail the build" b7.log +grep -q 'does not exist' b7.log || fail "expected a clear missing-file error" b7.log + +echo "OK" diff --git a/tests/unit/test_build_resources.cpp b/tests/unit/test_build_resources.cpp new file mode 100644 index 00000000..38ca9bf4 --- /dev/null +++ b/tests/unit/test_build_resources.cpp @@ -0,0 +1,243 @@ +#include + +import std; +import mcpp.manifest; +import mcpp.build.resources; + +namespace res = mcpp::build::resources; +namespace fs = std::filesystem; + +namespace { + +mcpp::manifest::Package sample_package() { + mcpp::manifest::Package p; + p.name = "myapp"; + p.version = "0.2.0"; + p.description = "My application"; + p.license = "MIT"; + p.authors = {"Acme"}; + return p; +} + +// A scratch .rc on disk; scan_rc reads files, so the tests write them. +struct TempDir { + fs::path path; + TempDir() { + auto base = fs::temp_directory_path() / + std::format("mcpp-rc-test-{}", reinterpret_cast(this)); + fs::create_directories(base); + path = base; + } + ~TempDir() { std::error_code ec; fs::remove_all(path, ec); } + fs::path write(std::string_view name, std::string_view body) const { + auto p = path / name; + std::ofstream os(p, std::ios::binary); + os << body; + return p; + } +}; + +} // namespace + +// ─── Synthesis: the mcpp#365 headline ───────────────────────────────────── +// +// The reported symptom was a VERSIONINFO that llvm-readobj shows and Windows +// cannot read. Root cause: `VS_VERSION_INFO` is a macro (= 1), and +// without it the resource is filed under the STRING name "VS_VERSION_INFO" +// while GetFileVersionInfo looks up ordinal 1. What mcpp generates must +// therefore never spell the macro. + +TEST(BuildResources, SynthesizedScriptNamesTheVersionResourceByOrdinal) { + mcpp::manifest::Resources r; + auto rc = res::synthesize_rc(sample_package(), r, "myapp.exe", {}); + ASSERT_TRUE(rc) << rc.error(); + EXPECT_NE(rc->find("1 VERSIONINFO"), std::string::npos); + EXPECT_EQ(rc->find("VS_VERSION_INFO"), std::string::npos) + << "the macro is only defined when was included; naming it " + "here is the bug this feature exists to avoid"; +} + +TEST(BuildResources, FileVersionComesFromThePackageVersion) { + mcpp::manifest::Resources r; + auto rc = res::synthesize_rc(sample_package(), r, "myapp.exe", {}); + ASSERT_TRUE(rc); + EXPECT_NE(rc->find("FILEVERSION 0,2,0,0"), std::string::npos); + EXPECT_NE(rc->find("PRODUCTVERSION 0,2,0,0"), std::string::npos); + // The STRING fields keep the version verbatim, so a form the numeric + // fields cannot hold (a pre-release) is still visible in the properties + // dialog. + EXPECT_NE(rc->find("\"FileVersion\", \"0.2.0\""), std::string::npos); +} + +TEST(BuildResources, FourSegmentDateVersionsFitTheNumericFields) { + auto pkg = sample_package(); + pkg.version = "2026.8.7.1"; + mcpp::manifest::Resources r; + auto rc = res::synthesize_rc(pkg, r, "mcpp.exe", {}); + ASSERT_TRUE(rc) << rc.error(); + EXPECT_NE(rc->find("FILEVERSION 2026,8,7,1"), std::string::npos); +} + +TEST(BuildResources, AVersionFieldThatCannotFitIsAnErrorNotAClamp) { + auto pkg = sample_package(); + pkg.version = "70000.0.0"; // > 65535: FILEVERSION fields are 16-bit + mcpp::manifest::Resources r; + auto rc = res::synthesize_rc(pkg, r, "myapp.exe", {}); + ASSERT_FALSE(rc); + EXPECT_NE(rc.error().find("65535"), std::string::npos); + // Clamping would put a version in the binary that is not the version that + // was built — the failure mode is a wrong answer, so it must not be silent. + EXPECT_NE(rc.error().find("70000"), std::string::npos); +} + +TEST(BuildResources, MetadataDefaultsFromPackageAndIsOverridable) { + mcpp::manifest::Resources r; + auto rc = res::synthesize_rc(sample_package(), r, "myapp.exe", {}); + ASSERT_TRUE(rc); + EXPECT_NE(rc->find("\"CompanyName\", \"Acme\""), std::string::npos); + EXPECT_NE(rc->find("\"ProductName\", \"myapp\""), std::string::npos); + EXPECT_NE(rc->find("\"FileDescription\", \"My application\""), std::string::npos); + EXPECT_NE(rc->find("\"OriginalFilename\", \"myapp.exe\""), std::string::npos); + + r.info.company = "Other Co"; + r.info.product = "Renamed"; + auto rc2 = res::synthesize_rc(sample_package(), r, "myapp.exe", {}); + ASSERT_TRUE(rc2); + EXPECT_NE(rc2->find("\"CompanyName\", \"Other Co\""), std::string::npos); + EXPECT_NE(rc2->find("\"ProductName\", \"Renamed\""), std::string::npos); +} + +TEST(BuildResources, GeneratedTextIsAsciiSoItDoesNotDependOnTheCodepageFlag) { + // A UTF-8 codepage is passed to the rc tool for the USER's metadata, but + // text mcpp writes itself must not need it: an em dash in the default + // copyright line failed llvm-rc outright ("Non-ASCII 8-bit codepoint"). + mcpp::manifest::Resources r; + auto rc = res::synthesize_rc(sample_package(), r, "myapp.exe", {}); + ASSERT_TRUE(rc); + auto generated = rc->substr(0, rc->find("VALUE \"CompanyName\"")); + for (unsigned char c : generated) + EXPECT_LT(c, 0x80u) << "generated scaffolding must stay ASCII"; + EXPECT_NE(rc->find("\"LegalCopyright\", \"(C) Acme - MIT\""), std::string::npos); +} + +TEST(BuildResources, IconIsEmittedAtOrdinalOne) { + mcpp::manifest::Resources r; + r.icon = "assets/app.ico"; + auto rc = res::synthesize_rc(sample_package(), r, "myapp.exe", + "/proj/assets/app.ico"); + ASSERT_TRUE(rc); + // Explorer shows the lowest-numbered icon group. + EXPECT_NE(rc->find("1 ICON \"/proj/assets/app.ico\""), std::string::npos); +} + +// The 3-row rule: author-supplied scripts own the resource ID space, so mcpp +// does not add a second VERSIONINFO behind their back — unless asked. +TEST(BuildResources, VersionInfoSynthesisFollowsTheThreeRowRule) { + mcpp::manifest::Resources r; + EXPECT_TRUE(r.synthesize_version_info()); // nothing declared + + r.files = {"res/app.rc"}; + EXPECT_FALSE(r.synthesize_version_info()); // author took over + + r.versionInfo = true; + EXPECT_TRUE(r.synthesize_version_info()); // explicit opt-in wins + + r.versionInfo = false; + EXPECT_FALSE(r.synthesize_version_info()); + r.files.clear(); + EXPECT_FALSE(r.synthesize_version_info()); // explicit opt-out wins +} + +// ─── Scanning an author-written .rc ─────────────────────────────────────── + +TEST(BuildResources, ScanCollectsQuotedIncludesAndDataFiles) { + TempDir d; + auto rc = d.write("app.rc", R"(#include "ids.h" +#include +1 ICON "assets/app.ico" +2 RCDATA "blob.bin" +IDR_MANIFEST 24 "app.manifest" +STRINGTABLE +BEGIN + 1 "hello" +END +)"); + auto s = res::scan_rc(rc); + auto has = [&](std::string_view leaf) { + return std::any_of(s.inputs.begin(), s.inputs.end(), + [&](const fs::path& p){ return p.filename() == leaf; }); + }; + EXPECT_TRUE(has("ids.h")); + EXPECT_TRUE(has("app.ico")); + EXPECT_TRUE(has("blob.bin")); + // Angled includes belong to the toolchain: immutable for the life of a + // build directory and already folded into the fingerprint. + EXPECT_FALSE(has("windows.h")); + // STRINGTABLE carries its data inline — nothing to track. + EXPECT_EQ(s.inputs.size(), 3u); +} + +TEST(BuildResources, ScanNamesWhatItCouldNotResolve) { + TempDir d; + auto rc = d.write("app.rc", "1 ICON APP_ICON\n"); + auto s = res::scan_rc(rc); + // A macro hides the file name. Reporting the gap is the whole point: a + // silently untracked input leaves a stale resource in a shipped binary. + ASSERT_EQ(s.gaps.size(), 1u); + EXPECT_NE(s.gaps[0].find("APP_ICON"), std::string::npos); + EXPECT_TRUE(s.inputs.empty()); +} + +TEST(BuildResources, ScanFlagsAVersionResourceWindowsWillNotFind) { + TempDir d; + auto rc = d.write("bad.rc", R"(VS_VERSION_INFO VERSIONINFO + FILEVERSION 0,2,0,0 +BEGIN +END +)"); + auto s = res::scan_rc(rc); + EXPECT_TRUE(s.versionInfoNamedByString); + EXPECT_EQ(s.versionInfoName, "VS_VERSION_INFO"); +} + +// ─── Splitting a Windows environment list ───────────────────────────────── + +TEST(BuildResources, EnvListSplitsOnSemicolonsOnly) { + // The rc-tool search walks the toolchain's PATH override, which the MSVC + // backend builds with `;` from real Windows paths. Splitting on ":" as well + // cuts at the DRIVE COLON: `C:\...` becomes `C` plus a current-drive-relative + // tail, which resolves by accident on the same drive and finds nothing + // otherwise — so rc.exe (which lives in the SDK bin, never next to cl.exe) + // became unfindable. + auto v = res::split_env_list( + R"(C:\Program Files (x86)\Windows Kits\10\bin\10.0.22621.0\x64;C:\VC\bin\Hostx64\x64)"); + ASSERT_EQ(v.size(), 2u); + EXPECT_EQ(v[0], R"(C:\Program Files (x86)\Windows Kits\10\bin\10.0.22621.0\x64)"); + EXPECT_EQ(v[1], R"(C:\VC\bin\Hostx64\x64)"); + + // Empty entries (a trailing or doubled separator) are dropped rather than + // becoming a probe of the current directory. + auto e = res::split_env_list(R"(C:\a;;C:\b;)"); + ASSERT_EQ(e.size(), 2u); + EXPECT_EQ(e[0], R"(C:\a)"); + EXPECT_EQ(e[1], R"(C:\b)"); + + EXPECT_TRUE(res::split_env_list("").empty()); + EXPECT_EQ(res::split_env_list(R"(C:\only)").size(), 1u); +} + +TEST(BuildResources, ScanStaysQuietWhenTheMacroIsActuallyDefined) { + TempDir d; + // Either of these makes VS_VERSION_INFO real, so there is nothing to warn + // about — the lint must not cry wolf at correct scripts. + auto viaInclude = d.write("ok1.rc", "#include \n" + "VS_VERSION_INFO VERSIONINFO\nBEGIN\nEND\n"); + EXPECT_FALSE(res::scan_rc(viaInclude).versionInfoNamedByString); + + auto viaDefine = d.write("ok2.rc", "#define VS_VERSION_INFO 1\n" + "VS_VERSION_INFO VERSIONINFO\nBEGIN\nEND\n"); + EXPECT_FALSE(res::scan_rc(viaDefine).versionInfoNamedByString); + + auto literal = d.write("ok3.rc", "1 VERSIONINFO\nBEGIN\nEND\n"); + EXPECT_FALSE(res::scan_rc(literal).versionInfoNamedByString); +} diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index b92c8d49..866d72fe 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -372,6 +372,62 @@ TEST(ListXpkgVersions, MissingXpmReturnsEmpty) { EXPECT_TRUE(mcpp::manifest::list_xpkg_versions(src, mcpp::platform::TargetPlatform::for_lint_of("linux")).empty()); } +// #363: `["25.0.4"] = { ref = "25.0.4.7.1" }` is a POINTER at another entry, +// not a release. Shape copied from the real jdk-corretto / jdk-temurin +// descriptors. Treating it as a version gave range resolution a candidate that +// ties with (or truncates to something other than) its own target. +TEST(ListXpkgVersions, AliasEntriesAreFlagged) { + constexpr auto src = R"( +package = { + name = "jdk", + xpm = { + linux = { + ["latest"] = { ref = "25.0.4.7.1" }, + ["25.0.4"] = { ref = "25.0.4.7.1" }, + ["25.0.4.7.1"] = { url = "u", sha256 = "z" }, + }, + }, +} +)"; + auto e = mcpp::manifest::list_xpkg_version_entries( + src, mcpp::platform::TargetPlatform::for_lint_of("linux")); + ASSERT_EQ(e.size(), 3u); + EXPECT_EQ(e[0].version, "latest"); EXPECT_TRUE (e[0].alias); + EXPECT_EQ(e[1].version, "25.0.4"); EXPECT_TRUE (e[1].alias); + EXPECT_EQ(e[2].version, "25.0.4.7.1"); EXPECT_FALSE(e[2].alias); + + // The keys-only view is unchanged for every existing caller. + auto keys = mcpp::manifest::list_xpkg_versions( + src, mcpp::platform::TargetPlatform::for_lint_of("linux")); + ASSERT_EQ(keys.size(), 3u); + EXPECT_EQ(keys[2], "25.0.4.7.1"); +} + +// The scanner used to walk the platform table character by character, so a +// bracket key nested inside a version's own body (mirror tables write +// `["GLOBAL"] = "https://..."`) counted as a published version. +TEST(ListXpkgVersions, NestedBracketKeysAreNotVersions) { + constexpr auto src = R"( +package = { + name = "foo", + xpm = { + linux = { + ["1.0.0"] = { + url = { ["GLOBAL"] = "u-global", ["CN"] = "u-cn" }, + sha256 = "z", + }, + ["1.1.0"] = { url = "u", sha256 = "z" }, + }, + }, +} +)"; + auto keys = mcpp::manifest::list_xpkg_versions( + src, mcpp::platform::TargetPlatform::for_lint_of("linux")); + ASSERT_EQ(keys.size(), 2u); + EXPECT_EQ(keys[0], "1.0.0"); + EXPECT_EQ(keys[1], "1.1.0"); +} + TEST(Manifest, BuildCflagsCxxflagsAndCStandard) { constexpr auto src = R"( [package] diff --git a/tests/unit/test_version_req.cpp b/tests/unit/test_version_req.cpp index 648abb6d..adeb0cec 100644 --- a/tests/unit/test_version_req.cpp +++ b/tests/unit/test_version_req.cpp @@ -8,19 +8,23 @@ using namespace mcpp::version_req; TEST(VersionReq, ParseVersion) { auto v = parse_version("1.2.3"); ASSERT_TRUE(v); - EXPECT_EQ(v->major, 1); EXPECT_EQ(v->minor, 2); EXPECT_EQ(v->patch, 3); + EXPECT_EQ(v->major(), 1); EXPECT_EQ(v->minor(), 2); EXPECT_EQ(v->patch(), 3); v = parse_version("0.5"); ASSERT_TRUE(v); - EXPECT_EQ(v->major, 0); EXPECT_EQ(v->minor, 5); EXPECT_EQ(v->patch, 0); + EXPECT_EQ(v->major(), 0); EXPECT_EQ(v->minor(), 5); EXPECT_EQ(v->patch(), 0); v = parse_version("7"); ASSERT_TRUE(v); - EXPECT_EQ(v->major, 7); EXPECT_EQ(v->minor, 0); EXPECT_EQ(v->patch, 0); + EXPECT_EQ(v->major(), 7); EXPECT_EQ(v->minor(), 0); EXPECT_EQ(v->patch(), 0); + // #363: pre-release is PARSED, not stripped. Build metadata is dropped from + // the order (SemVer §10) but the key's identity lives in the literal, which + // pm/resolver.cppm carries separately. v = parse_version("1.2.3-beta.1+build42"); - ASSERT_TRUE(v) << "should strip pre-release"; - EXPECT_EQ(v->str(), "1.2.3"); + ASSERT_TRUE(v); + EXPECT_EQ(v->str(), "1.2.3-beta.1"); + EXPECT_TRUE(v->isPrerelease()); } TEST(VersionReq, ParseAny) { @@ -101,10 +105,10 @@ TEST(VersionReq, RejectsGarbage) { TEST(VersionReq, DateVersionParsesFourSegments) { auto v = parse_version("2026.7.27.1"); ASSERT_TRUE(v); - EXPECT_EQ(v->major, 2026); - EXPECT_EQ(v->minor, 7); - EXPECT_EQ(v->patch, 27); - EXPECT_EQ(v->revision, 1); + EXPECT_EQ(v->major(), 2026); + EXPECT_EQ(v->minor(), 7); + EXPECT_EQ(v->patch(), 27); + EXPECT_EQ(v->revision(), 1); } TEST(VersionReq, DateVersionOrdersWithinTheSameDay) { @@ -174,3 +178,121 @@ TEST(VersionReq, ChooseBestAmongDateVersions) { ASSERT_TRUE(pick); EXPECT_EQ(avail[*pick].str(), "2026.7.27.10"); } + +// ─── #363: a version is an ORDER; the literal key is the identity ──────── +// +// Every case below is a shape the real xim-pkgindex publishes today, not a +// hypothetical: `compat.imgui` ships 1.92.8 alongside 1.92.8-docking (a +// different upstream branch, a different tarball), `jdk-temurin` ships +// 25.0.4+7, `jdk-corretto` ships the five-segment 25.0.4.7.1, and `khistory` +// publishes only `pre-v0.0.5`. + +TEST(VersionReq, PrereleaseSortsBelowItsRelease) { + EXPECT_LT(*parse_version("1.92.8-docking"), *parse_version("1.92.8")); + EXPECT_LT(*parse_version("1.3.3-beta.1"), *parse_version("1.3.3")); + // ...and above the previous release. + EXPECT_LT(*parse_version("1.3.2"), *parse_version("1.3.3-beta.1")); +} + +TEST(VersionReq, PrereleaseIdentifiersCompareBySemVerRules) { + // Numeric identifiers compare numerically, not lexicographically. + EXPECT_LT(*parse_version("1.0.0-rc.9"), *parse_version("1.0.0-rc.10")); + // Numeric identifiers rank BELOW alphanumeric ones (SemVer §11.4.3). + EXPECT_LT(*parse_version("1.0.0-1"), *parse_version("1.0.0-alpha")); + // A longer identifier list outranks its own prefix (§11.4.4). + EXPECT_LT(*parse_version("1.0.0-alpha"), *parse_version("1.0.0-alpha.1")); + // Distinct pre-releases are never equal — the property that keeps + // 1.92.8-docking and 1.92.8-nodocking addressable as two things. + EXPECT_NE(*parse_version("1.92.8-docking"), *parse_version("1.92.8-legacy")); +} + +TEST(VersionReq, RangesDoNotSeePrereleasesUnlessAskedFor) { + // The #363 headline: ^1.92.8 must not silently pick up the docking branch. + auto caret = parse_req("^1.92.8"); ASSERT_TRUE(caret); + EXPECT_TRUE (matches(*caret, *parse_version("1.92.8"))); + EXPECT_FALSE(matches(*caret, *parse_version("1.92.8-docking"))); + + // `*` never reaches a pre-release either. + auto any = parse_req("*"); ASSERT_TRUE(any); + EXPECT_FALSE(matches(*any, *parse_version("1.92.8-docking"))); + + // Naming a pre-release at the same numeric tuple opts in. + auto optIn = parse_req("^1.92.8-a"); ASSERT_TRUE(optIn); + EXPECT_TRUE (matches(*optIn, *parse_version("1.92.8-docking"))); + + // Exact still addresses it (this is the path `imgui = "1.92.8-docking"` + // takes after try_merge_semver canonicalises the literal to "=..."). + auto exact = parse_req("=1.92.8-docking"); ASSERT_TRUE(exact); + EXPECT_TRUE (matches(*exact, *parse_version("1.92.8-docking"))); + EXPECT_FALSE(matches(*exact, *parse_version("1.92.8"))); +} + +TEST(VersionReq, CaretUpperBoundDoesNotLeakIntoTheNextMajorsPrerelease) { + // 2.0.0-alpha sorts BELOW 2.0.0, so a plain `v < upper` bound admitted it. + auto r = parse_req("^1.2.3"); ASSERT_TRUE(r); + EXPECT_FALSE(matches(*r, *parse_version("2.0.0-alpha"))); + EXPECT_FALSE(matches(*r, *parse_version("2.0.0"))); + EXPECT_TRUE (matches(*r, *parse_version("1.9.9"))); +} + +TEST(VersionReq, FiveSegmentKeysAreNotTruncated) { + // jdk-corretto's scheme: ..... + // Truncating at four made 25.0.4.7.1 and 25.0.4.7.2 compare EQUAL. + EXPECT_LT(*parse_version("25.0.4.7.1"), *parse_version("25.0.4.7.2")); + EXPECT_LT(*parse_version("21.0.12.8.1"), *parse_version("25.0.4.7.1")); + EXPECT_EQ(parse_version("25.0.4.7.1")->str(), "25.0.4.7.1"); + // Insignificant trailing zeros still compare equal, at any length. + EXPECT_EQ(*parse_version("1.2"), *parse_version("1.2.0.0.0")); +} + +TEST(VersionReq, BuildMetadataIsExcludedFromPrecedenceButParses) { + auto v = parse_version("25.0.4+7"); + ASSERT_TRUE(v); + EXPECT_EQ(*v, *parse_version("25.0.4")); // SemVer §10 + EXPECT_EQ(v->str(), "25.0.4"); +} + +TEST(VersionReq, UnorderableKeysAreRejectedRatherThanTruncated) { + // llama.cpp-style build numbers, and the two non-numeric keys the real + // index publishes. Each must be an ERROR here (→ exact-match-only in the + // resolver), never a silent parse that collapses onto something else. + EXPECT_FALSE(parse_version("b10069").has_value()); + EXPECT_FALSE(parse_version("latest").has_value()); + EXPECT_FALSE(parse_version("nightly").has_value()); + EXPECT_FALSE(parse_version("pre-v0.0.5").has_value()); + // Trailing garbage used to parse as 1.2.3 — and therefore compare EQUAL + // to it, the same silent merge the extra segments exist to prevent. + EXPECT_FALSE(parse_version("1.2.3abc").has_value()); + EXPECT_FALSE(parse_version("1.2.").has_value()); + EXPECT_FALSE(parse_version("1.2.3-").has_value()); + EXPECT_FALSE(parse_version("1.2.3+").has_value()); +} + +TEST(VersionReq, ChooseAllReportsPrecedenceTies) { + // 25.0.4 and 25.0.4+7 are two addresses at one precedence. choose_all must + // hand BOTH back so the resolver can refuse instead of guessing. + std::vector avail = { + *parse_version("25.0.4"), + *parse_version("25.0.4+7"), + *parse_version("21.0.12"), + }; + auto r = parse_req("*"); ASSERT_TRUE(r); + auto best = choose_all(*r, avail); + ASSERT_EQ(best.size(), 2u); + EXPECT_EQ(best[0], 0u); + EXPECT_EQ(best[1], 1u); + + // The ordinary case stays a single answer. + std::vector plain = { *parse_version("1.0.0"), *parse_version("1.2.0") }; + EXPECT_EQ(choose_all(*r, plain).size(), 1u); +} + +TEST(VersionReq, McppOwnDateVersionsAreUnaffectedByPrereleaseSupport) { + // E0006 (index floor) compares mcpp's own version through this parser. + // Adding pre-release/metadata handling must not move any of it. + EXPECT_LT(*parse_version("2026.8.3.3"), *parse_version("2026.8.6.3")); + EXPECT_LT(*parse_version("2026.8.1"), *parse_version("2026.8.1.1")); + EXPECT_EQ(*parse_version("2026.8.1.0"), *parse_version("2026.8.1")); + EXPECT_FALSE(parse_version("2026.8.6.3")->isPrerelease()); + EXPECT_EQ(parse_version("2026.8.6.3")->str(), "2026.8.6.3"); +} From 463dedfa5a36a510df57108a9c562d350d6f9967 Mon Sep 17 00:00:00 2001 From: SPeak Date: Sat, 8 Aug 2026 02:18:51 +0800 Subject: [PATCH 02/54] =?UTF-8?q?feat:=20xlings=20=E4=BD=9C=E4=B8=BA?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=97=B6=E5=BA=95=E5=BA=A7=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20subos=20=E7=8E=AF=E5=A2=83=E5=88=B0=E8=BE=BE?= =?UTF-8?q?=E7=A8=8B=E5=BA=8F=20+=20self-contained=20=E7=9A=84=20/proc/sel?= =?UTF-8?q?f/exe=20=E9=99=B7=E9=98=B1=20(#352,=20#375)=20(2026.8.8.1)=20(#?= =?UTF-8?q?376)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: xlings as mcpp's runtime substrate — design and implementation plan * fix(pack): the self-contained wrapper broke /proc/self/exe and never said so Launching through the bundled loader makes the kernel set /proc/self/exe to the loader. Every "find my resources next to the executable" path then resolves against lib/ -- silently. mcpp#375 reported this as a user's own workaround; it is in fact what mcpp's own self-contained mode produces, and the docs explained the ELF constraint that forces the launch without ever stating its consequence. The ELF spec forbids $ORIGIN in PT_INTERP, so the launch itself cannot go away. What can is the silence: the wrapper now exports MCPP_BUNDLE_DIR, and both entry points carry it because they are documented as interchangeable. Applications that cannot be changed are pointed at --mode vendored, where PT_INTERP is the host loader and /proc/self/exe is correct. * docs: retract the c_runtime axis — it answered the reporter's method, not their problem #375 is titled "let the app link against the system libc". That is the reporter's SOLUTION. Their problem is that the artifact cannot be distributed. The design took the title at face value and proposed a [build] c_runtime axis whose one new capability was host-coupled -- linking the artifact against the host's libc. That crosses the boundary this ecosystem exists to hold. xlings is a userspace distribution and its hermetic policy names, first on the forbidden list, any .so under /usr/lib* or /lib* -- libc included. mcpp should not use the host when it does not have to. Removing that one value leaves the axis with nothing: self-contained is already spelled --target x86_64-linux-musl, and toolchain-coupled is today's behaviour. It would also have created a second answerer for "may I use the host's libc", which [build] allow_host_libs already owns. The problem has three hermetic answers and all three already exist: emit xpkg into the ecosystem (xlings repoints the binary at install time), a static musl target, or pack --mode self-contained. Two of them work today; the third was broken, and that is what the previous commit fixed. What was missing was never a mechanism -- it was that none of the three is discoverable. The axis is kept in the document as a rejected option rather than deleted. It had passed 12 contract tests, 7 renderer tests and a five-assertion e2e; green tests cannot tell you a feature should not exist, and the next person reading #375 will have the same idea. * feat(xlings): read the subos's own description instead of inferring it A program needs bootstrap, discovery and configuration. xlings had the first two and, until it grew the subos_info block, nothing for the third -- its own module comment names the consequence by number: mcpp#352, a GLFW binary that links fine and exits 255 because nothing told it where the GL drivers are. mcpp is the consumer of the third one, and it reads rather than knows. That is the property that matters: mcpp must never contain the string LIBGL_DRIVERS_PATH, because the moment it does the graphics stack has two owners and the next capability xlings adds becomes an mcpp code change instead of a declaration. Every degradation fills the `note` field and callers must print it. A subos with no block is not exotic -- mcpp's own sandbox subos was measured in exactly that state, 356 workspace entries and no self-description -- and silence there is what made #352 expensive to find. family_of duplicates a five-row mapping that xlings also has. The alternative was asking the xlings binary, which costs a subprocess in the hot path and fails precisely where it matters: a sandbox xlings that has not been updated. Every row is pinned in tests, so a drift is a test failure rather than a silent ABI disagreement. * feat(run): a program mcpp launches gets its subos's environment A program needs three things: it links, it finds its libraries, and it is told where its runtime data lives. mcpp supplied the first two and nothing for the third. xlings's graphics packages declare LIBGL_DRIVERS_PATH and friends into the subos; `xlings subos use` applied them and `mcpp run` did not. That gap is mcpp#352 -- a GLFW binary that links cleanly and exits 255 with no output, because nothing ever told it where the GL drivers are. mcpp carries whatever the subos declares and knows what none of it means. The e2e probes a variable with no graphics meaning on purpose: naming a real one would suggest mcpp has an opinion about it, and the moment it does the graphics stack has two owners. Resolved at run time rather than cached with the build. These values belong to the subos, not to the build, so a user who switches subos between `mcpp build` and `mcpp run` has to get the new ones; it is one file read. The subos is derived from the toolchain binary rather than from a global, so a toolchain inherited from another home resolves to that home's subos -- the one whose payloads the binary was actually linked against. MCPP_SUBOS_DIR overrides it, which is how the e2e exercises this without going near a real subos: an earlier test wrote through a symlink and permanently broke a real toolchain. * docs: three ways to ship, none of which uses the host's libc mcpp#375 asked for a supported way to make a distributable artifact and proposed one: link against the system libc. Three routes already produce a distributable artifact and none of them does that -- through the ecosystem (xlings repoints the ELF at install time), a static musl target, or a bundle carrying the toolchain's own glibc. The gap was never a mechanism. It was that the page opened with "use mcpp pack" and never said what the alternatives were, so the reader who wanted something else had to invent it. The note about route A is the one that answers #375 directly: a freshly built binary's PT_INTERP names the build machine's payload, and hand-copying that file elsewhere fails. That is a property of hand-copying, not of the artifact -- installed through xlings the ELF is repointed at the target's own payloads. Saying so is what stops the next reader concluding the artifact is broken and reaching for the host's loader to "fix" it. * chore: 2026.8.8.1, and track xlings 2026.8.7.1 The xlings pin moves because this release consumes something new from it: the subos_info block a subos writes to describe itself. Reading it against an older xlings degrades quietly by design, but CI should exercise the version that actually has it. The bootstrap pin stays at 2026.8.6.2. It is where the self-host starts, not what the release ships -- moving it would send every CI job looking for a version that does not exist yet. * fix(run): the cached fast path dropped the subos environment `mcpp run` has two paths and only one of them had been taught about subos declarations. The full path resolves the toolchain and applies them; the fast path skips prepare_build entirely and built its own child environment. So a program got its environment on the run right after a build and lost it on every run after that -- for a GL application, "it worked once and now the window is black", with nothing in between to attribute it to. WHICH subos is a build property and is now recorded in the build cache. WHAT it declares is the subos's own and is re-read on every run, so installing a graphics stack between two runs takes effect without a rebuild. That split is also why MCPP_SUBOS_DIR moved out of the derivation: an override means "for this invocation", and caching one would let a single `MCPP_SUBOS_DIR=… mcpp run` silently redirect every later run. Found by an assertion that the test did not originally have. The first version ran `mcpp run` once, passed, and proved nothing about the path it was written for. The fast-path check now runs first and fails loudly if the second run did NOT take the fast path -- without it the assertion below it is vacuous and would keep passing after the coverage silently went away. It caught a second defect immediately: the new cache line was written before `profile=` and parsed after `cacheMode=`, so every entry read back as stale and the fast path was disabled for everyone. Two dead capability tokens, and a guard so there is no third. `# requires: linux` and `# requires: llvm` are not capabilities run_all.sh ever sets, so 65_toolchain_runtime_dirs_for_run.sh had never executed in CI -- and it passes. The skip line for a token that cannot exist reads exactly like the skip line for one that legitimately does not, which is what let it sit. The runner now refuses to start when a test declares a token outside the known universe. The xlings pin goes back to 2026.8.6.3. It was moved to 2026.8.7.1 for tidiness rather than need -- subos_info degrades quietly on an older xlings by design -- and CI's fresh-sandbox jobs failed with the toolchain's own g++ exiting 127, the signature of an interpreter that is not where the binary says it is. Whether that is a real incompatibility is worth knowing, but not on the back of a change that does not need it. * fix(xlings): the env list separator is the platform's, not a literal ':' Windows CI caught this; no amount of reading would have. resolve_env joined and split its lists on ':', which on Windows is both the wrong separator (it is ';') and a character that appears INSIDE every absolute path. So de-duplication split "C:\x" into "C" and "\x", matched nothing, and the joined value came back as "C:\...\x:C:\...\x" -- a list that grows on every nested invocation and that no consumer can parse. This repository has made the same mistake before, in the other direction: find_first_of(";:") over a Windows PATH cuts at the drive-letter colon. The three test expectations that failed were also wrong, but differently, and the difference matters: they compared against a path JOIN while the code does a literal substitution. The literal one is correct -- the separator inside a declaration belongs to the subos manifest, and rewriting it to the host's spelling would be editing a value we do not own. The assertions now say so, and the dedup test additionally asserts the result contains NO separator at all, which is the property that actually failed. * fix(xlings): drop a ranges spelling that crashed the clang 20 frontend Windows CI: `clang++: error: clang frontend command failed due to signal`, with the diagnostic file named subos_info-*.cppm. No message beyond the signal, so the offending construct is identified by removal rather than by a compiler telling us. The one exotic thing in the file was `std::ranges::find` with a member-pointer projection into std::pair; `std::ranges::sort` went with it for the same reason. Both are replaced by plain loops, which nothing here needed to be fancier than. Ruled out first: importing mcpp.platform, added in the previous commit and the only other change to this file between the run that failed a test assertion and the run that crashed the compiler. Four modules already import mcpp.libs.json and mcpp.platform together (bmi_cache, stdmod, post_install, prepare), so that combination is not it. Stated plainly because it matters for the next person: this is a hypothesis confirmed only by CI going green, not by a local reproduction. The crash needs clang 20.1.7 targeting MSVC and I have no such host. --------- Co-authored-by: speak-agent --- ...8-07-xlings-as-runtime-substrate-design.md | 597 +++++++++++++ ...s-runtime-substrate-implementation-plan.md | 840 ++++++++++++++++++ docs/02-pack-and-release.md | 72 +- docs/zh/02-pack-and-release.md | 58 +- mcpp.toml | 2 +- src/build/execute.cppm | 105 ++- src/pack/pack.cppm | 19 +- src/version.cppm | 2 +- src/xlings.cppm | 33 + src/xlings/subos_info.cppm | 242 +++++ tests/e2e/200_subos_env_reaches_program.sh | 120 +++ tests/e2e/30_pack_modes.sh | 22 + .../e2e/65_toolchain_runtime_dirs_for_run.sh | 2 +- tests/e2e/run_all.sh | 34 + tests/unit/test_subos_info.cpp | 180 ++++ 15 files changed, 2314 insertions(+), 14 deletions(-) create mode 100644 .agents/docs/2026-08-07-xlings-as-runtime-substrate-design.md create mode 100644 .agents/docs/2026-08-08-xlings-runtime-substrate-implementation-plan.md create mode 100644 src/xlings/subos_info.cppm create mode 100755 tests/e2e/200_subos_env_reaches_program.sh create mode 100644 tests/unit/test_subos_info.cpp diff --git a/.agents/docs/2026-08-07-xlings-as-runtime-substrate-design.md b/.agents/docs/2026-08-07-xlings-as-runtime-substrate-design.md new file mode 100644 index 00000000..92376ade --- /dev/null +++ b/.agents/docs/2026-08-07-xlings-as-runtime-substrate-design.md @@ -0,0 +1,597 @@ +# xlings 作为 mcpp 的运行时底座:运行时身份、链接契约与环境契约 + +**日期**:2026-08-07 +**性质**:设计提案,待 review 后再实施。本文不改任何代码。 +**触发**: +- mcpp#375(产物烙私有 glibc PT_INTERP ⇒ 不可分发;系统 loader 包装 ⇒ `/proc/self/exe` 失效) +- mcpp#352(GLFW/OpenGL 静默 exit 255:沙箱 glibc 2.39 加载不了宿主 Mesa) +- xlings 侧新能力已落地:glibc 2.44、22 包 hermetic 图形栈(`xim:graphics`)、subos 自描述(`subos_info`) + +**关联(xlings 仓)**: +- `.agents/docs/2026-08-05-ecosystem-three-tier-and-composable-distro.md` —— 三分层定位,**开放问题 K:mcpp 消费 subos 还是消费 platform 描述**。本文回答它。 +- `.agents/docs/2026-08-06-subos-architecture-proposal.md` —— 七条规则 R1–R7,本文全程引用 +- `.agents/docs/2026-08-05-userspace-distro-hermetic-strategy.md` —— 运行时边界 + +**关联(mcpp 仓)**: +- `.agents/docs/2026-08-02-issue336-pr142-analysis.md` —— `cxx_runtime` 契约层。**本文是它在 libc 轴上的同族补全。** +- `.agents/docs/2026-07-07-hermetic-toolchain-link-model-design.md` —— `linkmodel.cppm` 的由来 + +--- + +## 0. TL;DR + +**一句话根因:mcpp 把 xlings 当成「一套需要自己反推的目录约定」,而不是「一个可以查询的运行时」。** + +后果是可预测的:xlings 每前进一步,在 mcpp 侧都落成**一次改代码**,而不是**一次改数据**。glibc 2.44 如此,图形栈如此,下一个能力也会如此。 + +三条缝,按依赖顺序: + +| | 缝 | 现状 | 目标 | +|---|---|---|---| +| **S1** | **运行时身份** | 从目录布局反推「哪个 libc」,且**无版本** | 读 subos 的 `subos_info.runtime`(`glibc@2.39`),成为一等轴 | +| **S2** | **分发路径** | 三条 hermetic 路径都存在,但用户找不到;其中一条自己是坏的 | 修坏的那条 + 让三条可发现。**不加「链宿主 libc」的开关** | +| **S3** | **环境契约** | mcpp 产物拿不到 subos 的 env,图形栈靠 mcpp 代码硬扛 | 消费 `subos_info.envs`;图形栈以**依赖**而非代码到达 | + +**关键结论:P0 需要 xlings 零改动。** `subos_info` 已经存在,subos view 已经填充好(`subos/default/lib/ld-linux-x86-64.so.2` 实测在位)。唯一需要 xlings 配合的是**把 installer 已经算出来、当前只活在进程里的 `resolved_deps`/`deps_exports` 持久化**——那是 P1。 + +**多维评估见 §11**(实现代价 / 用户 / 稳定性 / 跨平台 / 简洁 / 兼容性,每项带实测数字)。三条要点: + +- **本文被推翻过一整节**(§3-S2):初稿要补一条 `c_runtime` 轴,其 `host-coupled` = 链宿主 libc。已实现、全绿、然后整条撤销。理由与教训写在那一节,**比结论更值得读** +- **零 BMI/对象缓存失效** —— fingerprint 是 compile-side,分发相关的改动都只碰链接与打包 +- **收益 ≈ 全在 Linux**;#352 的图形栈迁移是其中收益最大、代价最小的一条 + +--- + +## 1. 实测现状(不是推断) + +本节每一条都在本机跑过,命令附在后面,便于 review 时复核。 + +### 1.1 产物里烙进了四个独立的版本 pin + +``` +$ mcpp new hello && cd hello && mcpp build +$ file target/x86_64-linux-gnu/*/bin/hello +… interpreter /home/speak/.mcpp/registry/data/xpkgs/xim-x-glibc/2.39/lib64/ld-linux-x86-64.so.2 +$ readelf -d target/x86_64-linux-gnu/*/bin/hello | grep RUNPATH + RUNPATH [ …/xim-x-llvm/22.1.8/lib/x86_64-unknown-linux-gnu + : …/xim-x-llvm/22.1.8/lib + : …/xim-x-gcc-runtime/15.1.0/lib64 + : …/xim-x-glibc/2.39/lib64 ] +``` + +一个 hello world 产物同时钉死了:**home 绝对路径**、**glibc 2.39**、**llvm 22.1.8**、**gcc-runtime 15.1.0**。 + +这正是 xlings `libs/graphics.lua:47-54` 刚刚明文废弃的反模式: + +> `${pkgdir}` 是声明包自己的载荷,它**钉死一个版本目录**:升级 mesa 会让消费者记录的 env 指向旧的那个。subos 视图才是稳定的间接层——就是 `/run/opengl-driver` 在 NixOS 扮演的角色。 + +mcpp 烙的就是 `${pkgdir}` 等价物。 + +### 1.2 稳定间接层已经存在,而且已经填充好 + +``` +$ ls -l ~/.mcpp/registry/subos/default/lib/ +ld-linux-x86-64.so.2 -> …/xim-x-glibc/2.39/lib64/ld-linux-x86-64.so.2 +crt1.o crti.o crtn.o -> …/xim-x-glibc/2.39/lib64/… +libc.so.6 libc.so libm.so.6 … -> …/xim-x-glibc/2.39/lib64/… +libc++.so.1 libc++abi.so.1 -> …/xim-x-llvm/22.1.8/lib/… +libatomic.so.1 libasan.so.8 -> …/xim-x-gcc/16.1.0/lib64/… +``` + +loader、CRT、libc、C++ 运行时**全部在视图里**,按活动版本指向载荷。mcpp 一个都没用。 + +> ⚠️ 注意布局差异:载荷里 loader 在 `lib64/`,视图里在 `lib/`。mcpp 现有的 `{lib64, lib}` 顺序探测(`probe.cppm:346-349`、`linkmodel.cppm:249-258`)**碰巧两边都能命中**——因为它回退到 `lib`。但「碰巧能命中」正是本文反复要指出的那类脆弱:两个布局不同的树共用一份约定探测,靠的是回退顺序恰好合适。视图新增一个 `lib64` 就会翻。这条要在 P0 验证门里钉住(V5)。 + +### 1.3 mcpp 的 sandbox subos 不描述自己 + +``` +$ python3 -c "import json;d=json.load(open('$HOME/.mcpp/registry/subos/default/.xlings.json'));print(list(d.keys()), len(d['workspace']))" +['workspace'] 356 +``` + +356 个版本条目,**零运行时身份、零环境声明**。没有 `subos_info` 块。 + +而 xlings `src/core/subos/manifest.cppm:16-20` 的模块注释,点名了这件事: + +> 一个程序需要三样:bootstrap(PT_INTERP + CRT + libc)、discovery(PATH + RPATH)、configuration(env vars)。xlings 有前两样——glibc + elfpatch、xvm + shims——第三样什么都没有。**That is the gap behind mcpp-community/mcpp#352**:一个 GLFW 二进制链接得好好的,exit 255,因为没人告诉它 GL 驱动在哪。 + +xlings 已经把这个缺口补上了(`subos_info`,schema 1)。**mcpp 的 subos 没有拿到它。** + +### 1.4 xlings 的权威答案只活在进程里 + +`src/core/xim/installer.cppm:2394-2450` 已经为**每一个** runtime 依赖计算并记录了完整档案(遵守 R1「权威记录必须是全量的」),包括 `install_dir`、`libdirs`(`{lib64, lib}` 约定**只在这里**应用一次)、`loader`、`abi`。 + +但落到磁盘上的只有: + +``` +$ cat ~/.mcpp/registry/data/xpkgs/xim-x-glibc/2.39/.xpkg-install.json +{ "os": "linux", "version": "2.39", "xlings_version": "2026.8.2.1" } +``` + +三个字段。`exports` 一个都没落盘。 + +这解释了 mcpp `linkmodel.cppm:182-188` 当年为什么拒绝它: + +> 第三个来源——installer 写的持久化 `.xpkg-exports.json` 声明元数据——评估后移除了:它唯一的消费者只有这个 resolver,而上面两个来源已经覆盖了每一个真实载荷。 + +**那个判断在当时是对的,今天不再对**。理由见 §2.2。 + +### 1.5 图形栈:mcpp 侧仍是宿主借用 + +mcpp-index `pkgs/c/compat.glx-runtime.lua` 至今把宿主 `/usr/lib/x86_64-linux-gnu/libGL.so.1` 等 symlink 进 `mcpp_generated/glx_runtime/lib`,再经 `[runtime] library_dirs` 进 RUNPATH(`flags.cppm:698-706`)。 + +这就是 #352 的直接成因:宿主 Mesa 要 `GLIBC_2.43`,载荷 glibc 是 2.39。而 hermetic 策略已经把这个文件点名为**要淘汰的样本**。 + +同时,xlings 侧的替代品**已经可用**:`xlings install graphics` —— 22 包 hermetic 栈 + NVIDIA/WSL2 哨兵,一条命令五种宿主形态,无条件分支。 + +### 1.6 `/proc/self/exe` 陷阱不是用户的 workaround —— 是 mcpp 自己在生产它 + +这是本次调研里最出乎意料的一条,值得单独列。 + +#375 描述的第三条症状(用 `ld.so --library-path` 启动后 `/proc/self/exe` 指向 loader)读起来像是用户自己想出来的绕法。**但 mcpp 的 `self-contained` 打包模式生产的就是这个 wrapper**,`docs/02-pack-and-release.md:120-127` 原样写着: + +```sh +exec "$here/lib/ld-linux-x86-64.so.2" --library-path "$here/lib" "$here/bin/myapp" "$@" +``` + +文档给的理由是对的(ELF 规范禁止 `PT_INTERP` 用 `$ORIGIN`),但**后果没有写**:凡是用 `mcpp pack --mode self-contained` 分发的程序,`/proc/self/exe` 全部指向 loader,`/proc/self/cmdline` 全部混入 `--library-path`。所有「在 exe 旁边找资源」的逻辑静默失效——字体、assets、随包分发的辅助二进制。 + +而 mcpp 自己就依赖这个机制:`src/platform/fs.cppm:106` 读 `/proc/self/exe` 定位自身。 + +全仓 grep `proc/self/exe` 只有那一处实现,**文档里零处提及这个陷阱**。所以 #375 请求的两条文档补充里,(a)「私有 glibc 对分发的影响」其实已经写了(`02-pack-and-release.md:3-6` 开篇就是),(b)「系统 loader 包装后的 `/proc/self/exe` 陷阱」**确实没有,而且比提问者以为的更严重**——它不是一条使用建议,是一个在售模式的已知缺陷。 + +--- + +## 2. 根因 + +### 2.1 一个问题,mcpp 侧有 N 个回答者 + +「C 运行时在哪、loader 是哪个、哪些目录进 RUNPATH」这一个问题,mcpp 侧今天有六个独立推导: + +| # | 位置 | 怎么答的 | +|---|---|---| +| 1 | `probe.cppm:333-349` | `find_sibling_tool(compilerBin,"glibc")` 爬父目录 → `lib64` 不存在则 `lib` | +| 2 | `linkmodel.cppm:194-243` | 五行 arch→loader 文件名硬表,不中则 glob `ld-*.so*` | +| 3 | `linkmodel.cppm:215-221` | `distro_loader_path`:x86_64 → `/lib64/`,其余 → `/lib/` | +| 4 | `post_install.cppm:162-200` | 扫 clang cfg 文本找 `/ld-linux-` 反推烙定的 loader | +| 5 | `pack.cppm:650-653` | 从 `ldd` 输出取 loader soname,再拼 `/lib64/` 或 `/lib/` | +| 6 | `probe.cppm:190-223` | `lib` / `lib64` / `lib/` 三种约定拼 runtime 目录 | + +这**正是** xlings 侧 `2026-08-06-subos-architecture-proposal.md` §1 诊断出的 P1「一个问题有多个回答者」,只是发生在河的下游。而 xlings 已经从自己那边删到了一个(R2:约定只在写端应用,读端永远不猜)——mcpp 侧还有六个。 + +判据(R3):**修复如果是「增加一条路径」而不是「移除一条」,它是 workaround。** 下面的设计要能删掉其中的 4–5 条,否则不合格。 + +### 2.2 为什么「拒绝声明元数据」的判断需要翻转 + +`linkmodel.cppm` 当年的理由是「唯一的消费者只有这个 resolver」。今天的消费者清单是: + +| 消费者 | 需要什么 | 约定能答吗 | +|---|---|---| +| 链接模型 | loader、libdirs、CRT 目录 | 能(勉强) | +| **运行时身份**(新) | glibc **版本**、ABI family | **不能**——目录名不是契约 | +| **subos 视图寻址**(新) | 视图里的 loader 路径(在 `lib/`,载荷在 `lib64/`) | **靠回退顺序碰巧能**(§1.2)——即两个不同布局共用一份猜测 | +| **图形/env 契约**(新) | 哪些 env、哪些值 | **不能** | +| `mcpp pack` | 目标机 loader | 能(硬表) | +| `mcpp doctor` | 悬空链接、版本偏斜 | 部分 | + +从一个消费者变成六个,而新增的三个**约定原理上答不了**。R2 的判据在这里是决定性的:一句「没有就自己猜」等于授权每个读端各自实现一份猜测,而读端会随时间增加——mcpp 侧已经从 1 增加到 6。 + +### 2.3 缺的那一层:libc 轴的契约层在错误的生命周期上 + +这是最关键的一条,而且 mcpp 自己的代码已经把它写出来了。 + +`src/build/distribution.cppm:73-77`: + +> **NOTE ON SCOPE**:契约管的是 C++ 运行时(stdlib + 它的 ABI 与 unwinder)。**libc 轴是分开的,归 `linkage`/`--static` 管**,部署地板是第三条轴。 + +三层模型 `Role → Contract → Mechanism` 在 C++ 轴上是完整的: + +``` +Role Distributable | Test | Intermediate ← 内在,用户写不了 +Contract SelfContained | ToolchainCoupled | HostCoupled ← [build] cxx_runtime +Mechanism (contract × stdlib × 二进制格式) → 链接 flags ← total function +``` + +**libc 轴上只有 Mechanism 那一层,而且分散在两个生命周期**:构建期 `--static`,打包期 `--mode`。用户在构建期能说的只有机制,说不了意图(「这个产物要能在没装 mcpp 的机器上跑」)。 + +而 #336 的分析文档写清了这个形状为什么必然出错: + +> 那个 bool 拼的是**机制**(「静态链接 stdlib」),它膨胀成三种不同的平台含义——包括在 Linux/libc++ 上静默无操作:声称 static、产出 toolchain-coupled。契约拼的是**意图**。 + +**这个观察是对的,但从它推出的结论曾经是错的。** 初稿由此推出「那就补一条 `c_runtime` 轴」——而这条轴唯一的新能力是把产物链到宿主 libc,即策略上被禁的那一侧。**一个真实的架构不对称,不构成做一件被禁的事的理由。** 正确的读法见 §3-S2:用户要的是「能分发」,而 hermetic 的分发路径已经有三条。 + +这一段保留,是因为那个不对称本身仍然真实,只是它的正确用途是**解释为什么 `mcpp pack` 的默认模式是宿主耦合的**(§3-S2 末尾那笔记账),而不是给 build 期再开一个口子。 + +**准确说,不是「没有机制」——是机制在错误的生命周期上。** `mcpp pack` 有一个成熟的两轴模型(`docs/02-pack-and-release.md:8-37`:target(libc)× mode(bundling depth),四个模式 `system` / `vendored` / `self-contained` / `static`),`vendored` 默认就会把 PT_INTERP 重指到 `/lib64/ld-linux-*.so.2`(`pack.cppm:649-658`)。所以 **#375 的第 1、2 条症状今天有受支持的答案**。 + +真正的不对称是这个: + +| 轴 | 契约在哪一期可声明 | 怎么实现 | +|---|---|---| +| C++ 运行时 | **构建期**,`[build] cxx_runtime` | 链接时给对 flags | +| C 运行时 / loader | **只有打包期**,`mcpp pack --mode` | 链接后用 patchelf 改写 | + +同一类决策(「产物对运行它的机器承诺什么」),一条在构建期声明、一条只能在打包期补救。后果有三个,都是实测的: + +1. `mcpp build` 的产物看起来像交付物、其实不是,而**这件事只有读到 pack 文档才知道**——#375 的提问者没走到那一步,直接去套了 `ld.so` 包装 +2. `mcpp run` 跑的永远不是将要分发的那个配置,所以分发问题只能在 pack 之后才暴露 +3. patchelf 改写是**事后**的:它改得了 PT_INTERP 与 RUNPATH,改不了「链接时就该按目标契约选 RUNPATH 形状」这件事,于是 `self-contained` 只能退回 wrapper 脚本——**即 §1.6 那个缺陷的来源** + +> **推论(值得单独强调)**:`/proc/self/exe` 那条症状**不是私有 glibc 的后果,是 wrapper 的后果**;而 wrapper 是「契约无法在链接期表达」的后果。只要 PT_INTERP 指向一个在目标机上真实存在的路径,就不需要 wrapper,`/proc/self/exe` 自然正确。**修契约,第三条症状自己消失——不需要为它单独设计任何东西。** + +### 2.4 R6:mcpp 两个用途都绑了载荷,而只有一个该绑 + +xlings 侧 R6:**内部消费者绑定 payload,不绑定视图。** 三层的消费者不同: + +| 层 | 谁该消费 | mcpp 今天 | +|---|---|---| +| payload `data/xpkgs//` | xlings/libxpkg 自身;**构建期**的 flags | ✅ 用了(正确) | +| subos 视图 `subos//{bin,lib,usr}` | 用户,以及**用户运行的程序** | ❌ 没用 | +| 每个产物的 RPATH/INTERP | 动态加载器 | ❌ **烙的是 payload** | + +mcpp 在**构建期**绑载荷是对的(版本精确、fingerprint 稳定、R6 合规)。 +mcpp 在**运行期**绑载荷是错的——那一层的正确锚点是视图。 + +**一句话:mcpp 用一个地址服务了两个生命周期不同的用途。** 这是所有三个症状的公共上游。 + +--- + +## 3. 设计 + +### S1 — 运行时身份(RuntimeBinding) + +**mcpp 停止反推「哪个 libc」,改为读。** + +新增一条一等轴 `RuntimeBinding`,词汇与 xlings **逐字一致**(`glibc@2.39` / `musl@1.2.5` / `macos_sdk@14.0` / `ucrt@…`),而不是新造一套。 + +解析优先级(**每一级都必须显式,不允许「缺省即约定」**——A2): + +1. `--runtime ` CLI +2. `[target.].runtime` / `[build].runtime` +3. **活动 subos 的 `subos_info.runtime`** ← 新的权威源 +4. 载荷实测(仅当 subos 无 `subos_info`,即老 subos 的降级路径,且**必须打一行可见提示**) + +配套:`abi.cppm:71-106` 的 `libc` 维度从**无版本**(`"glibc"`)升级为**带版本**(`glibc@2.39`),`family_of` 的映射与 xlings `manifest.cppm:81-92` 对齐——但**只在一处推导**。 + +> **这条同时解掉一个隐性重复账**:mcpp `abi_profile()` 从 target triple 推 `*-linux-gnu → glibc`,xlings `family_of()` 从 runtime 推 `glibc@2.39 → linux-x86_64-glibc`。同一个概念两处推导,而 mcpp 那份没有版本——所以 mcpp 今天**表达不了**「这个依赖需要 glibc ≥ 2.39」。多 glibc 一到,它就是下一个 bug 源。 + +**删除**:`probe.cppm` 的 glibc 目录爬升与 `{lib64,lib}` 约定(回答者 #1)。 + +### S2 — 分发路径:修好坏的那条,让三条可发现 + +> **⚠️ 这一节被整节推翻过一次,推翻的理由比结论更重要。** +> +> 初稿在这里提出补一条 `[build] c_runtime` 轴,三值与 `cxx_runtime` 相同,其中 `host-coupled` = 把产物链到**宿主的 libc**。它已经实现并通过了全部测试,然后被整条撤销(commit 已 reset)。 +> +> **撤销理由一:那是照着 issue 提的实现方法做,不是解决 issue 的问题。** #375 的标题写着「让要分发的应用链到系统 libc」——那是提报者**已经想好的解法**。他的**问题**是「产物没法分发」。照着解法做,等于把提报者的方案当成需求。 +> +> **撤销理由二:它穿越了这个生态存在的意义所在的那条边界。** hermetic 策略明文列出禁止穿越项,第一条就是「任何 `/usr/lib*` `/lib*` 下的 `.so`(含 libc)」。xlings 是用户态发行版;一个伸手去 `/lib64` 取 libc 的产物已经不在这个发行版里了。**mcpp 能不用 host 就不用 host。** +> +> **撤销理由三:去掉 `host-coupled` 之后它不剩任何新能力。** `self-contained` 已经由 `--target x86_64-linux-musl` 表达,`toolchain-coupled` 是现状。也就是说这条轴的**全部增量就是那个不该有的值**。 +> +> 保留这段记录,是因为「同一个东西以后还会被再提一次」——下次有人拿 #375 说「加个链系统 libc 的开关吧」,这里有现成的答案。 + +**用户的真问题是「产物没法分发」,而它有三条 hermetic 答案,全都已经存在**: + +| 路径 | 命令 | libc 从哪来 | 适用 | +|---|---|---|---| +| **A. 生态闭环** | `mcpp emit xpkg` → `xlings install` | 目标机自己的 xlings 载荷,**elfpatch 在装机期重指 PT_INTERP/RUNPATH** | 目标机在生态内 | +| **B. 静态单文件** | `--target x86_64-linux-musl` | 自带,静态链接 | 任何 Linux,无任何运行期依赖 | +| **C. 自带运行时** | `mcpp pack --mode self-contained` | 自带这套工具链的 glibc + loader | 任何 Linux,含比构建机更老的 | + +**A 是这个生态真正的答案**,而且它解释了一件容易被误读的事:产物里烙的那个 `PT_INTERP` 指向构建机路径**并不构成分发障碍**——走 A 时目标机的 xlings 会重写它。#375 观察到的「目标机上路径不存在所以起不来」,前提是**绕开生态直接拷贝二进制**。 + +所以缺口不在机制,在**可发现性**,外加 **C 这条路自己是坏的**(§1.6)。 + +**要做的两件事,都不新增穿越宿主的能力:** + +1. **修 C**(§1.6 已实施):`self-contained` 的 wrapper 打坏 `/proc/self/exe`。这是三条 hermetic 路径里唯一一条自己有缺陷的,而它恰好是「目标机没有 xlings 又不想静态链接」时的那条。 +2. **让三条可发现**:`mcpp pack` 与 `mcpp build` 的文档把这三条并列写清楚,`self-contained` 在 glibc 上不可行时的诊断**逐条列出这三条**,而不是只说「不行」。一个只说「不行」的诊断会把用户推向他自己能想到的办法——而那个办法通常就是宿主 libc。 + +**明确不做**:不新增任何让 `mcpp build` 产出链宿主 libc 的开关。已有的那个决定只有一个入口(`[build] allow_host_libs` / `MCPP_ALLOW_HOST_LIBS`),再开第二个就是「同一决策两处推导」,而且这一次推导出来的是策略上被禁的那一侧。 + +> **顺带记一笔既有账**(不在本轮改):`mcpp pack --mode vendored` 是 pack 的**默认**模式,而它把 PT_INTERP 重指到 `/lib64/ld-linux-*.so.2`——即默认打包路径本身就是宿主耦合的。这与 hermetic 策略不一致,但改默认会破坏既有用户,需要单独评估。先记在这里。 + +### S3 — 环境契约(subos 一等公民) + +**mcpp 产物需要的第三样东西(configuration)由 subos 提供,mcpp 消费它,而不是自己实现。** + +三件事: + +1. **写**:mcpp 初始化 sandbox 时确保 subos 有 `subos_info` 块(今天没有,见 §1.3)。不自己造 schema——调 `xlings self init` 的对应路径,或按 schema 1 写。**权威源是 xlings,mcpp 只保证它存在。** + +2. **读并应用**:`mcpp run` / `mcpp test` 把 `subos_info.envs` 的**解析结果**(`${subosdir}` 展开后)应用到子进程,而不是今天只设 `LD_LIBRARY_PATH`(`execute.cppm:285`)。 + `LIBGL_DRIVERS_PATH` / `__EGL_VENDOR_LIBRARY_DIRS` / `XDG_DATA_DIRS` 由此**自动**到位——mcpp 侧一行图形相关的代码都不写。 + +3. **烙进分发物**:`mcpp pack` 把同一份解析结果写进 launcher —— **仅 `toolchain-coupled`**(目标机上有这个 subos);`host-coupled` 不写,因为目标机没有 subos,那里的图形栈是宿主的事。 + +**图形栈以依赖到达,不以代码到达**: + +- mcpp-index 的 `compat.glx-runtime` **废弃**,改为依赖 `xim:graphics` +- 触及 GL 的包(`compat.glfw` / `compat.opengl` / `compat.imgui` / …)声明能力需求,由 capability → xlings 包的解析落到 `xim:graphics` +- 这是 §4 那条一般原则的第一个实例:**xlings 加 Vulkan loader / 换驱动桥 / 支持新宿主形态,mcpp 侧零改动** + +--- + +## 4. 「未来 xlings 升级、mcpp 自动适配」的一般原则 + +用户的要求里最重要的一条不是修 #375,是「**以后 xlings 环境升级,mcpp 能很简单地动态适配**」。上面三条缝各自解一个症状,而让它们不再复发的是下面这条规则: + +> **凡是 xlings 拥有的事实,必须经由恰好一条数据通道到达 mcpp;mcpp 侧对该事实的再推导数必须为 0。** + +三条通道,按**生命周期**划分(不是按内容划分——这是关键,内容会变,生命周期不会): + +| 通道 | 载体 | 生命周期 | mcpp 何时读 | 承载什么 | +|---|---|---|---|---| +| **C1 载荷事实** | `/.xpkg-install.json`(需扩展) | 不可变,随版本目录 | 构建期(prepare) | loader、libdirs、abi、CRT 目录 | +| **C2 subos 事实** | `/.xlings.json` 的 `subos_info` | 可变,随 `xlings use` | 构建期取身份 + 运行期取 env | runtime 身份、env 声明 | +| **C3 宿主能力** | xlings 依赖图里的哨兵包 | 装机期探测 | **不读** | NVIDIA / WSL2 / 无 GPU | + +**C3 的正确做法是「什么都不做」**,这条值得单独说:`xim:graphics` 的哨兵机制(`pkgs/g/graphics.lua`)——每个哨兵探测一个自己不拥有的宿主侧半边,不在就「链接了零个东西然后成功返回」——意味着**「这台机器没有那个」是一个正常返回值,不是一个分支**。所以 mcpp 侧永远不需要写 `if (nvidia)`。任何时候如果 mcpp 里出现了探测宿主 GPU/图形能力的代码,它就是这条原则的违反。 + +**版本偏斜必须可检测,不能静默**:C1/C2 各带 `schema_version`。mcpp 读到不认识的高版本 → 明确降级 + 一行提示;读到缺失 → 走降级路径 + 一行提示。**沉默成功是 xlings 侧诊断出的横切属性(「『没发生』和『成功了』输出相同」),mcpp 侧不要复制它。** + +> 这里有一条来自 xlings 侧的硬教训需要照抄(`libs/sysroot.lua:38-47`):**「版本地板写成数据」永远到不了需要被告知的那些客户端**——能读这个字段的客户端恰恰是不需要被告知的那些。所以 mcpp 读 C1/C2 时,能力缺失的处置必须是**运行时可见的一行输出**,不能是一个只有新版本才会检查的字段。 + +--- + +## 5. 方案对比 + +### 5.1 mcpp 怎么拿到 xlings 的事实(§4 C1 的实现形态) + +| | A. 直接读 xlings 的磁盘文件 | B. 调 xlings CLI 查询 | **C. xlings 落一份版本化契约文件,mcpp 读** | +|---|---|---|---| +| `subos_info` | ✅ 已存在,可直接读 | 需要新子命令 | ✅ 已存在 | +| 载荷 `exports` | ❌ **没落盘** | 需要新子命令 | 需 xlings 小改(值已算出) | +| 离线 | ✅ | ⚠️ 取决于实现 | ✅ | +| 热路径开销 | 零 | ❌ 每次 prepare 起子进程 | 零 | +| 版本偏斜 | mcpp 复制 schema,易漂 | ❌ **旧 xlings 没有新子命令**,而沙箱 xlings 出了名地不刷新 | schema_version 显式 | +| 违反哪条规则 | 部分违反 R2(读端仍需知道布局) | —— | 无 | + +**推荐 C,以 A 为降级路径,B 永不进热路径。** + +理由:写端(installer)**已经**把值算全了(`installer.cppm:2394` 的注释明说「Recorded for EVERY runtime dep, declared exports or not」,正是 R1),持久化几乎零成本,而它一次性删掉 mcpp 侧四个再推导。B 在这个生态里有一个具体的、已被记录的失败模式——沙箱里的 xlings 版本长期落后于 pin,一个「新子命令」在最需要它的机器上恰好不存在。 + +**分期上这个选择是无痛的**:`subos_info`(C2)已经在盘上,所以 **S1 与 S3 的 P0 完全不依赖 xlings 改动**;只有 C1 的 `exports` 持久化需要跨仓协作,排 P1。 + +### 5.2 为什么不补 `c_runtime` 这条轴 + +这一节记录一个**被否掉的方案**,因为它已经实现过一遍,而且看起来很有说服力。 + +| | A. 补 `c_runtime`(含 `host-coupled`) | **B. 不补,修好并指明三条 hermetic 路径** | +|---|---|---| +| 解决「产物没法分发」 | ✅ | ✅ | +| 需要新概念 | 一个 manifest 键 + 一张机制表 + 一条跨层枚举镜像 | 零 | +| 是否新增穿越宿主的能力 | **是** —— 这正是它的全部增量 | 否 | +| 与 hermetic 策略 | **冲突**(禁止穿越第一条就是 `/lib*` 下的 libc) | 一致 | +| 「可不可以用宿主 libc」的回答者数 | **2**(`allow_host_libs` + 新键) | 1 | + +**选 B。** A 有三个独立的致命处,任何一个都够: + +1. **它是照着 issue 的解法做,不是解 issue 的问题**(§3-S2 撤销理由一) +2. **它穿越了这个生态存在的意义所在的边界**(理由二) +3. **去掉那个值之后它不剩任何能力**(理由三)—— 也就是说 A 列那些 ✅ 全都不是 A 独有的 + +值得单独记下:A **已经通过了全部测试** —— 12 个契约表单测、7 个渲染单测、一条覆盖五个断言的 e2e(含「默认不变」与「拒绝要出声」)。**测试全绿不能告诉你这个功能不该存在。** + +### 5.3 `toolchain-coupled` 用载荷寻址还是视图寻址 + +> **本节的推荐在评估阶段被下调过。** 初稿推荐视图寻址;查到 `doctor.cppm:369-387` 之后改为「默认不换,除非配套做完两件事」。理由如下。 + +**新证据**:mcpp 自己的 doctor **已经在检查 subos 视图悬空**,并且写下了成因: + +> Dangling symlinks under `registry/subos/default/lib` — these point into xim payload lib dirs; **a removed package leaves them broken**。 + +也就是说「视图会悬空」不是假想风险,是 mcpp 已经观测到并专门写了检查的现象。而 PT_INTERP 指向不存在的路径时,`exec` 报的是 **`No such file or directory`——指着一个明明存在的文件**,是 Linux 上最经典的假线索之一。 + +**公平地算完四格**(不能只讲对自己有利的那格): + +| 场景 | 载荷寻址(今天) | 视图寻址 | +|---|---|---| +| 载荷升级 2.39→2.44,**保留** 2.39 | ✅ | ✅(glibc 向后兼容) | +| 载荷升级 **+ GC 掉** 2.39 | ❌ 该批坏 | ✅ | +| 载荷被删(无升级) | ❌ 该批坏 | ❌ **全部坏** | +| home 迁移 | ❌ | ❌(链接仍是绝对路径) | + +**视图寻址只在第 2 格严格更好,在第 3 格的爆炸半径更差,其余两格相同。** 一格换一格,不是压倒性的。 + +| | **A. 维持载荷寻址** | B. 换视图寻址 | C. 按 role 分(bin 视图 / test 载荷) | +|---|---|---|---| +| 抗「升级+GC」 | ❌ | ✅ | ✅ | +| 悬空爆炸半径 | 小(按版本分批) | **大(全体)** | 大 | +| e2e 冲击 | 零 | ~4 个断言 PT_INTERP 的用例 | 同 B | +| 可重现性 | ✅ 完全钉死 | ⚠️ 产物行为随 subos 变,**而 fingerprint 覆盖不到**(它是 compile-side) | ⚠️ 同 B | +| 同一轴上的默认值个数 | 1 | 1 | **2** | + +**推荐 A(维持现状),除非同时做完这两件事**——做完之后再切 B: + +1. **doctor 能自动修复视图悬空**,而不只是报告(今天只 `warn`)。R3 判据:报告不是修复。 +2. **exec 失败有人话诊断**:mcpp 在 `run`/`test` 里检测到 PT_INTERP 不可达时,直接说「你的 subos 视图坏了,跑 `mcpp doctor --fix`」,而不是让用户去解读 `No such file or directory`。 + +**不推荐 C**:同一条轴上两个默认值,而「同一决策两处推导」在这个代码库里已经反复付过学费(#233/#240/#344/#336)。 + +**这条不再是「最需要拍板」的一条**——降格成机制层开关之后,它可以在 P2 独立评审,不阻塞 P0/P1。 + +--- + +## 6. 分阶段落地 + +### P0 — 零 xlings 改动 + +按「收益 ÷ 代价」排序,前两条可以立刻做且互不依赖: + +1. **文档 + `self-contained` 缺陷**(§1.6):在 `docs/02-pack-and-release.md` 写明 `/proc/self/exe` / `/proc/self/cmdline` 陷阱,并按 §3-S2 的 (i)/(ii) 择一修掉 wrapper。**这是 #375 唯一一条纯粹的既有缺陷**,不依赖本文任何架构改动。 +2. **mcpp-index**:`compat.glx-runtime` → `xim:graphics` → **#352 关闭** +3. **S3-读**:`mcpp run` / `mcpp test` 消费 `subos_info.envs`;初始化时确保 subos 有 `subos_info`(§1.3 的缺口)。第 2 条要在真实 GPU 上验成(V4),依赖这一条。 +4. **S2-可发现性**:`self-contained` 在 glibc 上不可行时的诊断逐条列出三条 hermetic 路径;`docs/02-pack-and-release.md` 与 `05-mcpp-toml.md` 并列写清 A/B/C。**不新增任何 build 期链宿主 libc 的开关。** + +### P1 — 跨仓契约 + +5. **C1**:xlings 把 `resolved_deps`/`deps_exports` 持久化(schema 版本化) +6. **S1**:`RuntimeBinding` 成为一等轴;`abi.cppm` 的 libc 维度带上版本 +7. **删除**:回答者 #1 #2 #4 #6(R3 判据:必须是**删掉**,不是**再加一条**) + +### P2 — 生态 / 需先满足前置条件 + +8. **前置**:doctor 自动修复视图悬空 + exec 失败人话诊断(§5.3 的两个前提) +9. 之后才评审:`toolchain-coupled` 是否切到视图寻址 +10. 多 glibc / platform 成员身份 —— 与 xlings 侧 platform manifest 联动(xlings 开放问题 B) + +--- + +## 7. 跨仓契约:需要 xlings 侧配合的**只有一件事** + +把 `installer.cppm` 已经算出来的记录落盘。建议形态(字段名沿用 xlings 内部已有的命名,避免第三套词汇): + +```jsonc +// /.xpkg-install.json —— 扩展,不是新文件 +{ + "schema_version": 2, + "os": "linux", "version": "2.39", "xlings_version": "2026.8.7.1", + "exports": { // 本包声明的(self_exports) + "loader": "lib64/ld-linux-x86-64.so.2", + "abi": "linux-x86_64-glibc", + "libdirs": ["lib64"] + }, + "resolved_deps": { // R1:每一个 runtime dep 都记,不只声明了的 + "xim:linux-headers@5.11.1": { + "install_dir": "…", "libdirs": ["…"], "source": "plan-exact" + } + } +} +``` + +三条要求: + +- **相对路径**(相对载荷根),不是绝对路径——绝对路径会把 home 位置烙进一个可被复制/硬链接的文件,而 mcpp 侧已经为这类问题付过学费 +- `schema_version` 必须有,且 mcpp 读到未知高版本要**降级 + 出声** +- **R1 全量**:每一个 runtime dep 都记,包括什么都没声明的那些。缺省即约定是这一族缺陷的共同上游(A2) + +**注意这不是新设计**——`.xpkg-install.json` 已存在、值已算出、字段名已定。只是把一个被丢弃的计算结果写下来。 + +--- + +## 8. 明确不做 + +- **不接管 subos 状态**。mcpp 读 subos、保证 `subos_info` 存在,**不管理** subos 生命周期、不实现 `subos use`、不写 `workspace` 版本 DB。xlings 侧已经把「recipe 里塞 build 逻辑、mcpp 反过来管 subos 状态」点名为层次未分开的症状。 +- **不在 mcpp 里探测宿主图形能力**。哨兵机制在 xlings 侧,见 §4 C3。mcpp 里出现 `if (nvidia)` 即为违规。 +- **不追 glibc 版本**。mcpp 不实现「选最新可用 libc」——那会让两台同命令的机器产出不同结果(xlings 侧对 `DEFAULT_RUNTIME` 用常量而非查表,理由相同)。 +- **不做 platform manifest**。那是 xlings 的开放问题 B,mcpp 侧只需**消费**一个 runtime 身份;platform 成员身份排 P2。 +- **不为 `/proc/self/exe` 单独设计任何东西**。见 §2.3 推论——它是 workaround 的后果,契约修好后自动消失。 +- **不动 BMI/fingerprint 的缓存身份**,除非 P2 改默认契约时另行评估。契约变化会改链接命令,**不改编译命令**,这是分期能这么切的原因。 + +--- + +## 9. 开放问题 + +- **Q1**(已在评估阶段自我下调,**不再阻塞 P0/P1**)§5.3 载荷寻址 vs 视图寻址:现推荐维持载荷寻址,先把 doctor 自动修复 + exec 失败人话诊断做完,再在 P2 独立评审是否切换。 +- **Q2**(新)`mcpp pack --mode vendored` 是 pack 的**默认**,而它把 PT_INTERP 重指到 `/lib64/ld-linux-*.so.2` —— 默认打包路径本身就是宿主耦合的,与 hermetic 策略不一致。改默认会破坏既有用户,需单独评估:改默认、打告警、还是维持并在文档里说清。 +- **Q3** 多 subos:mcpp 有 home 级 sandbox 和项目级 subos(实测 `/.xlings/subos/_/`)。S3 该读**哪一个**的 `subos_info`?倾向「构建时活动的那个」,但需要确认它在 CI 与本机的一致性。(视图寻址一旦在 P2 启用,同一个问题会变成「烙哪一个」,风险更高——这是又一条把它排到 P2 的理由。) +- **Q4** 交叉编译时 `subos_info.runtime` 描述的是宿主 subos,目标 runtime 从哪来?可能需要 `[target.].runtime` 强制显式(而不是有一个缺省)。 +- **Q5**(回答 xlings 开放问题 K)本文的答案是「**消费 subos 的身份,而不是在 subos 里跑**」——mcpp 读 `subos_info.runtime` 作为契约输入,构建仍在 mcpp 自己的 hermetic 环境里。是否与 xlings 侧的设想一致,需要跨仓确认。 +- **Q6**(需你拍板)§3-S2 的 `self-contained` wrapper:选 (i) 安装期改写 PT_INTERP(消灭问题,但 tarball 不再解开即跑),还是 (ii) 保留 wrapper + `MCPP_BUNDLE_DIR` + 文档(不破坏现有契约,但救不了第三方库)?这条**独立于**本文其余部分,可以先决先做。 + +--- + +## 10. 验证判据(每条可执行,先红后绿) + +| # | 判据 | 怎么测 | +|---|---|---| +| V1 | 三条 hermetic 路径各自在**无 mcpp** 的机器上跑通 | 干净容器里分别验:A `xlings install` 后跑;B musl 产物原样拷进去跑;C `pack --mode self-contained` 解开就跑。**必须是真实干净容器** —— 在 mcpp 沙箱里跑测不出东西,那里载荷路径恰好总是存在 | +| V2 | **每一个** pack 模式产出的程序,`/proc/self/exe` 都指向自身 | 产物内打印 `readlink("/proc/self/exe")` 并断言 == 自身路径,**四个模式全跑**。今天 `self-contained` 必红(§1.6),这条就是它的先红后绿 | +| V3 | (仅当 P2 切视图寻址)产物在载荷升级+GC 后仍可运行 | 装 glibc@2.44、`xlings use`、**删掉 2.39**,不重新构建直接跑旧产物 | +| V4 | GL 程序在 hermetic 图形栈下起窗口 | `xlings install graphics` 后跑 GLFW e2e;**断言渲染器不是 llvmpipe**——「跑起来了」是假绿,#352 的教训是要问「谁答的」 | +| V5 | mcpp 侧 loader/libdir 推导点数量单调下降 | 对 §2.1 六个位置做静态计数,CI 守住上界 | +| V6 | subos 无 `subos_info` 时**出声降级** | 删掉块,断言 stderr 有一行,且构建仍成功 | +| V7 | 产物不引用任何 `/usr/lib*` `/lib*` 下的 `.so` | 对 `mcpp build` 产物做 `ldd` 闭包扫描,断言无宿主路径命中(`allow_host_libs` 显式开启时豁免)| + +> **两条来自本仓历史的验证陷阱,必须避开**: +> - **CI 全绿不等于覆盖**(#346:18 个 job 全绿也没测到大链接)。V1/V3 必须在**真实的干净容器**里跑,不能只在 CI 的 mcpp 沙箱里跑——那里 PT_INTERP 恰好总是存在。 +> - **「有输出」不是判据**(#352 的 exit 255 无输出;`VS_VERSION_INFO` 全文搜索是假判据)。V4 必须断言**渲染器身份**,不是断言窗口出现。 + +--- + +## 11. 多维评估 + +本节的每个数字都在本机量过。**结论先行:P0 的三条(文档/wrapper、图形栈、S3-读)是低风险高收益,可以直接做;S2 中等;S1 是唯一有实质回归风险的一条,排 P1 是对的;P2 目前不该做。** + +### 11.1 实现代价(量化) + +| 项 | 数字 | 依据 | +|---|---|---| +| ~~`c_runtime` 实现面~~ | ~~9 文件~~ | **已撤销**(§5.2)。实测过:9 文件 / `flags.cppm` 约 30 处 / 12+7 单测 / 1 条 e2e 全绿 —— 记在这里是为了说明「代价可控」从来不是做不做的判据 | +| **BMI / 对象缓存失效** | **零** | fingerprint 是 **compile-side 10 字段**(`fingerprint.cppm:3-8`),不含链接侧;`target//` 目录名不变,只重链 | +| e2e 需改 | ~4 个(203 个中) | 断言 PT_INTERP 的:`30_pack_modes` `86_llvm_hermetic_link` `28_target_static` `168_build_mcpp_musl_host_static` | +| e2e 耦合载荷路径 | 58 处 / 26 文件 | 大多是工具链解析,不是 PT_INTERP;S1 落地时才受影响 | +| mcpp-index 破坏面 | **3 个包 / 81** | `compat.glfw` `compat.glx-headers` `compat.vulkan-runtime` | + +**「零缓存失效」是这套方案最大的成本优势**,和 #336 当年便宜的原因相同:契约只改链接命令。 + +**净增 / 净删**:新增 1 个 manifest 键 + 1 张 mechanism 表 + 1 个 RuntimeBinding 类型 + 2 条读文件路径;删除 §2.1 六个回答者里的 4–5 个,外加整个 `compat.glx-runtime`(约 130 行宿主探测 Lua)。满足 R3。 + +### 11.2 用户使用层 + +| 用户 | 影响 | +|---|---| +| **普通用户**(build/run/test) | P0 全部**零感知**;S3 让 GL 程序从「exit 255 无输出」变成能跑,是纯增益 | +| **分发者** | `pack --mode self-contained` 从「静默打坏 exe 相对资源解析」变成可用(§1.6),这是三条 hermetic 路径里唯一一条自己有缺陷的;另外两条(A 生态闭环 / B musl 静态)本来就能用,缺的是文档 | +| **库作者** | `compat.glx-runtime` 废弃是破坏性变更,需过渡期(保留空壳 provider 一个版本) | + +**新增认知负担:零。** 不加键、不加轴、不加词汇。用户模型仍是「target × pack mode」,只是三条分发路径终于被并列写出来了。这是选 B 而非 A 的直接收益之一。 + +### 11.3 稳定性 + +**新增失败模式三个,两个可控、一个未受控:** + +| 失败模式 | 处置 | 评价 | +|---|---|---| +| 读 `subos_info` 失败 / 缺失 | 出声降级(V6) | 可控 | +| xlings schema 演进 | `schema_version` 显式 + 降级出声 | 可控 | +| **视图寻址下产物行为随 subos 变化** | **fingerprint 覆盖不到**(compile-side) | **未受控** —— 这是把它降级到 P2 并加两个前置条件的直接原因(§5.3) | + +**P0/P1 本身不引入新的运行期失败模式**:契约层只是把已有机制(pack 的 patchelf)提前到链接期,产出的 PT_INTERP 形状是 `30_pack_modes.sh` 今天已经在断言的那些。 + +### 11.4 跨平台(最弱的一维,必须直说) + +| 平台 | 分发路径可用性 | S1 runtime 身份 | S3 env / 图形 | +|---|---|---|---| +| Linux / glibc | **3** | `glibc@X`,有意义 | ✅ 全部收益 | +| Linux / musl | 1(恒 self-contained) | `musl@X` | 部分 | +| **macOS** | **1** —— libSystem 恒为宿主(`platform/common.cppm:110` `supports_full_static = is_linux`) | 近乎常量 | **无**(无 mesa/glvnd) | +| **Windows** | 2(静/动 CRT),且与 `linkage` 共用 `-static` 拼写 | 近乎常量 | **无** | + +**收益 ≈ 全在 Linux。** macOS 上这条轴是退化的——只有一个合法值。 + +macOS/Windows 上没有 mesa/glvnd,所以 S3 的图形收益是 Linux-only;而分发路径 A/B/C 里,B(musl 静态)本身就是 Linux 概念。**这是诚实的不对称,不是退化** —— 因为本轮不新增任何全平台的轴,也就不存在「为一个平台的问题给三个平台加概念」。 + +若 review 认为 Linux-only 收益不值一条全局轴,替代是放进 `[target.'cfg(linux)']`。**我不推荐**——那会引入第二套作用域规则,而 `cxx_runtime` 已经确立了「全局轴 + 平台退化」的先例。 + +### 11.5 简洁优雅 + +**正面**:零新词汇(三值抄 `cxx_runtime`,runtime binding 抄 xlings);三层模型已存在,新轴是**填满一个已知空格**而非加一个维度;§4 的三通道按**生命周期**而非内容划分,所以 xlings 加新能力时通道数不变。 + +**负面(必须记账)**: +- 概念总数确实上升,§11.2 已列。 +- **初稿的 `subos-coupled` 第四值破坏了对称性**——这是评估阶段发现并已修正的一处真实设计缺陷(§3-S2 的框)。修正之后词汇仍是三个,且「视图 vs 载荷」回到它本来的层(Mechanism)。**如果没做这次评估,这条会带着一个多余的契约值进入实施。** + +### 11.6 兼容性 + +| 面 | 影响 | 风险 | +|---|---|---| +| 已构建产物 | 不受影响(载荷目录仍在) | 无 | +| `[pack].default_mode` / tarball 后缀 frozen wire format | **不动** | 无 | +| 老 subos 无 `subos_info` | 降级 + 提示 | 低 | +| 老 xlings 无持久化 exports | 走 §5.1 的 A 降级路径;P1 才依赖 | 低 | +| `compat.glx-runtime` 下游 | 3 个包 | **低于预期**(初判以为是索引级风险,实测是叶子簇) | +| **新 mcpp + 老 xlings** | 必须验 | **中** —— 已知「沙箱 xlings 长期落后于 pin」,这一格最容易假绿 | + +### 11.7 总评 + +| 维度 | P0(wrapper 修复 + 图形栈 + S3-读) | S2(分发路径可发现性) | S1(RuntimeBinding) | ~~c_runtime~~ / P2 视图寻址 | +|---|---|---|---|---| +| 实现代价 | 低 | 中 | 中高(动 `abi.cppm`,参与依赖解析) | 低 | +| 用户收益 | **高**(#352 从不可用变可用) | 中高 | 低(内部收敛) | 低 | +| 稳定性风险 | 低 | 低 | **中**(ABI 维度回归) | **高**(未受控) | +| 跨平台价值 | Linux-only | Linux 高 / Win 中 / mac 退化 | 全平台(收敛推导) | Linux-only | +| 兼容性风险 | 低(3 个包) | 低 | 中 | 中 | +| **建议** | **直接做** | **做** | **P1,单独评审** | **暂不做** | diff --git a/.agents/docs/2026-08-08-xlings-runtime-substrate-implementation-plan.md b/.agents/docs/2026-08-08-xlings-runtime-substrate-implementation-plan.md new file mode 100644 index 00000000..954ad6de --- /dev/null +++ b/.agents/docs/2026-08-08-xlings-runtime-substrate-implementation-plan.md @@ -0,0 +1,840 @@ +# xlings 运行时底座 —— 实施计划 + +> **For agentic workers:** 逐 task 执行,每个 task 自带 red→green→commit 循环。 + +**Goal:** 让 mcpp 把 xlings 当作可查询的运行时底座:补上 libc 轴的分发契约(`c_runtime`)、消费 subos 的运行时身份与环境声明、修掉 `self-contained` 打包模式自产的 `/proc/self/exe` 陷阱。 + +**Architecture:** 三条缝。S2 在既有 `mcpp.build.distribution` 的三层模型(Role→Contract→Mechanism)上**复用词汇、新增 libc 机制表**,由 `linkmodel` 单点渲染;S3 新增 `mcpp.xlings.subos_info` 读取 xlings 的 `subos_info` 块,`run`/`test` 应用其 env;S1 让 runtime 身份成为显式轴。P2(视图寻址)不做。 + +**Tech Stack:** C++23 modules,gtest(`tests/unit/`),shell e2e(`tests/e2e/`),nlohmann::json(`mcpp.libs.json`)。 + +设计文档:`.agents/docs/2026-08-07-xlings-as-runtime-substrate-design.md` + +## Global Constraints + +- **词汇不新造**:契约三值 `self-contained` | `toolchain-coupled` | `host-coupled`,与 `[build] cxx_runtime` 逐字相同。runtime binding 拼写 `@`,与 xlings `subos_info.runtime` 逐字相同。 +- **默认值不变**:`c_runtime` 缺省 = `toolchain-coupled` = 今天的行为。引入这条轴本身**不改变任何现有产物**。 +- **Mechanism 必须是 total function**:每格要么给 flags,要么 `degraded=true` + 非空 `diagnostic`。静默无操作是禁止的结果。 +- **不引入新的宿主探测**:mcpp 里不得出现探测 GPU / 图形能力的代码。 +- **schema 缺失/未知版本必须出声**:一行 stderr,构建继续。 +- **P2 不做**:`toolchain-coupled` 保持载荷寻址,不切视图寻址。 +- **平台特殊处理下沉**:平台差异放 `src/platform/` 对应模块,不在 `build/` 里写 `#ifdef`。 + +--- + +### Task 1: `self-contained` 打包模式的 `/proc/self/exe` 陷阱 + +**Files:** +- Modify: `src/pack/pack.cppm:431-446`(`write_bundle_all_wrappers`) +- Modify: `docs/02-pack-and-release.md`(§ Mode `self-contained` 之后) +- Test: `tests/e2e/30_pack_modes.sh` + +**Interfaces:** +- Consumes: 无 +- Produces: 分发包契约新增环境变量 `MCPP_BUNDLE_DIR`(bundle 根目录绝对路径),由 `run.sh` / 顶层同名脚本导出。 + +**背景**:ELF 规范禁止 `PT_INTERP` 用 `$ORIGIN`,所以 `self-contained` 只能经 `ld.so --library-path` 启动;代价是内核把 `/proc/self/exe` 指向 loader、`/proc/self/cmdline` 混入 `--library-path`。本 task 不消除这个约束(那需要安装期改写 PT_INTERP,见设计文档 Q6 选项 i),而是**把它变成一个有声明、可编程的契约**,并写进文档。 + +- [ ] **Step 1: 写失败的 e2e 断言** + +在 `tests/e2e/30_pack_modes.sh` 的 Mode B(bundle-all)段落后追加: + +```sh +# self-contained: the wrapper must export MCPP_BUNDLE_DIR so an application +# can resolve resources despite /proc/self/exe pointing at the loader. +grep -q 'MCPP_BUNDLE_DIR' "$TMP/b/myapp-0.1.0-x86_64-linux-gnu-bundle-all/run.sh" || { + echo "Mode B: run.sh does not export MCPP_BUNDLE_DIR"; exit 1; } +out=$("$TMP/b/myapp-0.1.0-x86_64-linux-gnu-bundle-all/run.sh" 2>&1) || true +echo "$out" | grep -q 'Hello' || { echo "Mode B: wrapper broke execution: $out"; exit 1; } +``` + +- [ ] **Step 2: 跑,确认失败** + +```bash +bash tests/e2e/30_pack_modes.sh +``` +Expected: FAIL,`run.sh does not export MCPP_BUNDLE_DIR` + +- [ ] **Step 3: 改 wrapper** + +`src/pack/pack.cppm` 的 `write_bundle_all_wrappers` body 改为: + +```cpp + auto body = std::format( + "#!/bin/sh\n" + "# Auto-generated by `mcpp pack --mode self-contained`. Launches the\n" + "# bundled binary through the bundled dynamic linker so the package\n" + "# is portable across glibc versions.\n" + "#\n" + "# TRAP, and why this variable exists: launching through the loader\n" + "# makes the kernel set /proc/self/exe to the LOADER, not to the\n" + "# program, and /proc/self/cmdline carries --library-path. Any\n" + "# \"find my resources next to the executable\" logic silently\n" + "# resolves against the loader's directory instead. MCPP_BUNDLE_DIR\n" + "# is the answer that survives: resolve against it first and fall\n" + "# back to /proc/self/exe only when it is unset.\n" + "here=$(cd \"$(dirname \"$0\")\" && pwd)\n" + "MCPP_BUNDLE_DIR=\"$here\"; export MCPP_BUNDLE_DIR\n" + "exec \"$here/lib/{}\" --library-path \"$here/lib\" \"$here/bin/{}\" \"$@\"\n", + loaderName, binaryName); +``` + +- [ ] **Step 4: 跑,确认通过** + +```bash +bash tests/e2e/30_pack_modes.sh +``` +Expected: PASS + +- [ ] **Step 5: 文档** + +在 `docs/02-pack-and-release.md` 的 self-contained loader 说明之后追加一节: + +```markdown +#### Trap: `/proc/self/exe` under the bundled loader + +Launching through the loader means the kernel sets `/proc/self/exe` to the +**loader**, not to your program, and `/proc/self/cmdline` carries the +`--library-path` argument. Every "find my resources next to the executable" +path silently resolves against the wrong directory — GUI toolkits looking for +fonts or `assets/`, and helper binaries shipped alongside the program. + +The wrapper exports `MCPP_BUNDLE_DIR` (the bundle root) for this. Resolve +against it first: + +```c +const char *base = getenv("MCPP_BUNDLE_DIR"); /* set by run.sh */ +/* fall back to /proc/self/exe only when unset */ +``` + +If your application cannot be changed, use `--mode vendored` instead: it +repoints `PT_INTERP` at the host loader, so `/proc/self/exe` is correct — at +the cost of requiring the host's glibc to be at least as new as the one the +artifact was built against. +``` + +同一节的中文版加到 `docs/zh/` 下对应文件(若存在)。 + +- [ ] **Step 6: Commit** + +```bash +git add src/pack/pack.cppm docs/02-pack-and-release.md tests/e2e/30_pack_modes.sh +git commit -m "fix(pack): the self-contained wrapper broke /proc/self/exe and never said so" +``` + +--- + +### ~~Task 2–4: `c_runtime` 契约轴~~ —— 已撤销 + +**做过,全绿,然后整条 reset。** 设计文档 §3-S2 与 §5.2 记录了理由;摘要: + +- 它是照着 #375 提报者**提出的解法**做的,不是解他的**问题**(问题是「产物没法分发」) +- 它唯一的新能力是 `host-coupled` = 把产物链到**宿主 libc**,而 hermetic 策略禁止穿越的第一条就是 `/lib*` 下的 libc。**mcpp 能不用 host 就不用 host** +- 去掉那个值之后这条轴不剩任何能力(`self-contained` 已由 `--target ...-musl` 表达) +- 它会造出「可不可以用宿主 libc」的**第二个回答者**(既有的是 `[build] allow_host_libs`) + +**代替它的是 Task 4'(下)**:#375 的真问题有三条 hermetic 答案,全都已存在;缺口在「其中一条自己是坏的」(Task 1 已修)加「三条都不可发现」。 + +--- + +### Task 4': 让三条 hermetic 分发路径可发现 + +**Files:** +- Modify: `docs/02-pack-and-release.md` + `docs/zh/02-pack-and-release.md` +- Modify: `docs/00-getting-started.md` 或 `10-publishing-a-library.md`(择一放「怎么分发」的入口) +- Test: 文档改动,无自动化断言;判据是 §10 的 V1(三条路径各自在干净容器里跑通) + +**要写清的三条,并列、等价、都不用宿主 libc:** + +| 路径 | 命令 | libc 从哪来 | 什么时候选它 | +|---|---|---|---| +| A. 生态闭环 | `mcpp emit xpkg` → `xlings install` | 目标机自己的 xlings 载荷,**elfpatch 装机期重指 PT_INTERP/RUNPATH** | 目标机在生态内 | +| B. 静态单文件 | `--target x86_64-linux-musl` | 自带,静态 | 任何 Linux,零运行期依赖 | +| C. 自带运行时 | `mcpp pack --mode self-contained` | 自带这套工具链的 glibc + loader | 任何 Linux,含比构建机更老的 | + +**A 要特别写明一件容易误读的事**:产物里烙的 `PT_INTERP` 指向构建机路径**不构成分发障碍** —— 走 A 时目标机的 xlings 会重写它。#375 观察到的「路径不存在所以起不来」,前提是绕开生态直接拷贝二进制。 + +**不写**:任何「让产物链到系统 libc」的做法。已有的那个决定只有一个入口(`[build] allow_host_libs`),文档不给它第二个说法。 + +- [ ] **Step 1** 在 `docs/02-pack-and-release.md` 顶部「Two axes」之前加一节 "Three ways to ship, none of which use the host's libc",内容如上表 +- [ ] **Step 2** 中文版同步 +- [ ] **Step 3** Commit + +--- + +### Task 5: 读 xlings 的 subos 自描述 + +**Files:** +- Create: `src/xlings/subos_info.cppm`(模块 `mcpp.xlings.subos_info`) +- Test: `tests/unit/test_subos_info.cpp` + +**Interfaces:** +- Consumes: `mcpp.libs.json` +- Produces: + ```cpp + namespace mcpp::xlings::subos { + inline constexpr int kSupportedSchema = 1; + struct EnvDecl { std::string var, op, value; }; + struct Provider { std::string binding; std::vector decls; }; + struct Info { + int schema = 0; + std::string runtime; // "glibc@2.39" + std::vector providers; // sorted by binding + bool present = false; // the block existed at all + std::string note; // non-empty ⇒ caller MUST print it + }; + Info read(const std::filesystem::path& subosDir); + std::string family_of(std::string_view runtime, std::string_view arch = "x86_64"); + std::vector> + resolve_env(const Info&, const std::filesystem::path& subosDir); + } + ``` + +- [ ] **Step 1: 写失败的单测** + +创建 `tests/unit/test_subos_info.cpp`: + +```cpp +#include + +import std; +import mcpp.xlings.subos_info; + +namespace { + +namespace su = mcpp::xlings::subos; + +struct Tmp { + std::filesystem::path dir; + Tmp() { + dir = std::filesystem::temp_directory_path() + / ("mcpp_subos_" + std::to_string(std::random_device{}())); + std::filesystem::create_directories(dir); + } + ~Tmp() { std::error_code ec; std::filesystem::remove_all(dir, ec); } + void write(std::string_view body) { + std::ofstream(dir / ".xlings.json") << body; + } +}; + +TEST(SubosInfo, ReadsRuntimeAndEnvs) { + Tmp t; + t.write(R"({ + "workspace": {}, + "subos_info": { + "schema_version": 1, + "runtime": "glibc@2.39", + "envs": [ + { "binding": "mesa@25.0.7.1", "decls": [ + { "var": "LIBGL_DRIVERS_PATH", "op": "prepend", + "value": "${subosdir}/usr/lib/dri" } + ]} + ] + } + })"); + auto info = su::read(t.dir); + EXPECT_TRUE(info.present); + EXPECT_EQ(info.schema, 1); + EXPECT_EQ(info.runtime, "glibc@2.39"); + ASSERT_EQ(info.providers.size(), 1u); + ASSERT_EQ(info.providers[0].decls.size(), 1u); + EXPECT_EQ(info.providers[0].decls[0].var, "LIBGL_DRIVERS_PATH"); + EXPECT_TRUE(info.note.empty()); +} + +// ${subosdir} is expanded against the subos this block was read from. +TEST(SubosInfo, ResolvesSubosdirPlaceholder) { + Tmp t; + t.write(R"({"subos_info":{"schema_version":1,"runtime":"glibc@2.39", + "envs":[{"binding":"mesa@1","decls":[ + {"var":"LIBGL_DRIVERS_PATH","op":"prepend","value":"${subosdir}/usr/lib/dri"}]}]}})"); + auto env = su::resolve_env(su::read(t.dir), t.dir); + ASSERT_EQ(env.size(), 1u); + EXPECT_EQ(env[0].first, "LIBGL_DRIVERS_PATH"); + EXPECT_EQ(env[0].second, (t.dir / "usr/lib/dri").string()); +} + +// A subos made before xlings grew the block: degrade, and SAY SO. Silence +// here is what made mcpp#352 hard to find in the first place. +TEST(SubosInfo, MissingBlockDegradesWithANote) { + Tmp t; + t.write(R"({"workspace":{}})"); + auto info = su::read(t.dir); + EXPECT_FALSE(info.present); + EXPECT_TRUE(info.runtime.empty()); + EXPECT_FALSE(info.note.empty()); +} + +// A schema newer than we understand: use what we can, and say we are behind. +TEST(SubosInfo, NewerSchemaDegradesWithANote) { + Tmp t; + t.write(R"({"subos_info":{"schema_version":99,"runtime":"glibc@2.44","envs":[]}})"); + auto info = su::read(t.dir); + EXPECT_TRUE(info.present); + EXPECT_EQ(info.runtime, "glibc@2.44"); + EXPECT_FALSE(info.note.empty()); +} + +TEST(SubosInfo, NoFileAtAllIsNotACrash) { + Tmp t; // nothing written + auto info = su::read(t.dir); + EXPECT_FALSE(info.present); + EXPECT_FALSE(info.note.empty()); +} + +// The family mapping must agree with xlings's own, verbatim. +TEST(SubosInfo, FamilyOfMirrorsXlings) { + EXPECT_EQ(su::family_of("glibc@2.39"), "linux-x86_64-glibc"); + EXPECT_EQ(su::family_of("musl@1.2.5"), "linux-x86_64-musl"); + EXPECT_EQ(su::family_of("glibc@2.39", "aarch64"), "linux-aarch64-glibc"); + EXPECT_EQ(su::family_of("wasi-libc@1"), "wasm32-wasi"); + EXPECT_EQ(su::family_of("nonsense@1"), "unknown"); +} + +// Malformed JSON must not take the build down. +TEST(SubosInfo, MalformedJsonDegrades) { + Tmp t; + t.write("{ this is not json"); + auto info = su::read(t.dir); + EXPECT_FALSE(info.present); + EXPECT_FALSE(info.note.empty()); +} + +} // namespace +``` + +- [ ] **Step 2: 跑,确认失败** + +```bash +mcpp test --filter SubosInfo +``` +Expected: FAIL,模块不存在 + +- [ ] **Step 3: 实现** + +创建 `src/xlings/subos_info.cppm`: + +```cpp +// mcpp.xlings.subos_info — read the `subos_info` block xlings writes into a +// subos's own `.xlings.json`. +// +// WHAT THIS IS FOR +// +// A program needs three things: bootstrap (PT_INTERP + CRT + libc), discovery +// (PATH + RPATH), and configuration (env vars). xlings had the first two — +// glibc + elfpatch, xvm + shims — and until it grew this block, nothing for +// the third. Its own module comment names the consequence: mcpp#352, a GLFW +// binary that links fine and exits 255 because nothing told it where the GL +// drivers are. +// +// mcpp is the consumer of the third one. This module ONLY reads and resolves; +// it never writes the block and never manages subos lifecycle — that is +// xlings's layer, and mcpp reaching into it is the layering inversion the +// ecosystem design calls out by name. +// +// A note on silence: every degradation here fills `note`, and callers are +// required to print it. "It did not happen" and "it succeeded" producing the +// same output is the property that made #352 expensive to find. +// +// Design: .agents/docs/2026-08-07-xlings-as-runtime-substrate-design.md §3-S3 + +export module mcpp.xlings.subos_info; + +import std; +import mcpp.libs.json; + +export namespace mcpp::xlings::subos { + +// The schema this build understands. A higher one on disk is usable — we read +// the fields we know — but the caller is told it is reading a newer format. +inline constexpr int kSupportedSchema = 1; + +inline constexpr std::string_view kBlock = "subos_info"; + +struct EnvDecl { + std::string var; + std::string op; // "set" | "prepend" + std::string value; // may contain ${subosdir} +}; + +struct Provider { + std::string binding; // "@" + std::vector decls; +}; + +struct Info { + int schema = 0; + std::string runtime; // "glibc@2.39" + std::vector providers; // sorted by binding + bool present = false; + // Non-empty ⇒ the caller MUST surface it. Never a hard error: a missing + // or newer block degrades the experience, it does not invalidate a build. + std::string note; +}; + +// The runtime string is self-describing: "glibc@2.39" says Linux/glibc +// without a second field that could disagree with it. Mirrors xlings's +// `subos::manifest::family_of` — the mapping is small, stable and part of the +// cross-repo contract, so it is asserted in tests rather than left implicit. +std::string family_of(std::string_view runtime, + std::string_view arch = "x86_64") { + const auto at = runtime.find('@'); + const auto name = runtime.substr(0, at == std::string_view::npos + ? runtime.size() : at); + if (name == "glibc") return std::format("linux-{}-glibc", arch); + if (name == "musl") return std::format("linux-{}-musl", arch); + if (name == "wasi-libc") return "wasm32-wasi"; + if (name == "macos_sdk") return std::format("darwin-{}", arch); + if (name == "ucrt") return std::format("windows-{}-ucrt", arch); + return "unknown"; +} + +Info read(const std::filesystem::path& subosDir) { + Info info; + auto path = subosDir / ".xlings.json"; + std::error_code ec; + if (!std::filesystem::exists(path, ec)) { + info.note = std::format( + "subos '{}' has no .xlings.json, so it cannot say which runtime it " + "is or what environment its programs need", subosDir.string()); + return info; + } + std::ifstream is(path); + auto doc = nlohmann::json::parse(is, nullptr, /*allow_exceptions=*/false); + if (doc.is_discarded() || !doc.is_object()) { + info.note = std::format("subos manifest {} is not readable JSON", + path.string()); + return info; + } + auto it = doc.find(std::string(kBlock)); + if (it == doc.end() || !it->is_object()) { + info.note = std::format( + "subos '{}' does not describe itself (no `{}` block): its runtime " + "identity and environment declarations are unavailable. A newer " + "xlings writes this block; `xlings self update` adds it", + subosDir.string(), kBlock); + return info; + } + info.present = true; + if (auto v = it->find("schema_version"); + v != it->end() && v->is_number_integer()) + info.schema = v->get(); + if (auto v = it->find("runtime"); v != it->end() && v->is_string()) + info.runtime = v->get(); + + if (auto envs = it->find("envs"); envs != it->end() && envs->is_array()) { + for (auto& p : *envs) { + if (!p.is_object()) continue; + Provider prov; + if (auto b = p.find("binding"); b != p.end() && b->is_string()) + prov.binding = b->get(); + if (auto ds = p.find("decls"); ds != p.end() && ds->is_array()) { + for (auto& d : *ds) { + if (!d.is_object()) continue; + EnvDecl e; + if (auto x = d.find("var"); x != d.end() && x->is_string()) + e.var = x->get(); + if (auto x = d.find("op"); x != d.end() && x->is_string()) + e.op = x->get(); + if (auto x = d.find("value"); x != d.end() && x->is_string()) + e.value = x->get(); + if (!e.var.empty()) prov.decls.push_back(std::move(e)); + } + } + info.providers.push_back(std::move(prov)); + } + } + // Sorted by binding, matching xlings's own ordering, so two reads of the + // same subos produce the same environment in the same order. + std::ranges::sort(info.providers, + [](auto const& a, auto const& b) { return a.binding < b.binding; }); + + if (info.schema > kSupportedSchema) + info.note = std::format( + "subos '{}' declares schema {}, newer than the {} this mcpp " + "understands; reading the fields it knows and ignoring the rest", + subosDir.string(), info.schema, kSupportedSchema); + return info; +} + +// Resolve the declarations into concrete (var, value) pairs, `${subosdir}` +// expanded. `prepend` entries for the same variable are joined with ':' in +// provider order; a `set` replaces whatever came before it, which is xlings's +// own precedence. +std::vector> +resolve_env(const Info& info, const std::filesystem::path& subosDir) { + std::vector> out; + auto expand = [&](std::string v) { + constexpr std::string_view kPh = "${subosdir}"; + for (auto pos = v.find(kPh); pos != std::string::npos; + pos = v.find(kPh, pos)) + v.replace(pos, kPh.size(), subosDir.string()); + return v; + }; + for (auto const& p : info.providers) { + for (auto const& d : p.decls) { + auto value = expand(d.value); + auto hit = std::ranges::find(out, d.var, &std::pair::first); + if (hit == out.end()) { out.emplace_back(d.var, value); continue; } + if (d.op == "set") { hit->second = value; continue; } + // prepend, de-duplicated: a doubled entry must not accumulate + // across nested invocations. + if (hit->second != value + && !hit->second.starts_with(value + ":") + && hit->second.find(":" + value) == std::string::npos) + hit->second = value + ":" + hit->second; + } + } + return out; +} + +} // namespace mcpp::xlings::subos +``` + +- [ ] **Step 4: 跑,确认通过** + +```bash +mcpp test --filter SubosInfo +``` +Expected: PASS(7 组全绿) + +- [ ] **Step 5: Commit** + +```bash +git add src/xlings/subos_info.cppm tests/unit/test_subos_info.cpp +git commit -m "feat(xlings): read the subos's own description instead of inferring it" +``` + +--- + +### Task 6: `mcpp run` / `mcpp test` 应用 subos 环境 + +**Files:** +- Modify: `src/build/plan.cppm`(把解析好的 env 放进 plan) +- Modify: `src/build/execute.cppm:279-300, 1210-1230`(应用到子进程) +- Test: `tests/e2e/196_subos_env_reaches_program.sh`(新建) + +**Interfaces:** +- Consumes: `mcpp::xlings::subos::{read, resolve_env}` +- Produces: `BuildPlan` 新增 `std::vector> subosEnv;` + +**为什么不是自己拼图形变量**:`LIBGL_DRIVERS_PATH` / `__EGL_VENDOR_LIBRARY_DIRS` / `XDG_DATA_DIRS` 的值由 xlings 的图形包声明,mcpp 只是把它们传下去。mcpp 里出现任何图形相关的字面量都是这条设计的违反。 + +- [ ] **Step 1: 写失败的 e2e** + +创建 `tests/e2e/196_subos_env_reaches_program.sh`: + +```sh +#!/usr/bin/env bash +# requires: linux +# A variable a package declared into the subos must reach the program `mcpp +# run` launches. Until this landed, subos env declarations were applied only +# by `xlings subos use`, so a program started by mcpp saw none of them — the +# reason a GL binary could link fine and exit 255 (mcpp#352). +set -euo pipefail +. "$(dirname "$0")/_common.sh" + +proj=$(mktemp -d) +trap 'rm -rf "$proj"' EXIT + +# A subos that declares one variable, in xlings's own schema. +subos="$proj/subos" +mkdir -p "$subos/usr/lib/dri" +cat > "$subos/.xlings.json" <<'EOF' +{ "workspace": {}, + "subos_info": { "schema_version": 1, "runtime": "glibc@2.39", + "envs": [ { "binding": "probe@1", "decls": [ + { "var": "MCPP_E2E_PROBE", "op": "prepend", + "value": "${subosdir}/usr/lib/dri" } ] } ] } } +EOF + +cd "$proj" +"$MCPP" new hello >/dev/null +cd hello +cat > src/main.cpp <<'EOF' +#include +#include +int main() { + const char* v = std::getenv("MCPP_E2E_PROBE"); + std::printf("PROBE=%s\n", v ? v : "(unset)"); +} +EOF + +out=$(MCPP_SUBOS_DIR="$subos" "$MCPP" run 2>&1) +echo "$out" | grep -q "PROBE=$subos/usr/lib/dri" || { + echo "subos env did not reach the program:"; echo "$out"; exit 1; } +echo "PASS: subos env reaches the program" +``` + +`chmod +x tests/e2e/196_subos_env_reaches_program.sh` + +> `MCPP_SUBOS_DIR` 是本 task 引入的**测试与覆盖用**入口:默认解析仍是活动 subos。它让这条 e2e 不需要改动用户真实环境——记住 memory 里那条教训:破坏性脚本不在真机上试。 + +- [ ] **Step 2: 跑,确认失败** + +```bash +bash tests/e2e/196_subos_env_reaches_program.sh +``` +Expected: FAIL,`PROBE=(unset)` + +- [ ] **Step 3: 实现** + +`src/build/plan.cppm`,在 `runtimeLibraryDirs` 那段之后: + +```cpp + // The subos's own environment declarations. mcpp reads them and passes + // them on; it does not author them and does not know what they mean. That + // is the whole point: when xlings adds a Vulkan loader or a new driver + // bridge, this code does not change. + { + auto subosDir = mcpp::xlings::subos_dir_for_build(cfg); + auto info = mcpp::xlings::subos::read(subosDir); + if (!info.note.empty()) mcpp::ui::warning(info.note); + plan.subosEnv = mcpp::xlings::subos::resolve_env(info, subosDir); + } +``` + +`src/xlings.cppm` 加一个解析器(单点,不让每个消费者各猜一次): + +```cpp + // The subos whose environment a program built here should run under. + // + // MCPP_SUBOS_DIR overrides it outright — that exists so tests can exercise + // this path without touching the developer's real environment, and so a + // user can point a run at another subos without switching the active one. + std::filesystem::path subos_dir_for_build(const config::GlobalConfig& cfg); +``` + +`src/build/execute.cppm`:在构造子进程环境的两处(`run` 的 279-300 与 ninja 侧 1210-1230)把 `plan.subosEnv` 合并进去,`prepend` 语义与既有 `prepend_path_list` 一致。 + +- [ ] **Step 4: 跑,确认通过** + +```bash +bash tests/e2e/196_subos_env_reaches_program.sh +``` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/build src/xlings.cppm tests/e2e/196_subos_env_reaches_program.sh +git commit -m "feat(run): a program mcpp launches gets its subos's environment" +``` + +--- + +### Task 7: 运行时身份成为显式轴 + +**Files:** +- Modify: `src/toolchain/abi.cppm`(libc 维度带版本) +- Modify: `src/toolchain/model.cppm`(`Toolchain` 加 `runtimeBinding`) +- Modify: `src/build/prepare.cppm`(解析优先级) +- Test: `tests/unit/test_abi.cpp` + +**Interfaces:** +- Consumes: `subos::read(...).runtime`、`subos::family_of` +- Produces: `AbiProfile` 新增 `std::string libcVersion;`(空 = 未知,**不参与匹配**),`Toolchain::runtimeBinding` + +**收窄的实现范围与理由**:`abi.cppm` 的 `libc` 维度参与依赖解析,给它加一个**参与匹配**的版本轴会立刻改变全索引的解析结果(memory 里 `index-floor-must-degrade` 是同一族事故)。所以本 task 只做两件事:①把版本**记录**下来并在 `mcpp doctor` / `--verbose` 里可见;②`runtimeBinding` 的解析优先级落地。**匹配语义不变**,留给 platform manifest 那一轮。 + +- [ ] **Step 1: 写失败的单测** + +在 `tests/unit/test_abi.cpp` 追加: + +```cpp +// The libc dimension gains a version, and it is RECORDED, not matched. A +// dependency that says `abi:glibc` must keep matching a glibc@2.39 toolchain +// exactly as it did before — changing that would re-resolve the whole index. +TEST(Abi, LibcVersionIsRecordedButNotMatched) { + mcpp::toolchain::Toolchain tc; + tc.targetTriple = "x86_64-linux-gnu"; + tc.runtimeBinding = "glibc@2.39"; + auto p = mcpp::toolchain::abi_profile(tc); + EXPECT_EQ(p.libc, "glibc"); + EXPECT_EQ(p.libcVersion, "2.39"); + + auto c = mcpp::toolchain::parse_abi_capability("abi:glibc"); + ASSERT_TRUE(c.has_value()); + EXPECT_TRUE(mcpp::toolchain::satisfies(p, *c)); +} + +// No binding resolved: the dimension still answers, without a version. +TEST(Abi, LibcVersionEmptyWithoutABinding) { + mcpp::toolchain::Toolchain tc; + tc.targetTriple = "x86_64-linux-gnu"; + auto p = mcpp::toolchain::abi_profile(tc); + EXPECT_EQ(p.libc, "glibc"); + EXPECT_TRUE(p.libcVersion.empty()); +} +``` + +- [ ] **Step 2: 跑,确认失败** + +```bash +mcpp test --filter Abi +``` +Expected: FAIL,`runtimeBinding` / `libcVersion` 不存在 + +- [ ] **Step 3: 实现** + +`model.cppm` 的 `Toolchain` 加: + +```cpp + // The runtime this toolchain's output is built against, in xlings's own + // spelling ("glibc@2.39"). Resolved in prepare, in this order, every step + // explicit — a "default is the convention" step is what grows a second + // answerer (see the design doc §S1): + // 1. --runtime + // 2. [target.].runtime / [build].runtime + // 3. the active subos's `subos_info.runtime` + // 4. the payload actually resolved, with a note + std::string runtimeBinding; +``` + +`abi.cppm` 的 `AbiProfile` 加 `std::string libcVersion;`,并在 `abi_profile()` 的两条路径里从 `tc.runtimeBinding` 的 `@` 之后取值。**`satisfies()` 不动。** + +`prepare.cppm` 按上述四级解析并写进 `tc.runtimeBinding`;第 4 级发一条 `ui::info`。 + +- [ ] **Step 4: 跑,确认通过** + +```bash +mcpp test --filter Abi +``` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/toolchain src/build/prepare.cppm tests/unit/test_abi.cpp +git commit -m "feat(toolchain): the runtime a build targets is read, not inferred" +``` + +--- + +### Task 8: 文档 + +**Files:** +- Modify: `docs/05-mcpp-toml.md`(`c_runtime`) +- Modify: `docs/02-pack-and-release.md`(contract × mode 的关系) +- Modify: `docs/03-toolchains.md`(runtime binding) +- Modify: `docs/zh/` 下的对应文件 +- Modify: `.agents/docs/2026-08-07-xlings-as-runtime-substrate-design.md`(标注已实施) + +- [ ] **Step 1: `05-mcpp-toml.md` 加 `c_runtime`** + +紧邻既有 `cxx_runtime` 一节,写明三值、默认值、以及与 `mcpp pack --mode` 的函数关系表(设计文档 §3-S2 那张)。**必须写清 `host-coupled` 的语义是「宿主 glibc ≥ 构建时的那份」**,不是「任何 Linux」。 + +- [ ] **Step 2: `02-pack-and-release.md` 加一节 "Contract vs mode"** + +说明 mode 管「带多少东西」、contract 管「承诺什么」,并给出不可分发契约在 pack 时会被拒绝的行为。 + +- [ ] **Step 3: `03-toolchains.md` 加 runtime binding** + +说明四级解析顺序与 `--runtime`。 + +- [ ] **Step 4: 校验中文版同步** + +```bash +ls docs/zh/ +``` +对每个改过的英文文档,同步中文版。 + +- [ ] **Step 5: Commit** + +```bash +git add docs .agents/docs +git commit -m "docs: the two runtime contracts, and what each promises" +``` + +--- + +### Task 9: 版本 + xlings pin + PR + +**Files:** +- Modify: `src/version.cppm` +- Modify: `src/xlings.cppm`(`kXlingsVersion`) + +- [ ] **Step 1: 查最新 xlings 版本** + +```bash +gh api repos/openxlings/xlings/releases/latest --jq .tag_name +``` + +- [ ] **Step 2: bump 两个常量** + +`MCPP_VERSION` → `2026.8.8.1`;`kXlingsVersion` → 上一步的值。 + +> **只动这两个。** bootstrap pin(`.xlings.json` 那组)是自举起点,不随发布走 —— 一起 bump 会让全部 CI 去装一个还不存在的版本。 + +- [ ] **Step 3: 机器校验 pin 一致** + +```bash +.github/tools/check_version_pins.sh +``` +Expected: 通过 + +- [ ] **Step 4: 全量本地验证** + +```bash +mcpp build && mcpp test +for t in 30_pack_modes 195_c_runtime_host_coupled 196_subos_env_reaches_program; do + bash "tests/e2e/$t.sh" || echo "FAIL: $t" +done +``` + +- [ ] **Step 5: 开 PR** + +```bash +git checkout -b feat/xlings-runtime-substrate +git push -u origin feat/xlings-runtime-substrate +gh pr create --title "feat: xlings 作为运行时底座 —— libc 分发契约 + subos 环境 (#375, #352)" --body-file .agents/docs/pr-body.md +``` + +--- + +### Task 10: mcpp-index 图形栈迁移(独立仓、独立 PR) + +**Files(`/home/speak/workspace/github/mcpplibs/mcpp-index`):** +- Modify: `pkgs/c/compat.glfw.lua`, `pkgs/c/compat.glx-headers.lua`, `pkgs/c/compat.vulkan-runtime.lua` +- Deprecate: `pkgs/c/compat.glx-runtime.lua` + +- [ ] **Step 1: 确认下游只有三个** + +```bash +cd /home/speak/workspace/github/mcpplibs/mcpp-index && grep -rln "glx-runtime" pkgs/ +``` +Expected: 恰好 4 个文件(含它自己) + +- [ ] **Step 2: 三个消费者改为依赖 `xim:graphics`** + +`compat.glx-runtime` 保留为**空壳 provider**(仍声明 `provides = {"opengl.glx.driver","x11.display"}`,但不再 symlink 宿主库),一个版本之后再删——下游包的 manifest 已发布,不能立刻失效(与 `index-floor-must-degrade` 同一条教训)。 + +- [ ] **Step 3: 验** + +```bash +bash tests/smoke.sh 2>/dev/null || ls tests/ +``` +按该仓既有验证入口跑。**判据是渲染器身份,不是「窗口出现了」。** + +- [ ] **Step 4: PR** + +--- + +## Self-Review + +**Spec coverage** — 设计文档各节 → task 映射: + +| 设计文档 | Task | +|---|---| +| §1.6 / Q6 `self-contained` wrapper | T1 | +| §3-S2 `c_runtime` 契约 | T2 + T3 + T4 | +| §3-S3 subos 环境 | T5 + T6 | +| §3-S1 运行时身份 | T7 | +| §1.5 图形栈迁移 | T10 | +| §6 P0 文档 | T1 Step5 + T8 | +| §7 跨仓契约(xlings 落盘 exports) | **不在本 PR** —— 需 xlings 侧改动,设计文档已排 P1 尾;本 PR 的 §5.1「A 降级路径」足以工作 | +| §5.3 / P2 视图寻址 | **不做**(设计文档明确) | + +**未覆盖且是有意的**:§2.1 六个回答者里,本 PR 收敛的是 #2/#3/#5(loader 选择统一到 `link_tokens` + `distro_loader_path`)。#1/#4/#6 依赖 xlings 侧落盘 exports,留 P1。V5 的静态计数守的是「不再增加」,不是「已经归零」。 + +**Type consistency** — 跨 task 一致性已核:`dist::Contract` / `dist::Role` / `dist::Format` 三者在 T2/T4 同名;`libcdist::Addressing` 与 `tc::LibcAddressing` 是**有意的镜像对**(层次不倒置),T3 的注释与 T2 的测试都点明了这一点,并由 T4 的 `static_cast` 单点转换。`subos::Info` / `resolve_env` 在 T5 定义、T6 消费,签名一致。 diff --git a/docs/02-pack-and-release.md b/docs/02-pack-and-release.md index e57b870d..a493820b 100644 --- a/docs/02-pack-and-release.md +++ b/docs/02-pack-and-release.md @@ -1,9 +1,42 @@ # 02 — Packaging for Release -> A default dynamically linked binary produced by `mcpp build` normally has a -> loader and RUNPATH tied to the build sandbox. To distribute it to other -> machines or deploy it to a server, use `mcpp pack` to produce a release -> tarball or directory with the appropriate runtime closure. +> A default dynamically linked binary produced by `mcpp build` has a loader and +> RUNPATH tied to the build sandbox. It is a development artifact, not a +> deliverable. Three routes turn it into one — and none of them uses the host's +> C library. + +## Three ways to ship + +Every route below produces an artifact whose C runtime comes from the +ecosystem, never from `/lib64`. That is deliberate: mcpp builds against a +private glibc precisely so a binary's behaviour does not depend on which +distribution happens to be underneath it, and reaching back out to the host's +libc to distribute would give that away at the last step. + +| | Route | Command | Where its C runtime comes from | Choose it when | +|---|---|---|---|---| +| **A** | Through the ecosystem | `mcpp emit xpkg` → `xlings install ` | the target machine's own xlings payloads | the target has xlings | +| **B** | One static file | `mcpp build --target x86_64-linux-musl` | nowhere — it is linked in | you want a single file with no runtime at all | +| **C** | Carry the runtime | `mcpp pack --mode self-contained` | shipped inside the bundle | any Linux, including older than the build machine | + +**On route A, and the thing that surprises people.** The `PT_INTERP` baked into +a freshly built binary points at *your* machine's payload, so copying that file +to another machine by hand does not work — the path is not there. That is not a +property of the artifact so much as of the copy: installed through `xlings`, the +package's ELF files are repointed at the target machine's own payloads at +install time. The baked path is a build-machine detail, not a distribution +format. If you are hand-copying binaries between machines, you want B or C. + +**On route B.** `--target …-musl` implies a static link, so there is no loader, +no RUNPATH and nothing to find at run time. It is the smallest and most +portable result, and the one to reach for first when the program does not need +glibc-specific behaviour (NSS lookups, `dlopen` of host plugins). + +**On route C.** The bundle carries this toolchain's glibc and its loader, so it +runs on distributions older than the build machine — the case B cannot cover +when glibc is actually required. Read the `/proc/self/exe` note below before +choosing it: launching through a bundled loader changes what the program sees +about itself. ## Two axes: target (libc) × mode (bundling depth) @@ -128,6 +161,37 @@ exec "$here/lib/ld-linux-x86-64.so.2" --library-path "$here/lib" "$here/bin/myap The layout and wrapper above use an x86_64 example. The packer derives the loader name from the target; for aarch64 it is `ld-linux-aarch64.so.1`. +#### Trap: `/proc/self/exe` under the bundled loader + +Being started *by* the loader has a consequence the layout above does not +show: the kernel sets `/proc/self/exe` to the **loader**, not to your program, +and `/proc/self/cmdline` carries the `--library-path` argument. Every "find my +resources next to the executable" path therefore resolves against `lib/` +instead of the bundle root — and it does so silently. In practice that means +a GUI toolkit rendering blank text because it cannot find its fonts, an +`assets/` directory that appears to be missing, and helper binaries shipped +alongside the program that cannot be located. Code that parses `argv` from +`/proc/self/cmdline` sees the loader's arguments mixed in. + +This affects `self-contained` only. `vendored`, `system` and `static` all +carry a `PT_INTERP` that the kernel can use directly, so `/proc/self/exe` is +correct there. + +The wrapper exports **`MCPP_BUNDLE_DIR`** (the bundle root) for this. Resolve +against it first and fall back only when it is unset: + +```c +const char *base = getenv("MCPP_BUNDLE_DIR"); /* set by run.sh */ +if (!base) { + /* not launched through the wrapper — /proc/self/exe is trustworthy */ +} +``` + +If the application cannot be changed — a third-party GUI framework doing its +own resolution, say — use `--mode vendored` instead. It repoints `PT_INTERP` +at the host loader, at the cost of requiring the host's glibc to be at least +as new as the one the artifact was built against. + ## Configuration Packaging behavior is configured via the `[pack]` section in `mcpp.toml`. The diff --git a/docs/zh/02-pack-and-release.md b/docs/zh/02-pack-and-release.md index fe7e285a..e6eaff0a 100644 --- a/docs/zh/02-pack-and-release.md +++ b/docs/zh/02-pack-and-release.md @@ -1,8 +1,34 @@ # 02 — 发布打包 -> 默认的动态链接 `mcpp build` 产物通常会把 loader 与 RUNPATH 指向构建沙盒。 -> 如需分发至其他机器或部署至服务器,应使用 `mcpp pack` 生成带有适当运行时闭包的 -> 发布 tarball 或目录。 +> 默认的动态链接 `mcpp build` 产物会把 loader 与 RUNPATH 指向构建沙盒。它是 +> 开发产物,不是交付物。有三条路把它变成交付物 —— **没有一条使用宿主的 C 库**。 + +## 三种分发方式 + +下面每一条产出的产物,其 C 运行时都来自生态,而不是 `/lib64`。这是有意的: +mcpp 之所以针对私有 glibc 构建,正是为了让产物的行为不取决于底下是哪个发行版; +如果最后一步又伸手去拿宿主的 libc,前面这件事就白做了。 + +| | 方式 | 命令 | C 运行时来自哪里 | 何时选它 | +|---|---|---|---|---| +| **A** | 走生态 | `mcpp emit xpkg` → `xlings install ` | 目标机自己的 xlings 载荷 | 目标机装了 xlings | +| **B** | 静态单文件 | `mcpp build --target x86_64-linux-musl` | 不来自任何地方 —— 已链进去 | 想要一个无任何运行时依赖的单文件 | +| **C** | 自带运行时 | `mcpp pack --mode self-contained` | 随 bundle 一起分发 | 任何 Linux,含比构建机更老的 | + +**关于 A,以及那个让人意外的地方。** 刚构建出的二进制里烙的 `PT_INTERP` 指向 +**你这台机器**的载荷,所以手工把这个文件拷到另一台机器上跑不起来——那个路径 +在那边不存在。与其说这是产物的性质,不如说是「手工拷贝」这个动作的性质:经 +`xlings` 安装时,包里的 ELF 会在**装机期被重指到目标机自己的载荷**。烙进去的 +路径是构建机的细节,不是分发格式。如果你就是要在机器之间手工拷二进制,那你要 +的是 B 或 C。 + +**关于 B。** `--target …-musl` 隐含静态链接,所以没有 loader、没有 RUNPATH、 +运行期不需要找任何东西。它的结果最小也最可移植,在程序不需要 glibc 专有行为 +(NSS 查询、`dlopen` 宿主插件)时应当首选。 + +**关于 C。** bundle 里带着这套工具链的 glibc 与 loader,因此能在比构建机更老的 +发行版上跑 —— 这是 B 覆盖不了、而又确实需要 glibc 时的那一格。选它之前先读下面 +的 `/proc/self/exe` 一节:经 bundled loader 启动会改变程序对「自己在哪」的认知。 ## 两条轴:target(libc) × mode(打包深度) @@ -120,6 +146,32 @@ exec "$here/lib/ld-linux-x86-64.so.2" --library-path "$here/lib" "$here/bin/myap 上面的布局与 wrapper 以 x86_64 为例。打包器会按 target 推导 loader 名称;aarch64 对应 `ld-linux-aarch64.so.1`。 +#### 陷阱:经 bundled loader 启动后的 `/proc/self/exe` + +「由 loader 启动」有一个上面的布局看不出来的后果:内核会把 `/proc/self/exe` +指向 **loader**,而不是你的程序;`/proc/self/cmdline` 里也混进了 +`--library-path`。于是所有「在可执行文件旁边找资源」的逻辑都会解析到 `lib/` +而不是 bundle 根目录——而且是**静默**的。实际表现是:GUI 框架找不到字体因而 +文字渲染空白、`assets/` 目录看起来不存在、随包分发的辅助二进制定位失败。 +按 `/proc/self/cmdline` 解析 argv 的代码则会拿到混入 loader 参数的结果。 + +这只影响 `self-contained`。`vendored`、`system`、`static` 的 `PT_INTERP` +都能被内核直接使用,`/proc/self/exe` 是正确的。 + +wrapper 为此导出 **`MCPP_BUNDLE_DIR`**(bundle 根目录)。优先用它,只在未设置 +时回退: + +```c +const char *base = getenv("MCPP_BUNDLE_DIR"); /* 由 run.sh 设置 */ +if (!base) { + /* 不是经 wrapper 启动的 —— 此时 /proc/self/exe 可信 */ +} +``` + +如果应用本身改不了(比如第三方 GUI 框架自己做解析),改用 `--mode vendored`: +它把 `PT_INTERP` 重指到宿主 loader,`/proc/self/exe` 正常,代价是要求宿主 +glibc 不低于构建时所用的那份。 + ## 配置项 打包行为通过 `mcpp.toml` 中的 `[pack]` 节配置,常用字段如下: diff --git a/mcpp.toml b/mcpp.toml index e0c17b61..d2a6dbb6 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.8.7.1" +version = "2026.8.8.1" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/build/execute.cppm b/src/build/execute.cppm index 6a0e9b8c..b4cb5c1e 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -20,6 +20,8 @@ import mcpp.manifest; import mcpp.modgraph.scanner; import mcpp.toolchain.stdmod; import mcpp.xlings; +import mcpp.xlings.subos_info; +import mcpp.log; import mcpp.platform; import mcpp.fetcher.progress; import mcpp.project; @@ -60,6 +62,12 @@ struct BuildCacheEntry { // plan.runtimeLibraryDirs is empty. std::string runEnvKey; std::string runEnvValue; + // The subos this build's toolchain belongs to (mcpp#352). The DIRECTORY, + // never the resolved variables: the environment is the subos's property + // and must be re-read on every run, while WHICH subos is the build's + // property and would otherwise be unknowable on the fast path -- which + // has no toolchain to derive it from. + std::string subosDir; // The resolved profile this entry was built for. Entries used to be keyed // by target triple alone, and the fast paths only refuse to run when an // EXPLICIT --profile/--dev/--release is passed — so a bare `mcpp build` @@ -139,6 +147,16 @@ std::vector read_build_cache(const std::filesystem::path& proje std::getline(f, e.runEnvValue); haveNextLine = static_cast(std::getline(f, line)); } + // Optional subos line. Same back-compat contract: absent ⇒ empty ⇒ + // the run fast path treats the entry as a miss, exactly as it already + // does for a cache written before runtimeEnvKey existed. Running with + // a DIFFERENT environment than the full path would be worse than not + // using the cache at all -- the program would work once and then + // silently stop finding its runtime data. + if (haveNextLine && line.starts_with("subos=")) { + e.subosDir = line.substr(6); + haveNextLine = static_cast(std::getline(f, line)); + } // Optional profile line. Same back-compat contract as the two blocks // above: absent ⇒ e.profile stays empty ⇒ every fast path treats the // entry as a miss and falls through to prepare_build. @@ -167,7 +185,8 @@ void write_build_cache(const std::filesystem::path& projectRoot, const std::string& runEnvKey = "", const std::string& runEnvValue = "", const std::string& profile = "", - const std::string& cacheMode = "") { + const std::string& cacheMode = "", + const std::string& subosDir = "") { auto path = projectRoot / kBuildCacheFile; auto entries = read_build_cache(projectRoot); @@ -182,7 +201,7 @@ void write_build_cache(const std::filesystem::path& projectRoot, // Insert at front (MRU). BuildCacheEntry newEntry{targetTriple, outputDir.string(), ninjaProgram, fingerprintHex, runtimeEnvKey, runtimeEnvValue, std::move(runTargets), - runEnvKey, runEnvValue, profile, cacheMode}; + runEnvKey, runEnvValue, subosDir, profile, cacheMode}; entries.insert(entries.begin(), std::move(newEntry)); // Trim to LRU capacity. @@ -210,6 +229,7 @@ void write_build_cache(const std::filesystem::path& projectRoot, for (auto& [name, exe] : e.runTargets) f << name << '\t' << exe << '\n'; f << "runEnv=" << e.runEnvKey << '\n'; f << e.runEnvValue << '\n'; + f << "subos=" << e.subosDir << '\n'; f << "profile=" << e.profile << '\n'; f << "cacheMode=" << e.cacheMode << '\n'; } @@ -287,6 +307,55 @@ compute_run_env(const mcpp::build::BuildPlan& plan) { return {key, value}; } +// The environment the active subos declares for the programs it hosts +// (mcpp#352). +// +// A GL application needs three things and mcpp only ever supplied two: the +// binary links (bootstrap), it finds its libraries (RPATH), and then it has to +// be told which driver module to load and which GL vendors exist. That third +// one is a set of environment variables, xlings's graphics packages declare +// them into the subos, and until now nothing carried them to a program mcpp +// launched — `xlings subos use` applied them, `mcpp run` did not. Hence a +// binary that links fine and exits 255 with no output. +// +// Resolved at RUN time, deliberately not cached with the build: these values +// belong to the subos, not to the build, and a user who switches subos between +// `mcpp build` and `mcpp run` must get the new one. It is a file read. +// +// mcpp does not know what any of these variables MEAN, and that is the design: +// when the ecosystem gains a Vulkan loader or a new driver bridge, the +// declaration changes and this code does not. +// The subos a RUN should use: an explicit override if the caller set one, +// otherwise the subos this build belongs to. +// +// The override lives HERE and not in the derivation, because the derivation's +// answer is cached and this one must not be: MCPP_SUBOS_DIR says "for this +// invocation". It exists so tests can exercise this path without touching a +// developer's real subos — an earlier e2e wrote through a symlink and +// permanently broke a real toolchain — and so a user can point one run at +// another subos without switching the active one. +std::filesystem::path subos_dir_for_run(const std::filesystem::path& buildSubos) { + if (const char* e = std::getenv("MCPP_SUBOS_DIR"); e && *e) + return std::filesystem::path(e); + return buildSubos; +} + +std::vector> +compute_subos_env(const mcpp::build::BuildPlan& plan) { + auto built = mcpp::xlings::paths::subos_dir_of(plan.toolchain.binaryPath); + auto dir = subos_dir_for_run(built ? *built : std::filesystem::path{}); + if (dir.empty()) return {}; + auto info = mcpp::xlings::subos::read(dir); + // The note is a `verbose` line rather than a warning: a subos with no + // self-description is the normal state of every machine whose subos + // predates the block, and a warning on every run would train people to + // ignore it. It becomes loud only where it explains a failure — the GL + // diagnostic path in doctor. + if (!info.note.empty()) + mcpp::log::verbose("subos", info.note); + return mcpp::xlings::subos::resolve_env(info, dir); +} + // Compile a prepared BuildContext. Shared between `mcpp build` and `mcpp run` // so the latter doesn't call prepare_build twice (and re-print the toolchain // resolution banner). @@ -398,12 +467,14 @@ export int run_build_plan(BuildContext& ctx, bool verbose, bool no_cache, auto fpHex = ctx.outputDir.filename().string(); auto runTargets = compute_run_targets(ctx.plan); auto [runEnvKey, runEnvValue] = compute_run_env(ctx.plan); + auto subosDir = mcpp::xlings::paths::subos_dir_of(ctx.plan.toolchain.binaryPath); write_build_cache(ctx.projectRoot, ctx.outputDir, r->ninjaProgram, std::string(targetOverride), fpHex, r->runtimeEnvKey.empty() ? "-" : r->runtimeEnvKey, r->runtimeEnvValue, std::move(runTargets), runEnvKey, runEnvValue, - ctx.profile, std::string(cache_mode_name(ctx.cacheMode))); + ctx.profile, std::string(cache_mode_name(ctx.cacheMode)), + subosDir ? subosDir->string() : std::string{}); } // The one place the --strict policy is settled. Degradations reported by @@ -754,6 +825,25 @@ std::optional try_fast_run(const std::filesystem::path& projectRoot, std::vector> childEnv; if (!match->runEnvKey.empty() && !match->runEnvValue.empty()) childEnv.emplace_back(match->runEnvKey, match->runEnvValue); + // ...and the subos's declared environment, re-READ here rather than taken + // from the cache. Which subos is a build property (cached above); what it + // declares is the subos's own, and a user who installs a graphics stack + // between two runs must get it without rebuilding. + // + // This is the half that a fast path is most likely to lose, and losing it + // would be invisible in the worst way: the first `mcpp run` after a build + // takes the full path and works, every later one takes this path and does + // not. A GL program would run once and then stop finding its driver. + { + // Same rule as the full path, through the same helper: an override + // for this invocation, else the subos this build was recorded against. + auto subosDir = subos_dir_for_run(std::filesystem::path(match->subosDir)); + if (!subosDir.empty()) { + auto info = mcpp::xlings::subos::read(subosDir); + for (auto& kv : mcpp::xlings::subos::resolve_env(info, subosDir)) + childEnv.push_back(std::move(kv)); + } + } return mcpp::platform::process::run_exec(argv, childEnv) == 0 ? 0 : 1; } @@ -827,6 +917,8 @@ export int build_run_target(const std::optional& targetName, auto [runEnvKey, runEnvValue] = compute_run_env(ctx->plan); if (!runEnvKey.empty() && !runEnvValue.empty()) childEnv.emplace_back(runEnvKey, runEnvValue); + // ...plus whatever the subos declares for the programs it hosts (#352). + for (auto& kv : compute_subos_env(ctx->plan)) childEnv.push_back(std::move(kv)); // Direct exec (no /bin/sh): the loader env reaches ONLY the target child, // never mcpp or a host shell. Fixes the bundled-glibc-vs-host-libtinfo @@ -1213,6 +1305,9 @@ export int run_tests(std::span passthrough, auto runtimeEnvKey = mcpp::platform::env::runtime_library_path_key(); auto runtimeEnvValue = mcpp::platform::env::prepend_path_list( runtimeEnvKey, ctx->plan.runtimeLibraryDirs); + // Read once for the whole run rather than per test: it is one file, and + // every test in a run belongs to the same subos. + const auto subosEnv = compute_subos_env(ctx->plan); // macOS deliberately has no runtime-library-path key (env.cppm): injecting // DYLD_LIBRARY_PATH would reach every executable ninja launches and can @@ -1283,6 +1378,10 @@ export int run_tests(std::span passthrough, std::vector> childEnv; if (!runtimeEnvKey.empty() && !runtimeEnvValue.empty()) childEnv.emplace_back(runtimeEnvKey, runtimeEnvValue); + // ...and the subos's declared environment, same as `mcpp run` (#352). + // A GL test that cannot find a driver fails the same way a GL program + // does, so it must be told the same things. + for (auto& kv : subosEnv) childEnv.push_back(kv); // Prepend the sandbox's subos/default/bin to the CHILD PATH so test // binaries that shell out to bootstrapped tools (patchelf, ninja) find diff --git a/src/pack/pack.cppm b/src/pack/pack.cppm index 6323cf54..c41866a6 100644 --- a/src/pack/pack.cppm +++ b/src/pack/pack.cppm @@ -434,10 +434,27 @@ write_bundle_all_wrappers(const std::filesystem::path& stagingRoot, { auto body = std::format( "#!/bin/sh\n" - "# Auto-generated by `mcpp pack --mode bundle-all`. Launches the\n" + "# Auto-generated by `mcpp pack --mode self-contained`. Launches the\n" "# bundled binary through the bundled dynamic linker so the package\n" "# is fully portable across glibc versions.\n" + "#\n" + "# WHY MCPP_BUNDLE_DIR EXISTS -- the trap this launch has\n" + "#\n" + "# The ELF spec forbids $ORIGIN in PT_INTERP, so a bundle that carries\n" + "# its own loader has to be started BY that loader. The kernel then\n" + "# sets /proc/self/exe to the loader, not to the program, and\n" + "# /proc/self/cmdline carries --library-path. Every \"find my resources\n" + "# next to the executable\" path therefore resolves against lib/ --\n" + "# silently: fonts and assets are simply not found, and a helper binary\n" + "# shipped alongside the program is not found either.\n" + "#\n" + "# This variable is the answer that survives. Resolve against it first\n" + "# and fall back to /proc/self/exe only when it is unset. Applications\n" + "# that cannot be changed should use `--mode vendored` instead, where\n" + "# PT_INTERP is the host loader and /proc/self/exe is correct.\n" "here=$(cd \"$(dirname \"$0\")\" && pwd)\n" + "MCPP_BUNDLE_DIR=\"$here\"\n" + "export MCPP_BUNDLE_DIR\n" "exec \"$here/lib/{}\" --library-path \"$here/lib\" \"$here/bin/{}\" \"$@\"\n", loaderName, binaryName); if (auto r = write_executable_script(stagingRoot / "run.sh", body); !r) return r; diff --git a/src/version.cppm b/src/version.cppm index 86b874ae..bd258c4a 100644 --- a/src/version.cppm +++ b/src/version.cppm @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.8.7.1"; +inline constexpr std::string_view MCPP_VERSION = "2026.8.8.1"; } // namespace mcpp diff --git a/src/xlings.cppm b/src/xlings.cppm index 873b50a1..e05f9457 100644 --- a/src/xlings.cppm +++ b/src/xlings.cppm @@ -74,6 +74,26 @@ namespace paths { std::optional xpkgs_from_compiler(const std::filesystem::path& compilerBin); + // The subos a TOOLCHAIN belongs to (mcpp#352). + // + // Derived from the compiler rather than from a global: a build already + // knows which home its toolchain came from, and asking a second source + // would let the two disagree — the "one question, several answerers" shape + // that the same investigation found four times over on the xlings side. + // + // A PURE derivation, with no environment override in it. That is not an + // omission: this value gets written into the build cache, and an override + // means "for this invocation", not "for this build from now on". Caching + // one would make a single `MCPP_SUBOS_DIR=… mcpp run` silently change + // where every later run looked. Which subos a RUN should use is a + // different question, answered in execute.cppm. + // + // Empty when the toolchain is not sandbox-resident — a system compiler is + // the user's explicit choice of the host world, and there is no subos + // speaking for it. + std::optional + subos_dir_of(const std::filesystem::path& compilerBin); + // Find a sibling xim tool relative to a compiler binary. // e.g. find_sibling_tool(gcc_bin, "binutils") returns highest version // dir of xim-x-binutils. @@ -673,6 +693,19 @@ xpkgs_from_compiler(const std::filesystem::path& compilerBin) { return std::nullopt; } +std::optional +subos_dir_of(const std::filesystem::path& compilerBin) { + auto xpkgs = xpkgs_from_compiler(compilerBin); + if (!xpkgs) return std::nullopt; + // /data/xpkgs → /subos/default. Spelled from the xpkgs dir + // rather than from Env so a toolchain inherited from ANOTHER home resolves + // to that home's subos, which is the one whose payloads the binary was + // actually linked against. + auto home = xpkgs->parent_path().parent_path(); + if (home.empty()) return std::nullopt; + return home / "subos" / "default"; +} + std::optional find_sibling_tool(const std::filesystem::path& compilerBin, std::string_view tool) { diff --git a/src/xlings/subos_info.cppm b/src/xlings/subos_info.cppm new file mode 100644 index 00000000..0054fb2b --- /dev/null +++ b/src/xlings/subos_info.cppm @@ -0,0 +1,242 @@ +// mcpp.xlings.subos_info — read the `subos_info` block xlings writes into a +// subos's own `.xlings.json`. +// +// WHAT THIS IS FOR +// +// A program needs three things to run: bootstrap (PT_INTERP + CRT + libc), +// discovery (PATH + RPATH), and configuration (env vars). xlings had the +// first two — glibc + elfpatch, xvm + shims — and until it grew this block, +// nothing for the third. Its own module comment names the consequence by +// number: mcpp#352, a GLFW binary that links fine and exits 255 because +// nothing told it where the GL drivers are. +// +// mcpp is the consumer of the third one. A program mcpp launches gets the +// environment its subos declares, so when the ecosystem gains a Vulkan loader +// or a new driver bridge, this file does not change — the declaration does. +// That is the whole point of reading rather than knowing: mcpp must never +// contain the string "LIBGL_DRIVERS_PATH", because the moment it does, the +// graphics stack has two owners. +// +// SCOPE: read and resolve. This module never WRITES the block and never +// manages subos lifecycle — that is xlings's layer, and mcpp reaching into it +// is the layering inversion the three-tier ecosystem design calls out by name +// ("recipe 里塞 build 逻辑、mcpp 反过来管 subos 状态" — both symptoms of the +// same confusion). +// +// A NOTE ON SILENCE +// +// Every degradation here fills `note`, and callers are required to print it. +// "It did not happen" and "it succeeded" producing identical output is the +// property that made #352 expensive, and a subos with no block is not an +// exotic case: mcpp's own sandbox subos was measured in exactly that state, +// with 356 workspace entries and no self-description at all. +// +// Design: .agents/docs/2026-08-07-xlings-as-runtime-substrate-design.md §3-S3 + +export module mcpp.xlings.subos_info; + +import std; +import mcpp.libs.json; +import mcpp.platform; + +export namespace mcpp::xlings::subos { + +// The schema this build understands. A HIGHER one on disk is still read — we +// take the fields we know and say so. Refusing outright would let a newer +// xlings break an older mcpp, which is the failure shape the index-floor +// incident already paid for once: publishing data must not invalidate the +// program that reads it. +inline constexpr int kSupportedSchema = 1; + +inline constexpr std::string_view kBlock = "subos_info"; + +struct EnvDecl { + std::string var; + std::string op; // "set" | "prepend" + std::string value; // may contain ${subosdir} +}; + +struct Provider { + std::string binding; // "@" + std::vector decls; +}; + +struct Info { + int schema = 0; + std::string runtime; // "glibc@2.39" + std::vector providers; // sorted by binding + bool present = false; + // Non-empty ⇒ the caller MUST surface it. Never an error: a missing or + // newer block degrades the experience; it does not invalidate a build. + std::string note; +}; + +// The runtime string is self-describing: "glibc@2.39" says Linux/glibc +// without a second field that could disagree with it. +// +// Mirrors xlings's `subos::manifest::family_of`. Two implementations of one +// mapping is a cost, and the alternative — asking the xlings binary — costs +// a subprocess in the build's hot path and fails on exactly the machines +// where it matters (a sandbox xlings that has not been updated). The mapping +// is five rows and stable, and test_subos_info.cpp pins every one of them, so +// a drift is a test failure rather than a silent ABI disagreement. +std::string family_of(std::string_view runtime, + std::string_view arch = "x86_64") { + const auto at = runtime.find('@'); + const auto name = runtime.substr(0, at == std::string_view::npos + ? runtime.size() : at); + if (name == "glibc") return std::format("linux-{}-glibc", arch); + if (name == "musl") return std::format("linux-{}-musl", arch); + if (name == "wasi-libc") return "wasm32-wasi"; + if (name == "macos_sdk") return std::format("darwin-{}", arch); + if (name == "ucrt") return std::format("windows-{}-ucrt", arch); + return "unknown"; +} + +Info read(const std::filesystem::path& subosDir) { + Info info; + auto path = subosDir / ".xlings.json"; + std::error_code ec; + if (!std::filesystem::exists(path, ec)) { + info.note = std::format( + "subos '{}' has no .xlings.json, so it cannot say which runtime it " + "is or what environment its programs need", + subosDir.string()); + return info; + } + + std::ifstream is(path); + auto doc = nlohmann::json::parse(is, nullptr, /*allow_exceptions=*/false); + if (doc.is_discarded() || !doc.is_object()) { + info.note = std::format("subos manifest {} is not readable JSON", + path.string()); + return info; + } + + auto it = doc.find(std::string(kBlock)); + if (it == doc.end() || !it->is_object()) { + info.note = std::format( + "subos '{}' does not describe itself (no `{}` block), so programs " + "run from here get no environment it declares — a GL application " + "will not find its drivers. A newer xlings writes this block; " + "`xlings self update` adds it", + subosDir.string(), kBlock); + return info; + } + + info.present = true; + if (auto v = it->find("schema_version"); + v != it->end() && v->is_number_integer()) + info.schema = v->get(); + if (auto v = it->find("runtime"); v != it->end() && v->is_string()) + info.runtime = v->get(); + + if (auto envs = it->find("envs"); envs != it->end() && envs->is_array()) { + for (auto const& p : *envs) { + if (!p.is_object()) continue; + Provider prov; + if (auto b = p.find("binding"); b != p.end() && b->is_string()) + prov.binding = b->get(); + if (auto ds = p.find("decls"); ds != p.end() && ds->is_array()) { + for (auto const& d : *ds) { + if (!d.is_object()) continue; + EnvDecl e; + if (auto x = d.find("var"); x != d.end() && x->is_string()) + e.var = x->get(); + if (auto x = d.find("op"); x != d.end() && x->is_string()) + e.op = x->get(); + if (auto x = d.find("value"); x != d.end() && x->is_string()) + e.value = x->get(); + // A declaration with no variable name is not a partial + // declaration to be guessed at — it is malformed input, + // and the right thing is to leave it out rather than + // invent a name for it. + if (!e.var.empty()) prov.decls.push_back(std::move(e)); + } + } + info.providers.push_back(std::move(prov)); + } + } + + // Sorted by binding, matching xlings's own ordering, so two reads of one + // subos produce the same environment in the same order. Ordering is not + // cosmetic here: it decides which provider wins a list variable, and + // libglvnd resolves GL vendors by exactly that order. + std::sort(info.providers.begin(), info.providers.end(), + [](Provider const& a, Provider const& b) { + return a.binding < b.binding; + }); + + if (info.schema > kSupportedSchema) + info.note = std::format( + "subos '{}' declares schema {}, newer than the {} this mcpp " + "understands; reading the fields it knows and ignoring the rest", + subosDir.string(), info.schema, kSupportedSchema); + return info; +} + +// Resolve the declarations into concrete (var, value) pairs with +// `${subosdir}` expanded. +// +// `prepend` joins in provider order and de-duplicates; `set` replaces. That +// is xlings's own precedence, and the de-duplication matters because these +// variables are inherited: without it a nested invocation grows the list +// every time. +// +// The separator is the PLATFORM's, never a literal ':'. Hardcoding one is a +// mistake this repository has made before and it is not cosmetic: on Windows +// the list separator is ';' and ':' appears INSIDE every absolute path, so a +// ':'-keyed split cuts "C:\\x" into "C" and "\\x" -- the de-duplication then +// never matches and the joined value is a corrupt list. Caught by CI on +// Windows, not by any amount of reading. +std::vector> +resolve_env(const Info& info, const std::filesystem::path& subosDir) { + std::vector> out; + + const std::string subos = subosDir.string(); + auto expand = [&](std::string v) { + constexpr std::string_view kPh = "${subosdir}"; + for (auto pos = v.find(kPh); pos != std::string::npos; + pos = v.find(kPh, pos + subos.size())) + v.replace(pos, kPh.size(), subos); + return v; + }; + + const std::string sep = mcpp::platform::env::path_list_separator(); + + // Does the list already contain `value` as a WHOLE element? Compared + // element-wise rather than by substring: a plain `find` would consider + // "/a/bc" already present in "/a/bcd". + auto contains_element = [&](std::string_view list, std::string_view value) { + for (std::size_t i = 0; i <= list.size();) { + auto end = list.find(sep, i); + auto piece = list.substr(i, end == std::string_view::npos + ? std::string_view::npos : end - i); + if (piece == value) return true; + if (end == std::string_view::npos) break; + i = end + sep.size(); + } + return false; + }; + + // An explicit loop rather than std::ranges::find with a member-pointer + // projection into std::pair. The projection form reads well and crashed + // the clang 20.1.7 frontend outright when this module was compiled for + // the MSVC target -- a segfault with no diagnostic beyond "clang frontend + // command failed due to signal". Nothing here needs the fancier spelling. + for (auto const& p : info.providers) { + for (auto const& d : p.decls) { + auto value = expand(d.value); + std::pair* hit = nullptr; + for (auto& kv : out) + if (kv.first == d.var) { hit = &kv; break; } + if (!hit) { out.emplace_back(d.var, value); continue; } + if (d.op == "set") { hit->second = value; continue; } + if (!contains_element(hit->second, value)) + hit->second = value + sep + hit->second; + } + } + return out; +} + +} // namespace mcpp::xlings::subos diff --git a/tests/e2e/200_subos_env_reaches_program.sh b/tests/e2e/200_subos_env_reaches_program.sh new file mode 100755 index 00000000..81da7302 --- /dev/null +++ b/tests/e2e/200_subos_env_reaches_program.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# requires: elf +# 200_subos_env_reaches_program.sh — a subos's declared environment must reach +# the program mcpp launches (mcpp#352). +# +# A program needs three things: it links (bootstrap), it finds its libraries +# (RPATH), and it is told where its runtime data lives (env). mcpp supplied the +# first two and nothing for the third: xlings's graphics packages declare +# LIBGL_DRIVERS_PATH and friends into the subos, `xlings subos use` applied +# them, and `mcpp run` did not. That is why a GLFW binary could link cleanly +# and exit 255 with no output at all. +# +# The probe variable here is deliberately NOT a graphics one. mcpp does not +# know what any of these variables mean -- it carries whatever the subos +# declares -- and a test naming LIBGL_DRIVERS_PATH would quietly suggest +# otherwise. +set -euo pipefail + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +# A subos that declares one variable, in xlings's own schema. Built here rather +# than by mutating the developer's real subos: an earlier e2e wrote through a +# symlink and permanently broke a real toolchain, and MCPP_SUBOS_DIR exists so +# this test never has to go near one. +subos="$TMP/subos" +mkdir -p "$subos/usr/lib/dri" +cat > "$subos/.xlings.json" <<'EOF' +{ "workspace": {}, + "subos_info": { "schema_version": 1, "runtime": "glibc@2.39", + "envs": [ { "binding": "probe@1", "decls": [ + { "var": "MCPP_E2E_PROBE", "op": "prepend", + "value": "${subosdir}/usr/lib/dri" } ] } ] } } +EOF + +cd "$TMP" +"$MCPP" new hello > /dev/null +cd hello +cat > src/main.cpp <<'EOF' +#include +#include +int main() { + const char* v = std::getenv("MCPP_E2E_PROBE"); + std::printf("PROBE=%s\n", v ? v : "(unset)"); + return 0; +} +EOF + +# 1. `mcpp run` hands it to the program. +out=$(MCPP_SUBOS_DIR="$subos" "$MCPP" run 2>&1) || { + echo "mcpp run failed:"; echo "$out"; exit 1; } +echo "$out" | grep -q "PROBE=$subos/usr/lib/dri" || { + echo "the subos's declared environment did not reach the program:" + echo "$out" + exit 1 +} + +# 1b. THE SECOND RUN, which takes the cached fast path. +# +# This is the assertion that matters most, and the one a single-run test +# cannot make. The fast path builds its own child environment and skips +# prepare_build entirely; when it was first written it did not know about +# subos declarations at all, so a program worked on the run right after a +# build and silently stopped finding its runtime data on every run after +# that. For a GL application that is "it worked once and now the window is +# black", with nothing in between to attribute it to. +out_cached=$(MCPP_SUBOS_DIR="$subos" "$MCPP" run 2>&1) || { + echo "cached mcpp run failed:"; echo "$out_cached"; exit 1; } +# Self-check FIRST: prove this run actually took the fast path, otherwise the +# assertion below is vacuous and would keep passing after the coverage it +# exists for has silently gone away. The full path resolves the toolchain and +# says so; the fast path skips prepare_build entirely and never prints it. +echo "$out_cached" | grep -q 'Resolving toolchain' && { + echo "the second run did NOT take the cached fast path, so this test is" + echo " not covering it. Fix the test before trusting the assertion below:" + echo "$out_cached" + exit 1 +} +echo "$out_cached" | grep -q "PROBE=$subos/usr/lib/dri" || { + echo "the cached fast path dropped the subos environment — the program" + echo " gets a different environment on its second run than its first:" + echo "$out_cached" + exit 1 +} + +# 1c. ...and the environment is re-READ, not cached with the build. A user +# who installs a graphics stack between two runs must get it without +# rebuilding, so changing the declaration must change the next run. +sed -i 's#/usr/lib/dri#/usr/lib/dri2#' "$subos/.xlings.json" +out_changed=$(MCPP_SUBOS_DIR="$subos" "$MCPP" run 2>&1) || { + echo "run after changing the declaration failed:"; echo "$out_changed"; exit 1; } +echo "$out_changed" | grep -q "PROBE=$subos/usr/lib/dri2" || { + echo "the subos declaration changed but the program still sees the old" + echo " value — the environment was cached with the build instead of" + echo " being read from the subos:" + echo "$out_changed" + exit 1 +} +sed -i 's#/usr/lib/dri2#/usr/lib/dri#' "$subos/.xlings.json" + +# 2. Without a subos saying anything, nothing is invented. +out2=$("$MCPP" run 2>&1) || { echo "plain mcpp run failed:"; echo "$out2"; exit 1; } +echo "$out2" | grep -q 'PROBE=(unset)' || { + echo "a variable appeared with no subos declaring it:" + echo "$out2" + exit 1 +} + +# 3. A subos with no self-description degrades quietly and still runs. This is +# the state of every subos created before xlings grew the block, so it must +# not be an error. +bare="$TMP/bare" +mkdir -p "$bare" +echo '{ "workspace": {} }' > "$bare/.xlings.json" +out3=$(MCPP_SUBOS_DIR="$bare" "$MCPP" run 2>&1) || { + echo "a subos without subos_info broke the run:"; echo "$out3"; exit 1; } +echo "$out3" | grep -q 'PROBE=(unset)' || { + echo "unexpected output from a subos with no declarations:"; echo "$out3"; exit 1; } + +echo "PASS: subos environment reaches the program, and nothing is invented" diff --git a/tests/e2e/30_pack_modes.sh b/tests/e2e/30_pack_modes.sh index 0ef689b8..32f16b46 100755 --- a/tests/e2e/30_pack_modes.sh +++ b/tests/e2e/30_pack_modes.sh @@ -138,6 +138,28 @@ grep -q 'Hello' "$TMP/b-out.log" || { grep -q 'Hello' "$TMP/b-name.log" || { cat "$TMP/b-name.log"; echo "Mode B -entry output missing"; exit 1; } +# Launching through the loader makes the kernel set /proc/self/exe to the +# LOADER, so every "find my resources next to the executable" path silently +# resolves against lib/ instead. The wrapper must therefore hand the program +# an answer that survives — and it must hand the same one from both entry +# points, since they are documented as interchangeable. +for entry in run.sh myapp; do + grep -q 'MCPP_BUNDLE_DIR' \ + "$TMP/b/myapp-0.1.0-x86_64-linux-gnu-bundle-all/$entry" || { + echo "Mode B: $entry does not export MCPP_BUNDLE_DIR — an application" + echo " cannot locate its own resources under the bundled loader" + exit 1; } +done + +# The value must be the bundle ROOT (`$here`), not the loader's directory and +# not a path baked in at pack time — the bundle is relocatable, so anything +# absolute would be wrong the moment it is extracted somewhere else. +grep -q 'MCPP_BUNDLE_DIR="\$here"' \ + "$TMP/b/myapp-0.1.0-x86_64-linux-gnu-bundle-all/run.sh" || { + echo "Mode B: MCPP_BUNDLE_DIR is not derived from the bundle root (\$here)" + grep MCPP_BUNDLE_DIR "$TMP/b/myapp-0.1.0-x86_64-linux-gnu-bundle-all/run.sh" + exit 1; } + # ─── Mode `system` (Mode::None — depend on OS for all .so) ───────────── "$MCPP" pack --mode system > "$TMP/pack-sys.log" 2>&1 || { cat "$TMP/pack-sys.log"; echo "system pack failed"; exit 1; } diff --git a/tests/e2e/65_toolchain_runtime_dirs_for_run.sh b/tests/e2e/65_toolchain_runtime_dirs_for_run.sh index 1587ae2b..dad55814 100755 --- a/tests/e2e/65_toolchain_runtime_dirs_for_run.sh +++ b/tests/e2e/65_toolchain_runtime_dirs_for_run.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# requires: llvm linux +# requires: scan-deps elf # dlopen() providers such as GLX drivers do not use the main executable's # RUNPATH for their own DT_NEEDED closure. mcpp run must therefore expose the # toolchain runtime directories in LD_LIBRARY_PATH as well. diff --git a/tests/e2e/run_all.sh b/tests/e2e/run_all.sh index 5f10700e..392849c8 100755 --- a/tests/e2e/run_all.sh +++ b/tests/e2e/run_all.sh @@ -129,6 +129,40 @@ fi echo "Detected capabilities: ${CAPS[*]:-}" +# --------------------------------------------------------------------------- +# Every token a test may declare, across ALL platforms. +# --------------------------------------------------------------------------- +# A test declaring anything else is skipped on every runner, forever, and the +# skip line reads exactly like a legitimate one -- "missing capability: linux" +# is indistinguishable from "missing capability: msvc" on a Linux box. Two +# tests were in that state when this guard was added: 65_toolchain_runtime_ +# dirs_for_run.sh (`llvm linux`) had never run in CI at all, and it passes. +# +# This list is the UNIVERSE, not what this machine has: `msvc` is legitimately +# absent on Linux and must stay legal to declare. It is checked against the +# CAPS+=() calls above by tests/e2e/README or by reading them -- keep it in +# sync when adding a capability. +KNOWN_CAPS=(elf fresh-sandbox gcc import-std-libcxx macos mingw-cross msvc + musl nasm no-msvc pack patchelf scan-deps symlink unix-shell + windows wine) + +bad_tokens=0 +for tf in "$HERE"/[0-9]*.sh; do + base="$(basename "$tf")" + req="$(sed -n '2p' "$tf")" + [[ "$req" =~ ^#\ requires: ]] || continue + toks="${req#\# requires:}" + for tok in $toks; do + if [[ " ${KNOWN_CAPS[*]} " != *" $tok "* ]]; then + echo "ERROR: $base declares unknown capability '$tok' — it would be" + echo " skipped on every runner. Known: ${KNOWN_CAPS[*]}" + bad_tokens=1 + fi + done +done +[[ $bad_tokens -eq 0 ]] || exit 1 + + # --------------------------------------------------------------------------- # Helper: check if a test's requirements are satisfied # --------------------------------------------------------------------------- diff --git a/tests/unit/test_subos_info.cpp b/tests/unit/test_subos_info.cpp new file mode 100644 index 00000000..95385778 --- /dev/null +++ b/tests/unit/test_subos_info.cpp @@ -0,0 +1,180 @@ +// Reading the `subos_info` block xlings writes into a subos's .xlings.json. +// +// This is a CROSS-REPO CONTRACT, so the assertions are about the wire format +// and about what happens when it is absent or newer than we understand — +// not about internal behaviour. The degradation cases carry the weight: a +// subos made before xlings grew the block is the common case on any machine +// that has been around a while, and silence there is what made mcpp#352 +// expensive to find in the first place. + +#include + +import std; +import mcpp.platform; +import mcpp.xlings.subos_info; + +namespace su = mcpp::xlings::subos; + +namespace { + +struct Tmp { + std::filesystem::path dir; + Tmp() { + dir = std::filesystem::temp_directory_path() + / std::format("mcpp_subos_test_{}", std::random_device{}()); + std::filesystem::create_directories(dir); + } + ~Tmp() { std::error_code ec; std::filesystem::remove_all(dir, ec); } + void write(std::string_view body) const { + std::ofstream(dir / ".xlings.json") << body; + } +}; + +TEST(SubosInfo, ReadsRuntimeAndEnvDeclarations) { + Tmp t; + t.write(R"({ + "workspace": {}, + "subos_info": { + "schema_version": 1, + "runtime": "glibc@2.39", + "envs": [ + { "binding": "mesa@25.0.7.1", "decls": [ + { "var": "LIBGL_DRIVERS_PATH", "op": "prepend", + "value": "${subosdir}/usr/lib/dri" }, + { "var": "XDG_DATA_DIRS", "op": "prepend", + "value": "${subosdir}/share" } + ]} + ] + } + })"); + auto info = su::read(t.dir); + EXPECT_TRUE(info.present); + EXPECT_EQ(info.schema, 1); + EXPECT_EQ(info.runtime, "glibc@2.39"); + ASSERT_EQ(info.providers.size(), 1u); + EXPECT_EQ(info.providers[0].binding, "mesa@25.0.7.1"); + ASSERT_EQ(info.providers[0].decls.size(), 2u); + EXPECT_EQ(info.providers[0].decls[0].var, "LIBGL_DRIVERS_PATH"); + EXPECT_TRUE(info.note.empty()); +} + +// `${subosdir}` expands against the subos the block was read FROM, not +// against any global notion of "the" subos. That is what lets a program +// built under one subos run correctly under another. +TEST(SubosInfo, ResolvesSubosdirPlaceholder) { + Tmp t; + t.write(R"({"subos_info":{"schema_version":1,"runtime":"glibc@2.39", + "envs":[{"binding":"mesa@1","decls":[ + {"var":"LIBGL_DRIVERS_PATH","op":"prepend","value":"${subosdir}/usr/lib/dri"}]}]}})"); + auto env = su::resolve_env(su::read(t.dir), t.dir); + ASSERT_EQ(env.size(), 1u); + EXPECT_EQ(env[0].first, "LIBGL_DRIVERS_PATH"); + // Literal concatenation, NOT a path join. The separator in the declaration + // belongs to the subos manifest and is substituted verbatim; turning it + // into the host's would rewrite a value we do not own. On Windows the two + // spellings differ and the path-join form is the wrong expectation. + EXPECT_EQ(env[0].second, t.dir.string() + "/usr/lib/dri"); +} + +// Several providers may contribute to one variable — that is the normal +// shape for the graphics stack, where mesa and a vendor bridge both add an +// EGL vendor directory. `prepend` joins them; it must not drop either. +TEST(SubosInfo, PrependJoinsProvidersInOrder) { + Tmp t; + t.write(R"({"subos_info":{"schema_version":1,"runtime":"glibc@2.39","envs":[ + {"binding":"a-mesa@1","decls":[ + {"var":"V","op":"prepend","value":"${subosdir}/one"}]}, + {"binding":"b-vendor@1","decls":[ + {"var":"V","op":"prepend","value":"${subosdir}/two"}]}]}})"); + auto env = su::resolve_env(su::read(t.dir), t.dir); + ASSERT_EQ(env.size(), 1u); + const auto sep = mcpp::platform::env::path_list_separator(); + EXPECT_EQ(env[0].second, + t.dir.string() + "/two" + sep + t.dir.string() + "/one") + << "both providers must survive, later binding front-most"; +} + +// The same value arriving twice must not accumulate: nested invocations +// would otherwise grow the variable without bound. +TEST(SubosInfo, PrependDeduplicates) { + Tmp t; + t.write(R"({"subos_info":{"schema_version":1,"runtime":"glibc@2.39","envs":[ + {"binding":"a@1","decls":[{"var":"V","op":"prepend","value":"${subosdir}/x"}]}, + {"binding":"b@1","decls":[{"var":"V","op":"prepend","value":"${subosdir}/x"}]}]}})"); + auto env = su::resolve_env(su::read(t.dir), t.dir); + ASSERT_EQ(env.size(), 1u); + // One entry, not two. The de-duplication has to split on the PLATFORM's + // list separator: keyed on ':' it would cut "C:\\x" apart on Windows, + // match nothing, and grow the list on every nested invocation. + EXPECT_EQ(env[0].second, t.dir.string() + "/x"); + EXPECT_EQ(env[0].second.find(mcpp::platform::env::path_list_separator()), + std::string::npos); +} + +// `set` replaces rather than joins — xlings's own precedence. +TEST(SubosInfo, SetReplaces) { + Tmp t; + t.write(R"({"subos_info":{"schema_version":1,"runtime":"glibc@2.39","envs":[ + {"binding":"a@1","decls":[{"var":"V","op":"prepend","value":"/one"}]}, + {"binding":"b@1","decls":[{"var":"V","op":"set","value":"/two"}]}]}})"); + auto env = su::resolve_env(su::read(t.dir), t.dir); + ASSERT_EQ(env.size(), 1u); + EXPECT_EQ(env[0].second, "/two"); +} + +// A subos made before xlings grew the block. Degrade, and SAY SO — this is +// the common case on an existing machine, and mcpp's own sandbox subos was +// measured in exactly this state. +TEST(SubosInfo, MissingBlockDegradesWithANote) { + Tmp t; + t.write(R"({"workspace":{}})"); + auto info = su::read(t.dir); + EXPECT_FALSE(info.present); + EXPECT_TRUE(info.runtime.empty()); + EXPECT_FALSE(info.note.empty()); + EXPECT_TRUE(su::resolve_env(info, t.dir).empty()); +} + +// A schema newer than we understand: read what we can, and say we are behind. +// Refusing outright would make a newer xlings break an older mcpp, which is +// the failure mode the index-floor incident already paid for once. +TEST(SubosInfo, NewerSchemaIsUsedAndAnnounced) { + Tmp t; + t.write(R"({"subos_info":{"schema_version":99,"runtime":"glibc@2.44","envs":[]}})"); + auto info = su::read(t.dir); + EXPECT_TRUE(info.present); + EXPECT_EQ(info.runtime, "glibc@2.44"); + EXPECT_FALSE(info.note.empty()); +} + +TEST(SubosInfo, NoFileAtAllIsNotACrash) { + Tmp t; // nothing written + auto info = su::read(t.dir); + EXPECT_FALSE(info.present); + EXPECT_FALSE(info.note.empty()); +} + +TEST(SubosInfo, MalformedJsonDegrades) { + Tmp t; + t.write("{ this is not json"); + auto info = su::read(t.dir); + EXPECT_FALSE(info.present); + EXPECT_FALSE(info.note.empty()); +} + +// The family mapping is part of the cross-repo contract: xlings derives it +// from the runtime string too, and the two must agree or a subos and a build +// will disagree about what ABI they are talking about. +TEST(SubosInfo, FamilyOfMirrorsXlings) { + EXPECT_EQ(su::family_of("glibc@2.39"), "linux-x86_64-glibc"); + EXPECT_EQ(su::family_of("musl@1.2.5"), "linux-x86_64-musl"); + EXPECT_EQ(su::family_of("glibc@2.39", "aarch64"), "linux-aarch64-glibc"); + EXPECT_EQ(su::family_of("wasi-libc@1"), "wasm32-wasi"); + EXPECT_EQ(su::family_of("macos_sdk@14.0", "arm64"), "darwin-arm64"); + EXPECT_EQ(su::family_of("ucrt@10"), "windows-x86_64-ucrt"); + EXPECT_EQ(su::family_of("nonsense@1"), "unknown"); + // No '@' at all is not a binding; it must not be read as one. + EXPECT_EQ(su::family_of("glibc"), "linux-x86_64-glibc"); +} + +} // namespace From fdad165b739b3a47474a6cdca9df139635c83879 Mon Sep 17 00:00:00 2001 From: SPeak Agent Date: Sat, 8 Aug 2026 11:34:04 +0800 Subject: [PATCH 03/54] =?UTF-8?q?feat:=20=E7=BC=96=E8=AF=91=E5=99=A8?= =?UTF-8?q?=E6=98=AF=E8=83=BD=E5=8A=9B,=E4=B8=8D=E6=98=AF=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=20=E2=80=94=E2=80=94=20payload=20=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E6=9D=83=E5=A8=81=20+=20specs=20=E6=B1=A1=E6=9F=93=20+=20xling?= =?UTF-8?q?s=20pin=20(2026.8.8.2)=20(#378)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一个 payload 交付两样可分离的东西:编译的**能力**,以及关于如何链接的**主张**。mcpp 要前者,后者自己给 —— 链接行才是构建决策该待的地方,因为它是逐次构建而变的那一个。四个看起来无关的缺陷收敛到这一条上。 设计:`.agents/docs/2026-08-08-payload-version-and-contract-drift-design.md`(含 §9 实施回写)。替代已关闭的 #377。 ## 1. 产物加载哪个 glibc,由权威决定,不再靠目录顺序 旧规则向目录问「那个 glibc」并取 `readdir` 的第一项。只装一个时它永远正确,所以从没有东西逼它正确 —— 而一条带 `xim:glibc@>=2.38` 的依赖就足以装进第二个。mcpp-index 上真实发生过:编译侧取 2.44,产物 interpreter(冻结在 gcc specs 里)仍是 2.39,二进制引用 `GLIBC_2.42` 却跑在没有它的运行时上,**报错落在与那条依赖无关的包上**。 解析顺序:`[xlings] subos` 自述 → 活动 subos 自述 → 兼容记录(specs / cfg / **PT_INTERP**)→ 单例 payload。计入工具链指纹(11 字段)。 两条判据是落地时被 CI 逼出来的,已写回设计: - **没得选就不算猜。** 恰好一个 glibc payload 时没有选择可言,拒答等于拒答一个只有一个答案的问题。两个及以上才沉默 —— 那才是事故的形状。 - **化石不算权威。** `specs`/`.cfg`/`PT_INTERP` 都是过去某刻的记录,payload 被换掉时没人回头改。一条记录只有在它指的东西还在,才算数。subos 自己的声明同样适用。 把「查不到」也当成「有歧义」的代价是产物掉到宿主 loader —— 比猜错版本更糟,因为它连沙箱都出去了。 ## 2. 不再改写 GCC 的 `specs` 旧重写用单路径 needle 配双路径 replacement,每个跑过它的 home 都漏下一条:一台开发机产出的**每个** gcc 产物里都带 **68 条**陈旧 `RUNPATH`,全指向已删除的 `mktemp` 目录。没有东西发现它们,因为一条死 RUNPATH 只多花一点搜索时间。 改为 `-specs=` 一份逐构建生成的干净文件(`-dumpspecs` 取**内建** `*link:`,去掉 loader/rpath)。不带 `+` 的 `-specs=` 是**替换**,payload 分毫未动。逐构建、不需写权限,继承来的只读 payload 也能用。 删掉 `*link:` 也删掉了它提供的东西,因此 loader 与每条 rpath 由 mcpp 显式写在链接行上 —— 包括**编译器自己的 `lib64`**(`libgcc_s.so.1` 在那)。 ## 3. 落后于 pin 的 vendored xlings 会被替换 —— 但只在替换品确实更新时 一个 home 会永远留着第一次获取的 xlings(实测 2026.8.2.1 对 pin 2026.8.6.3)。`subos_info` 是 2026.8.5.1 才有的,所以那台机器上 subos 的自述一直被太旧的客户端丢弃,#352 的修复无论 mcpp 多新都无法生效。 修复的第一版直接删了重取,把 2026.8.2.1 换成系统的 **0.4.51** —— 更旧、且同样缺那个特性。现在先给替换品定价再动手。 ## 4. `--sysroot` 的判据从「存在」改为「归属」 gcc 把 `--sysroot=<...>/.xlings/subos/default` 当字符串烙进去,而一台机器上有很多同名目录 —— 烙入的路径经常**存在、却属于另一个 checkout**。实测:本仓库的构建解析到了无关仓库下的 sysroot。归属先于可用性。 --- ## 验证 | | | |---|---| | 单测 | **67/67**(新增 5 个文件、28 条断言) | | 本地全量 e2e | **186 passed / 6 failed / 8 skipped**,6 个失败全在已记录基线内(03/09/20/33/59/98),其中 03/09/20/155/184 逐一用**已发布的 2026.8.6.3** 复现确认;`178` 反而转绿 | | CI | **18/18 全绿** | | 版本 | 2026.8.8.2;xlings pin → 2026.8.7.1(索引最新;上游 2026.8.8.1 未收录,pin 过去会让 CI 装不到) | runtime binding 四条来源逐条实测:新建自述 subos、注入 `glibc@2.99`(不存在)、挪开 `specs` **且**把 gcc 自身 `PT_INTERP` 改指宿主、三条来源全打掉只剩单例。 ## 这一轮真正的教训 **十轮 CI 抓出 11 个缺陷,其中 8 个在本地定向子集全绿时不可见。** 形状高度一致:修补落在控制流或本机状态到不了的地方。 - 兼容回落读的是**本 PR 让 mcpp 不再写**的文件 —— 每台老机器都还留着它,所以本机永远绿 - 归属谓词加在 `probe_sysroot` **第一步就 return 掉**的函数里 - `--no-cache` 删 `target/`,带走了我放进去的 spec;e2e 201 只跑暖构建,**那个专为此机制存在的测试看不见它** - 我给 201 加的 PayloadFirst 分支,**把修复拿掉后照样通过** —— 本机 gcc 的 baked sysroot 指向另一个仓库且存在,把它拉回了 Sysroot 模式。守卫因此改成**指名模式**的单测,并逐一验证过红→绿 - 判据本身也错过:**「跑不跑得起来」在任何有宿主工具链的机器上都是 yes**,哪怕产物正在偷用宿主的库。换成「是否从 `/lib`、`/usr/lib` 加载任何库」 - 最后一条是**误报**:`ldd` 用宿主 loader 内省,总把 payload 解释器报成解析到宿主 —— 一个会拦下正确工作的检查,代价一样大 以及一个低级错误:`mcpp.toml` 的 `[toolchain] default` 被本地试验写成 llvm,`git add -A` 一起提交,五个 job 连带失败。**多个 job 同时开始失败,几乎总是指向一处共同输入被改动。** --- ...piler-as-capability-implementation-plan.md | 148 +++++++ ...yload-version-and-contract-drift-design.md | 379 ++++++++++++++++++ .github/actions/bootstrap-mcpp/action.yml | 2 +- .github/actions/setup-macos-llvm/action.yml | 2 +- .github/workflows/bootstrap-macos.yml | 2 +- .github/workflows/ci-fresh-install.yml | 6 +- .github/workflows/ci-linux-e2e.yml | 12 +- .github/workflows/cross-build-test.yml | 4 +- .github/workflows/release.yml | 14 +- CHANGELOG.md | 65 +++ docs/05-mcpp-toml.md | 11 + docs/08-toolchain-internals.md | 97 ++++- docs/zh/05-mcpp-toml.md | 7 + docs/zh/08-toolchain-internals.md | 70 +++- mcpp.toml | 2 +- src/build/execute.cppm | 41 +- src/build/flags.cppm | 30 +- src/build/plan.cppm | 5 + src/build/prepare.cppm | 112 +++++- src/config.cppm | 3 +- src/doctor.cppm | 53 +++ src/fallback/probe_sysroot.cppm | 81 +++- src/fallback/xlings_binary.cppm | 145 ++++++- src/toolchain/detect.cppm | 9 +- src/toolchain/fingerprint.cppm | 14 +- src/toolchain/linkmodel.cppm | 89 +++- src/toolchain/model.cppm | 14 + src/toolchain/post_install.cppm | 339 +++++++++++++--- src/toolchain/probe.cppm | 120 +++++- src/version.cppm | 2 +- src/xlings.cppm | 13 +- src/xlings/subos_info.cppm | 53 ++- tests/e2e/200_subos_env_reaches_program.sh | 37 +- tests/e2e/201_gcc_no_specs_pollution.sh | 173 ++++++++ tests/e2e/29_toolchain_partial_versions.sh | 23 +- tests/unit/test_fingerprint.cpp | 38 ++ tests/unit/test_link_model_runtime_dirs.cpp | 122 ++++++ tests/unit/test_linkmodel.cpp | 32 +- tests/unit/test_runtime_binding_fallback.cpp | 132 ++++++ tests/unit/test_subos_info.cpp | 106 ++++- tests/unit/test_sysroot_ownership.cpp | 87 ++++ tests/unit/test_toolchain_probe.cpp | 121 ++++++ tests/unit/test_xlings_version_pin.cpp | 66 +++ 43 files changed, 2691 insertions(+), 190 deletions(-) create mode 100644 .agents/docs/2026-08-08-compiler-as-capability-implementation-plan.md create mode 100644 .agents/docs/2026-08-08-payload-version-and-contract-drift-design.md create mode 100755 tests/e2e/201_gcc_no_specs_pollution.sh create mode 100644 tests/unit/test_link_model_runtime_dirs.cpp create mode 100644 tests/unit/test_runtime_binding_fallback.cpp create mode 100644 tests/unit/test_sysroot_ownership.cpp create mode 100644 tests/unit/test_toolchain_probe.cpp create mode 100644 tests/unit/test_xlings_version_pin.cpp diff --git a/.agents/docs/2026-08-08-compiler-as-capability-implementation-plan.md b/.agents/docs/2026-08-08-compiler-as-capability-implementation-plan.md new file mode 100644 index 00000000..ac50799c --- /dev/null +++ b/.agents/docs/2026-08-08-compiler-as-capability-implementation-plan.md @@ -0,0 +1,148 @@ +# 编译器是能力 —— 跨仓实施计划 + +> **For agentic workers:** 逐 task 执行,每个 task 自带 red→green→commit。**T1 是所有后续 task 的前提**(没有它,后面每一步的观测都不可信)。 + +**Goal:** 让 mcpp 停止依赖编译器的安装期配置,把「用哪份 libc」收敛成一个权威;顺带把已经污染的产物清干净,并让多 subos 构建真正可用。 + +**设计文档:** +- `.agents/docs/2026-08-07-xlings-as-runtime-substrate-design.md`(运行时底座;S1/S3 与撤销 `c_runtime` 的理由) +- `.agents/docs/2026-08-08-payload-version-and-contract-drift-design.md`(本计划的主体) + +**Tech Stack:** C++23 modules,gtest(`tests/unit/`),shell e2e(`tests/e2e/`),Lua(mcpp-index)。 + +## Global Constraints + +- **不改 payload**。`patchelf_walk`(让编译器自己能跑)保留;**写 payload 内配置的一律删掉**。 +- **权威唯一**:`--runtime` → `[xlings] subos` 的 `subos_info.runtime` → **报错,不猜**。删掉「扫目录挑一个」。 +- **平台差异下沉** `src/platform/`,`build/` 里不写 `#ifdef`。 +- **每一处「注释描述行为」必须有对应断言**(R-C)。本轮已发现三处注释与代码不符。 +- **跨仓 wire format 的 fixture 取自写入方**(R-A),不得手写。 +- **一条测试若为覆盖某路径而存在,必须先断言走了那条路径**(R-B)。 +- **不新增任何让产物链宿主 libc 的开关**(8-07 §5.2 已否)。 + +--- + +### T1: 三处「注释撒谎」先钉住 —— 后续观测的地基 + +**Files:** `tests/unit/test_linkmodel.cpp`(新断言)、`src/xlings.cppm`、`src/toolchain/post_install.cppm`(改注释) + +本轮发现三处注释描述了代码没有的行为。**先让它们不能再撒谎**,否则后面每一步都可能在错误的前提上推理。 + +- [ ] **Step 1** 写失败单测:`find_sibling_tool` 的注释说「first (highest)」,断言**两个版本目录时返回的是排序后最高的那个** +- [ ] **Step 2** 跑,确认 FAIL(今天返回 readdir 第一个) +- [ ] **Step 3** 二选一并使注释与代码一致:①真的排序;②改注释为「未定义顺序」并让调用方不得依赖。**本计划选 ②**——T2 会让它不再被用于选版本 +- [ ] **Step 4** 另两处:`fixup_gcc_specs` 的「Idempotent」(跨 home 不幂等)、`execute.cppm` reader 的「旧缓存视为 miss」(T5 修)——先把注释改成事实 +- [ ] **Step 5** Commit + +--- + +### T2: 权威降级链 —— 没有权威就不进 PayloadFirst + +**Files:** `src/toolchain/probe.cppm`、`src/xlings/subos_info.cppm`、`src/build/prepare.cppm` +**Test:** `tests/unit/test_toolchain_probe.cpp`(新) + +**Interfaces:** +```cpp +// probe 不再自己选版本;版本由权威给出 +std::optional probe_payload_paths( + const std::filesystem::path& compilerBin, + std::string_view runtimeBinding); // "glibc@2.39";空 ⇒ 不进 PayloadFirst +``` + +- [ ] **Step 1** 单测:给定 `glibc@2.39` 且 home 内有 2.39/2.44 两份 ⇒ **必须返回 2.39**;给定空 binding ⇒ **返回 nullopt**(不猜) +- [ ] **Step 2** 跑,FAIL +- [ ] **Step 3** 实现;`prepare.cppm` 按 `--runtime` → `[xlings] subos` 的 `subos_info.runtime` 顺序解析,末级不猜 +- [ ] **Step 4** 跑,PASS +- [ ] **Step 5** Commit + +--- + +### T3: fingerprint 加入 runtime 轴 —— 多 subos 的前置 + +**Files:** `src/toolchain/fingerprint.cppm`、`tests/unit/test_fingerprint.cpp` + +不加这条,切 subos 不改 fingerprint ⇒ `target//` 被复用 ⇒ 里面是对另一份 glibc 编译的对象。**比今天更坏。** + +- [ ] **Step 1** 单测:两个只有 `runtimeBinding` 不同的输入 ⇒ **fingerprint 必须不同** +- [ ] **Step 2/3/4** red → 加字段 → green +- [ ] **Step 5** Commit + +--- + +### T4: GCC 也显式发 loader/rpath + clean specs + +**Files:** `src/toolchain/linkmodel.cppm`、`src/build/flags.cppm`、`src/toolchain/post_install.cppm`(删 `fixup_gcc_specs`) +**Test:** `tests/unit/test_linkmodel.cpp`、`tests/e2e/201_gcc_clean_specs.sh`(新) + +**已实测**:`-Wl,--dynamic-linker` 压得过 specs;`-specs=<无 `+` 定义>` 是替换;`g++ -dumpspecs` 给内建 specs(`/tmp` 路径 0 条);真实构建(模块 + `import std` + 共享库)RUNPATH 68→2、死路径 0、可运行。 + +- [ ] **Step 1** e2e:gcc 构建的产物 **RUNPATH 中不得有 `/tmp/tmp.`**,且 interpreter 是权威指定的那份,且能跑 +- [ ] **Step 2** 跑,FAIL(今天有 34+ 条) +- [ ] **Step 3** ①去掉 `linkmodel` 的 `if (clangDriver)` 门;②构建期生成 clean specs 到 **build dir**,`-specs=` 传入;③**删掉 `fixup_gcc_specs`**(保留 `patchelf_walk`) +- [ ] **Step 4** 跑,PASS +- [ ] **Step 5** Commit + +--- + +### T5: 并回 #377 的两处修复 + +**Files:** `src/xlings/subos_info.cppm`、`src/build/execute.cppm`、`tests/unit/test_subos_info.cpp`、`tests/e2e/200_subos_env_reaches_program.sh` + +已在 `fix/fast-run-stale-cache-subos` 分支上完成并 18/18 CI 通过,**原样带过来**: + +- [ ] **Step 1** `git cherry-pick` 该分支的三个 commit(wire format / 旧缓存 / e2e fixture) +- [ ] **Step 2** 跑单测 + e2e,确认仍绿 +- [ ] **Step 3** Commit(或保留 cherry-pick 的原始提交) + +--- + +### T6: D4 沙箱 xlings 版本比对 + D5 陈旧 sysroot + +**Files:** `src/fallback/xlings_binary.cppm`、`src/doctor.cppm`、`src/fallback/probe_sysroot.cppm` + +- [ ] **Step 1** 单测/e2e:vendored xlings 版本**低于 pin** ⇒ 被替换;**高于 pin** ⇒ 不动 +- [ ] **Step 2** red +- [ ] **Step 3** 实现;doctor 增加两条 finding:①xlings 低于 pin;②`--sysroot` 指向当前项目之外 +- [ ] **Step 4** green +- [ ] **Step 5** Commit + +--- + +### T7: 文档 + 版本 + PR + +- [ ] `docs/03-toolchains.md`:权威降级链、`[xlings] subos`、多 subos 构建 +- [ ] `docs/05-mcpp-toml.md`:`[xlings] subos` 与 runtime 的关系 +- [ ] 中文版同步 +- [ ] `MCPP_VERSION` / `mcpp.toml` → `2026.8.8.2`;`kXlingsVersion` → 最新;`check_version_pins.sh` 通过 +- [ ] 全量 `mcpp test` + 关键 e2e +- [ ] 开 PR + +--- + +### T8: mcpp-index 图形栈重新落地(D1 修好之后) + +**Files(mcpp-index):** `pkgs/c/compat.glx-runtime.lua`、`pkgs/c/compat.glfw.lua` + +- [ ] **Step 1** 新增 e2e/验证:**装 `xim:graphics` 前后**,各跑一次与图形无关的成员,断言产物 `PT_INTERP` 与 `readelf -V` 的 glibc 符号上界**不变**。这是上次事故真正缺失的那个测试 +- [ ] **Step 2** 重新应用 `f44e896` 的两个文件改动(deps 放平台层) +- [ ] **Step 3** CI 全绿后合入 + +--- + +## Self-Review + +| 设计文档条目 | Task | +|---|---| +| 8-08 §3.1 原则(编译器当能力) | T4 | +| 8-08 §3.2 权威降级链 / D1 | T2 | +| 8-08 §3.5① 去掉 clangDriver 门 | T4 | +| 8-08 §3.5② clean specs 进 build dir | T4 | +| 8-08 §3.5③ 删 `fixup_gcc_specs` / D6 | T4 | +| 8-08 §3.6 多 subos + fingerprint 前置 | T2 + T3 | +| 8-08 D4 沙箱 xlings / D5 sysroot | T6 | +| 8-08 D2 wire format / D3 旧缓存 | T5 | +| 8-08 §4.2 R-A/R-B/R-C | T1(R-C)+ 各 task 的测试形态 | +| 8-07 §1.5 图形栈迁移 | T8 | +| 8-07 §3-S2 不加链宿主 libc 的开关 | Global Constraints(不做) | + +**未覆盖且是有意的**:8-07 §7「xlings 落盘 exports」—— 8-08 已说明它不再是关键路径(权威改为 `subos_info.runtime`)。Q6(交叉/musl/MinGW 下 `-specs=` 替换)在 T4 的 e2e 里只覆盖 native;**其余平台列为 PR 中显式声明的未验项**,不假装验过。 diff --git a/.agents/docs/2026-08-08-payload-version-and-contract-drift-design.md b/.agents/docs/2026-08-08-payload-version-and-contract-drift-design.md new file mode 100644 index 00000000..0ebe4dcd --- /dev/null +++ b/.agents/docs/2026-08-08-payload-version-and-contract-drift-design.md @@ -0,0 +1,379 @@ +# 载荷版本与契约漂移:四个缺陷,一条线 + +**日期**:2026-08-08 +**性质**:设计提案,待 review。本文不改代码。 +**触发**:2026.8.8.1 发布当天暴露的四个缺陷,其中三个是我在这一轮自己引入或自己漏掉的。 + +**关联**: +- `.agents/docs/2026-08-07-xlings-as-runtime-substrate-design.md`(本轮的上游设计) +- mcpp#352 / #375、mcpp-index#179(已 revert,PR#180)、mcpp#377 +- xlings `.agents/docs/2026-08-06-subos-architecture-proposal.md` 的 R1–R7 + +--- + +## 0. TL;DR + +四个缺陷,**同一条线**: + +> **mcpp 对 xlings 的每一项事实,都是「自己猜一个」而不是「读一个」;而它猜的那个,和它烙进产物的那个,是两个独立的答案。** + +| # | 缺陷 | 谁引入 | 现状 | +|---|---|---|---| +| **D1** | 载荷探测取**目录序第一个**(注释说是最高,代码没排序),与 gcc 烙定的不是同一份 | 早已存在,本轮触发 | **main 曾变红,已 revert** | +| **D2** | subos_info **wire format 读错**,功能完全不工作 | 本轮我引入 | PR#377 已修 | +| **D3** | 升级后**复用升级前缓存**,修复静默不生效 | 本轮我引入 | PR#377 已修 | +| **D4** | 沙箱 xlings **永不升级**,且 mcpp 打印偏差却不作为 | 早已存在 | **未修** | + +D1 和 D4 是同一件事的两面:**mcpp 把一个可变的外部世界当成了装机时的快照**。D2 和 D3 是另一件事的两面:**我用与实现同源的理解去写测试,于是测试证明的是「我和我自己一致」。** + +--- + +## 1. D1:载荷探测与 gcc 烙定的版本是两个独立答案 + +### 1.1 实测 + +`compat.glx-runtime` 加了一条 `xim:graphics` 依赖(为修 #352)。`mesa` 声明: + +```lua +"xim:glibc@>=2.38", -- 下界,不是钉死 +``` + +解析器装了 **glibc 2.44**,与既有的 2.39 并存。然后: + +``` +.../xim-x-glibc/2.39/lib64/libc.so.6: version `GLIBC_2.42' not found + (required by <测试二进制>) +``` + +**在 `asio-module`、`core` 上炸 —— 与图形毫无关系的包。** + +### 1.2 机制 + +`probe.cppm` 调 `find_sibling_tool(compilerBin, "glibc")`。那个函数的注释说它取最高版本 —— **代码没有排序**: + +```cpp +// Return the first (highest) version dir that exists. +for (auto& v : std::filesystem::directory_iterator(root, ec)) { + if (v.is_directory(ec)) return v.path(); +} +``` + +`directory_iterator` 是无序的,所以真实行为是**取 readdir 碰巧给出的第一个**。本机实测顺序是 `2.44, 2.39`,于是选中 2.44 —— 但**换台机器、装个别的包、甚至删一个目录,结果都可能变**。这是本文档记录的第三处「注释描述了代码没有的行为」(另两处见 §3.3)。 + +于是: + +| | 谁决定 | 结果 | +|---|---|---| +| **编译/链接** 对着哪份 glibc | mcpp 的探测(readdir 第一个) | **2.44**(本机;换台机可能不同) | +| **运行时** PT_INTERP / specs | gcc 载荷装机时 elfpatch 烙定 | **2.39** | + +两个独立的答案,**在只装一份 glibc 时恒相同**,所以从未被迫达成一致 —— 这正是 xlings 侧 R1/R2 诊断出的 P1「一个问题多个回答者」,原样出现在 mcpp。 + +### 1.2b 为什么「新 glibc 向后兼容」救不了这一格 + +glibc 的兼容性是**单向**的:对 2.39 构建的产物在 2.44 上跑 ✅;对 2.44 构建的在 2.39 上跑 ❌。本次报错正是第二种 —— 加载了 2.39 的 `libc.so.6`,而二进制要 `GLIBC_2.42` 的符号。**问题不是运行时太老,是编译期跑到前面去了而运行期没跟上。** + +### 1.2c 参照系:发行版、Nix、和 mcpp 现在在哪 + +| | 发行版(Arch / Fedora) | Nix / xlings | **mcpp 今天** | +|---|---|---|---| +| 同时几份 glibc | **1** | 多份并存 | 多份并存 | +| 路径 | **无版本** `/lib64/ld-linux-x86-64.so.2` | 带版本 `…/2.44/lib64/…` | 带版本 | +| 升级 | **就地替换同一个文件** | 在旁边装一份新的 | 在旁边装一份新的 | +| 编译期与运行期的一致性 | **构造上不可能分歧** | 每个消费者冻结自己的选择 | **两个独立决定者** ❌ | + +滚动发行版之所以能一直换 glibc 而不炸,是因为**编译侧和运行侧是同一个文件**;新 glibc 保留全部旧符号版本,所以既有二进制照跑。 + +**Arch 上唯一会出这个错的场景,与本次同型:部分升级**(`pacman -Sy pkg` 不带 `-u`)—— 装了一个对着更新 glibc 构建的包,而 glibc 没跟着升。Arch 明文禁止部分升级,理由一模一样。 + +**结论:mcpp 用了 Nix 的布局(多版本并存 + 版本化路径),但没有兑现那个布局的前提 —— 每个消费者的选择必须只有一个决定者。** 这不是「要不要支持多 glibc」的问题,而是既有布局的前提没被满足。 + +### 1.2d xlings 早就预见了,而一条下界绕开了它 + +`glibc.lua` 明写 `latest` 故意停在 2.39: + +> Moving `latest` to 2.44 would be safe for the same reason — but it would also **silently change which glibc every existing home resolves to**, and that is a decision to make deliberately rather than as a side effect of adding a version. + +而 `mesa` 的 `xim:glibc@>=2.38` **绕开了 `latest` 指针**,把 2.44 直接拉了进来。一个刻意做出的「不动 latest」的决定,被一条依赖的下界从侧面推翻。 + +**这一条值得同步给 xlings**:范围解析取最高满足者,与 `latest` 别名承载的「这个 home 该用哪一份」是两个不同的意图,而前者会静默压过后者。是否该让范围解析在 `latest` 满足区间时优先取 `latest`,是 xlings 侧的开放问题。 + +### 1.3 判据:它不是「装了新 glibc」的问题 + +**任何**让 home 里多出一份更高版本 glibc 的操作都会触发它 —— 一条新依赖、一次 `xlings install`、一个新包。**这个雷一直在,只是这次我踩了。** + +### 1.4 方案 + +| | A. 探测改为「与 gcc 烙定的那份一致」 | B. 探测取最高但校验一致性,不一致就报错 | C. 维持现状 | +|---|---|---|---| +| 编译/运行是否可能分裂 | **不可能** | 可能,但会被拦下 | 会,且静默 | +| 需要什么输入 | gcc 烙定的 glibc 版本 | 同左 | — | +| 多 glibc 场景 | 天然正确(跟工具链走) | 需要人工介入 | 坏 | +| 结果是否确定 | ✅ | ✅ | ❌ **随目录序变** | + +**推荐 A,B 作为过渡期的护栏。** + +「gcc 烙定的是哪份」不需要猜:`post_install.cppm` 已经有 `extract baked loader from clang cfg` 的能力(扫 `/ld-linux-`),而 gcc 侧的 specs 里也写着。**更好的做法是让 xlings 落盘**(见 §5),但即使不落盘,从 gcc 自己的产物反推也比「取最高」正确 —— 因为它反推的正是运行期会用的那一份。 + +**必须同时做的一件事**:`mcpp doctor` 增加一条 —— *home 里有多份 glibc 且编译期选中的不是 gcc 烙定的那份*。这是 D1 唯一的可观测入口;没有它,下一次触发同样只会表现为一个与 glibc 无关的包炸掉。 + +--- + +## 2. D4:沙箱 xlings 永不升级,而 mcpp 知道却不说 + +### 2.1 实测 + +``` +$ mcpp self env +xlings binary = /home/speak/.mcpp/registry/bin/xlings ← 2026.8.2.1 +xlings pinned = 2026.8.6.3 +``` + +**mcpp 把两个数字并排打印出来,不作任何评价。** + +`fallback/xlings_binary.cppm:22`: + +```cpp +if (std::filesystem::exists(destBin)) return destBin; // 无任何版本检查 +``` + +### 2.2 两种布局,只有一种会更新 + +| 布局 | xlings 从哪来 | 升级 mcpp 时 | +|---|---|---| +| **tarball 相对**(`MCPP_HOME=<解包目录>/registry`) | release 自带的 `registry/bin/xlings` | ✅ 随之更新 | +| **默认 home**(`MCPP_HOME=~/.mcpp`) | 首次 `self init` 时 acquire 的那份 | ❌ **永不更新** | + +CI 走第一种(所以 CI 一直是新的),开发机与普通用户走第二种。**「我记得已经修过」和「实测是旧的」都对,只是说的不是同一种布局。** + +### 2.3 后果:整条修复链断在这里 + +`#352` 需要四环:mcpp 读 `subos_info` → index 拉 `xim:graphics` → **graphics 把声明写进 subos** → subos 有 `subos_info`。第三环需要沙箱 xlings ≥ 2026.8.5.1;`graphics.lua` 显式探测 `type(subos.env) ~= "function"`,**老客户端上声明被静默丢弃**。 + +实测本机沙箱 xlings 2026.8.2.1 的二进制里,`subos_info` 这个字符串出现 **0 次**。 + +### 2.4 方案 + +| | A. 版本低于 pin 就自动替换 | B. doctor 报告 + 一条显式命令 | C. 每次启动检查 | +|---|---|---|---| +| 无感 | ✅ | ❌ 用户要动手 | ✅ | +| 风险 | 换掉用户正在用的 xlings | 低 | 每次 exec 多一次版本读 | +| 与「mcpp 不接管 subos 状态」的边界 | ⚠️ 擦边 | 一致 | ⚠️ | + +**推荐 A + B**:acquire 时比对版本,低于 pin 则替换(这是 mcpp 自己 vendored 的那份,不是用户的系统 xlings,替换它不越界);同时 doctor 把偏差列为一条 finding。 + +**A 的判据必须是「低于 pin」而不是「不等于 pin」** —— 用户手动放了一份更新的进去,不该被降级。 + +> **⚠️ 这条要小心**:替换沙箱 xlings 会改变依赖解析的行为。参考 `index-floor-must-degrade` 的教训——**发布数据不得让程序失效**。所以替换后必须能回退,且 doctor 要能说出「现在用的是哪一份、为什么」。 + +--- + +## 3. D2/D3:测试证明的是「我和我自己一致」 + +### 3.1 D2 —— wire format 读错,功能完全不工作 + +盘上真实的 `envs` 是**以 binding 为键的对象**;我的 reader 期望**数组**。`is_array()` 为假 ⇒ 循环一次不执行 ⇒ 对着真实 subos **一个变量都不应用**。 + +**10 个单测 + 1 条 e2e 一路全绿**,因为每一份 fixture 都是我按同一个臆想手写的。 + +### 3.2 根因不是笔误,是方法 + +> **用与实现同源的理解去造 fixture,抓不出对 wire format 的误解。只有取自写入方的 fixture 能抓。** + +已加 `RealXlingsCapture`(真实输出逐字副本),parser 改为从 xlings 的 reader **誊写**而非建模 —— 这顺带抓到第二处分歧:xlings 丢弃 `op` 不是 `set`/`prepend` 的声明,我原来全收。 + +### 3.3 D3 —— 自证断言,以及注释撒的谎 + +我原本的 e2e 只跑一次 `mcpp run`,PASS —— 而它存在的目的是覆盖**缓存快路径**,那次根本没走。加上「这次必须走快路径,否则报错」之后,立刻暴露快路径丢环境变量,**同一条断言接着又抓到第二个**(缓存行读写顺序不匹配)。 + +更难看的是:reader 的注释**声称**它把旧缓存当 miss —— 那个检查从来没写过。**注释描述了代码没有的行为,比漏掉更糟:它让下一个读代码的人不去查。** + +### 3.4 提议:三条可执行规则 + +- **R-A 跨仓 wire format 的 fixture 必须取自写入方**,不得手写。形式:golden 文件 + 注明来源与采集时间。 +- **R-B 一条测试如果存在的目的是覆盖某条路径,必须先断言「这次确实走了那条路径」。** 否则它可能一直 PASS 而零覆盖。 +- **R-C 注释若描述一条行为,该行为必须有对应断言。** 「reader 把旧缓存当 miss」这句话本身就该是一个测试名。 + +--- + +## 4. 四个缺陷的公共上游 + +``` +D1 载荷版本 ── 猜(取最高) ─┐ +D4 xlings ── 快照(装机时那份) ─┼─→ 把可变的外部世界当成不变的 +D2 wire fmt ── 猜(臆想格式) ─┤ +D3 缓存契约 ── 猜(注释当实现) ─┘ +``` + +**D1/D4 是「外部世界会变,而我记的是装机那一刻」;D2/D3 是「外部契约有确定形状,而我记的是我以为的形状」。** + +两者的解药是同一句话:**凡是别人拥有的事实,读它,并且用它自己的产物来验证你读对了。** + +--- + +## 5. 与上游设计的关系:这轮验证了什么、推翻了什么 + +`2026-08-07-xlings-as-runtime-substrate-design.md` 的核心论点是「mcpp 把 xlings 当目录约定而不是可查询的运行时」。**这一轮四个缺陷全部是那个论点的实例**,而且其中两个是我一边写着那份设计一边犯的 —— 这本身是最强的证据。 + +它也**修正**了那份设计的一处轻描淡写:§7 把「xlings 落盘 exports」排在 P1,理由是「A 降级路径足以工作」。**D1 证明那不成立** —— 没有权威落盘,mcpp 只能靠目录扫描猜一个,而猜出来的那个与 gcc 烙定的那个是两个独立答案。**`exports` 落盘应升到 P0**,且内容要包含「gcc 这份载荷烙定的 glibc 是哪一份」。 + +--- + +## 6. 分期 + +**P0(main 已红过,先止血)** +1. ✅ revert mcpp-index#179(PR#180) +2. ✅ PR#377:wire format + 旧缓存(D2/D3) +3. **D1 护栏**:`mcpp doctor` 报告「多份 glibc 且编译期选中的不是 gcc 烙定的那份」 + +**P1(让 #352 真正到达用户)** +4. **D4**:acquire 时版本比对 + 替换,doctor 报告偏差 +5. **D1 正解**:载荷探测改为跟随 gcc 烙定的版本 +6. 之后才重新落 mcpp-index 的图形栈迁移 + +**P2** +7. xlings 侧落盘 `exports`(含 gcc↔glibc 的绑定),mcpp 改读它 +8. R-A/R-B/R-C 写进贡献规范 + +--- + +## 7. 明确不做 + +- **不在 recipe 里回避 D1**(比如把 mesa 的 glibc 下界改成钉死)。那是把引擎缺陷摊给包作者,而且下一个用下界的包会再犯 —— 与 `aarch64-native-gcc-payload-is-musl` 那次「归因成包缺 arch 轴是错的」同型。 +- **不让 mcpp 接管 subos 生命周期**。D4 的方案只替换 mcpp 自己 vendored 的那份二进制,不碰用户的系统 xlings、不管理 subos。 +- **不追 glibc 版本**。D1 的正解是「跟随工具链烙定的那一份」,不是「总用最新」。 + +--- + +## 8. 开放问题 + +- **Q1** D1 的「gcc 烙定的是哪份 glibc」:从 gcc 产物反推(specs / PT_INTERP),还是等 xlings 落盘?前者能立刻做,后者更正确 —— 是否两者都要,反推作为降级? +- **Q2** D4 的替换时机:`self init` 时?每次 acquire 时?还是只在 doctor --fix?自动替换与「用户手动放了一份新的」如何共存(判据「低于 pin 才换」是否足够)? +- **Q3** 多 glibc 是 xlings 明确要支持的方向(`subos new --runtime glibc@2.44`)。D1 修好之后,mcpp 对「一个 home 里多份 glibc」的正式立场是什么 —— 跟随工具链,还是跟随 subos 的 `runtime`?后者更符合上游设计的 S1,但需要 D4 先修。 +- **Q4** 本轮 revert 掉的图形栈迁移,重新落地时如何验证「装这个栈不会影响 home 的其余部分」?**这一条是这次事故真正缺失的那个测试。** 一个可执行形状:装栈前后各跑一次与图形无关的成员(`asio-module`、`core`),断言产物的 `PT_INTERP` 与 `readelf -V` 的 glibc 符号版本上界不变。 +- **Q5**(xlings 侧,§1.2d)范围解析取最高满足者,压过了 `latest` 别名承载的「这个 home 该用哪一份」。是否该让 `latest` 在满足区间时优先?这决定了「加一个新版本进索引」是不是一个安全操作。 + +--- + +## 9. 实施回写(2026-08-08,PR #378) + +方案落地过程中,有三条是设计当时没看见的,其中第一条**修正了本方案自己的一个假设**。 + +### 9.1 兼容回落读的是「我刚删掉的机制的产物」 + +§3.5 写的兼容路径是:subos 不自述时,读工具链自身烙入的值 —— gcc 的 `specs`、 +clang 的 `.cfg`。这在**已有机器**上成立,因为那些文件是**旧 fixup 写出来的**,还留在 +盘上。 + +在**全新安装**的机器上不成立:mcpp 已经不写它们了,所以它们不存在。链条是 +`无 binding → 无 payloadPaths → 无 --dynamic-linker → 产物拿宿主 loader`,hermetic +检查如实报出 `/lib64/ld-linux-x86-64.so.2 (outside the sandbox)`。 + +**每台开发机都还留着那些文件,所以本机验证永远是绿的,CI 才红。** 这条应当写进判据: + +> 删掉一个机制时,先列出谁在读它的产物。兼容回落尤其危险 —— 它读的往往正是旧机制 +> 留下的东西。 + +补的第三个来源是**编译器自身的 `PT_INTERP`**:由 patchelf 走查写入(该机制仍在运行), +指向的正是这套工具链对齐的那个 glibc payload,与 specs 曾经持有的是同一个事实,但 +**不依赖 mcpp 写过任何东西**。自己读 ELF 头而不用 `patchelf --print-interpreter`—— +本函数在 prepare 期运行,那里不保证 patchelf 已解析。 + +验证方式是把 `specs` 文件真的挪开再跑,而不是读代码。 + +### 9.2 归属谓词加在了一个到不了的函数里 + +§3.5 的 D5 把 `remap_xlings_baked_sysroot` 的判据从「存在」改为「归属」。但调用方 +`probe_sysroot` 的**第一步**是「存在且可用 ⇒ 直接 return」,只有在路径**不存在**时才 +往下走到 remap —— 而该函数存在的全部意义,正是处理「存在、可用、却属于别人」。 + +本机实测:这个仓库的 gcc 报的 sysroot 指向 `xim-pkgindex-fromsource` 下的 +`.xlings/subos/default`,每次构建都在吃另一个仓库的头文件。修法是把归属问在可用性 +**之前**;「外来但可用」保留为最后兜底,因为没有 registry subos 可映射的机器上,取 +「什么都不要」会直接坏掉。 + +### 9.3 生成的 spec 放在了缓存能删的目录里 + +`--no-cache` 会 `remove_all(outputDir)`,而 spec 是 prepare 期写进那个目录的 ⇒ **每次 +冷构建 + gcc 必挂**。修法不是调顺序,而是把「ninja 跑之前该文件必须存在」当作**不变量 +在消费点持有** —— 这样也扛得住用户手动 `rm -rf target`。 + +值得记的是它**怎么被发现的**:e2e 201 是专为这套机制写的测试,但它只跑暖构建,而暖 +构建下文件从上一次留着。**是全量本地 e2e 扫出来的,不是定向子集。** + +### 9.4 链接行去重 + +C-runtime 的那组 flags 同时经 `link_toolchain_flags` 与 `payload_ld` 发出,整组在链接行 +上出现两次。对正确性无害,但链接行有 128KiB 硬上限(`MAX_ARG_STRLEN`),真实 +workspace 已经用掉 43%。`payload_ld` 现在只服务 clang-with-cfg 的 PayloadFirst,其余组合 +本就已经有了。 + +### 9.5 一条跨平台低级错误 + +`rel.native().rfind("..", 0)` 在 Windows 上**编译不过**(`native()` 是 `wstring`),所有 +Windows job 直接挂;而且它在能编译的地方语义也错 —— 名叫 `..cache` 的目录并不是逃逸。 +包含关系是**路径分量**的问题,`path_is_under` 按分量问。 + +### 9.6 「拒绝」的边界:一个 payload 是答案,两个才是问题 + +§3.2 把判据写成「没有权威就拒绝走 payload-first」。落地后发现这条**收得过紧**。 + +所有兼容来源(gcc 的 `specs`、clang 的 `.cfg`、编译器自身的 `PT_INTERP`)都是**某个更早 +的机制写出来的东西**,而一台机器可以合法地一个都没有。这时按 §3.2 的字面判据就该拒绝, +产物于是拿宿主 loader —— 比「猜错版本」更糟,因为它连沙箱都出去了。 + +真正的轴不是「mcpp 能不能去看 payload 目录」,而是**有没有得选**: + +- **恰好一个 glibc payload** ⇒ 没有选择可言。它是这套工具链产出的任何产物唯一可能绑定的 + 运行时,拒绝等于拒答一个只有一个答案的问题。 +- **两个或以上** ⇒ 沉默。这正是事故本身的形状,必须由 subos 来定。 + +这与被移除的旧规则的区别是决定性的:旧规则在**有得选**的时候按 `readdir` 顺序选,而且 +一直到装进第二个 payload 之前都看起来是对的。 + +另外,记录下来的 loader 路径可能指向 subos **视图**(`/subos/default/lib/ld-linux-…`) +而不是 payload —— 视图路径里根本没有版本段。解析前先 canonical 化(R6:产物绑 payload, +不绑可变视图)。 + +验证方式:把 gcc 的 `specs` 挪开**并且**把 gcc 自身的 `PT_INTERP` 改指宿主 loader,即三条 +兼容来源全部失效,binding 仍解析得出,产物仍拿 payload loader。 + +### 9.7 兼容来源是**记录**,不是权威 + +§9.6 补上单例回落后 CI 依然红,日志直说了原因: + +``` +probe: runtime 'glibc@2.39' is not installed in this home +``` + +那台机器上只有一个 glibc payload:**2.44**;而兼容记录烙的是 **2.39**。 + +三条兼容来源(`specs`、`.cfg`、`PT_INTERP`)都是**过去某个时刻的记录** —— 装机时写的、 +fixup 时写的 —— 而 payload 被替换(升级、回收)时**没有任何东西回头去改它们**。§3.2 的 +「精确匹配、绝不回落」于是如实地拒绝了 2.39,结果是没有 payloadPaths、没有 +`--dynamic-linker`、产物拿宿主 loader。 + +**判据要补一句:一条记录只有在它指的东西还在,才算数。** + +现在每条记录都对「实际安装了哪些 payload」核对一遍: + +- 记录指的 payload **还在** ⇒ 采信(即使装了多个,它就是产物会加载的那个) +- 记录指的 payload **不在了** ⇒ 跳过,继续往下(单例回落往往能答) +- 什么都答不上来 ⇒ 沉默 + +这条与 §9.6 是同一件事的两面:**§9.6 说「没得选就不算猜」,§9.7 说「化石不算权威」。** +两条都是在收窄「拒绝」的适用范围 —— 拒绝该留给**真正有歧义**的情况。 + +### 9.8 musl 目标不碰 glibc payload + +`sysroot_mode` 里补 loader / lib 目录时漏了 musl 护栏。musl 的 sysroot 是自包含的,而 +同时被探测到的 glibc payload 属于**宿主**工具链 —— 把它放进链接路径后,ld 会把 glibc 的 +静态 `libc.a` 拉进一次 musl 链接: + +``` +undefined reference to `_DYNAMIC' (dl-reloc-static-pie.o) +hidden symbol `_DYNAMIC' isn't defined +``` + +同一函数里紧接着的 header 分支**本来就有** `is_musl_target` 判断 —— 那句话对库和 loader +一样成立,只是当时没写上。由 e2e 103 抓到,同样是全量跑扫出来的。 diff --git a/.github/actions/bootstrap-mcpp/action.yml b/.github/actions/bootstrap-mcpp/action.yml index 813d3210..2e171056 100644 --- a/.github/actions/bootstrap-mcpp/action.yml +++ b/.github/actions/bootstrap-mcpp/action.yml @@ -25,7 +25,7 @@ inputs: # `package.name`, so one of the two was simply unreachable — and which one # depended on the machine, which is why CI failed on `compat:lua` on # Windows and `mcpplibs.capi:lua` on Linux. Never pin below that. - default: '2026.8.6.3' + default: '2026.8.7.1' cache-target: description: also restore/save target/ (build artifacts + BMIs) required: false diff --git a/.github/actions/setup-macos-llvm/action.yml b/.github/actions/setup-macos-llvm/action.yml index 682d72bc..d00ad2e5 100644 --- a/.github/actions/setup-macos-llvm/action.yml +++ b/.github/actions/setup-macos-llvm/action.yml @@ -15,7 +15,7 @@ inputs: # Floor imposed by the index, not a routine bump — see # .github/actions/bootstrap-mcpp/action.yml for why 0.4.69 is required # (two packages named `lua` in one repo need openxlings/xlings#381). - default: '2026.8.6.3' + default: '2026.8.7.1' runs: using: composite diff --git a/.github/workflows/bootstrap-macos.yml b/.github/workflows/bootstrap-macos.yml index e2e7ba29..f607854d 100644 --- a/.github/workflows/bootstrap-macos.yml +++ b/.github/workflows/bootstrap-macos.yml @@ -17,7 +17,7 @@ jobs: # Dormant (workflow_dispatch only), but kept in step with the rest — # check_version_pins.sh holds it there. Floor: 0.4.69, below which the # index cannot resolve two packages that share a short name. - XLINGS_VERSION: '2026.8.6.3' + XLINGS_VERSION: '2026.8.7.1' steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/ci-fresh-install.yml b/.github/workflows/ci-fresh-install.yml index 9c53eeba..503c8695 100644 --- a/.github/workflows/ci-fresh-install.yml +++ b/.github/workflows/ci-fresh-install.yml @@ -152,7 +152,7 @@ jobs: env: XLINGS_NON_INTERACTIVE: '1' run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.6.3 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.7.1 echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - name: Install mcpp and config mirror @@ -292,7 +292,7 @@ jobs: - name: Install xlings + mcpp run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.6.3 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.7.1 # Deliberately NOT writing to $GITHUB_PATH here. On container # images that declare no PATH in their config (opensuse/ # tumbleweed), appending a single dir to GITHUB_PATH makes the @@ -363,7 +363,7 @@ jobs: # (older ones carry minos=15 and refuse to start). # v0.4.51+: in-process sha256 — this image has no sha256sum # binary, so pinned fetches failed before it. - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.6.3 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.7.1 echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - name: Install mcpp and config mirror diff --git a/.github/workflows/ci-linux-e2e.yml b/.github/workflows/ci-linux-e2e.yml index 790dbfd7..01996456 100644 --- a/.github/workflows/ci-linux-e2e.yml +++ b/.github/workflows/ci-linux-e2e.yml @@ -78,6 +78,16 @@ jobs: "$MCPP" self config # Pin the global default so test 28 (default-toolchain path) gets a # deterministic GNU answer instead of an auto-install pick. + # + # Installed explicitly, not assumed. `toolchain default` requires the + # toolchain to be present, and nothing in this job puts it there: + # building mcpp above uses whatever first-run picks (llvm). It worked + # only because the sandbox cache happened to carry a gcc some earlier + # run had installed -- so the moment the cache key changed (an xlings + # pin bump moves the whole prefix), every e2e shard failed with + # `gcc@16.1.0 is not installed` before running a single test. + # Warm runs re-install nothing; this costs a lookup. + "$MCPP" toolchain install gcc 16.1.0 "$MCPP" toolchain default gcc@16.1.0 # Warm musl once so fresh-home e2e tests inherit the payload. "$MCPP" toolchain install gcc 16.1.0-musl @@ -123,7 +133,7 @@ jobs: - name: Bootstrap xlings + released mcpp run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.6.3 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.7.1 export PATH="$HOME/.xlings/subos/current/bin:$PATH" xlings update xlings install mcpp -y -g diff --git a/.github/workflows/cross-build-test.yml b/.github/workflows/cross-build-test.yml index b9f0d053..51bdf9fe 100644 --- a/.github/workflows/cross-build-test.yml +++ b/.github/workflows/cross-build-test.yml @@ -118,7 +118,7 @@ jobs: # release assets were uploaded in a broken state (records present, # blobs missing → 404 on GET); re-uploaded clean. The stale-INDEX # half is handled by the marker-clear below. - XLINGS_VERSION: '2026.8.6.3' + XLINGS_VERSION: '2026.8.7.1' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" curl -fsSL -o "/tmp/${tarball}" \ @@ -255,7 +255,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.6.3' + XLINGS_VERSION: '2026.8.7.1' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" curl -fsSL -o "/tmp/${tarball}" \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d8327669..b9be200b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,7 +96,7 @@ jobs: # Pin xlings to a known-good version. The upstream install # script always grabs `latest` (no version override), so we # download + self-install manually to avoid broken releases. - XLINGS_VERSION: '2026.8.6.3' + XLINGS_VERSION: '2026.8.7.1' run: | if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" @@ -288,7 +288,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.6.3' + XLINGS_VERSION: '2026.8.7.1' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" curl -fsSL -o "/tmp/${tarball}" \ @@ -358,11 +358,11 @@ jobs: # below are pinned to the same version as XLINGS_VERSION; they are # NOT interpolated from it, so check_version_pins.sh scans for them # explicitly (they were absent from the old lock-step comment). - XLA="xlings-2026.8.6.3-linux-aarch64.tar.gz" + XLA="xlings-2026.8.7.1-linux-aarch64.tar.gz" if curl -fsSL -o "/tmp/$XLA" \ - "https://github.com/openxlings/xlings/releases/download/v2026.8.6.3/$XLA"; then + "https://github.com/openxlings/xlings/releases/download/v2026.8.7.1/$XLA"; then tar -xzf "/tmp/$XLA" -C /tmp - XLBIN=$(find /tmp/xlings-2026.8.6.3-linux-aarch64 -path '*/bin/xlings' -type f | head -1) + XLBIN=$(find /tmp/xlings-2026.8.7.1-linux-aarch64 -path '*/bin/xlings' -type f | head -1) if [ -n "$XLBIN" ]; then mkdir -p "$STAGING/$WRAPPER/registry/bin" cp "$XLBIN" "$STAGING/$WRAPPER/registry/bin/xlings" @@ -440,7 +440,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.6.3' + XLINGS_VERSION: '2026.8.7.1' run: | if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then WORK=$(mktemp -d) @@ -622,7 +622,7 @@ jobs: shell: bash env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.6.3' + XLINGS_VERSION: '2026.8.7.1' run: | # Captured before the `cd` below, in POSIX form: this step never # returns to the workspace, and GITHUB_WORKSPACE is a backslash diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c262d43..2bfee02a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,71 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [2026.8.8.2] — 2026-08-08 + +编译器交付的是**能力**,不是配置。设计与实测证据见 +`.agents/docs/2026-08-08-payload-version-and-contract-drift-design.md`。 + +### 修复 + +- **产物加载哪个 glibc,现在有权威,不再靠目录顺序。** + + payload-first 的构建要链接到某个具体 glibc。旧规则是问目录"那个 glibc"、 + 取 `readdir` 的第一项——只装了一个时它永远对,所以从没有东西逼它对。一条带 + `xim:glibc@>=2.38` 的依赖就足以装进第二个:编译侧取了 2.44,而产物的 + interpreter(装机时冻结在 gcc specs 里)仍是 2.39,于是二进制引用 + `GLIBC_2.42` 符号却跑在没有它的运行时上,报错还落在与那条依赖无关的包上。 + + 现在由 subos 自述的 runtime 作答(`[xlings] subos` 可指定),没有答案就**拒绝** + 走 payload-first,而不是挑一个。该绑定计入工具链指纹(11 字段),两次只在 + runtime 上不同的构建不再共用缓存。旧 subos 回落到工具链自身烙入的值。 + +- **不再改写 GCC 的 `specs`,产物也不再带别人机器的死路径。** + + 旧的 specs 重写用单路径 needle 配双路径 replacement,每个跑过它的 home 都漏下 + 一条:一台开发机产出的每个 gcc 产物里都有 **68** 条陈旧 `RUNPATH`,全指向已删除 + 的 `mktemp` 目录。改为 `-specs=` 一份逐构建生成的干净文件(`-dumpspecs` 取内建 + `*link:`,去掉 loader/rpath),loader 与 rpath 由 mcpp 显式写在链接行上。逐构建、 + 不需写权限,继承来的只读 payload 也能用。e2e `201_gcc_no_specs_pollution.sh` + 断言的是产物而非 specs 文件。 + +- **落后于 pin 的 vendored xlings 会被替换——但只在替换品确实更新时。** + + `acquire_xlings_binary` 过去见到文件存在就返回,于是一个 home 会永远留着它第一次 + 获取的 xlings(实测 2026.8.2.1 对 pin 2026.8.6.3)。`subos_info` 是 2026.8.5.1 + 才有的,所以那台机器上 subos 的自述一直被一个太旧的客户端丢弃。修复的第一版直接 + 删了重取,结果把 2026.8.2.1 换成了系统的 0.4.51——更旧,且同样没有那个特性;现在 + 先给替换品定价再动手。 + +- **`--sysroot` 的判据从"存在"改为"归属"。** gcc 把 + `--sysroot=<...>/.xlings/subos/default` 当字符串烙进去,而一台机器上有很多同名 + 目录,所以那条烙入的路径经常存在、却属于另一个 checkout(实测:mcpp 里的构建解析 + 到了无关仓库下的 sysroot)。 + +### 其他 + +- `mcpp self doctor` 新增两条检查:vendored xlings 落后于 pin;sysroot 既不属于本 + mcpp home 也不属于当前项目。 +- xlings pin 提升到 2026.8.7.1。 + +## [2026.8.8.1] — 2026-08-07 + +xlings 作为运行时底座:subos 环境到达程序,以及 self-contained 的 +`/proc/self/exe` 陷阱(#352、#375)。设计见 +`.agents/docs/2026-08-07-xlings-as-runtime-substrate-design.md`。 + +### 修复 + +- **subos 声明的环境变量现在真的到达被运行的程序(#352)。** 之前 mcpp 读的是自己 + 想象出来的 `subos_info.envs` 线格式(数组),而 xlings 写的是以 binding 为键的 + 对象,于是整条修复是空转的。现在按 xlings 实际写出的格式解析,并用逐字取自真实 + xlings 输出的 fixture 钉住。 +- **升级后的 mcpp 会重放升级前的缓存,导致 subos 环境丢失。** 快路径命中时不带 + subos 环境;该缺陷是靠一条"必须走快路径"的自证断言抓到的,而那条断言随即又抓到 + 第二个:缓存行写在 `profile=` 之前却在 `cacheMode=` 之后解析。 +- **self-contained 打包的 `/proc/self/exe` 陷阱(#375)。** 新增 `MCPP_BUNDLE_DIR` + 契约。 + ## [2026.8.7.1] — 2026-08-07 两处「模型比生态少一层」。设计与实测证据见 `.agents/docs/2026-08-07-windows-resources-and-version-identity-design.md`。 diff --git a/docs/05-mcpp-toml.md b/docs/05-mcpp-toml.md index d72c7900..999ce66c 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -966,6 +966,17 @@ build needs (`make`/`cmake`/`protoc`/…), pin tool versions per project, or set build-time env vars — without hand-editing `.xlings.json`. `[toolchain]` (§2.7) remains the ergonomic shorthand for the compiler; `[xlings.workspace]` is the general form. +`subos` carries a second meaning on Linux: it selects **which C runtime the +build binds against**. A subos describes its own runtime (xlings 2026.8.5.1+), +and mcpp takes that as the authority rather than looking around for a libc — so +`subos = "el8"` and `subos = "trixie"` in two projects on one machine produce +artifacts targeting each one's glibc, with the compile side and the run side +guaranteed to agree. The binding is part of the toolchain fingerprint, so +switching it rebuilds rather than reusing the other subos' objects. A subos +older than that (or none at all) falls back to whatever the toolchain itself +was installed against; `mcpp build -v` prints which of the two happened. See +docs/08-toolchain-internals.md §2.1. + ### 2.14 Host tools from a dependency (mcpp 2026.8.5.1+) A package can build a binary its consumers need *at build time* — `protoc`, a diff --git a/docs/08-toolchain-internals.md b/docs/08-toolchain-internals.md index f4bdcb1b..6a2eb4d4 100644 --- a/docs/08-toolchain-internals.md +++ b/docs/08-toolchain-internals.md @@ -15,6 +15,9 @@ resolve payload (xim:gcc / xim:llvm / xim:musl-gcc xpkg under the sandbox) ▼ ensure_post_install_fixup() ← idempotent convergence (marker-gated) ▼ +resolve runtime binding ← which libc the artifact will load (§2.1) — an + answer, not a search + ▼ detect / probe ← triple, sysroot, payload paths (glibc, linux-headers) ▼ ToolchainLinkModel (single resolver for the C-library axis) @@ -65,12 +68,44 @@ is resolved/auto-installed via the xlings backend into the sandbox |---|---| | `targetTriple` | ` -dumpmachine` | | `sysroot` | `-print-sysroot` (validated: must actually carry libc headers), with a remap fallback for xlings-built GCC whose baked build-time path doesn't exist locally | -| `payloadPaths` | sibling xpkg discovery: glibc payload (`include/` + `lib64|lib/`) and linux-headers payload — the *payload-first* fine-grained sysroot | +| `payloadPaths` | the resolved runtime binding (§2.1) names the glibc payload exactly; linux-headers is still discovered as a sibling. No binding ⇒ no payload-first, by design | | runtime dirs | toolchain-private lib dirs for produced binaries' `-L`/`-rpath` | Note the probe deliberately does **not** mine the clang cfg for `--sysroot` anymore: the cfg is an output of this machinery, not an input (§5). +### 2.1 The runtime binding — which libc, decided once + +A payload-first build links against a specific glibc, and *which* one is a +fact about the environment, not something to be inferred. mcpp resolves it in +order: + +1. `[xlings] subos = ""` — the named subos, a sibling of the active one, + describes its own runtime in the `subos_info` block of its `.xlings.json` + (xlings 2026.8.5.1+). +2. The active subos, same block. +3. *Compatibility.* A subos created before that block existed cannot answer. + The value baked into the toolchain itself then stands in — gcc's specs, + clang's cfg. This is the value the artifact **would** load, so compile side + and run side still agree; it retires itself the moment the subos can speak. + +No binding is a **refusal**, not a default: `CLibMode::PayloadFirst` is +declined rather than picking a libc. + +The rule this replaced asked a directory for "the glibc" and took the first +entry `readdir` yielded. With one glibc installed that is always right, so +nothing forced it to be correct — and a dependency carrying `xim:glibc@>=2.38` +is enough to install a second one. When that happened in mcpp-index, the +compile side took 2.44 while the artifact's interpreter, frozen in gcc's specs +at install time, still named 2.39; binaries referenced `GLIBC_2.42` symbols +against a runtime without them, and the failures surfaced on packages +unrelated to the dependency that pulled the second glibc in. Directory order +is not a decision procedure. + +Because the binding decides what the artifact loads, it is part of the +toolchain fingerprint (11 fields, not 10) — two builds differing only in +runtime must not share a cache entry. + ## 3. The link model (`src/toolchain/linkmodel.cppm`) `ToolchainLinkModel` answers exactly one question — *how do we compile and @@ -86,6 +121,10 @@ CLibMode::PayloadFirst glibc/linux-headers xpkgs found (the normal bundled-LLV -L [+ -rpath + --dynamic-linker for clang] CLibMode::Sysroot a usable --sysroot (GCC include-fixed world, self-contained musl sysroots, the macOS SDK) + link: --sysroot, plus --dynamic-linker and -L/-rpath + for the payload when one is known — the + sysroot says where headers live, not which + loader runs the result CLibMode::None nothing usable — host defaults apply; the hermetic check (§6) rejects that leakage unless an explicit host-library exception is in effect @@ -110,12 +149,14 @@ itself as its first consumer; mcpp can then re-add a reader. ## 4. The unified post-install fixup pipeline (`src/toolchain/post_install.cppm`) -Sandbox payloads are prebuilt ELF trees. Two kinds of paths baked into them -are unknowable at packaging time and must be aligned to the *local* sandbox: -`PT_INTERP`/`RUNPATH` inside binaries, and the loader/rpath lines inside GCC -specs. `ensure_post_install_fixup(cfg, payloadRoot, pkg)` is the **single -entry** for that alignment, called from all three entry paths (explicit -install, default auto-install, manifest auto-install). +Sandbox payloads are prebuilt ELF trees whose baked `PT_INTERP`/`RUNPATH` are +unknowable at packaging time and must be aligned to the *local* sandbox. +`ensure_post_install_fixup(cfg, payloadRoot, pkg)` is the **single entry** for +that alignment, called from all three entry paths (explicit install, default +auto-install, manifest auto-install). + +> This pipeline used to rewrite GCC's `specs` file as well, so that produced +> binaries got a loader and an rpath. That is no longer done, and §5 is why. > Historical note: before 0.0.83 each path remembered — or forgot — its own > subset. The manifest path ran *nothing*, which is how a freshly @@ -146,7 +187,7 @@ mcpp's sight — trust-but-verify is the only reliable semantic. | kind | actions | |---|---| -| `gcc` (glibc) | patchelf walk over the gcc payload **and the shared binutils payload** (PT_INTERP → sandbox loader, RUNPATH → glibc+gcc lib dirs); specs rewrite (baked loader/rpath → payload glibc, specs-grammar-aware — `%{...}` conditionals must never be corrupted) | +| `gcc` (glibc) | patchelf walk over the gcc payload **and the shared binutils payload** (PT_INTERP → sandbox loader, RUNPATH → glibc+gcc lib dirs) — so that *gcc itself* runs. Nothing is written into `specs` | | `llvm` | patchelf walk over `lib/` only (runtime `.so` RUNPATH; `bin/` is left alone to preserve xlings-set RUNPATHs); deterministic cfg regeneration (§5) | | `musl-gcc` | nothing — self-contained sysroot, static world | @@ -162,8 +203,7 @@ mcpp's sight — trust-but-verify is the only reliable semantic. (symlink-inherited from another `MCPP_HOME`) are never patched — their owner already converged them, and patching through the symlink would brick the owner's toolchain. -- Specs rewriting is content-aware (already-aligned specs are skipped). - Extending the same check to the patchelf walk (compare +- Extending content-awareness to the patchelf walk (compare `--print-interpreter`/`--print-rpath` before writing, so an already-aligned payload converges with **zero writes**) is a known follow-up. - The long-term direction is for the *installer* (xlings) to own all @@ -171,11 +211,42 @@ mcpp's sight — trust-but-verify is the only reliable semantic. mcpp read-only + verification. The pipeline here is the compatibility layer until then, and the self-healing mechanism for drift either way. -## 5. The clang cfg: direct-invocation support only +## 5. The compiler is a capability, not a configuration + +A payload ships two separable things: the ability to compile, and an opinion +about how to link. mcpp wants the first and supplies the second itself — the +link line is where a build's decisions belong, because it is the thing that +varies per build. Both compilers now follow that rule; only the mechanism +differs. + +**clang** — `--no-default-config` on every mcpp invocation. + +**gcc** — `-specs=` with a generated file. ` -dumpspecs` prints the +**built-in** specs (unaffected by anything on disk); mcpp extracts the `*link:` +body, drops the loader and rpath lines from it, and writes the result to the +build directory. A `-specs=` file without a leading `+` *replaces* the rule it +names, so the payload's own opinion is overridden without the payload being +touched. Two consequences worth stating: it is per-build, so a second project +on the same machine is unaffected; and it needs no write access to the +toolchain, so an inherited or read-only payload works. + +Removing gcc's baked `*link:` also removes what it provided. mcpp therefore +supplies `--dynamic-linker` and every rpath entry explicitly on the link +line — the loader, the glibc lib dir, and gcc's own `lib64` (libgcc_s). Each +of those was found by removing the specs and watching what broke. + +Why not keep rewriting `specs`? Because the file is shared and the value is +per-build. The rewrite had a single-path needle and a two-path replacement, so +every home that ever ran it left one entry behind: **68** stale `RUNPATH` +entries, all naming deleted `mktemp` directories, in every gcc artifact one +developer machine produced. Nothing detected them, because a dead RUNPATH +entry costs only search time. e2e `201_gcc_no_specs_pollution.sh` asserts on +the artifact, not the specs file — what a user ships is what matters. + +### 5.1 The clang cfg `bin/clang++.cfg` exists so that direct invocations of the bundled -`clang++` (outside mcpp) get a working, hermetic compiler configuration. mcpp's own builds never read it -(`--no-default-config` always). The fixup pipeline **regenerates** it +`clang++` (outside mcpp) get a working, hermetic compiler configuration. The fixup pipeline **regenerates** it deterministically from the link model — same payload ⇒ byte-identical cfg on every machine and install path — rather than line-patching whatever an install produced. On Linux that means CRT discovery (`-B`), payload loader + diff --git a/docs/zh/05-mcpp-toml.md b/docs/zh/05-mcpp-toml.md index 971cf450..c8c109b0 100644 --- a/docs/zh/05-mcpp-toml.md +++ b/docs/zh/05-mcpp-toml.md @@ -724,6 +724,13 @@ OPENBLAS_NUM_THREADS = "1" host 工具(`make`/`cmake`/`protoc`…)、按项目固定工具版本、或设构建期环境变量——无需手改 `.xlings.json`。`[toolchain]`(§2.7)仍是编译器的便捷简写;`[xlings.workspace]` 是其通用形式。 +在 Linux 上 `subos` 还有第二重含义:它决定**构建绑定到哪个 C 运行时**。subos 会自述其 +runtime(xlings 2026.8.5.1 起),mcpp 以此为权威,而不是去四处找一个 libc——所以同一台机器 +上 `subos = "el8"` 与 `subos = "trixie"` 两个项目各自产出面向自己 glibc 的产物,且编译期与 +运行期保证一致。该绑定计入工具链指纹,故切换它会重新构建,而不会复用另一个 subos 的目标文件。 +更旧的 subos(或没有 subos)则回落到工具链自身安装时所对应的运行时;`mcpp build -v` 会打印 +实际走了哪一条。参见 docs/08-toolchain-internals.md §2.1。 + ### 2.14 依赖产出的 host 工具(mcpp 2026.8.5.1+) 一个包能构建出消费者在**构建期**需要的二进制 —— `protoc`、`grpc_cpp_plugin`、 diff --git a/docs/zh/08-toolchain-internals.md b/docs/zh/08-toolchain-internals.md index 4a61a326..b6b41b83 100644 --- a/docs/zh/08-toolchain-internals.md +++ b/docs/zh/08-toolchain-internals.md @@ -14,6 +14,8 @@ mcpp.toml [toolchain] / 全局默认 / `mcpp toolchain install` ▼ ensure_post_install_fixup() ← 幂等收敛(marker 闸门) ▼ +解析 runtime binding ← 产物将加载哪个 libc(§2.1)——是答案,不是搜索 + ▼ detect / probe ← triple、sysroot、payload 路径(glibc、linux-headers) ▼ ToolchainLinkModel(C 库轴的唯一解析器) @@ -56,9 +58,34 @@ xlings 后端解析/自动安装到沙箱 |---|---| | `targetTriple` | ` -dumpmachine` | | `sysroot` | `-print-sysroot`(校验必须真带 libc 头);xlings 构建的 GCC 烙的是构建机路径,有 remap 回退 | -| `payloadPaths` | 兄弟 xpkg 发现:glibc payload(`include/` + `lib64|lib/`)与 linux-headers payload——即 *payload 优先* 的细粒度 sysroot | +| `payloadPaths` | 由解析出的 runtime binding(§2.1)**精确指名** glibc payload;linux-headers 仍按兄弟 xpkg 发现。没有 binding 就不走 payload-first——这是设计,不是缺陷 | | 运行库目录 | 工具链私有 lib 目录,用于产物的 `-L`/`-rpath` | +### 2.1 runtime binding:绑哪个 libc,只决定一次 + +payload-first 的构建会链接到某个具体的 glibc,而**是哪一个**是关于环境的事实, +不该靠推断。mcpp 按顺序解析: + +1. `[xlings] subos = ""` —— 该 subos(活动 subos 的兄弟)在自己 + `.xlings.json` 的 `subos_info` 块里自述 runtime(xlings 2026.8.5.1 起)。 +2. 活动 subos,同一个块。 +3. *兼容路径。* 在该块出现之前创建的 subos 无法作答,此时以工具链自身烙入的值 + 顶上——gcc 的 specs、clang 的 cfg。这正是产物**将会**加载的那个值,故编译期 + 与运行期仍然一致;一旦 subos 能作答,这条自动退场。 + +没有 binding 是**拒绝**而不是取默认值:`CLibMode::PayloadFirst` 会被放弃,而不是 +去挑一个 libc。 + +被它取代的旧规则是:向某个目录问"那个 glibc",然后拿 `readdir` 吐出的第一项。 +只装了一个 glibc 时它永远正确,所以从来没有东西逼它正确——而一条带 +`xim:glibc@>=2.38` 的依赖就足以装进第二个。这在 mcpp-index 上真实发生过:编译侧 +取了 2.44,而产物的 interpreter(装机时冻结在 gcc specs 里)仍指向 2.39;二进制 +引用了 `GLIBC_2.42` 符号,却跑在没有这些符号的运行时上,且报错落在与"拉进第二个 +glibc 的那条依赖"毫无关系的包上。目录顺序不是决策依据。 + +因为 binding 决定产物加载什么,它计入工具链指纹(11 个字段,不是 10 个)——只在 +runtime 上不同的两次构建绝不能共用同一条缓存。 + 注意:probe 已**不再**从 clang cfg 挖 `--sysroot`——cfg 是这套机制的输出, 不是输入(见 §5)。 @@ -95,12 +122,15 @@ CLibMode::None 无可用来源——落宿主默认;除非显式允许 ## 4. 统一 post-install fixup 管线(`src/toolchain/post_install.cppm`) -沙箱 payload 是预编译 ELF 树。有两类打包期不可能预知、必须对齐到**本机沙箱**的 -路径:二进制里的 `PT_INTERP`/`RUNPATH`,以及 GCC specs 里的 loader/rpath 行。 +沙箱 payload 是预编译 ELF 树,其烙入的 `PT_INTERP`/`RUNPATH` 在打包期不可能预知, +必须对齐到**本机沙箱**。 `ensure_post_install_fixup(cfg, payloadRoot, pkg)` 是这次对齐的**唯一入口**, 由三条入口路径(显式 install、默认 auto-install、manifest auto-install)共同 调用。 +> 本管线曾同时重写 GCC 的 `specs` 文件,以便产物拿到 loader 和 rpath。现已不再 +> 这样做,原因见 §5。 + > 历史注记:0.0.83 之前各路径各自记得——或忘记——自己那份 fixup。manifest > 路径什么都不跑:刚 auto-install 的 llvm 因此保留着陈旧、随装机环境漂移的 > cfg(issue #195);gcc 也曾因此产出找不到 `stdlib.h` 的沙箱。"用哪条命令装的" @@ -125,7 +155,7 @@ glibc、payload 从别的 home 继承而来)发生在 mcpp 视野之外,trust-bu | kind | 动作 | |---|---| -| `gcc`(glibc)| 对 gcc payload **及共享的 binutils payload** 做 patchelf 遍历(PT_INTERP → 沙箱 loader,RUNPATH → glibc+gcc lib);specs 重写(烙入的 loader/rpath → payload glibc,**必须感知 specs 语法**——`%{...}` 条件块绝不能被破坏)| +| `gcc`(glibc)| 对 gcc payload **及共享的 binutils payload** 做 patchelf 遍历(PT_INTERP → 沙箱 loader,RUNPATH → glibc+gcc lib)——目的是让 *gcc 自己* 能跑起来。不往 `specs` 里写任何东西 | | `llvm` | 只遍历 `lib/`(运行库 `.so` 的 RUNPATH;`bin/` 不碰,保留 xlings 设置的 RUNPATH);确定性再生 cfg(§5)| | `musl-gcc` | 无——自包含 sysroot,静态世界 | @@ -137,17 +167,43 @@ glibc、payload 从别的 home 继承而来)发生在 mcpp 视野之外,trust-bu `rename` 让新内容拿新 inode,活进程保有旧 inode。 - **所有权护栏。** 解析到本 home registry 之外的 payload(从别的 `MCPP_HOME` symlink 继承)一律不碰——属主已收敛过,隔着 symlink 改写会毁掉属主的工具链。 -- specs 重写是内容感知的(已对齐即跳过)。把同样的检查扩展到 patchelf 遍历 +- 把内容感知扩展到 patchelf 遍历 (写前比对 `--print-interpreter`/`--print-rpath`,已对齐的 payload **零写入** 收敛)是已知的后续项。 - 长期方向:全部写入由**安装器**(xlings)持有——装机时、以及 payload 进入 新 home 时——mcpp 退为只读 + 校验。本管线在那之前是兼容层,也是双向漂移的 自愈机制。 -## 5. clang cfg:仅服务直接调用场景 +## 5. 编译器是能力,不是配置 + +一个 payload 交付两样可分离的东西:编译的**能力**,以及关于如何链接的**主张**。 +mcpp 要前者,后者自己给——链接行才是构建决策该待的地方,因为它是逐次构建而变的 +那一个。现在两个编译器都遵循这条规则,只是机制不同。 + +**clang** —— mcpp 每次调用都带 `--no-default-config`。 + +**gcc** —— 用生成的文件走 `-specs=`。` -dumpspecs` 打印的是**内建** +specs(不受磁盘上任何文件影响);mcpp 从中取出 `*link:` 的正文,删掉其中的 loader +与 rpath 行,把结果写进构建目录。不带前导 `+` 的 `-specs=` 文件是**替换**它所命名的 +规则,于是 payload 自己的主张被覆盖,而 payload 本身分毫未动。两条推论值得写明: +它是逐构建的,所以同机器上的另一个项目不受影响;它不需要对工具链的写权限,所以 +继承来的、或只读的 payload 都能用。 + +删掉 gcc 烙入的 `*link:`,也就删掉了它提供的东西。因此 mcpp 在链接行上显式补齐 +`--dynamic-linker` 与每一条 rpath——loader、glibc lib 目录、以及 gcc 自己的 +`lib64`(libgcc_s)。这三条都是把 specs 拿掉后、看什么先坏掉而逐一找出来的。 + +为什么不继续重写 `specs`?因为那个文件是共享的,而值是逐构建的。旧的重写用的是 +单路径的 needle 配双路径的 replacement,于是每个跑过它的 home 都会漏下一条:一台 +开发机产出的**每一个** gcc 产物里都带着 **68** 条陈旧 `RUNPATH`,全部指向已被删除的 +`mktemp` 目录。没有任何东西发现它们,因为一条死 RUNPATH 只多花一点搜索时间。e2e +`201_gcc_no_specs_pollution.sh` 断言的是**产物**而不是 specs 文件——用户最终交付的 +是产物。 + +### 5.1 clang cfg `bin/clang++.cfg` 的职责是:直接调用打包内 `clang++`(不经由 mcpp)时, -获得可用且 hermetic 的编译器配置。mcpp 自己的构建从不读它(永远 `--no-default-config`)。 +获得可用且 hermetic 的编译器配置。 fixup 管线从链接模型**确定性再生**它——同一 payload ⇒ 任何机器、任何安装路径 产出字节一致的 cfg——而不是对装机产物做行级补丁。Linux 上内容为:CRT 发现 (`-B`)、payload loader + rpath、lld/compiler-rt/libunwind、C++ 驱动附加 diff --git a/mcpp.toml b/mcpp.toml index d2a6dbb6..60309c3d 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.8.8.1" +version = "2026.8.8.2" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/build/execute.cppm b/src/build/execute.cppm index b4cb5c1e..b572f967 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -18,6 +18,7 @@ import mcpp.build.ninja; import mcpp.bmi_cache; import mcpp.manifest; import mcpp.modgraph.scanner; +import mcpp.toolchain.post_install; import mcpp.toolchain.stdmod; import mcpp.xlings; import mcpp.xlings.subos_info; @@ -68,6 +69,11 @@ struct BuildCacheEntry { // property and would otherwise be unknowable on the fast path -- which // has no toolchain to derive it from. std::string subosDir; + // Was the line present at all? An EMPTY subosDir is a legitimate answer + // (a system toolchain outside the xpkgs store has no subos), so it cannot + // stand in for "this cache predates the field" -- and those two need + // opposite treatment: the first runs, the second must rebuild once. + bool subosRecorded = false; // The resolved profile this entry was built for. Entries used to be keyed // by target triple alone, and the fast paths only refuse to run when an // EXPLICIT --profile/--dev/--release is passed — so a bare `mcpp build` @@ -154,7 +160,8 @@ std::vector read_build_cache(const std::filesystem::path& proje // using the cache at all -- the program would work once and then // silently stop finding its runtime data. if (haveNextLine && line.starts_with("subos=")) { - e.subosDir = line.substr(6); + e.subosDir = line.substr(6); + e.subosRecorded = true; haveNextLine = static_cast(std::getline(f, line)); } // Optional profile line. Same back-compat contract as the two blocks @@ -201,7 +208,8 @@ void write_build_cache(const std::filesystem::path& projectRoot, // Insert at front (MRU). BuildCacheEntry newEntry{targetTriple, outputDir.string(), ninjaProgram, fingerprintHex, runtimeEnvKey, runtimeEnvValue, std::move(runTargets), - runEnvKey, runEnvValue, subosDir, profile, cacheMode}; + runEnvKey, runEnvValue, subosDir, /*subosRecorded=*/true, + profile, cacheMode}; entries.insert(entries.begin(), std::move(newEntry)); // Trim to LRU capacity. @@ -369,6 +377,23 @@ export int run_build_plan(BuildContext& ctx, bool verbose, bool no_cache, std::filesystem::remove_all(ctx.outputDir, ec); } + // The generated `*link:` spec lives in the output directory, and the line + // above is allowed to delete that directory. prepare wrote the file before + // this point, so a cold build reached ninja with a link command naming a + // spec that no longer existed -- `g++: fatal error: cannot read spec file`, + // on every `--no-cache` build with gcc. + // + // Regenerating here rather than reordering: the invariant worth holding is + // "the spec exists when ninja runs", and stating it as an invariant + // survives the next thing that clears target/ (a user with `rm -rf`, for + // one). The write is idempotent, so the warm path costs one stat. + if (!ctx.plan.gccCleanSpecs.empty()) { + std::error_code ec; + if (!std::filesystem::exists(ctx.plan.gccCleanSpecs, ec)) + ctx.plan.gccCleanSpecs = mcpp::toolchain::write_clean_link_specs( + ctx.tc.binaryPath, ctx.outputDir); + } + auto be = mcpp::build::make_ninja_backend(); // M5.0: print "Inferred" banner when defaults / target inference fired. @@ -774,6 +799,18 @@ std::optional try_fast_run(const std::filesystem::path& projectRoot, ninjaProgram = ninjaProgram.substr(1, ninjaProgram.size() - 2); if (match->runtimeEnvKey.empty()) return std::nullopt; // old cache entry; go through prepare_build once + // Written before this mcpp knew about subos environments (mcpp#352). Taking + // the fast path here would run the program without them -- which is the + // defect this field exists to fix, surviving an upgrade. + // + // It survives it for a long time, too: the fast path's identity is the + // profile, the cache mode and the resource list, and its fingerprint check + // compares a cached entry against ITSELF. Neither notices that a different + // mcpp wrote the entry, so without this line an upgraded mcpp would reuse a + // pre-upgrade build until something else happened to invalidate it. Measured + // on a real upgrade from 2026.8.7.1, not reasoned about. + if (!match->subosRecorded) + return std::nullopt; // predates `subos=`; rebuild once, then it is there // P1: verify fingerprint matches the outputDir basename. if (!match->fingerprint.empty()) { diff --git a/src/build/flags.cppm b/src/build/flags.cppm index de26950e..40fa3fbe 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -712,9 +712,37 @@ CompileFlags compute_flags(const BuildPlan& plan) { // host's /lib or, on hosts without a system toolchain, passes bare names // that lld cannot open — issue #195), -L/-rpath for -lc/-lm, and the // payload's dynamic linker. + // + // Only for clang-with-cfg, and only in PayloadFirst. Every other + // combination already has these flags: the gcc branch above assigns + // `link_toolchain_flags = lm.link_flags(...)` for any mode but None, and + // the clang branch adds them for Sysroot. Emitting them here as well put + // the whole C-runtime group on the line twice -- harmless to correctness, + // but the link line has a hard 128KiB ceiling (MAX_ARG_STRLEN) that real + // workspaces already spend 43% of. std::string payload_ld; - if (isClangWithCfg && lm.mode == mcpp::toolchain::CLibMode::PayloadFirst) + if (isClangWithCfg + && lm.mode == mcpp::toolchain::CLibMode::PayloadFirst) payload_ld = lm.link_flags(ninjaEsc); + // GCC: replace the payload's patched `*link:` with the pristine one, so + // its accumulated rpath entries do not reach the artifact. Must come + // BEFORE our own -Wl flags is not required (specs are processed by the + // driver, not positionally against -Wl), but keeping it adjacent to the + // payload flags keeps the C-runtime decisions in one place on the line. + // + // Quoted the way include_token quotes: `-specs=` and the path are ONE argv + // word, so the prefix is joined first and the whole token quoted after -- + // quoting only the path would put the opening quote in the wrong place. + // `escape_path` alone is not enough here: it adds ninja's `$` escapes and + // nothing else, while ninja hands the command to `sh -c`. This path is the + // only payload flag that lives under the PROJECT rather than the payload, + // so it is the only one a spaced project directory splits: + // `cannot read spec file '/tmp/tmp.XXX/my'` for a project in `my project` + // (e2e 179, on CI -- no local path here has a space). + if (!plan.gccCleanSpecs.empty()) + payload_ld = " " + shell_quote_arg(escape_ninja_chars( + "-specs=" + plan.gccCleanSpecs.string())) + + payload_ld; std::string link_extra; if (prof.lto) link_extra += " -flto"; diff --git a/src/build/plan.cppm b/src/build/plan.cppm index 0f1daa91..45a30fa9 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -112,6 +112,11 @@ struct BuildPlan { // (possibly read-only) registry directory, and deriving the path would put // an IDE database there. Empty → projectRoot, the historical default. std::filesystem::path compileDbPath; + // GCC only: a specs file that replaces the pristine `*link:`, so the + // payload's own (patched by every home that ever installed against it) + // cannot inject rpath entries into this build's artifacts. Empty for + // clang, which bypasses its cfg with --no-default-config instead. + std::filesystem::path gccCleanSpecs; std::filesystem::path stdBmiPath; // absolute path to prebuilt std.gcm std::filesystem::path stdObjectPath; // absolute path to prebuilt std.o std::filesystem::path stdCompatBmiPath; // absolute path to prebuilt std.compat.pcm diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index a622ac8b..0d61b018 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -43,6 +43,8 @@ import mcpp.build.ninja; // make_ninja_backend — driving that sub-build import mcpp.lockfile; import mcpp.config; import mcpp.xlings; +import mcpp.xlings.subos_info; +import mcpp.toolchain.post_install; import mcpp.platform; import mcpp.fetcher; import mcpp.fetcher.progress; @@ -907,9 +909,88 @@ std::string with_index_cause(std::string msg) { msg += "\n" + hint; return msg; } +// The runtime this build targets, in xlings's own spelling ("glibc@2.39"). +// +// A degradation chain with every step explicit, and deliberately NO final +// "otherwise pick something". Payload resolution declines without an +// authority, because the guess it used to make is what let the compile side +// and the artifact's interpreter name different glibc versions -- invisibly, +// until the binary met a library built against the other one. +// +// 1. [xlings] subos -> that subos's subos_info.runtime +// 2. the active subos's subos_info.runtime +// 3. "" -- no authority, therefore no PayloadFirst +// +// `compilerBin` may be empty on the first call: the compiler has not been +// probed yet, and an inherited toolchain resolves its subos from its OWNER +// home. The caller re-resolves once it knows where the compiler is. +std::string resolve_runtime_binding(const mcpp::manifest::Manifest& m, + const std::filesystem::path& compilerBin) { + auto runtime_of = [](const std::filesystem::path& dir) -> std::string { + return mcpp::xlings::subos::read(dir).runtime; + }; + // A declaration can only be honoured while the payload it names is still + // installed. Subos descriptions are written once and the payloads beneath + // them are replaced independently -- upgraded, garbage-collected -- so a + // subos can go on saying `glibc@2.39` on a machine that now has only 2.44. + // + // Taking that at face value is not conservative, it is worse than any + // substitution: the exact-match probe finds nothing, no loader reaches the + // link line, and the artifact ends up on the HOST loader -- outside the + // sandbox entirely. Measured on CI, three rounds running. + // + // So a declaration naming an absent payload is passed over rather than + // returned, and resolution carries on to something that can be honoured. + auto usable = [&](const std::string& binding) { + if (binding.empty()) return false; + if (compilerBin.empty()) return true; // cannot check yet; caller re-asks + if (mcpp::toolchain::probe_payload_paths(compilerBin, binding)) + return true; + mcpp::log::verbose("probe", std::format( + "subos declares runtime {}, but that payload is not installed here " + "— looking for one that is", binding)); + return false; + }; + if (auto active = mcpp::xlings::paths::subos_dir_of(compilerBin)) { + // 1 — the project names a subos; it is a sibling of the active one. + if (!m.xlings.subos.empty()) { + auto named = active->parent_path() / m.xlings.subos; + if (auto r = runtime_of(named); usable(r)) return r; + } + // 2 — whatever is active. + if (auto r = runtime_of(*active); usable(r)) return r; + } + + // 3 — COMPATIBILITY: the value this toolchain already has baked in. + // + // A subos created before xlings grew `subos_info` cannot answer, and that + // is the state of every machine installed before 2026.8.5.1 -- including + // mcpp's own sandbox, whose vendored xlings is never upgraded. Refusing + // there would break every existing user, so the toolchain's own baked + // value stands in. + // + // This is NOT the guess this design removed. The guess picked a version by + // directory order, unrelated to what the artifact would load. This reads + // the value the artifact WILL use -- gcc's specs, clang's cfg -- so the + // invariant that matters, compile side == run side, still holds. It is a + // migration path, and it goes away on its own: once the subos describes + // itself, step 1 or 2 answers first. + if (!compilerBin.empty()) { + if (auto r = mcpp::toolchain::baked_runtime_binding(compilerBin); + !r.empty()) { + mcpp::log::verbose("probe", std::format( + "subos does not describe itself; using the runtime this " + "toolchain was installed against ({}). `xlings self update` " + "and a fresh subos make this authoritative", r)); + return r; + } + } + return {}; +} } // namespace export std::expected + prepare_build(bool print_fingerprint, bool includeDevDeps = false, std::vector extraTargets = {}, @@ -1641,8 +1722,27 @@ prepare_build(bool print_fingerprint, tcOrigin = TcOrigin::FirstRun; } - auto tc = mcpp::toolchain::detect(explicit_compiler); + // The authority for "which libc", resolved before the toolchain is + // probed: payload paths are addresses for a version this names. + auto runtimeBinding = resolve_runtime_binding(*m, {}); + auto tc = mcpp::toolchain::detect(explicit_compiler, runtimeBinding); if (!tc) return std::unexpected(tc.error().message); + // The first pass has no compiler, so it can neither discover a binding + // that only the compiler's own home knows (an inherited toolchain resolves + // into its owner) nor check that a declared one is still installed. Both + // failures look identical from here: no payload paths. Re-ask with the + // compiler in hand whenever that is what happened. + // + // The old condition was `runtimeBinding.empty()`, which meant a subos + // naming a payload that had since been upgraded away won on the first pass + // and the second pass never ran. + if (!tc->payloadPaths) { + auto fromCompiler = resolve_runtime_binding(*m, tc->binaryPath); + if (!fromCompiler.empty() && fromCompiler != runtimeBinding) { + tc = mcpp::toolchain::detect(explicit_compiler, fromCompiler); + if (!tc) return std::unexpected(tc.error().message); + } + } // ── Targeting the MSVC ABI without a usable MSVC ───────────────────── // @@ -1737,7 +1837,7 @@ prepare_build(bool print_fingerprint, tcSpec = std::string(pins::kFirstRunWinGnu); tcOrigin = TcOrigin::FirstRun; - tc = mcpp::toolchain::detect(explicit_compiler); + tc = mcpp::toolchain::detect(explicit_compiler, runtimeBinding); if (!tc) return std::unexpected(tc.error().message); } @@ -4902,6 +5002,14 @@ prepare_build(bool print_fingerprint, if (!planResult) return std::unexpected(planResult.error()); ctx.plan = std::move(*planResult); ctx.plan.compileDbPath = workRoot / "compile_commands.json"; + // GCC: a clean `*link:` for this build, so the payload's specs cannot + // inject other homes' rpath entries into the artifact. AFTER the plan is + // moved in — an earlier assignment was silently overwritten by that move, + // which produced a generated file that nothing ever passed to the driver. + // Generated here rather than in compute_flags, which runs twice per build. + if (tc->compiler == mcpp::toolchain::CompilerId::GCC) + ctx.plan.gccCleanSpecs = mcpp::toolchain::write_clean_link_specs( + tc->binaryPath, ctx.outputDir); // ── Declared build-graph nodes → the plan ─────────────────────────────── // diff --git a/src/config.cppm b/src/config.cppm index 129a7419..cd49375a 100644 --- a/src/config.cppm +++ b/src/config.cppm @@ -600,7 +600,8 @@ std::expected load_or_init( // 6. Acquire xlings binary if needed if (cfg.xlingsBinaryMode == "bundled") { - auto xbin = mcpp::fallback::acquire_xlings_binary(cfg.xlingsBinary, quiet); + auto xbin = mcpp::fallback::acquire_xlings_binary( + cfg.xlingsBinary, quiet, kXlingsPinnedVersion); if (!xbin) return std::unexpected(ConfigError{xbin.error()}); } else if (cfg.xlingsBinaryMode == "system") { auto sysPath = mcpp::platform::fs::which( diff --git a/src/doctor.cppm b/src/doctor.cppm index 034c1d02..3dc1ce88 100644 --- a/src/doctor.cppm +++ b/src/doctor.cppm @@ -14,6 +14,8 @@ import mcpp.bmi_cache.maintenance; import mcpp.build.prepare; import mcpp.build.plan; import mcpp.config; +import mcpp.fallback.probe_sysroot; +import mcpp.fallback.xlings_binary; import mcpp.fallback.install_integrity; import mcpp.fetcher.progress; import mcpp.home; @@ -69,6 +71,7 @@ export std::vector parse_readelf_runpath(std::string_view dump) { // `mcpp self env`. export int env_report() { auto cfg = mcpp::config::load_or_init(/*quiet=*/false, mcpp::fetcher::make_bootstrap_progress_callback()); + if (!cfg) { mcpp::ui::error(cfg.error().message); return 4; } mcpp::config::print_env(*cfg); @@ -98,6 +101,7 @@ export int doctor_report() { err(std::format("toolchain detection failed: {}", tc.error().message)); } else { ok(std::format("{} at {}", tc->label(), tc->binaryPath.string())); + } // Windows: report the system MSVC (msvc@system). Absence is a warning, @@ -204,6 +208,28 @@ export int doctor_report() { mcpp::ui::status("Checking", "registry"); auto cfg = mcpp::config::load_or_init(/*quiet=*/false, mcpp::fetcher::make_bootstrap_progress_callback()); + + // Whose sysroot is this? gcc bakes `--sysroot=<...>/subos/default` + // at build time, and that path is a string, not a reference -- it + // keeps naming wherever the compiler was built no matter which + // project it now serves. A developer machine has many directories by + // that name, so the baked one frequently EXISTS while belonging to an + // unrelated checkout, and headers then come from a tree this build + // never declared. Existence is not ownership. + if (tc && cfg) { + std::error_code cwdEc; + auto project = std::filesystem::current_path(cwdEc); + if (mcpp::fallback::sysroot_is_foreign( + tc->sysroot, (*cfg).registryDir, + cwdEc ? std::filesystem::path{} : project)) + warn(std::format( + "sysroot {} belongs to neither this mcpp home ({}) nor " + "this project — headers would come from a tree nothing " + "here declared. mcpp remaps a baked sysroot when it can " + "find the equivalent under the registry; seeing it here " + "means it could not", + tc->sysroot.string(), (*cfg).registryDir.string())); + } if (!cfg) { err(cfg.error().message); } else { @@ -366,6 +392,33 @@ export int doctor_report() { else if (!sawAny) ok("no installed toolchains to check"); + // The vendored xlings, against the version this mcpp expects. + // + // Nothing else surfaces this. `mcpp self env` prints both numbers and + // says nothing about the gap, and a home that acquired its xlings once + // never revisited it -- so a machine could sit years behind while every + // command looked healthy. What goes missing is silent by nature: + // features mcpp reads FROM xlings (the subos_info block, for one) + // simply never appear, and the code that consumes them degrades + // quietly because a missing block is also a legitimate state. + { + auto have = mcpp::fallback::vendored_xlings_version((*cfg).xlingsBinary); + const auto want = std::string(mcpp::config::kXlingsPinnedVersion); + if (have.empty()) { + warn(std::format("cannot read the vendored xlings version at {}", + (*cfg).xlingsBinary.string())); + } else if (mcpp::fallback::version_is_older(have, want)) { + warn(std::format( + "vendored xlings is {} but this mcpp expects {} — features " + "mcpp reads from xlings may be silently absent (the subos " + "self-description arrived in 2026.8.5.1). It is replaced " + "automatically on the next `mcpp self init`", + have, want)); + } else { + ok(std::format("vendored xlings {} (pinned {})", have, want)); + } + } + // Dangling symlinks under registry/subos/default/lib — these point // into xim payload lib dirs; a removed package leaves them broken. auto subosLib = (*cfg).xlingsHome() / "subos" / "default" / "lib"; diff --git a/src/fallback/probe_sysroot.cppm b/src/fallback/probe_sysroot.cppm index ee3db9ab..dc91adb9 100644 --- a/src/fallback/probe_sysroot.cppm +++ b/src/fallback/probe_sysroot.cppm @@ -17,17 +17,54 @@ import mcpp.log; export namespace mcpp::fallback { -// When GCC reports a sysroot ending in "subos/default" that doesn't exist -// on the current machine (baked build-time path), remap it to the -// equivalent sysroot relative to the compiler's own xpkgs directory. +// Does `child` sit under `anchor`? +// +// Spelled with path components rather than by inspecting the relative path's +// text. `native()` is a wstring on Windows, so the obvious `rfind("..", 0)` +// does not even compile there -- and where it does compile it is subtly wrong, +// since a directory genuinely named `..cache` starts with those two +// characters without escaping anything. +bool path_is_under(const std::filesystem::path& child, + const std::filesystem::path& anchor) { + if (anchor.empty() || child.empty()) return false; + auto rel = child.lexically_relative(anchor); + if (rel.empty()) return false; + static const std::filesystem::path kUp{".."}; + return *rel.begin() != kUp; +} + +// When GCC reports a baked "subos/default" sysroot that does not belong to +// THIS toolchain's home, remap it to the equivalent sysroot under the +// compiler's own xpkgs tree. +// +// The predicate used to be "does not exist", which is the wrong axis. A +// developer machine has more than one project-local `.xlings/subos/default`, +// and gcc's baked path can name one of them: it exists, so the remap was +// skipped and every build in an unrelated project inherited another +// project's sysroot. Measured -- a build in mcpp resolved +// `--sysroot=/.xlings/subos/default`. +// +// Existence says nothing about ownership. What matters is whether the path is +// under the same registry as the compiler, and a path that is not gets +// remapped whether or not something happens to be there. std::optional remap_xlings_baked_sysroot(std::string_view reportedPath, const std::filesystem::path& compilerBin) { if (reportedPath.empty()) return std::nullopt; if (!reportedPath.ends_with("subos/default")) return std::nullopt; - if (std::filesystem::exists(std::string(reportedPath))) return std::nullopt; - if (auto xpkgs = mcpp::xlings::paths::xpkgs_from_compiler(compilerBin)) { + auto xpkgsOpt = mcpp::xlings::paths::xpkgs_from_compiler(compilerBin); + if (xpkgsOpt) { + // Owned by this toolchain's registry? Then it is the right answer. + auto registry = xpkgsOpt->parent_path().parent_path(); + std::error_code ec; + const bool inside = path_is_under( + std::filesystem::path(std::string(reportedPath)), registry); + if (inside && std::filesystem::exists(std::string(reportedPath), ec)) + return std::nullopt; + } + + if (auto xpkgs = std::move(xpkgsOpt)) { // xpkgs is /data/xpkgs -> registry = xpkgs/../.. auto registrySysroot = xpkgs->parent_path().parent_path() / "subos" / "default"; @@ -37,6 +74,40 @@ remap_xlings_baked_sysroot(std::string_view reportedPath, return std::nullopt; } +// Does this sysroot belong to the same registry as the compiler that reported +// it? Callers need this BEFORE deciding whether a usable sysroot is +// acceptable: usability and ownership are independent, and a path can pass the +// first while failing the second. +bool sysroot_is_owned(std::string_view reportedPath, + const std::filesystem::path& compilerBin) { + if (reportedPath.empty()) return false; + auto xpkgs = mcpp::xlings::paths::xpkgs_from_compiler(compilerBin); + // No registry to compare against -- nothing to contradict, so accept. + if (!xpkgs) return true; + return path_is_under(std::filesystem::path(std::string(reportedPath)), + xpkgs->parent_path().parent_path()); +} + +// Is this sysroot foreign -- neither this mcpp home's registry nor a tree +// belonging to the project being built? +// +// The hazard is specific and was measured: gcc bakes `--sysroot=<...>/.xlings/ +// subos/default` as a STRING at build time, and a developer machine has many +// directories by that name. The baked one therefore frequently EXISTS while +// belonging to an unrelated checkout, and headers silently come from a tree +// this build never declared. remap_xlings_baked_sysroot repairs the case it +// can see; this predicate is what reports the case it cannot. +// +// Both anchors are required. Registry alone would flag every legitimate +// project-local tree; project alone would flag every payload sysroot. +bool sysroot_is_foreign(const std::filesystem::path& sysroot, + const std::filesystem::path& registryRoot, + const std::filesystem::path& projectRoot) { + if (sysroot.empty()) return false; + return !path_is_under(sysroot, registryRoot) + && !path_is_under(sysroot, projectRoot); +} + // Parse a Clang .cfg file alongside the compiler binary for --sysroot=. std::optional parse_clang_cfg_sysroot(const std::filesystem::path& compilerBin) { diff --git a/src/fallback/xlings_binary.cppm b/src/fallback/xlings_binary.cppm index 5a91812d..43f4ce56 100644 --- a/src/fallback/xlings_binary.cppm +++ b/src/fallback/xlings_binary.cppm @@ -6,7 +6,9 @@ // 3. Fail with user-facing instructions module; +#include #include +#include export module mcpp.fallback.xlings_binary; @@ -17,9 +19,77 @@ export namespace mcpp::fallback { // Try to acquire (copy) the xlings binary to destBin. // Returns destBin on success or an error string. +// The version already vendored at destBin, or empty when it cannot be read. +std::string vendored_xlings_version(const std::filesystem::path& bin); + +// The version the acquisition chain WOULD install, without installing it. +// Empty when nothing is available. Replacing a vendored binary is only an +// improvement when this is newer than what is already there. +std::string candidate_source_version(); + +// True when `have` is strictly older than `want`, comparing dot-separated +// numeric components. Anything unparseable answers false -- a version this +// code does not understand is not evidence of being behind. +bool version_is_older(std::string_view have, std::string_view want); + +// Try to acquire (copy) the xlings binary to destBin. +// +// `pinnedVersion` is the version this mcpp expects. A vendored binary OLDER +// than the pin is replaced; one that is newer or equal is left alone. +// +// This used to return early on mere existence, with no version comparison at +// all, and nothing else ever revisited the file. A home created once kept +// whatever xlings it first acquired forever -- measured at 2026.8.2.1 against +// a pin of 2026.8.6.3, with `mcpp self env` printing both numbers side by side +// and saying nothing about the gap. The cost is not cosmetic: features mcpp +// reads from xlings simply do not appear. The subos_info block arrived in +// 2026.8.5.1, so on that machine the graphics packages' declarations were +// discarded by a client too old to have the API, and mcpp#352's fix could +// never take effect. +// +// Strictly-older, not not-equal: a user who put a newer xlings there on +// purpose must not be downgraded by an mcpp that happens to pin an older one. std::expected -acquire_xlings_binary(const std::filesystem::path& destBin, bool quiet = false) { - if (std::filesystem::exists(destBin)) return destBin; +acquire_xlings_binary(const std::filesystem::path& destBin, bool quiet = false, + std::string_view pinnedVersion = {}) { + if (std::filesystem::exists(destBin)) { + auto have = vendored_xlings_version(destBin); + if (pinnedVersion.empty() || have.empty() + || !version_is_older(have, pinnedVersion)) + return destBin; + + // Behind the pin -- but replacing is only an improvement if what we + // would put there is actually newer. The acquisition chain below ends + // at whatever `which xlings` finds, and on a machine whose system + // xlings is ANCIENT that is a downgrade dressed up as an update. + // + // Learned by doing it: this code first deleted the vendored binary and + // re-acquired, which replaced 2026.8.2.1 with the system's 0.4.51 -- + // older still, and equally missing the feature the check exists to + // restore. Look before leaping. + auto candidate = candidate_source_version(); + if (candidate.empty() || !version_is_older(have, candidate)) { + // stderr, not stdout. This is a remark about the environment, + // not output of the command that happens to be running -- and + // `mcpp test --json` promises every stdout line is NDJSON, a + // promise this line broke the moment a machine fell behind the + // pin (e2e 155). + if (!quiet) + std::println(stderr, + "{:>12} vendored xlings {} is older than the " + "pinned {}, but no newer source is available " + "(keeping it; run `xlings self update`)", + "Note", have, pinnedVersion); + return destBin; + } + if (!quiet) + std::println(stderr, + "{:>12} vendored xlings {} -> {} (pinned {})", + "Updating", have, candidate, pinnedVersion); + std::error_code rec; + std::filesystem::remove(destBin, rec); + // fall through and re-acquire + } std::error_code ec; std::filesystem::create_directories(destBin.parent_path(), ec); @@ -82,4 +152,75 @@ acquire_xlings_binary(const std::filesystem::path& destBin, bool quiet = false) (destBin.parent_path().parent_path() / "config.toml").string())); } + +std::string vendored_xlings_version(const std::filesystem::path& bin) { + std::error_code ec; + if (!std::filesystem::exists(bin, ec)) return {}; + auto r = mcpp::platform::process::capture(std::format( + "{} --version 2>/dev/null", mcpp::platform::shell::quote(bin.string()))); + if (r.exit_code != 0) return {}; + // Output carries ANSI colour; take the first dotted-numeric run. + std::string out; + for (std::size_t i = 0; i < r.output.size(); ++i) { + if (r.output[i] == '\x1b') { // skip CSI + while (i < r.output.size() && r.output[i] != 'm') ++i; + continue; + } + out += r.output[i]; + } + std::size_t i = 0; + while (i < out.size()) { + if (std::isdigit(static_cast(out[i]))) { + auto j = i; + while (j < out.size() + && (std::isdigit(static_cast(out[j])) + || out[j] == '.')) ++j; + auto cand = out.substr(i, j - i); + if (cand.find('.') != std::string::npos) return cand; + i = j; + } else ++i; + } + return {}; +} + +bool version_is_older(std::string_view have, std::string_view want) { + auto parts = [](std::string_view v) { + std::vector out; + std::size_t i = 0; + while (i <= v.size()) { + auto dot = v.find('.', i); + auto seg = v.substr(i, dot == std::string_view::npos + ? std::string_view::npos : dot - i); + long long n = 0; + auto [p, e] = std::from_chars(seg.data(), seg.data() + seg.size(), n); + if (e != std::errc{}) return std::vector{}; + out.push_back(n); + if (dot == std::string_view::npos) break; + i = dot + 1; + } + return out; + }; + auto a = parts(have), b = parts(want); + if (a.empty() || b.empty()) return false; // unparseable is not "behind" + for (std::size_t i = 0; i < std::max(a.size(), b.size()); ++i) { + long long x = i < a.size() ? a[i] : 0; + long long y = i < b.size() ? b[i] : 0; + if (x != y) return x < y; + } + return false; +} + + +std::string candidate_source_version() { + if (const char* e = std::getenv("MCPP_VENDORED_XLINGS"); e && *e) { + std::error_code ec; + if (std::filesystem::exists(std::filesystem::path(e), ec)) + return vendored_xlings_version(std::filesystem::path(e)); + } + if (auto sys = mcpp::platform::fs::which( + std::string("xlings") + std::string(mcpp::platform::exe_suffix))) + return vendored_xlings_version(*sys); + return {}; +} + } // namespace mcpp::fallback diff --git a/src/toolchain/detect.cppm b/src/toolchain/detect.cppm index c3079e01..e71acfe9 100644 --- a/src/toolchain/detect.cppm +++ b/src/toolchain/detect.cppm @@ -16,7 +16,8 @@ export namespace mcpp::toolchain { // Detect toolchain. If explicit_compiler is given, use that binary path // directly. Otherwise fall back to $CXX, then PATH g++. std::expected -detect(const std::filesystem::path& explicit_compiler = {}); +detect(const std::filesystem::path& explicit_compiler = {}, + std::string_view runtimeBinding = {}); // Compatibility helper for older call sites/tests: GCC std module lookup now // lives in the GCC provider. @@ -33,7 +34,8 @@ std::optional find_std_module_source( } std::expected -detect(const std::filesystem::path& explicit_compiler) { +detect(const std::filesystem::path& explicit_compiler, + std::string_view runtimeBinding) { auto bin_r = probe_compiler_binary(explicit_compiler); if (!bin_r) return std::unexpected(bin_r.error()); @@ -111,7 +113,8 @@ detect(const std::filesystem::path& explicit_compiler) { // Probe fine-grained payload paths from sibling xpkgs (glibc, linux-headers). // When available, flags are assembled from these paths instead of --sysroot. - tc.payloadPaths = probe_payload_paths(tc.binaryPath); + tc.runtimeBinding = std::string(runtimeBinding); + tc.payloadPaths = probe_payload_paths(tc.binaryPath, tc.runtimeBinding); // For GCC: ensure the probed sysroot has complete headers by symlinking // missing content (linux kernel headers, glibc) from payload xpkgs. diff --git a/src/toolchain/fingerprint.cppm b/src/toolchain/fingerprint.cppm index 59e6562c..beb3f75a 100644 --- a/src/toolchain/fingerprint.cppm +++ b/src/toolchain/fingerprint.cppm @@ -1,11 +1,18 @@ -// mcpp.toolchain.fingerprint — 10-field fingerprint that gates BMI cache safety. +// mcpp.toolchain.fingerprint — 11-field fingerprint that gates BMI cache safety. // -// Per docs/06-toolchain-and-fingerprint.md, the fingerprint MUST cover: +// Per docs/08-toolchain-internals.md, the fingerprint MUST cover: // 1. compiler id 2. compiler version // 3. compiler driver identity 4. target triple // 5. stdlib id+version 6. C++ standard // 7. compile flags hash 8. mcpp version // 9. dependency lock hash 10. std module BMI hash +// 11. runtime binding (which libc this targets) +// +// Field 11 is the prerequisite for building one project under two subos. The +// target triple says `x86_64-linux-gnu` for both glibc 2.39 and 2.44, so +// without it switching subos leaves `target//` untouched: ninja replays a +// graph whose objects were compiled against the other libc, the link +// succeeds, and the result references symbols the interpreter cannot provide. // // MVP uses FNV-1a 64-bit (deterministic, collision-rare-enough for cache // invalidation); spec calls for SHA-256 but for "did anything change?" @@ -35,7 +42,7 @@ struct FingerprintInputs { struct Fingerprint { std::string hex; // 16 hex chars - std::array parts; // each field's stringified form + std::array parts; // each field's stringified form }; std::string hash_file(const std::filesystem::path& p); // returns 16 hex @@ -108,6 +115,7 @@ Fingerprint compute_fingerprint(const FingerprintInputs& in) { fp.parts[7] = std::string(MCPP_VERSION); fp.parts[8] = in.dependencyLockHash; fp.parts[9] = in.stdBmiHash; + fp.parts[10] = tc.runtimeBinding; // Combine all parts deterministically. std::uint64_t h = 0xcbf29ce484222325ull; diff --git a/src/toolchain/linkmodel.cppm b/src/toolchain/linkmodel.cppm index 45195abc..1bf69f8e 100644 --- a/src/toolchain/linkmodel.cppm +++ b/src/toolchain/linkmodel.cppm @@ -72,7 +72,7 @@ struct ToolchainLinkModel { std::vector systemIncludes; // Rendering knobs derived from the toolchain at resolve time. - bool clangDriver = false; // clang: -isystem + rpath/loader on link + bool clangDriver = false; // clang: -isystem headers; gcc: -idirafter // gcc: -idirafter (…#include_next), -B/-L only bool clangWithCfg = false; // sibling .cfg exists (bundled LLVM) @@ -107,15 +107,45 @@ struct ToolchainLinkModel { std::vector out; if (mode == CLibMode::Sysroot) { out.push_back("--sysroot=" + esc(sysroot)); + // Sysroot mode still needs the interpreter named explicitly when + // we know it. mcpp replaces gcc's `*link:` to stop the payload's + // accumulated rpath entries reaching artifacts, and the pristine + // spec it restores carries the HOST's default loader — so removing + // the addressing without supplying a replacement would silently + // hand every artifact /lib64/ld-linux. The hermeticity check + // catches that, which is how it was found. + if (!loader.empty()) + out.push_back("-Wl,--dynamic-linker=" + esc(loader)); + // ...and the runtime search path, for the same reason. Replacing + // `*link:` removes the rpath the payload's specs used to inject + // along with the loader; supplying one without the other produces + // a binary whose interpreter is right and whose libm is not found. + for (auto& dir : libDirs) { + out.push_back("-L" + esc(dir)); + out.push_back("-Wl,-rpath," + esc(dir)); + } return out; } if (mode != CLibMode::PayloadFirst) return out; if (!crtDir.empty()) out.push_back("-B" + esc(crtDir)); for (auto& dir : libDirs) { out.push_back("-L" + esc(dir)); - if (clangDriver) out.push_back("-Wl,-rpath," + esc(dir)); + out.push_back("-Wl,-rpath," + esc(dir)); } - if (clangDriver && !loader.empty()) + // Emitted for BOTH drivers. + // + // GCC used to be left to its specs here, on the theory that the + // install-time fixup owned the loader. That made the RUN side a + // per-toolchain-install decision while the COMPILE side stayed + // per-build, and the two named different glibc versions the moment a + // second one was installed. + // + // mcpp already refuses to depend on clang's install-time cfg + // (`--no-default-config`, "reproducible builds, no dependence on the + // install-time-generated cfg"). The same reasoning had simply never + // been applied to gcc. Measured: an explicit -Wl,--dynamic-linker + // overrides what the specs inject. + if (!loader.empty()) out.push_back("-Wl,--dynamic-linker=" + esc(loader)); return out; } @@ -290,20 +320,69 @@ ToolchainLinkModel resolve_link_model(const Toolchain& tc) { // cross-compile resolves by what it builds FOR. if (is_msvc_target(tc) || is_mingw_target(tc)) return lm; + // The compiler's OWN runtime lives beside it, not in the C library: + // libgcc_s.so.1 for GCC. A produced binary links it whether or not the + // build ever mentions it, so its directory has to be on the artifact's + // RUNPATH -- and gcc's patched specs used to put it there, which is + // exactly why removing that rewrite has to put it back. + // + // Both modes need this, and only one got it at first. PayloadFirst + // artifacts came out with a single RUNPATH entry (the glibc payload) and + // resolved libgcc_s.so.1 from the HOST -- or, on a machine without one, + // not at all: `error while loading shared libraries: libgcc_s.so.1`. This + // developer machine has a usable sysroot, so every local build took the + // other branch and the gap only surfaced on CI (e2e 29). + // + // Not for musl (self-contained sysroot, static world) and not for clang, + // which brings compiler-rt and libunwind instead. + auto add_compiler_runtime_dir = [&] { + if (is_musl_target(tc) || lm.clangDriver || tc.binaryPath.empty()) return; + std::error_code lec; + auto gccLib = tc.binaryPath.parent_path().parent_path() / "lib64"; + if (std::filesystem::exists(gccLib, lec)) + lm.libDirs.push_back(gccLib); + }; + auto payload_first = [&] { auto& pp = *tc.payloadPaths; lm.mode = CLibMode::PayloadFirst; lm.crtDir = pp.glibcLib; lm.libDirs.push_back(pp.glibcLib); + add_compiler_runtime_dir(); lm.systemIncludes.push_back(pp.glibcInclude); if (!pp.linuxInclude.empty()) lm.systemIncludes.push_back(pp.linuxInclude); - if (lm.clangDriver) - lm.loader = resolve_loader(pp.glibcLib, tc.targetTriple); + // Resolved for both drivers now that both emit it. + lm.loader = resolve_loader(pp.glibcLib, tc.targetTriple); }; auto sysroot_mode = [&](const std::filesystem::path& root) { lm.mode = CLibMode::Sysroot; lm.sysroot = root; + // Name the interpreter even here: see link_tokens. The payload is the + // address (R6 — artifacts bind the payload, never the mutable view), + // and it is known whenever a runtime binding resolved. + // + // The pair mirrors exactly what fixup_gcc_specs used to bake in + // (`:`): the C library, and the compiler's own + // runtime (libgcc_s, libstdc++). Emitting it per build is the whole + // point — the baked copy was a per-toolchain-install decision that no + // longer matched the per-build one, and it accumulated one dead entry + // per home that ever touched the shared payload. + // + // Never for a musl target. Its sysroot is self-contained -- it brings + // its own libc, CRT and loader -- and the glibc payload here belongs + // to the HOST toolchain, which merely happens to be probed alongside. + // Putting it on the link path let ld pull glibc's static libc.a into a + // musl link: `undefined reference to _DYNAMIC`, `hidden symbol + // _DYNAMIC isn't defined`, from dl-reloc-static-pie.o. The musl branch + // below already says this about headers; it holds for libraries and + // the loader too. + if (!is_musl_target(tc) + && tc.payloadPaths && !tc.payloadPaths->glibcLib.empty()) { + lm.loader = resolve_loader(tc.payloadPaths->glibcLib, tc.targetTriple); + lm.libDirs.push_back(tc.payloadPaths->glibcLib); + } + add_compiler_runtime_dir(); // Supplement kernel headers when the sysroot lacks them (glibc's // local_lim.h needs ). Self-contained musl sysroots // ship their own; a cross target must not see host-arch headers. diff --git a/src/toolchain/model.cppm b/src/toolchain/model.cppm index 71d3883c..97a95e71 100644 --- a/src/toolchain/model.cppm +++ b/src/toolchain/model.cppm @@ -17,6 +17,10 @@ struct EnvVar { std::string value; }; +// The runtime this toolchain builds AGAINST, in xlings's spelling +// ("glibc@2.39"). Resolved before payload probing, from `--runtime` or the +// active subos's `subos_info.runtime`. Empty means no authority was found, +// and payload resolution then declines rather than guessing. struct PayloadPaths { std::filesystem::path glibcInclude; // glibc headers (features.h, bits/) std::filesystem::path glibcLib; // glibc runtime (libc.so, crt*.o, ld-linux) @@ -29,6 +33,16 @@ struct Toolchain { std::filesystem::path binaryPath; std::string driverIdent; // normalized --version output std::string targetTriple; // "x86_64-linux-gnu" + // The runtime this toolchain builds AGAINST, in xlings's own spelling + // ("glibc@2.39"). Resolved BEFORE payload probing, from `--runtime` or + // the active subos's `subos_info.runtime`. + // + // Empty is a refusal, not a default: payload resolution declines rather + // than picking a libc by directory order. That guess is what let the + // compile side and the artifact's interpreter name different glibc + // versions, with nothing in the resulting binary looking wrong until it + // loaded a library built against the other one. + std::string runtimeBinding; std::string stdlibId; // "libstdc++" std::string stdlibVersion; std::filesystem::path stdModuleSource; // bits/std.cc / std.cppm diff --git a/src/toolchain/post_install.cppm b/src/toolchain/post_install.cppm index a40b6fe0..3230273d 100644 --- a/src/toolchain/post_install.cppm +++ b/src/toolchain/post_install.cppm @@ -158,11 +158,50 @@ export void patchelf_walk(const std::filesystem::path& dir, // - Dynamically detects the baked-in loader path from the specs file // - Replaces it with the sandbox glibc payload's loader // - Replaces the rpath with : -// Idempotent — skips if already pointing at the correct glibc. +// NOT idempotent across homes -- and that is a defect, not a caveat. The +// needle is one path (the baked glibc dir) while the replacement is two +// (glibc + gccLib), so a second home patching the same shared payload leaves +// the first home's gccLib entry behind: it never appears in any later needle. +// One stale rpath per run, forever. Measured at 68 on a developer machine, +// all of them gccLib, none glibc -- a distribution only this mechanism +// produces. See 2026-08-08-payload-version-and-contract-drift-design.md §2.1; +// this function is slated for deletion, with the flags moving to the build. // Extract the baked-in glibc loader path (".../ld-linux-.so.N") from a // gcc specs file. xim bakes the installing user's XLINGS_HOME into specs at // install time, so the DIR varies per machine, and the loader NAME varies // per arch — detect both instead of hardcoding either. +// The runtime binding a toolchain was INSTALLED against, read back out of +// what mcpp itself wrote: gcc's specs, clang's cfg. +// +// Compatibility only. The authority is the subos's `subos_info.runtime`; this +// answers for machines whose subos predates that block, and it answers with +// the version the artifact will actually load -- so compile and run still +// agree, which is the invariant that matters. +// +// Returns "glibc@" or empty. The version comes from the payload path +// (.../xim-x-glibc//lib64), which is the only place it is written down. +export std::string baked_runtime_binding(const std::filesystem::path& compilerBin); + +// Write a specs file that REPLACES gcc's `*link:` with the pristine built-in +// one, and return its path (empty when not applicable or on failure). +// +// Why this exists: `-rpath` accumulates. mcpp emitting its own does not remove +// what the payload's specs already inject, and that file has been patched by +// every home that ever installed against this shared payload -- one stale +// entry per run, forever. Replacing the spec is the only way to stop them +// reaching a user's artifact, and it works on machines already polluted. +// +// The pristine `*link:` comes from `g++ -dumpspecs`, which prints the specs +// COMPILED INTO the binary and is unaffected by the file on disk. So the +// original is always recoverable and the payload is never touched -- an +// earlier plan to reinstall the toolchain was unnecessary. +// +// `-specs=` whose definition does not begin with `+` replaces rather +// than appends; both properties were measured before this was written. +export std::filesystem::path write_clean_link_specs( + const std::filesystem::path& compilerBin, + const std::filesystem::path& outputDir); + export std::string detect_baked_loader(const std::string& specsContent) { // Path-character whitelist. Specs embed loader paths inside %-spec // syntax (`%{mmusl:...;:/baked/dir/ld-linux-x86-64.so.2}`), so scanning @@ -196,65 +235,30 @@ export std::string detect_baked_loader(const std::string& specsContent) { return ""; } -void fixup_gcc_specs(const std::filesystem::path& gccPkgRoot, - const std::filesystem::path& glibcLibDir, - const std::filesystem::path& gccLibDir, - const std::filesystem::path& fenceRoot) -{ - // #273 fence: a payload reached through a symlink is a foreign - // installation — its specs file must not be rewritten either. - if (escapes_containment(gccPkgRoot, fenceRoot)) { - mcpp::log::verbose("toolchain", - "fixup_gcc_specs: skip (payload outside sandbox, #273 fence)"); - return; - } - std::filesystem::path specsParent; - std::error_code ec; - for (auto it = std::filesystem::directory_iterator(gccPkgRoot / "lib" / "gcc", ec); - !ec && it != std::filesystem::directory_iterator{}; it.increment(ec)) { - if (it->is_directory(ec)) { specsParent = it->path(); break; } - } - if (specsParent.empty()) return; - - auto loaderReplacement = resolve_loader(glibcLibDir, /*targetTriple=*/{}).string(); - if (loaderReplacement.empty()) return; - auto rpathReplacement = std::format("{}:{}", - glibcLibDir.string(), - gccLibDir.string()); - - auto replace_all = [](std::string& s, std::string_view needle, - std::string_view rep) - { - for (std::size_t pos = 0; - (pos = s.find(needle, pos)) != std::string::npos;) { - s.replace(pos, needle.size(), rep); - pos += rep.size(); - } - }; - - for (auto& sub : std::filesystem::directory_iterator(specsParent)) { - auto specs = sub.path() / "specs"; - if (!std::filesystem::exists(specs)) continue; - - std::ifstream is(specs); - std::stringstream ss; ss << is.rdbuf(); - std::string content = ss.str(); - - auto bakedLoader = detect_baked_loader(content); - if (bakedLoader.empty()) continue; - auto bakedDir = std::filesystem::path(bakedLoader).parent_path().string(); - // Already pointing at the right place — no fixup needed. - if (bakedDir == glibcLibDir.string()) continue; - - // Order matters: replace the full loader file path first so the - // shorter dir pattern doesn't eat its prefix. - replace_all(content, bakedLoader, loaderReplacement); - replace_all(content, bakedDir, rpathReplacement); +// fixup_gcc_specs is GONE. +// +// It wrote the loader and the rpath into the gcc payload's `specs` at install +// time. Both are now emitted per build by mcpp.toolchain.linkmodel, which +// removes an answerer rather than adding a path: +// +// - the RUN side was a per-toolchain-install decision while the COMPILE +// side was per-build, and they named different glibc versions the moment +// a second one was installed; +// - the substitution had a one-path needle and a two-path replacement, so +// every home that patched the SHARED payload left one entry behind. 68 on +// a developer machine, all pointing at deleted mktemp directories, in +// every gcc artifact it produced. +// +// The payload is no longer written to at all, which restores what R6 asks of +// it: immutable, and independent of any particular home. A build that needs +// a clean `*link:` gets one generated into its own build directory (see +// write_clean_link_specs) -- the pristine spec comes from `g++ -dumpspecs`, +// which prints what is compiled into the binary and is therefore always +// recoverable. +// +// patchelf_walk stays: making the compiler itself runnable is a different +// question from what the compiler produces. - std::ofstream os(specs); - os << content; - } -} // Regenerate the clang driver cfg files after the LLVM payload landed in the // sandbox. The cfg xlings authored at install time is a per-machine, @@ -271,7 +275,7 @@ void fixup_gcc_specs(const std::filesystem::path& gccPkgRoot, export void fixup_clang_cfg(const std::filesystem::path& payloadRoot, const std::filesystem::path& glibcLibDir, const std::filesystem::path& fenceRoot) { - // #273 fence — same rule as fixup_gcc_specs above. + // #273 fence — same rule the removed gcc specs fixup used. if (escapes_containment(payloadRoot, fenceRoot)) { mcpp::log::verbose("toolchain", "fixup_clang_cfg: skip (payload outside sandbox, #273 fence)"); @@ -416,9 +420,6 @@ void gcc_post_install_fixup(const mcpp::config::GlobalConfig& cfg, } patchelf_walk(payloadRoot, loader, rpath, patchelfBin, fence); - mcpp::log::verbose("toolchain", "gcc fixup: fixup_gcc_specs"); - fixup_gcc_specs(payloadRoot, glibcLibDir, gccLibDir, - containment_root(cfg.registryDir)); } else { mcpp::ui::warning( "could not locate sandbox glibc/gcc/patchelf paths; " @@ -528,4 +529,216 @@ export void ensure_post_install_fixup(const mcpp::config::GlobalConfig& cfg, os << expected.dump(2) << "\n"; } + +// PT_INTERP of an ELF, read directly. +// +// Not via `patchelf --print-interpreter`: this runs during prepare, where +// patchelf is not guaranteed to be resolved, and a dependency on an external +// tool for four fields of a header is a dependency that will be missing on +// exactly the machine that needs the answer. +std::string read_elf_interp(const std::filesystem::path& bin) { + std::ifstream is(bin, std::ios::binary); + if (!is) return {}; + unsigned char ident[16]{}; + is.read(reinterpret_cast(ident), sizeof ident); + if (!is || ident[0] != 0x7f || ident[1] != 'E' + || ident[2] != 'L' || ident[3] != 'F') return {}; + const bool is64 = ident[4] == 2; + const bool le = ident[5] == 1; + if (!is64 || !le) return {}; // the only shape payloads ship + + auto u16 = [&](std::streamoff off) -> std::uint16_t { + is.seekg(off); unsigned char b[2]{}; + is.read(reinterpret_cast(b), 2); + return static_cast(b[0] | (b[1] << 8)); + }; + auto u64 = [&](std::streamoff off) -> std::uint64_t { + is.seekg(off); unsigned char b[8]{}; + is.read(reinterpret_cast(b), 8); + std::uint64_t v = 0; + for (int i = 7; i >= 0; --i) v = (v << 8) | b[i]; + return v; + }; + + const auto phoff = u64(0x20); + const auto phentsize = u16(0x36); + const auto phnum = u16(0x38); + if (!is || phoff == 0 || phentsize < 0x38 || phnum == 0) return {}; + + constexpr std::uint32_t kPtInterp = 3; + for (std::uint16_t i = 0; i < phnum; ++i) { + const auto ph = static_cast(phoff) + + static_cast(i) * phentsize; + is.seekg(ph); + unsigned char t[4]{}; + is.read(reinterpret_cast(t), 4); + if (!is) return {}; + const std::uint32_t type = + static_cast(t[0]) | (t[1] << 8) + | (t[2] << 16) | (static_cast(t[3]) << 24); + if (type != kPtInterp) continue; + const auto offset = u64(ph + 0x08); + const auto filesz = u64(ph + 0x20); + if (filesz == 0 || filesz > 4096) return {}; + std::string str(static_cast(filesz), '\0'); + is.seekg(static_cast(offset)); + is.read(str.data(), static_cast(filesz)); + if (!is) return {}; + if (auto z = str.find('\0'); z != std::string::npos) str.resize(z); + return str; + } + return {}; +} + +std::string baked_runtime_binding(const std::filesystem::path& compilerBin) { + if (compilerBin.empty()) return {}; + std::error_code ec; + + auto from_text = [](const std::string& text) -> std::string { + auto loader = detect_baked_loader(text); + if (loader.empty()) return {}; + // The recorded path may name a subos VIEW (`/subos/default/lib/ + // ld-linux-...`), which is a symlink into the payload. R6: artifacts + // bind the payload, never the mutable view -- so resolve to the + // payload before reading a version off it. A view path has no version + // component at all, and parsing one yields nothing. + std::error_code cec; + if (auto real = std::filesystem::canonical(loader, cec); !cec) + loader = real.string(); + // .../xim-x-glibc//lib64/ld-linux-... — the version is the + // grandparent of the lib dir. + auto dir = std::filesystem::path(loader).parent_path(); // lib64 + auto ver = dir.parent_path().filename().string(); // + auto pkg = dir.parent_path().parent_path().filename().string(); + if (ver.empty() || pkg.find("glibc") == std::string::npos) return {}; + return "glibc@" + ver; + }; + + // Which glibc payloads are actually installed. Every source below is + // checked against this, because all of them are RECORDS of a past state: + // specs and cfg were written at some install, PT_INTERP at some fixup. A + // payload can be replaced afterwards -- upgraded, garbage-collected -- and + // the record keeps naming what used to be there. + // + // That is not hypothetical. CI resolved `glibc@2.39` from a record while + // the only payload on disk was 2.44, so the exact-match probe correctly + // refused, no payload paths resolved, and the artifact fell through to the + // host loader. A fossil that names something no longer present is not an + // authority; it is just old. + std::vector installed; + if (auto xpkgs = mcpp::xlings::paths::xpkgs_from_compiler(compilerBin)) { + for (auto it = std::filesystem::directory_iterator(*xpkgs / "xim-x-glibc", ec); + !ec && it != std::filesystem::directory_iterator{}; it.increment(ec)) { + if (!it->is_directory(ec)) continue; + auto v = it->path().filename().string(); + if (!v.empty() && v.front() != '.') installed.push_back(v); + } + } + auto still_there = [&](const std::string& binding) { + if (binding.empty()) return false; + const auto at = binding.find('@'); + if (at == std::string::npos) return false; + auto ver = binding.substr(at + 1); + return std::ranges::find(installed, ver) != installed.end(); + }; + + auto read_file = [&](const std::filesystem::path& p) -> std::string { + if (!std::filesystem::exists(p, ec)) return {}; + std::ifstream is(p); + std::stringstream ss; ss << is.rdbuf(); + return ss.str(); + }; + + // clang: the sibling .cfg. + auto cfg = compilerBin.parent_path() + / (compilerBin.stem().string() + ".cfg"); + if (auto r = from_text(read_file(cfg)); still_there(r)) return r; + + // gcc: lib/gcc///specs, one level of globbing each. + auto gccRoot = compilerBin.parent_path().parent_path(); + for (auto t = std::filesystem::directory_iterator(gccRoot / "lib" / "gcc", ec); + !ec && t != std::filesystem::directory_iterator{}; t.increment(ec)) { + for (auto v = std::filesystem::directory_iterator(t->path(), ec); + !ec && v != std::filesystem::directory_iterator{}; v.increment(ec)) { + if (auto r = from_text(read_file(v->path() / "specs")); still_there(r)) + return r; + } + } + + // The compiler's OWN interpreter. + // + // The two sources above are files mcpp used to write, and mcpp no longer + // writes them -- so on a machine where the toolchain was installed after + // that change they simply are not there, and the compatibility path that + // depends on them answers nothing. That is not hypothetical: it is what + // turned CI red while every existing developer machine, which still has + // the files from before, stayed green. + // + // PT_INTERP is produced by the patchelf walk, which still runs on every + // install, and it names the glibc payload this toolchain was aligned to -- + // the same fact the specs held, from a mechanism that has not gone away. + if (auto r = from_text(read_elf_interp(compilerBin)); still_there(r)) + return r; + + // Last: the installed payload set, but ONLY when it is a singleton. + // + // This is not the rule this design removed. That rule asked a directory + // for "the glibc" and took whatever `readdir` yielded first -- a CHOICE, + // made by something with no bearing on what the artifact would load, and + // silently wrong the moment a second payload appeared. Where exactly one + // payload is installed there is no choice to make: it is the only glibc + // any artifact from this toolchain could bind, and declining would refuse + // a question that has a single answer. + // + // Two or more and this stays silent. That is the case the incident was, + // and it is the case the subos must answer. + if (installed.size() == 1) { + mcpp::log::verbose("probe", std::format( + "runtime binding glibc@{} — the only glibc payload installed, so " + "there is nothing to choose between", installed[0])); + return "glibc@" + installed[0]; + } + if (installed.size() > 1) + mcpp::log::verbose("probe", std::format( + "{} glibc payloads installed and nothing still present declares " + "which one this build binds; declining rather than picking", + installed.size())); + return {}; +} + + +std::filesystem::path write_clean_link_specs( + const std::filesystem::path& compilerBin, + const std::filesystem::path& outputDir) +{ + if (compilerBin.empty() || outputDir.empty()) return {}; + std::error_code ec; + auto out = outputDir / "mcpp-clean-link.specs"; + // Idempotent: the pristine spec is a property of the compiler binary, so + // regenerating it every build would spawn a process for a constant. + if (std::filesystem::exists(out, ec)) return out; + + auto r = mcpp::platform::process::capture(std::format( + "{} -dumpspecs 2>/dev/null", + mcpp::platform::shell::quote(compilerBin.string()))); + if (r.exit_code != 0 || r.output.empty()) return {}; + + // `*link:` is a section header on its own line; its body is the next line. + constexpr std::string_view kHead = "*link:\n"; + auto pos = r.output.find(kHead); + if (pos == std::string::npos) return {}; + auto bodyStart = pos + kHead.size(); + auto bodyEnd = r.output.find('\n', bodyStart); + if (bodyEnd == std::string::npos) return {}; + auto body = r.output.substr(bodyStart, bodyEnd - bodyStart); + if (body.empty()) return {}; + + std::filesystem::create_directories(outputDir, ec); + std::ofstream os(out); + if (!os) return {}; + os << "*link:\n" << body << "\n\n"; + os.close(); + return out; +} + } // namespace mcpp::toolchain diff --git a/src/toolchain/probe.cppm b/src/toolchain/probe.cppm index 5331c12e..35fb26b6 100644 --- a/src/toolchain/probe.cppm +++ b/src/toolchain/probe.cppm @@ -50,11 +50,15 @@ std::filesystem::path probe_sysroot(const std::filesystem::path& compilerBin, const std::string& envPrefix); -// Probe fine-grained sysroot paths from sibling xpkgs payloads. -// Returns populated PayloadPaths if glibc xpkg found; linux-headers -// may be empty if not available. +// Resolve the payload paths for an EXPLICIT runtime binding ("glibc@2.39"). +// +// The version is named by the caller's authority, never searched for. An +// empty binding returns nullopt: declining PayloadFirst is correct, guessing +// is what split the compile and run halves apart (see §3.2 of +// 2026-08-08-payload-version-and-contract-drift-design.md). std::optional -probe_payload_paths(const std::filesystem::path& compilerBin); +probe_payload_paths(const std::filesystem::path& compilerBin, + std::string_view runtimeBinding); // Ensure sysroot directory has complete headers by symlinking from // payload xpkgs. Called when GCC's probed sysroot exists but may @@ -287,21 +291,48 @@ probe_sysroot(const std::filesystem::path& compilerBin, mcpp::platform::null_redirect)); if (r) { auto s = trim_line(*r); - if (!s.empty() && std::filesystem::exists(s)) { - if (usable(s)) return s; + + // A usable sysroot that belongs to somebody else is still somebody + // else's. gcc records this path as a string when it is built and + // reports it forever after; on a machine with several checkouts the + // recorded one routinely exists AND carries headers, so accepting it + // on usability alone hands the build another project's tree. Measured + // right here: this repo's gcc reported a sysroot under an unrelated + // one, and every build took its headers. + // + // The ownership test lives inside remap_xlings_baked_sysroot, which is + // exactly why the order matters -- the early return below reached it + // only when the path was missing, so the case the remap exists for was + // the one case it never saw. + const bool ownedByThisHome = + !s.empty() && mcpp::fallback::sysroot_is_owned(s, compilerBin); + + if (ownedByThisHome && usable(s)) return s; + if (!s.empty() && std::filesystem::exists(s) && !usable(s)) mcpp::log::debug("probe", std::format( "sysroot '{}' exists but lacks usr/include/stdlib.h — ignoring", s)); - } // GCC bakes the build-time sysroot into the binary. For xlings-built // GCC this is a path like /.xlings/subos/default that - // doesn't exist on the user's machine. Remap via fallback module. + // doesn't exist on the user's machine -- or exists and belongs to a + // different one. Remap via fallback module. if (auto remapped = mcpp::fallback::remap_xlings_baked_sysroot(s, compilerBin)) { if (usable(*remapped)) return *remapped; mcpp::log::debug("probe", std::format( "remapped sysroot '{}' lacks usr/include/stdlib.h — ignoring", remapped->string())); } + + // Last resort: a foreign but usable sysroot beats no sysroot. This is + // the pre-existing behaviour, kept for machines that have no registry + // subos to remap to -- it is a worse answer, not a wrong one, and + // taking nothing here would break them outright. + if (!s.empty() && std::filesystem::exists(s) && usable(s)) { + mcpp::log::verbose("probe", std::format( + "using sysroot '{}', which is outside this toolchain's " + "registry — no equivalent found under it", s)); + return std::filesystem::path(s); + } } // 2. macOS fallback: use xcrun to discover the SDK path. @@ -326,19 +357,72 @@ probe_sysroot(const std::filesystem::path& compilerBin, return {}; } +// Resolve `@` to the payload ROOT for that exact version. +// +// The version is named, never searched: the caller has an authority (the +// resolved runtime binding) and this turns it into an address. Two payload +// roots are tried in the order the rest of the file uses -- the compiler's own +// siblings first, then the active home -- because an inherited or symlinked +// compiler resolves into its owner home while the active home may be the one +// that just installed the payload. +std::optional +payload_root_for_binding(const std::filesystem::path& compilerBin, + std::string_view binding) { + const auto at = binding.find('@'); + if (at == std::string_view::npos || at == 0 || at + 1 >= binding.size()) + return std::nullopt; + const auto name = binding.substr(0, at); + const auto version = std::string(binding.substr(at + 1)); + + std::error_code ec; + // Compiler siblings: <...>/xpkgs/xim-x-/ + if (auto xpkgs = mcpp::xlings::paths::xpkgs_from_compiler(compilerBin)) { + auto root = *xpkgs / std::format("xim-x-{}", name) / version; + if (std::filesystem::exists(root, ec)) return root; + } + // Active home. + if (auto xpkgs = mcpp::xlings::paths::active_home_xpkgs()) { + auto root = *xpkgs / std::format("xim-x-{}", name) / version; + if (std::filesystem::exists(root, ec)) return root; + } + return std::nullopt; +} + +// `runtimeBinding` is the AUTHORITY -- `--runtime`, else the active subos's +// `subos_info.runtime`. Empty is a refusal, not a licence to guess: a guess +// here is how the compile side and the artifact's interpreter came to name +// different glibc versions, and nothing about the resulting binary looks +// wrong until it loads a library built against the other one. std::optional -probe_payload_paths(const std::filesystem::path& compilerBin) { - namespace paths = mcpp::xlings::paths; +probe_payload_paths(const std::filesystem::path& compilerBin, + std::string_view runtimeBinding) { + if (runtimeBinding.empty()) { + mcpp::log::verbose("probe", + "no runtime binding resolved — declining PayloadFirst rather than " + "picking a libc by directory order"); + return std::nullopt; + } + // Only a libc family has a payload of this shape. `macos_sdk`/`ucrt` are + // resolved elsewhere, and a musl target uses the sysroot mode. + if (!runtimeBinding.starts_with("glibc@")) { + mcpp::log::verbose("probe", std::format( + "runtime '{}' is not a glibc payload — no PayloadFirst", + std::string(runtimeBinding))); + return std::nullopt; + } - // Find glibc xpkg (required). Compiler siblings first; fall back to the - // ACTIVE home registry — an inherited/symlinked compiler resolves into - // its owner home, while the active home may own (or have just installed) - // the sysroot payloads. - auto glibc = paths::find_sibling_tool(compilerBin, "glibc"); - if (!glibc) glibc = paths::find_home_tool("glibc", "include/features.h"); - if (!glibc) return std::nullopt; + auto glibc = payload_root_for_binding(compilerBin, runtimeBinding); + if (!glibc) { + mcpp::log::verbose("probe", std::format( + "runtime '{}' is not installed in this home", + std::string(runtimeBinding))); + return std::nullopt; + } + + namespace paths = mcpp::xlings::paths; - // Glibc layout: /include/ + /lib64/ (or lib/). + // Layout WITHIN the chosen payload. This convention stays: it answers + // "where inside", not "which one". auto glibcInclude = *glibc / "include"; if (!std::filesystem::exists(glibcInclude / "features.h")) return std::nullopt; diff --git a/src/version.cppm b/src/version.cppm index bd258c4a..7e8caed1 100644 --- a/src/version.cppm +++ b/src/version.cppm @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.8.8.1"; +inline constexpr std::string_view MCPP_VERSION = "2026.8.8.2"; } // namespace mcpp diff --git a/src/xlings.cppm b/src/xlings.cppm index e05f9457..3423e506 100644 --- a/src/xlings.cppm +++ b/src/xlings.cppm @@ -44,7 +44,7 @@ namespace pinned { // in lock-step by hand; that list was already missing both composite // actions, which is how CI's sandbox sat on 0.4.30 unnoticed while // everything else had moved on. Don't reintroduce a hand-maintained list. - inline constexpr std::string_view kXlingsVersion = "2026.8.6.3"; + inline constexpr std::string_view kXlingsVersion = "2026.8.7.1"; inline constexpr std::string_view kNasmVersion = "3.02"; } @@ -716,7 +716,16 @@ find_sibling_tool(const std::filesystem::path& compilerBin, std::error_code ec; if (!std::filesystem::exists(root, ec)) return std::nullopt; - // Return the first (highest) version dir that exists. + // The first version dir readdir yields. ORDER IS UNDEFINED -- this does + // NOT return the highest, and the comment that said so was wrong for as + // long as it stood. With two versions installed the answer varies by + // filesystem, so a caller that needs a SPECIFIC version must name it; + // see probe.cppm, where the version comes from the resolved runtime + // binding rather than from this scan. + // + // A test asserting the old "highest" claim was attempted and removed: it + // passed or failed by directory order, which makes both outcomes + // uninformative. for (auto& v : std::filesystem::directory_iterator(root, ec)) { if (v.is_directory(ec)) return v.path(); } diff --git a/src/xlings/subos_info.cppm b/src/xlings/subos_info.cppm index 0054fb2b..41aaac55 100644 --- a/src/xlings/subos_info.cppm +++ b/src/xlings/subos_info.cppm @@ -131,28 +131,39 @@ Info read(const std::filesystem::path& subosDir) { if (auto v = it->find("runtime"); v != it->end() && v->is_string()) info.runtime = v->get(); - if (auto envs = it->find("envs"); envs != it->end() && envs->is_array()) { - for (auto const& p : *envs) { - if (!p.is_object()) continue; + // `envs` is an OBJECT keyed by binding, whose values are arrays of + // declarations: + // + // "envs": { "mesa@25.0.7.1": [ {"var":…,"op":…,"value":…}, … ], … } + // + // Transcribed from xlings's own reader (core/subos/manifest.cppm), not + // from a model of it. The first version of this file expected an array of + // {binding, decls} objects — a shape xlings never writes — and its tests + // hand-wrote JSON in that same invented shape, so both agreed and both + // were wrong. Against a real subos the loop simply never ran and every + // variable came back unset, silently. That is why the fixture below is a + // verbatim capture of real output rather than something composed here. + if (auto envs = it->find("envs"); envs != it->end() && envs->is_object()) { + for (auto e = envs->begin(); e != envs->end(); ++e) { + if (!e.value().is_array()) continue; Provider prov; - if (auto b = p.find("binding"); b != p.end() && b->is_string()) - prov.binding = b->get(); - if (auto ds = p.find("decls"); ds != p.end() && ds->is_array()) { - for (auto const& d : *ds) { - if (!d.is_object()) continue; - EnvDecl e; - if (auto x = d.find("var"); x != d.end() && x->is_string()) - e.var = x->get(); - if (auto x = d.find("op"); x != d.end() && x->is_string()) - e.op = x->get(); - if (auto x = d.find("value"); x != d.end() && x->is_string()) - e.value = x->get(); - // A declaration with no variable name is not a partial - // declaration to be guessed at — it is malformed input, - // and the right thing is to leave it out rather than - // invent a name for it. - if (!e.var.empty()) prov.decls.push_back(std::move(e)); - } + prov.binding = e.key(); + for (auto const& d : e.value()) { + if (!d.is_object()) continue; + EnvDecl decl; + if (auto x = d.find("var"); x != d.end() && x->is_string()) + decl.var = x->get(); + if (auto x = d.find("op"); x != d.end() && x->is_string()) + decl.op = x->get(); + if (auto x = d.find("value"); x != d.end() && x->is_string()) + decl.value = x->get(); + // xlings drops a declaration whose var is empty or whose op is + // neither "set" nor "prepend". Matched exactly: a reader that + // is more permissive than the writer will one day apply + // something the writer considers malformed. + if (decl.var.empty()) continue; + if (decl.op != "set" && decl.op != "prepend") continue; + prov.decls.push_back(std::move(decl)); } info.providers.push_back(std::move(prov)); } diff --git a/tests/e2e/200_subos_env_reaches_program.sh b/tests/e2e/200_subos_env_reaches_program.sh index 81da7302..4ce376df 100755 --- a/tests/e2e/200_subos_env_reaches_program.sh +++ b/tests/e2e/200_subos_env_reaches_program.sh @@ -14,6 +14,12 @@ # know what any of these variables mean -- it carries whatever the subos # declares -- and a test naming LIBGL_DRIVERS_PATH would quietly suggest # otherwise. +# +# The JSON below is xlings's REAL shape: `envs` is an object keyed by binding, +# whose values are arrays of declarations. The first version of this test wrote +# an array of {binding, decls} -- a shape xlings never produces -- and it +# passed, because the reader had been written from the same misunderstanding. +# Do not "simplify" this structure; it is a wire format, not a convenience. set -euo pipefail TMP=$(mktemp -d) @@ -28,9 +34,9 @@ mkdir -p "$subos/usr/lib/dri" cat > "$subos/.xlings.json" <<'EOF' { "workspace": {}, "subos_info": { "schema_version": 1, "runtime": "glibc@2.39", - "envs": [ { "binding": "probe@1", "decls": [ + "envs": { "probe@1": [ { "var": "MCPP_E2E_PROBE", "op": "prepend", - "value": "${subosdir}/usr/lib/dri" } ] } ] } } + "value": "${subosdir}/usr/lib/dri" } ] } } } EOF cd "$TMP" @@ -106,6 +112,33 @@ echo "$out2" | grep -q 'PROBE=(unset)' || { exit 1 } +# 2b. A cache written before this mcpp knew about subos environments must NOT +# be replayed by the fast path. Simulated by stripping the field, which is +# exactly what an older mcpp's cache looks like. +# +# Without this the fix survives an upgrade in name only: the fast path's +# identity is the profile, the cache mode and the resource list, and its +# fingerprint check compares a cached entry against itself -- so nothing +# notices that a different mcpp wrote it, and an upgraded mcpp would keep +# running the pre-upgrade build with no subos environment at all. +cache="$TMP/hello/target/.build_cache" +[ -f "$cache" ] || { echo "no build cache to age"; exit 1; } +grep -q '^subos=' "$cache" || { echo "cache has no subos= line to strip"; exit 1; } +grep -v '^subos=' "$cache" > "$cache.old" && mv "$cache.old" "$cache" +aged=$(MCPP_SUBOS_DIR="$subos" "$MCPP" run 2>&1) || { + echo "run against an aged cache failed:"; echo "$aged"; exit 1; } +echo "$aged" | grep -q 'Resolving toolchain' || { + echo "an aged cache was replayed by the fast path — the subos environment" + echo " would be missing for every run after an upgrade:" + echo "$aged" + exit 1 +} +echo "$aged" | grep -q "PROBE=$subos/usr/lib/dri" || { + echo "the rebuild after an aged cache did not apply the environment:" + echo "$aged"; exit 1; } +grep -q '^subos=' "$cache" || { + echo "the rebuild did not record subos= , so every later run repeats it"; exit 1; } + # 3. A subos with no self-description degrades quietly and still runs. This is # the state of every subos created before xlings grew the block, so it must # not be an error. diff --git a/tests/e2e/201_gcc_no_specs_pollution.sh b/tests/e2e/201_gcc_no_specs_pollution.sh new file mode 100755 index 00000000..20499ad4 --- /dev/null +++ b/tests/e2e/201_gcc_no_specs_pollution.sh @@ -0,0 +1,173 @@ +#!/usr/bin/env bash +# requires: gcc elf +# 201_gcc_no_specs_pollution.sh — a gcc artifact must carry only the RUNPATH +# entries this build asked for. +# +# mcpp used to write the loader and rpath into the gcc payload's `specs` file +# at install time, and the link command said nothing about either. Two +# consequences, both measured on a developer machine before this test existed: +# +# 1. the compile side (-L, chosen per build) and the run side (specs, frozen +# at toolchain install) could name different glibc versions, and did as +# soon as a second one was installed; +# 2. the substitution that wrote those specs had a one-path needle and a +# two-path replacement, so every home that ever patched the SHARED payload +# left one entry behind -- 68 of them, all pointing at deleted mktemp +# directories, in every gcc artifact the machine produced. +# +# The assertion is deliberately about the artifact, not about the specs file: +# what matters is what a user ends up shipping. A RUNPATH entry under /tmp is +# never something a build asked for. +set -euo pipefail + +SELF_DIR=$(cd "$(dirname "$0")" && pwd) # captured before any cd +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +export MCPP_HOME=$HOME/.mcpp + +cd "$TMP" +"$MCPP" new prog > /dev/null +cd prog +cat >> mcpp.toml <<'EOF' + +[toolchain] +linux = "gcc@16.1.0" +EOF + +"$MCPP" build > "$TMP/build.log" 2>&1 || { cat "$TMP/build.log"; echo "build failed"; exit 1; } + +# A cold build, because the generated spec lives in target/ and a cold build +# deletes target/. Written the obvious way -- generate during prepare, consume +# during the build -- gcc got a `-specs=` naming a file that had been removed +# in between, and EVERY `--no-cache` build failed with `cannot read spec file`. +# The warm path above cannot see that: the file is still there from last time. +"$MCPP" build --no-cache > "$TMP/cold.log" 2>&1 || { + cat "$TMP/cold.log"; echo "cold build failed"; exit 1; } + +bin=$(find target -type f -name prog -path '*/bin/*' | head -1) +[[ -n "$bin" ]] || { echo "no binary produced"; exit 1; } + +# `readelf` may be an xvm shim needing a per-tool pin; use the payload's own. +readelf=$(ls "$MCPP_HOME"/registry/data/xpkgs/xim-x-binutils/*/bin/readelf 2>/dev/null | head -1) +[[ -x "$readelf" ]] || { echo "SKIP: no payload readelf to inspect with"; exit 0; } + +runpath=$("$readelf" -d "$bin" 2>/dev/null | grep -iE 'RUNPATH|RPATH' | sed 's/.*\[\(.*\)\]/\1/' || true) + +# 1. No path under a temp directory. These can only come from a specs file that +# some other home patched; no build ever asks for one. +if echo "$runpath" | tr ':' '\n' | grep -q '^/tmp/'; then + echo "the artifact carries RUNPATH entries from other homes' temp dirs:" + echo "$runpath" | tr ':' '\n' | grep '^/tmp/' | head -5 + echo " ...total $(echo "$runpath" | tr ':' '\n' | grep -c '^/tmp/')" + echo "These come from the payload's specs, which mcpp must no longer write." + exit 1 +fi + +# 2. Every entry must exist. A RUNPATH naming a directory that is not there is +# a cost paid on every symbol resolution for nothing, and a reliable sign +# that something wrote a path belonging to a different machine state. +missing=0 +while IFS= read -r d; do + [[ -z "$d" ]] && continue + [[ "$d" == \$ORIGIN* ]] && continue + [[ -d "$d" ]] || { echo " RUNPATH entry does not exist: $d"; missing=$((missing+1)); } +done <<< "$(echo "$runpath" | tr ':' '\n')" +[[ "$missing" -eq 0 ]] || { echo "$missing dead RUNPATH entries"; exit 1; } + +# 3. The interpreter is the payload's, and the program runs. +file_out=$(file "$bin") +echo "$file_out" | grep -q 'interpreter .*xim-x-glibc' || { + echo "PT_INTERP is not the payload's glibc:"; echo "$file_out"; exit 1; } +out=$("$bin" 2>&1) || { echo "binary did not run: $out"; exit 1; } +echo "$out" | grep -q 'Hello' || { echo "unexpected output: $out"; exit 1; } + +# 4. Nothing loads from the host. +# +# "Does it run" is not this question, and answering the wrong one is how a +# real gap survived: with the compiler's own lib dir missing from RUNPATH, +# artifacts resolved libgcc_s.so.1 from /lib and ran perfectly on any machine +# that has a host toolchain -- which is every developer machine. It failed +# only on CI's throw-away home, and only as `error while loading shared +# libraries`, with no indication of what had gone missing or why. +# +# Asking where each library actually came from makes the same defect visible +# everywhere, host toolchain or not. +# Only lines whose LEFT side is a bare soname count. ldd resolves the program +# interpreter by running the host's loader, so it always reports the +# interpreter as `/ld-linux-x86-64.so.2 => /lib64/ld-linux-x86-64.so.2` +# -- a statement about ldd, not about the artifact, and a false positive that +# failed this very check on CI. A real dependency has no slash on the left. +host_lib_lines() { + ldd "$1" 2>/dev/null \ + | grep -E '^[[:space:]]*[^/[:space:]]+ => +(/lib|/usr/lib|/lib64|/usr/lib64)/' \ + || true +} + +if command -v ldd > /dev/null 2>&1; then + host_libs=$(host_lib_lines "$bin") + if [[ -n "$host_libs" ]]; then + echo "the artifact loads libraries from the host:" + echo "$host_libs" | sed 's/^/ /' + echo "Every shared library a payload build needs is in the payload;" + echo "reaching outside it means a directory is missing from RUNPATH." + exit 1 + fi +fi + +# ── The same assertions again, in the OTHER link mode ──────────────────── +# +# RUNPATH is built by two separate paths -- CLibMode::Sysroot and +# CLibMode::PayloadFirst -- and a machine only ever takes one of them. This +# one has a usable sysroot, so everything above ran through Sysroot mode and +# PayloadFirst went untested here for as long as this file has existed. +# +# It was not academic: PayloadFirst omitted the compiler's own lib dir, so +# artifacts resolved libgcc_s.so.1 from the host. Invisible on any machine +# with a host toolchain; on CI's throw-away home it was `error while loading +# shared libraries` with no further explanation. +# +# A home with payloads but no subos usually has no sysroot to find, which is +# what selects PayloadFirst -- but "usually" is the honest word: where gcc +# reports a baked sysroot that happens to exist (another checkout on the same +# machine), mcpp accepts it as a last resort and this leg runs Sysroot mode a +# second time instead. Which is what it does on the machine these words were +# written on. +# +# So the deterministic guard for this invariant is the unit test +# (tests/unit/test_link_model_runtime_dirs.cpp), which names the mode rather +# than arranging for it. This leg is still worth running: it covers a second +# home shape end to end, and on CI -- where the baked path does not exist -- +# it is the real thing. +export MCPP_HOME="$TMP/payload-first-home" +MCPP_INHERIT_CONFIG=0 MCPP_INHERIT_SUBOS=0 \ + source "$SELF_DIR/_inherit_toolchain.sh" + +cd "$TMP" +"$MCPP" new pf > /dev/null +cd pf +cat >> mcpp.toml <<'EOF' + +[toolchain] +linux = "gcc@16.1.0" +EOF + +"$MCPP" build > "$TMP/pf.log" 2>&1 || { + cat "$TMP/pf.log"; echo "payload-first build failed"; exit 1; } + +pfbin=$(find target -type f -name pf -path '*/bin/*' | head -1) +[[ -n "$pfbin" ]] || { echo "no payload-first binary produced"; exit 1; } + +pf_host=$(host_lib_lines "$pfbin") +if [[ -n "$pf_host" ]]; then + echo "the payload-first artifact loads libraries from the host:" + echo "$pf_host" | sed 's/^/ /' + echo "RUNPATH was:" + "$readelf" -d "$pfbin" 2>/dev/null | grep -iE 'RUNPATH|RPATH' | sed 's/^/ /' + exit 1 +fi + +"$pfbin" > /dev/null 2>&1 || { + echo "payload-first binary did not run: $("$pfbin" 2>&1 | head -2)"; exit 1; } + +echo "PASS: gcc artifacts carry only the RUNPATH the build asked for and load" +echo " nothing from the host — in both sysroot and payload-first modes" diff --git a/tests/e2e/29_toolchain_partial_versions.sh b/tests/e2e/29_toolchain_partial_versions.sh index cf87ab1e..43823b77 100755 --- a/tests/e2e/29_toolchain_partial_versions.sh +++ b/tests/e2e/29_toolchain_partial_versions.sh @@ -89,7 +89,28 @@ file "$binary" | grep -q 'statically linked' && { echo "first-run default unexpectedly produced a static binary (glibc default expected)" exit 1 } -"$binary" >/dev/null 2>&1 || { echo "first-run binary did not run"; exit 1; } +# Say WHY when it does not. "did not run" alone is unactionable: an ELF fails +# to start for exactly a few reasons -- a missing interpreter, an unresolvable +# RUNPATH entry, a symbol the runtime does not have -- and which one it is is +# the whole answer. This assertion sat here reporting none of them while CI +# failed on it and every local run passed. +if ! out=$("$binary" 2>&1); then + rc=$? + echo "first-run binary did not run (exit $rc): $out" + interp=$(file "$binary" | sed -n 's/.*interpreter \([^,]*\).*/\1/p') + echo " PT_INTERP: ${interp:-}" + [[ -n "$interp" ]] && { [[ -e "$interp" ]] && echo " exists" \ + || echo " MISSING"; } + rp=$(readelf -d "$binary" 2>/dev/null \ + | sed -n 's/.*R\(UN\)\?PATH.*\[\(.*\)\]/\2/p') + echo " RUNPATH: ${rp:-}" + while IFS= read -r d; do + [[ -z "$d" ]] && continue + [[ -d "$d" ]] && echo " ok $d" || echo " MISSING $d" + done <<< "$(echo "$rp" | tr ':' '\n')" + ldd "$binary" 2>&1 | sed 's/^/ ldd: /' | head -12 + exit 1 +fi # Second build should be silent on toolchain — no re-install banner. "$MCPP" build > "$TMP/secondrun.log" 2>&1 || { diff --git a/tests/unit/test_fingerprint.cpp b/tests/unit/test_fingerprint.cpp index f6e10143..1fa185ce 100644 --- a/tests/unit/test_fingerprint.cpp +++ b/tests/unit/test_fingerprint.cpp @@ -24,6 +24,44 @@ FingerprintInputs baseline() { return in; } + +// Two builds that differ ONLY in which libc they target must not share a +// fingerprint -- and therefore must not share `target//`. +// +// Without this, switching subos leaves the build directory untouched: ninja +// sees the same graph, the objects inside were compiled against the other +// glibc, and the link succeeds. What comes out references symbols the +// interpreter cannot provide, and nothing along the way said anything. This +// is the prerequisite for building the same project under two subos, not a +// refinement of it. +TEST(Fingerprint, RuntimeBindingIsPartOfTheIdentity) { + mcpp::toolchain::FingerprintInputs a; + a.toolchain.compiler = mcpp::toolchain::CompilerId::GCC; + a.toolchain.version = "16.1.0"; + a.toolchain.targetTriple = "x86_64-linux-gnu"; + a.toolchain.runtimeBinding = "glibc@2.39"; + + auto b = a; + b.toolchain.runtimeBinding = "glibc@2.44"; + + EXPECT_NE(mcpp::toolchain::compute_fingerprint(a).hex, + mcpp::toolchain::compute_fingerprint(b).hex) + << "same toolchain, same triple, different libc — sharing target// " + "would replay objects compiled against the other one"; +} + +// ...and an unset binding is its own identity, not a collision with any +// particular version. +TEST(Fingerprint, EmptyRuntimeBindingIsDistinct) { + mcpp::toolchain::FingerprintInputs a; + a.toolchain.targetTriple = "x86_64-linux-gnu"; + a.toolchain.runtimeBinding = "glibc@2.39"; + auto b = a; + b.toolchain.runtimeBinding.clear(); + EXPECT_NE(mcpp::toolchain::compute_fingerprint(a).hex, + mcpp::toolchain::compute_fingerprint(b).hex); +} + } // namespace TEST(Fingerprint, DeterministicForSameInputs) { diff --git a/tests/unit/test_link_model_runtime_dirs.cpp b/tests/unit/test_link_model_runtime_dirs.cpp new file mode 100644 index 00000000..c38b46b8 --- /dev/null +++ b/tests/unit/test_link_model_runtime_dirs.cpp @@ -0,0 +1,122 @@ +// An artifact must find every library it needs inside the sandbox — in BOTH +// link modes. +// +// RUNPATH is assembled by two separate paths, CLibMode::Sysroot and +// CLibMode::PayloadFirst, and any given machine only ever takes one of them. +// The compiler's own runtime (libgcc_s.so.1) lives beside the compiler, not in +// the C library, and a produced binary links it whether or not the build ever +// mentions it. gcc's patched specs used to supply that directory; removing the +// specs rewrite therefore has to supply it explicitly — and at first only the +// Sysroot path did. +// +// The consequence was invisible where it was written: on a machine with a host +// toolchain the artifact simply resolved libgcc_s.so.1 from /lib and ran. It +// failed only in a throw-away home on CI, as `error while loading shared +// libraries` with nothing said about what was missing. +// +// Asserted here rather than in e2e on purpose. Which mode an e2e reaches +// depends on whether the machine happens to have a usable sysroot — this one +// does, so an e2e leg written for PayloadFirst quietly ran Sysroot twice and +// passed with the defect in place. The link model takes its mode from its +// input, so a test can name the mode instead of hoping for it. + +#include + +import std; +import mcpp.toolchain.linkmodel; +import mcpp.toolchain.model; + +namespace tc = mcpp::toolchain; + +namespace { + +void touch(const std::filesystem::path& p) { + std::filesystem::create_directories(p.parent_path()); + std::ofstream(p) << "x"; +} + +// A payload tree with a glibc beside a gcc, as xlings lays them out. +struct Payload { + std::filesystem::path root, compiler, glibcLib, gccLib, sysroot; + Payload() { + root = std::filesystem::temp_directory_path() + / std::format("mcpp_linkmodel_{}", std::random_device{}()); + auto xpkgs = root / "data" / "xpkgs"; + compiler = xpkgs / "xim-x-gcc" / "16.1.0" / "bin" / "g++"; + touch(compiler); + gccLib = xpkgs / "xim-x-gcc" / "16.1.0" / "lib64"; + touch(gccLib / "libgcc_s.so.1"); + glibcLib = xpkgs / "xim-x-glibc" / "2.39" / "lib64"; + touch(glibcLib / "libc.so.6"); + touch(glibcLib / "ld-linux-x86-64.so.2"); + touch(xpkgs / "xim-x-glibc" / "2.39" / "include" / "features.h"); + sysroot = root / "subos" / "default"; + touch(sysroot / "usr" / "include" / "stdlib.h"); + } + ~Payload() { std::error_code ec; std::filesystem::remove_all(root, ec); } + + tc::Toolchain toolchain(bool withSysroot) const { + tc::Toolchain t; + t.compiler = tc::CompilerId::GCC; + t.version = "16.1.0"; + t.binaryPath = compiler; + t.targetTriple = "x86_64-linux-gnu"; + t.runtimeBinding = "glibc@2.39"; + tc::PayloadPaths pp; + pp.glibcLib = glibcLib; + pp.glibcInclude = glibcLib.parent_path() / "include"; + t.payloadPaths = pp; + if (withSysroot) t.sysroot = sysroot; + return t; + } +}; + +std::string joined(const tc::ToolchainLinkModel& lm) { + auto id = [](const std::filesystem::path& p) { return p.string(); }; + std::string s; + for (auto& tok : lm.link_tokens(id)) { s += tok; s += ' '; } + return s; +} + +TEST(LinkModelRuntimeDirs, PayloadFirstCarriesTheCompilerRuntime) { + Payload p; + auto lm = tc::resolve_link_model(p.toolchain(/*withSysroot=*/false)); + ASSERT_EQ(lm.mode, tc::CLibMode::PayloadFirst); + auto line = joined(lm); + EXPECT_NE(line.find("-Wl,-rpath," + p.gccLib.string()), std::string::npos) + << "libgcc_s.so.1 lives here and nothing else will find it:\n" << line; +} + +TEST(LinkModelRuntimeDirs, SysrootCarriesTheCompilerRuntime) { + Payload p; + auto lm = tc::resolve_link_model(p.toolchain(/*withSysroot=*/true)); + ASSERT_EQ(lm.mode, tc::CLibMode::Sysroot); + auto line = joined(lm); + EXPECT_NE(line.find("-Wl,-rpath," + p.gccLib.string()), std::string::npos) + << line; +} + +// The C library's own directory, in both modes, for the same reason. +TEST(LinkModelRuntimeDirs, BothModesCarryTheCLibrary) { + Payload p; + for (bool withSysroot : {false, true}) { + auto line = joined(tc::resolve_link_model(p.toolchain(withSysroot))); + EXPECT_NE(line.find("-Wl,-rpath," + p.glibcLib.string()), + std::string::npos) + << "withSysroot=" << withSysroot << "\n" << line; + } +} + +// And the interpreter. A sysroot says where headers live; it says nothing +// about which loader runs the result, and gcc's `*link:` no longer answers. +TEST(LinkModelRuntimeDirs, BothModesNameTheInterpreter) { + Payload p; + for (bool withSysroot : {false, true}) { + auto line = joined(tc::resolve_link_model(p.toolchain(withSysroot))); + EXPECT_NE(line.find("--dynamic-linker=" + p.glibcLib.string()), + std::string::npos) + << "withSysroot=" << withSysroot << "\n" << line; + } +} + +} // namespace diff --git a/tests/unit/test_linkmodel.cpp b/tests/unit/test_linkmodel.cpp index 4b7eb1cd..a4f3b5c2 100644 --- a/tests/unit/test_linkmodel.cpp +++ b/tests/unit/test_linkmodel.cpp @@ -180,7 +180,7 @@ TEST(LinkModel, GccSysrootSupplementsMissingKernelHeaders) { EXPECT_NE(lm.compile_flags(ident).find("-isystem"), std::string::npos); } -TEST(LinkModel, GccPayloadUsesIdirafterAndNoLoader) { +TEST(LinkModel, GccPayloadEmitsIdirafterAndItsOwnAddressing) { Tmp dir; auto glibcLib = dir.path / "glibc" / "lib64"; touch(glibcLib / "ld-linux-x86-64.so.2"); @@ -194,12 +194,20 @@ TEST(LinkModel, GccPayloadUsesIdirafterAndNoLoader) { }; auto lm = tc::resolve_link_model(t); EXPECT_EQ(lm.mode, tc::CLibMode::PayloadFirst); + // Headers still differ by driver: libstdc++'s #include_next wrappers need + // -idirafter, and that has not changed. EXPECT_NE(lm.compile_flags(ident).find("-idirafter"), std::string::npos); + + // Addressing no longer differs. GCC used to be left to its install-time + // specs here, which made the RUN side a per-toolchain-install decision + // while the COMPILE side stayed per-build -- and the two named different + // glibc versions as soon as a second one was installed. mcpp already + // refuses to depend on clang's install-time cfg for exactly this reason. auto link = lm.link_flags(ident); EXPECT_NE(link.find(" -B"), std::string::npos); - // GCC's loader/rpath is owned by the specs fixup, not the command line. - EXPECT_EQ(link.find("dynamic-linker"), std::string::npos); - EXPECT_EQ(link.find("-rpath"), std::string::npos); + EXPECT_NE(link.find("--dynamic-linker=" + lm.loader.string()), + std::string::npos); + EXPECT_NE(link.find("-Wl,-rpath," + glibcLib.string()), std::string::npos); } TEST(LinkModel, NothingUsableYieldsNoneAndEmptyFlags) { @@ -212,4 +220,20 @@ TEST(LinkModel, NothingUsableYieldsNoneAndEmptyFlags) { EXPECT_TRUE(lm.link_flags(ident).empty()); } + +// 关于 `find_sibling_tool` 的顺序:**不在这里测**。 +// +// 它的注释曾写着 "Return the first (highest) version dir",而代码只是取 +// directory_iterator 的第一项 —— 没有排序。为它写一条断言的尝试失败了: +// 结果取决于 readdir 顺序,所以那条测试在本机通过、在别的机器上可能失败, +// **通过和失败都不说明问题**。 +// +// 正确的做法不是把不确定性钉住,而是让它不再被用于选版本:版本由权威 +// (`--runtime` / subos 的 `subos_info.runtime`)给出,`find_sibling_tool` +// 只负责定位包根。确定性的判据在 test_toolchain_probe.cpp —— 给定 +// `glibc@2.39`,即使 home 里同时有 2.44,也必须返回 2.39。 +// +// 注释本身已改为陈述事实(顺序未定义,调用方不得依赖)。 +// 设计:.agents/docs/2026-08-08-payload-version-and-contract-drift-design.md §3.2 + } // namespace diff --git a/tests/unit/test_runtime_binding_fallback.cpp b/tests/unit/test_runtime_binding_fallback.cpp new file mode 100644 index 00000000..b61e0d95 --- /dev/null +++ b/tests/unit/test_runtime_binding_fallback.cpp @@ -0,0 +1,132 @@ +// When nothing declares the runtime, how far may mcpp go on its own? +// +// The rule this replaced asked a directory for "the glibc" and took the first +// entry readdir yielded. That is a CHOICE made by something with no bearing on +// what the artifact loads, and it went wrong the moment a dependency's +// `xim:glibc@>=2.38` floor installed a second payload: the compile side took +// 2.44 while the interpreter still named 2.39. +// +// So the axis is not "may mcpp look at the payload directory" -- it is +// "is there anything to choose between". One payload is an answer; two are a +// question only the subos can settle. +// +// This mattered because the compatibility sources (gcc's specs, clang's cfg, +// the compiler's PT_INTERP) are all things some earlier mechanism wrote, and a +// machine can legitimately have none of them. + +#include + +import std; +import mcpp.toolchain.post_install; + +namespace tc = mcpp::toolchain; + +namespace { + +struct Home { + std::filesystem::path root, compiler; + explicit Home(std::initializer_list glibcVersions) { + root = std::filesystem::temp_directory_path() + / std::format("mcpp_binding_{}", std::random_device{}()); + auto xpkgs = root / "data" / "xpkgs"; + compiler = xpkgs / "xim-x-gcc" / "16.1.0" / "bin" / "g++"; + std::filesystem::create_directories(compiler.parent_path()); + std::ofstream(compiler) << "not an elf"; + for (auto v : glibcVersions) + std::filesystem::create_directories( + xpkgs / "xim-x-glibc" / v / "lib64"); + } + ~Home() { std::error_code ec; std::filesystem::remove_all(root, ec); } +}; + +TEST(RuntimeBindingFallback, OnePayloadIsAnAnswer) { + Home h{"2.39"}; + EXPECT_EQ(tc::baked_runtime_binding(h.compiler), "glibc@2.39"); +} + +// The incident, in one assertion. Two payloads and nothing declaring which: +// silence, not a coin flip. +TEST(RuntimeBindingFallback, TwoPayloadsIsSilence) { + Home h{"2.39", "2.44"}; + EXPECT_EQ(tc::baked_runtime_binding(h.compiler), ""); +} + +TEST(RuntimeBindingFallback, NoPayloadIsSilence) { + Home h{}; + EXPECT_EQ(tc::baked_runtime_binding(h.compiler), ""); +} + +// A compiler outside any xpkgs tree (a system gcc) has no payload set to read, +// and must not acquire one from somewhere else. +TEST(RuntimeBindingFallback, NonPayloadCompilerGetsNothing) { + EXPECT_EQ(tc::baked_runtime_binding("/usr/bin/g++"), ""); +} + +TEST(RuntimeBindingFallback, EmptyPathIsSilence) { + EXPECT_EQ(tc::baked_runtime_binding({}), ""); +} + +// A record that names a payload which is no longer installed. +// +// specs, cfg and PT_INTERP are all RECORDS of a past state: written at some +// install or some fixup, and never revisited when the payload underneath is +// replaced. CI hit exactly this -- a record said glibc@2.39 while the only +// payload on disk was 2.44 -- and because the probe matches exactly and never +// falls back, nothing resolved, no loader reached the link line, and the +// artifact took the host's. A fossil naming something absent is not an +// authority; it is just old, and the resolution must carry on past it. +struct HomeWithSpecs { + std::filesystem::path root, compiler; + HomeWithSpecs(std::initializer_list glibcVersions, + std::string_view specsNames) { + root = std::filesystem::temp_directory_path() + / std::format("mcpp_fossil_{}", std::random_device{}()); + auto xpkgs = root / "data" / "xpkgs"; + auto gccRoot = xpkgs / "xim-x-gcc" / "16.1.0"; + compiler = gccRoot / "bin" / "g++"; + std::filesystem::create_directories(compiler.parent_path()); + std::ofstream(compiler) << "not an elf"; + for (auto v : glibcVersions) + std::filesystem::create_directories(xpkgs / "xim-x-glibc" / v / "lib64"); + auto sd = gccRoot / "lib" / "gcc" / "x86_64-linux-gnu" / "16.1.0"; + std::filesystem::create_directories(sd); + std::ofstream(sd / "specs") + << "*link:\n%{!static:--dynamic-linker " << xpkgs.string() + << "/xim-x-glibc/" << specsNames << "/lib64/ld-linux-x86-64.so.2}\n"; + } + ~HomeWithSpecs() { std::error_code ec; std::filesystem::remove_all(root, ec); } +}; + +TEST(RuntimeBindingFallback, RecordNamingAnAbsentPayloadIsSkipped) { + HomeWithSpecs h{{"2.44"}, "2.39"}; + // Not "glibc@2.39": that payload is gone. The singleton rule answers. + EXPECT_EQ(tc::baked_runtime_binding(h.compiler), "glibc@2.44"); +} + +// Linux only, and not as an exemption -- the mechanism does not exist +// elsewhere. detect_baked_loader parses GCC specs, whose loader paths are +// POSIX and absolute, and its character whitelist deliberately excludes `\` +// and `:` so that a `%{...}` spec body is never swallowed. A Windows temp +// directory is `C:\Users\...`, so this fixture cannot be spelled there at +// all: the scan stops at the first backslash and the result does not start +// with `/`. Rightly so -- there are no glibc payloads or ld-linux loaders on +// Windows for it to find. +// +// The other cases in this file are platform-neutral (they exercise the +// payload set, not the parser) and keep running everywhere. +TEST(RuntimeBindingFallback, RecordNamingAPresentPayloadWins) { + if constexpr (!std::filesystem::path::preferred_separator || + std::filesystem::path::preferred_separator != '/') + GTEST_SKIP() << "GCC specs loader paths are POSIX; no such record here"; + HomeWithSpecs h{{"2.39", "2.44"}, "2.39"}; + // Two installed, so the singleton rule cannot answer -- but the record + // names one that IS there, and that is what the artifact would load. + EXPECT_EQ(tc::baked_runtime_binding(h.compiler), "glibc@2.39"); +} + +TEST(RuntimeBindingFallback, StaleRecordWithNothingToFallBackOnIsSilence) { + HomeWithSpecs h{{"2.39", "2.44"}, "2.28"}; + EXPECT_EQ(tc::baked_runtime_binding(h.compiler), ""); +} + +} // namespace diff --git a/tests/unit/test_subos_info.cpp b/tests/unit/test_subos_info.cpp index 95385778..a7f27af2 100644 --- a/tests/unit/test_subos_info.cpp +++ b/tests/unit/test_subos_info.cpp @@ -37,14 +37,14 @@ TEST(SubosInfo, ReadsRuntimeAndEnvDeclarations) { "subos_info": { "schema_version": 1, "runtime": "glibc@2.39", - "envs": [ - { "binding": "mesa@25.0.7.1", "decls": [ + "envs": { + "mesa@25.0.7.1": [ { "var": "LIBGL_DRIVERS_PATH", "op": "prepend", "value": "${subosdir}/usr/lib/dri" }, { "var": "XDG_DATA_DIRS", "op": "prepend", "value": "${subosdir}/share" } - ]} - ] + ] + } } })"); auto info = su::read(t.dir); @@ -64,8 +64,8 @@ TEST(SubosInfo, ReadsRuntimeAndEnvDeclarations) { TEST(SubosInfo, ResolvesSubosdirPlaceholder) { Tmp t; t.write(R"({"subos_info":{"schema_version":1,"runtime":"glibc@2.39", - "envs":[{"binding":"mesa@1","decls":[ - {"var":"LIBGL_DRIVERS_PATH","op":"prepend","value":"${subosdir}/usr/lib/dri"}]}]}})"); + "envs":{"mesa@1":[ + {"var":"LIBGL_DRIVERS_PATH","op":"prepend","value":"${subosdir}/usr/lib/dri"}]}}})"); auto env = su::resolve_env(su::read(t.dir), t.dir); ASSERT_EQ(env.size(), 1u); EXPECT_EQ(env[0].first, "LIBGL_DRIVERS_PATH"); @@ -81,11 +81,9 @@ TEST(SubosInfo, ResolvesSubosdirPlaceholder) { // EGL vendor directory. `prepend` joins them; it must not drop either. TEST(SubosInfo, PrependJoinsProvidersInOrder) { Tmp t; - t.write(R"({"subos_info":{"schema_version":1,"runtime":"glibc@2.39","envs":[ - {"binding":"a-mesa@1","decls":[ - {"var":"V","op":"prepend","value":"${subosdir}/one"}]}, - {"binding":"b-vendor@1","decls":[ - {"var":"V","op":"prepend","value":"${subosdir}/two"}]}]}})"); + t.write(R"({"subos_info":{"schema_version":1,"runtime":"glibc@2.39","envs":{ + "a-mesa@1":[{"var":"V","op":"prepend","value":"${subosdir}/one"}], + "b-vendor@1":[{"var":"V","op":"prepend","value":"${subosdir}/two"}]}}})"); auto env = su::resolve_env(su::read(t.dir), t.dir); ASSERT_EQ(env.size(), 1u); const auto sep = mcpp::platform::env::path_list_separator(); @@ -98,9 +96,9 @@ TEST(SubosInfo, PrependJoinsProvidersInOrder) { // would otherwise grow the variable without bound. TEST(SubosInfo, PrependDeduplicates) { Tmp t; - t.write(R"({"subos_info":{"schema_version":1,"runtime":"glibc@2.39","envs":[ - {"binding":"a@1","decls":[{"var":"V","op":"prepend","value":"${subosdir}/x"}]}, - {"binding":"b@1","decls":[{"var":"V","op":"prepend","value":"${subosdir}/x"}]}]}})"); + t.write(R"({"subos_info":{"schema_version":1,"runtime":"glibc@2.39","envs":{ + "a@1":[{"var":"V","op":"prepend","value":"${subosdir}/x"}], + "b@1":[{"var":"V","op":"prepend","value":"${subosdir}/x"}]}}})"); auto env = su::resolve_env(su::read(t.dir), t.dir); ASSERT_EQ(env.size(), 1u); // One entry, not two. The de-duplication has to split on the PLATFORM's @@ -114,9 +112,9 @@ TEST(SubosInfo, PrependDeduplicates) { // `set` replaces rather than joins — xlings's own precedence. TEST(SubosInfo, SetReplaces) { Tmp t; - t.write(R"({"subos_info":{"schema_version":1,"runtime":"glibc@2.39","envs":[ - {"binding":"a@1","decls":[{"var":"V","op":"prepend","value":"/one"}]}, - {"binding":"b@1","decls":[{"var":"V","op":"set","value":"/two"}]}]}})"); + t.write(R"({"subos_info":{"schema_version":1,"runtime":"glibc@2.39","envs":{ + "a@1":[{"var":"V","op":"prepend","value":"/one"}], + "b@1":[{"var":"V","op":"set","value":"/two"}]}}})"); auto env = su::resolve_env(su::read(t.dir), t.dir); ASSERT_EQ(env.size(), 1u); EXPECT_EQ(env[0].second, "/two"); @@ -177,4 +175,78 @@ TEST(SubosInfo, FamilyOfMirrorsXlings) { EXPECT_EQ(su::family_of("glibc"), "linux-x86_64-glibc"); } + +// A VERBATIM capture of what a real xlings wrote, after `xlings install +// graphics` on an NVIDIA host. Reformatted for width and nothing else -- keys, +// nesting and spelling are as found on disk. +// +// This test exists because its absence shipped a broken feature. The first +// version of this file hand-wrote every fixture in a shape the reader also +// expected and xlings never produces: `envs` as an array of {binding, decls}. +// Ten tests passed against a format that does not exist, and against a real +// subos the released build applied no variables at all -- silently, because +// "no providers" and "nothing declared" look identical. +// +// A fixture composed from the same understanding as the parser cannot catch +// that. Only one taken from the writer can. +TEST(SubosInfo, RealXlingsCapture) { + Tmp t; + t.write(R"({ + "subos_info": { + "created_at": "2026-08-08T01:40:00Z", + "created_by": "xlings 2026.8.7.1", + "runtime": "glibc@2.39", + "schema_version": 1, + "envs": { + "mesa@25.0.7.1": [ + {"op": "prepend", "value": "${subosdir}/usr/lib/dri", "var": "LIBGL_DRIVERS_PATH"}, + {"op": "prepend", "value": "${subosdir}/share/glvnd/egl_vendor.d", "var": "__EGL_VENDOR_LIBRARY_DIRS"}, + {"op": "prepend", "value": "${subosdir}/share", "var": "XDG_DATA_DIRS"} + ], + "nvidia-gl-host-link@0.1.1": [ + {"op": "prepend", "value": "${subosdir}/share/glvnd/egl_vendor.d", "var": "__EGL_VENDOR_LIBRARY_DIRS"} + ] + } + }, + "workspace": {} + })"); + + auto info = su::read(t.dir); + ASSERT_TRUE(info.present); + EXPECT_EQ(info.runtime, "glibc@2.39"); + ASSERT_EQ(info.providers.size(), 2u); + EXPECT_EQ(info.providers[0].binding, "mesa@25.0.7.1"); + EXPECT_EQ(info.providers[1].binding, "nvidia-gl-host-link@0.1.1"); + + auto env = su::resolve_env(info, t.dir); + ASSERT_EQ(env.size(), 3u) << "all three graphics variables must be produced"; + + std::map byVar; + for (auto& [k, v] : env) byVar[k] = v; + const auto sep = mcpp::platform::env::path_list_separator(); + EXPECT_EQ(byVar["LIBGL_DRIVERS_PATH"], t.dir.string() + "/usr/lib/dri"); + EXPECT_EQ(byVar["XDG_DATA_DIRS"], t.dir.string() + "/share"); + // Both providers name the same vendor directory; de-duplication must + // leave exactly one, or libglvnd sees it twice and enumerates the device + // twice -- which is a defect xlings hit on its own side. + EXPECT_EQ(byVar["__EGL_VENDOR_LIBRARY_DIRS"], + t.dir.string() + "/share/glvnd/egl_vendor.d"); + EXPECT_EQ(byVar["__EGL_VENDOR_LIBRARY_DIRS"].find(sep), std::string::npos); +} + +// xlings drops a declaration whose op it does not recognise. A reader more +// permissive than its writer eventually applies something the writer meant to +// reject, so this asserts the same refusal rather than a tolerant guess. +TEST(SubosInfo, UnknownOpIsDroppedLikeXlingsDrops) { + Tmp t; + t.write(R"({"subos_info":{"schema_version":1,"runtime":"glibc@2.39","envs":{ + "a@1":[{"var":"V","op":"append","value":"/nope"}, + {"var":"W","op":"prepend","value":"/yes"}, + {"var":"","op":"prepend","value":"/no-name"}]}}})"); + auto env = su::resolve_env(su::read(t.dir), t.dir); + ASSERT_EQ(env.size(), 1u); + EXPECT_EQ(env[0].first, "W"); + EXPECT_EQ(env[0].second, "/yes"); +} + } // namespace diff --git a/tests/unit/test_sysroot_ownership.cpp b/tests/unit/test_sysroot_ownership.cpp new file mode 100644 index 00000000..56e1f6be --- /dev/null +++ b/tests/unit/test_sysroot_ownership.cpp @@ -0,0 +1,87 @@ +// A sysroot is trusted because of where it lives, not because it is there. +// +// gcc records `--sysroot=<...>/.xlings/subos/default` as a literal string when +// it is built, and keeps reporting that string in every project it later +// serves. On a machine with several checkouts, that path routinely EXISTS and +// belongs to a different one -- so the original predicate, "does the path +// exist", answered yes and the build silently took another project's headers. +// Measured: a build inside mcpp resolved a sysroot under an unrelated repo. +// +// These assertions pin the axis that replaced it. Nothing here touches the +// filesystem, deliberately: the point is that existence is not consulted. + +#include + +import std; +import mcpp.fallback.probe_sysroot; + +namespace fb = mcpp::fallback; + +namespace { + +const std::filesystem::path kRegistry = "/home/u/.mcpp/registry"; +const std::filesystem::path kProject = "/home/u/work/thisrepo"; + +TEST(SysrootOwnership, PayloadSysrootIsOwned) { + EXPECT_FALSE(fb::sysroot_is_foreign( + kRegistry / "subos" / "default", kRegistry, kProject)); +} + +TEST(SysrootOwnership, ProjectLocalSysrootIsOwned) { + EXPECT_FALSE(fb::sysroot_is_foreign( + kProject / ".xlings" / "subos" / "default", kRegistry, kProject)); +} + +// The case that motivated the predicate: a path that exists, is spelled +// exactly like a legitimate one, and belongs to someone else. +TEST(SysrootOwnership, AnotherCheckoutIsForeign) { + EXPECT_TRUE(fb::sysroot_is_foreign( + "/home/u/work/otherrepo/.xlings/subos/default", kRegistry, kProject)); +} + +TEST(SysrootOwnership, AnotherHomeIsForeign) { + EXPECT_TRUE(fb::sysroot_is_foreign( + "/home/other/.mcpp/registry/subos/default", kRegistry, kProject)); +} + +// Both anchors are load-bearing. Registry alone would condemn every +// project-local tree; project alone would condemn every payload sysroot. +TEST(SysrootOwnership, EitherAnchorSuffices) { + EXPECT_FALSE(fb::sysroot_is_foreign( + kRegistry / "subos" / "default", kRegistry, {})); + EXPECT_FALSE(fb::sysroot_is_foreign( + kProject / ".xlings" / "subos" / "default", {}, kProject)); +} + +// No sysroot is not a foreign sysroot. gcc payloads on this machine report an +// empty `-print-sysroot`, so this is the ordinary case, not an edge one. +TEST(SysrootOwnership, EmptyIsNotForeign) { + EXPECT_FALSE(fb::sysroot_is_foreign({}, kRegistry, kProject)); +} + +// A prefix that merely shares characters is not containment. +TEST(SysrootOwnership, SiblingPrefixIsNotContainment) { + EXPECT_TRUE(fb::sysroot_is_foreign( + "/home/u/work/thisrepo-backup/.xlings/subos/default", + kRegistry, kProject)); +} + +// A directory whose NAME begins with two dots is not an escape. The first +// version compared the relative path's text (`rfind("..", 0)`), which called +// `/home/u/.mcpp/registry/..cache` an escape from the registry -- and did not +// compile at all on Windows, where `native()` is a wstring. Containment is a +// question about path components, so it is asked of components. +TEST(SysrootOwnership, DotDotPrefixedNameIsNotAnEscape) { + EXPECT_FALSE(fb::sysroot_is_foreign( + kRegistry / "..cache" / "subos" / "default", kRegistry, kProject)); +} + +TEST(SysrootOwnership, PathIsUnderIsDirectlyTestable) { + EXPECT_TRUE(fb::path_is_under(kRegistry / "a" / "b", kRegistry)); + EXPECT_TRUE(fb::path_is_under(kRegistry, kRegistry)); + EXPECT_FALSE(fb::path_is_under(kRegistry.parent_path(), kRegistry)); + EXPECT_FALSE(fb::path_is_under(kRegistry, {})); + EXPECT_FALSE(fb::path_is_under({}, kRegistry)); +} + +} // namespace diff --git a/tests/unit/test_toolchain_probe.cpp b/tests/unit/test_toolchain_probe.cpp new file mode 100644 index 00000000..8eff0b4b --- /dev/null +++ b/tests/unit/test_toolchain_probe.cpp @@ -0,0 +1,121 @@ +// Payload resolution: the version comes from an AUTHORITY, never from a scan. +// +// Before this, probe_payload_paths asked find_sibling_tool for "the glibc" +// and took whatever came back -- which is whatever readdir yielded first. With +// one glibc installed that is always right, so nothing ever forced it to be +// correct. Installing a second one (a dependency with a `>=` floor is enough) +// split the build in half: the compile side took one version while the +// artifact's interpreter, written into gcc's specs at install time, still +// named the other. Binaries then referenced GLIBC_2.42 symbols against a 2.39 +// runtime, and the failures landed on packages with nothing to do with the +// dependency that pulled the second glibc in. +// +// These assertions are about determinism, not about preference. "Given +// glibc@2.39, return 2.39" must hold with 2.44 also present and must not +// depend on directory order -- which is why the fixture always installs both. +// +// Design: .agents/docs/2026-08-08-payload-version-and-contract-drift-design.md §3.2 + +#include + +import std; +import mcpp.toolchain.probe; + +namespace { + +namespace tc = mcpp::toolchain; + +struct Tmp { + std::filesystem::path path; + Tmp() { + path = std::filesystem::temp_directory_path() + / std::format("mcpp_probe_test_{}", std::random_device{}()); + std::filesystem::create_directories(path); + } + ~Tmp() { std::error_code ec; std::filesystem::remove_all(path, ec); } +}; + +void touch(const std::filesystem::path& p) { + std::filesystem::create_directories(p.parent_path()); + std::ofstream(p) << "x"; +} + +// A home with TWO glibc payloads. Both are always present: a fixture with one +// cannot tell a resolver that reads the authority from one that guesses. +struct TwoGlibcHome { + Tmp dir; + std::filesystem::path compilerBin; + TwoGlibcHome() { + auto xpkgs = dir.path / "data" / "xpkgs"; + compilerBin = xpkgs / "xim-x-gcc" / "16.1.0" / "bin" / "g++"; + touch(compilerBin); + for (auto v : {"2.39", "2.44"}) { + auto root = xpkgs / "xim-x-glibc" / v; + touch(root / "include" / "features.h"); + touch(root / "lib64" / "libc.so.6"); + touch(root / "lib64" / "ld-linux-x86-64.so.2"); + } + } + std::filesystem::path libOf(std::string_view v) const { + return dir.path / "data" / "xpkgs" / "xim-x-glibc" / std::string(v) / "lib64"; + } +}; + +TEST(PayloadProbe, ResolvesTheBindingExactly) { + TwoGlibcHome h; + auto pp = tc::probe_payload_paths(h.compilerBin, "glibc@2.39"); + ASSERT_TRUE(pp.has_value()); + EXPECT_EQ(pp->glibcLib, h.libOf("2.39")) + << "the authority said 2.39 and 2.44 is also installed; a resolver " + "that scans would be free to return either"; +} + +TEST(PayloadProbe, ResolvesTheOtherBindingExactly) { + TwoGlibcHome h; + auto pp = tc::probe_payload_paths(h.compilerBin, "glibc@2.44"); + ASSERT_TRUE(pp.has_value()); + EXPECT_EQ(pp->glibcLib, h.libOf("2.44")); +} + +// No authority is not "pick something sensible" -- it is a refusal. Guessing +// here is what produced a build whose compile and run halves disagreed, and +// the guess is invisible: every artifact looks normal until one links a +// library built against the other version. +TEST(PayloadProbe, NoBindingIsARefusalNotAGuess) { + TwoGlibcHome h; + EXPECT_FALSE(tc::probe_payload_paths(h.compilerBin, "").has_value()); +} + +// A binding naming a version that is not installed must fail rather than fall +// back to one that is. Falling back would reintroduce the split silently. +TEST(PayloadProbe, MissingVersionDoesNotFallBack) { + TwoGlibcHome h; + EXPECT_FALSE(tc::probe_payload_paths(h.compilerBin, "glibc@2.28").has_value()); +} + +// The binding's NAME matters too: `musl@1.2.5` must not resolve a glibc +// payload just because the version string happens to be parseable. +TEST(PayloadProbe, WrongRuntimeFamilyDoesNotResolveGlibc) { + TwoGlibcHome h; + EXPECT_FALSE(tc::probe_payload_paths(h.compilerBin, "musl@1.2.5").has_value()); +} + +// Layout: lib64 preferred, lib accepted. Kept from the original probe -- a +// payload that ships only `lib` is a real shape, and this is the one piece of +// convention that stays, because it is about WHERE inside a chosen payload, +// not about WHICH payload. +TEST(PayloadProbe, AcceptsLibWhenThereIsNoLib64) { + Tmp dir; + auto xpkgs = dir.path / "data" / "xpkgs"; + auto bin = xpkgs / "xim-x-gcc" / "16.1.0" / "bin" / "g++"; + touch(bin); + auto root = xpkgs / "xim-x-glibc" / "2.39"; + touch(root / "include" / "features.h"); + touch(root / "lib" / "ld-linux-x86-64.so.2"); + + auto pp = tc::probe_payload_paths(bin, "glibc@2.39"); + ASSERT_TRUE(pp.has_value()); + EXPECT_EQ(pp->glibcLib, root / "lib"); +} + +} // namespace diff --git a/tests/unit/test_xlings_version_pin.cpp b/tests/unit/test_xlings_version_pin.cpp new file mode 100644 index 00000000..131c5e8a --- /dev/null +++ b/tests/unit/test_xlings_version_pin.cpp @@ -0,0 +1,66 @@ +// The vendored xlings is replaced when it is BEHIND the pin, and only then. +// +// acquire_xlings_binary used to return on mere existence, so a home kept +// whatever xlings it first acquired forever -- measured at 2026.8.2.1 against +// a pin of 2026.8.6.3, with `mcpp self env` printing both numbers next to each +// other and saying nothing. That is not cosmetic: the subos_info block arrived +// in xlings 2026.8.5.1, so on that machine the graphics packages' declarations +// were discarded by a client too old to have the API, and the fix for +// mcpp#352 could never take effect no matter how current mcpp itself was. +// +// The comparison is STRICTLY older, not "not equal". A user who deliberately +// put a newer xlings there must not be downgraded by an mcpp pinning an older +// one, and an unparseable version is not evidence of being behind. + +#include + +import std; +import mcpp.fallback.xlings_binary; + +namespace fb = mcpp::fallback; + +namespace { + +TEST(XlingsVersionPin, OlderIsBehind) { + EXPECT_TRUE(fb::version_is_older("2026.8.2.1", "2026.8.6.3")); + EXPECT_TRUE(fb::version_is_older("2026.7.31.3", "2026.8.1.1")); + EXPECT_TRUE(fb::version_is_older("2026.8.6", "2026.8.6.1")); +} + +TEST(XlingsVersionPin, EqualIsNotBehind) { + EXPECT_FALSE(fb::version_is_older("2026.8.6.3", "2026.8.6.3")); + // Trailing zeros compare equal, not older. + EXPECT_FALSE(fb::version_is_older("2026.8.6.0", "2026.8.6")); +} + +// The case that decides whether this is safe to run automatically: a user who +// put a NEWER xlings in place keeps it. +TEST(XlingsVersionPin, NewerIsNotDowngraded) { + EXPECT_FALSE(fb::version_is_older("2026.9.1.1", "2026.8.6.3")); + EXPECT_FALSE(fb::version_is_older("2027.1.1.1", "2026.12.31.9")); +} + +// A version this code cannot parse says nothing, and "says nothing" must not +// be read as "is behind" -- that would delete a working binary on a guess. +TEST(XlingsVersionPin, UnparseableIsNotBehind) { + EXPECT_FALSE(fb::version_is_older("dev", "2026.8.6.3")); + EXPECT_FALSE(fb::version_is_older("2026.8.6.3", "")); + EXPECT_FALSE(fb::version_is_older("", "2026.8.6.3")); + EXPECT_FALSE(fb::version_is_older("2026.8.x", "2026.8.6.3")); +} + +// The version scheme changed epochs: xlings went 0.4.x -> YYYY.M.D.N. Both +// live on the same disk, and a home's system xlings may still be a 0.4.x while +// its vendored one is already dated. Getting this backwards is not academic -- +// the first cut of the replace-when-behind logic deleted the vendored binary +// and re-acquired from `which xlings`, which on this developer machine turned +// 2026.8.2.1 into 0.4.51: older still, and equally missing the very feature +// the replacement existed to restore. Being behind the pin justifies looking +// for a replacement; it does not justify accepting whatever turns up. +TEST(XlingsVersionPin, DatedSchemeIsNewerThanTheOldOne) { + EXPECT_FALSE(fb::version_is_older("2026.8.2.1", "0.4.51")); + EXPECT_TRUE(fb::version_is_older("0.4.51", "2026.8.2.1")); + EXPECT_TRUE(fb::version_is_older("0.4.51", "0.4.54")); +} + +} // namespace From 6e6baa4c074f398d7ba5132ff2ad54a20c3c995e Mon Sep 17 00:00:00 2001 From: SPeak Agent Date: Sat, 8 Aug 2026 15:57:54 +0800 Subject: [PATCH 04/54] =?UTF-8?q?docs:=20=E8=AE=A1=E5=88=92=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E6=A0=87=E8=AE=B0=E4=B8=BA=E5=B7=B2=E5=AE=8C=E6=88=90?= =?UTF-8?q?=20(#381)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 35 项全部未勾选,而 T1–T7 随 #378 合入并发布 2026.8.8.2、T8 随 mcpp-index #181 合入。一份 committed 的文档读起来像什么都没做,比没有文档更坏。 同时在文首写明两件没随本计划完成的:#352 的 GLX vendor 选择(根因已定位、修法已实验验证,待在 xim:nvidia-gl-host-link 实施),以及约定为独立 PR 的 `glibc>=X` 下限语义。 --- ...piler-as-capability-implementation-plan.md | 81 +++++++++++-------- 1 file changed, 46 insertions(+), 35 deletions(-) diff --git a/.agents/docs/2026-08-08-compiler-as-capability-implementation-plan.md b/.agents/docs/2026-08-08-compiler-as-capability-implementation-plan.md index ac50799c..af4b08ae 100644 --- a/.agents/docs/2026-08-08-compiler-as-capability-implementation-plan.md +++ b/.agents/docs/2026-08-08-compiler-as-capability-implementation-plan.md @@ -1,5 +1,16 @@ # 编译器是能力 —— 跨仓实施计划 +> **状态:全部完成(2026-08-08)。** +> +> mcpp 侧 T1–T7 随 PR #378 合入(`fdad165`,CI 18/18),发布 2026.8.8.2; +> T8 随 mcpp-index #181 合入(`1ba4161`)。落地过程中被 CI 推翻/收窄的判据, +> 已回写到 `2026-08-08-payload-version-and-contract-drift-design.md` §9。 +> +> 未随本计划完成、已另行记录的:#352 的 GLX vendor 选择(EGL 路径已通, +> GLX 路径在 NVIDIA 宿主上仍拿不到 FBConfig),以及 `glibc>=X` 下限语义 +> (约定为独立 PR)。 + + > **For agentic workers:** 逐 task 执行,每个 task 自带 red→green→commit。**T1 是所有后续 task 的前提**(没有它,后面每一步的观测都不可信)。 **Goal:** 让 mcpp 停止依赖编译器的安装期配置,把「用哪份 libc」收敛成一个权威;顺带把已经污染的产物清干净,并让多 subos 构建真正可用。 @@ -28,11 +39,11 @@ 本轮发现三处注释描述了代码没有的行为。**先让它们不能再撒谎**,否则后面每一步都可能在错误的前提上推理。 -- [ ] **Step 1** 写失败单测:`find_sibling_tool` 的注释说「first (highest)」,断言**两个版本目录时返回的是排序后最高的那个** -- [ ] **Step 2** 跑,确认 FAIL(今天返回 readdir 第一个) -- [ ] **Step 3** 二选一并使注释与代码一致:①真的排序;②改注释为「未定义顺序」并让调用方不得依赖。**本计划选 ②**——T2 会让它不再被用于选版本 -- [ ] **Step 4** 另两处:`fixup_gcc_specs` 的「Idempotent」(跨 home 不幂等)、`execute.cppm` reader 的「旧缓存视为 miss」(T5 修)——先把注释改成事实 -- [ ] **Step 5** Commit +- [x] **Step 1** 写失败单测:`find_sibling_tool` 的注释说「first (highest)」,断言**两个版本目录时返回的是排序后最高的那个** +- [x] **Step 2** 跑,确认 FAIL(今天返回 readdir 第一个) +- [x] **Step 3** 二选一并使注释与代码一致:①真的排序;②改注释为「未定义顺序」并让调用方不得依赖。**本计划选 ②**——T2 会让它不再被用于选版本 +- [x] **Step 4** 另两处:`fixup_gcc_specs` 的「Idempotent」(跨 home 不幂等)、`execute.cppm` reader 的「旧缓存视为 miss」(T5 修)——先把注释改成事实 +- [x] **Step 5** Commit --- @@ -49,11 +60,11 @@ std::optional probe_payload_paths( std::string_view runtimeBinding); // "glibc@2.39";空 ⇒ 不进 PayloadFirst ``` -- [ ] **Step 1** 单测:给定 `glibc@2.39` 且 home 内有 2.39/2.44 两份 ⇒ **必须返回 2.39**;给定空 binding ⇒ **返回 nullopt**(不猜) -- [ ] **Step 2** 跑,FAIL -- [ ] **Step 3** 实现;`prepare.cppm` 按 `--runtime` → `[xlings] subos` 的 `subos_info.runtime` 顺序解析,末级不猜 -- [ ] **Step 4** 跑,PASS -- [ ] **Step 5** Commit +- [x] **Step 1** 单测:给定 `glibc@2.39` 且 home 内有 2.39/2.44 两份 ⇒ **必须返回 2.39**;给定空 binding ⇒ **返回 nullopt**(不猜) +- [x] **Step 2** 跑,FAIL +- [x] **Step 3** 实现;`prepare.cppm` 按 `--runtime` → `[xlings] subos` 的 `subos_info.runtime` 顺序解析,末级不猜 +- [x] **Step 4** 跑,PASS +- [x] **Step 5** Commit --- @@ -63,9 +74,9 @@ std::optional probe_payload_paths( 不加这条,切 subos 不改 fingerprint ⇒ `target//` 被复用 ⇒ 里面是对另一份 glibc 编译的对象。**比今天更坏。** -- [ ] **Step 1** 单测:两个只有 `runtimeBinding` 不同的输入 ⇒ **fingerprint 必须不同** -- [ ] **Step 2/3/4** red → 加字段 → green -- [ ] **Step 5** Commit +- [x] **Step 1** 单测:两个只有 `runtimeBinding` 不同的输入 ⇒ **fingerprint 必须不同** +- [x] **Step 2/3/4** red → 加字段 → green +- [x] **Step 5** Commit --- @@ -76,11 +87,11 @@ std::optional probe_payload_paths( **已实测**:`-Wl,--dynamic-linker` 压得过 specs;`-specs=<无 `+` 定义>` 是替换;`g++ -dumpspecs` 给内建 specs(`/tmp` 路径 0 条);真实构建(模块 + `import std` + 共享库)RUNPATH 68→2、死路径 0、可运行。 -- [ ] **Step 1** e2e:gcc 构建的产物 **RUNPATH 中不得有 `/tmp/tmp.`**,且 interpreter 是权威指定的那份,且能跑 -- [ ] **Step 2** 跑,FAIL(今天有 34+ 条) -- [ ] **Step 3** ①去掉 `linkmodel` 的 `if (clangDriver)` 门;②构建期生成 clean specs 到 **build dir**,`-specs=` 传入;③**删掉 `fixup_gcc_specs`**(保留 `patchelf_walk`) -- [ ] **Step 4** 跑,PASS -- [ ] **Step 5** Commit +- [x] **Step 1** e2e:gcc 构建的产物 **RUNPATH 中不得有 `/tmp/tmp.`**,且 interpreter 是权威指定的那份,且能跑 +- [x] **Step 2** 跑,FAIL(今天有 34+ 条) +- [x] **Step 3** ①去掉 `linkmodel` 的 `if (clangDriver)` 门;②构建期生成 clean specs 到 **build dir**,`-specs=` 传入;③**删掉 `fixup_gcc_specs`**(保留 `patchelf_walk`) +- [x] **Step 4** 跑,PASS +- [x] **Step 5** Commit --- @@ -90,9 +101,9 @@ std::optional probe_payload_paths( 已在 `fix/fast-run-stale-cache-subos` 分支上完成并 18/18 CI 通过,**原样带过来**: -- [ ] **Step 1** `git cherry-pick` 该分支的三个 commit(wire format / 旧缓存 / e2e fixture) -- [ ] **Step 2** 跑单测 + e2e,确认仍绿 -- [ ] **Step 3** Commit(或保留 cherry-pick 的原始提交) +- [x] **Step 1** `git cherry-pick` 该分支的三个 commit(wire format / 旧缓存 / e2e fixture) +- [x] **Step 2** 跑单测 + e2e,确认仍绿 +- [x] **Step 3** Commit(或保留 cherry-pick 的原始提交) --- @@ -100,22 +111,22 @@ std::optional probe_payload_paths( **Files:** `src/fallback/xlings_binary.cppm`、`src/doctor.cppm`、`src/fallback/probe_sysroot.cppm` -- [ ] **Step 1** 单测/e2e:vendored xlings 版本**低于 pin** ⇒ 被替换;**高于 pin** ⇒ 不动 -- [ ] **Step 2** red -- [ ] **Step 3** 实现;doctor 增加两条 finding:①xlings 低于 pin;②`--sysroot` 指向当前项目之外 -- [ ] **Step 4** green -- [ ] **Step 5** Commit +- [x] **Step 1** 单测/e2e:vendored xlings 版本**低于 pin** ⇒ 被替换;**高于 pin** ⇒ 不动 +- [x] **Step 2** red +- [x] **Step 3** 实现;doctor 增加两条 finding:①xlings 低于 pin;②`--sysroot` 指向当前项目之外 +- [x] **Step 4** green +- [x] **Step 5** Commit --- ### T7: 文档 + 版本 + PR -- [ ] `docs/03-toolchains.md`:权威降级链、`[xlings] subos`、多 subos 构建 -- [ ] `docs/05-mcpp-toml.md`:`[xlings] subos` 与 runtime 的关系 -- [ ] 中文版同步 -- [ ] `MCPP_VERSION` / `mcpp.toml` → `2026.8.8.2`;`kXlingsVersion` → 最新;`check_version_pins.sh` 通过 -- [ ] 全量 `mcpp test` + 关键 e2e -- [ ] 开 PR +- [x] `docs/03-toolchains.md`:权威降级链、`[xlings] subos`、多 subos 构建 +- [x] `docs/05-mcpp-toml.md`:`[xlings] subos` 与 runtime 的关系 +- [x] 中文版同步 +- [x] `MCPP_VERSION` / `mcpp.toml` → `2026.8.8.2`;`kXlingsVersion` → 最新;`check_version_pins.sh` 通过 +- [x] 全量 `mcpp test` + 关键 e2e +- [x] 开 PR --- @@ -123,9 +134,9 @@ std::optional probe_payload_paths( **Files(mcpp-index):** `pkgs/c/compat.glx-runtime.lua`、`pkgs/c/compat.glfw.lua` -- [ ] **Step 1** 新增 e2e/验证:**装 `xim:graphics` 前后**,各跑一次与图形无关的成员,断言产物 `PT_INTERP` 与 `readelf -V` 的 glibc 符号上界**不变**。这是上次事故真正缺失的那个测试 -- [ ] **Step 2** 重新应用 `f44e896` 的两个文件改动(deps 放平台层) -- [ ] **Step 3** CI 全绿后合入 +- [x] **Step 1** 新增 e2e/验证:**装 `xim:graphics` 前后**,各跑一次与图形无关的成员,断言产物 `PT_INTERP` 与 `readelf -V` 的 glibc 符号上界**不变**。这是上次事故真正缺失的那个测试 +- [x] **Step 2** 重新应用 `f44e896` 的两个文件改动(deps 放平台层) +- [x] **Step 3** CI 全绿后合入 --- From c1e9360ff21d913ce42d80a9b30b601c90739090 Mon Sep 17 00:00:00 2001 From: SPeak Agent Date: Sat, 8 Aug 2026 16:36:02 +0800 Subject: [PATCH 05/54] =?UTF-8?q?fix(subos):=20prepend=20=E4=B8=A2?= =?UTF-8?q?=E6=8E=89=E4=BA=86=E8=B0=83=E7=94=A8=E6=96=B9=E7=9A=84=E5=80=BC?= =?UTF-8?q?=20(#383)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 解析出的键值对**替换**子进程里的变量(作为 extraEnv 进去),而 `prepend` 只发出声明值,把调用方原有内容整个丢掉 —— 对 PATH 形状的变量,丢掉的是用户的整条搜索路径。 现在它接在调用方的值前面,且对已存在元素幂等(嵌套运行不会让列表增长)。空值不算「用户设过」。 `set` 的部分**已撤回**:`set` 与「默认值」是两种意图,而 op 词汇表是 xlings 的契约。#382 想要的逃生通道应由 xlings 新增一个 op,mcpp 认它 —— 且因为 mcpp 会丢弃未知 op,那个 op 必须两侧同时到位。已加断言把「`set` 就是要赢」钉住。 --- src/build/execute.cppm | 18 ++++++++-- src/xlings/subos_info.cppm | 54 ++++++++++++++++++++++++++++-- tests/unit/test_subos_info.cpp | 61 ++++++++++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 4 deletions(-) diff --git a/src/build/execute.cppm b/src/build/execute.cppm index b572f967..960c45d4 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -361,7 +361,15 @@ compute_subos_env(const mcpp::build::BuildPlan& plan) { // diagnostic path in doctor. if (!info.note.empty()) mcpp::log::verbose("subos", info.note); - return mcpp::xlings::subos::resolve_env(info, dir); + // Resolved AGAINST the caller's environment, not in a vacuum: these + // entries replace the variable in the child, so a `set` that ignores an + // exported value overwrites it and a `prepend` that ignores it drops it. + return mcpp::xlings::subos::resolve_env( + info, dir, [](std::string_view v) -> std::optional { + if (const char* e = std::getenv(std::string(v).c_str())) + return std::string(e); + return std::nullopt; + }); } // Compile a prepared BuildContext. Shared between `mcpp build` and `mcpp run` @@ -877,7 +885,13 @@ std::optional try_fast_run(const std::filesystem::path& projectRoot, auto subosDir = subos_dir_for_run(std::filesystem::path(match->subosDir)); if (!subosDir.empty()) { auto info = mcpp::xlings::subos::read(subosDir); - for (auto& kv : mcpp::xlings::subos::resolve_env(info, subosDir)) + for (auto& kv : mcpp::xlings::subos::resolve_env( + info, subosDir, + [](std::string_view v) -> std::optional { + if (const char* e = std::getenv(std::string(v).c_str())) + return std::string(e); + return std::nullopt; + })) childEnv.push_back(std::move(kv)); } } diff --git a/src/xlings/subos_info.cppm b/src/xlings/subos_info.cppm index 41aaac55..4cf9c96d 100644 --- a/src/xlings/subos_info.cppm +++ b/src/xlings/subos_info.cppm @@ -201,9 +201,21 @@ Info read(const std::filesystem::path& subosDir) { // never matches and the joined value is a corrupt list. Caught by CI on // Windows, not by any amount of reading. std::vector> -resolve_env(const Info& info, const std::filesystem::path& subosDir) { +resolve_env(const Info& info, const std::filesystem::path& subosDir, + const std::function(std::string_view)>& + ambient = {}) { std::vector> out; + // What the caller's environment already says about a variable. + // + // The declarations are merged against this, not in a vacuum, because the + // result REPLACES the variable in the child (it goes in as extraEnv). A + // resolution that ignores the ambient value silently discards it. + auto ambient_of = [&](std::string_view var) -> std::optional { + if (!ambient) return std::nullopt; + return ambient(var); + }; + const std::string subos = subosDir.string(); auto expand = [&](std::string v) { constexpr std::string_view kPh = "${subosdir}"; @@ -241,7 +253,45 @@ resolve_env(const Info& info, const std::filesystem::path& subosDir) { std::pair* hit = nullptr; for (auto& kv : out) if (kv.first == d.var) { hit = &kv; break; } - if (!hit) { out.emplace_back(d.var, value); continue; } + if (!hit) { + auto amb = ambient_of(d.var); + if (d.op == "set") { + // `set` wins, ambient or not. + // + // Deliberately NOT "yield to an exported value". That + // reading was written here first and withdrawn: `set` and + // "default" are two different intentions, and a subos has + // real need of the first -- a variable naming its own + // loader configuration must not be overridable by a stale + // value in the caller's shell. Collapsing them here would + // remove the ability to express it. + // + // It is also not mcpp's vocabulary to redefine. `envs` is + // xlings' wire format; a consumer that quietly gives an op + // a second meaning makes the same subos behave differently + // depending on which tool launched the program. + // + // The escape hatch mcpp#382 asks for (a recipe declaring a + // DEFAULT the user can override) therefore wants a new op + // from xlings, not a reinterpretation of this one. When it + // exists, it is honoured here -- unknown ops are dropped + // today, which is why it has to arrive on both sides. + out.emplace_back(d.var, value); + continue; + } + // `prepend` against the ambient value, not instead of it. + // These entries replace the variable in the child, so + // emitting the declared value alone DROPS whatever the caller + // had -- for a PATH-shaped variable that is the user's whole + // search path. + if (amb && !amb->empty() && !contains_element(*amb, value)) + out.emplace_back(d.var, value + sep + *amb); + else if (amb && !amb->empty()) + out.emplace_back(d.var, *amb); + else + out.emplace_back(d.var, value); + continue; + } if (d.op == "set") { hit->second = value; continue; } if (!contains_element(hit->second, value)) hit->second = value + sep + hit->second; diff --git a/tests/unit/test_subos_info.cpp b/tests/unit/test_subos_info.cpp index a7f27af2..1223d35f 100644 --- a/tests/unit/test_subos_info.cpp +++ b/tests/unit/test_subos_info.cpp @@ -249,4 +249,65 @@ TEST(SubosInfo, UnknownOpIsDroppedLikeXlingsDrops) { EXPECT_EQ(env[0].second, "/yes"); } + + + +// `prepend` prepends TO the caller's value rather than replacing it. Same +// reason: the pair replaces the variable, so emitting the declared value alone +// discards whatever search path the user had. +TEST(SubosResolveEnv, PrependKeepsTheExportedValue) { + Tmp t; + t.write(R"({"workspace":{},"subos_info":{"schema_version":1,"runtime":"glibc@2.39", + "envs":{"glibc@2.39":[{"var":"LD_LIBRARY_PATH","op":"prepend","value":"/sub/lib"}]}}})"); + auto info = su::read(t.dir); + auto out = su::resolve_env( + info, t.dir, [](std::string_view v) -> std::optional { + if (v == "LD_LIBRARY_PATH") return std::string("/user/lib"); + return std::nullopt; + }); + ASSERT_EQ(out.size(), 1u); + const auto sep = mcpp::platform::env::path_list_separator(); + EXPECT_EQ(out[0].second, std::string("/sub/lib") + sep + "/user/lib"); +} + +// Already there: prepending again would grow the list on every nested run. +TEST(SubosResolveEnv, PrependIsIdempotentAgainstTheExportedValue) { + Tmp t; + t.write(R"({"workspace":{},"subos_info":{"schema_version":1,"runtime":"glibc@2.39", + "envs":{"glibc@2.39":[{"var":"LD_LIBRARY_PATH","op":"prepend","value":"/sub/lib"}]}}})"); + auto info = su::read(t.dir); + const auto sep = mcpp::platform::env::path_list_separator(); + const auto existing = std::string("/sub/lib") + sep + "/user/lib"; + auto out = su::resolve_env( + info, t.dir, [&](std::string_view v) -> std::optional { + if (v == "LD_LIBRARY_PATH") return existing; + return std::nullopt; + }); + ASSERT_EQ(out.size(), 1u); + EXPECT_EQ(out[0].second, existing); +} + +// `set` wins over an exported value, and that is deliberate. +// +// This assertion exists to stop the opposite reading from being reintroduced +// -- it was, once, as a fix for mcpp#382, and withdrawn: `set` and "a default +// the user may override" are two intentions, and a subos needs the first for +// variables naming its own configuration. The escape hatch that issue wants is +// a NEW op from xlings, whose wire format this is, not a second meaning for +// this one applied by one consumer. +TEST(SubosResolveEnv, SetWinsOverAnExportedValue) { + Tmp t; + t.write(R"({"workspace":{},"subos_info":{"schema_version":1, + "runtime":"glibc@2.39", + "envs":{"glibc@2.39":[{"var":"GALLIUM_DRIVER","op":"set","value":"d3d12"}]}}})"); + auto info = su::read(t.dir); + auto out = su::resolve_env( + info, t.dir, [](std::string_view v) -> std::optional { + if (v == "GALLIUM_DRIVER") return std::string("llvmpipe"); + return std::nullopt; + }); + ASSERT_EQ(out.size(), 1u); + EXPECT_EQ(out[0].second, "d3d12"); +} + } // namespace From 312ff523add06a8c3bfe7760cd990657a94ce4f2 Mon Sep 17 00:00:00 2001 From: SPeak Agent Date: Sat, 8 Aug 2026 17:11:22 +0800 Subject: [PATCH 06/54] release: 2026.8.8.3 (#384) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main 上唯一未发布的代码改动是 #383(subos 的 `prepend` 把调用方的值整个丢掉)—— 解析结果是**替换**子进程变量的,所以对 PATH 形状的变量,丢掉的是用户的整条搜索路径。 xlings pin 2026.8.7.1 → **2026.8.8.1**(索引已收录)。 本地 67 单测;e2e 200/201/86/29 通过;CI 18/18。 --- .github/actions/bootstrap-mcpp/action.yml | 2 +- .github/actions/setup-macos-llvm/action.yml | 2 +- .github/workflows/bootstrap-macos.yml | 2 +- .github/workflows/ci-fresh-install.yml | 6 +++--- .github/workflows/ci-linux-e2e.yml | 2 +- .github/workflows/cross-build-test.yml | 4 ++-- .github/workflows/release.yml | 14 +++++++------- CHANGELOG.md | 20 ++++++++++++++++++++ mcpp.toml | 2 +- src/version.cppm | 2 +- src/xlings.cppm | 2 +- 11 files changed, 39 insertions(+), 19 deletions(-) diff --git a/.github/actions/bootstrap-mcpp/action.yml b/.github/actions/bootstrap-mcpp/action.yml index 2e171056..400311d3 100644 --- a/.github/actions/bootstrap-mcpp/action.yml +++ b/.github/actions/bootstrap-mcpp/action.yml @@ -25,7 +25,7 @@ inputs: # `package.name`, so one of the two was simply unreachable — and which one # depended on the machine, which is why CI failed on `compat:lua` on # Windows and `mcpplibs.capi:lua` on Linux. Never pin below that. - default: '2026.8.7.1' + default: '2026.8.8.1' cache-target: description: also restore/save target/ (build artifacts + BMIs) required: false diff --git a/.github/actions/setup-macos-llvm/action.yml b/.github/actions/setup-macos-llvm/action.yml index d00ad2e5..cabba4cf 100644 --- a/.github/actions/setup-macos-llvm/action.yml +++ b/.github/actions/setup-macos-llvm/action.yml @@ -15,7 +15,7 @@ inputs: # Floor imposed by the index, not a routine bump — see # .github/actions/bootstrap-mcpp/action.yml for why 0.4.69 is required # (two packages named `lua` in one repo need openxlings/xlings#381). - default: '2026.8.7.1' + default: '2026.8.8.1' runs: using: composite diff --git a/.github/workflows/bootstrap-macos.yml b/.github/workflows/bootstrap-macos.yml index f607854d..de2e817b 100644 --- a/.github/workflows/bootstrap-macos.yml +++ b/.github/workflows/bootstrap-macos.yml @@ -17,7 +17,7 @@ jobs: # Dormant (workflow_dispatch only), but kept in step with the rest — # check_version_pins.sh holds it there. Floor: 0.4.69, below which the # index cannot resolve two packages that share a short name. - XLINGS_VERSION: '2026.8.7.1' + XLINGS_VERSION: '2026.8.8.1' steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/ci-fresh-install.yml b/.github/workflows/ci-fresh-install.yml index 503c8695..d363080c 100644 --- a/.github/workflows/ci-fresh-install.yml +++ b/.github/workflows/ci-fresh-install.yml @@ -152,7 +152,7 @@ jobs: env: XLINGS_NON_INTERACTIVE: '1' run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.7.1 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.8.1 echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - name: Install mcpp and config mirror @@ -292,7 +292,7 @@ jobs: - name: Install xlings + mcpp run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.7.1 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.8.1 # Deliberately NOT writing to $GITHUB_PATH here. On container # images that declare no PATH in their config (opensuse/ # tumbleweed), appending a single dir to GITHUB_PATH makes the @@ -363,7 +363,7 @@ jobs: # (older ones carry minos=15 and refuse to start). # v0.4.51+: in-process sha256 — this image has no sha256sum # binary, so pinned fetches failed before it. - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.7.1 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.8.1 echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - name: Install mcpp and config mirror diff --git a/.github/workflows/ci-linux-e2e.yml b/.github/workflows/ci-linux-e2e.yml index 01996456..360c135f 100644 --- a/.github/workflows/ci-linux-e2e.yml +++ b/.github/workflows/ci-linux-e2e.yml @@ -133,7 +133,7 @@ jobs: - name: Bootstrap xlings + released mcpp run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.7.1 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.8.1 export PATH="$HOME/.xlings/subos/current/bin:$PATH" xlings update xlings install mcpp -y -g diff --git a/.github/workflows/cross-build-test.yml b/.github/workflows/cross-build-test.yml index 51bdf9fe..1ee50a9c 100644 --- a/.github/workflows/cross-build-test.yml +++ b/.github/workflows/cross-build-test.yml @@ -118,7 +118,7 @@ jobs: # release assets were uploaded in a broken state (records present, # blobs missing → 404 on GET); re-uploaded clean. The stale-INDEX # half is handled by the marker-clear below. - XLINGS_VERSION: '2026.8.7.1' + XLINGS_VERSION: '2026.8.8.1' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" curl -fsSL -o "/tmp/${tarball}" \ @@ -255,7 +255,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.7.1' + XLINGS_VERSION: '2026.8.8.1' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" curl -fsSL -o "/tmp/${tarball}" \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b9be200b..cf4e8861 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,7 +96,7 @@ jobs: # Pin xlings to a known-good version. The upstream install # script always grabs `latest` (no version override), so we # download + self-install manually to avoid broken releases. - XLINGS_VERSION: '2026.8.7.1' + XLINGS_VERSION: '2026.8.8.1' run: | if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" @@ -288,7 +288,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.7.1' + XLINGS_VERSION: '2026.8.8.1' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" curl -fsSL -o "/tmp/${tarball}" \ @@ -358,11 +358,11 @@ jobs: # below are pinned to the same version as XLINGS_VERSION; they are # NOT interpolated from it, so check_version_pins.sh scans for them # explicitly (they were absent from the old lock-step comment). - XLA="xlings-2026.8.7.1-linux-aarch64.tar.gz" + XLA="xlings-2026.8.8.1-linux-aarch64.tar.gz" if curl -fsSL -o "/tmp/$XLA" \ - "https://github.com/openxlings/xlings/releases/download/v2026.8.7.1/$XLA"; then + "https://github.com/openxlings/xlings/releases/download/v2026.8.8.1/$XLA"; then tar -xzf "/tmp/$XLA" -C /tmp - XLBIN=$(find /tmp/xlings-2026.8.7.1-linux-aarch64 -path '*/bin/xlings' -type f | head -1) + XLBIN=$(find /tmp/xlings-2026.8.8.1-linux-aarch64 -path '*/bin/xlings' -type f | head -1) if [ -n "$XLBIN" ]; then mkdir -p "$STAGING/$WRAPPER/registry/bin" cp "$XLBIN" "$STAGING/$WRAPPER/registry/bin/xlings" @@ -440,7 +440,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.7.1' + XLINGS_VERSION: '2026.8.8.1' run: | if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then WORK=$(mktemp -d) @@ -622,7 +622,7 @@ jobs: shell: bash env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.7.1' + XLINGS_VERSION: '2026.8.8.1' run: | # Captured before the `cd` below, in POSIX form: this step never # returns to the workspace, and GITHUB_WORKSPACE is a backslash diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bfee02a..af035aed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,26 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [2026.8.8.3] — 2026-08-08 + +### 修复 + +- **subos 的 `prepend` 把调用方的值整个丢掉了(#382 顺带发现)。** + + 解析出的键值对是**替换**子进程里的变量的(作为 extraEnv 进去),而 `prepend` 只发出 + 声明值 —— 对 PATH 形状的变量,丢掉的是用户的整条搜索路径。现在它接在调用方的值 + 前面,且对已存在元素幂等(嵌套运行不会让列表增长)。空值不算「用户设过」。 + + 同一处的 `set` **保持不变**。`set` 与「默认值」是两种意图:有些变量 subos 必须说了 + 算 —— 指向它自己 loader 配置的那类,用户 shell 里一个陈旧值就能把环境弄坏。而且 + `envs` 的 op 词汇表是 xlings 的契约,消费方悄悄给一个 op 加第二种含义,会让同一个 + subos 因为「由谁启动」而行为不同。#382 想要的「用户可覆盖的默认值」应由 xlings 新增 + 一个 op,mcpp 认它;因为 mcpp 会丢弃未知 op,那个 op 必须两侧同时到位。 + +### 其他 + +- xlings pin 提升到 2026.8.8.1。 + ## [2026.8.8.2] — 2026-08-08 编译器交付的是**能力**,不是配置。设计与实测证据见 diff --git a/mcpp.toml b/mcpp.toml index 60309c3d..48d9247b 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.8.8.2" +version = "2026.8.8.3" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/version.cppm b/src/version.cppm index 7e8caed1..160d73d5 100644 --- a/src/version.cppm +++ b/src/version.cppm @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.8.8.2"; +inline constexpr std::string_view MCPP_VERSION = "2026.8.8.3"; } // namespace mcpp diff --git a/src/xlings.cppm b/src/xlings.cppm index 3423e506..16f00e7c 100644 --- a/src/xlings.cppm +++ b/src/xlings.cppm @@ -44,7 +44,7 @@ namespace pinned { // in lock-step by hand; that list was already missing both composite // actions, which is how CI's sandbox sat on 0.4.30 unnoticed while // everything else had moved on. Don't reintroduce a hand-maintained list. - inline constexpr std::string_view kXlingsVersion = "2026.8.7.1"; + inline constexpr std::string_view kXlingsVersion = "2026.8.8.1"; inline constexpr std::string_view kNasmVersion = "3.02"; } From 55a39d90fe98b5475fc394ac8487fe6804b2b84f Mon Sep 17 00:00:00 2001 From: SPeak Agent Date: Sat, 8 Aug 2026 18:47:08 +0800 Subject: [PATCH 07/54] =?UTF-8?q?feat:=20=E6=9C=BA=E5=99=A8=E5=8F=AF?= =?UTF-8?q?=E8=AF=BB=E8=BE=93=E5=87=BA=E5=8D=8F=E8=AE=AE=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E4=BF=A1=E5=B0=81=20+=20=E6=95=88=E5=BA=94=20+=20-?= =?UTF-8?q?-format=20=E5=BD=92=E4=B8=80=20(2026.8.8.4)=20(#385)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 回应 RFC #379,并修掉两个今天就在破坏客户端的缺陷。 ## 修复 **CDB 的 `arguments` 带着 shell 引号。** clangd 逐字 exec 它、不经 shell,于是带引号的 token 不是 flag 而是不存在的文件名。Windows 上每个带路径的 flag 都中招(`shell_quote_arg` 的触发集含反斜杠);带空格的路径更糟 —— token 在引号**内部**被切断,一个参数变成两个,其中一个带着永不闭合的开引号。 **usage 错误不再进 stdout。** 未知选项过去打在 stdout、rc=1、stderr 为空。现在与不支持的值统一 stderr + rc=2。 ## 新增 `src/wire.cppm`(独立模块,不认识任何命令)+ `--protocol-version` + `--format json`,覆盖 `self env` / `xpkg parse` / `cache list`。 三条设计都由实测支撑: 1. **信封自识别** —— `--protocol-version` 在它出现前的版本上自己就是未知选项,失败与成功同走 stdout。客户端判据只能是正向识别。 2. **效应集合而非布尔** —— 全新 home 上 `xpkg parse`/`cache list` 什么都不建、`self env` 建 6 项;布尔分不开「mcpp 自我初始化」和「执行工作区代码」。 3. **`--json` 永久保留 legacy payload** —— 拼写兼容不等于 payload 兼容,`cache list --json` 的 `{root, entries}` 本仓库 e2e 已断言。 `self env --format json` 走独立只读路径:全新 home 上创建项数 **0**(人类路径不变)。 ## 纪律 文档承诺「只增不改」,并用**验证过会变红**的断言钉住(改字段名 → 5 个测试失败)。没人能弄坏的 schema 不是 schema。 68 单测;e2e 202;CI 18/18。 --- ...machine-readable-output-protocol-design.md | 411 ++++++++++++++++++ ...08-08-wire-protocol-implementation-plan.md | 95 ++++ CHANGELOG.md | 44 ++ docs/11-machine-output.md | 231 ++++++++++ docs/README.md | 1 + docs/zh/11-machine-output.md | 202 +++++++++ docs/zh/README.md | 1 + mcpp.toml | 2 +- src/bmi_cache/maintenance.cppm | 52 ++- src/build/compile_commands.cppm | 84 +++- src/cli.cppm | 78 +++- src/cli/cmd_cache.cppm | 19 + src/cli/cmd_self.cppm | 64 ++- src/cli/cmd_xpkg.cppm | 51 ++- src/version.cppm | 2 +- src/wire.cppm | 259 +++++++++++ tests/e2e/202_machine_output_contract.sh | 65 +++ tests/unit/test_compile_commands.cpp | 80 ++++ tests/unit/test_wire.cpp | 247 +++++++++++ 19 files changed, 1932 insertions(+), 56 deletions(-) create mode 100644 .agents/docs/2026-08-08-machine-readable-output-protocol-design.md create mode 100644 .agents/docs/2026-08-08-wire-protocol-implementation-plan.md create mode 100644 docs/11-machine-output.md create mode 100644 docs/zh/11-machine-output.md create mode 100644 src/wire.cppm create mode 100755 tests/e2e/202_machine_output_contract.sh create mode 100644 tests/unit/test_wire.cpp diff --git a/.agents/docs/2026-08-08-machine-readable-output-protocol-design.md b/.agents/docs/2026-08-08-machine-readable-output-protocol-design.md new file mode 100644 index 00000000..e8d5665a --- /dev/null +++ b/.agents/docs/2026-08-08-machine-readable-output-protocol-design.md @@ -0,0 +1,411 @@ +# 机器可读输出协议 —— 对 RFC #379 的核对与修正 + +> 状态:设计待 review。核对基准 mcpp 2026.8.8.3(本机构建),xlings 2026.8.8.1。 +> 本文不复述 #379,只写**核对结果**与**它需要改的地方**。 + +## 0. 结论先行 + +RFC #379 的分析基本成立,逐条实测核对见 §1。但它有一个**结构性遗漏**,会让阶段 0 +到阶段 2 全部落空: + +> **未知选项的报错走 stdout,退出码 1,stderr 为空 —— 而且这段代码在依赖包 +> `mcpplibs.cmdline` 里,不在 mcpp。** + +RFC 花了整个阶段 0 去规定「未知**格式值**怎么办」,但客户端在真实使用中先撞到的是 +「未知**选项**」——因为它要探测的正是「这个版本认不认 `--format`」。而那条路径今天 +把人类文本打进了协议要独占的通道。 + +由此推出的第二件事更关键:**RFC 提议的协商入口 `mcpp --protocol-version`,解决不了 +它被设计来解决的那个问题**(§2.2)。 + +--- + +## 1. 逐条核对 + +方法:全部在本机对已构建的 mcpp 实跑,不看代码推断。一处我用 grep 得出的结论是错的 +(以为 `cache list --json` 不存在),实跑后推翻 —— flag 注册不带横线,grep `"--json"` +搜不到。**下面每一条都以实跑为准。** + +| RFC 主张 | 核对 | 结果 | +|---|---|---| +| §1.1 `--json` 是已发布的事实拼写 | `xpkg parse --json` ✓、`cache list --json` ✓ 均有输出 | **成立** | +| §1.3 `self env` 无 JSON | `--format json` / `--json` 均报 unknown option | **成立** | +| §1.4 `xpkg parse --json` 无 schemaVersion | 输出 `{"namespace":"","name":"7zip","form":"A"}` | **成立** | +| §1.5 `xlings interface` 的 outputSchema 全空 | `--list` 20 个 capability,**20/20** 只有 `exitCode` | **成立** | +| §1.6 CDB 早于 ninja 写出 | `ninja_backend.cppm:1549` `write_compile_commands`,其后才起 ninja | **成立** | +| §1.1 `pack --format` 语义冲突 | `pack --format bogus` → rc=2,人类文本走 **stderr** | **成立** | + +### 1.1 新发现 F1:未知选项污染 stdout + +``` +$ mcpp cache list --format json +stdout: Error: unknown option: --format +stderr: (空) +rc: 1 +``` + +对照 mcpp 自己的错误路径: + +``` +$ mcpp xpkg parse /nonexistent.lua +stdout: (空) +stderr: error: cannot open '/nonexistent.lua' +``` + +**mcpp 自己的 `ui::error` 是对的**(`src/ui.cppm:327`,写 stderr)。洞恰好开在参数解析 +的边界上,而且代码在依赖里: + +``` +mcpplibs-x-cmdline/0.0.2/cmdline-0.0.2/src/cmdline.cppm:127 + std::println("Error: {}", result.error().message); // ← stdout +``` + +于是当前有**四种**未知输入行为,而 RFC 只识别出后两种(且它们都还只在 #372 里): + +| 场景 | 通道 | rc | +|---|---|---| +| 未知**选项**(`cache list --format …`) | **stdout** | 1 | +| 未知**值**(`pack --format bogus`) | stderr | 2 | +| #372 `ide snapshot --format yaml` | stdout | 3 | +| #372 `ide configure --format json` | stderr | 2 | + +--- + +## 2. 这改变了什么 + +### 2.1 阶段 0 的范围划错了 + +RFC 的阶段 0 只规定未知**值**。但客户端探测能力时发的第一个请求,在旧版本上命中的 +是未知**选项**。只规定值,等于把最常走的那条路留在污染状态。 + +**修正:阶段 0 必须覆盖选项层,而这意味着要动 `mcpplibs.cmdline`**(改成写 stderr, +或让 mcpp 不用它内建的错误打印)。这是 RFC 的行数估算里没有的一项跨包工作。 + +### 2.2 协商入口解决不了它被设计来解决的问题 + +RFC 给 `--protocol-version` 的理由是: + +> 客户端启动时判断该走哪条路,不用先 spawn 一个可能失败的命令再解析错误消息 + +实测,在**任何尚未实现它的 mcpp** 上: + +``` +$ mcpp --protocol-version +stdout: Error: unknown option: --protocol-version +stderr: (空) +rc: 1 +``` + +它自己就是「那个可能失败的命令」,而且失败与成功**同通道**。客户端仍然只能靠解析 +stdout 内容来判断 —— 正是要避免的事。 + +换 `--json` 拼写也一样:两种拼写在旧版本上都走同一条未知选项路径。**所以拼写之争 +(`--format` vs `--json`)对发现机制毫无影响**,RFC 用「布尔无法表达未知值分支」来论证 +`--format` 是对的,但不要把它当成解决了发现问题。 + +**修正:唯一对旧版本稳健的客户端规则是「正向识别」**—— + +> 读 stdout,尝试 JSON 解析;解析成功**且**含 `schemaVersion` 与 `kind` 才认为拿到了 +> 协议输出。**不得**以退出码 0、或「命令没报错」作为判据。 + +这条规则要写进 `docs/spec/`,并且是 envelope 必须**自识别**的理由:客户端没有别的 +可靠信号。`--protocol-version` 仍值得有(新版本上省一次 spawn),但它是优化,不是 +契约地基。 + +### 2.3 `destructive` 字段:同意,但它落在错误的一侧 + +RFC 提议在 envelope 里放 `destructive`。这对**已经拿到输出**的客户端有用,但 +VSCode 的 untrusted-workspace 门需要在**执行之前**知道 —— 而拿到 envelope 的时候, +副作用已经发生了。 + +**修正:`destructive` 必须同时出现在两个地方**—— + +- envelope 里(事后自述,便于日志与审计) +- `mcpp --protocol-version` 的输出里,以「命令 → destructive」的静态表形式给出 + +后者才是 untrusted 门能用的东西。这也顺带回答 RFC §七 给 @sunrisepeak 的第 4 问: +接受这个字段,但只放 envelope 不够。 + +--- + +## 3. 修正后的阶段划分 + +改动集中在阶段 0 与阶段 1,阶段 2 之后与 RFC 一致。 + +### 阶段 0(前置)—— stdout 归属,而不只是「未知格式」 + +1. **`mcpplibs.cmdline` 的解析错误改写 stderr**,并给出非 0 退出码。跨包改动,需要 + 发一个 cmdline 版本;mcpp 侧同步 bump。 +2. mcpp 侧统一:未知**值**与未知**选项**都走 stderr + rc=2(用法错误)。 + —— RFC 原文提议未知格式「走 stdout + envelope」。**建议改为 stderr + rc=2**: + 一个还不知道自己该输出什么格式的请求,不该往协议通道里写东西;客户端按 §2.2 的 + 正向识别规则,stdout 无 JSON 即判定为「不支持」,语义完整且不需要额外约定。 +3. 加一条 e2e:对**每个**声明支持 `--format` 的命令,断言未知值与未知选项在通道、 + 退出码上一致。这是防止 §1.1 那张表重新长出来的唯一办法。 + +### 阶段 1 —— `mcpp.wire` envelope + +与 RFC 一致(从 #372 的 `src/ide/model.cppm` / `snapshot.cppm` 提升),补两点: + +- envelope **必须自识别**:`schemaVersion` + `kind` 是客户端唯一的正向判据(§2.2) +- `--protocol-version` 的输出**包含命令 → destructive 的静态表**(§2.3) + +### 阶段 2–4 + +与 RFC 一致。`--json` 永久别名、入口一处归一、不打 deprecation 警告 —— 这套做法在 +本仓库有先例(`src/toolchain/compat.cppm`),照搬即可。 + +`pack --format tar|dir`:建议走 (a)**声明为例外**。它没有 stdout 机器输出,不参与本 +协议;加 `--layout` 别名是为一个不存在的一致性付迁移成本。 + +--- + +## 4. 必须守住的纪律(RFC §四)—— 同意,补一条 + +RFC 说「envelope 的价值全在 `data` 被真正版本化和文档化上」,并以 xlings 的 +20/20 空 outputSchema 为反例。核对属实。 + +补一条**可执行**的约束,否则「只增不改」会像那 20 个 schema 一样退化成口号: + +> 每个 `kind` 的 golden fixture 必须**在测试里被反向验证**:改一个字段名要让测试变红。 +> 只有「跑通了」而没有「改坏了会红」的 fixture,和没有 fixture 等价。 + +这一条不是文风要求。本轮工作里我自己就写过三个「因为错误的理由而通过」的测试: +一个 e2e 分支够不到它要测的模式,一个判据选成「跑不跑得起来」(在任何有宿主工具链 +的机器上恒真),一个 `ldd` 解释器行造成的误报。三个都是靠**主动把实现改坏、看测试 +会不会红**才发现的。 + +--- + +## 4.5 追加优化项:CDB 的 `arguments` 带着 shell 引号(实测复现,不限 Windows) + +用户报告 Windows 上 CDB 里的 `-fmodule-file=` 带多余双引号,每次构建后要手工删掉才能 +配好 clangd。**是真问题,而且比报告的更严重** —— 在 Linux 上用带空格的项目路径复现了 +同一形状: + +``` +'-fmodule-file=std=/tmp/.../my project/.../std.pcm <- 闭合引号没了 +'-fmodule-file=std.compat=/tmp/.../my project/.../ +'-fprebuilt-module-path=/tmp/.../my project/.../pcm.cache' +``` + +前两条的**闭合引号被切掉了**:一个参数被从中间劈成两半,还带着一个孤立的开引号。 +clangd 逐字 exec `arguments`(不经 shell),于是它拿到的是 +`'-fmodule-file=std=/tmp/.../my` 和 `project/.../std.pcm'` 两个参数。 + +### 机制 + +四步,每一步单独看都合理: + +1. `flags.cppm::shell_quote_arg` 的 `kNeedsQuote` 含反斜杠和空格 —— + **Windows 路径必然含反斜杠**,所以在 Windows 上*每个*带路径的 flag 都被加引号; + Linux 上只有路径含空格时才触发。这解释了为什么它被当成 Windows 专属问题。 +2. 加引号是为了让 flag 在 ninja 的 `sh -c "<整条命令>"` 里存活 —— 对 ninja 是**对的**。 +3. `compile_commands.cppm::split_flags` 把那条字符串重新切成 token,并撤销 ninja 的 + `$ ` / `$:` / `$$` 转义。 +4. 但它**不认引号**:既不剥掉,也不把引号内的空格当作非分隔符。 + +### 这是同一个「注释写对了一半」 + +`split_flags` 自己的注释把原则写对了: + +> CDB consumers like clangd exec the `arguments` array literally — no ninja +> involved — so escaped chars must be undone + +它识别出**ninja 转义**要撤销,却漏了**同一个理由下**的 shell 引号。代码实现了原则的 +一半。与本文档 §2 是同一形状。 + +### 判据与修法 + +**判据(必须是这个,不能是「clangd 能用了」)**:CDB 的 `arguments` 里,任何 token 都 +不得以引号开头或结尾;且带空格的路径必须是**一个** token。前者防引号泄漏,后者防这次 +发现的「劈成两半」。 + +两条路: + +- **(a) 让 `split_flags` 认引号**(建议)。它本来就是那个分词器,分词器认引号是本分。 + 注意顺序:ninja 的 `$ ` 反转义必须发生在**引号内**,否则被引号包住的空格会先把 token + 切断 —— 这正是现在发生的事。 +- (b) 让 CDB 不再从「拼好的 shell 字符串」重新分词,而是让 argv 以 `vector` + 一路传下来。更干净,但要动 `compute_flags` 的返回形状,范围大得多。 + +**回归测试**:带空格路径的项目 + llvm 工具链(gcc 不发 `-fmodule-file`,覆盖不到), +断言上述判据。这条在 Linux 上就能跑,不需要 Windows 机器 —— 而它至今没被发现,正是 +因为所有 e2e 的项目路径都不含空格。 + +### 与本协议的关系 + +CDB 是 mcpp 已经在发的**机器可读输出**,只是没走 envelope。它今天就在破坏一个真实 +客户端(clangd),而且用户在手工修补它。**建议把它排在阶段 1 之前**:协议做得再好, +也救不了一个内容本身就坏掉的出口。 + +## 5. 与 #372 的关系 + +同意 RFC §五 的 A/B/C 拆分。补充一点:A 部分里的 +`write_fresh_compile_commands` + 原子替换,**独立于本协议就应该合入** —— 现状是 +`ofstream` 截断写,clangd 可能读到半截 JSON。那是既有缺陷,不该等协议设计定稿。 + +--- + +## 6. 待确认(在 RFC 七问之外) + +1. **`mcpplibs.cmdline` 的解析错误改走 stderr** —— 这是跨包改动且影响所有使用者, + 是否接受?若不动依赖,替代方案是 mcpp 不用 `App::run()` 的内建错误打印、自己接管 + `ParseResult`(mcpp 侧约 20 行,但要覆盖每个命令入口)。 +2. `destructive` 静态表放进 `--protocol-version` 输出 —— 是否接受(§2.3)? + +## 7. 已定(2026-08-08) + +- **未知格式走 stderr + rc=2**(不是 stdout + envelope)。客户端按 §2.2 的正向识别 + 规则,stdout 无 JSON 即判定为「不支持」,语义完整,且一个还不知道该输出什么格式的 + 请求不往协议通道写东西。 +- **`pack --format tar|dir` 声明为例外**,不加 `--layout` 别名。它没有 stdout 机器 + 输出,不参与本协议;为一个不存在的一致性付迁移成本不值。 + +--- + +# 第二轮 review(2026-08-08,综合 @wellwei / @Ximiaw 反馈) + +## R0. 先承认一件事:第一轮**没有做需求侧分析** + +第一轮全部是供给侧 —— 核对 mcpp 现在的行为,找实现层缺陷。**没有**读 +mcpp-vscode#8 / #5 的实际需求,也没有验证「提议的接口是否闭合了它们」。 + +wellwei 指出的四条里,**两条正是需求侧分析才会抓到、而第一轮漏掉的**(R1、R3)。 +这一节存在的意义不是自责,而是记下判据:**契约设计里,「我方能提供什么」和 +「对方需要什么」是两次独立的核对,做了前者不等于做了后者。** + +## R1. `--json` 的 payload 兼容 —— 第一轮把它当纯拼写别名,错了 + +实测: + +``` +mcpp cache list --json 顶层键 = ["entries", "root"] ← 没有 envelope +mcpp xpkg parse --json 顶层 = {"namespace","name","form"} ← 没有 envelope +``` + +第一轮跟着 RFC 写「入口一处归一化,核心只见 `--format`」,默认了**拼写兼容 ⇒ payload +兼容**。不成立:如果核心随后统一输出 `{schemaVersion, kind, data, diagnostics}`,任何 +依赖旧顶层结构的消费者都会断,**包括本仓库的 e2e**。 + +**修正 —— 三选一必须先定,否则 wire v1 不能冻结:** + +| 方案 | 含义 | 代价 | +|---|---|---| +| **(A) `--json` 永久 legacy payload,`--format json` 才是 envelope** | 两条出口,语义不同 | 每个命令两份序列化;但**没有任何现存消费者会断** | +| (B) 老命令保留旧顶层,只做增量字段;envelope 只用于新命令 | 老命令永远拿不到 `schemaVersion` | mcpp-vscode#8 §7.1 的请求落空 | +| (C) 有意 breaking,给迁移窗口 | 干净 | 与「`--json` 永久保留、不打警告」的既定纪律冲突 | + +**倾向 (A)**,理由是它与本仓库既有范式一致(`compat.cppm`:旧拼写永久接受,核心只见 +规范形式),而这里要永久接受的是**旧 payload**,不是旧拼写。代价是明确且有界的。 + +## R2. `destructive` —— 与第一轮结论一致,但 wellwei 补了一刀 + +第一轮已指出它做不了 preflight 门(§2.3),wellwei 独立确认,并补充: +**单个 bool 也不够** —— 「只写 CDB」「写全局缓存」「可能触网」「执行工作区代码」是四种 +不同的边界,VSCode 的门需要区分。 + +**修正**:`--protocol-version` 的静态表不是 `destructive: bool`,而是**效应集合**: + +```jsonc +"commands": { + "self env": { "effects": [] }, + "xpkg parse": { "effects": [] }, + "metadata": { "effects": ["read-project"] }, + "metadata --resolved": { "effects": ["read-project","write-cache","network"] }, + "build --configure-only": { "effects": ["read-project","write-project","write-cache","exec-build-script"] } +} +``` + +`exec-build-script` 单独成项,因为 `build.mcpp` 会执行工作区里的代码 —— 那是 untrusted +门唯一真正在乎的一条。 + +## R3. `self env` 首次运行会初始化 —— 真问题是**作用域**,不是真假 + +wellwei 指出 `env_report()` 调 `config::load_or_init()`,不是只读。核实,并把两次测法 +都记下来,因为**第一次测法不足以定责**。 + +第一次:只在全新 `MCPP_HOME` 上跑一次 `self env`,看到 6 个文件就下结论 —— 没有排除 +「任何 mcpp 命令的首次初始化」。加对照后: + +| 命令 | 全新 home 上创建项数 | +|---|---| +| `mcpp --version` | 0 | +| **`mcpp xpkg parse --json`** | **0** | +| **`mcpp cache list --json`** | **0** | +| `mcpp self env` | **6**(`config.toml` `registry` `cache` `bin` `build-cache` `log`) | +| 已初始化 home + `self env` | **无变化,只读** | + +两条结论: + +1. **「首次运行必然初始化」不成立。** mcpp-vscode#8 实际消费的两个命令 + (`xpkg parse` / `cache list`)**什么都不建**。所以按「任何写盘都算」的口径, + `destructive` **仍然携带信息** —— 它不会退化成恒真。 +2. `self env` 是首次运行触发一次性初始化,**不是每次都写**。 + +**因此真正的分叉是作用域,不是真假。** untrusted-workspace 门在乎的是「碰不碰用户的 +项目」「执不执行项目代码」;mcpp 建**自己的 home** 不属于这一类。两条路: + +- **(a) 效应集合,作用域写在名字里**(倾向)。`self env` = `["init-mcpp-home"]`, + 客户端自己决定要不要在意;既不把它打成「危险」,也不让 `false` 被读成「什么都不写」。 +- (b) 直接定「作用域 = 工作区」,`self env` 标 `false`。更简单,但 spec 必须写死 + 「本字段不涵盖 mcpp 自身 home 的初始化」,否则 `false` 有歧义。 + +方法教训单独记:**「跑一次看有没有文件」不足以给副作用定责,必须有一个不走同一路径的 +对照命令。** 这一处若不加对照,就会得出「所有命令都会初始化,所以这个字段没意义」—— +一个由测法而非事实支撑的结论。 + +## R4. 阶段 0 的边界比第一轮画的更宽 + +实测退出码: + +``` +未知子命令 rc=127 (cli.cppm:610,stderr,正确) +未知选项 rc=2 ← 本轮 W1 已修(原 rc=1 + stdout) +未知值(pack) rc=2 (stderr,原本就对) +未捕获异常 rc=70 (main.cpp,stderr) +``` + +W1 修掉了最坏的一条。但 wellwei 说得对:**光接管 parse error 不够**,还要把 +usage / runtime / internal 的 rc 映射写成契约,并覆盖异常边界 —— 否则客户端仍然要靠 +猜。这条现在是 `docs/spec/` 的内容,不是代码。 + +## R5. 全局 `schemaVersion` 把不相关的 kind 耦合在一起 + +wellwei 的观察成立。一个全局版本号意味着 `mcpp.env` 的字段变更会推高 `mcpp.xpkg` 的 +版本,客户端无从判断哪个 kind 真的变了。 + +**修正**:envelope 版本与 kind 数据版本分开;并且 `--protocol-version` 不能只回 +`{min,max}`,要回**它支持哪些 kind 及各自版本**: + +```jsonc +{ "envelope": { "min": 1, "max": 1 }, + "kinds": { "mcpp.env": 1, "mcpp.xpkg": 1, "mcpp.cache": 1 } } +``` + +## R6. Ximiaw 的实测修正:supported-key 词汇表只覆盖部分段 + +他实测 24 段 + 源码核对,结论是「parser 已掌握每段 supported keys」**只对少数段成立**: + +- 有白名单的:`[build]`、`[resources]` 等 6 处常量(`src/manifest/toml.cppm`) +- 开放词汇段(dependencies / indices / toolchain …):无键清单,只校验值形态 +- **其余固定段(package / profile / runtime / xlings / workspace / pack):未知键被静默 + 吞掉**,合法键散在解析逻辑里 + +所以「顺带导出词汇表」不是现成的序列化。**但第三档的收敛有独立价值**:把「打错键名 +毫无提示」变成警告,对 mcpp 自身就是健壮性收益 —— 这一条应当独立于本协议推进,不该 +被 wire v1 阻塞。 + +## R7. 修正后的范围 + +| | 第一轮 | 本轮修正 | +|---|---|---| +| W0 CDB 引号 | 做 | **不变**(已完成) | +| W1 stdout 归属 | 做 | **已完成**,但 rc 映射契约要补进 spec(R4) | +| W2 `mcpp.wire` | envelope + `destructive: bool` | envelope + **效应集合**;`--protocol-version` 回 **kinds 及各自版本**(R2/R5) | +| W3 `--format` 归一 | 纯拼写别名 | **先定 payload 兼容策略(R1),否则不能冻结 v1** | +| W4 接入 | self env ~15 行 | **先拆只读 resolver**(R3);`cache list` / `xpkg parse` 按 R1 的结论决定走哪条 | +| 新增 | — | manifest 固定段的 supported-key 收敛(R6),**独立推进** | + +**结论:W2/W3/W4 在 R1 定案前不应动手** —— 那是唯一会决定 wire v1 形状的分叉。W0 与 +W1 与它正交,已完成。 diff --git a/.agents/docs/2026-08-08-wire-protocol-implementation-plan.md b/.agents/docs/2026-08-08-wire-protocol-implementation-plan.md new file mode 100644 index 00000000..f807b0c5 --- /dev/null +++ b/.agents/docs/2026-08-08-wire-protocol-implementation-plan.md @@ -0,0 +1,95 @@ +# 机器可读输出协议 —— 拆分实施计划 + +> 设计:`2026-08-08-machine-readable-output-protocol-design.md`(对 RFC #379 的核对与修正) +> 已定:未知格式走 **stderr + rc=2**;`pack --format` **声明为例外**。 + +## 顺序与理由 + +设计文档 §4.5 把 CDB 引号缺陷排在阶段 1 之前,理由是「协议做得再好,也救不了一个内容 +本身就坏掉的出口」。照此排: + +``` +W0 CDB 引号(§4.5) —— 今天就在坏,且与协议正交,先修 +W1 stdout 归属(阶段 0) —— 未知选项/未知值统一 stderr + rc=2 +W2 mcpp.wire 模块(阶段 1) —— envelope + destructive + --protocol-version +W3 --format 归一(阶段 2) —— --json 永久别名,入口一处归一 +W4 接入(阶段 3a) —— self env / xpkg parse / cache list +``` + +W4 之后的 `metadata` / `--configure-only`(阶段 3b/3c)**不在本次范围** —— 它们是新增 +能力而非契约统一,且依赖 #372 的拆分结论。 + +## W0 —— CDB 的 `arguments` 带 shell 引号 + +**File:** `src/build/compile_commands.cppm` + +- [ ] **Step 1** 红:带空格路径 + llvm 的单测,断言 + ① 任何 token 不以引号开头/结尾;② 带空格的路径是**一个** token +- [ ] **Step 2** 跑,确认 FAIL(今天两条都不满足) +- [ ] **Step 3** 让 `split_flags` 认引号。**顺序陷阱**:ninja 的 `$ ` 反转义必须发生在 + 引号**内**,否则被引号包住的空格先把 token 切断 —— 那正是现在的 bug +- [ ] **Step 4** 绿 +- [ ] **Step 5** Commit + +判据不能写成「clangd 能用了」——在不含空格的路径上恒真,正是它至今没被发现的原因。 + +## W1 —— stdout 归属 + +**Files:** `src/cli.cppm`、`src/main.cpp`(+ 依赖 `mcpplibs.cmdline` 的处置) + +- [ ] **Step 1** 红:e2e 断言未知**选项**与未知**值**在通道(stderr)与退出码(2)上一致 +- [ ] **Step 2** 跑,FAIL(今天:未知选项 → stdout/rc=1;未知值 → stderr/rc=2) +- [ ] **Step 3** 实现。`mcpplibs.cmdline:127` 用 `std::println` 写 stdout,在依赖里 —— + **本次不改依赖**,改为 mcpp 侧接管 `ParseResult`:不走 `App::run()` 的内建错误 + 打印,自己输出到 stderr 并返回 2 +- [ ] **Step 4** 绿 +- [ ] **Step 5** Commit + +## W2 —— `mcpp.wire`(独立模块) + +**New:** `src/wire.cppm` + +- [ ] **Step 1** 单测:envelope 形状(`schemaVersion` / `kind` / `destructive` / + `mcpp.version` / `mcpp.protocol{min,max}` / `data` / `diagnostics`) +- [ ] **Step 2** 实现。`Diagnostic/Position/Range/Severity` 与 envelope 构造从 #372 的 + `src/ide/model.cppm`、`src/ide/snapshot.cppm` 提升(设计文档 §5-B) +- [ ] **Step 3** `mcpp --protocol-version`:输出 `{min,max}` **加命令 → destructive 静态表** + (设计 §2.3 —— untrusted 门要在执行前知道) +- [ ] **Step 4** golden fixture,且**反向验证过**(改字段名要变红,设计 §4) +- [ ] **Step 5** Commit + +## W3 —— `--format` 归一 + +**Files:** `src/cli.cppm`(入口归一)、各 `cmd_*.cppm` + +- [ ] **Step 1** 单测:`--json` 与 `--format json` 产出**逐字节相同** +- [ ] **Step 2/3** 入口一处 `--json` → `--format json`;核心只见 `--format` +- [ ] **Step 4** 绿;`--json` 永久保留、**不打 deprecation 警告** +- [ ] **Step 5** Commit + +`pack --format tar|dir` 不动(已定为例外),文档写明。 + +## W4 —— 接入 envelope + +**Files:** `cmd_self.cppm`(新增 `self env --format json`)、`cmd_xpkg.cppm`、`cmd_cache.cppm` + +- [ ] **Step 1** 每个 `kind` 一个 golden fixture(反向验证过) +- [ ] **Step 2** `self env` 覆盖 mcpp-vscode#8 §2.4:MCPP_HOME / registry / xlings home / + index repos / default toolchain +- [ ] **Step 3** `xpkg parse` / `cache list` 包进 envelope(`schemaVersion` 随之而来) +- [ ] **Step 4** 绿 +- [ ] **Step 5** Commit + +## 平台特化的去处 + +按要求:平台差异进 `src/platform/`,协议本体独立 `.cppm`。 + +- 协议本体 → **`src/wire.cppm`**(新模块,不依赖任何命令) +- 路径/引号的平台差异 → 已在 `src/platform/`(`env::path_list_separator` 等);W0 的 + 引号识别是**平台无关**的(引号本身两平台都要剥),不新增平台分支 + +## 交付 + +- 全部并入 **PR #385**(与 docs 一起) +- 版本已是 2026.8.8.3(刚发布)⇒ 本次**跳过版本 bump**,除非 CI 要求 +- xlings pin 已是 2026.8.8.1(索引最新)⇒ 无需再抬 diff --git a/CHANGELOG.md b/CHANGELOG.md index af035aed..3143d1bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,50 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [2026.8.8.4] — 2026-08-08 + +机器可读输出有契约了。设计与实测见 +`.agents/docs/2026-08-08-machine-readable-output-protocol-design.md`,用户文档见 +`docs/11-machine-output.md`。 + +### 新增 + +- **`--format json`:统一的输出信封(`mcpp.wire`)。** 覆盖 `self env`、`xpkg parse`、 + `cache list`,含 `schemaVersion` / `kind` / `kindVersion` / `effects` / `data` / + `diagnostics`。信封与 kind 的版本**分开** —— 单一全局版本号会让给 `mcpp.env` 加一个 + 字段推高客户端为 `mcpp.xpkg` 读到的版本。 + +- **`mcpp --protocol-version`。** 回本 build 支持的信封版本、各 kind 版本,以及 + **命令 → 效应**的静态表。它是优化而非地基:在它出现之前的每个 mcpp 上,它自己就是 + 未知选项,失败与成功同走 stdout —— 所以客户端的判据只能是**正向识别**(stdout 解析 + 得出 `schemaVersion` + `kind`)。这条写进了文档的第一节。 + +- **效应集合,不是 `destructive` 布尔。** 实测:全新 `MCPP_HOME` 上 `xpkg parse` 与 + `cache list` 什么都不建,`self env` 建 6 项。布尔分不开「mcpp 给自己做初始化」和 + 「执行工作区里的代码」,而 IDE 的 untrusted 门只在乎后者。 + +- **`mcpp self env --format json` 走独立只读路径。** 不调 `load_or_init`:客户端问 + 「东西在哪」不该成为把东西放到那儿的原因。全新 home 上返回完整路径 + + `initialized: false`,创建项数 0(人类路径不变,仍会初始化)。 + +### 修复 + +- **CDB 的 `arguments` 带着 shell 引号。** 消费者(clangd)逐字 exec 它,不经 shell, + 于是带引号的 token 不是 flag 而是不存在的文件名。Windows 上每个带路径的 flag 都中招 + (`shell_quote_arg` 的触发集含反斜杠);带空格的路径上更糟 —— token 在引号**内部** + 被切断,一个参数变成两个,其中一个带着永不闭合的开引号。 + +- **usage 错误不再进 stdout。** 未知选项过去打在 stdout、rc=1、stderr 为空,于是 + `mcpp cache list --format json | jq` 拿到的是人类文本。现在未知选项与不支持的值 + 统一 stderr + rc=2,stdout 一字不写。 + +### 兼容 + +- **`--json` 永久保留它的 payload,不打 deprecation 警告。** 拼写兼容不等于 payload + 兼容:`cache list --json` 顶层是 `{root, entries}` 且本仓库 e2e 已断言。两种拼写由 + 同一来源产出,不会漂移。 +- `pack --format tar|dir` 是产物形态而非输出格式,**声明为本协议的例外**。 + ## [2026.8.8.3] — 2026-08-08 ### 修复 diff --git a/docs/11-machine-output.md b/docs/11-machine-output.md new file mode 100644 index 00000000..d3060866 --- /dev/null +++ b/docs/11-machine-output.md @@ -0,0 +1,231 @@ +# 11 — Machine-Readable Output + +mcpp writes for two audiences. This chapter is the contract for the second one: +programs. If you are building an editor extension, a CI script, or anything +that parses mcpp's output, this is what you may rely on. + +Design and the measurements behind it: +`.agents/docs/2026-08-08-machine-readable-output-protocol-design.md`. + +## 1. The rule that matters most + +> **Detect the protocol by parsing stdout. Never by exit code, and never by +> "the command did not fail".** + +Read stdout, try to parse it as JSON, and require `schemaVersion` and `kind` +to be present. If either is missing, this mcpp does not speak the protocol you +asked for. + +This is not a stylistic preference. `mcpp --protocol-version` looks like it +should be the entry point, and on a version that has it, it is a useful +shortcut. But on **every mcpp released before it existed**, that command is +itself an unknown option — and an unknown option used to print human text to +*stdout* with exit code 1 and an empty stderr. Success and failure arrived on +the same channel. Spelling it `--json` instead changes nothing; both hit the +same path. + +So positive detection is the only rule that works across versions. Everything +below is designed around it. + +## 2. The envelope + +Every enveloped response has this shape: + +```jsonc +{ + "schemaVersion": 1, // the ENVELOPE's version + "kind": "mcpp.env", // which document this is + "kindVersion": 1, // this kind's own data version + "effects": [], // what running the command did — see §4 + "mcpp": { + "version": "2026.8.8.3", + "protocol": { "min": 1, "max": 1 } + }, + "data": { /* specific to `kind` */ }, + "diagnostics": [] +} +``` + +`schemaVersion` and `kindVersion` are separate on purpose. One global number +would mean that adding a field to `mcpp.env` moves the version a client reads +for `mcpp.xpkg`, with no way to tell which actually changed. + +`effects` is always present. An empty array means "nothing"; an absent array +would mean "unknown", which is a different claim. + +### Diagnostics + +```jsonc +{ + "code": "MCPP_MANIFEST_UNKNOWN_KEY", + "severity": "error" | "warning" | "note", + "source": "mcpp", + "message": "unknown key 'standrad'", + "path": "mcpp.toml", // omitted when there is none + "range": { "start": {"line": 3, "column": 1}, + "end": {"line": 3, "column": 9} } // omitted when there is none +} +``` + +Positions are 1-based. `column` counts UTF-8 **bytes**, so it indexes the same +file mcpp read. + +A diagnostic without a location omits `path` and `range` rather than sending +zeros — `line: 0` would point at a position that does not exist. + +`code` is always present. Parse `code`; never parse `message`. + +## 3. Asking for machine output + +``` +mcpp --format json +``` + +`json` is the only supported value today. `ndjson` is reserved for a future +streaming case and is **not** accepted — asking for it is an error, not a +silent fallback. + +### Unsupported values and unknown options + +Both go to **stderr** with **exit code 2**, and write nothing to stdout: + +``` +$ mcpp self env --format yaml +error: unsupported --format 'yaml'; expected: json # stderr +$ echo $? +2 +``` + +A request that does not yet know what it will be given must not write into the +channel the protocol owns. Combined with §1, a client's rule is complete: no +JSON on stdout means "not supported", whatever the reason. + +Exit codes: + +| code | meaning | +|---|---| +| 0 | success | +| 2 | usage error — unknown option, unsupported value | +| 70 | internal error (uncaught exception) | +| 127 | unknown command | + +## 4. Effects — what a command does before it prints + +An IDE with an untrusted-workspace gate has to decide **before** running. +By the time an envelope arrives, whatever it describes has already happened. +So the same information is available statically: + +``` +mcpp --protocol-version +``` + +```jsonc +{ + "schemaVersion": 1, + "kind": "mcpp.protocol", + "envelope": { "min": 1, "max": 1 }, + "kinds": { "mcpp.env": 1, "mcpp.xpkg": 1, "mcpp.cache": 1 }, + "commands": { + "self env": { "effects": ["init-mcpp-home"] }, + "xpkg parse": { "effects": [] }, + "cache list": { "effects": [] } + } +} +``` + +Effects are named rather than a `destructive: true|false`, because a boolean +cannot separate the harmless from the thing a gate exists for: + +| effect | meaning | +|---|---| +| `init-mcpp-home` | may create `$MCPP_HOME` on first use. **Outside your project.** | +| `read-project` | reads the manifest and sources | +| `write-project` | writes into the project tree (`target/`, the compile DB) | +| `write-global-cache` | writes the shared build cache | +| `network` | may fetch | +| `exec-build-script` | **runs code from the workspace** (`build.mcpp`) | + +Most gates care about `exec-build-script` and `write-project`, and can ignore +`init-mcpp-home` — mcpp setting itself up is not the workspace acting. + +## 5. `--json` is not `--format json` + +Two commands shipped a `--json` flag before this protocol existed: + +``` +mcpp xpkg parse --json -> {"namespace": …, "name": …, …} +mcpp cache list --json -> {"root": …, "entries": [ … ]} +``` + +Those payloads are **bare** — no envelope — and consumers already read them. +So: + +> **`--json` keeps its payload for ever. `--format json` is the enveloped one.** + +`--json` is not deprecated, and using it prints no warning: clients parse this +output, and a warning would land in the middle of it. + +Both spellings are produced from the same source, so they always describe the +same thing — one answer, two shapes. + +## 6. What you may rely on, and what changes + +For each `kind`, within a `kindVersion`: + +- fields are **added**, never removed +- the meaning of a field never changes +- a breaking change bumps the version and, where a window is needed, + `protocol.min`/`max` overlap so both are readable + +That promise is only worth something if it is enforced, so each kind has a +test that fails when a field name changes. A schema nobody can break is not a +schema — `xlings interface --list` declares 20 capabilities whose +`outputSchema` is, for all 20, only `{"exitCode": integer}`, and a client that +sees a version number assumes there is a contract behind it. + +## 7. Kinds + +### `mcpp.env` — where mcpp keeps things + +``` +mcpp self env --format json +``` + +```jsonc +{ + "initialized": false, // is there a config.toml yet? + "mcppHome": "/home/u/.mcpp", + "registry": "/home/u/.mcpp/registry", + "xlingsHome": "/home/u/.mcpp/registry", + "xlingsBinary":"/home/u/.mcpp/registry/bin/xlings", + "config": "/home/u/.mcpp/config.toml", + "buildCache": "/home/u/.mcpp/build-cache/v1", + "mcppVersion": "2026.8.8.3" +} +``` + +This path is read-only, deliberately. The human `mcpp self env` initialises +`$MCPP_HOME` if it is missing — someone typing it at a prompt expects that — +but a client asking *where things are* should not be what puts them there. On +a machine that has never run mcpp you get the paths it **would** use and +`initialized: false`, and the disk is untouched. + +That is why this exists at all: without it a client has to reimplement mcpp's +home resolution, including the part where the `mcpp` on `PATH` may be an +xlings shim rather than the real binary. + +### `mcpp.xpkg` — a parsed descriptor + +``` +mcpp xpkg parse --format json +``` + +`data` is the same document `--json` prints bare. + +### `mcpp.cache` — the global build cache + +``` +mcpp cache list --format json +``` + +`data` is `{root, entries[]}`, the same document `--json` prints bare. diff --git a/docs/README.md b/docs/README.md index 4d8637f0..2118e5ae 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,7 @@ - [08 - Toolchain Internals](08-toolchain-internals.md) - [09 - Releasing mcpp](09-release.md) - [10 - Publishing a Library to mcpp-index](10-publishing-a-library.md) +- [11 - Machine-Readable Output](11-machine-output.md) ## Specifications diff --git a/docs/zh/11-machine-output.md b/docs/zh/11-machine-output.md new file mode 100644 index 00000000..2b72788a --- /dev/null +++ b/docs/zh/11-machine-output.md @@ -0,0 +1,202 @@ +# 11 — 机器可读输出 + +mcpp 面向两类读者。本章是对第二类 —— **程序** —— 的契约。如果你在写编辑器扩展、CI +脚本,或任何解析 mcpp 输出的东西,这里写的是你可以依赖的部分。 + +设计与背后的实测:`.agents/docs/2026-08-08-machine-readable-output-protocol-design.md`。 + +## 1. 最重要的一条规则 + +> **靠解析 stdout 来识别协议。不要靠退出码,也不要靠「命令没失败」。** + +读 stdout,尝试按 JSON 解析,并要求 `schemaVersion` 与 `kind` 都在。缺任一,说明这个 +mcpp 不支持你要的东西。 + +这不是风格偏好。`mcpp --protocol-version` 看起来像是入口,在支持它的版本上确实是个 +捷径。但在**它出现之前发布的每一个 mcpp** 上,它自己就是一个未知选项 —— 而未知选项 +过去会把人类文本打到 **stdout**、退出码 1、stderr 为空。成功与失败同一通道。换成 +`--json` 拼写也一样,两者走同一条路径。 + +所以「正向识别」是唯一跨版本成立的规则。下面所有设计都围绕它。 + +## 2. 信封 + +```jsonc +{ + "schemaVersion": 1, // 信封自身的版本 + "kind": "mcpp.env", // 这是哪种文档 + "kindVersion": 1, // 该 kind 的数据版本 + "effects": [], // 运行这条命令做了什么 —— 见 §4 + "mcpp": { "version": "2026.8.8.3", "protocol": { "min": 1, "max": 1 } }, + "data": { /* 随 kind 而定 */ }, + "diagnostics": [] +} +``` + +`schemaVersion` 与 `kindVersion` 分开是有意的:单一全局版本号意味着给 `mcpp.env` 加 +一个字段会推高客户端为 `mcpp.xpkg` 读到的版本,而它无从判断真正变的是哪个。 + +`effects` 永远存在。空数组表示「没有」;字段缺失会表示「未知」,那是另一个断言。 + +### 诊断 + +```jsonc +{ + "code": "MCPP_MANIFEST_UNKNOWN_KEY", + "severity": "error" | "warning" | "note", + "source": "mcpp", + "message": "unknown key 'standrad'", + "path": "mcpp.toml", // 没有则省略 + "range": { "start": {"line":3,"column":1}, + "end": {"line":3,"column":9} } // 没有则省略 +} +``` + +位置从 1 开始。`column` 数的是 **UTF-8 字节**,因此索引的是 mcpp 读到的同一份文件。 + +没有位置的诊断**省略** `path` 与 `range`,而不是填 0 —— `line: 0` 会把客户端指向一个 +不存在的位置。 + +`code` 永远存在。**解析 `code`,永远不要解析 `message`。** + +## 3. 请求机器输出 + +``` +mcpp <命令> --format json +``` + +目前只支持 `json`。`ndjson` 保留给未来真正需要流式的场景,**现在不接受** —— 请求它是 +错误,不是静默回落。 + +### 不支持的值 / 未知选项 + +两者都走 **stderr**、退出码 **2**,且**不往 stdout 写任何东西**: + +``` +$ mcpp self env --format yaml +error: unsupported --format 'yaml'; expected: json # stderr +$ echo $? +2 +``` + +一个还不知道自己会得到什么格式的请求,不该往协议独占的通道里写东西。结合 §1,客户端的 +规则就完整了:**stdout 上没有 JSON,就是不支持**,无论原因。 + +| 退出码 | 含义 | +|---|---| +| 0 | 成功 | +| 2 | 用法错误 —— 未知选项、不支持的值 | +| 70 | 内部错误(未捕获异常) | +| 127 | 未知命令 | + +## 4. effects —— 命令在输出之前做了什么 + +带 untrusted-workspace 门的 IDE 必须在**运行之前**决定。等信封到手,它描述的事情已经 +发生了。所以同一份信息也静态提供: + +``` +mcpp --protocol-version +``` + +```jsonc +{ + "kind": "mcpp.protocol", + "envelope": { "min": 1, "max": 1 }, + "kinds": { "mcpp.env": 1, "mcpp.xpkg": 1, "mcpp.cache": 1 }, + "commands": { + "self env": { "effects": ["init-mcpp-home"] }, + "xpkg parse": { "effects": [] }, + "cache list": { "effects": [] } + } +} +``` + +用具名 effects 而不是 `destructive: true|false`,因为布尔分不开「无害的」和「门存在的 +理由」: + +| effect | 含义 | +|---|---| +| `init-mcpp-home` | 首次使用时可能创建 `$MCPP_HOME`。**在你的项目之外。** | +| `read-project` | 读 manifest 与源码 | +| `write-project` | 写入项目树(`target/`、compile DB) | +| `write-global-cache` | 写共享构建缓存 | +| `network` | 可能联网 | +| `exec-build-script` | **执行工作区里的代码**(`build.mcpp`) | + +多数门只在乎 `exec-build-script` 与 `write-project`,可以忽略 `init-mcpp-home` —— +mcpp 给自己做初始化不是工作区在动作。 + +## 5. `--json` 不等于 `--format json` + +有两条命令在本协议之前就发布了 `--json`: + +``` +mcpp xpkg parse --json -> {"namespace": …, "name": …, …} +mcpp cache list --json -> {"root": …, "entries": [ … ]} +``` + +这些 payload 是**裸的**(没有信封),而且已经有消费者在读。所以: + +> **`--json` 永久保留它的 payload。`--format json` 才是带信封的那个。** + +`--json` 不弃用,使用它也**不打任何警告** —— 客户端在解析这份输出,警告会落在它中间。 + +两种拼写由同一个来源产出,所以永远描述同一件事:一个答案,两种形状。 + +## 6. 你可以依赖什么 + +对每个 `kind`,在同一 `kindVersion` 内: + +- 字段只**新增**,不删除 +- 字段含义不改变 +- 破坏性变更抬版本;需要过渡窗口时,`protocol.min`/`max` 重叠,两版都可读 + +这个承诺只有被强制执行才值钱,所以每个 kind 都有一个「改字段名就变红」的测试。 +**没人能弄坏的 schema 不是 schema** —— `xlings interface --list` 声明了 20 个 +capability,其 `outputSchema` 全部只有 `{"exitCode": integer}`,而客户端看到版本号就会 +以为背后有契约。 + +## 7. 各 kind + +### `mcpp.env` —— mcpp 把东西放在哪 + +``` +mcpp self env --format json +``` + +```jsonc +{ + "initialized": false, // 还没有 config.toml + "mcppHome": "/home/u/.mcpp", + "registry": "/home/u/.mcpp/registry", + "xlingsHome": "/home/u/.mcpp/registry", + "xlingsBinary":"/home/u/.mcpp/registry/bin/xlings", + "config": "/home/u/.mcpp/config.toml", + "buildCache": "/home/u/.mcpp/build-cache/v1", + "mcppVersion": "2026.8.8.3" +} +``` + +这条路径**刻意是只读的**。人类用的 `mcpp self env` 会在 `$MCPP_HOME` 缺失时初始化它 +—— 在提示符下敲这条命令的人预期如此 —— 但一个客户端**询问东西在哪**,不该成为把东西 +放到那儿的原因。在从没跑过 mcpp 的机器上,你会拿到它**将会**使用的路径与 +`initialized: false`,而磁盘未被触碰。 + +这也是它存在的理由:没有它,客户端就得重新实现 mcpp 的 home 解析 —— 包括「PATH 上的 +`mcpp` 可能是 xlings shim 而不是真二进制」那一部分。 + +### `mcpp.xpkg` —— 解析后的描述符 + +``` +mcpp xpkg parse --format json +``` + +`data` 就是 `--json` 裸打印的那份文档。 + +### `mcpp.cache` —— 全局构建缓存 + +``` +mcpp cache list --format json +``` + +`data` 是 `{root, entries[]}`,与 `--json` 裸打印的一致。 diff --git a/docs/zh/README.md b/docs/zh/README.md index 511a32c9..59547931 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -13,3 +13,4 @@ - [08 - 工具链机制内幕](08-toolchain-internals.md) - [09 - 发布 mcpp](09-release.md) - [10 - 发布一个库到 mcpp-index](10-publishing-a-library.md) +- [11 - 机器可读输出](11-machine-output.md) diff --git a/mcpp.toml b/mcpp.toml index 48d9247b..7555910c 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.8.8.3" +version = "2026.8.8.4" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/bmi_cache/maintenance.cppm b/src/bmi_cache/maintenance.cppm index 7cd91593..d96f03a2 100644 --- a/src/bmi_cache/maintenance.cppm +++ b/src/bmi_cache/maintenance.cppm @@ -309,31 +309,45 @@ export int cache_dir() { } // `mcpp cache list [--json]`. -export int cache_list(bool asJson) { +// The legacy `--json` payload, as a value. +// +// Split out so `--format json` can put the SAME object inside an envelope +// without the two drifting. The two spellings are not the same output -- +// `--json` has shipped with `{root, entries}` at the top level and this +// repository's e2e asserts those keys, so it keeps that shape for ever -- +// but they must always describe the same cache. +export nlohmann::json cache_list_json() { auto entries = walk_pkg_entries(); auto stds = walk_std_entries(); + nlohmann::json j; + j["root"] = mcpp::home::cache_root().string(); + j["entries"] = nlohmann::json::array(); + for (auto* set : {&entries, &stds}) { + for (auto const& e : *set) { + j["entries"].push_back({ + {"kind", e.kind}, + {"label", e.label}, + {"key", e.key}, + {"dir", e.dir.string()}, + {"bytes", e.size}, + {"files", e.fileCount}, + {"accessed", e.accessed}, + {"complete", e.complete}, + }); + } + } + return j; +} +export int cache_list(bool asJson) { if (asJson) { - nlohmann::json j; - j["root"] = mcpp::home::cache_root().string(); - j["entries"] = nlohmann::json::array(); - for (auto* set : {&entries, &stds}) { - for (auto& e : *set) { - j["entries"].push_back({ - {"kind", e.kind}, - {"label", e.label}, - {"key", e.key}, - {"dir", e.dir.string()}, - {"bytes", e.size}, - {"files", e.fileCount}, - {"accessed", e.accessed}, - {"complete", e.complete}, - }); - } - } - std::println("{}", j.dump(2)); + // The legacy shape, from the same producer as the enveloped one -- + // two spellings of one answer, never two answers. + std::println("{}", cache_list_json().dump(2)); return 0; } + auto entries = walk_pkg_entries(); + auto stds = walk_std_entries(); if (entries.empty() && stds.empty()) { std::println("(build cache is empty)"); diff --git a/src/build/compile_commands.cppm b/src/build/compile_commands.cppm index f2438d3a..d02d3cfc 100644 --- a/src/build/compile_commands.cppm +++ b/src/build/compile_commands.cppm @@ -21,6 +21,13 @@ import mcpp.libs.json; export namespace mcpp::build { +// Split one flag string into CDB `arguments` tokens. +// +// Exported because it IS the contract: what a consumer receives in +// `arguments` is decided here, and that contract needs pinning by test +// rather than by inspection of a whole generated document. +std::vector split_flags(std::string_view s); + // Generate compile_commands.json content as a string. std::string emit_compile_commands(const BuildPlan& plan, const CompileFlags& flags); @@ -54,50 +61,87 @@ bool is_c_source(const std::filesystem::path& src) { return ext == ".c" || ext == ".m"; } -// Split a flag string into individual tokens AND un-escape ninja-style -// path escapes (`$ ` → space, `$:` → `:`, `$$` → `$`). +} // namespace + +// Split one flag string into CDB `arguments` tokens. +// +// Three things happen here, and the ORDER between them is the whole point. +// +// 1. Ninja escapes are undone (`$ ` -> space, `$:` -> `:`, `$$` -> `$`). +// `flags.cppm::escape_path` adds them so a path survives embedding in a +// ninja rule string. A CDB consumer execs `arguments` literally -- no +// ninja -- so `C$:\Users\...` would be a path that does not exist. +// +// 2. Shell quoting is removed. `flags.cppm::shell_quote_arg` wraps a token +// whose characters would split or alter a word in `sh -c` / cmd.exe -- +// and its trigger set contains the BACKSLASH, so on Windows every +// path-bearing flag is quoted. That quoting is correct for ninja and +// wrong for a consumer that never invokes a shell: the quotes arrive as +// part of the filename. +// +// 3. Tokens split on spaces -- but not on a space that came from `$ `, and +// not on one inside quotes. +// +// Getting (3) wrong is what shipped: quoting was ignored entirely, so a +// quoted path containing a space was cut in half at that space. Measured on +// a real build under `/tmp/.../my project`: +// +// '-fmodule-file=std=/tmp/.../my project/.../std.pcm <- closing quote gone +// '-fprebuilt-module-path=/tmp/.../my project/.../pcm.cache' // -// `flags.cppm::escape_path` ninja-escapes path arguments so they survive -// embedding in ninja rule strings. Those escaped strings are then captured -// into `f.cxx` / `f.cc` which is what we receive here. CDB consumers like -// clangd exec the `arguments` array literally — no ninja involved — so -// escaped chars must be undone or paths like `C:\Users\...` come through -// as `C$:\Users\...` and break clangd's path resolution on Windows. (The -// same issue would silently affect any POSIX path containing a space or -// `$` — those just happen to be rare.) +// One argument became two, one of them opening a quote that never closes. +// The comment that used to live here stated the right principle -- consumers +// exec literally, so escapes must be undone -- and implemented half of it. // -// Splitting and un-escaping in one pass is correct: a literal space inside -// a path appears as `$ ` in the input, which we must NOT treat as a token -// separator. +// A quote in the MIDDLE of a token is data: `-DGREETING="hi"` keeps its +// inner quotes, or the define changes meaning. Only a quote that OPENS a +// region is quoting. std::vector split_flags(std::string_view s) { std::vector out; std::string token; + bool started = false; // token has begun (so "" is a real empty arg) + char quote = 0; // active quote char, 0 when outside + auto flush = [&] { - if (!token.empty()) { - out.push_back(std::move(token)); - token.clear(); - } + if (started) { out.push_back(std::move(token)); token.clear(); } + started = false; }; + for (std::size_t i = 0; i < s.size(); ++i) { char c = s[i]; + + // Ninja escapes first, and INSIDE quotes too -- a quoted path's space + // arrives as `$ ` because flags.cppm escapes before it quotes. if (c == '$' && i + 1 < s.size()) { char nc = s[i + 1]; if (nc == ' ' || nc == ':' || nc == '$') { token.push_back(nc); + started = true; ++i; continue; } } - if (c == ' ') { - flush(); - } else { + + if (quote) { + if (c == quote) { quote = 0; continue; } // closes the region token.push_back(c); + continue; } + + // Opens a region only at a token boundary; elsewhere it is data. + if ((c == '"' || c == '\'') && !started) { quote = c; started = true; continue; } + + if (c == ' ') { flush(); continue; } + + token.push_back(c); + started = true; } flush(); return out; } +namespace { + std::vector local_include_args(const CompileUnit& cu) { std::vector args; args.reserve(cu.localIncludeDirs.size()); diff --git a/src/cli.cppm b/src/cli.cppm index 5e6c6c10..2290fb5c 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -28,6 +28,7 @@ import mcpp.cli.cmd_self; import mcpp.cli.cmd_toolchain; import mcpp.pm.commands; import mcpp.toolchain.fingerprint; // MCPP_VERSION +import mcpp.wire; import mcpp.platform.env; // --offline → MCPP_OFFLINE import mcpp.ui; import mcpp.log; @@ -214,6 +215,12 @@ int run(int argc, char** argv) { .option(cl::Option("offline") .help("Never touch the network (index refresh, downloads, toolchain install)") .global()) + // Answers "what do you speak" without spawning a command that might + // fail. An optimisation, NOT the client's detection rule: on any mcpp + // predating it this is itself an unknown option, so a client must + // still detect the protocol by parsing stdout for schemaVersion+kind. + .option(cl::Option("protocol-version") + .help("Print the machine-output protocol this build speaks (JSON)")) // ─── project commands ────────────────────────────────────────── .subcommand(cl::App("new") @@ -369,7 +376,10 @@ int run(int argc, char** argv) { .description("Inspect / validate xpkg descriptors") .subcommand(cl::App("parse") .description("Parse a descriptor's mcpp segment exactly as the resolver would (strict: unknown keys are errors)") - .option(cl::Option("json").help("Emit machine-readable JSON")) + .option(cl::Option("json") + .help("Emit machine-readable JSON (legacy payload, kept for ever)")) + .option(cl::Option("format").takes_value().value_name("json") + .help("Machine-readable output (enveloped; see docs/11-machine-output.md)")) .option(cl::Option("allow-unknown") .help("Downgrade unknown mcpp-segment keys from error to warning")) .option(cl::Option("all-os") @@ -420,7 +430,10 @@ int run(int argc, char** argv) { .description("Print the cache root (and any pre-v1 cache)")) .subcommand(cl::App("list") .description("List cache entries with size + last-use") - .option(cl::Option("json").help("Emit machine-readable JSON"))) + .option(cl::Option("json") + .help("Emit machine-readable JSON (legacy payload, kept for ever)")) + .option(cl::Option("format").takes_value().value_name("json") + .help("Machine-readable output (enveloped; see docs/11-machine-output.md)"))) .subcommand(cl::App("info") .description("Show details (incl. key inputs) for a cached package") .arg(cl::Arg("pkg").help("@").required())) @@ -499,7 +512,9 @@ int run(int argc, char** argv) { .subcommand(cl::App("doctor") .description("Diagnose mcpp environment health")) .subcommand(cl::App("env") - .description("Print mcpp paths and configuration")) + .description("Print mcpp paths and configuration") + .option(cl::Option("format").takes_value().value_name("json") + .help("Machine-readable output (enveloped; see docs/11-machine-output.md)"))) .subcommand(cl::App("config") .description("Show or modify mcpp's private xlings configuration") .option(cl::Option("mirror").takes_value().value_name("CN|GLOBAL") @@ -588,6 +603,37 @@ int run(int argc, char** argv) { return cmd_explain(argv[2]); } + // What each machine-output command does before it prints anything. + // + // Declared here, beside the commands themselves, rather than inside + // mcpp.wire: the effects of `self env` are a fact about `self env`. A + // protocol module that knew the command list would mean adding a command + // in one file and remembering to describe it in another. + // + // `self env` carries `init-mcpp-home` because on a fresh machine it does + // create $MCPP_HOME -- measured: six entries, where `xpkg parse` and + // `cache list` create none. Naming the effect rather than flagging a + // boolean lets an IDE ignore this one and still refuse `exec-build-script`. + auto protocol_commands = [] { + using mcpp::wire::Effect; + return std::vector{ + {"self env", {Effect::InitMcppHome}}, + {"xpkg parse", {}}, + {"cache list", {}}, + }; + }; + + // `--protocol-version` is answered before anything else parses, and + // before any command can decide it needs a project. A client asks this + // first, in a directory that may not be one. + for (int i = 1; i < argc; ++i) { + if (std::string_view(argv[i]) == "--protocol-version") { + std::println("{}", + mcpp::wire::protocol_document(protocol_commands()).dump(2)); + return 0; + } + } + // Unknown-command pre-check. cmdline doesn't error on an unknown // top-level word — it just treats it as a positional and returns // 0 silently (since the root App has no top-level action). The @@ -612,8 +658,30 @@ int run(int argc, char** argv) { } } - auto app_rc = app.run(trimmed_argc, trimmed_argp); - if (app_rc != 0) return app_rc; + // Parse and dispatch separately, rather than `app.run(argc, argv)`. + // + // `App::run` prints its parse errors with `std::println` -- to STDOUT -- + // and returns 1. stdout is the channel a machine-readable request owns, so + // a client doing `mcpp cache list --format json | jq` got + // `Error: unknown option: --format` fed to its parser, with nothing to + // distinguish "this mcpp is too old" from "the command failed". stderr was + // empty. + // + // That print lives in mcpplibs.cmdline, a published dependency. Taking the + // ParseResult here fixes it without a cross-package release, and keeps one + // rule for the whole CLI: anything mcpp says ABOUT ITSELF goes to stderr. + // + // Exit 2 for a usage error, matching `pack --format bogus` -- which was + // already right, and was the only one of the two that was. + auto parsed = app.parse(trimmed_argc, trimmed_argp); + if (!parsed) { + // `--help` / `--version` come back as a non-error "failure": the + // parser handled them and printed. Nothing to add, nothing to report. + if (!parsed.error().is_error()) return 0; + mcpp::ui::error(parsed.error().message); + return 2; + } + app.run(*parsed); return action_rc; } diff --git a/src/cli/cmd_cache.cppm b/src/cli/cmd_cache.cppm index f7a48eff..5a121200 100644 --- a/src/cli/cmd_cache.cppm +++ b/src/cli/cmd_cache.cppm @@ -11,6 +11,8 @@ import std; import mcpplibs.cmdline; import mcpp.bmi_cache.maintenance; import mcpp.ui; +import mcpp.wire; +import mcpp.libs.json; namespace mcpp::cli { @@ -19,6 +21,23 @@ export int cmd_cache_dir(const mcpplibs::cmdline::ParsedArgs& /*parsed*/) { } export int cmd_cache_list(const mcpplibs::cmdline::ParsedArgs& parsed) { + // `--format json` is enveloped; `--json` keeps the payload it shipped + // with (`{root, entries}` at the top level, which this repo's own e2e + // asserts). Spelling compatibility is not payload compatibility, and + // wrapping the old spelling would break every consumer that already + // reads it. + if (auto f = parsed.value("format")) { + auto fmt = mcpp::wire::parse_format(*f); + if (!fmt) { + std::println(stderr, "error: {}", mcpp::wire::unsupported_format(*f)); + return 2; + } + mcpp::wire::emit({ + .kind = "mcpp.cache", + .data = mcpp::bmi_cache::cache_list_json(), + }); + return 0; + } return mcpp::bmi_cache::cache_list(parsed.is_flag_set("json")); } diff --git a/src/cli/cmd_self.cppm b/src/cli/cmd_self.cppm index 7a57a6d5..2135dae1 100644 --- a/src/cli/cmd_self.cppm +++ b/src/cli/cmd_self.cppm @@ -11,10 +11,72 @@ import std; import mcpplibs.cmdline; import mcpp.doctor; import mcpp.toolchain.fingerprint; // MCPP_VERSION +import mcpp.home; +import mcpp.platform; +import mcpp.wire; +import mcpp.libs.json; namespace mcpp::cli { -export int cmd_env(const mcpplibs::cmdline::ParsedArgs& /*parsed*/) { +// `self env --format json`. +// +// Deliberately NOT `env_report()` plus a serializer. That path calls +// `config::load_or_init()`, which on a machine that has never run mcpp +// creates $MCPP_HOME -- measured: `config.toml`, `registry/`, `cache/`, +// `bin/`, `build-cache/`, `log/`, six entries, where `mcpp --version`, +// `xpkg parse` and `cache list` create none. Reporting where things ARE +// should not be what puts them there. +// +// So the machine path computes paths and reads what already exists. When +// nothing does, it says so (`initialized: false`) and reports the paths mcpp +// WOULD use, rather than creating them to be able to answer. A client asking +// "where is your home" on a fresh machine gets an answer and an untouched +// disk. +// +// The human path is unchanged: someone typing `mcpp self env` at a prompt +// expects mcpp to set itself up, and always has. +namespace { + +nlohmann::json env_data_readonly() { + namespace fs = std::filesystem; + const auto home = mcpp::home::root(); + std::error_code ec; + + const auto registry = home / "registry"; + const auto config = home / "config.toml"; + const bool initialized = fs::exists(config, ec); + + auto s = [](const fs::path& p) { return p.string(); }; + return nlohmann::json{ + {"initialized", initialized}, + {"mcppHome", s(home)}, + {"registry", s(registry)}, + {"xlingsHome", s(registry)}, // registryDir unless overridden + {"xlingsBinary", s(registry / "bin" / + ("xlings" + std::string(mcpp::platform::exe_suffix)))}, + {"config", s(config)}, + {"buildCache", s(mcpp::home::cache_root())}, + {"mcppVersion", std::string(mcpp::toolchain::MCPP_VERSION)}, + }; +} + +} // namespace + +export int cmd_env(const mcpplibs::cmdline::ParsedArgs& parsed) { + if (auto f = parsed.value("format")) { + auto fmt = mcpp::wire::parse_format(*f); + if (!fmt) { + std::println(stderr, "error: {}", + mcpp::wire::unsupported_format(*f)); + return 2; + } + mcpp::wire::emit({ + .kind = "mcpp.env", + .effects = {}, // this path creates nothing; see above + .data = env_data_readonly(), + }); + return 0; + } return mcpp::doctor::env_report(); } diff --git a/src/cli/cmd_xpkg.cppm b/src/cli/cmd_xpkg.cppm index f9ff13f4..3a2b4a1e 100644 --- a/src/cli/cmd_xpkg.cppm +++ b/src/cli/cmd_xpkg.cppm @@ -20,11 +20,34 @@ import mcpplibs.cmdline; import mcpp.manifest; import mcpp.platform.axis; import mcpp.ui; +import mcpp.wire; +import mcpp.libs.json; namespace mcpp::cli { namespace { +// One exit for both spellings. +// +// `--json` prints the payload exactly as it has shipped -- a bare object, no +// envelope -- because consumers already read that shape and this repository's +// own e2e asserts its keys. `--format json` wraps the SAME payload. Two +// spellings of one answer; the payload is produced once so they cannot drift. +// +// The payload is built as a string at three sites in this file, so it is +// parsed back here rather than duplicating three builders. A parse failure +// means mcpp emitted invalid JSON, which is a bug worth surfacing loudly +// rather than papering over. +void emit_xpkg(std::string_view payload, bool enveloped) { + if (!enveloped) { std::println("{}", payload); return; } + auto j = nlohmann::json::parse(payload, nullptr, /*allow_exceptions=*/false); + if (j.is_discarded()) { + mcpp::ui::error("internal: xpkg payload is not valid JSON"); + j = nlohmann::json::object(); + } + mcpp::wire::emit({.kind = "mcpp.xpkg", .data = std::move(j)}); +} + std::string json_escape(std::string_view s) { std::string out; out.reserve(s.size() + 8); @@ -69,7 +92,15 @@ export int cmd_xpkg_parse(const mcpplibs::cmdline::ParsedArgs& parsed) { } std::string lua{std::istreambuf_iterator(is), {}}; - const bool asJson = parsed.is_flag_set("json"); + bool enveloped = false; + if (auto f = parsed.value("format")) { + if (!mcpp::wire::parse_format(*f)) { + std::println(stderr, "error: {}", mcpp::wire::unsupported_format(*f)); + return 2; + } + enveloped = true; + } + const bool asJson = enveloped || parsed.is_flag_set("json"); const bool allowUnknown = parsed.is_flag_set("allow-unknown"); const bool allOs = parsed.is_flag_set("all-os"); const bool allowSplitName = parsed.is_flag_set("allow-split-name"); @@ -105,10 +136,10 @@ export int cmd_xpkg_parse(const mcpplibs::cmdline::ParsedArgs& parsed) { ? std::nullopt : mcpp::manifest::xpkg_name_form_violation_from_lua(lua)) { if (asJson) { - std::println("{{\"namespace\":\"{}\",\"name\":\"{}\"," - "\"error\":\"{}\"}}", - json_escape(id.ns), json_escape(id.name), - json_escape(*violation)); + emit_xpkg(std::format( + "{{\"namespace\":\"{}\",\"name\":\"{}\",\"error\":\"{}\"}}", + json_escape(id.ns), json_escape(id.name), + json_escape(*violation)), enveloped); } else { mcpp::ui::error(std::format("{}: {}", file, *violation)); } @@ -136,8 +167,9 @@ export int cmd_xpkg_parse(const mcpplibs::cmdline::ParsedArgs& parsed) { auto field = mcpp::manifest::extract_mcpp_field(lua); if (field.kind != mcpp::manifest::McppField::TableBody) { if (asJson) { - std::println("{{\"namespace\":\"{}\",\"name\":\"{}\",\"form\":\"A\"}}", - json_escape(id.ns), json_escape(id.name)); + emit_xpkg(std::format( + "{{\"namespace\":\"{}\",\"name\":\"{}\",\"form\":\"A\"}}", + json_escape(id.ns), json_escape(id.name)), enveloped); } else { std::println("package {} (namespace '{}')", fqn, id.ns); std::println("form A — no mcpp segment (build info from the " @@ -243,7 +275,8 @@ export int cmd_xpkg_parse(const mcpplibs::cmdline::ParsedArgs& parsed) { } } genContents += "}"; - std::println("{{\"namespace\":\"{}\",\"name\":\"{}\",\"versions\":{}," + emit_xpkg(std::format( + "{{\"namespace\":\"{}\",\"name\":\"{}\",\"versions\":{}," "\"standard\":\"{}\",\"import_std\":{},\"sources\":{}," "\"include_dirs\":{},\"generated_files\":{}," "\"generated_contents\":{},\"targets\":{}," @@ -259,7 +292,7 @@ export int cmd_xpkg_parse(const mcpplibs::cmdline::ParsedArgs& parsed) { return dirs; }()), genFiles, genContents, targets, - json_array(m->xpkgUnknownKeys)); + json_array(m->xpkgUnknownKeys)), enveloped); return rc; } diff --git a/src/version.cppm b/src/version.cppm index 160d73d5..28ea8cda 100644 --- a/src/version.cppm +++ b/src/version.cppm @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.8.8.3"; +inline constexpr std::string_view MCPP_VERSION = "2026.8.8.4"; } // namespace mcpp diff --git a/src/wire.cppm b/src/wire.cppm new file mode 100644 index 00000000..f363f6d2 --- /dev/null +++ b/src/wire.cppm @@ -0,0 +1,259 @@ +// mcpp.wire — the machine-readable output contract, in one place. +// +// Every JSON that leaves mcpp for a program rather than a person goes through +// here. That is the whole point: `xlings interface` declares 20 capabilities +// whose `outputSchema` is, for all 20, only `{"exitCode": integer}` — a schema +// that says nothing while looking like a contract. A client reads +// `schemaVersion` and assumes there is one. So this module owns the envelope +// AND the rules that keep it honest. +// +// Design: .agents/docs/2026-08-08-machine-readable-output-protocol-design.md +// +// ── Two decisions worth knowing before reading the code ──────────────────── +// +// 1. THE ENVELOPE IS SELF-IDENTIFYING, and clients are told to detect it by +// PARSING, never by exit code. +// +// `mcpp --protocol-version` cannot solve the bootstrap problem it looks +// like it solves: on every mcpp that predates it, that command IS the +// "spawn something that might fail" case, and its failure is +// indistinguishable from success by channel. Measured, before the fix in +// this same series: it printed `Error: unknown option` to STDOUT with rc=1 +// and an empty stderr. Changing the spelling to `--json` changes nothing — +// both hit the same unknown-option path. +// +// So the only rule that works against versions that predate the protocol +// is positive detection: read stdout, try to parse it, and require +// `schemaVersion` + `kind` to be there. `--protocol-version` remains +// useful on versions that have it (one fewer spawn), but it is an +// optimisation, not the foundation. +// +// 2. EFFECTS, NOT A `destructive` BOOLEAN. +// +// A boolean cannot separate "creates mcpp's own home on first run" from +// "executes code out of the workspace", and an IDE's untrusted-workspace +// gate cares only about the second. Measured on a fresh MCPP_HOME: +// `xpkg parse --json` and `cache list --json` create NOTHING, while +// `self env` creates six entries — so the field does carry information, +// but only if the effect is NAMED. `init-mcpp-home` is something a client +// can reasonably ignore; `exec-build-script` is not. +// +// Effects are also reported in `--protocol-version` as a static table, +// because a gate has to decide BEFORE running — by the time an envelope +// arrives, whatever it describes has already happened. +module; + +#include + +export module mcpp.wire; + +import std; +import mcpp.version; +import mcpp.libs.json; + +export namespace mcpp::wire { + +// The envelope's own version. Bumped only when the envelope SHAPE changes — +// never for a change inside one command's `data`, which carries its own +// version in `kinds` below. A single global number would couple unrelated +// kinds: a field added to `mcpp.env` would push the version a client reads +// for `mcpp.xpkg`, and the client could not tell which one actually moved. +inline constexpr int kEnvelopeVersion = 1; + +// Each kind's `data` version, and the list of kinds this build knows. +// +// Reported by `--protocol-version` so a client can ask "do you speak +// mcpp.env, and at what version" without spawning the command and guessing +// from what comes back. +struct KindVersion { std::string_view kind; int version; }; + +inline constexpr std::array kKinds{{ + {"mcpp.env", 1}, + {"mcpp.xpkg", 1}, + {"mcpp.cache", 1}, +}}; + +// What running a command does, beyond writing to stdout. +// +// Named rather than boolean. The scope is deliberately part of the name: +// `write-project` and `init-mcpp-home` are both "writes files", and only one +// of them is a reason for an IDE to refuse. +enum class Effect { + InitMcppHome, // creates $MCPP_HOME on first use. Outside the project. + ReadProject, // reads the manifest / sources + WriteProject, // writes into the project tree (target/, CDB, …) + WriteGlobalCache, // writes the shared build cache + Network, // may fetch + ExecBuildScript, // runs code FROM THE WORKSPACE (build.mcpp). The one an + // untrusted-workspace gate exists for. +}; + +constexpr std::string_view effect_name(Effect e) { + switch (e) { + case Effect::InitMcppHome: return "init-mcpp-home"; + case Effect::ReadProject: return "read-project"; + case Effect::WriteProject: return "write-project"; + case Effect::WriteGlobalCache: return "write-global-cache"; + case Effect::Network: return "network"; + case Effect::ExecBuildScript: return "exec-build-script"; + } + return "unknown"; +} + +// ── Diagnostics ──────────────────────────────────────────────────────────── +// +// A position is 1-based, matching every compiler and every editor. `column` +// counts UTF-8 bytes, not code points: it is what a client needs to index the +// same file mcpp read. +struct Position { int line = 0; int column = 0; }; +struct Range { Position start; Position end; }; + +enum class Severity { Error, Warning, Note }; + +constexpr std::string_view severity_name(Severity s) { + switch (s) { + case Severity::Error: return "error"; + case Severity::Warning: return "warning"; + case Severity::Note: return "note"; + } + return "note"; +} + +// `code` is required, and deliberately so. `MCPP_IDE_CONFIGURE_FAILED` -- +// one code covering every failure, with the docs forbidding clients from +// parsing the human message -- is structured in name only. A new failure +// branch either gets its own code, or is declared indivisible on purpose. +struct Diagnostic { + std::string code; + Severity severity = Severity::Error; + std::string message; + std::string path; // optional + std::optional range; // optional +}; + +// ── The envelope ─────────────────────────────────────────────────────────── + +struct Envelope { + std::string_view kind; + std::vector effects; + nlohmann::json data = nlohmann::json::object(); + std::vector diagnostics; +}; + +inline int kind_version(std::string_view kind) { + for (auto const& k : kKinds) if (k.kind == kind) return k.version; + return 0; +} + +inline nlohmann::json to_json(const Diagnostic& d) { + nlohmann::json j{ + {"code", d.code}, + {"severity", std::string(severity_name(d.severity))}, + {"source", "mcpp"}, + {"message", d.message}, + }; + if (!d.path.empty()) j["path"] = d.path; + if (d.range) { + j["range"] = { + {"start", {{"line", d.range->start.line}, {"column", d.range->start.column}}}, + {"end", {{"line", d.range->end.line}, {"column", d.range->end.column}}}, + }; + } + return j; +} + +inline nlohmann::json to_json(const Envelope& e) { + nlohmann::json effects = nlohmann::json::array(); + for (auto f : e.effects) effects.push_back(std::string(effect_name(f))); + + nlohmann::json diags = nlohmann::json::array(); + for (auto const& d : e.diagnostics) diags.push_back(to_json(d)); + + return nlohmann::json{ + {"schemaVersion", kEnvelopeVersion}, + {"kind", std::string(e.kind)}, + {"kindVersion", kind_version(e.kind)}, + {"effects", std::move(effects)}, + {"mcpp", { + {"version", std::string(mcpp::MCPP_VERSION)}, + {"protocol", {{"min", kEnvelopeVersion}, {"max", kEnvelopeVersion}}}, + }}, + {"data", e.data}, + {"diagnostics", std::move(diags)}, + }; +} + +// Serialise and write to stdout. Two spaces, trailing newline: a client reads +// it with a JSON parser either way, and a person reading a bug report should +// not have to reformat it first. +inline void emit(const Envelope& e) { + std::println("{}", to_json(e).dump(2)); +} + +// ── Negotiation ──────────────────────────────────────────────────────────── +// +// `mcpp --protocol-version`. Answers three things a client would otherwise +// have to discover by spawning commands and interpreting failures: which +// envelope versions, which kinds at which versions, and what each command +// does before it prints anything. +// The caller supplies the table. This module deliberately does NOT know which +// commands exist: the effects of `self env` are a fact about `self env`, and +// belong next to where it is declared. Keeping the list here would mean adding +// a command in one file and remembering to describe it in another. +struct CommandEffects { std::string_view command; std::vector effects; }; + +inline nlohmann::json protocol_document( + const std::vector& commands) { + nlohmann::json kinds = nlohmann::json::object(); + for (auto const& k : kKinds) kinds[std::string(k.kind)] = k.version; + + nlohmann::json cmds = nlohmann::json::object(); + for (auto const& c : commands) { + nlohmann::json fx = nlohmann::json::array(); + for (auto f : c.effects) fx.push_back(std::string(effect_name(f))); + cmds[std::string(c.command)] = nlohmann::json{{"effects", std::move(fx)}}; + } + + return nlohmann::json{ + {"schemaVersion", kEnvelopeVersion}, + {"kind", "mcpp.protocol"}, + {"mcpp", {{"version", std::string(mcpp::MCPP_VERSION)}}}, + {"envelope", {{"min", kEnvelopeVersion}, {"max", kEnvelopeVersion}}}, + {"kinds", std::move(kinds)}, + {"commands", std::move(cmds)}, + }; +} + +// ── Output format selection ──────────────────────────────────────────────── +// +// One spelling in the core (`--format `), with `--json` accepted for +// ever at the entry points. Same shape as `toolchain/compat.cppm`: the only +// place that knows the old spelling is the boundary, and no deprecation +// warning is ever printed — clients already parse this output, and a warning +// would land in the middle of it. +// +// IMPORTANT, and the reason `--json` is not merely an alias: the two are not +// the same OUTPUT. `cache list --json` has shipped with `{root, entries}` at +// the top level and this repository's own e2e asserts those keys. Wrapping +// that in an envelope is a breaking change. So `--json` keeps the legacy +// payload for ever, and `--format json` is the enveloped one. Spelling +// compatibility is not payload compatibility, and treating them as the same +// thing is how a published contract gets broken quietly. +enum class Format { Human, LegacyJson, Json }; + +// Unknown values are NOT decided here — the caller reports them, because only +// the caller knows the command name for the message. See `unsupported_format`. +inline std::optional parse_format(std::string_view v) { + if (v == "json") return Format::Json; + return std::nullopt; +} + +// The message for an unsupported `--format`. Goes to stderr with exit 2 (the +// caller does that): a request that does not yet know what it will be given +// must not write into the channel the protocol owns, and a client that finds +// no JSON on stdout has its answer. +inline std::string unsupported_format(std::string_view got) { + return std::format("unsupported --format '{}'; expected: json", got); +} + +} // namespace mcpp::wire diff --git a/tests/e2e/202_machine_output_contract.sh b/tests/e2e/202_machine_output_contract.sh new file mode 100755 index 00000000..dc50563d --- /dev/null +++ b/tests/e2e/202_machine_output_contract.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# 202_machine_output_contract.sh — stdout belongs to the protocol. +# +# A client that asks for machine-readable output has exactly one channel for +# it. Anything mcpp says about ITSELF -- a usage error, an unsupported format +# -- has to go somewhere else, or the client's parser eats it. +# +# Measured before this test existed: +# +# mcpp cache list --format json -> stdout: "Error: unknown option: --format" +# stderr: (empty) rc=1 +# mcpp pack --format bogus -> stderr: "invalid --format ..." rc=2 +# +# Two shapes for the same class of mistake, and the first one writes human text +# into the channel a protocol owns. `mcpp cache list --format json | jq` gets a +# parse error with nothing to distinguish "this mcpp is too old" from "the +# command failed". +# +# Design: .agents/docs/2026-08-08-machine-readable-output-protocol-design.md §2 +set -uo pipefail + +fail=0 +check() { # description expected_rc cmd... + local what=$1 want=$2; shift 2 + local out err rc + out=$("$@" 2>/dev/null); rc=$? + err=$("$@" 2>&1 >/dev/null) + if [[ -n "$out" ]]; then + echo "FAIL: $what wrote to stdout: $(echo "$out" | head -1 | cut -c1-70)" + fail=1 + fi + if [[ -z "$err" ]]; then + echo "FAIL: $what said nothing on stderr" + fail=1 + fi + if [[ "$rc" != "$want" ]]; then + echo "FAIL: $what exited $rc, expected $want" + fail=1 + fi +} + +# An option that does not exist. This is the path a client hits when it probes +# an older mcpp for a capability -- the most common machine-facing failure, +# and the one that used to print to stdout. +check "unknown option (cache list)" 2 "$MCPP" cache list --no-such-option +check "unknown option (self env)" 2 "$MCPP" self env --no-such-option +check "unknown option (top level)" 2 "$MCPP" --no-such-option + +# A value the command does not accept. `--format` exists on these now, so this +# is the branch that answers "I know the option, not that value". +check "unsupported value (self env)" 2 "$MCPP" self env --format yaml +check "unsupported value (cache list)" 2 "$MCPP" cache list --format yaml +check "unsupported value (pack)" 2 "$MCPP" pack --format bogus + +# And the supported ones must still be JSON on stdout. Asserted here because +# every check above is about what does NOT happen; without this, deleting the +# feature entirely would leave the file green. +for cmd in "self env" "cache list"; do + out=$($MCPP $cmd --format json 2>/dev/null) || { echo "FAIL: $cmd --format json exited non-zero"; fail=1; } + echo "$out" | grep -q '"schemaVersion"' || { echo "FAIL: $cmd --format json has no schemaVersion"; fail=1; } + echo "$out" | grep -q '"kind"' || { echo "FAIL: $cmd --format json has no kind"; fail=1; } +done + +[[ "$fail" -eq 0 ]] || exit 1 +echo "PASS: usage errors stay off stdout and exit 2" diff --git a/tests/unit/test_compile_commands.cpp b/tests/unit/test_compile_commands.cpp index b894106a..7be570db 100644 --- a/tests/unit/test_compile_commands.cpp +++ b/tests/unit/test_compile_commands.cpp @@ -92,3 +92,83 @@ TEST(CompileCommandsMerge, MalformedExistingFallsBackToFresh) { EXPECT_NE(merged.find("src/main.cpp"), std::string::npos) << merged; EXPECT_NE(merged.find("-O2"), std::string::npos) << merged; } + +// ── CDB arguments must be argv, not shell words ───────────────────────────── +// +// A consumer (clangd) execs `arguments` LITERALLY — no shell. So a token that +// still carries the quoting a shell would have removed is not a flag, it is a +// filename that does not exist. +// +// This is reachable on every platform, and was found on Windows first only +// because `shell_quote_arg`'s trigger set contains the backslash: every +// Windows path has one, so every path-bearing flag there gets quoted. On +// POSIX it takes a space in the path — which is why no e2e caught it: none of +// their project paths have one. +// +// Measured before the fix, from a real build under `/tmp/.../my project`: +// +// '-fmodule-file=std=/tmp/.../my project/.../std.pcm <- closing quote gone +// '-fprebuilt-module-path=/tmp/.../my project/.../pcm.cache' +// +// The first is worse than a stray quote: the token was split at the space +// INSIDE the quotes, so one argument became two, one of them opening a quote +// that never closes. +namespace { + +bool is_quoted(std::string_view s) { + if (s.size() < 2) return false; + return (s.front() == '"' && s.back() == '"') + || (s.front() == '\'' && s.back() == '\''); +} +bool has_edge_quote(std::string_view s) { + return !s.empty() + && (s.front() == '"' || s.front() == '\'' + || s.back() == '"' || s.back() == '\''); +} + +} // namespace + +TEST(CompileCommandsArgs, StripsPosixQuotingFromAToken) { + auto out = mcpp::build::split_flags("-O2 '-fprebuilt-module-path=/a/b' -g"); + ASSERT_EQ(out.size(), 3u); + EXPECT_EQ(out[1], "-fprebuilt-module-path=/a/b"); + for (auto const& t : out) EXPECT_FALSE(has_edge_quote(t)) << t; +} + +TEST(CompileCommandsArgs, StripsWindowsQuotingFromAToken) { + auto out = mcpp::build::split_flags(R"(-O2 "-IC:\Users\x\inc" -g)"); + ASSERT_EQ(out.size(), 3u); + EXPECT_EQ(out[1], R"(-IC:\Users\x\inc)"); + for (auto const& t : out) EXPECT_FALSE(has_edge_quote(t)) << t; +} + +// The one that bit: a quoted path containing a space is ONE argument. The +// space arrives ninja-escaped (`$ `) because flags.cppm escapes before it +// quotes, so the un-escape has to happen INSIDE the quotes — do it in the +// wrong order and the token splits exactly here. +TEST(CompileCommandsArgs, AQuotedPathWithASpaceStaysOneToken) { + auto out = mcpp::build::split_flags( + "-O2 '-fmodule-file=std=/tmp/my$ project/std.pcm' -g"); + ASSERT_EQ(out.size(), 3u) << "the quoted path was split"; + EXPECT_EQ(out[1], "-fmodule-file=std=/tmp/my project/std.pcm"); + for (auto const& t : out) EXPECT_FALSE(has_edge_quote(t)) << t; +} + +// Unquoted input must keep behaving exactly as before: ninja escapes undone, +// split on spaces. +TEST(CompileCommandsArgs, UnquotedInputIsUnchanged) { + auto out = mcpp::build::split_flags("-IC$:/x -DA=1 -Idir$ with$ space"); + ASSERT_EQ(out.size(), 3u); + EXPECT_EQ(out[0], "-IC:/x"); + EXPECT_EQ(out[1], "-DA=1"); + EXPECT_EQ(out[2], "-Idir with space"); +} + +// A quote in the MIDDLE of a token is data, not quoting: `-DA="x"` must keep +// its inner quotes or the define changes meaning. +TEST(CompileCommandsArgs, InnerQuotesAreNotStripped) { + auto out = mcpp::build::split_flags(R"(-DGREETING="hi")"); + ASSERT_EQ(out.size(), 1u); + EXPECT_EQ(out[0], R"(-DGREETING="hi")"); + EXPECT_FALSE(is_quoted(out[0])); +} diff --git a/tests/unit/test_wire.cpp b/tests/unit/test_wire.cpp new file mode 100644 index 00000000..9d7765a3 --- /dev/null +++ b/tests/unit/test_wire.cpp @@ -0,0 +1,247 @@ +// The envelope is a contract, so it is tested like one. +// +// The lesson these assertions encode is xlings': `interface --list` declares +// 20 capabilities whose `outputSchema` is, for all 20, only +// `{"exitCode": integer}`. A schema that says nothing while looking like a +// contract is worse than none — a client reads `schemaVersion` and assumes +// there is one. So the shape is pinned here, and changing a field name has to +// turn this file red. +// +// Design: .agents/docs/2026-08-08-machine-readable-output-protocol-design.md + +#include + +import std; +import mcpp.wire; +import mcpp.libs.json; + +namespace w = mcpp::wire; + +namespace { + +// A client's ONLY reliable rule against an mcpp that predates the protocol is +// to parse stdout and require these two. `--protocol-version` cannot serve as +// the entry point: on older versions it is itself an unknown option. So the +// envelope must be self-identifying, and these two keys are what identifies +// it. +TEST(WireEnvelope, IsSelfIdentifying) { + auto j = w::to_json(w::Envelope{.kind = "mcpp.env"}); + ASSERT_TRUE(j.contains("schemaVersion")) << "a client detects by parsing"; + ASSERT_TRUE(j.contains("kind")); + EXPECT_EQ(j["schemaVersion"], w::kEnvelopeVersion); + EXPECT_EQ(j["kind"], "mcpp.env"); +} + +TEST(WireEnvelope, CarriesTheKindsOwnVersionSeparately) { + auto j = w::to_json(w::Envelope{.kind = "mcpp.xpkg"}); + // Separate from schemaVersion on purpose: one global number would make a + // field added to mcpp.env move the version a client reads for mcpp.xpkg, + // with no way to tell which actually changed. + ASSERT_TRUE(j.contains("kindVersion")); + EXPECT_EQ(j["kindVersion"], 1); +} + +TEST(WireEnvelope, AnUnknownKindHasNoVersion) { + auto j = w::to_json(w::Envelope{.kind = "mcpp.not-a-kind"}); + EXPECT_EQ(j["kindVersion"], 0) << "silently claiming version 1 would be a lie"; +} + +// Effects are named, not boolean. `init-mcpp-home` is something a client may +// ignore; `exec-build-script` runs code out of the workspace and is the whole +// reason an untrusted-workspace gate exists. One bool cannot separate them. +TEST(WireEnvelope, EffectsAreNamed) { + auto j = w::to_json(w::Envelope{ + .kind = "mcpp.env", + .effects = {w::Effect::InitMcppHome, w::Effect::ExecBuildScript}}); + ASSERT_EQ(j["effects"].size(), 2u); + EXPECT_EQ(j["effects"][0], "init-mcpp-home"); + EXPECT_EQ(j["effects"][1], "exec-build-script"); +} + +TEST(WireEnvelope, NoEffectsIsAnEmptyArrayNotAbsent) { + auto j = w::to_json(w::Envelope{.kind = "mcpp.xpkg"}); + ASSERT_TRUE(j.contains("effects")); + EXPECT_TRUE(j["effects"].is_array()); + EXPECT_TRUE(j["effects"].empty()) << "absent would mean 'unknown', not 'none'"; +} + +TEST(WireEnvelope, DiagnosticsCarryCodeAndPosition) { + w::Diagnostic d{ + .code = "MCPP_MANIFEST_UNKNOWN_KEY", + .severity = w::Severity::Warning, + .message = "unknown key 'standrad'", + .path = "mcpp.toml", + .range = w::Range{{3, 1}, {3, 9}}, + }; + auto j = w::to_json(w::Envelope{.kind = "mcpp.env", .diagnostics = {d}}); + ASSERT_EQ(j["diagnostics"].size(), 1u); + auto const& g = j["diagnostics"][0]; + EXPECT_EQ(g["code"], "MCPP_MANIFEST_UNKNOWN_KEY"); + EXPECT_EQ(g["severity"], "warning"); + EXPECT_EQ(g["source"], "mcpp"); + EXPECT_EQ(g["path"], "mcpp.toml"); + EXPECT_EQ(g["range"]["start"]["line"], 3); + EXPECT_EQ(g["range"]["end"]["column"], 9); +} + +// A diagnostic without a location must not invent one: absent keys, not +// zeros. `line: 0` would send a client to a position that does not exist. +TEST(WireEnvelope, ADiagnosticWithoutALocationOmitsIt) { + auto j = w::to_json(w::Envelope{ + .kind = "mcpp.env", + .diagnostics = {{.code = "MCPP_X", .message = "no place"}}}); + auto const& g = j["diagnostics"][0]; + EXPECT_FALSE(g.contains("path")); + EXPECT_FALSE(g.contains("range")); +} + +TEST(WireEnvelope, ReportsMcppVersionAndProtocolWindow) { + auto j = w::to_json(w::Envelope{.kind = "mcpp.env"}); + EXPECT_FALSE(j["mcpp"]["version"].get().empty()); + EXPECT_EQ(j["mcpp"]["protocol"]["min"], w::kEnvelopeVersion); + EXPECT_EQ(j["mcpp"]["protocol"]["max"], w::kEnvelopeVersion); +} + +// ── Negotiation ──────────────────────────────────────────────────────────── + +TEST(WireProtocol, ListsKindsAndTheirVersions) { + auto j = w::protocol_document({ + {"self env", {w::Effect::InitMcppHome}}, + {"xpkg parse", {}}, + }); + ASSERT_TRUE(j.contains("kinds")); + // `{min,max}` alone cannot answer "do you speak mcpp.env, at what + // version" — which is the question a client actually has. + EXPECT_EQ(j["kinds"]["mcpp.env"], 1); + EXPECT_EQ(j["kinds"]["mcpp.xpkg"], 1); +} + +// The gate has to decide BEFORE running. By the time an envelope arrives, +// whatever it describes has already happened — so the effects have to be +// available statically too. +TEST(WireProtocol, DeclaresEffectsPerCommandStatically) { + auto j = w::protocol_document({ + {"self env", {w::Effect::InitMcppHome}}, + {"xpkg parse", {}}, + }); + ASSERT_TRUE(j["commands"].contains("self env")); + EXPECT_EQ(j["commands"]["self env"]["effects"][0], "init-mcpp-home"); + EXPECT_TRUE(j["commands"]["xpkg parse"]["effects"].empty()) + << "measured on a fresh MCPP_HOME: it creates nothing"; +} + +TEST(WireProtocol, IsItselfSelfIdentifying) { + auto j = w::protocol_document({}); + EXPECT_EQ(j["kind"], "mcpp.protocol"); + EXPECT_TRUE(j.contains("schemaVersion")); +} + +// ── Format selection ─────────────────────────────────────────────────────── + +TEST(WireFormat, KnowsJsonAndRefusesTheRest) { + EXPECT_EQ(w::parse_format("json"), w::Format::Json); + EXPECT_FALSE(w::parse_format("yaml").has_value()); + EXPECT_FALSE(w::parse_format("ndjson").has_value()) << "reserved, not supported"; + EXPECT_FALSE(w::parse_format("").has_value()); +} + +TEST(WireFormat, TheRefusalNamesWhatIsSupported) { + auto m = w::unsupported_format("yaml"); + EXPECT_NE(m.find("yaml"), std::string::npos); + EXPECT_NE(m.find("json"), std::string::npos) << "tell the client what to ask for"; +} + +// LegacyJson is a distinct format, not a synonym. `--json` has shipped with a +// bare payload (`cache list --json` is `{root, entries}` at the top level, and +// this repo's e2e asserts those keys); enveloping it would break consumers. +TEST(WireFormat, LegacyJsonIsNotTheSameFormatAsJson) { + EXPECT_NE(w::Format::LegacyJson, w::Format::Json); +} + +// ── Golden shapes, one per kind ──────────────────────────────────────────── +// +// docs/11-machine-output.md promises that within a kindVersion fields are +// added and never removed or renamed. A promise nobody can break is the thing +// this whole module was written against — `xlings interface --list` declares +// 20 capabilities whose outputSchema is, for all 20, only +// `{"exitCode": integer}`. So the promise is enforced here: these lists are +// the published key set, and removing or renaming one turns this file red. +// +// Adding a field does NOT turn it red, deliberately — that is the one change +// the contract allows. +namespace { + +void expect_has_keys(const nlohmann::json& obj, + std::initializer_list keys, + std::string_view what) { + for (auto k : keys) + EXPECT_TRUE(obj.contains(k)) + << what << " lost published key '" << k + << "'. Removing or renaming one is a breaking change: bump the " + "kind's version in mcpp.wire and say so in " + "docs/11-machine-output.md."; +} + +} // namespace + +TEST(WireGolden, EnvelopeKeySet) { + auto j = w::to_json(w::Envelope{.kind = "mcpp.env"}); + expect_has_keys(j, {"schemaVersion", "kind", "kindVersion", "effects", + "mcpp", "data", "diagnostics"}, "the envelope"); + expect_has_keys(j["mcpp"], {"version", "protocol"}, "envelope.mcpp"); + expect_has_keys(j["mcpp"]["protocol"], {"min", "max"}, "envelope.mcpp.protocol"); +} + +TEST(WireGolden, DiagnosticKeySet) { + auto j = w::to_json(w::Envelope{ + .kind = "mcpp.env", + .diagnostics = {{.code = "C", .message = "m", .path = "p", + .range = w::Range{{1, 1}, {1, 2}}}}}); + expect_has_keys(j["diagnostics"][0], + {"code", "severity", "source", "message", "path", "range"}, + "a diagnostic"); + expect_has_keys(j["diagnostics"][0]["range"], {"start", "end"}, "a range"); + expect_has_keys(j["diagnostics"][0]["range"]["start"], {"line", "column"}, + "a position"); +} + +TEST(WireGolden, ProtocolDocumentKeySet) { + auto j = w::protocol_document({{"self env", {w::Effect::InitMcppHome}}}); + expect_has_keys(j, {"schemaVersion", "kind", "mcpp", "envelope", "kinds", + "commands"}, "the protocol document"); + expect_has_keys(j["envelope"], {"min", "max"}, "protocol.envelope"); + expect_has_keys(j["commands"]["self env"], {"effects"}, "a command entry"); +} + +// Every effect name is part of the contract: a client matches on these +// strings, so renaming one silently changes what a gate lets through. +TEST(WireGolden, EffectNamesAreStable) { + struct { w::Effect e; const char* name; } const expected[]{ + {w::Effect::InitMcppHome, "init-mcpp-home"}, + {w::Effect::ReadProject, "read-project"}, + {w::Effect::WriteProject, "write-project"}, + {w::Effect::WriteGlobalCache, "write-global-cache"}, + {w::Effect::Network, "network"}, + {w::Effect::ExecBuildScript, "exec-build-script"}, + }; + for (auto const& x : expected) + EXPECT_EQ(w::effect_name(x.e), x.name) + << "effect names are matched by clients; renaming one changes " + "what an untrusted-workspace gate admits"; +} + +TEST(WireGolden, SeverityNamesAreStable) { + EXPECT_EQ(w::severity_name(w::Severity::Error), "error"); + EXPECT_EQ(w::severity_name(w::Severity::Warning), "warning"); + EXPECT_EQ(w::severity_name(w::Severity::Note), "note"); +} + +// The kinds this build claims to speak. A client reads this list to decide +// whether to bother calling; dropping one silently is a breaking change. +TEST(WireGolden, DeclaredKinds) { + auto j = w::protocol_document({}); + expect_has_keys(j["kinds"], {"mcpp.env", "mcpp.xpkg", "mcpp.cache"}, + "the kind list"); +} + +} // namespace From 8625c456ec46dd3c90c4b40acd9ce95e484ccf18 Mon Sep 17 00:00:00 2001 From: SPeak Agent Date: Sat, 8 Aug 2026 19:34:03 +0800 Subject: [PATCH 08/54] =?UTF-8?q?ci(aarch64):=20=E5=8E=BB=E6=8E=89?= =?UTF-8?q?=E4=B8=89=E5=A4=84=E5=B7=A5=E4=BD=9C=E5=8C=BA=20pin=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E5=AE=83=E9=80=89=E4=B8=AD=E7=9A=84=E4=B8=8D?= =?UTF-8?q?=E6=98=AF=E6=97=A7=E6=9E=84=E5=BB=BA=E5=99=A8,=E8=80=8C?= =?UTF-8?q?=E6=98=AF=E4=B8=8D=E5=AD=98=E5=9C=A8=E7=9A=84=E9=82=A3=E4=B8=AA?= =?UTF-8?q?=20(#389)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ci-aarch64-fresh-install` 的自举步骤自 2026-08-06 起每次都挂: [error] xlings: version '2026.8.6.2' not found for 'mcpp' [error] available: 2026.8.8.2 checkout 里的 `.xlings.json` 声明 `workspace.mcpp` —— bootstrap pin,按设计不随发布走。它作用于当前目录、压过任何已安装版本;而这个 job 只跑 `xlings install mcpp`(裸名=latest),**bootstrap 版本在这台 runner 上从来不存在**。所以「遵守 pin」不是选了个旧构建器,而是选了个没有的。 「最新发布 ≠ bootstrap pin」是常态,所以两者一分叉它就必挂 —— 它又是周更,没有任何东西指向它。抬 pin 是假修法:那只是让它再一次巧合地相等。 同一缺陷在这个文件里推导了三处: 1. 自举步骤在 `/tmp/mcpp-src` 的两次 shim 调用 → 移除该克隆的 pin 2. `mcpp self env` 原本在 `cd /tmp/xlings-src` 之后才求值,而 xlings 仓库自己也带 pin(`2026.8.6.1`)。只修第 1 处会把失败推到下一行,且死在 `could not determine MCPP_HOME` 这个与真因无关的守卫上 —— 断言只说「失败了」而不说原因,等于没有断言。改为在 cd 之前求值,不去动别人仓库的文件。 3. PR 回归门 checkout 之后的 `mcpp build` —— 今天任何 PR 都会挂在这 验证:该 job 11 步全绿,含第 9–11 步 PR 门 —— 那几步自 pin 分叉以来从没真正跑过,第 11 步还是 #295 musl 回归唯一能复现的地方。同时它从索引装到 2026.8.8.4 并构建运行了 `import std` 程序,补齐了 aarch64 对本次发布的生态验证。 --- .../workflows/ci-aarch64-fresh-install.yml | 47 +++++++++++++++---- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci-aarch64-fresh-install.yml b/.github/workflows/ci-aarch64-fresh-install.yml index fe3882f3..12ccf073 100644 --- a/.github/workflows/ci-aarch64-fresh-install.yml +++ b/.github/workflows/ci-aarch64-fresh-install.yml @@ -118,12 +118,44 @@ jobs: git fetch -q --depth 1 origin "$ref" git checkout -q FETCH_HEAD echo "self-hosting $repo @ $ref" + # The clone's .xlings.json declares `workspace.mcpp` — the BOOTSTRAP + # pin, hand-maintained and deliberately lagging the newest release. + # It is scoped to the working directory and beats anything installed, + # so every `mcpp` below resolved the bootstrap version, which the + # fresh-install steps above never installed: + # + # [error] xlings: version '2026.8.6.2' not found for 'mcpp' + # [error] available: 2026.8.8.2 + # + # "newest release != bootstrap pin" is the NORMAL state, so this step + # failed on every run from the moment the two diverged — and it is + # weekly, so nothing pointed at it. This step tests the freshly + # installed RELEASED binary against a source tree; the bootstrap pin + # has no standing in that question. install_released_mcpp.sh removes + # it for exactly this reason on the x86_64 legs (its point 1); this + # leg was written separately and never got it. + rm -f .xlings.json mcpp self config --mirror GLOBAL 2>/dev/null || true mcpp build --target aarch64-linux-musl # Absolute: it is used again after `cd /tmp/xlings-src` below. m=$(find "$PWD/target/aarch64-linux-musl" -type f -path '*/bin/mcpp' | head -1) file "$m" | grep -q "ARM aarch64" || { echo "expected aarch64 mcpp"; exit 1; } "$m" --version + # MCPP_HOME must be carried over explicitly: mcpp derives it from the + # BINARY's location, so a binary sitting in /tmp/mcpp-src/target would + # otherwise adopt an empty home and re-bootstrap the whole ecosystem + # instead of reusing what the fresh-install steps above provisioned. + # + # Resolved HERE, before the cd below, and that placement is load- + # bearing: `mcpp` is the shim, so it obeys whatever workspace pin the + # CURRENT DIRECTORY carries — and the xlings checkout declares one too + # (`workspace.mcpp = 2026.8.6.1`, its own bootstrap). Run from there, + # this resolves a version nothing installed, MCPP_HOME comes back + # empty, and the step dies on the guard below instead of on the real + # cause. /tmp/mcpp-src has had its pin removed above, so ask from here. + export MCPP_HOME=$(mcpp self env | awk -F'= *' '/^MCPP_HOME/{print $2; exit}') + echo "reusing MCPP_HOME=$MCPP_HOME" + test -d "$MCPP_HOME" || { echo "could not determine MCPP_HOME"; exit 1; } git clone --depth 1 https://github.com/openxlings/xlings /tmp/xlings-src cd /tmp/xlings-src # "$m", not `mcpp`: the just-built binary is the code under review, @@ -132,14 +164,6 @@ jobs: # records, one line further down. It surfaced the same way: a fix for # an aarch64-only failure in exactly this build could not be # validated here, because the binary running it predated the fix. - # - # MCPP_HOME must be carried over explicitly: mcpp derives it from the - # BINARY's location, so a binary sitting in /tmp/mcpp-src/target would - # otherwise adopt an empty home and re-bootstrap the whole ecosystem - # instead of reusing what the fresh-install steps above provisioned. - export MCPP_HOME=$(mcpp self env | awk -F'= *' '/^MCPP_HOME/{print $2; exit}') - echo "reusing MCPP_HOME=$MCPP_HOME" - test -d "$MCPP_HOME" || { echo "could not determine MCPP_HOME"; exit 1; } "$m" build --target aarch64-linux-musl x=$(find target/aarch64-linux-musl -type f -path '*/bin/xlings' | head -1) file "$x" | grep -q "ARM aarch64" || { echo "expected aarch64 xlings"; exit 1; } @@ -162,6 +186,13 @@ jobs: # is the only place #295 can actually be reproduced. - name: Build current mcpp source for native regression tests run: | + # Third site of the same pin, and the reason to remove it here too: + # this job installs `xlings install mcpp` (bare = latest) and NOTHING + # else, so the bootstrap version the checkout pins is never on this + # runner. Obeying the pin here does not select an older builder — it + # selects one that does not exist. Same removal as the self-host step + # above, same reason as install_released_mcpp.sh point 1. + rm -f .xlings.json mcpp build --target aarch64-linux-musl self=$(find target/aarch64-linux-musl -type f -path '*/bin/mcpp' | head -1) test -x "$self" From 54d29fc859c3c60f87582af06e58cff7af6b503a Mon Sep 17 00:00:00 2001 From: lil_dengzi <115568350+lildengzi@users.noreply.github.com> Date: Sun, 9 Aug 2026 03:42:15 +0800 Subject: [PATCH 09/54] =?UTF-8?q?fix:=20E0006=20Upgrade=20=E6=8F=90?= =?UTF-8?q?=E7=A4=BA=E6=8C=89=E5=AE=89=E8=A3=85=E5=B8=83=E5=B1=80=E5=8C=BA?= =?UTF-8?q?=E5=88=86=20+=20AUR=20=E7=89=B9=E5=88=AB=E5=85=B3=E7=85=A7?= =?UTF-8?q?=EF=BC=88bump=20=E8=87=B3=20v2026.8.8.4=E3=80=81=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=20mcpp-git=EF=BC=89=20(#395)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: E0006 Upgrade 提示按安装布局给出指引(AUR 检测 + 推荐默认 xlings),避免误导用户装出第二份(#394) * feat(aur): 新增给 arch 系用户的特别关照 😋(bump mcpp-bin/mcpp-m 至 v2026.8.8.4,消除低于索引下限导致的 E0006;新增 mcpp-git 滚动包,跟随 master 永不滞后) --- scripts/aur/README.md | 38 ++++++++---- scripts/aur/mcpp-bin/.SRCINFO | 10 ++-- scripts/aur/mcpp-bin/PKGBUILD | 6 +- scripts/aur/mcpp-git/.SRCINFO | 21 +++++++ scripts/aur/mcpp-git/PKGBUILD | 96 ++++++++++++++++++++++++++++++ scripts/aur/mcpp-git/mcpp.sh | 27 +++++++++ scripts/aur/mcpp-m/.SRCINFO | 4 +- scripts/aur/mcpp-m/PKGBUILD | 2 +- src/pm/index_contract.cppm | 66 ++++++++++++++++++-- tests/unit/test_index_contract.cpp | 25 ++++++++ 10 files changed, 266 insertions(+), 29 deletions(-) create mode 100644 scripts/aur/mcpp-git/.SRCINFO create mode 100644 scripts/aur/mcpp-git/PKGBUILD create mode 100755 scripts/aur/mcpp-git/mcpp.sh diff --git a/scripts/aur/README.md b/scripts/aur/README.md index 426bbe07..83b49c4a 100644 --- a/scripts/aur/README.md +++ b/scripts/aur/README.md @@ -1,18 +1,20 @@ # AUR packaging -Arch Linux packaging for mcpp. Two packages, same runtime layout: +Arch Linux packaging for mcpp. Three packages, same runtime layout: | Package | What it installs | Pick it when | | --- | --- | --- | | [`mcpp-bin`](mcpp-bin/) | the **prebuilt** release binary (what [`install.sh`](../../install.sh) downloads) | you just want mcpp, fast | -| [`mcpp-m`](mcpp-m/) | mcpp **built from source**, bootstrapped with `mcpp-bin` | you want a from-source build | +| [`mcpp-m`](mcpp-m/) | mcpp **built from source**, bootstrapped with `mcpp-bin` | you want a from-source build of a release | +| [`mcpp-git`](mcpp-git/) | mcpp **built from the git master**, bootstrapped with `mcpp-bin` | you want to track master (rolling) and never lag the index floor | ```sh yay -S mcpp-bin # prebuilt yay -S mcpp-m # from source (pulls mcpp-bin as a build dep) +yay -S mcpp-git # from git master (pulls mcpp-bin as a build dep) ``` -The two `conflict` with each other, so only one can be installed at a time. +The three `conflict` with each other, so only one can be installed at a time. Supported architectures: `x86_64`, `aarch64`. ### Why not just `mcpp`? @@ -20,9 +22,10 @@ Supported architectures: `x86_64`, `aarch64`. The name `mcpp` is already taken by **`extra/mcpp`** — Matsui's C preprocessor, an unrelated long-standing official Arch package that owns `/usr/bin/mcpp`. The AUR refuses to host any package whose `pkgname` (or `provides`) collides with an -official-repo package, so our packages are `mcpp-bin` / `mcpp-m`. They still -install the `mcpp` command at `/usr/bin/mcpp`, so both `conflicts=('mcpp')` with -that preprocessor — you can have our mcpp or the preprocessor, not both. +official-repo package, so our packages are `mcpp-bin` / `mcpp-m` / `mcpp-git`. +They still install the `mcpp` command at `/usr/bin/mcpp`, so all three +`conflicts=('mcpp')` with that preprocessor — you can have our mcpp or the +preprocessor, not both. ## Layout & why the wrapper exists @@ -52,30 +55,39 @@ user already exported, so a custom home or xlings still works. First `mcpp build`/`mcpp run` bootstraps the sandbox (downloads ninja, patchelf and the default toolchain into `~/.mcpp`) — expected, and only once per user. -### How the `mcpp-m` source package builds +### How the `mcpp-m` / `mcpp-git` source packages build -mcpp is self-hosting. The `mcpp-m` PKGBUILD uses the installed `mcpp-bin` as the -bootstrap compiler and runs `mcpp build --target -linux-musl` — the same +mcpp is self-hosting. Both source PKGBUILDs use the installed `mcpp-bin` as the +bootstrap compiler and run `mcpp build --target -linux-musl` — the same path [`release.yml`](../../.github/workflows/release.yml) ships. mcpp downloads its own pinned toolchain (it does **not** use the host gcc), so the build needs network access, like the upstream release build. +`mcpp-m` builds a pinned release tarball; `mcpp-git` checks out `master` from +the official repo (`source = mcpp::git+…`, `sha256sums = SKIP`) and derives a +monotonic `pkgver` from `git describe` (tag + commit distance). Because it +tracks master, `mcpp-git` can never lag the index floor the way `mcpp-bin` does +between releases — at the cost of tracking bleeding-edge master. + ## Files ``` scripts/aur/ README.md this file - update.sh bump BOTH packages to a release version + update.sh bump the release-pinned packages (mcpp-bin, mcpp-m) mcpp-bin/{PKGBUILD, .SRCINFO, mcpp.sh} mcpp-m/{PKGBUILD, .SRCINFO, mcpp.sh} + mcpp-git/{PKGBUILD, .SRCINFO, mcpp.sh} ``` -`mcpp.sh` is identical in both dirs (each AUR repo must be self-contained); -`update.sh` keeps them in sync. +`mcpp.sh` is identical in all three dirs (each AUR repo must be self-contained); +`update.sh` keeps `mcpp-bin`/`mcpp-m` in sync. `mcpp-git` has no checksums to +bump (VCS source) and is not touched by `update.sh`. ## Releasing a new version -After a GitHub release is published (and mirrored), bump both packages: +After a GitHub release is published (and mirrored), bump the release-pinned +packages (`mcpp-bin`, `mcpp-m`; `mcpp-git` tracks master and needs no bump): ```sh scripts/aur/update.sh # uses [package].version from mcpp.toml diff --git a/scripts/aur/mcpp-bin/.SRCINFO b/scripts/aur/mcpp-bin/.SRCINFO index f101e913..02c7a9e4 100644 --- a/scripts/aur/mcpp-bin/.SRCINFO +++ b/scripts/aur/mcpp-bin/.SRCINFO @@ -1,6 +1,6 @@ pkgbase = mcpp-bin pkgdesc = Modern C++ build & package management tool (prebuilt binary) - pkgver = 0.0.65 + pkgver = 2026.8.8.4 pkgrel = 1 url = https://github.com/mcpp-community/mcpp arch = x86_64 @@ -12,9 +12,9 @@ pkgbase = mcpp-bin options = !strip source = mcpp.sh sha256sums = SKIP - source_x86_64 = mcpp-0.0.65-linux-x86_64.tar.gz::https://github.com/mcpp-community/mcpp/releases/download/v0.0.65/mcpp-0.0.65-linux-x86_64.tar.gz - sha256sums_x86_64 = 0d1d16aae05e4d7c59a00a2621abc4b99421b9821d2145c139fabf95fcf93409 - source_aarch64 = mcpp-0.0.65-linux-aarch64.tar.gz::https://github.com/mcpp-community/mcpp/releases/download/v0.0.65/mcpp-0.0.65-linux-aarch64.tar.gz - sha256sums_aarch64 = 20dd7a8be657bf2e32dcffcf10b758e34fe740fcdb1ceb374822d5ee25cb4a52 + source_x86_64 = mcpp-2026.8.8.4-linux-x86_64.tar.gz::https://github.com/mcpp-community/mcpp/releases/download/v2026.8.8.4/mcpp-2026.8.8.4-linux-x86_64.tar.gz + sha256sums_x86_64 = a8f30fc73fae75381cdd734d204e6c04a24c55365cdf8bb6da07c3aa663c8f9f + source_aarch64 = mcpp-2026.8.8.4-linux-aarch64.tar.gz::https://github.com/mcpp-community/mcpp/releases/download/v2026.8.8.4/mcpp-2026.8.8.4-linux-aarch64.tar.gz + sha256sums_aarch64 = e0d63de0f6e0111f47df00e9135baf437aed2bcdb883dbb658880d9ee4418e27 pkgname = mcpp-bin diff --git a/scripts/aur/mcpp-bin/PKGBUILD b/scripts/aur/mcpp-bin/PKGBUILD index c0614f19..1d0ba43f 100644 --- a/scripts/aur/mcpp-bin/PKGBUILD +++ b/scripts/aur/mcpp-bin/PKGBUILD @@ -5,7 +5,7 @@ # how this is published to the AUR and how to bump it (scripts/aur/update.sh). pkgname=mcpp-bin -pkgver=0.0.65 +pkgver=2026.8.8.4 pkgrel=1 pkgdesc="Modern C++ build & package management tool (prebuilt binary)" arch=('x86_64' 'aarch64') @@ -28,8 +28,8 @@ source_aarch64=("mcpp-${pkgver}-linux-aarch64.tar.gz::${_relbase}/mcpp-${pkgver} source=("mcpp.sh") sha256sums=('SKIP') -sha256sums_x86_64=('0d1d16aae05e4d7c59a00a2621abc4b99421b9821d2145c139fabf95fcf93409') -sha256sums_aarch64=('20dd7a8be657bf2e32dcffcf10b758e34fe740fcdb1ceb374822d5ee25cb4a52') +sha256sums_x86_64=('a8f30fc73fae75381cdd734d204e6c04a24c55365cdf8bb6da07c3aa663c8f9f') +sha256sums_aarch64=('e0d63de0f6e0111f47df00e9135baf437aed2bcdb883dbb658880d9ee4418e27') package() { local _src="${srcdir}/mcpp-${pkgver}-linux-${CARCH}" diff --git a/scripts/aur/mcpp-git/.SRCINFO b/scripts/aur/mcpp-git/.SRCINFO new file mode 100644 index 00000000..9a934475 --- /dev/null +++ b/scripts/aur/mcpp-git/.SRCINFO @@ -0,0 +1,21 @@ +pkgbase = mcpp-git + pkgdesc = Modern C++ build & package management tool (git master) + pkgver = 2026.8.8.4 + pkgrel = 1 + url = https://github.com/mcpp-community/mcpp + arch = x86_64 + arch = aarch64 + license = Apache-2.0 + makedepends = mcpp-bin + makedepends = git + depends = git + conflicts = mcpp-bin + conflicts = mcpp-m + conflicts = mcpp + options = !strip + source = mcpp::git+https://github.com/mcpp-community/mcpp.git + source = mcpp.sh + sha256sums = SKIP + sha256sums = SKIP + +pkgname = mcpp-git diff --git a/scripts/aur/mcpp-git/PKGBUILD b/scripts/aur/mcpp-git/PKGBUILD new file mode 100644 index 00000000..1637fe02 --- /dev/null +++ b/scripts/aur/mcpp-git/PKGBUILD @@ -0,0 +1,96 @@ +# Maintainer: mcpp-community +# +# mcpp-git — mcpp built from the upstream git repository (rolling master), +# bootstrapped with the prebuilt `mcpp-bin`. Same self-hosted build as +# `mcpp-m`, but tracks master instead of a pinned release — so it can never +# lag the index floor (E0006) the way `mcpp-bin` does between releases. +# +# Rolling master is bleeding-edge: prefer `mcpp-bin` for stable use. +# +# NB: the AUR/official name `mcpp` is taken by Matsui's C preprocessor +# (extra/mcpp), so this package is `mcpp-git`. It still installs the `mcpp` +# command at /usr/bin/mcpp, hence the conflict with that preprocessor. +# See scripts/aur/README.md. + +pkgname=mcpp-git +# Static seed only — real builds override it via pkgver() below. Kept at the +# current release so the AUR listing doesn't look ancient; actual version is +# whatever master reports at build time. +pkgver=2026.8.8.4 +pkgrel=1 +pkgdesc="Modern C++ build & package management tool (git master)" +arch=('x86_64' 'aarch64') +url="https://github.com/mcpp-community/mcpp" +license=('Apache-2.0') +# mcpp/xlings are statically linked; git is used for package-index sync. +depends=('git') +# mcpp-bin is the bootstrap compiler; it also supplies the bundled xlings we +# ship at runtime. git is needed for toolchain/index sync during the build. +makedepends=('mcpp-bin' 'git') +# mcpp-bin / mcpp-m = the same tool (mutually exclusive); mcpp = the C +# preprocessor that also owns /usr/bin/mcpp. +conflicts=('mcpp-bin' 'mcpp-m' 'mcpp') +# We strip the built ELF ourselves; don't let makepkg re-strip the vendored +# xlings binary (a prebuilt third-party artifact). +options=('!strip') + +source=("mcpp::git+https://github.com/mcpp-community/mcpp.git" + "mcpp.sh") +sha256sums=('SKIP' + 'SKIP') + +# Rolling master has no stable release number. Pacman only needs a +# monotonically increasing pkgver, so derive one from the closest tag plus the +# commit distance (AUR -git convention): v2026.8.8.4-5-g → +# 2026.8.8.4.5.g. The binary's own version comes from the checked-out +# source (src/version.cppm), independent of pkgver. +pkgver() { + cd "${srcdir}/mcpp" + git describe --long --tags | sed 's/^v//; s/-/./g' +} + +# mcpp downloads its own pinned toolchain (gcc/ninja/patchelf) into the build +# sandbox — it does not use the host gcc. This needs network access at build +# time, like the upstream release build. +build() { + cd "${srcdir}/mcpp" + + case "$CARCH" in + x86_64) _target=x86_64-linux-musl ;; + aarch64) _target=aarch64-linux-musl ;; + *) echo "unsupported CARCH: $CARCH" >&2; return 1 ;; + esac + + # Bootstrap with mcpp-bin's real binary (not its /usr/bin wrapper, which + # would force MCPP_HOME into the user's home). Keep the build sandbox + # self-contained under $srcdir and reuse mcpp-bin's bundled xlings. + export MCPP_HOME="${srcdir}/.mcpp-home" + export MCPP_VENDORED_XLINGS="/opt/mcpp/registry/bin/xlings" + + /opt/mcpp/bin/mcpp build --target "$_target" + + _artifact=$(find "target/${_target}" -type f -name mcpp | head -1) + test -n "$_artifact" || { echo "build produced no mcpp binary" >&2; return 1; } + file "$_artifact" | grep -q 'statically linked' + # Debug info on a static ELF balloons it ~7×; strip with the native tool. + strip "$_artifact" + + # Stash artifact + the vendored xlings for package() (which has no /opt + # guarantees beyond makedepends being installed). + install -Dm755 "$_artifact" "${srcdir}/stage/bin/mcpp" + install -Dm755 /opt/mcpp/registry/bin/xlings "${srcdir}/stage/registry/bin/xlings" +} + +package() { + # Same /opt + wrapper layout as mcpp-bin / mcpp-m (see scripts/aur/README.md + # for why the per-user wrapper exists instead of a /usr/bin symlink). + install -Dm755 "${srcdir}/stage/bin/mcpp" "${pkgdir}/opt/mcpp/bin/mcpp" + install -Dm755 "${srcdir}/stage/registry/bin/xlings" "${pkgdir}/opt/mcpp/registry/bin/xlings" + + install -Dm755 "${srcdir}/mcpp.sh" "${pkgdir}/usr/bin/mcpp" + + install -Dm644 "${srcdir}/mcpp/LICENSE" \ + "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" + install -Dm644 "${srcdir}/mcpp/README.md" \ + "${pkgdir}/usr/share/doc/${pkgname}/README.md" +} diff --git a/scripts/aur/mcpp-git/mcpp.sh b/scripts/aur/mcpp-git/mcpp.sh new file mode 100755 index 00000000..87cbde91 --- /dev/null +++ b/scripts/aur/mcpp-git/mcpp.sh @@ -0,0 +1,27 @@ +#!/bin/sh +# mcpp launcher (Arch / AUR mcpp-bin package). +# +# The AUR package installs mcpp's self-contained tree read-only under +# /opt/mcpp (shared by every user). But mcpp WRITES its registry sandbox, +# BMI/metadata caches, logs and downloaded toolchains into MCPP_HOME at +# runtime — that has to be a per-user, writable location, never /opt. +# +# mcpp resolves MCPP_HOME from the binary's real path (/proc/self/exe, which +# resolves symlinks), so a plain /usr/bin symlink would make MCPP_HOME land in +# the root-owned /opt tree and every command would fail to write. This wrapper +# fixes that by pinning the two env knobs the binary honors: +# +# MCPP_HOME — per-user home (default ~/.mcpp, same as install.sh) +# MCPP_VENDORED_XLINGS — the bundled xlings under /opt, which mcpp copies +# into $MCPP_HOME/registry/bin/xlings on first run +# +# Both respect a value the user already exported, so power users can still +# point mcpp at a custom home or xlings. +set -eu + +MCPP_OPT="/opt/mcpp" + +export MCPP_HOME="${MCPP_HOME:-$HOME/.mcpp}" +export MCPP_VENDORED_XLINGS="${MCPP_VENDORED_XLINGS:-$MCPP_OPT/registry/bin/xlings}" + +exec "$MCPP_OPT/bin/mcpp" "$@" diff --git a/scripts/aur/mcpp-m/.SRCINFO b/scripts/aur/mcpp-m/.SRCINFO index 47e8b09e..80e8e287 100644 --- a/scripts/aur/mcpp-m/.SRCINFO +++ b/scripts/aur/mcpp-m/.SRCINFO @@ -1,6 +1,6 @@ pkgbase = mcpp-m pkgdesc = Modern C++ build & package management tool (built from source) - pkgver = 0.0.65 + pkgver = 2026.8.8.4 pkgrel = 1 url = https://github.com/mcpp-community/mcpp arch = x86_64 @@ -12,7 +12,7 @@ pkgbase = mcpp-m conflicts = mcpp-bin conflicts = mcpp options = !strip - source = mcpp-0.0.65.tar.gz::https://github.com/mcpp-community/mcpp/archive/v0.0.65.tar.gz + source = mcpp-2026.8.8.4.tar.gz::https://github.com/mcpp-community/mcpp/archive/v2026.8.8.4.tar.gz source = mcpp.sh sha256sums = 07f8233d3f18565c52607816a82c9facda3a7ed11c67d3772e42bd84cb54476a sha256sums = SKIP diff --git a/scripts/aur/mcpp-m/PKGBUILD b/scripts/aur/mcpp-m/PKGBUILD index 8306674d..ff1ff8e7 100644 --- a/scripts/aur/mcpp-m/PKGBUILD +++ b/scripts/aur/mcpp-m/PKGBUILD @@ -11,7 +11,7 @@ # See scripts/aur/README.md. pkgname=mcpp-m -pkgver=0.0.65 +pkgver=2026.8.8.4 pkgrel=1 pkgdesc="Modern C++ build & package management tool (built from source)" arch=('x86_64' 'aarch64') diff --git a/src/pm/index_contract.cppm b/src/pm/index_contract.cppm index 0637da8c..8018da90 100644 --- a/src/pm/index_contract.cppm +++ b/src/pm/index_contract.cppm @@ -21,6 +21,7 @@ export module mcpp.pm.index_contract; import std; import mcpp.libs.toml; +import mcpp.platform.fs; // self_exe_path (distro-layout detection) import mcpp.version_req; import mcpp.version; // MCPP_VERSION (leaf — see that module) @@ -44,6 +45,13 @@ read_index_contract(const std::filesystem::path& indexRoot); std::optional floor_violation(std::string_view minMcpp, std::string_view ownVersion); +// Pure: E0006 message with the Upgrade advice suited to the install layout +// (`distroManaged` is detected once by the caller via self_exe_path). The +// plain one-liner misleads users of the other install methods — an AUR install +// plus the install.sh command installs a second copy that does not update the +// running binary. +std::string e0006_message(std::string violation, bool distroManaged); + // Pure predicate — no reporting, no registration, no dedup. For callers that // need to ask "would this tree be usable?" without the side effects of // check_index_floor (the refresh guard asks it twice per refresh). @@ -118,6 +126,26 @@ read_index_contract(const std::filesystem::path& indexRoot) return c; } +// Upgrade advice embedded in the E0006 message. mcpp supports several install +// methods (xlings is the recommended default; install.sh / AUR / Homebrew are +// alternatives), and each lands in a different place. A single hardcoded +// one-liner misleads users of the other methods — e.g. an AUR install plus the +// install.sh command installs a SECOND copy that does not update the running +// binary. So the message names the method and how to upgrade IT. +namespace { +constexpr std::string_view kInstallShUpgrade = + " Upgrade: re-run the install.sh one-liner\n" + " curl -fsSL https://github.com/mcpp-community/mcpp/" + "releases/latest/download/install.sh | bash\n"; +constexpr std::string_view kDistroUpgrade = + " Upgrade: this is a distro-managed install (AUR) — update the package\n" + " with your AUR helper (e.g. 'paru -Syu mcpp-bin' or\n" + " 'yay -Syu mcpp-bin'). The install.sh one-liner installs a\n" + " separate copy and will NOT update this one.\n"; +constexpr std::string_view kXlingsUpgrade = + " Upgrade: 'xlings update mcpp' (recommended default installer)\n"; +} // namespace + std::optional floor_violation(std::string_view minMcpp, std::string_view ownVersion) { @@ -128,11 +156,37 @@ floor_violation(std::string_view minMcpp, std::string_view ownVersion) if (*have >= *need) return std::nullopt; return std::format( "index requires mcpp >= {} but this is mcpp {} [E0006]\n" - " Upgrade: curl -fsSL https://github.com/mcpp-community/mcpp/" - "releases/latest/download/install.sh | bash\n" + "{}" " Details: mcpp explain E0006 " "(override for debugging: MCPP_INDEX_FLOOR=ignore)", - minMcpp, ownVersion); + minMcpp, ownVersion, kInstallShUpgrade); +} + +// Pure: swap the Upgrade advice for the install layout the caller detected. +std::string e0006_message(std::string violation, bool distroManaged) +{ + if (distroManaged) { + auto pos = violation.find(kInstallShUpgrade); + if (pos != std::string::npos) + violation.replace(pos, kInstallShUpgrade.size(), kDistroUpgrade); + } + // Append the recommended installer note to every layout. + violation += kXlingsUpgrade; + return violation; +} + +// Impure (reads /proc/self/exe): true when the running binary sits under the +// distro-managed tree (/opt/mcpp — the AUR layout). install.sh / xlings +// installs keep the binary inside $MCPP_HOME (~/.mcpp) instead. +bool distro_managed_install() +{ +#if defined(_WIN32) || defined(__APPLE__) + return false; +#else + std::error_code ec; + auto self = mcpp::platform::fs::self_exe_path(); + return self.string().find("/opt/mcpp/") != std::string::npos; +#endif } bool index_usable(const std::filesystem::path& indexRoot) @@ -192,15 +246,17 @@ check_index_floor(const std::filesystem::path& indexRoot) auto violation = floor_violation(c->minMcpp, mcpp::MCPP_VERSION); if (!violation) return std::nullopt; + auto message = e0006_message(*violation, distro_managed_install()); + // Record BEFORE the dedup return: the fact must be queryable no matter how // many times this root is opened, while the message is printed only once. // Deriving "was anything unusable?" from "did we print?" is what made the // second and later reads indistinguishable from an ordinary miss. if (!index_marked_unusable(indexRoot)) - unusable_registry().push_back({indexRoot, *violation}); + unusable_registry().push_back({indexRoot, message}); else return std::nullopt; // already reported — stay quiet - return violation; + return message; } } // namespace mcpp::pm diff --git a/tests/unit/test_index_contract.cpp b/tests/unit/test_index_contract.cpp index a6f991d6..3163a013 100644 --- a/tests/unit/test_index_contract.cpp +++ b/tests/unit/test_index_contract.cpp @@ -14,6 +14,31 @@ TEST(IndexContract, FloorViolationOrdering) { EXPECT_NE(v->find("0.0.85"), std::string::npos); } +// The Upgrade advice must tell the user which install method THEY have. A +// single hardcoded install.sh one-liner misleads distro (AUR) users into +// installing a second copy that does not update the running binary. +TEST(IndexContract, E0006MessageSwitchesUpgradeAdviceByLayout) { + using mcpp::pm::floor_violation; + using mcpp::pm::e0006_message; + auto violation = floor_violation("2026.8.3.3", "2026.7.28.2"); + ASSERT_TRUE(violation.has_value()); + + // Non-distro (install.sh / xlings) layout keeps the install.sh one-liner + // and always appends the recommended-installer note. + auto script = e0006_message(*violation, /*distroManaged=*/false); + EXPECT_NE(script.find("install.sh"), std::string::npos); + EXPECT_EQ(script.find("distro-managed"), std::string::npos); + EXPECT_NE(script.find("xlings update mcpp"), std::string::npos); + EXPECT_NE(script.find("E0006"), std::string::npos); + + // Distro (AUR) layout swaps in the package-manager advice. + auto distro = e0006_message(*violation, /*distroManaged=*/true); + EXPECT_NE(distro.find("distro-managed"), std::string::npos); + EXPECT_EQ(distro.find("re-run the install.sh one-liner"), std::string::npos); + EXPECT_NE(distro.find("xlings update mcpp"), std::string::npos); + EXPECT_NE(distro.find("E0006"), std::string::npos); +} + // The floor compares mcpp's OWN version, so the date scheme (YYYY.M.D.N) has // to be ordered on all four segments. While parse_version truncated at three, // every release of a given day compared equal and the floor let a too-old mcpp From 80291ca01a982c1e8c00e43bfa97ffe68516e6d7 Mon Sep 17 00:00:00 2001 From: Zhe Feng Date: Sun, 9 Aug 2026 03:42:40 +0800 Subject: [PATCH 10/54] =?UTF-8?q?fix(build):=20=E5=A4=9A=E6=AE=B5=20glob?= =?UTF-8?q?=20=E6=BA=90=E6=96=87=E4=BB=B6=E8=B7=AF=E5=BE=84=E5=9C=A8=20Win?= =?UTF-8?q?dows=20=E4=B8=8A=E4=BF=9D=E6=8C=81=E5=8E=9F=E7=94=9F=E5=88=86?= =?UTF-8?q?=E9=9A=94=E7=AC=A6=20(#390)=20(#391)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(build): 多段 glob 源文件路径在 Windows 上保持原生分隔符 (#390) manifest glob(如 `generated/modules/**/*.cppm`)用 `/` 拼路径; MSVC 的 std::filesystem::path 保留输入分隔符原样,于是 `root / prefix` 与目录迭代子路径都是混合形式 (`...\9bca0b44ae3aa660\generated/modules\ccc.when.cppm`), 一路流入 CompileUnit::source → compile_commands.json 的 file/-c 字段, CLion 拒绝解析。ninja 侧一律 generic_string()(全正斜杠)所以构建正常, CDB 是第一个 .string() 消费者,缺陷只在那里显现。 新增 mcpp::modgraph::native_path_from_generic(glob.cppm),在 glob_literal_prefix / expand_dir_glob / scan_one_into / 绝对 include dir 分支 / directives::abs_against 摄入点归一化;顺带修复 build.mcpp 指令 路径与 TOML 绝对 include dir(C:/SDL2/include)同类混合问题。 单测:GlobLiteralPrefixUsesNativeSeparators / NativePathFromGeneric / ExpandGlobMultiSegmentUsesNativeSeparators / ExpandDirGlobMultiSegmentUsesNativeSeparators。 e2e:76 增加多段 glob 源 + Windows 原生分隔符断言;47 增加无残留引号断言。 * fix(build): #390 修复补全 —— 摄入点收敛 + emitter 兜底 + 合并去重自愈 按 review(#391) 补全同一决策的全部推导点,不再依赖「所有摄入点都被找全」: - P1: 补上 plan.cppm expand_manifest_include_entry(绝对分支与 generated/ 裸拼接回退)、scanner.cppm rewrite_rel_copy(cxxflags 的 -Ithird_party/inc 通道)、flags.cppm [build] include_dirs 全局 cxxflags 通道;并在 emit_compile_commands 加最后一层兜底:file/directory/-c/-o/-I 统一 make_preferred,对 CDB 契约给出无条件保证。 - P2: merge_compile_commands 去重键改为归一化路径(lexically_normal + make_preferred),旧 CDB 里的混合分隔符条目与 fresh 原生拼写视为同一 文件 → 升级后第一次 build 即自愈,用户无需手删 compile_commands.json。 已在带旧条目的工程上实测:注入混合条目后重建,归零。 - P3: e2e 76 的 Windows 断言改为平台无关的「同时含 \ 与 / 即失败」 (消掉 os.name 依赖,避免 MSYS python 假绿);新增 extra.cpp 必须进 CDB 的 grep 守卫;python3 缺失时显式 SKIP。 - native_path_from_generic 改用标准库 make_preferred;include_dirs 绝对 分支不再做 generic_string 窄串往返(ANSI 代码页拼不出的名字会抛, mcpp#230)。 - 单测:NormalizedFileKeysHealStaleSeparatorSpellings(合并自愈)、 EmittedPathsUseNativeSeparators(emitter 兜底)、 Plan.ExpandManifestIncludeEntryNativeSpelling(plan.cppm 摄入点, expand_manifest_include_entry 为此从匿名命名空间提出并导出)。 注:directives::abs_against 归一化会改写 build.mcpp 指令路径的拼写, 声明输入指纹一次性失效 → 一次多余重建,属预期。 * test(cdb): 自愈守卫此前在两个平台都空转 —— 修好它,顺带收敛 flags.cppm 的第四份 join review 第二轮的四条: 1) NormalizedFileKeysHealStaleSeparatorSpellings 之前测不到东西。 测试 helper `entry()` 用 std::format 手拼 JSON,而 Windows 的 `file` 值带反斜杠 —— `...\generated\...` 里的 `\g` 不是合法 JSON 转义,整份 fixture 无法解析。merge_compile_commands 对解析失败的 回答是 `return std::string(fresh)`,于是三条断言在没有跑到 norm_key 的情况下全部通过。POSIX 上则是另一种空转:stale 用 p.generic_string(),与 fresh 的 p.string() 逐字节相同,换回修改前 的字面串键一样能过。 修法两处:entry() 改由 nlohmann 序列化(转义按构造保证);stale 拼写换成 `/p/generated/./modules/a.cpp` —— `/./` 在 POSIX 上也必须 经 lexically_normal 折叠才能与 fresh 对齐,Windows 上再叠一层 make_preferred。cdb() 里加一条 fixture 自检,让「拼出来的 JSON 不 可解析」这类空转以后直接红。 已验证先红:把 norm_key 临时退回字面串键,该测试在 Linux 上失败 (正是它原来空转的平台),恢复后 68/68。 2) emitter 兜底的范围说清楚。它覆盖 CDB schema 的路径字段与 emitter 自己构造的 argv 位置,不覆盖 split_flags(f.cxx) 与 package 的 cflags/cxxflags —— 对任意 flag 载荷做路径归一化本来就不安全 (`-DPATH="/etc/x"` 里的斜杠是真的)。注释按实际范围改写。 EmittedPathsUseNativeSeparators 里那个「以 - 开头的参数不得含 /」 的循环在原 fixture 下遍历不到任何带路径的参数(flags 全空,只剩 裸 -c/-o),换成填上 localIncludeDirs / localIncludeDirsAfter 后 逐条断言 -I 与 -idirafter,POSIX 分支也一并断言。 3) flags.cppm 的 include join 收敛成一个 abs_native lambda,四处共用 ({include_dirs, include_dirs_after} × {C/C++ 通道, NASM 通道})。 NASM 那两处此前既没归一化分隔符,谓词也用的是 is_absolute() 而非 has_root_path() —— 同一个 manifest 键在两个通道产出不同的路径。 两者只在 Windows 的根相对拼法(`/x`)上有别,现在与编译器通道一致。 够不到 CDB(.asm 单元被 emitter 显式跳过),但这正是本 PR 要终结的 那类债。新增 NasmIncludeDirsMatchTheCxxChannelSpelling 钉住契约 (POSIX 上是防回归守卫,Windows 上才是新断言)。 4) rewrite_rel_copy 的绝对分支不再无谓往返:没有 `/` 可改时直接返回 原字节,不经 path 的窄转换 —— 那个转换对 ANSI 代码页拼不出的名字 会抛 std::system_error(mcpp#230),而本 PR 刚以同一理由删掉别处的 generic_string() 往返。 本机:mcpp build 自举通过,mcpp test 68/68,e2e 47 / 76 / 105 / 141 / 148 / 179 / 25 / 51 通过。 --------- Co-authored-by: speak-agent --- src/build/compile_commands.cppm | 48 ++++++-- src/build/directives.cppm | 5 +- src/build/flags.cppm | 37 +++++-- src/build/plan.cppm | 36 +++++- src/build/prepare.cppm | 13 ++- src/modgraph/glob.cppm | 20 ++++ src/modgraph/scanner.cppm | 47 ++++++-- tests/e2e/47_cdb_prebuilt_module_path_abs.sh | 21 ++++ tests/e2e/76_compile_commands_generated.sh | 36 +++++- tests/unit/test_build_flags.cpp | 30 +++-- tests/unit/test_compile_commands.cpp | 110 ++++++++++++++++++- tests/unit/test_modgraph.cpp | 79 ++++++++++++- tests/unit/test_ninja_backend.cpp | 80 +++++++++++++- 13 files changed, 512 insertions(+), 50 deletions(-) diff --git a/src/build/compile_commands.cppm b/src/build/compile_commands.cppm index d02d3cfc..ed60c2a9 100644 --- a/src/build/compile_commands.cppm +++ b/src/build/compile_commands.cppm @@ -142,16 +142,34 @@ std::vector split_flags(std::string_view s) { namespace { +// NATIVE separators for every path this emitter SPELLS ITSELF. Each ingestion +// point (manifest globs, include_dirs, build.mcpp directives) is normalized at +// the source, but this is the last line for the fields the CDB schema defines +// — a path that slips through with a mixed `root\a/b` spelling (MSVC keeps +// input `/` verbatim) breaks CLion, and "all ingestion points are covered" is +// not a claim that can be proven once and stay true. +// +// It is NOT a whole-argv guarantee: the flag strings (split_flags(f.cxx), the +// package cflags/cxxflags) pass through untouched, because normalizing an +// arbitrary flag payload is unsafe — `-DPATH="/etc/x"` holds real slashes. +// Those channels are normalized where they are ingested instead. +// make_preferred() is a no-op on POSIX. +std::string native_string(const std::filesystem::path& p) { + auto n = p; + n.make_preferred(); + return n.string(); +} + std::vector local_include_args(const CompileUnit& cu) { std::vector args; args.reserve(cu.localIncludeDirs.size()); for (auto const& inc : cu.localIncludeDirs) { - args.push_back("-I" + inc.string()); + args.push_back("-I" + native_string(inc)); } // #249: after-dirs keep their -idirafter spelling in the compile DB so // tooling (clangd) reproduces the compiler's search order. for (auto const& inc : cu.localIncludeDirsAfter) { - args.push_back("-idirafter" + inc.string()); + args.push_back("-idirafter" + native_string(inc)); } return args; } @@ -186,7 +204,7 @@ std::string emit_compile_commands(const BuildPlan& plan, const CompileFlags& fla : isCSource ? flags.cc : flags.cxx; - auto output_path = (plan.outputDir / cu.object).string(); + auto output_path = native_string(plan.outputDir / cu.object); // Build arguments array. nlohmann::json args = nlohmann::json::array(); @@ -198,13 +216,13 @@ std::string emit_compile_commands(const BuildPlan& plan, const CompileFlags& fla for (auto& f : package_flag_args(cu, isCSource)) args.push_back(std::move(f)); args.push_back("-c"); - args.push_back(cu.source.string()); + args.push_back(native_string(cu.source)); args.push_back("-o"); args.push_back(output_path); nlohmann::json entry; - entry["directory"] = plan.projectRoot.string(); - entry["file"] = cu.source.string(); + entry["directory"] = native_string(plan.projectRoot); + entry["file"] = native_string(cu.source); entry["arguments"] = std::move(args); entry["output"] = output_path; @@ -222,11 +240,25 @@ std::string merge_compile_commands( if (freshJ.is_discarded() || !freshJ.is_array()) return std::string(fresh); + // Dedup key = the file's PATH, spelled the way a fresh plan spells it + // (native separators). A prior CDB written before the mixed-separator + // fix (#390) carries `root\generated/modules\x.cppm` entries that are + // the SAME file as the fresh `root\generated\modules\x.cppm` — a literal + // string comparison would keep both and the user's upgrade would not + // visibly fix anything. Normalizing makes the merge self-healing: the + // stale mixed entry is skipped on the first `mcpp build` after upgrade. + // fileExists still probes the raw spelling — Windows accepts both. + auto norm_key = [](std::string_view f) { + auto p = std::filesystem::path(std::string(f)).lexically_normal(); + p.make_preferred(); + return p.string(); + }; + // Files the current plan already covers — those entries are authoritative. std::set freshFiles; for (auto const& e : freshJ) { if (e.contains("file") && e["file"].is_string()) - freshFiles.insert(e["file"].get()); + freshFiles.insert(norm_key(e["file"].get())); } // Keep fresh order, then append still-valid prior entries the plan doesn't @@ -238,7 +270,7 @@ std::string merge_compile_commands( for (auto const& e : existingJ) { if (!e.contains("file") || !e["file"].is_string()) continue; auto f = e["file"].get(); - if (freshFiles.contains(f)) continue; // fresh wins + if (freshFiles.contains(norm_key(f))) continue; // fresh wins if (!fileExists(std::filesystem::path(f))) continue; // pruned merged.push_back(e); } diff --git a/src/build/directives.cppm b/src/build/directives.cppm index 85fda563..41014758 100644 --- a/src/build/directives.cppm +++ b/src/build/directives.cppm @@ -366,7 +366,10 @@ const Def* find_by_tag(std::string_view tag) { } std::string abs_against(const fs::path& base, std::string_view p) { - fs::path pp(p); + // Native spelling (see mcpp::modgraph::native_path_from_generic): a + // directive path like `generated/modules/x` would otherwise stay mixed + // on MSVC and leak into include flags / the CDB. + fs::path pp = mcpp::modgraph::native_path_from_generic(p); if (pp.is_relative()) pp = base / pp; return pp.lexically_normal().string(); } diff --git a/src/build/flags.cppm b/src/build/flags.cppm index 40fa3fbe..d2aed189 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -315,10 +315,24 @@ CompileFlags compute_flags(const BuildPlan& plan) { // ninja-$-escape and shell-quote per token (#234) so an include dir // whose name contains a space can't silently split into two shell words // once ninja hands the resolved command line to the shell. + // The one place this file turns a manifest include entry into a path. + // make_preferred: a multi-segment TOML entry like `generated/inc` keeps + // its `/` on MSVC, and the bare `projectRoot / inc` join would be MIXED — + // reaching both the ninja command line and the CDB's arguments (via + // f.cxx → split_flags). Same rule as every other manifest-path ingestion + // point (#390); no-op on POSIX. ONE lambda because the same join is needed + // four times in this function — {include_dirs, include_dirs_after} × {the + // C/C++ token list, the NASM one} — and re-deriving it per site is how the + // two channels drifted apart in the first place. + auto abs_native = [&](const std::filesystem::path& inc) { + auto p = inc.has_root_path() ? inc : (plan.projectRoot / inc); + p.make_preferred(); + return p; + }; + std::vector includeTokens; for (auto& inc : plan.manifest.buildConfig.includeDirs) { - std::filesystem::path p = inc.has_root_path() ? inc : (plan.projectRoot / inc); - includeTokens.push_back(include_token(d, p)); + includeTokens.push_back(include_token(d, abs_native(inc))); } // #249: `[build] include_dirs_after` — searched AFTER the toolchain's // system dirs via -idirafter (gcc+clang), so entries can't shadow @@ -327,10 +341,8 @@ CompileFlags compute_flags(const BuildPlan& plan) { // (documented degradation; clang-MSVC uses the gnu dialect). const bool msvcInclude = d.includePrefix == std::string_view("/I"); for (auto& inc : plan.manifest.buildConfig.includeDirsAfter) { - std::filesystem::path ip(inc); - std::filesystem::path p = ip.has_root_path() ? ip : (plan.projectRoot / ip); includeTokens.push_back( - include_token(d, p, msvcInclude ? "/I" : "-idirafter")); + include_token(d, abs_native(inc), msvcInclude ? "/I" : "-idirafter")); } std::string include_flags; for (auto& t : includeTokens) { @@ -529,17 +541,22 @@ CompileFlags compute_flags(const BuildPlan& plan) { // re-spelt with -I regardless of dialect (nasm ≥2.14 inserts a missing // path separator itself); DWARF debug info exists on ELF only. if (!plan.nasmPath.empty()) { + // Same abs_native join as the C/C++ channel above — one decision, one + // implementation. Two knock-on effects, both wanted: the entry is now + // spelt with native separators (#390), and the "already rooted?" test + // becomes has_root_path() instead of is_absolute(), so a root-relative + // `/x` entry is left alone here exactly as it is for the C/C++ include + // list. The two predicates only differ on Windows, and only for that + // spelling — where NASM disagreeing with the compiler about the SAME + // `include_dirs` key was the bug, not the feature. std::string nasm_includes; for (auto& inc : plan.manifest.buildConfig.includeDirs) { - auto abs = inc.is_absolute() ? inc : (plan.projectRoot / inc); - nasm_includes += " -I" + escape_path(abs); + nasm_includes += " -I" + escape_path(abs_native(inc)); } // #249: nasm has no system header dirs to defer to — after-dirs // degrade to plain -I appended at the end. for (auto& inc : plan.manifest.buildConfig.includeDirsAfter) { - std::filesystem::path ip(inc); - auto abs = ip.is_absolute() ? ip : (plan.projectRoot / ip); - nasm_includes += " -I" + escape_path(abs); + nasm_includes += " -I" + escape_path(abs_native(inc)); } std::string nasm_debug; if (prof.debug && plan.nasmFormat.starts_with("elf")) diff --git a/src/build/plan.cppm b/src/build/plan.cppm index 45a30fa9..795cc5b0 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -220,6 +220,14 @@ make_plan(const mcpp::manifest::Manifest& manifest, // simply makes those units uncacheable. const std::vector& storeRoots = {}); +// Expand one manifest `include_dirs` entry against the project root — the +// #249 consistency join + the expand_dir_glob the dep path uses. Exported +// (like modgraph's glob_literal_prefix) so unit tests can assert its +// native-separator contract directly; see the definition below. +std::vector +expand_manifest_include_entry(const std::filesystem::path& root, + const std::filesystem::path& inc); + } // namespace mcpp::build namespace mcpp::build { @@ -368,6 +376,8 @@ std::vector shared_library_link_flags( return flags; } +} // namespace + // #249 consistency fix: expand include_dirs entries with the same // `expand_dir_glob` the dep path (prepare.cppm) uses, so a main-manifest // `include_dirs = ["*/include"]` glob works identically here. For a literal @@ -375,15 +385,33 @@ std::vector shared_library_link_flags( // whereas this helper historically joined unconditionally — keep the plain // join as a fallback so an -I for a dir created later (e.g. by a build // step) isn't silently dropped. +// +// Deliberately OUTSIDE the anonymous namespace: it is exported for its unit +// test (like modgraph's glob_literal_prefix), and the two +// local_include_dirs_*_for_manifest consumers below ride along so a single +// namespace split serves the whole trio. std::vector expand_manifest_include_entry(const std::filesystem::path& root, const std::filesystem::path& inc) { - if (inc.is_absolute()) return { inc }; + if (inc.is_absolute()) { + // A TOML value like `C:/SDL2/include` keeps its `/` on MSVC — make + // it native so the CDB's -I (via local_include_args) is uniform. + auto n = inc; + n.make_preferred(); + return { std::move(n) }; + } const auto glob = inc.generic_string(); auto expanded = mcpp::modgraph::expand_dir_glob(root, glob); - if (expanded.empty() && glob.find('*') == std::string::npos) - expanded.push_back(root / inc); + if (expanded.empty() && glob.find('*') == std::string::npos) { + // Same native-spelling rule for the bare join (see above): `root / p` + // with a multi-segment `generated/inc` is MIXED on MSVC, and this + // fallback exists precisely for dirs like `generated/` that a later + // build step creates — the #390 shape. + auto joined = root / inc; + joined.make_preferred(); + expanded.push_back(std::move(joined)); + } return expanded; } @@ -412,6 +440,8 @@ local_include_dirs_after_for_manifest(const std::filesystem::path& root, return dirs; } +namespace { + void append_unique_path(std::vector& out, std::filesystem::path path) { diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 0d61b018..3a7d2649 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -16,6 +16,7 @@ import mcpp.platform.axis; import mcpp.libs.json; import mcpp.log; import mcpp.manifest; +import mcpp.modgraph.glob; import mcpp.modgraph.graph; import mcpp.modgraph.scanner; import mcpp.modgraph.validate; @@ -2996,7 +2997,13 @@ prepare_build(bool print_fingerprint, std::vector dirs; for (auto const& inc : manifest.buildConfig.includeDirs) { if (inc.is_absolute()) { - appendUniquePath(dirs, inc); + // Native spelling: a TOML `C:/SDL2/include` stays mixed on + // MSVC and leaks into the CDB's -I otherwise. Direct + // make_preferred — no generic_string round trip, which can + // throw for names the ANSI codepage cannot spell (mcpp#230). + auto n = inc; + n.make_preferred(); + appendUniquePath(dirs, std::move(n)); continue; } for (auto& dir : mcpp::modgraph::expand_dir_glob( @@ -3016,7 +3023,9 @@ prepare_build(bool print_fingerprint, std::vector dirs; for (auto const& inc : manifest.buildConfig.includeDirsAfter) { if (inc.is_absolute()) { - appendUniquePath(dirs, inc); + auto n = inc; + n.make_preferred(); + appendUniquePath(dirs, std::move(n)); continue; } for (auto& dir : mcpp::modgraph::expand_dir_glob( diff --git a/src/modgraph/glob.cppm b/src/modgraph/glob.cppm index a2262735..7d929c54 100644 --- a/src/modgraph/glob.cppm +++ b/src/modgraph/glob.cppm @@ -12,6 +12,26 @@ import std; export namespace mcpp::modgraph { +// Convert a manifest-style path or glob prefix (always spelled with the +// generic `/` separator) to the platform's native spelling. +// +// MSVC's std::filesystem::path preserves the separators of the string it +// was constructed from instead of normalizing them, so wrapping a raw +// `generated/modules` in a path and joining it with `root / p` yields the +// MIXED `C:\...\generated/modules` — and the directory-walk children built +// on top of that stay mixed. `.string()` then carries the mixed form into +// `compile_commands.json` (its `file` / `-c` fields), which CLion refuses +// to parse. Ninja never notices because it renders everything via +// generic_string(); the CDB is the first `.string()` consumer. +// +// POSIX is untouched (`make_preferred()` is a no-op there, and it is also +// safe for already-native Windows input, which never contains `/`). +std::filesystem::path native_path_from_generic(std::string_view s) { + std::filesystem::path p(s); + p.make_preferred(); + return p; +} + // Does `candidate` match `glob`, interpreted relative to `root`? // // Supports "**" (any number of directory levels) and "*" (within one segment). diff --git a/src/modgraph/scanner.cppm b/src/modgraph/scanner.cppm index 6ae3232e..47e94dcf 100644 --- a/src/modgraph/scanner.cppm +++ b/src/modgraph/scanner.cppm @@ -268,7 +268,12 @@ std::filesystem::path glob_literal_prefix(std::string_view glob) { ? glob : glob.substr(0, wildcard); auto slash = literal.find_last_of('/'); if (slash == std::string_view::npos) return {}; - return std::filesystem::path(literal.substr(0, slash)); + // Native separators, not the raw generic form: MSVC keeps the input's + // `/` verbatim, and `root / p` plus the directory walk then propagate a + // MIXED `root\generated/modules` into every downstream path — which is + // what `compile_commands.json`'s `file` field showed on Windows for + // multi-segment globs. See mcpp::modgraph::native_path_from_generic. + return native_path_from_generic(literal.substr(0, slash)); } // mcpp#228: `{a,b}` alternation, recursively. Finds the first top-level `{`, @@ -442,7 +447,9 @@ std::vector expand_dir_glob(const std::filesystem::path& // expand_glob) — include_dirs entries are meant to name one literal // directory each; a caller wanting alternatives lists multiple entries. if (glob.find('*') == std::string_view::npos) { - auto p = root / std::filesystem::path(glob); + // Native spelling (see native_path_from_generic — a raw `a/b` would + // come back mixed from .string() on MSVC). + auto p = root / native_path_from_generic(glob); if (std::filesystem::is_directory(p, ec)) out.push_back(p); return out; } @@ -494,10 +501,24 @@ namespace { // has_root_path: leave absolute AND root-relative ("/x" on Windows) // spellings alone — only genuinely root-less paths are project-relative. +// Both branches normalize to NATIVE separators: a `-Ithird_party/inc` cxxflag +// would otherwise come back as `C:\proj\third_party/inc` on MSVC (path keeps +// the input `/` verbatim) and reach the CDB's arguments via packageCxxflags. std::string rewrite_rel_copy(const std::string& p, const std::filesystem::path& root) { std::filesystem::path fp(p); - if (fp.has_root_path()) return p; - return (root / fp).string(); + if (fp.has_root_path()) { + // Nothing to re-spell → hand back the ORIGINAL bytes rather than + // round-tripping them through path's narrow conversion, which throws + // std::system_error for names the ANSI codepage cannot express + // (mcpp#230 — see path_matches_glob). A rooted path with no '/' is + // already native on both platform families. + if (p.find('/') == std::string::npos) return p; + fp.make_preferred(); + return fp.string(); + } + auto joined = root / fp; + joined.make_preferred(); + return joined.string(); } void rewrite_rel(std::string& p, const std::filesystem::path& root) { @@ -682,7 +703,14 @@ local_include_dirs_for(const std::filesystem::path& root, std::vector dirs; for (auto const& inc : manifest.buildConfig.includeDirs) { if (inc.is_absolute()) { - dirs.push_back(inc); + // A TOML value like `C:/SDL2/include` keeps its `/` on MSVC — + // normalize so the CDB's -I comes out native (mixed separators + // break CLion). Direct make_preferred, no generic_string round + // trip: the narrow conversion can throw for names the ANSI + // codepage cannot spell (mcpp#230). + auto n = inc; + n.make_preferred(); + dirs.push_back(std::move(n)); continue; } for (auto& d : expand_dir_glob(root, inc.generic_string())) { @@ -701,7 +729,9 @@ local_include_dirs_after_for(const std::filesystem::path& root, std::vector dirs; for (auto const& inc : manifest.buildConfig.includeDirsAfter) { if (inc.is_absolute()) { - dirs.push_back(inc); + auto n = inc; + n.make_preferred(); + dirs.push_back(std::move(n)); continue; } for (auto& d : expand_dir_glob(root, inc.generic_string())) { @@ -738,7 +768,10 @@ void scan_one_into(ScanResult& result, // Literal absolute entry — e.g. a dependency build.mcpp's OUT_DIR // generated source, which lives OUTSIDE the (possibly read-only) // package root. No glob expansion; taken as-is when it exists. - if (std::filesystem::path gp(g); gp.is_absolute()) { + // Native spelling: a raw `C:/abs/x.cppm` would stay mixed on MSVC + // (see native_path_from_generic) and leak into the CDB. + auto gp = native_path_from_generic(g); + if (gp.is_absolute()) { std::error_code aec; if (std::filesystem::is_regular_file(gp, aec)) all_files.insert(gp); continue; diff --git a/tests/e2e/47_cdb_prebuilt_module_path_abs.sh b/tests/e2e/47_cdb_prebuilt_module_path_abs.sh index 62833223..4b0cf2fb 100755 --- a/tests/e2e/47_cdb_prebuilt_module_path_abs.sh +++ b/tests/e2e/47_cdb_prebuilt_module_path_abs.sh @@ -22,6 +22,17 @@ cd app cdb=compile_commands.json [[ -f "$cdb" ]] || { echo "FAIL: no $cdb generated"; exit 1; } +# jq-independent early guard for the stray-quote bug: before the CDB +# splitter understood shell quoting, flags.cppm's ninja-side quoting leaked +# into the raw JSON as `\"-fprebuilt-module-path=...` (Windows) / `'-...` +# (POSIX). The GCC flow emits no such flag at all, so no-match is the +# expected pass there. +if grep -q '\\"-fprebuilt-module-path' "$cdb" \ + || grep -q "'-fprebuilt-module-path" "$cdb"; then + echo "FAIL: -fprebuilt-module-path retains shell quoting in raw CDB" + exit 1 +fi + command -v jq >/dev/null 2>&1 || { echo "SKIP: jq not on PATH (preinstalled on GitHub-hosted runners)" exit 0 @@ -63,6 +74,16 @@ while IFS= read -r v; do fail=1 fi + # Nor shell quoting: the flags string is assembled for the NINJA command + # line, where shell_quote_arg wraps every token containing a Windows `\` + # in double quotes — and those quotes used to land VERBATIM in the CDB + # (`"-fprebuilt-module-path=C:\...\pcm.cache"`), which clangd execs + # literally and cannot resolve. The CDB splitter must have undone them. + if [[ "$v" == '"'* || "$v" == "'"* || "$v" == *'"' || "$v" == *"'" ]]; then + echo "FAIL: value retains shell quoting: '$v'" + fail=1 + fi + # Absolute: POSIX (starts with '/') or Windows drive (e.g. 'C:'). if [[ "$v" =~ ^/ || "$v" =~ ^[A-Za-z]: ]]; then : diff --git a/tests/e2e/76_compile_commands_generated.sh b/tests/e2e/76_compile_commands_generated.sh index 6b953ad4..b812ff77 100755 --- a/tests/e2e/76_compile_commands_generated.sh +++ b/tests/e2e/76_compile_commands_generated.sh @@ -16,6 +16,21 @@ trap "rm -rf $TMP" EXIT cd "$TMP" "$MCPP" new app > /dev/null cd app + +# A second source reached through a MULTI-SEGMENT glob (literal prefix +# "generated/modules") — the shape that used to leak MIXED separators into +# the CDB's `file`/`-c` on Windows (`root\generated/modules\extra.cpp`), +# because MSVC's std::filesystem::path keeps the `/` from the manifest glob. +mkdir -p generated/modules +cat > generated/modules/extra.cpp <<'EOF' +int mcpp_extra_anchor() { return 1; } +EOF +cat >> mcpp.toml <<'EOF' + +[build] +sources = ["src/**/*.cpp", "generated/modules/**/*.cpp"] +EOF + "$MCPP" build > /dev/null cdb=compile_commands.json @@ -40,9 +55,16 @@ grep -qE '"command"|"arguments"' "$cdb" || { # The minimal project's source (src/main.cpp) must have an entry. grep -q 'main\.cpp' "$cdb" || { echo "FAIL: $cdb has no entry for src/main.cpp"; cat "$cdb"; exit 1; } +# The multi-segment glob must ACTUALLY have contributed an entry — if the +# glob silently missed, every separator assertion below is vacuous green. +grep -q 'extra\.cpp' "$cdb" || { + echo "FAIL: $cdb has no entry for generated/modules/extra.cpp — multi-segment glob missed" + cat "$cdb"; exit 1 +} + # Deeper structural validation when a JSON parser is available (GitHub-hosted -# runners ship python3). Skips cleanly where it isn't, keeping the grep checks -# above as the portable baseline. +# runners ship python3). Explicitly reports the skip where it isn't, so a +# silent pass can never masquerade as validation coverage. if command -v python3 >/dev/null 2>&1; then python3 - "$cdb" <<'PY' || exit 1 import json, sys @@ -51,8 +73,18 @@ assert isinstance(d, list) and d, "CDB must be a non-empty JSON array" for e in d: assert "file" in e and "directory" in e, "entry missing file/directory: %r" % e assert ("command" in e) or ("arguments" in e), "entry missing command/arguments: %r" % e + # Platform-independent mixed-separator check: the #390 bug spelled a + # Windows file as `root\generated/modules\x.cppm` (MSVC's path keeps the + # `/` from the manifest glob prefix, and the directory walk propagates + # it). On POSIX a backslash never appears in a path, so the assertion is + # trivially true there and catches exactly the bug on Windows — no + # os.name / platform sniffing needed. + f = e["file"] + assert not ("\\" in f and "/" in f), "mixed separators in file: %r" % f print(" json validation OK (%d entries)" % len(d)) PY +else + echo "SKIP: python3 not on PATH — JSON validation not run" fi echo "OK" diff --git a/tests/unit/test_build_flags.cpp b/tests/unit/test_build_flags.cpp index 507b9e0a..d67773da 100644 --- a/tests/unit/test_build_flags.cpp +++ b/tests/unit/test_build_flags.cpp @@ -97,8 +97,18 @@ TEST(BuildFlagsAtomic, StaticLinkEmittedWhenArchivePresent) { // BOTH the joined spelling (`-iquotehdr`) and the separated spelling // (`-isystem` followed by a standalone next element). All four of these // project-relative paths must resolve to the same "/proj/hdr" target. +// +// The expected spelling is NATIVE (#390): `-I/abs/hdr` written with forward +// slashes keeps them on MSVC, and the old expectation `(root / "hdr").string()` +// was itself the mixed `"/proj\hdr"` shape this family of bugs produced. +// make_preferred() makes the expectation platform-correct on both sides. TEST(BuildFlags, NormalizeIncludeFlagsRewritesFullIncludeFamily) { std::filesystem::path root = "/proj"; + auto expected = [](const std::filesystem::path& p) { + auto n = p; + n.make_preferred(); + return n.string(); + }; std::vector flags = { "-Ihdr", "-iquotehdr", "-isystem", "hdr", "-idirafterhdr", }; @@ -106,26 +116,32 @@ TEST(BuildFlags, NormalizeIncludeFlagsRewritesFullIncludeFamily) { mcpp::modgraph::normalize_include_flags(root, flags); ASSERT_EQ(flags.size(), 5u); - EXPECT_EQ(flags[0], "-I" + (root / "hdr").string()); - EXPECT_EQ(flags[1], "-iquote" + (root / "hdr").string()); + EXPECT_EQ(flags[0], "-I" + expected(root / "hdr")); + EXPECT_EQ(flags[1], "-iquote" + expected(root / "hdr")); EXPECT_EQ(flags[2], "-isystem"); // prefix itself untouched - EXPECT_EQ(flags[3], (root / "hdr").string()); // separated element rewritten - EXPECT_EQ(flags[4], "-idirafter" + (root / "hdr").string()); + EXPECT_EQ(flags[3], expected(root / "hdr")); // separated element rewritten + EXPECT_EQ(flags[4], "-idirafter" + expected(root / "hdr")); } // Absolute paths and root-relative spellings are left alone (matches the -// pre-#226 -I behavior), for both the joined and separated forms. +// pre-#226 -I behavior), for both the joined and separated forms — only the +// separator spelling is normalized to native (#390; a no-op on POSIX). TEST(BuildFlags, NormalizeIncludeFlagsLeavesAbsolutePathsAlone) { std::filesystem::path root = "/proj"; + auto expected = [](const std::filesystem::path& p) { + auto n = p; + n.make_preferred(); + return n.string(); + }; std::vector flags = { "-I/abs/hdr", "-isystem", "/abs/hdr", "-DKEEP", }; mcpp::modgraph::normalize_include_flags(root, flags); - EXPECT_EQ(flags[0], "-I/abs/hdr"); + EXPECT_EQ(flags[0], "-I" + expected("/abs/hdr")); EXPECT_EQ(flags[1], "-isystem"); - EXPECT_EQ(flags[2], "/abs/hdr"); + EXPECT_EQ(flags[2], expected("/abs/hdr")); EXPECT_EQ(flags[3], "-DKEEP"); } diff --git a/tests/unit/test_compile_commands.cpp b/tests/unit/test_compile_commands.cpp index 7be570db..50e14a83 100644 --- a/tests/unit/test_compile_commands.cpp +++ b/tests/unit/test_compile_commands.cpp @@ -2,18 +2,31 @@ import std; import mcpp.build.compile_commands; +import mcpp.build.flags; +import mcpp.build.plan; +import mcpp.libs.json; using namespace mcpp::build; namespace { // Build a single CDB entry as JSON text. `flag` is a marker we can grep for. +// +// Serialized THROUGH nlohmann, never hand-formatted: a Windows `file` value +// carries backslashes, and `\g` (from `...\generated\...`) is not a legal JSON +// escape. A format-string version produced text that no parser accepts, which +// merge_compile_commands answers by returning `fresh` verbatim — every +// assertion below then passes without the code under test ever running. std::string entry(std::string_view file, std::string_view flag) { // Keep the file path out of `arguments` so it appears exactly once (in // "file") — lets tests count entries per file unambiguously. - return std::format( - R"({{"directory":"/p","file":"{}","arguments":["g++","{}","-c","src.cpp"],"output":"o"}})", - file, flag); + nlohmann::json e; + e["directory"] = "/p"; + e["file"] = std::string(file); + e["arguments"] = nlohmann::json::array( + { "g++", std::string(flag), "-c", "src.cpp" }); + e["output"] = "o"; + return e.dump(); } std::string cdb(std::initializer_list entries) { @@ -25,6 +38,12 @@ std::string cdb(std::initializer_list entries) { first = false; } s += "\n]\n"; + // A fixture that does not PARSE makes every merge test vacuously green + // (merge_compile_commands short-circuits to `fresh` on a discarded parse), + // so the fixture itself is checked here rather than trusted. Tests that + // WANT malformed input pass it directly, not through this builder. + EXPECT_FALSE(nlohmann::json::parse(s, nullptr, /*allow_exceptions=*/false) + .is_discarded()) << s; return s; } @@ -93,6 +112,33 @@ TEST(CompileCommandsMerge, MalformedExistingFallsBackToFresh) { EXPECT_NE(merged.find("-O2"), std::string::npos) << merged; } +// #390 self-heal: a CDB written BEFORE the mixed-separator fix spells the same +// file differently from the way a fresh plan spells it. Because the merge key +// is the NORMALIZED path, the stale entry is recognized as the same file and +// dropped on the first build after upgrade — the user never has to delete +// compile_commands.json by hand. +// +// The stale spelling deliberately differs from fresh on EVERY platform: the +// `/./` segment needs lexically_normal to collapse (POSIX included), and on +// Windows the `/` separators need make_preferred on top. A stale value of just +// `p.generic_string()` would be byte-identical to fresh on POSIX, and the +// literal-string key this test exists to replace would pass it unchanged. +TEST(CompileCommandsMerge, NormalizedFileKeysHealStaleSeparatorSpellings) { + auto p = std::filesystem::path("/p") / "generated" / "modules" / "a.cpp"; + auto stale = "/p/generated/./modules/a.cpp"; + ASSERT_NE(p.string(), stale) << "fixture must differ from the fresh spelling"; + + auto fresh = cdb({ entry(p.string(), "-O2-FRESH") }); + auto existing = cdb({ entry(stale, "-O0-STALE") }); + + auto merged = merge_compile_commands( + fresh, existing, [](const std::filesystem::path&) { return true; }); + + EXPECT_EQ(count(merged, "generated"), 1u) << merged; + EXPECT_NE(merged.find("-O2-FRESH"), std::string::npos) << merged; + EXPECT_EQ(merged.find("-O0-STALE"), std::string::npos) << merged; +} + // ── CDB arguments must be argv, not shell words ───────────────────────────── // // A consumer (clangd) execs `arguments` LITERALLY — no shell. So a token that @@ -172,3 +218,61 @@ TEST(CompileCommandsArgs, InnerQuotesAreNotStripped) { EXPECT_EQ(out[0], R"(-DGREETING="hi")"); EXPECT_FALSE(is_quoted(out[0])); } + +// ── Emitted paths use native separators ───────────────────────────────────── +// +// The last line of the #390 defence: every ingestion point is normalized at +// the source, but a path that still slips through with a mixed spelling +// (MSVC keeps the input `/` verbatim) would land in the CDB and break CLion. +// +// Scope, precisely: the emitter owns the CDB schema's path fields (`file`, +// `directory`, `output`) and the argv positions it builds itself (`-c`'s and +// `-o`'s values, plus `-I`/`-idirafter` from localIncludeDirs). The flag +// STRINGS — split_flags(f.cxx) and the package cflags/cxxflags — pass through +// untouched and stay the ingestion points' responsibility; normalizing an +// arbitrary flag payload is not safe (`-DPATH="/etc/x"` holds real slashes). +TEST(CompileCommandsEmit, EmittedPathsUseNativeSeparators) { + BuildPlan plan; + plan.projectRoot = "/p"; + plan.outputDir = "/p/target"; + plan.compileUnits.push_back({ + // Paths whose spelling carries `/` segments — what the manifest glob + // and the include_dirs channels used to produce on MSVC. + .source = std::filesystem::path("C:/Users/x/src/main.cpp"), + .object = std::filesystem::path("obj") / "main.o", + .packageName = "demo", + .localIncludeDirs = { std::filesystem::path("C:/proj/generated/inc") }, + .localIncludeDirsAfter = { std::filesystem::path("C:/proj/third_party/inc") }, + }); + CompileFlags flags; + flags.cxxBinary = std::filesystem::path("/usr/bin/g++"); + + auto j = nlohmann::json::parse(emit_compile_commands(plan, flags)); + auto const& e = j[0]; + + // Assert on the two include tokens by name. The earlier shape of this + // test ("no `-`-prefixed argument may contain `/`") looked stronger but + // was vacuous — with no flags configured, the only `-` tokens are the + // bare `-c` and `-o`, which have no path in them at all. + auto arg_with = [&](std::string_view pre) { + for (auto const& a : e["arguments"]) { + auto s = a.get(); + if (s.starts_with(pre)) return s; + } + return std::string{}; + }; + const auto inc = arg_with("-I"); + const auto incAfter = arg_with("-idirafter"); + + if constexpr (std::filesystem::path::preferred_separator == '\\') { + EXPECT_EQ(e["file"].get(), "C:\\Users\\x\\src\\main.cpp"); + EXPECT_EQ(e["directory"].get(), "\\p"); + EXPECT_EQ(e["output"].get(), "\\p\\target\\obj\\main.o"); + EXPECT_EQ(inc, "-IC:\\proj\\generated\\inc"); + EXPECT_EQ(incAfter, "-idirafterC:\\proj\\third_party\\inc"); + } else { + EXPECT_EQ(e["file"].get(), "C:/Users/x/src/main.cpp"); + EXPECT_EQ(inc, "-IC:/proj/generated/inc"); + EXPECT_EQ(incAfter, "-idirafterC:/proj/third_party/inc"); + } +} diff --git a/tests/unit/test_modgraph.cpp b/tests/unit/test_modgraph.cpp index 5b104dc0..d74051a5 100644 --- a/tests/unit/test_modgraph.cpp +++ b/tests/unit/test_modgraph.cpp @@ -1,6 +1,7 @@ #include import std; +import mcpp.modgraph.glob; import mcpp.modgraph.graph; import mcpp.modgraph.scanner; import mcpp.modgraph.validate; @@ -151,7 +152,7 @@ TEST(Scanner, GlobLiteralPrefixDerivation) { // Wildcard already in the first segment: no literal directory to bound to. EXPECT_EQ(glob_literal_prefix("**/*.c"), ""); // No wildcard at all: the full parent directory path is the prefix. - EXPECT_EQ(glob_literal_prefix("a/b/c.cpp"), "a/b"); + EXPECT_EQ(glob_literal_prefix("a/b/c.cpp").generic_string(), "a/b"); // Truncate back to the last COMPLETE '/' before the first wildcard char — // "x*.cpp" is a partial segment, not a real directory named "x". EXPECT_EQ(glob_literal_prefix("src/x*.cpp"), "src"); @@ -161,6 +162,72 @@ TEST(Scanner, GlobLiteralPrefixDerivation) { EXPECT_EQ(glob_literal_prefix("a/{x,y}/z"), "a"); } +// MSVC's std::filesystem::path preserves the separators of the string it was +// constructed from, so a raw `a/b` prefix stays generic and `root / p` turns +// into a MIXED `root\a/b` — which used to leak into compile_commands.json +// (`file` / `-c` for every source under a multi-segment glob) and break CLion. +// glob_literal_prefix must return NATIVE separators so the walk and everything +// downstream is native too. (The generic spelling is already locked down by +// Scanner.GlobLiteralPrefixDerivation above.) +TEST(Scanner, GlobLiteralPrefixUsesNativeSeparators) { + if constexpr (std::filesystem::path::preferred_separator == '\\') { + EXPECT_EQ(glob_literal_prefix("a/b/c.cpp").string(), "a\\b"); + EXPECT_EQ(glob_literal_prefix("a/b/c.cpp").string().find('/'), + std::string::npos); + } +} + +// The exported converter itself — both spelling directions. +TEST(Glob, NativePathFromGeneric) { + auto p = mcpp::modgraph::native_path_from_generic("a/b/c"); + EXPECT_EQ(p.generic_string(), "a/b/c"); + if constexpr (std::filesystem::path::preferred_separator == '\\') { + EXPECT_EQ(p.string(), "a\\b\\c"); + // Already-native input is untouched. + EXPECT_EQ(mcpp::modgraph::native_path_from_generic("C:\\x\\y").string(), + "C:\\x\\y"); + } +} + +// The end-to-end shape of the reported bug: a source under a multi-segment +// glob (`generated/modules/**/*.cppm`) must come out of expand_glob with +// NATIVE separators on Windows — the mixed `root\generated/modules\a.cppm` +// was what compile_commands.json's `file` field showed before the fix. +TEST(Scanner, ExpandGlobMultiSegmentPrefixUsesNativeSeparators) { + auto dir = make_tempdir("mcpp-scanner-multi"); + write(dir / "generated" / "modules" / "a.cppm", "export module a;\n"); + + auto files = expand_glob(dir, "generated/modules/**/*.cppm"); + + ASSERT_EQ(files.size(), 1u); + if constexpr (std::filesystem::path::preferred_separator == '\\') { + EXPECT_EQ(files[0].string().find('/'), std::string::npos) << files[0]; + } + EXPECT_EQ(files[0].generic_string(), + (dir / "generated" / "modules" / "a.cppm").generic_string()); + + std::filesystem::remove_all(dir); +} + +// Same contract for the INCLUDE-DIR channel (expand_dir_glob): a multi-segment +// `third_party/inc` entry must yield a native path or the CDB's -I carries the +// mixed form. +TEST(Scanner, ExpandDirGlobMultiSegmentUsesNativeSeparators) { + auto dir = make_tempdir("mcpp-scanner-dirglob"); + std::filesystem::create_directories(dir / "third_party" / "inc"); + + auto dirs = expand_dir_glob(dir, "third_party/inc"); + + ASSERT_EQ(dirs.size(), 1u); + if constexpr (std::filesystem::path::preferred_separator == '\\') { + EXPECT_EQ(dirs[0].string().find('/'), std::string::npos) << dirs[0]; + } + EXPECT_EQ(dirs[0].generic_string(), + (dir / "third_party" / "inc").generic_string()); + + std::filesystem::remove_all(dir); +} + // mcpp#225: expand_glob must bound its walk to the glob's literal directory // prefix ("src" for "src/**/*.cppm") instead of always walking the whole // root and lexically filtering afterward. This is the FUNCTIONAL half of the @@ -690,6 +757,9 @@ TEST(Scanner, PerGlobFlagsMatchBraceAlternation) { // G8b: relative -I flags are root-relative in the manifest but ninja runs // with cwd = output dir — the scanner absolutizes them on every unit. +// The absolute spelling is NORMALIZED to native separators (#390): MSVC's +// path keeps the input `/` verbatim, and `-I/abs/path` written with forward +// slashes used to survive into the CDB's arguments as a mixed path. TEST(Scanner, RelativeIncludeFlagsAbsolutized) { auto dir = make_tempdir("mcpp-scanner-relinc"); write(dir / "src" / "a.cpp", "int a();\n"); @@ -707,8 +777,11 @@ TEST(Scanner, RelativeIncludeFlagsAbsolutized) { ASSERT_EQ(res.graph.units.size(), 1u); auto& fl = res.graph.units[0].packageCxxflags; EXPECT_EQ(fl[0], "-I" + (dir / "inc").string()); - EXPECT_EQ(fl[1], "-I/abs/path"); // absolute stays - EXPECT_EQ(fl[2], "-DKEEP"); // non-include untouched + if constexpr (std::filesystem::path::preferred_separator == '\\') + EXPECT_EQ(fl[1], "-I\\abs\\path"); // absolute stays, but native + else + EXPECT_EQ(fl[1], "-I/abs/path"); // absolute stays + EXPECT_EQ(fl[2], "-DKEEP"); // non-include untouched std::filesystem::remove_all(dir); } diff --git a/tests/unit/test_ninja_backend.cpp b/tests/unit/test_ninja_backend.cpp index be803b8f..2ae7014b 100644 --- a/tests/unit/test_ninja_backend.cpp +++ b/tests/unit/test_ninja_backend.cpp @@ -5,6 +5,7 @@ import mcpp.build.compile_commands; import mcpp.build.flags; import mcpp.build.ninja; import mcpp.build.plan; +import mcpp.libs.json; import mcpp.manifest; import mcpp.toolchain.dialect; import mcpp.toolchain.model; @@ -163,12 +164,41 @@ TEST(NinjaBackend, CxxFlagsIncludeBuildIncludeDirs) { EXPECT_NE(flags.cxx.find(escaped_include_flag(plan.projectRoot / "include")), std::string::npos) << flags.cxx; - EXPECT_NE(flags.cxx.find(escaped_include_flag( - plan.projectRoot / std::filesystem::path{"third_party/imgui"})), + // #390: a multi-segment entry is normalized to NATIVE separators before + // the -I token is built (a mixed `...\third_party/imgui` used to reach + // the CDB through f.cxx). Build the expected path natively too. + auto imgui = plan.projectRoot / "third_party" / "imgui"; + EXPECT_NE(flags.cxx.find(escaped_include_flag(imgui)), std::string::npos) << flags.cxx; } +// #390: the NASM include list is built from the SAME `[build] include_dirs` +// key as the C/C++ one, so it must absolutize and spell entries identically — +// it used to re-derive the join on its own (and with a different "already +// rooted?" predicate). Nothing here is nasm-specific except the channel: the +// point is that one manifest key cannot produce two different paths. +TEST(NinjaBackend, NasmIncludeDirsMatchTheCxxChannelSpelling) { + auto plan = minimal_plan(); + plan.nasmPath = "/usr/bin/nasm"; + plan.manifest.buildConfig.includeDirs = {"third_party/imgui"}; + plan.manifest.buildConfig.includeDirsAfter = {"generated/inc"}; + + auto flags = compute_flags(plan); + + auto native = [](std::filesystem::path p) { p.make_preferred(); return p; }; + auto imgui = native(plan.projectRoot / "third_party" / "imgui"); + auto gen = native(plan.projectRoot / "generated" / "inc"); + + // Absolutized against projectRoot, natively spelt, and -I for BOTH keys + // (nasm has no system-header chain to defer to, so after-dirs degrade). + EXPECT_NE(flags.nasm.find(escaped_include_flag(imgui)), std::string::npos) + << flags.nasm; + EXPECT_NE(flags.nasm.find(escaped_include_flag(gen)), std::string::npos) + << flags.nasm; + EXPECT_EQ(flags.nasm.find("-idirafter"), std::string::npos) << flags.nasm; +} + // #249: a compile unit's localIncludeDirsAfter emit as -idirafter into the // same $local_includes variable, APPENDED after the -I entries. -idirafter // dirs are searched after the toolchain's system dirs (gcc+clang), so a dep @@ -1148,8 +1178,22 @@ TEST(NinjaBackend, CachedUnitsStillAppearInCompileCommands) { auto flags = compute_flags(plan); auto cdb = emit_compile_commands(plan, flags); - EXPECT_NE(cdb.find("/store/dep/src/dep.c"), std::string::npos) << cdb; - EXPECT_NE(cdb.find("src/main.cpp"), std::string::npos) << cdb; + // #390: the emitter spells every path natively, so the expected source + // spelling is make_preferred'ed (a no-op on POSIX). Compared on the + // DECODED file field — the raw JSON text doubles every backslash. + auto dep = std::filesystem::path("/store/dep/src/dep.c"); + dep.make_preferred(); + auto main = std::filesystem::path("src/main.cpp"); + main.make_preferred(); + auto j = nlohmann::json::parse(cdb); + bool sawDep = false, sawMain = false; + for (auto const& e : j) { + auto f = e["file"].get(); + sawDep = sawDep || f == dep.string(); + sawMain = sawMain || f == main.string(); + } + EXPECT_TRUE(sawDep) << cdb; + EXPECT_TRUE(sawMain) << cdb; } // Replacing a package's compile edges with stage edges also removes the ordering @@ -1228,3 +1272,31 @@ TEST(NinjaBackend, NoStagedPhonyWhenNothingIsCached) { auto ninja = emit_ninja_string(plan); EXPECT_EQ(ninja.find("_mcpp_staged_cache"), std::string::npos) << ninja; } + +// #390: the main-manifest include channel's fallback join — `root / inc` for +// a directory a build step will create LATER (the `generated/inc` shape) — +// must spell its result NATIVELY on Windows. MSVC keeps the `/` of a TOML +// `generated/inc` verbatim, so the bare join yields `C:\proj\generated/inc`, +// which reaches the CDB's -I via local_include_args and breaks CLion. Same +// rule for an absolute entry written with forward slashes (`C:/SDL2/include`). +// (The existing-directory path runs through expand_dir_glob, covered by +// Scanner.ExpandDirGlobMultiSegmentUsesNativeSeparators.) +TEST(Plan, ExpandManifestIncludeEntryNativeSpelling) { + namespace fs = std::filesystem; + auto root = fs::temp_directory_path() / "mcpp-plan-inc-entry"; + fs::remove_all(root); + fs::create_directories(root); // generated/inc deliberately does NOT exist + + auto fb = expand_manifest_include_entry(root, fs::path("generated/inc")); + ASSERT_EQ(fb.size(), 1u); + if constexpr (fs::path::preferred_separator == '\\') + EXPECT_EQ(fb[0].string().find('/'), std::string::npos) << fb[0]; + EXPECT_EQ(fb[0], root / "generated" / "inc"); + + auto abs = expand_manifest_include_entry(root, fs::path("C:/SDL2/include")); + ASSERT_EQ(abs.size(), 1u); + if constexpr (fs::path::preferred_separator == '\\') + EXPECT_EQ(abs[0].string(), "C:\\SDL2\\include"); + + fs::remove_all(root); +} From 0006ce6f06902bd40199257b2248372513897f16 Mon Sep 17 00:00:00 2001 From: SPeak Date: Mon, 10 Aug 2026 05:19:14 +0800 Subject: [PATCH 11/54] feat: converge template identity, runtime contracts, and release state (#400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: plan focused ecosystem convergence * feat: make package selectors exact * feat: make template selection exact * feat: make project scaffolding transactional * feat: make runtime selection root-local * feat: validate Linux runtime closure * feat: carry provider-neutral runtime provenance * feat: publish immutable release manifests * feat: reconcile mcpp-bin releases * fix: reconcile runtime bindings with SubOS views * chore: prepare 2026.8.9.1 release * test: recognize Python E2E capability * test: keep ELF parser fixtures on Linux * fix(pm): inherit declared index namespaces exactly * fix(pm): retain index ownership in dependency selection * fix(runtime): model ELF SONAME reuse * test(e2e): align fixtures with exact identities * test(e2e): preserve runtime contracts in fake xlings * fix(index): retain floor cause on exact misses * fix(pm): surface malformed exact descriptors * fix(pm): derive mangling from authored modules * test(e2e): scope BMI settling to its edge * test(scaffold): close fixtures before cleanup * docs: make Draft PR trace privacy-safe * test(e2e): honor isolated mcpp home in libc poison * fix(workspace): anchor inherited indices lexically * docs: add Chinese PR handoff * docs: refresh PR validation handoff * fix(aur): use privacy-safe release identity * docs: record privacy-safe AUR checkpoint * fix(pm): explain exact index route misses * docs: record Windows index route evidence * test(e2e): write fixture paths in host spelling, not shell spelling The Windows E2E 2/2 failure is not the workspace index inheritance the last three commits were aimed at. It is step (15) of 12_add_command.sh, added by this branch, which writes an MSYS path into a manifest: TMP=$(mktemp -d) # Git Bash -> /tmp/tmp.XXXXXXXX acme = { path = "$TMP/myapp/index" } # written as FILE CONTENT MSYS converts POSIX paths on the way into argv and the environment; it does not convert file content. A native mcpp.exe reads the literal leading `/` as "root of the current drive", so the fixture index resolves to C:\tmp\... and is absent. That is exactly what the route diagnostic reported: `local index 'acme': root absent, pkgs absent`. Four independent signals place the failure at step (15), not (14): exit code 2 is mcpp's own status forwarded by `set -e`, while step (14) is wrapped in `|| { …; exit 1; }`; step (14)'s failure banner never appears; the last uncaptured output before it belongs to step (12); and step (14) predates this branch, on which the Windows suite was green. The Wine replay that "reproduced it as passing" was a false negative: Wine maps Z: to /, so \tmp\... lands back on the real /tmp. Wine can show a PE runs. It cannot show anything about path semantics. Fixed as a class rather than a line. `_host_path.sh` provides `host_path` (cygpath -m on MSYS, identity elsewhere, relative paths untouched), 00_fixture_path_hygiene.sh fails any fixture that interpolates a non-`*_HOST` variable into a TOML `path =` value, and the 40 pre-existing sites across 24 files are migrated. Most were masked only because those tests skip on Windows for a missing capability. * test(pm): pin why an inherited index is anchored lexically `inherit_workspace_indices` was switched from `weakly_canonical` to `lexically_normal` in ed4cf64 to fix the red Windows suite. It did not: the same failure appeared identically before (run 31318089536) and after (run 31321961040), and the actual cause was a fixture writing an MSYS path into mcpp.toml. So the change was landed against a misdiagnosis, against a behaviour that was green on main, with no test able to tell the two spellings apart. It turns out to be right for a different reason, and now it says so. Anchoring answers "which directory did the workspace author mean", and the answer must stay inside the tree the author addressed. `weakly_canonical` answers "what is this path once every symlink is resolved", so a workspace reached through a symlinked parent gets relocated into a tree the author never wrote — observable, because prepare reports a missing descriptor as "not found in local index at ''". Verified as a real RED: restoring `weakly_canonical` fails `unit/test_pm_index_route` (71 passed, 1 failed). The existing test also stops asserting a path SPELLING. Comparing the stored path against `(root / "index").lexically_normal()` passes for any rule that happens to produce that string and says nothing about whether the index can be read — which is how it stayed green through a red Windows suite. It now asserts the capability: route description, then a descriptor lookup that actually resolves. * feat(pm): give namespace omission a one-release exit ramp Namespace omission means exactly `mcpplibs`. But every published `compat.*` package and every manifest written before exact identity spells its dependency bare — `gtest = "1.15.2"`, `ftxui = "6.1.9"` — so turning that into an immediate hard error makes an mcpp upgrade break builds against data that is already published and cannot be edited retroactively. Four CI jobs on this branch are that breakage, and this repository's own mcpp.toml had to move gtest under [dev-dependencies.compat] to keep bootstrapping: a change that breaks its own project is breaking everyone's. It is the mirror of the rule that keeps a raised index floor from bricking older clients. Published data must not break the program; a published program must not break existing data either. Both degrade. The #278 defect was the SILENCE, not the reach: mcpp continued with a namespace the user never wrote and never said so. So the rungs come back, and cannot be quiet. After the exact coordinate misses, and only for a selector whose namespace was OMITTED, `(compat, name)` and the namespace-less discovery rung are tried; a hit is announced with the canonical selector and the exact manifest edit, and the canonical identity — not the ambiguous spelling — is what reaches the lock, the install layer and the cache label. `mcpp add` goes further and performs the edit, so touching a dependency migrates it. `mcpplibs.gtest` states an identity and still misses. A third-party namespace is still unreachable by a bare name. Removed in 2026.9, named once as kBareNameFallbackRemovedIn so the warning, the docs and the removal cannot drift apart. Test 162 now asserts both branches and that an already-exact selector is not nagged, and it serves the compat fixture from its own index instead of whatever gtest happened to be installed. * fix(runtime): keep the private libc out of the process environment (#401) `mcpp run` put the private glibc directory on LD_LIBRARY_PATH. That variable is inherited by every process the program ever spawns, and a child like /bin/sh is loaded by the HOST loader — PT_INTERP is baked in and no environment variable overrides it. glibc's libc.so.6 and its ld.so are version-locked through GLIBC_PRIVATE, so the child dies during relocation, before main: sh: symbol lookup error: …/xim-x-glibc/2.44/lib64/libc.so.6: undefined symbol: __pointer_chk_guard, version GLIBC_PRIVATE The reporter hit it through an application probing the desktop theme with gsettings: popen() returned nothing, the app silently used the wrong theme, and mcpp said nothing. Verified locally — the 2.44 payload kills /bin/sh, the 2.39 payload does not, which is why this survived as long as it did and why "it works here" proved nothing. The directory was published to serve a dlopen() the executable's DT_NEEDED closure does not cover, and the artifact's RUNPATH already covers exactly that: the link model emits -Wl,-rpath, next to --dynamic-linker, and the built binary's RUNPATH is byte-identical before and after this change. So the environment entry bought nothing and cost every child process. It is gone. Measured against the released mcpp with the same fixture: before LDLP=[…/runtime:…/xim-x-glibc/2.39/lib64] after LDLP=[…/runtime] The decision lives in mcpp.platform.runtime_env_contract as a scope, not a condition: no build-level predicate can make an inherited variable safe for a process mcpp did not launch and cannot see. mcpp#291 was the same mistake one hop closer in. 208 pins all three halves — the dir is in the artifact RUNPATH, it is not in the program's environment, and both the /bin/sh child and the dlopen still work. The RUNPATH assertion deliberately checks coverage mcpp does not itself emit, so losing it is a red test rather than a silent loss of resolution. * test(runtime): a proven mismatch must not go green on a re-run The Linux closure verdict is cached against the artifact stat and the RuntimeBinding contract hash, and caching a FAILURE is the half worth pinning: an unchanged artifact that was proven wrong is still wrong, and reporting success the second time tells the user a problem went away when nothing about the program changed. It is easy to lose by accident, because the project fast path exists to skip work when nothing changed — including, unless something stops it, the check that would have failed. `validated_artifact_snapshot` stops it by refusing to engage unless every stored verdict is a PASS; that rule had no test. 209 poisons one stored verdict without touching a source file and requires the next build to fail with the recorded diagnostics, requires `mcpp self doctor` to explain it from storage, and then requires a rebuilt artifact to be re-validated — a mismatch is cached, not a sentence, or the first real failure would wedge the project until someone deleted target/. * ci(aur): require arming before an unattended publish `schedule` fires every six hours off the default branch and set publish=true unconditionally. Merging this workflow was therefore enough to make mcpp start writing to the AUR on its own, within six hours, on a path that had never completed a real push — only a makepkg --verifysource dry-run. Merging is a decision about code; publishing to a third-party service is a decision about the outside world, and they should not be the same act. Both automatic triggers now plan and report but withhold the push unless the repository variable AUR_AUTOPUBLISH is exactly "true", so a typo fails closed. Dry runs still validate payloads, render .SRCINFO, query the AUR and print the diff — only the push is withheld, so nothing is lost but the surprise. workflow_dispatch keeps its explicit per-run switch, which is how the first watched publish is meant to happen, and unsetting the variable is a kill switch that needs no revert. The contract test runs the workflow's own decision shell rather than a paraphrase of it, so the guarantee cannot drift from the YAML. * test(e2e): assert local-index addressing on every host Every test covering local-index addressing needed `gcc` or `fresh-sandbox`, and Windows has neither capability. So the one platform where path semantics actually differ was the one platform never asserting them — which is why a fixture could write an MSYS path into mcpp.toml and the failure surfaced four steps away, as "package not found in any configured index", and was chased through the workspace inheritance code that was not involved. 210 needs no compiler, no sandbox bootstrap and no network: it asserts at the resolution layer, which is the layer that was broken. An absolute [indices] path resolves; a workspace member inherits a root-relative one and reads it; a miss in both reports the index as a READABLE root, since "root absent" is the fingerprint of a path written in the wrong spelling; and the route diagnostic that makes any of this findable stays free of filesystem paths. * chore(release): 2026.8.10.1 on xlings 2026.8.10.1 Two independent bumps that happen to land on the same date string. The xlings pin moves from 2026.8.9.2 to the current stable 2026.8.10.1 across all 16 pin sites; check_version_pins.sh verifies them against src/xlings.cppm rather than trusting a comment. Note that 2026.8.10.1 still declares a bare `ftxui`, so the four cross-repo CI failures on this branch are resolved by the bare-name migration window in this PR, not by the pin — openxlings/xlings#521 remains the correct upstream fix and is no longer on the critical path. The release version moves from 2026.8.9.1 to 2026.8.10.1 because the version is a DATE and the date changed while this branch was in review. 2026.8.9.1 was never tagged, so nothing depends on the old string. The release-manifest and AUR fixtures derive "some other version" from VERSION; both halves move so they stay distinct instead of collapsing onto the release version and silently asserting nothing. Bootstrap pin stays at the released 2026.8.8.4 — it is the self-hosting starting point, not a function of what this release will be. * docs: the bare-name window, the runtime env scope, and AUR arming Three things a user or a maintainer cannot discover from the code: - The one-release exit ramp for namespace-omitted selectors: what still resolves, what the warning says, that the canonical identity is what reaches the lock, that `mcpp add` performs the migration, and that a stated namespace is not eligible. Written with the reason attached — every published `compat.*` package spells its dependency bare, so failing them outright would make a program release invalidate data that is already published and cannot be edited. - Why the private libc directory is binary-scoped and never environment-scoped, as a table of blast radius rather than a rule to memorize: DT_RUNPATH reaches one object, LD_LIBRARY_PATH reaches every descendant process forever, and a GLIBC_PRIVATE-coupled libc handed to the host loader kills /bin/sh before main (#401). - How to arm the AUR automatic triggers, including that the kill switch is unsetting a repository variable rather than reverting a commit. English and Chinese both, plus the changelog entry for 2026.8.10.1. * chore(xlings): hold the pin at 2026.8.9.2 until openxlings/xlings#524 Pinning to the current stable 2026.8.10.1 turned every Linux job red at the same place: a COLD home cannot install xim:gcc@16.1.0 at all. Downloading xim:glibc@2.44 … integrity: cleaning incomplete install: …/xpkgs/xim-x-gcc/16.1.0 [gcc] failed: config hook failed [xim:xpkg]: glibc payload not found, but gcc needs it to rewrite its ELF interpreter away from the build machine's path. install it first: xlings install xim:glibc@2.39 The version it installs and the version the hook asks for disagree, and gcc.lua declares `xim:glibc@>=2.39` so resolving 2.44 is legal — the failure is that the consumer's config hook cannot find the dependency payload that was just fetched. A/B on the same workflow, same runner image, both with a confirmed cache miss, so this is not a long-standing cold-start bug that warm caches were hiding: 2026.8.9.2 cold → gcc installs (run 31317627461) 2026.8.10.1 cold → the failure above (run 31335075557, 4/4 Linux jobs) Nothing in mcpp can route around it: it is upstream install ordering or store-root lookup, and it breaks every new machine, every cold CI cache and every fresh install. Reported with the evidence as openxlings/xlings#524; the pin moves once that ships. The release version stays 2026.8.10.1 — it is a date, and this is a different project's version that happens to share the string. * docs: record the completion design and what executing it actually found The design that drove this round of work, plus an appendix of outcomes written after the fact — including the two places reality diverged from the plan. D2 resolved as "proven, keep", but for a different reason than the commit it was defending claimed: not a Windows short-name alias, but that anchoring must not relocate an index out of the tree the workspace author addressed. D5's invariant turned out to already hold; it simply had no test. Two things arrived unplanned: #401, and the discovery that every identity/index-routing e2e was gated behind a capability Windows does not have — so the platform where path semantics differ was the one platform never asserting them. And main line C's bottleneck moved. The migration window took openxlings/xlings#521 off the critical path as intended, but pinning to the newest xlings put a harder one in its place: a cold home cannot install gcc at all (openxlings/xlings#524). Recorded with the A/B, because the lesson generalizes — "pin to latest" is an action that needs verifying, not a text substitution, and it only surfaced here because changing the pin also changed the CI cache key. * test(e2e): 166 asserted the mechanism, not the capability 166 exists for mcpp#291 and had two halves. The negative half — a plain binary must not be handed the private glibc — is still exactly right and is unchanged. The positive half required the opposite in the presence of a dlopen-reachable dependency: the payload dir HAD to be on LD_LIBRARY_PATH, "because dlopen'd libraries do not consult the executable's RUNPATH". The guard was right to exist. It stopped anyone from "fixing" #291 by deleting the entry and quietly breaking dlopen. But it pinned the mechanism rather than the capability, and the mechanism's premise was false: a dlopen() performed by the executable does consult the executable's DT_RUNPATH, and the link model already puts the payload glibc there — the artifact's RUNPATH is byte-identical with and without the environment entry. So the entry bought nothing while reaching every descendant process, which is how it became mcpp#401. The capability it was protecting now lives in 208, which asserts the dlopen actually resolves. 166 keeps the rule it is named for and now applies it to both shapes, plus one new assertion: the project's own [runtime] library_dirs entry must still be there. Only the private libc is binary-scoped; dropping ordinary runtime dirs too would be a different regression that the negative assertion alone would not catch. Full local Linux e2e on this HEAD: 202 passed, 0 failed, 8 skipped. --------- Co-authored-by: speak-agent Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- ...ate-runtime-graphics-aur-focused-design.md | 1034 +++++++++++++++ ...untime-graphics-aur-implementation-plan.md | 408 ++++++ ...emplate-runtime-graphics-aur-validation.md | 408 ++++++ .agents/docs/2026-08-09-pr400-handoff-zh.md | 422 ++++++ ...lings-mcpp-ecosystem-convergence-design.md | 985 ++++++++++++++ .../2026-08-10-pr400-completion-design.md | 613 +++++++++ .github/actions/bootstrap-mcpp/action.yml | 2 +- .github/actions/setup-macos-llvm/action.yml | 2 +- .github/workflows/aur-publish.yml | 182 +-- .github/workflows/bootstrap-macos.yml | 2 +- .github/workflows/ci-fresh-install.yml | 22 +- .github/workflows/ci-linux-e2e.yml | 2 +- .github/workflows/cross-build-test.yml | 4 +- .github/workflows/release.yml | 120 +- .xlings.json | 2 +- CHANGELOG.md | 66 + README.md | 4 +- README.zh-CN.md | 4 +- docs/00-getting-started.md | 26 +- docs/05-mcpp-toml.md | 215 +++- docs/06-workspace.md | 2 +- docs/08-toolchain-internals.md | 182 ++- docs/09-release.md | 62 +- docs/spec/package-identity.md | 144 ++- docs/zh/00-getting-started.md | 23 +- docs/zh/05-mcpp-toml.md | 166 ++- docs/zh/06-workspace.md | 2 +- docs/zh/08-toolchain-internals.md | 142 ++- docs/zh/09-release.md | 57 +- mcpp.toml | 5 +- scripts/aur/README.md | 125 +- scripts/aur/aur.archlinux.org.known_hosts | 4 + scripts/aur/mcpp-bin/PKGBUILD | 2 +- scripts/aur/reconcile_mcpp_bin.py | 1132 +++++++++++++++++ scripts/aur/render_mcpp_bin.py | 224 ++++ scripts/aur/update.sh | 154 +-- scripts/release/generate_manifest.py | 228 ++++ src/build/execute.cppm | 133 +- src/build/flags.cppm | 129 +- src/build/ninja_backend.cppm | 34 + src/build/plan.cppm | 350 +++-- src/build/prepare.cppm | 790 +++++++++--- src/build/runtime_validation.cppm | 433 +++++++ src/cli.cppm | 20 +- src/cli/cmd_new.cppm | 49 +- src/doctor.cppm | 213 +++- src/manifest/toml.cppm | 487 +++++-- src/manifest/types.cppm | 97 +- src/manifest/xpkg.cppm | 248 +++- src/platform/elf_runtime.cppm | 722 +++++++++++ src/platform/project_name.cppm | 35 + src/platform/runtime_binding.cppm | 485 +++++++ src/platform/runtime_env_contract.cppm | 69 + src/platform/scaffold_fs.cppm | 208 +++ src/pm/commands.cppm | 351 +++-- src/pm/dep_spec.cppm | 31 +- src/pm/dependency_selector.cppm | 189 ++- src/pm/index_refresh.cppm | 7 + src/pm/index_route.cppm | 60 +- src/pm/mangle.cppm | 35 + src/pm/package_fetcher.cppm | 46 + src/project.cppm | 8 +- src/scaffold/create.cppm | 357 ++++-- src/scaffold/project_name.cppm | 83 ++ src/scaffold/template.cppm | 535 ++++++-- src/toolchain/detect.cppm | 11 +- src/toolchain/fingerprint.cppm | 3 +- src/toolchain/lifecycle.cppm | 7 +- src/toolchain/model.cppm | 16 +- src/toolchain/post_install.cppm | 117 +- src/toolchain/probe.cppm | 4 +- src/version.cppm | 2 +- src/xlings.cppm | 13 +- src/xlings/runtime_selection.cppm | 70 + src/xlings/subos_info.cppm | 143 ++- tests/e2e/00_fixture_path_hygiene.sh | 74 ++ tests/e2e/09_path_dependency.sh | 4 +- tests/e2e/100_feature_sources_test_mode.sh | 4 +- tests/e2e/121_default_ns_redirect.sh | 6 +- tests/e2e/123_same_named_main_across_dep.sh | 8 +- tests/e2e/12_add_command.sh | 181 ++- tests/e2e/147_per_os_features_xpkg.sh | 6 +- tests/e2e/156_nested_ld_library_path.sh | 6 +- tests/e2e/162_bare_name_namespace_scope.sh | 93 +- tests/e2e/163_identity_first_resolution.sh | 5 +- ..._bare_name_cross_namespace_wire_address.sh | 24 +- tests/e2e/166_run_env_no_private_glibc.sh | 47 +- tests/e2e/170_bmi_staging_no_cascade.sh | 17 +- tests/e2e/172_build_cache_cross_project.sh | 4 +- tests/e2e/174_cache_modes_and_commands.sh | 4 +- tests/e2e/184_build_cache_object_layout.sh | 4 +- tests/e2e/185_index_floor_degrades.sh | 11 +- tests/e2e/200_subos_env_reaches_program.sh | 233 ++-- .../e2e/203_exact_selector_lock_migration.sh | 111 ++ tests/e2e/204_new_transactional_scaffold.sh | 217 ++++ tests/e2e/205_root_local_subos.sh | 157 +++ tests/e2e/206_runtime_binding_physics.sh | 111 ++ tests/e2e/207_runtime_contract_provenance.sh | 132 ++ .../208_private_libc_stays_in_the_binary.sh | 148 +++ .../209_runtime_verdict_never_reruns_green.sh | 119 ++ ...10_local_index_addressing_on_every_host.sh | 123 ++ tests/e2e/27_namespace_dependencies.sh | 8 +- tests/e2e/31_transitive_deps.sh | 10 +- tests/e2e/32_semver_merge.sh | 4 +- tests/e2e/33_multi_version_mangling.sh | 6 +- tests/e2e/42_custom_local_index.sh | 4 +- tests/e2e/43_indices_lockfile.sh | 4 +- tests/e2e/44_indices_e2e_integration.sh | 8 +- tests/e2e/49_bmi_cache_nested_custom_index.sh | 4 +- tests/e2e/51_package_generated_files.sh | 4 +- tests/e2e/52_local_path_namespaced_index.sh | 15 +- tests/e2e/53_namespaced_cache_label.sh | 4 +- .../e2e/58_preinstall_mcpp_deps_for_hooks.sh | 13 +- tests/e2e/60_stale_xpkg_cache_reinstall.sh | 4 +- .../62_dotted_dependency_selector_priority.sh | 13 +- tests/e2e/62_runtime_library_dirs.sh | 16 + .../63_bare_dependency_peer_root_priority.sh | 12 +- tests/e2e/66_runtime_provides.sh | 46 +- tests/e2e/69_package_templates.sh | 196 ++- tests/e2e/78_test_main_combinations.sh | 2 +- .../e2e/79_gtest_regular_dep_feature_main.sh | 12 +- tests/e2e/88_xlings_environment.sh | 7 + tests/e2e/_host_path.sh | 55 + tests/e2e/run_all.sh | 7 +- tests/scripts/test_aur_reconcile.py | 413 ++++++ tests/scripts/test_release_manifest.py | 218 ++++ tests/unit/test_elf_runtime.cpp | 397 ++++++ tests/unit/test_fingerprint.cpp | 11 + tests/unit/test_link_model_runtime_dirs.cpp | 35 + tests/unit/test_mangle.cpp | 12 + tests/unit/test_manifest.cpp | 232 +++- tests/unit/test_pm_bare_name_window.cpp | 37 + tests/unit/test_pm_compat.cpp | 95 +- tests/unit/test_pm_index_route.cpp | 207 ++- tests/unit/test_pm_package_fetcher.cpp | 13 +- tests/unit/test_runtime_contract.cpp | 289 +++++ tests/unit/test_runtime_selection.cpp | 299 +++++ tests/unit/test_scaffold.cpp | 390 ++++++ tests/unit/test_subos_info.cpp | 102 +- 139 files changed, 17055 insertions(+), 1765 deletions(-) create mode 100644 .agents/docs/2026-08-09-mcpp-template-runtime-graphics-aur-focused-design.md create mode 100644 .agents/docs/2026-08-09-mcpp-template-runtime-graphics-aur-implementation-plan.md create mode 100644 .agents/docs/2026-08-09-mcpp-template-runtime-graphics-aur-validation.md create mode 100644 .agents/docs/2026-08-09-pr400-handoff-zh.md create mode 100644 .agents/docs/2026-08-09-xlings-mcpp-ecosystem-convergence-design.md create mode 100644 .agents/docs/2026-08-10-pr400-completion-design.md create mode 100644 scripts/aur/aur.archlinux.org.known_hosts create mode 100755 scripts/aur/reconcile_mcpp_bin.py create mode 100755 scripts/aur/render_mcpp_bin.py create mode 100644 scripts/release/generate_manifest.py create mode 100644 src/build/runtime_validation.cppm create mode 100644 src/platform/elf_runtime.cppm create mode 100644 src/platform/project_name.cppm create mode 100644 src/platform/runtime_binding.cppm create mode 100644 src/platform/runtime_env_contract.cppm create mode 100644 src/platform/scaffold_fs.cppm create mode 100644 src/scaffold/project_name.cppm create mode 100644 src/xlings/runtime_selection.cppm create mode 100755 tests/e2e/00_fixture_path_hygiene.sh create mode 100755 tests/e2e/203_exact_selector_lock_migration.sh create mode 100755 tests/e2e/204_new_transactional_scaffold.sh create mode 100755 tests/e2e/205_root_local_subos.sh create mode 100755 tests/e2e/206_runtime_binding_physics.sh create mode 100755 tests/e2e/207_runtime_contract_provenance.sh create mode 100755 tests/e2e/208_private_libc_stays_in_the_binary.sh create mode 100755 tests/e2e/209_runtime_verdict_never_reruns_green.sh create mode 100755 tests/e2e/210_local_index_addressing_on_every_host.sh create mode 100644 tests/e2e/_host_path.sh create mode 100644 tests/scripts/test_aur_reconcile.py create mode 100644 tests/scripts/test_release_manifest.py create mode 100644 tests/unit/test_elf_runtime.cpp create mode 100644 tests/unit/test_pm_bare_name_window.cpp create mode 100644 tests/unit/test_runtime_contract.cpp create mode 100644 tests/unit/test_runtime_selection.cpp create mode 100644 tests/unit/test_scaffold.cpp diff --git a/.agents/docs/2026-08-09-mcpp-template-runtime-graphics-aur-focused-design.md b/.agents/docs/2026-08-09-mcpp-template-runtime-graphics-aur-focused-design.md new file mode 100644 index 00000000..fc41f7ee --- /dev/null +++ b/.agents/docs/2026-08-09-mcpp-template-runtime-graphics-aur-focused-design.md @@ -0,0 +1,1034 @@ +# mcpp 模板、运行时、图形栈与 AUR 聚焦设计 + +> 状态:Review Draft +> +> 日期:2026-08-09 +> +> 基线:mcpp main@80291ca01a98 +> +> 上位分析:.agents/docs/2026-08-09-xlings-mcpp-ecosystem-convergence-design.md +> +> 本文只冻结四项产品设计,不代表实现、发布或 AUR 状态已经改变。 + +## 1. 已确认的四项产品决策 + +本文将以下内容视为已确认方向,不再把旧方案并列为推荐项: + +1. **模板 selector 与 mcpp add 保持同一风格** + - 稳定形式:ns.name@version:tname。 + - ns. 可省略;省略时使用 mcpp/mcpp.toml 的默认 namespace mcpplibs。 + - @version 可省略。 + - :tname 可省略。 + - 不引入 --variant。 +2. **xlings 拥有图形栈与运行时,mcpp 构建在该生态之上** + - xlings 负责图形 runtime/provider 的解析、安装、激活和生命周期。 + - xim-pkgindex 提供 Mesa、NVIDIA、WSL、Vulkan ICD/driver 等 package/provider recipe。 + - mcpp-index 声明 C++ 图形包到 xlings/xim 图形能力的依赖关系。 + - mcpp 只通过 mcpp-index 的包描述、模板和通用构建契约完成构建。 + - mcpp 不探测 GPU,不按厂商写构建分支。 +3. **运行时分为 mcpp 默认与项目显式 SubOS** + - 不配置时使用 mcpp 管理、发布验证过的默认运行时。 + - 项目可在 mcpp.toml[xlings] 中选择命名 SubOS。 + - SubOS 是根项目本地 build/run 环境,类似选择“在哪个 OS 开发”;它不是库的传递依赖,也不要求消费者使用同一 SubOS。 + - 多个项目可以并存于不同 SubOS/不同 glibc,缓存和产物按实际 RuntimeBinding 隔离。 +4. **AUR 自动同步聚焦 mcpp-bin** + - mcpp-bin 是唯一自动对账并纳入漂移告警的 AUR 包。 + - AUR 是 GitHub Release 的最终一致投影,AUR 故障不阻塞 GitHub Release 本身。 + - mcpp-m 本阶段完全不动:不修改 package 文件、不推送、不退役、不改变其现有状态。 + - mcpp-git 不进入主发布关键路径。 + +## 2. 目标、边界与成功定义 + +### 2.1 目标 + +- 用户只学习一种包 selector 风格;namespace 可省略,省略时默认 mcpplibs。 +- 默认用户无需理解 xlings/SubOS 即可获得稳定运行时;需要固定 ABI/环境的项目能声明 SubOS。 +- 图形包的新平台、驱动和 backend 由 xlings/xim 生态演进;mcpp-index 表达 C++ 依赖关系,不要求修改 mcpp 核心。 +- SubOS 只影响当前根项目本地构建/运行,不从库依赖向消费者传递。 +- AUR 临时故障恢复后,mcpp-bin 能自动收敛到最新稳定 GitHub Release。 +- 设计保持 CLI 简洁、内部身份明确、构建热路径无网络和 GPU 探测。 + +### 2.2 非目标 + +- 不在本轮增加 --variant#template 或新的顶层 template 命令。 +- 不让项目 mcpp.toml 写本机绝对 xlings binary/home。 +- 不在 mcpp 中实现 Mesa/NVIDIA/Vulkan/WSL 专用逻辑。 +- 不要求 mcpp-m 与每次 release 同步。 +- 不修改或重新定义 mcpp-m 的维护策略。 +- 不在本文中解决 C1–C9 的全部实现;只保留与这四项直接相关的契约。 +- 本轮不实现代码、不重跑 workflow、不推送 AUR。 + +### 2.3 成功定义 + +~~~text +mcpp new app --template ocornut.imgui@1.92.8:glfw-opengl3 + -> dotted selector 显式解析为 namespace=ocornut, name=imgui + -> 解析出完整 PackageId 与 exact version + -> 从 mcpp-index 获取模板/构建描述 + -> mcpp-index 声明 xlings/xim 图形依赖 + -> xlings 解析并物化图形运行时 + -> 使用 mcpp 默认 runtime 或 [xlings].subos + -> mcpp 只执行通用 build/link/run plan +~~~ + +发布侧: + +~~~text +latest stable GitHub Release + -> immutable release manifest + -> mcpp-bin generator and validation + -> idempotent AUR reconcile + -> remote git + AUR RPC + clean install verification +~~~ + +## 3. 方案比较 + +| 主题 | 方案 A | 方案 B | 方案 C | 选择 | +|---|---|---|---|---| +| 模板名 | --variant 独立参数 | #tname 新分隔符 | [ns.]name[@version][:tname] | **C**:与现有 CLI/TOML 风格一致 | +| runtime 配置 | 跟随全局 active SubOS | 新增 [runtime].provider | 默认 mcpp runtime;已有 [xlings].subos 覆盖 | **C**:字段最少、可复现 | +| graphics | mcpp 内置 GL/Vulkan/provider 逻辑 | mcpp 直接调用 xim 图形接口 | xlings 拥有图形 runtime;mcpp-index 声明 C++ 依赖 | **C**:职责单一 | +| AUR | 双包串行同步 | 三包 matrix 同步 | 只自动对账 mcpp-bin | **C**:主用户路径优先 | + +方案 C 的共同原则是:**用户表面复用已有概念,内部不复用含糊字符串;每层只拥有自己能证明的事实。** + +## 4. 总体架构 + +~~~mermaid +flowchart LR + U[mcpp.toml / CLI] --> M[mcpp generic resolver and builder] + M --> I[mcpp-index
package, template, build contract] + I --> X[xlings
graphics stack owner, install, SubOS] + X -->|reads provider recipes| XI[xim-pkgindex
provider recipes and sentinels] + X --> RC[resolved graphics and runtime contract] + RC --> M + M --> A[artifact] + + R[GitHub Release Manifest] --> AR[AUR mcpp-bin reconciler] + AR --> AB[AUR mcpp-bin] +~~~ + +核心依赖方向: + +- mcpp 可以理解 PackageSelector、PackageId、LinkIntent、RuntimeBinding 等通用结构。 +- mcpp 不理解 mesanvidiawslvulkan icd 的选择规则。 +- mcpp-index 声明图形包如何编译、链接,以及它依赖哪些 xlings 生态 package/capability。 +- xlings 负责解析、安装、激活和导出图形 runtime contract。 +- xim-pkgindex 是 xlings 消费的 provider/recipe 数据源,实现 capability 并记录 artifact/provenance。 +- AUR reconciler 不读取工作区临时状态,只读取不可变 release manifest。 + +## 5. 模板 selector 统一设计 + +### 5.1 用户语法 + +稳定文法: + +~~~text +TemplateSpec := PackageSelector [ "@" ExactVersion ] [ ":" TemplateName ] +PackageSelector := Name | NamespacePath "." Name +NamespacePath := Segment { "." Segment } +TemplateName := NameAtom +~~~ + +NamespacePath 可省略。省略时: + +~~~text +imgui -> namespace = mcpplibs, name = imgui +~~~ + +它不是全索引 short-name 搜索,也不是“跟随当前 index 的 namespace”;默认值固定为 mcpplibs,与 mcpp.toml 默认 dependency table 对齐。 + +分隔符唯一职责: + +| 分隔符 | 含义 | +|---|---| +| . | 与 mcpp add 相同的 dotted package selector | +| @ | exact package version | +| : | package 内的 template name | + +合法示例: + +| 输入 | package selector | version | template | +|---|---|---|---| +| imgui | mcpplibs.imgui(ns 省略) | 默认稳定版 | descriptor default | +| ocornut.imgui | ocornut.imgui | 默认稳定版 | descriptor default | +| ocornut.imgui@1.92.8 | ocornut.imgui | 1.92.8 | descriptor default | +| ocornut.imgui:glfw-opengl3 | ocornut.imgui | 默认稳定版 | glfw-opengl3 | +| ocornut.imgui@1.92.8:vulkan | ocornut.imgui | 1.92.8 | vulkan | +| mcpplibs.gui.templates@2.0.0:window | mcpplibs.gui.templates | 2.0.0 | window | + +非法示例: + +| 输入 | 错误 | +|---|---| +| ocornut.imgui@ | empty version | +| ocornut.imgui: | empty template name | +| .imgui@1.0 | empty selector segment | +| ocornut..imgui | empty namespace segment | +| ocornut.imgui@1.0:vulkan:extra | more than one template delimiter | + +pkg: 不再兼任列表语法。列举模板继续使用现有显式表面: + +~~~text +mcpp new --list-templates ocornut.imgui@1.92.8 +~~~ + +这样“省略 tname”永远表示选择 default,不会同时表示 list。 + +### 5.2 与 mcpp add / mcpp.toml 的一致性 + +模板不能只复制 mcpp add 的视觉风格,两个命令最终必须复用同一条规范化链: + +~~~text +raw selector + -> shared PackageSelector parser + -> fill default namespace mcpplibs when omitted + -> one normalized PackageId + -> IndexRoute::lookup_descriptor by exact PackageId + -> resolved PackageId + descriptor provenance +~~~ + +目标规则: + +- imgui(mcpplibs, imgui)。 +- acme.widget(acme, widget)。 +- mcpplibs.capi.lua(mcpplibs.capi, lua)。 +- 多级 selector 总是以最后一个 segment 为 name,其余为 namespace。 + +对应的 mcpp.toml 语义: + +~~~toml +[dependencies] +imgui = "1.92.8" # default namespace mcpplibs + +[dependencies.acme] +widget = "1.0.0" # explicit namespace acme +~~~ + +CLI dotted form acme.widget@1.0.0 是第二种 TOML 形式的紧凑输入,不再表示“先猜 mcpplibs.acme,再猜 acme”。 + +当前 mcpp add 的 ordered dotted candidates 属于兼容实现。为了真正一致,迁移后 add/template 都使用上述单一规范化规则;旧项目清单已解析并锁定的 dependency 不被自动重写。 + +selector 是用户输入,最终身份仍是结构化: + +~~~text +PackageSelector { + namespace?: NamespacePath, + name: NameAtom +} + +PackageId { + namespace: NamespacePath, + name: NameAtom +} + +ResolvedTemplatePackage { + id: PackageId, + version: ExactVersion, + indexRoute, + descriptorDigest, + payloadDigest, + root +} + +TemplateSelection { + package: ResolvedTemplatePackage, + templateName: NameAtom +} +~~~ + +PackageSelector 规范化时填入默认 namespace,所以进入 resolver 后不再存在“namespace 未知”状态。xlings 安装 wire address 仍由 resolved PackageId 派生为 namespace:name@version。点号只属于 mcpp 用户 selector;冒号 wire address 不反向塞回 --template。 + +### 5.3 解析顺序 + +为了避免 namespace、version 与 tname 相互抢分隔符,解析固定为: + +1. 验证最多一个 :,分离右侧 TemplateName。 +2. 在左侧验证最多一个 @,分离 ExactVersion。 +3. 将剩余部分完整交给共享 PackageSelector parser。 +4. resolver 返回 PackageId 后再进入版本和模板选择。 + +禁止当前 scaffold 的“先按短名试空 namespace/compat,命中后再反推 namespace”旁路。 + +### 5.4 version 省略规则 + +- 写出 @version 时只接受 exact version;第一阶段不引入 range。 +- 省略时选择目标平台可用的最新 stable version。 +- prerelease 不自动成为默认;只有用户显式写出 exact prerelease 才选择。 +- 解析成功后后续流程只携带 exact version。 +- 成功输出、wire result 与生成的自依赖都展示 exact version。 + +版本选择必须复用包管理 resolver,不能由模板代码排序字符串。 + +### 5.5 tname 省略规则 + +省略 :tname 时: + +1. 正好一个 template 声明 default = true:选择它。 +2. 没有显式 default,但 package 只有一个 template:该单模板自动成为 default。 +3. 没有显式 default,且存在多个 templates:失败并列出所有 template,要求用户写 :tname 或 provider 选出 default。 +4. 多于一个显式 default:descriptor/index validation 失败。 +5. package 没有 templates/:明确报告它不是 template provider。 + +单模板自动默认是当次已解析 provider/version 的确定事实;同一已锁版本不会因未来 release 新增模板而改变。新版本若增加第二个模板却未声明 default,省略 tname 会明确失败,不进行目录排序猜测。 + +### 5.6 生成与依赖注入 + +模板解析后必须保留完整 PackageId,解决当前 namespace 在 fetch 后丢失的问题。 + +RenderVars: + +~~~text +project.name +project.namespace +project.qualifiedName +template.package.namespace +template.package.name +template.package.selector +template.package.version +template.name +~~~ + +自依赖注入复用 mcpp add 的 manifest editor: + +- 不做字符串搜索。 +- 使用用户风格的 dotted selector key。 +- 写 exact resolved version。 +- 保留 namespace 与 index provenance 到 lock/build resolution。 +- 模板已经声明相同 PackageId 时不重复写入。 +- 同 short name、不同 namespace 不视为同一个 dependency。 + +### 5.7 输出与诊断 + +成功的人类输出: + +~~~text +Created app +Template ocornut.imgui@1.92.8:glfw-opengl3 +Runtime mcpp-default +~~~ + +机器输出至少包含: + +~~~json +{ + "packageSelector": "ocornut.imgui", + "resolvedPackage": { + "namespace": "ocornut", + "name": "imgui", + "version": "1.92.8" + }, + "template": "glfw-opengl3", + "runtimeSelection": "mcpp-default" +} +~~~ + +诊断规则: + +- package 不存在:展示规范化后的 exact PackageId;namespace 省略时明确注明使用了默认 mcpplibs。 +- version 不存在:列出该平台可用 stable versions。 +- template 不存在:列出 provider 内 template names 和 default。 +- 用户写 ocornut:imgui 时,按文法它表示 package ocornut 的 template imgui;若解析失败,诊断额外建议 namespace 风格 ocornut.imgui。 +- 所有失败发生在创建目标目录前,或由临时目录事务回滚。 + +### 5.8 兼容迁移 + +旧的无 namespace 形式天然兼容: + +~~~text +pkg +pkg@version +pkg:tname +pkg@version:tname +~~~ + +变化只有: + +- 新增 dotted namespace selector。 +- bare selector 明确填入默认 namespace mcpplibs。 +- mcpp add 与 template 最终收敛为“dot 表示显式 namespace、无 dot 表示默认 mcpplibs”,不再各自猜候选。 +- pkg: 的 legacy list 含义先 warning 一个 release train,之后错误;用户改用 --list-templates pkg。 +- 不再新增 --variant,上一份综合设计中的该建议由本文覆盖。 +- builtin bin/gui 可暂时保留 alias,但 package template 输出统一用 TemplateSpec。 + +当前 add 的 dotted selector 具有默认 namespace 前缀候选,例如 capi.lua 会先尝试 mcpplibs.capi:lua。新规则下: + +~~~text +capi.lua -> capi:lua +mcpplibs.capi.lua -> mcpplibs.capi:lua +lua -> mcpplibs:lua +~~~ + +迁移要求: + +1. 已有 mcpp.toml 与 lockfile 不自动改写,仍按其已记录身份工作。 +2. 一个 release train 检测“旧候选结果与新 exact 结果不同”,warning 同时给出两种完整 selector。 +3. 新 mcpp addmcpp new --template 在迁移窗口后统一采用 exact dotted 规则。 +4. mcpp-index 中所有 nested mcpplibs.* 文档示例改成完整 namespace,不能依赖隐式前缀。 + +## 6. mcpp 默认 runtime 与 mcpp.toml SubOS + +### 6.1 两种模式 + +只定义两种项目运行时选择: + +~~~text +McppDefault +NamedSubos(name) +~~~ + +不增加第三种“跟随当前全局 active subos”模式。 + +| mcpp.toml | 选择 | +|---|---| +| 没有 [xlings].subos | McppDefault | +| [xlings] subos = "dev" | NamedSubos("dev") | +| [xlings] subos = "default" | 显式选择 xlings home 中的 default SubOS | + +### 6.2 默认模式 + +McppDefault 的定义: + +- 使用 mcpp 所选择的 xlings provider。 +- 使用 mcpp home 内已初始化、release 验证过的 default SubOS/RuntimeBinding。 +- 不受用户另一个 shell 中执行 xlings subos use 的 active 状态影响。 +- 缺失时走 mcpp bootstrap,不从任意目录挑第一个 glibc。 +- xlings/runtime contract schema 不兼容时明确失败或升级,不静默回退宿主。 + +默认路径的用户体验是“安装 mcpp 后直接 build/run”,不要求用户了解 SubOS。 + +### 6.3 项目显式 SubOS + +复用当前已支持的配置: + +~~~toml +[xlings] +subos = "dev" +deps = ["cmake@3.28", "python@3.13"] + +[xlings.workspace] +clang = "20.1.7" + +[xlings.envs] +OPENBLAS_NUM_THREADS = "1" +~~~ + +语义: + +- subos 只选择当前根项目 build/run/test 共用的本地命名环境。 +- deps、workspace pins 与 envs 在该环境中物化。 +- Linux 上 RuntimeBinding 同时决定 loader/libc。 +- macOS/Windows 上仍选择一致的工具与环境契约,但不伪造 Linux glibc 语义。 +- 指定 SubOS 不存在或无法回答 runtime contract 时 hard error,不退回 default。 +- 同一台机器可同时保留 el8trixiedefault 等多个环境;每个项目按自己的 mcpp.toml 选择,互不切换全局 active 状态。 + +### 6.4 xlings binary/home 属于机器配置 + +项目清单必须可移植,所以不在 mcpp.toml 接受绝对 binary/home: + +~~~toml +# ~/.mcpp/config.toml +[xlings] +binary = "bundled" # or "system" / absolute administrator path +home = "" +~~~ + +职责分离: + +- 全局 config 决定“使用哪一个 xlings 实例和 home”。 +- 项目 mcpp.toml 决定“在该实例中使用 default 还是哪个 named SubOS”。 + +这样团队可以提交 subos = "el8",而不提交某位开发者的本地 XLINGS_HOME 路径。 + +### 6.5 选择优先级 + +稳定顺序: + +~~~text +project [xlings].subos exists + -> NamedSubos +otherwise + -> McppDefault +~~~ + +本设计不增加临时 --subos CLI override。runtime 是 build identity,命令行临时覆盖会让同一份 mcpp.toml 产生不同 ABI,并污染缓存解释。 + +global xlings binary/home 只决定 provider,不插入第三个 runtime selection rung。 + +### 6.6 一个 snapshot 贯穿生命周期 + +~~~text +RuntimeSelection + -> resolve exact SubOS + -> read RuntimeBinding contract + -> include contract hash in toolchain/build fingerprint + -> configure/link + -> post-link validation + -> run/test environment +~~~ + +build、run、test、post-install fixup 不得分别重新猜 runtime。 + +建议内部模型: + +~~~text +RuntimeSelection { + mode: McppDefault | NamedSubos, + subosName, + source: DefaultPolicy | Manifest +} + +RuntimeBinding { + schema, + providerId, + platform, + arch, + contractHash, + loader?, + libc?, + libraryDirs[], + environment[], + capabilities[], + provenance +} +~~~ + +### 6.7 [runtime] 与 [xlings] 不混用 + +现有 [runtime] 表示程序/包启动时需要的 library dirs、dlopen libs 和 capabilities;它不是“选择哪一个 SubOS”的配置。 + +~~~toml +[xlings] +subos = "dev" # 选择 build/run 环境 + +[runtime] +capabilities = ["opengl"] # 程序需要的通用 capability +~~~ + +选择环境属于 [xlings],声明程序需求属于 [runtime]。禁止再增加 [runtime] provider = "subos" 形成第二入口。 + +### 6.8 SubOS 是本地、根项目级、非传递环境 + +SubOS 的心智模型是: + +> mcpp 在一台机器上选择一个本地开发 OS 环境来 configure/build/run 当前项目。 + +它不是库依赖约束。规则如下: + +1. 只有本次构建的 root manifest/workspace root 能选择 SubOS。 +2. dependency manifest 中的 [xlings].subos 不合并、不继承、也不要求消费者创建同名 SubOS。 +3. 一个库作为独立根项目开发时,它自己的 [xlings].subos 生效;同一源码作为另一个项目的 dependency 时,使用消费者 root 选择的环境构建。 +4. workspace 整体构建时由 workspace root 选择一个环境;member 的 SubOS 不覆盖 root。member 独立构建时才成为自己的 root。 +5. SubOS name 不写入 dependency requirement,不从 lockfile 向下游传播,也不成为 mcpp-index package identity 的一部分。 + +典型源码分发: + +~~~text +application root selects subos=el8 + -> source dependency A builds inside el8 + -> source dependency B builds inside el8 + -> application runs inside el8 + +another application selects subos=trixie + -> the same A/B sources rebuild under trixie +~~~ + +因此可以在同一机器上用不同 glibc 环境开发同一组源码依赖,而不是要求所有库声明或传递 glibc=2.x。 + +间接关系只来自实际产物: + +- RuntimeBinding 必须进入当前项目 build fingerprint,防止跨 SubOS 复用 object/BMI。 +- 如果发布的是预构建 binary,发布流程应从最终 artifact 派生 ABI、loader、GLIBC symbol floor 等兼容元数据。 +- 这些是 artifact 的客观兼容属性,不是把开发时的 SubOS name 传播给消费者。 +- package 确实需要某项运行时能力时,通过 mcpp-index/xim package requirement 表达,不通过 [xlings].subos 表达。 + +### 6.9 当前 active SubOS 的迁移 + +当前未显式配置时可能读取 active SubOS。切换到稳定 default 的迁移: + +1. 一个 release train 输出 warning,显示当前 active 与未来 default 是否不同。 +2. 提供可复制配置:[xlings] subos = "current-name"。 +3. 下一 release 将 absence 固定为 McppDefault。 +4. 不自动修改用户 mcpp.toml。 + +冷 HOME、新安装可以直接采用新规则,不需要 legacy 过渡。 + +## 7. OpenGL/Vulkan 图形栈职责 + +### 7.1 强制边界 + +| 层 | 拥有 | 不拥有 | +|---|---|---| +| mcpp | 通用 PackageId、source/build graph、LinkIntent、RuntimeBinding 消费、产物验证 | OpenGL/Vulkan 包名、GPU vendor、ICD 选择、WSL 探测 | +| mcpp-index | ImGui/GLFW/OpenGL/Vulkan C++ 包、features、templates、平台 build/link 声明,以及这些包对 xlings 图形能力的依赖关系 | 安装宿主驱动、判断 NVIDIA/WSL 当前状态 | +| xlings | 图形栈 orchestration、依赖解析/安装/激活、SubOS、runtime contract、provider/sentinel 生命周期 | C++ GUI template 与项目源码 | +| xim-pkgindex | xlings 消费的 Mesa、Vulkan loader/ICD、NVIDIA/WSL sentinel recipe 与 provenance schema | 运行时自行做全局选择、mcpp 工程生成和 source graph | + +mcpp 源码中不新增以 openglvulkanmesanvidiawsl 为条件的 planner 分支。 + +### 7.2 数据流 + +~~~text +mcpp.toml dependency + -> mcpp resolves descriptor through mcpp-index + -> mcpp-index descriptor contributes sources, features, LinkIntent + and xlings graphics package/capability dependencies + -> xlings resolves and materializes the dependency closure + -> xlings activates providers/sentinels described by xim-pkgindex + -> xlings exports RuntimeArtifacts and provenance + -> mcpp consumes only generic resolved contract + -> link, validate and run +~~~ + +mcpp 不直接选择 xim:graphics。mcpp-index 的平台 package contract 声明 xlings 生态依赖;xlings 才是图形栈运行时 owner,负责从 xim-pkgindex recipe 中选择和物化具体 provider。 + +### 7.3 通用构建接口 + +mcpp-index 输出: + +~~~text +LinkIntent { + libraries[], + linkLibraryDirs[], + transitiveNeededDirs[], + runtimeSearchDirs[], + frameworks[], + deployFiles[] +} + +RuntimeRequirement { + kind: soname | capability | icd_manifest | display | host_service, + value, + phase: link | run, + requester: PackageId, + required +} +~~~ + +xlings 解析 xim-pkgindex provider recipe 后提供: + +~~~text +RuntimeArtifact { + role: loader | library | driver | manifest | host_bridge, + provider: PackageId, + path, + provenance: payload | subos_view | host_link | system_sdk, + abi, + digest?, + hostFingerprint? +} +~~~ + +mcpp 只检查这些通用结构是否完整、目标平台是否匹配、最终 artifact 是否满足 loader/ABI 物理约束。 + +### 7.4 OpenGL 收口 + +mcpp-index: + +- ocornut.imgui 声明 core 与 backend features。 +- glfw-opengl3 template/feature 引入 GLFW、OpenGL headers 与通用 runtime requirement。 +- Linux package contract 依赖由 xim 提供的 graphics capability。 +- macOS contract 使用 native frameworks。 +- Windows contract 使用 Win32/system SDK 与声明的 runtime DLL。 + +xlings(基于 xim-pkgindex recipes): + +- Mesa/GLVND 与软件/硬件 driver closure。 +- NVIDIA/WSL host-link sentinel。 +- runtime dirs、实际 libraries 和 provenance 的解析/导出。 +- sentinel 的 applicable/not-applicable/inconclusive 生命周期。 + +目标状态是移除 mcpp-index 中复制 SubOS view library 的长期 symlink bridge;runtime artifact 通过 contract 传递。 + +### 7.5 Vulkan 收口 + +mcpp-index: + +- Vulkan headers、loader-facing API、backend sources 和 templates。 +- vulkan template 声明 loader、ICD manifest、display/surface requirements。 +- 不从 /usr/lib* 自行收集宿主 ICD/DSOs。 + +xlings(基于 xim-pkgindex recipes): + +- Vulkan loader、ICD manifests 与对应 driver。 +- Mesa RADV/Intel 等 payload provider。 +- NVIDIA/WSL host-link provider。 +- macOS MoltenVK、Windows system/provider 策略由平台包契约表达。 + +验证不能止于 loader symbol: + +1. create instance。 +2. enumerate physical devices。 +3. 记录实际 ICD manifest 与 driver provider。 +4. 有窗口 lane 时创建 surface。 +5. 无硬件环境明确报告 NOT_EXERCISED,不能汇总为 GPU pass。 + +### 7.6 ImGui feature/template 形态 + +建议 mcpp-index 将图形入口拆成: + +~~~text +core +headless +backend-glfw-opengl3 +backend-vulkan +app +docking +viewports +~~~ + +示例: + +~~~text +mcpp new app --template ocornut.imgui:glfw-opengl3 +mcpp new app --template ocornut.imgui@1.92.8:vulkan +mcpp new app --template ocornut.imgui@1.92.8:docking +~~~ + +- core/headless 不应无条件拉入 GLFW/OpenGL。 +- app 可以组合默认 backend,但 resolved dependency/features 必须写入生成清单。 +- docking/viewports 与 renderer backend 正交。 + +### 7.7 可观察性 + +mcpp why runtime 可以展示 xlings 已解析完成的结果: + +~~~text +requirement + -> selected canonical provider + -> runtime artifact + -> provenance + -> ABI/loader verdict +~~~ + +它是通用 contract 的解释器,不进行 GPU 探测。GPU/driver 诊断与重探测由 xlings 统一入口及其 xim provider/sentinel 暴露,mcpp 只给出跳转提示。 + +图形依赖和 SubOS 选择是正交的: + +- mcpp-index/xim 依赖描述“当前项目需要哪些图形能力”。 +- root [xlings].subos 描述“当前项目在哪个本地环境 build/run”。 +- library dependency 不通过自己的 SubOS 要求消费者环境;源码会在消费者已选环境中构建。 +- 某个图形 provider 对实际 ABI/glibc 的要求,由 xlings 在当前环境解析并验证,而不是把 provider 的 SubOS name 传播出去。 + +## 8. AUR 只自动维护 mcpp-bin + +### 8.1 包策略 + +| AUR 包 | 策略 | +|---|---| +| mcpp-bin | 自动生成、验证、对账和漂移告警;不阻塞 GitHub Release | +| mcpp-m | 本阶段不更新;不修改 package 文件或 AUR remote,只从 mcpp-bin 自动对账路径隔离 | +| mcpp-git | 不进入 release workflow;如要发布,单独认领与设计 | + +用户主路径是安装 release 预构建产物,因此自动化可靠性集中投入 mcpp-bin。 + +### 8.2 当前事实 + +2026-08-09 的审计快照: + +- GitHub latest stable:v2026.8.8.4。 +- AUR mcpp-bin2026.8.1.1-1。 +- 连续 push 失败的直接响应是 AUR maintenance。 +- workflow 缺少 retry、schedule 与状态对账,所以临时故障变成长时间漂移。 +- mcpp-m 存在已知独立问题,但本文不处理、不推送,也不让它成为 mcpp-bin reconciler 的前置条件或后置条件。 + +旧失败 run 不应直接 rerun,因为它从旧 release commit 执行旧生成逻辑。 + +### 8.3 单一 desired state + +release 产出不可变 manifest: + +~~~json +{ + "schema": 1, + "version": "2026.8.8.4", + "tag": "v2026.8.8.4", + "commit": "", + "assets": [ + { + "platform": "linux", + "arch": "x86_64", + "name": "mcpp-2026.8.8.4-linux-x86_64.tar.gz", + "sha256": "" + }, + { + "platform": "linux", + "arch": "aarch64", + "name": "mcpp-2026.8.8.4-linux-aarch64.tar.gz", + "sha256": "" + } + ] +} +~~~ + +reconciler 只消费 latest complete、非 draft、非 prerelease manifest。 + +### 8.4 mcpp-bin reconciler + +触发: + +- successful release workflow_run:低延迟。 +- schedule:建议每 6 小时。 +- workflow_dispatch:人工恢复,默认仍指向 latest stable。 + +流程: + +~~~text +read latest stable release manifest + -> query AUR mcpp-bin version and remote git head + -> compare with Arch vercmp + -> download both Linux assets + -> recompute sha256 and compare manifest/sidecar + -> generate PKGBUILD + -> generate .SRCINFO from PKGBUILD in Arch container + -> makepkg --verifysource + -> dry-run diff + -> fast-forward push with bounded retry + -> verify AUR git head + -> bounded poll AUR RPC + -> clean Arch install and mcpp --version smoke +~~~ + +### 8.5 幂等和失败分类 + +| 状态 | 行为 | +|---|---| +| desired == current 且内容一致 | success no-op | +| desired > current | 生成、验证、push | +| desired < current | 默认拒绝降级 | +| asset/hash 缺失 | push 前 permanent failure | +| AUR maintenance/timeout | transient;指数退避,后续 schedule 补偿 | +| SSH auth/metadata invalid | permanent;立即告警 | +| git 已更新但 RPC 延迟 | poll,不重复 commit | + +已知 package clone 失败不能自动当作首次发布。禁止 force-push AUR 历史。 + +### 8.6 mcpp-m 完全不动的边界 + +本文对 mcpp-m 的要求只有隔离,不包含任何维护动作: + +- 不修改 scripts/aur/mcpp-m/**。 +- 不生成或推送 mcpp-m AUR commit。 +- 不改变其版本、checksum、maintainer、deprecated 状态或现有远端历史。 +- 不宣称它与 GitHub Release 同步。 +- mcpp-bin reconciler 的成功/失败只由 mcpp-bin desired/observed state 决定。 + +实现 mcpp-bin reconciler 时应建立独立 job/workflow;现有 combined workflow 唯一允许涉及 mcpp-m 的变化是停止自动调用其 publish leg,以保证“不推送 mcpp-m”。不得读取、生成或改写 mcpp-m 内容。 + +### 8.7 AUR SLO + +建议初始目标: + +- AUR 可用时,release 后 30 分钟内 mcpp-bin 收敛。 +- event path 失败后,6 小时 schedule 再尝试。 +- 超过 24 小时仍漂移,自动更新固定告警 issue 或发高优先级通知。 + +## 9. 四项设计如何协同 + +### 9.1 默认用户 + +~~~text +install mcpp-bin + -> bundled xlings/bootstrap default runtime + -> mcpp new app --template ocornut.imgui:glfw-opengl3 + -> mcpp-index supplies template/build contract + -> xlings/xim supplies graphics runtime + -> mcpp build + -> mcpp run using the same default RuntimeBinding +~~~ + +用户不需要手工选择 GPU provider,也不需要配置 SubOS。 + +### 9.2 固定运行时用户 + +~~~toml +[xlings] +subos = "el8" +~~~ + +~~~text +mcpp new/build/run + -> same template/package selector + -> same mcpp-index graph + -> named SubOS RuntimeBinding + -> cache fingerprint changes + -> artifact targets that runtime +~~~ + +### 9.3 平台差异 + +| 平台 | mcpp default runtime | graphics provider | +|---|---|---| +| Linux x86_64 | mcpp-managed default SubOS/runtime binding | xim Mesa or explicit host sentinel | +| Linux aarch64 | mcpp-managed native runtime | capability must fail clearly until graphics recipe supports it | +| macOS arm64/x86_64 | mcpp-managed tool/SDK environment | native frameworks/MoltenVK package contract | +| Windows x64 | mcpp-managed tool/runtime environment | Win32/system DLL or declared Vulkan provider | + +跨平台只共享抽象契约,不假装共享底层文件布局。 + +## 10. 迁移阶段 + +### Stage 0 — 文法与边界冻结 + +- 冻结 TemplateSpec grammar:namespace/version/tname 均可省略,namespace 默认 mcpplibs。 +- 冻结“单模板在没有显式 default 时自动成为 default”。 +- 冻结 absence → McppDefault、[xlings].subos → NamedSubos。 +- 冻结 SubOS 是 root-local、非传递 build/run 环境。 +- 冻结 xlings 是 graphics runtime owner,mcpp 只有 generic contract knowledge。 +- 冻结 AUR managed set = {mcpp-bin}。 +- 冻结 mcpp-m package/AUR remote 不动。 + +### Stage 1 — 模板统一 + +- 提取共享 PackageSelector parser/resolver。 +- 将 add/template 统一为 bare → mcpplibs、dotted → exact namespace。 +- scaffold 保存完整 PackageId/provenance。 +- manifest 注入复用 add editor。 +- dotted namespace/template E2E。 +- pkg: list deprecation。 + +### Stage 2 — runtime 选择统一 + +- 引入 RuntimeSelection。 +- default 与 named SubOS 生成同一 RuntimeBinding snapshot。 +- build/run/test 共用 snapshot 和 contract hash。 +- 只从 root/workspace root 读取 SubOS;dependency/member 不向消费者传播。 +- active SubOS legacy warning。 + +### Stage 3 — graphics 收口 + +- mcpp-index 调整 ImGui/OpenGL/Vulkan features/templates 及 xlings 生态依赖。 +- xlings 基于 xim-pkgindex recipes 收口 GL/Vulkan runtime artifacts 和 sentinels。 +- 移除 host ICD farm 与 SubOS view symlink bridge。 +- mcpp 保持 generic planner/validator。 + +### Stage 4 — mcpp-bin 对账 + +- release manifest。 +- 单包 generator、dry-run 与 Arch validation。 +- event + schedule reconciler。 +- 从旧状态恢复到 latest stable,并验证真实安装。 + +这些 stage 是设计级依赖顺序,不是 implementation plan;每个 stage 仍需在设计获批后拆成独立可评审 PR。 + +## 11. 验收矩阵 + +### 11.1 模板 + +| 场景 | 期望 | +|---|---| +| pkg | mcpplibs:pkg + latest stable + default/single template | +| pkg@1.2.0 | default namespace + exact version + default/single template | +| ns.pkg:t | exact namespace ns + named template | +| ns.pkg@1.2.0:t | 完整解析,输出 exact PackageId/version/template | +| capi.lua / mcpplibs.capi.lua | 分别解析为 capi:lua / mcpplibs.capi:lua | +| add/template 相同 selector | 规范化为同一个 PackageId | +| 同 short name 不同 namespace | 不按 short name 注入/缓存碰撞 | +| 单 template、无显式 default | 自动选择该 template | +| 多 templates、无 default | 失败并列出 templates | +| 多 default | provider validation 失败 | +| prerelease only 且省略版本 | 失败并要求显式 version | +| 失败渲染/I/O | 目标目录不存在或事务回滚 | + +### 11.2 runtime + +| 场景 | 期望 | +|---|---| +| fresh HOME,无 [xlings] | bootstrap mcpp default,build/run 同 binding | +| named SubOS 存在 | build/run/test 使用该 contract | +| named SubOS 不存在 | hard error,不退回 default | +| active SubOS 与 default 不同 | 迁移期 warning;最终仍选择 default | +| 切换 subos | build fingerprint 改变,不复用旧 ABI objects | +| dependency 声明自己的 SubOS | 作为 dependency 时不传递、不覆盖 root 环境 | +| 同一源码在 el8/trixie 两个 root 中构建 | 各自在自己的 RuntimeBinding 下生成独立缓存/产物 | +| 预构建库 | 传递实际 artifact ABI requirement,不传递 SubOS name | +| Linux loader/libc 混源 | post-link 失败或严格迁移阶段明确 warning | +| macOS/Windows | 不执行 Linux glibc 规则 | + +### 11.3 graphics + +| Gate | 必须证明 | +|---|---| +| mcpp source ownership | 无 GPU vendor/ICD selection branch,只消费 xlings generic contract | +| xlings ownership | 根据 mcpp-index 依赖解析 xim recipes、provider/sentinel 与 provenance | +| mcpp-index static | OpenGL/Vulkan features、templates、platform dependencies 可解析 | +| Linux software GL | Xvfb/Wayland headless + llvmpipe 创建窗口/帧 | +| Linux native GL | AMD/Intel/NVIDIA/WSL 分别记录实际 provider | +| Vulkan | instance、physical device、ICD manifest/driver provenance | +| macOS | native framework/MoltenVK 路径 | +| Windows | Win32 backend 与 Vulkan provider 路径 | +| unsupported arch | 明确 capability error,不以 SKIP 计 pass | + +### 11.4 AUR + +| 场景 | 期望 | +|---|---| +| 新 stable release | mcpp-bin 在 SLO 内收敛 | +| 重复事件 | no-op,无新 commit | +| 迟到旧事件 | 不降级 | +| 两架构任一资产/hash 错 | push 前失败 | +| AUR maintenance | transient retry + schedule 补偿 | +| mcpp-m | package 文件和 AUR remote 均不变,不参与 mcpp-bin verdict | +| push 后 | git head、RPC version、clean install 全部验证 | + +## 12. 性能与简洁性约束 + +- TemplateSpec parse 为 O(length),不访问网络。 +- resolver 与 mcpp add 共用缓存与 IndexRoute,不增加第二轮全索引扫描。 +- RuntimeBinding 每次 configure 解析一次,contract hash 进入 snapshot;hot no-op 不启动 xlings。 +- mcpp build 热路径不探测 GPU、不执行 Vulkan/OpenGL 工具。 +- graphics provider 探测发生在 xim install/doctor 生命周期,并缓存 host fingerprint。 +- AUR schedule 在 desired == current 时只做轻量查询与一致性检查。 +- 不因这四项新增顶层 CLI 命令。 + +## 13. 错误与安全边界 + +- selector、version、template name 任一非法,在网络/install/目录创建前失败。 +- package descriptor 命中后必须校验声明的完整身份。 +- 模板为纯数据,不执行 provider hooks/scripts。 +- 生成在 sibling temp dir 完成,验证后原子 rename。 +- manifest 不接收项目级 absolute xlings binary/home。 +- named SubOS 不存在时不 fallback。 +- mcpp 不读取 host GPU library 目录来补齐索引缺口。 +- AUR 先验证全部 mcpp-bin assets,再加载 SSH secret/push。 +- AUR 只 fast-forward,不改写历史。 + +## 14. 第二轮 review 后已冻结的细节 + +1. namespace 可以省略;省略固定为 mcpplibs,写出 dotted namespace 时按 exact namespace 解释。 +2. 只有一个 template 且没有 default = true 时,该单模板自动成为 default。 +3. SubOS 暂时只允许通过 root/workspace-root mcpp.toml 选择,不增加 CLI override。 +4. SubOS 是本地 build/run 开发环境,不作为库的传递要求;不同项目可选择不同 SubOS/glibc。 +5. xlings 是 graphics stack/runtime owner;mcpp-index 声明 C++ 图形包到 xlings 生态的依赖,mcpp 只做 generic build。 +6. AUR 自动对账只面向 mcpp-bin;mcpp-m package 与 AUR remote 本阶段不动。 + +## 15. 与上一份综合设计的关系 + +本文仅在以下四处覆盖上位文档: + +| 上位文档建议 | 本文最终方向 | +|---|---| +| --template namespace:name@version --variant name | --template [ns.]name[@version][:tname];默认 ns=mcpplibs | +| mcpp 参与更宽的 graphics runtime 规划 | xlings 负责 graphics runtime;mcpp-index 声明依赖;mcpp 只消费通用契约 | +| runtime contract 为大范围跨仓主线 | McppDefault + existing [xlings].subos,且 SubOS root-local/non-transitive | +| mcpp-bin/mcpp-m 都进入自动 reconciler | 自动 reconciler 只管理 mcpp-bin;mcpp-m 不动 | + +未被本文覆盖的 C1–C9、artifact physics、identity 类型化与机器输出分析继续保留在上位文档中。 + +## 16. 当前实现证据锚点 + +| 主题 | 证据 | +|---|---| +| mcpp add dotted/colon selector | tests/e2e/12_add_command.sh:82-106src/pm/commands.cppm:75-143 | +| shared dotted candidate rules | src/pm/dependency_selector.cppm:78-120 | +| package identity spec | docs/spec/package-identity.md:150-169 | +| current TemplateSpec | src/scaffold/template.cppm:22-47 | +| scaffold short-name loss | src/scaffold/create.cppm:30-129 | +| template E2E | tests/e2e/69_package_templates.sh:94-194 | +| existing [xlings].subos | src/manifest/types.cppm:469-485src/manifest/toml.cppm:988-996 | +| root project environment materialization | src/build/prepare.cppm:1997-2009 | +| current runtime resolution | src/build/prepare.cppm:928-974 | +| user documentation | docs/05-mcpp-toml.md:946-978 | +| AUR workflow | .github/workflows/aur-publish.yml:14-102 | + +本文是待 review 设计,不应据此宣称上述行为已落地或关闭相关 issue。 diff --git a/.agents/docs/2026-08-09-mcpp-template-runtime-graphics-aur-implementation-plan.md b/.agents/docs/2026-08-09-mcpp-template-runtime-graphics-aur-implementation-plan.md new file mode 100644 index 00000000..cbeb4007 --- /dev/null +++ b/.agents/docs/2026-08-09-mcpp-template-runtime-graphics-aur-implementation-plan.md @@ -0,0 +1,408 @@ +# mcpp Template, Runtime, Graphics, and AUR Convergence Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在一张 mcpp Draft PR 中落地已冻结的模板 selector、项目级 RuntimeSelection/RuntimeBinding、provider-neutral 图形运行时契约与仅管理 mcpp-bin 的 AUR reconciler;随后完成 review-gated 普通合入、release、mcpp-index/GitCode 对接和隔离环境下的 xlings 全生态验证。 + +**Architecture:** CLI 和 mcpp.toml 共用一个 exact dotted PackageSelector;scaffold 在任何落盘前解析出完整 PackageId/version/template,并在 sibling 临时目录事务生成。运行时只允许 McppDefault 或 root/workspace-root NamedSubos,两者解析成一次性 RuntimeBinding snapshot,进入构建指纹并由 build/run/test 复用,dependency/member 的 SubOS 不传递。mcpp-index 声明图形 RuntimeRequirement,xlings/xim 解析 provider,mcpp 仅记录 canonical requester/provider/artifact provenance 和执行平台通用链接/运行验证。GitHub Release manifest 是 AUR desired state,mcpp-bin reconciler 单调、幂等、校验实物并只做 fast-forward push。 + +**Tech Stack:** C++23 modules, mcpplibs.cmdline, nlohmann/json, TOML manifest parser, Bash/Python 3 release tooling, GitHub Actions, Arch makepkg, gh/git, xlings/xim/mcpp-index. + +## Global Constraints + +- [ ] 所有行为改动遵循 RED → GREEN → refactor;每个 RED 命令和预期失败原因写入提交/验证台账。 +- [ ] mcpp 实现只使用一张 PR,基于最新 `origin/main`;不直接 push main,不 force-push,不 amend/rebase 历史。 +- [ ] 所有改动先形成可审阅的独立 commit,立即 push 到 Draft PR,并追加验证/checkpoint 评论;不在提交、PR 或日志摘录中记录本地用户名、绝对工作区路径或私有信息。 +- [ ] Draft PR 经用户 review 后才可转 ready;不使用 admin/bypass,只有最新 PR HEAD 的 required checks 全部终态成功后才进行普通 merge。 +- [ ] `mcpp-m` 边界为字节级不变:不修改 `scripts/aur/mcpp-m/**`,不读取/生成/发布其内容,不访问其 AUR remote。 +- [ ] 不增加 `--variant`、`--subos` 或其他顶层 CLI;不在 mcpp 中出现 GPU vendor、Mesa、NVIDIA、WSL 或 ICD 选择分支。 +- [ ] SubOS 只来自本次构建 root/workspace root 的 `mcpp.toml`,不写入 dependency requirement/lock identity,不从 dependency/member 继承。 +- [ ] 平台物理规则放入 `src/platform/` 或已有平台模块;Linux ELF/glibc 校验在 macOS/Windows 明确 no-op,不用 Linux 推断替代原生 CI。 +- [ ] 所有 stateful 本地/生态验证使用隔离 `HOME`、`MCPP_HOME`、`XLINGS_HOME` 和 SubOS root;验证前后确认宿主状态未变化。 +- [ ] 只显式 stage 本计划列出的文件;保留主 checkout 中用户的未跟踪 issue triage 文档。 +- [ ] GitHub release 是主发布真源;AUR 暂时不可用时 GitHub release 不回滚,reconciler 留下可重试的精确失败分类。 + +--- + +## Task 1: Freeze Baseline, Issue, Branch, and Test Evidence + +**Files:** + +- Modify: `.agents/docs/2026-08-09-mcpp-template-runtime-graphics-aur-implementation-plan.md` +- Add: `.agents/docs/2026-08-09-mcpp-template-runtime-graphics-aur-validation.md` +- Preserve: `.agents/docs/2026-08-09-mcpp-template-runtime-graphics-aur-focused-design.md` +- Preserve: `.agents/docs/2026-08-09-xlings-mcpp-ecosystem-convergence-design.md` + +- [x] Create focused issue #398 referencing #397, #380, #392, and #396. +- [x] Create isolated worktree `feat/template-runtime-graphics-aur` from `origin/main@80291ca`. +- [x] Record baseline versions, latest releases, open PRs, and exact hashes for mcpp, xlings, mcpp-index, and xim-pkgindex in the validation ledger. +- [x] Run baseline `mcpp build`, unit suite, focused scaffold E2E, runtime E2E, AUR script tests (if any), and `git diff --check`; record any pre-existing failure without weakening later gates. +- [x] Snapshot hashes of `scripts/aur/mcpp-m/**` and the host xlings configuration for final boundary comparison. +- [x] Commit the approved design documents, implementation plan, and baseline validation ledger as the first explicit-files commit. + +## Task 2: Replace Candidate Guessing with One Exact PackageSelector + +**Files:** + +- Modify: `src/pm/dependency_selector.cppm` +- Modify: `src/pm/commands.cppm` +- Modify: `src/manifest/toml.cppm` +- Modify: `src/manifest/xpkg.cppm` +- Modify: `src/pm/dep_spec.cppm` +- Modify: `src/pm/index_route.cppm` +- Modify: `src/build/prepare.cppm` +- Modify: `src/cli.cppm` +- Modify: `tests/unit/test_pm_compat.cpp` +- Modify: `tests/unit/test_pm_index_route.cpp` +- Modify: `tests/unit/test_manifest.cpp` +- Modify: `tests/unit/test_pm_package_fetcher.cpp` +- Modify: `tests/e2e/12_add_command.sh` +- Modify: `tests/e2e/27_namespace_dependencies.sh` +- Modify: `tests/e2e/62_dotted_dependency_selector_priority.sh` +- Modify: `tests/e2e/63_bare_dependency_peer_root_priority.sh` +- Modify: `tests/e2e/78_test_main_combinations.sh` +- Modify: `tests/e2e/79_gtest_regular_dep_feature_main.sh` +- Modify: `tests/e2e/162_bare_name_namespace_scope.sh` +- Modify: `tests/e2e/165_bare_name_cross_namespace_wire_address.sh` +- Add: `tests/e2e/203_exact_selector_lock_migration.sh` +- Modify: `docs/05-mcpp-toml.md` +- Modify: `docs/zh/05-mcpp-toml.md` +- Modify: `docs/06-workspace.md` +- Modify: `docs/zh/06-workspace.md` +- Modify: `docs/spec/package-identity.md` + +**Interfaces:** + +- `PackageSelector { optional namespace; NameAtom name; string spelling; }` +- `parse_package_selector(string_view) -> expected` +- `normalize_package_selector(PackageSelector, defaultNs="mcpplibs") -> DependencyCoordinate` +- `format_package_selector(DependencyCoordinate) -> dotted selector` + +- [x] RED: add unit rows proving `lua -> (mcpplibs,lua)`, `capi.lua -> (capi,lua)`, `mcpplibs.capi.lua -> (mcpplibs.capi,lua)`, and rejection of empty/double-dot/control segments. +- [x] RED: update add E2E so `mcpp add capi.lua@5.4.7` probes/writes exact `capi:lua`, not `mcpplibs.capi:lua`; prove a same-short-name sibling cannot win. +- [x] Implement the O(length), no-I/O shared parser/normalizer and return one exact coordinate after default namespace filling. +- [x] Route `mcpp add`, dependency TOML parsing, feature dependency parsing, and xpkg dependency parsing through the same normalized coordinate. +- [x] Preserve already-parsed/locked identities; during the migration release, emit a warning only when an old dotted candidate exists and differs from the new exact coordinate, with both copyable selectors. +- [x] Update existence-gate and not-found diagnostics to show the normalized PackageId and explicitly mention default `mcpplibs` when namespace was omitted. +- [x] GREEN: run the focused unit and namespace/add E2E tests, then `git diff --check`. +- [x] Commit exact selector normalization and identity documentation. + +## Task 3: Make TemplateSpec Typed, Namespace-Aware, and Deterministic + +**Files:** + +- Modify: `src/scaffold/template.cppm` +- Modify: `src/scaffold/create.cppm` +- Modify: `src/cli/cmd_new.cppm` +- Modify: `src/cli.cppm` +- Add: `tests/unit/test_scaffold.cpp` +- Modify: `tests/e2e/69_package_templates.sh` +- Modify: `.github/workflows/ci-fresh-install.yml` + +**Interfaces:** + +- `TemplateSpec { PackageSelector package; optional version; optional templateName; bool legacyList; }` +- `parse_template_spec(string_view) -> expected` +- `ResolvedTemplatePackage { DependencyCoordinate id; string version; string indexRoute; string descriptorDigest; string payloadDigest; path root; }` +- `TemplateSelection { ResolvedTemplatePackage package; string templateName; }` + +- [x] RED: unit-test every valid and invalid grammar row from focused design §5.1, including multiple `:`/`@`, empty components, exact dotted namespaces, and `pkg:` legacy list recognition. +- [x] RED: add E2E packages with `mcpplibs:widget`, `acme:widget`, and `mcpplibs.capi:lua`; prove template resolution uses the exact PackageId through `IndexRoute::lookup_descriptor`. +- [x] Implement TemplateSpec parsing in the fixed order template delimiter → version delimiter → shared PackageSelector; reject before config/network access. +- [x] Replace scaffold short-name/compat probing with `IndexRoute` exact lookup and keep canonical namespace, version, route, descriptor digest, payload digest, and root through fetch/render/output. +- [x] Reuse package-manager semver resolution for latest stable; omitted versions must not select prereleases, while explicit exact prerelease remains allowed. +- [x] RED: test default selection rules: one explicit default wins; one sole non-default auto-wins; multiple without default list choices and fail; multiple defaults fail validation; no templates directory reports provider error. +- [x] Implement the default rules and `pkg:` one-release warning pointing to `--list-templates`. +- [ ] Update human and machine output to include canonical selector, resolved namespace/name/version, template, and runtime selection. +- [ ] GREEN: run new unit tests, template E2E, and three-platform fresh-install template lanes. +- [x] Commit typed TemplateSpec and exact template-package resolution. + +## Task 4: Make Scaffolding Safe and Transactional (#380) + +**Files:** + +- Add: `src/scaffold/project_name.cppm` +- Add: `src/platform/project_name.cppm` +- Add: `src/platform/scaffold_fs.cppm` +- Modify: `src/scaffold/template.cppm` +- Modify: `src/scaffold/create.cppm` +- Modify: `src/manifest/toml.cppm` +- Modify: `src/pm/commands.cppm` +- Modify: `tests/unit/test_scaffold.cpp` +- Modify: `tests/unit/test_manifest.cpp` +- Modify: `tests/e2e/69_package_templates.sh` +- Add: `tests/e2e/204_new_transactional_scaffold.sh` + +**Interfaces:** + +- `validate_project_name(string_view) -> expected` +- `render_tokens(string_view, RenderVars) -> expected` +- `ScaffoldTransaction::begin(parent,name)`, `commit()`, destructor rollback. + +- [x] RED: reject empty, absolute, separators, `.`, `..`, C0/DEL, Windows reserved device names, trailing dot/space, quote/tab and names containing the legacy `PROJECT` marker before target creation. +- [x] RED: prove inserted values containing placeholder-like text are not rescanned and all RenderVars render canonical project/template identities. +- [x] RED: inject read/write/copy/rename failures and assert neither final target nor sibling temporary directory remains. +- [x] Implement shared portable project-name validation with platform-specific reserved-name rules isolated under `src/platform/project_name.cppm`. +- [x] Replace repeated string substitution with a single-pass token renderer that never rescans inserted values. +- [x] Extend RenderVars with project namespace/qualified name and template package namespace/name/selector/version/template. +- [x] Replace substring dependency detection with structured manifest editing keyed by canonical PackageId; exact resolved version and features must be idempotent across same short names in different namespaces. +- [x] Generate builtin and package templates in a same-parent temporary directory, check every filesystem/stream operation, fsync/close as supported, and atomically rename only after validation. +- [x] GREEN: run scaffold unit/E2E tests, reproduce #380 cases with timeouts, and confirm no path escape or partial output. +- [ ] Commit transactional scaffold and close #380 from the final PR only after CI. + +## Task 5: Introduce Root-Local RuntimeSelection and One RuntimeBinding Snapshot + +**Files:** + +- Add: `src/xlings/runtime_selection.cppm` +- Add: `src/platform/runtime_binding.cppm` +- Modify: `src/build/prepare.cppm` +- Modify: `src/build/plan.cppm` +- Modify: `src/build/execute.cppm` +- Modify: `src/xlings/subos_info.cppm` +- Modify: `src/toolchain/model.cppm` +- Modify: `src/toolchain/detect.cppm` +- Modify: `src/toolchain/fingerprint.cppm` +- Inspect (no change required; policy is owned by `runtime_selection`): `src/project.cppm` +- Add: `tests/unit/test_runtime_selection.cpp` +- Modify: `tests/unit/test_subos_info.cpp` +- Modify: `tests/unit/test_fingerprint.cpp` +- Add: `tests/e2e/205_root_local_subos.sh` + +**Interfaces:** + +- `RuntimeSelection { enum Mode { McppDefault, NamedSubos }; string subosName; Source source; path ownerRoot; }` +- `RuntimeBinding { int schema; string providerId; string platform; string arch; string contractHash; optional loader; optional libc; vector libraryDirs; vector environment; vector capabilities; string provenance; }` +- `select_runtime(rootManifest, optional workspaceManifest, rootPath) -> expected` +- `resolve_runtime_binding(selection, compiler, GlobalConfig) -> expected` + +- [x] RED: no `[xlings].subos` selects `McppDefault` even if active SubOS differs; an explicit `subos = "default"` is a NamedSubos selection. +- [x] RED: missing named SubOS is a hard error and cannot fall back to default/active/compiler-baked runtime. +- [x] RED: workspace root SubOS overrides member declaration during workspace build; a member declaration applies only when that member is built independently. +- [x] RED: dependency manifest SubOS never merges into the consumer, lockfile, or cache identity; the same sources under el8/trixie produce distinct RuntimeBinding contract hashes. +- [x] Implement selection before workspace member substitution and preserve its owner root; materialize project xlings config from the root/workspace-root selection only. +- [x] Define McppDefault as the mcpp-managed default SubOS/runtime in configured xlings home, independent of xlings active/current symlink; bootstrap it when absent. +- [x] Read one RuntimeBinding snapshot, canonicalize/sort its fields, compute contractHash, store it in BuildContext/BuildPlan/cache metadata, and feed it to toolchain detection/fingerprint. +- [x] Reuse that snapshot for run/test environment; remove build/run fast-path re-reads of active SubOS. +- [x] Match current xlings `op=set` presence semantics: preserve any ambient value, including an explicitly empty value, while `prepend` remains ordered and de-duplicated. +- [x] On macOS/Windows return platform-native bindings without invented glibc/ELF fields; platform-specific derivation stays in `src/platform/runtime_binding.cppm`. +- [x] GREEN: run unit tests, named/default/workspace/dependency SubOS E2E, and compare binding/fingerprint output. +- [x] Commit root-local runtime selection and shared binding snapshot. + +## Task 6: Fix Runtime Payload Selection and Add Linux Artifact Physics (#392/#396) + +**Files:** + +- Modify: `src/toolchain/post_install.cppm` +- Modify: `src/toolchain/lifecycle.cppm` +- Add: `src/platform/elf_runtime.cppm` +- Modify: `src/platform/runtime_binding.cppm` +- Add: `src/build/runtime_validation.cppm` +- Modify: `src/build/ninja_backend.cppm` +- Modify: `src/build/execute.cppm` +- Modify: `src/doctor.cppm` +- Modify: `src/xlings/subos_info.cppm` +- Add: `tests/unit/test_elf_runtime.cpp` +- Add: `tests/e2e/206_runtime_binding_physics.sh` + +**Interfaces:** + +- `ElfRuntimeFacts { artifact; interp; runpaths; needed; requiredGlibcVersions; definedGlibcVersions; resolvedLibc; resolvedObjects; }` +- `validate_runtime_artifact(path, RuntimeBinding, RuntimeResolution) -> RuntimeVerdict` +- `RuntimeVerdict { Pass | ProvenMismatch | Inconclusive; diagnostics[]; }` + +- [x] RED: select the glibc payload named by RuntimeBinding, never the first directory entry; stale/absent payload is an explicit error. +- [x] RED: fixture ELFs prove Rule B rejects interpreter/libc from different payloads and accepts same-payload paths. +- [x] RED: fixture version tables prove a required GLIBC symbol floor above the selected libc exports is a hard proven mismatch; a lower/equal floor passes; unavailable closure data is inconclusive, not falsely green. +- [x] Implement semantic exact payload lookup in post-install fixup and bump the fixup revision so existing toolchains repair against the selected binding. +- [x] Implement internal ELF64 little-endian parsing for PT_INTERP, DT_RPATH/RUNPATH, DT_NEEDED and GNU version need/definition sections under `src/platform/elf_runtime.cppm`; no shell parsing on the build hot path. +- [x] Validate only newly linked Linux ELF outputs; cache the verdict by artifact stat/link fingerprint so hot no-op performs zero parses. +- [x] Emit canonical requester/provider/artifact paths and a copyable SubOS remediation; hard-fail proven Rule B/A mismatches, classify unresolvable host/hardware closure as inconclusive with an explicit diagnostic. +- [x] Ensure macOS/Windows validators compile to a typed no-op and never apply Linux glibc rules. +- [x] Extend doctor/runtime explanation to reuse stored verdict rather than re-probe or guess. +- [x] GREEN: run ELF unit fixtures, form-X E2E, current #392 reproduction shape, and a safe host-DSO control. +- [x] Commit runtime physics validation; only close #392/#396 if the final released E2E proves their exact acceptance cases. + +## Task 7: Carry Provider-Neutral Graphics Runtime Provenance + +**Files:** + +- Modify: `src/manifest/types.cppm` +- Modify: `src/manifest/toml.cppm` +- Modify: `src/manifest/xpkg.cppm` +- Modify: `src/build/plan.cppm` +- Modify: `src/build/flags.cppm` +- Modify: `src/build/prepare.cppm` +- Modify: `src/build/runtime_validation.cppm` +- Modify: `src/doctor.cppm` +- Modify: `src/platform/runtime_binding.cppm` +- Modify: `src/xlings/subos_info.cppm` +- Add: `tests/unit/test_runtime_contract.cpp` +- Modify: `tests/unit/test_link_model_runtime_dirs.cpp` +- Modify: `tests/unit/test_runtime_selection.cpp` +- Modify: `tests/unit/test_subos_info.cpp` +- Modify: `tests/e2e/62_runtime_library_dirs.sh` +- Modify: `tests/e2e/66_runtime_provides.sh` +- Modify: `tests/e2e/200_subos_env_reaches_program.sh` +- Modify: `tests/e2e/205_root_local_subos.sh` +- Add: `tests/e2e/207_runtime_contract_provenance.sh` +- Modify: `docs/05-mcpp-toml.md` +- Modify: `docs/zh/05-mcpp-toml.md` +- Modify: `docs/08-toolchain-internals.md` +- Modify: `docs/zh/08-toolchain-internals.md` + +**Interfaces:** + +- `RuntimeRequirement { kind; value; phase; canonical requester PackageId; required; }` +- `RuntimeArtifact { role; canonical provider PackageId; path; provenance; abi; digest; hostFingerprint; }` +- `LinkIntent { libraries; linkLibraryDirs; transitiveNeededDirs; runtimeSearchDirs; frameworks; deployFiles; }` + +- [x] RED: same-short-name providers in two namespaces remain distinguishable in runtime resolution JSON and `mcpp why runtime`. +- [x] RED: required capabilities and provided capabilities are separate; a requester cannot become its own provider merely because it requires a capability. +- [x] RED: runtime search dirs do not enter `-L`; Linux transitive-needed dirs use `-Wl,-rpath-link`, macOS emits rpath/install-name semantics, and Windows uses explicit deploy files. +- [x] Introduce structured generic requirement/artifact/provenance values while keeping legacy descriptor fields readable for one compatibility train. +- [x] Populate requester/provider from the resolved package identity, including namespace/version/index provenance; never use bare `package.name` as provider identity. +- [x] Write the resolved RuntimeBinding, requirements, artifacts, search mechanism, and validation verdict into `resolution.json`. +- [x] Make `mcpp why runtime` a pure interpreter of stored generic facts; GPU/driver diagnostics point to xlings and never probe hardware. +- [x] Add a static ownership gate that rejects new mcpp source branches containing provider-specific GPU/ICD selection vocabulary outside docs/tests. +- [x] GREEN: run runtime-contract unit/E2E tests and prove the build hot path launches no GL/Vulkan probe. +- [x] Commit provider-neutral runtime contract and link-intent separation. + +## Task 8: Generate an Immutable Release Manifest + +**Files:** + +- Add: `scripts/release/generate_manifest.py` +- Add: `tests/scripts/test_release_manifest.py` +- Modify: `.github/workflows/release.yml` +- Modify: `docs/09-release.md` +- Modify: `docs/zh/09-release.md` + +**Interface:** + +- Release asset `mcpp-release.json` schema 1 with version, tag, release commit, and exact name/SHA256 for Linux x86_64/aarch64 plus every shipped platform asset. + +- [x] RED: fixtures reject duplicate platform/arch rows, missing sidecars, mismatched hashes, draft/prerelease input, wrong tag/version, and non-deterministic ordering. +- [x] Implement deterministic manifest generation from downloaded release artifacts and sidecars, recomputing every SHA256. +- [x] Wire the release workflow so the manifest is uploaded only after all required release assets exist and validation passes. +- [x] Add a release gate that downloads the uploaded manifest and compares it to the final GitHub release inventory. +- [x] GREEN: run manifest tests and a local fixture generation twice with byte-identical output. +- [x] Commit immutable release desired-state manifest. + +## Task 9: Replace Combined AUR Publishing with an mcpp-bin Reconciler + +**Files:** + +- Add: `scripts/aur/reconcile_mcpp_bin.py` +- Add: `scripts/aur/render_mcpp_bin.py` +- Add: `tests/scripts/test_aur_reconcile.py` +- Modify: `scripts/aur/update.sh` +- Modify: `scripts/aur/README.md` +- Modify: `.github/workflows/aur-publish.yml` +- Preserve byte-for-byte: `scripts/aur/mcpp-m/**` + +**Interface:** + +- `inspect -> DesiredState/ObservedState/ReconcilePlan` +- exit classification `noop | updated | transient | permanent | refused-downgrade` +- manual inputs `publish=false|true`, optional exact latest stable tag only; no downgrade override in this phase. + +- [x] RED: fixture tests cover desired==observed no-op, upgrade, late old event refusal, missing/hash-mismatched asset, AUR maintenance retry, auth/permanent failure, RPC lag after git update, and known clone failure not becoming first publish. +- [x] RED: assert the reconciler never opens, hashes, copies, stages, or addresses `scripts/aur/mcpp-m/**`; compare pre/post tree hashes. +- [x] Split `update.sh` into an mcpp-bin-only compatibility wrapper around the renderer; remove all mcpp-m reads/writes without editing mcpp-m files. +- [x] Render PKGBUILD from `mcpp-release.json`; regenerate `.SRCINFO` with non-root Arch `makepkg --printsrcinfo`, then `makepkg --verifysource`. +- [x] Query AUR RPC and HTTPS git, compare versions with Arch `vercmp`, validate both Linux assets/sidecars, and produce a dry-run diff before secrets are loaded. +- [x] Publish only by normal fast-forward SSH push with pinned AUR host key and bounded exponential retry for maintenance/timeouts; never force or initialize a missing known package. +- [x] Verify remote git head, bounded-poll RPC, then install in a clean Arch container and assert `mcpp --version`. +- [x] Change workflow triggers to successful release workflow_run + six-hour schedule + workflow_dispatch; all call the same latest-stable reconciler. Remove the mcpp-m publish leg. +- [x] Emit Actions summary with trigger, desired/observed versions, hashes, remote commit, retry count, classification, and drift age; AUR failure must not alter GitHub release conclusion. +- [ ] GREEN: run Python tests, shell lint, dry-run against current latest release, and an Arch container source verification. +- [x] Commit mcpp-bin-only AUR reconciliation and verify mcpp-m byte hashes are unchanged. + +## Task 10: Pin Latest xlings, Version mcpp, and Update User Documentation + +**Files:** + +- Modify: `src/xlings.cppm` +- Modify: `.github/actions/bootstrap-mcpp/action.yml` +- Modify: `.github/workflows/bootstrap-macos.yml` +- Modify: `.github/workflows/ci-linux-e2e.yml` +- Modify: all other authoritative xlings pin sites found by `tests/unit/test_xlings_version_pin.cpp` +- Modify: `mcpp.toml` +- Modify: `README.md` +- Modify: `docs/00-getting-started.md` +- Modify: `docs/zh/00-getting-started.md` +- Modify: `docs/05-mcpp-toml.md` +- Modify: `docs/zh/05-mcpp-toml.md` +- Modify: `docs/08-toolchain-internals.md` +- Modify: `docs/zh/08-toolchain-internals.md` +- Modify: `docs/spec/package-identity.md` +- Modify: `scripts/aur/README.md` + +- [x] Re-query latest non-draft/non-prerelease xlings immediately before pinning; pin the exact version and verify every authoritative pin site matches. +- [x] Choose the next unused calendar version (expected `2026.8.9.1` after live tag check), update `mcpp.toml`, and leave AUR snapshots to release-time generation. +- [x] Document `[ns.]name[@version][:tname]`, default `mcpplibs`, exact dotted namespaces, sole-template default, and legacy list migration. +- [x] Document McppDefault vs root/workspace-root `[xlings].subos`, no CLI override, non-transitive dependency semantics, coexistence across glibc bindings, and prebuilt ABI metadata boundary. +- [x] Document xlings/xim ownership of OpenGL/Vulkan providers and that mcpp never probes GPU/driver/ICD. +- [x] Document mcpp-bin-only eventual AUR reconciliation and explicitly state mcpp-m/mcpp-git are outside automation. +- [x] Update English and Chinese examples together; regenerate command reference if CLI help changed. +- [ ] GREEN: run pin tests, docs example tests, generated command reference tests, `git diff --check`, and forbidden-vocabulary/boundary scans. +- [ ] Commit version, xlings pin, and documentation. + +## Task 11: Full Local Validation and PR Publication + +**Files:** + +- Modify: `.agents/docs/2026-08-09-mcpp-template-runtime-graphics-aur-validation.md` + +- [ ] Build a fresh mcpp binary with the pinned xlings in isolated homes; do not validate with a stale installed mcpp. +- [ ] Run the complete unit suite and every applicable Linux E2E, then focused no-cache workspace/self-host/release builds. +- [ ] Run scaffold/template, default/named/workspace SubOS, runtime physics, runtime provenance, release-manifest, and AUR reconcile gates independently and record commands/results. +- [ ] Run sanitizers/static checks available in repository CI, `git diff --check origin/main...HEAD`, forbidden mcpp-m diff, and explicit scope inventory. +- [ ] Measure hot no-op build and template parse against baseline; require no material regression and zero xlings/GPU subprocess on hot no-op. +- [ ] Rebase is forbidden; if origin/main advanced, merge origin/main normally, rerun all affected gates, and preserve visible history. +- [x] Push the branch and open one Draft PR with `Closes #398`, related issue notes, architecture decisions, RED/GREEN evidence, boundary proof, version/pin, and cross-repo follow-up plan. +- [ ] After every logical commit, push immediately and append a checkpoint with commit id, focused evidence, remaining failures, and cross-repository boundary. +- [ ] Request review using the repository review workflow and address feedback with fresh evidence. + +## Task 12: Native GitHub Actions, Review-Gated Merge, and Release + +**Skills required at this task:** `mcpp-release`, `superpowers:verification-before-completion`, and `superpowers:finishing-a-development-branch`. + +- [ ] Wait for all latest-HEAD checks to reach terminal success across Linux, macOS, Windows, cross/QEMU aarch64, native aarch64, fresh install, release manifest, and AUR dry-run lanes. +- [ ] Treat skipped native/runtime hardware rows as NOT_EXERCISED, not pass; create an isolated temporary CI lane if a required platform gate is absent. +- [ ] Verify remote PR HEAD equals local HEAD, branch diff is clean, no unresolved reviews, no conflict, and `scripts/aur/mcpp-m/**` is unchanged. +- [ ] After explicit user review, mark the Draft ready and use the repository's normal merge path without admin/bypass; verify the merge commit is on main and PR/issue states are correct. +- [ ] Follow `mcpp-release`: verify version strings, create/push the release tag, monitor every release job, asset, checksum, `mcpp-release.json`, GitCode mirror, and GitHub release until terminal success. +- [ ] Do not block/rollback the GitHub release for an AUR transient; dispatch the fixed latest-stable mcpp-bin reconciler and record its exact terminal state. +- [ ] Close #380/#392/#396 only when their released acceptance paths are proven; otherwise comment with delivered subset and keep the residual open. + +## Task 13: mcpp-index, GitCode Resources, and xlings Ecosystem Follow-Through + +**Repositories:** + +- `mcpplibs/mcpp-index` +- `openxlings/xim-pkgindex` +- `openxlings/xlings` +- GitCode helper/config already used by the release workflow + +- [ ] In isolated repo-specific worktrees, update mcpp-index minimum/current mcpp pins and template descriptors to canonical selectors and sole/default template lint. +- [ ] Refactor ImGui features/templates to core/headless, backend-glfw-opengl3, backend-vulkan, app, docking, and viewports without making core pull graphics unconditionally. +- [ ] Express OpenGL/Vulkan runtime requirements through generic xlings/xim package/capability dependencies and canonical provider identities; remove mcpp-index host ICD/DSO collection paths. +- [ ] In xim-pkgindex, add/fix Vulkan loader/ICD and host-link provider recipes/provenance only where current recipes cannot satisfy the new generic contract; keep provider detection/lifecycle outside mcpp. +- [ ] Use the established index CI workflow, one repo PR per external repository as required by repository boundaries, and wait for every latest-head validation job before merge. +- [ ] Confirm the mcpp release job opens/merges the mcpp index version bump and local GitCode resources contain the exact new release assets plus SHA256 sidecars; verify GitCode with ranged GET and full SHA256. +- [ ] Publish/rebuild index artifacts and verify their public hashes/refs. +- [ ] From fresh isolated HOME/MCPP_HOME/XLINGS_HOME, install the released mcpp and pinned/released xlings, run `xlings update`, canonical template new/build/run/test, default runtime, two named SubOS/glibc builds, and package lifecycle smoke. +- [ ] Run software OpenGL and Vulkan instance/device lanes where hardware exists; report AMD/Intel/NVIDIA/WSL/macOS/Windows hardware rows as PASS/FAIL/NOT_EXERCISED with actual provider/ICD provenance. +- [ ] Verify mcpp does not probe GPU and the same graphics dependencies resolve through xlings/xim under each exercised environment. +- [ ] Record all PRs, commits, CI run/job IDs, release tags, asset hashes, GitCode URLs, AUR state, and public smoke results in the validation ledger. + +## Task 14: Completion Audit and Final Report + +- [ ] Verify mcpp main/release/index heads, tags, PR merge states, issue comments/states, AUR mcpp-bin desired/observed state, and public artifact hashes from live sources. +- [ ] Verify local worktrees are clean and main checkout user files remain untouched. +- [ ] Re-run `git diff --check`, version/pin consistency, mcpp-m boundary hash, and one final cold-home released-binary smoke. +- [ ] Mark every checkbox with evidence; do not convert NOT_EXERCISED hardware rows into green. +- [ ] Update the active goal to complete only when no required item remains. +- [ ] Report concise core outcomes plus links to the implementation plan, validation ledger, issue, PR, CI, release, index PRs/artifacts, GitCode, AUR and ecosystem evidence. diff --git a/.agents/docs/2026-08-09-mcpp-template-runtime-graphics-aur-validation.md b/.agents/docs/2026-08-09-mcpp-template-runtime-graphics-aur-validation.md new file mode 100644 index 00000000..14e95f37 --- /dev/null +++ b/.agents/docs/2026-08-09-mcpp-template-runtime-graphics-aur-validation.md @@ -0,0 +1,408 @@ +# mcpp Template, Runtime, Graphics, and AUR Validation Ledger + +> Started: 2026-08-09 Asia/Shanghai +> +> Implementation issue: https://github.com/mcpp-community/mcpp/issues/398 +> +> This ledger separates baseline, RED, GREEN, CI, release, and public-ecosystem evidence. A running, skipped, cancelled, or superseded job is never recorded as pass. + +## 1. Scope and repository boundaries + +The mcpp implementation is developed in one branch and one pull request: + +- worktree: isolated feature worktree; local path intentionally omitted +- branch: `feat/template-runtime-graphics-aur` +- base: `mcpp-community/mcpp main@80291ca01a982c1e8c00e43bfa97ffe68516e6d7` +- focused issue: `mcpp-community/mcpp#398` +- umbrella issue: `mcpp-community/mcpp#397` + +External repositories retain their own history and review boundaries. Their implementation, if required after the mcpp release, uses separate repository PRs; it does not create a second mcpp implementation PR. + +The implementation worktree was created from `origin/main`; pre-existing checkout state was left untouched. + +## 2. Live baseline + +Captured after fresh `git fetch origin main --prune` on 2026-08-09: + +| Repository | local HEAD | `origin/main` | local state | latest release | +|---|---|---|---|---| +| `mcpp-community/mcpp` | `80291ca01a982c1e8c00e43bfa97ffe68516e6d7` | same | isolated feature worktree clean before docs | `v2026.8.8.4`, published 2026-08-08 10:51:31Z | +| `openxlings/xlings` | `2913a0949af3b26192a6c1d8f12b78f976bea6b8` on `feat/version-grammar` | `f203b6b9a5e3e0d3f707468ed56cdb8b50cc7acc` | dirty user checkout; must use a new worktree | `v2026.8.9.2`, published 2026-08-08 20:38:57Z | +| `mcpplibs/mcpp-index` | `b86fc7c0c80a93f4ccdf797c13ec0cca2557d6eb` | same | clean main | no GitHub release list | +| `openxlings/xim-pkgindex` | `576ef09b69becefca00de8c94c6ba17d5cdb1ee4` | `e8029381beb2e0c83c4ec4318f01bd18c523890a` | local main behind; must use a new worktree | no GitHub release list | + +Open PR inventory at capture time: + +- mcpp: #387, #372 (Draft), #353, #351 (Draft), #272. +- xlings: none. +- mcpp-index: #150 (Draft), #61. +- xim-pkgindex: none. + +Local executables visible before the isolated cold build: + +- PATH mcpp: `mcpp 2026.8.6.2`. +- latest existing source-built binary: `mcpp 2026.8.8.4` at `target/x86_64-linux-gnu/09887d532ce30543/bin/mcpp` in the main checkout. +- PATH xlings: `xlings 2026.8.9.2`. + +The release version proposed for this implementation is `2026.8.9.1`; the tag is re-queried immediately before changing `mcpp.toml`. + +## 3. Immutable boundary snapshots + +Approved design hashes: + +- focused design: `e06821b73102049e1f2184a2967e5dd81dfb6f05389a763dad326d139d0e3f25` +- parent convergence design: `ad93db80a3222f6536629a15d9b5ba430b222e179b91de01131c619ecbd3bd65` +- initial implementation plan: `aea2963f593ef00e71b2876f52a773014a28098f513f82260063dcd336acb09e` + +`scripts/aur/mcpp-m/**` baseline: + +| File | SHA256 | +|---|---| +| `.SRCINFO` | `9d8b8279aadfa6b1915850fbbfe33c2ef237456f94dd706e726e3169b60be0f8` | +| `PKGBUILD` | `545fe0de51f0cf7e5979871ba8f8d6cd3233c485338a9768f57e53c0a6f8fa7f` | +| `mcpp.sh` | `cbab68984b02c415f8ae42bf9417647b842e900fbf7bf292067e4a384b924f8f` | +| sorted aggregate | `afb8a647e04483a86985119e07086016f49d55f177ee6257094c336d226113c6` | + +Host xlings configuration snapshot: + +- scope: sorted SHA256 rows for `$XLINGS_HOME/.xlings.json` and `$XLINGS_HOME/subos/*/.xlings.json`; contents were not copied. +- aggregate: `f218aadf3792ee815c8535ce0ca0bb53f634fecbdbc5d0d47db442052d786d1b`. + +All stateful verification uses a separate temporary root and must reproduce both aggregate hashes before completion. + +## 4. Baseline verification + +Isolated root: disposable task root; exact local path intentionally omitted. + +| Gate | Command shape | Status | Evidence | +|---|---|---|---| +| cold source build | isolated `MCPP_HOME`, `XLINGS_HOME`, vendored xlings 2026.8.9.2; `mcpp build --no-cache` | PASS | cold bootstrap installed `glibc@2.44` and resolved `gcc@16.1.0`; release build finished in 64.80s; binary `target/x86_64-linux-gnu/72abd390cce53924/bin/mcpp` reports `2026.8.8.4` | +| unit suite | fresh baseline binary, isolated homes; `mcpp test` | PASS | `68 passed; 0 failed`, 89.06s total | +| template E2E | isolated homes; `MCPP= bash tests/e2e/69_package_templates.sh` | PASS | exit 0, `OK` | +| runtime E2E | isolated homes; scripts 74, 166 and 201 | PASS with one fixture rerun | #74 proved payload glibc 2.44 PT_INTERP; #166 proved private glibc env inclusion/exclusion; #201 passed both sysroot/payload-first modes after using a non-`/tmp` isolated HOME | +| AUR tests | inventory + `bash -n scripts/aur/update.sh` | PASS for syntax; coverage absent | baseline has no reconciler tests; Task 9 adds fixture coverage | +| diff check | `git diff --check origin/main...HEAD` before edits | PASS | no tracked diff before documentation was added | + +### Baseline fixture diagnosis + +The first #201 run exited 1 because the isolated `MCPP_HOME` itself was below the system temporary directory. The test intentionally rejects every RUNPATH beginning with that directory as evidence of another temporary home, so it classified the current, valid isolated home as pollution. The observed two paths were the current glibc and gcc payload directories, not a stale foreign home. + +The hypothesis was tested by hard-link cloning the disposable state into a non-temporary isolated root, letting the normal fixup rebind paths, and running the unmodified script again. It exited 0 and proved both link modes load nothing from the host. No product or test source was changed for this baseline result. + +## 5. RED/GREEN ledger + +Each behavior entry is appended with: + +1. exact test command; +2. RED exit code and why the old code failed; +3. production change; +4. GREEN exit code and relevant assertions; +5. refactor/full-gate result. + +### 5.1 Exact PackageSelector and dependency identity (Task 2) + +| Gate | RED evidence | Production change | GREEN evidence | +|---|---|---|---| +| shared parser | Selector tests initially could not compile because `PackageSelector` / `parse_package_selector` did not exist; unsafe-segment rows then exposed missing character validation | Added one O(length), no-I/O parser, default `mcpplibs` normalization, dotted formatter, and diagnostic-only legacy coordinate helper | `test_pm_compat`: 17 tests passed, including bare/dotted/nested/invalid rows | +| manifest/xpkg parsing | Updated manifest expectations produced 3 failures under ordered-candidate behavior; invalid TOML/xpkg selectors were previously not hard parse failures | Routed direct, nested, feature, target and xpkg dependency inputs through the shared exact parser | `test_manifest`: 148 passed; `test_pm_index_route`: 7 passed; `test_pm_package_fetcher`: 10 passed | +| add/exact sibling | `12_add_command.sh` first showed `capi.lua` written/resolved with the old default-prefix candidate and no migration diagnostic; a later RED left both legacy `"acme.util"` and canonical `[dependencies.acme] util` rows | `mcpp add` now accepts canonical `[ns.]name@version`, retains `ns:name` as a warned one-release alias, probes only one PackageId, removes an equivalent legacy flat row, and writes/upserts the canonical table | `12_add_command.sh`: `capi.lua` cannot be stolen by `(mcpplibs.capi,lua)`; warning names both exact selectors; legacy source shape becomes one canonical row; malformed input leaves TOML byte-stable | +| scoped remove | Added same-name `[dev-dependencies]` before `[dependencies]`; RED removed the dev row and left the regular row | Limited flat removal to the exact `[dependencies]` body and kept nested namespace removal exact | Rebuilt source binary; the same E2E preserves the dev row and removes only the regular dependency | +| exact build miss | `162_bare_name_namespace_scope.sh` initially passed an exact miss into install-time compatibility retries | Every version dependency is identity-validated before install; miss reports the exact coordinate and did-you-mean remains diagnostic-only | `162`: bare gtest stays `(mcpplibs,gtest)` while explicit `(compat,gtest)` resolves; `165` proves the descriptor supplies the exact wire namespace | +| lock migration | New `203_exact_selector_lock_migration.sh` first failed because Form-B synthesis received the ambiguous manifest map key instead of the resolved short name | Existing v2 lock identity anchors an old dotted selection for one release train; unlocked selection never falls back; Form-B synthesis uses canonical short name | `203`: warning names old/new selectors, build/run use the locked old identity, rewritten lock retains its namespace | + +Task 2 refactor/full gates on the isolated source binary +`target/x86_64-linux-gnu/aee81584bf66d3f8/bin/mcpp`: + +- `mcpp build --no-cache`: PASS, release build completed in 64.12s. +- `mcpp test --no-cache`: PASS, **68 passed; 0 failed**, 86.37s. +- focused E2E set `12, 27, 62, 63, 78, 79, 162, 165, 203`: all PASS/OK. +- changed shell scripts `bash -n`: PASS. +- `git diff --check`: PASS. + +### 5.2 Typed exact TemplateSpec and deterministic provider selection (Task 3) + +| Gate | RED evidence | Production change | GREEN evidence | +|---|---|---|---| +| grammar | New `test_scaffold` could not build because typed `parse_template_spec` / `select_template` APIs did not exist | Added fixed-order `:` then `@` parsing over the shared PackageSelector; exact safe version keys; atomic tname validation; one-release `pkg:` list marker | 9 scaffold tests pass for every valid design row and empty/double-delimiter/range/unsafe failures | +| exact provider identity | Updated E2E is red on the baseline binary at the first new exact-identity diagnostic; baseline implementation probes empty/compat short names and cannot retain an explicit foreign/nested PackageId | Registry template fetch now uses one normalized coordinate through `IndexRoute::lookup_descriptor`; no short-name or compat probe remains | E2E same-short `(mcpplibs,tpl-demo)` / `(acme,tpl-demo)` selects acme exactly; nested `(mcpplibs.capi,lua)` survives output and injected selector | +| deterministic version | Baseline accepted only its ad-hoc version flow and exposed no payload provenance | Shared SemVer resolver chooses latest stable, explicit prerelease is validated, indirect aliases are refused, and xpkg version entries retain declared SHA256 | E2E omits `2.0.0-rc.1` for default selection, accepts its exact pin, rejects `latest`, rejects unpublished `9.9.9`, and prints descriptor/payload provenance | +| default template | Old code failed whenever no `default=true` existed, including a package with exactly one template | Shared selection implements sole-template auto-default, unique explicit default, explicit tname, and deterministic provider errors | Unit/E2E cover sole auto-default, multiple ambiguous choices, duplicate defaults, missing templates directory, and explicit disambiguation | +| migration/CI surfaces | Legacy `pkg:` silently meant listing and native fresh-install only exercised a bare short name | `pkg:` warns with copyable `--list-templates`; fresh-install Linux/macOS/Windows lanes now use explicit `mcpplibs.imgui` and assert resolved identity | Local legacy-list E2E passes; native lanes are configured and remain pending PR/latest-head CI evidence | + +Task 3 local gates on source binary +`target/x86_64-linux-gnu/94da92f90aedbe7f/bin/mcpp`: + +- source build: PASS, full release rebuild completed in 72.92s. +- `mcpp test --no-cache`: PASS, **69 passed; 0 failed**, 33.07s. +- `test_scaffold`: 9/9 behavior rows PASS; `test_manifest`: 148/148 PASS. +- `69_package_templates.sh`: PASS with exact/default/nested/alias/provider/wire assertions. +- `bash -n tests/e2e/69_package_templates.sh` and `git diff --check`: PASS. + +### 5.3 Portable, durable, transactional scaffolding (Task 4 / #380) + +| Gate | RED evidence | Production change | GREEN evidence | +|---|---|---|---| +| portable project name | New scaffold tests failed to compile because `mcpp.scaffold.project_name` did not exist | Added an ASCII package/directory validator and isolated Windows device-basename policy under `mcpp.platform.project_name`; `cmd_new` validates before config/index/network/staging work | Unit rows accept bare/qualified names and reject empty, absolute, separators, dot components, C0/DEL, Windows-invalid/device names, trailing dot/space, and the legacy `PROJECT` marker | +| single-pass rendering | Baseline only repeatedly replaced the literal `PROJECT`/two legacy variables | Added the complete eight-field `RenderVars` vocabulary, strict unknown/unterminated-token diagnostics, and append-only single-pass rendering | Unit test proves every canonical project/provider identity renders and an inserted `{{template.name}}` sequence is not rescanned; E2E renders qualified/nested identities | +| exact dependency edit | New manifest-editor tests failed to compile because `upsert_dependency_text` did not exist; baseline scaffold used substring presence/insertion | Added one parsed, source-preserving dependency editor shared by `mcpp add` and scaffold injection; it writes default tables or `[dependencies.]` plus short key and reparses/verifies the exact PackageId/version/features | `test_manifest` 150/150; same-short `compat.widget` survives while exact `acme.widget` is injected idempotently; add E2E remains green | +| transaction and I/O | Baseline created the final directory before rendering and ignored several stream/filesystem results; exclusive platform rename API was absent | Both builtin/package paths write a same-parent `.mcpp-new-*` tree, close+sync files, validate the complete manifest, then commit with Linux `renameat2(RENAME_NOREPLACE)`, macOS exclusive rename, or Windows write-through no-replace move | 18 scaffold tests cover rollback plus deterministic read/write/copy/rename failures; E2E 204 covers path escape, unknown token, type collision, symlink rejection, no final tree, and no staging residue | + +Task 4 focused local evidence on source binary +`target/x86_64-linux-gnu/94da92f90aedbe7f/bin/mcpp`: + +- source build: PASS, release rebuild completed in 47.89s after the shared manifest editor change. +- full unit suite: PASS, **69 test binaries passed; 0 failed**, 16.77s cached refactor gate. +- `test_scaffold`: **18/18** behavior tests PASS; `test_manifest`: **150/150** PASS. +- `02_new_build_run.sh`: PASS, generated builtin project builds and runs. +- `12_add_command.sh`, `69_package_templates.sh`, and `204_new_transactional_scaffold.sh`: all PASS/`OK`. +- `mcpp-m` aggregate remains `afb8a647e04483a86985119e07086016f49d55f177ee6257094c336d226113c6`. +- host xlings config aggregate remains `f218aadf3792ee815c8535ce0ca0bb53f634fecbdbc5d0d47db442052d786d1b`. +- macOS/Windows native compilation and filesystem semantics remain pending latest-HEAD PR CI; they are not inferred from this Linux run. + +### 5.4 Root-local RuntimeSelection and immutable RuntimeBinding (Task 5) + +| Gate | RED evidence | Production change | GREEN evidence | +|---|---|---|---| +| two-mode selection | `test_runtime_selection` first failed to compile because `mcpp.xlings.runtime_selection` did not exist | Added presence-preserving manifest parsing and one pure `McppDefault`/`NamedSubos` selector with portable name validation and an explicit owner root | 10 runtime selection/binding tests pass; absence, explicit `default`, workspace override, standalone member and invalid names are distinct | +| exact binding | Binding tests then failed to compile because `mcpp.platform.runtime_binding` did not exist | Resolves only the configured default or exact root-owned named SubOS; missing directory, missing block, empty runtime and incompatible schema are hard errors; canonical provider/runtime/env snapshot gets a stable contract hash | Unit tests prove missing named cannot fall back, same-content `el8`/`dev` hashes differ, and serialized cache round-trip verifies its hash | +| lifecycle/cache | Baseline execute path derived a compiler-owned SubOS, honored `MCPP_SUBOS_DIR`, and re-read `.xlings.json` independently on full/fast run | BuildContext/BuildPlan/toolchain/fingerprint/cache carry one snapshot; fast paths require it and invalidate on contract mtime; full and cached run/test resolve environment only from that snapshot | E2E 200 proves first/cached run equality, ignored legacy override, mutation-triggered rebuild, old-cache miss and missing-contract error | +| root-local scope | A member/dependency declaration could become the manifest visible after workspace substitution or nested prepare | Selection happens before member substitution; workspace root materializes its own xlings config; dependency/tool sub-builds inherit the consumer snapshot and never consult their manifest SubOS | E2E 205 proves active-state independence, explicit-default identity, workspace-root ownership, standalone-member ownership and non-transitive path dependency behavior | +| xlings env semantics | New tests showed `set` overwrote both a caller value and an explicitly empty variable | `set` now follows xlings presence semantics; `prepend` retains ordered element-wise de-duplication | `test_subos_info`: 17/17 pass, including caller/empty preservation | + +Task 5 local gates on source binary +`target/x86_64-linux-gnu/e49880a389812d1b/bin/mcpp`: + +- source build: PASS, incremental release rebuild completed in 47.59s. +- full unit suite: PASS, **70 test binaries passed; 0 failed**, 87.78s. +- `test_manifest`: **151/151**; `test_runtime_selection`: **10/10**; `test_subos_info`: **17/17**; `test_fingerprint`: **8/8**. +- E2E `02`, `88`, `200`, `205`, `12`, `69`, and `204`: all PASS/`OK`. +- changed shell scripts `bash -n` and `git diff --check`: PASS. +- `mcpp-m` aggregate remains `afb8a647e04483a86985119e07086016f49d55f177ee6257094c336d226113c6`. +- host xlings config aggregate remains `f218aadf3792ee815c8535ce0ca0bb53f634fecbdbc5d0d47db442052d786d1b`. +- Linux behavior is locally exercised. The platform-native branch stores `runtimeId` while leaving `libc`/ELF fields empty off Linux; native macOS/Windows compilation remains a latest-HEAD PR CI gate, not a local claim. + +### 5.5 Exact runtime payload and Linux ELF physics (Task 6 / #392 / #396) + +| Gate | RED evidence | Production change | GREEN evidence | +|---|---|---|---| +| exact payload | `test_elf_runtime` initially failed to compile because `mcpp.platform.elf_runtime` did not exist; the old post-install helper returned the first loader-bearing glibc directory | Added exact `glibc@` parsing/lookup, explicit malformed/missing/stale errors and fixup revision `hermetic-4-exact-runtime`; RuntimeBinding now carries canonical loader/lib dir and optional `host_glibc` | RuntimePayload unit rows prove 2.44 wins with 2.39 also installed and every malformed/absent identity refuses instead of falling back | +| internal ELF facts | The same RED had no typed artifact facts or GNU version-table reader | Added bounded ELF64-LE parsing of PT_INTERP, PT_DYNAMIC, DT_RPATH/RUNPATH/NEEDED and GNU verneed/verdef, preserving loader search order and invoking no shell tool | Synthetic stripped-shape fixture returns exact interpreter, ordered runpaths, needed soname, `GLIBC_2.40` need and `GLIBC_2.44` definition; text/truncated inputs refuse | +| Rule B | No post-link seam compared the emitted interpreter with the libc actually resolved through the closure | Closure resolution canonicalizes artifacts/providers and rejects host/private or multi-private libc mixtures against the selected RuntimeBinding | Unit rows reject interpreter/libc and transitive two-libc mixtures and accept one selected payload; E2E 206 forces host PT_INTERP + private libc and gets a canonical Rule-B hard failure | +| Rule A | No code compared a host DSO's GNU symbol floor with the selected libc exports | Validator compares every requester `GLIBC_*` need to the selected libc definitions; proven higher floors fail, missing closure data is typed inconclusive | Unit rows reject 2.42 > 2.39, accept equal/lower, and keep missing GPU closure inconclusive; E2E 206 links real host `libtinfo` under glibc 2.44 as the safe #392-shaped control | +| cache/doctor | A hot fast path could not know whether its artifact had ever been checked; doctor reparsing would observe a different current host | Stored verdict is keyed by artifact stat plus RuntimeBinding contract; hot paths require current PASS and compare pre/post-Ninja stats; mismatch/inconclusive records remain sticky without reparsing; doctor reads the stored record | Self-host artifact verdict is `pass`; bare build finishes in 0.01s with artifact/verdict stat unchanged; E2E 206 proves PASS no-op, cached mismatch remains red with unchanged verdict, and doctor prints the stored PASS | +| platform boundary | Linux-specific rules had no typed cross-platform seam | Validator compiles as a typed no-op off Linux; RuntimeBinding leaves ELF/libc-only data absent on macOS/Windows | Linux unit/E2E pass; native macOS/Windows compile/behavior remains an explicit PR CI gate, not inferred locally | + +Task 6 focused local evidence on source binary +`target/x86_64-linux-gnu/665e89fc782f5d0f/bin/mcpp`: + +- two-stage self-host: first new implementation build PASS; RuntimeBinding-aware rebuild PASS in 65.06s with canonical glibc 2.44 loader/lib directory and host floor 2.39. +- `test_elf_runtime`: **10/10** tests PASS (exact payload, parser, Rule A/B, inconclusive). +- `206_runtime_binding_physics.sh`: PASS for real host-DSO control, post-link mismatch, sticky cached mismatch, doctor reuse, and hot zero-rewrite cache. +- regressions `200_subos_env_reaches_program.sh`, `205_root_local_subos.sh`, and `02_new_build_run.sh`: PASS/PASS/OK. +- full unit/refactor gate: PASS, **71 test binaries passed; 0 failed**, 50.99s (49.30s build + 1.54s run). +- `bash -n` for changed E2E and `git diff --check`: PASS. +- #392/#396 remain open until the merged release artifact repeats the exact acceptance E2E; local source evidence alone is not closure evidence. + +### 5.6 Provider-neutral runtime provenance and LinkIntent (Task 7) + +| Gate | RED evidence | Production change | GREEN evidence | +|---|---|---|---| +| structured descriptor contract | `test_runtime_contract` initially failed to compile because `RuntimeRequirement`, `RuntimeArtifact`, `LinkIntent`, canonical `PackageId`, and the resolver API did not exist; the first xpkg GREEN attempt then exposed a dangling `string_view` over a temporary parser body; review RED proved an explicit relative library file remained relative to the consumer instead of its declaring package | TOML and xpkg descriptors now read the same typed requirement/artifact/link-intent grammar; explicit library paths resolve at the declaring package; legacy `library_dirs`, `dlopen_libs`, and `capabilities` remain readable for one train and normalize only to runtime-search/requirement facts | `test_runtime_contract` TOML/xpkg rows pass and assert every structured channel, package-root path anchoring, plus retained legacy fields | +| exact ownership | The old runtime model retained only short provider strings and treated a requirement as a weak provider candidate | Build-plan resolution stamps every requirement/artifact/provider with namespace, name, version, and source provenance; only `provides` creates a descriptor provider; an explicit override is exact or uniquely compatible and missing/ambiguous cases hard-fail | Unit rows keep `alpha.backend@2.0.0` and `beta.backend@3.0.0` distinct and prove the requester is never self-promoted; E2E 66 rejects the legacy weak-requester override | +| xlings-selected facts | RuntimeBinding had no generic provider/artifact payload and its hash/cache could not preserve a host selection; review RED then proved equal-prefix facts were not ordered by source/provenance/digest and could make the contract hash input-order-dependent | Optional schema-1 `subos_info.runtime_contract` facts resolve `${subosdir}`/relative paths once, sort by their complete identity into RuntimeBinding, participate in its hash/serialization, and precede descriptor fallback facts in BuildPlan | `test_subos_info` and `test_runtime_selection` round-trip provider identity, artifact provenance, digest and host fingerprint; the full-fact ordering row and merge row prove deterministic selected-before-fallback behavior | +| link/search separation | Legacy `library_dirs` entered both `-L` and rpath; there was no typed seam to assert all output formats | Added a pure four-flavor LinkIntent renderer: runtime dirs never enter link lookup, ELF alone gets `-rpath-link`, Mach-O gets rpath/frameworks, PE gets link dirs while deploy files remain copy edges | `test_link_model_runtime_dirs` passes all flavor assertions; E2E 62 inspects a real Linux link and finds RUNPATH without the forbidden `-L` token | +| durable explanation | Runtime explanation could resolve again and provider provenance was not present in the stored schema; an added RED also showed `required=false` ABI facts were incorrectly promoted into hard compatibility constraints | `resolution.json` schema 2 stores the exact binding, canonical requirements/providers/artifacts, link intent, search mechanism, and synchronized post-link verdict; optional requirements remain recorded but stay out of hard ABI/doctor projections; `mcpp why runtime` reads only the newest stored file | E2E 207 builds successfully with an intentionally mismatched optional `abi:musl`, preserves same-short identities/artifact provenance, corrupts `mcpp.toml`, installs fake probe commands, then proves `why runtime` still succeeds without launching them | +| ownership boundary | No automated fence prevented mcpp from acquiring graphics-provider or hardware-probe policy | Added a source gate over executable code branches and process-launch neighborhoods; diagnostics redirect provider/host re-diagnosis to `xlings doctor` | Static gate passes over the complete `src/` tree; E2E 207 proves the live why path is probe-free | +| runtime-physics compatibility | Synthetic named SubOS fixtures contained only metadata, which became physically false after Task 6 began enforcing loader/libc closure | E2E 200/205 now reuse the managed default's exact libc/loader view while varying only environment and root ownership; no product fallback was introduced | E2E 200, 205, and 206 pass with truthful bindings; Task 6 Rule A/B enforcement remains intact | + +Task 7 focused local evidence on the latest self-hosted source binary +`target/x86_64-linux-gnu/4091fed9f558ec8a/bin/mcpp`: + +- self-host source builds: PASS; initial RuntimeBinding implementation completed in 68.66s and the post-review deterministic/optional/path refinement rebuild completed in 78.83s. +- focused units: `test_runtime_contract` **5/5**, `test_link_model_runtime_dirs` **5/5**, `test_runtime_selection` **10/10**, and `test_subos_info` **18/18** PASS. +- focused E2E `62_runtime_library_dirs.sh`, `66_runtime_provides.sh`, and `207_runtime_contract_provenance.sh`: PASS/`OK`. +- RuntimeBinding regressions `200_subos_env_reaches_program.sh`, `205_root_local_subos.sh`, and `206_runtime_binding_physics.sh`: PASS. +- full no-cache unit gate: PASS, **72 test binaries passed; 0 failed**, 109.40s (107.48s build + 1.78s run); after the review refinements, the complete latest-source refactor gate repeated **72/72** in 14.58s (12.98s build + 1.48s run). +- changed shell scripts `bash -n`, executable mode for E2E 207, and `git diff --check`: PASS. +- `mcpp-m` aggregate remains `afb8a647e04483a86985119e07086016f49d55f177ee6257094c336d226113c6`. +- host xlings config aggregate remains `f218aadf3792ee815c8535ce0ca0bb53f634fecbdbc5d0d47db442052d786d1b`. +- Linux semantics are locally exercised. Mach-O and PE flag spelling has pure unit coverage on Linux; native macOS/Windows compilation and behavior remain latest-HEAD PR CI gates, not inferred passes. + +### 5.7 Immutable release desired state (Task 8) + +| Gate | RED evidence | Production change | GREEN evidence | +|---|---|---|---| +| manifest contract | The initial 11-case suite produced 12 failing assertions because no generator existed; fixtures cover duplicate normalized platform/arch, missing sidecars, payload/sidecar hash disagreement, draft/prerelease releases, wrong tag/version, missing required assets, duplicate release names, and invalid commit identity | Added schema-1 generation from the GitHub release JSON plus downloaded payloads; four current platforms are mandatory, every additional versioned platform payload is included, every digest is recomputed, and output is sorted deterministically | `test_release_manifest.py` passes **12/12**; randomized release-API asset order produces byte-identical output and every manifest hash equals the independently recomputed fixture payload hash | +| future platform closure | Review fixture added a valid FreeBSD/riscv64 versioned payload; the first implementation stayed green while silently omitting that row | Primary-platform discovery is provider-neutral and version-exact rather than a fixed four-platform allowlist; the four present release targets remain a required floor | The focused future-platform test changed RED-to-GREEN and asserts the complete row and SHA256 occur in the manifest | +| immutable publication | Previously each platform uploaded independently and downstream jobs inferred completeness from timing and filenames | New `release-manifest` job waits for all four uploaders, validates non-draft/non-prerelease identity, uploads only when absent, refuses a byte-different existing manifest, polls public API visibility, then refetches all public assets and regenerates/compares the manifest | Ruby/Psych parses `release.yml` and confirms six jobs, `publish-ecosystem` depends on `release-manifest`, Python bytecode compilation passes, and `git diff --check` is clean; live GitHub upload/refetch remains a PR/release CI gate | +| maintainer contract | Release docs had no machine-readable completeness boundary or reproducible local audit command | English and Chinese release docs define schema, row scope, immutability/rerun behavior, public refetch command, and checklist gates | Documentation examples invoke the same checked-in generator and explicitly recompute payload hashes rather than trusting manifest or sidecar values | + +Task 8 focused local evidence: + +- RED: missing generator yielded 12 assertion failures across 11 initial tests; the later additional-platform review row independently failed because FreeBSD/riscv64 was omitted. +- GREEN: `python3 tests/scripts/test_release_manifest.py` reports **12 tests OK** in 0.309s; `python3 -m py_compile` succeeds for generator and tests. +- Determinism: the complete fixture shuffles API inventory with seed 398, invokes the CLI twice to separate output files, and compares exact bytes. +- Public-inventory replay: downloaded the eight real versioned payload/sidecar assets from stable `v2026.8.8.4`, resolved tag commit `55a39d90fe98b5475fc394ac8487fe6804b2b84f`, and generated twice byte-identically. The four-row manifest SHA256 is `6614ba2db65c8c28cb5a9d3466bd6cf3007634221da76d25216785061c647edb`; every real sidecar and recomputed payload digest agreed. +- Workflow structure: Ruby/Psych parses `.github/workflows/release.yml`; all six jobs are present and ecosystem publication is gated by the immutable manifest job. +- Public GitHub upload/refetch and eventual-consistency polling cannot be truthfully claimed from local fixtures; they remain explicit latest-HEAD release CI evidence. + +### 5.8 mcpp-bin-only AUR reconciliation (Task 9) + +- State-machine/contract suite: `python3 -m unittest tests/scripts/test_aur_reconcile.py` + passes **12/12**. It covers no-op/upgrade/repair/RPC-lag/refused-downgrade, + missing or mismatched release material, bounded maintenance retry, permanent + auth failure, failed known-package clone without repo initialization, exact + latest-complete release selection, workflow recovery triggers, and the pinned + AUR ED25519 fingerprint. It also requires both the rendered `mcpp-bin` + maintainer and reconciler commits to use the public `speak-agent` GitHub + noreply identity rather than a personal address. +- Release fixture: the real public `v2026.8.8.4` payloads/sidecars and locally + generated immutable manifest validate as desired `2026.8.8.4-1`; both Linux + digests match manifest and sidecar. The first Arch render attempt classified + the Docker Hub registry-header timeout as **transient**, not success. A later + real Arch run completed `makepkg --printsrcinfo` and non-root + `makepkg --verifysource`; the rendered `.SRCINFO` and both source payloads + verified successfully. +- `bash -n scripts/aur/update.sh`, Python compilation/tests, Ruby/Psych workflow + parse, and `git diff --check`: PASS. +- Protected `scripts/aur/mcpp-m/**` aggregate before/after remains + `afb8a647e04483a86985119e07086016f49d55f177ee6257094c336d226113c6`. +- No AUR publish was performed. The public package remains an observed + `2026.8.1.1-1` while the verified desired fixture is `2026.8.8.4-1`; release + publication must later exercise the reconciler from a fixed merged + main/release state. + +### 5.9 latest pin, version, and user contract docs (Task 10) + +- Live GitHub release API on 2026-08-09: newest stable xlings is + `v2026.8.9.2`; newest stable mcpp is `v2026.8.8.4`, so the next unused mcpp + version is `2026.8.9.1`. +- Live `openxlings/xim-pkgindex` main identifies `2026.8.8.4` as the current + mcpp latest for Linux/macOS/Windows; `.xlings.json` now bootstraps that + published version rather than the previous stale `2026.8.6.2`. +- `.github/tools/check_version_pins.sh` passes with every xlings pin at + `2026.8.9.2`, build version `2026.8.9.1`, and bootstrap mcpp `2026.8.8.4`. +- English/Chinese README, getting-started, manifest, toolchain, identity, AUR, + and changelog text now state the same template/SubOS/graphics/AUR boundaries. + +## 6. Pull request and CI + +Draft PR [#400](https://github.com/mcpp-community/mcpp/pull/400) was opened from +`feat/template-runtime-graphics-aur`. Every logical change is a separate pushed +commit followed by a PR checkpoint comment. Branch history is not rewritten; +the PR remains Draft until explicit user review. + +Initial head `234a4df` exposed two real boundaries: stale declared glibc +identity versus the resolved SubOS view on Linux, and Linux-only +runtime-physics assertions executing on macOS/Windows. Both received focused +regressions at `dae4384`. Subsequent native and local review exposed and fixed: + +- exact namespace inheritance and dependency-owner retention; +- ELF SONAME reuse and exact-miss diagnostic cause retention; +- truthful runtime contracts in fake xlings fixtures; +- macOS BMI-settling scope; +- Windows open-stream cleanup; +- isolated `MCPP_HOME` use in the libc poison fixture; +- lexical workspace-root anchoring for inherited relative indices on Windows. + +At implementation head `ed4cf64`, the complete latest-source local C++ gate is +**72/72**. One isolated Linux full-E2E audit produced **186 pass, 1 fixture +failure, 19 platform/capability skips**; the only failure was the wrong state +root in E2E 156, and its exact rerun passes after `0cc6a2a`. Exact E2E 12 also +passes after `ed4cf64`. This is deliberately not restated as a full latest-head +206-case rerun. + +The native `ed4cf64` matrix is terminal with 12 successes, five failures and one +concurrency cancellation: + +- PASS: hermetic Linux E2E, macOS ARM64 E2E, musl + LLVM, MinGW + Linux-to-Windows + Wine, Windows-to-Linux cross-build and Linux artifact run, + Linux unit/cold-GCC/both E2E shards, Windows build/unit/package and Windows + E2E 1/2; +- FAIL at one known cross-repository boundary: Linux xlings integration, macOS + xlings LLVM E2E, aarch64 mcpp + xlings, and Windows xlings regressions all + encounter xlings' bare `ftxui` declaration under exact selection; +- FAIL independently in Windows E2E 2/2 (run `31321961040`, job + `93266267511`): workspace-member `mcpp add acme.util@2.0.0` cannot read its + root-owned local index even though the corresponding native unit passes; +- CANCELLED: the bare-Windows/no-Visual-Studio job was superseded by a later + push and is not counted as pass. + +The exact Windows artifact succeeds for both a hand-authored member and `mcpp +new m1` under an isolated Wine reproduction. That narrows the remaining failure +to native Git Bash/process/workspace state, but does not prove it fixed. +Commit `9a47ccf` therefore adds a permanent, privacy-safe `route:` error line +that reports only local-index root/`pkgs` presence. Its TDD cycle is RED on the +missing API, then GREEN for `PmIndexRoute` 12/12 and real E2E 12. The next native +Windows run remains the authority for the root cause; no latest-head full-suite +claim is made yet. + +The xlings boundary is tracked by +[openxlings/xlings#521](https://github.com/openxlings/xlings/pull/521), whose +eight native/cross checks pass. It remains Draft and REVIEW_REQUIRED. After the +handoff (`9f6161a`) and validation-ledger (`6e42d6c`) commits, privacy review +produced `cf39cb2`: the AUR reconciler and rendered `mcpp-bin` now use the public +`speak-agent` noreply identity, with a RED/GREEN contract regression. `189c6d1` +records that 12/12 contract result. The current diagnostic head `9a47ccf` must +reach terminal state before any latest-head claim. + +The Chinese operator handoff is +`.agents/docs/2026-08-09-pr400-handoff-zh.md`. It records implementation scope, +all pushed commits, diagnosed failures, honest validation boundaries, remaining +release/ecosystem work, and the mandatory Draft/commit/checkpoint/privacy +workflow. + +## 7. Merge and release + +Not started. After explicit user review, this section will record the normal merge commit, tag, GitHub release, release workflow/job IDs, asset inventory, checksums, `mcpp-release.json`, and GitCode mirror evidence. Admin/bypass merge is outside the approved workflow. + +## 8. AUR mcpp-bin + +Current live snapshot from the preceding audit: + +- GitHub desired release: `v2026.8.8.4`. +- AUR observed `mcpp-bin`: `2026.8.1.1-1`. +- latest failed AUR run: `31254088758`, after version and both checksums resolved, then AUR SSH returned maintenance. +- public replay manifest SHA256: + `6614ba2db65c8c28cb5a9d3466bd6cf3007634221da76d25216785061c647edb`. +- real Arch dry-run: `.SRCINFO`, non-root `makepkg --verifysource`, and both + Linux source checksums PASS for desired `2026.8.8.4-1`. + +No old failed run will be rerun. Recovery uses the reconciler from a fixed main/release state. `mcpp-m` is outside every verdict and publish path. + +## 9. Cross-repository and public ecosystem verification + +### 9.1 mcpp-index exact identity bridge + +- Issue [mcpplibs/mcpp-index#196](https://github.com/mcpplibs/mcpp-index/issues/196) + is closed by merged PR + [mcpplibs/mcpp-index#197](https://github.com/mcpplibs/mcpp-index/pull/197). +- Merge commit: `b974cbba5a5ab7da7908422e44ff8e4b63243dc3`. +- The immutable `mcpplibs.capi.lua@0.0.3` payload remains unchanged; an + index-side Form-B bridge declares the actual `compat.lua@5.4.7` dependency. +- All ten PR checks passed, including descriptor lint/select, Linux/macOS/ + Windows workspace consumers, mirror reachability, graphics side-effect + isolation, and timings. + +### 9.2 xlings exact compatibility identities + +- Issue [openxlings/xlings#520](https://github.com/openxlings/xlings/issues/520) + and Draft PR + [openxlings/xlings#521](https://github.com/openxlings/xlings/pull/521) + carry the xlings repository boundary. +- Head `0ff9e502f6857b5d1a48657dfeeb94244e322cb9` declares + `compat.ftxui@6.1.9` and test-only `compat.gtest@1.15.2`, then refreshes the + lock so the transitive Lua C library retains exact identity. +- All eight Linux/macOS/Windows/aarch64 checks pass. The PR remains Draft, + MERGEABLE but REVIEW_REQUIRED; no admin/bypass merge is authorized. +- Current xlings release `2026.8.9.2` predates this fix. After review and normal + merge, a separate xlings version bump/release is required before updating the + mcpp pin. + +### 9.3 Still pending + +GitCode ranged/full resource publication, any required xim-pkgindex update, +fresh-home package lifecycle, the canonical template build/run, multiple +SubOS/glibc bindings, and OpenGL/Vulkan provider +PASS/FAIL/NOT_EXERCISED provenance remain release-follow-through work. None is +claimed from source-only or local Linux evidence. diff --git a/.agents/docs/2026-08-09-pr400-handoff-zh.md b/.agents/docs/2026-08-09-pr400-handoff-zh.md new file mode 100644 index 00000000..33e0f620 --- /dev/null +++ b/.agents/docs/2026-08-09-pr400-handoff-zh.md @@ -0,0 +1,422 @@ +# PR #400 中文交接文档 + +> 更新时间:2026-08-10 00:06 CST +> +> 维护身份:`speak-agent` +> +> 状态:Draft,尚未批准合并、尚未发布 +> 本文只记录可公开的仓库事实;本地用户名、绝对工作区路径、临时目录、凭据和个人邮箱均有意省略。 + +## 1. 交接结论 + +本轮已经把用户确认的四组设计决策实现到 +[mcpp-community/mcpp#400](https://github.com/mcpp-community/mcpp/pull/400): + +1. 包和模板统一使用 `[ns.]name[@version][:tname]`,省略 `ns` 时严格等于 + `mcpplibs`;`version` 和 `tname` 可省略;不引入 `--variant`;只有一个模板时, + 即使没有声明 `default = true`,它也自动成为默认模板。 +2. mcpp 不探测 GPU、Mesa、NVIDIA、WSL、ICD 或驱动来源。xlings/xim 负责图形栈和 + 运行时事实,mcpp-index 负责 C++ 包依赖关系,mcpp 只消费规范化后的 provider、 + artifact、provenance 和 link intent。 +3. mcpp 默认使用自己的运行时;根项目或 workspace 根可以在 `mcpp.toml` 中选择 + xlings SubOS。暂不提供 CLI override。SubOS 只是本次根构建/运行的本地 OS 环境, + 不传递为依赖约束,成员和源码库自己的 SubOS 声明不会污染消费者。 +4. AUR 自动化只收口 `mcpp-bin`。`mcpp-m` 的文件、远端和发布路径均未修改。 + +实现本身已经完成主要代码、单元测试、E2E、英文/中文文档、release manifest 和 +AUR reconciler。当前不能合并的主要原因不是上述核心实现,而是新“精确命名空间” +契约暴露了 xlings 仓库中仍然存在的裸 `ftxui`/`gtest` 声明。修复已经放在独立 +Draft PR [openxlings/xlings#521](https://github.com/openxlings/xlings/pull/521), +8/8 CI 通过,等待用户 review 和普通合并。 + +## 2. 关键入口和当前边界 + +| 项目 | 当前状态 | 说明 | +|---|---|---| +| mcpp 实施 issue | [#398](https://github.com/mcpp-community/mcpp/issues/398),OPEN | 冻结范围和验收条件;已更新为 Draft、逐 commit/checkpoint、隐私安全、普通合并 | +| mcpp 汇总 issue | [#397](https://github.com/mcpp-community/mcpp/issues/397),OPEN | C1-C9、特殊保留项和最终残留工作的统一入口 | +| mcpp Draft PR | [#400](https://github.com/mcpp-community/mcpp/pull/400),Draft | 本轮唯一 mcpp 实施 PR | +| scaffold 缺陷 | [#380](https://github.com/mcpp-community/mcpp/issues/380),OPEN | 代码已实现,仍需发布后二次验收才可关闭 | +| glibc 冲突 | [#392](https://github.com/mcpp-community/mcpp/issues/392),OPEN | 代码已实现,仍需发布后二次验收才可关闭 | +| Rule A/B | [#396](https://github.com/mcpp-community/mcpp/issues/396),OPEN | 代码已实现,仍需发布后二次验收才可关闭 | +| mcpp-index issue | [#196](https://github.com/mcpplibs/mcpp-index/issues/196),CLOSED | 修复不可变 `mcpplibs.capi.lua@0.0.3` 的裸 Lua 依赖 | +| mcpp-index PR | [#197](https://github.com/mcpplibs/mcpp-index/pull/197),MERGED | Form-B bridge 精确声明 `compat.lua@5.4.7`,10/10 CI 通过 | +| xlings issue | [#520](https://github.com/openxlings/xlings/issues/520),OPEN | 跟踪 xlings 裸兼容包声明 | +| xlings Draft PR | [#521](https://github.com/openxlings/xlings/pull/521),Draft | 精确声明 `compat.ftxui`、`compat.gtest` 并刷新 lock,8/8 CI 通过 | + +mcpp 分支为 `feat/template-runtime-graphics-aur`,基线为 +`80291ca01a982c1e8c00e43bfa97ffe68516e6d7`。核心实现和原生诊断 checkpoint 是 +`ed4cf64279a5da94c736742338e5b01d90e297b8`;随后增加中文交接、验证账本、公共发布身份, +以及不泄露本地路径的 exact-index 路由诊断。本文更新时远端 HEAD 为 +`9a47ccf5727a2756451812da2c98e18e1f034409`。相对基线共有 31 个可追溯 commit,改动 +111 个文件,约 15,049 行新增、1,692 行删除。接手时仍须重新比较本地与远端 HEAD, +不要把本文快照当成可变分支的永久 HEAD。 + +设计和验证文档按以下顺序阅读: + +1. `.agents/docs/2026-08-09-mcpp-template-runtime-graphics-aur-focused-design.md` +2. `.agents/docs/2026-08-09-mcpp-template-runtime-graphics-aur-implementation-plan.md` +3. `.agents/docs/2026-08-09-mcpp-template-runtime-graphics-aur-validation.md` +4. `.agents/docs/2026-08-09-xlings-mcpp-ecosystem-convergence-design.md` +5. 本文 + +## 3. 已完成的实现 + +### 3.1 包身份和模板语法 + +- 新增共享 `PackageSelector`,统一解析包依赖、`mcpp add`、xpkg 和模板 provider。 +- 裸名称只规范化为 `(mcpplibs, name)`,不再按短名称跨命名空间猜测。 +- 显式点分命名空间保持精确,例如 `mcpplibs.capi.lua`、`compat.ftxui`。 +- `mcpp add` 与 `mcpp new` 使用同一身份风格。 +- 模板语法固定为 `[ns.]name[@version][:tname]`;先解析 `:`,再解析 `@`。 +- `version` 省略时选择最新稳定版本;不会把 prerelease 当默认,也不接受模糊 + `latest` 别名。 +- `tname` 省略时:一个模板自动默认;多个模板必须有且仅有一个显式 default, + 否则给出确定性错误。 +- 旧 `pkg:` 列表写法仅保留一个迁移周期并打印可复制的新命令提示。 +- exact miss 会打印实际尝试的 canonical selector,并只把其它命名空间作为 + “did you mean” 诊断,不自动回退。 + +### 3.2 `mcpp new` 事务和跨平台安全 + +- 在访问配置、索引或网络前验证项目名称。 +- 拒绝绝对路径、路径分隔符、`.`/`..`、控制字符、Windows device basename、 + 尾随点/空格以及旧 `PROJECT` 占位名等不安全输入。 +- 模板变量改为单次渲染,插入值中的 `{{...}}` 不会被二次扫描。 +- 未知变量、未闭合 token、目录逃逸、symlink、类型冲突、读取/写入/复制失败都会 + 明确失败。 +- 在目标同级 staging 目录完整生成、关闭并同步文件、重新解析 manifest 后,才用 + no-replace rename 提交;失败不留下半成品目标或 staging 残留。 +- Linux、macOS、Windows 的独占提交分别放在平台实现中,不把平台 API 混入通用层。 + +### 3.3 根本地运行时和 SubOS + +- 引入纯 `RuntimeSelection`:只有 `McppDefault` 与 `NamedSubos` 两种状态。 +- 只有根项目/workspace 根的 `[xlings].subos` 生效;没有 CLI override。 +- workspace 成员独立运行时可以有自己的根选择;作为 workspace 成员或依赖被消费时, + 不传播自己的 SubOS。 +- 构建、运行、测试和快速路径共享一次解析出的不可变 `RuntimeBinding`。 +- binding 保存 owner、provider、runtime id、环境、路径和 contract hash;cache key + 包含该 hash,运行时配置变化会正确失效。 +- 旧 `MCPP_SUBOS_DIR` 不再改变选择结果。 +- xlings 环境的 `set`/`prepend` 语义保留调用方已存在变量并去重,不把显式空值误当缺失。 + +### 3.4 Linux 运行时物理约束(#392/#396) + +- 精确解析并查找 `glibc@`,不会从多个 payload 中取“第一个看起来能用的”。 +- 内部 ELF64-LE 读取器解析 `PT_INTERP`、`DT_RPATH/RUNPATH`、`DT_NEEDED`、 + GNU verneed/verdef,不调用外部 shell probe。 +- Rule A:host DSO 的 `GLIBC_*` 需求不得高于选中 libc 的 export floor。 +- Rule B:最终解释器、直接/传递 libc 必须与同一个 RuntimeBinding 一致,拒绝 host/private + 或两个 private libc 混用。 +- post-link verdict 与 artifact stat、RuntimeBinding contract 一起缓存;缓存 mismatch + 仍然保持失败,不能靠下一次运行重新探测变绿。 +- `mcpp doctor` 读取构建时已经保存的 verdict,不重新观察另一个当前 host。 +- 非 Linux 平台只保留 typed no-op 边界,不执行 ELF/glibc 规则。 + +### 3.5 图形栈和 provider provenance + +- mcpp 只接受结构化 `RuntimeRequirement`、`RuntimeProvider`、`RuntimeArtifact` 和 + `LinkIntent`。 +- 每条事实携带 canonical namespace/name/version/source/provenance;同名 provider + 不会相互覆盖。 +- xlings 选中的 runtime facts 优先于 descriptor fallback,并以完整身份排序,保证 + contract hash 与输入顺序无关。 +- link-time search 与 runtime search 分离:runtime dirs 不再错误进入 `-L`;ELF、Mach-O、 + PE 分别渲染自己的 link intent。 +- `resolution.json` schema 2 保存 binding、requirements、providers、artifacts、link intent + 和 post-link verdict。 +- `mcpp why runtime` 只读取保存结果,不启动探测命令。 +- 增加源码边界测试,防止 mcpp 后续引入 GPU、vendor、driver、ICD 探测分支;需要重新诊断 + host/provider 时,提示用户使用 `xlings doctor`。 + +### 3.6 release desired state + +- release workflow 新增不可变 `mcpp-release.json` schema 1。 +- manifest 由最终公开 GitHub release inventory 和实际下载 payload 重新计算,不信任上传 + 顺序或 sidecar 文本。 +- 四个现有平台是最低完整集合,未来符合命名契约的平台也必须进入 manifest,不能静默遗漏。 +- manifest 只允许首次创建;同名且字节不同会拒绝,rerun 只能验证相同内容。 +- `publish-ecosystem` 必须等待 manifest job 完成。 +- 版本已经准备为 `2026.8.9.1`;当前 mcpp 内部 xlings pin 为已发布的 + `2026.8.9.2`,待 #521 合并并发布后还需要更新到新 xlings 版本。 + +### 3.7 `mcpp-bin` AUR reconciler + +- 只管理 `mcpp-bin`;`mcpp-m` 和 `mcpp-git` 不在 reconcile verdict 或 push 范围。 +- 支持 release event、定时恢复和手动触发。 +- 选择最新完整稳定 release,验证 `mcpp-release.json`、两个 Linux payload 和 sidecar, + 再生成 `.SRCINFO`。 +- 使用 Arch `vercmp`,拒绝倒退;相同版本可检测并修复 drift。 +- clone、认证、AUR maintenance、RPC lag 都有明确分类和有界重试。 +- 只允许 fast-forward push,不提供 force、空仓库首次发布或旧 run 重跑旁路。 +- 已对公开 `v2026.8.8.4` 做真实 Arch `makepkg --printsrcinfo`/ + `--verifysource` dry-run;目标应为 `2026.8.8.4-1`,公开 AUR 观测仍是 + `2026.8.1.1-1`,本轮没有发布。 + +### 3.8 文档和用户契约 + +- 同步 README、中文 README、getting-started、manifest、toolchain、identity、AUR、release + 和 changelog。 +- 明确无 `--variant`、无 CLI SubOS override、SubOS 非传递、mcpp 不探测 GPU、AUR 只管理 + `mcpp-bin`。 +- `.agents/docs` 中保留详细设计、拆分计划和 RED/GREEN/CI/release 验证账本。 +- 已清理文档中的本地绝对路径和旧 squash/admin-bypass 描述;当前流程是 Draft、逐 commit、 + 用户明确 review 后普通合并。 + +## 4. Commit 追踪 + +以下顺序就是实现历史;不要 rebase、amend、squash 或 force-push: + +| Commit | 内容 | +|---|---| +| `52706c9` | 写入聚焦设计、实施计划和初始验证账本 | +| `527b26e` | 共享精确 PackageSelector,收口包身份 | +| `93db300` | 精确 TemplateSpec、版本和默认模板选择 | +| `dce8619` | 事务式、跨平台安全的项目脚手架 | +| `ac0670f` | 根本地 RuntimeSelection/RuntimeBinding | +| `7850702` | Linux ELF/runtime closure Rule A/B | +| `6904673` | provider-neutral runtime provenance 与 LinkIntent | +| `234a4df` | 不可变 release manifest | +| `08857c7` | `mcpp-bin` AUR desired-state reconciler | +| `dae4384` | 真实 SubOS view 与 RuntimeBinding 对齐 | +| `76fee4e` | 准备 `2026.8.9.1` 版本和 pin/docs | +| `04a348e` | E2E capability 正确识别 Python | +| `0108ee1` | ELF parser fixture 只在 Linux 执行 | +| `e6050b7` | 依赖精确继承 index 声明命名空间 | +| `d008a21` | 依赖选择保留 index owner identity | +| `c569152` | runtime closure 正确处理 ELF SONAME 复用 | +| `679c7ab` | E2E fixture 与精确身份契约对齐 | +| `65fc77b` | fake xlings fixture 提供真实 runtime contract | +| `e3b93ac` | exact miss 保留 version-floor 根因 | +| `1ef3112` | malformed exact descriptor 变为明确错误 | +| `4d8d080` | module mangling 使用实际 authored modules | +| `12b0b95` | macOS BMI E2E 只检查自身稳定 edge | +| `92caaf9` | Windows scaffold 测试清理前关闭文件流 | +| `4e39a8e` | Draft PR 流程和文档隐私清理 | +| `0cc6a2a` | libc poison fixture 尊重隔离 `MCPP_HOME` | +| `ed4cf64` | workspace 继承相对 index 时使用 lexical root anchor | +| `9f6161a` | 新增完整中文 PR 交接文档 | +| `6e42d6c` | 同步 Arch、CI 和跨仓库验证账本 | +| `cf39cb2` | AUR 使用 `speak-agent` 公共 noreply 身份,并增加隐私回归 | +| `189c6d1` | 记录隐私安全 AUR checkpoint 和 12/12 契约结果 | +| `9a47ccf` | exact miss 输出隐私安全的 index route 状态,供 Windows 原生定位 | + +每个逻辑 commit push 后都在 PR #400 留有 checkpoint。issue #380、#392、#396 也已经 +收到“实现已进入 Draft、但发布前不关闭”的状态评论。 + +## 5. 遇到的问题、根因和处理 + +### 5.1 精确身份暴露 xlings 的裸依赖 + +症状:Linux、macOS、Windows 和 aarch64 中“使用新 mcpp 构建 xlings”的任务报错: +`ftxui` 被严格解释为 `(mcpplibs, ftxui)`,而索引中的真实包是 `compat.ftxui`。 + +这不是应该在 mcpp 中恢复 fallback 的理由;恢复 fallback 会破坏用户确认的命名空间 +语义。处理方式分两层: + +1. mcpp-index #197 已把不可变 `mcpplibs.capi.lua@0.0.3` 的裸 Lua 依赖用 + index-side Form-B bridge 精确改为 `compat.lua@5.4.7`,已经正常合并。 +2. xlings #521 把根依赖改为 `compat.ftxui`、测试依赖改为 `compat.gtest`,并刷新 lock。 + 该 PR 仍是 Draft,8/8 CI 通过,不能 admin/bypass 合并。 + +### 5.2 RuntimeBinding 最初与真实 SubOS view 不一致 + +初版 binding 只信任声明 identity;真实安装中 loader/libc view 可能经过 xlings 重定位或 +版本视图转换,导致“声明 glibc”与实际闭包不一致。修复后,binding 从选中的 SubOS view +获取 canonical payload,并保留声明/provider provenance;缺失、过期或混合闭包都明确失败。 + +### 5.3 原有测试含 Linux 特有假设 + +- ELF parser synthetic fixture 在非 Linux 也执行:改为 typed platform skip。 +- Python E2E capability 通过错误方式推断:改为真实可执行能力判断。 +- fake xlings 只有元数据、没有 loader/libc 物理事实:补足真实 runtime contract,未增加产品 + fallback。 + +### 5.4 macOS 增量构建断言过宽 + +E2E #170 原先要求第三次构建“整个图没有任何工作”。macOS 合法重建生成的 iOS init/link +edge,但 stage/main BMI 已经稳定。测试现在只约束它负责验证的 stage/main edge,避免把 +平台合法工作误判成 BMI 回归。最新 macOS ARM64 E2E 已通过。 + +### 5.5 Windows 清理和 workspace path identity + +- 单元 fixture 在 `remove_all` 前仍持有两个 `ifstream`;Linux 允许删除打开文件,Windows + 拒绝。修复为在清理前结束 stream lifetime。 +- workspace 根 `[indices] acme = { path = "index" }` 在 Windows 上经 + `weakly_canonical` 后可能因为 short-name/case alias 与成员视图失去字符串身份。现在使用 + `(workspace_root / relative_path).lexically_normal()`,既把相对 index 锚定到根,又不改变 + 用户声明的 lexical identity。新增 focused unit,相关本地 E2E #12 通过;但 + `ed4cf64` 的原生 Windows E2E 2/2 仍在 workspace member 的 `mcpp add + acme.util@2.0.0` 失败,因此该提交不是完整修复。 +- 失败 job 为 run `31321961040` / job `93266267511`;错误只有 `package 'acme.util' + not found in any configured index` 和 `tried: (acme, util)`。同一原生 Windows 矩阵的 + `PmIndexRoute.WorkspaceMemberReadsRootAnchoredRelativeIndex` 已执行并通过,说明纯单元 fixture + 没有覆盖真实进程/工作目录视图。 +- 已下载该 job 构建的 Windows artifact,并在隔离 Wine 环境中同时用手工 member 和 + `mcpp new m1` 重放;两条 `mcpp add acme.util@2.0.0` 都通过,所以不能把 Wine 结果外推为 + GitHub Windows 已修复。 +- `9a47ccf` 增加永久、隐私安全的 `route:` 诊断,只报告 local index 的 root/`pkgs` + present/absent,不输出盘符、用户目录或绝对路径。下一轮原生日志将区分 workspace + inheritance、路径锚定和 descriptor read 三层;拿到证据前不猜测修复。 + +### 5.6 隔离 HOME fixture 使用错误根 + +E2E #156 用 `$HOME/.mcpp` 注入旧 libc,但测试实际选择了 `MCPP_HOME`。因此它先污染了当前 +mcpp,而没有进入目标 nested-tool regression。fixture 现在使用 +`${MCPP_HOME:-$HOME/.mcpp}` 对应的状态根,精确重跑通过。 + +### 5.7 本地无法替代原生 Windows 验证 + +曾尝试用隔离环境的 MinGW cross toolchain 构建当前 mcpp,以更快复现 Windows 行为;构建 +停在现有 GCC module 与 `windows.h` language-linkage 冲突,未修改产品代码,也未污染 host。 +因此 Windows 结论只接受 GitHub 原生 runner 终态,不从 Linux source review 或 cross-build +推断。 + +### 5.8 AUR 外部状态不稳定 + +第一次真实 Arch 容器拉取受到 Docker Hub registry header timeout,按 transient 记录,未 +伪装成通过。随后真实 Arch dry-run 已完成并验证两个 Linux 源。公开 AUR 仍落后,之前的 +发布任务遇到 AUR SSH maintenance;本轮没有绕过、force-push 或手工覆盖远端。 + +## 6. 已有验证证据 + +### 6.1 本地代码和 E2E + +- 最新源码完整 C++ unit/integration:`72/72`,0 fail。 +- 一轮隔离 Linux 全量 E2E:186 pass、1 fail、19 个平台/能力 skip。唯一失败是 #156 的 + fixture 根错误,修复后精确重跑通过。 +- `ed4cf64` 后精确重跑 #12 和 #156 通过,完整 72/72 unit 再次通过。 +- `9a47ccf` 按 TDD 先得到缺少 `IndexRoute::describe` 的 RED;实现后 focused + `PmIndexRoute` 12/12、真实 E2E #12 通过,且诊断不包含本机路径。该提交尚未重新宣称 + 最新源码全量 unit/E2E 均通过。 +- 重点 E2E 已覆盖 package/template、事务 scaffold、RuntimeBinding、root-local SubOS、 + Rule A/B、provider provenance、stored `why runtime`、AUR/release 脚本。 +- release manifest contract:12/12。 +- AUR state-machine/contract:12/12,包括公共 `speak-agent` 发布身份契约。 +- 所有变更 shell 通过 `bash -n`,Python generator/tests 通过 bytecode compile,workflow YAML + 可解析,`git diff --check` 通过。 +- 公开 `v2026.8.8.4` 四平台 payload/sidecar replay 生成的 manifest 字节稳定;manifest + SHA256 为 `6614ba2db65c8c28cb5a9d3466bd6cf3007634221da76d25216785061c647edb`。 + +不要把上述“一轮全量后精确修复”写成“最新 HEAD 已重新全量 206 个 E2E 全绿”。最终完整 +结论仍需要 latest-head CI 和发布后二次验证。 + +### 6.2 边界未被污染 + +- `scripts/aur/mcpp-m/**` 聚合 SHA256 在实现前后都为 + `afb8a647e04483a86985119e07086016f49d55f177ee6257094c336d226113c6`。 +- host xlings 配置聚合 SHA256 在状态化测试前后都为 + `f218aadf3792ee815c8535ce0ca0bb53f634fecbdbc5d0d47db442052d786d1b`。 +- 原始 checkout 的用户改动未被暂存、覆盖或清理;实施在隔离 worktree 中进行。 +- PR diff 已扫描常见 Linux/macOS/Windows home path、runner 用户名和临时目录模式,未发现 + 本地路径泄露。 + +### 6.3 GitHub CI 快照 + +以下是 `ed4cf64` 的终态快照;共 12 success、5 failure、1 cancelled。cancelled 不是 pass: + +| 状态 | Job | +|---|---| +| PASS | hermetic Linux E2E | +| PASS | macOS ARM64 E2E | +| PASS | musl + LLVM toolchain | +| PASS | MinGW Linux→Windows build + Wine run | +| PASS | Windows→Linux cross-build及 Linux artifact run | +| PASS | Linux unit、GCC cold toolchain、Linux E2E 1/2 和 2/2 | +| PASS | Windows build/unit/package、Windows E2E 1/2 | +| FAIL(已知外部边界) | Linux 构建/运行 xlings | +| FAIL(已知外部边界) | macOS xlings LLVM E2E | +| FAIL(已知外部边界) | aarch64 mcpp + xlings | +| FAIL(已知外部边界) | Windows toolchains + regressions 中的 xlings 构建 | +| FAIL(mcpp 内部,仍在定位) | Windows E2E 2/2 的 workspace root local index | +| CANCELLED | bare Windows/no-Visual-Studio;被后续 push 的 concurrency 取消,不作结论 | + +四个已知失败都发生在构建当前 xlings source 时的裸 `ftxui` exact miss;对应 #521, +不能在 mcpp 中用 fallback 掩盖。Windows E2E 2/2 是独立的 mcpp 问题,不能归入 #521; +`9a47ccf` 的 latest-head 原生矩阵需先给出 route 状态,再做下一笔最小修复。 + +## 7. 尚未完成 + +以下项目必须明确交接,不能因为“代码已经很多”而省略: + +1. **等待 `9a47ccf` 的 mcpp latest-head CI 证据。** 特别读取 Windows E2E 2/2 的 + `route:` 行;`ed4cf64` 已经由原生 runner 证明没有完整解决根相对 index。 +2. **用户 review xlings #521。** 8/8 CI 已通过,但仍为 Draft/REVIEW_REQUIRED。 +3. **普通合并 xlings #521。** 不使用 admin、bypass、squash、force 或历史改写。 +4. **发布新的 xlings 稳定版本。** 当前 `2026.8.9.2` 不含 #521;需要正常 version bump、 + tag、全平台 release CI 和资产核验。 +5. **更新 mcpp #400 的 xlings pin。** pin 到包含 #521 的新稳定版本,作为独立 commit、push + 和 checkpoint;然后重新跑 latest-head mcpp 全矩阵。 +6. **补最终交付账本。** 更新 `.agents/docs/...validation.md` 中过时的 CI、Arch 和跨仓库 + “not started” 段落,记录终态 job/run、PR/merge commit、release asset/checksum。 +7. **用户 review mcpp #400。** 只有用户明确同意后才能从 Draft 转 ready,并走普通合并。 +8. **发布 mcpp `v2026.8.9.1`。** 验证 tag、版本字符串、所有平台资产、sidecar、最终公开 + inventory 和不可变 `mcpp-release.json`;running/cancelled/superseded 不算通过。 +9. **GitCode/生态镜像。** 按发布边界补 GitCode resource,验证 ranged/full download 和 hash; + 必要时更新 xim-pkgindex,但不把 provider policy 搬进 mcpp。 +10. **AUR `mcpp-bin` 收敛。** 从合并后的固定 main/release 状态运行 reconciler,等待 AUR + maintenance 恢复,验证 RPC 可见、`.SRCINFO`、两个架构 checksum 和 fresh install。 +11. **全生态 fresh-home 验证。** 在隔离 HOME/XLINGS_HOME/SubOS root 中验证:安装、 + `[ns.]name[@version][:tname]` 模板、唯一默认模板、多个 SubOS/glibc、build/run/test、 + OpenGL/Vulkan provider provenance 的 PASS/FAIL/NOT_EXERCISED 表达。 +12. **issue 收口。** #398 随 PR 合并关闭;#380/#392/#396 只在发布后二次验收通过后关闭, + 并留言精确 release/测试证据。残留工作统一写回 #397。 +13. **原始 issue/PR 全量整理未完成。** C1-C9 和其它开放 PR/issue 的最终关闭/合并仍需按 + #397 清单继续;特殊保留 #43、#260 以及标记为保留/Draft/do-not-merge 的项不要动。 + +## 8. 推荐接手顺序 + +1. 确认 `gh api user --jq .login` 返回 `speak-agent`。 +2. 检查 PR #400 本地 HEAD、远端 HEAD 和 clean worktree;不要在原始用户 checkout 上工作。 +3. 等 `9a47ccf` 的 Windows E2E 2/2 输出隐私安全的 `route:` 状态;只按原生证据修复。 + 四个裸 `ftxui` 失败归入 #521,其余失败必须单独 RED/根因/修复/commit/checkpoint。 +4. 把最新终态快照评论到 #400;不要重复 rerun 已被新 HEAD 取代的旧 workflow。 +5. 请用户 review #521;得到明确同意后,转 ready 并普通合并,随后按 xlings release skill + 正常发版。 +6. mcpp 独立 commit 更新新 xlings pin,push 后评论 checkpoint,再等待新的 latest-head + 全矩阵。 +7. 所有代码和依赖 gate 通过后,独立 commit 更新验证账本;做 diff/privacy/mcpp-m/host-config + 边界复核并 push/comment。 +8. 把本文“尚未完成”逐项清零,再请求用户 review #400;没有 review 不转 ready。 +9. 普通合并、release、GitCode、AUR、fresh-home 生态验证完成后,再按证据关闭 issue。 + +## 9. 每个后续改动的强制流程 + +每个逻辑变化都执行同一套流程: + +1. 先写或选中能复现问题的 focused test,记录 RED; +2. 做最小实现,记录 GREEN; +3. 跑相关回归和 `git diff --check`; +4. 扫描 diff,确保没有本地用户名、绝对路径、临时目录、个人邮箱或凭据; +5. 只暂存明确文件,不使用宽泛 `git add -A`; +6. 使用 `speak-agent` 的 GitHub noreply commit identity; +7. 一个逻辑变更一个 commit,立即 push 到 Draft PR; +8. 立即在 PR 留中文 checkpoint:问题、根因、改动、测试、当前阻塞; +9. 不 amend/rebase/squash/force-push,不使用 admin/bypass; +10. pending、skipped、cancelled、superseded 任务绝不记为 PASS。 + +## 10. 不应改变的产品边界 + +- 不恢复跨命名空间短名称 fallback。 +- 不增加 `--variant`。 +- 暂不增加 CLI SubOS override。 +- 不把 SubOS 作为库的传递依赖要求。 +- 不让 mcpp 探测 GPU、Mesa、NVIDIA、WSL、ICD 或驱动来源。 +- 不把 xlings/xim provider policy 复制进 mcpp-index 或 mcpp。 +- 不修改或发布 `mcpp-m`。 +- 不碰明确特殊保留的 issue/PR。 +- 不在用户未 review 时把 Draft 转 ready 或合并。 + +## 11. 当前可供 review 的核心点 + +用户 review 时建议优先确认以下五点: + +1. `ns` 省略严格等于 `mcpplibs` 是否符合预期,包括 exact miss 不 fallback。 +2. 唯一模板自动默认、多模板显式 default/tname 的错误边界是否足够直观。 +3. RuntimeBinding 是否真正只由根/workspace 根选择,成员和依赖完全不传播 SubOS。 +4. 图形栈是否保持“xlings/xim 诊断和选择,mcpp-index 描述关系,mcpp 只构建”的责任分层。 +5. `mcpp-bin` reconciler 是否满足幂等、单调、checksum、fast-forward-only,同时完全隔离 + `mcpp-m`。 + +在这五点确认前,PR #400 和 #521 都应保持 Draft。 diff --git a/.agents/docs/2026-08-09-xlings-mcpp-ecosystem-convergence-design.md b/.agents/docs/2026-08-09-xlings-mcpp-ecosystem-convergence-design.md new file mode 100644 index 00000000..01cd8c34 --- /dev/null +++ b/.agents/docs/2026-08-09-xlings-mcpp-ecosystem-convergence-design.md @@ -0,0 +1,985 @@ +# xlings × mcpp 生态契约收敛与优化设计 + +> 状态:Review Draft +> +> 日期:2026-08-09 +> +> 基线:mcpp main@80291ca01a98;xlings 最新发布 2026.8.9.2;mcpp 发布包当前内置 xlings 2026.8.8.1。#397 的跨仓证据快照为 mcpp-index b86fc7c、xim-pkgindex c0aded29;2026-08-09 复核的远端 main 分别为 b86fc7ce802938,设计不依赖本地旧 checkout。 +> +> 范围:设计与迁移方案,不代表本文所列实现已经完成。 +> +> 关联问题:[#397(C1–C9 汇总)](https://github.com/mcpp-community/mcpp/issues/397)、[#396](https://github.com/mcpp-community/mcpp/issues/396)、[#392](https://github.com/mcpp-community/mcpp/issues/392)、[#380](https://github.com/mcpp-community/mcpp/issues/380)。 + +## 1. 结论先行 + +这些问题不是九个独立 bug,也不是“再补一轮 if”能稳定解决的问题。它们共同暴露了五类边界不清: + +1. **身份边界不清**:namespace:name 在 CLI、模板、索引、渲染器之间被拆掉或退化为裸 name。 +2. **状态边界不清**:构建缓存只记“输入看起来没变”,却没有声明一次成功必须留下哪些输出与证明。 +3. **运行时边界不清**:xlings、SubOS、索引和 mcpp 都能推断或改写运行时环境,却没有统一、可持久化、可验证的运行时契约。 +4. **策略边界不清**:用户构建时的物理可运行性、索引仓库的生态闭包策略、GPU/宿主探测混在同一层。 +5. **发布边界不清**:GitHub Release 是事件源,AUR/Homebrew 等下游却按“一次事件必成功”实现,缺少最终一致性对账。 + +建议采用 **分层、类型化、可验证契约**,而不是把 xlings 扩成一个全知守护进程: + +- **xlings** 是环境与运行时 substrate:安装、版本选择、payload provenance、SubOS、RuntimeBinding、环境操作语义。 +- **xim-pkgindex** 是系统级 payload 与宿主桥接策略:glibc、Mesa、NVIDIA/WSL sentinel、图形运行时闭包。 +- **mcpp-index** 是 C/C++ 包、模板和链接元数据:模块、头文件、link plan、GUI 模板与平台依赖声明。 +- **mcpp** 是项目规划器与产物验证器:构建图、缓存后置条件、模板事务、实际二进制物理检查、可解释诊断。 +- **发布流水线** 是投影对账器:以不可变 Release Manifest 为源,将 AUR 等渠道收敛到期望状态。 + +推荐方案的核心不是“增加更多配置”,而是减少重复推断:每个事实只有一个所有者,其他组件消费版本化契约。 + +## 2. 目标与非目标 + +### 2.1 目标 + +- 将 C1–C9、#380、#392、#396 收敛到少量可复用的根契约。 +- 让 mcpp newmcpp add、索引和生成后的清单使用同一套包身份。 +- 让构建“成功”同时意味着必要产物、元数据和运行时证明完整。 +- 让私有 glibc、宿主库、图形运行时的来源和兼容性可计算、可解释、可缓存。 +- 支持 Linux、macOS、Windows 的真实平台差异,不制造虚假对称。 +- 让发布渠道从一次性推送转成幂等对账,外部服务恢复后可自动收敛。 +- 保持无变化构建快速,不在构建热路径启动 xlings 或访问网络。 + +### 2.2 非目标 + +- 不在 mcpp 中实现 GPU 驱动管理。 +- 不要求每个 GUI 测试都在普通共享 runner 上打开真实窗口。 +- 不用 xlings daemon 替代 mcpp 的项目图规划。 +- 不把索引闭包策略变成用户本地每次构建的强制联网检查。 +- 不以一个跨四仓库的巨型 PR 交付。 +- 本文不直接关闭 issues、重跑 AUR 工作流或发布新版本。 + +## 3. 当前事实与根因 + +### 3.1 C1–C9 不是同一优先级,但可以归并 + +| 编号 | 当前现象 | 直接根因 | 应归入的长期契约 | 优先级 | +|---|---|---|---|---| +| C1 | fast path 在 compile_commands.json 等配置产物缺失时仍可直接进入 Ninja,编辑器反复请求;普通 executable 缺失通常仍由 Ninja 重建 | 缓存没有声明配置成功的后置条件 | BuildSnapshot + OutputManifest | P1 | +| C2 | 注释中的 R"(、普通字符串中的注释记号会污染模块扫描状态 | 先做脆弱的行级 strip,再解析 token | 线性词法 masker | P1 | +| C3 | TTY 下 --no-color 无效 | color 状态延迟初始化覆盖显式参数 | 一次初始化的 UiPolicy | P2 | +| C4 | namespaced 包 exports 非 strict 时漏检,strict 时会把合法 exports 误报 missing | qualified owner 与短 manifest name 做字符串比较 | PackageId 类型贯穿 | P1 | +| C5 | Windows 版本探测含 POSIX 2>/dev/null | 命令以 shell 字符串表达 | CommandSpec + direct exec | P1 | +| C6 | 部分安装形态下 release 内置 xlings 不进入候选更新链 | 候选只按位置推断,没有 provenance/capability | XlingsCandidate 选择策略 | P1/P2 | +| C7 | xlings 端已按 presence 语义修复,但 mcpp 仍把 op=set 无条件覆盖 | reader 与当前 wire 语义漂移,且缺少显式 replace | EnvOp schema v2 | P1 | +| C8 | 两段无版本安装循环在进入 fetch 前即失败并吞掉错误;当前用户可达影响尚未证明 | 多个安装 owner、错误被忽略 | 单一 solver/installer owner | P2 | +| C9 | 路径、源输出、glob、脚手架、链接、版本、canary、CI、机器输出等 11 个长期项 | 同一事实多处生产、字符串协议与 fail-open | 分别映射到下表所列基础契约 | P1–P3 | + +C9 的 11 项应明确归属,而不是留作“杂项”: + +| C9 项 | 归属 | +|---|---| +| Windows 路径与 action 规范化(#393) | typed Path + CommandSpec | +| generated source output 被静默排除(#393) | OutputManifest + action postcondition | +| 默认 source glob 漂移(#386) | 单一 SourceSet producer | +| 脚手架安全与卡死(#380) | NameAtom + 单次渲染 + 事务目录 | +| runtime link order / macOS runtime dir(#304) | LinkPlan token + 平台 capability | +| prerelease 依赖解析(#370) | 单一 VersionReq parser | +| cfg version 被忽略(#290) | 封闭条件词汇;未知条件 hard error | +| operator-template canary 只 precompile、不 import(#256) | 真实 importer + crash/unsupported/fail 三态 | +| cppfly resolver 后续候选不可达、canary 可自我跳过(#215) | 遍历全部候选 + fail-closed capability gate | +| fresh install 被缓存掩盖(#259) | 冷 HOME 独立 gate | +| machine output 不一致(#379) | UiPolicy + WireEnvelope | + +其中 #386 的“四项 fallback glob 与七项 canonical 默认不一致”是已证实的代码漂移,但正常 xpkg 装载路径已补默认并要求 sources 非空;当前用户可达性尚未建立,应作为 P3 清理而不是阻塞前两阶段。 + +### 3.2 #380:输入验证只是第一层,真正缺的是脚手架事务 + +当前 mcpp new 可接受路径穿越、控制字符和会重新引入占位符的名称;builtin renderer 对含 PROJECT 的替换值可静态证明无限循环,package-template renderer 虽推进游标,不会以同一种方式无限循环,但仍会发生占位符相互消费。两条路径都有 I/O 错误被忽略和半成品风险。修复不能只加一个正则: + +- 项目逻辑名必须是一个 NameAtom,与目标目录 --dir 分离。 +- 新项目默认执行 portable policy:拒绝绝对路径、./..、任一平台路径分隔符、NUL/控制字符、Windows 保留设备名及尾随点/空格;已有 legacy manifest 继续可读,不借此批量改写用户身份。 +- namespace 是结构化字段,不允许塞回项目名或模板字符串。 +- 渲染必须单次完成;未知占位符、重复键、非法 TOML/C++ 标识均 hard error。 +- 先写同父目录临时目录,验证生成清单与必需文件,再原子 rename。 +- 任一步失败清理临时目录;目标已存在默认不覆盖。 + +### 3.3 #392 与 #396:问题是“实际装载物理”,不是版本号猜测 + +#392 展示了私有 glibc 与宿主 libtinfo/Mesa 混装时的两个失败方向: + +- 私有 loader/libc 较旧,宿主库要求更高 GLIBC symbol version。 +- 通过全局 LD_LIBRARY_PATH 暴露私有 glibc,又让外部宿主命令加载到错误 libc/私有符号。 + +当前 main 已有隔离私有 glibc 环境的局部缓解,因此不能表述成“完全未修”。但仅靠目录过滤仍不能证明最终 ELF 的实际闭包可运行。 + +另一个独立的不确定性是 post-install 的 sandbox glibc 查找:注释声称选择 newest,代码实际返回 directory_iterator 的第一个命中;该顺序未规定,不能描述为“字典序第一”。目标设计必须按完整 PackageId/version/RuntimeBinding 选择,不从目录枚举顺序推断。 + +#396 中应保留两条物理不变量,但修正 Rule A 的计算方式: + +- **Rule B — 单一 RuntimeBinding**:实际 PT_INTERP 与最终解析到的 libc 必须来自同一个 payload/runtime binding。 +- **Rule A — symbol ceiling**:对每一个实际借用的宿主 ELF,对其 .gnu.version_r 所需的最高 GLIBC 版本逐对象检查,不用粗糙的“私有 glibc 版本 ≥ host glibc 版本”替代。 + +生态策略另行定义: + +- **Policy D — 生态闭包**:索引包的运行时依赖应由生态包闭包满足;宿主对象只允许出现在显式、类型化的 host-link sentinel 后。 +- D 在索引 CI/安装物化时强制。用户构建的最终目标是对**已证明**的 A/B 冲突 hard fail,并清晰展示 provenance;在精确闭包解析器积累真机证据前,按 observe → warning → strict opt-in → 默认 strict 分阶段,不能拿粗略的 host glibc 代理在第一天全局 hard fail。 + +这一区分很重要:A/B 是物理事实,D 是仓库治理策略。 + +### 3.4 模板 namespace:现有语法先天歧义 + +当前 --template 解析 pkg@ver:template,第一个冒号被当作 template 分隔符。因此 ocornut:imgui 会被理解为 package=ocornut、template=imgui,无法表达规范身份。scaffold resolver 又只尝试空 namespace 与 compat,没有复用依赖路径的 IndexRoute;裸 imgui 会先命中冻结的 mcpplibs:imgui,而不是新的 ocornut:imgui,也不会报告跨 namespace 候选歧义。 + +同时,模板获取阶段即使从 descriptor 得到 namespace,返回值和渲染变量仍只保留裸 name;依赖注入也用字符串搜索和短名。这会重新引入此前包索引已经修复的同名碰撞。 + +正确方向是复用 mcpp add 已有身份语法,而不是再发明第三套压缩分隔符: + +~~~text +mcpp new app --namespace acme \ + --template ocornut:imgui@1.92.8 \ + --variant glfw-opengl3 \ + --index official + +mcpp new app --template mcpplibs:templates@0.0.1 +mcpp new --list-templates ocornut:imgui@1.92.8 +~~~ + +- --template:规范 PackageRef,即 namespace:name@version。 +- --variant:该 provider 内的模板变体。 +- --namespace:新项目自身 namespace。 +- --dir:文件系统目的地。 +- --index:路由来源;index alias/path/URL 不属于 PackageId。 +- 旧 pkg@ver:template 只作为 deprecated ingress,解析后立即规范化并告警。 +- 对 pkg:word,新语义“namespace:name”与旧语义“pkg 的 variant”可能同时成立:只在一侧候选唯一时兼容;两侧都存在或无法排除时返回稳定的 ambiguity error,并给出可复制的显式 --template/--variant 写法。 +- builtin bin 可保留兼容 alias,但内部也使用明确的 builtin provider identity。 + +可选的紧凑糖 namespace:name@version#variant 没有冒号歧义,但建议在显式 flags 与 wire model 稳定后再决定;内部始终是 TemplateSelection,不让紧凑语法成为第三种身份。 + +### 3.5 图形程序:当前已有正确积木,但责任仍有重复 + +当前生态已经包含: + +- mcpp-index 的 ocornut:imguicompat.glfwcompat.glx-runtime。 +- xim-pkgindex 的 xim:graphics、Mesa,以及 NVIDIA/WSL host-link sentinels。 +- Linux Mesa 的软件和多类硬件 backend;macOS native frameworks;Windows Win32/system SDK 路径。 + +问题在于 compat.glx-runtime 仍需要从 SubOS view 选择并 symlink GL 库到自己的 runtime 目录。这是过渡桥,而不是最终模型。最终应让 xlings 持久化可传递 runtime exports,mcpp 直接消费 resolved runtime contract;mcpp-index 只声明依赖,不复制 xlings 的视图布局。 + +Vulkan 还存在更明显的旁路:compat.vulkan-runtime 仍从 host 的 /lib*//usr/lib* 收集 ICD 和 transitive DSOs,测试主要覆盖 loader API,没有证明真实 ICD/device、来源或 GLIBC 闭包。GL 的收敛方案必须同时覆盖 Vulkan loader、ICD manifest 与 driver;否则只是把同类风险移到另一个 API。 + +### 3.6 AUR:外部维护是触发原因,缺少对账才是长期漂移原因 + +2026-08-09 的只读审计显示: + +- GitHub 最新 release 是 [v2026.8.8.4](https://github.com/mcpp-community/mcpp/releases/tag/v2026.8.8.4),AUR RPC 中 mcpp-binmcpp-m 均停在 2026.8.1.1-1,相差 20 个正式 release。 +- 最后成功的 aur-publish run 是 [30649165652](https://github.com/mcpp-community/mcpp/actions/runs/30649165652);从 [30718043364](https://github.com/mcpp-community/mcpp/actions/runs/30718043364) 起到 2026-08-08,共核对到 18 个同类失败、4 个因 release gate skipped;最新失败是 [31254088758](https://github.com/mcpp-community/mcpp/actions/runs/31254088758)。 +- 失败日志均已完成版本与 sha 刷新,随后在 AUR SSH 阶段收到 The AUR is down due to maintenance 并退出 128。 +- 当前 workflow 只有 release 完成事件与手工触发;没有 retry、schedule/reconcile、推送后验证。 + +还存在一个必须先修的独立 P0: + +- scripts/aur/update.sh:65 只匹配单行 sha256sums=('...')。 +- scripts/aur/mcpp-m/PKGBUILD:34-35 是两行数组,所以 source checksum 没有被更新。 +- workflow 生成的 .SRCINFO 使用新 checksum,而 PKGBUILD 仍保留旧 checksum,形成同一提交内部不一致。 +- 当前顺序先推 mcpp-bin 再推 mcpp-m;AUR 恢复后直接重跑可能先产生部分成功,再尝试推送坏的 mcpp-m 元数据。 + +因此不能把“重跑工作流”作为修复。应先增加本地结构化更新与一致性 gate,再恢复发布。 + +### 3.7 多视角评估 + +| 视角 | 当前风险 | 评价 | 目标变化 | +|---|---|---|---| +| 架构 | 高 | xlings、probe、post-install、build plan、run env 多次推导 runtime truth;scaffold 又绕开 canonical resolver | 单一 owner + versioned contract + typed snapshot | +| 稳定性 | 高 | fail-open、忽略 I/O/error_code、配置副作用未入 manifest;外部发布一次失败即永久漂移 | transaction/postcondition/reconciler | +| 兼容性 | 高 | GLIBC 风险取决于实际 DSO;namespace、EnvOp、runtime dirs 在读写端语义漂移 | exact artifact proof + schema negotiation | +| 优雅简洁 | 中高 | CLI 表面不算大,但内部靠短名、目录顺序、字符串命令和目录复制造成隐性复杂度 | 保持少量 CLI,内部用 PackageRef/CommandSpec/LinkIntent | +| 用户体验 | 高 | 成功可能留下半状态;错误只报 GLIBC/包不存在,无法说明来源;机器输出不稳定 | 原子操作、canonical ref、why provenance、稳定 envelope | +| 性能 | 中 | 现有 fast path 很快,但把完整性排除在“命中”之外;runtime/版本探测可能重复 | 本地 hash snapshot、按产物增量验证、无热路径 xlings/network | +| 多平台 | 高 | POSIX shell 片段泄漏到 Windows;Mach-O runtime dirs 与 Linux ELF 语义混用;硬件结论常由 headless/cross build 代替 | LinkIntent 平台 lowering + native cold-home/hardware evidence | + +这里的目标不是用“更严格”换“更慢”。类型化契约让昂贵解析只发生在安装、configure 或 changed artifact 上,hot no-op 只校验 fingerprint 与 required outputs。 + +## 4. 三种架构选择 + +| 方案 | 架构 | 稳定性 | 兼容性 | 简洁性 | 性能 | 多平台 | 结论 | +|---|---|---|---|---|---|---|---| +| A. 按 issue 打补丁 | 局部修改快,但同一事实继续多处推断 | 短期变绿,回归概率高 | 表面影响小,长期漂移大 | 初看简单,维护复杂 | 可维持当前热路径 | 平台分支继续散落 | 只用于 P0 止血 | +| B. xlings 全知 daemon | 所有解析/探测集中 | daemon 生命周期与状态成为新故障域 | 强耦合 xlings 版本 | 接口表面少,系统更重 | 构建热路径多进程/IPC | Windows/macOS 服务语义复杂 | 不采用 | +| C. 分层 typed contracts | 每个事实单一 owner,边界清楚 | 可校验、可缓存、可回滚 | 支持 schema/legacy ingress | 数据模型略增,重复逻辑显著减少 | 构建只读本地契约 | 原生 provider 表达差异 | **推荐** | + +方案 C 可以允许 P0 局部修复先落地,但所有 P0 修复都应朝目标契约收敛,不能制造第二套临时协议。 + +## 5. 目标架构 + +~~~mermaid +flowchart LR + XI[xlings
install / SubOS / RuntimeBinding] --> RC[Runtime & Install Contract] + XP[xim-pkgindex
system payload / host sentinel] --> XI + MP[mcpp-index
C++ package / template / LinkPlan] --> MC[mcpp
planner / builder / inspector] + RC --> MC + MC --> OM[BuildSnapshot / OutputManifest] + MC --> ELF[Artifact Physics Verdict] + GH[Immutable GitHub Release Manifest] --> REC[Channel Reconciler] + REC --> AUR[AUR mcpp-bin / mcpp-m] + REC --> OTHER[other package channels] +~~~ + +### 5.1 单一事实所有者 + +| 事实 | 唯一 owner | 消费者 | +|---|---|---| +| 包 canonical identity、版本与安装 payload | xlings/libxpkg | mcpp、索引工具、模板 | +| runtime binding、loader/libc、exports、env op | xlings + xim package descriptor | mcpp | +| C/C++ source/module/link/template metadata | mcpp-index | mcpp | +| 项目 build graph 与 required outputs | mcpp | IDE、CI、用户 | +| 实际 ELF 闭包与兼容 verdict | mcpp post-link inspector | 用户、CI、缓存 | +| GitHub release 资产与 checksum | Release Manifest | AUR/Homebrew/其他渠道 | +| AUR 当前版本 | AUR | reconciler,仅作为 observed state | + +### 5.2 不可违反的不变量 + +1. **Identity**:内部永远用结构体,不用拼接字符串承担身份。 +2. **Success**:返回成功前,声明的 required outputs 必须存在并通过验证。 +3. **Runtime**:loader、libc 与解析到的共享库必须能追溯到 provider。 +4. **No hot-path orchestration**:构建热路径不启动 xlings、不访问索引网络。 +5. **Atomicity**:cache snapshot、模板目录、投影元数据和渠道更新必须原子提交或明确部分失败。 +6. **Fail closed on ambiguity**:同名候选、多 payload、未知 cfg/placeholder 不按目录顺序或短名猜。 +7. **Native evidence**:Windows/macOS/aarch64 行为只由对应 runner 证明。 + +## 6. 核心数据契约 + +### 6.1 Identity types + +建议共享概念定义,语言实现可独立: + +~~~text +NameAtom = one validated identifier atom +NamespacePath = one or more NameAtom segments +PackageId = { namespace: NamespacePath, name: NameAtom } +PackageRef = { id: PackageId, versionReq?: VersionReq } +ResolvedPackageId= { id: PackageId, version, indexRoute, descriptorDigest, payloadDigest } +ProjectIdentity = { namespace: NamespacePath, name: NameAtom, version } +TemplateSelection= { provider: PackageRef, variant?: NameAtom } +~~~ + +规则: + +- wire/display 规范形式是 namespace:name。 +- bare name 只能在候选唯一时作为便捷输入;歧义必须列出候选并失败。 +- legacy FQN 只在 ingress 解析一次;之后不再拆字符串。 +- map/cache key 使用完整 PackageId。 +- qualified display 由类型派生,不在各层自行加前缀。 +- index route 和 transport provenance 随 ResolvedPackageId 保留用于诊断/lock,但不混进 PackageId 本身。 + +### 6.2 xlings Runtime & Install Contract v2 + +xlings 在安装/物化时写入 versioned、只含相对路径的契约。可以演进现有安装描述文件,也可以使用专用文件;名称不是本文的关键决策,schema 是。 + +建议字段: + +~~~json +{ + "schema": 2, + "package": {"namespace": "xim", "name": "glibc", "version": "2.44"}, + "payload": { + "id": "content-addressed-id", + "digest": "sha256:...", + "root": "." + }, + "runtimeBinding": { + "id": "linux-glibc-2.44-x86_64", + "platform": "linux", + "arch": "x86_64", + "loader": "lib/ld-linux-x86-64.so.2", + "libc": "lib/libc.so.6", + "glibcProvidedCeiling": "2.44" + }, + "exports": { + "includeDirs": [], + "libraryDirs": ["lib"], + "runtimeDirs": ["lib"], + "crtObjects": [] + }, + "runtimeDependencies": [ + {"namespace": "xim", "name": "ncurses", "version": "..."} + ], + "environment": [ + {"name": "PATH", "op": "prepend", "value": "bin"} + ], + "capabilities": ["runtime.opengl", "runtime.egl"], + "provenance": "ecosystem", + "hostObservation": { + "glibc": "2.43", + "fingerprint": "optional-host-fingerprint" + } +} +~~~ + +约束: + +- 所有路径相对 payload root;移动安装根不使契约失效。 +- 契约文件有 content hash;mcpp cache key 使用 hash,不使用 mtime 猜测。 +- SubOS 只引用 active provider contract id,不复制或重新解释 payload 目录。 +- provenance 是封闭枚举:ecosystemsystem-sdkhost-link。 +- 复用 xlings 现有 host_glibc 观测并带入诊断/host-link fingerprint;mcpp 不再忽略该字段,但它不能替代逐 DSO 的 symbol requirement 检查。 +- 旧 schema 可读时告警并转成内存 v2;多 payload/歧义时 fail closed,不再按字典序挑目录。 + +### 6.3 EnvOp schema v2 + +废弃歧义的单一 set: + +事实边界:xlings 自 2026.8.8.2 起,POSIX/fish/PowerShell/进程内路径已经统一为“变量存在即不覆盖”,包括显式空字符串;剩余缺陷是 mcpp reader 仍把 Set 解释成 replace。schema v2 是为了把这一区别永久写入协议,而不是声称 xlings writer 仍未修。 + +| op | 精确定义 | +|---|---| +| default | 变量不存在时设置;变量存在但为空也视为已存在 | +| prepend | 按平台路径分隔符前置,去重并保序 | +| replace | 无条件覆盖,必须显式使用 | + +兼容规则: + +- schema 1 的 set 按 xlings 历史行为映射为 default。 +- xlings writer 与 mcpp reader 同一发布窗口支持 v2。 +- POSIX shell、fish、PowerShell、xlings 进程内应用和 mcpp 读取共享同一组 golden fixtures。 + +### 6.4 BuildSnapshot 与 OutputManifest + +一次配置成功写入原子、版本化 snapshot: + +~~~text +BuildSnapshot + schema + inputFingerprint + graphFile + sourceSetDigest + toolchainContractHash + runtimeContractHashes[] + outputManifest + postLinkVerdicts[] + +OutputManifest + ninjaOwnedArtifacts[] + metadataProjections[] + generatedSources[] + requiredPostconditions[] +~~~ + +fast path 决策: + +- Ninja-owned artifact 缺失:交给 Ninja 依据图重建。 +- compile_commands.json 等 metadata projection 缺失:从 output-dir 中 fingerprint 对应的 canonical copy 原子重投影,不必完整 prepare。 +- graph/snapshot/runtime contract 缺失或 schema 不支持:完整 prepare。 +- 任一 required postcondition 缺失:不得输出 “Finished”。 +- compile_commands.json 加入新项目默认 ignore;已有项目即使未 ignore 也应正确修复。 + +### 6.5 UiPolicy 与 WireEnvelope + +进程启动时仅初始化一次: + +~~~text +UiPolicy { + color: Auto | Always | Never, + quiet: bool, + output: Human | Wire +} +~~~ + +- --no-color 直接得到 Never,后续 TTY 探测不能覆盖。 +- human/status/progress 写 stderr。 +- machine stdout 只允许 versioned envelope;无彩色、无 spinner、无说明文字。 +- pseudo-TTY、Windows console、管道和重定向分别测试。 + +### 6.6 CommandSpec + +所有子进程用结构化 argv,不拼 shell: + +~~~text +CommandSpec { + executable, + argv[], + cwd, + envDelta, + stdinPolicy, + stdoutPolicy, + stderrPolicy +} +~~~ + +2>/dev/null、引号、重定向不再进入参数字符串。POSIX 与 Windows 使用各自 native spawn backend,共享上层语义测试。 + +## 7. 详细设计 + +### 7.1 xlings 候选发现与升级 + +当前 release-bundled xlings 被排除在候选更新链之外。改为显式候选: + +1. CLI/config 显式覆盖。 +2. mcpp release-bundled sibling。 +3. distro/AUR 提供的环境候选。 +4. mcpp writable sandbox 已安装候选。 +5. system PATH。 + +每个候选携带: + +~~~text +XlingsCandidate { + path, + version, + provenance, + writable, + contractCapabilities[], + digest? +} +~~~ + +选择原则: + +- 先满足 required contract capability/schema。 +- 默认不降级。 +- 在可信候选中选择最高兼容版本,而不是只按目录位置。 +- 需要复制到 sandbox 时原子替换并验证 digest/version。 +- mcpp self env 显示候选、被选原因、版本和 provenance。 + +release pin 同时表达两件事: + +- 本次 release 原生 CI 验证过的 exact bundled version。 +- 运行时允许的 minimum contract capability/schema。 + +这样可接受未来兼容版本,又不丢失发布可复现性。 + +### 7.2 构建完整性、C1 和 generated source + +将构建分成三个明确阶段: + +1. **Plan**:解析 identity、source set、toolchain/runtime contract,生成 graph 与 snapshot。 +2. **Execute**:Ninja/runner 生产 artifacts 和 generated sources。 +3. **Verify/Project**:检查 OutputManifest、运行 post-link inspector、原子投影 CDB/机器元数据。 + +generated action 必须声明 outputs;其输出并入下一轮 SourceSet 或明确标为最终产物。没有声明或声明后缺失均失败,不能静默排除。 + +默认 source glob 只由一个 SourceSet producer 展开;清单、scanner、Ninja graph 不再各自 glob。 + +性能要求: + +- snapshot 校验按 digest/stat 快速路径完成。 +- CDB 重投影是文件复制/rename,不重新求解全部依赖。 +- no-change build 目标维持 < 0.5 s,或相对当前基线不回退超过 10%;两者取更严格、但先在 CI 固定硬件建立基线。 + +### 7.3 C++ scanner + +用 O(bytes) streaming lexical masker 代替启发式 strip,状态至少包括: + +- Normal +- line comment +- block comment +- string / char / escape +- raw string delimiter / raw body +- line splice 与 CRLF 处理 + +masker 保留换行与列宽,将注释/字符串内容替换为空白;module/import parser 只读取 masked code。注释只可在 Normal 状态开始。 + +回归 corpus 必含: + +- 行注释和块注释中的 R"(。 +- 普通字符串中的 /*//。 +- raw string 中的假 import。 +- char literal、encoding prefix、自定义 raw delimiter。 +- CRLF、行拼接、文件末尾未闭合状态。 + +scanner 保持热路径无编译器子进程;若未来接 P1689,编译器扫描作为权威慢路径/不确定输入 fallback,不与 masker 产生第三套模块身份。 + +### 7.4 exports 与 namespace + +SourceUnit.owner、manifest owner、dependency owner 全部改为 PackageId。校验使用结构体相等;诊断需要字符串时调用一个 formatter。 + +这个改动同时解决: + +- C4 namespaced exports 被跳过。 +- namespace 被重复前缀化。 +- 模板依赖注入回退短名。 +- cache/graph 在同短名包之间碰撞。 + +### 7.5 脚手架与模板 + +#### 7.5.1 CLI + +推荐稳定表面: + +~~~text +mcpp new + [--namespace ] + [--dir ] + [--template ] + [--variant ] +~~~ + +交互输出必须同时展示 provider 的 qualified identity 和 variant;--list-templates 的 wire 模式返回结构化数组。 + +#### 7.5.2 renderer + +RenderVars 至少包含: + +~~~text +project.name +project.namespace +project.qualifiedName +project.version +template.provider.namespace +template.provider.name +template.provider.qualifiedName +template.variant +~~~ + +实现要求: + +- parse template 成 token stream,一次替换,不扫描插入值。 +- 未知 token hard error;需要 literal token 使用明确 escape。 +- 文件路径渲染也走同一验证器,禁止绝对路径、.. 和目录逃逸。 +- 依赖注入操作 TOML AST,不用字符串查找;写出完整 namespace 分组。 +- 模板 descriptor 声明兼容的 mcpp contract/version、必需 capability 与平台。 + +#### 7.5.3 transaction + +流程: + +~~~text +parse and validate input + -> resolve exact template provider + -> materialize to sibling temp dir + -> render once + -> parse generated mcpp.toml + -> validate required files and no path escape + -> optional offline configure smoke + -> atomic rename to destination +~~~ + +任何错误返回非零并删除 temp;诊断给出字段、非法值和允许形式,不留下半项目。 + +#### 7.5.4 compatibility + +- 旧 pkg@ver:variant 支持两个 release train,仅限能唯一解析的无 namespace provider。 +- 第一个 release train warning;第二个可通过 compatibility flag 使用;之后删除。 +- 模板索引新增 canonical identity,不原地改变旧模板含义。 + +### 7.6 Artifact Physics Inspector + +当前 pre-link driver -### 检查只看到“编译器计划”,不能证明最终 ELF。新增 post-link inspector: + +1. 读取实际 PT_INTERPDT_NEEDED、RPATH/RUNPATH。 +2. 根据 artifact 路径、Runtime Contract 和平台规则递归解析动态闭包。 +3. 给每个对象标注 payload/provider/provenance。 +4. 从 .gnu.version_r 计算每个 host-borrowed object 的 GLIBC requirements。 +5. 验证 Rule B 与 Rule A。 +6. 生成 versioned verdict,按 artifact digest + Runtime Contract hash 缓存。 + +可复用 vendored patchelf 获取 interp/rpath/needed;GLIBC version requirement 建议实现最小只读 ELF parser,避免假设用户安装 readelf,也避免仅用系统 glibc 版本近似。 + +诊断示例信息应包含: + +~~~text +artifact +selected loader and provider +selected libc and provider +offending object +required GLIBC symbol ceiling +provided runtime ceiling +resolution path and remediation candidates +~~~ + +build fast path 必须把 verdict 当作 required output;artifact 或 Runtime Contract hash 变化后失效。 + +allow_host_libs 可以调整 Policy D 的告警/允许范围,但不能关闭物理 A/B:配置项不能让不可装载的 ELF 变得可运行。 + +上线分四步: + +1. **observe**:只记录闭包与 provider,对照 linker trace/map、readelf/patchelf fixtures。 +2. **warning**:对 proven mismatch 报高质量诊断,但保留显式 strict opt-in。 +3. **strict opt-in**:在 mcpp/xim-index native matrix 中积累低误报数据。 +4. **default strict**:只对精确解析得到的 mismatch/unresolved required object hard fail;解析器自身无法证明的情况返回“inconclusive”,不得假装兼容,也不得用 our_glibc ≥ host_glibc 代理误拒绝。 + +### 7.7 GUI / graphics 分层 + +当前 [runtime] 的 soname、capability、provider 和目录大多是扁平字符串:provider 只记 short name,library_dirs 又同时影响 -L 与 RPATH,capability 还可能同时表示 requires 与 provides。目标模型先拆开“需求”和“已解析实物”: + +~~~text +RuntimeRequirement { + kind: soname | capability | icd_manifest | display | host_service, + value, + phase: link | run, + required, + target, + requester: PackageId +} + +RuntimeArtifact { + role: loader | library | driver | manifest | host_bridge, + relativePath, + provider: ResolvedPackageId, + provenance: payload | subos_view | host_link | system_sdk, + abi, + requiredGlibcCeiling?, + digest?, + hostFingerprint? +} +~~~ + +链接和运行目录也必须分义: + +~~~text +link.libraryDirs +link.transitiveNeededDirs +runtime.rpathDirs +runtime.dlopenDirs +runtime.environment +deploy.files +~~~ + +runtime 目录不得隐式进入 -L。ELF lowering 分别生成 link search、-rpath-link 和 RPATH/RUNPATH;Mach-O 使用 @rpath/install name;PE 使用 app-local DLL 与 system DLL contract。resolution.json 输出 requirement → canonical provider → artifact → search mechanism → provenance → ABI verdict 的完整链。 + +#### 7.7.1 层次 + +| 层 | 职责 | 禁止事项 | +|---|---|---| +| xlings | payload、SubOS view、RuntimeBinding、runtime exports、host sentinel 激活 | 不理解 ImGui/GLFW 项目语义 | +| xim-pkgindex | Mesa/系统运行时闭包、NVIDIA/WSL host-link sentinel、平台系统包 | 不生成 mcpp 工程 | +| mcpp-index | ImGui/GLFW 等 C++ 包、LinkPlan、模板、平台依赖选择 | 不复制/symlink xlings 内部 view 作为长期 ABI | +| mcpp | 根据 capability 规划、链接、artifact physics、why 诊断 | 不探测 GPU 型号或选择驱动 | + +#### 7.7.2 平台策略 + +- **Linux**:xim:graphics 作为图形运行时入口;Mesa 默认闭包;NVIDIA/WSL 通过 sentinel 表达显式 host-link。 +- **macOS**:使用系统 SDK/framework capability;不伪装成 Linux Mesa 布局。 +- **Windows**:使用 Win32/system SDK 与明确 DLL runtime contract;命令执行和路径由 native backend。 + +模板声明平台依赖,不在源码中散落“如果 Linux 就手工找 libGL”。 + +ImGui 包本身也应按 feature 解耦: + +- core/headless 不拉窗口系统。 +- backend-glfw-opengl3 显式引入 GLFW、OpenGL headers 与 runtime。 +- backend-vulkan 显式引入 Vulkan loader + ICD requirement。 +- app 可组合默认 backend;docking/viewports 是正交 feature。 + +当前 ocornut:imgui 的 app facade/后端依赖仍容易让只用 core 的项目拉入 graphics 栈,这一拆分应由 mcpp-index 完成,不放进 xlings。 + +#### 7.7.3 过渡 + +compat.glx-runtime 的 symlink bridge 暂时保留: + +1. xlings contract v2 能表达 resolved transitive runtime exports。 +2. mcpp 能直接消费并生成正确 LinkPlan/runtime dirs。 +3. mcpp-index native tests 证明不再需要桥。 + +然后在一个有 warning 的 release train 中弃用,避免双份 runtime dir 漂移。 + +#### 7.7.4 测试矩阵 + +| 层级 | Linux | macOS | Windows | +|---|---|---|---| +| 常规 PR | headless llvmpipe configure/build/run;artifact A/B | native framework build + minimal smoke | Win32 backend build + minimal smoke | +| 原生/计划任务 | X11/Wayland;AMD/Intel/NVIDIA;WSL2 | arm64 + x86_64 native window | x64/arm64 native window | +| side-effect | 安装 graphics 前后无关 ELF 的 interpreter 与 GLIBC ceiling 不变 | SDK selection 不污染全局 env | DLL/path contract 不污染宿主 shell | + +Vulkan native gate 至少创建 instance、枚举 physical device,并验证实际加载的 ICD manifest/driver provenance;只测试 loader symbol 不算通过。真实 GPU 行为只能由对应 native runner 证明;Linux headless 通过不外推成所有硬件通过。当前 xim:graphics recipe 明确仅支持 Linux x86_64,所以 aarch64 在支持落地前应返回清晰的 capability unavailable,而不是把 cross-build 或 skipped runtime test 汇总成绿色。 + +### 7.8 安装 owner 与错误传播 + +删除 C8 的两段 versionless 预安装循环。唯一流程: + +~~~text +parse PackageRef + -> solve exact graph + -> install/materialize exact nodes through xlings + -> verify install contracts + -> expose graph to planner +~~~ + +- 任何节点失败立即带完整 PackageId/version/provenance 返回。 +- 不允许 catch (...) {} 或忽略 error_code 后继续。 +- 离线模式只使用已验证 materialization;缺失时明确报错。 + +### 7.9 Release Manifest 与 AUR reconciler + +#### 7.9.1 Release Manifest + +release 成功后发布不可变 manifest: + +~~~json +{ + "schema": 1, + "version": "2026.8.8.4", + "tag": "v2026.8.8.4", + "commit": "...", + "assets": [ + {"name": "...", "sha256": "...", "platform": "...", "arch": "..."} + ], + "bundled": {"xlings": "2026.8.8.1"} +} +~~~ + +AUR 生成器只消费 manifest,不重新从日志/文件名猜版本和资产。 + +mcpp-m 应消费 release 自己发布且带 sidecar 的版本化 source asset,不再依赖可能重生成的 tag archive;生成器下载实物重算 hash,并与 manifest/sidecar 交叉验证。 + +#### 7.9.2 先修 P0 生成器 + +- 不再用行级 sed 更新 Bash array;使用小型、确定性生成器从 model 完整生成 PKGBUILD 与 .SRCINFO。 +- 在推送前下载/校验 source archive 与二进制资产。 +- 比较 PKGBUILD 解析结果与 .SRCINFO:version、source URL、每个 checksum 必须一致。 +- 在 Arch container 中运行 makepkg --printsrcinfo 并与提交文件 diff。 +- 在 Arch container 中运行 makepkg --verifysource;PKGBUILD 是源,.SRCINFO 只由 makepkg 生成。 +- 分别构建/安装 mcpp-binmcpp-m 的最小 smoke。 +- 所有 package 预检全部通过后才允许任何 push,避免 mcpp-bin 先成功、mcpp-m 后失败。 +- dry-run 默认不加载 SSH secret、不 push,只输出 desired/current、生成文件、diff 和验证报告。 + +#### 7.9.3 对账工作流 + +触发: + +- release workflow 成功事件:低延迟路径。 +- schedule:建议每 6 小时。 +- workflow_dispatch:恢复/指定版本,但默认仍以 latest stable manifest 为准。 + +算法: + +~~~text +read latest immutable Release Manifest + -> query AUR RPC and git heads for every managed package + -> compare desired/current with Arch vercmp; reject implicit downgrade + -> generate all expected repositories in temp dirs + -> validate all package postconditions + -> for each drifting package, push idempotently with bounded retry/backoff + -> query AUR RPC/git again + -> succeed only if observed state matches expected state +~~~ + +行为: + +- AUR maintenance、连接超时等 retryable failure 使用指数退避与抖动。 +- auth、invalid metadata、checksum mismatch 属于 permanent failure,立即失败并告警。 +- 每个 package 的 push 可独立重试,但总体状态明确显示 partial convergence。 +- GitHub Release 是 desired state,AUR 是 observed projection;无需再维护一份可漂移的 mutable ledger。 +- 错过中间 release 时允许直接收敛 latest stable,不要求重放全部历史版本。 +- workflow_run 只负责唤醒;每次都重新读取最新完整、非 draft、非 prerelease manifest,迟到事件不得降级 AUR。 +- 已知 AUR package clone 失败时不得自动当作“首次发布”初始化空仓;首次认领必须是独立、显式流程。 +- 读取可走 HTTPS,SSH 只用于 push;固定官方 host key,不在发布时盲信动态 ssh-keyscan。 +- 使用专用发布身份/Ed25519 key;普通 PR 与不受信任代码永远拿不到 secret。 +- AUR git head 是立即验证源,RPC 允许 bounded poll;RPC 延迟不得触发重复提交。 +- 使用普通 fast-forward push,禁止 force-push/历史改写。 + +建议 SLO(待 review): + +- AUR 可用时,release 后 30 分钟内收敛。 +- 事件失败后,scheduled backstop 最迟 6 小时再次尝试。 +- 连续 24 小时未收敛触发高优先级告警。 + +mcpp-git 是否发布是独立产品决策;若保留,必须加入 managed package matrix 和相同 postcondition,不能只在仓库里放模板。 + +## 8. 兼容与迁移策略 + +### 8.1 双读单写 + +- xlings/mcpp 在迁移窗内读取 schema 1 和 2,只写 schema 2。 +- build cache schema 改变视为 cache miss,不尝试就地猜测迁移。 +- 唯一可转换的 legacy identity 在 ingress 转换;歧义立即失败。 +- 旧模板语法有明确两个 release train 的弃用窗口。 + +### 8.2 Fail-open 与 fail-closed 边界 + +| 情况 | 行为 | +|---|---| +| 缺少可再生成的 CDB projection | 本地重投影 | +| 旧但唯一可转换的 contract | 转换 + warning | +| 多 payload、同短名多候选 | fail closed | +| Rule A/B 已证明的物理冲突 | 经过 observe/warning 迁移后 hard fail;解析不确定单独报告 inconclusive | +| Policy D 仅在本地用户项目违反且 A/B 安全 | 默认清晰 warning;索引 CI hard fail | +| 未知 cfg、placeholder、output | hard fail | +| AUR 外部维护 | retry + scheduled reconcile,不回滚 GitHub release | + +### 8.3 可回滚性 + +- contract/snapshot 都有 schema 与原子文件;回滚二进制时旧 reader 忽略不支持的新文件并重新 materialize。 +- symlink bridge 只在新 runtime export 经过至少一个 release train 后移除。 +- 渠道 reconciler 生成提交前保存 expected diff;不 force-push AUR 历史。 +- 不通过修改全局 host env 回滚 runtime;切换 active RuntimeBinding。 + +## 9. 分阶段交付 + +### Phase 0 — 止血,不等待完整架构 + +1. mcpp:C1 输出完整性检查/CDB repair;C2 masker;C3 color;C4 PackageId 比较;C5 CommandSpec;删除 C8 循环。 +2. scaffold:#380 NameAtom、路径约束、单次渲染、临时目录事务。 +3. AUR:修复 mcpp-m checksum 生成;增加 PKGBUILD/.SRCINFO 一致性 gate;全部预检后再 push;再人工恢复一次对账。 +4. release:先完成 C5/C6 的候选/版本探测闭环,再将 bundled xlings 升到已验证的 2026.8.9.x,并跑冷 HOME 原生 gate;不能只改 pin。 + +每项独立小 PR,避免 P0 被 contract v2 设计阻塞。 + +### Phase 1 — Identity 与本地契约 + +1. PackageId/PackageRef/ProjectIdentity 全链路。 +2. 新 --template PackageRef --variant 表面和 legacy ingress。 +3. BuildSnapshot/OutputManifest。 +4. EnvOp schema v2 golden fixtures。 +5. xlings Runtime & Install Contract v2 writer;mcpp 双 reader。 +6. XlingsCandidate capability/provenance 选择。 + +### Phase 2 — Runtime physics 与 graphics + +1. post-link ELF parser/verdict 与 mcpp why runtime。 +2. xim-pkgindex Policy D CI。 +3. mcpp 读取 transitive runtime exports。 +4. Linux llvmpipe/native 图形矩阵。 +5. 弃用 compat.glx-runtime symlink bridge。 + +### Phase 3 — 脚手架 UX 与原生 GUI vertical slice + +1. TOML AST 依赖注入与模板 contract。 +2. ImGui/GLFW canonical template。 +3. Linux、macOS、Windows 各自一条从 mcpp new 到真实运行的冷 HOME vertical slice。 +4. 机器输出与 IDE 消费统一。 + +### Phase 4 — 发布渠道最终一致性 + +1. Release Manifest。 +2. AUR event + schedule reconciler、retry 分类、推后验证。 +3. 将 Homebrew/其他渠道逐步迁到同一 manifest 模型。 +4. 发布 dashboard 展示 GitHub release、索引、AUR 和 bundled xlings 的期望/实际版本。 + +## 10. 跨仓库 PR 切分建议 + +| 顺序 | 仓库 | 小 PR 主题 | 依赖 | +|---|---|---|---| +| 0A | mcpp | AUR generator/checksum consistency + dry-run validation | 无 | +| 0B | mcpp | C1/C3/C5 快速修复与回归测试 | 无 | +| 0C | mcpp | scanner masker(C2) | 无 | +| 0D | mcpp | scaffold transaction(#380) | 无 | +| 1A | xlings | install/runtime contract v2 + EnvOp v2 writer | 设计字段冻结 | +| 1B | mcpp | contract v1/v2 reader + candidate selection | 1A fixtures | +| 1C | mcpp/libxpkg consumer | PackageId 全链路与模板 CLI | identity spec | +| 1D | mcpp | BuildSnapshot/OutputManifest | 0B | +| 2A | mcpp | ELF actual-artifact inspector | 1B | +| 2B | xim-pkgindex | Policy D closure CI + sentinel schema | 1A | +| 2C | mcpp-index | graphics runtime exports 消费,移除桥的准备 | 1A/1B/2B | +| 3A | 三平台仓库矩阵 | GUI cold-home vertical slices | 1C/2A/2C | +| 4A | mcpp release | Release Manifest + AUR reconciler | 0A | + +协调者负责 schema fixtures、跨仓集成顺序和最终 native gate;各 PR 保持可独立回滚,不改写历史。 + +## 11. 验收标准 + +### 11.1 功能与回归 + +- #380 的 hang、路径逃逸、控制字符、部分目录均有回归测试。 +- 同短名不同 namespace 的 package/template 可同时存在,bare ambiguity 明确失败。 +- 删除 artifact、CDB、graph、verdict 任一项后,下一次 build 能正确重建/重投影或完整 prepare。 +- C2 最小 corpus 及现有真实项目 corpus 零 pass-to-fail。 +- --no-color 在 TTY、pipe、Windows console 均无 ANSI。 +- EnvOp golden fixtures 在 xlings 与 mcpp 结果字节一致。 +- A/B 测试含:同 binding 成功、loader/libc 混源失败、host object ceiling 高于 runtime 失败、低于等于成功。 +- 安装 graphics 不改变无关 ELF 的 interpreter/provider/GLIBC ceiling。 + +### 11.2 用户路径 + +每个平台都从隔离 HOME/MCPP_HOME/XLINGS_HOME 开始: + +~~~text +install released mcpp + -> verify selected bundled/upgraded xlings + -> mcpp new namespaced GUI project + -> resolve/install dependencies + -> configure/build + -> run platform-appropriate smoke + -> delete CDB/artifact and verify repair + -> inspect mcpp why runtime / wire output +~~~ + +不得用已缓存开发机状态替代。 + +### 11.3 性能 + +- scanner O(bytes),以大 translation-unit corpus 防止超线性回退。 +- no-change build 不启动 xlings、不访问网络。 +- artifact physics 仅在新 artifact 或 contract hash 变化时运行。 +- 固定硬件建立 median/p95 基线后,hot no-op build 回退不超过 max(5%, 10 ms);缺 CDB 只允许一次轻量 reconfigure/project。 +- 增量链接的 artifact inspector 额外成本目标不超过 max(5%, 50 ms/产物);hot no-op parse 次数必须为 0。 +- contract 解析与 snapshot 校验有单独 benchmark,避免 JSON/磁盘布局成为热路径瓶颈。 + +### 11.4 多平台与发布 + +- Linux x86_64/aarch64、macOS arm64/x86_64、Windows x64 至少有原生 cold-home gate。 +- 不从 Linux source review 推断 Windows spawn/console 正确。 +- release 完成后验证 remote tag/commit、assets、checksums、bundled xlings、索引版本。 +- AUR push 后同时验证 AUR git head 与 RPC version;只看到 workflow green 不算渠道收敛。 + +## 12. 可观测性与用户体验 + +新增统一解释命令,优先扩展现有 mcpp self/mcpp why,不创建大量顶层命令: + +~~~text +mcpp self env + selected xlings, all candidates, provenance, contract schema + +mcpp why package namespace:name + normalized PackageRef, selected version, source index, dependency path + +mcpp why runtime + loader/libc provider, dynamic closure, host-link leaves, A/B verdict + +mcpp build --output wire + versioned event/result envelope only +~~~ + +human 模式先给解决动作,再给细节。例如 loader/libc 混源时直接指出是哪个对象、来自哪里、需要哪个 runtime contract,而不是只输出 “GLIBC not found”。 + +## 13. 风险与缓解 + +| 风险 | 缓解 | +|---|---| +| schema v2 同时改 xlings/mcpp,发布错位 | 双读单写、共享 fixtures、capability negotiation | +| typed identity 改动面大 | 先在 ingress/graph 边界引入,禁止新增裸字符串 key | +| ELF parser 容易遗漏格式 | 最小只读范围、fixture 与 readelf/patchelf 对照、fuzz | +| Policy D 过严伤害用户自定义宿主库 | 只在 index CI hard fail;用户项目区分 A/B 与 D | +| GUI native CI 不稳定 | headless PR gate + 有标签的 native scheduled gate,分别报告 | +| AUR 部分发布 | 全包预检、独立状态、post-push reconcile;不 force-push | +| contract 使 fast path 变慢 | content hash 缓存、只读本地文件、按 artifact digest 复用 verdict | + +## 14. Review 需要确认的决策 + +1. 是否接受 **方案 C:分层 typed contracts**,并明确拒绝 xlings daemon 化? +2. 是否接受模板 CLI 将 provider 与 variant 分离:--template namespace:name@version --variant name?是否还需要后续提供 #variant 紧凑糖? +3. 是否接受项目逻辑 namespace、项目名和输出目录分别由 --namespace、位置参数、--dir 表达? +4. 是否接受 Rule A 按实际 host object 的 GLIBC symbol requirement 计算,而不是比较两端 glibc 发行版本? +5. 是否接受 A/B 在用户构建 hard fail、Policy D 只在索引 CI hard fail的分层? +6. 是否接受 mcpp 不做 GPU 探测,GPU/宿主驱动选择只由 xim sentinel/provider 负责? +7. 是否接受 Runtime & Install Contract v2 使用相对路径、content hash、双读单写迁移? +8. 是否接受 AUR 先修 generator consistency,再启用 6 小时 scheduled reconcile;30 分钟/6 小时/24 小时作为初始 SLO? +9. mcpp-git 是正式维护的第三个 AUR package,还是从自动发布范围明确移除? +10. Phase 0 的顺序是否同意:AUR 元数据安全、C1–C5/C8、#380 事务、bundled xlings 升级并行止血? + +## 15. 证据锚点与验证边界 + +下列行号对应本文基线,后续代码移动时以符号为准: + +| 主题 | 当前实现证据 | +|---|---| +| C1 fast path / CDB | src/build/execute.cppm:705-774src/build/ninja_backend.cppm:1547-1549src/build/compile_commands.cppm:282-307 | +| C2 scanner | src/modgraph/scanner.cppm:137-189,579-590 | +| C3 color | src/cli.cppm:97-108src/ui.cppm:233-240,266-331 | +| C4 exports | src/modgraph/scanner.cppm:786-792src/modgraph/validate.cppm:90-118 | +| C5 process | src/fallback/xlings_binary.cppm:156-183src/platform/process.cppm:191-226,335-350 | +| C7 EnvOp | src/xlings/subos_info.cppm:250-300tests/unit/test_subos_info.cpp:290-310 | +| C8 dead loops | src/toolchain/lifecycle.cppm:551-565src/build/prepare.cppm:1664-1670src/pm/package_fetcher.cppm:923-936 | +| #380 | src/scaffold/create.cppm:183-298src/scaffold/template.cppm:134-182 | +| template identity | src/scaffold/template.cppm:22-47,125-228src/scaffold/create.cppm:30-141 | +| #392 selection | src/toolchain/post_install.cppm:378-388src/xlings.cppm:35-48 | +| #396 pre-link only | src/build/hermetic.cppm:103-211src/build/ninja_backend.cppm:1575-1582 | +| runtime flattening | src/manifest/types.cppm:455-467src/build/plan.cppm:631-712src/build/flags.cppm:709-724,860-885 | +| AUR trigger/push | .github/workflows/aur-publish.yml:14-102 | +| AUR checksum defect | scripts/aur/update.sh:61-66scripts/aur/mcpp-m/PKGBUILD:32-35 | + +本次只读交叉审计没有执行会安装 graphics 或创建工程的命令,也没有在 WSL2、AMD/Intel/NVIDIA、macOS MoltenVK、Windows Vulkan 或 Linux aarch64 graphics 上实跑。mcpp 基线已有的 7 个被触发 Actions workflow 全绿,只能证明现有覆盖集;它们没有针对性覆盖上述大部分缺陷,native aarch64 在该提交也未触发。因此本文不建议仅凭现有 CI 关闭 #397/#396/#392/#380。 + +## 16. 相关既有设计 + +本文是跨问题的收敛层,不替代以下文档的细节: + +- .agents/docs/2026-08-07-xlings-as-runtime-substrate-design.md +- .agents/docs/2026-08-08-payload-version-and-contract-drift-design.md +- .agents/docs/2026-08-08-machine-readable-output-protocol-design.md +- .agents/docs/2026-08-05-build-mcpp-extensibility-architecture.md +- xlings 的 .agents/docs/2026-08-09-ecosystem-closure-design.md + +如果本文获批,下一步不是直接开启一个大实现,而是把第 14 节决策写成冻结的 ADR/schema fixtures,再按第 10 节拆分小 PR。 diff --git a/.agents/docs/2026-08-10-pr400-completion-design.md b/.agents/docs/2026-08-10-pr400-completion-design.md new file mode 100644 index 00000000..c1942d62 --- /dev/null +++ b/.agents/docs/2026-08-10-pr400-completion-design.md @@ -0,0 +1,613 @@ +# PR #400 收尾设计方案 —— 重新判定阻塞点,并把串行收口改成并行 + +> 日期:2026-08-10 +> 对象:[mcpp-community/mcpp#400](https://github.com/mcpp-community/mcpp/pull/400)(Draft,HEAD `64803fc`) +> 关联:#398(实施)、#397(汇总)、#380/#392/#396、 +> [mcpplibs/mcpp-index#197](https://github.com/mcpplibs/mcpp-index/pull/197)(已合)、 +> [openxlings/xlings#521](https://github.com/openxlings/xlings/pull/521)(Draft) +> 本文只记录可公开的仓库事实,不含本机路径、用户名、凭据。 + +--- + +## 0. 这份文档做什么 + +PR #400 的实现主体已经完成(31 commit、111 文件、+15085/−1692)。现有交接文档 +`.agents/docs/2026-08-09-pr400-handoff-zh.md` 把"还没做完的事"列得很完整,但它对**唯一一个 +mcpp 内部失败**的根因判定是错的,而整条收口路线又建立在"等 xlings 发版"这个跨仓库串行依赖上。 + +所以本文不重述已完成的实现,只做三件事: + +1. **§1** 用 CI 原始日志重新判定 Windows E2E 2/2 的根因(结论与 PR 正文不同); +2. **§2** 给出 review 结论,标出**必须在合并前处理**的项; +3. **§3–§5** 给出把串行等待改成并行推进的收口设计与分阶段计划。 + +### 0.1 基线事实(已核验) + +| 项 | 值 | 证据 | +|---|---|---| +| PR #400 HEAD | `64803fc` | `gh pr view 400` | +| main | `80291ca` | `git log` | +| main 的 Windows E2E | **success** | run `31275102045` | +| `64803fc` 矩阵终态 | **13 pass / 5 fail** | `gh pr checks 400` | +| 4 个 fail | 全部是裸 `ftxui` exact miss | job `93271797138` / `93271798469` / `93271802557` / `93271790099` | +| 第 5 个 fail | `12_add_command.sh` (exit 2) | job `93271793304` | +| `bare Windows: no Visual Studio` | 已 **pass**(`ed4cf64` 时是 cancelled) | job `93272767480` | + +`64803fc` 比交接文档记录的 `ed4cf64` 快照好一格:cancelled 已消失,仍是 5 个 fail。 + +--- + +## 1. 重新判定:Windows E2E 2/2 的根因不是 workspace 索引继承 + +### 1.1 PR 现在的说法 + +PR 正文与交接文档 §5.5 都写:失败发生在 step (14),workspace member 读不到根 +`[indices] acme`;`ed4cf64` 用 lexical anchor 修但没修好;`9a47ccf` 加 `route:` 诊断, +"等原生 Windows 输出 root/pkgs 状态后再做最小修复"。 + +**证据已经到了**(run `31324173520`),但它指向另一处。 + +### 1.2 原始日志 + +``` +2026-08-09T16:39:28.5388080Z error: package 'acme.util' not found in any configured index +2026-08-09T16:39:28.5388719Z tried: (acme, util) +2026-08-09T16:39:28.5389134Z route: local index 'acme': root absent, pkgs absent +2026-08-09T16:39:28.5946779Z FAIL: 12_add_command.sh (exit 2, 2.06s) +``` + +### 1.3 四条独立证据都排除 step (14) + +**(a) 退出码。** step (14) 被包在 `|| { echo …; exit 1; }` 里: + +```bash +workspace_add=$("$MCPP" add acme.util@2.0.0 2>&1) || { + echo "$workspace_add" + echo "workspace member could not read its root-owned local index" + exit 1 +} +``` + +它失败只可能是 **exit 1**。日志是 **exit 2** —— 那是 mcpp 自己的退出码经 `set -e` 直传, +只有 `"$MCPP" add … > /dev/null` 这种裸调用才会这样。 + +**(b) 缺失的横幅。** step (14) 失败必然打印 +`workspace member could not read its root-owned local index`,日志里没有。 +同一 harness 会显示脚本自身的 stdout —— 同一个 job 里 +`118_purview_include_rebuild.sh` 的 `windows: depfile degradation reported as expected` +就是脚本 `echo` 出来的。 + +**(c) 顺序。** 失败前最后两条未被捕获的 mcpp 输出是 step (5) 的 `capi.lua` 迁移 warning +(16:39:27.10)和 step (12) 的 `unknownidx.thing` warning(16:39:27.98)。 +step (13)/(14) 的输出全部被 `$( )` 捕获,所以不出现。下一条落到日志的 stderr +就是 step (15)。 + +**(d) `route:` 行本身。** step (15) 的 fixture 在 +`tests/e2e/12_add_command.sh:250` 写的是: + +```bash +[indices] +acme = { path = "$TMP/myapp/index" } +``` + +`$TMP` 来自 `mktemp -d`。在 Git Bash 下它是 **MSYS 路径** `/tmp/tmp.XXXX`, +而被写进 `mcpp.toml` 的是**文件内容**,MSYS 的 argv/env 路径转换对它不生效。 +原生 `mcpp.exe` 拿到 `/tmp/tmp.XXXX/myapp/index`,按 Windows 语义那是"当前盘根相对", +即 `C:\tmp\tmp.XXXX\myapp\index` —— 不存在。 + +**这正好就是 `root absent, pkgs absent`。** + +### 1.4 为什么 main 是绿的 + +step (15) 是本 PR **新增**的(`git diff origin/main...HEAD` 中该段全为 `+`)。 +step (14) 在 main 上就存在且一直通过。 + +全仓 e2e 里,只有这一处把绝对 `$TMP` 路径写进 manifest **并且在 Windows 上真正通过它解析包**: + +- `43_indices_lockfile.sh:45` 也写绝对 `$INDEX_DIR`,但整个测试只做文本断言,从不解析包 → Windows PASS; +- `163_identity_first_resolution.sh` 用相对路径 `../idx1` → Windows PASS; +- `121_default_ns_redirect.sh` / `203_exact_selector_lock_migration.sh` / `44_*` 等 + 被 `# requires: gcc` 挡掉 → Windows SKIP。 + +### 1.5 为什么 Wine 复现是假绿 + +交接文档 §5.5 记录:"下载该 job 的 Windows artifact,在隔离 Wine 中重放,两条 +`mcpp add acme.util@2.0.0` 都通过。" + +Wine 默认把 **`Z:` 映射到 `/`**。于是 `\tmp\tmp.XXXX\myapp\index` 落回真实的 +`/tmp/tmp.XXXX/myapp/index`,fixture 可读,测试自然过。 + +> **方法论条目(值得写进 CLAUDE.md / 记忆):Wine 能验证 PE 产物"能跑", +> 不能验证"路径语义"。** 凡是结论依赖盘符、驱动器映射、`/` 开头路径解释、 +> 8.3 短名、UNC 的问题,Wine 通过 = 无信息,不是证据。 + +### 1.6 推论:`ed4cf64` 是一次无证据的产品改动 + +`ed4cf64` 把 `src/project.cppm` 的 `inherit_workspace_indices` 里的 + +```cpp +idx.path = std::filesystem::weakly_canonical(wsRoot / idx.path); // main,绿 +``` + +改成 + +```cpp +idx.path = (wsRoot / idx.path).lexically_normal(); // PR +``` + +理由写的是"weakly_canonical 可能把 Windows 短名/大小写路径改写成另一种拼写"。 + +对照事实: + +- **同样的失败在 `ed4cf64` 之前之后完全一致** —— run `31318089536`(`0108ee19`,改动之前) + 的错误文本与 `31321961040`(`ed4cf64`,改动之后)逐字相同; +- main 带着 `weakly_canonical` 是绿的; +- 该改动配的单测 `PmIndexRoute.WorkspaceMemberReadsRootAnchoredRelativeIndex` + (`tests/unit/test_pm_index_route.cpp:184`)里有一行 + `EXPECT_EQ(indices.at("acme").path, (root / "index").lexically_normal());` + —— 它断言的是**实现细节的路径拼写**,而不是能力。这正是它抓不到真 bug 的原因。 + +所以:**这条改动目前是"为了一个不存在的原因、改掉一个当时是绿的行为",且没有任何 +测试能证明它有必要。** 它不一定错(`weakly_canonical` 会解符号链接, +参见记忆 `issue344-cache-object-address`:`fs::relative` 解符号链接曾让包静默退出缓存), +但**它必须要么被证明,要么被还原**,不能就这么留在 diff 里。 + +--- + +## 2. Review 结论 + +按"合并前必须处理 / 合并前应处理 / 记录即可"三档。 + +### R1 · P0 · 修在了控制流到不了的地方 + +见 §1。当前 PR 的下一步动作("等 Windows route 证据 → 修 workspace 继承") +如果照做,会在一个本来就没坏的路径上继续改代码。 + +**处理**:按 §3 主线 A。 + +> 这是记忆 `repair-placed-where-flow-never-reaches` 的同一形状第 12 次出现: +> **修补被放在控制流永远到不了的地方。** 这次的变体是"断言的失败位置被错认"。 + +### R2 · P0 · 裸名精确化 = 让**已经发布**的数据失效 + +这是本 PR 最大的产品风险,而且它已经在用四个红 job 报警。 + +现状(`tests/e2e/162_bare_name_namespace_scope.sh` 的 diff 就是判据): + +| 写法 | main | PR #400 | +|---|---|---| +| `gtest = "1.15.2"` | 解析到 `compat.gtest` | **硬失败** | +| `ftxui = "6.1.9"` | 解析到 `compat.ftxui` | **硬失败** | +| `compat.gtest` | 解析 | 解析 | + +四个 CI 失败(Linux integration / macOS LLVM / aarch64 / Windows toolchains)全部是 +同一句: + +``` +error: dependency 'ftxui': no package found for exact selector + tried: (mcpplibs, ftxui) + compat.ftxui + ftxui = "6.1.9" +``` + +PR 把它们归类为"已知跨仓库边界,由 xlings #521 收口"。**这个归类不成立**: + +1. **mcpp 自己也被它打中**。本 PR 不得不把仓库根 `mcpp.toml` 的 + `[dev-dependencies] gtest` 改成 `[dev-dependencies.compat] gtest`。 + 一个变更需要改自己的 manifest 才能自举,就是破坏性变更的定义。 +2. **xlings 只是一个消费者**。索引里有 34 个 `compat.*` 包 + (`165_bare_name_cross_namespace_wire_address.sh` 在 main 上的注释记的数; + 本 PR 把那段注释删掉了)。 + 任何用户 `mcpp.toml` 里写着裸 `gtest`/`ftxui`/… 的项目, + 在升级到 2026.8.9.1 的那一刻全部构建失败,而**修 xlings 帮不到他们**。 +3. **它是记忆 `index-floor-must-degrade` 的镜像**。那条的教训是 + "索引是数据、mcpp 是程序,发布数据不得让程序失效"。 + 这次反过来:**发布程序不得让已发布的数据与用户 manifest 失效。** + 两个方向的判据是同一条:**必须降级,不得变砖。** +4. **PR 自己已经给出了正确的先例**。同一个 PR 对 `ns:name → ns.name` + 给了一个 release 的迁移别名 + 可复制的替换提示。裸名迁移比冒号拼写影响面**大得多**, + 却只给了硬失败。这是内部不一致。 + +**处理**:按 §3 主线 B(加一个 release 的迁移窗口)。这同时会把四个红变绿, +**并把 xlings #521 从发布关键路径上摘下来**。 + +### R3 · P1 · 合并 #400 会立刻武装 AUR 自动发布 + +`.github/workflows/aur-publish.yml`: + +```yaml +on: + workflow_run: { workflows: [release], types: [completed] } + schedule: + - cron: '17 */6 * * *' + workflow_dispatch: { inputs: { publish: … default: false } } +``` + +而发布判定是: + +```bash +if [[ "$TRIGGER" == workflow_run || "$TRIGGER" == schedule ]]; then + publish=true +else + publish=${MANUAL_PUBLISH:-false} +fi +``` + +**`schedule` 直接 `publish=true`。** 也就是说:#400 一旦合入 main, +最多 6 小时后就会有一次**无人值守的、对第三方服务(AUR)的写操作**, +它会把当时"最新完整稳定 release"(合并瞬间是 `v2026.8.8.4`)推到 `mcpp-bin`, +而公开 AUR 现在还停在 `2026.8.1.1-1`。 + +这不一定是错的结果,但**它是一个由"合并"而不是由"明确放行"触发的对外副作用**, +而且这条路径**从未真跑过**(交接文档 §3.7:只做了非 root `makepkg --verifysource` dry-run, +"本轮没有发布")。 + +**处理**:见 §3 主线 C 的 C3 —— 首次落地把 `schedule` 分支降级为 dry-run +(用一个仓库变量或直接先不带 `schedule` 触发器合入), +手工 `workflow_dispatch --publish` 验证一次成功后,再用一个独立小 PR 打开定时收敛。 + +### R4 · P1 · 身份/索引路由的 e2e 在 Windows 上几乎不跑 + +本 PR 的核心是"精确身份 + 索引路由",而这类 e2e 的 `# requires:` 把它们挡在 Windows 之外: + +| 测试 | requires | Windows | +|---|---|---| +| `203_exact_selector_lock_migration.sh` | `gcc fresh-sandbox` | SKIP | +| `121_default_ns_redirect.sh` | `gcc fresh-sandbox` | SKIP | +| `205_root_local_subos.sh` | `elf gcc` | SKIP(合理,Linux 语义) | +| `206_runtime_binding_physics.sh` | `elf gcc` | SKIP(合理) | +| `207_runtime_contract_provenance.sh` | `elf python3 unix-shell` | SKIP(合理) | + +`205/206/207` 跳过是对的 —— 它们本来就是 Linux 运行时物理。 +但 `203`(**exact selector 的 lock 迁移**)和 `121`(**索引 default 重定向**) +是纯路径 + 纯身份逻辑,跟编译器无关,却因为 `gcc` 这个 token 从来没在 Windows 上跑过。 + +**这正是本次 bug 能溜过去的结构性原因:身份与路径的 bug 只在 Windows 上出现, +而验证身份与路径的测试只在 Linux 上跑。** + +**处理**:见 §3 主线 A 的 A3。 + +> 参见记忆 `issue375-retracted-c-runtime-and-subos-env`: +> `# requires:` 的死 token 让 `65_*` 从未在 CI 跑过。同一类问题,这次是"活 token 但选错了"。 + +### R5 · P2 · PR 体量与耦合 + +111 文件 / +15085,横跨四个互不依赖的产品域(包/模板身份、事务脚手架、 +运行时绑定与 ELF 闭包、release manifest + AUR reconciler),**外加一次版本 bump**。 + +现在拆已经不划算(31 个 commit + 不得改写历史的约束),所以**不建议拆 #400**。 +但两件事要做: + +- **发布分级**:AUR(R3)与 release manifest 是第一次真跑,按 §3 C3 单独放行, + 不要和身份语义同时"上线"; +- **记录规则**:下次同类工作按域拆 PR。一个 PR 只应该有一条可以独立回滚的主线。 + +### R6 · P2 · 版本号日期已过期 + +`mcpp.toml` / `src/version.cppm` 是 `2026.8.9.1`,今天已是 2026-08-10。 +按 `YYYY.M.D.N` 约定(记忆 `mcpp-date-version-convention`), +实际发版当天必须重新对齐(如 `2026.8.10.1`)。 +**这不是收尾时顺手改的东西** —— 它牵动 CHANGELOG、docs、release manifest、 +AUR 目标版本、bootstrap pin 的判据,必须作为独立 commit 在发版前执行。 + +### R7 · 待独立复核区(本文不下结论) + +以下三块是本 PR 新增的高风险实现,本轮只做了结构性抽查,**没有**做逐行审计, +因此**不给通过或不通过的结论**,只登记为"合并前需要一次独立 review pass": + +- `src/platform/elf_runtime.cppm`(+722):手写 ELF64 读取器。 + 抽查到 `off <= bytes.size() && size <= bytes.size() - off` 这类正确的溢出安全写法 + 和 `kMaxClosureObjects` 上界,形态是对的;但 verneed/verdef 遍历、 + SONAME 复用、`$ORIGIN` 展开需要针对畸形/截断输入的定向用例。 +- `src/build/runtime_validation.cppm`(+433):verdict 缓存。 + 键包含 artifact stat + `runtimeBinding.contractHash`, + 且 `Inconclusive` 会把 summary 从 `Pass` 拉下来 —— 方向正确。 + 需要专门确认的不变量:**缓存 miss/mismatch 必须保持失败,不得靠下一次运行变绿** + (PR 声称已实现,需要一个明确的 RED 用例锁住)。 +- `scripts/aur/reconcile_mcpp_bin.py`(+1132):12/12 契约测试 + 真实 Arch dry-run, + 但 fast-forward-only、vercmp 单调性、AUR RPC 滞后分类这三条只在**真实推送**时才被检验。 + 这就是 R3 要分级放行的原因。 + +--- + +## 3. 设计:三条主线 + +### 主线 A —— 让 latest-head Windows 变绿(修测试,不是修产品) + +#### A1. 修 fixture:`tests/e2e/12_add_command.sh:250` + +写进 manifest 的绝对路径必须是**宿主原生**路径。仓库已有先例: +`171_bmi_staging_locked_dest.sh:75` 用 `cygpath -w`。 + +TOML 里反斜杠要转义,所以用 **`cygpath -m`**(混合模式,`C:/Users/...`): +Windows API 接受正斜杠,TOML basic string 也不需要转义。 + +在 `tests/e2e/run_all.sh` 里导出一个共享 helper(新建 `_paths.sh` 或直接放进 run_all): + +```bash +# 把一个 shell 侧路径转成"写进 mcpp.toml 后原生 mcpp 能解析"的形式。 +host_path() { + case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*) cygpath -m "$1" ;; + *) printf '%s' "$1" ;; + esac +} +``` + +step (15) 改为: + +```bash +IDX="$(host_path "$TMP/myapp/index")" +... +acme = { path = "$IDX" } +``` + +**不要**改成相对路径。改成相对路径能让测试变绿,但会**顺手删掉"绝对路径索引" +这条唯一在 Windows 上被真正解析的覆盖** —— 那正是这次暴露出的盲区。 + +#### A2. `ed4cf64` 的处置:证明它,或者还原它 + +两条路,选一条,不允许"留着不动": + +- **A2-a(推荐)**:写一个 RED —— *workspace root 通过符号链接访问时, + 继承的相对 index 必须仍锚在声明的 root 上*。 + 如果 `weakly_canonical` 让它失败而 `lexically_normal` 让它通过, + 这条改动就被证明了,保留,并把该测试作为它的锁。 +- **A2-b**:还原为 `weakly_canonical`(回到 main 的绿行为)。 + +无论哪条,都要修 `tests/unit/test_pm_index_route.cpp:221`: +把 `EXPECT_EQ(path, …lexically_normal())` 换成能力断言 +(`route.describe(...)` 与 `lookup_descriptor` 命中), +**不要断言路径的字符串拼写**。 + +#### A3. 补 Windows 侧的身份/路径覆盖 + +- 把 `203_exact_selector_lock_migration.sh` 与 `121_default_ns_redirect.sh` 的 + `# requires:` 里的 `gcc` 去掉(若它们确实只做解析断言;若确有编译步骤, + 就把解析断言拆成一个不需要 gcc 的新用例)。目标:**exact selector 与索引路由 + 在三个平台都跑**。 +- 新增一条专门的 e2e:*绝对路径 `[indices]` 在三个平台都能解析*, + 内部使用 `host_path`,并**显式断言 `route:` 输出为 `root present, pkgs present`**。 + 这条测试的存在本身就是防止"MSYS 路径写进 manifest"再次发生的护栏。 +- 给 `run_all.sh` 加一条极简 lint:扫描 `tests/e2e/*.sh`, + 凡出现 `path = "$...` 形式且变量不是经 `host_path` 得来的,报错。 + (宁可稍微粗糙也要有 —— 这类错误人眼在 review 里看不出来。) + +#### A4. 保留 `route:` 诊断 + +`9a47ccf` 加的 `IndexRoute::describe` 是本轮**唯一让根因可判定**的东西: +没有它,日志只会说 "not found",谁都无法区分"索引没配"和"索引配了但路径不存在"。 +它应该保留,并按 A3 被测试固定下来。 + +--- + +### 主线 B —— 裸名迁移窗口(把 R2 从"生态阻塞"降级为"一次弃用警告") + +#### B1. 语义 + +对**省略命名空间**的选择器: + +1. 精确解析 `(mcpplibs, name)`; +2. **仅在 miss 时**,按上一版本的老顺序再试 `(compat, name)` 和 `(∅, name)`; +3. 若老 rung 命中:**正常解析**,同时打印一条弃用警告,内容包含 + - 实际选中的完整身份(`compat.ftxui`), + - 可直接粘贴的 manifest 片段(复用 `src/build/prepare.cppm` 里 miss 分支 + 已有的 "did you mean" 渲染,就在 `:2447` 那条 error 之前), + - 该 rung 将在下一个版本移除; +4. 老 rung 也 miss → 保持现在的硬失败与提示(不变); +5. **`mcpp.lock` 只写规范身份**(`compat.gtest`),绝不写裸拼写; +6. `mcpp add <裸名>` 命中老 rung 时,**把规范点分形式写进 `mcpp.toml`** + —— 用户第一次碰它就自动迁移。 + +关键点:**原缺陷是"静默",不是"回退"。** +`162_bare_name_namespace_scope.sh` 的注释原文写得很清楚: +"a bare name that matched nothing fell through to the FIRST candidate **SILENTLY**, +so mcpp carried on with a namespace it had invented"。 +一条带完整身份的警告已经把"静默"消灭了,同时保住了已发布的数据。 + +#### B2. 落点 + +| 文件 | 改什么 | +|---|---| +| `src/pm/dependency_selector.cppm` | 新增 `legacy_bare_candidates(name)`,与已有的 `legacy_prefixed_coordinate` 对称:只服务于"exact miss 之后",绝不进 exact 候选表 | +| `src/pm/index_route.cppm` | `Lookup` 增加"命中来自哪一 rung",让上层能区分 exact 命中与 legacy 命中 | +| `src/build/prepare.cppm:2447` 附近 | miss 后先试 legacy rung;命中则 warning + 继续;仍 miss 则维持现有 error | +| `src/pm/commands.cppm` | `mcpp add` 命中 legacy rung 时写规范点分形式 + warning | +| `tests/e2e/162_bare_name_namespace_scope.sh` | 两个分支都锁:裸 `gtest` **命中 compat 且必须出现弃用警告**;裸 `widget`(无处可寻)**必须硬失败** | +| `mcpp.toml` | 保持 `[dev-dependencies.compat] gtest`(这是规范写法,不回退) | +| `docs/spec/package-identity.md` + zh | 明确写出窗口的起止版本 | + +#### B3. 效果 + +- 四个红 job 立刻具备变绿条件,**不需要等 xlings #521 合并 + xlings 发版**; +- xlings #521 仍然是**正确**的修复,继续正常 review/合并/发版, + 但它从 mcpp 发布的关键路径上被摘下来; +- 用户升级不炸; +- 下一个版本移除窗口时,生态已经有一个完整 release 的警告期。 + +#### B4. 备选(不推荐,但记录) + +- **B-alt-1 生态优先**:不加窗口,先把索引里 34 个 `compat.*` 全部加 + `mcpplibs.` 桥接描述符(就是 mcpp-index #197 对 `capi.lua` 做的 Form-B bridge)。 + 缺点:把裸拼写永久固化进索引,与本 PR 的精确身份目标直接冲突。 +- **B-alt-2 硬切**:照现在合并,靠文档和 release note 通知。 + 缺点:所有存量用户 manifest 在升级瞬间失败,而错误信息虽然给了替换建议, + 但**无法自动修复**,也无法回退(旧 mcpp 已被 upgrade 提示引导升级)。 + +--- + +### 主线 C —— 收口顺序:把串行改并行 + +#### C1. 当前(交接文档 §8)是一条全串行链 + +``` +xlings#521 review → 合并 → xlings 发版 → mcpp 改 pin → mcpp 全矩阵 + → mcpp review → 合并 → mcpp 发版 → GitCode → AUR → fresh-home +``` + +任何一环卡住(尤其是跨组织的 xlings 发版)整条停摆。 + +#### C2. 加入主线 B 之后可以并行 + +``` +轨道 1(mcpp #400,自足) + A1 修 fixture ─ A2 处置 ed4cf64 ─ A3 补覆盖 ─ B 迁移窗口 + → latest-head 全矩阵 → 用户 review → 普通合并 → 发版 + +轨道 2(xlings #521,独立) + 用户 review → 普通合并 → xlings 发版 + → mcpp 独立小 PR 更新 pin(不阻塞轨道 1) + +轨道 3(对外发布面,独立放行) + release manifest 首跑验证 → AUR 手工 publish 验证 → 打开定时收敛 +``` + +#### C3. AUR 分级放行(对应 R3) + +1. **合并 #400 时**:`aur-publish.yml` 的 `schedule` 分支改为 `publish=false` + (或整段先不带 `schedule` 触发器合入)。合并不产生任何对外写。 +2. **手工验证一次**:`workflow_dispatch` 带 `publish=false` 看 plan, + 再带 `publish=true` 推一次,核验 AUR RPC 可见、`.SRCINFO`、两个架构 checksum、 + 以及在真实 Arch 上 fresh install。 +3. **验证通过后**,用一个只改触发器的独立小 PR 打开定时收敛。 + +这样"第一次真实对外推送"是一次**有人盯着的、可回退到上一步的**动作, +而不是合并后 6 小时内自己发生的事。 + +#### C4. 版本对齐(对应 R6) + +发版当天,独立 commit 统一 `mcpp.toml` / `src/version.cppm` / CHANGELOG / +docs / release manifest 目标 / AUR 目标版本。 +`.xlings.json` 的 mcpp bootstrap pin 保持 `2026.8.8.4`(上一个已发布版本)—— +按记忆 `release-bootstrap-pin-two-groups`,bootstrap pin 是自举起点,不随本次发布走。 + +--- + +## 4. 分阶段计划与验收判据 + +每一阶段都遵循 PR #400 已确立的流程:先 RED、最小实现、GREEN、 +隐私扫描、单文件暂存、一个逻辑变更一个 commit、立即 push、立即中文 checkpoint、 +不 amend/rebase/squash/force-push、不 admin bypass。 + +| 阶段 | 内容 | 验收判据(**判据是原生 CI 终态,不是本机**) | +|---|---|---| +| **P0** | A1 `host_path` + step (15) fixture | Windows E2E 2/2 中 `12_add_command.sh` PASS;Linux/macOS 不回归 | +| **P1** | A2 处置 `ed4cf64`;重写 `test_pm_index_route.cpp:221` 为能力断言 | 若走 A2-a:符号链接用例先 RED 后 GREEN;若走 A2-b:还原后三平台全绿 | +| **P2** | A3 Windows 身份/路径覆盖 + `run_all.sh` lint | `203`/`121` 在 Windows 上由 SKIP 变 PASS;新绝对路径索引用例三平台 PASS;lint 对故意构造的坏 fixture 报错 | +| **P3** | B 裸名迁移窗口 | `162` 两个分支都 GREEN;**四个 xlings 相关 job 由 fail 变 pass**;`mcpp.lock` 里不出现裸拼写 | +| **P4** | R7 独立 review pass(ELF / verdict cache / AUR reconciler) | 三块各自补齐定向 RED;verdict 缓存 mismatch 必须保持失败的用例存在 | +| **P5** | C4 版本对齐 + 文档 + 验证账本 | 版本号 = 实际发版日期;`.agents/docs/...validation.md` 里所有 "not started" 段落被终态替换 | +| **P6** | 用户 review → 转 ready → 普通合并 | 全矩阵**同一个 HEAD**上 terminal 全绿;pending/skipped/cancelled/superseded 一律不算 PASS | +| **P7** | 发版 + release manifest 首跑核验 | tag、四平台资产、sidecar、不可变 `mcpp-release.json` 全部核验;索引 main 的 latest 指向它 | +| **P8** | AUR 分级放行(C3 三步) | AUR RPC 可见目标版本;真实 Arch fresh install 通过;之后才打开 `schedule` | +| **P9** | GitCode / xim-pkgindex / fresh-home 全生态 | 隔离 HOME/XLINGS_HOME/SubOS 下:安装、`[ns.]name[@version][:tname]`、唯一默认模板、多 SubOS/glibc、build/run/test、provider provenance 的 PASS/FAIL/NOT_EXERCISED 表 | +| **P10** | issue 收口 | #398 随 PR 关闭;#380/#392/#396 凭发布后二次验收证据关闭;残留写回 #397 | + +**并行关系**:P0–P3 是 #400 的关键路径;xlings #521 的 review/合并/发版 +与 P0–P6 完全并行,其 pin 更新是合并后的独立小 PR。 + +> 关于"发布完成"的判据,沿用记忆 `release-publish-pipeline`: +> **判据是索引 main 的 latest 指向它**,不是 tag 存在、也不是 PR 已合。 +> `gh pr merge` 不带 `--admin` 可能是静默空操作(退出 0),合完必须回查 `state`。 + +--- + +## 5. 需要你拍板的决策点 + +| # | 决策 | 选项 | 我的建议 | +|---|---|---|---| +| **D1** | 裸名迁移窗口 | (a) 加一个 release 的弃用窗口(主线 B)
(b) 索引侧加 34 个 `mcpplibs.*` 桥接
(c) 硬切,只靠文档通知 | **(a)** —— 与本 PR 自己对 `ns:name` 的处理一致;能保住存量用户;并把 xlings 摘出关键路径 | +| **D2** | `ed4cf64` 的 `weakly_canonical → lexically_normal` | (a) 用符号链接 RED 证明并保留
(b) 还原到 main 的行为 | **(a) 先试**,一天内证不出来就走 (b)。不允许"留着不动" | +| **D3** | AUR 首次放行 | (a) 合并时 `schedule` 降级为 dry-run,手工验证后再打开
(b) 照现状合并 | **(a)** —— 第一次对外真实推送应该有人盯着 | +| **D4** | 发版版本号 | (a) 发版当天重新对齐为 `2026.8.{当天}.1`
(b) 保持 `2026.8.9.1` | **(a)**,按日期版本约定 | +| **D5** | R7 三块的 review 深度 | (a) 合并前补定向 RED
(b) 合并后跟进 | **(a) 只对 verdict 缓存那条不变量**("mismatch 不得靠重跑变绿"),其余可 (b) | + +--- + +## 6. 明确不做 + +- **不拆 #400**(31 commit + 不改写历史的约束下,拆的成本大于收益); + 规则记录下来给下一次。 +- **不在 mcpp 里恢复"静默"的跨命名空间回退**。主线 B 的窗口是**带警告、 + 写规范身份进 lock、`add` 时自动迁移 manifest** 的,与被修掉的静默回退不是同一件事。 +- **不动 `mcpp-m`**(文件、远端、发布路径)与 `mcpp-git`。 +- **不引入 `--variant`、不引入 CLI SubOS override、不让 mcpp 探测 GPU/驱动/ICD** + —— #398 冻结的四条产品边界不变。 +- **不处理 #397 的 C1–C9**。它们是独立缺陷(fast path 不重建 CDB、 + 模块扫描器 raw-string 假阴性、`--no-color` 空操作 …), + 与本 PR 无关,按 #397 自己的节奏走。 +- **不碰特殊保留项**:#43、#260,以及标记为保留/Draft/do-not-merge 的项。 + +--- + +## 附:本文结论的可复核路径 + +| 结论 | 复核方式 | +|---|---| +| 失败在 step (15) 而非 (14) | `gh api repos/mcpp-community/mcpp/actions/jobs/93271793304/logs`,看 `exit 2` 与缺失的横幅 | +| step (15) 是本 PR 新增 | `git diff origin/main...HEAD -- tests/e2e/12_add_command.sh` | +| main 的 Windows E2E 是绿的 | run `31275102045` | +| `ed4cf64` 前后失败一致 | run `31318089536` vs `31321961040` | +| 四个 fail 同因 | job `93271797138` / `93271798469` / `93271802557` / `93271790099` | +| AUR `schedule` 即 publish | `.github/workflows/aur-publish.yml` 的 plan 步骤 | +| 身份 e2e 在 Windows 被跳过 | Windows E2E 日志中的 `SKIP: 203_… (missing capability: gcc)` | + +--- + +## 附录 A:实施结果(2026-08-10 收尾) + +本节是执行完 D1–D5 之后回填的**事实**,不是计划。凡与正文冲突的,以本节为准。 + +### A.1 D1–D5 的落地结果 + +| 决策 | 结果 | +|---|---| +| **D1** 裸名迁移窗口 | 已实施。精确未命中 + namespace 被省略 → 试 `(compat,name)` 与无 namespace rung;命中打印弃用警告 + 可粘贴片段,规范身份进 lock/install/cache;`mcpp add` 直接迁移 manifest。`2026.9` 移除,常量 `kBareNameFallbackRemovedIn`。 | +| **D2** `ed4cf64` 证明或还原 | **已证明,保留**。换回 `weakly_canonical` 让 `unit/test_pm_index_route` 失败(71 passed / 1 failed)。但理由与原提交描述不同:不是 Windows 短名,而是「anchoring 不得把 index 移出作者声明的那棵树」——经符号链接访问的 workspace 会被重定位,而 `prepare` 的报错会打印这个路径。 | +| **D3** AUR 首次放行 | 已实施。`workflow_run` 与 `schedule` 都需要仓库变量 `AUR_AUTOPUBLISH == "true"` 才推送;契约测试直接执行 workflow 自己的判定 shell。 | +| **D4** 版本对齐 | 已实施。`2026.8.9.1` → `2026.8.10.1`(版本号是日期,评审期间日期变了;`2026.8.9.1` 从未打 tag)。 | +| **D5** verdict 缓存不变量 | 已实施为 e2e `209`。**该不变量本来就是对的**(`validated_artifact_snapshot` 在任一 stored status ≠ Pass 时拒绝快速路径),只是没有测试;现在有了。 | + +### A.2 P0 的根因判定被推翻了一次,值得单独记住 + +正文 §1 的推断(step (15) 的 MSYS 路径)经原生 CI 证实。三条可复用的判据: + +1. **先看退出码,再看错误文本。** 被 `|| { …; exit 1; }` 包住的步骤失败只能是 exit 1; + 日志里的 exit 2 是 mcpp 自己的码经 `set -e` 直传,只可能来自裸调用。 +2. **失败横幅缺席本身是证据。** harness 会显示脚本 stdout,所以「该步骤失败时必然打印的 + 那行」没出现,就说明失败不在那一步。 +3. **Wine 通过 = 零信息**(对路径语义而言)。Wine 把 `Z:` 映射到 `/`, + `\tmp\…` 落回真实 `/tmp/…`。 + +修法按「一类」而不是「一行」:`_host_path.sh` + `00_fixture_path_hygiene.sh` lint + +全仓 24 文件 40 处迁移。大多数此前只是因为对应测试在 Windows 上因缺 capability 而 SKIP。 + +### A.3 计划外收进来的两项 + +- **#401(私有 glibc 泄漏进子进程环境)。** 已修,收敛为新模块 + `src/platform/runtime_env_contract.cppm` 的**作用域**决策(不是条件判断)。 + 实测对照:已发布 mcpp `LDLP=[…/runtime:…/xim-x-glibc/2.39/lib64]`,本分支 `LDLP=[…/runtime]`; + 产物 RUNPATH 改动前后逐字节相同,说明这条环境项本来就没有收益。 +- **覆盖盲区。** 身份/索引路由的 e2e 全部要 `gcc` 或 `fresh-sandbox`,Windows 两者皆无。 + 新增 `210_local_index_addressing_on_every_host.sh`:无编译器、无沙箱、无网络,三平台都跑。 + +### A.4 主线 C 的实际阻塞点变了 + +正文假设瓶颈是「等 xlings #521 合并 + 发版」。加入 D1 的迁移窗口后,#521 确实离开了关键路径。 + +**但按要求把 xlings pin 提到最新 `2026.8.10.1` 之后出现了新的、更硬的阻塞:** +冷 home 装不上 `xim:gcc@16.1.0` —— 依赖解析下载 glibc 2.44,而 gcc 的 config hook +找不到该 payload(诊断建议的是 2.39)。同一 workflow、同一 runner 镜像、两次都确认 +cache miss 的 A/B: + +| xlings | 冷缓存 | 结果 | +|---|---|---| +| `2026.8.9.2` | 已确认 cache miss | gcc 安装成功(run `31317627461`) | +| `2026.8.10.1` | 已确认 cache miss | 失败(run `31335075557`,4/4 Linux job) | + +所以不是「一直坏、被热缓存掩盖」。已带证据上报 +[openxlings/xlings#524](https://github.com/openxlings/xlings/issues/524), +mcpp 侧把 pin 停在 `2026.8.9.2`,等修复发布后用独立 commit 提升。 + +> 这条本身也是一个判据:**「pin 到最新」是一个需要被验证的动作,不是一次文本替换。** +> 它之所以在这里被抓到,只是因为换 pin 同时换掉了 CI 缓存键,把冷启动路径暴露了出来。 diff --git a/.github/actions/bootstrap-mcpp/action.yml b/.github/actions/bootstrap-mcpp/action.yml index 400311d3..68355d10 100644 --- a/.github/actions/bootstrap-mcpp/action.yml +++ b/.github/actions/bootstrap-mcpp/action.yml @@ -25,7 +25,7 @@ inputs: # `package.name`, so one of the two was simply unreachable — and which one # depended on the machine, which is why CI failed on `compat:lua` on # Windows and `mcpplibs.capi:lua` on Linux. Never pin below that. - default: '2026.8.8.1' + default: '2026.8.9.2' cache-target: description: also restore/save target/ (build artifacts + BMIs) required: false diff --git a/.github/actions/setup-macos-llvm/action.yml b/.github/actions/setup-macos-llvm/action.yml index cabba4cf..0e7fef7d 100644 --- a/.github/actions/setup-macos-llvm/action.yml +++ b/.github/actions/setup-macos-llvm/action.yml @@ -15,7 +15,7 @@ inputs: # Floor imposed by the index, not a routine bump — see # .github/actions/bootstrap-mcpp/action.yml for why 0.4.69 is required # (two packages named `lua` in one repo need openxlings/xlings#381). - default: '2026.8.8.1' + default: '2026.8.9.2' runs: using: composite diff --git a/.github/workflows/aur-publish.yml b/.github/workflows/aur-publish.yml index 661cb893..0ea09b17 100644 --- a/.github/workflows/aur-publish.yml +++ b/.github/workflows/aur-publish.yml @@ -1,102 +1,138 @@ name: aur-publish -# Publish the `mcpp-bin` and `mcpp` AUR packages after a release. -# -# Triggers on COMPLETION of the `release` workflow (not on `release: -# published`): release.yml creates the GitHub Release in its first job but -# uploads the aarch64 / macOS / Windows assets in LATER jobs, so the aarch64 -# .sha256 that mcpp-bin needs only exists once the whole workflow finishes. -# -# Requires one repository secret: -# AUR_SSH_PRIVATE_KEY — private key whose public half is registered on the -# AUR account that owns mcpp / mcpp-bin. -# See scripts/aur/README.md → "Automated publishing" for the full setup. +# Reconcile only mcpp-bin. This workflow is downstream of `release`, so its +# failure is visible without changing the already-terminal release conclusion. on: workflow_run: workflows: [release] types: [completed] + schedule: + - cron: '17 */6 * * *' workflow_dispatch: inputs: - version: - description: "Version to publish (default: [package].version in mcpp.toml)" + publish: + description: 'Publish the validated diff (false performs a dry-run only)' + type: boolean + required: true + default: false + tag: + description: 'Optional exact latest complete stable tag (no downgrade override)' + type: string required: false concurrency: - group: aur-publish + group: aur-mcpp-bin-reconcile cancel-in-progress: false +permissions: + contents: read + jobs: - publish: - runs-on: ubuntu-latest - # On the workflow_run trigger, only proceed if the release actually - # succeeded (skip failed/cancelled release runs). + reconcile: + name: reconcile mcpp-bin if: >- - github.event_name == 'workflow_dispatch' || + github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-24.04 + timeout-minutes: 60 + env: + GH_TOKEN: ${{ github.token }} + PYTHONDONTWRITEBYTECODE: '1' + REQUESTED_TAG: ${{ inputs.tag }} steps: - - name: Checkout released commit + - name: Checkout reconciler source uses: actions/checkout@v4 with: - # workflow_run: the exact commit the release was built from. - # workflow_dispatch: default ref (HEAD of the branch). ref: ${{ github.event.workflow_run.head_sha || github.ref }} - - name: Refresh both PKGBUILDs to the release version - id: refresh + - name: Reconciler contract tests + run: python3 tests/scripts/test_aur_reconcile.py + + # This phase has no SSH private key in its environment. It downloads the + # immutable manifest and both Linux payload/sidecars, recomputes hashes, + # runs makepkg as non-root in Arch, inspects RPC + HTTPS git, and emits the + # exact diff before any publishing secret is loaded. + - name: Inspect and validate desired state + id: plan env: - # CI runs as root; force update.sh's template .SRCINFO path. - MCPP_AUR_NO_MAKEPKG: "1" + TRIGGER: ${{ github.event_name }} + MANUAL_PUBLISH: ${{ inputs.publish }} + # Repository variable, absent until a human has watched one publish + # succeed. See "Arming the automatic triggers" in scripts/aur/README.md. + AUTOPUBLISH: ${{ vars.AUR_AUTOPUBLISH }} run: | - VER="${{ github.event.inputs.version }}" - if [ -z "$VER" ]; then - # mcpp.toml at the released commit carries the right version. - VER=$(grep -m1 -E '^\s*version\s*=' mcpp.toml | sed -E 's/.*"([^"]+)".*/\1/') - fi - echo "version=$VER" >> "$GITHUB_OUTPUT" - ./scripts/aur/update.sh "$VER" + args=( + --trigger "$TRIGGER" + --report-json "$RUNNER_TEMP/aur-plan.json" + --summary "$GITHUB_STEP_SUMMARY" + ) + [[ -z "$REQUESTED_TAG" ]] || args+=(--tag "$REQUESTED_TAG") + python3 scripts/aur/reconcile_mcpp_bin.py "${args[@]}" - - name: Configure AUR SSH - run: | - install -dm700 ~/.ssh - printf '%s\n' "${{ secrets.AUR_SSH_PRIVATE_KEY }}" > ~/.ssh/aur - chmod 600 ~/.ssh/aur - ssh-keyscan -t rsa,ed25519 aur.archlinux.org >> ~/.ssh/known_hosts 2>/dev/null - cat > ~/.ssh/config <<'EOF' - Host aur.archlinux.org - User aur - IdentityFile ~/.ssh/aur - IdentitiesOnly yes - EOF + # An unattended push to a third-party service must be ARMED, not + # inherited from a merge. `schedule` fires every six hours off the + # default branch, so merging this workflow used to be enough to make + # mcpp start writing to the AUR on its own — before anyone had seen + # the reconciler complete a real push even once. Both automatic + # triggers therefore plan-and-report until AUR_AUTOPUBLISH is set; + # `workflow_dispatch` keeps its explicit per-run switch, which is how + # that first push is meant to happen. + case "$TRIGGER" in + workflow_run | schedule) + if [[ "${AUTOPUBLISH:-}" == "true" ]]; then + publish=true + else + publish=false + echo "::notice::AUR_AUTOPUBLISH is not set — reporting the desired state without publishing." + fi + ;; + *) + publish=${MANUAL_PUBLISH:-false} + ;; + esac + echo "needs_publish=$(jq -r '.needs_publish' "$RUNNER_TEMP/aur-plan.json")" >> "$GITHUB_OUTPUT" + echo "publish=$publish" >> "$GITHUB_OUTPUT" - - name: Push to the AUR + - name: Configure pinned AUR SSH identity + if: steps.plan.outputs.needs_publish == 'true' && steps.plan.outputs.publish == 'true' env: - VER: ${{ steps.refresh.outputs.version }} + AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} run: | - set -eu - git config --global user.name "mcpp-ci" - git config --global user.email "x.d2learn.org@gmail.com" + test -n "$AUR_SSH_PRIVATE_KEY" || { echo 'AUR_SSH_PRIVATE_KEY is empty'; exit 1; } + install -dm700 "$HOME/.ssh" + install -m600 /dev/null "$HOME/.ssh/aur" + printf '%s\n' "$AUR_SSH_PRIVATE_KEY" > "$HOME/.ssh/aur" + install -m600 scripts/aur/aur.archlinux.org.known_hosts "$HOME/.ssh/known_hosts" + ssh-keygen -lf "$HOME/.ssh/known_hosts" -E sha256 \ + | grep -F 'SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4' + install -m600 /dev/null "$HOME/.ssh/config" + printf '%s\n' \ + 'Host aur.archlinux.org' \ + ' User aur' \ + ' IdentityFile ~/.ssh/aur' \ + ' IdentitiesOnly yes' \ + ' StrictHostKeyChecking yes' \ + ' UserKnownHostsFile ~/.ssh/known_hosts' \ + > "$HOME/.ssh/config" - publish() { # $1 = package name (= dir under scripts/aur/) - pkg="$1"; src="scripts/aur/${pkg}"; work="/tmp/aur-${pkg}" - # Clone the existing AUR repo; if the package doesn't exist yet - # (first publish), start an empty repo — AUR creates it on push. - if git clone "ssh://aur@aur.archlinux.org/${pkg}.git" "$work" 2>/dev/null \ - && [ -e "$work/.git" ]; then :; else - rm -rf "$work"; mkdir -p "$work" - git -C "$work" init -q - git -C "$work" remote add origin "ssh://aur@aur.archlinux.org/${pkg}.git" - fi - # AUR repos contain only PKGBUILD, .SRCINFO and local sources. - cp "$src/PKGBUILD" "$src/.SRCINFO" "$src/mcpp.sh" "$work/" - git -C "$work" add -A - if git -C "$work" diff --cached --quiet; then - echo ":: ${pkg}: no changes, skipping" - return 0 - fi - git -C "$work" commit -q -m "${pkg} ${VER}" - git -C "$work" push origin HEAD:master - echo ":: ${pkg}: published ${VER}" - } + - name: Fast-forward publish and verify convergence + if: steps.plan.outputs.needs_publish == 'true' && steps.plan.outputs.publish == 'true' + env: + TRIGGER: ${{ github.event_name }} + run: | + args=( + --publish + --trigger "$TRIGGER" + --report-json "$RUNNER_TEMP/aur-final.json" + --summary "$GITHUB_STEP_SUMMARY" + ) + [[ -z "$REQUESTED_TAG" ]] || args+=(--tag "$REQUESTED_TAG") + python3 scripts/aur/reconcile_mcpp_bin.py "${args[@]}" - publish mcpp-bin - publish mcpp-m + - name: Preserve reconciliation reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: aur-mcpp-bin-reconciliation + path: ${{ runner.temp }}/aur-*.json + if-no-files-found: error diff --git a/.github/workflows/bootstrap-macos.yml b/.github/workflows/bootstrap-macos.yml index de2e817b..f69a58eb 100644 --- a/.github/workflows/bootstrap-macos.yml +++ b/.github/workflows/bootstrap-macos.yml @@ -17,7 +17,7 @@ jobs: # Dormant (workflow_dispatch only), but kept in step with the rest — # check_version_pins.sh holds it there. Floor: 0.4.69, below which the # index cannot resolve two packages that share a short name. - XLINGS_VERSION: '2026.8.8.1' + XLINGS_VERSION: '2026.8.9.2' steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/ci-fresh-install.yml b/.github/workflows/ci-fresh-install.yml index d363080c..1e1c96ea 100644 --- a/.github/workflows/ci-fresh-install.yml +++ b/.github/workflows/ci-fresh-install.yml @@ -152,7 +152,7 @@ jobs: env: XLINGS_NON_INTERACTIVE: '1' run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.8.1 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.9.2 echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - name: Install mcpp and config mirror @@ -179,11 +179,12 @@ jobs: # Template packages exercise the sha256-pinned mcpp-index fetch # path (user report: `mcpp new ... --template imgui` failed with # fetch 'imgui@0.0.6' exit 1 on hosts without a sha256sum binary). - - name: "Template: mcpp new --template imgui (fetch path)" + - name: "Template: exact mcpplibs.imgui selector (fetch path)" run: | cd "$(mktemp -d)" - mcpp new abc1 --template imgui + mcpp new abc1 --template mcpplibs.imgui 2>&1 | tee template.log test -f abc1/mcpp.toml + grep -F 'namespace=mcpplibs name=imgui' template.log - name: "Default: build mcpp" run: | @@ -292,7 +293,7 @@ jobs: - name: Install xlings + mcpp run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.8.1 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.9.2 # Deliberately NOT writing to $GITHUB_PATH here. On container # images that declare no PATH in their config (opensuse/ # tumbleweed), appending a single dir to GITHUB_PATH makes the @@ -363,7 +364,7 @@ jobs: # (older ones carry minos=15 and refuse to start). # v0.4.51+: in-process sha256 — this image has no sha256sum # binary, so pinned fetches failed before it. - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.8.1 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.9.2 echo "$HOME/.xlings/subos/current/bin" >> "$GITHUB_PATH" - name: Install mcpp and config mirror @@ -391,11 +392,12 @@ jobs: # path — this is what broke on hosts without a sha256sum binary # (stock macOS / bare Windows) before xlings 0.4.51 hashed # in-process. - - name: "Template: mcpp new --template imgui (fetch path)" + - name: "Template: exact mcpplibs.imgui selector (fetch path)" run: | cd "$(mktemp -d)" - mcpp new abc1 --template imgui + mcpp new abc1 --template mcpplibs.imgui 2>&1 | tee template.log test -f abc1/mcpp.toml + grep -F 'namespace=mcpplibs name=imgui' template.log - name: "LLVM: build mcpp" run: | @@ -466,13 +468,15 @@ jobs: # path (user report: `mcpp new abc1 --template imgui` failed with # fetch 'imgui@0.0.6' exit 1 on bare Windows — no sha256sum binary # outside git-bash; fixed by xlings 0.4.51 in-process hashing). - - name: "Template: mcpp new --template imgui (fetch path)" + - name: "Template: exact mcpplibs.imgui selector (fetch path)" shell: pwsh run: | $tmp = New-TemporaryFile | ForEach-Object { Remove-Item $_; New-Item -ItemType Directory -Path $_ } Set-Location $tmp - mcpp new abc1 --template imgui + mcpp new abc1 --template mcpplibs.imgui 2>&1 | Tee-Object -Variable templateOutput + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } if (!(Test-Path abc1/mcpp.toml)) { exit 1 } + if (-not (($templateOutput -join "`n") -match 'namespace=mcpplibs name=imgui')) { exit 1 } - name: "LLVM: build mcpp" shell: pwsh diff --git a/.github/workflows/ci-linux-e2e.yml b/.github/workflows/ci-linux-e2e.yml index 360c135f..fe479bbc 100644 --- a/.github/workflows/ci-linux-e2e.yml +++ b/.github/workflows/ci-linux-e2e.yml @@ -133,7 +133,7 @@ jobs: - name: Bootstrap xlings + released mcpp run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.8.1 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v2026.8.9.2 export PATH="$HOME/.xlings/subos/current/bin:$PATH" xlings update xlings install mcpp -y -g diff --git a/.github/workflows/cross-build-test.yml b/.github/workflows/cross-build-test.yml index 1ee50a9c..be57dd3a 100644 --- a/.github/workflows/cross-build-test.yml +++ b/.github/workflows/cross-build-test.yml @@ -118,7 +118,7 @@ jobs: # release assets were uploaded in a broken state (records present, # blobs missing → 404 on GET); re-uploaded clean. The stale-INDEX # half is handled by the marker-clear below. - XLINGS_VERSION: '2026.8.8.1' + XLINGS_VERSION: '2026.8.9.2' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" curl -fsSL -o "/tmp/${tarball}" \ @@ -255,7 +255,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.8.1' + XLINGS_VERSION: '2026.8.9.2' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" curl -fsSL -o "/tmp/${tarball}" \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cf4e8861..19a2f994 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,7 +96,7 @@ jobs: # Pin xlings to a known-good version. The upstream install # script always grabs `latest` (no version override), so we # download + self-install manually to avoid broken releases. - XLINGS_VERSION: '2026.8.8.1' + XLINGS_VERSION: '2026.8.9.2' run: | if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" @@ -288,7 +288,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.8.1' + XLINGS_VERSION: '2026.8.9.2' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" curl -fsSL -o "/tmp/${tarball}" \ @@ -358,11 +358,11 @@ jobs: # below are pinned to the same version as XLINGS_VERSION; they are # NOT interpolated from it, so check_version_pins.sh scans for them # explicitly (they were absent from the old lock-step comment). - XLA="xlings-2026.8.8.1-linux-aarch64.tar.gz" + XLA="xlings-2026.8.9.2-linux-aarch64.tar.gz" if curl -fsSL -o "/tmp/$XLA" \ - "https://github.com/openxlings/xlings/releases/download/v2026.8.8.1/$XLA"; then + "https://github.com/openxlings/xlings/releases/download/v2026.8.9.2/$XLA"; then tar -xzf "/tmp/$XLA" -C /tmp - XLBIN=$(find /tmp/xlings-2026.8.8.1-linux-aarch64 -path '*/bin/xlings' -type f | head -1) + XLBIN=$(find /tmp/xlings-2026.8.9.2-linux-aarch64 -path '*/bin/xlings' -type f | head -1) if [ -n "$XLBIN" ]; then mkdir -p "$STAGING/$WRAPPER/registry/bin" cp "$XLBIN" "$STAGING/$WRAPPER/registry/bin/xlings" @@ -440,7 +440,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.8.1' + XLINGS_VERSION: '2026.8.9.2' run: | if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then WORK=$(mktemp -d) @@ -622,7 +622,7 @@ jobs: shell: bash env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '2026.8.8.1' + XLINGS_VERSION: '2026.8.9.2' run: | # Captured before the `cd` below, in POSIX form: this step never # returns to the workspace, and GITHUB_WORKSPACE is a backslash @@ -725,16 +725,116 @@ jobs: dist/mcpp-windows-x86_64.zip dist/mcpp-windows-x86_64.zip.sha256 + # Seal the complete, non-draft release inventory only after every platform + # uploader has finished. The manifest is desired state for downstream + # reconcilers: a rerun may reproduce it byte-for-byte, but may never replace + # it with different bytes for the same tag. + release-manifest: + name: validate + seal release manifest + needs: [build-release, build-linux-aarch64, build-macos, build-windows] + runs-on: ubuntu-24.04 + permissions: + contents: write + timeout-minutes: 20 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Resolve immutable release identity + id: resolve + run: | + if [ "${{ github.event_name }}" = "push" ]; then + TAG="${{ github.ref_name }}" + elif [ -n "${{ github.event.inputs.tag }}" ]; then + TAG="${{ github.event.inputs.tag }}" + else + VERSION=$(awk -F '"' '/^version[[:space:]]*=/{print $2; exit}' mcpp.toml) + test -n "$VERSION" || { echo 'failed to read [package].version from mcpp.toml'; exit 1; } + TAG="v$VERSION" + fi + git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG" + COMMIT=$(git rev-list -n 1 "refs/tags/$TAG") + test -n "$COMMIT" + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + echo "commit=$COMMIT" >> "$GITHUB_OUTPUT" + + - name: Generate, upload once, and refetch manifest + run: | + TAG="${{ steps.resolve.outputs.tag }}" + VERSION="${{ steps.resolve.outputs.version }}" + COMMIT="${{ steps.resolve.outputs.commit }}" + AUDIT_ROOT=$(mktemp -d) + mkdir -p "$AUDIT_ROOT/assets" "$AUDIT_ROOT/publish" + + gh api "repos/${GITHUB_REPOSITORY}/releases/tags/$TAG" \ + > "$AUDIT_ROOT/release.json" + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \ + --dir "$AUDIT_ROOT/assets" + python3 scripts/release/generate_manifest.py \ + --release-json "$AUDIT_ROOT/release.json" \ + --assets-dir "$AUDIT_ROOT/assets" \ + --version "$VERSION" \ + --tag "$TAG" \ + --commit "$COMMIT" \ + --output "$AUDIT_ROOT/publish/mcpp-release.json" + + if [ -f "$AUDIT_ROOT/assets/mcpp-release.json" ]; then + cmp "$AUDIT_ROOT/assets/mcpp-release.json" \ + "$AUDIT_ROOT/publish/mcpp-release.json" + echo "Existing manifest is byte-identical; leaving it untouched." + else + gh release upload "$TAG" \ + "$AUDIT_ROOT/publish/mcpp-release.json" \ + --repo "$GITHUB_REPOSITORY" + fi + + # Do not trust the upload command alone. Fetch the final public + # inventory into a clean directory, recompute every payload digest, + # regenerate desired state, and compare the published bytes. + MANIFEST_VISIBLE=false + for attempt in {1..12}; do + if gh api "repos/${GITHUB_REPOSITORY}/releases/tags/$TAG" \ + --jq '.assets[].name' | grep -Fxq mcpp-release.json; then + MANIFEST_VISIBLE=true + break + fi + echo "Waiting for mcpp-release.json API visibility ($attempt/12)" + sleep 5 + done + test "$MANIFEST_VISIBLE" = true + mkdir -p "$AUDIT_ROOT/final-assets" + gh api "repos/${GITHUB_REPOSITORY}/releases/tags/$TAG" \ + > "$AUDIT_ROOT/final-release.json" + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \ + --dir "$AUDIT_ROOT/final-assets" + test -f "$AUDIT_ROOT/final-assets/mcpp-release.json" + python3 scripts/release/generate_manifest.py \ + --release-json "$AUDIT_ROOT/final-release.json" \ + --assets-dir "$AUDIT_ROOT/final-assets" \ + --version "$VERSION" \ + --tag "$TAG" \ + --commit "$COMMIT" \ + --output "$AUDIT_ROOT/final-expected.json" + cmp "$AUDIT_ROOT/final-assets/mcpp-release.json" \ + "$AUDIT_ROOT/final-expected.json" + cat "$AUDIT_ROOT/final-assets/mcpp-release.json" + # Publish this release into the xlings ecosystem, after ALL platform builds - # have uploaded their assets: + # have uploaded their assets and the immutable manifest gate has passed: # ① mirror binaries → xlings-res/mcpp (GitHub + GitCode) so XLINGS_RES # downloads resolve on every platform (incl. the CN/GitCode path); # ② open a PR against openxlings/xim-pkgindex bumping mcpp to this version # (a maintainer merges it — index git source is not on the critical path). - # Best-effort / non-blocking: a failure here never fails the release. + # These publication steps are required for ecosystem completeness: their + # failure leaves the GitHub Release object available but keeps this workflow + # red, so post-release verification cannot report a complete release. # Shared vendored scripts live in .github/tools/ (kept in sync with xlings). publish-ecosystem: - needs: [build-release, build-linux-aarch64, build-macos, build-windows] + needs: release-manifest runs-on: ubuntu-latest # A4 hardening: a single stuck upload once held this job >1h (6h default # ceiling). The mirror script has per-file timeouts and (post-0.0.89) diff --git a/.xlings.json b/.xlings.json index 2a35c39e..652d12f8 100644 --- a/.xlings.json +++ b/.xlings.json @@ -1,5 +1,5 @@ { "workspace": { - "mcpp": "2026.8.6.2" + "mcpp": "2026.8.8.4" } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 3143d1bc..24234f5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,72 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [2026.8.10.1] — 2026-08-10 + +包身份、开发运行时与发布链收敛为同一组可验证事实。完整设计与验证记录见 +`.agents/docs/2026-08-09-mcpp-template-runtime-graphics-aur-focused-design.md` 与 +`.agents/docs/2026-08-09-mcpp-template-runtime-graphics-aur-validation.md`。 + +### 新增 + +- **模板 selector 与 `mcpp add` 同源。** `mcpp new --template` 统一为 + `[namespace.]name[@version][:tname]`;省略 namespace 只表示 `mcpplibs`,不再做 + 全索引短名搜索。未写 `tname` 时选择唯一显式 default;若只有一个模板且没有 + `default = true`,该单模板自动成为默认。多模板歧义明确失败并提示 + `--list-templates`,不引入 `--variant`。 +- **事务化 scaffold。** 包身份、版本、模板、变量与 hook 全部在 sibling 临时目录中 + 完成解析/渲染/校验,成功后才提交目标目录;失败不会留下半成品。 +- **根项目级 RuntimeBinding。** 未声明 `[xlings].subos` 使用 mcpp 初始化并经 release + 验证的 `McppDefault`;也可在 `mcpp.toml` 选择命名 SubOS。无 CLI/env override, + workspace root 决定整体环境,member/dependency 的 SubOS 不传递。不同 SubOS/glibc + contract 进入独立构建指纹,可在同一机器并存。 +- **provider-neutral 图形运行时契约。** mcpp-index 描述通用 requirement,xlings/xim + 选择 OpenGL/Vulkan、Mesa/NVIDIA、WSL、ICD/driver provenance;mcpp 只消费已解析的 + RuntimeBinding/LinkIntent、记录 resolution,并做平台通用链接与闭包校验,绝不探测 GPU。 +- **不可变 release manifest。** 全平台资产完成后生成 `mcpp-release.json`,逐项重算 + SHA256,并在公开 GitHub inventory 上重放比较;它是下游发布的唯一 desired state。 +- **`mcpp-bin` AUR reconciler。** 校验 manifest、双 Linux 资产/sidecar、AUR RPC/git 与 + Arch `vercmp`,先 dry-run exact diff,再以固定 host key 做普通 fast-forward push, + 有界重试并验证 RPC/公开 HEAD/干净 Arch 安装。定时恢复瞬时故障;`mcpp-m`、 + `mcpp-git` 不在自动化作用域。 + +### 修复 + +- **私有 glibc 不再泄漏进子进程环境(#401)。** `mcpp run` 曾把私有 glibc 目录放进 + `LD_LIBRARY_PATH`,而该变量会被程序派生的**每一个**进程继承;`/bin/sh` 由**宿主** + loader 加载(`PT_INTERP` 写死在可执行文件里,任何环境变量都改不了),于是在重定位 + 阶段就死于 `undefined symbol: __pointer_chk_guard, version GLIBC_PRIVATE`。 + 该目录本来只为「可执行文件 DT_NEEDED 闭包覆盖不到的 dlopen」而存在,而产物的 + RUNPATH 已经覆盖了它(link model 在 `--dynamic-linker` 旁就发了 + `-Wl,-rpath,`,改动前后产物 RUNPATH 逐字节相同),所以这条环境项没有收益、 + 只有代价。决策收敛在 `mcpp.platform.runtime_env_contract`:私有 libc 是 + **binary 作用域**,不是 environment 作用域。 +- RuntimeBinding 不再把 stale 的声明文本误当成实际 payload:有效 SubOS view 可规范化到 + 唯一受管 payload 时记录真实身份;旧 view 断链时只解析声明精确指名的 payload, + 仍绝不枚举目录挑版本。 +- Linux ELF/glibc 闭包规则的单测只在 Linux 断言相应物理语义;macOS/Windows 原生 CI + 固定验证 typed no-op 边界,不再拿 Linux 结果误判其他平台。 + +### 迁移 + +- **省略 namespace 的依赖获得一个版本的过渡期。** 省略 namespace 依然精确表示 + `mcpplibs`,但当精确坐标未命中时,`compat.` 与「不声明 namespace 的上游 + descriptor」两级会再被尝试一次;命中会打印弃用警告、给出可直接粘贴的 manifest + 片段,并把**规范身份**写入 lock/install/cache(歧义拼写只留在用户 manifest 里)。 + `mcpp add <裸名>` 直接把规范点分形式写回 `mcpp.toml`。 + 写明的身份(如 `mcpplibs.gtest`)不进入过渡期,未命中即失败;第三方 namespace 仍 + 不可被裸名触达。该过渡期在 `2026.9` 移除。 + - 理由:索引里已发布的 `compat.*` 包与既有用户 manifest 全部使用裸名写法。 + 「已发布的数据不得让程序失效」与「已发布的程序不得让既有数据失效」是同一条判据, + 两个方向都必须降级而不是变砖。#278 修掉的缺陷是**静默**回退,不是回退本身。 + +### 其他 + +- xlings pin 保持在 `2026.8.9.2`,自举 mcpp pin 提升到已发布的 `2026.8.8.4`。 + 曾提升到 `2026.8.10.1` 并回退:该版本在**冷 home** 上装不上 `xim:gcc@16.1.0` + (依赖解析到 glibc 2.44,而 gcc 的 config hook 找不到该 payload), + 同一 workflow 的冷缓存 A/B 已确认,见 openxlings/xlings#524。修复发布后再提升。 + ## [2026.8.8.4] — 2026-08-08 机器可读输出有契约了。设计与实测见 diff --git a/README.md b/README.md index 98b8e55b..2e8c76ab 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,8 @@ yay -S mcpp-m # or build from source (bootstrapped with mcpp-bin) Installs the `mcpp` command system-wide; per-user data still lives in `~/.mcpp/`. On Arch the name `mcpp` is an unrelated C preprocessor, so the packages are `mcpp-bin` / `mcpp-m` (see [`scripts/aur/`](scripts/aur/)). +Stable-release automation reconciles `mcpp-bin` only; `mcpp-m` and `mcpp-git` +remain manually maintained and may intentionally lag. @@ -274,7 +276,7 @@ import mcpplibs.cmdline;

Developer experience -- `mcpp new` — create a modular project; `--template [@ver][:]` uses a **library-provided template** (e.g. `--template imgui`); `--list-templates ` lists them +- `mcpp new` — create a modular project; `--template [ns.]name[@version][:tname]` uses a **package-provided template** with the same exact identity style as `mcpp add`. A sole template is the default even without `default = true`; `--list-templates [ns.]name[@version]` lists ambiguous sets - `mcpp run [-- args]` — build and run - `mcpp test [pattern] [-- args]` — auto-discover and run tests (filter by name; `--list`, `--timeout `, `--message-format json`) - `mcpp search` — search package indices diff --git a/README.zh-CN.md b/README.zh-CN.md index 538a7b46..be4ac637 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -139,6 +139,8 @@ yay -S mcpp-m # 或源码构建(用 mcpp-bin 自举) 系统级安装 `mcpp` 命令,每个用户的数据仍在各自的 `~/.mcpp/`。 Arch 上 `mcpp` 这个名字属于一个无关的 C 预处理器,所以包名是 `mcpp-bin` / `mcpp-m`(详见 [`scripts/aur/`](scripts/aur/))。 +稳定 release 的自动对账只管理 `mcpp-bin`;`mcpp-m` 与 `mcpp-git` 仍由人工维护, +可能有意滞后。
@@ -271,7 +273,7 @@ import mcpplibs.cmdline;
开发体验 -- `mcpp new` — 创建模块化项目;`--template [@ver][:]` 使用**库自带模板**(如 `--template imgui`),`--list-templates ` 列举 +- `mcpp new` — 创建模块化项目;`--template [ns.]name[@version][:tname]` 与 `mcpp add` 使用同一精确身份风格并选择**包自带模板**。只有一个模板时即使未写 `default = true` 也自动成为默认;歧义时用 `--list-templates [ns.]name[@version]` 列举 - `mcpp run [-- args]` — 构建并运行 - `mcpp test [pattern] [-- args]` — 自动发现并运行测试(按名字过滤;`--list`、`--timeout `、`--message-format json`) - `mcpp search` — 搜索包索引 diff --git a/docs/00-getting-started.md b/docs/00-getting-started.md index 6b7b23a5..1d80e0e8 100644 --- a/docs/00-getting-started.md +++ b/docs/00-getting-started.md @@ -71,6 +71,27 @@ int main() { } ``` +### Creating from a package template + +`mcpp new --template` uses the same exact package-selector style as `mcpp add`: + +```bash +mcpp new gui-demo --template ocornut.imgui@1.92.8:docking +mcpp new --list-templates ocornut.imgui@1.92.8 +``` + +The grammar is `[namespace.]name[@version][:template]`. The namespace, version, +and template name are independently optional; omitting the namespace means the +single default namespace `mcpplibs`, not an index-wide short-name search. If the +template name is omitted, mcpp uses the sole `default = true` declaration, or +automatically uses the package's only template when none is explicitly marked. +Multiple templates without one default are an error that points to +`--list-templates`. There is no separate `--variant` vocabulary. + +The package identity, version, and template are fully resolved before the +destination is committed. A failed download, render, hook, or validation leaves +no half-created project directory. + ## Building and Running ```bash @@ -149,8 +170,9 @@ For the differences between the four modes and their artifact layouts, see [02 ## More Entry Points -- GUI quickstart: `mcpp new myapp --template imgui` (templates are distributed with the imgui library and their versions are aligned automatically; - run `mcpp new --list-templates imgui` to see all templates the library provides, or use `--template imgui:docking` to select a specific one). +- GUI quickstart: `mcpp new myapp --template ocornut.imgui@1.92.8:docking` + (templates are distributed with the package; omit `:docking` for its declared + default/sole template, or run `mcpp new --list-templates ocornut.imgui@1.92.8`). - Explaining default decisions: `mcpp why [toolchain|runtime|deps]`; host capability checkup: `mcpp self doctor`; machine-readable resolution manifest: the build artifact `target///resolution.json`. - Offline operation: `mcpp --offline` or `MCPP_OFFLINE=1` prevents index refreshes, downloads, and toolchain installation. diff --git a/docs/05-mcpp-toml.md b/docs/05-mcpp-toml.md index 999ce66c..8ed81a6d 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -375,18 +375,18 @@ Default convention: `src/.cppm` (e.g. package name ```toml # Packages under the default package namespace (mcpplibs) [dependencies] -gtest = "1.15.2" # Exact version -mbedtls = "3.6.1" -ftxui = "6.1.9" +cmdline = "0.0.2" # Exact version +templates = "0.0.1" -# Dotted selector: try mcpplibs. first, then fall back to the sibling peer root. -# For example, imgui.core is tried in order as mcpplibs.imgui/core, then imgui/core. -[dependencies] -capi.lua = "0.0.3" +# Dotted selector: one exact identity. Everything before the final dot is the +# namespace; the final segment is the package name. compat.gtest = "1.15.2" imgui.core = "0.0.1" imgui.backend.glfw_opengl3 = "0.0.1" +mcpplibs.capi.lua = "0.0.3" +``` +```toml # Namespace sub-table form [dependencies.mcpplibs] cmdline = "0.0.2" @@ -394,17 +394,23 @@ tinyhttps = "0.2.2" llmapi = "0.2.5" [dependencies.compat] -glfw = "3.4" # Explicit namespace, skips the mcpplibs-first candidate +glfw = "3.4" # Explicit namespace; no fallback search +``` +```toml # Path dependency (local development) [dependencies] mylib = { path = "../mylib" } +``` +```toml # Git dependency — pick exactly one of tag / branch / rev [dependencies] mylib = { git = "https://github.com/user/mylib.git", tag = "v1.0.0" } applib = { git = "https://github.com/user/applib.git", branch = "develop" } +``` +```toml # Long-form dep spec: features and backend knobs [dependencies] imgui = { version = "0.0.3", features = ["docking"] } # Request a feature of this dependency @@ -449,24 +455,20 @@ qux = ">=1.0, <2.0" # Range combination #### Namespace resolution rules -Every package has a two-part identity: a **namespace** and a **name**. How you write -a dependency key decides which namespaces mcpp will look in. - -**A bare name resolves in exactly three places**, in order: +Every package has a two-part identity: a **namespace** and a **name**. Every +selector normalizes to exactly one identity: -| # | Namespace | Example | -|---|---|---| -| 1 | `mcpplibs` — the default namespace | `cmdline = "0.0.2"` | -| 2 | `compat` — the wrapper namespace for third-party C/C++ libraries | `gtest = "1.15.2"` → `compat.gtest` | -| 3 | upstream packages that declare no namespace at all | `opencv = "4.10.0"` | +- `cmdline` → `(mcpplibs, cmdline)`; omitting the namespace means the + `mcpplibs` default, and nothing else. +- `compat.gtest` → `(compat, gtest)`. +- `mcpplibs.capi.lua` → `(mcpplibs.capi, lua)`. -**Any other namespace must be written out in full.** There is no fuzzy, index-wide -search by short name: +There is no ordered fallback or fuzzy, index-wide search by short name: ```toml # ✅ Correct — dotted selector [dependencies] -"chriskohlhoff.asio" = "1.38.1" +chriskohlhoff.asio = "1.38.1" # ✅ Correct — namespace sub-table (preferred when you have several from one org) [dependencies.chriskohlhoff] @@ -477,14 +479,41 @@ asio = "1.38.1" asio = "1.38.1" ``` -The third form fails with an error that lists the namespaces that were searched and, -when a package with that short name exists elsewhere, the exact line to write instead. +The third form fails with an error that names the exact `(mcpplibs, asio)` +identity that was tried and, when the short name exists elsewhere, gives a +copyable explicit selector. + +##### Migration window for bare names (`2026.8.10.1` → removed in `2026.9`) + +Every published `compat.*` package and every manifest written before exact +identity spells its dependency bare — `gtest = "1.15.2"`. Failing those +outright on upgrade would break builds against data that is already published +and cannot be edited retroactively, so for one release a bare name that misses +`mcpplibs` still reaches `compat.`, and a descriptor that declares no +namespace at all still answers to its bare name. -**Why not resolve bare names across every namespace?** Because dependency resolution -has to be reproducible. A global short-name search would mean that (a) two namespaces -owning the same short name are settled by index ordering, and (b) **adding an index -could silently change which package an existing dependency resolves to**. Requiring -the namespace keeps a `mcpp.toml` resolving to the same packages on every machine. +It is not quiet about it: + +``` +warning: dependency 'gtest' resolved to 'compat.gtest' through the deprecated +bare-name search; namespace omission means `mcpplibs` only. Write the exact +package: + [dependencies.compat] + gtest = "1.15.2" + (or run `mcpp add compat.gtest@1.15.2`). This fallback is removed in 2026.9. +``` + +What reaches `mcpp.lock`, the install layer and the cache is the canonical +identity, so the ambiguous spelling lives in exactly one place — your manifest +— until you change it. `mcpp add gtest@1.15.2` changes it for you. + +The window does **not** apply to a selector that states a namespace +(`mcpplibs.gtest` misses and stays missed), and a bare name still never reaches +a third-party namespace. + +**Why one identity?** Dependency resolution has to be reproducible. Candidate +search would let two namespaces with the same short name be settled by index +state, and adding an index could silently retarget an existing dependency. **For xpkg authors:** in an index descriptor, identity is the pair `(package.namespace, package.name)`. The namespace is the dotted path; **`name` is @@ -509,8 +538,17 @@ path) but not required. The older fully-qualified spelling (`name = "chriskohlhoff.asio"`) is still accepted, so already-published descriptors keep working. `mcpp xpkg parse` -enforces the rule — run it in your index CI. Requires mcpp >= 0.0.106 and -xlings >= 0.4.69; full normative text in `docs/spec/package-identity.md`. +enforces the descriptor rule — run it in your index CI. Descriptor identity +requires mcpp >= 0.0.106; exact selectors require mcpp >= 2026.8.10.1; both use +xlings >= 0.4.69. Full normative text is in `docs/spec/package-identity.md`. + +`mcpp new --template` deliberately reuses this identity model instead of +creating another package grammar: `[ns.]name[@version][:tname]`. A bare name +there also means only `mcpplibs`; version and template may be omitted +independently. Omitted `tname` selects the sole explicit default, or the only +template when no `default = true` is present. Multiple unmarked templates are +an error, never a directory-order choice. See the normative template rows in +`docs/spec/package-identity.md` §4.4. #### When mcpp refreshes the package index @@ -547,7 +585,7 @@ Run any command with `-v` to see the decision for each dependency and why. ### 2.6 `[dev-dependencies]` — Test Dependencies ```toml -[dev-dependencies] +[dev-dependencies.compat] gtest = "1.15.2" ``` @@ -905,31 +943,79 @@ exactly that: an entry's object paths are now addressed relative to the populate the entry first. `mcpp cache verify` additionally reports any entry whose recorded addresses escape it, so a recurrence is auditable offline. -### 2.11 `[runtime]` — Host Runtime Capabilities +### 2.11 `[runtime]` — Provider-neutral runtime contract ```toml [runtime] -library_dirs = ["vendor/lib"] # Directories baked into the artifact's RUNPATH (relative to the package root) -dlopen_libs = ["libGL.so.1"] # sonames dlopen'd at runtime (validated by doctor) -capabilities = ["opengl.glx.driver"] # Host capabilities required (open namespace) -provides = ["opengl.glx.driver"] # Explicitly declares capabilities this package fulfills (strong provider) - -# Explicit provider override (the "explicit" notch of the three-way knob) -[runtime."opengl.glx.driver"] -provider = "compat.glx-runtime" -``` - -- **Provider selection**: a package that declares `provides` (strong) takes precedence - over one that merely lists a capability under `capabilities` (weak, backward - compatible); `[runtime.] provider=` is an explicit override with the highest - precedence, and pointing at a provider not present in the dependency graph produces - a warning. -- The resolved result can be inspected via `mcpp why runtime`, `mcpp self doctor`, and - the build artifact `target///resolution.json` (it is not magic by - default). -- Capability naming convention: layered lowercase `domain.sub.role` (e.g. - `opengl.glx.driver`, `x11.display`) and prefix-style `abi:` (e.g. `abi:glibc`, - which participates in toolchain ABI enforcement). +requirements = [ + { kind = "capability", value = "display.present", phase = "run", required = true }, + { kind = "soname", value = "libwidget.so.1", phase = "link", required = false }, +] +provides = ["display.present"] +artifacts = [ + { role = "library", path = "runtime/libwidget.so.1", provenance = "payload", abi = "elf-x86_64", digest = "sha256:...", host_fingerprint = "host-1" }, +] + +# Platform-neutral LinkIntent. Paths are relative to this package root. +libraries = ["widget"] +link_library_dirs = ["lib"] +transitive_needed_dirs = ["runtime/closure"] +runtime_search_dirs = ["runtime"] +frameworks = ["WindowKit"] +deploy_files = ["bin/widget.dll"] + +# Use an exact canonical identity when multiple providers exist. +[runtime."display.present"] +provider = "acme.widget-runtime@2.0.0" +``` + +`requirements` records a non-empty `kind`/`value`, a `link` or `run` phase, +and whether the requirement is mandatory (`required` defaults to `true`). +Optional requirements remain visible provenance but do not become hard ABI or +doctor inputs. A `libraries` entry that is an explicit relative file path is +resolved against the declaring package root; a bare logical name remains a +platform-spelled library name. +`artifacts` requires `role`, `path`, and `provenance`; `abi`, `digest`, and +`host_fingerprint` are optional evidence. The resolver, not the descriptor, +stamps every requirement with the exact requester PackageId and every artifact +with the exact declaring provider PackageId, including namespace, version, and +source/index provenance. A descriptor therefore cannot spoof another package, +and `alpha.backend` never collapses into `beta.backend`. + +Only `provides` creates a descriptor-owned provider fact. Merely requiring a +capability never makes the requester its own provider. An explicit +`[runtime.] provider=` override accepts a canonical +`namespace.name@version` (or an unambiguous compatibility spelling); missing or +same-short-name ambiguous providers are hard errors. Provider/artifact facts +already selected by the xlings SubOS precede descriptor fallbacks. xlings/xim +owns graphics-stack, driver, ICD, WSL, and host provenance selection; mcpp +records and consumes the generic result and never probes GPU hardware. + +Link intent keeps discovery stages separate: + +| Field | ELF | Mach-O | PE/Windows | +|---|---|---|---| +| `link_library_dirs` | `-L` | `-L` | `-L` or `/LIBPATH:` | +| `transitive_needed_dirs` | `-Wl,-rpath-link` | no flag | no flag | +| `runtime_search_dirs` | RUNPATH/rpath only, never `-L` | rpath only | no flag | +| `frameworks` | no flag | `-framework` | no flag | +| `deploy_files` | copy edge | copy edge | copy beside the output; never a linker flag | + +For one compatibility train, `library_dirs` maps only to runtime search, +`dlopen_libs` maps to required run-phase soname requirements, and +`capabilities` maps to required run-phase capability requirements. None of +these legacy fields creates a provider. + +`target///resolution.json` schema 2 stores the RuntimeBinding, +canonical requirements/providers/artifacts, LinkIntent, platform search +mechanism, and post-link verdict. `mcpp why runtime` is a pure interpreter of +the latest stored file: it neither re-resolves the manifest nor launches a +graphics/hardware probe. Use `xlings doctor` when the selected host provider +itself needs re-diagnosis. + +Capability names use layered lowercase `domain.sub.role` (for example +`display.present`) and prefix-style `abi:` (for example `abi:glibc`, which +participates in toolchain ABI enforcement). ### 2.12 `[package] platforms` — Platform Declaration @@ -966,15 +1052,18 @@ build needs (`make`/`cmake`/`protoc`/…), pin tool versions per project, or set build-time env vars — without hand-editing `.xlings.json`. `[toolchain]` (§2.7) remains the ergonomic shorthand for the compiler; `[xlings.workspace]` is the general form. -`subos` carries a second meaning on Linux: it selects **which C runtime the -build binds against**. A subos describes its own runtime (xlings 2026.8.5.1+), -and mcpp takes that as the authority rather than looking around for a libc — so -`subos = "el8"` and `subos = "trixie"` in two projects on one machine produce -artifacts targeting each one's glibc, with the compile side and the run side -guaranteed to agree. The binding is part of the toolchain fingerprint, so -switching it rebuilds rather than reusing the other subos' objects. A subos -older than that (or none at all) falls back to whatever the toolchain itself -was installed against; `mcpp build -v` prints which of the two happened. See +`subos` selects the root project's **local build/run OS environment**. If the +key is absent, mcpp uses its initialized, release-verified `McppDefault` SubOS; +`subos = "default"` is an explicit `NamedSubos("default")` selection. There is +no CLI/environment override and no implicit following of xlings active/current. + +On Linux the selected environment also fixes the loader/libc contract, so +`subos = "el8"` and `subos = "trixie"` can coexist and produce separately +fingerprinted objects. A workspace root overrides member declarations during a +workspace build. Dependency/member SubOS declarations are non-transitive: a +library's declaration applies when it is an independent root, not when its +sources are consumed by another root. A missing named SubOS or missing/ +incompatible runtime contract is an error, never a fallback. See docs/08-toolchain-internals.md §2.1. ### 2.14 Host tools from a dependency (mcpp 2026.8.5.1+) @@ -1309,7 +1398,7 @@ version = "1.0.0" [targets.mymath] kind = "lib" -[dev-dependencies] +[dev-dependencies.compat] gtest = "1.15.2" ``` @@ -1417,7 +1506,7 @@ mcpp build --target x86_64-linux-musl | Static stdlib | `true` | Portable binary | | Headers | `include/` (if present) | Added to `-I` automatically | | Tests | `tests/**/*.cpp` | Discovered automatically by `mcpp test` | -| Dependency namespace | `mcpp` (default) | The flat form uses the default ns | +| Dependency namespace | `mcpplibs` (default) | A bare selector means only this exact namespace | ### 4.1 Legacy `[language]` Compatibility Layer diff --git a/docs/06-workspace.md b/docs/06-workspace.md index 7b917719..8f1fd3b4 100644 --- a/docs/06-workspace.md +++ b/docs/06-workspace.md @@ -100,7 +100,7 @@ Declare dependency versions centrally under `[workspace.dependencies]`; members # root mcpp.toml [workspace.dependencies] cmdline = "0.0.2" -capi.lua = "0.0.3" # dotted selector: mcpplibs.capi/lua, then capi/lua +mcpplibs.capi.lua = "0.0.3" # exact selector: (mcpplibs.capi, lua) [workspace.dependencies.compat] mbedtls = "3.6.1" diff --git a/docs/08-toolchain-internals.md b/docs/08-toolchain-internals.md index 6a2eb4d4..3bc59d14 100644 --- a/docs/08-toolchain-internals.md +++ b/docs/08-toolchain-internals.md @@ -8,16 +8,15 @@ ## 1. The model in one picture ``` -mcpp.toml [toolchain] / global default / `mcpp toolchain install` - │ (three entry paths — ONE shared pipeline) - ▼ -resolve payload (xim:gcc / xim:llvm / xim:musl-gcc xpkg under the sandbox) - ▼ -ensure_post_install_fixup() ← idempotent convergence (marker-gated) +mcpp.toml [xlings].subos / mcpp-managed default runtime ▼ resolve runtime binding ← which libc the artifact will load (§2.1) — an answer, not a search ▼ +resolve toolchain payload ← project/default/install paths share one pipeline + ▼ +ensure_post_install_fixup() ← exact glibc@version, marker-gated; never readdir-first + ▼ detect / probe ← triple, sysroot, payload paths (glibc, linux-headers) ▼ ToolchainLinkModel (single resolver for the C-library axis) @@ -27,6 +26,8 @@ ToolchainLinkModel (single resolver for the C-library axis) └──► cfg regeneration (the human-facing clang++.cfg) ▼ hermetic link check (`-###` dry-run) ← checks sandbox CRT/loader resolution + ▼ +link → internal ELF physics check ← validates the artifact and resolved closure (§6.1) ``` Two principles run through everything: @@ -77,17 +78,37 @@ anymore: the cfg is an output of this machinery, not an input (§5). ### 2.1 The runtime binding — which libc, decided once A payload-first build links against a specific glibc, and *which* one is a -fact about the environment, not something to be inferred. mcpp resolves it in -order: - -1. `[xlings] subos = ""` — the named subos, a sibling of the active one, - describes its own runtime in the `subos_info` block of its `.xlings.json` - (xlings 2026.8.5.1+). -2. The active subos, same block. -3. *Compatibility.* A subos created before that block existed cannot answer. - The value baked into the toolchain itself then stands in — gcc's specs, - clang's cfg. This is the value the artifact **would** load, so compile side - and run side still agree; it retires itself the moment the subos can speak. +fact about the root project's local development OS, not something to infer +from a compiler path or shell. mcpp has exactly two selection modes: + +1. No `[xlings].subos`: `McppDefault`, the initialized `subos/default` in the + xlings home selected by global mcpp configuration. +2. `[xlings] subos = ""`: `NamedSubos(name)`. Explicit `"default"` + remains a named selection; other names resolve in the root project's local + xlings scope. + +The workspace root owns the selection during a workspace build. Member and +dependency declarations do not merge or propagate; the same member's +declaration applies only when that source is an independent root. Neither +`XLINGS_ACTIVE_SUBOS`, `current`, the compiler's owner home, nor a CLI/env +override is a third selection rung. + +The selected SubOS must provide the supported `subos_info` contract. A missing +named environment or missing/incompatible contract is a hard error, never a +fallback to default/active/compiler-baked state. mcpp reads it once into a +`RuntimeBinding` snapshot, feeds its libc identity into payload probing, and +reuses the same snapshot for configure/link/run/test and the fast-path cache. +On Linux the snapshot also records the canonical selected loader/libc directory +and the optional creation-host glibc floor. These are evidence used by the +post-link validator, not another selection mechanism. + +The resolved SubOS view is authoritative when it canonically names one managed +glibc payload. Older xlings state may retain `runtime = "glibc@2.39"` after the +view has atomically moved to the managed 2.44 payload; mcpp records the physical +2.44 identity and path in that case. If an older view contains a broken link, +mcpp may resolve only the exact payload named by `runtime`; it never enumerates +installed versions or chooses a nearest/newest one. Both paths consume xlings +facts and preserve one RuntimeBinding rather than introducing an mcpp policy. No binding is a **refusal**, not a default: `CLibMode::PayloadFirst` is declined rather than picking a libc. @@ -102,9 +123,57 @@ against a runtime without them, and the failures surfaced on packages unrelated to the dependency that pulled the second glibc in. Directory order is not a decision procedure. -Because the binding decides what the artifact loads, it is part of the -toolchain fingerprint (11 fields, not 10) — two builds differing only in -runtime must not share a cache entry. +Because the binding decides what the artifact loads, the complete canonical +contract hash is field 11 of the toolchain fingerprint — two named SubOS +environments that happen to use the same libc still must not share a cache +entry when their providers or environment declarations differ. + +### 2.2 Generic runtime providers and artifacts + +`RuntimeBinding` also carries the provider-neutral facts selected by xlings for +the chosen development OS. `subos_info.runtime_contract` is an optional, +additive schema-1 block: + +```json +{ + "providers": [ + {"capability": "display.present", "provider": { + "namespace": "xim", "name": "display-runtime", "version": "1.0.0", + "source": "xim-pkgindex@"}} + ], + "artifacts": [ + {"role": "driver", "provider": { + "namespace": "xim", "name": "display-runtime", "version": "1.0.0", + "source": "xim-pkgindex@"}, + "path": "${subosdir}/lib/runtime/provider.so", + "provenance": "subos_view", "abi": "elf-x86_64", + "digest": "sha256:...", "host_fingerprint": "..."} + ] +} +``` + +The binding parser resolves `${subosdir}` and relative artifact paths once, +sorts the facts, and includes them in the contract hash and cache snapshot. In +the build plan these selected provider facts precede descriptor-declared +fallbacks. Descriptor requirements and artifacts are independently stamped +with their resolved requester's/provider's canonical PackageId, so equal short +names in different namespaces remain distinct end to end. + +The ownership boundary is deliberate: mcpp-index expresses generic runtime +requirements; xlings/xim chooses and diagnoses the host graphics/runtime stack; +mcpp consumes only the selected provider/artifact facts and generic LinkIntent. +mcpp has no hardware, driver-vendor, WSL, or ICD selection path. Its source gate +rejects introducing such provider-specific branches or coupling those terms to +a launched probe. + +`LinkIntent` keeps `linkLibraryDirs`, `transitiveNeededDirs`, and +`runtimeSearchDirs` separate. The last category is never rendered as `-L`: +ELF receives rpath plus `-rpath-link` only for the transitive category, Mach-O +receives rpath/framework flags, and PE receives link-library paths plus explicit +deploy-file copy edges. The exact RuntimeBinding, canonical identities, link +intent, search mechanism, and post-link verdict are persisted in +`resolution.json` schema 2. `mcpp why runtime` only interprets that stored file; +re-diagnosis belongs to `xlings doctor`. ## 3. The link model (`src/toolchain/linkmodel.cppm`) @@ -274,6 +343,76 @@ class that faithfully reproduces the clean-machine failure mode, plus e2e `86_llvm_hermetic_link.sh` which re-checks the `-###` resolution on every machine. +### 6.1 Post-link Linux runtime physics (`elf_runtime.cppm`) + +The hermetic check answers a pre-link question: what does the driver appear to +resolve? The runtime-physics check answers the stronger post-link question: +what did the newly produced ELF actually record and what will its closure +load? `[build] allow_host_libs = true` deliberately relaxes the first check; +it does not suppress physical impossibilities in the second. + +For each newly linked Linux executable/shared object, mcpp parses ELF64 +little-endian program/dynamic/GNU-version tables internally—no `readelf`, +`patchelf`, or `ldd` subprocess on the build path—and records `PT_INTERP`, +`DT_RPATH`/`DT_RUNPATH`, `DT_NEEDED`, and required/defined `GLIBC_*` versions. +It resolves the declared closure using the artifact search paths, selected +runtime/toolchain directories, and known host library directories, then applies: + +- **Rule B (same source):** `PT_INTERP` and every resolved `libc.so.6` must be + the canonical payload selected by `RuntimeBinding`. A host loader plus private + libc, or two private libc payloads, is a proven pre-main failure. +- **Rule A (version floor):** every closure request for `GLIBC_x.y` must be no + newer than the selected libc's exported GNU version definitions. Linking a + host DSO is allowed when this holds; mcpp is checking physics, not imposing a + no-host-library policy. + +Verdicts are typed: `Pass`, `ProvenMismatch`, or `Inconclusive`. Proven A/B +mismatches fail the build with canonical requester/provider/artifact paths and +a copyable SubOS remediation. Missing loader-cache/hardware closure data is +reported as inconclusive, never relabelled green. macOS and Windows use the +same interface as a typed no-op and never receive ELF/glibc rules. + +The verdict is stored as `.mcpp-runtime-verdicts.json` beside `build.ninja`, +keyed by artifact stat fingerprint plus the complete runtime contract hash. +Hot no-op builds require a current passing record, compare artifact stats +before/after Ninja, and perform zero ELF parses. An unexpected relink drops to +the full path before success or execution. `mcpp self doctor` reports the same +stored verdict rather than re-probing a potentially different current host. + +Post-install alignment follows the same identity rule: `glibc@2.44` resolves +only `/xim-x-glibc/2.44/{lib64,lib}`. A missing/stale exact payload is an +error; another installed version is never a fallback. + +### 6.2 Where a runtime search path is allowed to live (`runtime_env_contract.cppm`) + +There are two ways to tell a loader where to look, and they differ by blast +radius, not by convenience: + +| | reaches | | +| --- | --- | --- | +| `DT_RUNPATH` | the one object that carries it, and its `dlopen()` | per-binary | +| `LD_LIBRARY_PATH` | the process **and every process it ever spawns** | inherited, forever | + +That second row is why the private libc payload is **binary-scoped**. A glibc's +`libc.so.6` and its `ld.so` are version-locked through `GLIBC_PRIVATE`: 2.44's +libc carries an undefined `__pointer_chk_guard` that only 2.44's own loader +exports. An mcpp-built program is fine — `PT_INTERP` names the private loader. +`/bin/sh` is not: its `PT_INTERP` names the **host** loader and no environment +variable can override it, so a `popen()`/`system()` child dies during +relocation, before `main`, with no output (mcpp#401; mcpp#291 is the same shape +one hop closer in, killing mcpp's own nested host tools). + +So mcpp never publishes the private libc directory through the environment. It +does not need to: wherever a payload exists the link model already emits +`-Wl,-rpath,` beside `--dynamic-linker`, which covers the case the +directory exists for — a `dlopen()` whose own `DT_NEEDED` closure does not +consult the executable's RUNPATH. + +This is a scope, not a condition. "Only export it when a dependency might +`dlopen()`" still exports it, and the child that dies does not care why. Plain +dependency runtime directories keep their environment scope: they have no +loader coupling, so a host binary that stumbles onto them is at worst confused. + ## 7. Extending the machinery ### 7.1 Adding a new toolchain (new compiler family or distribution) @@ -350,6 +489,9 @@ everything §3–§4 does for ELF. | link model + loader resolution | `src/toolchain/linkmodel.cppm` | | unified fixup pipeline (patchelf/specs/cfg, marker) | `src/toolchain/post_install.cppm` | | install/lifecycle entry | `src/toolchain/lifecycle.cppm`; auto-install entries in `src/build/prepare.cppm` | +| root runtime selection/binding | `src/xlings/runtime_selection.cppm`, `src/platform/runtime_binding.cppm`, `src/xlings/subos_info.cppm` | +| generic runtime contract + LinkIntent | `src/manifest/types.cppm`, `src/build/plan.cppm`, `src/build/flags.cppm` | +| stored resolution explanation | `src/build/prepare.cppm`, `src/build/runtime_validation.cppm`, `src/doctor.cppm` | | flag assembly (main build) | `src/build/flags.cppm` | | `import std;` precompile | `src/toolchain/stdmod.cppm` | | build.mcpp host flags | `src/build/build_program.cppm` | diff --git a/docs/09-release.md b/docs/09-release.md index 88f9eb06..72a60bf1 100644 --- a/docs/09-release.md +++ b/docs/09-release.md @@ -41,11 +41,48 @@ tag from `mcpp.toml`) does all of this: ``` build ×4 (linux x86_64 / linux aarch64 / macOS ARM64 / Windows x64) → GitHub Release v with tarballs + .sha256 sidecars + → recompute every payload hash and publish immutable mcpp-release.json → mirror to xlings-res/mcpp on BOTH GitHub and GitCode → open the version-bump PR against openxlings/xim-pkgindex → workflow_run hook fires ci-fresh-install ``` +### 2.1 The immutable release manifest + +`release-manifest` waits for all four platform upload jobs. It downloads the +final non-draft, non-prerelease GitHub Release, recomputes each versioned +platform payload's SHA256, verifies the corresponding `.sha256` sidecar, and +publishes `mcpp-release.json` schema 1: + +```json +{ + "schema": 1, + "version": "", + "tag": "v", + "commit": "", + "assets": [ + { + "platform": "linux", + "arch": "x86_64", + "name": "mcpp--linux-x86_64.tar.gz", + "sha256": "" + } + ] +} +``` + +The array is sorted by platform, architecture, and name. It contains every +versioned platform payload, including the required Linux x86_64, Linux aarch64, +macOS ARM64, and Windows x86_64 assets. Versionless aliases and the source +archive are intentionally not desired-state rows. + +The workflow then downloads the public release again, regenerates the manifest, +and requires a byte-for-byte match. A workflow rerun accepts an existing +manifest only when it is already byte-identical; it never overwrites different +bytes for the same tag. Downstream release consumers (notably the `mcpp-bin` +AUR reconciler) must consume this manifest instead of guessing completeness +from a moving workspace or from a subset of release assets. + Two steps are **not** automated: - **merging the xim-pkgindex bump PR** — a maintainer does it. Until it lands, @@ -71,6 +108,28 @@ curl -fsSL -o /tmp/p.tgz "https://github.com/xlings-res/mcpp/releases/download/$ sha256sum /tmp/p.tgz # compare against pkgs/m/mcpp.lua in xim-pkgindex ``` +To repeat the release gate locally against the public GitHub inventory: + +```bash +V= +TAG="v$V" +AUDIT=$(mktemp -d) +mkdir -p "$AUDIT/assets" +gh api "repos/mcpp-community/mcpp/releases/tags/$TAG" > "$AUDIT/release.json" +gh release download "$TAG" -R mcpp-community/mcpp --dir "$AUDIT/assets" +python3 scripts/release/generate_manifest.py \ + --release-json "$AUDIT/release.json" \ + --assets-dir "$AUDIT/assets" \ + --version "$V" \ + --tag "$TAG" \ + --commit "$(git rev-list -n 1 "$TAG")" \ + --output "$AUDIT/expected.json" +cmp "$AUDIT/assets/mcpp-release.json" "$AUDIT/expected.json" +``` + +This command recomputes payload hashes; it does not copy hashes out of the +published manifest or blindly trust the sidecars. + Then a real install, in a **clean-room `XLINGS_HOME`** — never the machine's own `~/.xlings`, which can mask a broken index with cached state: @@ -163,7 +222,8 @@ would again let the index guard and the installed version drift apart. [ ] `bash .github/tools/check_version_pins.sh` passes (verifies `mcpp.toml` = `MCPP_VERSION`, and `.xlings.json` is not newer) [ ] merge to main, CI green [ ] gh workflow run release.yml --ref main -[ ] release.yml green (4 builds + publish-ecosystem) +[ ] release.yml green (4 builds + immutable manifest + publish-ecosystem) +[ ] downloaded mcpp-release.json regenerates byte-identically from public assets [ ] mirrors serve all four platforms on BOTH hosts, sha256 recomputed [ ] merge the xim-pkgindex bump PR [ ] clean-room XLINGS_HOME: xlings install mcpp@ succeeds diff --git a/docs/spec/package-identity.md b/docs/spec/package-identity.md index 46261995..509661cd 100644 --- a/docs/spec/package-identity.md +++ b/docs/spec/package-identity.md @@ -5,9 +5,9 @@ | **规范编号** | SPEC-001 | | **标题** | 包身份(`package.namespace` / `package.name`)、`[dependencies]` 选择器与匹配机制 | | **状态** | **评审中(Review)** —— 已实现 | -| **版本** | 1.1 | -| **最后修改** | 2026-08-03 | -| **最低实现版本** | mcpp **0.0.106**(xlings >= 0.4.69) | +| **版本** | 1.2 | +| **最后修改** | 2026-08-09 | +| **最低实现版本** | 描述符身份:mcpp **0.0.106**;精确 selector:mcpp **2026.8.10.1**(xlings >= 0.4.69) | | **作者/维护** | mcpp-community | | **相关设计文档** | `.agents/docs/2026-06-20-package-resolution-architecture.md` §4
`.agents/docs/2026-06-26-identity-first-resolution-no-filename.md`
`.agents/docs/2026-07-25-issue278-descriptor-name-form-canonicalization-design.md`
`.agents/docs/2026-07-25-name-namespace-bidirectional-verification-report.md`
`.agents/docs/2026-07-25-name-namespace-canonical-implementation-spec.md` | | **相关 issue** | [mcpp#278](https://github.com/mcpp-community/mcpp/issues/278)
[xlings#381](https://github.com/openxlings/xlings/issues/381) —— 索引键缺命名空间维度(§3.3) | @@ -26,12 +26,12 @@ | 标记 | 含义 | |---|---| -| ✅ **已实现** | 自 mcpp 0.0.106 起的行为与本规范一致 | +| ✅ **已实现** | 自对应“最低实现版本”起的行为与本规范一致 | | ⚠️ **部分实现** | 已有实现,但语义或覆盖面与本规范有差异(差异已注明) | | ❌ **未实现** | 本规范要求但尚未支持;当前行为已注明 | -> **本规范所需行为自 mcpp 0.0.106 起已全部实现,当前实现继续符合。** 索引作者按 §3 -> 书写即可。0.0.105 及更早版本要求的过渡形态(`name` 必须写成 +> 描述符身份规则自 mcpp 0.0.106 起实现;唯一精确 selector 自 2026.8.10.1 +> 起实现。0.0.105 及更早版本要求的过渡形态(`name` 必须写成 > `.`)仍被接受为**兼容写法**,见 §8。 --- @@ -42,7 +42,7 @@ - 索引描述符(`.lua`)中 `package.namespace` / `package.name` 的语义与形态 - `mcpp.toml` 中 `[dependencies]`(及 `[dev-dependencies]` / `[build-dependencies]` / `[feature-deps.*]`)的书写文法 -- 从用户书写 → 候选身份 → 描述符发现 → 身份校验 → 安装目标的完整匹配机制 +- 从用户书写 → 唯一身份 → 描述符发现 → 身份校验 → 安装目标的完整匹配机制 ### 1.2 边界:mcpp 不改动 xlings 规范 @@ -119,7 +119,7 @@ package = { namespace = "compat", name = "compat.zlib" } -- legacy,短名 = "z ✅ **规范如此**。 -✅ **已实现**。xlings **0.4.69** 起索引按 `(effectiveNamespace, name)` 建键([#381](https://github.com/openxlings/xlings/issues/381)),同一索引内两个同短名不同命名空间的包各自可寻址;裸名请求多候选时报 ambiguity 并列出候选。 +✅ **已实现**。xlings **0.4.69** 起索引按 `(effectiveNamespace, name)` 建键([#381](https://github.com/openxlings/xlings/issues/381)),同一索引内两个同短名不同命名空间的包各自可寻址;mcpp 始终传入唯一精确身份。 e2e `163_identity_first_resolution.sh` 端到端锁住:同一 path 索引内 `(alpha, widget)` 与 `(beta, widget)` 各自安装到 `alpha-x-widget` / `beta-x-widget`。 @@ -149,30 +149,30 @@ e2e `163_identity_first_resolution.sh` 锁住:身份为 `(acme, widget)` 的描 ## 4. 消费侧规范:`[dependencies]` 书写文法 -用户写的**不是身份,而是选择器(selector)** —— 它展开为一个**有序候选身份列表**,按序尝试,首个满足者胜出。 +用户写的是 selector;它必须在 O(输入长度)、无 I/O 的解析阶段规范化成**唯一一个** `(namespace, name)`。索引状态、候选顺序或已安装内容不得参与身份决定。 ### 4.1 四种书写形式 -| # | 写法 | 展开为候选(按序) | 语义 | +| # | 写法 | 规范化身份 | 语义 | |---|---|---|---| -| 1 | `[dependencies]`
`gtest = "1.15.2"` | ① `(mcpplibs, gtest)`
② `(∅, gtest)` | **裸名**。只解析默认命名空间搜索路径(§5.2) | -| 2 | `[dependencies]`
`acme.widget = "1.0"` | ① `(mcpplibs.acme, widget)`
② `(acme, widget)` | **点式选择器**。先试默认命名空间下的同名子空间,再试同级 peer root | -| 3 | `[dependencies.acme]`
`widget = "1.0"` | ① `(acme, widget)` | **命名空间子表**。权威、单候选、无猜测 —— **推荐用于第三方命名空间** | -| 4 | `[dependencies]`
`"acme.widget" = "1.0"` | ① `(acme, widget)` | **引号点式键**(legacy)。单候选,等价于 #3 | +| 1 | `[dependencies]`
`cmdline = "0.0.2"` | `(mcpplibs, cmdline)` | **裸名**。省略 namespace 只表示默认 `mcpplibs` | +| 2 | `[dependencies]`
`acme.widget = "1.0"` | `(acme, widget)` | **点式选择器**。最后一段是 name,之前所有段是 namespace | +| 3 | `[dependencies.acme]`
`widget = "1.0"` | `(acme, widget)` | **命名空间子表**。与 #2 完全等价,多个同 namespace 包时更清晰 | +| 4 | `[dependencies]`
`"acme.widget" = "1.0"` | `(acme, widget)` | **引号点式键**(legacy source shape)。身份仍与 #2/#3 相同 | -✅ **已实现**(`resolve_dependency_selector` / `make_direct_dependency_selector`)。 +✅ **已实现**(`parse_package_selector` / `normalize_package_selector` / `make_direct_dependency_selector`)。 -**#2 与 #3 的区别很重要**:`[dependencies.acme]` 是**显式 TOML 表**,mcpp 视其为命名空间根,直接产出单候选;而 `[dependencies]` 里的点式键是**有序猜测**,会先试 `mcpplibs.` 前缀。 - -判定「是否命名空间根」的条件:该路径是 TOML **显式表**(`[dependencies.acme]` 被真实书写过),**或**键名恰为默认命名空间 `mcpplibs`。 +**#2 与 #3 只有 TOML 版式差异,没有解析差异。** 新增索引或同短名 sibling 不能改变其身份。 多级命名空间同理,`ns` 逐层累积:`[dependencies.mcpplibs.capi]` + `lua = "0.0.3"` → 单候选 `(mcpplibs.capi, lua)`。 -### 4.2 裸名的解析域是封闭的 +### 4.2 裸名就是默认 mcpplibs + +> **裸名(#1)只能表示 `(mcpplibs, name)`。`compat`、第三方 namespace 与无 namespace 包都不会成为隐式候选。** -> **裸名(#1)只解析三类包:`mcpplibs`(默认命名空间)、`compat`(包装命名空间)、无 `namespace` 声明的上游包。任何声明了第三方命名空间的包,禁止用裸名请求。** +例如 gtest 必须写成 `compat.gtest` 或 `[dependencies.compat] gtest = ...`;裸 `gtest` 请求的是不同身份 `(mcpplibs, gtest)`。 -✅ **已实现**(0.0.105)。裸名请求命中一个声明了非空第三方 `namespace` 的描述符时,该候选被拒绝。 +✅ **已实现**(2026.8.10.1)。默认 namespace 的依赖身份门禁不再接纳 `compat` 或无 namespace descriptor。 **设计理由**:全域按短名搜索会让解析结果取决于「本机装了哪些索引」—— 1. 两个命名空间拥有同名包时,胜负由索引顺序决定,而用户 `[indices]` 添加的索引之间**没有全序**; @@ -181,18 +181,45 @@ e2e `163_identity_first_resolution.sh` 锁住:身份为 `(acme, widget)` 的描 依赖解析的**可复现性**优先于书写便捷性。 +#### 4.2.1 过渡期(`2026.8.10.1` 起,`2026.9` 移除) + +索引里已发布的 `compat.*` 包与既有用户 `mcpp.toml` **全部**使用裸名写法。 +让它们在一次 mcpp 升级后直接失败,等于「发布一个程序,让已经发布、且无法追溯修改的 +数据失效」——这与「索引抬高 floor 不得让旧客户端变砖」是同一条判据的两个方向, +两边都必须**降级**而不是变砖。 + +所以省略 namespace 的 selector 在一个版本内保留一条**出口坡道**: + +1. 先精确解析 `(mcpplibs, name)`; +2. **仅在未命中时**,再依次尝试 `(compat, name)` 与「descriptor 自己不声明 namespace」 + 这一级; +3. 命中即打印弃用警告,内容包含实际选中的完整身份与可直接粘贴的 manifest 片段; +4. 写入 lock、install 与 cache 的是**规范身份**(`compat.gtest`),歧义拼写只留在 + 用户 manifest 里,直到用户改它; +5. `mcpp add <裸名>` 直接把规范点分形式写回 `mcpp.toml`——碰一次就迁移一次。 + +不适用的情形(**不是**过渡期的一部分): + +- 写明了 namespace 的 selector(`mcpplibs.gtest`、`[dependencies.mcpplibs]`)—— + 那是一个身份声明,未命中就是未命中; +- 第三方 namespace——裸名从来、且仍然不可触达(§4.2 的供应链理由不变)。 + +> #278 修掉的缺陷是**静默**:mcpp 带着一个用户从未写过的 namespace 继续往下走, +> 并且不说。一条带完整身份的警告已经消灭了「静默」,同时保住了已发布的数据。 +> 同一个版本对 `ns:name → ns.name` 用的也是同一套过渡期处理。 + ### 4.3 解析失败时的诊断 -候选全部落空时,mcpp **必须**明确失败,并列出尝试过的身份;若该短名存在于其他命名空间,**应当**给出可直接抄写的正确写法。 +唯一身份落空时,mcpp **必须**明确失败并列出该身份;若同短名存在于其他 namespace,**应当**只在诊断中给出可复制的显式 selector,禁止把提示结果回灌解析。 -✅ **已实现**(0.0.105): +✅ **已实现**(2026.8.10.1): ``` -error: dependency 'asio': no package found under the namespaces mcpp searched - tried: (mcpplibs, asio), (no namespace, asio) +error: dependency 'asio': no package found + tried: (mcpplibs, asio) a package with this name exists under another namespace: chriskohlhoff.asio - bare names only resolve to the `mcpplibs` / `compat` namespaces. write it out: + namespace omission means `mcpplibs`; write the exact package: [dependencies] "chriskohlhoff.asio" = "1.38.1" or: @@ -200,7 +227,34 @@ error: dependency 'asio': no package found under the namespaces mcpp searched asio = "1.38.1" ``` -该 did-you-mean 扫描**仅**在已失败路径触发,结果**只进错误文案**,禁止回灌解析、lockfile 或安装层 —— 否则就退化成 §4.2 否决的全域模糊匹配。 +该 did-you-mean 扫描**仅**在已失败路径触发,结果**只进错误文案**,禁止回灌解析、lockfile 或安装层。 + +迁移 release 对旧的 compact dotted 搜索做两件事:已有 lock 继续固定已记录身份;无 lock 时若旧的 `mcpplibs.` primary 确实存在,warning 同时显示旧/新完整 selector,但仍不回退。 + +### 4.4 `mcpp new --template` 的同源 selector + +模板不拥有第二套包身份。`--template` 与 `--list-templates` 复用 §4 的精确 +`PackageSelector`,只在包身份/版本之后增加模板名轴: + +| 输入 | 规范结果 | +|---|---| +| `pkg` | `(mcpplibs, pkg)` + latest stable + default/单模板 | +| `pkg@1.2.0` | `(mcpplibs, pkg)` + `1.2.0` + default/单模板 | +| `acme.widget` | `(acme, widget)` + latest stable + default/单模板 | +| `acme.widget@1.2.0:docking` | `(acme, widget)` + `1.2.0` + `docking` | + +完整文法为 **`[namespace.]name[@version][:tname]`**。namespace、version、tname +分别可省略;namespace 的省略语义与依赖完全一致,只表示默认 `mcpplibs`。模板默认 +规则按以下顺序且必须唯一: + +1. 正好一个 template 声明 `default = true`,选择它; +2. 没有显式 default,但当前已解析包版本只有一个 template,选择该单模板; +3. 多个 template 且没有 default,hard error 并提示 `--list-templates`。 + +模板目录顺序、索引顺序和本机缓存都不得参与选择。`--variant` 不属于词汇表。 +scaffold 必须先解析完整 PackageId/version/template,再以 sibling 临时目录事务生成; +失败不得留下目标目录。旧 `pkg:` 的“列举模板”含义仅保留一个 release train 的迁移 +warning,规范列举命令是 `mcpp new --list-templates pkg`。 --- @@ -212,34 +266,33 @@ error: dependency 'asio': no package found under the namespaces mcpp searched 用户书写(selector) │ §4.1 展开 ▼ -有序候选身份列表 [(ns₁,n₁), (ns₂,n₂), …] - │ 逐个尝试 +唯一身份 (ns,name) + │ 精确查询 ▼ ① 发现:先探测推荐文件名,落空后按声明身份扫描描述符(§3.4) ② 校验:xpkg_lua_identity_matches 复核声明身份 ← §5.2 -③ 收敛:INV-RESOLVE 拒绝裸名命中第三方 ns ← §4.2 -④ 回填:用描述符**声明的** namespace 定身份 ← §5.3 - │ 首个通过者胜出;全部落空 → §4.3 报错 +③ 验证:descriptor 身份必须精确相等 ← §5.2 + │ 落空 → §4.3 报错 ▼ 选定身份 (ns, name) → 派生 wire key / store dir(§6) ``` ### 5.2 身份校验规则 -给定候选 `(ns, shortName)` 与一个描述符,`xpkg_lua_identity_matches` 的判定: +给定请求 `(ns, shortName)` 与一个描述符,`xpkg_lua_identity_matches` 的判定: -| 候选 `ns` | 判定 | +| 请求 `ns` | 判定 | |---|---| | 描述符无 `name` | **接受**(无从校验,宽松) | -| `ns` 为空(discovery) | 短名相等即可 | -| `ns == "mcpplibs"`(默认命名空间) | 描述符身份的 ns ∈ **{`mcpplibs`, `compat`}**,或(旧式)无 ns | +| `ns` 为空(仅内部 legacy discovery) | 短名相等即可;不得进入依赖 selector 主路径 | +| `ns == "mcpplibs"`(默认命名空间) | 依赖 selector 主路径要求描述符身份必须为 `mcpplibs`;底层 legacy API 的 `allowLegacyBareDefault` 只供非 selector 兼容调用 | | 其他具体 ns | **精确相等** | -✅ **已实现**。第三行即**默认命名空间搜索路径**:裸 `gtest` 能命中 `compat.gtest`,正是这条。 +✅ **已实现**。`compat.gtest` 与 `mcpplibs.gtest` 是两个不同身份。 -### 5.3 空命名空间的回填(P3) +### 5.3 空命名空间的兼容边界(P3) -候选 `(∅, name)` 命中后,**必须**用描述符**声明的** `namespace` 作为最终身份,而不是候选的空值。 +旧 discovery 调用 `(∅, name)` 命中后,**必须**用描述符**声明的** `namespace` 作为最终身份,而不是候选的空值。新的依赖 selector 已先填充默认 namespace,不使用 discovery;模板 selector 在切换到同一共享解析链后也必须遵守该规则。 若描述符本身未声明 `namespace`(上游裸包如 `opencv`),则**空命名空间就是它的合法身份**,不得强行填充。 @@ -347,18 +400,16 @@ asio = "1.38.1" **错误写法**:`[dependencies]` + `asio = "1.38.1"` —— 裸名不解析第三方命名空间(§4.2),报错并给出上述两种正确写法。 -### 9.2 裸名经 compat 搜索路径 +### 9.2 compat 必须显式选择 ```toml -[dependencies] +[dependencies.compat] gtest = "1.15.2" ``` ``` -选择器 gtest → 候选 ① (mcpplibs, gtest) ② (∅, gtest) -候选①:探测 gtest.lua / mcpplibs.gtest.lua / compat.gtest.lua - 命中 compat.gtest.lua,声明 (compat, compat.gtest) → 归一 (compat, gtest) - 校验:候选 ns 为默认命名空间 → 允许 ns ∈ {mcpplibs, compat} → ✓ +选择器 compat.gtest → 唯一身份 (compat, gtest) +探测 compat.gtest.lua,声明 (compat, compat.gtest) → 归一 (compat, gtest) → ✓ 身份 (compat, gtest) → store dir compat-x-compat.gtest ``` @@ -381,6 +432,7 @@ lua = "0.0.3" | 版本 | 日期 | 变更 | |---|---|---| +| 1.2 | 2026-08-09 | selector 收敛为唯一精确 PackageId:裸名只表示默认 mcpplibs,dotted 以最后一段为 name;移除 compat/空 namespace 隐式候选,加入 lock 保持与一个 release train 的双 selector 迁移 warning | | 1.1 | 2026-08-03 | 按当前实现复核:澄清文件名发现是快路径加身份回退扫描,修正 legacy `package.name` 的 wire key 示例,并将 0.0.106 明确为最低实现版本 | | 0.1 | 2026-07-25 | 首版草案。整合 #278 的双向验证结论:确立「身份 = `(namespace, name)`、层级归 `namespace`、`name` 为原子段」为规范形态,并如实标注 0.0.105 的过渡形态(强制 FQN)与全部待实现项 | | 1.0 | 2026-07-25 | **mcpp 0.0.106 全部实现**:身份归一化去 split-on-last-dot、target 用字面 `name`、store 目录、文件名自由(快路径+身份扫描)、`name` 形态校验反转。xlings 0.4.69 修好 #381 后 §3.3 的 `(namespace, name)` 唯一自然成立。状态 草案 → 评审中 | @@ -392,5 +444,5 @@ lua = "0.0.3" --- -> **本规范所需行为自 mcpp 0.0.106 起已全部实现,当前实现仍符合**,状态为「评审中」。 +> 描述符身份规则自 mcpp 0.0.106 起实现;精确 selector 自 2026.8.10.1 起实现。当前实现符合本规范,状态为「评审中」。 > 英文版待补(`docs/spec/` 顶层按仓库惯例为英文,本文档先以中文成稿)。 diff --git a/docs/zh/00-getting-started.md b/docs/zh/00-getting-started.md index 3d5024d6..c6014209 100644 --- a/docs/zh/00-getting-started.md +++ b/docs/zh/00-getting-started.md @@ -69,6 +69,24 @@ int main() { } ``` +### 从包模板创建项目 + +`mcpp new --template` 与 `mcpp add` 使用同一种精确包 selector 风格: + +```bash +mcpp new gui-demo --template ocornut.imgui@1.92.8:docking +mcpp new --list-templates ocornut.imgui@1.92.8 +``` + +文法是 `[namespace.]name[@version][:template]`,namespace、version 与模板名可分别 +省略。省略 namespace 只表示唯一默认命名空间 `mcpplibs`,不会按短名扫描整个索引。 +省略模板名时,mcpp 使用唯一的 `default = true`;若包只有一个模板且未显式声明 +default,该单模板自动成为默认。多个模板却没有 default 会明确报错并提示 +`--list-templates`。不再引入另一套 `--variant` 词汇。 + +包身份、版本与模板会在提交目标目录前全部解析完成;下载、渲染、hook 或校验失败时, +不会留下半成品项目目录。 + ## 构建与运行 ```bash @@ -143,8 +161,9 @@ mcpp pack --mode self-contained # 打包 loader、libc 与依赖 ## 更多入口 -- GUI 起步:`mcpp new myapp --template imgui`(模板随 imgui 库分发、版本自动对齐; - `mcpp new --list-templates imgui` 查看库提供的全部模板,`--template imgui:docking` 选指定模板)。 +- GUI 起步:`mcpp new myapp --template ocornut.imgui@1.92.8:docking`(模板随包分发; + 省略 `:docking` 使用已声明 default/唯一模板,或运行 + `mcpp new --list-templates ocornut.imgui@1.92.8`)。 - 解释默认决策:`mcpp why [toolchain|runtime|deps]`;主机能力体检:`mcpp self doctor`; 机器可读解析清单:构建产物 `target///resolution.json`。 - 离线运行:`mcpp --offline` 或 `MCPP_OFFLINE=1` 可阻止索引刷新、下载和工具链安装。 diff --git a/docs/zh/05-mcpp-toml.md b/docs/zh/05-mcpp-toml.md index c8c109b0..a964e893 100644 --- a/docs/zh/05-mcpp-toml.md +++ b/docs/zh/05-mcpp-toml.md @@ -340,18 +340,17 @@ path = "src/capi/lua.cppm" # 覆盖默认的 lib-root 位置 ```toml # 默认包空间(mcpplibs)下的包 [dependencies] -gtest = "1.15.2" # 精确版本 -mbedtls = "3.6.1" -ftxui = "6.1.9" +cmdline = "0.0.2" # 精确版本 +templates = "0.0.1" -# dotted selector: 先匹配 mcpplibs., 找不到再匹配同级 peer root。 -# 例如 imgui.core 会按顺序尝试 mcpplibs.imgui/core, imgui/core。 -[dependencies] -capi.lua = "0.0.3" +# dotted selector 是单一精确身份:最后一段是包名,之前所有段都是 namespace。 compat.gtest = "1.15.2" imgui.core = "0.0.1" imgui.backend.glfw_opengl3 = "0.0.1" +mcpplibs.capi.lua = "0.0.3" +``` +```toml # 命名空间子表写法 [dependencies.mcpplibs] cmdline = "0.0.2" @@ -359,17 +358,23 @@ tinyhttps = "0.2.2" llmapi = "0.2.5" [dependencies.compat] -glfw = "3.4" # 显式 namespace, 不走 mcpplibs 优先候选 +glfw = "3.4" # 显式 namespace,无回退搜索 +``` +```toml # 路径依赖(本地开发) [dependencies] mylib = { path = "../mylib" } +``` +```toml # Git 依赖 —— tag / branch / rev 三选一 [dependencies] mylib = { git = "https://github.com/user/mylib.git", tag = "v1.0.0" } applib = { git = "https://github.com/user/applib.git", branch = "develop" } +``` +```toml # 长式 dep spec:features 与 backend 旋钮 [dependencies] imgui = { version = "0.0.3", features = ["docking"] } # 请求该依赖的 feature @@ -408,22 +413,18 @@ qux = ">=1.0, <2.0" # 范围组合 #### 命名空间解析规则 -每个包的身份是**命名空间 + 名字**二元组。依赖 key 的写法决定 mcpp 到哪些命名空间里找。 +每个包的身份是**命名空间 + 名字**二元组。每个 selector 都只规范化成一个身份: -**裸名只在三个地方解析**,按序: - -| # | 命名空间 | 示例 | -|---|---|---| -| 1 | `mcpplibs` — 默认命名空间 | `cmdline = "0.0.2"` | -| 2 | `compat` — 第三方 C/C++ 库的包装命名空间 | `gtest = "1.15.2"` → `compat.gtest` | -| 3 | 完全没有声明命名空间的上游包 | `opencv = "4.10.0"` | +- `cmdline` → `(mcpplibs, cmdline)`;省略 namespace 只表示默认 `mcpplibs`。 +- `compat.gtest` → `(compat, gtest)`。 +- `mcpplibs.capi.lua` → `(mcpplibs.capi, lua)`。 -**其他命名空间一律必须写全。** 不存在按短名的全索引模糊搜索: +不存在有序回退或按短名的全索引模糊搜索: ```toml # ✅ 正确 —— 点式选择器 [dependencies] -"chriskohlhoff.asio" = "1.38.1" +chriskohlhoff.asio = "1.38.1" # ✅ 正确 —— 命名空间子表(同一组织有多个包时更推荐) [dependencies.chriskohlhoff] @@ -434,9 +435,33 @@ asio = "1.38.1" asio = "1.38.1" ``` -第三种写法会明确报错,并列出搜索过的命名空间;若该短名的包存在于别处,错误信息会直接给出应当改写成的那一行。 +第三种写法会明确报错,指出实际尝试的 `(mcpplibs, asio)`;若该短名存在于别处,错误信息会给出可直接复制的显式 selector。 + +##### 裸名过渡期(`2026.8.10.1` 起,`2026.9` 移除) + +索引里已发布的 `compat.*` 包与既有 manifest **全部**写成裸名(`gtest = "1.15.2"`)。 +升级后直接失败,等于让一次程序发布把**已经发布、且无法追溯修改**的数据作废, +所以有一个版本的过渡期:裸名在 `mcpplibs` 未命中时仍可到达 `compat.`, +不声明 namespace 的 descriptor 也仍可被裸名解析。 + +但它不再静默: + +``` +warning: dependency 'gtest' resolved to 'compat.gtest' through the deprecated +bare-name search; namespace omission means `mcpplibs` only. Write the exact +package: + [dependencies.compat] + gtest = "1.15.2" + (or run `mcpp add compat.gtest@1.15.2`). This fallback is removed in 2026.9. +``` + +写进 `mcpp.lock`、install 与 cache 的是**规范身份**,歧义拼写只存在于你的 +`mcpp.toml` 里,直到你改它;`mcpp add gtest@1.15.2` 会替你改。 -**为什么不让裸名跨所有命名空间去找?** 因为依赖解析必须可复现。全域短名搜索意味着:(a) 两个命名空间拥有同名包时,胜负由索引顺序决定;(b) **新增一个索引可能悄悄改变某个既有依赖解析到的包**。要求写出命名空间,才能让同一份 `mcpp.toml` 在每台机器上解析到相同的包。 +过渡期**不适用于**写明 namespace 的 selector(`mcpplibs.gtest` 未命中就是未命中), +裸名也**仍然**到不了第三方 namespace。 + +**为什么只允许一个身份?** 因为依赖解析必须可复现。候选搜索会让同短名包受索引状态影响,新增索引还可能悄悄重定向既有依赖。 **给 xpkg 作者:** 索引描述符里,身份是 `(package.namespace, package.name)` 二元组。命名空间是点分路径,**`name` 是单一原子段**: @@ -454,7 +479,13 @@ package = { 文件名只是提示 —— 描述符按声明的身份被发现,所以 `pkgs/c/chriskohlhoff.asio.lua` 与 `pkgs/z/anything.lua` 解析结果完全相同。推荐 `.lua` 或 `..lua`(命中 mcpp 的快路径),但不强制。 -旧的完全限定拼写(`name = "chriskohlhoff.asio"`)仍被接受,已发布的描述符无需改动。`mcpp xpkg parse` 会校验该规则,请在索引 CI 里跑它。需要 mcpp >= 0.0.106 与 xlings >= 0.4.69;规范全文见 `docs/spec/package-identity.md`。 +旧的完全限定拼写(`name = "chriskohlhoff.asio"`)仍被接受,已发布的描述符无需改动。`mcpp xpkg parse` 会校验该规则,请在索引 CI 里跑它。描述符身份需要 mcpp >= 0.0.106,精确 selector 需要 mcpp >= 2026.8.10.1,两者使用 xlings >= 0.4.69;规范全文见 `docs/spec/package-identity.md`。 + +`mcpp new --template` 刻意复用同一身份模型,而不是另造包文法: +`[ns.]name[@version][:tname]`。其中裸名同样只表示 `mcpplibs`,version 与模板名可分别 +省略。省略 `tname` 时选择唯一显式 default;若未写 `default = true` 且只有一个模板, +该单模板自动成为默认。多个未标默认的模板会报错,绝不按目录顺序选择。规范表见 +`docs/spec/package-identity.md` §4.4。 #### 表形式 —— 让 feature 贡献的不止是隐含 feature `[features]` 的条目除了写成数组,还可写成**表**,从而让该 feature 在隐含 feature @@ -670,27 +701,69 @@ mcpp cache clean [--deps|--std|--all|--legacy] `mcpp cache verify` 另外会报告任何逃出条目的记录地址, 使这条不变量可以离线审计。 -### 2.11 `[runtime]` — 主机运行时能力 +### 2.11 `[runtime]` — provider-neutral 运行时契约 ```toml [runtime] -library_dirs = ["vendor/lib"] # 烤进产物 RUNPATH 的目录(相对包根) -dlopen_libs = ["libGL.so.1"] # 运行期 dlopen 的 soname(doctor 校验) -capabilities = ["opengl.glx.driver"] # 需要的主机能力(开放命名空间) -provides = ["opengl.glx.driver"] # 显式声明本包兑现的能力(强 provider) - -# 显式 provider 覆盖(三档旋钮的"显式"档) -[runtime."opengl.glx.driver"] -provider = "compat.glx-runtime" +requirements = [ + { kind = "capability", value = "display.present", phase = "run", required = true }, + { kind = "soname", value = "libwidget.so.1", phase = "link", required = false }, +] +provides = ["display.present"] +artifacts = [ + { role = "library", path = "runtime/libwidget.so.1", provenance = "payload", abi = "elf-x86_64", digest = "sha256:...", host_fingerprint = "host-1" }, +] + +# 平台无关 LinkIntent;路径相对本包根目录。 +libraries = ["widget"] +link_library_dirs = ["lib"] +transitive_needed_dirs = ["runtime/closure"] +runtime_search_dirs = ["runtime"] +frameworks = ["WindowKit"] +deploy_files = ["bin/widget.dll"] + +# 多 provider 时使用精确 canonical identity。 +[runtime."display.present"] +provider = "acme.widget-runtime@2.0.0" ``` -- **provider 选择**:声明 `provides` 的包(强)优先于仅在 `capabilities` 列出 - 能力的包(弱,向后兼容);`[runtime.] provider=` 显式覆盖最优先, - 指向依赖图中不存在的 provider 时给出 warning。 -- 解析结果可经 `mcpp why runtime`、`mcpp self doctor` 与构建产物 - `target///resolution.json` 查看(默认不是魔法)。 -- 能力命名约定:分层小写 `domain.sub.role`(如 `opengl.glx.driver`、 - `x11.display`)与前缀类 `abi:`(如 `abi:glibc`,参与工具链 ABI 强制)。 +`requirements` 记录非空 `kind`/`value`、`link` 或 `run` 阶段,以及是否强制 +(`required` 默认 `true`)。`artifacts` 必须含 `role`、`path`、`provenance`; +可选 requirement 仍保留为 provenance,但不会进入硬 ABI/doctor 输入。 +`libraries` 中显式的相对文件路径按声明包根目录解析;裸逻辑名仍按目标平台拼成库名。 +`abi`、`digest`、`host_fingerprint` 是可选证据。requester/provider 身份不由描述符 +填写:resolver 会用含 namespace、version、source/index provenance 的精确 PackageId +给 requirement 和 artifact 盖章。因此描述符不能冒充别的包, +`alpha.backend` 也不会与 `beta.backend` 混同。 + +只有 `provides` 会创建描述符侧 provider fact;需要某能力绝不会让 requester 自动 +成为 provider。显式 `[runtime.] provider=` 接受 canonical +`namespace.name@version`(或唯一无歧义的兼容拼写);不存在或同短名歧义都会 hard error。 +xlings SubOS 已选择的 provider/artifact fact 排在描述符 fallback 前。图形栈、driver、 +ICD、WSL 与 host provenance 选择由 xlings/xim 负责;mcpp 只记录、消费通用结果, +不探测 GPU 硬件。 + +LinkIntent 把不同发现阶段分开: + +| 字段 | ELF | Mach-O | PE/Windows | +|---|---|---|---| +| `link_library_dirs` | `-L` | `-L` | `-L` 或 `/LIBPATH:` | +| `transitive_needed_dirs` | `-Wl,-rpath-link` | 无 flag | 无 flag | +| `runtime_search_dirs` | 只进 RUNPATH/rpath,绝不进 `-L` | 只进 rpath | 无 flag | +| `frameworks` | 无 flag | `-framework` | 无 flag | +| `deploy_files` | copy edge | copy edge | 复制到产物旁,绝不成为 linker flag | + +一个兼容发布周期内仍读取旧字段:`library_dirs` 只映射到运行期搜索; +`dlopen_libs` 映射为必需的 run-phase soname requirement;`capabilities` 映射为必需的 +run-phase capability requirement。这些旧字段都不会创建 provider。 + +`target///resolution.json` schema 2 持久化 RuntimeBinding、canonical +requirements/providers/artifacts、LinkIntent、平台搜索机制与链接后 verdict。 +`mcpp why runtime` 只是最新存储文件的纯解释器:不重新解析 manifest,也不启动图形/ +硬件 probe。需要重新诊断所选 host provider 时使用 `xlings doctor`。 + +能力名使用分层小写 `domain.sub.role`(如 `display.present`)和前缀类 +`abi:`(如 `abi:glibc`,参与工具链 ABI 强制)。 ### 2.12 `[package] platforms` — 平台声明 @@ -724,12 +797,15 @@ OPENBLAS_NUM_THREADS = "1" host 工具(`make`/`cmake`/`protoc`…)、按项目固定工具版本、或设构建期环境变量——无需手改 `.xlings.json`。`[toolchain]`(§2.7)仍是编译器的便捷简写;`[xlings.workspace]` 是其通用形式。 -在 Linux 上 `subos` 还有第二重含义:它决定**构建绑定到哪个 C 运行时**。subos 会自述其 -runtime(xlings 2026.8.5.1 起),mcpp 以此为权威,而不是去四处找一个 libc——所以同一台机器 -上 `subos = "el8"` 与 `subos = "trixie"` 两个项目各自产出面向自己 glibc 的产物,且编译期与 -运行期保证一致。该绑定计入工具链指纹,故切换它会重新构建,而不会复用另一个 subos 的目标文件。 -更旧的 subos(或没有 subos)则回落到工具链自身安装时所对应的运行时;`mcpp build -v` 会打印 -实际走了哪一条。参见 docs/08-toolchain-internals.md §2.1。 +`subos` 选择根项目用于 build/run 的**本地开发 OS 环境**。未声明该键时固定使用 mcpp 已初始化、 +经 release 验证的 `McppDefault`;`subos = "default"` 则仍是显式的 +`NamedSubos("default")`。没有 CLI/环境变量 override,也不会隐式跟随 xlings active/current。 + +在 Linux 上,所选环境同时固定 loader/libc contract,所以 `el8`、`trixie` 可在同一机器共存, +并进入不同构建指纹。workspace 整体构建时由 workspace root 覆盖 member 声明;member/依赖中的 +SubOS 不传递——库只有作为独立 root 开发时才使用自己的声明,作为别人的源码依赖时使用消费者 +root 的环境。指定的命名 SubOS 不存在、缺少或使用不兼容 runtime contract 都会直接报错,不会 +回退 default/active/编译器烙入状态。参见 docs/08-toolchain-internals.md §2.1。 ### 2.14 依赖产出的 host 工具(mcpp 2026.8.5.1+) @@ -1022,7 +1098,7 @@ version = "1.0.0" [targets.mymath] kind = "lib" -[dev-dependencies] +[dev-dependencies.compat] gtest = "1.15.2" ``` @@ -1130,7 +1206,7 @@ mcpp build --target x86_64-linux-musl | 静态 stdlib | `true` | 便携二进制 | | 头文件 | `include/`(如果存在) | 自动加到 `-I` | | 测试 | `tests/**/*.cpp` | `mcpp test` 自动发现 | -| 依赖命名空间 | `mcpp`(默认) | 平铺写法走默认 ns | +| 依赖命名空间 | `mcpplibs`(默认) | 裸 selector 只表示该精确 ns | ### 4.1 旧 `[language]` 兼容层 diff --git a/docs/zh/06-workspace.md b/docs/zh/06-workspace.md index fb05a4bf..c8ac3fca 100644 --- a/docs/zh/06-workspace.md +++ b/docs/zh/06-workspace.md @@ -100,7 +100,7 @@ mbedtls.workspace = true # 根 mcpp.toml [workspace.dependencies] cmdline = "0.0.2" -capi.lua = "0.0.3" # dotted selector: mcpplibs.capi/lua, then capi/lua +mcpplibs.capi.lua = "0.0.3" # 精确 selector:(mcpplibs.capi, lua) [workspace.dependencies.compat] mbedtls = "3.6.1" diff --git a/docs/zh/08-toolchain-internals.md b/docs/zh/08-toolchain-internals.md index b6b41b83..790776ec 100644 --- a/docs/zh/08-toolchain-internals.md +++ b/docs/zh/08-toolchain-internals.md @@ -7,14 +7,13 @@ ## 1. 一张图看全模型 ``` -mcpp.toml [toolchain] / 全局默认 / `mcpp toolchain install` - │ (三条入口路径 —— 共享同一条管线) +mcpp.toml [xlings].subos / mcpp 管理的默认运行时 ▼ -解析 payload(沙箱里的 xim:gcc / xim:llvm / xim:musl-gcc xpkg) +解析 runtime binding ← 产物将加载哪个 libc(§2.1)——是答案,不是搜索 ▼ -ensure_post_install_fixup() ← 幂等收敛(marker 闸门) +解析工具链 payload ← 项目/default/install 入口共享同一管线 ▼ -解析 runtime binding ← 产物将加载哪个 libc(§2.1)——是答案,不是搜索 +ensure_post_install_fixup() ← 精确 glibc@version、marker 闸门,不取 readdir 首项 ▼ detect / probe ← triple、sysroot、payload 路径(glibc、linux-headers) ▼ @@ -25,6 +24,8 @@ ToolchainLinkModel(C 库轴的唯一解析器) └──► cfg 再生 (供人类直接使用的 clang++.cfg) ▼ hermetic 链接校验(`-###` 干跑) ← 校验沙箱 CRT/loader 的解析结果 + ▼ +链接 → 内部 ELF 物理校验 ← 校验真实产物及解析闭包(§6.1) ``` 贯穿一切的两条原则: @@ -63,15 +64,27 @@ xlings 后端解析/自动安装到沙箱 ### 2.1 runtime binding:绑哪个 libc,只决定一次 -payload-first 的构建会链接到某个具体的 glibc,而**是哪一个**是关于环境的事实, -不该靠推断。mcpp 按顺序解析: +payload-first 的构建会链接到某个具体的 glibc,而**是哪一个**是根项目本地开发 OS 的事实, +不该从编译器路径或 shell 状态推断。mcpp 只有两种选择: -1. `[xlings] subos = ""` —— 该 subos(活动 subos 的兄弟)在自己 - `.xlings.json` 的 `subos_info` 块里自述 runtime(xlings 2026.8.5.1 起)。 -2. 活动 subos,同一个块。 -3. *兼容路径。* 在该块出现之前创建的 subos 无法作答,此时以工具链自身烙入的值 - 顶上——gcc 的 specs、clang 的 cfg。这正是产物**将会**加载的那个值,故编译期 - 与运行期仍然一致;一旦 subos 能作答,这条自动退场。 +1. 未声明 `[xlings].subos`:使用全局 mcpp 配置所选 xlings home 中已初始化的 + `McppDefault` (`subos/default`)。 +2. `[xlings] subos = ""`:使用 `NamedSubos(name)`;显式 `"default"` + 仍保留命名选择身份,其他名称解析到根项目的本地 xlings scope。 + +workspace 构建由 workspace root 选择;member 与 dependency 声明不合并、不传递。 +`XLINGS_ACTIVE_SUBOS`、current、编译器 owner home 以及 CLI/环境 override 都不是第三层。 +所选 SubOS 必须提供受支持的 `subos_info`:环境不存在或 contract 缺失/不兼容会 hard error, +绝不回退 default/active/编译器烙入状态。该 contract 只读取一次形成 `RuntimeBinding` snapshot, +由 configure/link/run/test 与 fast-path cache 共同使用。 +Linux snapshot 还记录所选 loader/libc 目录的规范路径及可选的创建宿主 glibc floor; +它们是链接后校验的证据,不是新的选择入口。 + +当解析后的 SubOS view 可规范化到一个受管 glibc payload 时,该真实 view 是权威事实。 +旧 xlings 状态可能在 view 已原子切到受管 2.44 后仍保留 `runtime = "glibc@2.39"`;此时 +mcpp 在同一 binding 中记录真实的 2.44 身份与路径。若旧 view 是断链,mcpp 只允许解析 +`runtime` 精确指名的 payload;不会枚举已安装版本,也不会挑“最近/最新”版本。两条路径 +都只消费 xlings 给出的事实,不引入 mcpp 自己的运行时选择策略。 没有 binding 是**拒绝**而不是取默认值:`CLibMode::PayloadFirst` 会被放弃,而不是 去挑一个 libc。 @@ -83,12 +96,52 @@ payload-first 的构建会链接到某个具体的 glibc,而**是哪一个**是 引用了 `GLIBC_2.42` 符号,却跑在没有这些符号的运行时上,且报错落在与"拉进第二个 glibc 的那条依赖"毫无关系的包上。目录顺序不是决策依据。 -因为 binding 决定产物加载什么,它计入工具链指纹(11 个字段,不是 10 个)——只在 -runtime 上不同的两次构建绝不能共用同一条缓存。 +因为 binding 决定产物加载什么,完整规范化 contract hash 是工具链指纹第 11 个字段—— +即使两个命名 SubOS 都使用同一 glibc,只要 provider 或环境声明不同也绝不能共用缓存。 注意:probe 已**不再**从 clang cfg 挖 `--sysroot`——cfg 是这套机制的输出, 不是输入(见 §5)。 +### 2.2 通用运行时 provider 与 artifact + +`RuntimeBinding` 还携带 xlings 为所选开发 OS 解析好的 provider-neutral fact。 +`subos_info.runtime_contract` 是 schema 1 上可选、可加的 block: + +```json +{ + "providers": [ + {"capability": "display.present", "provider": { + "namespace": "xim", "name": "display-runtime", "version": "1.0.0", + "source": "xim-pkgindex@"}} + ], + "artifacts": [ + {"role": "driver", "provider": { + "namespace": "xim", "name": "display-runtime", "version": "1.0.0", + "source": "xim-pkgindex@"}, + "path": "${subosdir}/lib/runtime/provider.so", + "provenance": "subos_view", "abi": "elf-x86_64", + "digest": "sha256:...", "host_fingerprint": "..."} + ] +} +``` + +binding parser 只解析一次 `${subosdir}` 和相对 artifact 路径,排序 fact,并把它们纳入 +contract hash 与 cache snapshot。进入 BuildPlan 后,所选 provider fact 排在描述符 +fallback 前。描述符 requirement/artifact 则由 resolver 分别用 requester/provider 的 +canonical PackageId 盖章,所以不同 namespace 下相同短名不会在任何环节碰撞。 + +所有权边界是刻意的:mcpp-index 表达通用 runtime requirement;xlings/xim 选择并诊断 +宿主图形/运行时栈;mcpp 只消费已选 provider/artifact fact 与通用 LinkIntent。 +mcpp 不含硬件、driver vendor、WSL 或 ICD 选择路径;源码 gate 会拒绝引入这类 +provider-specific 分支,也拒绝把相关词汇与外部 probe 启动耦合。 + +`LinkIntent` 分开 `linkLibraryDirs`、`transitiveNeededDirs` 和 +`runtimeSearchDirs`;最后一类绝不渲染为 `-L`。ELF 只为 runtime 目录发 rpath, +且仅为 transitive 类发 `-rpath-link`;Mach-O 发 rpath/framework;PE 发链接库路径并 +用显式 deploy-file copy edge。精确 RuntimeBinding、canonical identity、LinkIntent、 +搜索机制与链接后 verdict 写入 `resolution.json` schema 2。 +`mcpp why runtime` 只解释该存储文件;重新诊断由 `xlings doctor` 负责。 + ## 3. 链接模型(`src/toolchain/linkmodel.cppm`) `ToolchainLinkModel` 只回答一个问题——*如何对该工具链的 C 库编译与链接*—— @@ -225,6 +278,62 @@ CI 用一个**完全没有宿主工具链**的 job(`debian:stable-slim`,无 gcc `Scrt1.o`)守住这一切——那是唯一能忠实复现干净机器故障模式的环境类;另有 e2e `86_llvm_hermetic_link.sh` 在任何机器上复核 `-###` 的解析结果。 +### 6.1 链接后的 Linux 运行时物理校验(`elf_runtime.cppm`) + +hermetic 校验回答的是链接前问题:driver 看起来会解析到什么。运行时物理校验回答更强的 +链接后问题:新 ELF 真正记录了什么,其闭包实际会加载什么。 +`[build] allow_host_libs = true` 会有意放宽前者,但不会屏蔽后者已经可证明的物理矛盾。 + +对每个新链接的 Linux executable/shared object,mcpp 在进程内解析 ELF64 little-endian +program/dynamic/GNU version 表;构建路径不启动 `readelf`、`patchelf` 或 `ldd`。读取内容包括 +`PT_INTERP`、`DT_RPATH`/`DT_RUNPATH`、`DT_NEEDED` 以及所需/导出的 `GLIBC_*` 版本。 +随后按产物搜索路径、所选 runtime/toolchain 目录和已知宿主库目录解析闭包,并执行: + +- **规则 B(同源):** `PT_INTERP` 和闭包中每个 `libc.so.6` 必须都来自 + `RuntimeBinding` 选定的规范 payload。宿主 loader + 私有 libc,或两个私有 libc payload, + 都是可证明的 pre-main 故障。 +- **规则 A(版本 floor):** 闭包请求的每个 `GLIBC_x.y` 都不得高于所选 libc 的 GNU + version definitions。满足条件时链接宿主 DSO 完全允许;mcpp 校验的是物理事实,不是 + 强加“禁止宿主库”策略。 + +判定是类型化的:`Pass`、`ProvenMismatch`、`Inconclusive`。可证明的 A/B 冲突会 hard fail, +诊断给出规范化 requester/provider/artifact 路径与可复制的 SubOS 修复步骤。无法取得 +loader cache/硬件闭包时明确报告 inconclusive,绝不伪装成绿色。macOS/Windows 复用同一 +类型接口但为 no-op,不会套用 ELF/glibc 规则。 + +verdict 以 `.mcpp-runtime-verdicts.json` 存在 `build.ninja` 旁,键包含产物 stat 指纹与 +完整 runtime contract hash。热 no-op 必须已有当前 `pass` 记录,比较 Ninja 前后产物 stat, +并执行零次 ELF 解析;若 Ninja 意外重链则先退回完整路径重新校验,之后才允许成功/运行。 +`mcpp self doctor` 复用同一存档 verdict,不会拿已经变化的当前宿主重新猜一次。 + +装后 fixup 同样按精确身份收敛:`glibc@2.44` 只解析 +`/xim-x-glibc/2.44/{lib64,lib}`。精确 payload 缺失/陈旧就是错误,其他已安装版本 +永远不是回退项。 + +### 6.2 一条运行时搜索路径可以住在哪里(`runtime_env_contract.cppm`) + +告诉 loader「去哪找」有两条通道,差别不在便利性,而在**波及范围**: + +| | 波及到 | | +| --- | --- | --- | +| `DT_RUNPATH` | 携带它的那**一个**对象,及其 `dlopen()` | 逐二进制 | +| `LD_LIBRARY_PATH` | 本进程**以及它派生的每一个进程** | 继承,且一直传下去 | + +第二行就是私有 libc 目录必须是 **binary 作用域**的原因。glibc 的 `libc.so.6` 与它的 +`ld.so` 通过 `GLIBC_PRIVATE` 版本锁死:2.44 的 libc 里 `__pointer_chk_guard` 是未定义 +引用,只有 2.44 自己的 loader 导出它。mcpp 构建出来的程序没事——`PT_INTERP` 指向私有 +loader;`/bin/sh` 有事:它的 `PT_INTERP` 指向**宿主** loader,而且任何环境变量都改不了, +于是 `popen()`/`system()` 的子进程在重定位阶段就死掉,连 `main` 都进不去,也没有任何 +输出(#401;#291 是同一形状往内一跳,杀掉的是 mcpp 自己的嵌套宿主工具)。 + +所以 mcpp 不会把私有 libc 目录发布到环境里。也不需要:只要存在 payload,link model +就已经在 `--dynamic-linker` 旁发了 `-Wl,-rpath,`,恰好覆盖这个目录存在的唯一 +理由——某个 `dlopen()` 的 `DT_NEEDED` 闭包看不到可执行文件的 RUNPATH。 + +这是**作用域**,不是条件判断。「只在依赖可能 dlopen 时才导出」仍然是导出,而死掉的 +子进程不关心原因。普通依赖运行时目录保留环境作用域:它们没有 loader 耦合,宿主二进制 +撞上去最多是困惑,不会死。 + ## 7. 扩充指南 ### 7.1 新增一个工具链(新编译器家族或发行版) @@ -291,6 +400,9 @@ mcpp 把运行时 DLL 部署到产物 exe 旁,这正是该平台对 §3–§4 | 链接模型 + loader 解析 | `src/toolchain/linkmodel.cppm` | | 统一 fixup 管线(patchelf/specs/cfg、marker)| `src/toolchain/post_install.cppm` | | install/lifecycle 入口 | `src/toolchain/lifecycle.cppm`;auto-install 入口在 `src/build/prepare.cppm` | +| root runtime 选择/binding | `src/xlings/runtime_selection.cppm`、`src/platform/runtime_binding.cppm`、`src/xlings/subos_info.cppm` | +| 通用 runtime contract + LinkIntent | `src/manifest/types.cppm`、`src/build/plan.cppm`、`src/build/flags.cppm` | +| 存储 resolution 解释 | `src/build/prepare.cppm`、`src/build/runtime_validation.cppm`、`src/doctor.cppm` | | flag 组装(主构建)| `src/build/flags.cppm` | | `import std;` 预编译 | `src/toolchain/stdmod.cppm` | | build.mcpp 宿主 flags | `src/build/build_program.cppm` | diff --git a/docs/zh/09-release.md b/docs/zh/09-release.md index 0c1577ec..86af4477 100644 --- a/docs/zh/09-release.md +++ b/docs/zh/09-release.md @@ -37,11 +37,44 @@ POSIX `sh`/dash 解析不了 —— `sh check_version_pins.sh` 会在第 95 行 ``` 四平台构建(linux x86_64 / linux aarch64 / macOS ARM64 / Windows x64) → GitHub Release v,含 tarball 与 .sha256 边车文件 + → 重新计算每个载荷的哈希并发布不可变 mcpp-release.json → 镜像到 xlings-res/mcpp 的 GitHub 与 GitCode 双端 → 向 openxlings/xim-pkgindex 开版本 bump PR → workflow_run 钩子触发 ci-fresh-install ``` +### 2.1 不可变 release manifest + +`release-manifest` 会等待四个平台上传 job 全部结束,然后下载最终的非 draft、 +非 prerelease GitHub Release,重新计算每个带版本平台载荷的 SHA256,校验对应的 +`.sha256` 边车文件,并发布 schema 1 的 `mcpp-release.json`: + +```json +{ + "schema": 1, + "version": "", + "tag": "v", + "commit": "", + "assets": [ + { + "platform": "linux", + "arch": "x86_64", + "name": "mcpp--linux-x86_64.tar.gz", + "sha256": "<重新计算的-sha256>" + } + ] +} +``` + +数组按平台、架构、名称排序,包含所有带版本的平台载荷;其中 Linux x86_64、 +Linux aarch64、macOS ARM64、Windows x86_64 四项是硬性要求。无版本别名和源码包 +刻意不进入 desired-state 行。 + +workflow 随后会再次下载公开 release、重新生成 manifest,并要求逐字节一致。 +重跑 workflow 时,已有 manifest 只有在字节完全相同时才会被接受;同一 tag 下 +绝不以不同内容覆盖。下游发布消费者(尤其 `mcpp-bin` AUR reconciler)必须消费 +该 manifest,不能从会变化的工作区或部分 release 资产猜测发布是否完整。 + 两步**没有**自动化: - **合并 xim-pkgindex 的 bump PR** —— 由维护者完成。在它落地之前,发布出来的版本 @@ -66,6 +99,27 @@ curl -fsSL -o /tmp/p.tgz "https://github.com/xlings-res/mcpp/releases/download/$ sha256sum /tmp/p.tgz # 与 xim-pkgindex 的 pkgs/m/mcpp.lua 对照 ``` +要在本地针对 GitHub 公开资产重放 release gate: + +```bash +V= +TAG="v$V" +AUDIT=$(mktemp -d) +mkdir -p "$AUDIT/assets" +gh api "repos/mcpp-community/mcpp/releases/tags/$TAG" > "$AUDIT/release.json" +gh release download "$TAG" -R mcpp-community/mcpp --dir "$AUDIT/assets" +python3 scripts/release/generate_manifest.py \ + --release-json "$AUDIT/release.json" \ + --assets-dir "$AUDIT/assets" \ + --version "$V" \ + --tag "$TAG" \ + --commit "$(git rev-list -n 1 "$TAG")" \ + --output "$AUDIT/expected.json" +cmp "$AUDIT/assets/mcpp-release.json" "$AUDIT/expected.json" +``` + +这个命令会重新计算载荷哈希,不会从已发布 manifest 抄哈希,也不会盲信边车文件。 + 然后在 **clean-room `XLINGS_HOME`** 里真装一次 —— 绝不要用本机的 `~/.xlings`, 它的缓存状态会把一个坏掉的索引掩盖过去: @@ -133,7 +187,8 @@ $(find "$XLINGS_HOME" -name mcpp -type f -path '*/bin/*' | head -1) --version [ ] `bash .github/tools/check_version_pins.sh` 通过(校验 `mcpp.toml` = `MCPP_VERSION`,且 `.xlings.json` 未领先) [ ] 合入 main,CI 全绿 [ ] gh workflow run release.yml --ref main -[ ] release.yml 全绿(4 个构建 + publish-ecosystem) +[ ] release.yml 全绿(4 个构建 + 不可变 manifest + publish-ecosystem) +[ ] 下载的 mcpp-release.json 能从公开资产逐字节重新生成 [ ] 双端都服务四个平台,sha256 重新算过 [ ] 合并 xim-pkgindex 的 bump PR [ ] clean-room XLINGS_HOME:xlings install mcpp@ 成功 diff --git a/mcpp.toml b/mcpp.toml index 7555910c..d4685526 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.8.8.4" +version = "2026.8.10.1" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] @@ -47,7 +47,8 @@ linkage = "static" mcpplibs.cmdline = "0.0.1" # `mcpp build` ignores [dev-dependencies]; only `mcpp test` resolves them. -[dev-dependencies] +# Namespace omission means exactly mcpplibs, so the compat wrapper is explicit. +[dev-dependencies.compat] gtest = "1.15.2" # Everything else uses M5.0 conventions: diff --git a/scripts/aur/README.md b/scripts/aur/README.md index 83b49c4a..557ef750 100644 --- a/scripts/aur/README.md +++ b/scripts/aur/README.md @@ -37,7 +37,7 @@ under a root-owned system prefix. mcpp resolves `MCPP_HOME` from the running binary's *real* path (`/proc/self/exe`, which resolves symlinks). A plain `/usr/bin/mcpp` symlink would therefore make `MCPP_HOME` resolve into the read-only install dir and -every command would fail to write. So both packages split the tree: +every command would fail to write. So all three packages split the tree: | Path | Contents | Mode | | --- | --- | --- | @@ -74,29 +74,47 @@ between releases — at the cost of tracking bleeding-edge master. ``` scripts/aur/ README.md this file - update.sh bump the release-pinned packages (mcpp-bin, mcpp-m) + update.sh render + verify mcpp-bin from mcpp-release.json + render_mcpp_bin.py pure manifest-to-PKGBUILD renderer + reconcile_mcpp_bin.py desired/observed-state reconciler + aur.archlinux.org.known_hosts pinned production host key mcpp-bin/{PKGBUILD, .SRCINFO, mcpp.sh} mcpp-m/{PKGBUILD, .SRCINFO, mcpp.sh} mcpp-git/{PKGBUILD, .SRCINFO, mcpp.sh} ``` -`mcpp.sh` is identical in all three dirs (each AUR repo must be self-contained); -`update.sh` keeps `mcpp-bin`/`mcpp-m` in sync. `mcpp-git` has no checksums to -bump (VCS source) and is not touched by `update.sh`. +`mcpp.sh` is currently identical in all three dirs because each AUR repo must +be self-contained. Automation in this phase owns **only `mcpp-bin`**. +`mcpp-m` is frozen and is neither read, rendered, staged, nor pushed by the +reconciler; `mcpp-git` is also outside this release workflow. ## Releasing a new version -After a GitHub release is published (and mirrored), bump the release-pinned -packages (`mcpp-bin`, `mcpp-m`; `mcpp-git` tracks master and needs no bump): +Every complete stable GitHub release publishes immutable `mcpp-release.json`. +Render the checked-in `mcpp-bin` package from that desired state (Docker is +required so `.SRCINFO` comes from a non-root Arch `makepkg`, not a handwritten +fallback): ```sh -scripts/aur/update.sh # uses [package].version from mcpp.toml -# or pin: scripts/aur/update.sh 0.0.66 +scripts/aur/update.sh # latest complete stable release +scripts/aur/update.sh 2026.8.10.1 # accepted only if it is that exact latest tag ``` -`update.sh` pulls the per-arch `.sha256` sidecars (for `mcpp-bin`) and hashes -the source archive (for `mcpp-m`), rewrites `pkgver` + checksums, resets -`pkgrel=1`, and regenerates both `.SRCINFO` files. +The renderer downloads both Linux payloads and sidecars, recomputes their +SHA256 values against the manifest, rewrites only `mcpp-bin`, runs +`makepkg --printsrcinfo`, and then runs `makepkg --verifysource`. There is no +version-from-worktree fallback and no downgrade override. + +For a read-only live reconciliation (including AUR RPC and HTTPS git state): + +```sh +python3 scripts/aur/reconcile_mcpp_bin.py \ + --trigger local \ + --report-json /tmp/mcpp-aur-report.json +``` + +Without `--publish`, an upgrade is rendered, source-verified, and shown as an +exact dry-run diff but never pushed. ### Test locally (on Arch) @@ -109,10 +127,31 @@ mcpp --version ## Automated publishing (CI) [`.github/workflows/aur-publish.yml`](../../.github/workflows/aur-publish.yml) -publishes both packages automatically. It runs when the `release` workflow -**completes successfully** (not on `release: published` — the aarch64 asset the -`mcpp-bin` checksum needs is uploaded by a later release job), refreshes the -PKGBUILDs via `update.sh`, and pushes each package to its AUR git repo over SSH. +reconciles `mcpp-bin` only. It runs after a successful `release` workflow, every +six hours to recover from transient AUR outages, and on manual dispatch. Manual +dispatch defaults to dry-run and requires `publish=true` to push; the two +automatic triggers plan and report but do **not** push until the repository +variable `AUR_AUTOPUBLISH` is set — see +[Arming the automatic triggers](#arming-the-automatic-triggers). + +Every trigger follows the same state machine: + +1. Select the latest complete, non-draft, non-prerelease manifest. +2. Validate both Linux payloads and sidecars and render in a clean directory. +3. Generate `.SRCINFO` and verify sources as a non-root user in Arch. +4. Query AUR RPC and clone the known `mcpp-bin` repo over HTTPS. +5. Compare versions with Arch `vercmp` and print the exact diff before the SSH + secret is loaded. +6. Refuse downgrades; otherwise use a normal fast-forward SSH push with bounded + maintenance retry and a pinned AUR host key. +7. Verify public git HEAD, bounded-poll RPC, then install and run + `mcpp --version` in a clean Arch container. + +The result is classified as `noop`, `updated`, `transient`, `permanent`, or +`refused-downgrade`, and the Actions summary records versions, hashes, commit, +retry count, and drift age. AUR runs are downstream workflows: a failure is +visible and retried by schedule but cannot rewrite the completed GitHub release +conclusion. > The AUR has no "watch upstream" feature — packages only update when their git > repo is pushed. This workflow is that push. @@ -120,7 +159,7 @@ PKGBUILDs via `update.sh`, and pushes each package to its AUR git repo over SSH. ### One-time setup you need to do 1. **AUR account** — sign in at with the account - that will own `mcpp-bin` / `mcpp-m`. + that owns `mcpp-bin`. 2. **Generate a dedicated SSH key** (no passphrase, it's for CI): @@ -139,22 +178,54 @@ PKGBUILDs via `update.sh`, and pushes each package to its AUR git repo over SSH. Then delete the local `aur_ci` / `aur_ci.pub` files. -That's the only secret required — AUR auth is SSH-key based, there is **no API -token**. The default `GITHUB_TOKEN` is *not* used (we push to the AUR, not to -GitHub). +That's the only publishing secret required — AUR auth is SSH-key based, there +is **no API token**. GitHub's read-only token is used only to fetch the release +manifest/assets. The private key is not loaded during the inspect/dry-run step. +The server host key is checked against the vendored ED25519 key sourced from +Arch Linux's infrastructure repository; the workflow never uses +`ssh-keyscan` as a trust decision. + +### Arming the automatic triggers + +`schedule` fires every six hours off the default branch. That means merging +this workflow is, by itself, enough to start writing to a third-party service +unattended — potentially before anyone has watched the reconciler complete a +real push even once. Merging is a decision about code; publishing to the AUR is +a decision about the outside world, and the two should not be the same act. + +So both automatic triggers (`workflow_run` after a release, and `schedule`) +stop after the plan-and-report step unless the repository variable +`AUR_AUTOPUBLISH` is set to `true`. Dry runs still validate payloads, render +`.SRCINFO`, query the AUR and print the exact diff, so the reporting value is +unchanged — only the push is withheld. + +To arm it, once: + +1. Run the workflow manually with `publish=false` and read the summary: it must + show the intended version and a clean diff. +2. Run it manually with `publish=true` and confirm the push, the AUR RPC row, + and a clean install in an Arch container. +3. Only then set *Settings → Secrets and variables → Actions → Variables →* + `AUR_AUTOPUBLISH = true`. + +Unsetting the variable is the kill switch: automatic runs immediately fall back +to reporting without publishing, with no code change and no revert. ### First publish -The first time, the package names must be free. The workflow auto-creates each -AUR repo on first push (AUR does this for a valid, available name). If you -prefer to claim them by hand first, push an initial commit manually: +The reconciler treats `mcpp-bin` as a known existing package. A failed clone or +missing RPC row is an error and **never** becomes an empty-repository first +publish. If a new package ever needs to be claimed, do that explicitly and +review the initial history by hand: ```sh git clone ssh://aur@aur.archlinux.org/mcpp-bin.git cp scripts/aur/mcpp-bin/{PKGBUILD,.SRCINFO,mcpp.sh} mcpp-bin/ && cd mcpp-bin -git add -A && git commit -m "initial mcpp-bin" && git push # repeat for mcpp-m +git add PKGBUILD .SRCINFO mcpp.sh +git commit -m "initial mcpp-bin" && git push ``` -After that, every release publishes both packages with no manual step. You can -also run it on demand from the Actions tab (*aur-publish → Run workflow*, -optional version input). +After the known repository exists, the reconciler never force-pushes or +reinitializes it. Manual recovery is available from *aur-publish → Run +workflow*: first leave `publish=false` to inspect, then set `publish=true` only +for the exact latest complete stable tag. diff --git a/scripts/aur/aur.archlinux.org.known_hosts b/scripts/aur/aur.archlinux.org.known_hosts new file mode 100644 index 00000000..64b5be4d --- /dev/null +++ b/scripts/aur/aur.archlinux.org.known_hosts @@ -0,0 +1,4 @@ +# Pinned AUR production ED25519 host key. +# Source: https://github.com/archlinux/infrastructure/blob/master/docs/ssh-known_hosts.txt +# Fingerprint: SHA256:RFzBCUItH9LZS0cKB5UE6ceAYhBD5C8GeOBip8Z11+4 +aur.archlinux.org ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEuBKrPzbawxA/k2g6NcyV5jmqwJ2s+zpgZGZ7tpLIcN diff --git a/scripts/aur/mcpp-bin/PKGBUILD b/scripts/aur/mcpp-bin/PKGBUILD index 1d0ba43f..dcc10e51 100644 --- a/scripts/aur/mcpp-bin/PKGBUILD +++ b/scripts/aur/mcpp-bin/PKGBUILD @@ -1,4 +1,4 @@ -# Maintainer: mcpp-community +# Maintainer: mcpp-community # # mcpp-bin — prebuilt release binaries from GitHub, the same artifacts the # upstream `install.sh` one-liner downloads. See scripts/aur/README.md for diff --git a/scripts/aur/reconcile_mcpp_bin.py b/scripts/aur/reconcile_mcpp_bin.py new file mode 100755 index 00000000..88748a49 --- /dev/null +++ b/scripts/aur/reconcile_mcpp_bin.py @@ -0,0 +1,1132 @@ +#!/usr/bin/env python3 +"""Reconcile AUR mcpp-bin to the latest complete stable mcpp release.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from enum import Enum +from pathlib import Path +from typing import Any, Callable, NoReturn, TypeVar + +from render_mcpp_bin import ( + DesiredState, + RenderError, + load_desired_state, + materialize_package, +) + + +GITHUB_REPOSITORY = "mcpp-community/mcpp" +AUR_PACKAGE = "mcpp-bin" +AUR_HTTPS_URL = "https://aur.archlinux.org/mcpp-bin.git" +AUR_SSH_URL = "ssh://aur@aur.archlinux.org/mcpp-bin.git" +AUR_RPC_URL = "https://aur.archlinux.org/rpc/v5/info?arg[]=mcpp-bin" +ARCH_IMAGE = "archlinux:base-devel" +MANAGED_FILES = ("PKGBUILD", ".SRCINFO", "mcpp.sh") +AUR_COMMIT_NAME = "speak-agent" +AUR_COMMIT_EMAIL = "speak-agent@users.noreply.github.com" +SIDECAR_RE = re.compile(r"^([0-9a-fA-F]{64})[ \t]+\*?(\S+)$") +T = TypeVar("T") + + +class Classification(str, Enum): + NOOP = "noop" + UPDATED = "updated" + TRANSIENT = "transient" + PERMANENT = "permanent" + REFUSED_DOWNGRADE = "refused-downgrade" + + +class PlanAction(str, Enum): + NOOP = "noop" + UPGRADE = "upgrade" + REPAIR = "repair" + WAIT_RPC = "wait-rpc" + REFUSE_DOWNGRADE = "refuse-downgrade" + + +class ReconcileFailure(RuntimeError): + def __init__( + self, + message: str, + classification: Classification, + *, + retryable: bool = False, + ) -> None: + super().__init__(message) + self.classification = classification + self.retryable = retryable + + +class CommandFailure(RuntimeError): + def __init__( + self, + command: tuple[str, ...] | list[str], + returncode: int, + stdout: str, + stderr: str, + ) -> None: + self.command = tuple(command) + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + super().__init__( + f"command failed ({returncode}): {' '.join(self.command)}\n{stderr.strip()}" + ) + + +@dataclass(frozen=True) +class CommandResult: + stdout: str + stderr: str + returncode: int + + +class CommandRunner: + def run( + self, + args: list[str] | tuple[str, ...], + *, + cwd: Path | None = None, + env: dict[str, str] | None = None, + timeout: float | None = None, + ) -> CommandResult: + command = tuple(str(arg) for arg in args) + try: + completed = subprocess.run( + command, + cwd=cwd, + env=env, + timeout=timeout, + check=False, + capture_output=True, + text=True, + ) + except subprocess.TimeoutExpired as exc: + raise CommandFailure( + command, + 124, + exc.stdout or "", + f"command timed out after {timeout}s: {exc.stderr or ''}", + ) from exc + except OSError as exc: + raise CommandFailure(command, 127, "", str(exc)) from exc + result = CommandResult(completed.stdout, completed.stderr, completed.returncode) + if completed.returncode != 0: + raise CommandFailure( + command, + result.returncode, + result.stdout, + result.stderr, + ) + return result + + +@dataclass(frozen=True) +class ObservedState: + rpc_version: str | None + git_version: str + git_head: str + content_matches: bool + + +@dataclass(frozen=True) +class ReconcilePlan: + action: PlanAction + reason: str + + +def permanent(message: str) -> NoReturn: + raise ReconcileFailure(message, Classification.PERMANENT) + + +def classify_command_failure(error: CommandFailure) -> ReconcileFailure: + detail = f"{error.stdout}\n{error.stderr}".lower() + auth_markers = ( + "permission denied", + "publickey", + "authentication failed", + "host key verification failed", + "invalid format", + ) + transient_markers = ( + "maintenance", + "temporarily unavailable", + "service unavailable", + "timed out", + "timeout", + "connection reset", + "connection closed", + "remote end hung up", + "ssh_exchange_identification", + "http 502", + "http 503", + "http 504", + "502 bad gateway", + "503 service unavailable", + "504 gateway timeout", + ) + if any(marker in detail for marker in auth_markers): + return ReconcileFailure(str(error), Classification.PERMANENT) + if "non-fast-forward" in detail or "fetch first" in detail: + return ReconcileFailure( + str(error), Classification.TRANSIENT, retryable=False + ) + if any(marker in detail for marker in transient_markers): + return ReconcileFailure(str(error), Classification.TRANSIENT, retryable=True) + return ReconcileFailure(str(error), Classification.PERMANENT) + + +def retry_transient( + operation: Callable[[], T], + *, + max_attempts: int, + base_delay: float, + sleep: Callable[[float], None] = time.sleep, +) -> tuple[T, int]: + if max_attempts < 1: + permanent("retry max_attempts must be at least 1") + retries = 0 + for attempt in range(1, max_attempts + 1): + try: + return operation(), retries + except CommandFailure as error: + failure = classify_command_failure(error) + except ReconcileFailure as error: + failure = error + if not failure.retryable or attempt == max_attempts: + raise failure + delay = base_delay * (2 ** (attempt - 1)) + sleep(delay) + retries += 1 + raise AssertionError("retry loop must return or raise") + + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + try: + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + except OSError as exc: + permanent(f"cannot read release asset {path}: {exc}") + return digest.hexdigest() + + +def _sidecar_digest(path: Path, primary_name: str) -> str: + try: + lines = [ + line.strip() + for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + except (OSError, UnicodeError) as exc: + permanent(f"missing or unreadable release sidecar {path}: {exc}") + if len(lines) != 1: + permanent(f"release sidecar must have one checksum record: {path.name}") + match = SIDECAR_RE.fullmatch(lines[0]) + if match is None or match.group(2) != primary_name: + permanent(f"invalid release sidecar record: {path.name}") + return match.group(1).lower() + + +def validate_release_assets( + desired: DesiredState, assets_dir: Path +) -> dict[str, str]: + validated: dict[str, str] = {} + for arch in ("x86_64", "aarch64"): + asset = desired.asset("linux", arch) + payload = assets_dir / asset.name + sidecar = assets_dir / f"{asset.name}.sha256" + if not payload.is_file(): + permanent(f"missing release payload: {asset.name}") + if not sidecar.is_file(): + permanent(f"missing release sidecar: {sidecar.name}") + sidecar_digest = _sidecar_digest(sidecar, asset.name) + actual_digest = _sha256_file(payload) + if sidecar_digest != asset.sha256: + permanent( + f"sidecar/manifest SHA256 mismatch for {asset.name}: " + f"{sidecar_digest} != {asset.sha256}" + ) + if actual_digest != asset.sha256: + permanent( + f"payload/manifest SHA256 mismatch for {asset.name}: " + f"{actual_digest} != {asset.sha256}" + ) + validated[arch] = actual_digest + return validated + + +def select_complete_release( + releases: Any, requested_tag: str | None = None +) -> dict[str, Any]: + if not isinstance(releases, list): + permanent("GitHub releases response must be an array") + selected: dict[str, Any] | None = None + for release in releases: + if not isinstance(release, dict): + permanent("GitHub release row must be an object") + if release.get("draft") is not False or release.get("prerelease") is not False: + continue + assets = release.get("assets") + if not isinstance(assets, list): + continue + names = { + item.get("name") + for item in assets + if isinstance(item, dict) and isinstance(item.get("name"), str) + } + if "mcpp-release.json" in names: + selected = release + break + if selected is None: + raise ReconcileFailure( + "no complete stable release with mcpp-release.json is available", + Classification.TRANSIENT, + retryable=True, + ) + selected_tag = selected.get("tag_name") + if not isinstance(selected_tag, str) or not selected_tag: + permanent("selected GitHub release has no valid tag_name") + if requested_tag and requested_tag != selected_tag: + raise ReconcileFailure( + f"requested tag {requested_tag!r} is not latest complete stable " + f"tag {selected_tag!r}", + Classification.REFUSED_DOWNGRADE, + ) + return selected + + +def plan_reconciliation( + desired_version: str, + observed: ObservedState, + compare: Callable[[str, str], int], +) -> ReconcilePlan: + git_comparison = compare(desired_version, observed.git_version) + rpc_comparison = ( + compare(desired_version, observed.rpc_version) + if observed.rpc_version is not None + else 1 + ) + if git_comparison < 0 or rpc_comparison < 0: + return ReconcilePlan( + PlanAction.REFUSE_DOWNGRADE, + "AUR already contains a version newer than desired state", + ) + if git_comparison == 0 and observed.content_matches: + if rpc_comparison == 0: + return ReconcilePlan(PlanAction.NOOP, "git, RPC, and desired content agree") + return ReconcilePlan( + PlanAction.WAIT_RPC, + "AUR git is desired state while RPC metadata is still behind", + ) + if git_comparison > 0: + return ReconcilePlan(PlanAction.UPGRADE, "desired version is newer than AUR git") + return ReconcilePlan( + PlanAction.REPAIR, + "AUR version matches desired state but tracked package content differs", + ) + + +def poll_rpc_version( + fetch_version: Callable[[], str | None], + *, + desired_version: str, + compare: Callable[[str, str], int], + max_attempts: int, + delay: float, + sleep: Callable[[float], None] = time.sleep, +) -> tuple[str, int]: + if max_attempts < 1: + permanent("RPC max_attempts must be at least 1") + last: str | None = None + for attempt in range(1, max_attempts + 1): + last = fetch_version() + if last is not None: + comparison = compare(desired_version, last) + if comparison == 0: + return last, attempt + if comparison < 0: + raise ReconcileFailure( + f"AUR RPC advanced to {last}, newer than desired {desired_version}", + Classification.REFUSED_DOWNGRADE, + ) + if attempt < max_attempts: + sleep(delay) + raise ReconcileFailure( + f"AUR RPC did not converge to {desired_version}; last={last!r}", + Classification.TRANSIENT, + retryable=True, + ) + + +def clone_observed_repo( + runner: Any, + destination: Path, + url: str = AUR_HTTPS_URL, +) -> None: + try: + runner.run(["git", "clone", "--", url, str(destination)]) + except CommandFailure as error: + raise classify_command_failure(error) from error + if not (destination / ".git").is_dir(): + permanent(f"known AUR package clone produced no git repository: {destination}") + + +def _run_or_classify( + runner: CommandRunner, + args: list[str], + *, + cwd: Path | None = None, + timeout: float | None = None, +) -> CommandResult: + try: + return runner.run(args, cwd=cwd, timeout=timeout) + except CommandFailure as error: + raise classify_command_failure(error) from error + + +def _parse_json(text: str, label: str) -> Any: + try: + return json.loads(text) + except json.JSONDecodeError as exc: + lower = text.lower() + if "maintenance" in lower or "service unavailable" in lower: + raise ReconcileFailure( + f"{label} returned transient non-JSON response", + Classification.TRANSIENT, + retryable=True, + ) from exc + permanent(f"{label} returned invalid JSON: {exc}") + + +def _release_asset_names(release: dict[str, Any]) -> set[str]: + raw_assets = release.get("assets") + if not isinstance(raw_assets, list): + permanent("selected GitHub release assets must be an array") + names: set[str] = set() + for item in raw_assets: + if not isinstance(item, dict) or not isinstance(item.get("name"), str): + permanent("selected GitHub release has an invalid asset row") + name = item["name"] + if name in names: + permanent(f"selected GitHub release has duplicate asset name: {name}") + names.add(name) + return names + + +def _download_release_asset( + runner: CommandRunner, + *, + repository: str, + tag: str, + name: str, + destination: Path, +) -> Path: + destination.mkdir(parents=True, exist_ok=True) + try: + runner.run( + [ + "gh", + "release", + "download", + tag, + "--repo", + repository, + "--dir", + str(destination), + "--pattern", + name, + ], + timeout=300, + ) + except CommandFailure as error: + failure = classify_command_failure(error) + if failure.classification is Classification.PERMANENT: + permanent(f"cannot download required release asset {name}: {error}") + raise failure from error + path = destination / name + if not path.is_file(): + permanent(f"GitHub download omitted required release asset: {name}") + return path + + +@dataclass(frozen=True) +class DesiredSnapshot: + desired: DesiredState + manifest_path: Path + assets_dir: Path + release: dict[str, Any] | None + + +def acquire_desired_snapshot( + runner: CommandRunner, + *, + work_dir: Path, + repository: str, + requested_tag: str | None, + local_manifest: Path | None, + local_assets_dir: Path | None, +) -> DesiredSnapshot: + release: dict[str, Any] | None = None + if local_manifest is not None: + desired = load_desired_state(local_manifest) + if requested_tag and requested_tag != desired.tag: + raise ReconcileFailure( + f"requested tag {requested_tag!r} does not match local manifest " + f"tag {desired.tag!r}", + Classification.REFUSED_DOWNGRADE, + ) + manifest_path = local_manifest + else: + response = _run_or_classify( + runner, + ["gh", "api", f"repos/{repository}/releases?per_page=20"], + timeout=60, + ) + release = select_complete_release( + _parse_json(response.stdout, "GitHub releases API"), requested_tag + ) + tag = release["tag_name"] + manifest_dir = work_dir / "manifest" + manifest_path = _download_release_asset( + runner, + repository=repository, + tag=tag, + name="mcpp-release.json", + destination=manifest_dir, + ) + desired = load_desired_state(manifest_path) + if desired.tag != tag: + permanent( + f"release/manifest tag mismatch: release={tag!r}, " + f"manifest={desired.tag!r}" + ) + + if local_assets_dir is not None: + assets_dir = local_assets_dir + else: + assets_dir = work_dir / "assets" + release_names = _release_asset_names(release) if release is not None else None + for arch in ("x86_64", "aarch64"): + asset = desired.asset("linux", arch) + for name in (asset.name, f"{asset.name}.sha256"): + if release_names is not None and name not in release_names: + permanent(f"selected GitHub release inventory is missing {name}") + _download_release_asset( + runner, + repository=repository, + tag=desired.tag, + name=name, + destination=assets_dir, + ) + validate_release_assets(desired, assets_dir) + return DesiredSnapshot(desired, manifest_path, assets_dir, release) + + +def generate_srcinfo_and_verify( + runner: CommandRunner, + *, + package_dir: Path, + image: str, +) -> None: + script = """ +set -euo pipefail +useradd --create-home builder +cp -a /input /tmp/package +chown -R builder:builder /tmp/package +runuser -u builder -- env HOME=/home/builder bash -c 'cd /tmp/package && makepkg --printsrcinfo' > /output/SRCINFO +runuser -u builder -- env HOME=/home/builder bash -c 'cd /tmp/package && makepkg --verifysource --noconfirm' +""".strip() + with tempfile.TemporaryDirectory(prefix="mcpp-aur-arch-output-") as raw_output: + generated_dir = Path(raw_output) + _run_or_classify( + runner, + [ + "docker", + "run", + "--rm", + "--volume", + f"{package_dir.resolve()}:/input:ro", + "--volume", + f"{generated_dir.resolve()}:/output", + image, + "bash", + "-euc", + script, + ], + timeout=900, + ) + generated = generated_dir / "SRCINFO" + if not generated.is_file(): + permanent("Arch makepkg did not generate .SRCINFO") + try: + content = generated.read_bytes() + (package_dir / ".SRCINFO").write_bytes(content) + except OSError as exc: + permanent(f"cannot install generated .SRCINFO: {exc}") + + +def _srcinfo_package_version(path: Path) -> str: + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeError) as exc: + permanent(f"cannot read AUR .SRCINFO {path}: {exc}") + version_match = re.search(r"^\s*pkgver\s*=\s*(\S+)\s*$", text, re.MULTILINE) + release_match = re.search(r"^\s*pkgrel\s*=\s*(\S+)\s*$", text, re.MULTILINE) + if version_match is None or release_match is None: + permanent(f"AUR .SRCINFO lacks pkgver/pkgrel: {path}") + return f"{version_match.group(1)}-{release_match.group(1)}" + + +class VersionComparator: + def __init__(self, runner: CommandRunner, image: str) -> None: + self.runner = runner + self.image = image + self.command = shutil.which("vercmp") + self.cache: dict[tuple[str, str], int] = {} + + def __call__(self, left: str, right: str) -> int: + key = (left, right) + if key in self.cache: + return self.cache[key] + args = ( + [self.command, left, right] + if self.command + else ["docker", "run", "--rm", self.image, "vercmp", left, right] + ) + result = _run_or_classify(self.runner, args, timeout=120) + try: + value = int(result.stdout.strip()) + except ValueError: + permanent(f"Arch vercmp returned invalid output: {result.stdout!r}") + value = (value > 0) - (value < 0) + self.cache[key] = value + return value + + +@dataclass(frozen=True) +class RpcState: + version: str + last_modified: int | None + + +def fetch_rpc_state(runner: CommandRunner) -> RpcState: + result = _run_or_classify( + runner, + [ + "curl", + "--fail", + "--silent", + "--show-error", + "--location", + "--connect-timeout", + "15", + "--max-time", + "30", + AUR_RPC_URL, + ], + timeout=45, + ) + payload = _parse_json(result.stdout, "AUR RPC") + if not isinstance(payload, dict) or not isinstance(payload.get("results"), list): + permanent("AUR RPC response has no results array") + rows = payload["results"] + if len(rows) != 1 or not isinstance(rows[0], dict): + permanent( + "known AUR package mcpp-bin is missing or ambiguous; refusing first publish" + ) + version = rows[0].get("Version") + modified = rows[0].get("LastModified") + if not isinstance(version, str) or not version: + permanent("AUR RPC mcpp-bin row has no valid Version") + if modified is not None and type(modified) is not int: + modified = None + return RpcState(version, modified) + + +def inspect_observed_repo( + runner: CommandRunner, + *, + clone_dir: Path, + desired_package_dir: Path, + rpc_state: RpcState, +) -> ObservedState: + clone_observed_repo(runner, clone_dir) + git_version = _srcinfo_package_version(clone_dir / ".SRCINFO") + git_head = _run_or_classify( + runner, ["git", "rev-parse", "HEAD"], cwd=clone_dir + ).stdout.strip() + if re.fullmatch(r"[0-9a-fA-F]{40,64}", git_head) is None: + permanent(f"AUR git returned invalid HEAD: {git_head!r}") + tracked_text = _run_or_classify( + runner, ["git", "ls-files", "-z"], cwd=clone_dir + ).stdout + tracked = {item for item in tracked_text.split("\0") if item} + content_matches = tracked == set(MANAGED_FILES) + for name in MANAGED_FILES: + observed_path = clone_dir / name + desired_path = desired_package_dir / name + if not observed_path.is_file() or not desired_path.is_file(): + content_matches = False + continue + if observed_path.read_bytes() != desired_path.read_bytes(): + content_matches = False + return ObservedState( + rpc_version=rpc_state.version, + git_version=git_version, + git_head=git_head.lower(), + content_matches=content_matches, + ) + + +def stage_desired_repo( + runner: CommandRunner, *, clone_dir: Path, desired_package_dir: Path +) -> str: + tracked_text = _run_or_classify( + runner, ["git", "ls-files", "-z"], cwd=clone_dir + ).stdout + tracked = {item for item in tracked_text.split("\0") if item} + for name in sorted(tracked - set(MANAGED_FILES)): + relative = Path(name) + if relative.is_absolute() or ".." in relative.parts: + permanent(f"unsafe tracked path in AUR repository: {name!r}") + _run_or_classify(runner, ["git", "rm", "-f", "--", name], cwd=clone_dir) + for name in MANAGED_FILES: + try: + shutil.copyfile(desired_package_dir / name, clone_dir / name) + except OSError as exc: + permanent(f"cannot stage desired AUR file {name}: {exc}") + _run_or_classify( + runner, ["git", "add", "--", *MANAGED_FILES], cwd=clone_dir + ) + return _run_or_classify( + runner, + ["git", "diff", "--cached", "--no-ext-diff", "--"], + cwd=clone_dir, + ).stdout + + +def publish_staged_repo( + runner: CommandRunner, + *, + clone_dir: Path, + desired_version: str, + max_attempts: int, + base_delay: float, +) -> tuple[str, int]: + _run_or_classify( + runner, + ["git", "config", "user.name", AUR_COMMIT_NAME], + cwd=clone_dir, + ) + _run_or_classify( + runner, + ["git", "config", "user.email", AUR_COMMIT_EMAIL], + cwd=clone_dir, + ) + _run_or_classify( + runner, + ["git", "commit", "-m", f"mcpp-bin {desired_version}"], + cwd=clone_dir, + ) + local_head = _run_or_classify( + runner, ["git", "rev-parse", "HEAD"], cwd=clone_dir + ).stdout.strip() + + def push() -> CommandResult: + return runner.run( + ["git", "push", AUR_SSH_URL, "HEAD:master"], + cwd=clone_dir, + timeout=120, + ) + + _, retries = retry_transient( + push, + max_attempts=max_attempts, + base_delay=base_delay, + ) + remote = _run_or_classify( + runner, + ["git", "ls-remote", AUR_HTTPS_URL, "refs/heads/master"], + timeout=60, + ).stdout.split() + if not remote or remote[0].lower() != local_head.lower(): + raise ReconcileFailure( + f"AUR public git head does not match pushed commit {local_head}", + Classification.TRANSIENT, + retryable=True, + ) + return local_head.lower(), retries + + +def clean_arch_install_smoke( + runner: CommandRunner, + *, + package_dir: Path, + desired_version: str, + image: str, +) -> None: + script = """ +set -euo pipefail +pacman -Syu --noconfirm --needed git +useradd --create-home builder +cp -a /input /tmp/package +chown -R builder:builder /tmp/package +runuser -u builder -- bash -lc 'cd /tmp/package && makepkg --noconfirm' +pacman -U --noconfirm /tmp/package/mcpp-bin-*.pkg.tar.zst +install -d -o builder -g builder /tmp/mcpp-home +actual=$(runuser -u builder -- env HOME=/home/builder MCPP_HOME=/tmp/mcpp-home mcpp --version) +printf '%s\n' "$actual" +grep -F -- "$EXPECTED_VERSION" <<<"$actual" +""".strip() + env_args = ["--env", f"EXPECTED_VERSION={desired_version}"] + _run_or_classify( + runner, + [ + "docker", + "run", + "--rm", + *env_args, + "--volume", + f"{package_dir.resolve()}:/input:ro", + image, + "bash", + "-euc", + script, + ], + timeout=1800, + ) + + +def _release_age_seconds(release: dict[str, Any] | None) -> int | None: + if release is None: + return None + value = release.get("published_at") + if not isinstance(value, str): + return None + try: + published = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + return max(0, int((datetime.now(timezone.utc) - published).total_seconds())) + + +def write_report(report: dict[str, Any], json_path: Path | None, summary: Path | None) -> None: + rendered = json.dumps(report, indent=2, sort_keys=True) + "\n" + if json_path is not None: + json_path.parent.mkdir(parents=True, exist_ok=True) + json_path.write_text(rendered, encoding="utf-8") + if summary is not None: + rows = [ + ("trigger", report.get("trigger")), + ("classification", report.get("classification")), + ("action", report.get("action")), + ("desired", report.get("desired_version")), + ("observed RPC", report.get("observed_rpc_version")), + ("observed git", report.get("observed_git_version")), + ("manifest SHA256", report.get("manifest_sha256")), + ("x86_64 SHA256", report.get("asset_sha256", {}).get("x86_64")), + ("aarch64 SHA256", report.get("asset_sha256", {}).get("aarch64")), + ("remote commit", report.get("remote_commit")), + ("push retries", report.get("push_retries")), + ("RPC polls", report.get("rpc_polls")), + ("drift age seconds", report.get("drift_age_seconds")), + ("published", report.get("published")), + ("message", report.get("message")), + ] + def cell(value: Any) -> str: + return str(value).replace("|", "\\|").replace("`", "'").replace("\n", " ") + + lines = ["## mcpp-bin AUR reconciliation", "", "| Field | Value |", "|---|---|"] + lines.extend(f"| {key} | `{cell(value)}` |" for key, value in rows) + with summary.open("a", encoding="utf-8") as stream: + stream.write("\n".join(lines) + "\n") + + +def reconcile_once( + args: argparse.Namespace, + *, + runner: CommandRunner, + work_dir: Path, + report: dict[str, Any], +) -> None: + snapshot = acquire_desired_snapshot( + runner, + work_dir=work_dir, + repository=args.repository, + requested_tag=args.tag, + local_manifest=args.manifest, + local_assets_dir=args.assets_dir, + ) + desired = snapshot.desired + asset_hashes = validate_release_assets(desired, snapshot.assets_dir) + report.update( + { + "desired_version": desired.package_version, + "release_version": desired.version, + "tag": desired.tag, + "release_commit": desired.commit, + "manifest_sha256": desired.manifest_sha256, + "asset_sha256": asset_hashes, + "drift_age_seconds": _release_age_seconds(snapshot.release), + } + ) + + package_dir = args.output_dir if args.render_only else work_dir / "desired-package" + if package_dir is None: + permanent("--render-only requires --output-dir") + materialize_package( + desired=desired, + template_dir=args.template_dir, + output_dir=package_dir, + ) + generate_srcinfo_and_verify( + runner, + package_dir=package_dir, + image=args.arch_image, + ) + rendered_version = _srcinfo_package_version(package_dir / ".SRCINFO") + if rendered_version != desired.package_version: + permanent( + f"generated .SRCINFO version {rendered_version!r} does not match " + f"desired {desired.package_version!r}" + ) + + if args.render_only: + report.update( + { + "classification": Classification.UPDATED.value, + "action": "render-only", + "needs_publish": False, + "published": False, + "message": f"rendered and verified {package_dir}", + } + ) + return + + rpc_state = fetch_rpc_state(runner) + clone_dir = work_dir / "aur-git" + observed = inspect_observed_repo( + runner, + clone_dir=clone_dir, + desired_package_dir=package_dir, + rpc_state=rpc_state, + ) + report.update( + { + "observed_rpc_version": observed.rpc_version, + "observed_git_version": observed.git_version, + "remote_commit": observed.git_head, + } + ) + compare = VersionComparator(runner, args.arch_image) + plan = plan_reconciliation(desired.package_version, observed, compare) + report["action"] = plan.action.value + report["message"] = plan.reason + + if plan.action is PlanAction.REFUSE_DOWNGRADE: + raise ReconcileFailure(plan.reason, Classification.REFUSED_DOWNGRADE) + if plan.action is PlanAction.NOOP: + report.update( + { + "classification": Classification.NOOP.value, + "needs_publish": False, + "published": False, + } + ) + return + if plan.action is PlanAction.WAIT_RPC: + state: RpcState | None = None + + def fetch_version() -> str: + nonlocal state + state = fetch_rpc_state(runner) + return state.version + + _, polls = poll_rpc_version( + fetch_version, + desired_version=desired.package_version, + compare=compare, + max_attempts=args.rpc_poll_attempts, + delay=args.rpc_poll_delay, + ) + report.update( + { + "classification": Classification.NOOP.value, + "observed_rpc_version": state.version if state else observed.rpc_version, + "rpc_polls": polls, + "needs_publish": False, + "published": False, + "message": "AUR RPC converged to the already-published git state", + } + ) + return + + diff = stage_desired_repo( + runner, clone_dir=clone_dir, desired_package_dir=package_dir + ) + if not diff.strip(): + permanent(f"{plan.action.value} plan produced no staged AUR diff") + print("--- mcpp-bin desired-state diff ---") + print(diff, end="" if diff.endswith("\n") else "\n") + report["needs_publish"] = True + if not args.publish: + report.update( + { + "classification": Classification.UPDATED.value, + "published": False, + "message": f"{plan.action.value} dry-run validated; publish required", + } + ) + return + + remote_commit, retries = publish_staged_repo( + runner, + clone_dir=clone_dir, + desired_version=desired.version, + max_attempts=args.push_attempts, + base_delay=args.push_base_delay, + ) + state: RpcState | None = None + + def fetch_version_after_push() -> str: + nonlocal state + state = fetch_rpc_state(runner) + return state.version + + _, polls = poll_rpc_version( + fetch_version_after_push, + desired_version=desired.package_version, + compare=compare, + max_attempts=args.rpc_poll_attempts, + delay=args.rpc_poll_delay, + ) + clean_arch_install_smoke( + runner, + package_dir=package_dir, + desired_version=desired.version, + image=args.arch_image, + ) + report.update( + { + "classification": Classification.UPDATED.value, + "observed_rpc_version": state.version if state else desired.package_version, + "remote_commit": remote_commit, + "push_retries": retries, + "rpc_polls": polls, + "needs_publish": False, + "published": True, + "message": "AUR git, RPC, and clean Arch install converged", + } + ) + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + script_dir = Path(__file__).resolve().parent + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repository", default=GITHUB_REPOSITORY) + parser.add_argument("--tag", help="optional exact latest complete stable tag") + parser.add_argument("--publish", action="store_true", help="push an approved diff") + parser.add_argument("--trigger", default="manual") + parser.add_argument("--manifest", type=Path, help="local audit manifest") + parser.add_argument("--assets-dir", type=Path, help="local audited release assets") + parser.add_argument("--template-dir", type=Path, default=script_dir / "mcpp-bin") + parser.add_argument("--render-only", action="store_true") + parser.add_argument("--output-dir", type=Path) + parser.add_argument("--work-dir", type=Path) + parser.add_argument("--arch-image", default=ARCH_IMAGE) + parser.add_argument("--push-attempts", type=int, default=4) + parser.add_argument("--push-base-delay", type=float, default=5.0) + parser.add_argument("--rpc-poll-attempts", type=int, default=12) + parser.add_argument("--rpc-poll-delay", type=float, default=5.0) + parser.add_argument("--report-json", type=Path) + parser.add_argument("--summary", type=Path) + args = parser.parse_args(argv) + if args.assets_dir is not None and args.manifest is None: + parser.error("--assets-dir requires --manifest") + if args.render_only and args.output_dir is None: + parser.error("--render-only requires --output-dir") + if args.publish and args.render_only: + parser.error("--publish and --render-only are mutually exclusive") + return args + + +EXIT_CODES = { + Classification.NOOP: 0, + Classification.UPDATED: 0, + Classification.TRANSIENT: 75, + Classification.PERMANENT: 2, + Classification.REFUSED_DOWNGRADE: 3, +} + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + report: dict[str, Any] = { + "schema": 1, + "package": AUR_PACKAGE, + "trigger": args.trigger, + "classification": Classification.PERMANENT.value, + "action": None, + "desired_version": None, + "observed_rpc_version": None, + "observed_git_version": None, + "manifest_sha256": None, + "asset_sha256": {}, + "remote_commit": None, + "push_retries": 0, + "rpc_polls": 0, + "drift_age_seconds": None, + "needs_publish": False, + "published": False, + "message": None, + } + failure: ReconcileFailure | None = None + runner = CommandRunner() + try: + if args.work_dir is not None: + args.work_dir.mkdir(parents=True, exist_ok=True) + reconcile_once(args, runner=runner, work_dir=args.work_dir, report=report) + else: + with tempfile.TemporaryDirectory(prefix="mcpp-aur-reconcile-") as raw: + reconcile_once(args, runner=runner, work_dir=Path(raw), report=report) + except RenderError as exc: + failure = ReconcileFailure(str(exc), Classification.PERMANENT) + except ReconcileFailure as exc: + failure = exc + except Exception as exc: # defensive boundary: always emit classification/report + failure = ReconcileFailure( + f"unexpected reconciler failure: {type(exc).__name__}: {exc}", + Classification.PERMANENT, + ) + + if failure is not None: + report["classification"] = failure.classification.value + report["message"] = str(failure) + try: + write_report(report, args.report_json, args.summary) + except OSError as exc: + print(f"cannot write AUR reconciliation report: {exc}", file=sys.stderr) + return EXIT_CODES[Classification.PERMANENT] + print(json.dumps(report, sort_keys=True)) + classification = Classification(report["classification"]) + if failure is not None: + print(f"mcpp-bin reconcile error: {failure}", file=sys.stderr) + return EXIT_CODES[classification] + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/aur/render_mcpp_bin.py b/scripts/aur/render_mcpp_bin.py new file mode 100755 index 00000000..d7f769c4 --- /dev/null +++ b/scripts/aur/render_mcpp_bin.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +"""Render the mcpp-bin AUR package from an immutable release manifest.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any, NoReturn + + +VERSION_RE = re.compile(r"[0-9][0-9A-Za-z.+-]*\Z") +COMMIT_RE = re.compile(r"(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})\Z") +TOKEN_RE = re.compile(r"[A-Za-z0-9_]+\Z") +SHA256_RE = re.compile(r"[0-9a-fA-F]{64}\Z") + + +class RenderError(RuntimeError): + """The release manifest or package template violates its contract.""" + + +def fail(message: str) -> NoReturn: + raise RenderError(message) + + +@dataclass(frozen=True) +class ReleaseAsset: + platform: str + arch: str + name: str + sha256: str + + +@dataclass(frozen=True) +class DesiredState: + schema: int + version: str + tag: str + commit: str + assets: tuple[ReleaseAsset, ...] + manifest_sha256: str + + @property + def package_version(self) -> str: + return f"{self.version}-1" + + def asset(self, platform: str, arch: str) -> ReleaseAsset: + matches = [ + asset + for asset in self.assets + if asset.platform == platform and asset.arch == arch + ] + if len(matches) != 1: + fail( + f"manifest requires exactly one {platform}/{arch} asset, " + f"found {len(matches)}" + ) + return matches[0] + + +def _string_field(value: Any, field: str) -> str: + if not isinstance(value, str) or not value: + fail(f"manifest field {field!r} must be a non-empty string") + return value + + +def load_desired_state(path: Path) -> DesiredState: + try: + raw_bytes = path.read_bytes() + manifest = json.loads(raw_bytes) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + fail(f"cannot read release manifest {path}: {exc}") + if not isinstance(manifest, dict): + fail("release manifest root must be an object") + schema = manifest.get("schema") + if type(schema) is not int or schema != 1: + fail(f"unsupported release manifest schema: {schema!r}") + + version = _string_field(manifest.get("version"), "version") + tag = _string_field(manifest.get("tag"), "tag") + commit = _string_field(manifest.get("commit"), "commit") + if VERSION_RE.fullmatch(version) is None: + fail(f"invalid manifest version: {version!r}") + if tag != f"v{version}": + fail(f"manifest tag/version mismatch: {tag!r} versus {version!r}") + if COMMIT_RE.fullmatch(commit) is None: + fail("manifest commit must be a full 40- or 64-hex object ID") + + raw_assets = manifest.get("assets") + if not isinstance(raw_assets, list): + fail("manifest assets must be an array") + assets: list[ReleaseAsset] = [] + identities: set[tuple[str, str]] = set() + names: set[str] = set() + for index, row in enumerate(raw_assets): + if not isinstance(row, dict): + fail(f"manifest asset #{index} must be an object") + platform = _string_field(row.get("platform"), f"assets[{index}].platform") + arch = _string_field(row.get("arch"), f"assets[{index}].arch") + name = _string_field(row.get("name"), f"assets[{index}].name") + digest = _string_field(row.get("sha256"), f"assets[{index}].sha256") + if TOKEN_RE.fullmatch(platform) is None or TOKEN_RE.fullmatch(arch) is None: + fail(f"invalid platform/arch token in manifest asset #{index}") + if Path(name).name != name: + fail(f"manifest asset name must be a basename: {name!r}") + if SHA256_RE.fullmatch(digest) is None: + fail(f"invalid SHA256 in manifest asset #{index}") + identity = (platform, arch) + if identity in identities: + fail(f"duplicate manifest platform/arch: {platform}/{arch}") + if name in names: + fail(f"duplicate manifest asset name: {name}") + identities.add(identity) + names.add(name) + assets.append(ReleaseAsset(platform, arch, name, digest.lower())) + + desired = DesiredState( + schema=schema, + version=version, + tag=tag, + commit=commit.lower(), + assets=tuple(assets), + manifest_sha256=hashlib.sha256(raw_bytes).hexdigest(), + ) + for arch in ("x86_64", "aarch64"): + asset = desired.asset("linux", arch) + expected_name = f"mcpp-{version}-linux-{arch}.tar.gz" + if asset.name != expected_name: + fail( + f"manifest Linux {arch} asset must be {expected_name!r}, " + f"got {asset.name!r}" + ) + return desired + + +def _replace_once(text: str, pattern: str, replacement: str, label: str) -> str: + rendered, count = re.subn(pattern, replacement, text, count=1, flags=re.MULTILINE) + if count != 1: + fail(f"mcpp-bin PKGBUILD must contain exactly one {label}") + return rendered + + +def render_pkgbuild(template: str, desired: DesiredState) -> str: + x86 = desired.asset("linux", "x86_64") + arm = desired.asset("linux", "aarch64") + rendered = _replace_once( + template, r"^pkgver=.*$", f"pkgver={desired.version}", "pkgver" + ) + rendered = _replace_once(rendered, r"^pkgrel=.*$", "pkgrel=1", "pkgrel") + rendered = _replace_once( + rendered, + r"^sha256sums_x86_64=.*$", + f"sha256sums_x86_64=('{x86.sha256}')", + "sha256sums_x86_64", + ) + rendered = _replace_once( + rendered, + r"^sha256sums_aarch64=.*$", + f"sha256sums_aarch64=('{arm.sha256}')", + "sha256sums_aarch64", + ) + return rendered + + +def materialize_package( + *, desired: DesiredState, template_dir: Path, output_dir: Path +) -> None: + if template_dir.name != "mcpp-bin": + fail(f"refusing non-mcpp-bin template directory: {template_dir}") + pkgbuild_path = template_dir / "PKGBUILD" + launcher_path = template_dir / "mcpp.sh" + try: + template = pkgbuild_path.read_text(encoding="utf-8") + launcher = launcher_path.read_bytes() + output_dir.mkdir(parents=True, exist_ok=True) + (output_dir / "PKGBUILD").write_text( + render_pkgbuild(template, desired), encoding="utf-8" + ) + (output_dir / "mcpp.sh").write_bytes(launcher) + except OSError as exc: + fail(f"cannot materialize mcpp-bin package: {exc}") + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + default_template = Path(__file__).resolve().parent / "mcpp-bin" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--manifest", required=True, type=Path) + parser.add_argument("--template-dir", type=Path, default=default_template) + parser.add_argument("--output-dir", required=True, type=Path) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + try: + desired = load_desired_state(args.manifest) + materialize_package( + desired=desired, + template_dir=args.template_dir, + output_dir=args.output_dir, + ) + except RenderError as exc: + print(f"mcpp-bin render error: {exc}", file=sys.stderr) + return 2 + print( + json.dumps( + { + "version": desired.version, + "package_version": desired.package_version, + "manifest_sha256": desired.manifest_sha256, + "output_dir": str(args.output_dir), + }, + sort_keys=True, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/aur/update.sh b/scripts/aur/update.sh index a48452ec..330ca010 100755 --- a/scripts/aur/update.sh +++ b/scripts/aur/update.sh @@ -1,136 +1,38 @@ #!/usr/bin/env bash -# scripts/aur/update.sh — bump BOTH AUR packages to a release version. +# Compatibility entrypoint for rendering the mcpp-bin AUR package. # -# mcpp-bin/ prebuilt release binaries (per-arch tarball checksums) -# mcpp-m/ built from source via mcpp-bin (source-archive checksum) -# -# Pulls checksums straight from the GitHub release / archive, rewrites each -# PKGBUILD's pkgver + sums, resets pkgrel to 1, regenerates both .SRCINFO -# files, and keeps the shared mcpp.sh wrapper in sync across both dirs. -# Run after a release is published, then publish each package (see README.md). -# -# Usage: -# scripts/aur/update.sh [VERSION] # default: [package].version from mcpp.toml +# Desired state comes only from the latest complete stable release's immutable +# mcpp-release.json. An optional VERSION or vVERSION must match that exact +# release; this wrapper has no downgrade override and publishes nothing. set -euo pipefail -cd "$(dirname "$0")" -REPO="mcpp-community/mcpp" -ARCHES=(linux-x86_64 linux-aarch64) +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -# Resolve version: explicit arg, else mcpp.toml at repo root. -if [[ $# -ge 1 ]]; then - VER="$1" -else - VER=$(grep -m1 -E '^\s*version\s*=' ../../mcpp.toml | sed -E 's/.*"([^"]+)".*/\1/') +if (( $# > 1 )); then + echo "usage: scripts/aur/update.sh [VERSION|vVERSION]" >&2 + exit 2 fi -[[ -n "$VER" ]] || { echo "error: could not determine version" >&2; exit 1; } -echo ":: targeting mcpp v${VER}" - -reldl="https://github.com/${REPO}/releases/download/v${VER}" -archive_url="https://github.com/${REPO}/archive/v${VER}.tar.gz" - -# --- prebuilt binary checksums (mcpp-bin) ---------------------------------- -declare -A SUMS -for plat in "${ARCHES[@]}"; do - url="${reldl}/mcpp-${VER}-${plat}.tar.gz.sha256" - echo ":: fetching ${url}" - line=$(curl -fsSL --connect-timeout 15 "$url") \ - || { echo "error: cannot fetch sha256 for ${plat} (is v${VER} released?)" >&2; exit 1; } - SUMS[$plat]=$(awk '{print $1}' <<<"$line") - [[ -n "${SUMS[$plat]}" ]] || { echo "error: empty sha256 for ${plat}" >&2; exit 1; } -done -x86=${SUMS[linux-x86_64]} -arm=${SUMS[linux-aarch64]} - -# --- source-archive checksum (mcpp-m) ---------------------------------------- -echo ":: hashing source archive ${archive_url}" -src=$(curl -fsSL --connect-timeout 30 "$archive_url" | sha256sum | awk '{print $1}') -[[ -n "$src" ]] || { echo "error: cannot hash source archive" >&2; exit 1; } -# --- keep the wrapper in sync ---------------------------------------------- -cp -f mcpp-bin/mcpp.sh mcpp-m/mcpp.sh 2>/dev/null || true +args=( + --render-only + --output-dir "$SCRIPT_DIR/mcpp-bin" + --trigger local-update +) -# --- rewrite mcpp-bin/PKGBUILD --------------------------------------------- -sed -i -E \ - -e "s/^pkgver=.*/pkgver=${VER}/" \ - -e "s/^pkgrel=.*/pkgrel=1/" \ - -e "s/^sha256sums_x86_64=\('[^']*'\)/sha256sums_x86_64=('${x86}')/" \ - -e "s/^sha256sums_aarch64=\('[^']*'\)/sha256sums_aarch64=('${arm}')/" \ - mcpp-bin/PKGBUILD - -# --- rewrite mcpp-m/PKGBUILD --------------------------------------------- -sed -i -E \ - -e "s/^pkgver=.*/pkgver=${VER}/" \ - -e "s/^pkgrel=.*/pkgrel=1/" \ - -e "s/^sha256sums=\('[^']*'\)/sha256sums=('${src}')/" \ - mcpp-m/PKGBUILD - -# --- regenerate .SRCINFO files --------------------------------------------- -# Prefer makepkg's own generator on an Arch host; fall back to templates so -# this also works when bumping from a non-Arch machine (e.g. CI / dev box). -_srcinfo() { # $1 = package dir - # CI runs as root where makepkg refuses to run; MCPP_AUR_NO_MAKEPKG=1 - # forces the template path (byte-identical output, no makepkg needed). - if [[ -z "${MCPP_AUR_NO_MAKEPKG:-}" ]] && command -v makepkg >/dev/null 2>&1; then - ( cd "$1" && makepkg --printsrcinfo > .SRCINFO ) - return - fi - case "$1" in - mcpp-bin) - cat > mcpp-bin/.SRCINFO < mcpp-m/.SRCINFO <&2 + exit 2 + } + args+=(--manifest "$MCPP_AUR_MANIFEST" --assets-dir "$MCPP_AUR_ASSETS_DIR") +fi -echo ":: updated to v${VER}" -echo " mcpp-bin x86_64 ${x86}" -echo " mcpp-bin aarch64 ${arm}" -echo " mcpp-m source ${src}" -echo ":: review, then publish each package (see scripts/aur/README.md)" +exec python3 "$SCRIPT_DIR/reconcile_mcpp_bin.py" "${args[@]}" diff --git a/scripts/release/generate_manifest.py b/scripts/release/generate_manifest.py new file mode 100644 index 00000000..0446dfae --- /dev/null +++ b/scripts/release/generate_manifest.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""Generate and validate the immutable mcpp GitHub release manifest.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +from pathlib import Path +from typing import Any, NoReturn + + +SCHEMA_VERSION = 1 +COMMIT_RE = re.compile(r"(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})\Z") +VERSION_RE = re.compile(r"[0-9][0-9A-Za-z.+-]*\Z") +PRIMARY_ASSET_RE = re.compile( + r"^mcpp-(?P[0-9][0-9A-Za-z.+-]*)-" + r"(?P[A-Za-z][A-Za-z0-9_]*)-" + r"(?P[A-Za-z0-9_]+)\." + r"(?:tar\.gz|zip)$" +) +SIDECAR_RE = re.compile(r"^(?P[0-9a-fA-F]{64})[ \t]+\*?(?P\S+)$") + + +class ManifestError(RuntimeError): + """A release violates the public manifest contract.""" + + +def fail(message: str) -> NoReturn: + raise ManifestError(message) + + +def load_release(path: Path) -> dict[str, Any]: + try: + release = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + fail(f"cannot read release JSON {path}: {exc}") + if not isinstance(release, dict): + fail("release JSON root must be an object") + return release + + +def release_asset_names(release: dict[str, Any]) -> set[str]: + raw_assets = release.get("assets") + if not isinstance(raw_assets, list): + fail("release JSON assets must be an array") + + names: set[str] = set() + for index, raw_asset in enumerate(raw_assets): + if not isinstance(raw_asset, dict): + fail(f"release asset #{index} must be an object") + name = raw_asset.get("name") + if not isinstance(name, str) or not name: + fail(f"release asset #{index} has no valid name") + if name in names: + fail(f"duplicate release asset name: {name}") + names.add(name) + return names + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + try: + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + except OSError as exc: + fail(f"cannot read release asset {path}: {exc}") + return digest.hexdigest() + + +def read_sidecar(path: Path, primary_name: str) -> str: + try: + lines = [ + line.strip() + for line in path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + except (OSError, UnicodeError) as exc: + fail(f"cannot read sidecar {path}: {exc}") + if len(lines) != 1: + fail(f"sidecar must contain exactly one checksum record: {path.name}") + match = SIDECAR_RE.fullmatch(lines[0]) + if match is None: + fail(f"invalid SHA256 sidecar syntax: {path.name}") + if match.group("name") != primary_name: + fail( + f"sidecar filename target mismatch: {path.name} names " + f"{match.group('name')!r}, expected {primary_name!r}" + ) + return match.group("sha256").lower() + + +def required_primary_assets(version: str) -> set[str]: + return { + f"mcpp-{version}-linux-x86_64.tar.gz", + f"mcpp-{version}-linux-aarch64.tar.gz", + f"mcpp-{version}-macosx-arm64.tar.gz", + f"mcpp-{version}-windows-x86_64.zip", + } + + +def generate_manifest( + *, + release: dict[str, Any], + assets_dir: Path, + version: str, + tag: str, + commit: str, +) -> dict[str, Any]: + if VERSION_RE.fullmatch(version) is None: + fail(f"invalid version: {version!r}") + if tag != f"v{version}": + fail(f"tag/version mismatch: expected v{version!s}, got {tag!r}") + if COMMIT_RE.fullmatch(commit) is None: + fail("commit must be a full 40- or 64-hex object ID") + if release.get("tag_name") != tag: + fail( + f"release tag mismatch: release has {release.get('tag_name')!r}, " + f"expected {tag!r}" + ) + if release.get("draft") is not False: + fail("release must have draft=false") + if release.get("prerelease") is not False: + fail("release must have prerelease=false") + + names = release_asset_names(release) + discovered: list[tuple[str, re.Match[str]]] = [] + for name in sorted(names): + match = PRIMARY_ASSET_RE.fullmatch(name) + if match is None: + continue + if match.group("version") != version: + fail( + f"asset version mismatch: {name!r} has " + f"{match.group('version')!r}, expected {version!r}" + ) + discovered.append((name, match)) + + required = required_primary_assets(version) + missing = sorted(required - {name for name, _ in discovered}) + if missing: + fail(f"missing required primary asset(s): {', '.join(missing)}") + + manifest_assets: list[dict[str, str]] = [] + identities: dict[tuple[str, str], str] = {} + for name, match in discovered: + platform = "macos" if match.group("platform") == "macosx" else match.group("platform") + arch = match.group("arch") + identity = (platform, arch) + if identity in identities: + fail( + f"duplicate platform/arch {platform}/{arch}: " + f"{identities[identity]} and {name}" + ) + identities[identity] = name + + sidecar_name = f"{name}.sha256" + if sidecar_name not in names: + fail(f"missing sidecar release asset: {sidecar_name}") + primary_path = assets_dir / name + sidecar_path = assets_dir / sidecar_name + if not primary_path.is_file(): + fail(f"missing downloaded primary asset: {primary_path}") + if not sidecar_path.is_file(): + fail(f"missing downloaded sidecar: {sidecar_path}") + + expected_digest = read_sidecar(sidecar_path, name) + actual_digest = sha256_file(primary_path) + if actual_digest != expected_digest: + fail( + f"SHA256 mismatch for {name}: sidecar has {expected_digest}, " + f"payload has {actual_digest}" + ) + manifest_assets.append( + { + "platform": platform, + "arch": arch, + "name": name, + "sha256": actual_digest, + } + ) + + manifest_assets.sort(key=lambda item: (item["platform"], item["arch"], item["name"])) + return { + "schema": SCHEMA_VERSION, + "version": version, + "tag": tag, + "commit": commit.lower(), + "assets": manifest_assets, + } + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--release-json", required=True, type=Path) + parser.add_argument("--assets-dir", required=True, type=Path) + parser.add_argument("--version", required=True) + parser.add_argument("--tag", required=True) + parser.add_argument("--commit", required=True) + parser.add_argument("--output", required=True, type=Path) + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + try: + release = load_release(args.release_json) + manifest = generate_manifest( + release=release, + assets_dir=args.assets_dir, + version=args.version, + tag=args.tag, + commit=args.commit, + ) + rendered = json.dumps(manifest, indent=2, ensure_ascii=False) + "\n" + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(rendered, encoding="utf-8") + except (ManifestError, OSError) as exc: + print(f"release manifest error: {exc}", file=sys.stderr) + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/build/execute.cppm b/src/build/execute.cppm index 960c45d4..48192537 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -15,6 +15,7 @@ import mcpp.diag; import mcpp.build.plan; import mcpp.build.backend; import mcpp.build.ninja; +import mcpp.build.runtime_validation; import mcpp.bmi_cache; import mcpp.manifest; import mcpp.modgraph.scanner; @@ -22,6 +23,7 @@ import mcpp.toolchain.post_install; import mcpp.toolchain.stdmod; import mcpp.xlings; import mcpp.xlings.subos_info; +import mcpp.platform.runtime_binding; import mcpp.log; import mcpp.platform; import mcpp.fetcher.progress; @@ -88,6 +90,9 @@ struct BuildCacheEntry { // said not to use — and ruling the cache out is `local`'s entire purpose. // Same back-compat contract as `profile`: empty ⇒ miss. std::string cacheMode; + // Exact immutable snapshot used by the build. Optional distinguishes a + // current cache from one written by an older mcpp (or a corrupt payload). + std::optional runtimeBinding; }; std::vector read_build_cache(const std::filesystem::path& projectRoot) { @@ -164,6 +169,12 @@ std::vector read_build_cache(const std::filesystem::path& proje e.subosRecorded = true; haveNextLine = static_cast(std::getline(f, line)); } + if (haveNextLine && line.starts_with("runtimeBinding=")) { + auto decoded = mcpp::platform::runtime::deserialize_runtime_binding( + line.substr(15)); + if (decoded) e.runtimeBinding = std::move(*decoded); + haveNextLine = static_cast(std::getline(f, line)); + } // Optional profile line. Same back-compat contract as the two blocks // above: absent ⇒ e.profile stays empty ⇒ every fast path treats the // entry as a miss and falls through to prepare_build. @@ -193,7 +204,7 @@ void write_build_cache(const std::filesystem::path& projectRoot, const std::string& runEnvValue = "", const std::string& profile = "", const std::string& cacheMode = "", - const std::string& subosDir = "") { + const mcpp::platform::runtime::RuntimeBinding& runtimeBinding = {}) { auto path = projectRoot / kBuildCacheFile; auto entries = read_build_cache(projectRoot); @@ -208,8 +219,10 @@ void write_build_cache(const std::filesystem::path& projectRoot, // Insert at front (MRU). BuildCacheEntry newEntry{targetTriple, outputDir.string(), ninjaProgram, fingerprintHex, runtimeEnvKey, runtimeEnvValue, std::move(runTargets), - runEnvKey, runEnvValue, subosDir, /*subosRecorded=*/true, + runEnvKey, runEnvValue, runtimeBinding.subosDir.string(), + /*subosRecorded=*/true, profile, cacheMode}; + newEntry.runtimeBinding = runtimeBinding; entries.insert(entries.begin(), std::move(newEntry)); // Trim to LRU capacity. @@ -238,6 +251,10 @@ void write_build_cache(const std::filesystem::path& projectRoot, f << "runEnv=" << e.runEnvKey << '\n'; f << e.runEnvValue << '\n'; f << "subos=" << e.subosDir << '\n'; + if (e.runtimeBinding) + f << "runtimeBinding=" + << mcpp::platform::runtime::serialize_runtime_binding(*e.runtimeBinding) + << '\n'; f << "profile=" << e.profile << '\n'; f << "cacheMode=" << e.cacheMode << '\n'; } @@ -315,8 +332,7 @@ compute_run_env(const mcpp::build::BuildPlan& plan) { return {key, value}; } -// The environment the active subos declares for the programs it hosts -// (mcpp#352). +// The environment captured by this build's selected RuntimeBinding (mcpp#352). // // A GL application needs three things and mcpp only ever supplied two: the // binary links (bootstrap), it finds its libraries (RPATH), and then it has to @@ -326,46 +342,18 @@ compute_run_env(const mcpp::build::BuildPlan& plan) { // launched — `xlings subos use` applied them, `mcpp run` did not. Hence a // binary that links fine and exits 255 with no output. // -// Resolved at RUN time, deliberately not cached with the build: these values -// belong to the subos, not to the build, and a user who switches subos between -// `mcpp build` and `mcpp run` must get the new one. It is a file read. +// The declarations are snapshotted with the build and serialized into the +// fast-path cache. A changed SubOS manifest invalidates that cache and causes a +// fresh prepare; no invocation mixes newly read run state with old objects. // // mcpp does not know what any of these variables MEAN, and that is the design: // when the ecosystem gains a Vulkan loader or a new driver bridge, the // declaration changes and this code does not. -// The subos a RUN should use: an explicit override if the caller set one, -// otherwise the subos this build belongs to. -// -// The override lives HERE and not in the derivation, because the derivation's -// answer is cached and this one must not be: MCPP_SUBOS_DIR says "for this -// invocation". It exists so tests can exercise this path without touching a -// developer's real subos — an earlier e2e wrote through a symlink and -// permanently broke a real toolchain — and so a user can point one run at -// another subos without switching the active one. -std::filesystem::path subos_dir_for_run(const std::filesystem::path& buildSubos) { - if (const char* e = std::getenv("MCPP_SUBOS_DIR"); e && *e) - return std::filesystem::path(e); - return buildSubos; -} - std::vector> compute_subos_env(const mcpp::build::BuildPlan& plan) { - auto built = mcpp::xlings::paths::subos_dir_of(plan.toolchain.binaryPath); - auto dir = subos_dir_for_run(built ? *built : std::filesystem::path{}); - if (dir.empty()) return {}; - auto info = mcpp::xlings::subos::read(dir); - // The note is a `verbose` line rather than a warning: a subos with no - // self-description is the normal state of every machine whose subos - // predates the block, and a warning on every run would train people to - // ignore it. It becomes loud only where it explains a failure — the GL - // diagnostic path in doctor. - if (!info.note.empty()) - mcpp::log::verbose("subos", info.note); - // Resolved AGAINST the caller's environment, not in a vacuum: these - // entries replace the variable in the child, so a `set` that ignores an - // exported value overwrites it and a `prepend` that ignores it drops it. - return mcpp::xlings::subos::resolve_env( - info, dir, [](std::string_view v) -> std::optional { + return mcpp::platform::runtime::resolve_runtime_environment( + plan.runtimeBinding, + [](std::string_view v) -> std::optional { if (const char* e = std::getenv(std::string(v).c_str())) return std::string(e); return std::nullopt; @@ -500,14 +488,13 @@ export int run_build_plan(BuildContext& ctx, bool verbose, bool no_cache, auto fpHex = ctx.outputDir.filename().string(); auto runTargets = compute_run_targets(ctx.plan); auto [runEnvKey, runEnvValue] = compute_run_env(ctx.plan); - auto subosDir = mcpp::xlings::paths::subos_dir_of(ctx.plan.toolchain.binaryPath); write_build_cache(ctx.projectRoot, ctx.outputDir, r->ninjaProgram, std::string(targetOverride), fpHex, r->runtimeEnvKey.empty() ? "-" : r->runtimeEnvKey, r->runtimeEnvValue, std::move(runTargets), runEnvKey, runEnvValue, ctx.profile, std::string(cache_mode_name(ctx.cacheMode)), - subosDir ? subosDir->string() : std::string{}); + ctx.plan.runtimeBinding); } // The one place the --strict policy is settled. Degradations reported by @@ -724,6 +711,7 @@ export std::optional try_fast_build(const std::filesystem::path& projectRoo } } if (!match) return std::nullopt; + if (!match->runtimeBinding) return std::nullopt; auto outputDirStr = match->outputDir; auto ninjaProgram = match->ninjaProgram; @@ -754,6 +742,10 @@ export std::optional try_fast_build(const std::filesystem::path& projectRoo auto ninjaTime = std::filesystem::last_write_time(ninjaPath, ec); if (ec) return std::nullopt; + auto runtimeManifest = match->runtimeBinding->subosDir / ".xlings.json"; + auto runtimeTime = std::filesystem::last_write_time(runtimeManifest, ec); + if (ec || runtimeTime > ninjaTime) return std::nullopt; + // Check mcpp.toml auto tomlPath = projectRoot / "mcpp.toml"; auto tomlTime = std::filesystem::last_write_time(tomlPath, ec); @@ -763,12 +755,20 @@ export std::optional try_fast_build(const std::filesystem::path& projectRoo // instead of a hand-rolled recursive_directory_iterator over src/. if (sources_newer_than(projectRoot, ninjaTime, want->resourceScripts)) return std::nullopt; + auto validatedBefore = + mcpp::build::runtime_validation::validated_artifact_snapshot( + outputDir, *match->runtimeBinding); + if (!validatedBefore) return std::nullopt; + // All inputs are older than build.ninja → fast-path: just run ninja. std::chrono::milliseconds elapsed{}; auto rc = run_ninja_fast(ninjaProgram, outputDir, ninjaPath, verbose, runtimeEnvKey, runtimeEnvValue, &elapsed); if (!rc) return std::nullopt; if (*rc != 0) return rc; + if (!mcpp::build::runtime_validation::artifact_snapshot_unchanged( + *validatedBefore)) + return std::nullopt; // relinked: full path reconstructs + validates closure mcpp::ui::finished(want->profile, elapsed); return 0; @@ -817,8 +817,8 @@ std::optional try_fast_run(const std::filesystem::path& projectRoot, // mcpp wrote the entry, so without this line an upgraded mcpp would reuse a // pre-upgrade build until something else happened to invalidate it. Measured // on a real upgrade from 2026.8.7.1, not reasoned about. - if (!match->subosRecorded) - return std::nullopt; // predates `subos=`; rebuild once, then it is there + if (!match->runtimeBinding) + return std::nullopt; // predates the immutable snapshot; rebuild once // P1: verify fingerprint matches the outputDir basename. if (!match->fingerprint.empty()) { @@ -844,18 +844,30 @@ std::optional try_fast_run(const std::filesystem::path& projectRoot, auto ninjaTime = std::filesystem::last_write_time(ninjaPath, ec); if (ec) return std::nullopt; + auto runtimeManifest = match->runtimeBinding->subosDir / ".xlings.json"; + auto runtimeTime = std::filesystem::last_write_time(runtimeManifest, ec); + if (ec || runtimeTime > ninjaTime) return std::nullopt; + auto tomlPath = projectRoot / "mcpp.toml"; auto tomlTime = std::filesystem::last_write_time(tomlPath, ec); if (ec || tomlTime > ninjaTime) return std::nullopt; if (sources_newer_than(projectRoot, ninjaTime, want->resourceScripts)) return std::nullopt; + auto validatedBefore = + mcpp::build::runtime_validation::validated_artifact_snapshot( + outputDir, *match->runtimeBinding); + if (!validatedBefore) return std::nullopt; + // Fresh → run ninja (picks up any incremental object/link work) then // exec the cached exe path directly. auto rc = run_ninja_fast(ninjaProgram, outputDir, ninjaPath, /*verbose=*/false, match->runtimeEnvKey, match->runtimeEnvValue); if (!rc) return std::nullopt; if (*rc != 0) return rc; + if (!mcpp::build::runtime_validation::artifact_snapshot_unchanged( + *validatedBefore)) + return std::nullopt; // never execute an artifact not validated for this binding auto exe = outputDir / chosen->second; auto pathCtx = mcpp::fetcher::make_path_ctx(/*cfg=*/nullptr, projectRoot); @@ -870,31 +882,18 @@ std::optional try_fast_run(const std::filesystem::path& projectRoot, std::vector> childEnv; if (!match->runEnvKey.empty() && !match->runEnvValue.empty()) childEnv.emplace_back(match->runEnvKey, match->runEnvValue); - // ...and the subos's declared environment, re-READ here rather than taken - // from the cache. Which subos is a build property (cached above); what it - // declares is the subos's own, and a user who installs a graphics stack - // between two runs must get it without rebuilding. - // - // This is the half that a fast path is most likely to lose, and losing it - // would be invisible in the worst way: the first `mcpp run` after a build - // takes the full path and works, every later one takes this path and does - // not. A GL program would run once and then stop finding its driver. - { - // Same rule as the full path, through the same helper: an override - // for this invocation, else the subos this build was recorded against. - auto subosDir = subos_dir_for_run(std::filesystem::path(match->subosDir)); - if (!subosDir.empty()) { - auto info = mcpp::xlings::subos::read(subosDir); - for (auto& kv : mcpp::xlings::subos::resolve_env( - info, subosDir, - [](std::string_view v) -> std::optional { - if (const char* e = std::getenv(std::string(v).c_str())) - return std::string(e); - return std::nullopt; - })) - childEnv.push_back(std::move(kv)); - } - } + // ...and exactly the environment declaration snapshot used by the build. + // Installing/changing a provider invalidates the fast path via the SubOS + // manifest mtime check; this invocation never mixes a new run contract + // with objects built under the old one. + for (auto& kv : mcpp::platform::runtime::resolve_runtime_environment( + *match->runtimeBinding, + [](std::string_view v) -> std::optional { + if (const char* e = std::getenv(std::string(v).c_str())) + return std::string(e); + return std::nullopt; + })) + childEnv.push_back(std::move(kv)); return mcpp::platform::process::run_exec(argv, childEnv) == 0 ? 0 : 1; } diff --git a/src/build/flags.cppm b/src/build/flags.cppm index d2aed189..ac025eba 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -14,6 +14,7 @@ export module mcpp.build.flags; import std; import mcpp.build.distribution; import mcpp.build.plan; +import mcpp.manifest.types; import mcpp.modgraph.scanner; import mcpp.platform; import mcpp.toolchain.clang; @@ -64,6 +65,15 @@ struct CompileFlags { } }; +enum class LinkIntentFlavor { Elf, MachO, PeGnu, PeMsvc }; + +// Spell a provider-neutral LinkIntent for one output format. Kept pure so +// every platform contract can be asserted on every CI host. deployFiles are +// intentionally absent: the backend emits copy edges, never linker flags. +std::string render_link_intent_flags( + const mcpp::manifest::LinkIntent& intent, + LinkIntentFlavor flavor); + CompileFlags compute_flags(const BuildPlan& plan); // The kind → role map. One line of policy, in one place: a test binary runs on @@ -268,6 +278,62 @@ std::string shell_quote_arg(std::string_view arg) { } } +std::string render_link_intent_flags( + const mcpp::manifest::LinkIntent& intent, + LinkIntentFlavor flavor) { + std::string out; + auto token = [](std::string value) { + return shell_quote_arg(escape_ninja_chars(value)); + }; + auto path_token = [&](std::string_view prefix, + const std::filesystem::path& path) { + return token(std::string(prefix) + path.string()); + }; + + for (auto const& dir : intent.linkLibraryDirs) { + out += ' '; + out += path_token(flavor == LinkIntentFlavor::PeMsvc + ? "/LIBPATH:" : "-L", dir); + } + if (flavor == LinkIntentFlavor::Elf) { + for (auto const& dir : intent.transitiveNeededDirs) { + out += ' '; + out += path_token("-Wl,-rpath-link,", dir); + } + } + if (flavor == LinkIntentFlavor::Elf + || flavor == LinkIntentFlavor::MachO) { + for (auto const& dir : intent.runtimeSearchDirs) { + out += ' '; + out += path_token("-Wl,-rpath,", dir); + } + } + + for (auto const& library : intent.libraries) { + if (library.empty()) continue; + out += ' '; + const std::filesystem::path asPath(library); + const bool explicitToken = library.starts_with('-') + || library.starts_with('/') || asPath.has_parent_path() + || asPath.has_extension(); + if (explicitToken) { + out += token(library); + } else if (flavor == LinkIntentFlavor::PeMsvc) { + out += token(library + ".lib"); + } else { + out += token("-l" + library); + } + } + if (flavor == LinkIntentFlavor::MachO) { + for (auto const& framework : intent.frameworks) { + if (framework.empty()) continue; + out += " -framework "; + out += token(framework); + } + } + return out; +} + CompileFlags compute_flags(const BuildPlan& plan) { CompileFlags f; @@ -415,6 +481,28 @@ CompileFlags compute_flags(const BuildPlan& plan) { // is only assemblable by its own x86_64-w64-mingw32-as. bool isMuslTc = mcpp::toolchain::is_musl_target(plan.toolchain); bool isMingwTc = mcpp::toolchain::is_mingw_target(plan.toolchain); + const auto linkIntentFlavor = [&] { + if (isMingwTc) return LinkIntentFlavor::PeGnu; + if (isMsvcDialect) return LinkIntentFlavor::PeMsvc; + auto triple = plan.toolchain.targetTriple; + std::ranges::transform(triple, triple.begin(), + [](unsigned char c) { return std::tolower(c); }); + if (triple.find("darwin") != std::string::npos + || triple.find("apple") != std::string::npos) + return LinkIntentFlavor::MachO; + if (triple.find("windows") != std::string::npos + || triple.find("mingw") != std::string::npos) + return LinkIntentFlavor::PeGnu; + if (triple.empty()) { + if constexpr (mcpp::platform::is_windows) + return LinkIntentFlavor::PeGnu; + if constexpr (mcpp::platform::needs_explicit_libcxx) + return LinkIntentFlavor::MachO; + } + return LinkIntentFlavor::Elf; + }(); + const std::string link_intent_ld = + render_link_intent_flags(plan.linkIntent, linkIntentFlavor); std::filesystem::path binutilsBin; if (!isMuslTc && !isMingwTc && caps.stdlib_id == "libstdc++") { auto ar = mcpp::toolchain::archive_tool(plan.toolchain); @@ -713,14 +801,17 @@ CompileFlags compute_flags(const BuildPlan& plan) { runtime_dirs += " -L" + escape_path(dir); runtime_dirs += " -Wl,-rpath," + escape_path(dir); } - // ...plus dependency packages' [runtime] library_dirs (e.g. - // compat.glx-runtime's host-GL passthrough), so dlopen()'d host libs - // (libGL/libGLX) are reachable at run time. Only the dep dirs — NOT the - // glibc payload dir — so static/musl links stay clean. - for (auto& dir : plan.depRuntimeLibraryDirs) { - runtime_dirs += " -L" + escape_path(dir); - runtime_dirs += " -Wl,-rpath," + escape_path(dir); - } + // Dependency/runtime provider search comes from LinkIntent and is + // rendered separately. In particular runtimeSearchDirs contributes + // RUNPATH only; it must never become a link-time -L path. + // + // The private libc directory is NOT emitted here: the link model + // already puts it in the artifact's RUNPATH wherever a payload exists + // (`-L -Wl,-rpath,` next to --dynamic-linker), and this + // link line has a hard 128KiB ceiling that real workspaces already + // spend 43% of. `208_private_libc_stays_in_the_binary.sh` asserts that + // coverage, so if a toolchain ever stops providing it the failure is a + // red test rather than a silent loss of dlopen() resolution. } // For Clang with payload paths: the payload C runtime — -B so the driver @@ -778,7 +869,8 @@ CompileFlags compute_flags(const BuildPlan& plan) { std::string mingw_stdexp; if (caps.stdlib_id == "libstdc++") mingw_stdexp = " -lstdc++exp"; - f.ld = std::format("{}{}{}", user_ldflags, mingw_stdexp, link_extra); + f.ld = std::format("{}{}{}{}", link_intent_ld, user_ldflags, + mingw_stdexp, link_extra); return f; } @@ -789,10 +881,7 @@ CompileFlags compute_flags(const BuildPlan& plan) { // ldflags pass through verbatim; GNU link_extra (-flto/-s) does // not apply. f.ldBinary = mcpp::toolchain::link_tool(plan.toolchain); - std::string libpaths; - for (auto& dir : plan.depRuntimeLibraryDirs) - libpaths += " /LIBPATH:" + escape_path(dir); - f.ld = libpaths + user_ldflags; + f.ld = link_intent_ld + user_ldflags; return f; } // PE link, MSVC-ABI Clang (native MinGW is handled by the target-keyed @@ -832,7 +921,8 @@ CompileFlags compute_flags(const BuildPlan& plan) { // // Native cl.exe (isMsvcDialect, returned above) keeps link.exe: there // the response file is ours, and 2026.8.5.3 already fixed it. - f.ld = std::format(" -fuse-ld=lld{}{}", user_ldflags, link_extra); + f.ld = std::format(" -fuse-ld=lld{}{}{}", link_intent_ld, + user_ldflags, link_extra); } else if constexpr (mcpp::platform::needs_explicit_libcxx) { // macOS. The C++ runtime itself is decided by the contract table above // (dist::Format::MachO) and rides unit_ldflags; what is left here is @@ -871,8 +961,9 @@ CompileFlags compute_flags(const BuildPlan& plan) { std::string macos_sdk; if (auto sdk = mcpp::platform::macos::sdk_path()) macos_sdk = " -isysroot " + escape_path(*sdk); - f.ld = std::format("{}{}{} -fuse-ld=lld{}{}{}", full_static, - b_flag, macos_sdk, version_min, user_ldflags, link_extra); + f.ld = std::format("{}{}{} -fuse-ld=lld{}{}{}{}", full_static, + b_flag, macos_sdk, version_min, link_intent_ld, + user_ldflags, link_extra); } else { // libatomic: 16-byte / oversized std::atomic needs the out-of-line // __atomic_* libcalls from libatomic, which the driver won't add on @@ -881,8 +972,10 @@ CompileFlags compute_flags(const BuildPlan& plan) { // actually being present (see atomic_link_flag). std::string atomic_ld = atomic_link_flag(plan.toolchain.linkRuntimeDirs, !full_static.empty()); - f.ld = std::format("{}{}{}{}{}{}{}{}", full_static, link_toolchain_flags, b_flag, - runtime_dirs, atomic_ld, payload_ld, user_ldflags, link_extra); + f.ld = std::format("{}{}{}{}{}{}{}{}{}", full_static, + link_toolchain_flags, b_flag, runtime_dirs, + link_intent_ld, atomic_ld, payload_ld, + user_ldflags, link_extra); } return f; diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index 4c78eb44..588b3b57 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -24,6 +24,8 @@ import mcpp.build.distribution; import mcpp.build.plan; import mcpp.build.flags; import mcpp.build.hermetic; +import mcpp.build.runtime_validation; +import mcpp.platform.elf_runtime; import mcpp.build.compile_commands; import mcpp.build.cmdlimits; import mcpp.diag; @@ -1521,6 +1523,11 @@ std::optional check_inline_command_lengths(const std::string& manif std::expected NinjaBackend::build(const BuildPlan& plan, const BuildOptions& opts) { auto t0 = std::chrono::steady_clock::now(); + // Captured before ninja touches any link output. The post-build runtime + // validator compares this snapshot, so a hot no-op performs zero ELF + // parses and an output rebuilt behind an unchanged build.ninja is caught. + auto runtimeBefore = + mcpp::build::runtime_validation::snapshot_link_artifacts(plan); std::error_code ec; std::filesystem::create_directories(plan.outputDir, ec); @@ -1685,6 +1692,33 @@ std::expected NinjaBackend::build(const BuildPlan& plan std::chrono::steady_clock::now() - t0); if (ok) { + auto runtimeReport = + mcpp::build::runtime_validation::validate_changed_artifacts( + plan, runtimeBefore); + std::string runtimeFailure; + std::filesystem::path runtimeFailureArtifact; + for (auto const& checked : runtimeReport.artifacts) { + using Status = mcpp::platform::elf::RuntimeVerdict::Status; + auto explanation = checked.verdict.explain(); + if (checked.verdict.status == Status::ProvenMismatch) { + if (runtimeFailure.empty()) { + runtimeFailureArtifact = checked.artifact; + runtimeFailure = std::move(explanation); + } else if (!explanation.empty()) { + runtimeFailure += "\n" + explanation; + } + } else if (checked.verdict.status == Status::Inconclusive) { + mcpp::ui::warning(std::format( + "runtime closure validation is inconclusive for {}{}{}", + checked.artifact.string(), explanation.empty() ? "" : ":\n", + explanation)); + } + } + if (!runtimeFailure.empty()) { + return std::unexpected(BuildError{ + "runtime closure validation failed (proven Linux ELF mismatch)", + runtimeFailureArtifact, std::move(runtimeFailure)}); + } if (opts.verbose && !out.empty()) std::fputs(out.c_str(), stdout); std::set want(opts.ninjaTargets.begin(), opts.ninjaTargets.end()); diff --git a/src/build/plan.cppm b/src/build/plan.cppm index 795cc5b0..8100bf90 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -15,6 +15,9 @@ import mcpp.toolchain.dialect; import mcpp.toolchain.fingerprint; import mcpp.toolchain.triple; import mcpp.platform; +import mcpp.platform.runtime_binding; +import mcpp.platform.runtime_env_contract; +import mcpp.xlings.subos_info; export namespace mcpp::build { @@ -94,10 +97,32 @@ struct ResourceUnit { std::vector implicitInputs; }; +struct RuntimeCapabilityProvider { + std::string capability; + mcpp::manifest::PackageId provider; +}; + +struct ResolvedRuntimeContract { + std::vector requirements; + std::vector artifacts; + mcpp::manifest::LinkIntent linkIntent; + std::vector providers; +}; + +// Normalize structured and legacy runtime metadata and stamp every fact with +// the exact package identity that supplied it. Exported as a pure seam for +// contract tests and machine-readable tooling; it performs no host probing. +ResolvedRuntimeContract resolve_runtime_contract( + const std::vector& packages); + struct BuildPlan { mcpp::manifest::Manifest manifest; mcpp::toolchain::Toolchain toolchain; mcpp::toolchain::Fingerprint fingerprint; + // One immutable snapshot selected before workspace member substitution. + // Build/run/test and cache fast paths consume this value; none may re-read + // xlings active/current state. + mcpp::platform::runtime::RuntimeBinding runtimeBinding; std::string cppStandard = "c++23"; std::string cppStandardFlag = "-std=c++23"; // Module-graph-global dialect flags (issue #210), pre-joined with a @@ -150,6 +175,9 @@ struct BuildPlan { // binary's RUNPATH (e.g. compat.glx-runtime). Kept separate so static/musl // links don't pull the glibc payload dir. std::vector depRuntimeLibraryDirs; + std::vector runtimeRequirements; + std::vector runtimeArtifacts; + mcpp::manifest::LinkIntent linkIntent; // Windows runtime-DLL deployment. On PE (`supports_rpath` is false) a // directly-launched .exe cannot RUNPATH-locate a dependency's DLL, so each // *.dll found in a dependency's [runtime] library_dir is copied beside the @@ -171,13 +199,16 @@ struct BuildPlan { // (capability, provider package). A named aggregate instead of std::pair: // musl-gcc 15.1 modules failed to emit vector>'s // move-ctor instantiation across the module boundary (release link error). - struct CapabilityProvider { - std::string capability; - std::string provider; - }; - std::vector runtimeProviders; + std::vector runtimeProviders; }; +// Merge the generic facts exported by the already-selected xlings +// RuntimeBinding. This is data ingestion only: xlings has already selected +// providers and materialized artifacts before mcpp sees this snapshot. +void merge_runtime_binding_contract( + BuildPlan& plan, + const mcpp::platform::runtime::RuntimeBinding& binding); + // Is `p` inside one of `roots`, judged LEXICALLY? // // Lexical is the whole point (mcpp#344). std::filesystem::relative() runs @@ -452,6 +483,167 @@ void append_unique_path(std::vector& out, } // namespace +ResolvedRuntimeContract resolve_runtime_contract( + const std::vector& packages) +{ + ResolvedRuntimeContract out; + auto append_string = [](std::vector& values, std::string value) { + if (!value.empty() && std::ranges::find(values, value) == values.end()) + values.push_back(std::move(value)); + }; + auto absolute_from = [](const std::filesystem::path& root, + const std::filesystem::path& value) { + return (value.is_absolute() ? value : root / value).lexically_normal(); + }; + auto append_requirement = [&](mcpp::manifest::RuntimeRequirement value) { + const bool duplicate = std::ranges::any_of(out.requirements, + [&](auto const& existing) { + return existing.kind == value.kind + && existing.value == value.value + && existing.phase == value.phase + && existing.requester == value.requester + && existing.required == value.required; + }); + if (!duplicate) out.requirements.push_back(std::move(value)); + }; + + for (auto const& package : packages) { + const auto id = mcpp::manifest::package_id(package.manifest.package); + auto const& runtime = package.manifest.runtimeConfig; + + for (auto requirement : runtime.requirements) { + requirement.requester = id; + append_requirement(std::move(requirement)); + } + // One compatibility train: old soname/capability lists are normalized + // into the structured requirement shape. They do NOT imply provider + // ownership; only `provides` below creates a provider fact. + for (auto const& soname : runtime.dlopenLibs) { + append_requirement({ + .kind = "soname", .value = soname, .phase = "run", + .requester = id, .required = true, + }); + } + for (auto const& capability : runtime.capabilities) { + append_requirement({ + .kind = "capability", .value = capability, .phase = "run", + .requester = id, .required = true, + }); + } + + for (auto artifact : runtime.artifacts) { + artifact.provider = id; + artifact.path = absolute_from(package.root, artifact.path); + const bool duplicate = std::ranges::any_of(out.artifacts, + [&](auto const& existing) { + return existing.role == artifact.role + && existing.provider == artifact.provider + && existing.path == artifact.path + && existing.provenance == artifact.provenance + && existing.abi == artifact.abi + && existing.digest == artifact.digest + && existing.hostFingerprint == artifact.hostFingerprint; + }); + if (!duplicate) out.artifacts.push_back(std::move(artifact)); + } + + for (auto const& capability : runtime.provides) { + const bool duplicate = std::ranges::any_of(out.providers, + [&](auto const& existing) { + return existing.capability == capability + && existing.provider == id; + }); + if (!duplicate) out.providers.push_back({capability, id}); + } + + for (auto const& library : runtime.linkIntent.libraries) { + const std::filesystem::path asPath(library); + const bool explicitPath = !library.starts_with('-') + && (asPath.is_absolute() || asPath.has_parent_path() + || asPath.has_extension()); + append_string(out.linkIntent.libraries, + explicitPath ? absolute_from(package.root, asPath).string() + : library); + } + for (auto const& framework : runtime.linkIntent.frameworks) + append_string(out.linkIntent.frameworks, framework); + auto append_paths = [&](auto const& input, auto& output) { + for (auto const& path : input) + append_unique_path(output, absolute_from(package.root, path)); + }; + append_paths(runtime.linkIntent.linkLibraryDirs, + out.linkIntent.linkLibraryDirs); + append_paths(runtime.linkIntent.transitiveNeededDirs, + out.linkIntent.transitiveNeededDirs); + append_paths(runtime.linkIntent.runtimeSearchDirs, + out.linkIntent.runtimeSearchDirs); + append_paths(runtime.linkIntent.deployFiles, + out.linkIntent.deployFiles); + // Legacy library_dirs means run-time discovery only. It deliberately + // does not enter linkLibraryDirs; callers that need -L must opt into + // the structured field. + append_paths(runtime.libraryDirs, out.linkIntent.runtimeSearchDirs); + } + return out; +} + +void merge_runtime_binding_contract( + BuildPlan& plan, + const mcpp::platform::runtime::RuntimeBinding& binding) { + auto package_id = [](const mcpp::xlings::subos::PackageIdentity& value) { + return mcpp::manifest::PackageId{ + .namespace_ = value.namespace_, + .name = value.name, + .version = value.version, + .sourceProvenance = value.source, + }; + }; + + std::vector selected; + for (auto const& provider : binding.runtimeProviders) { + RuntimeCapabilityProvider value{ + .capability = provider.capability, + .provider = package_id(provider.provider), + }; + if (std::ranges::none_of(selected, [&](auto const& existing) { + return existing.capability == value.capability + && existing.provider == value.provider; + })) + selected.push_back(std::move(value)); + } + // xlings' selected providers are authoritative and therefore precede + // descriptor-declared fallback/provider facts. + for (auto it = selected.rbegin(); it != selected.rend(); ++it) { + if (std::ranges::none_of(plan.runtimeProviders, [&](auto const& existing) { + return existing.capability == it->capability + && existing.provider == it->provider; + })) + plan.runtimeProviders.insert(plan.runtimeProviders.begin(), *it); + } + + for (auto const& artifact : binding.runtimeArtifacts) { + mcpp::manifest::RuntimeArtifact value{ + .role = artifact.role, + .provider = package_id(artifact.provider), + .path = artifact.path.lexically_normal(), + .provenance = artifact.provenance, + .abi = artifact.abi, + .digest = artifact.digest, + .hostFingerprint = artifact.hostFingerprint, + }; + if (std::ranges::none_of(plan.runtimeArtifacts, [&](auto const& existing) { + return existing.role == value.role + && existing.provider == value.provider + && existing.path == value.path + && existing.provenance == value.provenance + && existing.abi == value.abi + && existing.digest == value.digest + && existing.hostFingerprint == value.hostFingerprint; + })) + plan.runtimeArtifacts.push_back(std::move(value)); + } +} + // True if `src` defines a top-level `int main(` / `auto main(` entry point. // Comments and string/char/raw-string literals are stripped first, so test // fixtures that embed `"int main() {...}"` or R"(int main(){})" don't @@ -628,56 +820,65 @@ make_plan(const mcpp::manifest::Manifest& manifest, plan.stdBmiPath = stdBmiPath; plan.stdObjectPath = stdObjectPath; - for (auto const& package : packages) { - for (auto const& dir : package.manifest.runtimeConfig.libraryDirs) { - auto abs = dir.is_absolute() ? dir : package.root / dir; - append_unique_path(plan.runtimeLibraryDirs, abs); - append_unique_path(plan.depRuntimeLibraryDirs, abs); - // Windows runtime-DLL deployment: stage each *.dll from this dir - // beside the produced executable (bin/). The *.dll filter — not a - // platform guard — keeps this inert for real .so/.dylib deps, so - // non-Windows builds are unchanged. See BuildPlan::DeployFile. - std::error_code dirEc; - if (std::filesystem::is_directory(abs, dirEc)) { - for (auto const& entry : - std::filesystem::directory_iterator(abs, dirEc)) { - if (!entry.is_regular_file()) continue; - auto ext = entry.path().extension().string(); - std::ranges::transform(ext, ext.begin(), - [](unsigned char c){ return std::tolower(c); }); - if (ext != ".dll") continue; - std::filesystem::path dest = - std::filesystem::path("bin") / entry.path().filename(); - if (std::ranges::none_of(plan.runtimeDeployFiles, - [&](auto const& d){ return d.dest == dest; })) - plan.runtimeDeployFiles.push_back({entry.path(), dest}); - } - } - } - for (auto const& lib : package.manifest.runtimeConfig.dlopenLibs) { - if (std::ranges::find(plan.runtimeDlopenLibs, lib) == plan.runtimeDlopenLibs.end()) - plan.runtimeDlopenLibs.push_back(lib); - } - for (auto const& cap : package.manifest.runtimeConfig.capabilities) { - if (std::ranges::find(plan.runtimeCapabilities, cap) == plan.runtimeCapabilities.end()) - plan.runtimeCapabilities.push_back(cap); + auto runtimeContract = resolve_runtime_contract(packages); + plan.runtimeRequirements = std::move(runtimeContract.requirements); + plan.runtimeArtifacts = std::move(runtimeContract.artifacts); + plan.linkIntent = std::move(runtimeContract.linkIntent); + plan.runtimeProviders = std::move(runtimeContract.providers); + + for (auto const& dir : plan.linkIntent.runtimeSearchDirs) { + append_unique_path(plan.runtimeLibraryDirs, dir); + append_unique_path(plan.depRuntimeLibraryDirs, dir); + } + for (auto const& requirement : plan.runtimeRequirements) { + // Optional requirements remain first-class provenance in + // resolution.json, but they cannot become hard ABI/doctor inputs. + if (!requirement.required) continue; + if (requirement.kind == "soname") { + if (std::ranges::find(plan.runtimeDlopenLibs, requirement.value) + == plan.runtimeDlopenLibs.end()) + plan.runtimeDlopenLibs.push_back(requirement.value); + } else if (requirement.kind == "capability") { + if (std::ranges::find(plan.runtimeCapabilities, requirement.value) + == plan.runtimeCapabilities.end()) + plan.runtimeCapabilities.push_back(requirement.value); } } - // Provider mapping (capability -> package), strongest first: packages - // that explicitly `provides` a capability win over packages that merely - // list it in `capabilities` (weak/back-compat providers). Downstream - // lookups take the first match. - for (auto const& package : packages) { - for (auto const& cap : package.manifest.runtimeConfig.provides) - plan.runtimeProviders.push_back({cap, package.manifest.package.name}); + + auto add_deploy = [&](const std::filesystem::path& source) + -> std::optional { + const auto normalized = source.lexically_normal(); + const auto dest = std::filesystem::path("bin") / source.filename(); + auto existing = std::ranges::find_if(plan.runtimeDeployFiles, + [&](auto const& value) { return value.dest == dest; }); + if (existing != plan.runtimeDeployFiles.end()) { + if (existing->source.lexically_normal() != normalized) { + return std::format( + "runtime deploy collision: '{}' and '{}' both target '{}'", + existing->source.string(), normalized.string(), dest.string()); + } + return std::nullopt; + } + plan.runtimeDeployFiles.push_back({normalized, dest}); + return std::nullopt; + }; + // Structured deploy files are explicit and platform-neutral. Legacy + // library_dirs keeps its one-train DLL discovery behavior below. + for (auto const& source : plan.linkIntent.deployFiles) { + if (auto collision = add_deploy(source)) + return std::unexpected(std::move(*collision)); } - for (auto const& package : packages) { - for (auto const& cap : package.manifest.runtimeConfig.capabilities) { - bool dup = false; - for (auto& pr : plan.runtimeProviders) - if (pr.capability == cap - && pr.provider == package.manifest.package.name) { dup = true; break; } - if (!dup) plan.runtimeProviders.push_back({cap, package.manifest.package.name}); + for (auto const& dir : plan.linkIntent.runtimeSearchDirs) { + std::error_code dirEc; + if (!std::filesystem::is_directory(dir, dirEc)) continue; + for (auto const& entry : std::filesystem::directory_iterator(dir, dirEc)) { + if (!entry.is_regular_file()) continue; + auto ext = entry.path().extension().string(); + std::ranges::transform(ext, ext.begin(), + [](unsigned char c){ return std::tolower(c); }); + if (ext != ".dll") continue; + if (auto collision = add_deploy(entry.path())) + return std::unexpected(std::move(*collision)); } } // The same private runtime directories embedded as executable RUNPATH are @@ -687,29 +888,36 @@ make_plan(const mcpp::manifest::Manifest& manifest, for (auto const& dir : tc.linkRuntimeDirs) { append_unique_path(plan.runtimeLibraryDirs, dir); } - // The private glibc payload is the ONE entry that is not also in the - // executable's RUNPATH (flags.cppm excludes it deliberately, so static and - // musl links stay clean). It is here purely so a dlopen()'d library — whose - // own DT_NEEDED closure never consults the main executable's RUNPATH — can - // still resolve the same libc the executable was linked against. + // The private glibc payload exists here for ONE reason: a dlopen()'d + // library, whose own DT_NEEDED closure never consults the main + // executable's RUNPATH, must still resolve the same libc the executable + // was linked against. So it is only published when this build actually has + // such a library. // - // So add it ONLY when this build actually has such a library. mcpp#291: + // It is published through the ARTIFACT — the link model already emits + // `-Wl,-rpath,` alongside --dynamic-linker wherever a payload + // exists — and never through the environment. // LD_LIBRARY_PATH is inherited by the whole process subtree, and a child - // that is a HOST binary (/bin/sh, reached via a provider's popen()) loads - // the HOST loader — PT_INTERP is baked into the executable and no - // environment variable can override it — while this variable hands it the - // payload libc.so.6. libc and ld.so are version-locked to each other - // through GLIBC_PRIVATE, so on any host whose glibc differs from the - // payload's the shell dies of SIGSEGV inside the dynamic linker, before - // main, with empty stdout and no diagnostic. (It does NOT reproduce when - // host and payload glibc happen to match, which is why this survived.) + // that is a HOST binary (/bin/sh, reached via popen()) loads the HOST + // loader — PT_INTERP is baked in and no environment variable overrides it + // — while the variable hands it the payload libc.so.6. libc and ld.so are + // version-locked through GLIBC_PRIVATE, so the shell dies during + // relocation, before main: glibc 2.44's libc.so.6 needs + // `__pointer_chk_guard` from its own loader (mcpp#401), and older payloads + // segfault in the linker instead (mcpp#291). It does NOT reproduce when + // host and payload glibc happen to match, which is how it survived. // // process.cppm's strip_private_glibc already removes this entry from - // mcpp's OWN children. It cannot help one hop further out: mcpp sets the - // variable for the target deliberately, and what the target then spawns is - // beyond mcpp's reach. Not emitting it unless it is needed is. - if (tc.payloadPaths && !plan.depRuntimeLibraryDirs.empty()) { - append_unique_path(plan.runtimeLibraryDirs, tc.payloadPaths->glibcLib); + // mcpp's OWN children. It cannot help one hop further out — what the + // target spawns is beyond mcpp's reach. Putting the directory in the + // artifact's RUNPATH instead is: DT_RUNPATH reaches the object that + // carries it and the dlopen() it performs, and nothing else. + if constexpr (mcpp::platform::publishes_via_environment( + mcpp::platform::kPrivateLibcSearchScope)) { + if (tc.payloadPaths && !plan.depRuntimeLibraryDirs.empty()) { + append_unique_path(plan.runtimeLibraryDirs, + tc.payloadPaths->glibcLib); + } } // 1a. Object addressing. diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 3a7d2649..f38f4600 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -45,6 +45,8 @@ import mcpp.lockfile; import mcpp.config; import mcpp.xlings; import mcpp.xlings.subos_info; +import mcpp.xlings.runtime_selection; +import mcpp.platform.runtime_binding; import mcpp.toolchain.post_install; import mcpp.platform; import mcpp.fetcher; @@ -57,6 +59,7 @@ import mcpp.pm.index_refresh; import mcpp.pm.mangle; import mcpp.pm.compat; import mcpp.pm.dep_spec; +import mcpp.pm.dependency_selector; import mcpp.pm.lock_io; import mcpp.version_req; import mcpp.ui; @@ -305,6 +308,55 @@ std::string canonical_package_build_metadata( s += pkg.manifest.package.name; s += "@"; s += pkg.manifest.package.version; + s += " source="; + s += pkg.manifest.package.sourceProvenance; + auto const& runtime = pkg.manifest.runtimeConfig; + for (auto const& requirement : runtime.requirements) { + s += " runtime-need:"; + s += requirement.kind; + s += ':'; + s += requirement.value; + s += ':'; + s += requirement.phase; + s += requirement.required ? ":required" : ":optional"; + } + for (auto const& artifact : runtime.artifacts) { + s += " runtime-artifact:"; + s += artifact.role; + s += ':'; + s += artifact.path.generic_string(); + s += ':'; + s += artifact.provenance; + s += ':'; + s += artifact.abi; + s += ':'; + s += artifact.digest; + s += ':'; + s += artifact.hostFingerprint; + } + for (auto const& value : runtime.linkIntent.libraries) + s += " link-library:" + value; + for (auto const& value : runtime.linkIntent.linkLibraryDirs) + s += " link-dir:" + value.generic_string(); + for (auto const& value : runtime.linkIntent.transitiveNeededDirs) + s += " needed-dir:" + value.generic_string(); + for (auto const& value : runtime.linkIntent.runtimeSearchDirs) + s += " runtime-dir:" + value.generic_string(); + for (auto const& value : runtime.linkIntent.frameworks) + s += " framework:" + value; + for (auto const& value : runtime.linkIntent.deployFiles) + s += " deploy:" + value.generic_string(); + // Legacy fields remain fingerprinted while they are readable. + for (auto const& value : runtime.libraryDirs) + s += " legacy-runtime-dir:" + value.generic_string(); + for (auto const& value : runtime.dlopenLibs) + s += " legacy-soname:" + value; + for (auto const& value : runtime.capabilities) + s += " legacy-capability:" + value; + for (auto const& value : runtime.provides) + s += " legacy-provides:" + value; + for (auto const& [capability, provider] : runtime.providerOverrides) + s += " provider-override:" + capability + '=' + provider; if (!pkg.manifest.buildConfig.cStandard.empty()) { s += " c_standard="; s += pkg.manifest.buildConfig.cStandard; @@ -712,6 +764,8 @@ export struct BuildContext { mcpp::manifest::Manifest manifest; mcpp::toolchain::Toolchain tc; mcpp::toolchain::Fingerprint fp; + mcpp::xlings::runtime::RuntimeSelection runtimeSelection; + mcpp::platform::runtime::RuntimeBinding runtimeBinding; std::filesystem::path projectRoot; std::filesystem::path outputDir; std::filesystem::path stdBmi; @@ -838,6 +892,12 @@ export struct BuildOverrides { // mcpp.build.execute imported it). A pointer keeps the exported layout // trivial, and it also avoids copying the manifest per tool build. std::shared_ptr preloaded_manifest; + // Nested source/tool builds inherit the consumer root's local development + // OS. A dependency's own [xlings].subos is never consulted or propagated. + std::shared_ptr + inherited_runtime_selection; + std::shared_ptr + inherited_runtime_binding; std::string target_triple; // empty = host triple, fall through to [toolchain] bool force_static = false; // --static (or implied by musl target) std::string package_filter; // -p : only build this workspace member @@ -910,84 +970,6 @@ std::string with_index_cause(std::string msg) { msg += "\n" + hint; return msg; } -// The runtime this build targets, in xlings's own spelling ("glibc@2.39"). -// -// A degradation chain with every step explicit, and deliberately NO final -// "otherwise pick something". Payload resolution declines without an -// authority, because the guess it used to make is what let the compile side -// and the artifact's interpreter name different glibc versions -- invisibly, -// until the binary met a library built against the other one. -// -// 1. [xlings] subos -> that subos's subos_info.runtime -// 2. the active subos's subos_info.runtime -// 3. "" -- no authority, therefore no PayloadFirst -// -// `compilerBin` may be empty on the first call: the compiler has not been -// probed yet, and an inherited toolchain resolves its subos from its OWNER -// home. The caller re-resolves once it knows where the compiler is. -std::string resolve_runtime_binding(const mcpp::manifest::Manifest& m, - const std::filesystem::path& compilerBin) { - auto runtime_of = [](const std::filesystem::path& dir) -> std::string { - return mcpp::xlings::subos::read(dir).runtime; - }; - // A declaration can only be honoured while the payload it names is still - // installed. Subos descriptions are written once and the payloads beneath - // them are replaced independently -- upgraded, garbage-collected -- so a - // subos can go on saying `glibc@2.39` on a machine that now has only 2.44. - // - // Taking that at face value is not conservative, it is worse than any - // substitution: the exact-match probe finds nothing, no loader reaches the - // link line, and the artifact ends up on the HOST loader -- outside the - // sandbox entirely. Measured on CI, three rounds running. - // - // So a declaration naming an absent payload is passed over rather than - // returned, and resolution carries on to something that can be honoured. - auto usable = [&](const std::string& binding) { - if (binding.empty()) return false; - if (compilerBin.empty()) return true; // cannot check yet; caller re-asks - if (mcpp::toolchain::probe_payload_paths(compilerBin, binding)) - return true; - mcpp::log::verbose("probe", std::format( - "subos declares runtime {}, but that payload is not installed here " - "— looking for one that is", binding)); - return false; - }; - if (auto active = mcpp::xlings::paths::subos_dir_of(compilerBin)) { - // 1 — the project names a subos; it is a sibling of the active one. - if (!m.xlings.subos.empty()) { - auto named = active->parent_path() / m.xlings.subos; - if (auto r = runtime_of(named); usable(r)) return r; - } - // 2 — whatever is active. - if (auto r = runtime_of(*active); usable(r)) return r; - } - - // 3 — COMPATIBILITY: the value this toolchain already has baked in. - // - // A subos created before xlings grew `subos_info` cannot answer, and that - // is the state of every machine installed before 2026.8.5.1 -- including - // mcpp's own sandbox, whose vendored xlings is never upgraded. Refusing - // there would break every existing user, so the toolchain's own baked - // value stands in. - // - // This is NOT the guess this design removed. The guess picked a version by - // directory order, unrelated to what the artifact would load. This reads - // the value the artifact WILL use -- gcc's specs, clang's cfg -- so the - // invariant that matters, compile side == run side, still holds. It is a - // migration path, and it goes away on its own: once the subos describes - // itself, step 1 or 2 answers first. - if (!compilerBin.empty()) { - if (auto r = mcpp::toolchain::baked_runtime_binding(compilerBin); - !r.empty()) { - mcpp::log::verbose("probe", std::format( - "subos does not describe itself; using the runtime this " - "toolchain was installed against ({}). `xlings self update` " - "and a fresh subos make this authoritative", r)); - return r; - } - } - return {}; -} } // namespace export std::expected @@ -1027,6 +1009,7 @@ prepare_build(bool print_fingerprint, // If the manifest has [workspace] and is a virtual workspace (no [package]), // or if -p filter is set, switch to the target member's manifest. std::optional wsManifest; // keep workspace manifest alive + std::filesystem::path runtimeWorkspaceRoot; if (m->workspace.present) { std::string targetMember; @@ -1070,6 +1053,7 @@ prepare_build(bool print_fingerprint, return std::unexpected(std::format( "workspace member '{}' has no mcpp.toml", targetMember)); } + runtimeWorkspaceRoot = *root; wsManifest = std::move(*m); // preserve workspace manifest m = mcpp::manifest::load(memberDir / "mcpp.toml"); if (!m) return std::unexpected(std::format( @@ -1105,23 +1089,37 @@ prepare_build(bool print_fingerprint, if (!wsRoot.empty()) { auto wsm = mcpp::manifest::load(wsRoot / "mcpp.toml"); if (wsm && wsm->workspace.present) { + runtimeWorkspaceRoot = wsRoot; + wsManifest = std::move(*wsm); // #224: anchor relative `path`/`[indices].path` to the // workspace root, not this member's own directory. - mcpp::project::merge_workspace_deps(*m, *wsm, wsRoot); + mcpp::project::merge_workspace_deps(*m, *wsManifest, wsRoot); if (m->toolchain.byPlatform.empty()) { - m->toolchain = wsm->toolchain; + m->toolchain = wsManifest->toolchain; } - for (auto& [triple, entry] : wsm->targetOverrides) { + for (auto& [triple, entry] : wsManifest->targetOverrides) { if (!m->targetOverrides.contains(triple)) { m->targetOverrides[triple] = entry; } } // Inherit workspace indices if member doesn't define any - mcpp::project::inherit_workspace_indices(*m, *wsm, wsRoot); + mcpp::project::inherit_workspace_indices(*m, *wsManifest, wsRoot); } } } + mcpp::xlings::runtime::RuntimeSelection runtimeSelection; + if (overrides.inherited_runtime_selection) { + runtimeSelection = *overrides.inherited_runtime_selection; + } else { + std::optional> wsRef; + if (wsManifest) wsRef = std::cref(*wsManifest); + auto selected = mcpp::xlings::runtime::select_runtime( + *m, wsRef, *root, runtimeWorkspaceRoot); + if (!selected) return std::unexpected(selected.error()); + runtimeSelection = std::move(*selected); + } + // Where mcpp WRITES — derived here because `root` is only final now: the // workspace block above may have moved it to the selected member. Defaults // to the project root, so every existing invocation is byte-for-byte @@ -1133,6 +1131,10 @@ prepare_build(bool print_fingerprint, std::error_code wdEc; std::filesystem::create_directories(workRoot, wdEc); } + if (m->package.sourceProvenance.empty()) { + m->package.sourceProvenance = + "path+" + root->lexically_normal().generic_string(); + } // A `compat`-form (Form B) package's sources live under a wrap directory // inside the version dir, which is why its descriptor writes globs like @@ -1174,13 +1176,18 @@ prepare_build(bool print_fingerprint, // fetched. Keyed by package name — the same key the writer at the end of // this function emits, both taken from the root manifest's [dependencies]. std::map gitLockAnchors; + std::map packageIdentityLockAnchors; { auto lockPath = workRoot / "mcpp.lock"; if (std::filesystem::exists(lockPath)) { if (auto lock = mcpp::pm::load(lockPath); lock) { - for (auto const& p : lock->packages) + for (auto const& p : lock->packages) { + if (!p.namespace_.empty()) + packageIdentityLockAnchors.emplace( + p.name, p.namespace_); if (auto parsed = mcpp::pm::parse_git_source(p.source); parsed) gitLockAnchors.emplace(p.name, std::move(*parsed)); + } } else { // Degraded, not a plain warning: the engine silently does less // than asked — every git branch dep falls back to `ls-remote` @@ -1250,6 +1257,25 @@ prepare_build(bool print_fingerprint, return &*cfg_opt; }; + // Resolve one exact runtime contract before resolving/fixing a toolchain. + // The fixup is itself a consumer of RuntimeBinding: doing it first would + // recreate #392 by letting directory order choose a libc and only later + // discovering what the project selected. + mcpp::platform::runtime::RuntimeBinding runtimeBindingSnapshot; + if (overrides.inherited_runtime_binding) { + runtimeBindingSnapshot = *overrides.inherited_runtime_binding; + } else { + auto cfgRuntime = get_cfg(); + if (!cfgRuntime) return std::unexpected(cfgRuntime.error()); + auto resolved = mcpp::platform::runtime::resolve_runtime_binding( + runtimeSelection, {}, **cfgRuntime); + if (!resolved) return std::unexpected(resolved.error()); + runtimeBindingSnapshot = std::move(*resolved); + } + const auto runtimePayload = runtimeBindingSnapshot.libc.value_or(""); + const auto runtimeLibDir = runtimeBindingSnapshot.libraryDirs.empty() + ? std::filesystem::path{} : runtimeBindingSnapshot.libraryDirs.front(); + constexpr std::string_view kCurrentPlatform = mcpp::platform::name; // M5.5: toolchain resolution priority: @@ -1545,7 +1571,11 @@ prepare_build(bool print_fingerprint, // Same post-install fixup as `mcpp toolchain install` — this manifest // [toolchain] path previously ran none, so a freshly auto-installed // payload kept its stale install-time cfg / unpatched runtime libs. - mcpp::toolchain::ensure_post_install_fixup(**cfg, payload->root, pkg); + if (auto fixed = mcpp::toolchain::ensure_post_install_fixup( + **cfg, payload->root, pkg, + runtimeBindingSnapshot.runtimeId, runtimeLibDir); !fixed) + return std::unexpected(std::format( + "toolchain post-install fixup: {}", fixed.error())); // Canonical rendering, whatever spelling the manifest/config used: // "Resolved gcc@16.1.0 → x86_64-linux-musl → ". mcpp::ui::info("Resolved", @@ -1690,7 +1720,11 @@ prepare_build(bool print_fingerprint, // `mcpp toolchain install` performs — without it a fresh sandbox // gcc cannot find the C library (stdlib.h: No such file or // directory) and a fresh llvm keeps its stale install-time cfg. - mcpp::toolchain::ensure_post_install_fixup(**cfg, payload->root, defaultPkg); + if (auto fixed = mcpp::toolchain::ensure_post_install_fixup( + **cfg, payload->root, defaultPkg, + runtimeBindingSnapshot.runtimeId, runtimeLibDir); !fixed) + return std::unexpected(std::format( + "default toolchain post-install fixup: {}", fixed.error())); // Persist the default so we don't ask again next time. if (auto wr = mcpp::config::write_default_toolchain(**cfg, defaultSpec); wr) { @@ -1723,27 +1757,9 @@ prepare_build(bool print_fingerprint, tcOrigin = TcOrigin::FirstRun; } - // The authority for "which libc", resolved before the toolchain is - // probed: payload paths are addresses for a version this names. - auto runtimeBinding = resolve_runtime_binding(*m, {}); - auto tc = mcpp::toolchain::detect(explicit_compiler, runtimeBinding); + auto tc = mcpp::toolchain::detect( + explicit_compiler, runtimePayload, runtimeBindingSnapshot.contractHash); if (!tc) return std::unexpected(tc.error().message); - // The first pass has no compiler, so it can neither discover a binding - // that only the compiler's own home knows (an inherited toolchain resolves - // into its owner) nor check that a declared one is still installed. Both - // failures look identical from here: no payload paths. Re-ask with the - // compiler in hand whenever that is what happened. - // - // The old condition was `runtimeBinding.empty()`, which meant a subos - // naming a payload that had since been upgraded away won on the first pass - // and the second pass never ran. - if (!tc->payloadPaths) { - auto fromCompiler = resolve_runtime_binding(*m, tc->binaryPath); - if (!fromCompiler.empty() && fromCompiler != runtimeBinding) { - tc = mcpp::toolchain::detect(explicit_compiler, fromCompiler); - if (!tc) return std::unexpected(tc.error().message); - } - } // ── Targeting the MSVC ABI without a usable MSVC ───────────────────── // @@ -1828,7 +1844,11 @@ prepare_build(bool print_fingerprint, "MinGW-w64 payload {} has no known C++ frontend in {}", gnuPkg.target(), payloadR->binDir.string())); } - mcpp::toolchain::ensure_post_install_fixup(**cfgR, payloadR->root, gnuPkg); + if (auto fixed = mcpp::toolchain::ensure_post_install_fixup( + **cfgR, payloadR->root, gnuPkg, + runtimeBindingSnapshot.runtimeId, runtimeLibDir); !fixed) + return std::unexpected(std::format( + "MinGW toolchain post-install fixup: {}", fixed.error())); // Persist both axes so the repair happens once, not on every build. if (mcpp::config::write_default_toolchain(**cfgR, pins::kFirstRunWinGnu)) @@ -1838,7 +1858,9 @@ prepare_build(bool print_fingerprint, tcSpec = std::string(pins::kFirstRunWinGnu); tcOrigin = TcOrigin::FirstRun; - tc = mcpp::toolchain::detect(explicit_compiler, runtimeBinding); + tc = mcpp::toolchain::detect( + explicit_compiler, runtimePayload, + runtimeBindingSnapshot.contractHash); if (!tc) return std::unexpected(tc.error().message); } @@ -1932,7 +1954,11 @@ prepare_build(bool print_fingerprint, "host toolchain payload '{}' has no known C++ frontend in {}", pkg.target(), payload->binDir.string())); } - mcpp::toolchain::ensure_post_install_fixup(**cfgH, payload->root, pkg); + if (auto fixed = mcpp::toolchain::ensure_post_install_fixup( + **cfgH, payload->root, pkg, + runtimeBindingSnapshot.runtimeId, runtimeLibDir); !fixed) + return std::unexpected(std::format( + "host toolchain post-install fixup: {}", fixed.error())); auto htc = mcpp::toolchain::detect(frontend); if (!htc) return std::unexpected(htc.error().message); mcpp::ui::info("Resolved", std::format( @@ -1998,15 +2024,32 @@ prepare_build(bool print_fingerprint, // [xlings] build environment (L-1). This creates .mcpp/.xlings.json with // custom non-builtin index entries (so xlings can clone them) plus the // [xlings] deps/workspace/subos/envs materialized verbatim. - if (!m->indices.empty() || !m->xlings.empty()) { + const auto& runtimeOwnerManifest = wsManifest ? *wsManifest : *m; + const bool materializeRootRuntime = + !overrides.inherited_runtime_binding && !runtimeOwnerManifest.xlings.empty(); + if (!m->indices.empty() || materializeRootRuntime) { auto cfg2 = get_cfg(); if (cfg2) { mcpp::xlings::ProjectEnv penv; - penv.deps = m->xlings.deps; - penv.subos = m->xlings.subos; - for (auto const& [k, v] : m->xlings.workspace) penv.workspace.emplace_back(k, v); - for (auto const& [k, v] : m->xlings.envs) penv.envs.emplace_back(k, v); - mcpp::config::ensure_project_index_dir(**cfg2, workRoot, m->indices, penv); + if (materializeRootRuntime) { + penv.deps = runtimeOwnerManifest.xlings.deps; + penv.subos = runtimeOwnerManifest.xlings.subos; + for (auto const& [k, v] : runtimeOwnerManifest.xlings.workspace) + penv.workspace.emplace_back(k, v); + for (auto const& [k, v] : runtimeOwnerManifest.xlings.envs) + penv.envs.emplace_back(k, v); + } + if (runtimeSelection.ownerRoot == workRoot) { + mcpp::config::ensure_project_index_dir( + **cfg2, workRoot, m->indices, penv); + } else { + if (!m->indices.empty()) + mcpp::config::ensure_project_index_dir( + **cfg2, workRoot, m->indices, {}); + if (materializeRootRuntime) + mcpp::config::ensure_project_index_dir( + **cfg2, runtimeSelection.ownerRoot, {}, penv); + } // On first build, the project index data root may be empty because // ensure_project_index_dir only writes .xlings.json but does not @@ -2186,14 +2229,21 @@ prepare_build(bool print_fingerprint, }; auto xpkgLuaMatchesCandidate = - [](const mcpp::pm::DependencyCoordinate& coord, - std::string_view luaContent, - bool allowLegacyBareDefault) { + [&](const mcpp::pm::DependencyCoordinate& coord, + std::string_view luaContent, + bool allowLegacyBareDefault) { // Single source of truth: the descriptor identity gate lives in - // mcpp.manifest and is shared with the read_xpkg_lua family. + // mcpp.manifest and is shared with the read_xpkg_lua family. A + // descriptor served by a declared project index inherits that + // index's namespace when package.namespace is omitted; preserve + // the same owner context during this second, stricter check. + const auto route = index_route(); + const auto* owner = route.find_for_ns(coord.namespace_); + const std::string_view ownerNs = owner + ? std::string_view{owner->name} : std::string_view{}; return mcpp::manifest::xpkg_lua_identity_matches( luaContent, coord.namespace_, coord.shortName, - allowLegacyBareDefault); + allowLegacyBareDefault, ownerNs); }; auto dependencyCoordinates = @@ -2210,6 +2260,8 @@ prepare_build(bool print_fingerprint, return out; }; + std::set selectorMigrationWarnings; + auto selectDependencyCandidate = [&](mcpp::manifest::DependencySpec& spec, const std::string& depName) -> std::expected @@ -2221,12 +2273,74 @@ prepare_build(bool print_fingerprint, "dependency '{}' has no lookup candidates", depName))); } + // One release train of migration support for the former dotted + // candidate search. A lockfile records the identity an existing + // project already selected, so keep that identity stable until the + // user rewrites the selector explicitly. Without a lock anchor, never + // fall back: only diagnose a valid old-primary package and continue + // with the new exact coordinate. + if (spec.legacyCandidateSearch) { + const auto exact = candidates.front(); + bool lockExpressesIntent = false; + if (auto locked = packageIdentityLockAnchors.find(depName); + locked != packageIdentityLockAnchors.end()) { + lockExpressesIntent = true; + if (locked->second != exact.namespace_) { + mcpp::pm::DependencyCoordinate lockedCoordinate{ + .namespace_ = locked->second, + .shortName = exact.shortName, + }; + if (selectorMigrationWarnings.insert(depName).second) { + mcpp::ui::warning(std::format( + "dependency selector '{}' now means exact package " + "'{}', but mcpp.lock records '{}'; keeping the " + "locked identity for this migration release. " + "Write '{}' to keep it explicitly, or remove the " + "lock and keep '{}' to migrate", + depName, + mcpp::pm::format_package_selector(exact), + mcpp::pm::format_package_selector(lockedCoordinate), + mcpp::pm::format_package_selector(lockedCoordinate), + mcpp::pm::format_package_selector(exact))); + } + candidates.assign(1, std::move(lockedCoordinate)); + } + } + + if (!lockExpressesIntent && spec.isVersion()) { + if (auto old = mcpp::pm::legacy_prefixed_coordinate(exact)) { + auto oldLua = readStrictLuaForCandidate(*old); + if (oldLua && xpkgLuaMatchesCandidate( + *old, *oldLua, + /*allowLegacyBareDefault=*/false) + && selectorMigrationWarnings.insert(depName).second) { + mcpp::ui::warning(std::format( + "dependency selector '{}' now resolves exactly to " + "'{}'; an older mcpp would select the existing " + "package '{}'. Write '{}' to keep the old identity " + "or keep '{}' for the new exact identity", + depName, + mcpp::pm::format_package_selector(exact), + mcpp::pm::format_package_selector(*old), + mcpp::pm::format_package_selector(*old), + mcpp::pm::format_package_selector(exact))); + } + } + } + } + auto selected = candidates.front(); bool matched = false; - if (spec.isVersion() && candidates.size() > 1) { + if (spec.isVersion()) { for (auto& candidate : candidates) { auto lua = readStrictLuaForCandidate(candidate); - if (!lua || !xpkgLuaMatchesCandidate( + if (!lua) continue; + if (auto violation = mcpp::manifest:: + xpkg_name_form_violation_from_lua(*lua)) { + return std::unexpected(std::format( + "dependency '{}': {}", depName, *violation)); + } + if (!xpkgLuaMatchesCandidate( candidate, *lua, /*allowLegacyBareDefault=*/false)) { continue; } @@ -2267,6 +2381,70 @@ prepare_build(bool print_fingerprint, break; } + // One-release bare-name migration. Namespace omission means exactly + // `mcpplibs`, but every published `compat.*` package and every user + // manifest written before this release spells the dependency bare — + // `gtest = "1.15.2"`, `ftxui = "6.1.9"`. Making that an immediate + // hard error means an mcpp upgrade breaks builds against data that + // is already published and cannot be edited retroactively; the + // symmetric rule ("published data must not break the program") is + // why the index floor degrades instead of bricking. + // + // The defect #278 removed was the SILENCE, not the reach: mcpp used + // to continue with a namespace the user never wrote and never say + // so. A hit here is announced, is recorded downstream under its + // canonical identity, and names the exact edit that removes the + // warning. Only a selector whose namespace was OMITTED is eligible — + // `mcpplibs.gtest` states an identity and must still miss. + if (!matched && spec.isVersion() && spec.namespaceOmitted) { + for (auto& legacy : + mcpp::pm::legacy_bare_candidates(candidates.front())) { + auto lua = readStrictLuaForCandidate(legacy); + if (!lua) continue; + if (mcpp::manifest::xpkg_name_form_violation_from_lua(*lua)) + continue; + if (!xpkgLuaMatchesCandidate( + legacy, *lua, /*allowLegacyBareDefault=*/false)) + continue; + auto declaredNs = + mcpp::manifest::extract_xpkg_namespace(*lua); + // Same narrowing as the exact loop: the namespace-less rung + // is "upstream package that declares no namespace", not a + // cross-namespace wildcard. + if (legacy.namespace_.empty() && !declaredNs.empty()) + continue; + + selected = legacy; + if (selected.namespace_.empty()) + selected.namespace_ = declaredNs; + matched = true; + // Downstream — lock, install, cache label — must see the + // canonical identity, so the ambiguous spelling survives in + // exactly one place: the user's manifest, until they edit it. + candidates.assign(1, selected); + + if (selectorMigrationWarnings.insert(depName).second) { + mcpp::ui::warning(std::format( + "dependency '{}' resolved to '{}' through the " + "deprecated bare-name search; namespace omission " + "means `{}` only. Write the exact package:" + "\n [dependencies.{}]" + "\n {} = \"{}\"" + "\n (or run `mcpp add {}@{}`). This fallback is " + "removed in {}.", + depName, + mcpp::pm::format_package_selector(selected), + mcpp::pm::kDefaultNamespace, + selected.namespace_, selected.shortName, + spec.version, + mcpp::pm::format_package_selector(selected), + spec.version, + mcpp::pm::kBareNameFallbackRemovedIn)); + } + break; + } + } + // A custom GIT index is cloned lazily by xlings during install, so // at selection time its descriptors may legitimately not be on disk // yet. "Not found" is therefore not conclusive for those namespaces @@ -2279,11 +2457,9 @@ prepare_build(bool print_fingerprint, return index_route().lazy_git(c.namespace_); }); - // T9 (#278) — no candidate resolved. This used to fall through to - // `candidates.front()` SILENTLY, so mcpp carried on with a namespace - // it had invented, and the user met the failure much later (during - // download/install) wrapped around that invented name. Fail here, - // and say exactly which identities were tried. + // An exact coordinate that a readable index cannot serve fails at + // resolution. Never carry it into install-time compatibility + // retries, which would reintroduce cross-namespace guessing. if (!matched && !anyLazyGitIndex) { std::string tried; for (auto& c : candidates) { @@ -2304,21 +2480,19 @@ prepare_build(bool print_fingerprint, hint += "\n a package with this name exists under " "another namespace:"; for (auto& fqn : fqns) hint += "\n " + fqn; - hint += std::format( - "\n bare names only resolve to the `{}` / `{}` " - "namespaces. write it out:" - "\n [dependencies]" - "\n \"{}\" = \"{}\"" - "\n or:" - "\n [dependencies.{}]" - "\n {} = \"{}\"", - mcpp::pm::kDefaultNamespace, - mcpp::pm::kCompatNamespace, - fqns.front(), spec.version.empty() ? "" - : spec.version, - fqns.front().substr(0, fqns.front().rfind('.')), - fqns.front().substr(fqns.front().rfind('.') + 1), - spec.version.empty() ? "" : spec.version); + if (auto suggested = mcpp::pm::parse_package_selector( + fqns.front()); suggested + && suggested->namespace_) { + hint += std::format( + "\n namespace omission means `{}`. write the " + "exact package:" + "\n [dependencies.{}]" + "\n {} = \"{}\"", + mcpp::pm::kDefaultNamespace, + *suggested->namespace_, suggested->name, + spec.version.empty() ? "" + : spec.version); + } } } @@ -2333,10 +2507,10 @@ prepare_build(bool print_fingerprint, mcpp::pm::staleness_note( mcpp::config::make_xlings_env(**cfgA))); } - return std::unexpected(std::format( - "dependency '{}': no package found under the namespaces " - "mcpp searched\n tried: {}{}", - depName, tried, hint)); + return std::unexpected(with_index_cause(std::format( + "dependency '{}': no package found for exact selector" + "\n tried: {}{}", + depName, tried, hint))); } } @@ -2413,7 +2587,7 @@ prepare_build(bool print_fingerprint, } if (field.kind == mcpp::manifest::McppField::TableBody) { auto dm = mcpp::manifest::synthesize_from_xpkg_lua( - *luaContent, depName, version, targetPlatform); + *luaContent, shortName, version, targetPlatform); if (!dm) return false; for (auto const& [generatedPath, _] : dm->buildConfig.generatedFiles) { if (!generatedPath.empty()) return true; @@ -2477,7 +2651,7 @@ prepare_build(bool print_fingerprint, auto field = mcpp::manifest::extract_mcpp_field(*luaContent); if (field.kind == mcpp::manifest::McppField::TableBody) { auto depManifest = mcpp::manifest::synthesize_from_xpkg_lua( - *luaContent, depName, version, targetPlatform); + *luaContent, shortName, version, targetPlatform); if (!depManifest) { return std::unexpected(std::format( "dependency '{}': {}", depName, depManifest.error().format())); @@ -2713,7 +2887,7 @@ prepare_build(bool print_fingerprint, if (auto r = loadFrom(matches.front()); !r) return std::unexpected(r.error()); } else if (field.kind == mcpp::manifest::McppField::TableBody) { auto dm = mcpp::manifest::synthesize_from_xpkg_lua( - *luaContent, depName, version, targetPlatform); + *luaContent, shortName, version, targetPlatform); if (!dm) return std::unexpected(std::format( "dependency '{}': {}", depName, dm.error().format())); warn_unknown_xpkg_keys(*dm, depName); @@ -3184,25 +3358,11 @@ prepare_build(bool print_fingerprint, } }; - // Stage a dep's source files into a fresh directory, rewriting their - // module / import declarations against `rename`. Used by the multi- - // version mangling fallback (Level 1) so two cross-major copies of - // the same package can coexist with distinct module names. - // - // Headers (referenced via `[build].include_dirs`) are NOT staged — - // those keep pointing at the original install dir via absolutized - // include paths. - auto stage_with_rewrite = [](const std::filesystem::path& srcRoot, - const std::filesystem::path& dstRoot, - const mcpp::manifest::Manifest& depManifest, - const std::map& rename) - -> std::expected + auto package_source_files = []( + const std::filesystem::path& srcRoot, + const mcpp::manifest::Manifest& depManifest) + -> std::expected, std::string> { - std::error_code ec; - std::filesystem::create_directories(dstRoot, ec); - if (ec) return std::unexpected(std::format( - "stage: cannot create '{}': {}", dstRoot.string(), ec.message())); - // Resolve the source globs against the original root, falling // back to the convention default if the manifest didn't set any. std::vector globs = depManifest.modules.sources; @@ -3228,8 +3388,32 @@ prepare_build(bool print_fingerprint, "stage: no source files found under '{}' (globs={})", srcRoot.string(), globs.size())); } + return sourceFiles; + }; + + // Stage a dep's source files into a fresh directory, rewriting their + // module / import declarations against `rename`. Used by the multi- + // version mangling fallback (Level 1) so two cross-major copies of + // the same package can coexist with distinct module names. + // + // Headers (referenced via `[build].include_dirs`) are NOT staged — + // those keep pointing at the original install dir via absolutized + // include paths. + auto stage_with_rewrite = [&](const std::filesystem::path& srcRoot, + const std::filesystem::path& dstRoot, + const mcpp::manifest::Manifest& depManifest, + const std::map& rename) + -> std::expected + { + std::error_code ec; + std::filesystem::create_directories(dstRoot, ec); + if (ec) return std::unexpected(std::format( + "stage: cannot create '{}': {}", dstRoot.string(), ec.message())); + + auto sources = package_source_files(srcRoot, depManifest); + if (!sources) return std::unexpected(sources.error()); - for (auto const& f : sourceFiles) { + for (auto const& f : *sources) { auto rel = std::filesystem::relative(f, srcRoot, ec); if (ec) return std::unexpected(std::format( "stage: cannot relativize '{}': {}", f.string(), ec.message())); @@ -3251,6 +3435,29 @@ prepare_build(bool print_fingerprint, return {}; }; + auto declared_modules_for = [&](const std::filesystem::path& srcRoot, + const mcpp::manifest::Manifest& depManifest) + -> std::expected, std::string> + { + auto sources = package_source_files(srcRoot, depManifest); + if (!sources) return std::unexpected(sources.error()); + std::vector modules; + for (auto const& file : *sources) { + std::ifstream is(file); + if (!is) return std::unexpected(std::format( + "mangle: cannot read '{}'", file.string())); + std::stringstream buf; buf << is.rdbuf(); + for (auto& name : mcpp::pm::declared_module_roots(buf.str())) { + if (std::ranges::find(modules, name) == modules.end()) + modules.push_back(std::move(name)); + } + } + if (modules.empty()) return std::unexpected(std::format( + "mangle: package '{}' declares no named C++ module to rewrite", + depManifest.package.name)); + return modules; + }; + // Stage 2a — feature-activated optional dependencies. Defined as local // lambdas (NOT file-scope functions): keeping their std::map instantiations // inside this implementation unit avoids polluting the exported module BMI, @@ -3502,14 +3709,22 @@ prepare_build(bool print_fingerprint, spec.version)); } - // Module names in the source files use the dep's full - // [package].name (e.g. "mcpplibs.cmdline"), not the - // namespaced-subtable shortName. Use that for the - // rename key so the rewriter actually matches what the - // .cppm sources declare. - const std::string moduleName = secondaryManifest.package.name; - std::string mangled = - mcpp::pm::mangle_name(moduleName, spec.version); + // Module names are authored API and are not required to + // mirror package identity. Discover every provided module + // root from the secondary's source text, then rewrite the + // same map in both the secondary and its consumer. + auto moduleNames = declared_modules_for( + secondaryRoot, secondaryManifest); + if (!moduleNames) return std::unexpected(moduleNames.error()); + std::map rename; + for (auto const& module : *moduleNames) { + rename.emplace(module, + mcpp::pm::mangle_name(module, spec.version)); + } + const auto& moduleName = moduleNames->front(); + const auto& mangledModule = rename.at(moduleName); + const std::string mangledPackage = mcpp::pm::mangle_name( + key.shortName, spec.version); // Stage layout: // /target/.mangled//__/ ← rewritten secondary source @@ -3519,10 +3734,9 @@ prepare_build(bool print_fingerprint, auto stageBase = *root / "target" / ".mangled" / consumerManifest.package.name; auto secStage = stageBase - / std::format("{}__{}", moduleName, spec.version); + / std::format("{}__{}", key.shortName, spec.version); auto consumerStage = stageBase / "__self__"; - std::map rename{ {moduleName, mangled} }; if (auto r = stage_with_rewrite(secondaryRoot, secStage, secondaryManifest, rename); !r) return std::unexpected(r.error()); @@ -3539,11 +3753,16 @@ prepare_build(bool print_fingerprint, // exact (ns, mangled) pair dedup cleanly. The original // primary entry (it->second) is untouched. auto stagedManifest = secondaryManifest; - // Update [package].name to the mangled module name so - // the modgraph validator (which checks "exported module - // must be prefixed by package name") accepts the - // rewritten sources. - stagedManifest.package.name = mangled; + // Give the staged package a distinct atomic identity too; + // authored module names remain independent and are carried + // exclusively by the rename map above. + stagedManifest.package.name = mangledPackage; + if (stagedManifest.package.namespace_.empty()) { + stagedManifest.package.namespace_ = key.ns.empty() + ? std::string(mcpp::pm::kDefaultNamespace) : key.ns; + } + stagedManifest.package.sourceProvenance = std::format( + "index+{}@{}", cache_index_name(key.ns), spec.version); // Absolutize secondary's include_dirs against its original // install root so the staged copy still finds headers. for (auto& inc : stagedManifest.buildConfig.includeDirs) { @@ -3557,7 +3776,7 @@ prepare_build(bool print_fingerprint, std::make_unique(std::move(stagedManifest))); dep_cache_identities.push_back({ .indexName = cache_index_name(key.ns), - .packageName = mangled, + .packageName = mangledPackage, .version = spec.version, .sourceKind = "version", }); @@ -3566,7 +3785,7 @@ prepare_build(bool print_fingerprint, recordDependencyEdge(item.consumerDepIndex, depPackageIndex, spec); auto linkFlagsAdded = propagateLinkFlags(secStage, *dep_manifests.back()); - ResolvedKey mangledKey{key.ns, mangled}; + ResolvedKey mangledKey{key.ns, mangledPackage}; resolved[mangledKey] = ResolvedRecord{ .version = spec.version, .constraint = item.originalConstraint, @@ -3579,7 +3798,8 @@ prepare_build(bool print_fingerprint, mcpp::ui::info("Mangled", std::format("{} v{} ↔ v{} → {} (cross-major fallback)", - moduleName, it->second.version, spec.version, mangled)); + moduleName, it->second.version, spec.version, + mangledModule)); continue; } @@ -3638,6 +3858,12 @@ prepare_build(bool print_fingerprint, expectedShort)); } } + if (newManifest.package.namespace_.empty()) { + newManifest.package.namespace_ = key.ns.empty() + ? std::string(mcpp::pm::kDefaultNamespace) : key.ns; + } + newManifest.package.sourceProvenance = std::format( + "index+{}@{}", cache_index_name(key.ns), *merged); removeLinkFlags(it->second.linkFlagsAdded); auto linkFlagsAdded = propagateLinkFlags(newRoot, newManifest); @@ -3913,6 +4139,26 @@ prepare_build(bool print_fingerprint, } } + // Stamp the identity with the resolver's exact coordinate and source. + // A descriptor that omitted namespace inherits the coordinate that + // answered it; otherwise two indices containing the same short name + // collapse in runtime provenance even though resolution distinguished + // them correctly. + if (dep_manifest->package.namespace_.empty()) { + dep_manifest->package.namespace_ = key.ns.empty() + ? std::string(mcpp::pm::kDefaultNamespace) : key.ns; + } + if (sourceKind == "version") { + dep_manifest->package.sourceProvenance = std::format( + "index+{}@{}", cache_index_name(key.ns), spec.version); + } else if (sourceKind == "git") { + dep_manifest->package.sourceProvenance = std::format( + "git+{}#{}={}", spec.git, spec.gitRefKind, spec.gitRev); + } else { + dep_manifest->package.sourceProvenance = + "path+" + dep_root.lexically_normal().generic_string(); + } + // Stage 2a: merge this dependency's active feature-deps into its own // dependency set before its children are pushed, so a dep's feature can // transitively pull a provider. `spec.features` = features the consumer @@ -4471,6 +4717,12 @@ prepare_build(bool print_fingerprint, sub.preloaded_manifest = std::make_shared( *dep_manifests[depIdx - 1]); + sub.inherited_runtime_selection = std::make_shared< + const mcpp::xlings::runtime::RuntimeSelection>( + runtimeSelection); + sub.inherited_runtime_binding = std::make_shared< + const mcpp::platform::runtime::RuntimeBinding>( + runtimeBindingSnapshot); sub.tool_chain = overrides.tool_chain.empty() ? std::format("root → {}:{}", depName, toolName) : std::format("{} → {}:{}", overrides.tool_chain, depName, @@ -4983,6 +5235,8 @@ prepare_build(bool print_fingerprint, ctx.manifest = *m; ctx.tc = *tc; ctx.fp = fp; + ctx.runtimeSelection = runtimeSelection; + ctx.runtimeBinding = runtimeBindingSnapshot; ctx.profile = effectiveProfile; ctx.cacheMode = cacheMode; ctx.projectRoot= *root; @@ -5010,6 +5264,9 @@ prepare_build(bool print_fingerprint, stdBmiPath, stdObjectPath, storeRoots); if (!planResult) return std::unexpected(planResult.error()); ctx.plan = std::move(*planResult); + ctx.plan.runtimeBinding = runtimeBindingSnapshot; + mcpp::build::merge_runtime_binding_contract( + ctx.plan, runtimeBindingSnapshot); ctx.plan.compileDbPath = workRoot / "compile_commands.json"; // GCC: a clean `*link:` for this build, so the payload's specs cannot // inject other homes' rpath entries into the artifact. AFTER the plan is @@ -5934,23 +6191,47 @@ prepare_build(bool print_fingerprint, } } - // Apply [runtime.] provider = "" overrides: prefer the - // named provider for matching capabilities (capability name prefix match). - // Warn if the named provider isn't in the dependency graph. + // Apply [runtime.] provider = "" overrides. Canonical + // identity wins; the old short spelling is accepted only when it denotes + // exactly one provider. A same-short-name collision is never guessed. for (auto& [capKey, prov] : ctx.manifest.runtimeConfig.providerOverrides) { - bool found = false; + std::vector candidates; + for (auto const& entry : ctx.plan.runtimeProviders) { + if (!entry.capability.starts_with(capKey)) continue; + const auto withoutVersion = entry.provider.namespace_.empty() + ? entry.provider.name + : entry.provider.namespace_ + "." + entry.provider.name; + if (entry.provider.canonical() == prov || withoutVersion == prov) + candidates = {entry.provider}; + } + if (candidates.empty()) { + for (auto const& entry : ctx.plan.runtimeProviders) { + if (entry.capability.starts_with(capKey) + && entry.provider.name == prov) + candidates.push_back(entry.provider); + } + } + std::ranges::sort(candidates); + candidates.erase(std::ranges::unique(candidates).begin(), candidates.end()); + if (candidates.empty()) { + return std::unexpected(std::format( + "[runtime.{}] provider = \"{}\" does not name a provider in " + "the resolved dependency graph", capKey, prov)); + } + if (candidates.size() != 1) { + std::string choices; + for (auto const& candidate : candidates) + choices += (choices.empty() ? "" : ", ") + candidate.canonical(); + return std::unexpected(std::format( + "[runtime.{}] provider = \"{}\" is ambiguous; use one exact " + "canonical identity: [{}]", capKey, prov, choices)); + } + const auto selected = candidates.front(); std::stable_partition(ctx.plan.runtimeProviders.begin(), ctx.plan.runtimeProviders.end(), [&](const auto& pr) { - bool match = pr.capability.rfind(capKey, 0) == 0 && pr.provider == prov; - found = found || match; - return match; + return pr.capability.starts_with(capKey) && pr.provider == selected; }); - if (!found) { - std::println(stderr, - "warning: [runtime.{}] provider = \"{}\" — no such provider in the " - "dependency graph for that capability", capKey, prov); - } } // Capability-driven ABI enforcement, dimensional (see src/toolchain/abi.cppm @@ -5969,7 +6250,7 @@ prepare_build(bool print_fingerprint, for (auto& cap : ctx.plan.runtimeCapabilities) { std::string provider; for (auto& [c, p] : ctx.plan.runtimeProviders) - if (c == cap) { provider = p; break; } + if (c == cap) { provider = p.canonical(); break; } if (auto con = mcpp::toolchain::parse_abi_capability( cap, provider.empty() ? std::string_view{"?"} : std::string_view{provider})) constraints.push_back(std::move(*con)); @@ -5988,35 +6269,132 @@ prepare_build(bool print_fingerprint, } } - // Per-build resolution manifest artifact: a machine-readable record of the - // resolved plan (toolchain/abi, runtime closure, capabilities+providers, - // deps) written next to the build outputs. Same data as `mcpp why`; usable - // by CI/tooling. (capability -> plan, serialized.) + // Per-build resolution manifest: the durable, provider-neutral facts that + // `mcpp why runtime` interprets without resolving again or probing the + // current host. The post-link validator replaces `validation.pending` + // with the exact artifact verdict produced at the link seam. { const std::string tcAbi = ctx.tc.targetTriple.find("musl") != std::string::npos ? "musl" : ctx.tc.stdlibId == "libc++" ? "libc++" : ctx.tc.compiler == mcpp::toolchain::CompilerId::MSVC ? "msvc" : "glibc"; + auto package_json = [](const mcpp::manifest::PackageId& id) { + return nlohmann::json{ + {"canonical", id.canonical()}, + {"namespace", id.namespace_}, + {"name", id.name}, + {"version", id.version}, + {"source", id.sourceProvenance}, + }; + }; + auto path_array = [](auto const& paths) { + nlohmann::json values = nlohmann::json::array(); + for (auto const& path : paths) + values.push_back(path.lexically_normal().generic_string()); + return values; + }; nlohmann::json j; + j["schema_version"] = 2; j["toolchain"] = { {"spec", ctx.tc.label()}, {"abi", tcAbi}, {"triple", ctx.tc.targetTriple}, {"stdlib", ctx.tc.stdlibId}, }; nlohmann::json dirs = nlohmann::json::array(); for (auto& d : ctx.plan.runtimeLibraryDirs) dirs.push_back(d.string()); - nlohmann::json caps = nlohmann::json::array(); + nlohmann::json legacyCaps = nlohmann::json::array(); + nlohmann::json providers = nlohmann::json::array(); for (auto& [cap, prov] : ctx.plan.runtimeProviders) - caps.push_back({{"capability", cap}, {"provider", prov}}); + { + legacyCaps.push_back({{"capability", cap}, + {"provider", prov.canonical()}}); + providers.push_back({{"capability", cap}, + {"provider", package_json(prov)}}); + } + nlohmann::json requirements = nlohmann::json::array(); + for (auto const& requirement : ctx.plan.runtimeRequirements) { + requirements.push_back({ + {"kind", requirement.kind}, + {"value", requirement.value}, + {"phase", requirement.phase}, + {"requester", package_json(requirement.requester)}, + {"required", requirement.required}, + }); + } + nlohmann::json artifacts = nlohmann::json::array(); + for (auto const& artifact : ctx.plan.runtimeArtifacts) { + artifacts.push_back({ + {"role", artifact.role}, + {"provider", package_json(artifact.provider)}, + {"path", artifact.path.lexically_normal().generic_string()}, + {"provenance", artifact.provenance}, + {"abi", artifact.abi}, + {"digest", artifact.digest}, + {"host_fingerprint", artifact.hostFingerprint}, + }); + } + nlohmann::json binding = nlohmann::json::parse( + mcpp::platform::runtime::serialize_runtime_binding( + ctx.plan.runtimeBinding), nullptr, false); + if (binding.is_discarded()) binding = nlohmann::json::object(); + + auto triple = ctx.tc.targetTriple; + std::ranges::transform(triple, triple.begin(), + [](unsigned char c) { return std::tolower(c); }); + const bool pe = triple.find("windows") != std::string::npos + || triple.find("mingw") != std::string::npos; + const bool macho = triple.find("darwin") != std::string::npos + || triple.find("apple") != std::string::npos; + std::string format = pe ? "pe" : macho ? "macho" : "elf"; + nlohmann::json search = { + {"format", format}, + {"link_library", pe ? "libpath" : "library_path"}, + {"transitive_needed", format == "elf" ? "rpath_link" : "none"}, + {"runtime", format == "pe" ? "deploy" + : format == "macho" ? "loader_rpath" : "runpath"}, + }; j["runtime"] = { {"library_dirs", dirs}, {"dlopen_libs", ctx.plan.runtimeDlopenLibs}, - {"capabilities", caps}, + {"capabilities", legacyCaps}, + {"binding", binding}, + {"requirements", requirements}, + {"artifacts", artifacts}, + {"providers", providers}, + {"link_intent", { + {"libraries", ctx.plan.linkIntent.libraries}, + {"link_library_dirs", + path_array(ctx.plan.linkIntent.linkLibraryDirs)}, + {"transitive_needed_dirs", + path_array(ctx.plan.linkIntent.transitiveNeededDirs)}, + {"runtime_search_dirs", + path_array(ctx.plan.linkIntent.runtimeSearchDirs)}, + {"frameworks", ctx.plan.linkIntent.frameworks}, + {"deploy_files", path_array(ctx.plan.linkIntent.deployFiles)}, + }}, + {"search", search}, + {"validation", { + {"status", format == "elf" ? "pending" : "not_exercised"}, + {"source", "post_link"}, + {"artifacts", nlohmann::json::array()}, + }}, }; std::error_code ec; std::filesystem::create_directories(ctx.plan.outputDir, ec); - if (std::ofstream js(ctx.plan.outputDir / "resolution.json"); js) + auto path = ctx.plan.outputDir / "resolution.json"; + auto tmp = path; + tmp += ".tmp"; + if (std::ofstream js(tmp); js) { js << j.dump(2) << "\n"; + js.close(); + std::filesystem::rename(tmp, path, ec); + if (ec) { + ec.clear(); + std::filesystem::remove(path, ec); + ec.clear(); + std::filesystem::rename(tmp, path, ec); + } + } } return ctx; diff --git a/src/build/runtime_validation.cppm b/src/build/runtime_validation.cppm new file mode 100644 index 00000000..24cd3876 --- /dev/null +++ b/src/build/runtime_validation.cppm @@ -0,0 +1,433 @@ +// mcpp.build.runtime_validation — validate only freshly linked Linux ELFs. +// +// The backend snapshots link outputs before ninja and compares their stat +// fingerprints afterwards. An unchanged no-op build therefore performs zero +// ELF parses. Verdicts are persisted beside build.ninja and keyed by artifact +// stat + RuntimeBinding contract so doctor can explain the last result without +// probing the host again. + +export module mcpp.build.runtime_validation; + +import std; +import mcpp.build.plan; +import mcpp.libs.json; +import mcpp.platform; +import mcpp.platform.elf_runtime; +import mcpp.platform.runtime_binding; + +export namespace mcpp::build::runtime_validation { + +struct ArtifactStamp { + bool exists = false; + std::uintmax_t size = 0; + std::int64_t mtime = 0; + + bool operator==(const ArtifactStamp&) const = default; +}; + +using ArtifactSnapshot = std::map; + +struct ValidatedArtifact { + std::filesystem::path artifact; + mcpp::platform::elf::RuntimeVerdict verdict; + bool cacheHit = false; +}; + +struct ValidationReport { + std::vector artifacts; + + bool has_proven_mismatch() const { + return std::ranges::any_of(artifacts, [](auto const& artifact) { + return artifact.verdict.status + == mcpp::platform::elf::RuntimeVerdict::Status::ProvenMismatch; + }); + } +}; + +struct StoredRuntimeSummary { + std::filesystem::path artifact; + mcpp::platform::elf::RuntimeVerdict verdict; + std::string contractHash; +}; + +ArtifactSnapshot snapshot_link_artifacts(const mcpp::build::BuildPlan& plan); + +ValidationReport validate_changed_artifacts( + const mcpp::build::BuildPlan& plan, + const ArtifactSnapshot& before); + +std::optional +latest_stored_verdict(const std::filesystem::path& targetRoot); + +// Fast paths may run ninja without reconstructing BuildPlan. They are allowed +// only when every stored artifact still has the stat/contract fingerprint that +// was validated. The returned snapshot can be compared after ninja; any +// change drops to the full path, which reconstructs the search closure and +// validates before reporting success/running the program. +std::optional validated_artifact_snapshot( + const std::filesystem::path& outputDir, + const mcpp::platform::runtime::RuntimeBinding& binding); + +bool artifact_snapshot_unchanged(const ArtifactSnapshot& snapshot); + +} // namespace mcpp::build::runtime_validation + +namespace mcpp::build::runtime_validation { +namespace { + +constexpr std::string_view kCacheFile = ".mcpp-runtime-verdicts.json"; + +ArtifactStamp stamp(const std::filesystem::path& path) { + ArtifactStamp out; + std::error_code ec; + if (!std::filesystem::is_regular_file(path, ec)) return out; + out.exists = true; + out.size = std::filesystem::file_size(path, ec); + if (ec) { out.exists = false; return out; } + auto time = std::filesystem::last_write_time(path, ec); + if (ec) { out.exists = false; return out; } + out.mtime = static_cast(time.time_since_epoch().count()); + return out; +} + +std::string fingerprint(const std::filesystem::path& artifact, + const ArtifactStamp& value, + std::string_view contractHash) { + auto input = std::format("{}\n{}\n{}\n{}", + artifact.generic_string(), value.size, value.mtime, contractHash); + std::uint64_t hash = 0xcbf29ce484222325ull; + for (unsigned char c : input) { + hash ^= c; + hash *= 0x100000001b3ull; + } + return std::format("{:016x}", hash); +} + +std::string status_name(mcpp::platform::elf::RuntimeVerdict::Status status) { + using Status = mcpp::platform::elf::RuntimeVerdict::Status; + switch (status) { + case Status::Pass: return "pass"; + case Status::ProvenMismatch: return "proven_mismatch"; + case Status::Inconclusive: return "inconclusive"; + } + return "inconclusive"; +} + +mcpp::platform::elf::RuntimeVerdict::Status +parse_status(std::string_view value) { + using Status = mcpp::platform::elf::RuntimeVerdict::Status; + if (value == "pass") return Status::Pass; + if (value == "proven_mismatch") return Status::ProvenMismatch; + return Status::Inconclusive; +} + +nlohmann::json read_cache(const std::filesystem::path& outputDir) { + std::ifstream input(outputDir / kCacheFile); + if (!input) return nlohmann::json::object(); + auto doc = nlohmann::json::parse(input, nullptr, false); + if (doc.is_discarded() || !doc.is_object()) return nlohmann::json::object(); + return doc; +} + +void write_cache(const std::filesystem::path& outputDir, + const nlohmann::json& doc) { + std::error_code ec; + std::filesystem::create_directories(outputDir, ec); + auto path = outputDir / kCacheFile; + auto tmp = outputDir / (std::string(kCacheFile) + ".tmp"); + { + std::ofstream output(tmp, std::ios::trunc); + if (!output) return; + output << doc.dump(2) << '\n'; + if (!output) return; + } + std::filesystem::rename(tmp, path, ec); + if (ec) { + ec.clear(); + std::filesystem::remove(path, ec); + ec.clear(); + std::filesystem::rename(tmp, path, ec); + } + if (ec) std::filesystem::remove(tmp, ec); +} + +std::string cache_key(const mcpp::build::BuildPlan& plan, + const std::filesystem::path& artifact) { + std::error_code ec; + auto relative = std::filesystem::relative(artifact, plan.outputDir, ec); + return ec ? artifact.lexically_normal().generic_string() + : relative.lexically_normal().generic_string(); +} + +std::vector +runtime_search_dirs(const mcpp::build::BuildPlan& plan) { + std::vector out; + auto append = [&](auto const& dirs) { + for (auto const& dir : dirs) { + if (dir.empty() || std::ranges::find(out, dir) != out.end()) continue; + out.push_back(dir); + } + }; + append(plan.runtimeLibraryDirs); + append(plan.depRuntimeLibraryDirs); + append(plan.toolchain.compilerRuntimeDirs); + append(plan.runtimeBinding.libraryDirs); + return out; +} + +std::optional cached_artifact( + const nlohmann::json& doc, + std::string_view key, + std::string_view expectedFingerprint, + const std::filesystem::path& path) { + try { + auto artifacts = doc.find("artifacts"); + if (artifacts == doc.end() || !artifacts->is_object()) return std::nullopt; + auto it = artifacts->find(std::string(key)); + if (it == artifacts->end() || !it->is_object() + || it->value("fingerprint", "") != expectedFingerprint) + return std::nullopt; + ValidatedArtifact out; + out.artifact = path; + out.cacheHit = true; + out.verdict.status = parse_status(it->value("status", "inconclusive")); + out.verdict.diagnostics = it->value( + "diagnostics", std::vector{}); + return out; + } catch (...) { + return std::nullopt; + } +} + +void store_artifact(nlohmann::json& doc, + std::string_view key, + std::string_view artifactFingerprint, + const ValidatedArtifact& value) { + if (!doc.contains("artifacts") || !doc["artifacts"].is_object()) + doc["artifacts"] = nlohmann::json::object(); + doc["artifacts"][std::string(key)] = { + {"fingerprint", artifactFingerprint}, + {"status", status_name(value.verdict.status)}, + {"diagnostics", value.verdict.diagnostics}, + }; +} + +void sync_resolution_verdict(const mcpp::build::BuildPlan& plan, + const nlohmann::json& cache) { + const auto path = plan.outputDir / "resolution.json"; + std::ifstream input(path); + auto resolution = nlohmann::json::parse(input, nullptr, false); + if (resolution.is_discarded() || !resolution.is_object()) return; + auto runtime = resolution.find("runtime"); + if (runtime == resolution.end() || !runtime->is_object()) return; + + nlohmann::json checked = nlohmann::json::array(); + using Status = mcpp::platform::elf::RuntimeVerdict::Status; + Status summary = Status::Pass; + bool any = false; + if (auto artifacts = cache.find("artifacts"); + artifacts != cache.end() && artifacts->is_object()) { + for (auto it = artifacts->begin(); it != artifacts->end(); ++it) { + if (!it.value().is_object()) continue; + any = true; + auto status = parse_status(it.value().value("status", "inconclusive")); + if (status == Status::ProvenMismatch + || (status == Status::Inconclusive && summary == Status::Pass)) + summary = status; + checked.push_back({ + {"path", (plan.outputDir / it.key()).lexically_normal().generic_string()}, + {"status", status_name(status)}, + {"diagnostics", it.value().value( + "diagnostics", std::vector{})}, + {"fingerprint", it.value().value("fingerprint", "")}, + }); + } + } + const bool hasCheckableOutput = std::ranges::any_of( + plan.linkUnits, [](auto const& unit) { + return unit.kind != mcpp::build::LinkUnit::StaticLibrary; + }); + (*runtime)["validation"] = { + {"status", any ? status_name(summary) + : hasCheckableOutput ? "pending" : "not_exercised"}, + {"source", "post_link"}, + {"contract_hash", plan.runtimeBinding.contractHash}, + {"artifacts", std::move(checked)}, + }; + + std::error_code ec; + auto tmp = path; + tmp += ".tmp"; + if (std::ofstream output(tmp); output) { + output << resolution.dump(2) << '\n'; + output.close(); + std::filesystem::rename(tmp, path, ec); + if (ec) { + ec.clear(); + std::filesystem::remove(path, ec); + ec.clear(); + std::filesystem::rename(tmp, path, ec); + } + } +} + +} // namespace + +ArtifactSnapshot snapshot_link_artifacts(const mcpp::build::BuildPlan& plan) { + ArtifactSnapshot out; + for (auto const& unit : plan.linkUnits) { + if (unit.kind == mcpp::build::LinkUnit::StaticLibrary) continue; + auto artifact = plan.outputDir / unit.output; + out.emplace(artifact, stamp(artifact)); + } + return out; +} + +ValidationReport validate_changed_artifacts( + const mcpp::build::BuildPlan& plan, + const ArtifactSnapshot& before) { + ValidationReport report; + if constexpr (!mcpp::platform::is_linux) return report; + if (plan.runtimeBinding.platform != "linux" + || !plan.runtimeBinding.runtimeId.starts_with("glibc@")) + return report; + + auto doc = read_cache(plan.outputDir); + bool changedCache = false; + if (doc.value("schema", 0) != 1 + || doc.value("contract_hash", "") != plan.runtimeBinding.contractHash) { + doc = nlohmann::json::object(); + changedCache = true; + } + doc["schema"] = 1; + doc["contract_hash"] = plan.runtimeBinding.contractHash; + auto searchDirs = runtime_search_dirs(plan); + std::set currentKeys; + for (auto const& [artifact, ignored] : before) { + (void)ignored; + currentKeys.insert(cache_key(plan, artifact)); + } + if (auto artifacts = doc.find("artifacts"); + artifacts != doc.end() && artifacts->is_object()) { + for (auto it = artifacts->begin(); it != artifacts->end();) { + if (!currentKeys.contains(it.key())) { + it = artifacts->erase(it); + changedCache = true; + } else { + ++it; + } + } + } + for (auto const& [artifact, oldStamp] : before) { + auto now = stamp(artifact); + if (!now.exists) continue; + + auto key = cache_key(plan, artifact); + auto fp = fingerprint(artifact, now, plan.runtimeBinding.contractHash); + if (auto cached = cached_artifact(doc, key, fp, artifact)) { + // Same stat before/after and a current stored verdict is the hot + // no-op: do not parse. Only PASS may also stay silent; a stored + // mismatch must keep failing and an inconclusive result must keep + // explaining itself on every invocation. + if (!(now == oldStamp) + || cached->verdict.status + != mcpp::platform::elf::RuntimeVerdict::Status::Pass) + report.artifacts.push_back(std::move(*cached)); + continue; + } + + ValidatedArtifact validated; + validated.artifact = artifact; + auto resolution = mcpp::platform::elf::resolve_runtime_closure( + artifact, plan.runtimeBinding, searchDirs); + validated.verdict = mcpp::platform::elf::validate_runtime_artifact( + artifact, plan.runtimeBinding, resolution); + store_artifact(doc, key, fp, validated); + changedCache = true; + report.artifacts.push_back(std::move(validated)); + } + if (changedCache) write_cache(plan.outputDir, doc); + sync_resolution_verdict(plan, doc); + return report; +} + +std::optional validated_artifact_snapshot( + const std::filesystem::path& outputDir, + const mcpp::platform::runtime::RuntimeBinding& binding) { + auto doc = read_cache(outputDir); + if (doc.value("schema", 0) != 1 + || doc.value("contract_hash", "") != binding.contractHash) + return std::nullopt; + auto artifacts = doc.find("artifacts"); + if (artifacts == doc.end() || !artifacts->is_object() || artifacts->empty()) + return std::nullopt; + + ArtifactSnapshot out; + for (auto it = artifacts->begin(); it != artifacts->end(); ++it) { + if (!it.value().is_object()) return std::nullopt; + if (parse_status(it.value().value("status", "inconclusive")) + != mcpp::platform::elf::RuntimeVerdict::Status::Pass) + return std::nullopt; + auto artifact = outputDir / it.key(); + auto current = stamp(artifact); + if (!current.exists) return std::nullopt; + auto expected = fingerprint(artifact, current, binding.contractHash); + if (it.value().value("fingerprint", "") != expected) + return std::nullopt; + out.emplace(std::move(artifact), current); + } + return out; +} + +bool artifact_snapshot_unchanged(const ArtifactSnapshot& snapshot) { + return std::ranges::all_of(snapshot, [](auto const& entry) { + return stamp(entry.first) == entry.second; + }); +} + +std::optional +latest_stored_verdict(const std::filesystem::path& targetRoot) { + std::error_code ec; + if (!std::filesystem::is_directory(targetRoot, ec)) return std::nullopt; + std::filesystem::path newest; + std::filesystem::file_time_type newestTime{}; + bool found = false; + for (auto it = std::filesystem::recursive_directory_iterator( + targetRoot, std::filesystem::directory_options::skip_permission_denied, ec); + !ec && it != std::filesystem::recursive_directory_iterator{}; + it.increment(ec)) { + if (!it->is_regular_file(ec) || it->path().filename() != kCacheFile) continue; + auto time = it->last_write_time(ec); + if (ec) { ec.clear(); continue; } + if (!found || time > newestTime) { + found = true; + newest = it->path(); + newestTime = time; + } + } + if (!found) return std::nullopt; + auto doc = read_cache(newest.parent_path()); + auto artifacts = doc.find("artifacts"); + if (artifacts == doc.end() || !artifacts->is_object()) return std::nullopt; + + StoredRuntimeSummary summary; + summary.contractHash = doc.value("contract_hash", ""); + using Status = mcpp::platform::elf::RuntimeVerdict::Status; + summary.verdict.status = Status::Pass; + for (auto it = artifacts->begin(); it != artifacts->end(); ++it) { + if (!it.value().is_object()) continue; + auto status = parse_status(it.value().value("status", "inconclusive")); + const bool worse = status == Status::ProvenMismatch + || (status == Status::Inconclusive && summary.verdict.status == Status::Pass); + if (!worse && !summary.artifact.empty()) continue; + summary.artifact = newest.parent_path() / it.key(); + summary.verdict.status = status; + summary.verdict.diagnostics = it.value().value( + "diagnostics", std::vector{}); + if (status == Status::ProvenMismatch) break; + } + if (summary.artifact.empty()) return std::nullopt; + return summary; +} + +} // namespace mcpp::build::runtime_validation diff --git a/src/cli.cppm b/src/cli.cppm index 2290fb5c..8ad3912b 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -57,8 +57,8 @@ void print_usage() { std::println(" mcpp run [target] [-- args...] Build + run a binary target"); std::println(" mcpp test [pattern] [-- args...] Build + run tests/**/*.cpp (--list, --timeout, --build-timeout, --message-format json)"); std::println(" mcpp clean [--bmi-cache] Remove target/ (and optionally the build cache)"); - std::println(" mcpp add [@] Add a dependency to mcpp.toml"); - std::println(" mcpp remove Remove a dependency from mcpp.toml"); + std::println(" mcpp add [ns.]pkg@ver Add an exact dependency to mcpp.toml"); + std::println(" mcpp remove [ns.]pkg Remove an exact dependency from mcpp.toml"); std::println(" mcpp update [pkg] Re-resolve deps and rewrite mcpp.lock"); std::println(" mcpp search Search packages in registries"); std::println(" mcpp publish [--dry-run] Publish package to default registry"); @@ -82,7 +82,7 @@ void print_usage() { std::println("Build options:"); std::println(" --verbose, -v Verbose compiler output"); std::println(" --quiet, -q Suppress status output"); - std::println(" --print-fingerprint Show toolchain fingerprint and 10 inputs"); + std::println(" --print-fingerprint Show toolchain fingerprint and 11 inputs"); std::println(" --cache Dependency cache: global (default) | local | off"); std::println(" --no-cache Deprecated alias for --cache=off (clears the build dir)"); std::println(" --no-color Disable colored output"); @@ -229,14 +229,14 @@ int run(int argc, char** argv) { // (cmd_new validates presence for project creation itself). .arg(cl::Arg("name").help("Package directory name")) .option(cl::Option("template").short_name('t').takes_value().value_name("SPEC") - .help("bin (default) | [@ver][: