Skip to content

Commit fff80dc

Browse files
committed
fix: a host-tool sub-build merges its dependency's conditional sections once (#690 F12); self-review record
1 parent c592395 commit fff80dc

7 files changed

Lines changed: 163 additions & 5 deletions
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
subject: review
3+
status: active
4+
---
5+
6+
# #690: self-review before release, engine and ecosystem
7+
8+
- Pull request: mcpp-community/mcpp#691 (2026.9.25.1)
9+
- Design: [2026-09-25-issue-690-workspace-build-inheritance-consistency.md](2026-09-25-issue-690-workspace-build-inheritance-consistency.md); plan: [2026-09-25-issue-690-implementation-plan.md](2026-09-25-issue-690-implementation-plan.md)
10+
- Method: the full `src/` and `modules/` diff read against the principles of the design record (P1 to P8), each finding checked by a measurement or a code citation, and the ecosystem consumers enumerated.
11+
12+
---
13+
14+
## 1. Findings of the review, and what was done
15+
16+
| # | Finding | Evidence | Resolution |
17+
|---|---|---|---|
18+
| R1 | The archive commit made by `mcpp publish` honoured `commit.gpgSign`. On a host that signs commits, the object would carry a signature timestamp, which breaks reproducibility, or it would fail where no key is available. | Code: `git commit-tree` without `--no-gpg-sign`. After the fix, measured with `commit.gpgsign = true` and `gpg.program = /bin/false` on the repository: `publish --dry-run` succeeds twice with the same sha256. | `--no-gpg-sign` added. |
19+
| R2 | The manifest blob was hashed with the repository's filters, and the scratch file lies inside the repository (`target/dist`). | Code: `git hash-object -w` without `--no-filters`. | `--no-filters` added. |
20+
| R3 | The publisher carried a second copy of the inheritable key set, written in parallel with `kWorkspaceBuildKeys` because the two tasks started from the same base. | Code: `kStringVectors`, `kPathVectors`, `kScalars` in `normalize.cppm`. | Replaced by `kWorkspaceBuildKeys`. |
21+
| R4 | A host-tool sub-build receives its dependency's manifest preloaded. After W1 that manifest is already inherited, and the sub-build's member branch would have inherited it a second time. | Code: `prepare_build` member branch before W4. | The preloaded manifest is treated as effective. Only its workspace is recorded, for the membership test of its own dependencies. |
22+
| R5 | Two e2e scripts from the parallel tasks used the number 770, the same as the lead's. | Directory listing. | Renumbered to 772 and 773. |
23+
| R7 | A host-tool sub-build merged its dependency's conditional sections a second time (design record F12). Found as an open item of this review and then measured: `-include once.h` from a matching section reached the tool twice, on 2026.9.24.1 as well. | e2e 775 fails on 2026.9.24.1 and passes on the candidate; the package builds on its own (control). | The sub-build receives `Manifest::beforeConditionalMerge`. |
24+
| R6 | A member inside an index archive (a Form A descriptor pointing at `*/<dir>/mcpp.toml`) did not inherit its archive's workspace, which is the same position independence gap as F4 for a third route. | Measured, design record F11 and e2e 774. | Applied through `inherit_as_workspace_member`, bounded by the install root. |
25+
26+
## 2. Principles, checked
27+
28+
| Principle | Holds because | Residual |
29+
|---|---|---|
30+
| P1 position independence | One function (`inherit_as_workspace_member`) serves the sibling, git and index-archive routes. The root inherits at load through the effective loader. e2e 770 and 774 count the words in each position. | None known. |
31+
| P2 merge, normalise, snapshot | `makePackageRoot` performs no merge and refuses unfolded `defines`. | The layer-conditional second pass folds again by design, and the keyed fold removes superseded words across passes (unit test `SecondPassRemovesAWordTheFirstPassFolded`). |
32+
| P3 one source of truth | One key table, one loader, one inheritance function. | `inherit_workspace_build` in `project.cppm` still lists its fields explicitly. The unit test `EveryTableRowIsParsedAndInherited` fails if the table and that function disagree. |
33+
| P4 scope | No include directory is broadcast; the std module, the scanner and every rule read per-unit includes. | A consumer-supplied configuration header has no channel. None is needed today (section 3). |
34+
| P5 cache soundness | `kCacheEpoch` 4; a dependency's command is shown identical under two roots (e2e 765 (c)). | None known. |
35+
| P6 published form | Normalised manifest, reproducible archive, `.orig` kept, released 2026.9.24.1 client builds it (e2e 772 with `MCPP_BOOT`). | `[indices]` inherited from the workspace is not written into the published manifest; a member whose dependencies resolve through a workspace-declared index publishes a manifest that names no index for them. This matches a non-member package, which also publishes no `[indices]`. |
36+
| P7 loud invariants | Internal error text follows `plan.cppm`'s form. | None. |
37+
| P8 measured blast radius | Section 3. | The full mcpp-index matrix runs after release, on the pin-moving pull request. |
38+
39+
## 3. Ecosystem review
40+
41+
- **mcpp-index members.** 166 test members. None declares `include_dirs` (the two matches are comments), so W6 removes nothing a member relied on. The root workspace declares no `[workspace.build]`, so W1 changes no member's flags. 16 members with C sources, `defines` and include directories (`cjson`, `zlib`, `brotli`, `c-ares`, `expat`, `libpng`, `sqlite3`, `pcre2`, `spdlog-compiled`, `fmtlib.fmt`, `yaml-cpp`, `xxhash`, `md4c`, `libffi`, `mimalloc`, `yyjson`) pass `mcpp test -p` with the candidate.
42+
- **Index packages with a workspace in their archive.** 22 are installed on the measuring machine. None declares `[workspace.package]`, `[workspace.build]` or `[workspace.dependencies]`, so F11's inheritance changes none of them.
43+
- **Descriptor comments.** `compat.godot-cpp.lua:163` states that "a consumer-side header shadow never reaches" the package. That statement was false for an uncached compile before this release and is true after it; no descriptor change is needed.
44+
- **Published clients.** The normalised manifest uses only keys 2026.9.24.1 accepts. `!NAME` in `defines` needs 2026.9.25.1, and docs/04 states the floor.
45+
- **Caches.** Epoch 4 orphans every dependency-cache entry once. mcpp-index's CI caches already key on `MCPP_VERSION`, so the pin move costs the same cold run it always does.
46+
- **xlings.** The internal pin moves to 2026.9.20.1 (openxlings/xlings#610). `check_version_pins.sh` passes. The bootstrap `.xlings.json` does not move (review decision 2026-09-25).
47+
48+
## 4. Open items outside #690
49+
50+
- **The e2e harness still shares existing payload versions by link.** An in-place rewrite of an existing payload by a test still reaches the developer's registry (#293, first shape).

‎.agents/docs/2026-09-25-issue-690-workspace-build-inheritance-consistency.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ The decisions below are derived from these rules. Each rule names its source in
7777

7878
**F11 (measured during implementation): a member inside an index package's archive does not inherit its archive's workspace.** A descriptor may point at a member manifest (`mcpp = "*/mcpp/cairo/mcpp.toml"`; 22 installed index packages on the measuring machine have a workspace root in their archive). The resolver loaded that manifest as a stand-alone file, so a member that omits `version` was refused and `[workspace.build]` was ignored, while the same commit consumed through `git` inherits (D1). None of the 22 installed archives declares `[workspace.package]`, `[workspace.build]` or `[workspace.dependencies]`, so applying the inheritance changes no existing package. It is applied through the same function as the sibling and git cases, searching no higher than the install root (e2e 774).
7979

80+
**F12 (measured during review): a dependency built as a host tool merges its conditional sections twice.** The resolver merges a dependency's `[target.<selector>.build]` sections for the consumer's target, and the host-tool sub-build received that merged manifest and merged it again for the host. A package whose matching section adds `-include once.h` (a header without a guard) builds on its own and fails as a host tool with `redefinition of 'int once_counter'`, on 2026.9.24.1 as well. The comment above the sub-build called the manifest "pristine". The manifest now records its state before the first merge (`Manifest::beforeConditionalMerge`), and the sub-build receives that state (e2e 775).
81+
8082
**F8 (reasoned): the F1 dependency's cache key records a define its compile does not carry.** `cache_key.cppm:534` reads `pkg.manifest.buildConfig.defines`, which retains the unfolded entries.
8183

8284
Root cause: the build half of inheritance runs inside the snapshot, after the fold.

‎.agents/docs/README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ superseded_by: 2026-09-07-....md # when status is superseded
1818
---
1919
```
2020

21-
308 records.
21+
309 records.
2222

2323
## By subject
2424

@@ -65,6 +65,7 @@ Records that declare one. Everything else is listed by date below.
6565

6666
### review
6767

68+
- [#690: self-review before release, engine and ecosystem](2026-09-25-issue-690-self-review.md) — active
6869
- [本轮生态级自审](2026-09-20-wave-self-review.md) — active
6970
- [#674 设计方案评审:`-include unistd.h` 在 Windows + `presents = "posix"` 上的可行性](2026-09-20-issue-674-design-review.md) — active
7071
- [`__cxa_thread_atexit` 在 openkal-Windows 上:两层都已定位并修复](2026-09-20-cxa-thread-atexit-finding.md) — landed
@@ -100,6 +101,7 @@ Records that declare one. Everything else is listed by date below.
100101
### 2026-09
101102

102103
- [Workspace inheritance, flag scoping and the published form: a unified repair plan (#690)](2026-09-25-issue-690-workspace-build-inheritance-consistency.md) — active
104+
- [#690: self-review before release, engine and ecosystem](2026-09-25-issue-690-self-review.md) — active
103105
- [#690: implementation plan](2026-09-25-issue-690-implementation-plan.md) — active
104106
- [工具链选择与载荷可信度:实施计划](2026-09-24-toolchain-selection-implementation-plan.md) — landed
105107
- [MSVC toolset 的选择、#685、#687 与工具链管理规范:总体设计](2026-09-24-toolchain-selection-and-payload-trust-design.md) — active

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@
4848
之后指出该头文件所在的使用方目录。依赖需要通过自己的 `include_dirs` 或它的某个依赖找到头文件。
4949
依赖缓存的 epoch 由 3 升为 4,升级后首次构建重建一次依赖缓存,不需要任何操作。
5050

51+
### 作为 host tool 构建的依赖只合并一次条件节
52+
53+
依赖的 `[target.<selector>.build]` 条件节先按使用方的目标合并,host tool 子构建此前拿到的正是合并后的
54+
清单,又按宿主合并一次,同时命中两者的条目因此到达工具两次。实测:条件节中的 `-include once.h`
55+
(无 include guard)让单独构建正常的包作为工具时报重定义错误,2026.9.24.1 同样如此。清单现在记录
56+
第一次合并之前的状态,子构建从该状态按宿主合并。
57+
5158
### 成员清单只有一种读法
5259

5360
`mcpp publish`、`mcpp pack`、`mcpp emit xpkg`、`mcpp toolchain list`、`mcpp sbom`、`mcpp index list`/`update`、

‎modules/manifest/src/types.cppm‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2083,6 +2083,14 @@ struct Manifest {
20832083
// values are paths. Kept as text so this module's interface names no JSON
20842084
// type.
20852085
std::string packageMetadataJson;
2086+
// The manifest as it was before its first conditional merge, recorded by
2087+
// `merge_conditional_config`. The merge evaluates `[target.<selector>]`
2088+
// sections for ONE target, and a package's manifest can be needed for a
2089+
// second one: a host-tool sub-build compiles a dependency for the host,
2090+
// and merging the already merged manifest again applied the consumer's
2091+
// entries and then the host's, so a flag in a matching section reached
2092+
// the tool twice (#690, F12). Null when no merge has run.
2093+
std::shared_ptr<const Manifest> beforeConditionalMerge;
20862094
};
20872095

20882096
struct ManifestError {

‎src/build/prepare.cppm‎

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,9 @@ void replace_dependencies(
402402
export void merge_conditional_config(mcpp::manifest::Manifest& m,
403403
const cfgpred::Ctx& ctx)
404404
{
405+
// Recorded before the first merge; see Manifest::beforeConditionalMerge.
406+
if (!m.beforeConditionalMerge)
407+
m.beforeConditionalMerge = std::make_shared<const mcpp::manifest::Manifest>(m);
405408
// A DISTRIBUTION package may carry a leg's link line twice: as `ldflags`
406409
// (GNU spelling, which is all an older mcpp reads) and as the neutral
407410
// `[target.<pred>.runtime]` pair, which mcpp renders per dialect. Applying
@@ -10374,11 +10377,18 @@ prepare_build(bool print_fingerprint,
1037410377
// twice. A `compat` (Form B) package has no mcpp.toml on
1037510378
// disk at all, so without this the sub-build could not read
1037610379
// a manifest for it in the first place.
10380+
//
10381+
// UNMERGED, because the sub-build targets the HOST: the
10382+
// resolver merged this manifest's conditional sections for
10383+
// the consumer's target, and the sub-build merges them for
10384+
// its own (#690, F12).
1037710385
if (depIdx >= 1 && depIdx - 1 < dep_manifests.size()
10378-
&& dep_manifests[depIdx - 1])
10379-
sub.preloaded_manifest =
10380-
std::make_shared<const mcpp::manifest::Manifest>(
10381-
*dep_manifests[depIdx - 1]);
10386+
&& dep_manifests[depIdx - 1]) {
10387+
auto const& dep = *dep_manifests[depIdx - 1];
10388+
sub.preloaded_manifest = dep.beforeConditionalMerge
10389+
? dep.beforeConditionalMerge
10390+
: std::make_shared<const mcpp::manifest::Manifest>(dep);
10391+
}
1038210392
sub.inherited_runtime_selection = std::make_shared<
1038310393
const mcpp::xlings::runtime::RuntimeSelection>(
1038410394
runtimeSelection);
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
#!/usr/bin/env bash
2+
# 775 -- a dependency compiled as a host tool receives each entry of its
3+
# matching `[target.<selector>.build]` sections once (#690, design record F12).
4+
#
5+
# The resolver merges a dependency's conditional sections for the consumer's
6+
# target. A host-tool sub-build used to receive that merged manifest and merge
7+
# it again for the host, so an entry of a section matching both reached the
8+
# tool twice. The section here adds `-include once.h`, and `once.h` defines a
9+
# variable without an include guard: included twice, the tool does not compile.
10+
# The package builds on its own, which is the control.
11+
set -e
12+
13+
TMP=$(mktemp -d)
14+
trap 'rm -rf "$TMP"' EXIT
15+
export MCPP_HOME="$TMP/home"
16+
source "$(dirname "$0")/_inherit_toolchain.sh"
17+
fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && cat "$2"; exit 1; }
18+
19+
mkdir -p "$TMP/toolpkg/src" "$TMP/toolpkg/inc" "$TMP/app/src"
20+
echo 'int once_counter = 1;' > "$TMP/toolpkg/inc/once.h"
21+
cat > "$TMP/toolpkg/mcpp.toml" <<'EOF'
22+
[package]
23+
name = "toolpkg"
24+
version = "0.1.0"
25+
26+
[targets.gen]
27+
kind = "bin"
28+
main = "src/gen.cpp"
29+
30+
[build]
31+
include_dirs = ["inc"]
32+
33+
[target.'cfg(not(os = "none"))'.build]
34+
cxxflags = ["-include once.h"]
35+
EOF
36+
cat > "$TMP/toolpkg/src/gen.cpp" <<'EOF'
37+
#include <cstdio>
38+
int main(int argc, char** argv) {
39+
if (argc < 2) return 2;
40+
std::FILE* f = std::fopen(argv[1], "w");
41+
if (!f) return 3;
42+
std::fprintf(f, "int generated_answer() { return %d; }\n", 40 + once_counter);
43+
std::fclose(f);
44+
return 0;
45+
}
46+
EOF
47+
cat > "$TMP/app/mcpp.toml" <<'EOF'
48+
[package]
49+
name = "app"
50+
version = "0.1.0"
51+
52+
[dependencies]
53+
toolpkg = { path = "../toolpkg", tools = ["gen"] }
54+
EOF
55+
cat > "$TMP/app/src/main.cpp" <<'EOF'
56+
#include <cstdio>
57+
int generated_answer();
58+
int main() { std::printf("ANSWER=%d\n", generated_answer()); }
59+
EOF
60+
cat > "$TMP/app/build.mcpp" <<'EOF'
61+
import std;
62+
import mcpp;
63+
int main() {
64+
const char* tool = mcpp::dep_bin("toolpkg", "gen");
65+
if (!tool || !*tool) return 1;
66+
std::string out = std::string(mcpp::out_dir()) + "/gen.cpp";
67+
std::string cmd = std::string("\"") + tool + "\" \"" + out + "\"";
68+
if (std::system(cmd.c_str()) != 0) return 1;
69+
mcpp::generated(out.c_str());
70+
}
71+
EOF
72+
73+
( cd "$TMP/toolpkg" && "$MCPP" build > "$TMP/direct.log" 2>&1 ) \
74+
|| fail "control: the tool package does not build on its own" "$TMP/direct.log"
75+
cd "$TMP/app"
76+
"$MCPP" build > build.log 2>&1 || fail "the host tool received a conditional entry twice" build.log
77+
"$MCPP" run > run.log 2>&1 || fail "the program did not run" run.log
78+
grep -q '^ANSWER=41' run.log || fail "wrong answer" run.log
79+
echo "PASS: 775_a_host_tool_merges_its_conditional_sections_once"

0 commit comments

Comments
 (0)