diff --git a/.agents/docs/2026-08-18-open-items-analysis-and-axis-discipline.md b/.agents/docs/2026-08-18-open-items-analysis-and-axis-discipline.md new file mode 100644 index 00000000..3c643dc9 --- /dev/null +++ b/.agents/docs/2026-08-18-open-items-analysis-and-axis-discipline.md @@ -0,0 +1,192 @@ +# 四项遗留的统一分析:判据挂错轴,以及 cl.exe 到底怎么办 + +> 2026-08-18 · 针对 2026.8.18.2 发布后列出的四项遗留 +> 状态:**分析与方案,代码已就绪但未合入(分支 `fix/cl-exe-consumption`)** + +先说结论,因为它比四项本身重要: + +> **四项里有三项是同一个形状** —— 一个决定被挂在了错误的轴上。 +> 而第四项(`.md` 硬失败)是刻意的破坏性变更,它的正确性恰恰来自 +> 「不要再让分类不出的东西静默通过」。 + +--- + +## 一、`cl.exe` 消费:它已经能用了,是文档没跟上 + +### 1.1 为什么曾经不行 + +生成的 manifest 用 GNU 拼写描述每条腿: + +```toml +[target.'cfg(…, env = "msvc")'.build] +ldflags = ["-Llib/x86_64-windows-msvc", "-lmathkit"] +``` + +mcpp 用到的每个**编译器驱动**都吃这一套 —— 包括 Windows 默认的、面向 MSVC ABI +的 clang。**原生 `cl.exe` 不吃**:它在第一个 `-L` 上就停下。 + +### 1.2 工业界怎么做 + +调研的结论出奇一致:**没有任何一个包管理器分发「一条命令行」。** +它们分发的是**抽象的链接意图**,由消费方的构建系统渲染成命令行。 + +| 方案 | 包里存的是什么 | 谁负责渲染 | +|---|---|---| +| **pkg-config** | `.pc` 文件:`Libs: -L${libdir} -lfoo` | `pkg-config --libs` 展开变量;Unix 生态,MSVC 上要靠 pkgconf + 手工桥接 | +| **CMake config-file package** | `fooConfig.cmake` + imported target,`INTERFACE_LINK_LIBRARIES` 里是**绝对路径或 target 名** | CMake 按当前 generator/toolchain 生成 `link.exe` 或 `ld` 的命令行 | +| **vcpkg / Conan** | 上游自己的头与库 + **generator** 产物 | 按消费方生成 `.props`(MSBuild)、`.cmake`、`.pc` —— 一份意图,多种渲染 | +| **MSBuild `.props`** | `` / `` | MSBuild 渲染成 `/LIBPATH:` + `x.lib` | + +**共同点:库名与库目录是两个字段,不是两个 flag。** 谁把它变成 flag,取决于 +最终调用的是哪个程序。 + +### 1.3 mcpp 的答案(上一轮已实现) + +同一条腿再写一遍,这一遍不带方言: + +```toml +[target.'cfg(…, env = "msvc")'.runtime] +link_library_dirs = ["lib/x86_64-windows-msvc"] +libraries = ["mathkit"] +``` + +`render_link_intent_flags` 按 flavor 渲染成 `/LIBPATH:` + `.lib` 或 +`-L` + `-l`。**这不是新词表** —— `[runtime]` 顶层一直有这两个键,这里只是让 +它们可以按 target 给,与 CMake 的 imported target、vcpkg 的 generator 是同一个思路。 + +**两种拼写都写出来**:旧版 mcpp 只读 `ldflags` 并静默忽略新段,去掉它会让所有 +旧客户端一个链接 flag 都拿不到;新版读到中立形式时**丢掉同腿的库引用**而不是叠加。 + +### 1.4 所以「❌」是文档缺陷,不是产品缺陷 + +docs/12 的边界表仍写着 `consuming a package with native cl.exe ❌ see below`, +而同一份文档的正文已经在描述实现好的方案。**正文更新了,表格没有** —— +这正是 `mcpp-docs-style` 里「断言强度必须与证据相符」要防的那一类。 + +### 1.5 ⚠️ 一个反直觉的发现:这一处**方言才是对的轴** + +上一轮我修了三个「挂错轴」的 flag(`-fPIC`、`--out-implib`、`/DEF:`), +它们都应该按**目标 ABI** 判定。于是很容易顺手认为 `LinkIntentFlavor` 的选择 +(`if (isMsvcDialect) return PeMsvc;`)是第四处同样的错误。 + +**它不是。** 区别在于这些 flag 交给谁: + +| flag | 交给谁 | 因此判据是 | +|---|---|---| +| `-fPIC` | 编译器,但语义属于目标格式 | **目标格式**(PE 不需要) | +| `--out-implib` / `/IMPLIB:` | 链接器(经 `-Wl,`) | **目标 ABI**(lld-link vs ld) | +| `/DEF:` | 链接器 | **目标 ABI** | +| `-L` / `/LIBPATH:` | **mcpp 直接调用的那个程序** | **方言**(= 是否 `SeparateLinker`) | + +clang 面向 MSVC ABI 时,产物是 MSVC ABI 的,但**它自己是一个编译器驱动**, +只吃 `-L`。所以按 ABI 判会把它错误地喂成 `/LIBPATH:`。 + +**判据:先问「这个 flag 最终被谁解析」,再决定挂哪根轴。** +已把这条写成单测(`test_link_intent_spelling.cpp`),并在其中点名 clang-on-MSVC +是分开这两个问题的那个反例。 + +> 顺带:写这条单测时我自己先断言错了 —— 找 `/LIBPATH:` 而实际输出是 +> `/LIBPATH$:`(**ninja 转义**,不是 shell 命令行)。渲染是对的,断言是错的。 + +--- + +## 二、第三处同族缺陷(本次分析中发现,已控制对照证实) + +上一轮修好了 `mcpp pack` 的 lib-root 解析(按声明的扩展名探测),但 +**非探测版本还有两个调用点,而它们都应该探测**: + +| 位置 | 后果 | +|---|---| +| `prepare.cppm:4488` — host-module 依赖 | 依赖的接口若是 `.ixx`,解析到不存在的 `src/.cppm`,消费方的 `build.mcpp` 拿到一个指向空的路径 | +| `validate.cppm:134` — lib root 存在性检查 | `.ixx` 工程每次构建都收到**虚假警告** | + +**控制对照(用刚发布的 2026.8.18.2 二进制,对同一个 `.ixx` 工程):** + +``` +warning: src/mathkit.cppm: lib target without conventional lib root + 'src/mathkit.cppm' (create the file or set [lib].path) +``` + +修复后该警告消失,构建不变。 + +**这说明「修了主路径」不等于「修了这个决定」** —— 同一个问题有 N 个调用点时, +只改自己正在测的那一个,剩下的会在别人的工程里显形。 + +--- + +## 三、四项遗留,逐项分析 + +### 3.1 `cl.exe` 端到端未验证 → **可关闭**(方案已就绪) + +- 渲染侧:`test_link_intent_spelling.cpp`,4 条,**三平台都跑**; +- 端到端:`e2e 262`,`# requires: msvc`,消费方**钉死 `msvc@system`** —— + 这一点是关键:如果中立形式被忽略而 ldflags 生效,clang 消费者**照样能过**, + 只有 cl 会因为一个 `-L` 而失败,所以只有它能证明这件事。 +- 262 还断言**生成的图里没有该腿的 `-L`**:「跑通了」也可能是 cl 恰好容忍。 + +### 3.2 数据符号需要 `dllimport` → **应做成可复现,而不是继续写在文档里** + +现状:docs/12 记录了这条限制(与 CMake 为同一机制记录的一致),但没有测试。 +问题在于**它是一条关于「什么不工作」的断言**,而这类断言最容易随实现漂移 —— +哪天自动 `.def` 学会了给数据符号加 `DATA`(它已经加了),没人会想起来复核 +这条限制是否仍然成立、以及**成立到什么程度**。 + +方案:在 `e2e 258` 里加一对 fixture: + +| 消费方声明 | 期望 | +|---|---| +| 不写 `__declspec(dllimport)` 读导出变量 | **失败或读到错值** —— 把限制钉住 | +| 写了 `dllimport` | 通过 | + +⚠️ 这条要小心写:「失败」的具体形态(链接错 vs 读到桩地址)取决于工具链版本, +断言必须钉**可观测的差异**(两者行为不同),而不是钉某一条错误文本。 + +### 3.3 `.md` 在 `sources` 里现在硬失败 → **保留,但这是需要明说的破坏性变更** + +三个选项: + +| 选项 | 代价 | +|---|---| +| **硬失败(现状)** | 把 `.md` 放进 `sources` 的工程会报错。消息点名文件、扩展名与该写的键 | +| 警告并忽略 | **回到原点** —— 「编译出一个没人链接的对象」正是被警告忽略掉的那种失败 | +| 只对已知非编译扩展名(`.md`/`.txt`)放行 | 需要维护一张「哪些扩展名可以被静默忽略」的表,而这张表永远不完整 | + +**保留硬失败。** 理由是这条缺陷的形状:它不是「多编了一个文件」,而是 +**「编了但没链」**,报错落在一个模块修饰过的 `undefined reference` 上。 +警告在这里没有力量 —— 构建仍然会失败,只是失败得更晚更远。 + +补充动作:CHANGELOG 已标注 ⚠️;**建议再在 docs/05 的 `sources` 一节写明** +「`sources` 的每一项都必须能产出被链接的对象」,把它变成一条可引用的规则。 + +### 3.4 探测式解析器放在 `mcpp.manifest.toml` → **接受,并且它本来就更对** + +事实:给 `mcpp.manifest.types`(几乎所有东西都依赖的低层模块)加一条 +`import mcpp.source_kind` 之后,GCC 16.1 在编译**与改动无关的 `src/main.cpp`** +时 ICE,清 gcm.cache 无效。 + +但把这件事只记成「被编译器逼的」是不完整的: + +- `mcpp.manifest.types` 的职责是**数据模型**,注释里写着「No parsing lives here」; +- **探测文件系统不是数据模型的职责**。 + +所以这个位置在架构上本来就更对,编译器只是先一步告诉了我们。 +**留下的真实代价是这一族被拆成两个模块**,而 §2 里那两个漏网的调用点正是 +这种拆分容易漏人的证据 —— 已修,并在两处都写明了为什么用探测形式。 + +--- + +## 四、优化方案(按依赖排序) + +| # | 内容 | 状态 | +|---|---|---| +| **P-A** | `render_link_intent_flags` 的方言判据单测(含 clang-on-MSVC 反例) | **已写,三平台通过** | +| **P-B** | `e2e 262`:原生 `cl.exe` 消费打包库,并断言图里没有该腿的 `-L` | **已写,待 Windows CI** | +| **P-C** | docs/12 边界表改 ✅(中英) | **已改** | +| **P-D** | `prepare.cppm` / `validate.cppm` 两处改用探测式解析 | **已改,控制对照证实** | +| **P-E** | `e2e 258` 增加 data-symbol 的 `dllimport` 对照 | 待做 | +| **P-F** | docs/05 写明「`sources` 的每一项都要能产出被链接的对象」 | 待做 | +| **P-G** | 把「flag 挂哪根轴」写成一张表,放进 docs/08 §7.4 | 待做 | + +**P-G 是这份分析里最有复用价值的一条**:本轮四个 flag 分别属于三根不同的轴, +而每一次挂错都表现为「在某一个平台上莫名其妙地失败」。把轴写下来, +下一个加 flag 的人就不必重新踩一遍。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 85c44c12..5345c966 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,57 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [2026.8.18.3] — 2026-08-18 + +### 新增 + +- **原生 `cl.exe` 可以消费打包库了 —— 而且这条此前就已可用,是文档没跟上。** + + 机制(方言中立的 `[target..runtime]`)在 2026.8.18.2 就落地了,而 + docs/12 的边界表仍写着 ❌,同一份文档的正文却在描述解决方案。现在两端都验证了: + 渲染侧有可移植单测,端到端的 e2e **消费方钉死 `msvc@system`** —— 这一点是判据: + 若中立形式被忽略而 `ldflags` 生效,clang 消费者**照样能过**,只有 cl 会因为 + 一个 `-L` 失败,所以只有它能证明这件事。e2e 还断言生成的图里**没有**该腿的 `-L`。 + +- **「一个 flag 由哪根轴决定」写进了 docs/08 §7.5。** + + 2026.8.18 那一轮改的四个 flag 分属**三根不同的轴**(目标格式 / 目标 ABI / 方言), + 而每次挂错的表现都相同:**在恰好一个平台上莫名其妙地失败**,报错既不点名那个 + flag,也不点名它背后的决定。 + + ⚠️ 其中一条是反直觉的:`-L` vs `/LIBPATH:` **按方言判才是对的** —— + 它交给的是 mcpp 直接调用的那个程序,而不是链接器。面向 MSVC ABI 的 clang + 是同时区分这三根轴的反例:它说 GNU 方言、产 MSVC ABI 对象、出 PE 映像。 + +### 修复 + +- **lib root 约定在**所有**调用点跟随已声明的扩展名。** + + 上一轮只修了打包器用的那个解析器,另外两个调用点仍是非探测版本 —— + **而「修了主路径」不等于「修了这个决定」**: + + - `validate.cppm`:`.ixx` 工程每次构建都收到**虚假警告**。控制对照(用已发布的 + 2026.8.18.2 二进制跑同一工程): + `warning: src/mathkit.cppm: lib target without conventional lib root`; + - `prepare.cppm`:接口是 `.ixx` 的 host-module 依赖被交出一个指向不存在文件的路径。 + + e2e 263 **按调用点各钉一条**,并带负向对照(真的缺 lib root 时仍须告警), + 否则这条测试对「验证器干脆不检查了」也会通过。 + +- **host-module 依赖不再收到虚假的 `module_extensions` 死条目告警。** + 这类依赖的源码 glob 是**被刻意清空**的(那正是把构建规则挡在消费者二进制之外的 + 机制),于是它声明的每个扩展名都显得是死的 —— 规则包作者会在每个消费者的构建里 + 看到一条关于自己**正确** manifest 的告警,而且无从修起。 + +### 文档 + +- docs/05 增加一条可引用的规则:**`sources` 匹配到的每一项都必须产出会被链接的对象** + —— 让 2026.8.18.2 引入的硬失败有出处,而不是凭空多出一条禁令。 + 中文版此前连 `sources = []` 那条注记都没有,一并补齐。 +- docs/12 的边界表改为 ✅(中英);MSVC **数据符号仍需 `dllimport`** 这条限制 + 由 e2e 258 **做成可复现对照**,不再只是散文 —— 断言钉的是「有/无 `dllimport` + 行为不同」,而不是某条随工具链版本变化的报错文本。 + ## [2026.8.18.2] — 2026-08-18 ### 新增 diff --git a/docs/05-mcpp-toml.md b/docs/05-mcpp-toml.md index e5facc93..58337318 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -153,6 +153,16 @@ the package/feature boundary, not on an individual target. ### 2.3 `[build]` — Build Configuration +> **Every entry `sources` matches must produce an object that gets linked.** A +> file mcpp cannot place — an extension outside the built-ins and outside +> `module_extensions` — is refused, naming the file, the extension and the key. +> It is not ignored, because the failure that produced this rule was not "one +> file too many" but *compiled and then linked by nobody*: the scanner reads +> `export module` and gives the edge a BMI while the classifier says the file has +> no role, and what the author sees is `undefined reference` to a module-mangled +> symbol. Headers belong in `include_dirs`; Windows resource scripts in +> `[resources]`. + > **`sources = []` is not the same as omitting `sources`.** An absent key > selects the default glob; an explicitly empty list means *compile nothing*, > which is what a header-only distribution package needs to say. Until diff --git a/docs/08-toolchain-internals.md b/docs/08-toolchain-internals.md index 519071d4..c6d1d17d 100644 --- a/docs/08-toolchain-internals.md +++ b/docs/08-toolchain-internals.md @@ -587,6 +587,38 @@ question "can this machine produce it", and `prepare.cppm` now asks it — with explicit `[target.X] toolchain = "…"` as the escape hatch for a cross toolchain supplied by the author. +### 7.5 Which axis decides a flag + +Four flags changed in the 2026.8.18 round, and each had been keyed on the wrong +axis. Every one of those mistakes showed up the same way: an inexplicable +failure on exactly one platform, with a message that named neither the flag nor +the decision behind it. + +There are three axes, and the question that picks between them is **who finally +reads this flag**. + +| axis | the question | examples | how it is asked | +|---|---|---|---| +| **target format** | what kind of image is produced | `-fPIC` (PE code is position independent by design; clang refuses the flag outright) | `triple::parse(...)->is_pe()`, host fallback | +| **target ABI** | which linker will consume this | `--out-implib` vs `/IMPLIB:`, `/DEF:`, the SONAME / install-name form | `is_msvc_target(tc)`, `triple->is_msvc_env()` | +| **dialect** | which program mcpp is invoking | `-L` vs `/LIBPATH:`, `-I` vs `/I`, the archive command | `dialect_for(tc)`, `LinkStyle::SeparateLinker` | + +**Clang targeting the MSVC ABI is the case that separates all three.** It speaks +the GNU dialect, produces MSVC-ABI objects, and emits a PE image. Ask it the +wrong question and: + +- keyed on the dialect, it is handed `-Wl,--out-implib` and lld-link answers + `ignoring unknown argument` followed by a missing file; +- keyed on the ABI, it is handed `/LIBPATH:`, which a compiler driver does not + take; +- keyed on the compiler binary, it is handed `-fPIC` and refuses to run at all. + +The failure mode is always the same shape: the flag is spelled for a +neighbouring platform, and the diagnostic comes from a program three steps away +from the decision. `ninja_backend`'s `pe_link_flag` is where the linker-facing +answers live; the dialect table says, where its entry used to be, why it cannot +answer them. + ## 8. Source map | Concern | File | diff --git a/docs/12-binary-distribution.md b/docs/12-binary-distribution.md index 6b349d60..6373a778 100644 --- a/docs/12-binary-distribution.md +++ b/docs/12-binary-distribution.md @@ -268,7 +268,7 @@ package published to a mixed-version audience. | `kind = "shared"` on `*-musl` | ❌ a musl target links statically | | shipping prebuilt BMIs | ❌ not attempted; BMIs are compiler-build-exact | | bundling dependencies into the package | ❌ declare them instead (above) | -| consuming a package with **native `cl.exe`** | ❌ see below | +| consuming a package with **native `cl.exe`** | ✅ — via the neutral link intent; see below | ### Exports on the MSVC ABI diff --git a/docs/zh/05-mcpp-toml.md b/docs/zh/05-mcpp-toml.md index fabdcf31..fb33fd5b 100644 --- a/docs/zh/05-mcpp-toml.md +++ b/docs/zh/05-mcpp-toml.md @@ -146,6 +146,19 @@ mcpp 刻意不在一次构建里把同一个共享源编译成两份:一个源 ### 2.3 `[build]` — 构建配置 +> **`sources` 匹配到的每一项都必须产出一个会被链接的对象。** mcpp 放不下的文件 —— +> 扩展名既不在内建表也不在 `module_extensions` 里 —— 会被拒绝,并点名文件、 +> 扩展名与该写的键。**不是忽略**:催生这条规则的失败不是「多编了一个文件」, +> 而是**编了却没人链** —— 扫描器读到 `export module` 就给那条边挂了 BMI, +> 而分类器说这个文件没有角色,作者看到的是一条模块修饰过的 `undefined reference`。 +> 头文件应放进 `include_dirs`,Windows 资源脚本放进 `[resources]`。 + +> **`sources = []` 与不写 `sources` 不是一回事。** 不写这条键选择默认 glob; +> 显式的空列表意味着**什么都不编** —— 那正是一个纯头文件的分发包需要表达的。 +> 在 mcpp 2026.8.18.1 之前两者逐字节等价,于是「什么都不编」无从表达, +> `src/` 下剩下的任何文件都会被扫进来。 + + ```toml [build] sources = ["src/**/*.cppm", "src/**/*.cpp"] # 源文件 glob(默认: src/**/*.{cppm,cpp,cc,c,S,s,asm}) diff --git a/docs/zh/08-toolchain-internals.md b/docs/zh/08-toolchain-internals.md index 867ffd67..3f20ae8f 100644 --- a/docs/zh/08-toolchain-internals.md +++ b/docs/zh/08-toolchain-internals.md @@ -455,6 +455,32 @@ C 世界(`CLibMode::Sysroot`)并有自己的 libc++ 链接处理;Windows 没有 mcpp 把运行时 DLL 部署到产物 exe 旁,这正是该平台对 §3–§4 所做一切的原生 等价物。 +### 7.5 一个 flag 由哪根轴决定 + +2026.8.18 那一轮改了四个 flag,每一个此前都挂在错误的轴上。而这类错误的表现 +永远相同:**在恰好一个平台上莫名其妙地失败**,报错既不点名那个 flag, +也不点名它背后的决定。 + +一共三根轴,而在它们之间做选择的问题是:**这个 flag 最终被谁读到。** + +| 轴 | 问题 | 例子 | 怎么问 | +|---|---|---|---| +| **目标格式** | 产出的是哪种映像 | `-fPIC`(PE 代码本就位置无关;clang 直接拒绝这个 flag) | `triple::parse(...)->is_pe()`,宿主兜底 | +| **目标 ABI** | 哪个链接器会消费它 | `--out-implib` vs `/IMPLIB:`、`/DEF:`、SONAME / install-name 的形式 | `is_msvc_target(tc)`、`triple->is_msvc_env()` | +| **方言** | mcpp 直接调用的是哪个程序 | `-L` vs `/LIBPATH:`、`-I` vs `/I`、归档命令 | `dialect_for(tc)`、`LinkStyle::SeparateLinker` | + +**面向 MSVC ABI 的 clang 是同时区分这三根轴的那个反例。** 它说 GNU 方言、 +产出 MSVC ABI 的对象、生成 PE 映像。问错了轴就会: + +- 按**方言**判 ⇒ 拿到 `-Wl,--out-implib`,lld-link 回 + `ignoring unknown argument`,随后是「文件不存在」; +- 按**ABI** 判 ⇒ 拿到 `/LIBPATH:`,而编译器驱动不认; +- 按**编译器二进制**判 ⇒ 拿到 `-fPIC`,直接拒绝运行。 + +失败形状总是同一个:flag 按邻近平台的拼法发出去,而报错来自离那个决定三步远的 +另一个程序。面向链接器的答案集中在 `ninja_backend` 的 `pe_link_flag`; +方言表在原来那个条目的位置写明了为什么它答不了这些问题。 + ## 8. 源码地图 | 关注点 | 文件 | diff --git a/docs/zh/12-binary-distribution.md b/docs/zh/12-binary-distribution.md index a7692ffb..4a636872 100644 --- a/docs/zh/12-binary-distribution.md +++ b/docs/zh/12-binary-distribution.md @@ -243,7 +243,7 @@ ldflags = ["-Llib/x86_64-linux-musl", "-lmathkit"] | `kind = "shared"` on `*-musl` | ❌ musl target 是静态链接的 | | 发布预编译 BMI | ❌ 未尝试;BMI 与编译器构建逐位绑定 | | 把依赖打包进去 | ❌ 改为声明依赖(见上) | -| 用**原生 `cl.exe`** 消费这种包 | ❌ 见下 | +| 用**原生 `cl.exe`** 消费这种包 | ✅ —— 经方言中立的链接意图;见下 | ### MSVC ABI 上的符号导出 diff --git a/mcpp.toml b/mcpp.toml index 24fb84b8..1a84aee2 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.8.18.2" +version = "2026.8.18.3" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/build/hostprogram.cppm b/src/build/hostprogram.cppm index b49c710c..72bbb07d 100644 --- a/src/build/hostprogram.cppm +++ b/src/build/hostprogram.cppm @@ -438,12 +438,53 @@ build_host_module(const fs::path& bdir, const fs::path& compiler, return out; } + // The interface's LANGUAGE, stated rather than inferred from its extension. + // + // ⚠️ Measured on macOS CI: a rule package whose lib root is `rulepkg.ixx` + // made `clang++ --precompile rulepkg.ixx -o rulepkg.pcm` EXIT 0 AND WRITE + // NOTHING — clang's driver does not recognise `.ixx`, so it treated the file + // as a linker input, warned that it was unused, and succeeded. The failure + // surfaced one step later as `no such file or directory: …/rulepkg.pcm`, + // naming an output rather than the input that was never read. + // + // Every other module compile in mcpp already says this (BmiTraits:: + // moduleInterfaceLangFlag — `/interface /TP`, `-x c++-module`, `-x c++`); + // the host-module path was the one place that still let the driver guess. + // It is positional on GNU-style drivers, so it goes immediately before the + // input. + std::vector langArgv; + { + std::string_view lang = traits.moduleInterfaceLangFlag; + for (std::size_t i = 0; i < lang.size(); ) { + while (i < lang.size() && lang[i] == ' ') ++i; + auto j = lang.find(' ', i); + if (j == std::string_view::npos) j = lang.size(); + if (j > i) langArgv.emplace_back(lang.substr(i, j - i)); + i = j; + } + } + if (mcpp::toolchain::is_clang(tc)) { fs::path pcm = bdir / (stem + std::string(traits.bmiExt)); - if (auto r = run(with_base({compiler.string(), stdFlag, "--precompile", - interfacePath.string(), "-o", pcm.string()}), - "precompile"); !r) + std::vector pre{compiler.string(), stdFlag, "--precompile"}; + for (auto const& l : langArgv) pre.push_back(l); + pre.push_back(interfacePath.string()); + pre.push_back("-o"); pre.push_back(pcm.string()); + if (auto r = run(with_base(std::move(pre)), "precompile"); !r) return std::unexpected(r.error()); + // The precompile can succeed and write nothing when the driver ignored + // the input, which is exactly what happened above. Checked here so the + // diagnostic names the interface rather than a missing output. + if (!fs::exists(pcm, ec)) { + return std::unexpected(std::format( + "host module '{}': the compiler accepted '{}' and produced no " + "BMI.\n" + " The interface's language is passed explicitly, so this " + "is not an extension\n" + " the driver failed to recognise — check that the file " + "really is a module interface.", + logicalName, interfacePath.string())); + } if (auto r = run(with_base({compiler.string(), stdFlag, "-c", pcm.string(), "-o", out.object.string()}), "object"); !r) @@ -455,9 +496,11 @@ build_host_module(const fs::path& bdir, const fs::path& compiler, // GCC: BMIs are implicit under /gcm.cache, so nothing to name — which // is also why the compile has to happen in bdir (it already does). - if (auto r = run(with_base({compiler.string(), stdFlag, "-fmodules", "-c", - interfacePath.string(), "-o", out.object.string()}), - "compile"); !r) + std::vector gccArgv{compiler.string(), stdFlag, "-fmodules", "-c"}; + for (auto const& l : langArgv) gccArgv.push_back(l); + gccArgv.push_back(interfacePath.string()); + gccArgv.push_back("-o"); gccArgv.push_back(out.object.string()); + if (auto r = run(with_base(std::move(gccArgv)), "compile"); !r) return std::unexpected(r.error()); out.useFlags = {"-fmodules"}; return out; diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 08d2939b..79e705fa 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -4485,7 +4485,12 @@ prepare_build(bool print_fingerprint, if (pr.provider >= packages.size()) continue; auto const& depPkg = packages[pr.provider]; auto const& canon = depPkg.manifest.package.name; - auto rel = mcpp::manifest::resolve_lib_root_path(depPkg.manifest); + // PROBING form: a host-module dependency whose interface + // is `.ixx` resolves to a `src/.cppm` that does not + // exist, and the consumer's build.mcpp is then handed a + // path to nothing. + auto rel = mcpp::manifest::resolve_lib_root_path( + depPkg.manifest, depPkg.root); hostModulesByConsumer[c].emplace_back(canon, depPkg.root / rel); } } diff --git a/src/modgraph/scanner.cppm b/src/modgraph/scanner.cppm index 2b64fb7a..9a983373 100644 --- a/src/modgraph/scanner.cppm +++ b/src/modgraph/scanner.cppm @@ -998,7 +998,16 @@ void scan_one_into(ScanResult& result, // extension before the first file that uses it is legitimate, and a // package whose `.ixx` sources are all behind an inactive feature would // otherwise fail to build. + // + // ⚠️ And not at all when this package HAS no sources to look at. A + // `host-module = true` dependency has its source globs emptied on purpose — + // that is how a build rule is kept out of the consumer's binary — so every + // one of its declared extensions then looks dead. The author of an `.ixx` + // rule package would see this warning in every consumer's build, about + // their own correct manifest, with nothing to fix. + const bool nothingToScan = all_files.empty(); for (auto const& raw : manifest.buildConfig.moduleExtensions) { + if (nothingToScan) break; auto ext = mcpp::normalize_extension(raw); if (ext.empty()) continue; bool seen = false; diff --git a/src/modgraph/validate.cppm b/src/modgraph/validate.cppm index e8026bca..b7af1193 100644 --- a/src/modgraph/validate.cppm +++ b/src/modgraph/validate.cppm @@ -131,7 +131,13 @@ ValidateReport validate(const Graph& g, // Pure-binary projects (mcpp itself, scaffolded `mcpp new`) skip this // check — they have no lib-root concept. if (mcpp::manifest::has_lib_target(manifest)) { - auto lib_root_rel = mcpp::manifest::resolve_lib_root_path(manifest); + // PROBING when there is a tree to probe: the convention offers one + // candidate per declared module extension, and checking only the + // `.cppm` one warns that an `.ixx` project's lib root is missing when + // it is right there. + auto lib_root_rel = projectRoot.empty() + ? mcpp::manifest::resolve_lib_root_path(manifest) + : mcpp::manifest::resolve_lib_root_path(manifest, projectRoot); const bool was_explicit = !manifest.lib.path.empty(); // On-disk existence check (skipped when projectRoot is empty — diff --git a/src/version.cppm b/src/version.cppm index 884646df..afcca825 100644 --- a/src/version.cppm +++ b/src/version.cppm @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.8.18.2"; +inline constexpr std::string_view MCPP_VERSION = "2026.8.18.3"; } // namespace mcpp diff --git a/tests/e2e/258_shared_library_msvc_auto_def.sh b/tests/e2e/258_shared_library_msvc_auto_def.sh index 14203f0c..98fe952a 100755 --- a/tests/e2e/258_shared_library_msvc_auto_def.sh +++ b/tests/e2e/258_shared_library_msvc_auto_def.sh @@ -157,4 +157,98 @@ anndef="$(find annotated/target -name 'annkit.def' | head -1)" echo " chosen surface with all of it." exit 1; } -echo "PASS: MSVC exports without dllexport, and defers to dllexport when present" +# ── the LIMIT, made reproducible ──────────────────────────────────────── +# +# Auto-export covers code. Exported DATA additionally needs +# `__declspec(dllimport)` on the CONSUMER's declaration — CMake documents the +# same limit for the same mechanism. Until now that lived in prose, and a claim +# about what does NOT work is exactly the kind that drifts: the generated `.def` +# already marks variables `DATA`, so it is reasonable to wonder whether the limit +# still bites. It does, and here is the difference. +# +# ⚠️ The assertion is on the DIFFERENCE between the two spellings, not on any +# particular error text — which form the failure takes (a link error, or a +# pointer read where a value was meant) depends on the toolset version, and +# pinning one of them would make this test a hostage to that. +cd "$TMP" +mkdir -p datalib/src +cat > datalib/src/datalib.cppm <<'EOF' +export module datalib; +export extern "C" int dl_value; +EOF +cat > datalib/src/datalib.cpp <<'EOF' +module datalib; +extern "C" int dl_value = 99; +EOF +cat > datalib/mcpp.toml <<'EOF' +[package] +name = "datalib" +version = "0.1.0" +[build] +sources = ["src/*.cppm", "src/*.cpp"] +[targets.datalib] +kind = "shared" +[toolchain] +windows = "msvc@system" +EOF +( cd datalib && "$MCPP" build > build.log 2>&1 ) || { + cat datalib/build.log; echo "FAIL: the data library did not build"; exit 1; } + +# It IS exported, and marked DATA — so the limit is about the consumer's +# declaration, not about the export. +datadef="$(find datalib/target -name 'datalib.def' | head -1)" +grep -q 'dl_value DATA' "$datadef" || { + cat "$datadef" + echo "FAIL: the exported variable is not marked DATA. Without that the" + echo " linker generates a call thunk and the consumer reads the thunk." + exit 1; } + +consume() { # $1 = declaration, $2 = log + rm -rf "$TMP/dataapp" + mkdir -p "$TMP/dataapp/src" + cat > "$TMP/dataapp/src/main.cpp" < +$1 +int main(){ std::printf("data=%d\n", dl_value); return 0; } +EOF + local dep_host; dep_host="$(host_path "$TMP/datalib")" + cat > "$TMP/dataapp/mcpp.toml" < "$2" 2>&1 ) +} + +consume 'extern "C" int dl_value;' "$TMP/plain.log" && plain_ok=1 || plain_ok=0 +consume 'extern "C" __declspec(dllimport) int dl_value;' "$TMP/imported.log" && imported_ok=1 || imported_ok=0 + +if [[ "$imported_ok" != 1 ]]; then + cat "$TMP/imported.log" + echo "FAIL: even WITH __declspec(dllimport) the data symbol was unusable." + echo " Then the limit documented in docs/12 is not the whole story and" + echo " the documentation needs to say what actually holds." + exit 1 +fi +grep -q 'data=99' "$TMP/imported.log" || { + cat "$TMP/imported.log"; echo "FAIL: dllimport built but read the wrong value"; exit 1; } + +if [[ "$plain_ok" == 1 ]] && grep -q 'data=99' "$TMP/plain.log"; then + cat "$TMP/plain.log" + echo "NOTE: reading exported DATA without __declspec(dllimport) WORKED here." + echo " docs/12 (and CMake's documentation for the same mechanism) say it" + echo " should not. Either this toolset is more forgiving than the" + echo " documented contract, or the limit has stopped applying — and the" + echo " documentation is now the thing that is wrong." + exit 1 +fi +echo " (the limit reproduces: dllimport is required for exported data)" + +echo "PASS: MSVC exports without dllexport, defers to dllexport, and data still needs dllimport" diff --git a/tests/e2e/262_pack_consumed_by_native_cl.sh b/tests/e2e/262_pack_consumed_by_native_cl.sh new file mode 100755 index 00000000..b257e703 --- /dev/null +++ b/tests/e2e/262_pack_consumed_by_native_cl.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# requires: msvc +# 262_pack_consumed_by_native_cl.sh — a packaged library linked by NATIVE cl.exe. +# +# The generated manifest states each leg's link line twice. The first spelling is +# GNU — `-Llib/ -l` — which every compiler DRIVER mcpp uses +# accepts, including clang targeting the MSVC ABI, and which native `cl.exe` +# rejects at the first `-L`. The second is the dialect-neutral pair: +# +# [target.'cfg(…, env = "msvc")'.runtime] +# link_library_dirs = ["lib/x86_64-windows-msvc"] +# libraries = ["mathkit"] +# +# mcpp renders that as `/LIBPATH:` + `.lib` when it invokes link.exe +# directly, and as `-L` + `-l` for every compiler driver. Both spellings +# ship because an older mcpp reads only the first and silently ignores the +# second, so dropping the first would leave every older client with no link line. +# +# ⚠️ WHY BOTH HALVES ARE ASSERTED. The renderer is unit tested +# (tests/unit/test_link_intent_spelling.cpp) and only a real `cl.exe` can say +# whether the result links. But a consumer that merely SUCCEEDS proves less than +# it looks: if the neutral form were ignored and the ldflags applied instead, +# clang would still link it. So the consumer here pins `msvc@system`, which is +# the one toolchain that cannot survive a stray `-L`. +set -e +source "$(dirname "$0")/_host_path.sh" + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +mkdir -p mathkit/src +cat > mathkit/src/mathkit.cppm <<'EOF' +export module mathkit; +export namespace mk { int answer(); } +EOF +cat > mathkit/src/impl.cpp <<'EOF' +module mathkit; +namespace mk { int answer() { return 42; } } +EOF +cat > mathkit/mcpp.toml <<'EOF' +[package] +name = "mathkit" +version = "0.1.0" +[build] +sources = ["src/*.cppm", "src/*.cpp"] +[targets.mathkit] +kind = "lib" +[toolchain] +windows = "msvc@system" +EOF + +cd mathkit +"$MCPP" pack mathkit > pack.log 2>&1 || { cat pack.log; echo "pack failed"; exit 1; } +pkg="$TMP/mathkit/$(find target/dist -maxdepth 1 -type d -name 'mathkit-0.1.0-*' | head -1)" + +# ── the package carries the neutral form ──────────────────────────────── +grep -q "\.runtime\]" "$pkg/mcpp.toml" || { + cat "$pkg/mcpp.toml" + echo "FAIL: no conditional [runtime] block. Without it a cl.exe consumer has" + echo " only the GNU ldflags, and cl stops at the first -L." + exit 1; } +grep -q 'link_library_dirs' "$pkg/mcpp.toml" || { cat "$pkg/mcpp.toml"; echo "FAIL: no link_library_dirs"; exit 1; } +grep -q 'libraries' "$pkg/mcpp.toml" || { cat "$pkg/mcpp.toml"; echo "FAIL: no libraries"; exit 1; } +# …and still the GNU one, for clients that predate the neutral form. +grep -q 'ldflags' "$pkg/mcpp.toml" || { + cat "$pkg/mcpp.toml" + echo "FAIL: the ldflags are gone. An older mcpp reads only those, so removing" + echo " them leaves it with no link line at all." + exit 1; } + +# ── and native cl.exe links it ────────────────────────────────────────── +PKG_HOST="$(host_path "$pkg")" +cd "$TMP" +mkdir -p app/src +cat > app/src/main.cpp <<'EOF' +#include +import mathkit; +int main(){ std::printf("cl-ok=%d\n", mk::answer()); return 0; } +EOF +cat > app/mcpp.toml < run.log 2>&1 ) || { + cat app/run.log + echo "FAIL: native cl.exe could not consume the package." + echo " A message naming '-L' means the GNU ldflags reached the command" + echo " line — the neutral form must REPLACE that leg's library" + echo " references, not be added alongside them." + exit 1; } +grep -q 'cl-ok=42' app/run.log || { cat app/run.log; echo "FAIL: wrong answer"; exit 1; } + +# ── the GNU spelling did not reach the command line ───────────────────── +# +# The run above could also pass if `-L` were accepted and ignored. Asserted +# against the generated graph so the claim is about what mcpp emitted. +nj="$(find app/target -name build.ninja | head -1)" +grep -E 'ldflags|unit_ldflags' "$nj" | grep -q -- '-Llib/' && { + grep -n -- '-Llib/' "$nj" | head -3 + echo "FAIL: a GNU -L for the package leg is still on the link line. cl.exe" + echo " happens to be tolerant here only by accident." + exit 1; } + +echo "PASS: native cl.exe links a packaged library through the neutral link intent" diff --git a/tests/e2e/263_lib_root_follows_the_extension.sh b/tests/e2e/263_lib_root_follows_the_extension.sh new file mode 100755 index 00000000..a97e7897 --- /dev/null +++ b/tests/e2e/263_lib_root_follows_the_extension.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# requires: +# (no capability: both assertions read mcpp's own output about a manifest key.) +# +# 263_lib_root_follows_the_extension.sh — the lib-root convention offers one +# candidate per DECLARED module extension, at every call site. +# +# ⚠️ WHY THIS EXISTS AS ITS OWN TEST. The previous round fixed the resolver the +# packer uses and left two other callers on the non-probing form, because the +# path under test was the only one anybody looked at. Both were reachable and +# both were wrong for an `.ixx` project: +# +# validate.cppm warned that the lib root was missing when it was right there +# prepare.cppm handed a host-module dependency a path to a file that does +# not exist +# +# Control-verified against the RELEASED 2026.8.18.2 binary on this fixture: +# +# warning: src/mathkit.cppm: lib target without conventional lib root +# 'src/mathkit.cppm' (create the file or set [lib].path) +# +# "Fixed the path I was testing" is not "fixed the decision", and a test per +# CALL SITE is the only thing that distinguishes them. +set -e +source "$(dirname "$0")/_host_path.sh" + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +# ── 1. the validator does not warn about a lib root that is present ───── +mkdir -p lib/src +cat > lib/src/mathkit.ixx <<'EOF' +export module mathkit; +export namespace mk { int answer() { return 42; } } +EOF +cat > lib/mcpp.toml <<'EOF' +[package] +name = "mathkit" +version = "0.1.0" +[build] +sources = ["src/*.ixx"] +module_extensions = [".ixx"] +[targets.mathkit] +kind = "lib" +EOF +( cd lib && "$MCPP" build > build.log 2>&1 ) || { cat lib/build.log; echo "FAIL: build"; exit 1; } +grep -q 'conventional lib root' lib/build.log && { + cat lib/build.log + echo "FAIL: the validator looked for src/mathkit.cppm on a project whose" + echo " interface is src/mathkit.ixx. The convention has to offer one" + echo " candidate per DECLARED extension, not just the built-in one." + exit 1; } + +# The negative control: a project with NO lib root at all must still be warned +# about, or this test would pass against a validator that stopped checking. +mkdir -p noroot/src +cat > noroot/src/other.ixx <<'EOF' +export module other; +export namespace o { int v() { return 1; } } +EOF +cat > noroot/mcpp.toml <<'EOF' +[package] +name = "mathkit" +version = "0.1.0" +[build] +sources = ["src/*.ixx"] +module_extensions = [".ixx"] +[targets.mathkit] +kind = "lib" +EOF +( cd noroot && "$MCPP" build > build.log 2>&1 ) || true +grep -q 'conventional lib root' noroot/build.log || { + cat noroot/build.log + echo "FAIL: a genuinely missing lib root produced no warning, so the check" + echo " above proves nothing — it would pass against a validator that" + echo " simply stopped looking." + exit 1; } + +# ── 2. a host-module dependency resolves its .ixx lib root ────────────── +# +# The second call site, which had no test of any kind. A build rule's interface +# is handed to build_program.cppm BY PATH, so a lib root resolved to a file that +# does not exist fails inside the build program rather than here. +mkdir -p rulepkg/src +cat > rulepkg/src/rulepkg.ixx <<'EOF' +export module rulepkg; +export namespace rp { int magic() { return 7; } } +EOF +cat > rulepkg/mcpp.toml <<'EOF' +[package] +name = "rulepkg" +version = "0.1.0" +[build] +sources = ["src/*.ixx"] +module_extensions = [".ixx"] +[targets.rulepkg] +kind = "lib" +[modules] +exports = ["rulepkg"] +EOF + +mkdir -p app/src +cat > app/src/main.cpp <<'EOF' +#include +#ifndef RULE_MAGIC +#error "the host-module rule did not reach this compile" +#endif +int main(){ std::printf("host-mod=%d\n", RULE_MAGIC); return 0; } +EOF +cat > app/build.mcpp <<'EOF' +import std; +import rulepkg; +int main() { + std::println("mcpp:rerun-if-changed=build.mcpp"); + // A real directive, so the assertion is on an EFFECT rather than on a log + // line: the rule's return value has to reach the consumer's compile. + std::println("mcpp:cfg=RULE_MAGIC={}", rp::magic()); + return 0; +} +EOF +# `[dependencies]`, not `[build-dependencies]`: `host-module = true` is what +# makes it build-time-only, and the key lives in the ordinary dependency table +# (docs/05 §2.14). The first version of this fixture put it under +# `[build-dependencies]` and failed identically for a `.cppm` package — i.e. the +# fixture was wrong, not the resolver. Checking the `.cppm` case first is what +# separated those two. +RULEPKG_HOST="$(host_path "$TMP/rulepkg")" +cat > app/mcpp.toml < build.log 2>&1 ) || { + cat app/build.log + echo "FAIL: a host-module dependency whose interface is .ixx could not be" + echo " resolved. 'module rulepkg not found' means the lib root was" + echo " looked for as src/rulepkg.cppm, which does not exist." + exit 1; } +# The `#error` above is the real assertion: if the rule module could not be +# imported, or its value never reached the compile, this file does not build. +grep -qE "warning:.*module_extensions declares" app/build.log && { + cat app/build.log + echo "FAIL: the rule package was warned about a dead module_extensions entry." + echo " Its sources are emptied on purpose (that is what keeps a build" + echo " rule out of the consumer's binary), so every declared extension" + echo " looks dead — a warning about a correct manifest, with nothing to fix." + exit 1; } + +echo "PASS: the lib-root convention follows the declared extension at every call site" diff --git a/tests/unit/test_link_intent_spelling.cpp b/tests/unit/test_link_intent_spelling.cpp new file mode 100644 index 00000000..a0de0373 --- /dev/null +++ b/tests/unit/test_link_intent_spelling.cpp @@ -0,0 +1,82 @@ +#include + +import std; +import mcpp.build.flags; +import mcpp.manifest; + +using mcpp::build::LinkIntentFlavor; +using mcpp::build::render_link_intent_flags; + +// A distribution package states each leg's link line TWICE: once as `ldflags` +// (`-L…`, `-l…`), which is all an older mcpp reads, and once as the neutral +// `[target..runtime]` pair. The neutral half exists for exactly one +// reader — a consumer driven by native `cl.exe`, which rejects `-L` — and this +// is where "neutral" is turned back into a command line. +// +// ⚠️ THE FLAVOUR IS A QUESTION ABOUT THE DRIVER, NOT ABOUT THE TARGET, and that +// is the opposite of the rule three other flags in this area follow. `-fPIC`, +// `--out-implib` and `/DEF:` reach the LINKER (through `-Wl,`), so the target +// ABI decides their spelling. These reach whatever mcpp INVOKES: with the MSVC +// dialect that is `link.exe` directly (`LinkStyle::SeparateLinker`), which takes +// `/LIBPATH:`; everything else is a compiler driver that takes `-L`. Clang +// targeting the MSVC ABI is the case that separates the two questions — it +// takes `-L` while producing MSVC-ABI objects, and it must not be handed +// `/LIBPATH:`. + +namespace { + +mcpp::manifest::LinkIntent leg_intent() { + // What `manifest_emit` writes for one leg. + mcpp::manifest::LinkIntent i; + i.linkLibraryDirs.emplace_back("lib/x86_64-windows-msvc"); + i.libraries.push_back("mathkit"); + return i; +} + +} // namespace + +TEST(LinkIntentSpelling, MsvcGetsLibpathAndADotLib) { + auto s = render_link_intent_flags(leg_intent(), LinkIntentFlavor::PeMsvc); + // `/LIBPATH` without the colon: this is NINJA text, not a shell command + // line, and `:` is escaped to `$:` because ninja reads a bare colon as the + // outputs/inputs separator. Asserting the raw `/LIBPATH:` fails against a + // perfectly correct renderer — which is what the first version of this test + // did. + EXPECT_NE(s.find("/LIBPATH"), std::string::npos) << s; + EXPECT_NE(s.find("mathkit.lib"), std::string::npos) << s; + // The GNU spelling must be absent, not merely outnumbered: `cl` stops at + // the first `-L` it does not recognise. + EXPECT_EQ(s.find("-L"), std::string::npos) << s; + EXPECT_EQ(s.find("-lmathkit"), std::string::npos) << s; +} + +TEST(LinkIntentSpelling, EveryDriverFlavourGetsTheGnuSpelling) { + // PeGnu is not only MinGW: clang targeting the MSVC ABI lands here too, + // because it is a compiler driver and takes `-L`. Handing it `/LIBPATH:` + // because its OUTPUT is MSVC-ABI would be the mistake this test exists to + // prevent. + for (auto flavour : { LinkIntentFlavor::Elf, LinkIntentFlavor::MachO, + LinkIntentFlavor::PeGnu }) { + auto s = render_link_intent_flags(leg_intent(), flavour); + EXPECT_NE(s.find("-L"), std::string::npos) << s; + EXPECT_NE(s.find("-lmathkit"), std::string::npos) << s; + EXPECT_EQ(s.find("/LIBPATH:"), std::string::npos) << s; + } +} + +TEST(LinkIntentSpelling, AnExplicitTokenIsPassedThroughUntouched) { + // A library named with a path or an extension is already a file, not a + // name to decorate. Decorating it would produce `lib/x.lib.lib`. + mcpp::manifest::LinkIntent i; + i.libraries.push_back("lib/x86_64-windows-msvc/mathkit.lib"); + auto s = render_link_intent_flags(i, LinkIntentFlavor::PeMsvc); + EXPECT_NE(s.find("mathkit.lib"), std::string::npos) << s; + EXPECT_EQ(s.find("mathkit.lib.lib"), std::string::npos) << s; +} + +TEST(LinkIntentSpelling, AnEmptyIntentRendersNothing) { + // The generated manifest omits the block for legs that do not need it, and + // an empty intent must not put a stray flag on the line. + EXPECT_TRUE(render_link_intent_flags({}, LinkIntentFlavor::PeMsvc).empty()); + EXPECT_TRUE(render_link_intent_flags({}, LinkIntentFlavor::Elf).empty()); +}