diff --git a/.agents/docs/2026-08-03-b3-target-aware-artifact-naming.md b/.agents/docs/2026-08-03-b3-target-aware-artifact-naming.md new file mode 100644 index 00000000..25f3f913 --- /dev/null +++ b/.agents/docs/2026-08-03-b3-target-aware-artifact-naming.md @@ -0,0 +1,398 @@ +# B3 — 产物命名按 target 而非 host(交叉构建每次重链)— 修复方案 + +> 2026-08-03 · 基于 `9a696d2`(2026.8.3.2)代码审计 + 本机实测 +> 来源:`2026-08-03-windows-host-linux-cross-design.md` §6.5 登记的 follow-up +> 记忆:[[windows-host-linux-cross-canadian]]、[[soname-alias-explicit-ninja-goals]]、 +> [[explicit-ninja-goals-two-regressions]] + +## 0. 一句话 + +`plan.cppm::target_output()` 用**主机**常量拼产物名,交叉构建时 **ninja 声明的输出文件根本不会 +出现**,于是 **每次 `mcpp build` 都重新链接一遍** —— 这不是命名难看,是增量构建对 PE 目标失效。 + +--- + +## 1. 先更正一处判断 + +`windows-host-linux-cross-design.md` §6.5 把 B3 写成「对称地错」: + +> | 方向 | 现状 | 应当 | +> |---|---|---| +> | Windows → linux-musl | `mcpp.exe`(却是 ELF) | `mcpp` | +> | **Linux → windows-gnu** | **`mcpp`(却是 PE)** | `mcpp.exe` | + +**下面那一行是错的。** 本机实测,Linux 主机交叉到 `x86_64-windows-gnu`: + +```console +$ mcpp build --target x86_64-windows-gnu +$ find target -type f -path "*/bin/*" +target/x86_64-windows-gnu/751cb693195ee82c/bin/b3probe.exe ← 有 .exe +$ file …/b3probe.exe +PE32+ executable (console) x86-64, for MS Windows +``` + +产物名是对的。原因是 **mingw 的 GCC driver 自己补的**:`-o foo` 且 `foo` 无扩展名时, +mingw GCC 输出 `foo.exe`。mcpp 从未参与这个决定。 + +真正的问题因此换了一个形态,而且更实际。 + +--- + +## 2. 真实症状:ninja 的输出声明与产物不符 ⇒ 每次重链 + +`target_output()` 在 Linux 主机上返回 `bin/`(`exe_suffix` 为空),ninja 规则照抄: + +```ninja +build bin/b3probe : cxx_link obj/main.o # ← 声明的产物 +``` + +GCC 却写出 `bin/b3probe.exe`。于是 `bin/b3probe` **从来不存在**: + +```console +$ test -f target/x86_64-windows-gnu/*/bin/b3probe && echo EXISTS || echo MISSING +MISSING +``` + +ninja 每次都发现声明的输出缺失 ⇒ 重跑链接边。实测(连续两次 build 比对 mtime): + +```console +mtime before: 1785736156 +mtime after rebuild: 1785736180 +RESULT: RELINKED — declared output never exists, so ninja reruns the link every build +``` + +**两个方向的实际后果因此并不对称:** + +| 方向 | ninja 声明 | 实际产物 | 后果 | +|---|---|---|---| +| Linux → windows-gnu | `bin/foo` | `bin/foo.exe`(GCC 补) | ❌ **声明永不满足 ⇒ 每次重链** | +| Windows → linux-musl | `bin/foo.exe` | `bin/foo.exe`(ELF) | ⚠️ 声明与产物一致,构建正常;只是 ELF 顶着 `.exe` | + +也就是说:**Windows→Linux 只是难看,Linux→Windows 是功能缺陷。** 后者恰恰是已经发布、 +CI 每天在跑的那条路径(`mingw-cross linux→windows`)。 + +> **为什么 CI 一直绿**:`102_mingw_cross_wine.sh:58` 用 `find target/$TRIPLE -name '*.exe'` +> 找产物 —— 它找的是**真实产物**,不是 ninja 声明的那个,所以完全感知不到这层不一致。 +> 测试断言的是「产物存在且是 PE32+」,这两条都成立。**一个测试可以全绿而问题就在它脚下**, +> 与 [[explicit-ninja-goals-two-regressions]] 同型。 + +--- + +## 3. 根因 + +`src/build/plan.cppm:200-213`: + +```cpp +std::filesystem::path target_output(const mcpp::manifest::Target& t) { + if (t.kind == Library) + return "bin" / std::format("{}{}{}", platform::lib_prefix, t.name, + platform::static_lib_ext); + if (t.kind == SharedLibrary) + return "bin" / std::format("{}{}{}", platform::lib_prefix, t.name, + platform::shared_lib_ext); + return "bin" / std::format("{}{}", t.name, platform::exe_suffix); +} +``` + +四个 `platform::` 常量(`platform/common.cppm:18-33`)按 `#if defined(_WIN32)/__APPLE__` 编译期 +分支,描述的是**这台机器**的约定。函数签名 `(const Target&)` 里**没有任何 target 信息**, +所以它连"想按 target 求值"都做不到 —— 这才是问题的结构性来源。 + +同一文件里 `shared_library_link_flags()`(`:236-243`)有同样的毛病,而且更隐蔽: + +```cpp +if constexpr (mcpp::platform::is_windows) { // ← host + flags.push_back(target_output(t).generic_string()); +} else { + flags.push_back("-L" + …); + if constexpr (mcpp::platform::supports_rpath) { // ← host + if constexpr (mcpp::platform::is_macos) { … } // ← host +``` + +「链接一个共享库要用完整路径还是 `-L`/`-l`/`-rpath`」是 **target** 的属性(PE 无 rpath, +Mach-O 用 `@loader_path`,ELF 用 `$ORIGIN`)。今天交叉构建共享库会发出宿主形态的链接参数。 + +### 3.1 已核对:其余 `platform::` 用法都是对的 + +`exe_suffix` 全仓 16 处引用,除 `plan.cppm` 外全部在找**主机上的可执行文件** +(`ninja`/`xlings`/`nasm`/`llvm-ar`/`clang-scan-deps`/`clang++`)—— +`config.cppm`、`xlings.cppm`、`clang.cppm`、`llvm.cppm`、`ninja_backend.cppm:1394`、 +`fallback/xlings_binary.cppm`。**这些是正确的 host 语义,不要动。** + +`build_program.cppm:506` 的 `build.mcpp.exe` 也是 host 语义(L3 构建程序跑在主机上), +同样不动 —— 参见 [[build-mcpp-helper-self-containment]]。 + +**本方案的改动面只有 `src/build/plan.cppm` 一个文件。** + +--- + +## 4. 正确的命名是 (os, env) 二元函数,不是 os 一元 + +这是本方案唯一需要动脑的地方 —— 直觉上「Windows 就是 `.lib`」是错的: + +| target | exe | 静态库 | 共享库 | 导入库 | +|---|---|---|---|---| +| `*-linux-*` | *(无)* | `libfoo.a` | `libfoo.so` | — | +| `*-macos` | *(无)* | `libfoo.a` | `libfoo.dylib` | — | +| `x86_64-windows-**gnu**` | `.exe` | **`libfoo.a`** | `foo.dll` | `libfoo.dll.a` | +| `x86_64-windows-**msvc**` | `.exe` | **`foo.lib`** | `foo.dll` | `foo.lib` | + +`windows-gnu` 用的是 **GNU 约定**(`lib` 前缀 + `.a`),而现行的 `_WIN32` 分支写死 +`lib_prefix=""` / `static_lib_ext=".lib"` —— 也就是说**在 Windows 主机上用 mingw 构建静态库, +今天的命名就已经是错的**(会叫 `foo.lib` 而 mingw 的 `ar` 产出的是 GNU archive)。 +这一条与交叉无关,是存量缺陷,顺带一并修掉。 + +--- + +## 5. 方案 + +### 5.1 引入 `ArtifactNaming`(从 triple 导出,一次求值) + +放在 `toolchain/triple.cppm` 旁边(它是 triple 语义的家),或 `build/plan.cppm` 内部: + +```cpp +// 产物命名约定 —— 由 TARGET 的 (os, env) 决定,与构建主机无关。 +struct ArtifactNaming { + std::string_view exeSuffix; // "" | ".exe" + std::string_view libPrefix; // "lib" | "" + std::string_view staticLibExt; // ".a" | ".lib" + std::string_view sharedLibExt; // ".so" | ".dylib" | ".dll" + bool sharedNeedsImportLib; // PE: 链接消费者要 import lib +}; + +ArtifactNaming artifact_naming(const triple::Triple& t); +``` + +空 triple(host target)回退到今天的 `platform::` 常量 —— 与 B2 的 +`target_supports_full_static(triple, hostCapability)` 完全同型,**保证 host 构建逐位不变**。 + +### 5.2 `target_output` 接受它 + +```cpp +std::filesystem::path target_output(const Target& t, const ArtifactNaming& n); +``` + +9 处调用点全在 `plan.cppm` 内,且都在 `make_plan()` 的作用域里 —— +`make_plan` 已经有 `const Toolchain& tc`(`plan.cppm:129`),`tc.targetTriple` 直接可达, +在函数入口构造一次 `ArtifactNaming` 传下去即可。**不需要改任何跨模块签名。** + +### 5.3 `shared_library_link_flags` 同步改为按 target + +`is_windows` → `n.sharedNeedsImportLib`(或直接判 `triple.is_pe()`); +`supports_rpath` / `is_macos` → 按 target 的 os 求值。 + +### 5.4 `runtime_aliases_for_target` 要一起看 + +`plan.cppm:215-228` 依赖 `target_output()` 的结果去比对 `t.soname`: + +```cpp +auto output = target_output(t); +if (t.soname != output.filename().string()) + aliases.push_back(output.parent_path() / t.soname); +``` + +签名要一起加参数。**这条边曾经因为类似改动漏生成过 soname 别名** +(见 [[soname-alias-explicit-ninja-goals]]:别名是独立 ninja 边,只挂 `default`, +改成显式目标后被跳过)。**改完必须有一条断言 soname 别名仍然生成的测试**,不能只靠 review。 + +--- + +## 6. 影响面(已实测清点) + +### 6.1 会改变行为的只有交叉构建 + +host 构建(host == target)命名逐位不变,因为空 triple 回退到今天的常量。 +**22 个 e2e 里出现 `.exe` 的**绝大多数是 Windows 上的 host 构建,不受影响。 + +真正受影响的是三处交叉断言: + +| 文件 | 现状 | 改后 | +|---|---|---| +| `tests/e2e/102_mingw_cross_wine.sh:58` | `find target/$TRIPLE -name '*.exe'` | ✅ 仍然匹配(产物名不变,只是 ninja 声明对上了) | +| `tests/e2e/112_build_mcpp_cross.sh:53` | `find target -name 'crossbp.exe'` | ✅ 仍然匹配 | +| `.github/workflows/cross-build-test.yml` windows→linux job | 已写成 `\( -name "mcpp" -o -name "mcpp.exe" \)` | ✅ 两种拼写都收,改前改后都对 | + +**结论与 §6.5 当初的担心相反:e2e 与 CI 基本不用改。** 因为 Linux→Windows 的产物名本来就由 +GCC 决定为 `.exe`,修复只是让 **ninja 的声明** 追上事实;Windows→Linux 的产物名会从 +`mcpp.exe` 变成 `mcpp`,而唯一消费它的 CI job 已经两种都匹配。 + +### 6.2 需要确认的一处 + +`release.yml` / `bootstrap-macos.yml` 里的打包路径(`find target -path "*/bin/mcpp"` 等)全部是 +**host 构建**的产物,不经过交叉路径,不受影响。已逐条核对。 + +--- + +## 7. 验证判据(缺一不可) + +1. **回归断言先行**:新增测试,断言 `artifact_naming()` 对 + `x86_64-linux-musl` / `x86_64-windows-gnu` / `x86_64-windows-msvc` / `aarch64-macos` / 空 triple + 的五元组取值。**先跑一次确认它在修复前是红的** —— 与 B2 的 commit 1 同样的纪律, + 见 [[windows-host-linux-cross-canadian]]。 +2. **`windows-gnu` 用 GNU 约定**:静态库断言为 `libfoo.a`,**不是** `foo.lib`(§4 的存量缺陷)。 +3. **不再重链**(本方案的核心收益,也是唯一能证伪「修好了」的判据): + ```bash + mcpp build --target x86_64-windows-gnu + T1=$(stat -c %Y ) + mcpp build --target x86_64-windows-gnu + T2=$(stat -c %Y ) + [ "$T1" = "$T2" ] # 修复前必失败 + ``` + 这条要进 e2e,否则它会悄悄退化回去。 +4. **host 构建逐位不变**:Linux/macOS/Windows 三个 host 的 `mcpp build` 产物名与改前一致。 +5. **soname 别名仍然生成**(§5.4)。 +6. 现有 22 个含 `.exe` 的 e2e 全绿。 + +--- + +## 8. 分期 + +**三个 PR,不要合并**(依据见 §9): + +#### PR-1 — B3 主体(本文档的 §5) + +单 PR,提交分层(与 B2 同款,理由见 [[windows-host-linux-cross-canadian]]): + +| commit | 内容 | +|---|---| +| 1 | `artifact_naming()` + 判据 1/2 的断言,**实现为返回 host 常量的桩** ⇒ 测试红 | +| 2 | 实现按 (os, env) 求值 ⇒ 测试绿(含 `windows-gnu` → `libfoo.a`,§9.1) | +| 3 | `target_output` / `runtime_aliases_for_target` 接受 `ArtifactNaming` | +| 4 | `shared_library_link_flags` 按 target 求值 | +| 5 | e2e:判据 3 的「不重链」断言 | + +CHANGELOG 必须写出 Windows + mingw 静态库改名这条**对外可见的行为变更**。 + +#### PR-2 — 非 ELF 目标上的共享库明确拒绝(§9.2 路线 B) + +独立小 PR。`make_plan` 入口检查:target 非 ELF 且存在 `SharedLibrary` 目标 ⇒ 返回明确错误, +指向追踪 issue。附一条 e2e:「PE 目标上声明 SharedLibrary 得到可读的 mcpp 错误, +而不是一个链接不上的产物」。 + +**先于 PR-3,也可以先于 PR-1** —— 它不依赖命名改动,且立刻消除一类未定义行为。 + +#### PR-3 — import lib 完整支持(§9.2 路线 A) + +**另开设计文档。** 前置条件是先补齐 PE 与 Mach-O 的共享库 e2e 覆盖,否则又是一批 +无验证代码。不要在没有覆盖的前提下加产物边。 + +--- + +## 9. 两个未决问题的深度分析(2026-08-03 补,基于代码实测) + +--- + +### 9.1 `windows-gnu` 静态库 `foo.lib` → `libfoo.a`:**同期做,影响面实测为空** + +原本的顾虑是「它会改变 host 构建的产物名」。把消费链逐段查完之后,这个顾虑站不住。 + +#### 谁真正消费一个静态库的名字 + +| 消费者 | 机制 | 受改名影响? | +|---|---|---| +| **mcpp 包间依赖** | **object 级内联** —— `plan.cppm:836` 把依赖包的 `.o` 直接塞进 `lu.objects`,**根本不产生也不读取 `.a`** | ❌ 无影响 | +| **外部预编译库**(compat.* 等) | 自由形式 `ldflags`(`types.cppm:173`),库名由包描述符写死,mcpp 不参与命名 | ❌ 无影响 | +| **`[runtime] library_dirs`** | 是**目录**而非库名(`types.cppm:321`);Windows 侧只按 `.dll` 扩展名做运行期部署(`plan.cppm:418-434`) | ❌ 无影响 | +| **ninja 输出声明** | 来自 `target_output()` 本身 | ✅ 同步改,这正是修复 | +| **最终用户 / 外部构建系统** | 直接引用产物路径 | ⚠️ 唯一真实影响面 | + +**关键结构性事实:mcpp 的静态库不是内部链接单元。** 包依赖走 object 内联,所以 `Library` +目标产出的 `.a`/`.lib` 只有一个消费者 —— **把产物拿去给 mcpp 之外的世界用的人**。 +这让改名从「牵一发动全身」降级为「改一个对外文件名」。 + +#### 缓存与 fingerprint:影响为零(实测) + +`toolchain/fingerprint.cppm:94-103` 的 8 个 part 里**没有产物名**,但**有 `MCPP_VERSION`**。 +也就是说任何版本 bump 都会换一个 `target///` 目录 —— 改名后不会出现 +「旧目录里躺着旧名产物、新逻辑找不到」的混合态。**不需要任何缓存迁移或 `cache clean` 提示。** + +#### 为什么必须同期做(架构角度) + +把「命名由 (os, env) 决定」做成半截,会留下一个**比现状更难解释的状态**: +`exe_suffix` 按 target 走了,`static_lib_ext` 还按 host —— 下一个读代码的人无从判断 +哪个常量能信。§4 那张表的价值恰恰在于它是**一条完整的规则**,而不是四个独立特例。 + +而且这不是「顺带做的优化」,它是**存量正确性缺陷**:今天在 Windows 主机上用 mingw 工具链 +构建静态库,产物叫 `foo.lib`,而 mingw 的 `ar` 产出的是 GNU archive —— 一个 MSVC 拿不去用、 +名字又冒充 MSVC 约定的文件。**这个错误与交叉编译无关,今天就在发生。** + +#### 结论 + +**同期做。** 唯一需要的额外动作是在 CHANGELOG 明确写出这条行为变更(Windows + mingw +静态库产物改名),因为它对外可见 —— 但它影响的是一个**今天就是错的**名字。 + +--- + +### 9.2 PE import lib:**先别建模,先把边界画出来** + +调研到一个改变问题性质的事实。 + +#### 共享库在 PE 和 Mach-O 上是零验证覆盖 + +5 个共享库 e2e **全部**声明 `# requires: elf`: + +``` +tests/e2e/08_shared_library.sh:2: # requires: elf +tests/e2e/55_dependency_shared_artifact.sh:2:# requires: elf +tests/e2e/56_transitive_shared_artifact.sh:2:# requires: elf +tests/e2e/57_static_dep_shared_artifact.sh:2:# requires: elf +tests/e2e/64_shared_soname_runtime_alias.sh:2:# requires: elf +``` + +而 `elf` 这个 capability **只有 Linux 分支会加**(`run_all.sh:46`); +Darwin 加的是 `macos`,Windows 加的是 `windows`。 + +**所以 mcpp 的共享库支持是 Linux-only 的既成事实 —— 不只是 PE,连 macOS 的 Mach-O +也从未被端到端验证过。** + +这把问题从「缺一个 import lib 功能」重新定义为:**共享库这条路在非 ELF 平台上从来没走通过, +而代码里却有看起来能走的分支。** + +#### 那些分支是未验证的推测代码 + +`plan.cppm:236-243`: + +```cpp +if constexpr (mcpp::platform::is_windows) { + flags.push_back(target_output(t).generic_string()); // 直接塞 foo.dll 的路径 +} else { + flags.push_back("-L" + …); flags.push_back("-l" + t.name); +``` + +- **mingw**:链接器确实容忍直接链 `.dll`(ld 会自动生成 import stub),所以**可能**能工作 —— + 但没有任何测试证明过。 +- **MSVC**:`link.exe` **无法**链接 `.dll`,它需要 `.lib` import library。这条路径必然失败。 +- 而且这个分支按 **host** 求值(`is_windows`),交叉时连方向都是错的 —— 与 B3 主体同病。 + +#### 长期架构:三条路,建议第 2 条 + +| 路线 | 内容 | 评价 | +|---|---|---| +| **A. 完整建模 import lib** | `ArtifactNaming` 增加 import lib 产物,ninja 加一条边,链接消费者改用 import lib;mingw 用 `-Wl,--out-implib`,MSVC 用 `link.exe` 自动产出的 `.lib` | 正确但**昂贵**:要同时补 PE 与 Mach-O 的共享库 e2e,否则又是一批无覆盖代码。**不该和 B3 混在一起。** | +| **B. 先画边界:非 ELF 目标上共享库明确拒绝** ✅ | `SharedLibrary` 目标在 PE / Mach-O 目标上返回一条清晰的 mcpp 错误,指明「共享库目前仅支持 ELF 目标,追踪 issue: …」 | **静默产出不可用的东西,比明确拒绝坏得多。**成本极低,立刻消除一整类未定义行为 | +| C. 维持现状 | 保留未验证分支 | ❌ 最差:代码看起来支持,实际未知,用户踩坑时离根因隔三层 | + +**推荐 B,而且它应该先于 A。** 理由与 [[index-refresh-resolution-driven]] 里那条 +「offline-first 被 TTL 门压成不可达代码」同源:**一段没有测试覆盖、又没有明确拒绝的分支, +是最难清理的技术债** —— 它既不能被信任,又不能被删除,因为没人知道谁在依赖它。 + +先把边界写死,`ArtifactNaming::sharedNeedsImportLib` 这个字段就有了明确语义: +**它当前的唯一用途是驱动那条拒绝**,而不是假装支持。等真要做 A 时,再把它变成产物声明。 + +#### 与 B3 主体的关系 + +B3 主体(§5)**不依赖**这个决定:`target_output()` 对 `SharedLibrary` 仍按 (os, env) 给出 +正确的 `foo.dll` / `libfoo.so` / `libfoo.dylib`,该怎么命名怎么命名。路线 B 只是在 +`make_plan` 入口多一条前置检查。 + +**建议拆分:** B3 主体一个 PR(§8 的五段);路线 B 一个独立小 PR(带一条「PE 目标上声明 +SharedLibrary 得到明确错误」的 e2e);路线 A 另开设计文档。 + +--- + +### 9.3 汇总:两个问题的处置 + +| # | 问题 | 决定 | 依据 | +|---|---|---|---| +| 1 | `foo.lib` → `libfoo.a` | **同期做** | 消费链实测无影响(包依赖走 object 内联、外部库走 ldflags、fingerprint 不含产物名);且它是**存量正确性缺陷**,不是顺带优化 | +| 2 | PE import lib | **本期不建模,改为明确拒绝** | 共享库在 PE/Mach-O 上**零 e2e 覆盖**,现有分支是未验证推测;静默产出不可用产物比明确报错坏得多。完整支持另开设计 | diff --git a/.agents/docs/2026-08-03-windows-host-linux-cross-design.md b/.agents/docs/2026-08-03-windows-host-linux-cross-design.md index 240f15d4..82ca558e 100644 --- a/.agents/docs/2026-08-03-windows-host-linux-cross-design.md +++ b/.agents/docs/2026-08-03-windows-host-linux-cross-design.md @@ -643,21 +643,36 @@ return std::filesystem::path("bin") / ``` `exe_suffix` / `lib_prefix` / `static_lib_ext` / `shared_lib_ext` **四个都是 host 常量** -(`platform/common.cppm:18-29`),却用来命名 **target** 产物。与 B2 完全同构,而且**对称地错**: +(`platform/common.cppm:18-33`),却用来命名 **target** 产物。与 B2 同构。 -| 方向 | 现状 | 应当 | -|---|---|---| -| Windows → linux-musl | `mcpp.exe`(却是 ELF) | `mcpp` | -| Linux → windows-gnu | `mcpp`(却是 PE) | `mcpp.exe` | - -**为什么本期不修**:改产物命名是行为变更,会同时动到 `tests/e2e/102_mingw_cross_wine.sh`、 -release 打包路径和任何用户脚本;而两个方向的现状都**不致命**(扩展名在 Linux 上无意义, -Windows 命令行也能跑无扩展名的 PE)。把一个有回归面的重命名塞进本 PR,违背了 §1.3 定下的 -「单 PR 但提交分层、失败可归因」的初衷。 - -**修的时候要一起改的四个常量**,并且要注意 `runtime_aliases_for_target()`(`plan.cppm:215`) -依赖 `target_output()` 的结果去比对 soname —— 见 [[soname-alias-explicit-ninja-goals]], -那条边曾经因为类似改动漏生成过。 +> ### ⚠️ 本节初版的判断有误,已在专项文档中更正 +> +> 初版写的是「对称地错:Linux→Windows 产出 `mcpp` 却是 PE」。**后半句是错的** —— +> 实测 Linux 主机交叉到 `x86_64-windows-gnu`,产物就是 `b3probe.exe`(PE32+), +> 因为 **mingw 的 GCC driver 自己会补 `.exe`**,mcpp 从未参与这个决定。 +> +> 真正的症状是另一件事,而且更实际:**ninja 声明的输出是 `bin/foo`,GCC 写出 `bin/foo.exe`, +> 声明的那个文件从来不存在 ⇒ 每次 `mcpp build` 都重跑链接边**。实测连续两次构建 +> 产物 mtime 会变。也就是说 Linux→Windows 是**功能缺陷(增量构建失效)**, +> Windows→Linux 才只是「ELF 顶着 `.exe`」的观感问题。 + +**完整分析、修复方案与验证判据见专项文档:** +`2026-08-03-b3-target-aware-artifact-naming.md` + +要点摘录: +- 改动面只有 `src/build/plan.cppm` 一个文件;其余 15 处 `exe_suffix` 引用都是找主机上的 + `ninja`/`xlings`/`clang++` 等,**是正确的 host 语义,不要动** +- 正确的命名是 **(os, env) 二元函数**:`windows-gnu` 用 GNU 约定(`libfoo.a`), + `windows-msvc` 才是 `foo.lib` —— 现行 `_WIN32` 分支写死后者,**Windows 主机上用 mingw + 构建静态库今天就已经命名错了**,与交叉无关的存量缺陷 +- e2e/CI 基本不用改(与本节初版的担心相反):`102_mingw_cross_wine.sh` 找的是真实产物 + 而非 ninja 声明,改前改后都匹配 +- `runtime_aliases_for_target()`(`plan.cppm:215`)依赖 `target_output()` 比对 soname, + 必须一起改并补断言 —— 见 [[soname-alias-explicit-ninja-goals]] + +**为什么当期没修**:发现时 #339 已进入 CI 验证阶段,把一个尚未查清真实形态的改动塞进去, +会毁掉 §1.3 定下的「提交分层、失败可归因」。事后看这个决定是对的 —— 初版对症状的描述 +本身就是错的,当场改只会改错方向。 --- diff --git a/.github/workflows/cross-build-test.yml b/.github/workflows/cross-build-test.yml index 5fe4c7dd..376e7f6c 100644 --- a/.github/workflows/cross-build-test.yml +++ b/.github/workflows/cross-build-test.yml @@ -307,6 +307,25 @@ jobs: steps: - uses: actions/checkout@v4 - uses: ./.github/actions/bootstrap-mcpp + with: + # Do NOT restore target/ in a cross job. This job builds twice — once + # for the host, then once for x86_64-linux-musl — and a restored BMI + # tree makes the second build read `std` BMIs that no longer match + # what the dependency BMIs were compiled against: + # + # mcpplibs.cmdline: error: import 'std' has CRC mismatch + # + # GCC bakes a CRC of each imported module's BMI into the importer, so + # the two have to come from the same build round; a cache that + # restores one without the other is not a partial speedup, it is an + # unbuildable tree. It reproduced on rerun, and only in this job — + # the other two cross jobs below cache ~/.mcpp and ~/.xlings but + # deliberately never target/, which is the convention this now follows. + # + # It stayed hidden until a PR touched neither mcpp.toml nor + # .xlings.json: those two files key the sandbox cache, so every + # earlier run had been a cold miss. + cache-target: 'false' - name: Build mcpp from source (self-host) shell: bash diff --git a/CHANGELOG.md b/CHANGELOG.md index e936b0e1..4095222f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,26 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [2026.8.3.3] — 2026-08-03 + +### 修复 + +- **交叉构建每次都重新链接(增量构建对 PE 目标实际失效)。** `plan.cppm` 的 `target_output()` 用 `mcpp::platform::{exe_suffix,lib_prefix,static_lib_ext,shared_lib_ext}` 拼产物名 —— 这四个是**主机**常量,由 `#if defined(_WIN32)/__APPLE__` 选择。主机构建下「这台机器叫什么」与「产物该叫什么」恰好同解,所以它一直没被发现;`host ≠ target` 时两者分岔。 + + 后果不是命名难看:Linux 交叉到 PE 时,ninja 被告知产出 `bin/foo`,而 mingw 的 GCC driver 写出的是 `bin/foo.exe` —— **声明的那个文件从来不存在**,ninja 每次都发现输出缺失并重跑链接边。这条路径正是 CI 每天在跑的。 + + 产物命名现在由 `ArtifactNaming` 决定,每个 plan 从 target triple 求值一次。**它是 (os, env) 二元函数,不是 os 一元**:`x86_64-windows-gnu` 用 GNU 约定(`libfoo.a`),`x86_64-windows-msvc` 才是 `foo.lib`。空 triple 表示「为本机构建」,只有那时主机答案才是对的,因此它作为回退传入而非被直接读取 —— **主机构建逐位不变**。 + + 同样的处理给了 `shared_library_link_flags`:消费者链接一个共享库时用完整路径(PE)、`@loader_path`(Mach-O)还是 `$ORIGIN`(ELF),是**产物**的属性;按主机求值在交叉时方向就是反的。 + +- **`windows-gnu` 静态库命名错误(行为变更)。** 在 Windows 主机上用 mingw 工具链构建静态库,产物此前叫 `foo.lib` —— 一个 GNU archive 顶着 MSVC 的名字,MSVC 拿不去用。现在按 GNU 约定命名为 `libfoo.a`。这修正的是一个**今天就是错的**名字,与交叉编译无关。 + +### 改进 + +- **非 ELF 目标上声明 `SharedLibrary` 现在明确报错,而不是产出未经验证的东西。** 全部 5 个共享库 e2e 都声明 `# requires: elf`,而这个 capability 只在 Linux 上授予 —— 也就是说共享库在 PE 与 Mach-O 上**从未被端到端验证过**。相关分支是推测代码:mingw 的 ld 容忍直接链 `.dll`,MSVC 的 `link.exe` 不行,而两者都没有 import library 可链,因为 mcpp 还没有建模它。 + + 一段既没有测试覆盖、又不肯明确拒绝的分支是最难清理的债 —— 它既不能被信任,也不能被删除,因为没人知道谁在依赖它。先把边界写死,等真要支持时再补(前置条件是先有 PE/Mach-O 的共享库覆盖)。 + ## [2026.8.3.2] — 2026-08-03 ### 新增 diff --git a/mcpp.toml b/mcpp.toml index 64e9ac80..0c3be15c 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.8.3.2" +version = "2026.8.3.3" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/build/plan.cppm b/src/build/plan.cppm index ff28a4f1..318f1981 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -13,6 +13,7 @@ import mcpp.toolchain.cppfly; import mcpp.toolchain.detect; import mcpp.toolchain.dialect; import mcpp.toolchain.fingerprint; +import mcpp.toolchain.triple; import mcpp.platform; export namespace mcpp::build { @@ -197,29 +198,52 @@ std::vector dependency_name_candidates( return out; } -std::filesystem::path target_output(const mcpp::manifest::Target& t) { +// The naming this MACHINE would use for its own binaries. Correct only for a +// host-target build; passed to artifact_naming() as the fallback for an empty +// triple, and never consulted directly when a target triple is present. +mcpp::toolchain::triple::ArtifactNaming host_artifact_naming() { + return { + .exeSuffix = mcpp::platform::exe_suffix, + .libPrefix = mcpp::platform::lib_prefix, + .staticLibExt = mcpp::platform::static_lib_ext, + .sharedLibExt = mcpp::platform::shared_lib_ext, + .sharedNeedsImportLib = mcpp::platform::is_windows, + }; +} + +mcpp::toolchain::triple::ArtifactNaming naming_for(const mcpp::toolchain::Toolchain& tc) { + auto t = mcpp::toolchain::triple::parse(tc.targetTriple); + return mcpp::toolchain::triple::artifact_naming( + t ? *t : mcpp::toolchain::triple::Triple{}, host_artifact_naming()); +} + +// What the artifact is CALLED — a property of the target, not of this machine. +// Reading the host constants here made ninja declare an output the compiler +// never writes (Linux -> PE: declared `bin/foo`, produced `bin/foo.exe`), so +// the link edge could never be satisfied and reran on every build. +std::filesystem::path target_output(const mcpp::manifest::Target& t, + const mcpp::toolchain::triple::ArtifactNaming& n) { if (t.kind == mcpp::manifest::Target::Library) { return std::filesystem::path("bin") / - std::format("{}{}{}", mcpp::platform::lib_prefix, t.name, - mcpp::platform::static_lib_ext); + std::format("{}{}{}", n.libPrefix, t.name, n.staticLibExt); } if (t.kind == mcpp::manifest::Target::SharedLibrary) { return std::filesystem::path("bin") / - std::format("{}{}{}", mcpp::platform::lib_prefix, t.name, - mcpp::platform::shared_lib_ext); + std::format("{}{}{}", n.libPrefix, t.name, n.sharedLibExt); } return std::filesystem::path("bin") / - std::format("{}{}", t.name, mcpp::platform::exe_suffix); + std::format("{}{}", t.name, n.exeSuffix); } std::vector runtime_aliases_for_target( - const mcpp::manifest::Target& t) { + const mcpp::manifest::Target& t, + const mcpp::toolchain::triple::ArtifactNaming& n) { std::vector aliases; if (t.kind != mcpp::manifest::Target::SharedLibrary || t.soname.empty()) { return aliases; } - auto output = target_output(t); + auto output = target_output(t, n); if (t.soname != output.filename().string()) { aliases.push_back(output.parent_path() / t.soname); } @@ -232,19 +256,30 @@ bool is_implementation_source(const std::filesystem::path& source) { || ext == ".S" || ext == ".s" || ext == ".asm"; } -std::vector shared_library_link_flags(const mcpp::manifest::Target& t) { +// How a CONSUMER links against a shared library. Also a target property: PE has +// no rpath and wants an import library, Mach-O uses @loader_path, ELF uses +// $ORIGIN. Keying this on the host pointed it the wrong way under cross builds. +// +// NOTE: shared libraries have never been verified end to end on PE or Mach-O — +// every shared-library e2e declares `# requires: elf`, and that capability is +// only granted on Linux. The PE branch here (linking the .dll path directly) +// is therefore unproven: mingw's ld tolerates it, MSVC's link.exe cannot. +// make_plan() rejects SharedLibrary targets on non-ELF targets rather than +// emitting something unverifiable — see the guard there. +std::vector shared_library_link_flags( + const mcpp::manifest::Target& t, + const mcpp::toolchain::triple::ArtifactNaming& n, + const mcpp::toolchain::triple::Triple& target) { std::vector flags; - if constexpr (mcpp::platform::is_windows) { - flags.push_back(target_output(t).generic_string()); + const bool pe = n.sharedNeedsImportLib; + const bool macho = target.empty() ? bool(mcpp::platform::is_macos) + : target.os == "macos"; + if (pe) { + flags.push_back(target_output(t, n).generic_string()); } else { - flags.push_back("-L" + target_output(t).parent_path().generic_string()); - if constexpr (mcpp::platform::supports_rpath) { - if constexpr (mcpp::platform::is_macos) { - flags.push_back("-Wl,-rpath,@loader_path"); - } else { - flags.push_back("-Wl,-rpath,'$$ORIGIN'"); - } - } + flags.push_back("-L" + target_output(t, n).parent_path().generic_string()); + flags.push_back(macho ? "-Wl,-rpath,@loader_path" + : "-Wl,-rpath,'$$ORIGIN'"); flags.push_back("-l" + t.name); } return flags; @@ -384,6 +419,45 @@ make_plan(const mcpp::manifest::Manifest& manifest, plan.manifest = manifest; plan.toolchain = tc; plan.fingerprint = fp; + + // Artifact naming and shared-library link shape are properties of the + // TARGET. Resolved once here from tc.targetTriple (empty = host target, in + // which case the host constants ARE the right answer) and threaded down, + // so nothing below reaches for mcpp::platform to describe an output. + const auto targetTriple = [&] { + auto t = mcpp::toolchain::triple::parse(tc.targetTriple); + return t ? *t : mcpp::toolchain::triple::Triple{}; + }(); + const auto naming = naming_for(tc); + + // Shared libraries have never been verified end to end on PE or Mach-O: + // every shared-library e2e declares `# requires: elf`, and run_all.sh only + // grants that capability on Linux. The non-ELF paths through + // shared_library_link_flags are therefore unproven — mingw's ld tolerates + // linking a .dll directly, MSVC's link.exe cannot, and neither has an + // import library to link against because mcpp does not model one. + // + // Refusing is strictly better than emitting something unverifiable: a + // branch that is neither tested nor willing to say no is the hardest kind + // of debt, because it can be neither trusted nor deleted. + if (!targetTriple.empty() && targetTriple.os != "linux") { + for (auto const& t : manifest.targets) { + if (t.kind != mcpp::manifest::Target::SharedLibrary) continue; + return std::unexpected(std::format( + "target '{}': shared libraries are only supported for Linux (ELF) " + "targets today.\n" + " target '{}' is kind=\"shared\"; build it as kind=\"lib\" " + "(static) for this target,\n" + " or build it for a linux target.\n" + " note: PE consumers need an import library and Mach-O needs " + "install-name handling;\n" + " neither is modelled yet, so mcpp refuses rather than " + "producing an artifact\n" + " nothing has ever verified.", + targetTriple.str(), t.name)); + } + } + bool experimentalStd = false; if (auto stdCfg = mcpp::manifest::normalize_cpp_standard(manifest.package.standard)) { plan.cppStandard = stdCfg->canonical; @@ -686,7 +760,7 @@ make_plan(const mcpp::manifest::Manifest& manifest, .packageIndex = i, .packageName = qname, .target = t, - .output = target_output(t), + .output = target_output(t, naming), }); sharedTargetsByPackage[i].push_back(targetIndex); } @@ -767,9 +841,9 @@ make_plan(const mcpp::manifest::Manifest& manifest, // (0.0.104-0.0.106). The failure surfaced far away, as // `libX11.so: undefined reference to xcb_connect` or a test // exiting 127. - for (auto const& alias : runtime_aliases_for_target(dep.target)) + for (auto const& alias : runtime_aliases_for_target(dep.target, naming)) lu.implicitInputs.push_back(alias); - auto flags = shared_library_link_flags(dep.target); + auto flags = shared_library_link_flags(dep.target, naming, targetTriple); lu.linkFlags.insert(lu.linkFlags.end(), flags.begin(), flags.end()); } } @@ -812,7 +886,7 @@ make_plan(const mcpp::manifest::Manifest& manifest, lu.kind = LinkUnit::SharedLibrary; lu.output = dep.output; lu.soname = dep.target.soname; - lu.runtimeAliases = runtime_aliases_for_target(dep.target); + lu.runtimeAliases = runtime_aliases_for_target(dep.target, naming); append_package_objects(lu, dep.packageName); append_direct_shared_deps(lu, dep.packageIndex); plan.linkUnits.push_back(std::move(lu)); @@ -833,19 +907,19 @@ make_plan(const mcpp::manifest::Manifest& manifest, lu.targetName = t.name; if (t.kind == mcpp::manifest::Target::Library) { lu.kind = LinkUnit::StaticLibrary; - lu.output = target_output(t); + lu.output = target_output(t, naming); } else if (t.kind == mcpp::manifest::Target::SharedLibrary) { lu.kind = LinkUnit::SharedLibrary; - lu.output = target_output(t); + lu.output = target_output(t, naming); lu.soname = t.soname; - lu.runtimeAliases = runtime_aliases_for_target(t); + lu.runtimeAliases = runtime_aliases_for_target(t, naming); } else if (t.kind == mcpp::manifest::Target::TestBinary) { lu.kind = LinkUnit::TestBinary; - lu.output = target_output(t); + lu.output = target_output(t, naming); if (!t.main.empty()) lu.entryMain = projectRoot / t.main; } else { lu.kind = LinkUnit::Binary; - lu.output = target_output(t); + lu.output = target_output(t, naming); if (!t.main.empty()) lu.entryMain = projectRoot / t.main; } diff --git a/src/toolchain/fingerprint.cppm b/src/toolchain/fingerprint.cppm index 2fb9dcda..91222c64 100644 --- a/src/toolchain/fingerprint.cppm +++ b/src/toolchain/fingerprint.cppm @@ -18,7 +18,7 @@ import mcpp.toolchain.detect; export namespace mcpp::toolchain { -inline constexpr std::string_view MCPP_VERSION = "2026.8.3.2"; +inline constexpr std::string_view MCPP_VERSION = "2026.8.3.3"; struct FingerprintInputs { Toolchain toolchain; diff --git a/src/toolchain/triple.cppm b/src/toolchain/triple.cppm index a84c7d12..676ae858 100644 --- a/src/toolchain/triple.cppm +++ b/src/toolchain/triple.cppm @@ -173,6 +173,74 @@ namespace pins { inline constexpr std::string_view kSuggestGccMingw = "gcc 16.1.0"; } // namespace pins +// ── Artifact naming conventions ────────────────────────────────────────────── +// +// How a built artifact is NAMED is a property of the TARGET, never of the +// machine doing the build. `mcpp::platform::{exe_suffix,lib_prefix,…}` answer a +// different question — "what does THIS machine call its own binaries" — and +// using them to name build outputs is wrong the moment host != target. +// +// It is a function of (os, env), not of os alone. The trap: +// +// x86_64-windows-gnu → libfoo.a (GNU/mingw convention) +// x86_64-windows-msvc → foo.lib (MSVC convention) +// +// A single `_WIN32` branch cannot express that, which is why building a static +// library with mingw ON a Windows host produces `foo.lib` today — a GNU archive +// wearing an MSVC name. That is a pre-existing defect, unrelated to cross +// compilation. +// +// See .agents/docs/2026-08-03-b3-target-aware-artifact-naming.md. +struct ArtifactNaming { + std::string_view exeSuffix; // "" | ".exe" + std::string_view libPrefix; // "lib" | "" + std::string_view staticLibExt; // ".a" | ".lib" + std::string_view sharedLibExt; // ".so" | ".dylib" | ".dll" + // PE consumers link against an import library, not the .dll itself. mcpp + // does not model import libraries yet, so this currently marks "shared + // libraries are not supported for this target" rather than describing a + // produced artifact. Shared libraries have never been verified end-to-end + // on PE or Mach-O — every shared-library e2e declares `# requires: elf`. + bool sharedNeedsImportLib; +}; + +// Naming for an explicit target triple. An EMPTY triple means "build for this +// machine", and only then is the host answer the correct one — so the caller +// passes it in rather than this module reaching for mcpp::platform, which keeps +// the decision testable from any host (and keeps this module dependency-free). +inline ArtifactNaming artifact_naming(const Triple& t, const ArtifactNaming& hostNaming) { + if (t.empty()) return hostNaming; + + if (t.os == "windows") { + // PE. The static-library convention splits on env, not on os. + const bool msvc = t.is_msvc_env(); + return ArtifactNaming{ + .exeSuffix = ".exe", + .libPrefix = msvc ? "" : "lib", + .staticLibExt = msvc ? ".lib" : ".a", + .sharedLibExt = ".dll", + .sharedNeedsImportLib = true, + }; + } + if (t.os == "macos") { + return ArtifactNaming{ + .exeSuffix = "", .libPrefix = "lib", + .staticLibExt = ".a", .sharedLibExt = ".dylib", + .sharedNeedsImportLib = false, + }; + } + if (t.os == "linux") { + return ArtifactNaming{ + .exeSuffix = "", .libPrefix = "lib", + .staticLibExt = ".a", .sharedLibExt = ".so", + .sharedNeedsImportLib = false, + }; + } + // Outside the triple language: fall back to the host answer rather than + // guessing. A wrong guess here silently misnames every artifact. + return hostNaming; +} + } // namespace mcpp::toolchain::triple namespace mcpp::toolchain::triple { diff --git a/tests/e2e/183_cross_no_relink.sh b/tests/e2e/183_cross_no_relink.sh new file mode 100755 index 00000000..14ea5f8f --- /dev/null +++ b/tests/e2e/183_cross_no_relink.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# requires: mingw-cross +# +# A cross build must not relink on every invocation. +# +# plan.cppm's target_output() used to spell the artifact suffix from +# mcpp::platform::exe_suffix — a HOST constant. Cross-compiling from Linux to +# a PE target, that yields `bin/foo` while mingw's GCC driver writes +# `bin/foo.exe`, so the file ninja was told to produce never appears. ninja +# finds the declared output missing on every run and reruns the link edge +# forever: incremental builds are effectively off for PE targets. +# +# The symptom is invisible to the other cross tests because they look for the +# REAL artifact (`find -name '*.exe'`), not for what ninja declared — both of +# their assertions hold while the inconsistency sits underneath them. +# +# See .agents/docs/2026-08-03-b3-target-aware-artifact-naming.md. +set -euo pipefail + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT +cd "$TMP" + +mkdir -p src +cat > mcpp.toml <<'EOF' +[package] +name = "relinkprobe" +version = "0.1.0" +EOF + +cat > src/main.cpp <<'EOF' +int main() { return 0; } +EOF + +TRIPLE=x86_64-windows-gnu + +"$MCPP" build --target "$TRIPLE" > "$TMP/build1.log" 2>&1 || { + echo "first cross build failed:"; cat "$TMP/build1.log"; exit 1; } + +# The produced artifact — whatever it is actually called. +ART="$(find "target/$TRIPLE" -type f -path '*/bin/*' -name 'relinkprobe*' | head -1)" +[[ -n "$ART" ]] || { echo "no artifact produced under target/$TRIPLE"; exit 1; } + +# ── The declared ninja output must be the file that actually gets written ──── +# This is the root assertion. Positive grep on purpose: `! cmd | grep` is exempt +# from errexit and can never fail. +NINJA="$(find "target/$TRIPLE" -name build.ninja | head -1)" +[[ -n "$NINJA" ]] || { echo "no build.ninja found"; exit 1; } + +DECLARED="$(grep -oE '^build [^:]*bin/relinkprobe[^ :]*' "$NINJA" | head -1 | sed 's/^build //' | tr -d ' ')" +[[ -n "$DECLARED" ]] || { echo "no link edge for relinkprobe in build.ninja"; exit 1; } + +BUILDDIR="$(dirname "$NINJA")" +if [[ ! -f "$BUILDDIR/$DECLARED" ]]; then + echo "FAIL: ninja declares output '$DECLARED' but that file does not exist" + echo " actually produced: $(basename "$ART")" + echo " => the link edge can never be satisfied, so it reruns every build" + exit 1 +fi + +# ── And the observable consequence: a second build must not relink ────────── +T1="$(stat -c %Y "$ART" 2>/dev/null || stat -f %m "$ART")" +sleep 1 # ensure a relink would be visible at 1s mtime granularity +"$MCPP" build --target "$TRIPLE" > "$TMP/build2.log" 2>&1 || { + echo "second cross build failed:"; cat "$TMP/build2.log"; exit 1; } +T2="$(stat -c %Y "$ART" 2>/dev/null || stat -f %m "$ART")" + +if [[ "$T1" != "$T2" ]]; then + echo "FAIL: artifact was relinked on an up-to-date rebuild ($T1 -> $T2)" + echo " ninja declared: $DECLARED" + echo " produced: $(basename "$ART")" + exit 1 +fi + +echo "OK: cross build declares the artifact it actually produces, and does not relink" diff --git a/tests/unit/test_artifact_naming.cpp b/tests/unit/test_artifact_naming.cpp new file mode 100644 index 00000000..f1ba7794 --- /dev/null +++ b/tests/unit/test_artifact_naming.cpp @@ -0,0 +1,125 @@ +#include + +import std; +import mcpp.platform; +import mcpp.toolchain.triple; + +// How a built artifact is NAMED is a property of the TARGET, never of the +// machine doing the build. +// +// mcpp used to answer it with mcpp::platform::{exe_suffix,lib_prefix, +// static_lib_ext,shared_lib_ext} — host constants selected by +// `#if defined(_WIN32)/__APPLE__`. On a host build the two questions coincide, +// which is why this survived; it only diverges once host != target. +// +// The host answer is threaded in as a parameter precisely so these can be +// pinned from any host: the `hostNaming` argument models "what the build +// machine would have said", and every assertion below that passes a deliberately +// wrong one is checking that the target answer wins. + +namespace { + +namespace tr = mcpp::toolchain::triple; + +// A deliberately WRONG host answer. If any assertion below leaks through to it, +// the target axis is not being honoured. +constexpr tr::ArtifactNaming kBogusHost{ + .exeSuffix = ".HOST", .libPrefix = "HOST", .staticLibExt = ".HOST", + .sharedLibExt = ".HOST", .sharedNeedsImportLib = false, +}; + +tr::Triple T(std::string_view s) { + auto t = tr::parse(s); + return t ? *t : tr::Triple{}; +} + +// ── The regression: naming must not come from the build host ──────────────── + +TEST(ArtifactNaming, LinuxTargetIgnoresHostAnswer) { + auto n = tr::artifact_naming(T("x86_64-linux-musl"), kBogusHost); + EXPECT_EQ(n.exeSuffix, ""); + EXPECT_EQ(n.libPrefix, "lib"); + EXPECT_EQ(n.staticLibExt, ".a"); + EXPECT_EQ(n.sharedLibExt, ".so"); + EXPECT_FALSE(n.sharedNeedsImportLib); +} + +TEST(ArtifactNaming, MacosTargetIgnoresHostAnswer) { + auto n = tr::artifact_naming(T("aarch64-macos"), kBogusHost); + EXPECT_EQ(n.exeSuffix, ""); + EXPECT_EQ(n.libPrefix, "lib"); + EXPECT_EQ(n.staticLibExt, ".a"); + EXPECT_EQ(n.sharedLibExt, ".dylib"); + EXPECT_FALSE(n.sharedNeedsImportLib); +} + +// ── The (os, env) split — the part a single _WIN32 branch cannot express ───── +// +// windows-gnu uses the GNU convention. mcpp names this `foo.lib` today even on +// a Windows host, so mingw's `ar` emits a GNU archive wearing an MSVC name. +// That is a pre-existing defect, independent of cross compilation. +TEST(ArtifactNaming, WindowsGnuUsesGnuConvention) { + auto n = tr::artifact_naming(T("x86_64-windows-gnu"), kBogusHost); + EXPECT_EQ(n.exeSuffix, ".exe"); + EXPECT_EQ(n.libPrefix, "lib"); // NOT "" + EXPECT_EQ(n.staticLibExt, ".a"); // NOT ".lib" + EXPECT_EQ(n.sharedLibExt, ".dll"); + EXPECT_TRUE(n.sharedNeedsImportLib); +} + +TEST(ArtifactNaming, WindowsMsvcUsesMsvcConvention) { + auto n = tr::artifact_naming(T("x86_64-windows-msvc"), kBogusHost); + EXPECT_EQ(n.exeSuffix, ".exe"); + EXPECT_EQ(n.libPrefix, ""); + EXPECT_EQ(n.staticLibExt, ".lib"); + EXPECT_EQ(n.sharedLibExt, ".dll"); + EXPECT_TRUE(n.sharedNeedsImportLib); +} + +// The legacy mingw spelling must resolve identically to the canonical one — +// the triple parser is the single source of truth, not a substring match. +TEST(ArtifactNaming, LegacyMingwSpellingMatchesCanonical) { + auto legacy = tr::artifact_naming(T("x86_64-w64-mingw32"), kBogusHost); + auto canonical = tr::artifact_naming(T("x86_64-windows-gnu"), kBogusHost); + EXPECT_EQ(legacy.exeSuffix, canonical.exeSuffix); + EXPECT_EQ(legacy.libPrefix, canonical.libPrefix); + EXPECT_EQ(legacy.staticLibExt, canonical.staticLibExt); + EXPECT_EQ(legacy.sharedLibExt, canonical.sharedLibExt); +} + +// ── Host target: the one case where the host answer IS correct ────────────── + +TEST(ArtifactNaming, EmptyTripleFallsBackToHost) { + auto n = tr::artifact_naming(tr::Triple{}, kBogusHost); + EXPECT_EQ(n.exeSuffix, ".HOST"); + EXPECT_EQ(n.libPrefix, "HOST"); +} + +// An unparseable triple must not be guessed at — a wrong guess here silently +// misnames every artifact of the build. +TEST(ArtifactNaming, UnknownOsFallsBackToHost) { + tr::Triple wasm; wasm.arch = "wasm32"; wasm.os = "unknown"; + auto n = tr::artifact_naming(wasm, kBogusHost); + EXPECT_EQ(n.exeSuffix, ".HOST"); + EXPECT_EQ(n.staticLibExt, ".HOST"); +} + +// ── Host builds must be bit-for-bit unchanged ─────────────────────────────── +// Passing the real host constants for the host target has to reproduce exactly +// what the old code produced on this machine. +TEST(ArtifactNaming, RealHostConstantsRoundTrip) { + const tr::ArtifactNaming host{ + .exeSuffix = mcpp::platform::exe_suffix, + .libPrefix = mcpp::platform::lib_prefix, + .staticLibExt = mcpp::platform::static_lib_ext, + .sharedLibExt = mcpp::platform::shared_lib_ext, + .sharedNeedsImportLib = mcpp::platform::is_windows, + }; + auto n = tr::artifact_naming(tr::Triple{}, host); + EXPECT_EQ(n.exeSuffix, mcpp::platform::exe_suffix); + EXPECT_EQ(n.libPrefix, mcpp::platform::lib_prefix); + EXPECT_EQ(n.staticLibExt, mcpp::platform::static_lib_ext); + EXPECT_EQ(n.sharedLibExt, mcpp::platform::shared_lib_ext); +} + +} // namespace