Skip to content

Commit c28b8d1

Browse files
committed
T3 (#724 W3/W4/W5): a device source is not a compile unit; a failed build
program's own diagnostic survives; emit writes no project file W3 (src/build/plan.cppm): the compile-unit loop now skips SourceKind::Device graph units, so build.ninja carries no dead cxx_object edge for a rule-claimed device source, and compile_commands.json / the S1 document agree without their own filter (unit_invocations already excluded only NASM; nothing else needed to change). The source stays in `watch` and still reaches the package's build program through MCPP_DEVICE_SOURCES, since both read the manifest's sources glob directly, not plan.compileUnits. Checked every other consumer of plan.compileUnits (prepare/plan.cpp's dependency-cache collection keys by path, not by index, so it is unaffected beyond a smaller artifact set for a package with device sources). W4: a package whose build program failed under `emit`'s plan_only records MCPP_BUILD_DATABASE_PROGRAM_FAILED and applies none of its directives (state.cppm: new PrepareState::programFailedPackages, set at both call sites in target_side.cpp and features.cpp). The device-source orphan check in target_side.cpp now skips such a package outright, instead of reading every device source as unclaimed and failing the whole member. Also: notes a phase recorded before prepare_build's own failing return are no longer silently dropped (driver.cpp: a thread_local sink in the same per-run-sink style as mcpp::build::refusal, exported as mcpp::build::take_notes_on_failure); cmd_build.cppm's emit failure path folds any such note into the one diagnostic SPEC-005 R5.2 allows a wholly- failed member (path stays the member's mcpp.toml, exactly one entry), so the true cause is not lost behind a downstream symptom without violating that invariant. hasProgram's existing exists(build.mcpp) check is now correct by construction, since a failed program's package never reaches it. W5 (src/build/prepare/xlings.cpp): ensure_project_index_dir's two calls under a private work_dir are collapsed into the one call the ownerRoot==workRoot branch always made, targeting workRoot in every case. Previously the runtime-environment half (deps/subos/workspace) went to runtimeSelection.ownerRoot, which is always the real project root regardless of emit's private work_dir -- so `emit build-database` wrote <root>/.mcpp/.xlings.json into a project that declares [xlings] deps. SPEC-005: R3.7 names device sources beside NASM units (both absent from S1 and compile_commands.json, for different reasons -- NASM is a compile unit excluded from export, a device source is never a compile unit at all). R5.2 gains the sentence that a check whose premise is a build program's directives does not run for a package whose program failed in this pass. R2.1 needed no change. Tests: e2e 808 (device source: no dead ninja edge, absent from both databases, and the rule still compiles it and the build still runs), e2e 809 (a device source plus a build.mcpp that does not compile: PROGRAM_FAILED with path build.mcpp, no device-source mention, package still described), e2e 817 (688's project-tree digest repeated on a stub-xlings fixture with [xlings] deps: byte-identical tree, no .mcpp/.xlings.json, no write-project effect, and the private work directory does gain one naming the dependency). Each fails against the released 2026.9.27.1 binary and passes on the fresh build. Full regression: all 15 emit/build-database e2e scripts, 798, and four more that exercise the compile-unit loop (asm/GAS, NASM, object-path-collision, multi-module) all still pass; `mcpp test` (130 unit tests) passes.
1 parent b439fd9 commit c28b8d1

12 files changed

Lines changed: 465 additions & 26 deletions

‎docs/specs/build-database.md‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,11 @@ mcpp 输出的 S1 文档满足 S1 等级 2,不输出 `ide.options`。等级 3
100100

101101
### 3.3 翻译单元
102102

103-
- **R3.7** 除 NASM 单元外,构建计划中的每个编译单元是一个翻译单元。`source`、
103+
- **R3.7** 除 NASM 单元与规则声明的设备源文件(`SourceKind::Device`)外,构建计划中
104+
的每个编译单元是一个翻译单元;两者都不在 S1 文档与 `compile_commands.json` 中
105+
出现,但原因不同——NASM 单元是构建计划的编译单元,只是被逐出翻译单元的集合;
106+
设备源文件从不是构建计划的编译单元(引擎对其扩展名没有编译规则,能编译它的只有
107+
包自己的构建程序,通过一个动作),因而也从不进入这一集合。`source`、
104108
`work-directory`、`arguments`、`object` 与 `compile_commands.json` 中对应条目的
105109
`file`、`directory`、`arguments`、`output` 取自同一条记录,因而逐字相同。
106110
`work-directory` 是编译器实际运行的目录——即输出目录
@@ -170,7 +174,10 @@ mcpp 输出的 S1 文档满足 S1 等级 2,不输出 `ide.options`。等级 3
170174
的成员中,构建程序失败的包被描述为不含该程序产生的指令(清单自身的配置、工具链、
171175
模块图与标准库单元仍照常描述),`diagnostics` 另有一条 `error`,
172176
`MCPP_BUILD_DATABASE_PROGRAM_FAILED`,`path` 为该包的 `build.mcpp`;后续失败若是
173-
由缺失的指令引起,则按前一条规则使整个成员失败。只要 `diagnostics` 中有一条
177+
由缺失的指令引起,则按前一条规则使整个成员失败。一项检查若以构建程序的指令为
178+
前提(例如"每个设备源文件都被某个动作消费"),对本轮构建程序失败的包不运行:
179+
该包已经带着这一条 `PROGRAM_FAILED` 诊断被描述,不应因指令缺失这一后果本身被
180+
判成第二个失败,把真正的诊断挤出信封。只要 `diagnostics` 中有一条
174181
`error`,退出码就是 1,无论 `data` 是否出现。**已实现**(离线诊断码:
175182
mcpp >= 2026.9.16.1;成员独立规划、`path` 与构建程序失败的描述:mcpp >= 2026.9.26.2)
176183
- **R5.3** 信封的 `effects` 为 `read-project` 与 `write-global-cache`,运行了构建程序时

‎src/build/plan.cppm‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1844,8 +1844,24 @@ make_plan(const mcpp::manifest::Manifest& manifest,
18441844
};
18451845

18461846
// 1. Compile units in topological order
1847+
//
1848+
// A device-kind source (`SourceKind::Device`) is not one of them. The
1849+
// engine has no compile rule for it — it is compiled, if at all, by the
1850+
// package's build program through an action (`mcpp::action`), never by a
1851+
// `cxx_object`/`c_object` edge — so turning it into a `CompileUnit` here
1852+
// gave every consumer of `plan.compileUnits` a unit nothing runs: a dead
1853+
// `cxx_object` edge in `build.ninja` with no consumer, and an entry in
1854+
// `compile_commands.json` and the S1 document naming a compiler that never
1855+
// ran on the file (design 2026-09-27 §4.1, mcpp#724). Fixed at the source,
1856+
// once, so ninja, the compile database and S1 agree without each needing
1857+
// its own filter for this kind. The file itself still reaches `watch`
1858+
// (build_database.cppm expands the sources glob directly, not through
1859+
// `plan.compileUnits`), and it still reaches the package's build program
1860+
// through `MCPP_DEVICE_SOURCES` (features.cpp), which is the one thing
1861+
// that does compile it.
18471862
for (auto idx : topoOrder) {
18481863
auto& u = graph.units[idx];
1864+
if (u.kind == mcpp::SourceKind::Device) continue;
18491865
CompileUnit cu;
18501866
cu.source = u.path;
18511867
cu.packageName = u.packageName;

‎src/build/prepare.cppm‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -725,4 +725,27 @@ prepare_build(bool print_fingerprint, bool includeDevDeps = false,
725725
std::vector<mcpp::manifest::Target> extraTargets = {},
726726
BuildOverrides overrides = {});
727727

728+
// The PlanNotes a failed call recorded before the phase that failed it.
729+
//
730+
// On success, `prepare_build` copies `PrepareState::planNotes` into
731+
// `BuildContext::planNotes` (phase13_finish) — but on failure it returns only
732+
// `r.error()`, a plain string, and the `PrepareState` that held the notes is a
733+
// local of `prepare_build` and is gone the moment it returns. A note recorded
734+
// by an earlier phase (`MCPP_BUILD_DATABASE_PROGRAM_FAILED`, say) was
735+
// therefore lost on every failure of a later phase, not only the one that
736+
// motivated this (design 2026-09-27 §4.2, mcpp#724 side finding A, fix item
737+
// 2): under `emit build-database`'s `plan_only`, a member the caller could
738+
// otherwise describe the ordinary way (R5.2) instead reported nothing but the
739+
// later phase's own message.
740+
//
741+
// Same per-run-sink discipline as `mcpp::build::refusal` (refusal.cppm), and
742+
// for the same reason: widening `prepare_build`'s return type would touch
743+
// every caller of `.error()` to carry something only the failure path of one
744+
// caller (`emit`) reads. Written immediately before prepare_build's own
745+
// failing return, from the state that failure saw; read by the caller that
746+
// turns that failure into diagnostics. `take` reads and clears, so neither a
747+
// later failure of the SAME call nor a later, unrelated call inherits a stale
748+
// set of notes.
749+
export std::vector<PlanNote> take_notes_on_failure();
750+
728751
} // namespace mcpp::build

‎src/build/prepare/driver.cpp‎

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,22 @@ import mcpp.platform;
2424

2525
namespace mcpp::build {
2626

27+
namespace {
28+
// `thread_local` for the same reason `mcpp::build::refusal`'s sink is
29+
// (refusal.cppm): `prepare_build` recurses for nested host sub-builds on the
30+
// calling thread, and a failure of the INNER call must not leave notes behind
31+
// for an outer call that goes on to succeed. Cleared at the top of every
32+
// `prepare_build` call and on its success path, so only a call that is
33+
// itself failing can leave something here for its caller to take.
34+
thread_local std::vector<PlanNote> g_notesOnFailure;
35+
} // namespace
36+
37+
std::vector<PlanNote> take_notes_on_failure() {
38+
auto notes = std::move(g_notesOnFailure);
39+
g_notesOnFailure.clear();
40+
return notes;
41+
}
42+
2743
std::expected<BuildContext, std::string>
2844
prepare_build(bool print_fingerprint,
2945
bool includeDevDeps,
@@ -32,18 +48,29 @@ prepare_build(bool print_fingerprint,
3248
PrepareState state(print_fingerprint, includeDevDeps,
3349
std::move(extraTargets), std::move(overrides));
3450
pending_flag_words_notes().clear();
51+
g_notesOnFailure.clear();
52+
53+
// Every early return below carries `state.planNotes` as they stood at the
54+
// failing phase, so a caller whose only handle on the failure is
55+
// `.error()` (a plain string) can still read what an earlier phase
56+
// recorded — see `take_notes_on_failure`'s declaration in prepare.cppm.
57+
auto fail = [&](std::string message) -> std::unexpected<std::string> {
58+
g_notesOnFailure = state.planNotes;
59+
return std::unexpected(std::move(message));
60+
};
3561

36-
if (auto r = phase0_manifest_and_workspace(state); !r) return std::unexpected(r.error());
37-
if (auto r = phase1_toolchain_spec_and_axes(state); !r) return std::unexpected(r.error());
38-
if (auto r = phase2_define_toolchain_resolver(state); !r) return std::unexpected(r.error());
39-
if (auto r = phase3_xlings_before_graph(state); !r) return std::unexpected(r.error());
40-
if (auto r = phase4a_graph_load(state); !r) return std::unexpected(r.error());
41-
if (auto r = phase4b_graph_worklist(state); !r) return std::unexpected(r.error());
42-
if (auto r = phase5_toolchain_after_graph(state); !r) return std::unexpected(r.error());
43-
if (auto r = phase6_features_and_host_tools(state); !r) return std::unexpected(r.error());
44-
if (auto r = phase9_target_side(state); !r) return std::unexpected(r.error());
45-
if (auto r = phase11_scan(state); !r) return std::unexpected(r.error());
62+
if (auto r = phase0_manifest_and_workspace(state); !r) return fail(r.error());
63+
if (auto r = phase1_toolchain_spec_and_axes(state); !r) return fail(r.error());
64+
if (auto r = phase2_define_toolchain_resolver(state); !r) return fail(r.error());
65+
if (auto r = phase3_xlings_before_graph(state); !r) return fail(r.error());
66+
if (auto r = phase4a_graph_load(state); !r) return fail(r.error());
67+
if (auto r = phase4b_graph_worklist(state); !r) return fail(r.error());
68+
if (auto r = phase5_toolchain_after_graph(state); !r) return fail(r.error());
69+
if (auto r = phase6_features_and_host_tools(state); !r) return fail(r.error());
70+
if (auto r = phase9_target_side(state); !r) return fail(r.error());
71+
if (auto r = phase11_scan(state); !r) return fail(r.error());
4672

73+
g_notesOnFailure.clear();
4774
return phase13_finish(state);
4875
}
4976

‎src/build/prepare/features.cpp‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1768,6 +1768,12 @@ std::expected<void, std::string> phase6_features_and_host_tools(PrepareState& st
17681768
pkg.manifest.package.name, r.error()),
17691769
mcpp::wire::Severity::Error,
17701770
(pkg.root / "build.mcpp").string()});
1771+
// Same reason as the root's mirror of this in
1772+
// target_side.cpp: a later check whose premise is this
1773+
// program's directives (the device-source check) must be
1774+
// able to tell this package apart from one with no program
1775+
// at all.
1776+
state.programFailedPackages.insert(pkg.root.string());
17711777
continue;
17721778
}
17731779
return std::unexpected(std::format(

‎src/build/prepare/state.cppm‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,16 @@ struct PrepareState {
462462
std::vector<std::pair<std::string, std::string>> abiRequiresExceptions;
463463
std::map<std::string, std::vector<std::string>> capExclusive;
464464
std::map<std::string, std::vector<std::string>> deviceSourcesByPackage;
465+
// Keyed like `deviceSourcesByPackage`, by `pkg.root.string()` (root
466+
// package included: `packages[0].root == *root`). Holds a package whose
467+
// build program failed IN THIS PASS, under `plan_only` (`emit
468+
// build-database`) — the one case a failed program does not already end
469+
// the whole call (SPEC-005 R5.2, #699 item 2, E3). A check whose premise
470+
// is that program's directives must not run for such a package: with no
471+
// directives applied, every premise reads as unmet, which is a symptom of
472+
// the recorded `MCPP_BUILD_DATABASE_PROGRAM_FAILED`, not a second defect
473+
// (design 2026-09-27 §4.2, mcpp#724 side finding A).
474+
std::set<std::string> programFailedPackages;
465475
std::function<std::optional<std::string>()> checkVersionFloors;
466476
mcpp::targetside::TargetSide resolvedTargetSide;
467477
std::optional<std::size_t> cxxLayerProviderIndex;

‎src/build/prepare/target_side.cpp‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1622,6 +1622,10 @@ std::expected<void, std::string> phase9_target_side(PrepareState& state) {
16221622
state.planNotes.push_back({"MCPP_BUILD_DATABASE_PROGRAM_FAILED",
16231623
bp.error(), mcpp::wire::Severity::Error,
16241624
(*state.root / "build.mcpp").string()});
1625+
// Named so the device-source check below (and anything else whose
1626+
// premise is this program's directives) can tell a package whose
1627+
// program failed apart from one that simply has no program.
1628+
state.programFailedPackages.insert(state.root->string());
16251629
}
16261630
if (bp) {
16271631
// THE SAME RULE THE DEPENDENCIES ARE HELD TO, WITH THE ROOT AS A PARTY.
@@ -1764,8 +1768,18 @@ std::expected<void, std::string> phase9_target_side(PrepareState& state) {
17641768
// condition an action needs anyway -- one that compiles a file it does not
17651769
// declare as an input does not rerun when that file changes -- so a rule
17661770
// that satisfies it is a rule that rebuilds correctly.
1771+
//
1772+
// THE PREMISE OF THIS CHECK IS THE BUILD PROGRAM'S DIRECTIVES: an action
1773+
// consuming a device source is one such directive. A package whose program
1774+
// failed in this pass (`plan_only`, above) applied none of them, so every
1775+
// device source would read as an orphan -- not a second defect, only the
1776+
// shape the first one takes here. Such a package already carries its one
1777+
// diagnostic, `MCPP_BUILD_DATABASE_PROGRAM_FAILED`; this check does not run
1778+
// for it, exactly as SPEC-005 R5.2 now states (design 2026-09-27 §4.2,
1779+
// mcpp#724 side finding A).
17671780
for (std::size_t i = 0; i < state.packages.size(); ++i) {
17681781
auto const& pkg = state.packages[i];
1782+
if (state.programFailedPackages.contains(pkg.root.string())) continue;
17691783
auto dit = state.deviceSourcesByPackage.find(pkg.root.string());
17701784
if (dit == state.deviceSourcesByPackage.end() || dit->second.empty()) continue;
17711785
auto const& mm = (i == 0) ? *state.m : pkg.manifest;
@@ -1781,6 +1795,9 @@ std::expected<void, std::string> phase9_target_side(PrepareState& state) {
17811795
orphans += " " + rel + "\n";
17821796
if (orphans.empty()) continue;
17831797
std::error_code hasEc;
1798+
// The `programFailedPackages` skip above means this package's program,
1799+
// if it has one, ran and succeeded — `exists(build.mcpp)` here can no
1800+
// longer be true of a program that merely started and failed.
17841801
const bool hasProgram = std::filesystem::exists(pkg.root / "build.mcpp", hasEc)
17851802
|| !pkg.manifest.buildConfig.ruleModules.empty();
17861803
refusal::record(refusal::Code::DeviceSourceUnconsumed);

‎src/build/prepare/xlings.cpp‎

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -429,17 +429,24 @@ std::expected<void, std::string> phase3_xlings_before_graph(PrepareState& state)
429429
penv.workspace.emplace_back(entry.target, entry.pin());
430430
}
431431
}
432-
if (state.runtimeSelection.ownerRoot == state.workRoot) {
433-
mcpp::config::ensure_project_index_dir(
434-
**cfg2, state.workRoot, state.m->indices, penv);
435-
} else {
436-
if (!state.m->indices.empty())
437-
mcpp::config::ensure_project_index_dir(
438-
**cfg2, state.workRoot, state.m->indices, {});
439-
if (materializeRootRuntime)
440-
mcpp::config::ensure_project_index_dir(
441-
**cfg2, state.runtimeSelection.ownerRoot, {}, penv);
442-
}
432+
// `state.workRoot` is where THIS invocation writes (SPEC-005
433+
// R2.1's cache directory under `emit build-database`'s
434+
// `plan_only`, or a host tool's private store under tool
435+
// provisioning); `runtimeSelection.ownerRoot` is always the real
436+
// project root (`select_runtime`, `runtime_selection.cppm`),
437+
// regardless of either. The two used to disagree on where the
438+
// runtime-environment half (`penv`: deps/subos/workspace) of
439+
// `.mcpp/.xlings.json` belongs whenever they differ: the custom-
440+
// indices half already went to `workRoot`, but `penv` went to
441+
// `ownerRoot` — the actual project tree — which is exactly what
442+
// `emit` must never write into (design 2026-09-27 §4.3, mcpp#724
443+
// side finding B). Both halves now go to the one root this
444+
// invocation writes everything else to, `workRoot`; when it
445+
// equals `ownerRoot` (an ordinary rooted build) that is the same
446+
// write the `if` branch always made, so this is one call in
447+
// every case, not two.
448+
mcpp::config::ensure_project_index_dir(
449+
**cfg2, state.workRoot, state.m->indices, penv);
443450

444451
// `[xlings] deps` are DECLARED above and, until now, nothing
445452
// installed them (mcpp-index #281 §9).

‎src/cli/cmd_build.cppm‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -400,10 +400,28 @@ export int cmd_emit_build_database(const mcpplibs::cmdline::ParsedArgs& parsed)
400400
includeDevDeps,
401401
std::move(discovered->targets), mo);
402402
if (!ctx) {
403+
// A wholly-failed member contributes exactly one `error`
404+
// diagnostic, `path` its `mcpp.toml` (SPEC-005 R5.2) — that
405+
// invariant is kept exactly, so a note an earlier phase
406+
// recorded (most importantly
407+
// `MCPP_BUILD_DATABASE_PROGRAM_FAILED`) is folded into THIS
408+
// diagnostic's own message instead of becoming a diagnostic of
409+
// its own. Without it, a later phase's failure that follows
410+
// from the missing directives (SPEC-005 R5.2's own words) read
411+
// as a single, unexplained symptom, and the actual cause —
412+
// recorded, then discarded the moment `prepare_build` returned
413+
// — never reached the reader (design 2026-09-27 §4.2, mcpp#724
414+
// side finding A, fix item 2).
415+
std::string message = member.empty() ? ctx.error()
416+
: std::format("{}: {}", member, ctx.error());
417+
for (auto const& note : mcpp::build::take_notes_on_failure())
418+
message += note.path.empty()
419+
? std::format("\n earlier in this pass, {}: {}",
420+
note.code, note.message)
421+
: std::format("\n earlier in this pass, {} ({}): {}",
422+
note.code, note.path, note.message);
403423
diagnostics.push_back({plan_failure_code(), Severity::Error,
404-
member.empty() ? ctx.error()
405-
: std::format("{}: {}", member, ctx.error()),
406-
memberPath});
424+
std::move(message), memberPath});
407425
failedMemberRoots.push_back(memberRoot);
408426
continue;
409427
}

0 commit comments

Comments
 (0)