Skip to content

Commit a51142f

Browse files
committed
feat: link-flag, and an open vocabulary that can say "empty"
Stage one of the general build-infrastructure design (.agents/docs/2026-09-07-general-build-infrastructure-gaps-design.md). mcpp:link-flag (protocol v8). link-lib, link-search and link-script each name one kind of thing, so a flag the program COMPUTED had no outlet: a version script whose content depends on which features are on, -Wl,--wrap=malloc for a runtime that takes over a C-library symbol, -Wl,--exclude-libs,ALL so a statically absorbed third party does not become part of this package's ABI. It reaches the consumer, and that CORRECTS the design doc, which first ruled it private by analogy with include-dir. The analogy is false and the code says so: linkUsage.ldflags is a copy of buildConfig.ldflags, so a private link flag is not a policy this engine can express, and [build] ldflags -- the declarative twin -- already propagates. Making the computed form behave differently would be the inconsistency rather than the safeguard. The hazard that follows is stated in the docs rather than hidden: a dependency emitting --version-script puts it on the consumer's link too, which a dependency writing the same flag in [build] ldflags has always done. cfg(accelerator = "none"). A CPU fallback could only be written by enumerating the backends it is not, and accelerator's vocabulary is open by design -- so that predicate changes meaning the day a fifth backend exists, and every fallback already written starts treating a build that named the new backend as having no accelerator. The spelling reuses os = "none", which this manifest already means "bare metal" by. Not cpu: that puts a second question on an axis whose job is "which device compiler, which architecture", and leaves cfg(accelerator = "cpu") under accel = "cuda" with no self-consistent answer. Both criteria measure the property rather than its shadow. e2e 620 asserts the LINKER'S BEHAVIOUR -- the program computes -Wl,--defsym=mcpp_e2e_620=42 and the artifact prints that symbol's address -- because grepping build.ninja would pass for a flag written down and never handed to the linker. The unit test SIMULATES the fifth backend arriving: the enumeration starts lying on the spot and none does not, which is the entire reason the row exists and is invisible under a single backend.
1 parent 6f75688 commit a51142f

11 files changed

Lines changed: 271 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,38 @@
55
66
## [Unreleased]
77

8+
### 构建程序能发出它算出来的链接标志:`mcpp:link-flag`
9+
10+
`link-lib``link-search``link-script` 各自命名一类东西,于是一条**算出来的**标志无处
11+
可去:内容依赖 feature 组合的版本脚本、运行时接管 C 库符号用的 `-Wl,--wrap=malloc`
12+
以及 `-Wl,--exclude-libs,ALL`(静态吞入的第三方不得成为本包 ABI 的一部分)。签入仓库
13+
的标志一直可以走 `[build] ldflags`,生成的不行。
14+
15+
新增 `mcpp:link-flag=``mcpp::link_flag()`(协议 v8)。原样传递 —— 引擎不解析链接器
16+
词汇。**它到达消费者**,与 `[build] ldflags` 一致:编译接口有声明式公开对应物因而
17+
`include-dir` 必须私有,链接标志没有这个分裂,让"算出来"的形态与它自己的声明式孪生
18+
行为不同才是不一致。后果写明:依赖发出的 `--version-script` 也会落到消费者链接行上,
19+
而这个隐患不是新的。
20+
21+
判据是 e2e 620,它断言**链接器的行为**而不是命令行文本:程序算出
22+
`-Wl,--defsym=mcpp_e2e_620=42`,产物打印那个符号的地址。值只可能来自链接器真的收到了
23+
这条标志。
24+
25+
### `cfg(accelerator = "none")` —— 开放词表不能靠枚举取反
26+
27+
CPU 回退此前只能写成 `not(any(accelerator = "cuda", accelerator = "vulkan"))`
28+
`accelerator` 的取值是**开放的**(docs/20:第五个后端是一个包,不是引擎改动),所以这条
29+
谓词的含义会随生态增长**静默改变** —— 新增一个后端之后,每个已写好的回退谓词都开始把
30+
"命名了新后端的构建"当成"没有加速器",于是 CPU 实现与设备实现一起编进去。
31+
32+
`accelerator = "none"` 为真当且仅当加速器集合为空。拼法沿用本仓库已有的
33+
`os = "none"`(裸机),不新造词。不用 `cpu`:那会让这条轴同时承载两个问题,并且
34+
`cfg(accelerator = "cpu")``accel = "cuda"` 下的真假无法自洽地定下来。
35+
36+
判据 `test_cfg_accelerator_none.cpp` 直接**模拟第五个后端到来**:枚举写法当场开始说谎,
37+
`none` 不变。这是这项改动的全部理由,单后端下跑绿零信息量。
38+
39+
840
### 工具也有两条解析轴:`[target.<selector>.xlings…]`
941

1042
一条工具条目回答的是两个不同问题中的一个:它是在构建机上执行的(宿主),还是产物编译

docs/07-build-mcpp.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ is ignored, so diagnostics may be logged freely.
5353
| `mcpp:include-dir=<dir>` *(0.0.100+)* | add a **private** include directory (`-I`) for this package's own TUs (absolute, or relative to the package root; normalized). Replaces the `cxxflag=-I` + `cflag=-I` double emission |
5454
| `mcpp:include-dir-after=<dir>` *(0.0.100+)* | like `include-dir`, but searched **after** the system directories (`-idirafter`) — for payload trees that shadow system headers |
5555
| `mcpp:runner=<token>` *(2026.8.19.2+)* | one argv token of the command that EXECUTES this build's artifact, when the host cannot. Emitted once per token, in order; the artifact path is appended (or substituted for `{}`). Reaches the **consumer**. Emit the executable as an ABSOLUTE path, and only **one** dependency may supply it |
56+
| `mcpp:link-flag=<flag>` *(2026.9.6.5+)* | add a **linker flag** this program computed, verbatim. The outlet `link-lib` / `link-search` / `link-script` leave open: a generated version script (`-Wl,--version-script=`), `-Wl,--wrap=malloc` for a runtime that takes over a C-library symbol, `-Wl,--exclude-libs,ALL` so a statically absorbed third party does not become part of this package's ABI. Appended after `[build] ldflags`, in emission order. **Reaches the consumer**, exactly as `[build] ldflags` does — see below |
5657
| `mcpp:link-script=<path>` *(2026.8.19+)* | link with this **linker script** (`-T`; relative resolves against the package root, and the emitted path is absolute because the link runs in the build directory). Reaches the **consumer**, unlike `include-dir` — a board's memory layout is the one thing a consumer cannot write for itself |
5758
| `mcpp:warning=<text>` *(2026.8.21.2+)* | say something to the user and **keep going**. The one directive that changes no compile line, no link line and no source set. Survives the build cache — see below |
5859
| `mcpp:fact=<name>=<version>` *(2026.9.5.2+)* | state something the program **established about the machine** (`cuda.driver=12.4`). Compared against floors before anything is compiled; see below |
@@ -65,6 +66,19 @@ registry dependency — the dependency graph stays declarative in `mcpp.toml`
6566
(including platform-conditional `[target.windows.dependencies]`). `build.mcpp`
6667
is for *leaf* decisions: flags, codegen, link requirements.
6768

69+
`link-flag` is deliberately **not** private, and the reason is worth stating
70+
because the opposite looks safer. A compile interface has a declarative public
71+
counterpart (`[build] include_dirs`), so a build-time program widening it would
72+
go behind the manifest's back — hence `include-dir`'s privateness. Link flags
73+
have no such split: `[build] ldflags` already propagates to consumers, so a
74+
private computed form would behave differently from its own declarative twin.
75+
76+
The consequence is stated rather than hidden. A dependency emitting
77+
`-Wl,--version-script=` puts it on the consumer's link line too, which is
78+
usually not what that dependency meant. That hazard is not new — a dependency
79+
writing the same flag in `[build] ldflags` has always done this — so this
80+
directive widens *who can compute the value*, not *what the value can reach*.
81+
6882
`include-dir`/`include-dir-after` are deliberately **private** (Cargo
6983
discipline): they color only this package's own TUs and are never propagated
7084
to consumers. An include directory consumers must see is part of the public
@@ -104,6 +118,7 @@ int main() {
104118
| `mcpp::rerun_if_changed(p)` / `mcpp::rerun_if_env_changed(v)` | the matching `rerun-*` directives |
105119
| `mcpp::rerun_if_changed_glob(pat)` *(2026.8.6.2+)* | `mcpp:rerun-if-changed-glob=` — re-run when the **set** of files matching `pat` changes (see below) |
106120
| `mcpp::dep_bin(pkg, tool)` *(2026.8.5.1+)* | reads `MCPP_DEP_<PKG>_BIN_<TOOL>` — the absolute path of a **host tool** built by a dependency (see below) |
121+
| `mcpp::link_flag(s)` *(2026.9.6.5+)* | `mcpp:link-flag=` |
107122
| `mcpp::link_script(p)` *(2026.8.19+)* | `mcpp:link-script=` |
108123
| `mcpp::runner(tok)` *(2026.8.19.2+)* | `mcpp:runner=` — see below |
109124
| `mcpp::xpkg_dir(ns, name)` / `mcpp::xpkg_dir(name)` *(2026.8.19+)* | the payload directory of a package this manifest declared in `[xlings.workspace]`; `""` when it was not declared or is not installed (see below) |

docs/zh/07-build-mcpp.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ mcpp build # 编译 + 运行 build.mcpp,然后构建工程
5050
| `mcpp:include-dir=<dir>` *(0.0.100+)* | 为本包自身 TU 增加一个**私有** include 目录(`-I`;绝对路径或相对包根,自动规范化)。取代过去 `cxxflag=-I` + `cflag=-I` 的双重裸发 |
5151
| `mcpp:include-dir-after=<dir>` *(0.0.100+)* |`include-dir`,但排在系统目录**之后**搜索(`-idirafter`)——用于会遮蔽系统头的 payload 源树 |
5252
| `mcpp:runner=<token>` *(2026.8.19.2+)* | 执行本次构建产物的命令的**一个 argv token**(宿主跑不了它时)。一个 token 一次调用、按顺序;产物路径会被追加(或替换 `{}`)。**到达消费者**。可执行文件要发**绝对路径**,且**只能有一个**依赖提供它 |
53+
| `mcpp:link-flag=<flag>` *(2026.9.6.5+)* | 加一条本程序**算出来的**链接标志,原样传递。这是 `link-lib` / `link-search` / `link-script` 各自命名一类东西之后留下的出口:生成的版本脚本(`-Wl,--version-script=`)、运行时接管 C 库符号用的 `-Wl,--wrap=malloc`、以及 `-Wl,--exclude-libs,ALL`(静态吞入的第三方不得成为本包 ABI 的一部分)。按发出顺序追加在 `[build] ldflags` 之后。**到达消费者**,与 `[build] ldflags` 一致 —— 理由见下 |
5354
| `mcpp:link-script=<path>` *(2026.8.19+)* | 用这个**链接脚本**链接(`-T`;相对路径按包根解析,发出的是绝对路径,因为链接是在构建目录里跑的)。与 `include-dir` 不同,它**到达消费者** —— 板子的内存布局恰恰是消费者写不出来的那一项 |
5455
| `mcpp:warning=<text>` *(2026.8.21.2+)* | 对用户说一句话并**继续**。唯一一条不改变编译行、链接行与源码集的指令。它**穿过构建缓存** —— 见下 |
5556
| `mcpp:fact=<name>=<version>` *(2026.9.5.2+)* | 陈述程序**测得的机器事实**(`cuda.driver=12.4`)。在编译任何东西之前与 floor 比较;见下 |
@@ -61,6 +62,15 @@ mcpp build # 编译 + 运行 build.mcpp,然后构建工程
6162
`mcpp.toml` 里声明式管理(包括平台条件依赖 `[target.windows.dependencies]`)。
6263
`build.mcpp` 用于*叶子*决策:开关、代码生成、链接需求。
6364

65+
`link-flag` 刻意****私有,而这一点值得说明,因为相反的选择看上去更安全。编译接口有
66+
一个声明式的公开对应物(`[build] include_dirs`),所以构建期程序若能加宽它就是绕过了
67+
manifest —— 这正是 `include-dir` 私有的理由。链接标志没有这个分裂:`[build] ldflags`
68+
本来就传播给消费者,因此一个私有的"算出来"形态会与它自己的声明式孪生行为不一致。
69+
70+
后果写明而不藏起来:一个依赖发出 `-Wl,--version-script=`,该标志也会落到消费者的链接
71+
行上,而那通常不是它的本意。这个隐患不是新的 —— 依赖在 `[build] ldflags` 里写同一条
72+
标志一直如此 —— 所以这条指令加宽的是**谁能算出这个值**,不是**这个值能到达哪里**
73+
6474
`include-dir`/`include-dir-after` 刻意保持**私有**(Cargo 纪律):只染色本包自身的
6575
TU,绝不向消费者传播。需要消费者可见的 include 目录属于公共接口,应写在声明式
6676
manifest/描述符里(`[build] include_dirs`),而不是构建期程序里。
@@ -97,6 +107,7 @@ int main() {
97107
| `mcpp::rerun_if_changed(p)` / `mcpp::rerun_if_env_changed(v)` | 对应的 `rerun-*` 指令 |
98108
| `mcpp::rerun_if_changed_glob(pat)` *(2026.8.6.2+)* | `mcpp:rerun-if-changed-glob=` —— 匹配 `pat` 的文件**集合**发生变化时重跑(见下) |
99109
| `mcpp::dep_bin(pkg, tool)` *(2026.8.5.1+)* |`MCPP_DEP_<PKG>_BIN_<TOOL>` —— 依赖构建出的 **host 工具**的绝对路径(见下) |
110+
| `mcpp::link_flag(s)` *(2026.9.6.5+)* | `mcpp:link-flag=` |
100111
| `mcpp::link_script(p)` *(2026.8.19+)* | `mcpp:link-script=` |
101112
| `mcpp::runner(tok)` *(2026.8.19.2+)* | `mcpp:runner=` —— 见下 |
102113
| `mcpp::xpkg_dir(ns, name)` / `mcpp::xpkg_dir(name)` *(2026.8.19+)* | 本 manifest 在 `[xlings.workspace]` 里声明的包的载荷目录;没声明或没安装时返回 `""`(见下) |

mcpp.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "mcpp"
3-
version = "2026.9.6.4"
3+
version = "2026.9.6.5"
44
description = "Modern C++ build & package management tool"
55
license = "Apache-2.0"
66
authors = ["mcpp-community"]

modules/buildmcpp/src/directives.cppm

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ struct Def {
217217
int sinceProtocol;
218218
};
219219

220-
inline constexpr std::array<Def, 21> kTable{{
220+
inline constexpr std::array<Def, 22> kTable{{
221221
// wire tag slot scope transform must missingPrefix missingSuffix since
222222
{"cxxflag", "cxxflag", Slot::CxxFlags, Scope::PackagePrivate, Transform::Verbatim, false, "", "", 1},
223223
{"cflag", "cflag", Slot::CFlags, Scope::PackagePrivate, Transform::Verbatim, false, "", "", 1},
@@ -267,6 +267,35 @@ inline constexpr std::array<Def, 21> kTable{{
267267
{"runner-longlived", "runner-longlived", Slot::RunnerLongLived, Scope::RunGlobal, Transform::Verbatim, false, "", "", 6},
268268
{"run-exclusive", "run-exclusive", Slot::RunExclusive, Scope::RunGlobal, Transform::Verbatim, false, "", "", 6},
269269
{"link-script", "ldflag", Slot::LdFlags, Scope::LinkGlobal, Transform::LinkerScript, false, "", "", 3},
270+
// THE OUTLET THE LINK FAMILY WAS MISSING (v8).
271+
//
272+
// `link-lib`, `link-search` and `link-script` each name one KIND of thing.
273+
// A flag the program COMPUTED belongs to none of them: a generated version
274+
// script (`-Wl,--version-script=`), `-Wl,--wrap=malloc` for a runtime that
275+
// takes over a C-library symbol, `-Wl,--exclude-libs,ALL` so a statically
276+
// absorbed third party does not become part of this package's ABI.
277+
//
278+
// Scope::LinkGlobal, AND THAT IS THE CORRECTION OF AN EARLIER DESIGN.
279+
// The design doc first ruled it PackagePrivate by analogy with
280+
// `include-dir`. The analogy is false. `include-dir` is private because a
281+
// compile interface has a declarative public counterpart
282+
// (`[build] include_dirs`) and a build-time program must not widen it
283+
// behind the manifest's back. Link flags have no such split: the
284+
// declarative `[build] ldflags` ALREADY propagates to consumers, and
285+
// `linkUsage.ldflags` is a copy of `buildConfig.ldflags`. A private link
286+
// flag is not a policy this engine can express today, and making the
287+
// computed form behave differently from its declarative twin would be the
288+
// inconsistency, not the safeguard.
289+
//
290+
// The consequence is stated rather than hidden: a dependency emitting
291+
// `-Wl,--version-script=` puts it on the consumer's link too. That hazard
292+
// is not new -- a dependency writing the same flag in `[build] ldflags`
293+
// has always done this -- so this row widens who can compute the value,
294+
// not what the value can reach.
295+
//
296+
// Verbatim: the engine does not parse linker flags. `-Wl,` forms, `-z`
297+
// pairs and vendor spellings are the linker's vocabulary, not this table's.
298+
{"link-flag", "ldflag", Slot::LdFlags, Scope::LinkGlobal, Transform::Verbatim, false, "", "", 8},
270299
{"include-dir", "include-dir", Slot::IncludeDirs, Scope::PackagePrivate, Transform::AbsPath, false, "", "", 1},
271300
{"include-dir-after", "include-dir-after", Slot::IncludeDirsAfter, Scope::PackagePrivate, Transform::AbsPath, false, "", "", 1},
272301
{"rerun-if-changed", "", Slot::RerunFiles, Scope::RerunKey, Transform::Verbatim, false, "", "", 1},

modules/buildmcpp/src/program_protocol.cppm

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,13 @@ export namespace mcpp::build::program_protocol {
6060
// fact about the machine and the floor it needs of it; the engine compares
6161
// them before compiling. Same cost as v5's: a package calling `mcpp::fact()`
6262
// fails on an older engine at the build.mcpp COMPILE, not through a refusal.
63-
inline constexpr int kProtocolVersion = 7;
63+
// v8: adds `link-flag` -- the generic linker-flag outlet. `link-lib`,
64+
// `link-search` and `link-script` cover a library, a search path and a layout;
65+
// a flag a program COMPUTED (a generated version script, `--wrap`,
66+
// `--exclude-libs`) had no way out. Same cost as v5's: a package calling
67+
// `mcpp::link_flag()` fails on an older engine at the build.mcpp COMPILE,
68+
// because that engine's bundled module has no such function.
69+
inline constexpr int kProtocolVersion = 8;
6470

6571
// ── Cache-format epoch ─────────────────────────────────────────────────────
6672
//

modules/versioning/src/version.cppm

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,6 @@ import std;
3131

3232
export namespace mcpp {
3333

34-
inline constexpr std::string_view MCPP_VERSION = "2026.9.6.4";
34+
inline constexpr std::string_view MCPP_VERSION = "2026.9.6.5";
3535

3636
} // namespace mcpp

src/build/hostprogram.cppm

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,11 @@ inline void floor(const char* spec) { std::printf("mcpp:floor=%s\n
128128
// (like link_lib/link_search, unlike include_dir), because the package that
129129
// knows a board's layout is not the package being built.
130130
inline void link_script(const char* path) { std::printf("mcpp:link-script=%s\n", path); }
131+
// A linker flag this program COMPUTED. The outlet `link_lib` / `link_search` /
132+
// `link_script` leave open: a generated version script, `--wrap`,
133+
// `--exclude-libs`. Reaches the consumer's link line, as `[build] ldflags`
134+
// already does -- see the table row for why a private form is not offered.
135+
inline void link_flag(const char* flag) { std::printf("mcpp:link-flag=%s\n", flag); }
131136
// ── Build-graph nodes (mcpp 2026.8.5.1+) ────────────────────────────────
132137
// Declare WORK instead of doing it. A build program is a good place to decide
133138
// what the build looks like and a bad place to perform it: work done here is

src/build/prepare_inputs.cppm

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,37 @@ struct Ctx {
9292
// backends are enabled". Membership everywhere keeps `any`/`all`/`not`
9393
// pure boolean combinators, and a single-backend build still answers
9494
// `accelerator = "cuda"` true and `accelerator = "rocm"` false.
95+
// `none` IS THE EMPTY SET, AND AN OPEN VOCABULARY CANNOT SAY THAT BY
96+
// ENUMERATION.
97+
//
98+
// A CPU fallback used to be written `not(any(accelerator = "cuda",
99+
// accelerator = "vulkan"))`. `accelerator`'s vocabulary is OPEN by design
100+
// -- docs/20 states that a fifth backend is a package rather than an
101+
// engine change -- so that predicate's meaning changes the day a fifth one
102+
// exists: every fallback already written silently starts matching a build
103+
// that named the new backend. The failure is that the CPU implementation
104+
// and the device implementation compile together, or that neither does.
105+
//
106+
// The spelling is the one this manifest already uses for the same idea:
107+
// `os = "none"` is bare metal (docs/05 section 2.7.2). One word, one
108+
// meaning, no new vocabulary.
109+
//
110+
// NOT `cpu`. That would put a second question on this axis -- the axis
111+
// answers "which device compiler, which architecture", and the CPU needs
112+
// neither -- and it would leave `cfg(accelerator = "cpu")` undecided under
113+
// `accel = "cuda"`: true makes the fallback compile alongside the device
114+
// path and destroys the mutual exclusion the seam exists for; false forces
115+
// every existing manifest to write `accel = "cuda, cpu"`.
116+
//
117+
// A build where BOTH a CPU path and a device path are wanted needs none of
118+
// this: the CPU sources go in the unconditional `[build] sources` and the
119+
// device sources under `cfg(accelerator = "x")`. `not(...)` was only ever
120+
// needed for a mutually exclusive seam, which is the case this repairs.
95121
bool layer_matches(std::string_view k, std::string_view v) const {
96-
if (k == "accelerator")
122+
if (k == "accelerator") {
123+
if (v == "none") return accelerators.empty();
97124
return std::ranges::find(accelerators, v) != accelerators.end();
125+
}
98126
return layer_value(k) == v;
99127
}
100128
};

0 commit comments

Comments
 (0)