Skip to content

Commit d4a8324

Browse files
authored
2026.9.8.1: a package's host modules are ordered by what they import, and the design document aligned with what shipped (#589)
* 2026.9.8.1: a package's host modules are ordered by what they import 一个 host-module 包贡献的那些单元,过去按**路径**排序:lib root 在前,其余按 `std::set<path>` 的字母序。而 `build_program.cppm` 是一边编译一边累积模块旗标的, 每个单元只看得见排在它前面的那些 BMI。于是 `rules/spirv.cppm` 排在 `src/declare.cppm` 之前,一个 import 了本包共享单元的成员先被编译,失败于: failed to read compiled module: No such file or directory note: imports must be built before being imported **两个方向都复现过**:把共享单元改个名让它的路径排在前面,同一个包就构建通过。所以 成因是那次排序,不是别的。 它的代价不是一次失败,而是一个基于误读的设计决定 ------------------------------------------------ `mcpp:plugins` 把这个失败读成「第二个单元根本不会被编译成 host module」,于是把成员 共享的一切都折进 lib root,让它从约二十行涨到约七百行(该包设计文档 §13.1.1 把这笔 代价记了下来,连同那个错误的成因)。真正的成因是一次排序。 修法 ---- 按 import 图拓扑排序,并以**路径序做稳定次序**:没有包内 import 时结果与今天逐字相同, 只有在今天已经坏掉的情形下才不同 —— 对既有包严格更安全。 `declared_imports` 与它旁边的 `declared_interface_name` 同形:按行、认注释、刻意浅。 它回答的是「本包哪些单元必须排在这个之前」,任何不是本包单元的名字由调用方丢弃。它不是 模块扫描器的替代品。 环留给编译器报。那是 ill-formed C++,编译器会点名那两个单元;在这里拒绝只会把同一个 事实报在更差的位置,而顺序错这件事无论如何已经不可能发生了。 判据 ---- e2e 633 两条腿: 1. 一条**完全逆序**的链 —— 三个单元的 import 序与路径序恰好相反。路径排序下每一个 都排在它 import 的东西前面;import 排序下每一个都排在后面。除了顺序没有别的东西 决定它建不建得起来。判据是三个模块共同算出的那个值,经 `mcpp::warning` 输出 —— 那是构建成功时唯一会被打印的构建程序通道。 2. 包内无 import 的情形必须保持原次序。不是装饰:一个忽略原次序的拓扑排序可以吐出 任意合法序,于是今天能用的包会被无理由重排,而且是静默的。 把修复退回路径排序,第一条腿如实变红。 * device_extensions and rule_module are read, and were reported as unsupported 同一个解析器往上约四十行就把这两个键读进 `featureDeviceExtensions` 与 `featureRuleModule`,prepare 在消费者激活该 feature 时会读它们 —— 它们正是「新增一门 设备语言不需要引擎发版」的全部依据。它们只是没被加进 `kKnownFeatureKeys`,于是引擎对 一个它刚刚用过的键打印: [features].rules-spirv has unsupported key 'rule_module' (ignored). Supported keys: defines, flags, forward, implies, provides, requires, sources. 比消息错更坏:它在**建议包作者删掉让规则生效的那两行**。 没被发现,是因为规则包平时走的 host-module 路径不打印 schema 警告;而**普通**构建会 打印。当一个规则包开始产出 `tools = [...]` 要的二进制,它的普通构建就成了常态,这条 噪音于是浮到每个用户面前。 * docs(plan): the design document, aligned with what shipped `.agents/docs/2026-09-07-module-first-heterogeneous-surface.md` 已经变成一个标题下的 **四份文档**:第 1–12 节是写在早于发布的 base commit 上的设计,第 13 节是计划与实施 记录,第 14 节是现状登记。读到前面就停下的人会被误导,而这份文档正是别人理解这套设计 的入口。 十四处就地更正,分三类 ---------------------- **被设计后撤回的语法。** `[rules]` 这张 manifest 表从未实现也不打算实现(该文 13.1.2 给了论证,`grep '"rules"' modules/manifest/src/toml.cppm` 为空),而 §5.2、§5.5、§6、 §8.D 仍在按它写。指向真正发布的形状:`options::module_name`、`options::surface`、 读 `MCPP_LANGUAGE_MODULES` 的 `default_surface()`。 **以不同形状发布的机制。** §4.1 提的是「给 embed 一个由图调起的宿主工具二进制」; 发布的是在 build.mcpp 里调用的库形态 —— 而它在 plan 期写 `.S`,正是 14.1 那条边存在 的原因。§4.3 写下了正确的要求,又加了「与 depfile 那一项共用同一个修法」,而那五个字 是错的:实测三条通道,只有 GNU as 报 `.incbin`,clang 的集成汇编器根本没有依赖输出。 **已关闭却仍列为 open 的条目。** §1 的三条里两条已发布;§3.5 还在断言那个字段不存在; §7 的四条里三条已解决;§12 描述的 Slang 规则已经发布。 不删掉,因为一个论证被否决也是它记录的一部分,一个设计在接触现实时变的形,同样是。 状态只在一处陈述:「哪些规则传了 depfile」活在 §14.2,不在别处 —— 三份拷贝就是它变化 时要记得改的三处。 §14 是唯一的现状登记表 ---------------------- 14.1 与 14.2 标为 CLOSED 并保留分析(14.1 的两半是可迁移的:一条要求被写下然后丢失, 以及第一次测量问错了工具)。新增 14.7(§5.6 承诺、§8.C 说要在同一次改动里加、结果既 没做也没登记的 `--no-accel` 诊断)与 14.8(L0 的现状,以及它的代价为什么是引擎侧的)。 新增 13.2.1 记这一批的跨仓波次。 14.1 按最终形态重写:缺陷在已发布生态里的沙箱复现、它为什么不是新发现、问错工具那次 测量、以及为什么最后**不需要**新引擎通道 —— 成因是生成跑在错误的时刻,不是引擎表达 不了那条边。 --------- Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com>
1 parent 716cce1 commit d4a8324

8 files changed

Lines changed: 837 additions & 36 deletions

File tree

.agents/docs/2026-09-07-module-first-heterogeneous-surface.md

Lines changed: 449 additions & 32 deletions
Large diffs are not rendered by default.

CHANGELOG.md

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

8+
## [2026.9.8.1] - 2026-09-08
9+
10+
### 一个包的 host module,按它们互相 import 的顺序编译
11+
12+
一个 host-module 包贡献的那些单元,过去按**路径**排序:lib root 在前,其余按
13+
`std::set<path>` 的字母序。而 `build_program.cppm` 是一边编译一边累积模块旗标的,
14+
每个单元只看得见排在它前面的那些 BMI。于是 `rules/spirv.cppm` 排在
15+
`src/surface.cppm` 之前,一个 import 了本包共享单元的成员先被编译,失败于:
16+
17+
```
18+
failed to read compiled module: No such file or directory
19+
note: imports must be built before being imported
20+
```
21+
22+
**两个方向都复现过**:把共享单元改个名让它的路径排在前面,同一个包就构建通过。所以
23+
成因是那次排序,不是别的。
24+
25+
**它的代价不是一次失败,而是一个基于误读的设计决定。** `mcpp:plugins` 把这个失败读成
26+
「第二个单元根本不会被编译成 host module」,于是把成员共享的一切都折进 lib root,
27+
让它从约二十行涨到约七百行。真正的成因是一次排序。
28+
29+
现在按 import 图拓扑排序,并以**路径序做稳定次序**:没有包内 import 时结果与今天逐字
30+
相同,只有在今天已经坏掉的情形下才不同。环留给编译器报——那是 ill-formed C++,编译器
31+
会点名那两个单元,在这里拒绝只会把同一个事实报在更差的位置。
32+
33+
判据是 e2e 633 的两条腿:一条**完全逆序**的链(路径序与 import 序恰好相反),以及一个
34+
包内无 import 的包必须保持原次序。把修复退回路径排序,第一条腿如实变红。
35+
36+
### `device_extensions``rule_module` 不再被报成 unsupported
37+
38+
这两个键在同一个解析器里往上约四十行就被读进 `featureDeviceExtensions`
39+
`featureRuleModule`,而且 prepare 在消费者激活该 feature 时会读它们——它们正是「新增一门
40+
设备语言不需要引擎发版」的全部依据。它们只是没被加进 `kKnownFeatureKeys`,于是引擎对
41+
一个它刚刚用过的键打印「unsupported key (ignored)」。
42+
43+
比消息错更坏:它在**建议包作者删掉让规则生效的那两行**
44+
45+
没被发现是因为规则包平时走的 host-module 路径不打印 schema 警告;而**普通**构建会打印。
46+
`tools = [...]` 让规则包的普通构建成为常态,这条噪音于是浮上来。
47+
48+
### 撤回:一条曾经加上的编译依赖通道
49+
50+
本版早先的草案加过 `mcpp::recompile_if_changed`(协议 9),用来声明「编译某个源文件时
51+
读到、却没有任何 depfile 会报告」的依赖——`.incbin` 就是这种。它工作正常,判据齐备。
52+
53+
**它被撤回,因为原型证明它不必要。** 那条依赖用引擎已有的唯一图原语就能表达:让生成
54+
`.S` 的那一步成为一条 `mcpp::action`,载荷是它的**声明输入**。载荷一变,action 重跑;
55+
没有 `restat`,其 output 被视为新的;汇编边随之重跑。实测 `bytes=64 -> 192`,零引擎改动。
56+
57+
协议因此保持在 8。发一条只有一个使用者、而那个使用者本可以不需要它的协议面,是永久成本
58+
换一次便利。
59+
860
## [2026.9.7.1] - 2026-09-07
961

1062
### 四条通道,都是「规则包知道而引擎收不到」的形状

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.7.1"
3+
version = "2026.9.8.1"
44
description = "Modern C++ build & package management tool"
55
license = "Apache-2.0"
66
authors = ["mcpp-community"]

modules/buildmcpp/src/provisions.cppm

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,68 @@ inline std::string declared_interface_name(std::string_view source)
357357
return {};
358358
}
359359

360+
// The module names a unit IMPORTS, for ordering one package's host modules
361+
// among themselves.
362+
//
363+
// WHY THIS EXISTS. The units a host-module package contributes used to be
364+
// compiled in PATH order, and a package whose members share a unit could
365+
// therefore be handed to the compiler in an order the compiler cannot accept:
366+
// `rules/spirv.cppm` sorts before `src/surface.cppm`, so a member importing
367+
// the shared unit failed with "failed to read compiled module ... imports must
368+
// be built before being imported". Measured, and measured in both directions:
369+
// renaming the shared unit so its path sorted first made the same package
370+
// build. The cost of not having this was a design decision made against the
371+
// wrong cause -- a collection folded everything its members shared into the lib
372+
// root, taking it from about twenty lines to about seven hundred, because the
373+
// ordering was read as "a second unit is not compiled at all".
374+
//
375+
// Deliberately line-based and deliberately shallow, matching
376+
// `declared_interface_name` above: this answers "which units of THIS package
377+
// must precede this one", and every name that is not another unit of the same
378+
// package is discarded by the caller. It is not a substitute for the module
379+
// scanner, which answers a much harder question about the build graph proper.
380+
//
381+
// `export import` counts -- it is an import. A partition import (`import :p;`)
382+
// does not: a partition is not a unit compiled alone under a name of its own,
383+
// which is the same reason `declared_interface_name` rejects one.
384+
inline std::vector<std::string> declared_imports(std::string_view source)
385+
{
386+
auto is_name = [](char c) {
387+
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
388+
|| (c >= '0' && c <= '9') || c == '_' || c == '.';
389+
};
390+
auto is_ws = [](char c) { return c == ' ' || c == '\t' || c == '\r'; };
391+
std::vector<std::string> out;
392+
std::size_t lineStart = 0;
393+
while (lineStart < source.size()) {
394+
auto eol = source.find('\n', lineStart);
395+
if (eol == std::string_view::npos) eol = source.size();
396+
auto line = source.substr(lineStart, eol - lineStart);
397+
lineStart = eol + 1;
398+
399+
std::size_t i = 0;
400+
while (i < line.size() && is_ws(line[i])) ++i;
401+
if (line.substr(i).starts_with("//")) continue;
402+
if (line.substr(i).starts_with("export")) {
403+
i += 6;
404+
if (i >= line.size() || !is_ws(line[i])) continue;
405+
while (i < line.size() && is_ws(line[i])) ++i;
406+
}
407+
if (!line.substr(i).starts_with("import")) continue;
408+
i += 6;
409+
if (i >= line.size() || !is_ws(line[i])) continue;
410+
while (i < line.size() && is_ws(line[i])) ++i;
411+
std::size_t start = i;
412+
while (i < line.size() && is_name(line[i])) ++i;
413+
if (i == start) continue; // `import :part;`, `import <h>;`
414+
std::string name(line.substr(start, i - start));
415+
while (i < line.size() && is_ws(line[i])) ++i;
416+
if (i >= line.size() || line[i] != ';') continue;
417+
out.push_back(std::move(name));
418+
}
419+
return out;
420+
}
421+
360422
// One host module as registered for one consumer's build program.
361423
struct HostModule {
362424
std::string module; // what `import` in build.mcpp addresses

modules/manifest/src/toml.cppm

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -837,6 +837,25 @@ std::expected<Manifest, ManifestError> parse_string(std::string_view content,
837837
static constexpr std::string_view kKnownFeatureKeys[] = {
838838
"defines", "flags", "forward", "implies", "provides",
839839
"requires", "sources",
840+
// THE TWO RULE-PACKAGE KEYS, WHICH THIS PARSER READS ABOUT
841+
// FORTY LINES ABOVE AND THEN REPORTED AS UNSUPPORTED.
842+
//
843+
// `device_extensions` and `rule_module` are parsed into
844+
// `featureDeviceExtensions` and `featureRuleModule`, and
845+
// read by prepare when a consumer activates the feature --
846+
// they are the whole reason a new device language costs no
847+
// engine release. Leaving them off this list made every
848+
// ordinary load of such a package print "unsupported key
849+
// (ignored)" for a key it had just used, which is worse
850+
// than a wrong message: it tells a package author to delete
851+
// the two lines that make their rule work.
852+
//
853+
// It went unnoticed because the host-module path a rule
854+
// package is normally loaded through does not print schema
855+
// warnings. An ORDINARY build of the same package does --
856+
// and `tools = [...]` made ordinary builds of rule packages
857+
// routine.
858+
"device_extensions", "rule_module",
840859
};
841860
for (auto& [fkey, fignored] : fval.as_table()) {
842861
(void)fignored;

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.7.1";
34+
inline constexpr std::string_view MCPP_VERSION = "2026.9.8.1";
3535

3636
} // namespace mcpp

src/build/prepare.cppm

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7634,17 +7634,82 @@ prepare_build(bool print_fingerprint,
76347634
}
76357635
}
76367636
const auto root = iface.lexically_normal();
7637+
// ORDERED BY WHAT THEY IMPORT, NOT BY WHERE THEY SIT.
7638+
//
7639+
// The compile loop accumulates BMIs in list order, so each
7640+
// entry sees only what precedes it. Path order was the previous
7641+
// rule and it is not a valid one: `rules/spirv.cppm` sorts
7642+
// before `src/surface.cppm`, so a member importing a unit its
7643+
// package shares was compiled first and failed with "failed to
7644+
// read compiled module ... imports must be built before being
7645+
// imported". Reproduced, and reproduced in both directions --
7646+
// renaming the shared unit so its path sorted first made the
7647+
// same package build, which is what says the cause is the sort
7648+
// and nothing else.
7649+
//
7650+
// A package that works today is ordered IDENTICALLY: the sort
7651+
// below keeps path order wherever no import constrains it, so
7652+
// it differs only where the old order was already broken.
7653+
struct Unit {
7654+
std::filesystem::path path;
7655+
std::string name;
7656+
std::vector<std::string> imports;
7657+
};
7658+
std::vector<Unit> pending;
76377659
for (auto const& f : matched) { // std::set: sorted
76387660
if (dropped.contains(f)) continue;
76397661
if (std::filesystem::equivalent(f, root, ec)) continue;
76407662
std::ifstream is(f);
76417663
if (!is) continue;
76427664
std::stringstream buf;
76437665
buf << is.rdbuf();
7644-
auto name = prov::declared_interface_name(buf.str());
7666+
auto text = buf.str();
7667+
auto name = prov::declared_interface_name(text);
76457668
if (name.empty()) continue;
7646-
push(f, std::move(name));
7669+
pending.push_back({f, std::move(name), prov::declared_imports(text)});
76477670
}
7671+
7672+
// Only names this package itself declares constrain anything.
7673+
// `import std;` and the lib root are already ahead of every
7674+
// entry here, and a name from another package is ordered by the
7675+
// cross-package DFS below rather than by this sort.
7676+
std::map<std::string, std::size_t> byName;
7677+
for (std::size_t i = 0; i < pending.size(); ++i)
7678+
byName.emplace(pending[i].name, i);
7679+
7680+
std::vector<char> state(pending.size(), 0); // 0 new, 1 open, 2 done
7681+
std::vector<std::size_t> order;
7682+
order.reserve(pending.size());
7683+
// Iterative post-order DFS over the path-sorted list: the first
7684+
// unit that can be emitted is emitted, which is what preserves
7685+
// path order in the unconstrained case.
7686+
const auto visit = [&](std::size_t start) {
7687+
std::vector<std::pair<std::size_t, std::size_t>> stack{{start, 0}};
7688+
while (!stack.empty()) {
7689+
auto& [u, k] = stack.back();
7690+
if (state[u] == 2) { stack.pop_back(); continue; }
7691+
state[u] = 1;
7692+
if (k < pending[u].imports.size()) {
7693+
auto const& want = pending[u].imports[k++];
7694+
auto it = byName.find(want);
7695+
// A CYCLE IS LEFT TO THE COMPILER, ON PURPOSE. It
7696+
// is ill-formed C++ and the compiler says so with
7697+
// the two units named; refusing here would report
7698+
// the same fact in a worse place, and getting the
7699+
// ordering wrong is no longer possible either way.
7700+
if (it != byName.end() && state[it->second] == 0)
7701+
stack.push_back({it->second, 0});
7702+
continue;
7703+
}
7704+
state[u] = 2;
7705+
order.push_back(u);
7706+
stack.pop_back();
7707+
}
7708+
};
7709+
for (std::size_t i = 0; i < pending.size(); ++i)
7710+
if (state[i] == 0) visit(i);
7711+
7712+
for (auto i : order) push(pending[i].path, std::move(pending[i].name));
76487713
return out;
76497714
};
76507715
for (std::size_t c = 0; c < provisionGraph.visible.size(); ++c) {

0 commit comments

Comments
 (0)