diff --git a/.agents/docs/2026-10-05-std-module-pair-msvc-lto-and-export-discovery-design.md b/.agents/docs/2026-10-05-std-module-pair-msvc-lto-and-export-discovery-design.md new file mode 100644 index 00000000..24e7afaa --- /dev/null +++ b/.agents/docs/2026-10-05-std-module-pair-msvc-lto-and-export-discovery-design.md @@ -0,0 +1,558 @@ +--- +subject: design +status: active +--- + +# 下一个版本的发布方案:标准库模块、原生 MSVC LTO 与导出发现、共享库的链接配置、Windows 参数引号(#768 后续、#770、#771) + +- 日期:2026-10-05。状态:修订 6,按 review 结论(D10a、D10b、D11、O6 通过,其余按建议)实现于 mcpp 2026.10.5.2。本文是**下一个版本的统一发布方案**,所有项目在一个发布 PR 中完成(§14)。 +- 基线:`main` `8d9bde29`(已合入 #769)。 +- 证据: + - 临时探测 PR #772:只保留一个探测 workflow,探测脚本不做断言。 + - run `37290831503`:直接调用 cl / link / lib。 + - run `37291652508`:用当前 commit 构建的 mcpp,在普通环境和 VS 开发者环境各跑一次。 + - run `37297856040`:O6(Windows 代码页)和 O7(工作空间的 profile,Linux)。O7 的结果与本地结果逐条一致,只有图目录的排列顺序不同。 + - Linux 本地对照:mcpp 2026.10.5.1,加上一个修改了链接组的实验构建。 + - 环境:`windows-latest`,cl 19.51.36260 / MSVC 14.51.36231 / LLVM 22.1.8 / SDK 10.0.26100.0;Linux x86_64,gcc 16.1。 +- 只对 review 有价值的部分写进正文;完整日志见 #772 的 job summary。 + +**修订 6:实施记录。** 实现与本文的差异: + +| 项 | 本文 | 实现 | 理由 | +| --- | --- | --- | --- | +| D1 | `StdModuleSet` 值类型 | `Toolchain::set_std_modules` / `clear_std_modules` 两个成员函数,六处写入点全部改用它们 | 字段保持原样,避免改动每个读取点;不变式同样由一处保证 | +| D2 | 只覆盖已有 compat 的行 | 同时补上 GCC 行的 `std.compat`(`bits/std.compat.cc`) | 实施中发现 GCC 行的 `import std.compat` 一直失败,而 SPEC-009 §6.2 要求 Default 行通过它 | +| D5 | `std::optional` | `windowsAutoExport` 加 `windowsAutoExportDeclared` 两个成员 | 仓库约定:导出结构体不用 `std::optional` 数据成员(`Profile` 的注释记录了 clang + MSVC STL 下的编译失败) | +| D5 | 按包降级 | 以 `/GL-` 追加到受影响包的单元 flag | cl.exe 的后置 `/GL-` 覆盖前置 `/GL`,不需要第二套全局 flag | +| D6 | `pack` 静态库不带 LTO | `BuildOverrides::no_lto_in_archives`,由库打包流水线设置;gcc / clang 用 `-fno-lto`,cl.exe 用 `/GL-` | 与 D5 同一机制 | +| D9 | `+ lto (partial)` | `BuildConfig::ltoPartial` | — | +| D7b | 一个版本的 note | `link/root-flags`,只在根包私有 flag 中有搜索路径或库、且计划中有依赖的共享库时给出 | — | +| D11 | `[test] windows_code_page` | 测试程序只接收应用程序清单,名字置于 `res/tests/` 之下 | 测试名是路径,并可能与程序名相同 | + +**任务依赖与跨仓库协作。** + +``` +mcpp(一个发布 PR,release/2026.10.5.2) + D8 引号 ─┐ + D7 链接组 ┤ + D3 读取器 ┼─→ SPEC / docs / CHANGELOG ─→ 完整 CI ─→ 自审 ─→ squash 合入 main + D4–D6,D9 ┤ + D1,D2 ┤ + D10,D11 ┘ + ─→ release.yml(四个平台构建 → GitHub Release → mcpp-release.json) + ─→ xlings-res/mcpp 镜像(GitHub + GitCode;GitCode 不完整时用 gtc 在本地补齐) + ─→ openxlings/xim-pkgindex 的 bump PR(机器生成)─→ 合入 ─→ 索引 latest 生效 + ─→ xlings 生态验证(干净的 XLINGS_HOME 中 install mcpp@2026.10.5.2,构建并运行样例) + ─→ (可选).xlings.json 的引导版本 +``` + +- 发布 PR 合入前,main 上排队的 CI 让位于 PR 的 CI;合入后先跑 release,再重新触发 main 的 CI。 +- #772(探测 PR)保留到发布完成,用于复测;#773 由本发布覆盖,合入后在其中说明并关闭。 + +**修订 5 的变化:** +- O6 查明原因(§13):测试本身依赖代码页;测试程序与 mcpp.exe 运行在不同的代码页。纳入方案(D11)。 +- O7 查明并扩展为 “工作空间根与根包的复用”(§12): + - 虚拟根上写的 `[profile]` 被静默忽略。 + - profile 取自第一个被选中的成员。 + - 带 `[package]` 的工作空间,根包不继承 `[workspace.*]`。 + - 纳入方案(D10a–D10c)。 + +**修订 4 的变化:** +- 新增 §1 “诊断分级方针”,并把每一种情形逐条归类为报错、降级报告或提示(§1.3 总表)。 +- 多角度自审后的修正: + - **D5 降级的粒度从 “按目标” 改为 “按包”。** 发现边读取的 `lu.objects` 包含放入该镜像的静态依赖的对象(#646 F1),而且一个源文件只编译一次。 + - **新增:用户在 flag 中手写的 `/GL` 视为显式陈述。** mcpp 不改写透传的 flag,所以这种情形与自动导出冲突时在规划期报错,不降级。 + - **新增:`import std.compat` 而标准库不提供 compat 时,在规划期报错。** 今天这种情形只会在编译期得到 `module not found`。 + - D6 扩展为 “`pack` 产出的静态库不携带任何 LTO 中间表示”,覆盖所有工具链。 + - D7 定稿:图级 flag(ABI 一定是,profile 建议是)加上闭包 flag;消费者包的私有 flag 不再进入依赖的共享库。用一条 note 加 release note 处理这项兼容性变化。 +- 去掉对外部 PR 的依赖项。#773 的对照数据作为证据保留在 §10.5。 + +--- + +## 0. 决策一览(待 review) + +| # | 问题 | 提议 | 分级(§1) | +| --- | --- | --- | --- | +| D1 | std / std.compat 由谁设置 | `StdModuleSet`,每种标准库一个推导函数(§3) | 无诊断 | +| D2 | std.compat 何时编译 | 按需编译;import 了 compat 但标准库不提供时,规划期报错(§4) | E1 | +| D3 | 导出发现读到非普通 COFF 对象 | `/bigobj` 改为可读;`/GL` 和未知的匿名对象在构建期报错,并给出专门诊断(§6) | E3 | +| D4 | cl.exe 上的 `lto = true` | 兑现:`/GL`、`/LTCG`、`lib /LTCG`(§7) | 无诊断 | +| D5 | 原生 LTO 与自动导出冲突 | 显式 `true` → E1;flag 中手写 `/GL` → E1;没写 → 按包降级(W) | E1 / W | +| D6 | `pack` 产出的静态库与 LTO | 不携带 LTO 中间表示(所有工具链),降级并报告(§9) | W | +| D7 | 依赖拥有的共享库用哪条链接行 | 图级 flag + 自己闭包的 flag,所有计划都一样(§10) | — | +| D7a | profile 的 `ldflags` 是不是图级 | 是(保持非工作空间的现状) | — | +| D7b | 根包私有 flag 不再进入依赖 DLL | 接受(SPEC-004 §9.6);一个版本内给出 N,并写 release note | N | +| D8 | Windows 参数引号 | 按 argv 规则引号;`WindowsSdkDir` 规范化(§11) | 无诊断 | +| D9 | 构建输出中的 `+ lto` | 只在 LTO 实际生效时显示(§7) | — | +| D10a | 工作空间中 `[profile.*]` 的来源 | 成为根位置键:成员按 profile 名继承工作空间根的表,成员自己声明的同名表优先(§12) | 无诊断,写 release note | +| D10b | 带 `[package]` 的工作空间,根包是否继承 `[workspace.package]` / `[workspace.build]` | 是,与其他成员一样恰好一次,各种选择下一致(§12) | 无诊断,写 release note | +| D10c | 根清单中键的归属 | 写成一张表:哪些是工作空间级、哪些是根包级(§12.4,进入 SPEC-004 §9) | — | +| D11 | Windows 代码页测试失败(O6) | 修正测试;测试程序可以声明代码页(`[test] windows_code_page`),mcpp 自己的测试使用 UTF-8(§13) | — | + +--- + +## 1. 诊断分级方针 + +### 1.1 依据 + +- **mcpp 自身的不变式**(`src/diag.cppm` 模块注释):“Any branch that does less because a condition was not met MUST either return an error or report through `diag::degraded()`.” `degraded` 必须写出 impact;`--strict` 在一处把所有 degraded 提升为错误;`note` 永不被提升。 +- **mcpp 的先例:** + - 互相矛盾的显式陈述要拒绝:`exports` + `windows_auto_export = false`(`plan.cppm`)。 + - 拒绝会破坏昨天还能构建的程序时,报告而不拒绝:Apple SDK libc++(`scan.cpp`)。 + - 无法产出正确产物时报错:`--required` 下的空导出面、`StdModulePrecompile`。 + - 透传的 flag 原样传递,mcpp 不改写(`flags.cppm`:“ldflags pass through verbatim”)。 + - 拒绝必须有 `refusal::Code`,并映射到 SPEC-003 的退出码。 +- **行业惯例:** + - CMake:IPO 被请求而工具链不支持时,CMP0069 的 NEW 行为是报错;它只是不默默忽略。 + - 发行版:不在静态库中分发 LTO 中间表示(例如 Fedora 的 LTO 方针)。 + - MSVC 文档:不建议分发由 `/GL` 对象组成的 `.lib`。 + +### 1.2 五条规则 + +| 级别 | 何时使用 | 机制 | +| --- | --- | --- | +| **E1 规划期拒绝** | 用户的**显式**陈述互相矛盾,或者构建图在规划期就能判定无法满足 | `refusal::Code` + 退出码;诊断点名两条陈述,并给出改法 | +| **E3 构建期报错** | 无法产出正确产物,而且只有到构建期才能看到(例如对象的内容) | 构建边失败,给出准确的原因与改法,不能是误导性的通用错误 | +| **W 降级报告** | 一个**隐式默认**无法兑现,但仍能产出正确(只是没那么优化)的产物;拒绝会破坏现有构建 | `diag::degraded`(what + impact + hint),每次运行一条汇总;`--strict` 下为错误 | +| **N 提示** | 修正了一个泄漏或不一致,带来规划期无法判定是否会产生影响的语义变化 | `diag::note`,只在一个版本内给出,只在相关时给出;同时写 release note | +| **无诊断** | 纯修复、纯重构、纯性能 | — | + +还有两条约束: +- **绝不改写用户手写的透传 flag。** 手写的 flag 是显式陈述,只能接受它,或者在矛盾时拒绝。 +- **绝不声称没有兑现的能力**(D9)。 + +### 1.3 每种情形的分级总表 + +| 情形 | 级别 | 理由 | +| --- | --- | --- | +| `windows_auto_export = true`(显式)+ cl.exe 上 LTO 生效,目标是 PE 共享库 | **E1** | 两条显式陈述矛盾;与 `exports` + `false` 同类;CMake 对显式不可满足的请求也报错 | +| 用户在 `cxxflags` 中写了 `/GL`(或 `-GL`),该包的对象进入一个开启自动导出的 DLL | **E1** | 手写 flag 是显式陈述,mcpp 不能去掉它;改法是关闭自动导出或去掉 `/GL` | +| 没写 `windows_auto_export` + cl.exe 上 LTO 生效 | **W** | 隐式默认无法兑现;降级后产物仍然正确;拒绝会破坏今天能构建的项目(今天 LTO 被忽略,构建成功) | +| 发现边读到 `/GL` 对象(来源是构建程序或 `CL` 环境变量,规划期看不到) | **E3** | 读不出符号,就无法产出正确的导出面;静默跳过会得到缺少导出的 DLL | +| 发现边读到未知类别的匿名对象 | **E3** | 不猜格式 | +| 发现边读到 `/bigobj` 对象 | 无(改为支持) | 格式有公开文档,CMake 也支持 | +| `import std.compat`,但所选标准库没有 compat 源 | **E1** | 规划期即可判定;今天要到编译期才得到 `module not found`,说不出原因 | +| `pack` 产出静态库,而 profile 开启了 LTO | **W** | 产物仍然正确(不带 LTO 中间表示);不分发是行业惯例 | +| 某个行请求了 LTO 却无法兑现(今后出现的任何行) | **W**,并且不显示 `+ lto` | 一致的兜底规则 | +| 根包私有的 `ldflags` 中有搜索路径或库(`-L`、`-l`、`/LIBPATH:`、`*.lib`),而计划中有依赖拥有的共享库 | **N**(一个版本) | 这些 flag 不再进入依赖 DLL;规划期无法判定依赖是否需要它们;其他种类的 flag(rpath、version script、`/SUBSYSTEM`)进入 DLL 本来就是错的,不提示 | +| 引号修复、SDK 根规范化、按需编译 compat、`StdModuleSet` | 无 | 纯修复或重构 | +| 工作空间根上的 `[profile]` 今天被静默忽略(违反 `diag.cppm` 的不变式) | 修复为生效(D10a) | 这是该不变式所说的 “静默少做”,正确的修复是兑现它,而不是加一条警告 | +| 成员自己声明了与工作空间根同名的 profile | 无(文档化的优先级) | 与 `[toolchain]` 等根位置键的规则一致 | + +--- + +## 2. 约束 + +| 约束 | 出处 | +| --- | --- | +| Default 行验收包含 `import std.compat` | SPEC-009 §6.2 | +| 能力取自载荷,不按版本推断 | SPEC-009 §6.1 | +| profile 的旋钮在每个编译器上兑现 | `flags.cppm` 中 `realised_opt_level` 的注释 | +| 同一份 manifest 在 ELF / Mach-O / PE 上都能构建 | `docs/04-mcpp-toml.md`(`windows_auto_export`) | +| 使用需求只从依赖流向消费者,禁止反向 | SPEC-004 §9.6 | +| ABI 开关到达 “every TU of every package, and the link” | `docs/22-target-side.md` 中的 `abi` 表 | +| 一个源文件在一次构建中只编译成一个对象 | `docs/04-mcpp-toml.md`(选择构建配置放在哪里) | +| 已发布的键不改名 | SPEC-004 §5.2 | +| 构建数据库与实际构建出自同一推导 | SPEC-005 | + +--- + +## 第一部分:标准库模块 + +## 3. D1:`StdModuleSet` + +- **现状**:`stdModuleSource` 和 `stdCompatSource` 是两个独立字段,在 6 处被设置或清空(clang 检测、msvc 检测、`bind_msvc_sysroot`、包提供的标准库、无宿主 std、Apple SDK)。#768 就是其中一处只改了一半。 +- **设计**: + - 新增 `struct StdModuleSet { path std; path compat; }`,不变式是 “compat 非空 ⇒ std 非空,且来自同一个库”。 + - 每种标准库一个推导函数:libc++ 取同目录的 `std.compat.cppm`;MSVC STL 取 `modules/` 下的两个 `.ixx`;包提供的标准库读 manifest 的两个键。 + - 写入点只能整体赋值;清空统一调用 `clear_std_modules`。`hasImportStd` 由 `std` 是否非空推导(第一步可以先加断言)。 +- **自审**:纯重构,命令行和缓存键逐字节不变,用现有测试守住。 + +## 4. D2:std.compat 按需编译,缺失时在规划期报错 + +- **实测**(run `37291652508`,普通环境,llvm + MSVC STL):清空 std 缓存后,只 `import std` 的程序构建完,缓存里出现了 `std.compat.pcm` / `std.compat.o`。 +- **设计**: + 1. 扫描结果改为 `{ needsStd, needsStdCompat }`,覆盖整个图;判定复用 `imports_module(…, "std.compat")`。 + 2. `ensure_built` / `describe_std_module` 增加 `wantCompat`,**只控制是否执行**。元数据仍包含 compat 的推导,否则 “要不要 compat” 的变化会连带重建 std。 + 3. 构建数据库与构建程序路径用同一个条件。 + 4. **新增 E1**:`needsStdCompat` 为真而 `StdModuleSet.compat` 为空时,在 `step11_std_module_availability_gate` 中拒绝(与 `std` 不可用时同一个代码,或者新增一个兄弟代码),诊断说明所选标准库(`stdlibId`)不提供 `std.compat`。 +- **行业惯例**:CMake 和 MSBuild 都把两个模块一起编译。mcpp 偏离这一点的理由是故障隔离,而且 mcpp 本来就扫描源码,判断是否需要 compat 没有额外成本。 +- **自审**: + - 构建程序(`build.mcpp`)和 `mcpp test` 的扫描都要覆盖到。 + - 只 import std 的项目,第一次用到 compat 时只补编 compat,std 不重建。这一点要有测试。 + +## 5. 模块源语言判定与验收 + +- `.ixx → -x c++-module` 的判断有两处:`std` 构建命令写在 `#if defined(_WIN32)` 里,compat 构建命令(#769)按扩展名判断。抽出 `module_source_language_flags(path)` 两处共用,去掉对宿主的依赖。 +- **验收**:#768 的示例程序在普通环境中输出 `compat=3`(run `37291652508`),#769 得到实测确认。把它加入 Windows e2e,**普通环境和开发者环境各跑一次**(开发者环境依赖 D8),并更新 SPEC-009 §6.2。 + +--- + +## 第二部分:COFF 读取器 + +## 6. D3:按类别处理匿名对象 + +**实测头部**(run `37290831503`): + +| 编译选项 | 前 4 字节 | Version | ClassID(偏移 12) | +| --- | --- | --- | --- | +| 无 | `64 86 …` | — | — | +| `/bigobj` | `00 00 ff ff` | 2 | `c7a1bad1 eeba a94b af20 faf66aa4dcb8`(LLVM 的 `BigObjMagic`) | +| `/GL` | `00 00 ff ff` | 1 | `38feb30c a5d9 ab4d ac9b d6b6222653c2`(LLVM 的 `ClGlObjMagic`) | +| `/GL /bigobj` | 与 `/GL` 相同 | 1 | 与 `/GL` 相同 | + +另外: +- `dumpbin /symbols` 对 `/GL` 对象只给出 `ANONYMOUS OBJECT`。 +- 短导入对象是 Version 0(来自 PE 规范,没有实测)。 +- mcpp 今天把 `/GL` 对象报成 “this is a /bigobj object”(实测)。 + +**设计:** +- 判别:`Sig1 = 0 && Sig2 = 0xFFFF` 时,先看 Version:0 是导入对象;≥ 1 时再看 ClassID:bigobj / `/GL` / 未知。 +- `/bigobj`:实现读取(56 字节的头、32 位的节数和符号数、20 字节的 `IMAGE_SYMBOL_EX`),与 CMake 的 `bindexplib.cxx` 能力对齐。 +- `/GL`、未知类别、导入对象:E3,各自给出准确的诊断。`/GL` 的诊断给出两条改法:`windows_auto_export = false` + `__declspec(dllexport)`(实测今天就能用,见 §8),或者去掉 `/GL`。 + +**自审(稳定性):** +- 读取器的错误会**静默**产出错误的导出面,这是最难发现的一类问题。所以要求: + - 测试数据使用 CI 中由 cl.exe 实际产出的 bigobj / `/GL` 对象字节(从 #772 的 job 中提取,作为 unit 测试的数据文件)。 + - 加一个 Windows e2e:对同一个库分别用 bigobj 和普通 COFF 构建,比较两者的 `.def`,必须一致。 + +--- + +## 第三部分:原生 MSVC LTO + +## 7. D4 / D9:cl.exe 上兑现 `lto = true` + +**实测的现状**:`[profile.release] lto = true` 时输出 `Finished release [optimized + lto]`,但 `build.ninja` 中没有 `/GL` 也没有 `/LTCG`。 + +**工具行为实测**(run `37290831503`): + +| 情形 | 结果 | +| --- | --- | +| `/GL` 对象,链接时不加 `/LTCG` | 成功,link.exe 提示 “restarting link with /LTCG” | +| `lib` 归档 `/GL` 对象,不加或加 `/LTCG` | 都成功,都没有输出 | +| `/LTCG /DEBUG` | 成功,没有警告 | +| `/LTCG /INCREMENTAL` | LNK4075 警告 | +| `/GL` 加 `/Zi /FS` | 成功 | +| `/GL` DLL + `__declspec(dllexport)` | 导出正确 | +| `import std`:std 与消费者的 `/GL` 取三种组合 | 都能链接并运行 | + +**设计:** +- 项目 TU 编译加 `/GL`;exe / DLL 链接加 `/LTCG`;`lib` 加 `/LTCG`(与 CMake 的 `ARCHIVE_CREATE_IPO` 一致)。 +- 不加 `/INCREMENTAL:NO`(实测不需要)。 +- std 模块的对象不加 `/GL`(实测三种组合都可以),std 缓存因此不区分 LTO。 +- D9:`profile_descriptor` 只在 LTO 实际生效时显示 `+ lto`;降级时(§8)显示 `+ lto (partial)`,详情在 W 报告中。 +- 缓存:`lto` 已经在 profile 指纹中,cl 版本已经在键中(#746)。 + +**自审(兼容性)**:已经写了 `lto = true` 的 MSVC 项目,链接会变慢、产物会改变。这是用户声明的意图,release note 要写明。 + +## 8. D5:与 `windows_auto_export` 的交互 + +**降级的单位是包,不是目标(修订 4 的更正):** +- 发现边读取的是 `lu.objects`。对于依赖拥有的 DLL,它包含拥有者包的对象,以及放入该镜像的静态依赖包的对象(`staticsByImagePackage`,#646 F1)。根包的共享库同理。 +- mcpp 中一个源文件只编译成一个对象。所以降级必须作用于 “对象进入某个开启自动导出的 DLL 的那些包”。这些包的对象如果也链接进 exe,exe 中的这部分代码同样不做 LTO,是可接受的代价,W 报告中要写出来。 + +**实测支撑**(run `37290831503`):DLL 自己的对象是普通 COFF,加一个 `/GL` 静态库(`lib /LTCG`),再加 `.def`,`link /DLL /LTCG`:导出正确。不加 `/LTCG` 时 link 自动重启,同样正确。 + +**`windowsAutoExport` 改为 `std::optional`**: +- 两个解析器(`toml.cppm`、`xpkg.cppm`)只在显式书写时赋值;读取时 `value_or(true)`。 +- `pack` 的清单生成(`manifest_emit`)必须保留 “没写” 与 “显式 `true`” 的区别。 + +**规则**(cl.exe 行、LTO 生效、PE 共享库;根包、成员、依赖都适用): + +| 情形 | 级别 | 行为 | +| --- | --- | --- | +| 显式 `true` | E1 | 拒绝;改法:写 `false` 并标注导出,或者关闭 LTO | +| 用户 flag 中有 `/GL`,并且该包的对象进入开启自动导出的 DLL | E1 | 拒绝;改法:写 `false`,或者去掉 `/GL` | +| 没写 | W | 上述包的对象不加 `/GL`;DLL 仍加 `/LTCG`;每次运行一条汇总,列出受影响的 DLL 和包,以及两条改法 | +| 显式 `false` | — | 完整的 `/GL` + `/LTCG`。实测:显式 `false` + `__declspec(dllexport)` + `/GL` 在今天就能构建,`dumpbin /exports` 正确 | + +不适用:LLVM LTO(#763)、clang 的 MSVC-ABI 行、exe、静态库、ELF、Mach-O、MinGW。 + +**为什么默认值不改**: +- 改成全局 `false`,依赖自动导出的现有 DLL 会得到空的导入库。 +- 改成 “仅在 LTO 下为 `false`”,同一份 manifest 在 debug 和 release 下的 DLL 接口不同。 + +## 9. D6:`pack` 产出的静态库不携带 LTO 中间表示 + +- 依据:MSVC 的 `/GL` 文档;发行版不在静态库中分发 LTO 中间表示(例如 Fedora 的 LTO 方针)。GCC 默认的 slim LTO 对象,换一个编译器版本就无法使用。 +- 设计:`mcpp pack` 产出静态库时,对这些对象不启用 LTO(cl.exe 不加 `/GL`,gcc / clang 不加 `-flto`),报告一条 W。exe 和 DLL 不受影响。 +- 自审:今天 gcc / clang 的 `pack` 遇到 `lto = true` 时会产出带中间表示的 `.a`(`src/pack` 中没有 LTO 处理),所以这对它们也是一项行为变化。实现时先核实 `pack` 实际使用的 profile。 + +--- + +## 第四部分:共享库的链接配置(#771) + +## 10. D7:依赖拥有的共享库 = 图级 flag + 自己闭包的 flag + +### 10.1 判定 + +真实缺陷,不是使用问题。用户用的是有文档的 `build.mcpp` API;期望的行为就是工作空间设计 §15 对成员程序的承诺。 + +### 10.2 复现 + +- **Windows**(run `37291652508`,cl.exe 行,`link_lib("ws2_32")`): + - 成员 DLL 的链接行缺少 `ws2_32.lib`,却带着**另一个成员**的 `/IGNORE:4099`。 + - 结果 `LNK2019 __imp_WSAGetLastError`。`-p player` 同样失败。 + - 同一个成员的 exe 链接成功。 +- **Linux**(本地):成员的 `.so` 缺少自己的 `build.mcpp` flag(`-lm`、标记),却带着别的成员的标记。 +- **非工作空间对照**:依赖的 `.so` 走汇集行,自己的 flag 不缺,但还带着根包和兄弟依赖的 flag。 + +### 10.3 成因 + +- 依赖拥有的共享库链接单元(`plan.cppm` 的 `sharedDepTargets` 循环)没有设置 `linkGroup`,所以用的是计划的全局链接行。 +- 在工作空间中,成员 `build.mcpp` 的输出不进入全局行,而成员 manifest 的 `ldflags` 会被汇集进全局行。 + +### 10.4 设计 + +1. **把图级 flag 单独记录。** 新增 `graphLdflags`,在写入 `state.m` 的同时记录: + - ABI 渲染的 `-pthread` / `-fexceptions`:docs/22 的契约要求它们到达链接,一定是图级。 + - profile 的 `ldflags`(D7a,建议算图级):链接选项多是整件产物的属性(加固、链接器选择、sanitizer 运行库),而且这样保持非工作空间的现状。 + + 根包自己的 `[build] ldflags` 和根包 `build.mcpp` 的输出是根包**私有**的,不进入 `graphLdflags`。 +2. **一个 helper**:`link_group_for(closure, productDir, linkOnly)`: + - `ldflags = graphLdflags + 闭包内各包的 linkUsage.ldflags`(拥有者在前,其余按发现顺序)。 + - `derive_runtime(闭包)`。 + - 闭包:从拥有者出发,沿依赖边走,跳过 `artifacts` 边和 `buildTimeOnly` 包。 +3. **三处共用这个 helper**: + - 依赖拥有的共享库(所有计划;同一个包的多个共享库目标共用一个组)。 + - 成员程序:今天从 `packages[0].linkUsage.ldflags` 起步,改为从 `graphLdflags` 起步。这样带 `[package]` 的工作空间根,其私有 flag 也不再泄漏给成员。 + - artifact 程序。 +4. **刷新链接快照**:构建程序运行后,对**所有**依赖重新计算 `linkUsage.ldflags`(今天只在工作空间中做),搜索路径按声明它的包解析。 +5. 根包自己的 exe 和共享库仍然使用全局行(汇集),不变。 + +### 10.5 对照数据(来自 #773 的实验构建,Linux,非工作空间) + +这些数据用来定出 §10.4 的边界: + +| 依赖 `.so` 的链接行 | main | “只取闭包,不取根” 的实现 | +| --- | --- | --- | +| 根包私有标记 | 有(泄漏) | 无 | +| profile 的 `-z relro,-z now` | 有 | 无 → 所以需要 D7a | +| ABI 的 `-pthread` | 有 | 无 → 违反 docs/22,所以需要 `graphLdflags` | +| 根包 `-L`,依赖只写 `-lfoo` | 构建成功 | `ld: cannot find -lfoo` → 这就是 D7b 的兼容性变化 | + +### 10.6 D7b:根包私有 flag 不再进入依赖 DLL + +- **接受这项变化**: + - SPEC-004 §9.6 禁止需求从消费者流向依赖。 + - 而且泄漏本身会**静默**产出错误的产物:根包的 `-Wl,--version-script`、`/DEF:`、`/SUBSYSTEM`、`/ENTRY` 进入依赖 DLL 后,导出面或入口会是错的。一次带提示的链接失败,好过一个静默错误的产物。 +- **N**(只在一个版本内):根包私有 `ldflags` 中有搜索路径或库(`-L`、`-l`、`/LIBPATH:`、`*.lib`),并且计划中有依赖拥有的共享库时,给出一条 note,点名这些 flag 和受影响的共享库,说明依赖应当自己声明(`[build] ldflags` 或 `mcpp::link_search` / `link_lib`)。 +- release note 写明迁移方法。 + +### 10.7 测试 + +- unit: + - 依赖 DLL 的组包含:拥有者和闭包的 flag、`graphLdflags`(ABI、profile)。 + - 不包含:根包的私有 flag、兄弟成员或兄弟依赖的 flag。 + - 工作空间和普通计划都测,ELF 和 PE 都测,一个包有两个共享库目标的情形也测。 +- e2e: + - 在产物上断言:ELF 用 `readelf -d` 读 RUNPATH 标记,Mach-O 用 `otool -l`,Windows 用 `ws2_32` 做行为测试(修复前 LNK2019)。 + - 全量构建和 `-p` 各跑一次。 + - N 只在出现搜索路径或库时给出。 + +--- + +## 第五部分:Windows 参数引号 + +## 11. D8:以反斜杠结尾的参数 + +- **现象**(run `37291652508`):在 VS 开发者环境中(`WindowsSdkDir=…\10\`),llvm + MSVC 行的 std 模块预编译失败(`no such file or directory: 'Files\Microsoft'` 等)。同一个 job 在普通环境中,两个项目都成功。 +- **成因**: + - SDK 根原样带着末尾的 `\`,渲染成 `-Xmicrosoft-windows-sdk-root "…\10\"`。 + - `quote_windows`(`modules/platform/src/shell.cppm`)只转义 `"`。按 Windows 的 argv 规则,`\"` 是字面引号,这个参数的引号不会闭合。 + - `CreateProcess` 的命令行(`process.cppm`)也用这个函数,所以任何以 `\` 结尾的参数都会触发。 +- **判定**:真实缺陷,影响面大:在开发者命令行中,llvm 行的 `import std` 完全不可用。现有 Windows CI 不在开发者环境中运行 llvm 行,所以没有发现。 +- **设计**: + 1. `quote_windows` 实现标准的 argv 引号算法:一串反斜杠后面跟 `"` 或位于末尾时,把它们加倍。只有今天本来就是坏的参数,输出才会改变。 + 2. `WindowsSdkDir` 读入时去掉末尾分隔符,这样两种环境的 SDK 身份和缓存键一致,可以共享缓存。 + 3. 测试:unit 测试做往返校验(按 CommandLineToArgvW 规则解回,必须等于原参数),覆盖末尾 `\`、`\"`、`\\\"`、空格;Windows e2e 显式设置带末尾 `\` 的 `WindowsSdkDir`,构建 `import std` 和 `import std.compat`。 + +--- + +## 第六部分:工作空间根与根包的复用(O7) + +## 12. D10:profile、`[workspace.*]` 与根包 + +### 12.1 实测(Linux;本地 2026.10.5.1 与 CI run `37297856040` 中从源码构建的 main 结果一致) + +每个成员的 `[build]` 带自己的标记;`[profile.release]` 设置 `opt = 3`,并在 cxxflags 和 ldflags 中带标记;用 `--release` 构建,检查每条 ninja 边。 + +| 场景 | 命令 | 观察 | +| --- | --- | --- | +| W1 虚拟根,`[profile.release]` 写在根上,成员没有 profile | `build`、`-p app` | **根上的 profile 完全没有生效**:仍是 `-O2`,没有任何 profile 标记,也**没有任何诊断** | +| W2 虚拟根有 profile,成员 app 也有自己的 | `build` | 分成两个图:app 的图用 app 的 profile(`-O3`),lib 单独一个图(`-O2`);**lib 被编译两遍**;根上的 profile 依然没有生效 | +| W2 | `-p lib` | `-O2`,没有 profile | +| W3 带 `[package]` 的根:同一个文件里有 `[package] app` + `[workspace]` + `[profile.release]` + 自己的 `[build]` | `build`(构建根包) | 根文件的 profile 生效(`-O3`),lib 也是 `-O3`;**app 没有得到 `[workspace.build]`**,lib 得到了 | +| W3 | `--workspace` | 分成两个图:app 的图 `-O3`,lib 的图 `-O2`,**同一个命令下 lib 被编译成两种样子** | +| W3 | `-p lib` | `-O2`:根文件的 profile 对成员不生效 | +| 各场景 | — | lib 的 DLL 带着 app 的私有 `ldflags`,属于 #771 同类的泄漏,由 D7 处理 | + +### 12.2 成因 + +- 工作空间计划的根是一个合成的虚拟根(`project.cppm` 的 `virtual_workspace_root`),它的 `profiles = first.profiles`,也就是**取第一个被选中成员的 profile**。工作空间根自己的 `[profile]` 不在其中。 +- profile 不在根位置继承的范围内:`inherit_workspace_root_position` 只继承 `[toolchain]`、`[target.*]` 和 `[indices]`。 +- profile 是 `root_position_key` 的一部分,所以 profile 不同的成员会被分进不同的图。 +- 根包作为成员 `"."` 时,`load_member_manifest(".")` 直接返回工作空间清单本身,**不执行** `inherit_workspace_package` / `inherit_workspace_build`。直接构建根包时也不执行。两种路径的结果一致,但都没有继承。 + +### 12.3 判定 + +- W1 违反 `diag.cppm` 的不变式:用户写的配置被静默忽略。这是缺陷。 +- W3 中 “同一个文件里的 `[profile]` 是否生效取决于选择了哪个成员”,`--workspace` 下同一个库编译成两种样子,与工作空间设计 §5.4 的目标(成员共享配置时构建为一个图)相违背。 +- 行业对照:Cargo 的 profile 只在工作空间根上声明,成员的 profile 被忽略并给出警告;根包本身就是工作空间成员。 + +### 12.4 设计 + +**D10a:`[profile.*]` 加入根位置键。** +- `inherit_workspace_root_position` 增加 profile:成员按 profile **名**继承工作空间根上的 `[profile.]`;成员自己声明的同名 profile 整体优先。这与 SPEC-004 §9.10 对 `[toolchain]` 等键的规则一致;比 Cargo(忽略成员的 profile)更保守,现有成员的 profile 继续有效。 +- 由此: + - W1:根上的 profile 生效。 + - W3 `--workspace`:lib 继承根文件的 profile,与 app 的根位置键相同,合并为一个图,lib 只编译一次。 + - W3 `-p lib`:同样 `-O3`。 +- 与 D7a 的关系:图级 flag 中的 profile `ldflags` 取自继承之后的 profile。 + +**D10b:根包与其他成员一样继承 `[workspace.package]` / `[workspace.build]`。** +- 根包是成员 `"."`(`manifest.cpp` 中的注释:“A rooted workspace's own package is a member like any other”)。`load_member_manifest(".")` 和直接构建根包的加载路径都调用同一个继承函数,恰好一次;不调用根位置继承,因为根包与工作空间就是同一个文件。 +- 这样根包在每种选择下得到相同的命令,也满足 SPEC-004 §9 第 1 条 “恰好一次” 的要求。第 1 条列出的位置要补上 “带 `[package]` 的工作空间根自己”。 +- 向量的顺序遵循已有规则:`[workspace.build]` 在前,根包自己的 `[build]` 在后。 + +**D10c:根清单中键的归属,写成一张表**(进入 SPEC-004 §9 和 docs/07): + +| 键 | 归属 | 对根包的作用 | 对其他成员的作用 | +| --- | --- | --- | --- | +| `[workspace]`、`[workspace.dependencies]` | 工作空间 | 通过 `x.workspace = true` 显式引用 | 同左 | +| `[workspace.package]`、`[workspace.build]` | 工作空间 | 继承(D10b) | 继承 | +| `[toolchain]`、`[target.*]`、`[indices]`、`[profile.*]` | 根位置 | 直接生效 | 成员作为构建的根时继承;成员自己声明的优先(D10a) | +| `[package]`、`[build]`、`[dependencies]`、`[targets]`、`[features]`、`[resources]`、`[test]` | 根包 | 只作用于根包 | 不作用于成员 | + +- 根包的 `[build] ldflags` 是根包私有的。在 D7 中,它们进入根包自己镜像的链接组,不进入 `graphLdflags`,也不进入成员或依赖的共享库。这一点同样适用于带 `[package]` 的工作空间。 + +### 12.5 测试 + +- 把 #772 中的 `ws_profile.sh` 场景改写成 e2e,断言修复后的预期: + - W1:根上的 profile 生效。 + - W2:成员的 profile 优先。 + - W3:`--workspace` 下只有一个图,lib 只编译一次;`-p lib` 下 `-O3`;根包得到 `[workspace.build]`。 + - 各场景中,lib 的 DLL 不带 app 的私有 flag。 +- 断言每个包在 `build`、`--workspace`、`-p` 下的编译命令完全一致(工作空间设计 §5.3 的 “同一个单元在每种选择中都用同一条命令编译”)。 + +## 第七部分:Windows 代码页(O6) + +## 13. D11:`Glob.EscapedSpellingIsUtf8WhateverTheName` + +### 13.1 实测(run `37297856040`,`windows-latest`) + +| 项 | 结果 | +| --- | --- | +| runner 的 ANSI 代码页 | 1252 | +| CI 上用当前 main 运行 `mcpp test test_modgraph` | 该测试**通过** | +| 同一段转换,在不声明代码页的程序中(`GetACP() = 1252`) | `std::filesystem::path("caf\xE9")` 成功 | +| 同一段转换,在 `windows_code_page = "utf-8"` 的程序中(`GetACP() = 65001`) | **抛出** `No mapping for the Unicode character exists in the target multi-byte code page.`,与报告中的异常相同 | +| `MultiByteToWideChar(…, MB_ERR_INVALID_CHARS, "caf\xE9")` | cp 1252 成功;cp 936(GBK)失败;cp 65001 失败 | + +### 13.2 成因 + +- 测试的最后一行在**所有平台**上执行:`escaped_spelling(std::filesystem::path("caf\xE9"))`。 + - 在 Windows 上,`path` 用进程的 ANSI 代码页把窄字符串转为 UTF-16。 + - `\xE9` 只在 1252 这类单字节代码页中可以转换;在 936(中文系统)和 65001(UTF-8)中都会抛异常。 + - 这一行想测的是 POSIX 的情形(名字的字节不是 UTF-8),只应该放在 POSIX 分支。 +- **为什么 CI 一直发现不了**:mcpp.exe 声明了 UTF-8 代码页(#693,`docs/res/mcpp.rc`),而 `mcpp test` 构建的测试程序运行在系统代码页中(CI 上是 1252;`windows_code_page` 只能写在 `[targets]` 上,发现得到的测试没有地方写)。所以: + - 测试没有在产品的运行环境中执行。 + - 测试结果取决于运行它的机器的区域设置。 +- **与之前的修复的关系**:#231、#517、#698 修的是产品代码中的窄化(UTF-16 → 窄字符串)站点,并加了 `check_narrow_conversions.sh` 这道关卡。本问题出在 #698 加入的测试本身,以及测试程序的代码页,不在那些修复的覆盖范围内。产品本身(UTF-8 进程)不会构造这样的路径,没有发现产品缺陷。 + +### 13.3 设计 + +1. **修正测试**:Windows 分支只用 UTF-16 输入(孤立代理项、`é`);窄字符串的非 UTF-8 字节只放在 POSIX 分支。 +2. **测试程序可以声明代码页**: + - 新增 `[test] windows_code_page = "utf-8" | "legacy"`,作用于所有发现得到的测试程序。语义与 `[targets.].windows_code_page` 相同(SPEC-004 §5.3 的平台前缀规约,非 PE 时不产生任何东西)。 + - 默认仍是 `legacy`(程序的编码由程序自己决定,不改变现有项目)。 + - mcpp 自己的 `mcpp.toml` 写 `"utf-8"`,使单元测试与 mcpp.exe 运行在同一个代码页中,结果不再取决于机器的区域设置。 +3. **防止再次发生**:在 Windows CI 中,单元测试本来就在 UTF-8 中运行(第 2 条),这就是对 65001 的覆盖。936 等多字节代码页由第 1 条的测试修正覆盖;不为它单独增加 runner。 +4. **附带审计(低优先级,可以不进入本次发布)**: + - 在 UTF-8 进程中,用**非 UTF-8 的外部文本**构造 `path`(例如工具以 OEM 或 ANSI 代码页输出的路径)同样会抛异常。 + - 建议参照 `check_narrow_conversions.sh` 审计这类 “加宽” 站点,并按需要增加关卡。 + +## 14. 发布方案:一个发布 PR + +每个提交都可以单独构建、单独测试、单独 revert;前面的提交不依赖后面的决策。 + +| # | 提交 | 内容 | 新的诊断 | +| --- | --- | --- | --- | +| 1 | Windows 参数引号 | D8 + unit / e2e | — | +| 2 | 链接组 | D7 / D7a / D7b:`graphLdflags`、`link_group_for` 三处共用、快照刷新 | N | +| 3 | COFF 读取器 | D3:bigobj 读取、`/GL` 和未知类别报错,测试数据用 CI 中实际产出的对象 | E3 | +| 4 | 构建输出 | D9:`+ lto` 只在生效时显示 | — | +| 5 | SPEC 修订 | SPEC-004:`lto` 的语义(各行如何兑现,不能兑现时 W)、`windows_auto_export` 三态、链接 flag 的作用域(图级 / 私有 / 闭包);SPEC-003:新增的拒绝代码 | — | +| 6 | 原生 MSVC LTO | D4、D5(E1 / W,按包降级)、D6(所有工具链) | E1、W | +| 7 | 标准库模块 | D1、D2(按需编译 + E1)、§5 的语言判定统一 | E1 | +| 8 | 工作空间与根包 | D10a / D10b / D10c:profile 成为根位置键、根包继承、键归属表进入 SPEC-004 §9 和 docs/07;`ws_profile` e2e | — | +| 9 | 代码页测试 | D11:修正测试、`[test] windows_code_page`、mcpp 自己的测试使用 UTF-8 | — | +| 10 | 验收与文档 | Windows `import std.compat` e2e(两种环境)、SPEC-009 §6.2、中英文 docs、CHANGELOG | — | + +**CI 覆盖:** + +| 内容 | 覆盖它的 job | +| --- | --- | +| D8 | Windows e2e,在一个作业中设置 `WindowsSdkDir`,或者新增一个开发者环境的作业 | +| D7 | Linux / macOS / Windows e2e(在产物上断言) | +| D3、D4、D5 | Windows e2e,cl.exe 行(`ci-windows-e2e`) | +| D2、§5 | Windows e2e,llvm + MSVC 行 | +| D10 | Linux / macOS / Windows e2e(在边和产物上断言) | +| D11 | Windows 单元测试(UTF-8 进程中运行) | + +#772 中的探测脚本保留到发布 PR 合入,期间可以随时复测。 + +## 15. 兼容性与风险 + +| 变化 | 影响面 | 处理 | +| --- | --- | --- | +| 引号修复 | 只有今天本来就是坏的参数 | 无 | +| SDK 根规范化 | 开发者环境中的 std 缓存重建一次 | release note | +| 根包私有 flag 不再进入依赖 DLL | 依赖这一泄漏的项目会链接失败 | N(一个版本)+ release note | +| 依赖 DLL 得到自己 `build.mcpp` 的 flag | 修复 | — | +| cl.exe 上 LTO 真正生效 | 写了 `lto = true` 的 MSVC 项目 | release note | +| 显式 `true` 或手写 `/GL` 与自动导出冲突 | 很少 | E1 | +| `pack` 的静态库不带 LTO 中间表示 | 开了 LTO 并打包静态库的项目 | W | +| import 了 compat 但标准库不提供 | 今天就失败,只是诊断变好 | E1 | +| bigobj 可以参与发现 | 以前失败的项目 | 无 | +| 工作空间根上的 `[profile]` 开始生效 | 在虚拟根上写了 profile 的工作空间(今天被忽略) | release note | +| 带 `[package]` 的工作空间根,profile 对所有成员生效、合并为一个图 | 这类工作空间的成员 | release note(结果是少编译一遍) | +| 根包继承 `[workspace.build]` / `[workspace.package]` | 带 `[package]` 的工作空间的根包 | release note | +| `[test] windows_code_page` | 新增的键,默认不变 | 无 | + +**风险与对策:** +- COFF 读取器出错会静默得到错误的导出面 → 用真实对象字节做测试,再用 bigobj 与 COFF 的 `.def` 对比。 +- 链接组改变了所有依赖共享库的链接行 → unit 测试做正向和反向断言,e2e 在产物上断言。 +- 一个发布 PR 体量大 → 按提交可以 bisect,并跑完整 CI。 + +## 16. 开放问题 + +- **已关闭**: + - **O1**:没有 clang-cl 行。 + - **O2**:std 模块对象不加 `/GL`。 + - **O5**:所有计划使用同一规则(§10)。 + - **O6**:查明原因并纳入方案(§13,D11)。可以登记一个 issue 追踪,由发布 PR 关闭。 + - **O7**:查明原因并扩展为 §12(D10a–D10c)。 +- **仍然开放**: + - **O3**:LLVM bitcode 静态库的分发,由 D6 统一处理;如果需要例外,再单独讨论。 + - **O4**:W 报告每次运行汇总一条(已经纳入设计,请确认)。 + + +## 17. 发布说明草稿 + +- **修复**: + - 在 Visual Studio 开发者命令行中使用 llvm 工具链(MSVC ABI)时,`import std` 编译失败。原因是以 `\` 结尾的参数被错误引用。 + - 依赖和工作空间成员的共享库,会用上它自己的包(包括 `build.mcpp`)及其依赖声明的链接库和选项(#771)。ABI 和 profile 的链接选项仍然到达所有镜像。 + - `import std.compat` 在所选标准库不提供它时,会在构建开始前给出明确的错误。 + - MSVC 的 `/bigobj` 对象可以参与自动导出发现;`/GL` 对象得到准确的诊断。 + - 工作空间根上写的 `[profile.*]` 现在会生效(以前被静默忽略);成员自己声明的同名 profile 优先。在带 `[package]` 的工作空间中,根文件的 profile 对所有成员一致生效,不再因选择不同而把同一个库编译成两种样子。 + - mcpp 的单元测试在 Windows 上与 mcpp.exe 运行在同一个代码页(UTF-8)中,不再依赖机器的区域设置。 +- **变化**: + - 带 `[package]` 的工作空间,根包与其他成员一样继承 `[workspace.package]` 和 `[workspace.build]`。 + - 新增 `[test] windows_code_page`。 + - 根包自己的 `[build] ldflags` 不再进入依赖的共享库。依赖需要的搜索路径和库,应当由依赖自己声明。 + - cl.exe 上的 `lto = true` 现在会真正启用 LTO(`/GL` + `/LTCG`)。 + - 没写 `windows_auto_export` 的 DLL 在 LTO 下降级(报告);显式 `true` 或手写 `/GL` 与自动导出同时出现时报错。 + - `mcpp pack` 产出的静态库不携带 LTO 中间表示。 + - `import std.compat` 只在被使用时编译。 +- **构建输出**:`+ lto` 只在 LTO 实际生效时显示。 diff --git a/.agents/docs/README.md b/.agents/docs/README.md index 53c1c7b7..8354c2f1 100644 --- a/.agents/docs/README.md +++ b/.agents/docs/README.md @@ -18,7 +18,7 @@ superseded_by: 2026-09-07-....md # when status is superseded --- ``` -325 records. +326 records. ## By subject @@ -30,6 +30,7 @@ Records that declare one. Everything else is listed by date below. ### design +- [下一个版本的发布方案:标准库模块、原生 MSVC LTO 与导出发现、共享库的链接配置、Windows 参数引号(#768 后续、#770、#771)](2026-10-05-std-module-pair-msvc-lto-and-export-discovery-design.md) — active - [`mcpp run` hands the terminal to the program, and the follow-ups of #761, #763 and #765 (#766)](2026-10-05-run-terminal-handoff-and-766-follow-ups-design.md) — landed - [PR CI acceleration and the toolchain specification (#756, #757, #669)](2026-10-02-pr-ci-acceleration-and-the-toolchain-specification-design.md) — active - [工具与工具链的来源:声明、编程决定、可观察](2026-10-01-tool-and-toolchain-sources-design.md) — landed @@ -116,6 +117,7 @@ Records that declare one. Everything else is listed by date below. ### 2026-10 +- [下一个版本的发布方案:标准库模块、原生 MSVC LTO 与导出发现、共享库的链接配置、Windows 参数引号(#768 后续、#770、#771)](2026-10-05-std-module-pair-msvc-lto-and-export-discovery-design.md) — active - [`mcpp run` hands the terminal to the program, and the follow-ups of #761, #763 and #765 (#766)](2026-10-05-run-terminal-handoff-and-766-follow-ups-design.md) — landed - [PR CI acceleration and the toolchain specification (#756, #757, #669)](2026-10-02-pr-ci-acceleration-and-the-toolchain-specification-design.md) — active - [工具与工具链的来源:声明、编程决定、可观察](2026-10-01-tool-and-toolchain-sources-design.md) — landed diff --git a/.gitignore b/.gitignore index a039560e..020f2909 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,6 @@ examples/*/target/ # `mcpp test` writes a per-invocation scratch dir here. .mcpp/ + +# Local review reports (not part of the design record). +/.agents/reviews/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 33d6a8d4..79228969 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,79 @@ > Each `## []` section is that release's notes. Entries are written in English > from 2026.9.28.3 on; earlier entries remain as written. +## [2026.10.5.2] - 2026-10-05 + +This release closes mcpp#771 and mcpp#770 and the follow-ups of mcpp#768: the +link line of a shared library that is not the root's, native MSVC LTO and its +meeting with export discovery, and `std.compat` on every Default row. It also +fixes the Windows quoting of an argument that ends in a backslash, and makes a +workspace's `[profile.*]` and its own package behave as members do. No default +toolchain changes. The design record is +`.agents/docs/2026-10-05-std-module-pair-msvc-lto-and-export-discovery-design.md`. + +### Fixed + +- **A shared library links with its own package's flags (#771).** A shared + library that is not the root's linked with the root's link line. In a + workspace a member's `build.mcpp` libraries did not reach the member's own + DLL (`LNK2019` on the symbol), and in every plan the root's private + `ldflags`, and an unrelated member's, reached a dependency's image. Such a + library now links with the graph's flags (the profile's `ldflags`, the words + `[target..abi]` renders) and those of the packages its owner + reaches. E2E 884 reads the result from the produced images. +- **Arguments ending in a backslash on Windows.** The quoting of a Windows + command line escaped `"` but not the backslashes before it, so an argument + ending in `\` closed no quote and shifted every argument after it. In a + Visual Studio developer environment, whose `WindowsSdkDir` ends in `\`, the + LLVM row's `import std` failed in the std module precompile. The quoting + follows the argv rules of the Windows runtime, and `WindowsSdkDir` is read + without its trailing separator, so one SDK has one cache key inside and + outside a developer environment. E2E 888. +- **`import std.compat` on the GCC row.** libstdc++'s `bits/std.compat.cc` was + not built, and the first unit importing `std.compat` failed. It is built + beside `std` now, as SPEC-009 §6.2 requires of a Default row. E2E 886. +- **`/bigobj` objects take part in export discovery.** The reader of a DLL's + objects reads the `/bigobj` layout. A `/GL` object, which has no symbol table, + and an import object are reported as what they are; both were reported as + `/bigobj` before. +- **A workspace's `[profile.*]` reaches its members.** A virtual root's profiles + reached no member and were ignored without a diagnostic. Profiles are now + root-position keys, inherited by name, and a member's own table of a name + replaces the workspace's. In a workspace with its own `[package]`, the root + file's profile is the same for every selection, so `--workspace` plans one + graph and compiles a shared library once. E2E 885. +- **The unit tests of mcpp run in mcpp.exe's code page.** + `Glob.EscapedSpellingIsUtf8WhateverTheName` constructed a Windows path from + bytes that only a single-byte code page converts, and failed on a machine in + the Chinese region; it passed on CI, whose code page is 1252. The test is + corrected, and mcpp's tests run in UTF-8 as mcpp.exe does. + +### Changed + +- **`lto = true` on cl.exe.** It compiled nothing differently while the build + summary said `+ lto`. It now compiles with `/GL` and links and archives with + `/LTCG`. A DLL whose exports are discovered cannot hold `/GL` objects: when + `windows_auto_export` is omitted, the packages linked into it compile with + `/GL-` and the build reports this once (`+ lto (partial)`); a stated `true`, + and `/GL` written into those packages' flags, are refused with + `lto-export-discovery`. +- **The root package's `[build] ldflags` no longer reach a dependency's shared + library.** A dependency that needs a search path or a library states it in + its own `ldflags` or `build.mcpp`. When the root names one and the plan holds + a dependency's shared library, the build notes it under `link/root-flags`. +- **A workspace's own package receives `[workspace.package]` and + `[workspace.build]`** once, as every member does. +- **`std.compat` is built only when a unit imports it**, and an import of it + from a standard library that does not provide it is refused before any + compile, naming the library. +- **`mcpp pack` ships static libraries without LTO intermediate code**, which + only the compiler release that wrote it can read. + +### Added + +- **`[test] windows_code_page`** states the code page of every discovered test + program on Windows, with the values of the target key of the same name. + ## [2026.10.5.1] - 2026-10-05 This release gives the program that `mcpp run` starts the terminal, and closes diff --git a/docs/04-mcpp-toml.md b/docs/04-mcpp-toml.md index e5be0390..cc61a05a 100644 --- a/docs/04-mcpp-toml.md +++ b/docs/04-mcpp-toml.md @@ -343,9 +343,24 @@ executables, ELF, Mach-O and MinGW. MSVC-ABI target is planned, since `exports` narrows the discovered symbols; the same manifest builds on ELF and Mach-O. -Discovery reads COFF objects directly and LLVM bitcode (FullLTO and ThinLTO, -alone or mixed with COFF objects) with the selected LLVM compiler and the -`llvm-nm` beside it. +Discovery reads COFF objects directly, including `/bigobj` objects +(2026.10.5.2+), and LLVM bitcode (FullLTO and ThinLTO, alone or mixed with COFF +objects) with the selected LLVM compiler and the `llvm-nm` beside it. An object +compiled by cl.exe with `/GL` holds no symbol table and is reported by name. + +**With LTO on cl.exe (2026.10.5.2+).** `lto = true` compiles with `/GL`, which +leaves nothing to discover. The three ways of writing the key behave +differently there, because only an omitted key is mcpp's to decide: + +| Written | Result under `lto = true` on cl.exe | +|---|---| +| omitted | the packages whose objects the DLL links (its own and the static libraries placed in it) compile with `/GL-`; the DLL still links with `/LTCG`; the build reports this once and its summary reads `+ lto (partial)` | +| `true` | refused when the target is planned (`lto-export-discovery`) | +| `false` | full `/GL` and `/LTCG`; the sources declare the exports with `__declspec(dllexport)` | + +`/GL` written into the flags of a package whose objects such a DLL links is +refused in the same way, with or without `lto`. LLVM LTO on the MSVC ABI emits +bitcode, which discovery reads, and is not affected. #### `windows_subsystem` and `windows_entry` — a Windows GUI executable (mcpp 2026.9.12.2+) @@ -550,6 +565,18 @@ root, and a dependency's `ldflags` reach its consumer word by word. An element escaped for ninja or the shell by hand (`\$ORIGIN`, `'$$ORIGIN'`) now reads as written; the first plan names such an element under `build/flag-words`. +**Which link a package's `ldflags` reach (2026.10.5.2+).** The root package's +own programs and shared libraries link with its `ldflags` and those of every +package it reaches. A shared library that another package owns links with the +graph's flags (the profile's `ldflags` and the words `[target..abi]` +renders for the link) and the `ldflags` of the packages its owner reaches, +`build.mcpp` output included. Before 2026.10.5.2 such a library linked with the +root's line, so the root's private flags and an unrelated workspace member's +reached it, and in a workspace a member's own `build.mcpp` libraries did not +(#771). A dependency that needs a search path or a library states it in its own +`ldflags` or `build.mcpp`; when the root names one and the plan holds a +dependency's shared library, the build notes this under `link/root-flags`. + `compile_commands.json` and `mcpp emit build-database` list the same words in `arguments`, ready to execute without a shell. @@ -1386,7 +1413,7 @@ where it would become a BMI path that nothing reports. [profile.dist] opt = 3 # -O level (a number, or the string "s"/"z") debug = false # -g -lto = true # -flto (note: some packaged gcc builds ship without the LTO plugin) +lto = true # -flto; /GL + /LTCG on cl.exe (note: some packaged gcc builds ship without the LTO plugin) strip = true # -s at link time # passthrough escape hatch (fixed keys, open values): cflags = ["-fno-plt"] @@ -1819,6 +1846,7 @@ discover = ["tests/**/*.cpp"] # the default | Key | Type | Meaning | |---|---|---| | `discover` | array of globs | every file a glob matches is one test program; a glob beginning with `!` removes the files it matches; `[]` discovers none | +| `windows_code_page` | `"utf-8"` or `"legacy"` | the ANSI code page of every test program on Windows, in the meaning of the target key of the same name; the default is `"legacy"`, the system's code page *(2026.10.5.2+)* | The globs use the vocabulary of `[build] sources`. A test's name is its path relative to the fixed directory of the first glob that matched it, without the @@ -1827,6 +1855,12 @@ an array of non-empty strings is an error; any other key in `[test]` is a warning, and an error under `--strict`. [08 — Testing](08-testing.md) describes the test model. +A test program exercises the code it links, and that code may run in a +declared code page. `windows_code_page = "utf-8"` lets the tests run in the same +one, so that their result does not depend on the region setting of the machine +that runs them. The test programs receive the application manifest and nothing +else of `[resources]`. + ## 3. Worked Examples diff --git a/docs/07-workspace.md b/docs/07-workspace.md index 1dc8df77..89e3d701 100644 --- a/docs/07-workspace.md +++ b/docs/07-workspace.md @@ -282,6 +282,30 @@ in the workspace root is already inherited by every member, per triple, with the member winning. A second spelling for the same capability would be surface with no function. +**Profiles are inherited by name (2026.10.5.2+).** A `[profile.]` in the +workspace root reaches every member that does not declare a profile of that +name; a member's own table replaces the workspace's whole, as +`[target.]` does. A profile is one value per graph, so members that +share it are planned and compiled together. Before 2026.10.5.2 a virtual root's +profiles reached no member and were ignored without a diagnostic, and a rooted +workspace's applied only when its own package was the first one selected. + +**The root package is a member.** In a workspace whose root carries +`[package]`, the root package receives `[workspace.package]` and +`[workspace.build]` once, as every other member does (2026.10.5.2+), so its +commands are the same in every selection. The root manifest holds three kinds +of keys: + +| Keys | Owner | Effect on the root package | Effect on other members | +|---|---|---|---| +| `[workspace]`, `[workspace.dependencies]` | the workspace | through `x.workspace = true` | through `x.workspace = true` | +| `[workspace.package]`, `[workspace.build]` | the workspace | inherited | inherited | +| `[toolchain]`, `[target.]`, `[indices]`, `[profile.]` | the root position | its own | inherited where the member is the root of a build; the member's own declaration wins | +| `[package]`, `[build]`, `[dependencies]`, `[targets]`, `[features]`, `[resources]`, `[test]` | the root package | its own | none | + +The root package's `[build] ldflags` are its own as well: they reach its own +images, and neither the members' images nor a dependency's shared library. + ### 4.2 One standard for the whole module graph A C++ module graph has exactly one standard: BMIs are not compatible across diff --git a/docs/12-binary-distribution.md b/docs/12-binary-distribution.md index f6ea80c6..c30de078 100644 --- a/docs/12-binary-distribution.md +++ b/docs/12-binary-distribution.md @@ -345,6 +345,14 @@ archive is only ever `--strip-debug`ed, because `--strip-all` removes the archive symbol index and the consumer's link then fails with `archive has no index; run ranlib to add one`. +### LTO intermediate code is not shipped (2026.10.5.2+) + +A profile with `lto = true` compiles a packed static library's objects without +LTO: no `-flto` intermediate code, and no `/GL` objects on cl.exe. Such code is +readable only by the compiler release that wrote it, and a prebuilt archive is +consumed by other releases. The build reports this once and its summary reads +`+ lto (partial)`. Programs and shared libraries in the same build keep LTO. + ## Current limitations | | status | diff --git a/docs/20-toolchains.md b/docs/20-toolchains.md index 74a7b861..fd28ca92 100644 --- a/docs/20-toolchains.md +++ b/docs/20-toolchains.md @@ -1034,6 +1034,24 @@ The device row keeps `runner` unset. An artifact cannot be run off an iOS device without a signature the developer owns, which is not something a build tool can supply. +## The standard library modules: `std` and `std.compat` + +A toolchain provides `import std` when its standard library ships the module +source, and `import std.compat` when it ships the second one beside it. The two +are read from one directory and offered as a pair (2026.10.5.2+): + +| Standard library | `std` | `std.compat` | +|---|---|---| +| libstdc++ (GCC 15 and later) | `bits/std.cc` | `bits/std.compat.cc` (2026.10.5.2+) | +| libc++ | `std.cppm` | `std.compat.cppm` | +| MSVC STL (cl.exe and the llvm row on the MSVC ABI) | `modules/std.ixx` | `modules/std.compat.ixx` (llvm row 2026.10.5.1+) | +| a package (`[package] std-module`) | its `std-module` | its `std-compat-module`, or none | + +Each module is compiled once per configuration into the global cache, and +`std.compat` only when a unit of the build imports it (2026.10.5.2+). A build +that imports `std.compat` from a library that does not provide it is refused +before any compile, naming the library. + ## Project-Level Version Pinning If a project needs to pin a specific version rather than rely on the global default, declare it in the project's `mcpp.toml`: diff --git a/docs/50-machine-output.md b/docs/50-machine-output.md index fead53fd..bbb849bc 100644 --- a/docs/50-machine-output.md +++ b/docs/50-machine-output.md @@ -486,6 +486,7 @@ a program classifying the outcome reads `reason`: | `managed-only` | `--managed-only` met a source that is not the ecosystem's: the message names each one and where it was stated *(2026.10.1.3+)* | | `payload-override` | an `[xlings.overrides]` entry names a path that does not exist, a version a requirement refuses, or is stated by a dependency *(2026.10.1.3+)* | | `payload-request` | a build program asked for a payload no manifest of this build declares `provision = "on-request"`, or asked again in three consecutive runs *(2026.10.1.3+)* | +| `lto-export-discovery` | a PE shared library whose exports are discovered would be compiled by cl.exe into `/GL` objects, which hold no symbol table: `windows_auto_export = true` stated under `lto = true`, or `/GL` written into the flags of a package it links *(2026.10.5.2+)* | | `local-toolchain` | a toolchain named by path, or stated by a build program's toolchain phase, cannot be used: no driver, a contradicting family, a missing tool or sysroot *(2026.10.1.3+)* | | `convention-unreplaced` | the convention was overridden and nothing replaced it | | `os-mismatch` | the requested and resolved triples name different systems | diff --git a/docs/specs/README.md b/docs/specs/README.md index d86f907c..80f38863 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -33,12 +33,12 @@ | [SPEC-001](package-identity.md) | 包身份(`package.namespace` / `package.name`)、`[dependencies]` 选择器与匹配机制 | 评审中 v1.1 | 2026-08-03 | mcpp >= 0.0.106 | | [SPEC-002](target-side.md) | 目标侧模型与能力声明(`mcpp:` 保留命名空间、五层、三条规则) | 评审中 v1.0 | 2026-08-24 | mcpp >= 2026.8.24.2 | | [SPEC-003](exit-codes.md) | 退出码契约(分类、语义、稳定性承诺) | 评审中 v1.0 | 2026-09-01 | mcpp >= 2026.9.1.1 | -| [SPEC-004](manifest-semantics.md) | `mcpp.toml` 的平面划分、条件化形状、解析轴与命名规约 | 草案 v1.11 | 2026-10-05 | 条件化形状 mcpp >= 2026.8.29.1;目标轴 mcpp >= 2026.9.6.4;`linkage` 默认值 mcpp >= 2026.9.15.2;链接 flag 的词读法 mcpp >= 2026.9.26.2;条件化的 `dialect_cxxflags` 与 `-p` 的包身份 mcpp >= 2026.9.28.1;条件表按具体程度生效 mcpp >= 2026.9.28.2;平台前缀规约 mcpp >= 2026.10.5.1 | +| [SPEC-004](manifest-semantics.md) | `mcpp.toml` 的平面划分、条件化形状、解析轴与命名规约 | 草案 v1.12 | 2026-10-05 | 条件化形状 mcpp >= 2026.8.29.1;目标轴 mcpp >= 2026.9.6.4;`linkage` 默认值 mcpp >= 2026.9.15.2;链接 flag 的词读法 mcpp >= 2026.9.26.2;条件化的 `dialect_cxxflags` 与 `-p` 的包身份 mcpp >= 2026.9.28.1;条件表按具体程度生效 mcpp >= 2026.9.28.2;平台前缀规约 mcpp >= 2026.10.5.1;根包的继承、根位置的 profile、链接 flag 的作用域、链接期优化与导出发现 mcpp >= 2026.10.5.2 | | [SPEC-005](build-database.md) | 构建数据库:`mcpp emit build-database` 的内容、取值规则与不写工程目录的保证 | 评审中 v1.6 | 2026-09-29 | mcpp >= 2026.9.15.1;v1.3 条款 mcpp >= 2026.9.26.2;v1.4 条款 mcpp >= 2026.9.27.1;v1.5 条款 mcpp >= 2026.9.28.1;v1.6 条款 mcpp >= 2026.9.29.5 | | [SPEC-006](toolchain-management.md) | 工具链管理:身份、来源、选择与载荷契约 | 草案 v0.6 | 2026-10-02 | 逐条标注;已实现条款 mcpp >= 2026.9.24.1;§3.7 mcpp >= 2026.9.28.1;§3.7.1 mcpp >= 2026.9.28.2;§2.2.1 与 §3.3 的非缺省来源 mcpp >= 2026.10.1.3 | | [SPEC-007](build-plugins.md) | 构建插件:配置、施工与校验的分工,运行时与规划期的义务 | 草案 v0.6 | 2026-09-28 | 逐条标注;mcpp >= 2026.9.26.2;v0.3 条款 mcpp >= 2026.9.27.1;v0.4 条款 mcpp >= 2026.9.28.1;v0.5 条款 mcpp >= 2026.9.28.2;v0.6(§9)mcpp >= 2026.9.28.3 | | [SPEC-008](library-interface.md) | 库的接口:公开模块、发布闭包与两种形态的一致 | 草案 v0.1 | 2026-09-28 | 第一阶段(只警告)mcpp >= 2026.9.28.3 | -| [SPEC-009](toolchain-maintenance.md) | 工具链的支持与维护:版本线、默认值、来源、移动与退役 | 草案 v0.2 | 2026-10-05 | 逐条标注;本版只有规范,多数条款未实现 | +| [SPEC-009](toolchain-maintenance.md) | 工具链的支持与维护:版本线、默认值、来源、移动与退役 | 草案 v0.3 | 2026-10-05 | 逐条标注;本版只有规范,多数条款未实现 | ## 文档约定 diff --git a/docs/specs/manifest-semantics.md b/docs/specs/manifest-semantics.md index 1476eda6..e8fc3953 100644 --- a/docs/specs/manifest-semantics.md +++ b/docs/specs/manifest-semantics.md @@ -5,11 +5,11 @@ | **规范编号** | SPEC-004 | | **标题** | `mcpp.toml` 的平面划分、条件化形状、解析轴与命名规约 | | **状态** | **草案(Draft)** | -| **版本** | 1.11 | +| **版本** | 1.12 | | **最后修改** | 2026-10-05 | | **最低实现版本** | 条件化形状:mcpp **2026.8.29.1**(`[target..build-dependencies]` 起齐备);目标轴:mcpp **2026.9.6.4** | | **作者/维护** | mcpp-community | -| **相关设计文档** | `.agents/docs/2026-09-07-mcpp-toml-unified-semantics-design.md`
`.agents/docs/2026-06-04-manifest-schema-ownership.md`
`.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md`
`.agents/docs/2026-09-25-issue-690-workspace-build-inheritance-consistency.md`
`.agents/docs/2026-09-27-eight-reports-by-home-and-one-optimisation-plan.md` | +| **相关设计文档** | `.agents/docs/2026-09-07-mcpp-toml-unified-semantics-design.md`
`.agents/docs/2026-06-04-manifest-schema-ownership.md`
`.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md`
`.agents/docs/2026-09-25-issue-690-workspace-build-inheritance-consistency.md`
`.agents/docs/2026-09-27-eight-reports-by-home-and-one-optimisation-plan.md`
`.agents/docs/2026-10-05-std-module-pair-msvc-lto-and-export-discovery-design.md` | | **相关使用文档** | [docs/04 —— mcpp.toml 字段参考](../04-mcpp-toml.md) | ## 规范用语 @@ -451,6 +451,18 @@ feature-deps feature-xlings ← 限定词是门 `mcpp pack` 的归档含该程序;有 musl 工具链时,`--target x86_64-linux-musl` 下它为目标构建 (`tests/e2e/801_a_dependency_program_is_shipped_with_the_consumer.sh`)。 +21. §9 第 1 条的根包与第 10 条的 profile:虚拟根的 `[profile.release]` 到达成员;成员自己的 + 同名表替换它;带 `[package]` 的工作空间在根构建与 `--workspace` 下都只有一张图,根包的 + 编译命令恰好含一次 `[workspace.build]` 的词(`tests/e2e/885_workspace_profiles_and_the_root_package.sh`, + `tests/unit/test_workspace_plan.cpp`)。 +22. §9 第 11 条的判据**必须**在产物上读取:成员共享库的 RUNPATH 含它自己构建程序的标记而不含 + 另一成员的,`-p` 下相同;非工作空间中依赖的共享库含自己与 profile 的标记而不含根包与兄弟 + 依赖的;只由根声明的搜索路径不再到达依赖的共享库,构建给出 `link/root-flags` 提示,依赖 + 自己声明后链接通过(`tests/e2e/884_a_shared_library_links_with_its_own_closure.sh`)。 +23. §11 的判据(`tests/e2e/887_msvc_lto_and_export_discovery.sh`,需要 cl.exe):cl.exe 上 + `lto = true` 以 `/GL` 编译、以 `/LTCG` 链接;省略 `windows_auto_export` 的 DLL 所链接的包以 + `/GL-` 编译并报告一次;陈述 `true` 与写入 `/GL` 被拒绝;陈述 `false` 时完整使用 `/GL`。 + ## 8. flag 列表的元素 `cflags`、`cxxflags`、`asmflags` 与 `ldflags` 的一个元素是一段文本,代表零个或多个词; @@ -505,7 +517,9 @@ mcpp 2026.9.26.2,#703)。** 工作空间、工作空间根在哪里)取决于清单**在哪里**,与命令走的是哪条分支无关——带 `[package]` 的工作空间根按自身构建时,同样要在解析任何依赖之前建立这一上下文。 `-p`/`--package` 首先按成员的包身份(限定名 `.`,其次是裸包名) - 为其命名,目录路径与目录名是回落拼法。 + 为其命名,目录路径与目录名是回落拼法。带 `[package]` 的工作空间根自己的包是成员 + `"."`,同样恰好一次地接收 `[workspace.package]` 与 `[workspace.build]`,无论命令选中 + 的是它、它与其它成员,还是只有其它成员。 2. 向量按工作空间、成员、命中的 `[target..build]` 的顺序追加,命中的条件表 之间按 §3.1.1 的具体程度排序;`defines` 按 §8 的集合语义合并。标量仅在成员未 **声明**该键时取工作空间的值。 @@ -529,16 +543,24 @@ mcpp 2026.9.26.2,#703)。** (根包、`-p` 选中的成员、`path` 与 `git` 依赖、索引依赖)拒绝它,并点名条目所在的表与 名称。带 `[package]` 的工作空间根按它自己的 `[workspace.dependencies]` 解析自己的 `workspace = true` 条目。 -10. `[toolchain]`、`[target.]` 与 `[indices]` 是根位置的键:它们为整个依赖图选择 - 编译器、目标行与索引,因此只在成员作为一次构建的根时继承。作为宿主工具构建的成员是其 +10. `[toolchain]`、`[target.]`、`[indices]` 与 `[profile.]` 是根位置的键:它们 + 为整个依赖图选择编译器、目标行、索引与构建 profile,因此只在成员作为一次构建的根时继承。 + profile 按名字继承,成员自己声明的同名表整体替换工作空间的。作为宿主工具构建的成员是其 子构建的根,同样继承这三项(§10.1)。`[build] dialect_cxxflags`(及其条件形式 `[target..build] dialect_cxxflags`)同样是根位置的键:它是 §3.1 所述的图级联方言开关,只在包作为一次构建的根时被渲染并到达命令。与前三项相同, 一个包声明它不被诊断——一个依赖包为自己将来作为根的构建合法地声明这些键,这一条 只是把已有行为写成明文规则。 +11. 一个包的 `ldflags`(含其构建程序的链接指令)是它的使用需求,按第 6 条只流向消费者。 + 根包自己的程序与共享库以根的链接行链接:根包的 `ldflags` 与它所到达的每个包的 + `ldflags`。其它包拥有的共享库**必须**以图级链接 flag 加上其拥有者所到达的包的 + `ldflags` 链接,**禁止**接收根包或无关包的私有 `ldflags`。图级链接 flag 是 profile 的 + `ldflags` 与 `[target..abi]` 为链接渲染的词;它们到达每一个镜像。 + **状态:已实现(第 1 至 7 条 mcpp 2026.9.25.1;第 8 至 10 条 mcpp 2026.9.27.1,mcpp#713、 -#714、#710;第 10 条的 `dialect_cxxflags` 为 mcpp 2026.9.28.1,#717)。** +#714、#710;第 10 条的 `dialect_cxxflags` 为 mcpp 2026.9.28.1,#717;第 1 条的根包、第 10 条的 +`[profile.]` 与第 11 条为 mcpp 2026.10.5.2,#771)。** ## 10. 依赖的程序 @@ -580,6 +602,21 @@ mcpp 2026.9.26.2,#703)。** **状态:已实现**(mcpp 2026.9.27.1,mcpp#711)。 +## 11. 链接期优化与导出发现 + +1. `lto = true` 在每一个能兑现它的编译器上兑现:gcc 与 clang 以 `-flto` 编译与链接,cl.exe + 以 `/GL` 编译、以 `/LTCG` 链接与归档。一个行无法兑现时**禁止**声称兑现。 +2. `[targets.] windows_auto_export` 区分「省略」与「陈述 `true`」。在 cl.exe 上 LTO 生效 + 时,导出需要被发现的 PE 共享库: + - 省略该键:其对象所来自的包以 `/GL-` 编译,DLL 仍以 `/LTCG` 链接,构建报告一次降级; + - 陈述 `true`:规划期拒绝(`lto-export-discovery`); + - 陈述 `false`:不受影响。 +3. 写进这类 DLL 所链接的包的 flag 中的 `/GL`,无论 LTO 是否生效,都在规划期被拒绝。 +4. `mcpp pack` 产出的静态库**禁止**携带 LTO 中间表示;其对象不以 LTO 编译,构建报告一次。 +5. `[test] windows_code_page` 为所发现的每个测试程序陈述 Windows 代码页,取值与 target 键相同。 + +**状态:已实现(mcpp 2026.10.5.2,#770)。** + ## 变更记录 | 版本 | 日期 | 变更 | @@ -596,3 +633,4 @@ mcpp 2026.9.26.2,#703)。** | 1.9 | 2026-09-28 | mcpp 2026.9.28.1:§9 第 1 条补上带 `[package]` 的工作空间根自己的 `path` 依赖所到达的成员,`-p` 先按包的身份解析(#725);§3.1 接受 `[target..build] dialect_cxxflags`,§9 第 10 条把它列为根位置的键(#717);§3.1.1 的状态改为部分实现,多个命中的条件表的先后见 mcpp#728。 | | 1.10 | 2026-09-28 | 多个命中的条件表按选择器的具体程度生效,三元组高于操作系统高于族,字典序只打破平局(mcpp 2026.9.28.2,mcpp#728,2026-09-28 设计 D7):§3.1.1 陈述规则与具体程度,§3.1 与 §9 第 2 条的「按清单顺序」随之更正;§3.1.1 转为已实现。 | | 1.11 | 2026-10-05 | 新增 §5.3:只在一个平台生效的键带平台前缀(mcpp 2026.10.5.1,#766;`auto_export` 在发布前更名为 `windows_auto_export`)。§1 与 §6 引用的字段准入条件改指 docs/90,字段参考改指 docs/04。 | +| 1.12 | 2026-10-05 | mcpp 2026.10.5.2:§9 第 1 条补上带 `[package]` 的工作空间根自己的包;第 10 条把 `[profile.]` 列为根位置的键;新增第 11 条链接 flag 的作用域(#771);新增 §11 链接期优化与导出发现(#770)与 `[test] windows_code_page`;§7 补第 21 至 23 条判据。 | diff --git a/docs/specs/toolchain-maintenance.md b/docs/specs/toolchain-maintenance.md index e463bd91..3e17d5c3 100644 --- a/docs/specs/toolchain-maintenance.md +++ b/docs/specs/toolchain-maintenance.md @@ -4,7 +4,7 @@ |---|---| | 规范编号 | SPEC-009 | | 标题 | 工具链的支持与维护:版本线、默认值、来源、移动与退役 | -| 状态 | 草案 v0.2 | +| 状态 | 草案 v0.3 | | 最后修改 | 2026-10-05 | | 对应实现 | 逐条标注;本版只有规范,多数条款未实现 | | 相关设计文档 | `.agents/docs/2026-10-02-pr-ci-acceleration-and-the-toolchain-specification-design.md`(第 IV 部分) | @@ -163,7 +163,7 @@ 在一个线是 Default 的每一行上,引擎**必须**通过验收程序(一个使用 ``、`` 与 `` 的程序;`import std`;`import std.compat`)与 e2e 套件; 该宿主构建 mcpp 时,还**必须**通过 mcpp 自身的构建。 -当前:e2e 套件在各 CI 宿主上运行;验收程序的矩阵部分实现(SPEC-006 §6.2)。macOS 的 Default `llvm@20.1.7` 无法构建 mcpp 自身 +当前:e2e 套件在各 CI 宿主上运行;验收程序的矩阵部分实现(SPEC-006 §6.2)。`import std.compat` 在 GCC 行由 2026.10.5.2 补上(libstdc++ 的 `bits/std.compat.cc`,此前在第一个 import 它的单元中失败),在 MSVC ABI 的 llvm 行由 2026.10.5.1 补上;两行与开发者环境中的 llvm 行由 e2e 886 与 888 覆盖。macOS 的 Default `llvm@20.1.7` 无法构建 mcpp 自身 (其 libc++ 的 `std` 模块不暴露 `directory_iterator` 的比较),mcpp 的清单因此以 22.1.8 构建;该行的第三项验收不成立。 ### 6.3 随发布编码的输出 部分实现 @@ -386,3 +386,4 @@ mcpp 自己的清单**必须**使用其构建所在的每一行的 Default 发 |---|---|---| | v0.1 | 2026-10-02 | 初版 | | v0.2 | 2026-10-05 | §10.5 增加 G7:MSVC ABI 行上 e2e 881 以候选发布通过(mcpp 2026.10.5.1,#766)。 | +| v0.3 | 2026-10-05 | §6.2 的「当前」:GCC 行与 MSVC ABI 的 llvm 行的 `import std.compat`(mcpp 2026.10.5.2)。 | diff --git a/docs/zh/04-mcpp-toml.md b/docs/zh/04-mcpp-toml.md index 5f45a5e8..ae1451ef 100644 --- a/docs/zh/04-mcpp-toml.md +++ b/docs/zh/04-mcpp-toml.md @@ -333,8 +333,21 @@ windows_auto_export = false 规划 MSVC ABI 的 target 时,`windows_auto_export = false` 与 `exports` 同时出现会被 拒绝,因为 `exports` 收窄的是被发现的符号;同一份 manifest 在 ELF 与 Mach-O 上照常构建。 -发现直接读取 COFF 对象,并用所选 LLVM 编译器及其旁边的 `llvm-nm` 读取 LLVM bitcode -(FullLTO 与 ThinLTO,单独或与 COFF 对象混用)。 +发现直接读取 COFF 对象(包括 `/bigobj` 对象,2026.10.5.2+),并用所选 LLVM 编译器 +及其旁边的 `llvm-nm` 读取 LLVM bitcode(FullLTO 与 ThinLTO,单独或与 COFF 对象混用)。 +cl.exe 以 `/GL` 编译的对象没有符号表,会被点名报告。 + +**cl.exe 上的 LTO(2026.10.5.2+)。** `lto = true` 以 `/GL` 编译,发现因此无从读取。 +这个键的三种写法在此表现不同,因为只有省略的键由 mcpp 决定: + +| 写法 | cl.exe 上 `lto = true` 时的结果 | +|---|---| +| 省略 | 对象被该 DLL 链接的包(它自己的,以及放入它的静态库)以 `/GL-` 编译;DLL 仍以 `/LTCG` 链接;构建报告一次,摘要显示 `+ lto (partial)` | +| `true` | 规划该 target 时被拒绝(`lto-export-discovery`) | +| `false` | 完整的 `/GL` 与 `/LTCG`;源码以 `__declspec(dllexport)` 声明导出 | + +写进这类 DLL 所链接的包的 flag 中的 `/GL` 同样被拒绝,与 `lto` 是否开启无关。 +MSVC ABI 上的 LLVM LTO 产出 bitcode,发现可以读取,不受影响。 #### `windows_subsystem` 与 `windows_entry` —— Windows GUI 可执行文件(mcpp 2026.9.12.2+) @@ -532,6 +545,15 @@ ninja 转义、没有为 shell 加引号,所以在 Linux 与 macOS 上 `$ORIGI `ldflags` 按词传给消费者。为 ninja 或 shell 手工转义的元素(`\$ORIGIN`、 `'$$ORIGIN'`)现在按写法读取;首次 plan 会在 `build/flag-words` 下点名这样的元素。 +**一个包的 `ldflags` 到达哪些链接(2026.10.5.2+)。** 根包自己的程序与共享库以它的 +`ldflags` 以及它所到达的每个包的 `ldflags` 链接。由其它包拥有的共享库,以图级 flag +(profile 的 `ldflags`,以及 `[target..abi]` 为链接渲染的词)和其拥有者 +所到达的包的 `ldflags`(含 `build.mcpp` 的输出)链接。2026.10.5.2 之前,这样的库以 +根的链接行链接,于是根包的私有 flag 与无关工作空间成员的 flag 会到达它,而在工作空间 +中成员自己 `build.mcpp` 声明的库却不会(#771)。依赖需要的搜索路径或库,由依赖在自己 +的 `ldflags` 或 `build.mcpp` 中声明;根声明了这样的词且计划中有依赖的共享库时,构建会 +在 `link/root-flags` 下给出提示。 + `compile_commands.json` 与 `mcpp emit build-database` 在 `arguments` 里列出同样的词,可以不经 shell 直接执行。 @@ -1302,7 +1324,7 @@ GCC 16.1 报告 `sorry, unimplemented: private module fragment`,mcpp [profile.dist] opt = 3 # -O level (a number, or the string "s"/"z") debug = false # -g -lto = true # -flto (note: some packaged gcc builds ship without the LTO plugin) +lto = true # -flto; /GL + /LTCG on cl.exe (note: some packaged gcc builds ship without the LTO plugin) strip = true # -s at link time # passthrough escape hatch (fixed keys, open values): cflags = ["-fno-plt"] @@ -1710,6 +1732,7 @@ discover = ["tests/**/*.cpp"] # the default | 键 | 类型 | 含义 | |---|---|---| | `discover` | glob 数组 | 每个被某条 glob 匹配到的文件都是一个测试程序;以 `!` 开头的 glob 会移除它匹配到的文件;`[]` 不发现任何测试 | +| `windows_code_page` | `"utf-8"` 或 `"legacy"` | Windows 上每个测试程序的 ANSI 代码页,含义与同名的 target 键相同;默认 `"legacy"`,即系统代码页 *(2026.10.5.2+)* | 这些 glob 使用与 `[build] sources` 相同的词汇。一个测试的名字,是它 相对第一条匹配到它的 glob 所在的固定目录的路径,去掉扩展名。两个 @@ -1717,6 +1740,10 @@ discover = ["tests/**/*.cpp"] # the default 一个错误;`[test]` 里的其它任何键都是警告,`--strict` 下是错误。 测试模型见[08 —— 测试](08-testing.md)。 +测试程序检验它所链接的代码,而这些代码可能运行在声明的代码页中。`windows_code_page += "utf-8"` 使测试在同一个代码页中运行,于是测试结果不取决于运行它的机器的区域设置。 +测试程序只接收应用程序清单,不接收 `[resources]` 的其它内容。 + ## 3. 实战示例 其中四个是可运行的工程,而不是片段,工程是更好的答案:它能构建,并且 diff --git a/docs/zh/07-workspace.md b/docs/zh/07-workspace.md index c15eb004..a9e3e05f 100644 --- a/docs/zh/07-workspace.md +++ b/docs/zh/07-workspace.md @@ -271,6 +271,26 @@ workspace 键。 块本来就按 triple 逐项被全体成员继承(成员优先)。为同一能力再造一种拼法,只会 增加接口面而不增加功能。 +**profile 按名字继承(2026.10.5.2+)。** 工作空间根里的 `[profile.]` 到达每个 +没有声明同名 profile 的成员;成员自己的同名表整体替换工作空间的,与 +`[target.]` 相同。profile 是每张构建图一个的值,因此共享它的成员一起规划、 +一起编译。2026.10.5.2 之前,虚拟根的 profile 不到达任何成员,并且被无声忽略;带根包 +的工作空间的 profile 只在根包是第一个被选中的包时生效。 + +**根包是一个成员。** 根上带 `[package]` 的工作空间中,根包与其它成员一样恰好一次地 +继承 `[workspace.package]` 与 `[workspace.build]`(2026.10.5.2+),因此它的命令在 +每种选择下相同。根清单包含三类键: + +| 键 | 归属 | 对根包的作用 | 对其它成员的作用 | +|---|---|---|---| +| `[workspace]`、`[workspace.dependencies]` | 工作空间 | 通过 `x.workspace = true` | 通过 `x.workspace = true` | +| `[workspace.package]`、`[workspace.build]` | 工作空间 | 继承 | 继承 | +| `[toolchain]`、`[target.]`、`[indices]`、`[profile.]` | 根位置 | 本身的值 | 成员作为一次构建的根时继承;成员自己的声明优先 | +| `[package]`、`[build]`、`[dependencies]`、`[targets]`、`[features]`、`[resources]`、`[test]` | 根包 | 本身的值 | 无 | + +根包的 `[build] ldflags` 同样属于它自己:它们到达根包自己的镜像,不到达成员的镜像, +也不到达依赖的共享库。 + ### 4.2 整个模块图只有一个标准 C++ 模块图有且只有一个标准:BMI 跨档位不兼容,因此根包的 `standard` 施加于图中 diff --git a/docs/zh/12-binary-distribution.md b/docs/zh/12-binary-distribution.md index 0fdbed41..656b4d87 100644 --- a/docs/zh/12-binary-distribution.md +++ b/docs/zh/12-binary-distribution.md @@ -318,6 +318,13 @@ error while loading shared libraries: libstdc++.so.6: cannot open shared object 使消费方的链接失败并报 `archive has no index; run ranlib to add one`。 +### 不分发 LTO 中间码(2026.10.5.2+) + +`lto = true` 的 profile 下,被打包的静态库的对象不以 LTO 编译:没有 `-flto` 中间码, +在 cl.exe 上也没有 `/GL` 对象。这种代码只能由写出它的编译器版本读取,而预制的归档 +会被其它版本消费。构建报告一次,摘要显示 `+ lto (partial)`。同一次构建中的程序与 +共享库仍然使用 LTO。 + ## 当前边界 | | 状态 | diff --git a/docs/zh/20-toolchains.md b/docs/zh/20-toolchains.md index 72819a37..d456457a 100644 --- a/docs/zh/20-toolchains.md +++ b/docs/zh/20-toolchains.md @@ -947,6 +947,22 @@ libc++abi 用到的线程局部存储;在 Xcode 16.4 上实测过),所以 设备行的 `runner` 保持未设置。没有开发者自己拥有的签名,一个产物无法在一台 iOS 设备上运行,而那不是一个构建工具能供给的东西。 +## 标准库模块:`std` 与 `std.compat` + +工具链的标准库附带模块源码时,它提供 `import std`;附带第二份源码时,它还提供 +`import std.compat`。两者从同一个目录读取,并成对提供(2026.10.5.2+): + +| 标准库 | `std` | `std.compat` | +|---|---|---| +| libstdc++(GCC 15 及之后) | `bits/std.cc` | `bits/std.compat.cc`(2026.10.5.2+) | +| libc++ | `std.cppm` | `std.compat.cppm` | +| MSVC STL(cl.exe,以及 MSVC ABI 上的 llvm 行) | `modules/std.ixx` | `modules/std.compat.ixx`(llvm 行 2026.10.5.1+) | +| 包(`[package] std-module`) | 它的 `std-module` | 它的 `std-compat-module`,或没有 | + +每个模块按配置在全局缓存中编译一次;`std.compat` 只在构建中有单元 import 它时编译 +(2026.10.5.2+)。从不提供 `std.compat` 的标准库 import 它的构建,会在任何编译之前 +被拒绝,并点名该标准库。 + ## 项目级版本锁定 若工程需要锁定某个特定版本,而不依赖全局默认值,可在工程自己的 `mcpp.toml` diff --git a/docs/zh/50-machine-output.md b/docs/zh/50-machine-output.md index 9a8a4c2f..8cd9ef91 100644 --- a/docs/zh/50-machine-output.md +++ b/docs/zh/50-machine-output.md @@ -440,6 +440,7 @@ replaced}` —— `origin` 与构建的状态行使用的是同一句话 | `managed-only` | `--managed-only` 遇到了不属于生态的来源:消息逐条点名它与陈述它的位置 *(2026.10.1.3+)* | | `payload-override` | 一条 `[xlings.overrides]` 点名的路径不存在、版本被某条要求拒绝,或它由依赖写出 *(2026.10.1.3+)* | | `payload-request` | 构建程序请求了本次构建中没有任何清单声明 `provision = "on-request"` 的载荷,或连续三次运行都在请求 *(2026.10.1.3+)* | +| `lto-export-discovery` | 导出需要被发现的 PE 共享库会被 cl.exe 编译成没有符号表的 `/GL` 对象:在 `lto = true` 下陈述了 `windows_auto_export = true`,或在它所链接的包的 flag 中写了 `/GL` *(2026.10.5.2+)* | | `local-toolchain` | 由路径命名的工具链,或构建程序工具链阶段陈述的工具链无法使用:没有驱动、族与驱动矛盾、缺少某个工具或 sysroot *(2026.10.1.3+)* | | `convention-unreplaced` | 约定被推翻了,而没有任何东西接替它 | | `os-mismatch` | 请求的三元组与解析出的三元组命名不同的系统 | diff --git a/mcpp.toml b/mcpp.toml index fa822283..fafd1042 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.10.5.1" +version = "2026.10.5.2" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] @@ -40,6 +40,12 @@ bmi_schedule = "on" [resources] files = ["docs/res/mcpp.rc"] +# The unit tests run in the code page mcpp.exe runs in, so a result does not +# depend on the region setting of the machine that runs them (2026.10.5.2). An +# engine older than the key reports it as unsupported and ignores it. +[test] +windows_code_page = "utf-8" + [toolchain] default = "gcc@16.1.0" macos = "llvm@22.1.8" diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index e94e9c2d..626ebb2e 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -1854,6 +1854,7 @@ std::expected parse_string(std::string_view content, return std::unexpected(error(origin, std::format( "targets.{}.windows_auto_export must be a boolean", tname))); t.windowsAutoExport = it->second.as_bool(); + t.windowsAutoExportDeclared = true; } // `exports` -- a file of symbol patterns, or the patterns inline. // @@ -3244,9 +3245,10 @@ std::expected parse_string(std::string_view content, } } - // [test] — which files are test programs (#634 A5). + // [test] — which files are test programs (#634 A5), and the code page + // they run in on Windows. // - // One key. A suite compiled from several sources is a package of its own, + // Two keys. A suite compiled from several sources is a package of its own, // as any program that must diverge from its siblings is (see `Target`); // what a project could not say before was only WHERE its test programs // are, which mattered once `tests/` belonged to another build system. @@ -3259,10 +3261,24 @@ std::expected parse_string(std::string_view content, } if (auto* tt = doc->get_table("test")) { for (auto& [key, value] : *tt) { + if (key == "windows_code_page") { + // A test exercises the code it links, and that code may run in + // a declared code page (mcpp.exe does): the test program has to + // be able to say the same, or it measures another environment. + if (!value.is_string()) + return std::unexpected(error(origin, + "[test] windows_code_page must be a string")); + const std::string v = value.as_string(); + if (auto list = windows_code_page_problem(v); !list.empty()) + return std::unexpected(error(origin, std::format( + "[test] windows_code_page = \"{}\" is not one of {}", v, list))); + m.testWindowsCodePage = v; + continue; + } if (key != "discover") { m.schemaWarnings.push_back(std::format( "[test] has unsupported key '{}' (ignored). Supported keys: " - "discover.", key)); + "discover, windows_code_page.", key)); continue; } bool ok = value.is_array(); diff --git a/modules/manifest/src/types.cppm b/modules/manifest/src/types.cppm index 3717032f..a8e4f84f 100644 --- a/modules/manifest/src/types.cppm +++ b/modules/manifest/src/types.cppm @@ -180,7 +180,12 @@ struct Target { std::string soname; // ABI name for shared libraries, e.g. libfoo.so.1 // PE / MSVC ABI only: discover exports when the objects do not declare // any. False leaves export control entirely to the native linker inputs. + // Whether the manifest states it is kept beside the value, because an + // omitted key yields to cl.exe's `/GL` and a stated one does not + // (2026.10.5.2). Two members, for the reason `Profile` records. bool windowsAutoExport = true; + bool windowsAutoExportDeclared = false; + bool windows_auto_export() const { return windowsAutoExport; } // WHICH SYMBOLS THIS ARTIFACT PUBLISHES. Empty = every symbol, which is // what both platforms do today (ELF default visibility; PE gets an // auto-generated .def listing everything, mcpp.build.coff_exports). @@ -762,6 +767,15 @@ inline std::string effective_c_standard(std::string_view declared) { } struct BuildConfig : BuildInputs { + // THE LINK FLAGS OF THE GRAPH, NOT OF THE ROOT PACKAGE (2026.10.5.2). + // + // The profile's `ldflags` and the words `[target..abi]` renders + // for the link belong to every image the plan links. They are also in + // `ldflags`, which the root's own images read; this copy is what an image + // that is not the root's reads in their place, beside its own closure's + // flags, so that the root package's private `[build] ldflags` stay with + // the root (SPEC-004 §9.6). + std::vector graphLdflags; // How `mcpp run` / `mcpp test` execute an artifact this host cannot run, // as an argv template (the artifact path is appended, or substituted for // `{}`). @@ -1065,7 +1079,12 @@ struct BuildConfig : BuildInputs { // Resolved build-profile knobs (from [profile.] + built-in defaults). std::string optLevel = "2"; // -O level bool debug = false; // -g - bool lto = false; // -flto + bool lto = false; // -flto, /GL + /LTCG on cl.exe + // LTO was requested and some objects of the plan are compiled without it: + // those that feed a PE shared library whose exports are discovered, or a + // static library `mcpp pack` ships (2026.10.5.2). Set by prepare, read by + // the build summary, which says `+ lto (partial)` rather than `+ lto`. + bool ltoPartial = false; bool strip = false; // link -s // `[build].default-profile` (alias: `profile`) — the project's DEFAULT // profile when no --profile/--dev/--release is passed. The global convention @@ -2011,6 +2030,15 @@ struct Manifest { // optional vector, for the reason `TargetEntry::sysrootDeclared` records. std::vector testDiscover; bool testDiscoverDeclared = false; + // `[test] windows_code_page`: the ANSI code page of every discovered test + // program on Windows, in the vocabulary of the target key of the same + // name. Empty is `legacy`, the system's code page. + std::string testWindowsCodePage; + // Set once a workspace root's own package has received `[workspace.package]` + // and `[workspace.build]` (`mcpp::project::inherit_as_root_package`). The + // manifest is read on two paths before it is planned, and an appended + // vector must be appended once. + bool inheritedAsRootPackage = false; std::vector conditionalConfigs; // [target.'cfg(...)'.build], deferred std::map profiles; // [profile.] // [features] — feature name → implied features ("default" = default set). diff --git a/modules/manifest/src/xpkg.cppm b/modules/manifest/src/xpkg.cppm index c123d85d..782accd9 100644 --- a/modules/manifest/src/xpkg.cppm +++ b/modules/manifest/src/xpkg.cppm @@ -1633,6 +1633,7 @@ synthesize_from_xpkg_lua(std::string_view luaContent, std::format("targets.{}.windows_auto_export must be a boolean", tname), m.sourcePath, 0, 0}); t.windowsAutoExport = raw == "true"; + t.windowsAutoExportDeclared = true; } else if (sub == "required_features") { // #355: without this, a Form B descriptor could not // express the cost gate that makes an optional host diff --git a/modules/platform/src/shell.cppm b/modules/platform/src/shell.cppm index 5da75324..d620120b 100644 --- a/modules/platform/src/shell.cppm +++ b/modules/platform/src/shell.cppm @@ -83,14 +83,35 @@ constexpr std::string_view silent_redirect = ">/dev/null 2>&1"; namespace mcpp::platform::shell { +// THE ARGV RULES OF CommandLineToArgvW AND THE MSVC RUNTIME, NOT ONLY `\"`. +// +// Backslashes are literal unless a run of them precedes a `"`: then 2n +// backslashes stand for n and the quote ends (or begins) a quoted region, +// and 2n+1 stand for n and a literal quote. Escaping only the quote is wrong +// whenever backslashes precede it. The case that was met is an +// argument ENDING in a backslash: `"C:\Kits\10\"` closes nothing, because +// its last two characters are an escaped quote, and every following +// argument shifts. Measured 2026-10-05 in a Visual Studio developer +// environment, whose `WindowsSdkDir` ends in `\`: the LLVM row's std module +// precompile received `Files\Microsoft`, `Visual` and the rest of the +// command as separate inputs. Doubling such a run changes only arguments +// that were parsed wrongly before. std::string quote_windows(std::string_view s) { std::string out; out.reserve(s.size() + 2); out.push_back('"'); + std::size_t backslashes = 0; for (char c : s) { - if (c == '"') out += "\\\""; - else out.push_back(c); + if (c == '\\') { ++backslashes; continue; } + if (c == '"') { + out.append(backslashes * 2 + 1, '\\'); + } else { + out.append(backslashes, '\\'); + } + out.push_back(c); + backslashes = 0; } + out.append(backslashes * 2, '\\'); out.push_back('"'); return out; } diff --git a/modules/toolchain-model/src/model.cppm b/modules/toolchain-model/src/model.cppm index f7cad233..032d8787 100644 --- a/modules/toolchain-model/src/model.cppm +++ b/modules/toolchain-model/src/model.cppm @@ -251,6 +251,22 @@ struct Toolchain { // member added to this exported class: "failed to load pendings".) std::vector envOverrides; bool hasImportStd = false; + + // THE STANDARD LIBRARY'S MODULES ARE SET AND CLEARED AS ONE (2026.10.5.2). + // + // `std.compat` is a second module over the library that provides `std`, + // and a compat source from another library, or without its `std`, cannot + // be built. Six places once wrote the two fields one at a time, and #768 + // was the place that wrote one of them. Every writer goes through these. + void set_std_modules(std::filesystem::path stdSource, + std::filesystem::path compatSource = {}) { + stdModuleSource = std::move(stdSource); + stdCompatSource = stdModuleSource.empty() ? std::filesystem::path{} + : std::move(compatSource); + hasImportStd = !stdModuleSource.empty(); + } + void clear_std_modules() { set_std_modules({}, {}); } + // Lowest -std= level this toolchain can build the std module at. 0 = the // provider did not say, callers fall back to the plain hasImportStd // question. Filled next to hasImportStd by each provider, because "which diff --git a/modules/versioning/src/version.cppm b/modules/versioning/src/version.cppm index 6ffea8e8..bf2f2328 100644 --- a/modules/versioning/src/version.cppm +++ b/modules/versioning/src/version.cppm @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.10.5.1"; +inline constexpr std::string_view MCPP_VERSION = "2026.10.5.2"; } // namespace mcpp diff --git a/src/build/build_program.cppm b/src/build/build_program.cppm index a60ddcb6..b8286f44 100644 --- a/src/build/build_program.cppm +++ b/src/build/build_program.cppm @@ -1659,7 +1659,8 @@ std::expected run_build_program_impl( static std::mutex stdModuleMutex; std::lock_guard stdLock(stdModuleMutex); auto built = mcpp::toolchain::ensure_built( - tc, cppStandard.canonical, std_flag, macosDeploymentTarget); + tc, cppStandard.canonical, std_flag, macosDeploymentTarget, + mcpp::toolchain::default_cache_root(), {}, usesStdCompat); if (built) smValue = std::move(*built); else smError = built.error().message; } diff --git a/src/build/coff_exports.cppm b/src/build/coff_exports.cppm index 02b5cc20..dded41c5 100644 --- a/src/build/coff_exports.cppm +++ b/src/build/coff_exports.cppm @@ -110,6 +110,24 @@ constexpr std::size_t kFileHeaderSize = 20; constexpr std::size_t kSymbolRecordSize = 18; constexpr std::size_t kSectionHeaderSize = 40; +// AN ANONYMOUS OBJECT: machine 0 and 0xFFFF where an ordinary header has its +// section count, then a version and, from version 1 on, a class GUID at +// offset 12 that says what the rest is. Three kinds reach a link (2026.10.5.2; +// headers measured from cl 19.51 output, GUIDs as LLVM's `identify_magic` +// spells them): +// version 0 a short import object, a member of an import library; +// kBigObjClassId `/bigobj`, an ordinary object with 32-bit counts; +// kClGlClassId `/GL`, cl's intermediate code, with no symbol table. +// `/GL` together with `/bigobj` yields the `/GL` header. +constexpr std::size_t kBigObjHeaderSize = 56; +constexpr std::size_t kBigObjSymbolSize = 20; +constexpr std::array kBigObjClassId{ + 0xc7, 0xa1, 0xba, 0xd1, 0xee, 0xba, 0xa9, 0x4b, + 0xaf, 0x20, 0xfa, 0xf6, 0x6a, 0xa4, 0xdc, 0xb8}; +constexpr std::array kClGlClassId{ + 0x38, 0xfe, 0xb3, 0x0c, 0xa5, 0xd9, 0xab, 0x4d, + 0xac, 0x9b, 0xd6, 0xb6, 0x22, 0x26, 0x53, 0xc2}; + // The subset of IMAGE_FILE_MACHINE_* that mcpp targets or can be handed. constexpr std::uint16_t kMachineI386 = 0x014c; constexpr std::uint16_t kMachineAmd64 = 0x8664; @@ -132,6 +150,66 @@ std::uint32_t rd32(std::span b, std::size_t off) { | (std::to_integer(b[off + 3]) << 24)); } +// Where an object keeps its sections and symbols. An ordinary header and a +// `/bigobj` one differ in the width of the counts and of a symbol record and in +// where the section headers start; the records themselves are the same apart +// from the section number, which `/bigobj` widens to 32 bits. +struct Layout { + std::uint16_t machine = 0; + std::uint32_t numSections = 0; + std::size_t sectionsOff = 0; + std::uint32_t symTableOff = 0; + std::uint32_t numSymbols = 0; + std::size_t symbolSize = kSymbolRecordSize; + bool big = false; +}; + +bool class_id_is(std::span b, const std::array& id) { + if (b.size() < 28) return false; + for (std::size_t i = 0; i < id.size(); ++i) + if (std::to_integer(b[12 + i]) != id[i]) return false; + return true; +} + +std::expected layout_of(std::span b) { + if (b.size() < kFileHeaderSize) + return std::unexpected("not a COFF object: shorter than a file header"); + Layout l; + if (rd16(b, 0) == 0 && rd16(b, 2) == 0xFFFF) { + const auto version = b.size() >= 6 ? rd16(b, 4) : 0; + if (version == 0) + return std::unexpected( + "this is a short import object (a member of an import library), " + "not a compiled object; it declares no symbols to export"); + if (class_id_is(b, kClGlClassId)) + return std::unexpected( + "this object was compiled by cl.exe with /GL: it holds the " + "compiler's intermediate code and no symbol table, so its exports " + "cannot be discovered.\n" + " Set `windows_auto_export = false` on the shared library and mark " + "its public surface with __declspec(dllexport), or compile it " + "without /GL."); + if (!class_id_is(b, kBigObjClassId) || b.size() < kBigObjHeaderSize) + return std::unexpected(std::format( + "this is an anonymous COFF object of a kind mcpp does not read " + "(version {}); reading it would be a guess at its layout", version)); + l.big = true; + l.machine = rd16(b, 6); + l.numSections = rd32(b, 44); + l.symTableOff = rd32(b, 48); + l.numSymbols = rd32(b, 52); + l.sectionsOff = kBigObjHeaderSize; + l.symbolSize = kBigObjSymbolSize; + return l; + } + l.machine = rd16(b, 0); + l.numSections = rd16(b, 2); + l.symTableOff = rd32(b, 8); + l.numSymbols = rd32(b, 12); + l.sectionsOff = kFileHeaderSize + rd16(b, 16); // + optional header + return l; +} + // Symbols bindexplib skips, and why each one would be wrong to export. bool is_skipped_name(std::string_view n) { // Scalar-deleting and vector-deleting destructor thunks. They are emitted @@ -165,12 +243,13 @@ std::optional export_name(std::string_view name, bool i386) { } bool declares_exports(std::span bytes) { - if (bytes.size() < kFileHeaderSize) return false; - const auto numSections = rd16(bytes, 2); - const std::size_t sectionsOff = kFileHeaderSize + rd16(bytes, 16); + // An object whose layout cannot be read declares nothing here; reading its + // symbols reports why (`read_exports`). + const auto layout = layout_of(bytes); + if (!layout) return false; - for (std::uint16_t i = 0; i < numSections; ++i) { - const auto hdr = sectionsOff + std::size_t(i) * kSectionHeaderSize; + for (std::uint32_t i = 0; i < layout->numSections; ++i) { + const auto hdr = layout->sectionsOff + std::size_t(i) * kSectionHeaderSize; if (hdr + kSectionHeaderSize > bytes.size()) return false; std::string name; @@ -209,26 +288,11 @@ bool is_supported_machine(std::uint16_t machine) { std::expected, std::string> read_exports(std::span bytes) { - if (bytes.size() < kFileHeaderSize) - return std::unexpected("not a COFF object: shorter than a file header"); - - const auto machine = rd16(bytes, 0); - - // `/bigobj` objects are a DIFFERENT container: machine 0 and a `0xFFFF` - // where the section count would be, followed by a class GUID and a much - // larger header. Named here rather than left to fall out as "unsupported - // machine 0x0000", which is true and useless — the reader would be blamed - // for a flag the project passed. - if (machine == 0 && bytes.size() >= 4 && rd16(bytes, 2) == 0xFFFF) { - return std::unexpected( - "this is a /bigobj object, whose header layout differs from an " - "ordinary COFF one.\n" - " mcpp's export reader does not parse it. Build the shared library " - "without /bigobj,\n" - " or mark its public surface with __declspec(dllexport) — an " - "annotated library needs\n" - " no generated .def at all."); - } + // An anonymous object is read when it is `/bigobj`, and named when it is + // anything else: "unsupported machine 0x0000" would be true and useless. + const auto layout = layout_of(bytes); + if (!layout) return std::unexpected(layout.error()); + const auto machine = layout->machine; if (!is_supported_machine(machine)) { return std::unexpected(std::format( @@ -237,15 +301,19 @@ read_exports(std::span bytes) "has never been run against.", machine)); } - const auto numSections = rd16(bytes, 2); - const auto symTableOff = rd32(bytes, 8); - const auto numSymbols = rd32(bytes, 12); + const auto numSections = layout->numSections; + const auto symTableOff = layout->symTableOff; + const auto numSymbols = layout->numSymbols; + const auto symbolSize = layout->symbolSize; if (symTableOff == 0 || numSymbols == 0) return std::vector{}; // Section characteristics, indexed 1-based the way symbols address them. - const std::size_t sectionsOff = kFileHeaderSize + rd16(bytes, 16); // + optional header - std::vector sectionFlags(numSections + 1, 0); - for (std::uint16_t i = 0; i < numSections; ++i) { + // A count no file of this size can hold is refused before it sizes a vector. + const std::size_t sectionsOff = layout->sectionsOff; + if (sectionsOff + std::size_t(numSections) * kSectionHeaderSize > bytes.size()) + return std::unexpected("COFF section headers run past the end of the file"); + std::vector sectionFlags(std::size_t(numSections) + 1, 0); + for (std::uint32_t i = 0; i < numSections; ++i) { const auto off = sectionsOff + std::size_t(i) * kSectionHeaderSize; if (off + kSectionHeaderSize > bytes.size()) return std::unexpected("COFF section headers run past the end of the file"); @@ -253,7 +321,7 @@ read_exports(std::span bytes) } const std::size_t symbolsEnd = std::size_t(symTableOff) - + std::size_t(numSymbols) * kSymbolRecordSize; + + std::size_t(numSymbols) * symbolSize; if (symbolsEnd > bytes.size()) return std::unexpected("COFF symbol table runs past the end of the file"); @@ -289,11 +357,16 @@ read_exports(std::span bytes) std::vector out; for (std::uint32_t i = 0; i < numSymbols; ) { const std::size_t rec = std::size_t(symTableOff) - + std::size_t(i) * kSymbolRecordSize; - const auto sectionNum = static_cast(rd16(bytes, rec + 12)); - const auto type = rd16(bytes, rec + 14); - const auto storage = std::to_integer(bytes[rec + 16]); - const auto numAux = std::to_integer(bytes[rec + 17]); + + std::size_t(i) * symbolSize; + // `/bigobj` widens the section number to 32 bits, which moves the + // three fields after it by two bytes. + const std::size_t wide = layout->big ? 2 : 0; + const auto sectionNum = layout->big + ? static_cast(rd32(bytes, rec + 12)) + : static_cast(static_cast(rd16(bytes, rec + 12))); + const auto type = rd16(bytes, rec + 14 + wide); + const auto storage = std::to_integer(bytes[rec + 16 + wide]); + const auto numAux = std::to_integer(bytes[rec + 17 + wide]); // Advance past auxiliary records regardless of whether this symbol is // taken — an aux record is not a symbol and reading it as one produces diff --git a/src/build/flags.cppm b/src/build/flags.cppm index defa273d..2e9a8f8a 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -621,7 +621,7 @@ bool realises_optimization(const mcpp::manifest::BuildConfig& bc) { std::string profile_descriptor(const mcpp::manifest::BuildConfig& bc) { std::string d = realises_optimization(bc) ? "optimized" : "unoptimized"; if (bc.debug) d += " + debuginfo"; - if (bc.lto) d += " + lto"; + if (bc.lto) d += bc.ltoPartial ? " + lto (partial)" : " + lto"; return d; } @@ -1138,7 +1138,10 @@ CompileFlags compute_flags(const BuildPlan& plan) { ? " /Od" // MSVC's no-opt spelling (there is no /O0) : std::format(" {}{}", d.optPrefix, optLevel); if (prof.debug) opt_flag += std::format(" {}", d.debugFlags); - if (prof.lto && !isMsvcDialect) opt_flag += " -flto"; + // LTO ON EVERY COMPILER (2026.10.5.2). cl.exe spells it `/GL` here and + // `/LTCG` on the link and the archive; before, it was not spelled at all, + // and a build with `lto = true` reported `+ lto` while doing none. + if (prof.lto) opt_flag += isMsvcDialect ? " /GL" : " -flto"; // MSVC baseline: /nologo /EHsc /utf-8 (dialect alwaysFlags) + the CRT // model — /MD by default, /MT when either knob asks for the static CRT @@ -1879,7 +1882,9 @@ CompileFlags compute_flags(const BuildPlan& plan) { // ldflags pass through verbatim; GNU link_extra (-flto/-s) does // not apply. f.ldBinary = mcpp::toolchain::link_tool(plan.toolchain); - f.ld = link_intent_ld + user_ldflags; + // `/LTCG` whenever LTO is requested: link.exe restarts with it on + // the first `/GL` object anyway, and says so on every link. + f.ld = link_intent_ld + user_ldflags + (prof.lto ? " /LTCG" : ""); f.ldC = f.ld; // link.exe: no driver, nothing implicit to elide return f; } diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index d2cd860c..133d3808 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -2103,7 +2103,11 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements, link_rule("cxx_link", "$ld /nologo /OUT:$out $in $ldflags $unit_ldflags", "LINK"); - link_rule("cxx_archive", std::string(dial.archiveCmd), "AR"); + // lib.exe takes `/LTCG` for `/GL` objects, as CMake's IPO archive + // rule passes it (2026.10.5.2). + link_rule("cxx_archive", plan.manifest.buildConfig.lto + ? std::string("$ar /nologo /LTCG /OUT:$out $in") + : std::string(dial.archiveCmd), "AR"); link_rule("cxx_shared", "$ld /nologo /DLL /OUT:$out $implib_flag $def_flag " "$in $ldflags $unit_ldflags", diff --git a/src/build/plan.cppm b/src/build/plan.cppm index 196a0f85..c03dc071 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -163,6 +163,9 @@ struct LinkUnit { // fails with unresolved externals for symbols that are visibly in the // objects. MinGW's linker auto-exports and needs none of this. std::filesystem::path defFile; // relative to plan.outputDir + // The target states `windows_auto_export` (either value); see + // `Target::windowsAutoExport`. + bool autoExportStated = false; std::string soname; // ABI name for shared libraries std::vector runtimeAliases; // relative aliases, e.g. bin/libfoo.so.1 std::optional entryMain; // src path of main.cpp for bin @@ -2537,34 +2540,6 @@ make_plan(const mcpp::manifest::Manifest& manifest, } }; - for (auto const& dep : sharedDepTargets) { - LinkUnit lu; - lu.targetName = dep.target.name; - lu.package = dep.packageName; - lu.kind = LinkUnit::SharedLibrary; - lu.dependencyOwned = true; - lu.output = dep.output; - lu.importLibrary = import_library_for(dep.target, naming); - if (msvcTarget && !dep.target.windowsAutoExport && !dep.target.exportPatterns.empty()) - return std::unexpected(exports_without_discovery(dep.target, dep.packageName)); - if (msvcTarget && dep.target.windowsAutoExport && !lu.importLibrary.empty()) - lu.defFile = std::filesystem::path("bin") / (dep.target.name + ".def"); - lu.soname = dep.target.soname; - lu.exportPatterns = dep.target.exportPatterns; - lu.runtimeAliases = runtime_aliases_for_target(dep.target, naming); - lu.loaderTagFlag = loader_tag_flag(lu.kind); - append_package_objects(lu, dep.packageName); - append_direct_shared_deps(lu, dep.packageIndex); - if (auto it = staticsByImagePackage.find(dep.packageIndex); - it != staticsByImagePackage.end()) { - for (auto staticIndex : it->second) { - append_package_objects( - lu, qualified_package_name(packages[staticIndex].manifest)); - append_direct_shared_deps(lu, staticIndex); - } - } - plan.linkUnits.push_back(std::move(lu)); - } // THE PROGRAMS A CONSUMER SHIPS FROM ITS DEPENDENCIES (mcpp#711). // @@ -2615,6 +2590,91 @@ make_plan(const mcpp::manifest::Manifest& manifest, } } } + // THE LINK LINE OF AN IMAGE THAT IS NOT THE ROOT'S (2026.10.5.2, #771). + // + // A dependency's shared library linked with the plan's line, which pools + // every package's `ldflags` and the root's own. Two defects followed. In a + // workspace plan a member's `build.mcpp` output was not on that line, so + // its DLL lost the libraries it declared (`LNK2019` on the symbol); and in + // every plan the root's private flags, and an unrelated member's, reached a + // dependency's image, which SPEC-004 §9.6 forbids. Such an image now links + // with the graph's flags (`graphLdflags`: the profile, the ABI words) and + // the flags of the packages its owner reaches, which is the rule a member's + // program already followed. Artifact edges and build-time-only packages + // contribute nothing to an image, so they are not walked. + auto image_closure = [&](std::size_t owner) { + std::set closure{owner}; + std::vector work{owner}; + while (!work.empty()) { + const auto i = work.back(); work.pop_back(); + if (auto it = directPackageDeps.find(i); it != directPackageDeps.end()) + for (auto j : it->second) { + if (artifactEdges.contains({i, j}) || packages[j].buildTimeOnly) continue; + if (closure.insert(j).second) work.push_back(j); + } + } + return closure; + }; + // The owner's flags first, then those of the packages it reaches, in + // discovery order. + auto closure_ldflags = [&](std::size_t owner, const std::set& closure) { + std::vector out = manifest.buildConfig.graphLdflags; + auto add = [&](std::size_t i) { + for (auto const& f : packages[i].linkUsage.ldflags) out.push_back(f); + }; + add(owner); + for (auto i : closure) if (i != owner) add(i); + return out; + }; + std::map sharedLinkGroups; // package index → group + auto shared_link_group = [&](std::size_t owner, const std::filesystem::path& productDir) { + if (auto it = sharedLinkGroups.find(owner); it != sharedLinkGroups.end()) + return it->second; + const auto closure = image_closure(owner); + BuildPlan::LinkGroup group; + group.linkOnly = true; + group.productDir = productDir; + group.ldflags = closure_ldflags(owner, closure); + std::vector closurePackages; + for (auto i : closure) closurePackages.push_back(packages[i]); + derive_runtime(closurePackages, group.productDir, group); + const int index = static_cast(plan.linkGroups.size()); + plan.linkGroups.push_back(std::move(group)); + sharedLinkGroups[owner] = index; + return index; + }; + + for (auto const& dep : sharedDepTargets) { + LinkUnit lu; + lu.targetName = dep.target.name; + lu.package = dep.packageName; + lu.kind = LinkUnit::SharedLibrary; + lu.dependencyOwned = true; + lu.output = dep.output; + lu.importLibrary = import_library_for(dep.target, naming); + if (msvcTarget && !dep.target.windows_auto_export() && !dep.target.exportPatterns.empty()) + return std::unexpected(exports_without_discovery(dep.target, dep.packageName)); + if (msvcTarget && dep.target.windows_auto_export() && !lu.importLibrary.empty()) + lu.defFile = std::filesystem::path("bin") / (dep.target.name + ".def"); + lu.autoExportStated = dep.target.windowsAutoExportDeclared; + lu.soname = dep.target.soname; + lu.exportPatterns = dep.target.exportPatterns; + lu.runtimeAliases = runtime_aliases_for_target(dep.target, naming); + lu.loaderTagFlag = loader_tag_flag(lu.kind); + lu.linkGroup = shared_link_group(dep.packageIndex, lu.output.parent_path()); + append_package_objects(lu, dep.packageName); + append_direct_shared_deps(lu, dep.packageIndex); + if (auto it = staticsByImagePackage.find(dep.packageIndex); + it != staticsByImagePackage.end()) { + for (auto staticIndex : it->second) { + append_package_objects( + lu, qualified_package_name(packages[staticIndex].manifest)); + append_direct_shared_deps(lu, staticIndex); + } + } + plan.linkUnits.push_back(std::move(lu)); + } + // Reached through a non-artifact edge from the root (its dependencies, // dev- and build-dependencies included), versus reached only through an // artifact edge. Only the second set is withheld from the root's images, @@ -2679,10 +2739,11 @@ make_plan(const mcpp::manifest::Manifest& manifest, lu.importLibrary = import_library_for(t, naming); // MSVC only: MinGW's linker auto-exports, and generating a second // source of truth for what a DLL exports is how the two disagree. - if (msvcTarget && !t.windowsAutoExport && !t.exportPatterns.empty()) + if (msvcTarget && !t.windows_auto_export() && !t.exportPatterns.empty()) return std::unexpected(exports_without_discovery(t, qualified_package_name(manifest))); - if (msvcTarget && t.windowsAutoExport && !lu.importLibrary.empty()) + if (msvcTarget && t.windows_auto_export() && !lu.importLibrary.empty()) lu.defFile = std::filesystem::path("bin") / (t.name + ".def"); + lu.autoExportStated = t.windowsAutoExportDeclared; lu.soname = t.soname; lu.exportPatterns = t.exportPatterns; lu.runtimeAliases = runtime_aliases_for_target(t, naming); @@ -2982,7 +3043,7 @@ make_plan(const mcpp::manifest::Manifest& manifest, BuildPlan::LinkGroup group; group.linkOnly = true; group.productDir = lu.output.parent_path(); - group.ldflags = packages[0].linkUsage.ldflags; + group.ldflags = manifest.buildConfig.graphLdflags; for (auto i : seen) for (auto const& f : packages[i].linkUsage.ldflags) group.ldflags.push_back(f); @@ -3049,14 +3110,14 @@ make_plan(const mcpp::manifest::Manifest& manifest, for (auto i : closureIdx) closure.insert(qualified_package_name(packages[i].manifest)); // The member's link group: the closure's link flags after the - // root's own (`packages[0]` is snapshotted before any dependency is - // loaded, so it holds the profile's flags and nothing pooled), in the - // order a root build gives them (the member, then its dependencies in - // discovery order), and the closure's runtime. + // graph's (`graphLdflags`: the profile and the ABI words, and not the + // workspace's own package's `[build] ldflags`, which are that + // package's), in the order a root build gives them (the member, then + // its dependencies in discovery order), and the closure's runtime. BuildPlan::LinkGroup group; group.member = owner; group.productDir = productDir; - group.ldflags = packages[0].linkUsage.ldflags; + group.ldflags = manifest.buildConfig.graphLdflags; for (auto i : closureIdx) for (auto const& f : packages[i].linkUsage.ldflags) group.ldflags.push_back(f); diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 21578941..82211163 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -844,6 +844,10 @@ export struct BuildOverrides { // on every machine the author has. Both are read out of what the first pass // and `make_plan` already answered. std::string pack_format; + // `mcpp pack` of a library: the static libraries this build produces are + // shipped, so no object of theirs carries LTO intermediate code, which is + // tied to the exact compiler that wrote it (2026.10.5.2). + bool no_lto_in_archives = false; // What a packaging pass knows of one packed member: where its tree is // staged, and what the staging resolved for the programs that act for it. struct PackStage { diff --git a/src/build/prepare/config.cpp b/src/build/prepare/config.cpp index a318c2df..7c235ea9 100644 --- a/src/build/prepare/config.cpp +++ b/src/build/prepare/config.cpp @@ -802,6 +802,26 @@ bool graph_or_targets_import_std(const mcpp::modgraph::Graph& graph, const mcpp::manifest::Manifest& manifest, const std::filesystem::path& projectRoot, const std::vector& packages) { + return graph_or_targets_import(graph, manifest, projectRoot, packages, is_std_module); +} + +// `std.compat` alone: its BMI is built when, and only when, a unit of the plan +// imports it (2026.10.5.2), so a library whose `std.compat` fails to build +// costs nothing to a project that does not use it. +bool graph_or_targets_import_std_compat(const mcpp::modgraph::Graph& graph, + const mcpp::manifest::Manifest& manifest, + const std::filesystem::path& projectRoot, + const std::vector& packages) { + return graph_or_targets_import(graph, manifest, projectRoot, packages, + [](std::string_view n) { return n == "std.compat"; }); +} + +bool graph_or_targets_import(const mcpp::modgraph::Graph& graph, + const mcpp::manifest::Manifest& manifest, + const std::filesystem::path& projectRoot, + const std::vector& packages, + const std::function& wanted) { + const auto is_std_module = [&](std::string_view n) { return wanted(n); }; for (auto& u : graph.units) { for (auto& req : u.requires_) { if (is_std_module(req.logicalName)) @@ -815,8 +835,8 @@ bool graph_or_targets_import_std(const mcpp::modgraph::Graph& graph, // packages whose targets make_plan compiles are the root and, in a // workspace plan, every selected member (a member whose only sources are // its tests is the case the root alone misses). - auto targets_import_std = [](const mcpp::manifest::Manifest& m, - const std::filesystem::path& root) { + auto targets_import_std = [&](const mcpp::manifest::Manifest& m, + const std::filesystem::path& root) { const auto extTable = mcpp::extension_table_for(m.buildConfig.moduleExtensions, m.buildConfig.deviceExtensions); for (auto& t : m.targets) { diff --git a/src/build/prepare/graph.cpp b/src/build/prepare/graph.cpp index 2f955364..4df4cb58 100644 --- a/src/build/prepare/graph.cpp +++ b/src/build/prepare/graph.cpp @@ -1615,12 +1615,15 @@ step4b_acquire_dependency_source(PrepareState& state, WorklistItemCtx& ctx) { }; // A rooted workspace's own package, the member "." of a workspace // plan (§15), is the workspace's manifest: it reads its own - // `[workspace.dependencies]`, as it did as the root. + // `[workspace.dependencies]`, as it did as the root, and receives + // `[workspace.package]` and `[workspace.build]` as every member does. const bool depIsWorkspacePackage = state.workspacePlan() && state.wsManifest && dep_root.lexically_normal() == state.runtimeWorkspaceRoot.lexically_normal(); if (depIsWorkspacePackage) { mcpp::project::merge_workspace_deps(*dep_manifest, *state.wsManifest, state.runtimeWorkspaceRoot); + mcpp::project::inherit_as_root_package(*dep_manifest, + state.runtimeWorkspaceRoot); } else if (depIsMember) { if (auto bad = inheritAsMember(*state.wsManifest, state.runtimeWorkspaceRoot)) return std::unexpected(*bad); diff --git a/src/build/prepare/link_scope.cpp b/src/build/prepare/link_scope.cpp new file mode 100644 index 00000000..f01325cb --- /dev/null +++ b/src/build/prepare/link_scope.cpp @@ -0,0 +1,176 @@ +// link_scope.cpp -- P13: what an image links with beyond its objects, decided +// once the plan exists (2026.10.5.2): the statement that the root's private +// link flags no longer reach a dependency's shared library, and where LTO +// cannot reach. Declared in `:state`. + +module mcpp.build.prepare; +import :state; + +import std; +import mcpp.diag; +import mcpp.build.refusal; +import mcpp.build.plan; +import mcpp.toolchain.model; + +namespace mcpp::build { + +// A STATEMENT FOR THE RELEASE THAT CHANGED THE RULE (2026.10.5.2, D7b). +// +// Until 2026.10.5.2 a dependency's shared library linked with the root's +// `[build] ldflags`, and a project could rely on that without knowing it: a +// search path the root names for a library the dependency links. Such a link +// now fails, and the linker names the library rather than the cause. The +// words that can be missed this way, search paths and libraries, are named +// once beside the shared libraries they no longer reach. Other words reaching +// a dependency's image were defects (a version script, an entry point) and +// are not mentioned. To be removed in the release after next. +void note_root_link_words_withheld(const PrepareState& state, const BuildContext& ctx) { + if (state.workspacePlan() || state.packages.empty()) return; + std::vector shared; + for (auto const& lu : ctx.plan.linkUnits) + if (lu.dependencyOwned && lu.kind == mcpp::build::LinkUnit::SharedLibrary) + shared.push_back(lu.targetName); + if (shared.empty()) return; + const auto& graph = state.m->buildConfig.graphLdflags; + auto linkInput = [](std::string_view w) { + return w.starts_with("-L") || w.starts_with("-l") || w.starts_with("-Wl,-L") + || w.starts_with("/LIBPATH:") || w.starts_with("-LIBPATH:") + || w.ends_with(".lib") || w.ends_with(".a") || w.ends_with(".so"); + }; + std::string words; + for (auto const& w : state.packages[0].linkUsage.ldflags) { + if (std::ranges::find(graph, w) != graph.end() || !linkInput(w)) continue; + words += (words.empty() ? "`" : ", `") + w + "`"; + } + if (words.empty()) return; + std::string libs; + for (auto const& n : shared) libs += (libs.empty() ? "" : ", ") + n; + mcpp::diag::note("link/root-flags", std::format( + "the root package's [build] ldflags {} no longer reach the shared " + "libraries of its dependencies ({}) since 2026.10.5.2; a dependency " + "that needs a search path or a library states it in its own [build] " + "ldflags or build.mcpp", words, libs)); +} + +// WHERE LTO CANNOT REACH (2026.10.5.2, D5 and D6 of the 2026-10-05 design). +// +// Two kinds of image cannot hold LTO intermediate code, and in both the +// objects that feed them are compiled without it, which is said once: +// +// * a PE shared library whose exports cl.exe's `/GL` would hide. Its +// exports are discovered from the symbol tables of its objects, and a +// `/GL` object has none. The packages whose objects it links (its own and +// the statics placed in it) are compiled with `/GL-`, and the DLL is still +// linked with `/LTCG`. A target that STATES `windows_auto_export = true` +// is refused instead, as is `/GL` written into those packages' flags: +// both are the author's words, and one of them has to change. +// * a static library `mcpp pack` ships. Intermediate code is readable only +// by the compiler that wrote it, so a prebuilt archive carries objects. +// +// A source is compiled once in a build, so an object that also reaches a +// program is compiled the same way there. The summary says `+ lto (partial)`. +std::expected scope_lto(PrepareState& state, BuildContext& ctx) { + auto& plan = ctx.plan; + auto& bc = plan.manifest.buildConfig; + const bool cl = plan.toolchain.compiler == mcpp::toolchain::CompilerId::MSVC; + std::map unitOf; + for (std::size_t i = 0; i < plan.compileUnits.size(); ++i) + unitOf.emplace(plan.compileUnits[i].object, i); + auto packages_of = [&](const mcpp::build::LinkUnit& lu) { + std::set out; + for (auto const& o : lu.objects) + if (auto it = unitOf.find(o); it != unitOf.end()) + out.insert(plan.compileUnits[it->second].packageName); + return out; + }; + auto writes_gl = [](const std::vector& flags) { + return std::ranges::any_of(flags, [](std::string_view w) { + return w == "/GL" || w == "-GL"; + }); + }; + auto discovers = [](const mcpp::build::LinkUnit& lu) { + return lu.kind == mcpp::build::LinkUnit::SharedLibrary && !lu.defFile.empty(); + }; + + if (cl) { + for (auto const& lu : plan.linkUnits) { + if (!discovers(lu)) continue; + const auto pkgs = packages_of(lu); + for (auto const& cu : plan.compileUnits) { + if (!pkgs.contains(cu.packageName)) continue; + if (!writes_gl(cu.packageCxxflags) && !writes_gl(cu.packageCflags)) continue; + refusal::record(refusal::Code::LtoExportDiscovery); + return std::unexpected(std::format( + "package '{}' compiles with /GL, and its objects are linked into the " + "shared library '{}', whose exports are discovered from their symbol " + "tables; a /GL object has none.\n" + " Set `windows_auto_export = false` on '{}' and mark its exports " + "with __declspec(dllexport), or remove /GL (`lto = true` in a profile " + "applies it where it can).", + cu.packageName, lu.targetName, lu.targetName)); + } + } + } + if (!bc.lto) return {}; + + std::set withoutLto; + std::vector dlls, archives; + if (cl) { + for (auto const& lu : plan.linkUnits) { + if (!discovers(lu)) continue; + if (lu.autoExportStated) { + refusal::record(refusal::Code::LtoExportDiscovery); + return std::unexpected(std::format( + "the shared library '{}' states `windows_auto_export = true`, and " + "`lto = true` compiles its objects with /GL on cl.exe, which leaves " + "no symbol table to discover its exports from.\n" + " Set `windows_auto_export = false` and mark the exports with " + "__declspec(dllexport), or omit the key to have this library's " + "objects compiled without LTO.", lu.targetName)); + } + dlls.push_back(lu.targetName); + for (auto const& p : packages_of(lu)) withoutLto.insert(p); + } + } + if (state.overrides.no_lto_in_archives) { + for (auto const& lu : plan.linkUnits) { + if (lu.kind != mcpp::build::LinkUnit::StaticLibrary) continue; + archives.push_back(lu.targetName); + for (auto const& p : packages_of(lu)) withoutLto.insert(p); + } + } + if (withoutLto.empty()) return {}; + + const std::string off = cl ? "/GL-" : "-fno-lto"; + state.ltoWithheld = withoutLto; + state.ltoOffWord = off; + for (auto& cu : plan.compileUnits) { + if (!withoutLto.contains(cu.packageName)) continue; + cu.packageCxxflags.push_back(off); + cu.packageCflags.push_back(off); + } + bc.ltoPartial = true; + auto join = [](const auto& v) { + std::string s; + for (auto const& x : v) s += (s.empty() ? "" : ", ") + std::string(x); + return s; + }; + if (!dlls.empty()) + mcpp::diag::degraded("build/lto", std::format( + "LTO is not applied to the packages linked into the shared libraries {} " + "({}), whose exports are discovered from symbol tables that /GL objects " + "do not have", join(dlls), join(withoutLto)), + "those packages' code is optimized per translation unit; the rest of the " + "build is link-time optimized", + "set `windows_auto_export = false` on each library and mark its exports " + "with __declspec(dllexport) to apply LTO to it as well"); + if (!archives.empty()) + mcpp::diag::degraded("pack/lto", std::format( + "LTO is not applied to the objects of the packed static libraries {}", + join(archives)), + "a shipped archive holds machine code, because LTO intermediate code can " + "be read only by the compiler version that wrote it"); + return {}; +} + +} // namespace mcpp::build diff --git a/src/build/prepare/plan.cpp b/src/build/prepare/plan.cpp index ad2f93a1..0253197f 100644 --- a/src/build/prepare/plan.cpp +++ b/src/build/prepare/plan.cpp @@ -422,19 +422,21 @@ static std::expected step13_link_forms(PrepareState& state, B } static std::expected step13_make_plan(PrepareState& state, BuildContext& ctx) { - // A workspace plan links each member's closure with that closure's own - // flags (workspace design 2026-09-29 §15): each package's link flags, + // An image that is not the root's links with its owner's closure's own + // flags (workspace design 2026-09-29 §15; for a dependency's shared + // library in every plan since 2026.10.5.2): each package's link flags, // its build program's included, with search paths made absolute against - // the package, as the root's pooled list holds them outside a workspace. - if (state.workspacePlan()) - for (std::size_t i = 1; i < state.packages.size(); ++i) - state.packages[i].linkUsage.ldflags = normalized_dependency_ldflags( - state.packages[i].root, state.packages[i].manifest.buildConfig.ldflags); + // the package, as the root's pooled list holds them. + for (std::size_t i = 1; i < state.packages.size(); ++i) + state.packages[i].linkUsage.ldflags = normalized_dependency_ldflags( + state.packages[i].root, state.packages[i].manifest.buildConfig.ldflags); auto planResult = mcpp::build::make_plan(*state.m, *state.tc, state.fp, state.scan.graph, state.report.topoOrder, state.packages, *state.root, ctx.outputDir, state.stdBmiPath, state.stdObjectPath, state.storeRoots); if (!planResult) return std::unexpected(planResult.error()); ctx.plan = std::move(*planResult); + note_root_link_words_withheld(state, ctx); + if (auto r = scope_lto(state, ctx); !r) return std::unexpected(r.error()); // The request the graph is planned for (§3, §15): the plan's members and // the requested features, which name no directory. if (state.workspacePlan()) { @@ -1379,428 +1381,6 @@ static std::expected step13_assembly_units(PrepareState& stat return {}; } -// The resource compiler of the plan's resource units, and each unit's flags. -static std::expected -step13_resource_compiler(PrepareState& state, BuildContext& ctx) { - if (ctx.plan.resourceUnits.empty()) return {}; - namespace rsrc = mcpp::build::resources; - const auto trip = mcpp::toolchain::triple::parse(state.tc->targetTriple) - .value_or(mcpp::toolchain::triple::host_triple()); - const auto dialectId = mcpp::toolchain::dialect_for(*state.tc).id; - const bool msvcStyle = (dialectId == "msvc"); - - // 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(*state.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(), state.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. - // - // Include search: the script's package 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. Each unit carries the - // flags of its package; the plan's flags are the first unit's, so a - // plan with one package states them once. - const std::string ip = msvcStyle ? "/I" : "-I"; - std::vector systemIncludes; - if (msvcStyle && tool->name().find("llvm-rc") != std::string::npos) { - for (auto const& ev : state.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)) - systemIncludes.push_back(ip + std::string(dir)); - } - } - for (auto& ru : ctx.plan.resourceUnits) { - ru.flags.push_back(msvcStyle ? "/C" : "--codepage=65001"); - if (msvcStyle) ru.flags.push_back("65001"); - for (auto const& d : ru.includeDirs) ru.flags.push_back(ip + d.string()); - ru.flags.insert(ru.flags.end(), systemIncludes.begin(), systemIncludes.end()); - } - ctx.plan.rcFlags = ctx.plan.resourceUnits.front().flags; - return {}; -} - -static std::expected step13_windows_resources(PrepareState& state, BuildContext& ctx) { - // ─── Windows resources: [resources] → a tracked link input (mcpp#365) ── - // - // Four rules, in this order: - // 1. Only the [resources] of the package being built is read: the root, - // or in a workspace plan each selected member, whose resources reach - // that member's images only. A dependency's version resource would - // fight its consumer's for ordinal 1, and a dependency that produces - // no PE image of its own has nothing to embed into. - // 2. A DECLARED FILE THAT DOES NOT EXIST IS AN ERROR — on EVERY target. - // Whether a path exists is a fact about the working tree, not about - // the target; gating it on is_pe() meant a Linux or macOS CI could not - // see a typo in `icon = …` at all and only the Windows job went red, - // which is the same "find out late" failure the hard error exists to - // remove. Existence is checked everywhere; only COMPILATION is PE-only. - // 3. On a non-PE target nothing is compiled — no units, no warning, - // byte-identical build. This is what makes `cfg(windows)` unnecessary - // (and it could not be used anyway: the conditional channel carries - // BuildInputs only). - // 4. Nothing to embed into (an archive-only package) → say so and stop. - // - // The same pipeline carries the application manifest of `windows_code_page` - // (#693). A PE executable embeds one that makes its process ANSI code page - // UTF-8 when its target says `windows_code_page = "utf-8"`, or, with nothing - // said, when it is built as a host tool (D6): such a tool receives mcpp's - // UTF-8 paths on its command line. `legacy` opts out, and an ordinary target - // that says nothing embeds nothing (M6: the program's encoding is its own). - // - // The host-tool default yields to a manifest the package embeds itself - // through `[resources] files`: both would sit at ordinal 1, the package - // said nothing about code pages, and its own manifest is the one it ships. - // A DECLARED `utf-8` beside such a manifest is refused below instead. - // - // A SUBJECT is one package whose resources are planned: its manifest, the - // directory its paths were written in, and the images it owns. Outside a - // workspace plan the root is the only subject and owns every image that - // is not a dependency's program, which is the historical rule. - struct Subject { - const mcpp::manifest::Manifest* m; - std::filesystem::path dir; - std::string owner; // empty: the root - }; - std::vector subjects; - if (!state.workspacePlan()) { - subjects.push_back({&*state.m, *state.root, {}}); - } else { - for (auto const& pkg : state.packages) - if (pkg.selectedMember) - subjects.push_back({&pkg.manifest, pkg.root, - mcpp::build::qualified_package_name(pkg.manifest)}); - } - const bool hostToolBuild = state.overrides.tool_depth > 0; - const auto trip = mcpp::toolchain::triple::parse(state.tc->targetTriple) - .value_or(mcpp::toolchain::triple::host_triple()); - const auto dialectId = mcpp::toolchain::dialect_for(*state.tc).id; - const bool msvcStyle = (dialectId == "msvc"); - - for (auto const& S : subjects) { - const auto& M = *S.m; - const bool ownManifest = hostToolBuild - && std::ranges::any_of(M.resources.files, [&](const auto& f) { - const auto abs = (f.is_absolute() ? f : (S.dir / f)).lexically_normal(); - return mcpp::build::resources::scan_rc(abs).declaresManifest; - }); - auto codePageOf = [&](const mcpp::manifest::Target& t) -> std::string_view { - if (!t.windowsCodePage.empty()) return t.windowsCodePage; - return (hostToolBuild && t.is_program() && !ownManifest) ? "utf-8" : "legacy"; - }; - const bool anyUtf8Image = std::ranges::any_of(M.targets, [&](const auto& t) { - return t.is_program() && codePageOf(t) == "utf-8"; - }); - if (M.resources.declared() || anyUtf8Image) { - namespace rsrc = mcpp::build::resources; - const auto& R = M.resources; - - // Rule 2 — target-independent, so it runs before the is_pe() gate. - auto resolve_declared = [&](const std::filesystem::path& p, - std::string_view key) - -> std::expected - { - // Lexical, not weakly_canonical: canonicalising resolves symlinks, - // and a symlinked source tree would then bake a different path into - // the generated script than the one the user wrote. (Same reason - // mcpp#344 made the cache anchor lexical.) - auto abs = (p.is_absolute() ? p : (S.dir / p)).lexically_normal(); - std::error_code ec; - if (!std::filesystem::is_regular_file(abs, ec)) - return std::unexpected(std::format( - "[resources] {} = \"{}\" does not exist (looked at {}).\n" - " A declared resource is a build input like any other " - "source: mcpp will not quietly ship a binary without it. " - "Remove the key if the resource is not wanted.", - key, p.generic_string(), abs.generic_string())); - return abs; - }; - - std::filesystem::path iconAbs; - if (!R.icon.empty()) { - auto r = resolve_declared(R.icon, "icon"); - if (!r) return std::unexpected(r.error()); - iconAbs = *r; - } - std::vector extraInputs; - for (auto const& e : R.extraInputs) { - auto r = resolve_declared(e, "extra-inputs"); - if (!r) return std::unexpected(r.error()); - extraInputs.push_back(*r); - } - std::vector scriptFiles; - for (auto const& f : R.files) { - auto r = resolve_declared(f, "files"); - if (!r) return std::unexpected(r.error()); - scriptFiles.push_back(*r); - } - - // 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 std::string_view outExt = msvcStyle ? ".res" : ".o"; - // A member's resources are compiled in a directory of its own, so - // two members' scripts and synthesised scripts never share a name. - const auto resRel = S.owner.empty() ? std::filesystem::path("res") - : std::filesystem::path("res") / S.owner; - const auto resDir = ctx.plan.outputDir / resRel; - // Where the resource compiler looks for a script's includes and - // files: the package's directory, then its include_dirs. The - // scan resolves them the same way. - std::vector rcIncludes{S.dir}; - for (auto const& d : M.buildConfig.includeDirs) - rcIncludes.push_back(d.is_absolute() ? d : (S.dir / d)); - std::error_code mkEc; - std::filesystem::create_directories(resDir, mkEc); - - // Which link units embed resources: images, not archives. A `.res` - // inside a static library is dropped by every linker that reads one. - // Test binaries are images too, but deliberately excluded: an icon - // and an OriginalFilename belong to what the project SHIPS, and a - // test executable is not that. (`role = "object"` makes the opposite - // call, for the opposite reason — see its note above.) - // In a workspace plan a member's images are its link units and - // its shared libraries, which are linked with the graph's. - auto owns = [&](const mcpp::build::LinkUnit& lu) { - if (S.owner.empty()) return true; - if (lu.memberOf == S.owner) return true; - return lu.kind == mcpp::build::LinkUnit::SharedLibrary - && std::ranges::any_of(M.targets, [&](const auto& t) { - return t.kind == mcpp::manifest::Target::SharedLibrary - && t.name == lu.targetName; - }); - }; - std::vector peUnits; - for (std::size_t i = 0; i < ctx.plan.linkUnits.size(); ++i) { - auto k = ctx.plan.linkUnits[i].kind; - // A dependency's program (mcpp#711) carries its own package's - // identity, not this one's. - if (!ctx.plan.linkUnits[i].artifactOf.empty()) continue; - if (!owns(ctx.plan.linkUnits[i])) continue; - if (k == mcpp::build::LinkUnit::Binary || - k == mcpp::build::LinkUnit::SharedLibrary) - peUnits.push_back(i); - } - // Nothing to embed into. Compiling the scripts anyway would leave - // orphan edges nothing depends on, and demanding a resource - // compiler for them would fail a build that has no use for one. - // A degradation, not a warning: the user asked for something and - // got nothing, so `--strict` should see it. - if (peUnits.empty()) { - mcpp::diag::degraded("resources/no-image", std::format( - "[resources] is declared but '{}' produces no executable or " - "shared library for {}", M.package.name, trip.str()), - "nothing embeds the icon or the version metadata", - "add a [targets.] with kind = \"bin\" or \"shared\", " - "or drop the [resources] section"); - return {}; - } - - // Two scripts with the same stem in different directories would - // otherwise write the same artifact — a silent "multiple rules - // generate" that ninja reports far from the cause. - std::set usedStems; - auto add_unit = [&](const std::filesystem::path& src, - std::string_view stem, - std::vector inputs, - std::size_t attachTo) - -> std::expected - { - if (!usedStems.insert(std::string(stem)).second) - return std::unexpected(std::format( - "[resources] two resource scripts are named '{}.rc'; " - "they would produce the same artifact. Rename one.", stem)); - mcpp::build::ResourceUnit ru; - ru.package = mcpp::build::qualified_package_name(M); - ru.source = src; - ru.output = resRel / (std::string(stem) + std::string(outExt)); - ru.includeDirs = rcIncludes; - ru.implicitInputs = std::move(inputs); - ctx.plan.resourceUnits.push_back(std::move(ru)); - const auto& out = ctx.plan.resourceUnits.back().output; - if (attachTo == static_cast(-1)) { - for (auto i : peUnits) ctx.plan.linkUnits[i].objects.push_back(out); - } else { - ctx.plan.linkUnits[attachTo].objects.push_back(out); - } - return {}; - }; - - // Author-written scripts: compiled once, linked into every image. - for (auto const& rcSrc : scriptFiles) { - auto scan = rsrc::scan_rc(rcSrc, rcIncludes); - if (scan.versionInfoNamedByString) { - // The mcpp#365 silent failure, caught on the way in. A - // degradation rather than a warning: the impact is exactly - // the thing this feature exists to remove — a shipped binary - // whose version metadata Windows cannot read — so a build - // that asked for `--strict` must not pass over it. - mcpp::diag::degraded("resources/versioninfo", std::format( - "{}: `{} VERSIONINFO` names the version resource '{}' " - "instead of ordinal 1", - rcSrc.filename().generic_string(), scan.versionInfoName, - scan.versionInfoName), - "Windows will not find it — GetFileVersionInfo looks up " - "MAKEINTRESOURCE(1) and every field comes back empty, " - "while every tool that prints the resource TYPE still " - "says it is fine", - "VS_VERSION_INFO is a macro from ; add " - "`#include ` to the script, or write " - "`1 VERSIONINFO`"); - } - for (auto const& g : scan.gaps) { - mcpp::diag::degraded("resources/inputs", - std::format("{}: `{}` names its file through a macro, so " - "mcpp cannot track it", - rcSrc.filename().generic_string(), g), - "editing that file will not trigger a rebuild", - "list it in [resources] extra-inputs = [...]"); - } - if (scan.declaresManifest && anyUtf8Image) - return std::unexpected(std::format( - "[resources] {} embeds an application manifest, and " - "`windows_code_page = \"utf-8\"` embeds another at the same " - "ordinal (1).\n Keep one: add `" - "UTF-8` to your manifest and set " - "`windows_code_page = \"legacy\"`, or drop your manifest.", - rcSrc.filename().generic_string())); - auto inputs = std::move(scan.inputs); - inputs.insert(inputs.end(), extraInputs.begin(), extraInputs.end()); - if (auto a = add_unit(rcSrc, rcSrc.stem().string(), - std::move(inputs), - static_cast(-1)); !a) - return std::unexpected(a.error()); - } - - // The synthesised script: per image, because OriginalFilename and - // the version block belong to a specific artifact, and the - // manifest to a specific executable. - const bool synthVersion = R.declared() && R.synthesize_version_info(); - auto wantsUtf8 = [&](const mcpp::build::LinkUnit& lu) { - if (lu.kind != mcpp::build::LinkUnit::Binary) return false; - if (!lu.artifactOf.empty()) return false; - for (auto const& t : M.targets) - if (t.name == lu.targetName) - return t.is_program() && codePageOf(t) == "utf-8"; - return false; - }; - if (!iconAbs.empty() || synthVersion || anyUtf8Image) { - // 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 (synthVersion && !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]; - const bool utf8 = wantsUtf8(lu); - if (iconAbs.empty() && !synthVersion && !utf8) continue; - std::filesystem::path manifestAbs; - if (utf8) { - manifestAbs = resDir / (lu.targetName + ".mcpp.manifest"); - const auto manifestText = rsrc::utf8_code_page_manifest(); - std::string had; - if (std::ifstream in(manifestAbs, std::ios::binary); in) - had.assign(std::istreambuf_iterator(in), {}); - if (had != manifestText) { - std::ofstream os(manifestAbs, std::ios::binary); - if (!os) return std::unexpected(std::format( - "cannot write the application manifest '{}'", - manifestAbs.string())); - os << manifestText; - } - } - // A script synthesised for the manifest alone carries - // nothing else: a package that declares no [resources] - // asked for no version resource. - mcpp::manifest::Resources forScript = R; - if (!synthVersion) forScript.versionInfo = false; - auto text = rsrc::synthesize_rc( - M.package, forScript, lu.output.filename().string(), - iconAbs, manifestAbs); - 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); - if (!manifestAbs.empty()) inputs.push_back(manifestAbs); - 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()); - } - } - - return {}; - }; - - if (trip.is_pe()) - if (auto r = plan_resources(); !r) return std::unexpected(r.error()); - } - } - - return step13_resource_compiler(state, ctx); -} - -// The member path (relative to the workspace root) of a package root, when the -// root is a member of the workspace this build runs in; empty otherwise. -// Read by W3 (a member's non-public modules). -std::string workspace_member_of(const PrepareState& state, const std::filesystem::path& root) { - if (!state.wsManifest || state.runtimeWorkspaceRoot.empty()) return {}; - const auto rel = root.lexically_normal() - .lexically_relative(state.runtimeWorkspaceRoot.lexically_normal()) - .generic_string(); - if (rel.empty() || rel == "." || rel.starts_with("..")) return {}; - for (auto const& m : state.wsManifest->workspace.members) { - if (m == rel) return rel; - if (m.ends_with("/*") && rel.starts_with(m.substr(0, m.size() - 1)) - && rel.find('/', m.size() - 1) == std::string::npos) - return rel; - } - return {}; -} static std::expected step13_dependency_cache(PrepareState& state, BuildContext& ctx) { // ─── Global dependency cache: per-package keys, hit → stage edges ── @@ -1952,6 +1532,13 @@ static std::expected step13_dependency_cache(PrepareState& st state.storeRoots.empty() ? std::filesystem::path{} : state.storeRoots.front()); pa.sources = pkgSources[idx]; + // What `scope_lto` appended to this package's units is part of + // their command, so it is part of the entry's identity. + if (state.ltoWithheld.contains( + mcpp::build::qualified_package_name(state.packages[idx].manifest))) { + pa.cflags.push_back(state.ltoOffWord); + pa.cxxflags.push_back(state.ltoOffWord); + } const bool selfIsIndex = idx > 0 && idx - 1 < state.dep_cache_identities.size() && state.dep_cache_identities[idx - 1].sourceKind == "version"; diff --git a/src/build/prepare/scan.cpp b/src/build/prepare/scan.cpp index 94ef2ac1..14ceb30e 100644 --- a/src/build/prepare/scan.cpp +++ b/src/build/prepare/scan.cpp @@ -147,6 +147,8 @@ static std::expected step11_scan_sources(PrepareState& state) return std::unexpected(msg); } + state.needsStdCompat = graph_or_targets_import_std_compat( + state.scan.graph, *state.m, *state.root, state.packages); return graph_or_targets_import_std(state.scan.graph, *state.m, *state.root, state.packages); } @@ -410,7 +412,6 @@ step11_package_std_module_source(PrepareState& state) { pkg.manifest.package.name, pkg.manifest.stdModule, pkg.root.string())); } - state.tc->stdModuleSource = src; // AND THE COMPAT MODULE, FROM THE SAME PACKAGE OR NOT AT ALL. // // `std.compat` is a second module over the SAME library. Leaving it @@ -434,12 +435,11 @@ step11_package_std_module_source(PrepareState& state) { pkg.manifest.package.name, pkg.manifest.stdCompatModule, pkg.root.string())); } - state.tc->stdCompatSource = csrc; + state.tc->set_std_modules(src, csrc); } else { - state.tc->stdCompatSource.clear(); + state.tc->set_std_modules(src); } state.tc->targetCxxRuntime = true; - state.tc->hasImportStd = true; state.tc->importStdMinLevel = 20; // libc++'s own floor; see clang.cppm // The target, first. On a freestanding target that means the whole ISA // profile --- `--target', `-march', `-mabi', `-mcmodel' --- because a @@ -612,9 +612,7 @@ step11_apple_sdk_cxx_runtime(PrepareState& state, bool needsStdModule) { && !state.resolvedTargetSide.cxx.fromGraph()) { if (!needsStdModule) { state.tc->appleSdkCxxHeaders = true; - state.tc->hasImportStd = false; - state.tc->stdModuleSource.clear(); - state.tc->stdCompatSource.clear(); + state.tc->clear_std_modules(); } else { mcpp::diag::degraded("target/cxx-runtime", std::format( "{} links the SDK's libc++ under the toolchain payload's " @@ -718,6 +716,19 @@ step11_std_module_availability_gate(PrepareState& state, bool needsStdModule) { "source imports std but toolchain '{}' provides no std module source", state.tc->label())); } + // `std.compat` is a second module of the same library, and not every + // library has it: a package that supplies `std` alone, or a toolset without + // `std.compat.ixx`. Said here, before any compile, instead of as the + // compiler's "module 'std.compat' not found" inside the first unit that + // imports it, which names neither the library nor the reason. + if (state.needsStdCompat && state.tc->hasImportStd + && state.tc->stdCompatSource.empty()) { + return std::unexpected(std::format( + "source imports std.compat but the standard library of toolchain '{}' " + "({}) provides `std` without `std.compat`; import std and the C headers " + "the code uses instead", + state.tc->label(), state.tc->stdlibId.empty() ? "unknown" : state.tc->stdlibId)); + } // `import std` availability is two-dimensional once C++20 is a legal level: // having a std module source is not the same as being able to build it at // the project's level. Every toolchain mcpp ships answers 20; only an MSVC @@ -833,7 +844,8 @@ step11_prebuild_std_module(PrepareState& state, bool needsStdModule) { *state.tc, state.m->package.standard, state.stdFlagAndDialect, mcpp::platform::macos::deployment_target( stdTargetIsMacos, state.m->buildConfig.macosDeploymentTarget), - mcpp::toolchain::default_cache_root(), stdCrt); + mcpp::toolchain::default_cache_root(), stdCrt, + state.needsStdCompat); if (!described) { refusal::record(refusal::Code::StdModulePrecompile); return std::unexpected(described.error().message); @@ -848,7 +860,8 @@ step11_prebuild_std_module(PrepareState& state, bool needsStdModule) { *state.tc, state.m->package.standard, state.stdFlagAndDialect, mcpp::platform::macos::deployment_target( stdTargetIsMacos, state.m->buildConfig.macosDeploymentTarget), - mcpp::toolchain::default_cache_root(), stdCrt); + mcpp::toolchain::default_cache_root(), stdCrt, + state.needsStdCompat); if (!sm) { // THE ONE CODE IN THE TAXONOMY THAT NOTHING WROTE. // @@ -885,7 +898,8 @@ step11_prebuild_std_module(PrepareState& state, bool needsStdModule) { *state.tc, state.m->package.standard, state.stdFlagAndDialect, mcpp::platform::macos::deployment_target( stdTargetIsMacos, state.m->buildConfig.macosDeploymentTarget), - mcpp::toolchain::default_cache_root(), stdCrt); + mcpp::toolchain::default_cache_root(), stdCrt, + state.needsStdCompat); if (described) state.describedStdModule = std::move(*described); } } diff --git a/src/build/prepare/state.cppm b/src/build/prepare/state.cppm index 010cc036..9092a077 100644 --- a/src/build/prepare/state.cppm +++ b/src/build/prepare/state.cppm @@ -731,6 +731,14 @@ struct PrepareState { std::filesystem::path stdBmiPath; std::filesystem::path stdObjectPath; std::filesystem::path stdCompatBmiPath; + // A unit of the plan imports `std.compat` (2026.10.5.2): its BMI is built + // only then. + bool needsStdCompat = false; + // The packages whose units `scope_lto` compiles without LTO, and the word + // that does it (2026.10.5.2). A package's dependency-cache key folds the + // word in, because the same package is compiled with LTO in another plan. + std::set ltoWithheld; + std::string ltoOffWord; std::filesystem::path stdCompatObjectPath; std::optional describedStdModule; std::string stdFlagAndDialect; @@ -752,6 +760,11 @@ std::expected phase0_manifest_and_workspace(PrepareState& sta // plan.cpp: the member path of a package root within the workspace this build // runs in, or empty (W3). std::string workspace_member_of(const PrepareState& state, const std::filesystem::path& root); +// windows_resources.cpp: the Windows resources of the plan's images (P13). +std::expected step13_windows_resources(PrepareState& state, BuildContext& ctx); +// link_scope.cpp: what an image links with beyond its objects (P13, 2026.10.5.2). +void note_root_link_words_withheld(const PrepareState& state, const BuildContext& ctx); +std::expected scope_lto(PrepareState& state, BuildContext& ctx); // graph.cpp: a dependency's link flags as its consumer's link reads them -- // word by word, each search path made absolute against the package. std::vector normalized_dependency_ldflags( @@ -809,6 +822,15 @@ bool graph_or_targets_import_std(const mcpp::modgraph::Graph& graph, const mcpp::manifest::Manifest& manifest, const std::filesystem::path& projectRoot, const std::vector& packages); +bool graph_or_targets_import_std_compat(const mcpp::modgraph::Graph& graph, + const mcpp::manifest::Manifest& manifest, + const std::filesystem::path& projectRoot, + const std::vector& packages); +bool graph_or_targets_import(const mcpp::modgraph::Graph& graph, + const mcpp::manifest::Manifest& manifest, + const std::filesystem::path& projectRoot, + const std::vector& packages, + const std::function& wanted); // toolchain_env.cpp: target rows, sysroots, the MSVC binding, build-program environments const mcpp::manifest::TargetEntry* diff --git a/src/build/prepare/toolchain.cpp b/src/build/prepare/toolchain.cpp index f0af74ec..a648a823 100644 --- a/src/build/prepare/toolchain.cpp +++ b/src/build/prepare/toolchain.cpp @@ -260,6 +260,8 @@ static std::expected step1_define_early_toolchain_closures(Pr state.profileCxxflags = pr.cxxflags; state.m->buildConfig.ldflags.insert(state.m->buildConfig.ldflags.end(), pr.ldflags.begin(), pr.ldflags.end()); + state.m->buildConfig.graphLdflags.insert(state.m->buildConfig.graphLdflags.end(), + pr.ldflags.begin(), pr.ldflags.end()); } // Every directory a package payload may legitimately have been INSTALLED @@ -1099,6 +1101,7 @@ static std::expected step1_device_axis_and_layer_merge(Prepar state.add_once(state.m->buildConfig.dialectCxxflags, "-pthread"); state.add_once(state.m->buildConfig.cflags, "-pthread"); state.add_once(state.m->buildConfig.ldflags, "-pthread"); + state.add_once(state.m->buildConfig.graphLdflags, "-pthread"); } // `[target..abi] exceptions` -- design 2026-09-12 (the UI // framework record), section 2.1, A1: the second `abi` member, the @@ -1118,6 +1121,7 @@ static std::expected step1_device_axis_and_layer_merge(Prepar if (abiExceptionsRendered) { state.add_once(state.m->buildConfig.dialectCxxflags, "-fexceptions"); state.add_once(state.m->buildConfig.ldflags, "-fexceptions"); + state.add_once(state.m->buildConfig.graphLdflags, "-fexceptions"); } // `[build].defines` must reach the scanner (P1689) and the compile edge, // and must participate in the fingerprint. Fold before dependency @@ -1946,9 +1950,7 @@ step2_retarget_for_retargetable_driver(PrepareState& state) { // an ordinary package (`mcpplibs.std.freestanding`), so mcpp's job // here is to stop pretending the hosted one exists and to say // where the other one is. - state.tc->hasImportStd = false; - state.tc->stdModuleSource.clear(); - state.tc->stdCompatSource.clear(); + state.tc->clear_std_modules(); // ── The target's C library, resolved like its compiler ───────── // diff --git a/src/build/prepare/toolchain_env.cpp b/src/build/prepare/toolchain_env.cpp index 13018549..f0fe54ff 100644 --- a/src/build/prepare/toolchain_env.cpp +++ b/src/build/prepare/toolchain_env.cpp @@ -238,16 +238,12 @@ bind_msvc_sysroot(mcpp::toolchain::Toolchain& tc, const bool msvcStl = tc.stdModuleSource.empty() || tc.stdModuleSource.filename() == "std.ixx"; if (msvcStl && std::filesystem::exists(ixx, ec)) { - tc.stdModuleSource = ixx; const auto compat = ixx.parent_path() / "std.compat.ixx"; - tc.stdCompatSource = std::filesystem::exists(compat, ec) - ? compat : std::filesystem::path{}; - tc.hasImportStd = true; + tc.set_std_modules(ixx, std::filesystem::exists(compat, ec) + ? compat : std::filesystem::path{}); tc.importStdMinLevel = msvc::std_module_min_level_for_stl(ixx); } else if (msvcStl && !tc.stdModuleSource.empty()) { - tc.stdModuleSource.clear(); - tc.stdCompatSource.clear(); - tc.hasImportStd = false; + tc.clear_std_modules(); } mcpp::ui::info("Resolved", std::format( diff --git a/src/build/prepare/windows_resources.cpp b/src/build/prepare/windows_resources.cpp new file mode 100644 index 00000000..399cf0d7 --- /dev/null +++ b/src/build/prepare/windows_resources.cpp @@ -0,0 +1,539 @@ +// windows_resources.cpp -- P13: the Windows resources of a plan's images +// (mcpp#365): author scripts, the synthesised version block and icon, and the +// application manifest of `windows_code_page`. Split from plan.cpp at a phase +// step boundary (2026.10.5.2). Declared in `:state`. + +module mcpp.build.prepare; +import :state; +import mcpp.build.prepare_inputs; +import std; +import mcpp.diag; +import mcpp.build.stage; +import mcpp.build.refusal; +import mcpp.build.version_floor; +import mcpp.home; +import mcpp.platform.axis; +import mcpp.libs.json; +import mcpp.log; +import mcpp.manifest; +import mcpp.source_kind; +import mcpp.toolchain.clang; +import mcpp.toolchain.hostflags; // the compile-token producer the package std module reuses +import mcpp.toolchain.cppfly; +import mcpp.toolchain.detect; +import mcpp.toolchain.dialect; +import mcpp.toolchain.model; // is_msvc_target — the MSVC-ABI default (#718) +import mcpp.toolchain.fingerprint; +import mcpp.toolchain.registry; +import mcpp.toolchain.linkmodel; +import mcpp.toolchain.lifecycle; +import mcpp.toolchain.stdmod; +import mcpp.toolchain.post_install; +import mcpp.toolchain.abi; +import mcpp.toolchain.triple; +import mcpp.build.linkage_form; // #519 — which form each dependency takes +import mcpp.build.plan; +import mcpp.build.schedule.policy; +import mcpp.build.flags; // compute_flags — the per-role contracts (#418) +import mcpp.build.distribution; // dist::Role / dist::Contract to_string +import mcpp.platform.capacity; // the host fallback handed to schedule::decide +import mcpp.build.graph_shape; // #407: the graph says which mode wrote it +import mcpp.build.runtime_validation; // declared artifact -> identity verdict +import mcpp.build.cache_key; +import mcpp.graph; +import mcpp.pack.abi_tag; // the tag a prebuilt dependency is checked against +import mcpp.pack.prebuilt; // …and the check itself +import mcpp.pack.stage_tree; // where `${mcpp.stage_dir}` points, and its manifest +import mcpp.build.build_program; +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; +import mcpp.config; +import mcpp.xlings; +import mcpp.runtime.binding; +import mcpp.platform.runtime_search; +import mcpp.toolchain.post_install; +import mcpp.platform; +import mcpp.build.runner_lookup; +import mcpp.fetcher; +import mcpp.fetcher.progress; +import mcpp.pm.resolver; +import mcpp.pm.index_spec; +import mcpp.pm.index_contract; +import mcpp.pm.index_route; +import mcpp.pm.index_refresh; +import mcpp.pm.mangle; +import mcpp.pm.compat; +import mcpp.pm.dep_spec; +import mcpp.pm.dependency_selector; +import mcpp.pm.lock_io; +import mcpp.version_req; +import mcpp.ui; +import mcpp.log; +import mcpp.bmi_cache; + +namespace mcpp::build { + +// The resource compiler of the plan's resource units, and each unit's flags. +static std::expected +step13_resource_compiler(PrepareState& state, BuildContext& ctx) { + if (ctx.plan.resourceUnits.empty()) return {}; + namespace rsrc = mcpp::build::resources; + const auto trip = mcpp::toolchain::triple::parse(state.tc->targetTriple) + .value_or(mcpp::toolchain::triple::host_triple()); + const auto dialectId = mcpp::toolchain::dialect_for(*state.tc).id; + const bool msvcStyle = (dialectId == "msvc"); + + // 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(*state.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(), state.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. + // + // Include search: the script's package 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. Each unit carries the + // flags of its package; the plan's flags are the first unit's, so a + // plan with one package states them once. + const std::string ip = msvcStyle ? "/I" : "-I"; + std::vector systemIncludes; + if (msvcStyle && tool->name().find("llvm-rc") != std::string::npos) { + for (auto const& ev : state.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)) + systemIncludes.push_back(ip + std::string(dir)); + } + } + for (auto& ru : ctx.plan.resourceUnits) { + ru.flags.push_back(msvcStyle ? "/C" : "--codepage=65001"); + if (msvcStyle) ru.flags.push_back("65001"); + for (auto const& d : ru.includeDirs) ru.flags.push_back(ip + d.string()); + ru.flags.insert(ru.flags.end(), systemIncludes.begin(), systemIncludes.end()); + } + ctx.plan.rcFlags = ctx.plan.resourceUnits.front().flags; + return {}; +} + +std::expected step13_windows_resources(PrepareState& state, BuildContext& ctx) { + // ─── Windows resources: [resources] → a tracked link input (mcpp#365) ── + // + // Four rules, in this order: + // 1. Only the [resources] of the package being built is read: the root, + // or in a workspace plan each selected member, whose resources reach + // that member's images only. A dependency's version resource would + // fight its consumer's for ordinal 1, and a dependency that produces + // no PE image of its own has nothing to embed into. + // 2. A DECLARED FILE THAT DOES NOT EXIST IS AN ERROR — on EVERY target. + // Whether a path exists is a fact about the working tree, not about + // the target; gating it on is_pe() meant a Linux or macOS CI could not + // see a typo in `icon = …` at all and only the Windows job went red, + // which is the same "find out late" failure the hard error exists to + // remove. Existence is checked everywhere; only COMPILATION is PE-only. + // 3. On a non-PE target nothing is compiled — no units, no warning, + // byte-identical build. This is what makes `cfg(windows)` unnecessary + // (and it could not be used anyway: the conditional channel carries + // BuildInputs only). + // 4. Nothing to embed into (an archive-only package) → say so and stop. + // + // The same pipeline carries the application manifest of `windows_code_page` + // (#693). A PE executable embeds one that makes its process ANSI code page + // UTF-8 when its target says `windows_code_page = "utf-8"`, or, with nothing + // said, when it is built as a host tool (D6): such a tool receives mcpp's + // UTF-8 paths on its command line. `legacy` opts out, and an ordinary target + // that says nothing embeds nothing (M6: the program's encoding is its own). + // + // The host-tool default yields to a manifest the package embeds itself + // through `[resources] files`: both would sit at ordinal 1, the package + // said nothing about code pages, and its own manifest is the one it ships. + // A DECLARED `utf-8` beside such a manifest is refused below instead. + // + // A SUBJECT is one package whose resources are planned: its manifest, the + // directory its paths were written in, and the images it owns. Outside a + // workspace plan the root is the only subject and owns every image that + // is not a dependency's program, which is the historical rule. + struct Subject { + const mcpp::manifest::Manifest* m; + std::filesystem::path dir; + std::string owner; // empty: the root + }; + std::vector subjects; + if (!state.workspacePlan()) { + subjects.push_back({&*state.m, *state.root, {}}); + } else { + for (auto const& pkg : state.packages) + if (pkg.selectedMember) + subjects.push_back({&pkg.manifest, pkg.root, + mcpp::build::qualified_package_name(pkg.manifest)}); + } + const bool hostToolBuild = state.overrides.tool_depth > 0; + const auto trip = mcpp::toolchain::triple::parse(state.tc->targetTriple) + .value_or(mcpp::toolchain::triple::host_triple()); + const auto dialectId = mcpp::toolchain::dialect_for(*state.tc).id; + const bool msvcStyle = (dialectId == "msvc"); + + for (auto const& S : subjects) { + const auto& M = *S.m; + const bool ownManifest = hostToolBuild + && std::ranges::any_of(M.resources.files, [&](const auto& f) { + const auto abs = (f.is_absolute() ? f : (S.dir / f)).lexically_normal(); + return mcpp::build::resources::scan_rc(abs).declaresManifest; + }); + auto codePageOf = [&](const mcpp::manifest::Target& t) -> std::string_view { + if (!t.windowsCodePage.empty()) return t.windowsCodePage; + return (hostToolBuild && t.is_program() && !ownManifest) ? "utf-8" : "legacy"; + }; + // A test program carries the code page `[test] windows_code_page` + // gave it, and nothing by default: the host-tool default is a + // program's. + auto takesCodePage = [](const mcpp::manifest::Target& t) { + return t.is_program() || t.kind == mcpp::manifest::Target::TestBinary; + }; + const bool anyUtf8Image = std::ranges::any_of(M.targets, [&](const auto& t) { + return takesCodePage(t) && codePageOf(t) == "utf-8"; + }); + // Author scripts reach programs and shared libraries, never a test + // program, so only a program's manifest can collide with one. + const bool anyUtf8Program = std::ranges::any_of(M.targets, [&](const auto& t) { + return t.is_program() && codePageOf(t) == "utf-8"; + }); + if (M.resources.declared() || anyUtf8Image) { + namespace rsrc = mcpp::build::resources; + const auto& R = M.resources; + + // Rule 2 — target-independent, so it runs before the is_pe() gate. + auto resolve_declared = [&](const std::filesystem::path& p, + std::string_view key) + -> std::expected + { + // Lexical, not weakly_canonical: canonicalising resolves symlinks, + // and a symlinked source tree would then bake a different path into + // the generated script than the one the user wrote. (Same reason + // mcpp#344 made the cache anchor lexical.) + auto abs = (p.is_absolute() ? p : (S.dir / p)).lexically_normal(); + std::error_code ec; + if (!std::filesystem::is_regular_file(abs, ec)) + return std::unexpected(std::format( + "[resources] {} = \"{}\" does not exist (looked at {}).\n" + " A declared resource is a build input like any other " + "source: mcpp will not quietly ship a binary without it. " + "Remove the key if the resource is not wanted.", + key, p.generic_string(), abs.generic_string())); + return abs; + }; + + std::filesystem::path iconAbs; + if (!R.icon.empty()) { + auto r = resolve_declared(R.icon, "icon"); + if (!r) return std::unexpected(r.error()); + iconAbs = *r; + } + std::vector extraInputs; + for (auto const& e : R.extraInputs) { + auto r = resolve_declared(e, "extra-inputs"); + if (!r) return std::unexpected(r.error()); + extraInputs.push_back(*r); + } + std::vector scriptFiles; + for (auto const& f : R.files) { + auto r = resolve_declared(f, "files"); + if (!r) return std::unexpected(r.error()); + scriptFiles.push_back(*r); + } + + // 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 std::string_view outExt = msvcStyle ? ".res" : ".o"; + // A member's resources are compiled in a directory of its own, so + // two members' scripts and synthesised scripts never share a name. + const auto resRel = S.owner.empty() ? std::filesystem::path("res") + : std::filesystem::path("res") / S.owner; + const auto resDir = ctx.plan.outputDir / resRel; + // Where the resource compiler looks for a script's includes and + // files: the package's directory, then its include_dirs. The + // scan resolves them the same way. + std::vector rcIncludes{S.dir}; + for (auto const& d : M.buildConfig.includeDirs) + rcIncludes.push_back(d.is_absolute() ? d : (S.dir / d)); + std::error_code mkEc; + std::filesystem::create_directories(resDir, mkEc); + + // Which link units embed resources: images, not archives. A `.res` + // inside a static library is dropped by every linker that reads one. + // Test binaries are images too, but deliberately excluded: an icon + // and an OriginalFilename belong to what the project SHIPS, and a + // test executable is not that. (`role = "object"` makes the opposite + // call, for the opposite reason — see its note above.) + // In a workspace plan a member's images are its link units and + // its shared libraries, which are linked with the graph's. + auto owns = [&](const mcpp::build::LinkUnit& lu) { + if (S.owner.empty()) return true; + if (lu.memberOf == S.owner) return true; + return lu.kind == mcpp::build::LinkUnit::SharedLibrary + && std::ranges::any_of(M.targets, [&](const auto& t) { + return t.kind == mcpp::manifest::Target::SharedLibrary + && t.name == lu.targetName; + }); + }; + std::vector peUnits; + for (std::size_t i = 0; i < ctx.plan.linkUnits.size(); ++i) { + auto k = ctx.plan.linkUnits[i].kind; + // A dependency's program (mcpp#711) carries its own package's + // identity, not this one's. + if (!ctx.plan.linkUnits[i].artifactOf.empty()) continue; + if (!owns(ctx.plan.linkUnits[i])) continue; + if (k == mcpp::build::LinkUnit::Binary || + k == mcpp::build::LinkUnit::SharedLibrary) + peUnits.push_back(i); + } + // Nothing to embed into. Compiling the scripts anyway would leave + // orphan edges nothing depends on, and demanding a resource + // compiler for them would fail a build that has no use for one. + // A degradation, not a warning: the user asked for something and + // got nothing, so `--strict` should see it. + const bool utf8Tests = std::ranges::any_of(ctx.plan.linkUnits, [&](const auto& lu) { + return lu.kind == mcpp::build::LinkUnit::TestBinary && owns(lu) + && std::ranges::any_of(M.targets, [&](const auto& t) { + return t.name == lu.targetName && takesCodePage(t) + && codePageOf(t) == "utf-8"; + }); + }); + if (peUnits.empty()) { + if (R.declared()) + mcpp::diag::degraded("resources/no-image", std::format( + "[resources] is declared but '{}' produces no executable or " + "shared library for {}", M.package.name, trip.str()), + "nothing embeds the icon or the version metadata", + "add a [targets.] with kind = \"bin\" or \"shared\", " + "or drop the [resources] section"); + // A test program's code page still has an image to go to. + if (!utf8Tests) return {}; + } + + // Two scripts with the same stem in different directories would + // otherwise write the same artifact — a silent "multiple rules + // generate" that ninja reports far from the cause. + std::set usedStems; + auto add_unit = [&](const std::filesystem::path& src, + std::string_view stem, + std::vector inputs, + std::size_t attachTo) + -> std::expected + { + if (!usedStems.insert(std::string(stem)).second) + return std::unexpected(std::format( + "[resources] two resource scripts are named '{}.rc'; " + "they would produce the same artifact. Rename one.", stem)); + mcpp::build::ResourceUnit ru; + ru.package = mcpp::build::qualified_package_name(M); + ru.source = src; + ru.output = resRel / (std::string(stem) + std::string(outExt)); + ru.includeDirs = rcIncludes; + ru.implicitInputs = std::move(inputs); + ctx.plan.resourceUnits.push_back(std::move(ru)); + const auto& out = ctx.plan.resourceUnits.back().output; + if (attachTo == static_cast(-1)) { + for (auto i : peUnits) ctx.plan.linkUnits[i].objects.push_back(out); + } else { + ctx.plan.linkUnits[attachTo].objects.push_back(out); + } + return {}; + }; + + // Author-written scripts: compiled once, linked into every image. + for (auto const& rcSrc : peUnits.empty() ? decltype(scriptFiles){} : scriptFiles) { + auto scan = rsrc::scan_rc(rcSrc, rcIncludes); + if (scan.versionInfoNamedByString) { + // The mcpp#365 silent failure, caught on the way in. A + // degradation rather than a warning: the impact is exactly + // the thing this feature exists to remove — a shipped binary + // whose version metadata Windows cannot read — so a build + // that asked for `--strict` must not pass over it. + mcpp::diag::degraded("resources/versioninfo", std::format( + "{}: `{} VERSIONINFO` names the version resource '{}' " + "instead of ordinal 1", + rcSrc.filename().generic_string(), scan.versionInfoName, + scan.versionInfoName), + "Windows will not find it — GetFileVersionInfo looks up " + "MAKEINTRESOURCE(1) and every field comes back empty, " + "while every tool that prints the resource TYPE still " + "says it is fine", + "VS_VERSION_INFO is a macro from ; add " + "`#include ` to the script, or write " + "`1 VERSIONINFO`"); + } + for (auto const& g : scan.gaps) { + mcpp::diag::degraded("resources/inputs", + std::format("{}: `{}` names its file through a macro, so " + "mcpp cannot track it", + rcSrc.filename().generic_string(), g), + "editing that file will not trigger a rebuild", + "list it in [resources] extra-inputs = [...]"); + } + if (scan.declaresManifest && anyUtf8Program) + return std::unexpected(std::format( + "[resources] {} embeds an application manifest, and " + "`windows_code_page = \"utf-8\"` embeds another at the same " + "ordinal (1).\n Keep one: add `" + "UTF-8` to your manifest and set " + "`windows_code_page = \"legacy\"`, or drop your manifest.", + rcSrc.filename().generic_string())); + auto inputs = std::move(scan.inputs); + inputs.insert(inputs.end(), extraInputs.begin(), extraInputs.end()); + if (auto a = add_unit(rcSrc, rcSrc.stem().string(), + std::move(inputs), + static_cast(-1)); !a) + return std::unexpected(a.error()); + } + + // The synthesised script: per image, because OriginalFilename and + // the version block belong to a specific artifact, and the + // manifest to a specific executable. + const bool synthVersion = R.declared() && R.synthesize_version_info(); + auto wantsUtf8 = [&](const mcpp::build::LinkUnit& lu) { + if (lu.kind != mcpp::build::LinkUnit::Binary + && lu.kind != mcpp::build::LinkUnit::TestBinary) return false; + if (!lu.artifactOf.empty()) return false; + for (auto const& t : M.targets) + if (t.name == lu.targetName) + return takesCodePage(t) && codePageOf(t) == "utf-8"; + return false; + }; + if (!iconAbs.empty() || synthVersion || anyUtf8Image) { + // 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 (synthVersion && !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"); + } + // The images that receive a synthesised script: every + // program and shared library, and a test program that asked + // for a code page, which receives the manifest and nothing + // else (see `owns` above for why). + std::vector synthUnits = peUnits; + for (std::size_t i = 0; i < ctx.plan.linkUnits.size(); ++i) { + const auto& lu = ctx.plan.linkUnits[i]; + if (lu.kind == mcpp::build::LinkUnit::TestBinary && owns(lu) + && wantsUtf8(lu)) + synthUnits.push_back(i); + } + for (auto i : synthUnits) { + const auto& lu = ctx.plan.linkUnits[i]; + const bool utf8 = wantsUtf8(lu); + const bool testImage = lu.kind == mcpp::build::LinkUnit::TestBinary; + if (!testImage && iconAbs.empty() && !synthVersion && !utf8) continue; + // A test's name is a path (`unit/test_span`) and may + // equal a program's, so its files live under `tests/`. + const std::string imageName = + testImage ? "tests/" + lu.targetName : lu.targetName; + std::filesystem::path manifestAbs; + if (utf8) { + manifestAbs = resDir / (imageName + ".mcpp.manifest"); + std::filesystem::create_directories(manifestAbs.parent_path(), mkEc); + const auto manifestText = rsrc::utf8_code_page_manifest(); + std::string had; + if (std::ifstream in(manifestAbs, std::ios::binary); in) + had.assign(std::istreambuf_iterator(in), {}); + if (had != manifestText) { + std::ofstream os(manifestAbs, std::ios::binary); + if (!os) return std::unexpected(std::format( + "cannot write the application manifest '{}'", + manifestAbs.string())); + os << manifestText; + } + } + // A script synthesised for the manifest alone carries + // nothing else: a package that declares no [resources] + // asked for no version resource. + mcpp::manifest::Resources forScript = R; + if (!synthVersion || testImage) forScript.versionInfo = false; + auto text = rsrc::synthesize_rc( + M.package, forScript, lu.output.filename().string(), + testImage ? std::filesystem::path{} : iconAbs, manifestAbs); + 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 / (imageName + ".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() && !testImage) inputs.push_back(iconAbs); + if (!manifestAbs.empty()) inputs.push_back(manifestAbs); + inputs.insert(inputs.end(), extraInputs.begin(), extraInputs.end()); + if (auto a = add_unit(rcPath, imageName + ".mcpp", + std::move(inputs), i); !a) + return std::unexpected(a.error()); + } + } + + return {}; + }; + + if (trip.is_pe()) + if (auto r = plan_resources(); !r) return std::unexpected(r.error()); + } + } + + return step13_resource_compiler(state, ctx); +} + +// The member path (relative to the workspace root) of a package root, when the +// root is a member of the workspace this build runs in; empty otherwise. +// Read by W3 (a member's non-public modules). +std::string workspace_member_of(const PrepareState& state, const std::filesystem::path& root) { + if (!state.wsManifest || state.runtimeWorkspaceRoot.empty()) return {}; + const auto rel = root.lexically_normal() + .lexically_relative(state.runtimeWorkspaceRoot.lexically_normal()) + .generic_string(); + if (rel.empty() || rel == "." || rel.starts_with("..")) return {}; + for (auto const& m : state.wsManifest->workspace.members) { + if (m == rel) return rel; + if (m.ends_with("/*") && rel.starts_with(m.substr(0, m.size() - 1)) + && rel.find('/', m.size() - 1) == std::string::npos) + return rel; + } + return {}; +} + +} // namespace mcpp::build diff --git a/src/build/refusal.cppm b/src/build/refusal.cppm index eb876285..f66303a5 100644 --- a/src/build/refusal.cppm +++ b/src/build/refusal.cppm @@ -171,6 +171,12 @@ enum class Code { PayloadOverride, PayloadRequest, LocalToolchain, + // A PE shared library whose exports are discovered (`windows_auto_export` + // stated `true`, or `/GL` written into its sources' flags) would be + // compiled by cl.exe into `/GL` objects, which hold no symbol table to + // discover them from (2026.10.5.2). Distinct from every other code: both + // statements are the author's, and one of them has to change. + LtoExportDiscovery, Other, // a refusal that has not been given a code yet }; @@ -223,6 +229,7 @@ constexpr std::string_view name(Code c) { case Code::PayloadOverride: return "payload-override"; case Code::PayloadRequest: return "payload-request"; case Code::LocalToolchain: return "local-toolchain"; + case Code::LtoExportDiscovery: return "lto-export-discovery"; case Code::Other: return "other"; } return "other"; diff --git a/src/build/test_targets.cppm b/src/build/test_targets.cppm index 85fa28e3..7b2f8db9 100644 --- a/src/build/test_targets.cppm +++ b/src/build/test_targets.cppm @@ -121,6 +121,7 @@ discover_test_targets(const std::filesystem::path& manifestRoot, target.name = name; target.kind = mcpp::manifest::Target::TestBinary; target.main = mainRelative->string(); + if (packageManifest) target.windowsCodePage = packageManifest->testWindowsCodePage; for (std::size_t i = 0; i < globFlags.size(); ++i) { if (!globHits[i].contains(file)) continue; for (auto const& define : globFlags[i].defines) diff --git a/src/pack/library_pipeline.cppm b/src/pack/library_pipeline.cppm index a59089d1..d12b16cb 100644 --- a/src/pack/library_pipeline.cppm +++ b/src/pack/library_pipeline.cppm @@ -158,6 +158,7 @@ export int build_and_pack_library(const std::string& targetName, // rather than the interactive "dev". `[build] default-profile` still // decides when the project states one — see resolve_profile_name. ov.profile = opts.profile; + ov.no_lto_in_archives = true; ov.profile_fallback = "release"; ov.features = opts.features; auto ctx = mcpp::build::prepare_build(false, /*includeDevDeps=*/false, {}, ov); diff --git a/src/project.cppm b/src/project.cppm index 7fd73474..0906b2d1 100644 --- a/src/project.cppm +++ b/src/project.cppm @@ -272,9 +272,10 @@ export void inherit_workspace_xlings(mcpp::manifest::Manifest& member, } // The keys a member inherits only where it is the ROOT of a build: `[toolchain]`, -// `[target.]` and `[indices]`. They choose the compiler, the target -// rows and the indices for the whole graph, so a member reached as somebody's -// dependency takes them from that build's root instead. A member built as a +// `[target.]`, `[indices]` and `[profile.]`. They choose the +// compiler, the target rows, the indices and the build profile for the whole +// graph, so a member reached as somebody's dependency takes them from that +// build's root instead. A member built as a // host tool is the root of its own sub-build, which is the second caller // (#710): without it, `mcpp build -p tool` used the workspace's compiler and // the same tool built for a consumer used the global default. @@ -286,9 +287,34 @@ export void inherit_workspace_root_position(mcpp::manifest::Manifest& member, for (auto& [triple, entry] : workspace.targetOverrides) if (!member.targetOverrides.contains(triple)) member.targetOverrides[triple] = entry; + // A profile is inherited by name, and a member's own table of that name + // replaces it whole, as `[target.]` does. Before 2026.10.5.2 the + // workspace's profiles reached no member: a virtual root's were ignored + // without a word, and a rooted root's applied only when its own package + // was the first one selected. + for (auto& [name, profile] : workspace.profiles) + if (!member.profiles.contains(name)) + member.profiles[name] = profile; inherit_workspace_indices(member, workspace, wsRoot); } +// THE ROOT PACKAGE OF A WORKSPACE IS ONE OF ITS MEMBERS. +// +// A root that carries `[package]` beside `[workspace]` is selected as member +// `"."`, and it receives `[workspace.package]` and `[workspace.build]` once, +// as every other member does, so that one manifest compiles the same way in +// every selection. Its root-position keys and `[workspace.dependencies]` are +// handled by the caller: they are its own already, or merged explicitly. +export void inherit_as_root_package(mcpp::manifest::Manifest& root, + const std::filesystem::path& wsRoot) { + if (!root.workspace.present || root.package.name.empty()) return; + if (root.inheritedAsRootPackage) return; + root.inheritedAsRootPackage = true; + const auto workspace = root; + inherit_workspace_package(root, workspace); + inherit_workspace_build(root, workspace, wsRoot); +} + export void inherit_workspace_config(mcpp::manifest::Manifest& member, const mcpp::manifest::Manifest& workspace, const std::filesystem::path& wsRoot) { @@ -457,9 +483,9 @@ unresolved_workspace_dependency_error(const mcpp::manifest::Manifest& m, // The rule is the one `prepare_build` follows: a directory that its workspace // lists as a member is loaded with `insideWorkspace` and receives // `inherit_workspace_config` anchored at the workspace root, and the -// required-field check runs after inheritance. A directory that is not a -// member, including a workspace root that carries its own `[package]`, is -// loaded as written. +// required-field check runs after inheritance. A workspace root that carries +// its own `[package]` receives the same inheritance as member `"."` +// (`inherit_as_root_package`). Any other directory is loaded as written. export struct EffectiveManifest { mcpp::manifest::Manifest manifest; // after inheritance std::optional workspace; // set when `member` @@ -474,6 +500,7 @@ load_effective_manifest(const std::filesystem::path& dir) { if (wsRoot.empty()) { auto m = mcpp::manifest::load(manifestPath); if (!m) return std::unexpected(m.error().format()); + inherit_as_root_package(*m, std::filesystem::absolute(dir)); return EffectiveManifest{ std::move(*m), std::nullopt, {}, false }; } auto m = mcpp::manifest::load(manifestPath, {.insideWorkspace = true}); @@ -685,6 +712,7 @@ load_member_manifest(const mcpp::manifest::Manifest& workspace, if (memberPath == ".") { auto m = workspace; merge_workspace_deps(m, workspace, wsRoot); + inherit_as_root_package(m, wsRoot); return m; } if (!std::filesystem::exists(dir / "mcpp.toml")) diff --git a/src/toolchain/clang.cppm b/src/toolchain/clang.cppm index c79a5539..cbd849d0 100644 --- a/src/toolchain/clang.cppm +++ b/src/toolchain/clang.cppm @@ -198,8 +198,8 @@ void enrich_toolchain(Toolchain& tc, const std::string& envPrefix) { tc.binaryPath, tc.targetTriple); if (auto p = find_libcxx_std_module_source(tc.binaryPath, envPrefix)) { - tc.stdModuleSource = *p; - tc.hasImportStd = true; + tc.set_std_modules(*p, find_libcxx_std_compat_source(tc.binaryPath, envPrefix) + .value_or(std::filesystem::path{})); // libc++ documents the std module for C++20 and later, and its // std.cppm carries no __cplusplus guard. Verified on clang 22.1.8 + // libc++ (std and std.compat) at -std=c++20 (design §2.2). @@ -215,8 +215,11 @@ void enrich_toolchain(Toolchain& tc, const std::string& envPrefix) { // as `mcpp toolchain list`. if (!tc.hasImportStd && msvTarget) { if (auto p = mcpp::toolchain::msvc::find_std_module_source()) { - tc.stdModuleSource = *p; - tc.hasImportStd = true; + // The STL's own pair: `std.compat.ixx` beside `std.ixx`. + std::error_code ec; + const auto compat = p->parent_path() / "std.compat.ixx"; + tc.set_std_modules(*p, std::filesystem::exists(compat, ec) + ? compat : std::filesystem::path{}); // This is MSVC STL's std.ixx, so the STL's own C++20 policy // applies rather than libc++'s. `tc.version` is clang's and cannot // answer it -- which is a reason to change the input, not to assume @@ -227,12 +230,6 @@ void enrich_toolchain(Toolchain& tc, const std::string& envPrefix) { } } #endif - - if (tc.hasImportStd) { - if (auto p = find_libcxx_std_compat_source(tc.binaryPath, envPrefix)) { - tc.stdCompatSource = *p; - } - } } std::filesystem::path std_bmi_path(const std::filesystem::path& cacheDir) { @@ -243,6 +240,20 @@ std::filesystem::path staged_std_bmi_path(const std::filesystem::path& outputDir return outputDir / "pcm.cache" / "std.pcm"; } +// THE LANGUAGE OF A STANDARD LIBRARY MODULE SOURCE, FROM ITS NAME, ON EVERY HOST. +// +// MSVC STL names its module sources `.ixx`, which clang does not infer as a +// module interface: given one without `-x c++-module`, `--precompile` treats +// it as linker input, writes nothing and exits 0. It also includes headers in +// the module purview, which clang reports. One answer for `std` and +// `std.compat`, decided by the file and not by the machine running the build; +// a libc++ `.cppm` receives nothing, so its commands are unchanged. +static std::string module_source_language_flags(const std::filesystem::path& source) { + return source.extension() == ".ixx" + ? " -x c++-module -Wno-include-angled-in-module-purview" + : ""; +} + std::vector std_module_build_commands(const Toolchain& tc, const std::filesystem::path& cacheDir, const std::filesystem::path& bmiPath, @@ -312,13 +323,7 @@ std::vector std_module_build_commands(const Toolchain& tc, // -x c++-module is needed for MSVC STL's .ixx files (Clang doesn't // recognize the .ixx extension as a module source by default). auto absBmi = (cacheDir / relBmi).string(); - auto ext = tc.stdModuleSource.extension().string(); - // MSVC STL's std.ixx needs -x c++-module (Clang doesn't recognize .ixx) - // and generates harmless warnings about #include in module purview and - // the reserved 'std' module name — suppress both. - std::string ixxFlags = (ext == ".ixx") - ? " -x c++-module -Wno-include-angled-in-module-purview" - : ""; + std::string ixxFlags = module_source_language_flags(tc.stdModuleSource); // AND THE RESERVED-NAME WARNING UNCONDITIONALLY, WHICH IS WHAT THE OTHER // BRANCH DOES. // @@ -380,7 +385,7 @@ std::vector std_module_build_commands(const Toolchain& tc, #else return { std::format( - "cd {} && {}{} {}{} -Wno-reserved-module-identifier{}{} " + "cd {} && {}{} {}{} -Wno-reserved-module-identifier{}{}{} " "--precompile {} -o {} 2>&1", mcpp::xlings::shq(cacheDir.string()), mcpp::toolchain::compiler_env_prefix(tc), @@ -389,6 +394,7 @@ std::vector std_module_build_commands(const Toolchain& tc, crtToken, sysrootFlag, precompileFlags, + module_source_language_flags(tc.stdModuleSource), mcpp::xlings::shq(tc.stdModuleSource.string()), mcpp::xlings::shq(relBmi)), std::format( @@ -566,11 +572,8 @@ std::vector std_compat_build_commands(const Toolchain& tc, auto absBmi = (cacheDir / relBmi).string(); auto absStdBmi = (cacheDir / relStdBmi).string(); auto absObj = (cacheDir / "std.compat.o").string(); - // MSVC STL ships .ixx, which Clang does not infer as C++ module input. - // Apply the override only when parsing source, never when compiling a BMI. - std::string ixxFlags = (tc.stdCompatSource.extension() == ".ixx") - ? " -x c++-module -Wno-include-angled-in-module-purview" - : ""; + // Applied when parsing the source, never when compiling the BMI. + std::string ixxFlags = module_source_language_flags(tc.stdCompatSource); return { std::format("{}{} {}{} -Wno-reserved-module-identifier{}{}{} " "-fmodule-file=std={} " diff --git a/src/toolchain/gcc.cppm b/src/toolchain/gcc.cppm index 79e38c5f..547f29c7 100644 --- a/src/toolchain/gcc.cppm +++ b/src/toolchain/gcc.cppm @@ -50,6 +50,15 @@ std::vector std_module_build_commands( std::string_view sysrootFlag, std::string_view cppStandardFlag); +// `std.compat` (2026.10.5.2): compiled in the same directory after `std`, whose +// `gcm.cache/std.gcm` it finds there, into `gcm.cache/std.compat.gcm`. +std::filesystem::path std_compat_bmi_path(const std::filesystem::path& cacheDir); +std::vector std_compat_build_commands( + const Toolchain& tc, + const std::filesystem::path& cacheDir, + std::string_view sysrootFlag, + std::string_view cppStandardFlag); + } // namespace mcpp::toolchain::gcc namespace mcpp::toolchain::gcc { @@ -143,8 +152,12 @@ void enrich_toolchain(Toolchain& tc) { tc.stdlibId = "libstdc++"; tc.stdlibVersion = tc.version; if (auto p = find_std_module_source(tc.binaryPath, tc.version)) { - tc.stdModuleSource = *p; - tc.hasImportStd = true; + // libstdc++ installs `bits/std.compat.cc` beside `bits/std.cc` (GCC 15 + // and later), so the pair is read from one directory, as libc++'s is. + std::error_code ec; + const auto compat = p->parent_path() / "std.compat.cc"; + tc.set_std_modules(*p, std::filesystem::exists(compat, ec) + ? compat : std::filesystem::path{}); // libstdc++'s bits/std.cc carries no __cplusplus guard: any GCC that // ships it builds the std module at C++20 too. Verified on gcc 15.1.0 // and 16.1.0 across glibc / musl / mingw-cross targets (design §2.2). @@ -188,6 +201,44 @@ std::filesystem::path staged_std_bmi_path(const std::filesystem::path& outputDir return outputDir / "gcm.cache" / "std.gcm"; } +// One compile of a libstdc++ module source in the cache directory, which is +// where GCC writes and reads `gcm.cache/`. +static std::string module_source_command(const Toolchain& tc, + const std::filesystem::path& cacheDir, + std::string_view sysrootFlag, + std::string_view cppStandardFlag, + const std::filesystem::path& source, + std::string_view object) { + std::string bFlag; + if (auto binutilsBin = binutils_prefix_dir(tc); !binutilsBin.empty()) + bFlag = std::format(" -B{}", mcpp::xlings::shq(binutilsBin.string())); + const char* cd = mcpp::platform::is_windows ? "cd /d" : "cd"; + return std::format( + "{} {} && {}{} {} -fmodules -O2{}{} -c {} -o {} 2>&1", + cd, + mcpp::xlings::shq(cacheDir.string()), + mcpp::toolchain::compiler_env_prefix(tc), + mcpp::xlings::shq(tc.binaryPath.string()), + cppStandardFlag, + sysrootFlag, + bFlag, + mcpp::xlings::shq(source.string()), + object); +} + +std::filesystem::path std_compat_bmi_path(const std::filesystem::path& cacheDir) { + return cacheDir / "gcm.cache" / "std.compat.gcm"; +} + +std::vector std_compat_build_commands( + const Toolchain& tc, + const std::filesystem::path& cacheDir, + std::string_view sysrootFlag, + std::string_view cppStandardFlag) { + return { module_source_command(tc, cacheDir, sysrootFlag, cppStandardFlag, + tc.stdCompatSource, "std.compat.o") }; +} + std::string std_module_build_command(const Toolchain& tc, const std::filesystem::path& cacheDir, std::string_view sysrootFlag, diff --git a/src/toolchain/msvc.cppm b/src/toolchain/msvc.cppm index 3aa2f4a6..932dd9c1 100644 --- a/src/toolchain/msvc.cppm +++ b/src/toolchain/msvc.cppm @@ -1242,10 +1242,14 @@ std::optional find_windows_sdk( // 1. Declared: WindowsSdkDir (+ WindowsSdkVersion). vcvars exports both; // WindowsSdkVersion carries a trailing backslash there, which is not - // part of the directory name. + // part of the directory name, and so does WindowsSdkDir. The separator + // is removed so that one SDK has one spelling, and therefore one cache + // key, inside and outside a developer environment. const std::string want = declared_sdk_version(); if (auto* dir = std::getenv("WindowsSdkDir"); dir && *dir) { - if (auto s = pick_sdk_in(std::filesystem::path{dir}, want)) return s; + std::filesystem::path root{dir}; + if (!root.has_filename() && root.has_relative_path()) root = root.parent_path(); + if (auto s = pick_sdk_in(root, want)) return s; } // 2. Roots the caller knows about (managed toolset's own store). @@ -1640,8 +1644,9 @@ std::expected enrich_toolchain_from_cl(Toolchain& tc) { std::error_code ec; if (auto ixx = toolsDir / "modules" / "std.ixx"; std::filesystem::exists(ixx, ec)) { - tc.stdModuleSource = ixx; - tc.hasImportStd = true; + const auto compat = toolsDir / "modules" / "std.compat.ixx"; + tc.set_std_modules(ixx, std::filesystem::exists(compat, ec) + ? compat : std::filesystem::path{}); } if (tc.hasImportStd) { // The STL, not the banner. For a real cl installation the two agree by @@ -1651,10 +1656,6 @@ std::expected enrich_toolchain_from_cl(Toolchain& tc) { // there the file that will be compiled is the correct answer. tc.importStdMinLevel = std_module_min_level_for_stl(tc.stdModuleSource); } - if (auto compat = toolsDir / "modules" / "std.compat.ixx"; - std::filesystem::exists(compat, ec)) { - tc.stdCompatSource = compat; - } // Build environment (INCLUDE/LIB/PATH/VSLANG). SDK absence keeps // detection working (selection UX on SDK-less boxes); the build path diff --git a/src/toolchain/registry.cppm b/src/toolchain/registry.cppm index 7b17f718..e476835c 100644 --- a/src/toolchain/registry.cppm +++ b/src/toolchain/registry.cppm @@ -1585,6 +1585,8 @@ std::filesystem::path staged_std_compat_bmi_path(const Toolchain& tc, const std::filesystem::path& outputDir) { if (tc.compiler == CompilerId::MSVC) return mcpp::toolchain::msvc::staged_std_compat_bmi_path(outputDir); + if (tc.compiler == CompilerId::GCC) + return outputDir / "gcm.cache" / "std.compat.gcm"; return mcpp::toolchain::clang::staged_std_compat_bmi_path(outputDir); } diff --git a/src/toolchain/stdmod.cppm b/src/toolchain/stdmod.cppm index 84fded32..8e4cf367 100644 --- a/src/toolchain/stdmod.cppm +++ b/src/toolchain/stdmod.cppm @@ -90,16 +90,23 @@ std::expected ensure_built( // other one makes every importing TU fail in the ucrt headers. It also // enters `std_build_commands`, which is part of the cache identity, so two // CRT models cannot share a cache directory. - std::string_view msvc_crt_flag = {}); - -// The derivation ensure_built builds from, with the same parameters. + std::string_view msvc_crt_flag = {}, + // Whether `std.compat` is built (2026.10.5.2): only when a unit imports + // it. The derivation, and so the cache identity and metadata, is the same + // either way, so a later build that does import it adds the BMI to the + // same directory and leaves `std` as it is. + bool want_compat = true); + +// The derivation ensure_built builds from, with the same parameters. Without +// `want_compat` the description names no `std.compat` unit. std::expected describe_std_module( const Toolchain& tc, std::string_view cpp_standard, std::string_view cpp_standard_flag, std::string_view macos_deployment_target = {}, const std::filesystem::path& cache_root = default_cache_root(), - std::string_view msvc_crt_flag = {}); + std::string_view msvc_crt_flag = {}, + bool want_compat = true); } // namespace mcpp::toolchain @@ -361,6 +368,9 @@ std::expected derive_std_module( d.compatCommands = mcpp::toolchain::clang::std_compat_build_commands( tc, cacheDir, compatBmi, d.bmiPath, sysroot_flag, cpp_standard_flag, msvc_crt_flag); + } else { + d.compatCommands = mcpp::toolchain::gcc::std_compat_build_commands( + tc, cacheDir, sysroot_flag, cpp_standard_flag); } } d.metadata = metadata_for(tc, cpp_standard, cpp_standard_flag, @@ -383,7 +393,9 @@ std::expected derive_std_module( if (!desc.compatCommands.empty()) { desc.compatBmiPath = isMsvc ? mcpp::toolchain::msvc::std_compat_bmi_path(desc.cacheDir) - : mcpp::toolchain::clang::std_compat_bmi_path(desc.cacheDir); + : is_clang(tc) + ? mcpp::toolchain::clang::std_compat_bmi_path(desc.cacheDir) + : mcpp::toolchain::gcc::std_compat_bmi_path(desc.cacheDir); desc.compatObjectPath = desc.cacheDir / (isMsvc ? "std.compat.obj" : "std.compat.o"); } @@ -399,11 +411,17 @@ std::expected describe_std_module( std::string_view cpp_standard_flag, std::string_view macos_deployment_target, const std::filesystem::path& cache_root, - std::string_view msvc_crt_flag) + std::string_view msvc_crt_flag, + bool want_compat) { auto d = derive_std_module(tc, cpp_standard, cpp_standard_flag, macos_deployment_target, cache_root, msvc_crt_flag); if (!d) return std::unexpected(d.error()); + if (!want_compat) { + d->description.compatCommands.clear(); + d->description.compatBmiPath.clear(); + d->description.compatObjectPath.clear(); + } return std::move(d->description); } @@ -413,7 +431,8 @@ std::expected ensure_built( std::string_view cpp_standard_flag, std::string_view macos_deployment_target, const std::filesystem::path& cache_root, - std::string_view msvc_crt_flag) + std::string_view msvc_crt_flag, + bool want_compat) { auto derivation = derive_std_module(tc, cpp_standard, cpp_standard_flag, macos_deployment_target, cache_root, @@ -451,8 +470,9 @@ std::expected ensure_built( rebuiltStd = true; } - // Build std.compat after std (std.compat imports std; Clang + MSVC). - if (!compatCommands.empty()) { + // Build std.compat after std (std.compat imports std; Clang + MSVC), and + // only for a build that imports it. + if (want_compat && !compatCommands.empty()) { const auto& compatBmi = desc.compatBmiPath; if (rebuiltStd || !std::filesystem::exists(compatBmi) || !metadata_matches(metaPath, metadata)) { diff --git a/tests/e2e/884_a_shared_library_links_with_its_own_closure.sh b/tests/e2e/884_a_shared_library_links_with_its_own_closure.sh new file mode 100755 index 00000000..a0d6b69a --- /dev/null +++ b/tests/e2e/884_a_shared_library_links_with_its_own_closure.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# requires: elf +# 884 -- mcpp#771: a shared library that is not the root's links with the +# graph's flags and the flags of the packages its owner reaches, and with no +# other package's (2026.10.5.2). +# +# Read from the produced images: every flag carries an `-rpath` marker, and +# `readelf -d` lists the RUNPATH entries that reached each link. +# +# A a workspace member's shared library takes the libraries its own +# `build.mcpp` declares, and no other member's flags; `-p` agrees. +# B a path dependency's shared library outside a workspace takes its own +# flags and the profile's, and not the root's or a sibling's. +# C a search path only the root names no longer reaches a dependency's +# shared library; the build states this once beside the library. +set -e +source "$(dirname "$0")/_host_path.sh" + +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; } +MCPP="${MCPP:-mcpp}" +REGISTRY_HOST=$(host_path "${MCPP_HOME:-$HOME/.mcpp}/registry") +export MCPP_HOME="$TMP/mcpp-home" +mkdir -p "$MCPP_HOME" +cat > "$MCPP_HOME/config.toml" < mcpp.toml +cat > player/mcpp.toml <<'EOF' +[package] +name = "player" +version = "0.1.0" + +[targets.player_dll] +kind = "shared" + +[targets.player] +kind = "bin" +main = "src/main.cpp" +EOF +cat > player/build.mcpp <<'EOF' +import mcpp; +int main() { + mcpp::link_lib("m"); + mcpp::link_flag("-Wl,-rpath,/t884-player-program"); + return 0; +} +EOF +printf 'export module t884_player;\nexport int answer() { return 42; }\n' > player/src/player.cppm +printf 'import t884_player;\nint main() { return answer() == 42 ? 0 : 1; }\n' > player/src/main.cpp +cat > other/mcpp.toml <<'EOF' +[package] +name = "other" +version = "0.1.0" + +[build] +ldflags = ["-Wl,-rpath,/t884-other"] + +[targets.other] +kind = "bin" +main = "src/main.cpp" +EOF +printf 'int main() { return 0; }\n' > other/src/main.cpp + +for selection in "" "-p player"; do + rm -rf target + # shellcheck disable=SC2086 + "$MCPP" build $selection > a.log 2>&1 || fail "A: build ${selection:-of the workspace} failed" a.log + so=$(lib libplayer_dll.so) + [ -n "$so" ] || fail "A: no libplayer_dll.so" a.log + runpath "$so" > a.dyn + grep -q '/t884-player-program' a.dyn \ + || fail "A (${selection:-workspace}): the member's build.mcpp flag did not reach its shared library" a.dyn + if grep -q '/t884-other' a.dyn; then + fail "A (${selection:-workspace}): another member's flag reached the shared library" a.dyn + fi +done + +# ── B and C ──────────────────────────────────────────────────────────────── +mkdir -p "$TMP/b/app/src" "$TMP/b/dep/src" "$TMP/b/sib/src" "$TMP/b/ext" +cd "$TMP/b" +cat > dep/mcpp.toml <<'EOF' +[package] +name = "dep" +version = "0.1.0" + +[build] +ldflags = ["-Wl,-rpath,/t884-dep"] + +[targets.dep] +kind = "shared" +EOF +printf 'export module t884_dep;\nexport int dv() { return 1; }\n' > dep/src/dep.cppm +cat > sib/mcpp.toml <<'EOF' +[package] +name = "sib" +version = "0.1.0" + +[build] +ldflags = ["-Wl,-rpath,/t884-sib"] + +[targets.sib] +kind = "lib" +EOF +printf 'export module t884_sib;\nexport int sv() { return 2; }\n' > sib/src/sib.cppm +cat > app/mcpp.toml <<'EOF' +[package] +name = "app" +version = "0.1.0" + +[build] +ldflags = ["-Wl,-rpath,/t884-root"] + +[profile.release] +ldflags = ["-Wl,-rpath,/t884-profile"] + +[dependencies] +dep = { path = "../dep" } +sib = { path = "../sib" } + +[targets.app] +kind = "bin" +main = "src/main.cpp" +EOF +printf 'import t884_dep;\nimport t884_sib;\nint main() { return dv() + sv() == 3 ? 0 : 1; }\n' > app/src/main.cpp +cd app +"$MCPP" build --release > b.log 2>&1 || fail "B: build failed" b.log +runpath "$(lib libdep.so)" > b.dyn +grep -q '/t884-dep' b.dyn || fail "B: the dependency's own flag did not reach its shared library" b.dyn +grep -q '/t884-profile' b.dyn || fail "B: the profile's flag did not reach the dependency's shared library" b.dyn +for foreign in /t884-root /t884-sib; do + if grep -q "$foreign" b.dyn; then fail "B: $foreign reached the dependency's shared library" b.dyn; fi +done +runpath "$(lib app)" > app.dyn +grep -q '/t884-root' app.dyn || fail "B: the root's own flag did not reach the root's program" app.dyn + +# C: the root names a search path the dependency's library needs. +printf 'int ext_fn(void) { return 1; }\n' > ../ext/ext.c +gcc -shared -fPIC ../ext/ext.c -o ../ext/libt884ext.so 2>/dev/null || cc -shared -fPIC ../ext/ext.c -o ../ext/libt884ext.so +sed -i 's|ldflags = \["-Wl,-rpath,/t884-dep"\]|ldflags = ["-Wl,-rpath,/t884-dep", "-lt884ext"]|' ../dep/mcpp.toml +sed -i "s|ldflags = \\[\"-Wl,-rpath,/t884-root\"\\]|ldflags = [\"-Wl,-rpath,/t884-root\", \"-L$TMP/b/ext\"]|" mcpp.toml +rm -rf target +if "$MCPP" build > c.log 2>&1; then + fail "C: the dependency's library linked with a search path only the root names" c.log +fi +grep -q "no longer reach the shared" c.log || fail "C: the change of rule was not stated" c.log +grep -q -- "-L$TMP/b/ext" c.log || fail "C: the statement does not name the root's search path" c.log +# The dependency stating its own search path is the migration. +sed -i "s|\"-lt884ext\"\\]|\"-L$TMP/b/ext\", \"-Wl,-rpath,$TMP/b/ext\", \"-lt884ext\"]|" ../dep/mcpp.toml +"$MCPP" build > c2.log 2>&1 || fail "C: the dependency's own search path did not fix the link" c2.log + +echo "PASS: 884 a shared library links with its own closure" diff --git a/tests/e2e/885_workspace_profiles_and_the_root_package.sh b/tests/e2e/885_workspace_profiles_and_the_root_package.sh new file mode 100755 index 00000000..c2e1465b --- /dev/null +++ b/tests/e2e/885_workspace_profiles_and_the_root_package.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# requires: unix-shell +# 885 -- the workspace's `[profile.*]` and its own package (2026.10.5.2). +# +# W1 a virtual root's `[profile.release]` reaches every member; it used to +# be ignored without a word. +# W2 a member's own table of a profile name replaces the workspace's. +# W3 in a workspace with its own `[package]`, the root file's profile is the +# same for every selection, so `--workspace` plans one graph and compiles +# the library once, and the root package receives `[workspace.build]` as +# every member does. +# +# Read from build.ninja: every statement carries a `-DMARK_*` word. +set -e +source "$(dirname "$0")/_host_path.sh" + +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; } +MCPP="${MCPP:-mcpp}" +REGISTRY_HOST=$(host_path "${MCPP_HOME:-$HOME/.mcpp}/registry") +export MCPP_HOME="$TMP/mcpp-home" +mkdir -p "$MCPP_HOME" +cat > "$MCPP_HOME/config.toml" < "$1/mcpp.toml" + printf 'export module t885_lib;\nexport int lv() { return 1; }\n' > "$1/src/lib.cppm" +} +app_main() { + mkdir -p "$1/src" + printf 'import t885_lib;\nint main() { return lv() == 1 ? 0 : 1; }\n' > "$1/src/main.cpp" +} + +# ── W1 / W2 ──────────────────────────────────────────────────────────────── +mkdir -p "$TMP/v" && cd "$TMP/v" +{ printf '[workspace]\nmembers = ["app", "lib"]\n\n'; profile WSROOT; } > mcpp.toml +member_lib lib +app_main app +printf '[package]\nname = "app"\nversion = "0.1.0"\n\n[dependencies]\nlib = { path = "../lib" }\n\n[targets.app]\nkind = "bin"\nmain = "src/main.cpp"\n' > app/mcpp.toml +"$MCPP" build --release -p app > w1.log 2>&1 || fail "W1: build failed" w1.log +edge obj/app/src/main > w1.edge +grep -q MARK_PROFILE_WSROOT w1.edge || fail "W1: the workspace's profile did not reach a member" w1.edge + +{ cat app/mcpp.toml; echo; profile APP; } > app/mcpp.toml.new && mv app/mcpp.toml.new app/mcpp.toml +rm -rf target +"$MCPP" build --release -p app > w2.log 2>&1 || fail "W2: build failed" w2.log +edge obj/app/src/main > w2.edge +grep -q MARK_PROFILE_APP w2.edge || fail "W2: the member's own profile was not used" w2.edge +if grep -q MARK_PROFILE_WSROOT w2.edge; then + fail "W2: the workspace's profile was merged into the member's own table" w2.edge +fi + +# ── W3 ───────────────────────────────────────────────────────────────────── +mkdir -p "$TMP/r" && cd "$TMP/r" +member_lib lib +app_main . +{ printf '[package]\nname = "app"\nversion = "0.1.0"\n\n[build]\ncxxflags = ["-DMARK_OWN"]\n\n' + printf '[dependencies]\nlib = { path = "lib" }\n\n[targets.app]\nkind = "bin"\nmain = "src/main.cpp"\n\n' + printf '[workspace]\nmembers = ["lib"]\n\n[workspace.build]\ncxxflags = ["-DMARK_WS_BUILD"]\n\n' + profile ROOTFILE; } > mcpp.toml +for selection in "" "--workspace"; do + rm -rf target + # shellcheck disable=SC2086 + "$MCPP" build --release $selection > w3.log 2>&1 || fail "W3: build ${selection:-of the root} failed" w3.log + [ "$(graphs)" = 1 ] || fail "W3 (${selection:-root}): $(graphs) graphs, expected one" w3.log + edge obj/app/src/main > w3.edge + grep -q MARK_WS_BUILD w3.edge || fail "W3 (${selection:-root}): the root package did not receive [workspace.build]" w3.edge + grep -q MARK_OWN w3.edge || fail "W3 (${selection:-root}): the root package lost its own [build]" w3.edge + awk '{ if (gsub(/MARK_WS_BUILD/, "") > 1) bad = 1 } END { exit bad }' w3.edge \ + || fail "W3 (${selection:-root}): [workspace.build] was applied more than once" w3.edge +done +rm -rf target +"$MCPP" build --release -p lib > w3p.log 2>&1 || fail "W3: build -p lib failed" w3p.log +grep -rq MARK_PROFILE_ROOTFILE target/*/*/build.ninja \ + || fail "W3: the root file's profile did not reach a member built on its own" w3p.log + +echo "PASS: 885 workspace profiles and the root package" diff --git a/tests/e2e/886_std_compat_is_built_when_imported.sh b/tests/e2e/886_std_compat_is_built_when_imported.sh new file mode 100755 index 00000000..c05d34ae --- /dev/null +++ b/tests/e2e/886_std_compat_is_built_when_imported.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# requires: gcc +# 886 -- `std.compat` on the GCC row, and only when a unit imports it +# (2026.10.5.2). +# +# libstdc++ installs `bits/std.compat.cc` beside `bits/std.cc`. The GCC row +# did not build it, so `import std.compat` failed in the first unit with +# "returning to the gate for a mechanical issue", which SPEC-009 §6.2's +# acceptance of a Default row does not allow. And every row that does build it +# built it for every `import std`, so a library whose `std.compat` failed to +# compile cost every project that never imported it. +set -e +source "$(dirname "$0")/_host_path.sh" + +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; } +MCPP="${MCPP:-mcpp}" +REGISTRY_HOST=$(host_path "${MCPP_HOME:-$HOME/.mcpp}/registry") +export MCPP_HOME="$TMP/mcpp-home" +mkdir -p "$MCPP_HOME" +cat > "$MCPP_HOME/config.toml" < "$TMP/only/mcpp.toml" +printf 'import std;\nint main() { std::println("std"); }\n' > "$TMP/only/src/main.cpp" +printf '[package]\nname = "compat"\nversion = "0.1.0"\n\n[toolchain]\ndefault = "gcc@16.1.0"\n' > "$TMP/compat/mcpp.toml" +cat > "$TMP/compat/src/main.cpp" <<'EOF' +import std.compat; +int main() { + auto* memory = ::malloc(32); + if (!memory) return 1; + ::free(memory); + std::vector values{1, 2, 3}; + ::printf("compat=%zu\n", values.size()); + return values.size() == 3 ? 0 : 1; +} +EOF + +cd "$TMP/only" +"$MCPP" run > only.log 2>&1 || fail "import std failed" only.log +grep -q '^std$' only.log || fail "the std program did not run" only.log +[ "$(compat_files)" = 0 ] || fail "std.compat was built for a build that does not import it" only.log + +cd "$TMP/compat" +"$MCPP" run > compat.log 2>&1 || fail "import std.compat failed on the GCC row" compat.log +grep -q 'compat=3' compat.log || fail "the std.compat program did not run" compat.log +[ "$(compat_files)" != 0 ] || fail "std.compat was not built in the cache" compat.log + +echo "PASS: 886 std.compat is built when imported" diff --git a/tests/e2e/887_msvc_lto_and_export_discovery.sh b/tests/e2e/887_msvc_lto_and_export_discovery.sh new file mode 100755 index 00000000..17b92c82 --- /dev/null +++ b/tests/e2e/887_msvc_lto_and_export_discovery.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# requires: msvc +# 887 -- `lto = true` on cl.exe, and its meeting with export discovery +# (2026.10.5.2, #770). +# +# A a program: `/GL` on the compile, `/LTCG` on the link, `+ lto`. +# B a DLL whose exports are discovered (key omitted): its packages compile +# with `/GL-`, the build says so once, the summary says `+ lto (partial)`, +# and a consumer links against the discovered exports. +# C the same DLL stating `windows_auto_export = true`: refused. +# D stating `false` with `__declspec(dllexport)`: full `/GL`, and it links. +# E `/GL` written into the DLL's flags with discovery on: refused. +# F `/bigobj` with discovery on: read, and the consumer links. +set -e +source "$(dirname "$0")/_host_path.sh" + +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; } +MCPP="${MCPP:-mcpp}" +REGISTRY_HOST=$(host_path "${MCPP_HOME:-$HOME/.mcpp}/registry") +export MCPP_HOME="$TMP/home" +mkdir -p "$MCPP_HOME" +cat > "$MCPP_HOME/config.toml" < a/mcpp.toml <<'EOF' +[package] +name = "a" +version = "0.1.0" +[toolchain] +windows = "msvc@system" +[profile.release] +lto = true +EOF +printf 'int main() { return 0; }\n' > a/src/main.cpp +(cd a && "$MCPP" build --release > ../a.log 2>&1) || fail "A: build failed" a.log +grep -q '+ lto\]' a.log || fail "A: the summary does not say + lto" a.log +(cd a && grep -q '/GL' "$(ninja_of)" && grep -q '/LTCG' "$(ninja_of)") \ + || fail "A: /GL or /LTCG is missing from the build" a.log + +# ── the DLL and its consumer, for B to F ─────────────────────────────────── +write_dll() { # $1 = extra [targets.dll] lines, $2 = extra [build] lines, $3 = source + mkdir -p dll/src app/src + cat > dll/mcpp.toml < dll/src/dll.cpp + cat > app/mcpp.toml <<'EOF' +[package] +name = "app" +version = "0.1.0" +[toolchain] +windows = "msvc@system" +[profile.release] +lto = true +[dependencies] +dll = { path = "../dll" } +[targets.app] +kind = "bin" +main = "src/main.cpp" +EOF + printf 'int twice(int);\nint main() { return twice(21) == 42 ? 0 : 1; }\n' > app/src/main.cpp + rm -rf app/target +} +PLAIN='int twice(int x) { return 2 * x; }' +ANNOTATED='__declspec(dllexport) int twice(int x) { return 2 * x; }' + +# B +write_dll "" "" "$PLAIN" +(cd app && "$MCPP" run --release > ../b.log 2>&1) || fail "B: the consumer of a discovered DLL did not link or run" b.log +grep -q 'LTO is not applied to the packages linked into the shared libraries' b.log \ + || fail "B: the downgrade was not stated" b.log +grep -q 'lto (partial)' b.log || fail "B: the summary does not say + lto (partial)" b.log +(cd app && grep -q '/GL-' "$(ninja_of)") || fail "B: the DLL's package was not compiled with /GL-" b.log + +# C +write_dll "windows_auto_export = true" "" "$PLAIN" +if (cd app && "$MCPP" build --release > ../c.log 2>&1); then fail "C: a stated windows_auto_export = true was not refused under LTO" c.log; fi +grep -q 'states `windows_auto_export = true`' c.log || fail "C: the refusal does not name the key" c.log + +# D +write_dll "windows_auto_export = false" "" "$ANNOTATED" +(cd app && "$MCPP" run --release > ../d.log 2>&1) || fail "D: an annotated DLL under full LTO did not link or run" d.log +(cd app && ! grep -q '/GL-' "$(ninja_of)") || fail "D: a DLL that opted out of discovery was compiled with /GL-" d.log + +# E +write_dll "" 'cxxflags = ["/GL"]' "$PLAIN" +sed -i 's/^lto = true/lto = false/' app/mcpp.toml +if (cd app && "$MCPP" build > ../e.log 2>&1); then fail "E: /GL beside export discovery was not refused" e.log; fi +grep -q 'compiles with /GL' e.log || fail "E: the refusal does not name /GL" e.log + +# F +write_dll "" 'cxxflags = ["/bigobj"]' "$PLAIN" +sed -i 's/^lto = true/lto = false/' app/mcpp.toml +(cd app && "$MCPP" run > ../f.log 2>&1) || fail "F: a /bigobj DLL's discovered exports did not link" f.log + +echo "PASS: 887 msvc lto and export discovery" diff --git a/tests/e2e/888_the_llvm_row_in_a_developer_environment.sh b/tests/e2e/888_the_llvm_row_in_a_developer_environment.sh new file mode 100755 index 00000000..787b16ae --- /dev/null +++ b/tests/e2e/888_the_llvm_row_in_a_developer_environment.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# requires: windows +# 888 -- the LLVM row on the MSVC ABI in a Visual Studio developer +# environment, and `import std.compat` with the MSVC STL (2026.10.5.2). +# +# vcvars exports `WindowsSdkDir` with a trailing backslash. Rendered as +# `"…\Windows Kits\10\"`, the argument's closing quote was escaped under the +# Windows argv rules, every following argument shifted, and the std module +# precompile received `Files\Microsoft`, `Visual` and the rest of the command +# as inputs (measured on windows-latest). The same build outside a developer +# environment succeeded, which is why no runner saw it. +# +# Read only when the runner's default toolchain is the llvm row, as 703 is. +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; } +MCPP="${MCPP:-mcpp}" + +SDK_POSIX="/c/Program Files (x86)/Windows Kits/10" +if [ ! -d "$SDK_POSIX/Include" ]; then + echo "PASS: 888 (no Windows SDK at the conventional root; nothing to assert)" + exit 0 +fi +export WindowsSdkDir='C:\Program Files (x86)\Windows Kits\10\' + +cd "$TMP" +mkdir -p std/src compat/src +printf '[package]\nname = "s"\nversion = "0.1.0"\nstandard = "c++23"\n' > std/mcpp.toml +printf 'import std;\nint main() { std::println("std"); }\n' > std/src/main.cpp +printf '[package]\nname = "c"\nversion = "0.1.0"\nstandard = "c++23"\n' > compat/mcpp.toml +cat > compat/src/main.cpp <<'EOF' +import std.compat; +int main() { + auto* memory = ::malloc(32); + if (!memory) return 1; + ::free(memory); + std::vector values{1, 2, 3}; + ::printf("compat=%zu\n", values.size()); + return values.size() == 3 ? 0 : 1; +} +EOF + +(cd std && "$MCPP" run > ../std.log 2>&1) || fail "import std failed with WindowsSdkDir='$WindowsSdkDir'" std.log +if ! grep -q "Resolved llvm@" std.log; then + echo "PASS: 888 (the default toolchain here is not the llvm row: $(grep -m1 'Resolved' std.log))" + exit 0 +fi +grep -q '^std' std.log || fail "the std program did not run" std.log +(cd compat && "$MCPP" run > ../compat.log 2>&1) || fail "import std.compat failed on the llvm row" compat.log +grep -q 'compat=3' compat.log || fail "the std.compat program did not run" compat.log + +echo "PASS: 888 the llvm row in a developer environment" diff --git a/tests/unit/test_coff_exports.cpp b/tests/unit/test_coff_exports.cpp index 7694b69a..f886cd6d 100644 --- a/tests/unit/test_coff_exports.cpp +++ b/tests/unit/test_coff_exports.cpp @@ -214,19 +214,99 @@ TEST(CoffExports, RefusesATruncatedSymbolTable) { EXPECT_NE(r.error().find("past the end"), std::string::npos); } -TEST(CoffExports, NamesBigobjRatherThanCallingItAnUnknownMachine) { - // A `/bigobj` object is a different container: machine 0 and 0xFFFF where - // the section count would be. Falling through to "unsupported machine - // 0x0000" is true and useless — it blames the reader for a flag the project - // passed. - auto obj = make_obj({ Sym{ .name = "f" } }); - obj[0] = std::byte{0}; obj[1] = std::byte{0}; - obj[2] = std::byte{0xFF}; obj[3] = std::byte{0xFF}; - auto r = read_exports(obj); +// ─── anonymous objects (2026.10.5.2) ──────────────────────────────────────── +// +// Machine 0 and 0xFFFF open three containers, told apart by a version and a +// class GUID. The headers below are the ones cl 19.51 writes (measured on +// windows-latest, 2026-10-05). + +namespace { + +constexpr std::array kBigObj{ + 0xc7, 0xa1, 0xba, 0xd1, 0xee, 0xba, 0xa9, 0x4b, + 0xaf, 0x20, 0xfa, 0xf6, 0x6a, 0xa4, 0xdc, 0xb8}; +constexpr std::array kClGl{ + 0x38, 0xfe, 0xb3, 0x0c, 0xa5, 0xd9, 0xab, 0x4d, + 0xac, 0x9b, 0xd6, 0xb6, 0x22, 0x26, 0x53, 0xc2}; + +std::vector anonymous(std::uint16_t version, + const std::array& classId, + std::size_t size = 64) { + std::vector b(size, std::byte{0}); + put16(b, 2, 0xFFFF); + put16(b, 4, version); + put16(b, 6, 0x8664); + for (std::size_t i = 0; i < 16; ++i) b[12 + i] = std::byte(classId[i]); + return b; +} + +// The `/bigobj` form of `make_obj`: a 56-byte header, 32-bit counts and +// section numbers, 20-byte symbol records. +std::vector make_bigobj(std::vector syms, + std::vector sectionFlags = { 0x60000020u }) { + constexpr std::size_t kHdr = 56, kSec = 40, kSym = 20; + const std::size_t nsec = sectionFlags.size(); + const std::size_t symOff = kHdr + nsec * kSec; + auto b = anonymous(2, kBigObj, symOff + syms.size() * kSym + 4); + put32(b, 44, static_cast(nsec)); + put32(b, 48, static_cast(symOff)); + put32(b, 52, static_cast(syms.size())); + for (std::size_t i = 0; i < nsec; ++i) + put32(b, kHdr + i * kSec + 36, sectionFlags[i]); + for (std::size_t i = 0; i < syms.size(); ++i) { + const auto rec = symOff + i * kSym; + for (std::size_t c = 0; c < syms[i].name.size() && c < 8; ++c) + b[rec + c] = std::byte(syms[i].name[c]); + put32(b, rec + 12, static_cast(static_cast(syms[i].section))); + put16(b, rec + 16, syms[i].type); + b[rec + 18] = std::byte(syms[i].storage); + b[rec + 19] = std::byte(syms[i].aux); + } + put32(b, b.size() - 4, 4); + return b; +} + +} // namespace + +TEST(CoffExports, ReadsABigobjObjectAsTheOrdinaryOneWithTheSameSymbols) { + const std::vector syms = { + Sym{ .name = "f" }, Sym{ .name = "v", .section = 2, .type = 0 }, + Sym{ .name = "s", .storage = 3 }, Sym{ .name = "u", .section = 0 }, + Sym{ .name = "g", .aux = 1 }, Sym{ .name = "aux" }, Sym{ .name = "h" }, + }; + auto small = read_exports(make_obj(syms, { kText, kData })); + auto big = read_exports(make_bigobj(syms, { kText, kData })); + ASSERT_TRUE(small) << small.error(); + ASSERT_TRUE(big) << big.error(); + EXPECT_EQ(*big, *small); + EXPECT_EQ(names(*big), (std::vector{ "f", "g", "h", "v" })); +} + +TEST(CoffExports, NamesAGlObjectAndSaysWhatToDo) { + auto r = read_exports(anonymous(1, kClGl)); ASSERT_FALSE(r); - EXPECT_NE(r.error().find("bigobj"), std::string::npos); - // And it says what to do, or it is a dead end. + EXPECT_NE(r.error().find("/GL"), std::string::npos) << r.error(); + EXPECT_EQ(r.error().find("bigobj"), std::string::npos) << r.error(); + EXPECT_NE(r.error().find("windows_auto_export = false"), std::string::npos); EXPECT_NE(r.error().find("dllexport"), std::string::npos); + EXPECT_FALSE(declares_exports(anonymous(1, kClGl))); +} + +TEST(CoffExports, NamesAnImportObjectAndAnUnknownAnonymousObject) { + auto imp = read_exports(anonymous(0, {})); + ASSERT_FALSE(imp); + EXPECT_NE(imp.error().find("import object"), std::string::npos) << imp.error(); + auto other = read_exports(anonymous(3, {})); + ASSERT_FALSE(other); + EXPECT_NE(other.error().find("anonymous"), std::string::npos) << other.error(); +} + +TEST(CoffExports, RefusesABigobjObjectWhoseCountsOverrunIt) { + auto obj = make_bigobj({ Sym{ .name = "f" } }); + put32(obj, 44, 0x00ffffffu); // sections no file of this size can hold + auto r = read_exports(obj); + ASSERT_FALSE(r); + EXPECT_NE(r.error().find("past the end"), std::string::npos) << r.error(); } TEST(CoffExports, RefusesSomethingThatIsNotAnObject) { diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index 92c96392..88ad09e6 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -16,7 +16,9 @@ TEST(Manifest, PeAutoExportDefaultsOnAndAcceptsAnExplicitOptOut) { "[targets.dll]\nkind = \"shared\"\n{}\n", declaration)); ASSERT_TRUE(m.has_value()) << m.error().format(); ASSERT_EQ(m->targets.size(), 1u); - EXPECT_EQ(m->targets.front().windowsAutoExport, std::string_view(declaration) != "windows_auto_export = false"); + EXPECT_EQ(m->targets.front().windows_auto_export(), std::string_view(declaration) != "windows_auto_export = false"); + // An omitted key stays distinguishable from a stated `true`. + EXPECT_EQ(m->targets.front().windowsAutoExportDeclared, *declaration != '\0'); EXPECT_TRUE(m->schemaWarnings.empty()); } auto bad = mcpp::manifest::parse_string( @@ -37,7 +39,8 @@ package = {{ )", declaration), "dll", "0.1.0", mcpp::platform::HostPlatform::current()); ASSERT_TRUE(m.has_value()) << m.error().format(); ASSERT_EQ(m->targets.size(), 1u); - EXPECT_EQ(m->targets.front().windowsAutoExport, std::string_view(declaration) != "windows_auto_export = false,"); + EXPECT_EQ(m->targets.front().windows_auto_export(), std::string_view(declaration) != "windows_auto_export = false,"); + EXPECT_EQ(m->targets.front().windowsAutoExportDeclared, *declaration != '\0'); } auto bad = mcpp::manifest::synthesize_from_xpkg_lua(R"( package = { @@ -6786,3 +6789,17 @@ TEST(ConditionalOrder, EqualSpecificityKeepsTheSelectorTextOrder) { EXPECT_EQ(tables[1].predicate, "cfg(env = \"gnu\")"); EXPECT_EQ(tables[2].predicate, "cfg(unix)"); } + +// `[test] windows_code_page` (2026.10.5.2): the code page of every discovered +// test program, in the closed vocabulary of the target key. +TEST(Manifest, TestWindowsCodePageIsAClosedSetOfTwo) { + auto ok = mcpp::manifest::parse_string( + "[package]\nname = \"p\"\nversion = \"0.1.0\"\n[test]\nwindows_code_page = \"utf-8\"\n"); + ASSERT_TRUE(ok.has_value()) << ok.error().format(); + EXPECT_EQ(ok->testWindowsCodePage, "utf-8"); + EXPECT_TRUE(ok->schemaWarnings.empty()); + auto bad = mcpp::manifest::parse_string( + "[package]\nname = \"p\"\nversion = \"0.1.0\"\n[test]\nwindows_code_page = \"936\"\n"); + ASSERT_FALSE(bad.has_value()); + EXPECT_NE(bad.error().format().find("windows_code_page"), std::string::npos); +} diff --git a/tests/unit/test_modgraph.cpp b/tests/unit/test_modgraph.cpp index 252e36a5..8656af0b 100644 --- a/tests/unit/test_modgraph.cpp +++ b/tests/unit/test_modgraph.cpp @@ -1167,17 +1167,25 @@ TEST(Glob, Utf8ValidityIsDecidedByteByByte) { } // A diagnostic names a path that has no UTF-8 spelling through an escaped one. +// +// Each platform is given a name in its own native form. A Windows path is +// UTF-16, and constructing one from the bytes "caf\xE9" converts them through +// the process's ANSI code page: code page 1252 accepts them, 936 and 65001 +// throw. The assertion that once followed the branches did exactly that, so +// the test passed on an English runner and failed on a Chinese one (O6 of the +// 2026-10-05 design). TEST(Glob, EscapedSpellingIsUtf8WhateverTheName) { #ifdef _WIN32 const std::wstring lone{L'a', wchar_t(0xD800), L'b'}; EXPECT_EQ(escaped_spelling(std::filesystem::path(lone)), "a\\u{D800}b"); + EXPECT_TRUE(is_valid_utf8(escaped_spelling(std::filesystem::path(lone)))); const std::wstring cafe{L'c', L'a', L'f', wchar_t(0x00E9)}; EXPECT_EQ(escaped_spelling(std::filesystem::path(cafe)), "caf\xC3\xA9"); #else EXPECT_EQ(escaped_spelling("/x/caf\xE9"), "/x/caf\\xE9"); EXPECT_EQ(escaped_spelling("/x/caf\xC3\xA9"), "/x/caf\xC3\xA9"); -#endif EXPECT_TRUE(is_valid_utf8(escaped_spelling(std::filesystem::path("caf\xE9")))); +#endif } TEST(Scanner, GlobWalkSkipsNamesThatAreNotUtf8) { diff --git a/tests/unit/test_windows_command_line.cpp b/tests/unit/test_windows_command_line.cpp index f1acde0c..656a1d72 100644 --- a/tests/unit/test_windows_command_line.cpp +++ b/tests/unit/test_windows_command_line.cpp @@ -267,3 +267,56 @@ TEST(WindowsCommandLine, MetacharacterQuotingIsUnchangedForPlainText) { ASSERT_EQ(argv.size(), 3u) << parsed.passedOn; EXPECT_EQ(argv[2], plain); } + +// ─── The argv rules of CommandLineToArgvW (2026.10.5.2) ────────────────── +// +// A parser of the rules, so that every quoted argument is checked by what a +// Windows program reads back rather than by the string it looks like. +static std::vector argv_of(std::string_view line) { + std::vector out; + std::size_t i = 0; + while (i < line.size()) { + while (i < line.size() && (line[i] == ' ' || line[i] == '\t')) ++i; + if (i >= line.size()) break; + std::string arg; + bool quoted = false; + while (i < line.size() && (quoted || (line[i] != ' ' && line[i] != '\t'))) { + std::size_t backslashes = 0; + while (i < line.size() && line[i] == '\\') { ++backslashes; ++i; } + if (i < line.size() && line[i] == '"') { + arg.append(backslashes / 2, '\\'); + if (backslashes % 2) arg.push_back('"'); + else quoted = !quoted; + ++i; + } else { + arg.append(backslashes, '\\'); + if (i < line.size() && (quoted || (line[i] != ' ' && line[i] != '\t'))) + arg.push_back(line[i++]); + } + } + out.push_back(std::move(arg)); + } + return out; +} + +TEST(WindowsCommandLine, EveryArgumentRoundTripsThroughTheArgvRules) { + const std::vector args = { + "plain", + "C:\\Program Files (x86)\\Windows Kits\\10\\", // vcvars' WindowsSdkDir + "trailing\\\\", + "a\\\"b", + "a\\\\\"b", + "say \"hi\"", + "", + "C:\\dir with space\\file.cpp", + }; + std::string line; + for (auto const& a : args) line += mcpp::platform::shell::quote_windows(a) + " "; + EXPECT_EQ(argv_of(line), args) << line; +} + +TEST(WindowsCommandLine, AnArgumentEndingInABackslashDoesNotSwallowTheNext) { + const auto line = mcpp::platform::shell::quote_windows("C:\\Kits\\10\\") + " " + + mcpp::platform::shell::quote_windows("-o"); + EXPECT_EQ(argv_of(line), (std::vector{"C:\\Kits\\10\\", "-o"})) << line; +} diff --git a/tests/unit/test_workspace_plan.cpp b/tests/unit/test_workspace_plan.cpp index ddb24216..297e9369 100644 --- a/tests/unit/test_workspace_plan.cpp +++ b/tests/unit/test_workspace_plan.cpp @@ -136,3 +136,67 @@ TEST(WorkspacePlan, IndexPathsAreAnchoredWhereTheyWereWritten) { std::error_code ec; fs::remove_all(ws, ec); } + +// THE WORKSPACE'S PROFILES ARE ROOT-POSITION VALUES (2026.10.5.2). A member +// inherits each `[profile.]` it does not declare, and its own table of a +// name replaces the workspace's whole, so members without a profile of their +// own share one configuration with the workspace's profile in it. +TEST(WorkspacePlan, MembersInheritTheWorkspacesProfilesByName) { + namespace fs = std::filesystem; + const auto ws = fs::temp_directory_path() + / std::format("mcpp-ws-profile-{}", std::random_device{}()); + fs::create_directories(ws / "a"); + fs::create_directories(ws / "b"); + auto write = [](const fs::path& p, std::string_view text) { std::ofstream(p) << text; }; + write(ws / "mcpp.toml", + "[workspace]\nmembers = [\"a\", \"b\"]\n\n" + "[profile.release]\nopt = 3\nldflags = [\"-Wl,-z,now\"]\n\n" + "[profile.bench]\nopt = 2\n"); + write(ws / "a" / "mcpp.toml", "[package]\nname = \"a\"\nversion = \"0.1.0\"\n"); + write(ws / "b" / "mcpp.toml", + "[package]\nname = \"b\"\nversion = \"0.1.0\"\n\n[profile.release]\nopt = 1\n"); + auto root = mcpp::manifest::load(ws / "mcpp.toml"); + ASSERT_TRUE(root.has_value()); + auto a = mcpp::project::load_member_manifest(*root, ws, "a"); + auto b = mcpp::project::load_member_manifest(*root, ws, "b"); + ASSERT_TRUE(a.has_value()) << a.error(); + ASSERT_TRUE(b.has_value()) << b.error(); + EXPECT_EQ(a->profiles.at("release").optLevel, "3"); + EXPECT_EQ(a->profiles.at("release").ldflags, (std::vector{"-Wl,-z,now"})); + EXPECT_EQ(b->profiles.at("release").optLevel, "1"); + EXPECT_TRUE(b->profiles.at("release").ldflags.empty()); + EXPECT_EQ(b->profiles.at("bench").optLevel, "2"); + std::error_code ec; + fs::remove_all(ws, ec); +} + +// THE ROOT PACKAGE OF A WORKSPACE IS A MEMBER (2026.10.5.2): it receives +// `[workspace.build]` once, before its own `[build]`, on every path that reads it. +TEST(WorkspacePlan, TheRootPackageInheritsTheWorkspaceBuildOnce) { + namespace fs = std::filesystem; + const auto ws = fs::temp_directory_path() + / std::format("mcpp-ws-rooted-{}", std::random_device{}()); + fs::create_directories(ws / "lib"); + std::ofstream(ws / "mcpp.toml") + << "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n" + "[build]\ncxxflags = [\"-DOWN\"]\n\n" + "[workspace]\nmembers = [\"lib\"]\n\n" + "[workspace.build]\ncxxflags = [\"-DSHARED\"]\n"; + std::ofstream(ws / "lib" / "mcpp.toml") << "[package]\nname = \"lib\"\nversion = \"0.1.0\"\n"; + const std::vector expected{"-DSHARED", "-DOWN"}; + auto effective = mcpp::project::load_effective_manifest(ws); + ASSERT_TRUE(effective.has_value()) << effective.error(); + EXPECT_EQ(effective->manifest.buildConfig.cxxflags, expected); + // The manifest a selection reads is the one already inherited; the member + // "." loaded from it is not inherited a second time. + auto dot = mcpp::project::load_member_manifest(effective->manifest, ws, "."); + ASSERT_TRUE(dot.has_value()) << dot.error(); + EXPECT_EQ(dot->buildConfig.cxxflags, expected); + auto raw = mcpp::manifest::load(ws / "mcpp.toml"); + ASSERT_TRUE(raw.has_value()); + auto fromRaw = mcpp::project::load_member_manifest(*raw, ws, "."); + ASSERT_TRUE(fromRaw.has_value()) << fromRaw.error(); + EXPECT_EQ(fromRaw->buildConfig.cxxflags, expected); + std::error_code ec; + fs::remove_all(ws, ec); +}