这份是方案。 发现(现状、实测、file:line)在
2026-08-17-distribution-architecture-analysis-and-design.md;那份文档的 §2(五轮实测)是本方案每一条判据的证据来源,本文只在需要时回指。实施状态(2026-08-17):P0 已实现并开 PR #451(未合入)。 落地与设计的三处差异,以及实现过程中被实测推翻的四条,记在 §11。
起因:issue #433「预编译 .so + .ixx/.h/.cppm 接口」。 讨论过程中我有四处设计被自己的实测推翻,都记在 §9,因为错的那版看起来同样合理。
模型三句话:
- 二进制包不是新格式 —— 它是一个自带
mcpp.toml的普通 tarball,走 mcpp 已有的 Form A 通路(prepare.cppm:2764)。索引条目与源码包一字不差。 - 产出什么由
[targets.<n>].kind决定 ——mcpp pack <target>。 没有--lib,没有--artifact。新增 manifest 段 0 个、键 0 个 —— 生成的包就是一个普通的mcpp.toml(§2.4)。 - 能算出来的东西不给字段 —— 接口发布集是模块闭包,公开头是
include_dirs全量, 两者都不允许在 pack 期裁剪(裁了会让源码分发与二进制分发语义不同)。
四条不可让步的判据:
| # | 判据 | 违反会怎样(实测) |
|---|---|---|
| J1 | 接口与二进制原子产出、不可分别替换 | 随包 .cppm 改一行结构体字段序 ⇒ 编译链接运行全过、数据是错的、零诊断 |
| J2 | 产物里不含生产机器的绝对路径 | .so 烧着 /home/…/.mcpp/… 的 RUNPATH,换机器即废 |
| J3 | 发布集 = 从公开根算的模块闭包,不是 .m.o |
实现分区照样产 .m.o ⇒ 按 .m.o 选会泄露闭源源码 |
| J4 | per-target 的 ldflags 用 cfg(...) 不用裸三元组 |
裸三元组在原生构建下不匹配 ⇒ CI 全绿、本机静默失配 |
pkg/
├── mcpp.toml ← 生成物:一个普通的 mcpp.toml(§2.4),没有新段
├── include/ ← 文本接口:原样全发,消费者 #include,不产生任何对象
├── interface/ ← 模块接口:算闭包,消费者编译它得到 BMI + object
├── lib/<triple>/ ← 链接面:.a / .so / .dylib / .lib(PE 导入库)
├── bin/<triple>/ ← 运行面(仅 PE):.dll,部署到 .exe 旁
├── HOST-REQUIREMENTS ← 与 mcpp pack 同一份推导(仅当有内容要说)
└── LICENSE / THIRD-PARTY
include/ |
interface/ |
|
|---|---|---|
| 是谁的输入 | 预处理器 | 编译器 |
| 消费者要编译吗 | 否 | 是 |
| 要算闭包吗 | 否 | 是(J3) |
| 要从归档剔对象吗 | 否 | 是 |
| ABI 闸门 | extern "C" ⇒ tag 只有三段(只约束 libc) |
tag 全段 |
两种模式互不干扰、可同时存在 —— 实测:同一个包被「只 #include」/「只 import」/
「两者同时」三种方式消费 × 静态/动态两种形态,六格全过。
lib/ 与 bin/ 分开是机制不是风格:PE 上链接的文件(.lib 导入库)与运行的文件
(.dll)是两个;ELF/Mach-O 是同一个。
按三元组分目录不按 OS 分:MinGW 与 MSVC 同为 windows,一个产 lib*.a 一个产 *.lib。
abi_tag |
build_key |
|
|---|---|---|
| 回答 | 「这份二进制能不能链进你的构建」 | 「这份 BMI 能不能直接用」 |
| 谁能算 | 生产者(消费者存在之前就能枚举) | 只有消费者(含依赖闭包 Merkle) |
| 来源 | mcpp build --print-fingerprint 的 [1][2][4][5][6] 的纯投影 |
cache_key::key_hex(已存在) |
<arch>-<os>-<env>-<compiler><major>-<stdlib><major>-c++<level>
x86_64-linux-gnu-gcc16-libstdcxx16-c++23
aarch64-macos-none-llvm22-libcxx22-c++23
x86_64-windows-msvc-msvc194-msvcstl194-c++23
arch-os-env 必须用 triple.cppm 的规范拼写,不是 fingerprint 的 [4] ——
[4] 是编译器自报的(x86_64-w64-mingw32),与 [target.'…'] 键的拼写
(x86_64-windows-gnu)不同,直接用会让同一个决定有两个拼写。
纯 extern "C" 库发一个更短的 tag —— x86_64-linux-gnu,没有
compiler/stdlib/std 段。闸门按段比对 tag 里有的东西,而 abi_check
(abi.cppm:157)早就是「未指定的维度 = 不关心」。tag 的形状就是 surface,
不需要额外的开关;C 库的 tag 组合数因此天然从 N×M 掉回 N。
| Tier | 包里有什么 | 生效条件 | 失配时 |
|---|---|---|---|
| S source | 全部源码 | 永远 | —— |
| I interface+binary | 接口 + 预编译库 | abi_tag 匹配 |
降级到 S;无 S 则明确拒绝并列出可用 tag |
| B +BMI | 再加预编译 BMI | build_key 精确匹配 |
静默降级到 I |
Tier B 必须先做跨机器可行性实验才能实施(GCC 把时间戳与源码路径写进 BMI), 它只能是加速器,永不成为正确性依赖。
mcpp pack # 唯一可打包目标;有歧义 → 报错并列出候选
mcpp pack mathkit # kind = "lib" → 静态库包
mcpp pack mathkit-shared # kind = "shared" → 动态库包
mcpp pack myapp # kind = "bin" → 应用 bundle(既有四档 --mode)
mcpp pack mathkit --target x86_64-linux-gnu \
--target aarch64-linux-gnu # 胖包(--target 可重复)新增 CLI:一个位置参数(与 mcpp run [target] 同形)+ --target 可重复。
新增 manifest 键:0 个。
[targets.<n>].kind |
产出 | --mode 适用 |
|---|---|---|
bin |
应用 bundle | ✅ 既有四档 |
lib |
静态库包 | ✅ system(默认)/ vendored —— 见下 |
shared |
动态库包 | ✅ system / vendored / self-contained |
本文前一版写「静态库的 --mode 不适用,归档不携带任何东西」。那句是错的。
「归档不携带依赖的代码」恰恰是问题所在:消费者必须自己把 zlib 拉进来 ——
除非包把它一起带上。这就是 --mode 问的那件事,只是机制随形态不同:
--mode |
库包里有什么 | 消费者要什么 | 适合 |
|---|---|---|---|
system(默认) |
只有本库 + [dependencies] 声明 |
自己解析依赖(公开索引 / 私有索引) | 依赖都是公开包 |
vendored |
本库 + 依赖的产物一起进 lib/<triple>/,包内不声明 [dependencies] |
什么都不要 | 内网 / 离线 / 依赖本身也是闭源 |
self-contained |
仅 shared:再带上 libc 一侧的闭包 |
什么都不要 | 跨发行版 |
与应用的 --mode 是同一个问题的同一批答案:闭包跟不跟着走。机制不同
(应用问的是运行期 .so,静态库包问的是构建期可链接的产物),但问题相同,
所以复用既有词汇而不是发明第二套。
vendored 形态下,依赖产物在 [[runtime.artifacts]] 里各占一条,
provenance 记它来自哪个包("vendored compat.zlib@1.3.2 by mcpp-pack 2026.8.17.1"),
生成的 ldflags 按拓扑序把它们排在本库之后。
分期:system 是 P0(当前实现自然就是它),vendored 是 P1。
# examples/05-lib-distribution/producer/mcpp.toml
[package]
name = "mathkit"
version = "0.1.0"
description = "Demo: shipping a prebuilt library with both header and module interfaces"
license = "Apache-2.0"
platforms = ["linux", "windows"] # 既有键。pack 用它做覆盖校验(缺腿告警)
[build]
sources = ["src/*.cppm", "src/*.cpp", "src/*.c"]
include_dirs = ["include"] # 公开头。整发,不可裁
# [lib] path 不写 ⇒ src/mathkit.cppm 约定 ⇒ 模块闭包从这里出发
[targets.mathkit]
kind = "lib" # → mcpp pack mathkit 产出静态库包
[targets.mathkit-shared]
kind = "shared" # → mcpp pack mathkit-shared 产出动态库包
soname = "libmathkit.so.1" # 给出正确的运行期名
[pack]
include = ["share/**"] # 既有键 —— 只作用于 extrasexamples/05-lib-distribution/producer/
├── mcpp.toml
├── README.md
├── include/mathkit_c.h # extern "C" 头接口
└── src/
├── mathkit.cppm # export module mathkit; export import :api; ← 闭包根
├── api.cppm # export module mathkit:api; ← 闭包内
├── secret.cppm # module mathkit:secret; ← 实现分区,**不发布**
├── impl.cpp # module mathkit; import :secret;
└── capi.c # C API 实现
1. build 出产物 ⚠️ 用这次构建的 target/<triple>/<fp>/bin/… 路径,
绝不 glob(会挑到陈旧的 fingerprint 目录)
2. 算接口闭包(从 lib-root 出发) ⚠️ 走 P1689,不走 M1 文本扫描器(§9-D)
→ 拷进 interface/
→ 打印「将发布 / 不发布」两张清单
→ 闭包里出现实现分区 ⇒ 告警「它的源码会被发布」
3. 拷 include_dirs 全量 → include/
4. 从归档剔除**已发布闭包那些单元**的对象 ⚠️ 不是「所有 .m.o」(J3 同一个闭包的第二个用途)
5. artifact_kind = shared 时额外:
· RUNPATH / INTERP 重写($ORIGIN 化) ← J2
· 第三方闭包收集(ELF: LD_TRACE;PE: 导入表)
· PE: 导入库进 lib/,DLL 进 bin/
6. 生成包内 mcpp.toml(§2.4)+ HOST-REQUIREMENTS
7. 确定性归档(PE → .zip,其余 → .tar.gz)
计划阶段必须校验的组合(不能留到链接期;--mode 与形态的组合见 §2.1.1):
| target | kind = "lib" |
kind = "shared" |
|---|---|---|
x86_64-linux-gnu |
✅ | ✅ |
x86_64-linux-musl |
✅ | ❌ musl 蕴含 -static,产不出共享库 |
x86_64-windows-gnu / -msvc |
✅ | ❌ P1 解锁(PE 导入库) |
*-macos |
✅ | ❌ P1 解锁(install_name) |
本节是第三次重写。 前两版分别提了
MCPP-PACKAGE.toml和[distribution]段。 逐条追问「这件事别处已经能说了吗」之后,两者都被删掉:每一条事实都有既有字段。
| 曾经想新增的 | 归属(全部既有) |
|---|---|
artifact_kind(static/shared) |
[[runtime.artifacts]].role = static-library / shared-library |
abi_tags(包级列表) |
[[runtime.artifacts]].abi —— 每条腿一个,比包级列表更准 |
abi_surface(c/cxx) |
tag 自身的形状(见 2.4.2) |
modules |
[modules] exports(既有的完备性断言) |
cxx_runtime |
[build] cxx_runtime(既有) |
interface_digest |
[[runtime.artifacts]] 里 role = "interface" 的条目,逐文件一条 |
built_by |
provenance = "mcpp-pack <version>" |
build_key |
host_fingerprint(docs/05 §2.11 原文:optional evidence) |
| 「这是分发包」的标记 | provenance 以 mcpp-pack 开头 —— 可推导 |
schema |
不需要:没有新 schema 要版本化 |
实测(mcpp 2026.8.15.3):role = "interface"、任意 abi 串、digest、
provenance 全部原样进 resolution.json,并挂上既有的 identity 判定
(路径不存在 ⇒ missing)。兼容性比新段更好 —— 新段会被老客户端静默跳过
(等于没有记录),而 [[runtime.artifacts]] 是老客户端已经在读的段。
abi.cppm:157 的 abi_check 早就是「未指定的维度 = 不关心」。所以不需要一个
布尔来说「我只约束 libc」—— 发一个短 tag 就是在说这件事:
纯 extern "C" 库 abi = "x86_64-linux-gnu" ← 只有三段
C++ 模块库 abi = "x86_64-linux-gnu-gcc16-libstdcxx16-c++23" ← 全段
闸门按段比对 tag 里有的东西。C 库的 tag 组合数因此天然从 N×M 掉回 N, 不需要任何开关。
一个二进制包的描述应当和源码包一样、甚至更简单。核心就三样:接口 / 库 / 依赖。
其余的都不是「描述」,是证据,而证据挂在既有的 [[runtime.artifacts]] 上,
每条腿一条 + 接口一条,不是每个文件一条。
# ══ 由 `mcpp pack mathkit` 生成。手工编辑会使 interface 的 digest 失配并被拒绝。 ══
[package]
namespace = "acme"
name = "mathkit"
version = "0.1.0"
# ── ① 接口 ────────────────────────────────────────────────────────
[build]
sources = ["interface/mathkit.cppm", "interface/api.cppm"] # 模块接口:消费者编译它
include_dirs = ["include"] # 头接口:消费者 #include
cxx_runtime = "self-contained"
[modules]
exports = ["mathkit"]
[targets.mathkit]
kind = "lib"
# ── ② 库(每条腿一段;必须是 cfg(...),不能是裸三元组 —— J4)──────────
[target.'cfg(all(linux, not(env = "musl")))'.build]
ldflags = ["-Llib/x86_64-linux-gnu", "-lmathkit"]
[target.'cfg(all(linux, env = "musl"))'.build]
ldflags = ["-Llib/x86_64-linux-musl", "-lmathkit"]
[target.'cfg(windows)'.build]
ldflags = ["-Llib/x86_64-windows-gnu", "-lmathkit"]
# ── ③ 依赖(从生产者工程原样带过来)────────────────────────────────
[dependencies.compat]
zlib = "1.3.2"
# ── 证据:每条腿一条 + 接口一条 ────────────────────────────────────
[[runtime.artifacts]]
role = "static-library"
path = "lib/x86_64-linux-gnu/libmathkit.a"
provenance = "mcpp-pack 2026.8.17.1" # 前缀即「这是分发包」的标记
abi = "x86_64-linux-gnu-gcc16-libstdcxx16-c++23"
digest = "sha256:…"
host_fingerprint = "aeb4c4d29e437696" # build_key,Tier B 用
[[runtime.artifacts]]
role = "interface"
path = "interface" # 目录,一条即可 —— 不是每个文件一条
provenance = "mcpp-pack 2026.8.17.1"
digest = "sha256:…" # 对有序文件集的摘要.a 不携带它的第三方依赖 ——
消费者链接时必须自己把 zlib 拉进来。所以 mcpp pack 要把生产者的
[dependencies] 原样写进包(排除 dev-dependencies 与 path 依赖 ——
后者是本地的,发出去解析不了)。这条规则 emit_xpkg 已经在用
(publisher.cppm:186-192),同一份推导,第二个投影。
shared 形态另外多一段:
[runtime]
runtime_search_dirs = ["lib/x86_64-linux-gnu"] # 进消费者的 RPATH
# deploy_files = ["bin/x86_64-windows-gnu/mathkit.dll"] # PE:部署到 .exe 旁它防的是「解开之后有人改了随包的接口」,不是「生产者一开始就发错了配对」——
后者只有原子产出能防(§0 J1)。索引通路上,整包已经有 sha256;真正裸奔的是
path 依赖 / 已解开的 store,而那里一条目录级 digest 就够:
诊断说「interface/ 与打包时不一致,拒绝构建」已经是可行动的。
逐文件 digest 能多说一句「是哪个文件」,代价是描述里多出 N 行 —— 不值得。
判据:任一 [[runtime.artifacts]] 的 provenance 以 mcpp-pack 开头。
此时在解开的包目录里直接 mcpp build 必须拒绝,并说清这是分发包不是源码树。
今天它会成功 —— 把 interface/ 里只有声明的接口单元编出来,产出一个几乎空的库,
实现全在预编译产物里没被链进来。典型的「看起来成功的失败」。
# ① 离线文件 —— P0。不需要索引、网络、鉴权,不需要动 xlings
mathkit = { package = "vendor/mathkit-0.1.0-x86_64-linux-gnu-gcc16-libstdcxx16-c++23.tar.gz" }
# ② 索引(公开或私有)—— P1。描述符与源码包同构:只有 url + sha256
mathkit = "0.1.0"
# ③ 本地目录(内部团队最常走的一条)
mathkit = { path = "../mathkit-dist" }mcpp add ./mathkit-0.1.0-<tag>.tar.gzmcpp.toml 而不是旁路文件的理由。
| 检查 | 失配 |
|---|---|
| arch / os / env | 拒绝 —— 载荷本身不对 |
abi_surface == "c" |
上一行之后全部跳过 |
| compiler 族 / 主版本 | 拒绝;--allow-abi-drift 强制并打印它保护的是什么 |
| stdlib id / 主版本 | 拒绝 |
| C++ 标准档位 | 消费者 < 生产者 → 拒绝;> → 放行 |
cxx_runtime 契约 |
警告 + 说清双运行时危险;--strict 升级为错误 |
interface_digest |
拒绝 |
模块符号存在性(ELF _ZGIW<M> / PE 导出表) |
拒绝 |
build_key(Tier B) |
静默降级到 Tier I |
error: acme.mathkit@0.1.0 has no prebuilt artifact for this toolchain
your toolchain : x86_64-linux-gnu-gcc16-libstdcxx16-c++23
published tags : x86_64-linux-gnu-gcc15-libstdcxx15-c++23
aarch64-linux-gnu-gcc15-libstdcxx15-c++23
note: this package ships no source tier, so there is nothing to fall back to.
fix : ask the publisher for a gcc16 build, or pin [toolchain] to gcc@15.
「不可用」不能被拼成「不存在」 —— 被拼成「找不到包」的失败会让客户端自己驱动 重复刷新索引(#349 的教训)。
能构建,只是没有闸门。 实测(mcpp 2026.8.15.3):
包内 mcpp.toml 用的 sources / [build] / [modules] exports /
[target.'cfg(…)'] / [[runtime.artifacts]] 全部是已发布能力 —— 老客户端
逐字读得懂,只是不会执行闸门(它不知道 provenance = "mcpp-pack" 意味着要校验
digest 与 abi tag)。这是降级而不是变砖 ——
但闸门只保护新客户端,必须写进发布说明。
这也是不新增段的第二个好处:一个新段会被老客户端静默跳过,连记录都没有;
而 [[runtime.artifacts]] 是老客户端已经在读、并且会写进 resolution.json 的段,
所以即使闸门不执行,证据仍然落盘、仍然可审计。
| 文件 | 动作 | 内容 |
|---|---|---|
docs/02-pack-and-release.md |
改 | 标题改为「打包:应用与库」。新增「§库分发」:mcpp pack <target> 的 kind 表、两种接口模式的目录规则、包布局、跨平台合法组合表。既有的应用四档 --mode 一字不改 |
docs/12-binary-distribution.md |
新增 | 库作者的完整链路:写工程 → mcpp pack → 检查两张清单 → 分发(文件/私有索引/公开索引)→ 消费者怎么用。含 §2.2 与 §3.1 的完整示例 |
docs/05-mcpp-toml.md |
改 | ① [pack] 一节补「只作用于 extras,裁不到头与接口」;② §2.11 补 role = "interface" 与 provenance = "mcpp-pack" 的约定用法(生成物,不是手写配置);③ Appendix A 补一条准入判据:「能从别处推出来的不给字段」 |
docs/10-publishing-a-library.md |
改 | 新增「发布二进制包」小节:与源码包同构的索引条目、platforms 覆盖校验、老客户端降级说明 |
docs/03-toolchains.md |
改 | abi_tag 的六个成分与 --print-fingerprint 的对应关系 |
docs/zh/* |
同步 | 上述五处的中文版 |
沿用既有编号与「每目录一个 README.md」的约定。
结构见 §2.2。README 要点:
- 一个工程同时提供头接口与模块接口,消费者可以只用其中一种;
secret.cppm是实现分区,不会被发布 —— 跑mcpp pack mathkit看两张清单;- 两个目标(
lib+shared)如何同时发布,以及soname给出的正确运行期名; - 反面演示:把
src/mathkit.cppm改成import :secret;,再 pack, 观察闭包里多出secret.cppm并触发告警。
examples/05-lib-distribution/consumer/
├── mcpp.toml # mathkit = { path = "../producer/target/dist/mathkit-0.1.0-<tag>" }
├── README.md
└── src/
├── main_header.cpp # 只 #include <mathkit_c.h>
├── main_module.cpp # 只 import mathkit;
└── main_both.cpp # 两者同时
README 要点:三种消费方式 × 静态/动态两种包 = 六格,全部可跑;
以及篡改 interface/ 后必须被拒绝的演示。
一个 mcpp.toml + 一条命令产出三条腿,消费者用 --target 选。README 要点:
- 为什么每条腿的
ldflags是cfg(...)而不是裸三元组(J4,附最小探针); lib/为什么按三元组分目录而不按 OS 分。
相对初稿的修订。 初稿提议新建
pack-dist-matrix.yml。不需要。tests/e2e/run_all.sh已经有能力探测(elf/gcc/mingw-cross/fresh-sandbox/ …)并按# requires:分流,而ci-linux-e2e.yml已经在跑 整个tests/e2e/。再建一条并行流水线就是同一个决策的第二处推导。矩阵是 e2e 集合 + 每个测试头部那行
# requires:。
| e2e | # requires: |
钉住 |
|---|---|---|
242 pack_library_interface_and_headers |
gcc |
两种接口模式共存;包布局;两张清单都被打印 |
243 pack_library_interface_closure |
gcc |
J3 —— 实现分区源码不外发 且 它的对象留在归档里 |
244 pack_library_gate |
gcc |
J1 接口篡改被拒 + tag 失配被拒(且列出可用 tag)+ 包内 build 被拒 |
245 pack_library_fat_target_selection |
gcc |
J4 —— 胖包每条腿只被自己的 target 看到,含原生构建 |
246 explicit_empty_sources |
gcc |
§8-B2 —— sources = [] 与「不写」可区分 |
247 bare_triple_conditional_native |
gcc |
§8-B1 —— 裸三元组谓词在原生构建下命中 |
248 pack_library_fat_pe_leg |
gcc mingw-cross |
跨 OS 边界的腿(PE);tag 用规范三元组而非编译器自报 |
245 最初写成 # requires: gcc mingw-cross(gnu + musl + windows 三条腿)。
ci-linux-e2e.yml 只预热 gcc 与 musl,不装 mingw-cross ——
那条测试会在每一次普通 CI 上静默跳过,于是胖包这一整套机制
(以及 J4)在绿色的套件里从未被验证过。
拆法:核心机制用 CI 一定有的工具链(245:gnu + musl,requires: gcc),
只把新增的二进制格式覆盖单列(248:PE,requires: mingw-cross)。
判据:一个测试的 # requires: 必须是它所验证机制的真实下限,不是它能跑的上限。
| 文件 | 覆盖 |
|---|---|
tests/unit/test_pack_abi_tag.cpp |
tag 的投影 / 规范三元组 / C 表面短 tag / 从尾解析 / 档位下限 / 一次报全部失配(15 例) |
tests/unit/test_pack_interface.cpp |
闭包 / 剔除集不是「所有 .m.o」/ 依赖模块不越界 / 未解析分区报错(8 例) |
- macOS 与 Windows 的 e2e 分片会自动跑 242/243/244/246/247(它们只
requires: gcc), 但尚未在这两个平台上人工确认过; kind = "shared"的库包 e2e(P1,随 PE 导入库 / Mach-O install_name 一起);--target覆盖与[package].platforms的比对告警(P1)。
sources = []的三态(§8-B2)+ §2.5 的 build 守卫(判据 =provenance前缀)- 包布局 + 生成包内
mcpp.toml mcpp pack <target>位置参数 +--target可重复;kind = "lib"⇒ 静态库包- 接口闭包(P1689)+ 两张清单 + 实现分区告警
- 从归档剔除已发布闭包的对象
abi_tag计算 + 闸门表 +interface_digest+ 模块符号存在性mcpp add ./x.tar.gz+{ package = "…" }依赖形态- e2e 242 / 244 / 245 / 246 / 248 / 250 / 251;examples 05 + 06
为什么 static 能在 P0 覆盖三平台:kind = "lib" 无平台限制,
所以 G2、RUNPATH 重写、闭包收集、DLL 部署这一期全部不需要。
- PE 导入库(
--out-implib//IMPLIB:)+ Mach-O-install_name @rpath/… kind = "shared"守卫的判据改成「该 target 是否动态链接」(musl 走计划期拒绝)shared形态的 pack:RUNPATH 重写 + 闭包收集 +bin/部署面- 胖包上索引(描述符与源码包同构)+
platforms覆盖校验 - e2e 243 / 247 / 249;examples 07;
pack-dist-matrix.yml
- 瘦包(按 tag 分资产)+
install_packages加 target 轴(跨仓库,与 xlings 同步) IndexSpec鉴权(值从环境变量读,永不落盘)target_cfg/[target.'cfg(…)']承载 LinkIntent(顺手修 #258 同形状的债)
- Tier B:先做跨机器可行性实验,做不到就不做
.pc/ CMake config 产出- #304(
library_dirs的 link/runtime 分离收口)、#290(按版本区分构建规则)
| # | 缺陷 | 位置 | 对本方案的影响 |
|---|---|---|---|
| B1 | [target.'<三元组>'.build] 在原生构建下永不命中 |
prepare_inputs.cppm:139 if (triple.empty()) return false;,而同文件 context_for() 对 cfg() 回落到 host_triple();types.cppm:665 的注释承诺的是回落 |
直接阻塞胖包的裸三元组写法(J4)。任何用该写法的工程都中招,形状是「CI 全绿、本机静默失配」 |
| B2 | sources = [] 与整行删掉逐字节等价 |
toml.cppm:1703 的默认 glob 吞掉显式空 |
二进制包无法表达「什么都不要编」;遗留在 src/ 的文件会被编进消费者的构建 |
| B3 | M1 扫描器不把实现分区建模为 provider | scanner.cppm:641-650 对非 export 的 module X; 从不设 u.provides |
「接口够到实现分区」的告警产不出来 ⇒ 闭包必须走 P1689 |
| B4 | kind = "shared" 的守卫判据是 os != "linux" |
plan.cppm:1002 |
musl 过闸后死在 crtbeginT.o,消息里既没有 musl 也没有 shared |
B1 / B2 建议先单独开 issue 并各带一条回归测试,不要埋进这个大特性里。
| # | 我写过的 | 被什么推翻 | 正确的 |
|---|---|---|---|
| A | 新增描述符键 kind = "prebuilt" + 版本 floor |
它属于「不降级」的那一类,老客户端会被砖 | 走 Form A(包自带 mcpp.toml)⇒ 无新描述符键、无 floor、兼容几乎免费 |
| B | 打包时「剔除所有 .m.o」 |
三个 target 全部链接失败(undefined reference to mk::secret_helper@mathkit())—— 实现分区照样是 .m.o 且里面是真代码 |
剔除集 = 已发布闭包里那些单元的对象;与发布集是同一个闭包的两个用途 |
| C | 胖包每条腿用裸三元组 [target.'x86_64-linux-gnu'.build] |
显式 --target 三个全绿,裸 mcpp build 链接失败;最小探针:裸三元组 0 命中 / cfg(linux) 2 命中 |
一律用 cfg(...)(J4);并把根因(B1)单独开 issue |
| D | [pack] 加五组键(default_kind / default_artifact / targets / [pack.interface] / [pack.headers]) |
逐条问「别处已经说过了吗」之后全部落空 | 新增 0 个键:kind → [targets.<n>].kind;接口根 → [lib];头目录 → [build].include_dirs;平台 → [package].platforms 升格为断言 |
外加一处我自己踩的坑:原型用 rglob 找产物,挑到了陈旧的 fingerprint 目录
(少一个 capi.o,症状是消费者 undefined reference,看起来完全像 mcpp 的问题)。
打包器绝不能 glob 产物 —— 这与仓库既有的「ls | head -1 会自查到旧二进制」是同一形状。
全部实测记录在 2026-08-17-distribution-architecture-analysis-and-design.md:
| 判据 | 那份文档的位置 |
|---|---|
| J1(接口↔二进制无绑定,静默错数据) | §2.2(c) |
J2(.so 烧生产机器 RUNPATH) |
§2.2(a) |
J3(闭包 vs .m.o;剔除集) |
§2.4.2 / §2.4.3 |
| J4(裸三元组不匹配 + 最小探针) | §2.4.5 |
| 六格矩阵(两种接口 × 两种形态) | §2.5.1 |
| 两条目录规则 | §2.5.2 |
| musl + shared 链接期炸 | §2.5.3 |
sources = [] 不生效 |
§2.5.4 |
| 新段的兼容边界 + array-of-tables 白名单(为什么最终不新增段) | §4.2 |
[[runtime.artifacts]] 能承载 role/abi/digest/provenance + identity 判定 |
本文 §2.4.1 实测 |
[pack] 推导审计 |
§4.6.1 |
复现脚本(scratchpad/lab/) |
附录 A |
| file:line 索引 | 附录 B |
| 设计说的 | 实际做的 | 为什么 |
|---|---|---|
[distribution] 段(4 个字段) |
一个字段都没加 | 追问「这件事别处能说吗」之后,role/abi/digest/provenance/host_fingerprint 全在 [[runtime.artifacts]] 上;provenance 前缀就是「这是分发包」的标记 |
abi_surface = "c" 开关 |
tag 的形状本身 | abi_check 早就是「未指定 = 不关心」,短 tag 就是那句声明 |
新建 pack-dist-matrix.yml |
复用既有 e2e 通路 | run_all.sh 已有能力探测与 # requires: 分流,再建一条是同一决策的第二处推导 |
shared 排 P1 |
Linux/ELF 已可用 | 实测发现只差一件事:包里要同时带链接名与 SONAME |
(a) mcpp.pack.library 里叫 Error。 mcpp.pack 已经有一个
mcpp::pack::Error,而一个名字只能归属一个模块。GCC 接受了,clang 直接拒绝
(cannot be attached to other modules)—— Windows 与 macOS 全红、Linux 全绿,
这正是三平台矩阵存在的理由。
(b) mcpp pack 在 workspace 根上不能用了。 路由要在构建前读 manifest,
而 workspace 根没有自己的目标(虚拟 workspace 连 [package] 都没有),
于是新路由读到空列表、判定「无可打包」。用上一版发布的二进制跑
examples/04-workspace 对照才发现 —— e2e 249 就是这次对照的固化。
(c) 位置参数没传给应用通路。 两个 bin 目标的工程接受 mcpp pack app2
却打包 app1 —— 命令成功、答案错误。e2e 250 两个方向都钉。
(d) 动态库包只带了构建出来的文件名。 -lmathkit-shared 链的是
libmathkit-shared.so,而对象记的是 SONAME libmathkit.so.1 —— 两个不同的文件名。
包链得上、起不来。是 mcpp 自己的运行期闭包检查报出来的
(libmathkit.so.1 not found on the search path this artifact will actually use),
而不是加载器错误。e2e 251 用 run 而不是 build:这里链接过了什么都不证明。
(e) 「新测试在 CI 里跑了吗」必须单独查一遍 —— 我自己造了第二次假绿。
十个新 e2e 一开始全写 # requires: gcc,而那个能力按设计只在 Linux 成立
(macOS 的 g++ 是 Apple Clang,Windows 的也不是 mcpp 兼容的 GCC)。于是它们
在 macOS 与 Windows 上全部跳过、套件报绿,而文档写着「静态库包:所有 target」。
判据不是「套件绿了」,是「这条测试跑了吗」——grep 'SKIP:' 三分钟就能查。
放开之后 CI 给出了真实答案,这才是这次放开的价值:
- Windows(MSVC-ABI clang)那条腿上,
pack内部的库构建以一句error: build failed失败(本机无法复现该路径); - macOS 上
ar解析到一个报「未安装」的 xlings shim,闭包测试查不了归档。
处理方式是记成明确的限制,不是塞回去。 打包测试重新 # requires: gcc
并在头部写明范围;命令本身(路由 / 未知名字 / workspace 根)保持三平台运行
(249、250);docs/12 与其中文版加了「验证到哪一步」一节。
「所有 target 都支持」与「只在 Linux 验证过」是两句话,文档现在两句都说。
外加一条 e2e 卫生:六个 fixture 把包路径按 shell 拼写写进了 mcpp.toml。
00_fixture_path_hygiene.sh 在 macOS 那条腿上抓到 —— 规则是 Windows 的
(MSYS 只转 argv 不转文件内容),而 lint 在所有平台跑,正是为了让 Linux 上的
reviewer 先于 Windows CI 发现。
放开可移植测试之后 CI 报出两条真实失败,追下去发现根因在扫描器,不在打包:
module M:part;(实现分区)与 module M;(实现单元)共用一个拼写、是两种声明,
而扫描器把前者记成**「requires M:part、provides 空」** —— 一个文件 requires
自己的名字。于是图里没有「import 分区的单元 → 定义分区的单元」这条边,
构建顺序无约束:GCC 与 macOS clang 靠各自的依赖扫描兜住,
Windows clang 以 failed to read compiled module 失败。
同一处第二半:import :part; 的解析读 u.provides,而实现单元没有 provides ⇒
import :secret; 停在字面 :secret。两平台都刷的那条
module 'M:part' imported but not provided in this build 就是病因,读起来像提示。
实现分区在此之前 mcpp 里任何地方都没有测试覆盖 —— 库分发的 e2e 是第一个用它的。 修好之后:
| e2e | linux | macOS | windows | 跑在哪 |
|---|---|---|---|---|
| 242 布局 + 两种接口模式 | ✅ | ✅ | ✅ | e2e 分片 |
| 243 闭包 + 剔除集 + 分区告警 | ✅ | ✅ | ✅ | e2e 分片 |
| 244 三条拒绝 | ✅ | ✅ | ✅ | e2e 分片 |
246 sources = [] |
✅ | ✅ | ✅ | e2e 分片 |
| 247 裸三元组条件 | ✅ | ✅ | ✅ | e2e 分片 |
| 249 workspace 根仍能打包 | ✅ | ✅ | ✅ | e2e 分片 |
250 pack <name> 打的是那个 |
✅ | ✅ | ✅ | e2e 分片 |
| 251 动态库:产出正确 或 明确拒绝 | ✅ 产出 | ✅ 拒绝 | ✅ 拒绝 | e2e 分片 |
| 248 跨 OS 边界的胖包(PE) | ✅ | — | — | cross-build-test.yml 的 mingw job |
| 245 胖包(含原生构建) | ✅ | skip | skip | e2e 分片 |
只剩 245 一条在 Linux 之外跳过,而它是真限制:那条测试要两个都能构建的
target(x86_64-linux-gnu + x86_64-linux-musl),而 macOS / Windows 的 CI 上
不存在第二个现成可用的 target。它验的机制(每条腿一个 cfg()、原生构建也命中)
本身与平台无关,已在 Linux 上覆盖。
另外四条原本的 skip 理由,查下来三条是我偷懒:
| 原理由 | 真相 |
|---|---|
| 246「读编译器特定的 flag 拼写」 | 错的 —— 它 grep 的是文件名。# requires: gcc 是复制粘贴来的 |
| 247「读编译器特定的 flag 拼写」 | 半对 —— 它 grep 了 -D 前缀,而 MSVC 是 /D(dialect.cppm:200)。改成 grep 宏名即可移植 |
| 248「需要 mingw-cross,没有 job 装它」 | 可修 —— cross-build-test.yml 的 mingw job 已经在显式点名跑那些「Linux 分片因缺 mingw-cross 而跳过」的 e2e(102/198/240),照办即可 |
251「shared 仅 ELF」 |
理由成立,但只测了一侧 —— 补成两侧断言:ELF 上产出且带两个名字,非 ELF 上必须拒绝且说明原因。只测能用的那侧分不清「门被处理」与「没有门」 |
副作用:一条与打包无关的既有缺陷被修好了 —— 用实现分区的工程现在在 Windows 上 能构建了。
判据(第二次得到验证):一条测试的 # requires: 必须是它所验证机制的真实下限。
写下它的时候很难分辨「真限制」与「我没想清楚」,所以事后必须逐条追问理由;
四条里三条经不起追问。
ls -t target/*/bin/mcpp | head -1 挑到了陈旧/别的工具链的 fingerprint 目录 ——
三次。 一次让 B1 的修复看起来没生效,一次让全部新 e2e 报段错误(实际是拿了
clang 构建的二进制,而 clang 构建的 mcpp 在本机会段错误)。
这与打包器「绝不 glob 产物」是同一条判据,只是发生在验证侧。
| 新模块 | src/pack/{abi_tag,digest,interface,library,library_pipeline,manifest_emit,prebuilt,route}.cppm |
| 改动 | prepare_inputs(B1)、toml+types(B2)、source_kind(object_filename_for 归位)、prepare(图 + 两道闸门)、plan、cli |
| 单测 | test_pack_abi_tag(15)、test_pack_interface(8) |
| e2e | 242–251(10 个) |
| 文档 | docs/12-binary-distribution.md + zh;docs/02/05/README 索引 |
| 示例 | examples/05-lib-distribution/producer、examples/05-lib-distribution/consumer |
权威答案是 host_can_serve(registry.cppm:542-566),不是测试的能力表。
| target | linux host | macOS host | windows host |
|---|---|---|---|
<hostarch>-linux-gnu |
✅ | ❌ | ❌ |
*-linux-musl |
✅ 任意 arch | ❌ | ✅ 仅 host arch |
*-windows-gnu(mingw) |
✅ | ❌ | ✅ |
*-windows-msvc |
❌ | ❌ | ✅ |
*-macos |
❌ | ✅ | ❌ |
- macOS 只能服务一个 target。 胖包至少要两条腿 ⇒ 在 macOS 上 结构上不可能,不是测试没写。代码自己的注释: "macOS has no Linux-targeting payload at all."
- Windows 能服务三个(msvc / mingw / linux-musl)⇒ 245 与 248 的等价用例在
Windows 上是可行的,只是没做。 这是覆盖缺口,不是产品缺口,而且它有独立价值:
mathkit.lib(MSVC)+libmathkit.a(MinGW)同处一包,是「lib/必须按三元组分」 最强的证据。代价是要在 Windows 的 e2e job 里装xim:mingw-gcc(e2e 97 已经在 另一个 job 里装它)。 - 248 需要一个能产 PE 的工具链:Linux 上是
mingw-cross(现在跑在cross-build-test.yml的 mingw job),Windows 上是原生 mingw/msvc(同上缺口)。
src/pack/ 现在是两层,必须保持:
叶层(prepare 可以 import 它们): abi_tag digest interface manifest_emit prebuilt route library
编排层(它 import prepare): library_pipeline
实测的 import 图里,只有 library_pipeline 碰 mcpp.build.*;而
prepare 只 import pack.abi_tag 与 pack.prebuilt(两个叶子)。
不变量:除 library_pipeline 外,src/pack/ 下不得 import mcpp.build.* ——
否则 prepare ↔ pack 成环。
三处收敛(都是把「同一个决定的第二处推导」删掉,不是加抽象):
object_filename_for从 plan.cppm 搬进mcpp.source_kind—— 策略与格式化同处;- 「怎么删归档成员」进
dialect.cppm的archiveRemoveArg—— 与archiveCmd同处, packer 不再默认ar语法(这条是 review 发现的,不是测试发现的); - 解析后的三元组进
cfgpred::Ctx—— 删掉裸三元组分支那个第二答题者。
| 变化 | 谁会受影响 |
|---|---|
sources = [] 从「等于不写」变成「什么都不编」 |
真写了它又依赖默认 glob 的工程。此前无法表达「什么都不编」,所以这种写法只可能是误解 |
| 两个文件声明同一个分区 ⇒ 拒绝并点名两个文件 | 本来就 ill-formed 的程序。但它是一条新的失败路径 |
扫描器的改动只会增加图上的边,不会减少 —— 边缺失只可能允许错误的顺序, 所以不存在「依赖那条缺失边」的工程。
老客户端兼容:两侧都验了(静态:生成的 manifest 段 ⊆ 既有词汇,三平台跑;
真实:2026.8.15.3 消费并运行成功,本机)。$MCPP_BOOT 是 xvm shim(在 e2e 改过的环境里报「未安装」),这一点写在 252 的头部。
| # | 项 | 我的建议 |
|---|---|---|
| O1 | 扫描器改动是全 PR 里影响面最大的一块,而它与打包无关 | 拆成独立 PR 先合,这样它能被独立 revert;库分发那半 rebase 在它上面 |
| O2 | scan_overrides 声明的实现分区会被误判成接口(providesInterface 只在文本扫描路径设 false,scan_overrides/P1689 默认 true)⇒ 它的源码会被发布 |
窄洞,而且 scan_overrides 的 schema 没有表达「是否 export」的位置。先记录;要修就是给它加一个键(那会是本方案唯一的新 manifest 键) |
| O3 | MSVC 的 /REMOVE: 路径未测试(mcpp 的 Windows CI 用 clang 的 llvm-ar,走 GNU 形式) |
失败会带命令原文报错,不会静默;要真测需要 msvc@system 的 job |
| O4 | Windows 上的胖包(msvc + mingw)未覆盖 | 见 §12.1;要在 Windows e2e job 装 xim:mingw-gcc |
| O5 | 设计 §2.2 承诺的「--target 集合与 [package].platforms 覆盖比对告警」没有实现 |
我漏了。它便宜(一次集合比对 + 一条 warning),但属于「发布纪律」而不是正确性,可以随后补 |
裁决是「统一在单 PR 451 上实现最大兼容和支持度」,所以 O1(拆 PR)不做, 其余四项全部落地,并顺带补上了两个在做的过程中被逼出来的真缺陷。
| # | 项 | 结局 |
|---|---|---|
| O1 | 把扫描器改动拆成独立 PR | 不做 —— 单 PR 是裁决。代价照旧:revert 粒度与库分发绑在一起 |
| O2 | scan_overrides 声明的实现分区被误判成接口 |
已修,而且没加 manifest 键(见 §13.2) |
| O3 | MSVC /REMOVE: 未测试 |
两端都测了:archive_remove_command 抽出成缝 + 单测钉命令,e2e 255 用真 lib.exe |
| O4 | Windows 胖包未覆盖 | e2e 256(msvc 腿 + mingw 腿同处一包),Windows e2e job 增加 xim:mingw-gcc 安装步与 mingw 能力 |
| O5 | [package].platforms 覆盖告警未实现 |
已实现(见 §13.3) |
SourceUnit::providesInterface 从 bool 改成 std::optional<bool>。
三条建图路径各说自己真的知道的事:
| 路径 | 它知道什么 |
|---|---|
| 文本扫描器 | 读到了关键字 ⇒ 显式 true / false |
| P1689 读取器 | 编译器的答案,包括它的沉默(is-interface 是可选键) |
scan_overrides |
什么都不知道 ⇒ 留空。schema 里没有地方说 export |
判据:true 是那个「不产生任何警告」的值。 字段原来的注释把默认 true
叫「保守方向,因为这个标志只会产生一条警告」—— 说反了,所以用
[scan_overrides] 声明的实现分区被一声不响地发布,而那正是整个闭包设计要防的事。
两个细节让告警可用:
- 只问分区。
module M;不 provide 任何东西,所以能 provide 裸M的只有export module M;—— 在那里问会对每个用了 override 的包的每个主接口都告警; - 未知与已知说不同的话。 一条是「你正在发布你的实现」,另一条是 「mcpp 判不出你是不是在发布」。
P1689 那一半是真修复而不是变通:编译器早就报了 is-interface,
mcpp 解析进了一个从来没人读的字段。
e2e 253 两侧都钉:同一个 fixture 打两次(扫描 / override),两次必须给出
不同的句子 —— 只断言 override 会告警,分不清「mcpp 建模了三态」与
「mcpp 对每个发布的分区都告警」。控制探针验证过:把 override 那一处恢复成
true(其余修复保留),253 在正好那条断言上失败。
四种比较只有两种能打印:
| 情况 | 处理 |
|---|---|
| 打了却没声明 | 永远可行动 —— manifest 否认了一个包明明能服务的平台 |
| 声明了却没打,且本宿主能构建它 | 可行动 |
| 声明了却没打,本宿主构建不了 | 不说话 |
第三行才是这个检查可用的原因:正常流程是 CI 每平台各跑一次 mcpp pack,
Linux runner 不产 macOS 腿不是遗漏、是每一次。永远触发的告警会把真正该看的那条盖掉。
判据用 host_can_serve —— 与 --target 接受什么是同一个函数,所以告警只可能
点出作者此刻做得到的事。e2e 254 把沉默也断言了。
(a) kind = "shared" 此前只在 ELF 上可用,其余一律拒绝 —— 这一条是能力增加。
!targetTriple.empty() && os != "linux",
我据此推断「原生构建 targetTriple 为空 ⇒ 守卫不触发」。实测否掉了这个推断:
tc.targetTriple 由编译器的 -dumpmachine 填(detect.cppm:88),原生构建上非空 ——
resolution.json 里记的就是 x86_64-linux-gnu;而
parse("x86_64-pc-windows-msvc") 会跳过 vendor 段并成功。所以原生 macOS / 原生 Windows
本来就被拦住,不存在那个洞。判据:结构上「可能为空」不等于运行时真的为空 ——
要么读运行时产物(resolution.json),要么别把它写成实测。
放开之后,真正缺的东西各不相同,而且都不是 flag 拼写:
| 格式 | 缺什么 | 现在 |
|---|---|---|
| PE | 导入库 —— 只写了 .dll,消费者直接链 .dll,mingw 的 ld 容忍、别人都不容忍 |
链接边把导入库声明成隐式输出;包里两个都带;-static 会让 ld 拒绝导入库(报 have you installed the static version…,既没点 DLL 也没点 -static),所以那条 -l 前先 -Wl,-Bdynamic |
| Mach-O | install name —— 只在声明了 soname 时才发;放开 macOS 的那一刻,每个没写 soname 的 .dylib 都会把构建目录烙进去 |
无条件 @rpath/<file>;而且改成按 target 判定(原来是宿主的 #if defined(__APPLE__) —— 在旧的拒绝之下不可达,放开之后会发错) |
| PE/MSVC | 符号导出(不是链接器 —— link /DLL /IMPLIB: 一直都在) |
仍然拒绝,但换成真理由,并点名 MinGW 这条路 |
.dll,所以「PE 共享库是两个文件」这件事从来没被暴露。
(b) --target 接受了这台机器产不出来的 target,并悄悄按宿主构建。
实测(Linux):
$ mcpp build --target x86_64-windows-msvc
Resolved gcc@16.1.0 → x86_64-windows-msvc → …/xim-x-gcc/16.1.0/bin/g++
Finished dev [unoptimized + debuginfo] in 0.07s
$ ls target/
x86_64-linux-gnu/ ← 一个 ELF,被当成 Windows 构建交付
词表的 tier 说的是「mcpp 支持这个 target」,从来没说「这台机器能产出它」。
host_can_serve 此前只用于 toolchain list 的展示,构建路径根本没问它。
现在 prepare.cppm 在紧邻那条「typo 绝不能悄悄回落到宿主工具链(最坏的失败模式)」
的检查之后问它,并把这台宿主能构建的清单列进错误里。逃生口保留:
显式 [target.X] toolchain = "…" 表示交叉链是作者自己提供的。
(c) 消费一个 shared 的分发包直接失败:ninja: multiple rules generate bin/libmathkit.dll —— 依赖循环为一个已经构建好的库创建了 link unit。
就算不撞名也是错的:分发包的 sources 只有它发布的接口,重链会静默产出一个
缺掉发布方保留的每个实现单元的库。
| e2e | linux | macOS | windows | 备注 |
|---|---|---|---|---|
| 242/243/244/246/247/249/250/251/253/254 | ✅ | ✅ | ✅ | 无能力门 |
| 245 胖包(gnu+musl) | ✅ | 结构性不可能 | — | # requires: gcc musl(补上了 musl) |
| 256 胖包(msvc+mingw) | — | 结构性不可能 | ✅ | # requires: msvc mingw;job 里装 xim:mingw-gcc |
| 248 跨 OS 胖包(PE 腿) | ✅ | — | — | mingw job |
255 lib.exe /REMOVE: |
— | — | ✅ | # requires: msvc |
| 257 PE 共享库(产出+打包+跑) | ✅ wine | — | — | # requires: mingw-cross wine |
258 MSVC 拒绝 shared |
— | — | ✅ | # requires: msvc;在 Linux 上看不到(target 门先答) |
| 259 Mach-O 共享库可重定位 | — | ✅ | — | # requires: macos;先删掉发布方的构建树再消费 |
macOS 那两格是产品事实不是测试缺口:它只能服务一个 target。
| # | 项 | 说明 |
|---|---|---|
| P1 | 打包库不能被原生 cl.exe 消费 |
生成的 manifest 用 -L/-l(GNU 拼写)。改成写文件路径也不行:ninja 的链接命令 cwd 是输出目录,只有 include 家族前缀会被相对包根绝对化,无前缀 token 会去错地方找(ld: cannot find lib/…/libmathkit.a),而绝对路径就不再可重定位。实测:旧版 mcpp 读到 [target.'cfg(…)'.runtime] 不报错、静默忽略 ⇒ 把某条腿的链接 flag 挪到方言中立通道,会让旧客户端一个 flag 都拿不到。所以补齐它要么两种拼写都带(新客户端把库链两遍),要么给这类包版本下限 —— 不是工作量问题,是与「零新键 + 老客户端可用」这两条约束直接冲突 |
| P2 | MSVC 的 kind = "shared" |
要生成 .def(对对象做符号扫描)—— 那是一个构建图节点,不是 flag |
| P3 | 扫描器改动的 revert 粒度 | O1 的代价,按裁决接受 |