Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 192 additions & 0 deletions .agents/docs/2026-08-18-open-items-analysis-and-axis-discipline.md
Original file line number Diff line number Diff line change
@@ -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`** | `<AdditionalLibraryDirectories>` / `<AdditionalDependencies>` | 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:` + `<n>.lib` 或
`-L` + `-l<n>`。**这不是新词表** —— `[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/<tail>.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 的人就不必重新踩一遍。
51 changes: 51 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<pred>.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

### 新增
Expand Down
10 changes: 10 additions & 0 deletions docs/05-mcpp-toml.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions docs/08-toolchain-internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/12-binary-distribution.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 13 additions & 0 deletions docs/zh/05-mcpp-toml.md
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down
26 changes: 26 additions & 0 deletions docs/zh/08-toolchain-internals.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 源码地图

| 关注点 | 文件 |
Expand Down
2 changes: 1 addition & 1 deletion docs/zh/12-binary-distribution.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 上的符号导出

Expand Down
Loading
Loading