From 2ea1b7c2fee41e70179d8ae97442b8451cba67e1 Mon Sep 17 00:00:00 2001 From: speak-agent Date: Fri, 7 Aug 2026 07:50:12 +0800 Subject: [PATCH 1/8] =?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)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两个 issue 领域无关,失效形状同一条:生态已经产出的东西 mcpp 的模型 表达不了,于是走到一条「不报错但结果是错的」路径上。 #365 Windows 资源 - 新增 `[resources]`:icon 与版本信息一行搞定,元数据从 [package] 取默认值; 自写 .rc 走 files = [...],被当作构建输入跟踪。 - 只有 PE 目标消费;非 PE 上「不适用」(不做事、不警告、逐字节不变), 因此不需要也不能用 cfg(windows) —— 条件通道只载 BuildInputs。 - 声明了却不存在的文件是硬错误(对 issue 第 3 条的有意偏离)。 - 新增 BuildAction::Role::Object:产出接到链接输入,补齐角色表原本缺的 那一格;ldflags 塞路径不产生任何 implicit input,正是本 issue 的成因。 ⚠️ issue 结尾「llvm-rc 生成的 VERSIONINFO 不被解析」的归因是错的,已实测 证伪:VS_VERSION_INFO 是 的宏,没有它时资源被存成字符串名而 非序号 1,而类型两种情况都是 RT_VERSION(16) —— 这正是它看着正常的原因。 合成脚本写字面 1,构造性正确;自写脚本命中这个形状时给出指名的警告。 #363 版本身份 - resolve_semver 返回索引的字面键,不再从解析出的数字重造地址。 真实索引里被这条修好的:1.92.8-docking(预发布塌成 1.92.8)、 25.0.4.7.1(五段截断成不存在的 25.0.4.7)、25.0.4={ref=…}(别名与 自己的目标构成平局)、pre-v0.0.5(报「no valid versions in index」)。 - 预发布按 SemVer 排序 + npm 预发布可见性规则;数值段任意长度; 别名不参与范围候选;不可排序键只精确匹配并给出可粘贴的 pin 行; 只差 build metadata 的真平局硬错,而精确形式按字面命中。 - mcpp.lock 记录解析结果并覆盖传递依赖,Compiling 行同源;两者原本读的 都是未解析的 m->dependencies,而 ResolvedRecord 早就覆盖整张图。 lock 本批不「权威化」,文件头自己声明这一点(e2e 断言,改时会红)。 实施中撞出来的(设计里没有):非 ASCII 元数据会让 rc 编译器拒绝整个脚本, 必须传 UTF-8 codepage,否则中文描述的项目根本构建不了。 设计与实测证据:.agents/docs/2026-08-07-windows-resources-and-version-identity-design.md --- ...s-resources-and-version-identity-design.md | 444 ++++++++++++++++++ .github/workflows/cross-build-test.yml | 10 + CHANGELOG.md | 48 ++ docs/05-mcpp-toml.md | 94 ++++ docs/07-build-mcpp.md | 18 +- docs/zh/05-mcpp-toml.md | 83 ++++ docs/zh/07-build-mcpp.md | 14 +- mcpp.toml | 2 +- src/build/directives.cppm | 2 + src/build/execute.cppm | 11 +- src/build/hostprogram.cppm | 10 +- src/build/ninja_backend.cppm | 54 ++- src/build/plan.cppm | 31 ++ src/build/prepare.cppm | 398 ++++++++++++++-- src/build/resources.cppm | 422 +++++++++++++++++ src/manifest/toml.cppm | 59 +++ src/manifest/types.cppm | 84 +++- src/manifest/xpkg.cppm | 120 ++++- src/pm/lock_io.cppm | 13 + src/pm/resolver.cppm | 124 ++++- src/version.cppm | 2 +- src/version_req.cppm | 327 ++++++++++--- tests/e2e/169_semver_project_index.sh | 9 +- tests/e2e/188_build_actions.sh | 104 +++- tests/e2e/196_version_identity_and_lock.sh | 178 +++++++ tests/e2e/197_windows_resources.sh | 14 + tests/e2e/198_windows_resources_cross.sh | 43 ++ tests/e2e/_windows_resources_body.sh | 219 +++++++++ tests/unit/test_build_resources.cpp | 217 +++++++++ tests/unit/test_manifest.cpp | 56 +++ tests/unit/test_version_req.cpp | 140 +++++- 31 files changed, 3192 insertions(+), 158 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 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..3693ab87 --- /dev/null +++ b/.agents/docs/2026-08-07-windows-resources-and-version-identity-design.md @@ -0,0 +1,444 @@ +# 两处「模型比生态少一层」: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 里更正。 + +## 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` 自动生效。 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..77ce176c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,54 @@ > 本文件追踪 `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 要消灭的失效模式写成规定行为——一个没有图标、没有版本信息、且什么都没说的正式二进制。不要图标已经可表达:把那一行删掉。 + +- **`role = "object"`:build.mcpp 的 action 现在能把产物接到链接输入上。** 角色表原本三格接在「编译输入 / 无 / 链接输出」上,缺的正是「链接输入」——一个构建图显然有的接线点。后果不是理论上的:预编译对象只能塞进 `[build].ldflags`,而那是链接命令里的一串字符、不是图里的文件,于是改了图标得到 `ninja: no work to do`。可选 `.target("name")` 指定接哪条边,省略 = 声明包的全部镜像;未知名字报错而不是静默不接。 + +### 修复 + +- **解析出的版本现在是索引里的字面键,不是重新渲染的数字(#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 依赖仍每次从约束重新解析)。一个记着真实版本却不生效的文件,比一个明显记着范围的文件更容易被误当权威。 + +### 其他 + +- 版本号 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 目录名随之变化。 + ## [2026.8.5.4] — 2026-08-06 命令长度这一族缺陷的**第七次**,这次不再补洞。架构分析见 `.agents/docs/2026-08-06-command-length-architecture.md`。 diff --git a/docs/05-mcpp-toml.md b/docs/05-mcpp-toml.md index 0695538b..07be1955 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -1151,6 +1151,100 @@ 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 consume this.** On Linux and macOS the section is +*inapplicable*: no work, no warning, 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.** A resource is a build +input like a source file; mcpp will not quietly ship a binary without it. 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 for every image this package produces + .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..2fa1dad6 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,33 @@ 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; omit it +and the outputs attach to every image (binary / shared library) the declaring +package produces. It needs the name because, unlike `artifact`, it runs *before* +the link and so has no `${mcpp.target_file:…}` to infer one from — an unknown +name is an error rather than an edge that quietly attaches to 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..3e44ccc6 100644 --- a/docs/zh/05-mcpp-toml.md +++ b/docs/zh/05-mcpp-toml.md @@ -884,6 +884,89 @@ 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 不会 +悄悄产出一个缺了它的二进制。不想要图标,把那一行删掉。 + +**版本字段。** `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..03f84a52 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,27 @@ 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:…}` 可以反推 —— 未知名字是错误,而不是一条静默不接的边。 + +> 把预编译对象写进 `[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..b66e64cb 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)); diff --git a/src/build/hostprogram.cppm b/src/build/hostprogram.cppm index d309932e..8b2af85e 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,11 @@ 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". 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 +91,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 +100,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..9e555b2d 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -69,6 +69,27 @@ 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; + std::string packageName; +}; + struct BuildPlan { mcpp::manifest::Manifest manifest; mcpp::toolchain::Toolchain toolchain; @@ -97,6 +118,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..985b9757 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: @@ -4950,6 +4959,54 @@ 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; + for (auto const& o : a.outputs) { + bool attached = false; + for (auto& lu : ctx.plan.linkUnits) { + const bool image = lu.kind == mcpp::build::LinkUnit::Binary + || lu.kind == mcpp::build::LinkUnit::SharedLibrary; + const bool wanted = a.targets.empty() + ? image + : std::find(a.targets.begin(), a.targets.end(), + lu.targetName) != a.targets.end(); + if (!wanted) continue; + lu.objects.emplace_back(o); + attached = true; + } + if (!attached && !a.targets.empty()) + for (auto const& t : a.targets) { + bool known = false; + for (auto const& lu : ctx.plan.linkUnits) + if (lu.targetName == t) known = true; + if (!known) unknownObjectTargets.insert(t); + } + } + } + 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)", + bad, known.empty() ? std::string("none") : known)); + } } ctx.plan.stdCompatBmiPath = stdCompatBmiPath; ctx.plan.stdCompatObjectPath = stdCompatObjectPath; @@ -5031,6 +5088,251 @@ prepare_build(bool print_fingerprint, } } + // ─── Windows resources: [resources] → a tracked link input (mcpp#365) ── + // + // Three rules decide whether anything happens here, 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. On a non-PE target the section is INAPPLICABLE — no work, 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). + // 3. A declared file that does not exist is a hard error. Every other + // declared input in mcpp behaves this way, and "missing → skip" is how + // a release binary ships with no icon and nothing says so. + if (m->resources.declared()) { + namespace rsrc = mcpp::build::resources; + const auto trip = mcpp::toolchain::triple::parse(tc->targetTriple) + .value_or(mcpp::toolchain::triple::host_triple()); + if (trip.is_pe()) { + const auto dialectId = mcpp::toolchain::dialect_for(*tc).id; + const auto& R = m->resources; + + 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); + } + + 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. + 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. + if (peUnits.empty()) { + mcpp::diag::warning("resources/no-image", std::format( + "[resources] is declared but '{}' produces no executable or " + "shared library for {} — nothing to embed the resources into", + m->package.name, trip.str())); + } else { + + // 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); + ru.packageName = m->package.name; + 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& f : R.files) { + auto r = resolve_declared(f, "files"); + if (!r) return std::unexpected(r.error()); + auto scan = rsrc::scan_rc(*r); + if (scan.versionInfoNamedByString) { + // The mcpp#365 silent failure, caught on the way in. Only a + // warning: the file may define the macro somewhere this + // scanner cannot see. + mcpp::diag::warning("resources/versioninfo", std::format( + "{}: `{} VERSIONINFO` names the version resource '{}' " + "instead of ordinal 1, so Windows will not find it " + "(GetFileVersionInfo looks up MAKEINTRESOURCE(1) and " + "every field comes back empty). VS_VERSION_INFO is a " + "macro from ; add `#include ` to " + "the script, or write `1 VERSIONINFO`.", + r->filename().generic_string(), scan.versionInfoName, + scan.versionInfoName)); + } + for (auto const& g : scan.gaps) { + mcpp::diag::degraded("resources/inputs", + std::format("{}: `{}` names its file through a macro, so " + "mcpp cannot track it", + r->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(*r, r->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()) { + // 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; + + // 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. + // 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"); + + 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; + std::string_view rest = ev.value; + while (!rest.empty()) { + const auto sep = rest.find(';'); + auto dir = rest.substr(0, sep); + if (!dir.empty()) ctx.plan.rcFlags.push_back(ip + std::string(dir)); + if (sep == std::string_view::npos) break; + rest = rest.substr(sep + 1); + } + } + } + } + } // peUnits non-empty + } + } + // ─── Global dependency cache: per-package keys, hit → stage edges ── // // Every index package gets a key over the axes that actually reach its @@ -5346,10 +5648,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 +5684,60 @@ 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; + 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..bf6b1df3 --- /dev/null +++ b/src/build/resources.cppm @@ -0,0 +1,422 @@ +// 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); + +// ─── 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::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. + 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; + std::string_view rest = ev.value; + while (!rest.empty()) { + const auto sep = rest.find_first_of(";:"); + const auto dir = rest.substr(0, sep); + if (!dir.empty()) + if (auto p = probe_dir(std::filesystem::path(dir), names)) + return RcTool{*p, "msvc"}; + if (sep == std::string_view::npos) break; + rest = rest.substr(sep + 1); + } + } + 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..bedc8bdb 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,39 @@ 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 image + // (binary / shared library) of the declaring package. + // + // 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 an + // unknown name is an error rather than a silently unattached edge. + 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 +269,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 +624,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..3f8bb386 100644 --- a/src/pm/lock_io.cppm +++ b/src/pm/lock_io.cppm @@ -133,6 +133,19 @@ 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"; out += std::format("version = {}\n", lock.schemaVersion); // Write [indices.] sections. diff --git a/src/pm/resolver.cppm b/src/pm/resolver.cppm index 16d01a26..1a83a010 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,34 @@ 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. + { + auto exact = constraint; + if (exact.starts_with('=')) exact.remove_prefix(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 +162,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+) ─────────── 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..c22ba830 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,104 @@ 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; } + +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..e72921c7 --- /dev/null +++ b/tests/e2e/196_version_identity_and_lock.sh @@ -0,0 +1,178 @@ +#!/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 (same for all platforms) + cat > "local-index/pkgs/a/acme.$1.lua" < ".mcpp/.xlings/data/xpkgs/acme.$1/$2/src/gadget.cppm" +} +seed im 1.92.8 +seed jdk 25.0.4.7.1 + +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 + +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..bd21e831 --- /dev/null +++ b/tests/e2e/198_windows_resources_cross.sh @@ -0,0 +1,43 @@ +#!/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; } + +echo "OK" diff --git a/tests/e2e/_windows_resources_body.sh b/tests/e2e/_windows_resources_body.sh new file mode 100644 index 00000000..e0329c6f --- /dev/null +++ b/tests/e2e/_windows_resources_body.sh @@ -0,0 +1,219 @@ +# 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; } + +# 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 1x1 32bpp icon: ICONDIR + ICONDIRENTRY + +# BITMAPINFOHEADER + one BGRA pixel + AND mask. The pixel is ff0000ff so the +# payload can be found again inside the linked image. +write_icon() { # $1 = BGRA pixel bytes as printf escapes + printf '\x00\x00\x01\x00\x01\x00\x01\x01\x00\x00\x01\x00\x20\x00\x30\x00\x00\x00\x16\x00\x00\x00\x28\x00\x00\x00\x01\x00\x00\x00\x02\x00\x00\x00\x01\x00\x20\x00\x00\x00\x00\x00\x08\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 +} +write_icon '\xff\x00\x00\xff' + +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 readable form, wherever llvm-readobj is around (it ships +# in the LLVM payload). Worth having in BOTH shapes because the type line is +# identical either way — `Type: VERSIONINFO (ID 16)` is exactly what convinced +# the reporter the resource was fine. The name is the discriminator: +# +# Name: (ID 1) ← Windows finds it +# Name: VS_VERSION_INFO ← Windows does not +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) +if [ -n "$READOBJ" ]; then + "$READOBJ" --coff-resources "$RES_ART" > readobj.log 2>&1 || true + if grep -q 'VERSIONINFO' readobj.log; then + grep -q 'Name: (ID 1)' readobj.log \ + || fail "the version resource is not named by ordinal 1" readobj.log + fi +fi + +# ── The resource actually reached the linked image ──────────────────────── +EXE="$BUILD_DIR/bin/resapp$EXE_SUFFIX" +[ -f "$EXE" ] || fail "no executable at $EXE" b1.log +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 'ff0000ff' \ + || 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 '\x00\xff\x00\xff' # a different pixel: same size, new bytes +"$MCPP" build $BUILD_ARGS > b3.log 2>&1 || fail "rebuild after icon change failed" b3.log +hexof "$BUILD_DIR/bin/resapp$EXE_SUFFIX" | grep -q '00ff00ff' \ + || fail "editing the icon did not reach the executable (the #365 symptom)" 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 ── +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 + +# ── 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..5e087c30 --- /dev/null +++ b/tests/unit/test_build_resources.cpp @@ -0,0 +1,217 @@ +#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"); +} + +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 74b87a84a187cbd1824515f00bf9d08d6068eebf Mon Sep 17 00:00:00 2001 From: speak-agent Date: Fri, 7 Aug 2026 08:04:18 +0800 Subject: [PATCH 2/8] =?UTF-8?q?fix(resources):=20.rc=20=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E8=A6=81=E8=83=BD=E7=A9=BF=E8=BF=87=E5=B7=A5=E7=A8=8B=E7=BA=A7?= =?UTF-8?q?=20fast=20path,=E5=B9=B6=E8=AE=A9=20e2e=20=E6=96=AD=E8=A8=80?= =?UTF-8?q?=E7=9C=9F=E7=9A=84=E8=83=BD=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows e2e 抓到的:一次只改 `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"`, 那个头文件永远不会被跟踪。与 `build.mcpp`、glob 输入是同一类输入:**改了它, 图本身应该长得不一样**,而 mtime 扫描看不见。 只扫 `files`。`icon` 与 `extra-inputs` 已经是 ninja 的 implicit input,改它们 不会改变图的形状,为一次改图标强制走完整 prepare 买不到任何东西。 顺带修掉两处**我自己写的假绿**: - 图标断言原来搜 4 字节(`00ff00ff`),在 MB 级二进制里撞上是常事 —— 换成 4 像素 icon 的 16 字节高熵标记,并加断言「旧标记必须消失」。 (Linux 上之所以过,很可能就是撞上了。) - b3 之所以在 Windows 上没暴露 fast path 问题,正是因为那条弱断言。 验证:同一工程连构两次(第二次 0.00s、无 prepare),只改 .rc 后第三次 prepare 重跑且诊断触发。 --- src/build/execute.cppm | 32 +++++++++++++++++++++++++--- tests/e2e/_windows_resources_body.sh | 29 +++++++++++++++++-------- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/src/build/execute.cppm b/src/build/execute.cppm index b66e64cb..6a0e9b8c 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -447,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 @@ -465,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" && @@ -556,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 @@ -567,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, }; } @@ -631,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{}; @@ -706,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/tests/e2e/_windows_resources_body.sh b/tests/e2e/_windows_resources_body.sh index e0329c6f..99ee96f2 100644 --- a/tests/e2e/_windows_resources_body.sh +++ b/tests/e2e/_windows_resources_body.sh @@ -28,13 +28,21 @@ utf16hex() { mkdir -p "$TMP/proj/src" "$TMP/proj/assets" cd "$TMP/proj" -# A minimal but structurally valid 1x1 32bpp icon: ICONDIR + ICONDIRENTRY + -# BITMAPINFOHEADER + one BGRA pixel + AND mask. The pixel is ff0000ff so the -# payload can be found again inside the linked image. -write_icon() { # $1 = BGRA pixel bytes as printf escapes - printf '\x00\x00\x01\x00\x01\x00\x01\x01\x00\x00\x01\x00\x20\x00\x30\x00\x00\x00\x16\x00\x00\x00\x28\x00\x00\x00\x01\x00\x00\x00\x02\x00\x00\x00\x01\x00\x20\x00\x00\x00\x00\x00\x08\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 +# 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 } -write_icon '\xff\x00\x00\xff' +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 @@ -111,7 +119,7 @@ EXE="$BUILD_DIR/bin/resapp$EXE_SUFFIX" 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 'ff0000ff' \ +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 ──────────────────────────────── @@ -123,10 +131,13 @@ echo "$EXE_HEX" | grep -q 'ff0000ff' \ grep -qE 'no work to do|Finished' b2.log || fail "unexpected rebuild output" b2.log sleep 1 -write_icon '\x00\xff\x00\xff' # a different pixel: same size, new bytes +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 -hexof "$BUILD_DIR/bin/resapp$EXE_SUFFIX" | grep -q '00ff00ff' \ +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. From faf0ec08fdaa584d23631140bc719b94e494e525 Mon Sep 17 00:00:00 2001 From: speak-agent Date: Fri, 7 Aug 2026 08:05:32 +0800 Subject: [PATCH 3/8] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=20fast=20path=20?= =?UTF-8?q?=E4=B8=8E=E5=81=87=E7=BB=BF=E6=96=AD=E8=A8=80=E4=B8=A4=E6=9D=A1?= =?UTF-8?q?=E5=AE=9E=E6=96=BD=E5=8F=91=E7=8E=B0(#365)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...windows-resources-and-version-identity-design.md | 13 +++++++++++++ CHANGELOG.md | 2 ++ 2 files changed, 15 insertions(+) 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 index 3693ab87..81192182 100644 --- 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 @@ -411,6 +411,19 @@ std::map resolved; // 覆盖整张图,含传 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 工具并硬失败——为一次没有消费者的编译要求一个工具。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 77ce176c..bdf0c019 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,8 @@ lock 头部现在自己声明**它还不 pin 后续构建**(index 依赖仍每次从约束重新解析)。一个记着真实版本却不生效的文件,比一个明显记着范围的文件更容易被误当权威。 +- **`.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**。 From c624a79e8ab4d3850a9c13408e1d19cd723c0fe8 Mon Sep 17 00:00:00 2001 From: speak-agent Date: Fri, 7 Aug 2026 15:49:31 +0800 Subject: [PATCH 4/8] =?UTF-8?q?fix:=20review=20=E8=BD=AE=20=E2=80=94?= =?UTF-8?q?=E2=80=94=20=E5=9B=9B=E6=9D=A1=E5=AE=9E=E6=B5=8B=E7=BC=BA?= =?UTF-8?q?=E9=99=B7=20+=20=E4=B8=89=E5=A4=84=E5=8F=A3=E5=BE=84=E4=B8=8D?= =?UTF-8?q?=E4=B8=80=E8=87=B4(#365,=20#363)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 对已实施版本做架构/稳定性/一致性/多平台 review,每条可疑点都真机复现 而不是代码推理。复现同时充当「断言能失败」的证明:每条新断言都对应一段 用修复前二进制跑出来的、看得见的错误输出。 ## 缺陷(实测) **MSVC 下找不到 rc.exe。** 工具链 PATH 覆盖按 `find_first_of(";:")` 切, 而 Windows 路径的盘符冒号就在下标 1 —— `C:\Windows Kits\…` 被切成 `C` 加一段当前盘相对路径。而这条 PATH 遍历正是 msvc 下的主路径(rc.exe 属于 Windows SDK,从不在 cl.exe 旁边)。同一个 PR 里两个调用点各自推导同一条 规则、且已经彼此不一致 ⇒ 收敛成 `rsrc::split_env_list`。 **`role = "object"` 默认目标集漏了测试二进制。** `mcpp build` 通过而 `mcpp test` 在这个 action 本来要提供的符号上报 undefined symbol。而它 没有可用的逃生口:测试链接单元是从 tests/*.cpp 发现出来的,名字不在 mcpp.toml 里,只写测试目标又会让 `mcpp build` 硬失败 ⇒ 目标名字空间是 mode-dependent 的,这一层设计没承认。默认集合加入 TestBinary。 **未知 target 只在「一个都没匹配上」时报错**,于是拼错的名字挨着一个 对的名字时被静默丢弃 —— 与 types.cppm 和 docs 明写的契约相反。改为逐个 校验。⚠️ 这条与上一条耦合:先修它会当场废掉上一条的唯一逃生口。 **mcpp.lock 在 build/test 之间抖动。** lock 从 `m->dependencies` 改读 `resolved` 后带进了 dev-deps,而只有 `mcpp test` 解析它们 ⇒ build/test/ build 写出三个不同的文件。ResolvedRecord 增加 devOnly 并沿依赖边传播 (多消费者取 AND),lock 跳过。判据:**lock 是 manifest 的函数,不是命令 的函数**。 ## 口径 - `[resources]` 的「声明了必须存在」提到 `is_pe()` 之前:路径存不存在是 关于工作树的事实,不是关于目标的事实。按 PE 设门让 Linux/macOS 完全 看不见 icon 里的拼写错误。新增 199(无 requires,每个 shard 都跑)。 - `resources/versioninfo` 与 `resources/no-image` 由 warning 改 degraded (`--strict` 要看得见);`role="object"` 无消费者新增 `action/no-target`, 与 resources/no-image 同口径。 - resources 编排抽成带早返回的 lambda(消灭 150 行不缩进的 else 块); 删死字段 `ResourceUnit::packageName`;改 `LinkUnit::objects` 注释口径; `resolve_semver` 字面短路收紧为必须 `=` 前缀(裸 `1.2.3` 是 caret, 判据不能只活在调用方);`try_merge_semver` 相同约束不再拼成 `=X,=X`。 ## 测试 - e2e 序号断言改用 llvm-readobj 的资源目录,`.o` 与 `.exe` 同一判据 —— GNU 那一半原本被 `case *.res` 整个跳过。⚠️ 先写成「全文搜 UTF-16 VS_VERSION_INFO」并在正确的 windres 产物上误报:那是 VS_VERSIONINFO 结构自带的 szKey,正确资源里也有。改判据后加正向反证(故意构造坏脚本, 断言它确实是字符串名),两个方向在同一次运行里都被证明。 - 改写 res/app.rc 前补 sleep 1 —— 该段唯一的重跑 prepare 触发源就是它的 mtime。 - 188 补三条(部分拼错必须报错 / 测试二进制拿到 object / 无消费者要报); 196 补 build→test→build 三段 cmp;新增 199。 ## 其他 README 的包索引链接指向 https://mcpplibs.github.io/mcpp-index/ --- ...s-resources-and-version-identity-design.md | 74 ++++ CHANGELOG.md | 10 +- README.md | 6 +- README.zh-CN.md | 6 +- docs/05-mcpp-toml.md | 21 +- docs/07-build-mcpp.md | 22 +- docs/zh/05-mcpp-toml.md | 14 +- docs/zh/07-build-mcpp.md | 17 +- src/build/hostprogram.cppm | 8 +- src/build/plan.cppm | 8 +- src/build/prepare.cppm | 359 +++++++++++------- src/build/resources.cppm | 42 +- src/manifest/types.cppm | 17 +- src/pm/lock_io.cppm | 5 +- src/pm/resolver.cppm | 24 +- tests/e2e/188_build_actions.sh | 80 ++++ tests/e2e/196_version_identity_and_lock.sh | 60 ++- tests/e2e/198_windows_resources_cross.sh | 14 + tests/e2e/199_resources_validation.sh | 82 ++++ tests/e2e/_windows_resources_body.sh | 63 ++- tests/unit/test_build_resources.cpp | 26 ++ 21 files changed, 747 insertions(+), 211 deletions(-) create mode 100755 tests/e2e/199_resources_validation.sh 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 index 81192182..5216d29c 100644 --- 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 @@ -455,3 +455,77 @@ std::map resolved; // 覆盖整张图,含传 ## 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 方法论 + +**每一条都先真机复现再下判断,没有一条是纯代码推理。** 复现同时充当「断言能失败」的证明:五条新断言各自对应一段用**修复前**二进制跑出来的、看得见的错误输出。这比事后再造一个反向用例更便宜也更可信。 diff --git a/CHANGELOG.md b/CHANGELOG.md index bdf0c019..5baa35c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,11 @@ **声明了却不存在的文件是硬错误**,这是对 issue 第 3 条请求的**有意偏离**:mcpp 里每个「声明过的输入」都是这个规则(`main = "…"` 必须匹配恰好一个文件、nasm 缺失是硬错误),而「缺失就跳过」会把这个 feature 要消灭的失效模式写成规定行为——一个没有图标、没有版本信息、且什么都没说的正式二进制。不要图标已经可表达:把那一行删掉。 -- **`role = "object"`:build.mcpp 的 action 现在能把产物接到链接输入上。** 角色表原本三格接在「编译输入 / 无 / 链接输出」上,缺的正是「链接输入」——一个构建图显然有的接线点。后果不是理论上的:预编译对象只能塞进 `[build].ldflags`,而那是链接命令里的一串字符、不是图里的文件,于是改了图标得到 `ninja: no work to do`。可选 `.target("name")` 指定接哪条边,省略 = 声明包的全部镜像;未知名字报错而不是静默不接。 + **这条校验在每个目标上都跑**,「不适用」只停在*编译*那一步。路径存不存在是关于工作树的事实、不是关于目标的事实;按 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——这条边只能经由链接被达成,没有链接就意味着命令一次都不跑。 ### 修复 @@ -46,6 +50,10 @@ 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。 ### 其他 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 07be1955..d72c7900 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -1173,13 +1173,18 @@ generates the resource script for you. | `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 consume this.** On Linux and macOS the section is -*inapplicable*: no work, no warning, 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.** A resource is a build -input like a source file; mcpp will not quietly ship a binary without it. If you -do not want an icon, delete the line. +**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 @@ -1236,7 +1241,7 @@ 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 for every image this package produces + .target("myapp") // omit: every image, test binaries included .submit(); ``` diff --git a/docs/07-build-mcpp.md b/docs/07-build-mcpp.md index 2fa1dad6..da068594 100644 --- a/docs/07-build-mcpp.md +++ b/docs/07-build-mcpp.md @@ -193,11 +193,23 @@ 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; omit it -and the outputs attach to every image (binary / shared library) the declaring -package produces. It needs the name because, unlike `artifact`, it runs *before* -the link and so has no `${mcpp.target_file:…}` to infer one from — an unknown -name is an error rather than an edge that quietly attaches to nothing. +`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 diff --git a/docs/zh/05-mcpp-toml.md b/docs/zh/05-mcpp-toml.md index 3e44ccc6..971cf450 100644 --- a/docs/zh/05-mcpp-toml.md +++ b/docs/zh/05-mcpp-toml.md @@ -904,11 +904,15 @@ icon = "assets/app.ico" | `version-info` | 布尔 | `false` 表示不要生成版本资源 | | `[resources.version-info]` | 表 | `company`、`product`、`description`、`copyright`、`original-filename`、`internal-name` | -**只有 PE 目标消费这一节。** 在 Linux/macOS 上它**不适用**:不做事、不警告、 -构建逐字节不变。你**不需要**(也不能)加 `cfg(windows)` 谓词 —— 无条件写一次即可。 +**只有 PE 目标会*编译*这一节。** 在 Linux/macOS 上它**不适用**:不产资源单元、 +不出诊断、构建逐字节不变。你**不需要**(也不能)加 `cfg(windows)` 谓词 —— +无条件写一次即可。 -**声明了却不存在的文件会让构建失败。** 资源和源码一样是构建输入;mcpp 不会 -悄悄产出一个缺了它的二进制。不想要图标,把那一行删掉。 +**声明了却不存在的文件会让构建失败 —— 在每个目标上都是。** 资源和源码一样是 +构建输入;mcpp 不会悄悄产出一个缺了它的二进制。校验刻意**不**按 PE 设门: +路径存不存在是关于你工作树的事实,不是关于目标的事实,所以 `icon = "assets/app.ico"` +里的拼写错误由你的 Linux/macOS 构建(以及它们的 CI job)当场抓住,而不是等 +Windows 那条。不想要图标,把那一行删掉。 **版本字段。** `FILEVERSION` 取 `[package].version` 的四段数值,每段必须放得进 16 位;字符串字段保留版本原文,所以数值字段装不下的形态(`1.0.0-rc1`)在属性 @@ -959,7 +963,7 @@ 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") // 省略则接到本包产出的每个镜像 + .target("myapp") // 省略:接到每个镜像,含测试二进制 .submit(); ``` diff --git a/docs/zh/07-build-mcpp.md b/docs/zh/07-build-mcpp.md index 03f84a52..3977fb84 100644 --- a/docs/zh/07-build-mcpp.md +++ b/docs/zh/07-build-mcpp.md @@ -177,9 +177,20 @@ int main() { 全程不涉及任何 phase 机制:顺序由 ninja 自己的文件依赖决定 —— 这也是为什么 `artifact` 不会像朴素的「post 构建钩子」那样把自己重复施加一遍。 -`object`(2026.8.7.1+)可选 `.target("name")`,可重复;省略则接到声明包产出的 -每个镜像(可执行 / 动态库)。它必须写名字:与 `artifact` 不同,它跑在链接**之前**, -没有 `${mcpp.target_file:…}` 可以反推 —— 未知名字是错误,而不是一条静默不接的边。 +`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 是链接命令里的一串字符、不是图里的文件,没有任何东西跟踪它,改了它得到的是 diff --git a/src/build/hostprogram.cppm b/src/build/hostprogram.cppm index 8b2af85e..b49c710c 100644 --- a/src/build/hostprogram.cppm +++ b/src/build/hostprogram.cppm @@ -73,9 +73,11 @@ struct action { 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". 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. + // 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); diff --git a/src/build/plan.cppm b/src/build/plan.cppm index 9e555b2d..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 @@ -87,7 +92,6 @@ struct ResourceUnit { // (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; - std::string packageName; }; struct BuildPlan { diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 985b9757..a622ac8b 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -1980,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 }; @@ -1995,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; @@ -3260,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}); } } @@ -3306,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( @@ -3452,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), }; @@ -3546,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; } @@ -3823,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), }; @@ -3852,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}); } } @@ -4972,26 +4986,59 @@ prepare_build(bool print_fingerprint, std::set unknownObjectTargets; for (auto const& a : ctx.plan.actions) { if (a.role != mcpp::manifest::BuildAction::Role::Object) continue; - for (auto const& o : a.outputs) { - bool attached = false; - for (auto& lu : ctx.plan.linkUnits) { - const bool image = lu.kind == mcpp::build::LinkUnit::Binary - || lu.kind == mcpp::build::LinkUnit::SharedLibrary; - const bool wanted = a.targets.empty() - ? image - : std::find(a.targets.begin(), a.targets.end(), - lu.targetName) != a.targets.end(); - if (!wanted) continue; - lu.objects.emplace_back(o); - attached = true; - } - if (!attached && !a.targets.empty()) - for (auto const& t : a.targets) { - bool known = false; - for (auto const& lu : ctx.plan.linkUnits) - if (lu.targetName == t) known = true; - if (!known) unknownObjectTargets.insert(t); - } + + // 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()) { @@ -5004,7 +5051,9 @@ prepare_build(bool print_fingerprint, "target(s): {}\n" " targets in this build: [{}]\n" " (a target gated by required_features is absent unless those " - "features are active)", + "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)); } } @@ -5090,59 +5139,72 @@ prepare_build(bool print_fingerprint, // ─── Windows resources: [resources] → a tracked link input (mcpp#365) ── // - // Three rules decide whether anything happens here, in this order: + // 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. On a non-PE target the section is INAPPLICABLE — no work, 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). - // 3. A declared file that does not exist is a hard error. Every other - // declared input in mcpp behaves this way, and "missing → skip" is how - // a release binary ships with no icon and nothing says so. + // 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 trip = mcpp::toolchain::triple::parse(tc->targetTriple) - .value_or(mcpp::toolchain::triple::host_triple()); - if (trip.is_pe()) { - const auto dialectId = mcpp::toolchain::dialect_for(*tc).id; - const auto& R = m->resources; + const auto& R = m->resources; - 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; - }; + // 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::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"; @@ -5151,6 +5213,10 @@ prepare_build(bool print_fingerprint, // 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; @@ -5161,12 +5227,17 @@ prepare_build(bool print_fingerprint, // 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::warning("resources/no-image", std::format( + mcpp::diag::degraded("resources/no-image", std::format( "[resources] is declared but '{}' produces no executable or " - "shared library for {} — nothing to embed the resources into", - m->package.name, trip.str())); - } else { + "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 @@ -5187,7 +5258,6 @@ prepare_build(bool print_fingerprint, ru.output = std::filesystem::path("res") / (std::string(stem) + std::string(outExt)); ru.implicitInputs = std::move(inputs); - ru.packageName = m->package.name; ctx.plan.resourceUnits.push_back(std::move(ru)); const auto& out = ctx.plan.resourceUnits.back().output; if (attachTo == static_cast(-1)) { @@ -5199,35 +5269,39 @@ prepare_build(bool print_fingerprint, }; // Author-written scripts: compiled once, linked into every image. - for (auto const& f : R.files) { - auto r = resolve_declared(f, "files"); - if (!r) return std::unexpected(r.error()); - auto scan = rsrc::scan_rc(*r); + for (auto const& rcSrc : scriptFiles) { + auto scan = rsrc::scan_rc(rcSrc); if (scan.versionInfoNamedByString) { - // The mcpp#365 silent failure, caught on the way in. Only a - // warning: the file may define the macro somewhere this - // scanner cannot see. - mcpp::diag::warning("resources/versioninfo", std::format( + // 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, so Windows will not find it " - "(GetFileVersionInfo looks up MAKEINTRESOURCE(1) and " - "every field comes back empty). VS_VERSION_INFO is a " - "macro from ; add `#include ` to " - "the script, or write `1 VERSIONINFO`.", - r->filename().generic_string(), scan.versionInfoName, - scan.versionInfoName)); + "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", - r->filename().generic_string(), g), + 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(*r, r->stem().string(), std::move(inputs), + if (auto a = add_unit(rcSrc, rcSrc.stem().string(), + std::move(inputs), static_cast(-1)); !a) return std::unexpected(a.error()); } @@ -5279,58 +5353,57 @@ prepare_build(bool print_fingerprint, } } - if (!ctx.plan.resourceUnits.empty()) { - // 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; - - // 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. - // 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"); - - 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; - std::string_view rest = ev.value; - while (!rest.empty()) { - const auto sep = rest.find(';'); - auto dir = rest.substr(0, sep); - if (!dir.empty()) ctx.plan.rcFlags.push_back(ip + std::string(dir)); - if (sep == std::string_view::npos) break; - rest = rest.substr(sep + 1); - } - } + 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)); } } - } // peUnits non-empty - } + 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 ── @@ -5708,6 +5781,10 @@ prepare_build(bool print_fingerprint, 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; diff --git a/src/build/resources.cppm b/src/build/resources.cppm index bf6b1df3..5bc8a1c7 100644 --- a/src/build/resources.cppm +++ b/src/build/resources.cppm @@ -79,6 +79,19 @@ struct RcTool { 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 { @@ -185,6 +198,18 @@ std::string escape_rc_string(std::string_view s) { } // 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(); @@ -193,20 +218,17 @@ std::optional find_rc_tool(const mcpp::toolchain::Toolchain& tc, // 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; - std::string_view rest = ev.value; - while (!rest.empty()) { - const auto sep = rest.find_first_of(";:"); - const auto dir = rest.substr(0, sep); - if (!dir.empty()) - if (auto p = probe_dir(std::filesystem::path(dir), names)) - return RcTool{*p, "msvc"}; - if (sep == std::string_view::npos) break; - rest = rest.substr(sep + 1); - } + 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; } diff --git a/src/manifest/types.cppm b/src/manifest/types.cppm index bedc8bdb..a5d0f2d7 100644 --- a/src/manifest/types.cppm +++ b/src/manifest/types.cppm @@ -249,13 +249,22 @@ struct BuildAction { 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 image - // (binary / shared library) of the declaring package. + // 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 an - // unknown name is an error rather than a silently unattached edge. + // 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 diff --git a/src/pm/lock_io.cppm b/src/pm/lock_io.cppm index 3f8bb386..eb2ef5a8 100644 --- a/src/pm/lock_io.cppm +++ b/src/pm/lock_io.cppm @@ -145,7 +145,10 @@ std::string serialize(const Lockfile& lock) { 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"; + "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 1a83a010..e59b0e54 100644 --- a/src/pm/resolver.cppm +++ b/src/pm/resolver.cppm @@ -143,9 +143,18 @@ resolve_semver(std::string_view ns, std::string_view shortName, // // Aliases are eligible here: pinning `latest` or `25.0.4` exactly is a // legitimate address, and only RANGE selection has to ignore pointers. - { - auto exact = constraint; - if (exact.starts_with('=')) exact.remove_prefix(1); + // + // 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')) @@ -266,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/tests/e2e/188_build_actions.sh b/tests/e2e/188_build_actions.sh index c22ba830..1d88f0c4 100755 --- a/tests/e2e/188_build_actions.sh +++ b/tests/e2e/188_build_actions.sh @@ -351,6 +351,86 @@ 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 ────────────────────────── diff --git a/tests/e2e/196_version_identity_and_lock.sh b/tests/e2e/196_version_identity_and_lock.sh index e72921c7..591afb5e 100755 --- a/tests/e2e/196_version_identity_and_lock.sh +++ b/tests/e2e/196_version_identity_and_lock.sh @@ -30,15 +30,16 @@ 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 (same for all platforms) +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/gadget.cppm" + printf 'export module %s;\nexport int %s_value() { return 42; }\n' "$MOD" "$MOD" \ + > ".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 @@ -175,4 +180,49 @@ 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/198_windows_resources_cross.sh b/tests/e2e/198_windows_resources_cross.sh index bd21e831..24f51dff 100755 --- a/tests/e2e/198_windows_resources_cross.sh +++ b/tests/e2e/198_windows_resources_cross.sh @@ -40,4 +40,18 @@ grep -qi 'resource' host.log && { cat host.log; echo "FAIL: a non-PE build must 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 index 99ee96f2..056b862b 100644 --- a/tests/e2e/_windows_resources_body.sh +++ b/tests/e2e/_windows_resources_body.sh @@ -15,6 +15,11 @@ 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'; } @@ -95,27 +100,42 @@ case "$RES_ART" in ;; esac -# The same assertion in readable form, wherever llvm-readobj is around (it ships -# in the LLVM payload). Worth having in BOTH shapes because the type line is -# identical either way — `Type: VERSIONINFO (ID 16)` is exactly what convinced -# the reporter the resource was fine. The name is the discriminator: +# The dialect-independent form of the SAME assertion — and the only one that +# covers the GNU fork, since the byte-offset check above reads the `.res` +# container and windres emits a COFF object. llvm-readobj reads both, and the +# resource DIRECTORY is where the discriminator lives (measured on both): +# +# Type: VERSIONINFO (ID 16) → Name: (ID 1) ← Windows finds it +# Type: VERSIONINFO (ID 16) → Name: VS_VERSION_INFO ← Windows does not # -# Name: (ID 1) ← Windows finds it -# Name: VS_VERSION_INFO ← 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. 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) -if [ -n "$READOBJ" ]; then - "$READOBJ" --coff-resources "$RES_ART" > readobj.log 2>&1 || true - if grep -q 'VERSIONINFO' readobj.log; then - grep -q 'Name: (ID 1)' readobj.log \ - || fail "the version resource is not named by ordinal 1" readobj.log - fi -fi +[ -n "$READOBJ" ] || fail "llvm-readobj not found under $MCPP_HOME — the ordinal assertion cannot run, and silently skipping it is how #365 shipped" b1.log + +# $1 = file to inspect, $2 = log suffix, $3 = expected `Name:` text +version_name_is() { + "$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 -q "Name: $3" \ + || fail "expected the version resource to be named '$3'" "readobj.$2.log" +} +version_name_is "$RES_ART" art '(ID 1)' # ── 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 '(ID 1)' EXE_HEX=$(hexof "$EXE") echo "$EXE_HEX" | grep -q "$(utf16hex 'Acme Corp')" \ || fail "the version metadata did not reach the executable" b1.log @@ -184,6 +204,12 @@ 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 @@ -205,6 +231,17 @@ 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 'VS_VERSION_INFO' # ── D-6. A declared resource that does not exist is an ERROR ────────────── # diff --git a/tests/unit/test_build_resources.cpp b/tests/unit/test_build_resources.cpp index 5e087c30..38ca9bf4 100644 --- a/tests/unit/test_build_resources.cpp +++ b/tests/unit/test_build_resources.cpp @@ -200,6 +200,32 @@ END 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 From d7c725aaf909d1316364a3a3026a120e78a94af4 Mon Sep 17 00:00:00 2001 From: speak-agent Date: Fri, 7 Aug 2026 15:57:22 +0800 Subject: [PATCH 5/8] =?UTF-8?q?test(resources):=20=E5=BA=8F=E5=8F=B7?= =?UTF-8?q?=E5=88=A4=E6=8D=AE=E6=94=B9=E7=94=A8=20windres=20=E5=8F=8D?= =?UTF-8?q?=E8=AF=BB,=E4=B8=8D=E5=86=8D=E4=BE=9D=E8=B5=96=20LLVM=20?= =?UTF-8?q?=E8=BD=BD=E8=8D=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mingw-cross job 的 sandbox 里没有 llvm-readobj(CI 实证: `llvm-readobj not found under /home/runner/.mcpp`)——它只装 mingw 工具链。 硬失败本身是对的(静默跳过正是 #365 的出厂方式),错的是判据选了一个 这条 job 拿不到的工具。 `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 载荷就是默认工具链本身。 --- tests/e2e/_windows_resources_body.sh | 76 ++++++++++++++++++++-------- 1 file changed, 54 insertions(+), 22 deletions(-) diff --git a/tests/e2e/_windows_resources_body.sh b/tests/e2e/_windows_resources_body.sh index 056b862b..3f155e5c 100644 --- a/tests/e2e/_windows_resources_body.sh +++ b/tests/e2e/_windows_resources_body.sh @@ -100,42 +100,74 @@ case "$RES_ART" in ;; esac -# The dialect-independent form of the SAME assertion — and the only one that -# covers the GNU fork, since the byte-offset check above reads the `.res` -# container and windres emits a COFF object. llvm-readobj reads both, and the -# resource DIRECTORY is where the discriminator lives (measured on both): +# 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. # -# Type: VERSIONINFO (ID 16) → Name: (ID 1) ← Windows finds it -# Type: VERSIONINFO (ID 16) → Name: VS_VERSION_INFO ← Windows does not +# `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. -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 "llvm-readobj not found under $MCPP_HOME — the ordinal assertion cannot run, and silently skipping it is how #365 shipped" b1.log +# +# 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" -# $1 = file to inspect, $2 = log suffix, $3 = expected `Name:` text +# $1 = file to inspect, $2 = log suffix, $3 = "ordinal" | "string" version_name_is() { - "$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 -q "Name: $3" \ - || fail "expected the version resource to be named '$3'" "readobj.$2.log" + case "$3" in + ordinal) _want='^[[:space:]]*1[[:space:]]+VERSIONINFO' ;; + string) _want='"VS_VERSION_INFO"[[:space:]]+VERSIONINFO' ;; + esac + case "$RC_TOOL" in + *windres*) + "$RC_TOOL" -J coff -O rc -i "$1" -o "rt.$2.rc" 2>"rt.$2.err" \ + || fail "windres could not read back $1" "rt.$2.err" + grep -qiE 'VERSIONINFO' "rt.$2.rc" \ + || fail "$1 carries no version resource at all" "rt.$2.rc" + grep -qE "$_want" "rt.$2.rc" \ + || fail "expected the version resource to be named by $3 in $1" "rt.$2.rc" + ;; + *) + # rc.exe / llvm-rc cannot read back, so use llvm-readobj — which ships + # with the LLVM payload that IS the default Windows toolchain, i.e. the + # only configuration that reaches this branch. Same discriminator, in + # the resource directory: `Name: (ID 1)` vs `Name: VS_VERSION_INFO`. + 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 (no windres, no llvm-readobj) — silently skipping this assertion is how #365 shipped" b1.log + "$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" + case "$3" in ordinal) _n='Name: (ID 1)' ;; string) _n='Name: VS_VERSION_INFO' ;; esac + # 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" + ;; + esac } -version_name_is "$RES_ART" art '(ID 1)' +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 '(ID 1)' +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 @@ -241,7 +273,7 @@ grep -q 'GetFileVersionInfo' b6.log \ # 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 'VS_VERSION_INFO' +version_name_is "$BROKEN" broken string # ── D-6. A declared resource that does not exist is an ERROR ────────────── # From 6d73ebc02526e85f3b7b1d50b93597022a7f2e9a Mon Sep 17 00:00:00 2001 From: speak-agent Date: Fri, 7 Aug 2026 15:58:29 +0800 Subject: [PATCH 6/8] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=E7=AC=AC?= =?UTF-8?q?=E4=B8=89=E6=9D=A1=E5=88=A4=E6=8D=AE=E4=BF=AE=E6=AD=A3=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E5=B7=A5=E5=85=B7=E5=BF=85=E9=A1=BB?= =?UTF-8?q?=E6=9D=A5=E8=87=AA=E8=A2=AB=E6=96=AD=E8=A8=80=E7=9A=84=E9=82=A3?= =?UTF-8?q?=E6=9D=A1=E8=B7=AF=E5=BE=84(#365)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...s-resources-and-version-identity-design.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) 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 index 5216d29c..c74869ff 100644 --- 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 @@ -529,3 +529,24 @@ mcpp build → 1 条 mcpp test → 2 条(多了 dev-dep) mcpp build → 又 ## 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 载荷就是默认工具链本身。 + +**判据:一条断言要用的工具,必须来自它所断言的那条路径本身。** 「本机装得全」是最容易把环境假设藏起来的地方——三次改判据里,前两次都是本机绿、别处红。 From 3d2731323c2f5e719ba771beda77c67a4009ff7c Mon Sep 17 00:00:00 2001 From: speak-agent Date: Fri, 7 Aug 2026 15:59:46 +0800 Subject: [PATCH 7/8] =?UTF-8?q?docs(changelog):=20=E8=A1=A5=20review=20?= =?UTF-8?q?=E8=BD=AE=E7=9A=84=E8=AF=8A=E6=96=AD=E5=8F=A3=E5=BE=84=E5=8F=98?= =?UTF-8?q?=E5=8C=96=E4=B8=8E=20README=20=E7=B4=A2=E5=BC=95=E9=93=BE?= =?UTF-8?q?=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5baa35c5..6c262d43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,8 @@ - 版本号 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 From a655b95447c1c65f023e2396a98b426a6d292211 Mon Sep 17 00:00:00 2001 From: speak-agent Date: Fri, 7 Aug 2026 16:14:11 +0800 Subject: [PATCH 8/8] =?UTF-8?q?test(resources):=20=E5=BA=8F=E5=8F=B7?= =?UTF-8?q?=E5=88=A4=E6=8D=AE=E4=B8=A4=E6=9D=A1=E8=B7=AF=E7=BA=BF=E4=BE=9D?= =?UTF-8?q?=E6=AC=A1=20TRY;=E8=AE=B0=E5=BD=95=20msvc=20=E6=94=AF=E9=9B=B6?= =?UTF-8?q?=20CI=20=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows job 红在 `windres could not read back ...resapp.mcpp.o`: `llvm-windres` 只是 llvm-rc 的单向包装、没有 `-J coff`,而它和 GNU binutils 的 windres **都匹配 `*windres*`** ⇒ 按工具名分派是错的。改成 反读 → llvm-readobj 依次 TRY,只有两条都不可用才硬失败。 同一条失败还暴露:native Windows 默认工具链走的是 GNU 支不是 msvc 支 (target 目录 `x86_64-windows-msvc` 而产物是 `.o`)⇒ `.res` 那一支在 两个 CI job 里都不执行,e2e 的 `case *.res` 是死分支。这条已记进设计 文档 §F.11 作为明确缺口。 --- ...s-resources-and-version-identity-design.md | 20 +++++++ tests/e2e/_windows_resources_body.sh | 59 +++++++++---------- 2 files changed, 49 insertions(+), 30 deletions(-) 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 index c74869ff..6c04d471 100644 --- 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 @@ -550,3 +550,23 @@ FAIL: llvm-readobj not found under /home/runner/.mcpp 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/tests/e2e/_windows_resources_body.sh b/tests/e2e/_windows_resources_body.sh index 3f155e5c..41c6de26 100644 --- a/tests/e2e/_windows_resources_body.sh +++ b/tests/e2e/_windows_resources_body.sh @@ -123,42 +123,41 @@ esac 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' ;; - string) _want='"VS_VERSION_INFO"[[:space:]]+VERSIONINFO' ;; + ordinal) _want='^[[:space:]]*1[[:space:]]+VERSIONINFO' ; _n='Name: (ID 1)' ;; + string) _want='"VS_VERSION_INFO"[[:space:]]+VERSIONINFO' ; _n='Name: VS_VERSION_INFO' ;; esac - case "$RC_TOOL" in - *windres*) - "$RC_TOOL" -J coff -O rc -i "$1" -o "rt.$2.rc" 2>"rt.$2.err" \ - || fail "windres could not read back $1" "rt.$2.err" - grep -qiE 'VERSIONINFO' "rt.$2.rc" \ - || fail "$1 carries no version resource at all" "rt.$2.rc" + + # 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" - ;; - *) - # rc.exe / llvm-rc cannot read back, so use llvm-readobj — which ships - # with the LLVM payload that IS the default Windows toolchain, i.e. the - # only configuration that reaches this branch. Same discriminator, in - # the resource directory: `Name: (ID 1)` vs `Name: VS_VERSION_INFO`. - 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 (no windres, no llvm-readobj) — silently skipping this assertion is how #365 shipped" b1.log - "$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" - case "$3" in ordinal) _n='Name: (ID 1)' ;; string) _n='Name: VS_VERSION_INFO' ;; esac - # 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" - ;; - esac + 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