Skip to content

Commit 1a79c0a

Browse files
committed
fix(features): mcpp test compiled sources a feature had not activated
Same package, same manifest, two commands, different source sets: mcpp build --target riscv64-none-elf skips src/kal/** (correct) mcpp test --target riscv64-none-elf compiles it, and dies on 'openkal/abort.h' file not found The header arrives through that feature's `[feature-deps]`; with the feature inactive it is not there, and neither are the files that include it. ⭐ TWO FAMILIES REACH THIS CODE AND THEY WANT OPPOSITE THINGS. gtest lists `*/googletest/src/gtest_main.cc` in base `sources` AND under `features.main`. The package provides the file unconditionally; the feature is a gate over it, and the dev-dependency track's per-test main detection must SEE it in order to prune it per test. riscv-virt-rt names `src/kal/**` under `features.openkal` and nowhere else. Those files are not part of the package without the feature. The engine gated the whole exclusion on "is this a test build", which is right for the first family and wrong for the second. ⚠️ THIS IS THE FOURTH ATTEMPT, AND THE THIRD WAS ABANDONED ON A MISTAKEN READING. It was recorded as failing because "gtest's base entry is a glob that MATCHES the file rather than the same string". Measured against the descriptor the index actually carries — `compat.gtest.lua` lines 71-73 and 90 — the two entries are byte-identical. The reason had been written from memory without opening the file. What actually defeated that attempt was WHEN the criterion was applied: it tested membership against `bc.sources` after `drop()` had already removed the entry, so the test could only ever be false. The fix snapshots the base globs BEFORE the drop and uses them only in test mode: a glob in both places is a gate and stays visible; a glob in one place is a provider and gets the `!` exclusion. The exclusion is the whole mechanism — `src/kal/**` is never in `bc.sources` at all, because that package declares no `sources` and its files are matched by the inferred `src/**`, so erasing the string erases nothing. Verified, all three of the plan's criteria: 1. riscv-virt-rt: `mcpp test` no longer compiles src/kal/**; both commands exit 0. 2. mcpp's own 92 unit tests still link and pass (the gtest family). 3. tests/e2e/138_feature_sources_gate_vs_provider.sh covers both families with two minimal packages and no ecosystem dependency. ⭐ (3) was checked the way a regression test has to be: run against the engine before this commit it fails with exactly the symptom above; against the engine after it, it passes. The defect survived this long because no test covered both families at once, and each of the three earlier attempts broke the one it did not cover.
1 parent f1b0bc5 commit 1a79c0a

3 files changed

Lines changed: 251 additions & 6 deletions

File tree

.agents/docs/2026-08-21-freestanding-outstanding-four.md

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ payload: /c/Users/runneradmin/.mcpp/registry/data/xpkgs/xim-x-llvm/*/
3939

4040
## 1. `mcpp test``mcpp build` 对 feature 源的判断不一致
4141

42+
> **已修复(mcpp 2026.8.21.1)。** ⚠️ 而修法既不是路线 A 也不是路线 B —— 是第三次
43+
> 尝试的判据,它当初被以一个**错误的理由**放弃了。见 1.3.1。
44+
4245
### 1.1 现象与位置
4346

4447
同一个包、同一份清单,两条命令编译的源文件集合不同:
@@ -82,11 +85,38 @@ base `sources` 里,而 dev 依赖那条轨道的「逐测试 main 探测」需
8285
|---|---|
8386
| DROP 与 `!` 排除在两种模式下都跑 | **弄坏 gtest**:mcpp 自己的单测链接失败,`ld returned 1 exit status` |
8487
| test 模式只删 glob 字符串、不加 `!` 排除 | 无效:`src/kal/**` 从来不在 `bc.sources` 里,删字符串什么都没删,文件仍由 base glob `src/**` 匹配 |
85-
| 按「该 glob 是否也出现在 base `sources`」判别 | 无效:gtest 的 base 是一条**能匹配到**那个文件的 glob,不是同一个字符串;字符串相等判别不到 |
88+
| 按「该 glob 是否也出现在 base `sources`」判别 | 记录为无效,理由是「gtest 的 base 是一条能匹配到那个文件的 glob,不是同一个字符串」。**这条记录是错的** —— 见 1.3.1 |
8689

8790
三次都已撤销。**不留半个修复**:把一个只在部分情形下正确的门放进已发布的
8891
引擎,比一个有文档的缺陷更糟。
8992

93+
### 1.3.1 ⚠️ 第三次尝试的判据是对的,被放弃的理由是错的
94+
95+
去读索引真正携带的描述符 `pkgs/c/compat.gtest.lua`:
96+
97+
```lua
98+
sources = { "*/googletest/src/gtest-all.cc",
99+
"*/googletest/src/gtest_main.cc" }, -- 第 71–73 行
100+
features = { ["main"] = { sources = { "*/googletest/src/gtest_main.cc" } } }, -- 第 90 行
101+
```
102+
103+
两处**逐字节相同**。所以「字符串相等判别不到」这句话与事实不符,而当初把它写进
104+
文档时没有去读那个文件 —— 判据被一个凭印象写下的理由否掉了。
105+
106+
真正让第三次尝试失效的是**判据被施加的时机**:membership 是拿 `bc.sources` 去比
107+
的,而 `drop()` 已经先一步把那条 glob 从里面删掉了,于是这个测试只可能为假。
108+
109+
**修法**:在 `drop()` 之前把 base 的 glob 集合快照下来,并且只在 test 模式使用
110+
它 ——
111+
112+
* glob 同时出现在 base `sources` 与 feature 里 ⇒ 这是一个****(gtest 那一族),
113+
test 模式下保持可见,逐测试 main 探测仍然剪得掉它;
114+
* glob 只出现在 feature 里 ⇒ 这是一个**提供者**(riscv-virt-rt 那一族),
115+
test 模式下也要 `!` 排除。
116+
117+
⚠️ 而 `!` 排除才是整个机制,删 glob 字符串不是:`src/kal/**` 从来就不在
118+
`bc.sources` 里(该包根本没声明 `sources`),它的文件由推导出的 `src/**` 匹配。
119+
90120
### 1.4 正确的修法
91121

92122
三次失败指向同一个结论:**判据不是「这条 glob 字符串在不在 base 里」,而是
@@ -129,14 +159,19 @@ openkal = { sources = ["src/kal/**"] } # 只经 feat
129159

130160
必须同时满足,**任缺其一即视为未修复**:
131161

132-
1. `mcpplibs/riscv-virt-rt` 不带 feature 时 `mcpp test``mcpp build`
133-
编出的目标文件集合相同;
134-
2. mcpp 自己的 `mcpp test unit/test_manifest` 仍然链接并通过(gtest 那一族);
135-
3. 两条断言进 e2e,用两个最小包分别表达上面两族,而不是依赖生态包。
162+
1.`mcpplibs/riscv-virt-rt` 不带 feature 时 `mcpp test` 不再编译
163+
`src/kal/**`,两条命令都以 0 退出(此前 test 死于
164+
`'openkal/abort.h' file not found`);
165+
2. ✅ mcpp 自己的 92 个单测仍然链接并通过(gtest 那一族);
166+
3.`tests/e2e/138_feature_sources_gate_vs_provider.sh`,两个最小包分别表达
167+
上面两族,不依赖任何生态包。
136168

137169
⚠️ 第 3 条是重点。这个缺陷之所以活到今天,是因为**没有任何测试同时覆盖两族**;
138170
只修不测,下一次同样的改动会再次在两者之间摇摆。
139171

172+
⭐ 那条 e2e 已按「回归测试必须先失败」验证过:拿修复前的引擎跑它,它以正是本节
173+
开头那句症状失败;拿修复后的引擎跑它,它通过。
174+
140175
### 1.6 副作用
141176

142177
修好之后,`mcpplibs/riscv-virt-rt` CI 里那句
@@ -598,7 +633,7 @@ openarch 的第三个后端因此写得出来,而**第三台机器正是把门
598633
| || 状态 | 建议 |
599634
|---|---|---|---|
600635
| 1 | Windows libc++(第 2 节) | 未做。⭐ 结论已从「加进载荷」**改为独立成包**,理由见 2.5 | 先做 2.1 的测量,再按三步落地。**我们自己能做完** |
601-
| 2 | feature 源不一致(第 1 节) | 未做,三次修法被证伪 | 需要 glob 展开 + 两族的 e2e |
636+
| 2 | feature 源不一致(第 1 节) | **已修复**(2026.8.21.1)。⚠️ 修法就是第三次尝试的判据 —— 它当初被一个**没有去读文件就写下的理由**否掉了 | |
602637
| 3 | openarch(第 3 节) |**0.4.0 已完成**:混合式的根、两个门面、feature 选后端、三个指令集 ||
603638
| 4 | openarch 的时钟归属(3.3) | 未做 | ⭐ 先作为**调研**:两种启动方式各一个最小探针,答案决定接口归属 |
604639
| 5 | x86_64 裸机(第 4 节) |**阶段 4 已完成**(目标行 + 引擎的直连链接);阶段 1–3 未做 | `xim:qemu-x86` 仍缺。阶段 1 先做 linux x64 供 openkal-uefi 用;阶段 2 用**临时 PR 的 CI 矩阵**跑通五条腿再谈收录 |

src/build/prepare.cppm

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4535,6 +4535,45 @@ prepare_build(bool print_fingerprint,
45354535
// doubly-listed gtest_main.cc cannot land twice.
45364536
auto& bc = pkg.manifest.buildConfig;
45374537
if (!bc.featureSources.empty()) {
4538+
// ⭐ WHETHER A FEATURE *GATES* A SOURCE OR *PROVIDES* IT, AND
4539+
// THE ANSWER IS WRITTEN IN THE MANIFEST ALREADY.
4540+
//
4541+
// Two families of package reach this code and they want
4542+
// opposite things under `mcpp test`:
4543+
//
4544+
// gtest lists `*/googletest/src/gtest_main.cc` in
4545+
// base `sources` AND under `features.main`.
4546+
// The package provides the file unconditionally;
4547+
// the feature is a gate over it. The
4548+
// dev-dependency track's per-test main detection
4549+
// must still SEE it in order to prune it per
4550+
// test, so an inactive gate must not make it
4551+
// vanish.
4552+
//
4553+
// riscv-virt-rt names `src/kal/**` under `features.openkal`
4554+
// and nowhere else. The package does not provide
4555+
// those files at all without the feature — the
4556+
// headers they include arrive through that
4557+
// feature's `[feature-deps]` — so compiling them
4558+
// fails on `'openkal/abort.h' file not found`.
4559+
//
4560+
// The discriminator is membership in base `sources`, evaluated
4561+
// BEFORE the drop below removes it. A glob in both places is a
4562+
// gate; a glob in one place is a provider.
4563+
//
4564+
// ⚠️ THIS IS THE FOURTH ATTEMPT, AND THE THIRD WAS ABANDONED ON
4565+
// A MISTAKEN READING. It was recorded as failing because
4566+
// "gtest's base entry is a glob that MATCHES the file rather
4567+
// than the same string". Measured against the descriptor the
4568+
// index actually carries, the two entries are byte-identical
4569+
// (`compat.gtest.lua` lines 71 and 90). The criterion was
4570+
// sound; what it was applied to was not — the earlier attempt
4571+
// compared against `bc.sources` AFTER `drop()` had already
4572+
// removed the entry, so the membership test could only ever be
4573+
// false.
4574+
std::set<std::string> baseGlobs(bc.sources.begin(), bc.sources.end());
4575+
baseGlobs.insert(pkg.manifest.modules.sources.begin(),
4576+
pkg.manifest.modules.sources.end());
45384577
if (!includeDevDeps) {
45394578
// glob → owned by at least one ACTIVE feature?
45404579
std::set<std::string> activeNow(active.begin(), active.end());
@@ -4558,6 +4597,30 @@ prepare_build(bool print_fingerprint,
45584597
pkg.manifest.modules.sources.push_back("!" + g);
45594598
}
45604599
}
4600+
else {
4601+
// `mcpp test`. The gate that build mode applies wholesale is
4602+
// applied here only to the globs the package provides
4603+
// NOWHERE ELSE, which leaves gtest's doubly-listed source
4604+
// visible and stops riscv-virt-rt's feature-only sources
4605+
// from being compiled without their feature.
4606+
//
4607+
// ⚠️ THE `!` EXCLUSION IS THE WHOLE MECHANISM, NOT THE GLOB
4608+
// REMOVAL. `src/kal/**` is never IN `bc.sources` — the
4609+
// package declares no `sources` at all and its files are
4610+
// matched by the inferred `src/**`. Erasing the string
4611+
// erases nothing; only an exclusion gates.
4612+
std::set<std::string> activeNow(active.begin(), active.end());
4613+
std::map<std::string, bool> gated;
4614+
for (auto& [f, globs] : bc.featureSources)
4615+
for (auto& g : globs)
4616+
gated[g] = gated[g] || activeNow.contains(f);
4617+
for (auto& [g, isActive] : gated) {
4618+
if (isActive || g.starts_with("!")) continue;
4619+
if (baseGlobs.contains(g)) continue; // a gate, not a provider
4620+
bc.sources.push_back("!" + g);
4621+
pkg.manifest.modules.sources.push_back("!" + g);
4622+
}
4623+
}
45614624
std::set<std::string> activeSet(active.begin(), active.end());
45624625
auto add = [](std::vector<std::string>& v, const std::string& g) {
45634626
if (std::ranges::find(v, g) == v.end()) v.push_back(g);
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
#!/usr/bin/env bash
2+
# requires: unix-shell
3+
# A feature that GATES a source and a feature that PROVIDES one, under both
4+
# `mcpp build` and `mcpp test`.
5+
#
6+
# ⚠️ THIS DEFECT SURVIVED FOR MONTHS BECAUSE NO TEST COVERED BOTH FAMILIES, AND
7+
# THREE ATTEMPTED FIXES EACH BROKE THE ONE THEY DID NOT COVER.
8+
#
9+
# Two shapes of package reach the same code in `prepare_build`, and under
10+
# `mcpp test` they want opposite things:
11+
#
12+
# the GATE family gtest lists `*/googletest/src/gtest_main.cc` in base
13+
# `sources` AND under `features.main`. The package
14+
# provides the file unconditionally; the feature is a
15+
# switch over it. The dev-dependency track's per-test
16+
# main detection has to SEE it to prune it per test, so
17+
# an inactive gate must not make it disappear.
18+
#
19+
# the PROVIDER family riscv-virt-rt names `src/kal/**` under
20+
# `features.openkal` and nowhere else. Those files are
21+
# not part of the package without the feature — the
22+
# headers they include arrive through that feature's
23+
# `[feature-deps]` — so compiling them fails on a header
24+
# that was never meant to be there.
25+
#
26+
# The engine gated the whole exclusion on "is this a test build", which is
27+
# right for the first family and wrong for the second. Fixing it by removing
28+
# the gate broke gtest; fixing it by removing only the glob string was a no-op,
29+
# because a provider's glob is not in base `sources` at all and its files are
30+
# matched by the inferred `src/**`.
31+
#
32+
# Both families are asserted here so that the next change to this code cannot
33+
# satisfy one at the other's expense.
34+
set -e
35+
36+
TMP=$(mktemp -d)
37+
trap "rm -rf $TMP" EXIT
38+
cd "$TMP"
39+
40+
# ── The PROVIDER family ─────────────────────────────────────────────────────
41+
#
42+
# `src/gated/**` is named only by the feature, and the file includes a header
43+
# that does not exist. With the feature inactive both commands must ignore it;
44+
# a command that compiles it fails on the missing header, which is exactly the
45+
# symptom the real package produced.
46+
mkdir -p provider/src/gated provider/tests
47+
cat > provider/mcpp.toml <<'EOF'
48+
[package]
49+
name = "provider"
50+
version = "0.1.0"
51+
52+
# ⚠️ NO `[build] sources`, WHICH IS THE POINT. The base set is the inferred
53+
# `src/**`, which matches `src/gated/only_with_feature.cpp` — so removing the
54+
# feature's glob STRING from a list it was never in gates nothing.
55+
[features]
56+
default = []
57+
gated = { sources = ["src/gated/**"] }
58+
EOF
59+
cat > provider/src/lib.cpp <<'EOF'
60+
int provider_value() { return 7; }
61+
EOF
62+
cat > provider/src/gated/only_with_feature.cpp <<'EOF'
63+
// The header arrives with the feature's dependencies and does not exist
64+
// without it. Compiling this file without the feature is the defect.
65+
#include <a_header_the_feature_would_have_brought.h>
66+
int gated_value() { return 1; }
67+
EOF
68+
cat > provider/tests/basic.cpp <<'EOF'
69+
extern int provider_value();
70+
int main() { return provider_value() == 7 ? 0 : 1; }
71+
EOF
72+
73+
cd provider
74+
"$MCPP" build > build.log 2>&1 || { cat build.log; echo "provider: build compiled a feature-only source"; exit 1; }
75+
"$MCPP" test > test.log 2>&1 || { cat test.log; echo "provider: TEST compiled a feature-only source — the defect this file exists for"; exit 1; }
76+
77+
if find target -name '*only_with_feature*' | grep -q .; then
78+
find target -name '*only_with_feature*'
79+
echo "provider: an object was produced for a source the feature did not activate"
80+
exit 1
81+
fi
82+
83+
# With the feature ON it must be reached — otherwise the check above would pass
84+
# for a build that simply ignores feature sources entirely.
85+
if "$MCPP" build --features gated > gated.log 2>&1; then
86+
cat gated.log
87+
echo "provider: the feature did not bring its own source in"
88+
exit 1
89+
fi
90+
grep -q "a_header_the_feature_would_have_brought.h" gated.log || {
91+
cat gated.log
92+
echo "provider: activating the feature failed for some other reason"; exit 1; }
93+
cd ..
94+
95+
# ── The GATE family ─────────────────────────────────────────────────────────
96+
#
97+
# The same glob in base `sources` and under a feature. `mcpp build` excludes it;
98+
# `mcpp test` keeps it visible, which is what the dev-dependency track's
99+
# per-test main detection depends on.
100+
mkdir -p gate/src gate/tests
101+
cat > gate/mcpp.toml <<'EOF'
102+
[package]
103+
name = "gate"
104+
version = "0.1.0"
105+
106+
# ⚠️ `src/gated_main.cpp` IS IN BOTH LISTS, EXACTLY AS gtest'S DESCRIPTOR HAS IT.
107+
# The package provides the file; the feature is a switch over it.
108+
[build]
109+
sources = ["src/lib.cpp", "src/gated_main.cpp"]
110+
111+
[features]
112+
default = []
113+
main = { sources = ["src/gated_main.cpp"] }
114+
EOF
115+
cat > gate/src/lib.cpp <<'EOF'
116+
int gate_value() { return 11; }
117+
EOF
118+
cat > gate/src/gated_main.cpp <<'EOF'
119+
int gated_main_marker() { return 42; }
120+
EOF
121+
cat > gate/tests/basic.cpp <<'EOF'
122+
extern int gate_value();
123+
int main() { return gate_value() == 11 ? 0 : 1; }
124+
EOF
125+
126+
cd gate
127+
"$MCPP" build > build.log 2>&1 || { cat build.log; echo "gate: build failed"; exit 1; }
128+
if find target -name '*gated_main*' | grep -q .; then
129+
find target -name '*gated_main*'
130+
echo "gate: build compiled a gated source with the feature inactive"
131+
exit 1
132+
fi
133+
134+
rm -rf target
135+
"$MCPP" test > test.log 2>&1 || { cat test.log; echo "gate: test failed"; exit 1; }
136+
# ⚠️ THE ASSERTION IS THAT IT IS STILL THERE. A fix that excluded every
137+
# inactive feature glob under `mcpp test` would remove it — and would then
138+
# break gtest, whose per-test main detection needs to see the file in order to
139+
# prune it. Measured once as `ld returned 1 exit status` across mcpp's own
140+
# unit tests.
141+
find target -name '*gated_main*' | grep -q . || {
142+
echo "gate: a source the package provides unconditionally vanished under \`mcpp test\`"
143+
echo " (this is the shape that broke gtest)"
144+
exit 1; }
145+
cd ..
146+
147+
echo "PASS: a feature that gates a source and a feature that provides one behave correctly under both commands"

0 commit comments

Comments
 (0)