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
481 changes: 449 additions & 32 deletions .agents/docs/2026-09-07-module-first-heterogeneous-surface.md

Large diffs are not rendered by default.

52 changes: 52 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,58 @@

## [Unreleased]

## [2026.9.8.1] - 2026-09-08

### 一个包的 host module,按它们互相 import 的顺序编译

一个 host-module 包贡献的那些单元,过去按**路径**排序:lib root 在前,其余按
`std::set<path>` 的字母序。而 `build_program.cppm` 是一边编译一边累积模块旗标的,
每个单元只看得见排在它前面的那些 BMI。于是 `rules/spirv.cppm` 排在
`src/surface.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,
让它从约二十行涨到约七百行。真正的成因是一次排序。

现在按 import 图拓扑排序,并以**路径序做稳定次序**:没有包内 import 时结果与今天逐字
相同,只有在今天已经坏掉的情形下才不同。环留给编译器报——那是 ill-formed C++,编译器
会点名那两个单元,在这里拒绝只会把同一个事实报在更差的位置。

判据是 e2e 633 的两条腿:一条**完全逆序**的链(路径序与 import 序恰好相反),以及一个
包内无 import 的包必须保持原次序。把修复退回路径排序,第一条腿如实变红。

### `device_extensions` 与 `rule_module` 不再被报成 unsupported

这两个键在同一个解析器里往上约四十行就被读进 `featureDeviceExtensions` 与
`featureRuleModule`,而且 prepare 在消费者激活该 feature 时会读它们——它们正是「新增一门
设备语言不需要引擎发版」的全部依据。它们只是没被加进 `kKnownFeatureKeys`,于是引擎对
一个它刚刚用过的键打印「unsupported key (ignored)」。

比消息错更坏:它在**建议包作者删掉让规则生效的那两行**。

没被发现是因为规则包平时走的 host-module 路径不打印 schema 警告;而**普通**构建会打印。
`tools = [...]` 让规则包的普通构建成为常态,这条噪音于是浮上来。

### 撤回:一条曾经加上的编译依赖通道

本版早先的草案加过 `mcpp::recompile_if_changed`(协议 9),用来声明「编译某个源文件时
读到、却没有任何 depfile 会报告」的依赖——`.incbin` 就是这种。它工作正常,判据齐备。

**它被撤回,因为原型证明它不必要。** 那条依赖用引擎已有的唯一图原语就能表达:让生成
`.S` 的那一步成为一条 `mcpp::action`,载荷是它的**声明输入**。载荷一变,action 重跑;
没有 `restat`,其 output 被视为新的;汇编边随之重跑。实测 `bytes=64 -> 192`,零引擎改动。

协议因此保持在 8。发一条只有一个使用者、而那个使用者本可以不需要它的协议面,是永久成本
换一次便利。

## [2026.9.7.1] - 2026-09-07

### 四条通道,都是「规则包知道而引擎收不到」的形状
Expand Down
2 changes: 1 addition & 1 deletion mcpp.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "mcpp"
version = "2026.9.7.1"
version = "2026.9.8.1"
description = "Modern C++ build & package management tool"
license = "Apache-2.0"
authors = ["mcpp-community"]
Expand Down
62 changes: 62 additions & 0 deletions modules/buildmcpp/src/provisions.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,68 @@ inline std::string declared_interface_name(std::string_view source)
return {};
}

// The module names a unit IMPORTS, for ordering one package's host modules
// among themselves.
//
// WHY THIS EXISTS. The units a host-module package contributes used to be
// compiled in PATH order, and a package whose members share a unit could
// therefore be handed to the compiler in an order the compiler cannot accept:
// `rules/spirv.cppm` sorts before `src/surface.cppm`, so a member importing
// the shared unit failed with "failed to read compiled module ... imports must
// be built before being imported". Measured, and measured in both directions:
// renaming the shared unit so its path sorted first made the same package
// build. The cost of not having this was a design decision made against the
// wrong cause -- a collection folded everything its members shared into the lib
// root, taking it from about twenty lines to about seven hundred, because the
// ordering was read as "a second unit is not compiled at all".
//
// Deliberately line-based and deliberately shallow, matching
// `declared_interface_name` above: this answers "which units of THIS package
// must precede this one", and every name that is not another unit of the same
// package is discarded by the caller. It is not a substitute for the module
// scanner, which answers a much harder question about the build graph proper.
//
// `export import` counts -- it is an import. A partition import (`import :p;`)
// does not: a partition is not a unit compiled alone under a name of its own,
// which is the same reason `declared_interface_name` rejects one.
inline std::vector<std::string> declared_imports(std::string_view source)
{
auto is_name = [](char c) {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|| (c >= '0' && c <= '9') || c == '_' || c == '.';
};
auto is_ws = [](char c) { return c == ' ' || c == '\t' || c == '\r'; };
std::vector<std::string> out;
std::size_t lineStart = 0;
while (lineStart < source.size()) {
auto eol = source.find('\n', lineStart);
if (eol == std::string_view::npos) eol = source.size();
auto line = source.substr(lineStart, eol - lineStart);
lineStart = eol + 1;

std::size_t i = 0;
while (i < line.size() && is_ws(line[i])) ++i;
if (line.substr(i).starts_with("//")) continue;
if (line.substr(i).starts_with("export")) {
i += 6;
if (i >= line.size() || !is_ws(line[i])) continue;
while (i < line.size() && is_ws(line[i])) ++i;
}
if (!line.substr(i).starts_with("import")) continue;
i += 6;
if (i >= line.size() || !is_ws(line[i])) continue;
while (i < line.size() && is_ws(line[i])) ++i;
std::size_t start = i;
while (i < line.size() && is_name(line[i])) ++i;
if (i == start) continue; // `import :part;`, `import <h>;`
std::string name(line.substr(start, i - start));
while (i < line.size() && is_ws(line[i])) ++i;
if (i >= line.size() || line[i] != ';') continue;
out.push_back(std::move(name));
}
return out;
}

// One host module as registered for one consumer's build program.
struct HostModule {
std::string module; // what `import` in build.mcpp addresses
Expand Down
19 changes: 19 additions & 0 deletions modules/manifest/src/toml.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -837,6 +837,25 @@ std::expected<Manifest, ManifestError> parse_string(std::string_view content,
static constexpr std::string_view kKnownFeatureKeys[] = {
"defines", "flags", "forward", "implies", "provides",
"requires", "sources",
// THE TWO RULE-PACKAGE KEYS, WHICH THIS PARSER READS ABOUT
// FORTY LINES ABOVE AND THEN REPORTED AS UNSUPPORTED.
//
// `device_extensions` and `rule_module` are parsed into
// `featureDeviceExtensions` and `featureRuleModule`, and
// read by prepare when a consumer activates the feature --
// they are the whole reason a new device language costs no
// engine release. Leaving them off this list made every
// ordinary load of such a package print "unsupported key
// (ignored)" for a key it had just used, which is worse
// than a wrong message: it tells a package author to delete
// the two lines that make their rule work.
//
// It went unnoticed because the host-module path a rule
// package is normally loaded through does not print schema
// warnings. An ORDINARY build of the same package does --
// and `tools = [...]` made ordinary builds of rule packages
// routine.
"device_extensions", "rule_module",
};
for (auto& [fkey, fignored] : fval.as_table()) {
(void)fignored;
Expand Down
2 changes: 1 addition & 1 deletion modules/versioning/src/version.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,6 @@ import std;

export namespace mcpp {

inline constexpr std::string_view MCPP_VERSION = "2026.9.7.1";
inline constexpr std::string_view MCPP_VERSION = "2026.9.8.1";

} // namespace mcpp
69 changes: 67 additions & 2 deletions src/build/prepare.cppm
Original file line number Diff line number Diff line change
Expand Up @@ -7634,17 +7634,82 @@ prepare_build(bool print_fingerprint,
}
}
const auto root = iface.lexically_normal();
// ORDERED BY WHAT THEY IMPORT, NOT BY WHERE THEY SIT.
//
// The compile loop accumulates BMIs in list order, so each
// entry sees only what precedes it. Path order was the previous
// rule and it is not a valid one: `rules/spirv.cppm` sorts
// before `src/surface.cppm`, so a member importing a unit its
// package shares was compiled first and failed with "failed to
// read compiled module ... imports must be built before being
// imported". Reproduced, and reproduced in both directions --
// renaming the shared unit so its path sorted first made the
// same package build, which is what says the cause is the sort
// and nothing else.
//
// A package that works today is ordered IDENTICALLY: the sort
// below keeps path order wherever no import constrains it, so
// it differs only where the old order was already broken.
struct Unit {
std::filesystem::path path;
std::string name;
std::vector<std::string> imports;
};
std::vector<Unit> pending;
for (auto const& f : matched) { // std::set: sorted
if (dropped.contains(f)) continue;
if (std::filesystem::equivalent(f, root, ec)) continue;
std::ifstream is(f);
if (!is) continue;
std::stringstream buf;
buf << is.rdbuf();
auto name = prov::declared_interface_name(buf.str());
auto text = buf.str();
auto name = prov::declared_interface_name(text);
if (name.empty()) continue;
push(f, std::move(name));
pending.push_back({f, std::move(name), prov::declared_imports(text)});
}

// Only names this package itself declares constrain anything.
// `import std;` and the lib root are already ahead of every
// entry here, and a name from another package is ordered by the
// cross-package DFS below rather than by this sort.
std::map<std::string, std::size_t> byName;
for (std::size_t i = 0; i < pending.size(); ++i)
byName.emplace(pending[i].name, i);

std::vector<char> state(pending.size(), 0); // 0 new, 1 open, 2 done
std::vector<std::size_t> order;
order.reserve(pending.size());
// Iterative post-order DFS over the path-sorted list: the first
// unit that can be emitted is emitted, which is what preserves
// path order in the unconstrained case.
const auto visit = [&](std::size_t start) {
std::vector<std::pair<std::size_t, std::size_t>> stack{{start, 0}};
while (!stack.empty()) {
auto& [u, k] = stack.back();
if (state[u] == 2) { stack.pop_back(); continue; }
state[u] = 1;
if (k < pending[u].imports.size()) {
auto const& want = pending[u].imports[k++];
auto it = byName.find(want);
// A CYCLE IS LEFT TO THE COMPILER, ON PURPOSE. It
// is ill-formed C++ and the compiler says so with
// the two units named; refusing here would report
// the same fact in a worse place, and getting the
// ordering wrong is no longer possible either way.
if (it != byName.end() && state[it->second] == 0)
stack.push_back({it->second, 0});
continue;
}
state[u] = 2;
order.push_back(u);
stack.pop_back();
}
};
for (std::size_t i = 0; i < pending.size(); ++i)
if (state[i] == 0) visit(i);

for (auto i : order) push(pending[i].path, std::move(pending[i].name));
return out;
};
for (std::size_t c = 0; c < provisionGraph.visible.size(); ++c) {
Expand Down
Loading
Loading