Skip to content

Commit 9211fd4

Browse files
committed
feat(dyndep): plan-vs-ddi reconciliation — the compiler audits the planner
The .ddi (compiler's own P1689 scan under real flags) is the ground truth of what phase 4 saw. ninja_backend now embeds the planner's per-TU assumption on the dyndep edge (--expect-provides / --expect-imports / --expect-none); `mcpp dyndep --single` compares and fails the edge on divergence with both sides named. Mandatory for scan_overrides units (an assertion needs its auditor); MCPP_VERIFY_MODGRAPH=1 at generation time extends it to every module unit. Zero extra compiler invocations. Verified: 3 unit tests; e2e negative — an fmt override deliberately omitting imports={std} fails at DYNDEP with planned : provides [fmt] imports [<none>] compiler: provides [fmt] imports [std] while the correct declaration builds green. Design: .agents/docs/2026-07-08-scanner-backend-abstraction-design.md §3d.
1 parent ea7ea43 commit 9211fd4

6 files changed

Lines changed: 144 additions & 1 deletion

File tree

src/build/ninja_backend.cppm

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,7 @@ std::string emit_ninja_string(const BuildPlan& plan) {
314314
// P1: per-file dyndep rule. Converts one .ddi → .dd independently.
315315
append(std::format(
316316
"rule cxx_dyndep\n"
317-
" command = $mcpp dyndep --single --bmi-dir {} --bmi-ext {} --output $out $in\n"
317+
" command = $mcpp dyndep --single --bmi-dir {} --bmi-ext {} $expect --output $out $in\n"
318318
" description = DYNDEP $out\n"
319319
" restat = 1\n\n",
320320
traits.bmiDir, traits.bmiExt));
@@ -502,10 +502,42 @@ std::string emit_ninja_string(const BuildPlan& plan) {
502502
// invalidates that file's .dd and its compile edge, not all edges.
503503
// Map ddi path → dd path for Phase 3 reference.
504504
std::map<std::string, std::string> ddi_to_dd;
505+
// Plan-vs-ddi reconciliation (design 2026-07-08 scanner doc §3d):
506+
// scan_overrides units ALWAYS carry their planned (provides, imports)
507+
// on the dyndep edge — the compiler's own P1689 scan audits the
508+
// author's assertion, per TU, failing the edge on divergence.
509+
// MCPP_VERIFY_MODGRAPH=1 (read at generation time) extends the
510+
// check to every module unit.
511+
const bool verifyAll = [] {
512+
const char* v = std::getenv("MCPP_VERIFY_MODGRAPH");
513+
return v && std::string_view(v) == "1";
514+
}();
515+
std::map<std::string, std::string> ddi_expect;
516+
for (auto& cu : plan.compileUnits) {
517+
if (is_c_source(cu.source)) continue;
518+
if (!cu.scanOverridden && !verifyAll) continue;
519+
auto ddi = (cu.object.parent_path() / cu.source.filename()).string() + ".ddi";
520+
std::string exp;
521+
if (cu.providesModule)
522+
exp += std::format("--expect-provides {}", *cu.providesModule);
523+
if (!cu.imports.empty()) {
524+
std::string csv;
525+
for (auto& m : cu.imports) {
526+
if (!csv.empty()) csv += ",";
527+
csv += m;
528+
}
529+
if (!exp.empty()) exp += " ";
530+
exp += std::format("--expect-imports {}", csv);
531+
}
532+
if (exp.empty()) exp = "--expect-none";
533+
ddi_expect[ddi] = std::move(exp);
534+
}
505535
for (auto& ddi : ddi_paths) {
506536
auto dd = ddi + ".dd"; // e.g. obj/cli.cppm.ddi.dd
507537
ddi_to_dd[ddi] = dd;
508538
append(std::format("build {} : cxx_dyndep {}\n", dd, ddi));
539+
if (auto it = ddi_expect.find(ddi); it != ddi_expect.end())
540+
append(std::format(" expect = {}\n", it->second));
509541
}
510542
append("\n");
511543

src/build/plan.cppm

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ struct CompileUnit {
2424
std::vector<std::string> packageCxxflags;
2525
std::optional<std::string> providesModule; // logical name, if .cppm export
2626
std::vector<std::string> imports; // logical names imported
27+
// Unit came from a scan_overrides declaration — plan-vs-ddi
28+
// verification is mandatory for it (ninja_backend emits --expect-*).
29+
bool scanOverridden = false;
2730
};
2831

2932
struct LinkUnit {
@@ -420,6 +423,7 @@ BuildPlan make_plan(const mcpp::manifest::Manifest& manifest,
420423
cu.providesModule = u.provides->logicalName;
421424
}
422425
for (auto& req : u.requires_) cu.imports.push_back(req.logicalName);
426+
cu.scanOverridden = u.scanOverridden;
423427
plan.compileUnits.push_back(std::move(cu));
424428
}
425429

src/cli.cppm

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,12 @@ int run(int argc, char** argv) {
469469
.help("BMI cache directory name (default: gcm.cache)"))
470470
.option(cl::Option("bmi-ext").takes_value().value_name("EXT")
471471
.help("BMI file extension (default: .gcm)"))
472+
.option(cl::Option("expect-provides").takes_value().value_name("NAME")
473+
.help("(verification) planned provided module for this TU"))
474+
.option(cl::Option("expect-imports").takes_value().value_name("CSV")
475+
.help("(verification) planned imports for this TU, comma-separated"))
476+
.option(cl::Option("expect-none")
477+
.help("(verification) planner assumed no provides/imports"))
472478
.action(wrap_rc(cmd_dyndep)))
473479
;
474480

src/cli/cmd_build.cppm

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,35 @@ export int cmd_dyndep(const mcpplibs::cmdline::ParsedArgs& parsed) {
182182
std::println(stderr, "error: --single requires exactly one .ddi input");
183183
return 2;
184184
}
185+
// Plan-vs-ddi reconciliation: when the generator declared what the
186+
// planner assumed for this TU, compare against the compiler's own
187+
// scan and fail the edge on divergence (mandatory for
188+
// scan_overrides units; opt-in elsewhere via MCPP_VERIFY_MODGRAPH).
189+
std::string expProvides = parsed.option_or_empty("expect-provides").value();
190+
std::string expImports = parsed.option_or_empty("expect-imports").value();
191+
if (!expProvides.empty() || !expImports.empty() ||
192+
parsed.is_flag_set("expect-none")) {
193+
std::ifstream is{std::filesystem::path{parsed.positional(0)}};
194+
std::string ddiBody{std::istreambuf_iterator<char>(is), {}};
195+
auto unit = mcpp::dyndep::parse_ddi(ddiBody);
196+
if (!unit) {
197+
std::println(stderr, "error: {}: {}", parsed.positional(0), unit.error());
198+
return 1;
199+
}
200+
std::optional<std::string> ep;
201+
if (!expProvides.empty()) ep = expProvides;
202+
std::vector<std::string> ei;
203+
for (std::size_t b = 0; b < expImports.size();) {
204+
auto e = expImports.find(',', b);
205+
if (e == std::string::npos) e = expImports.size();
206+
if (e > b) ei.emplace_back(expImports.substr(b, e - b));
207+
b = e + 1;
208+
}
209+
if (auto err = mcpp::dyndep::verify_unit_expectations(*unit, ep, ei)) {
210+
std::println(stderr, "error: {}", *err);
211+
return 1;
212+
}
213+
}
185214
body = mcpp::dyndep::emit_dyndep_single(parsed.positional(0), opts);
186215
} else {
187216
std::vector<std::filesystem::path> ddis;

src/dyndep.cppm

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,18 @@ std::expected<std::string, std::string>
6666
emit_dyndep_single(const std::filesystem::path& ddiPath,
6767
const DyndepOptions& opts = {});
6868

69+
// Plan-vs-ddi reconciliation: compare the compiler's OWN scan of a TU
70+
// (the .ddi — ground truth of what phase 4 saw under real flags) against
71+
// the planner's assumption. Returns an error message on divergence.
72+
// Mandatory for scan_overrides units (an assertion needs its auditor);
73+
// opt-in for the rest via MCPP_VERIFY_MODGRAPH=1 (ninja_backend decides
74+
// at generation time). Design: .agents/docs/2026-07-08-scanner-backend-
75+
// abstraction-design.md §3d.
76+
std::optional<std::string>
77+
verify_unit_expectations(const UnitInfo& actual,
78+
const std::optional<std::string>& expectProvides,
79+
const std::vector<std::string>& expectImports);
80+
6981
} // namespace mcpp::dyndep
7082

7183
namespace mcpp::dyndep {
@@ -327,4 +339,34 @@ emit_dyndep_single(const std::filesystem::path& ddiPath,
327339
return out;
328340
}
329341

342+
343+
std::optional<std::string>
344+
verify_unit_expectations(const UnitInfo& actual,
345+
const std::optional<std::string>& expectProvides,
346+
const std::vector<std::string>& expectImports)
347+
{
348+
std::set<std::string> act_p(actual.provides.begin(), actual.provides.end());
349+
std::set<std::string> exp_p;
350+
if (expectProvides && !expectProvides->empty()) exp_p.insert(*expectProvides);
351+
std::set<std::string> act_r(actual.requires_.begin(), actual.requires_.end());
352+
std::set<std::string> exp_r(expectImports.begin(), expectImports.end());
353+
354+
if (act_p == exp_p && act_r == exp_r) return std::nullopt;
355+
356+
auto join = [](const std::set<std::string>& s) {
357+
std::string out;
358+
for (auto& v : s) { if (!out.empty()) out += ", "; out += v; }
359+
return out.empty() ? std::string("<none>") : out;
360+
};
361+
return std::format(
362+
"module-graph divergence in {}:\n"
363+
" planned : provides [{}] imports [{}]\n"
364+
" compiler: provides [{}] imports [{}]\n"
365+
" The compiler's P1689 scan disagrees with the planner's assumption\n"
366+
" (stale scan_overrides declaration, or a conditional/include-carried\n"
367+
" import). Fix the declaration or the source.",
368+
actual.primaryOutput.string(),
369+
join(exp_p), join(exp_r), join(act_p), join(act_r));
370+
}
371+
330372
} // namespace mcpp::dyndep

tests/unit/test_dyndep.cpp

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,33 @@ TEST(Dyndep, EmitDyndepFromFiles) {
104104

105105
std::filesystem::remove_all(tmp);
106106
}
107+
108+
// ── plan-vs-ddi reconciliation (scan_overrides auditor) ──
109+
110+
TEST(VerifyUnitExpectations, MatchPasses) {
111+
mcpp::dyndep::UnitInfo u;
112+
u.primaryOutput = "obj/fmt.o";
113+
u.provides = {"fmt"};
114+
u.requires_ = {"std"};
115+
auto err = mcpp::dyndep::verify_unit_expectations(u, "fmt", {"std"});
116+
EXPECT_FALSE(err.has_value()) << *err;
117+
}
118+
119+
TEST(VerifyUnitExpectations, DivergenceReportsBothSides) {
120+
mcpp::dyndep::UnitInfo u;
121+
u.primaryOutput = "obj/fmt.o";
122+
u.provides = {"fmt"};
123+
u.requires_ = {"std"};
124+
auto err = mcpp::dyndep::verify_unit_expectations(u, "fmt", {});
125+
ASSERT_TRUE(err.has_value());
126+
EXPECT_NE(err->find("divergence"), std::string::npos);
127+
EXPECT_NE(err->find("planned"), std::string::npos);
128+
EXPECT_NE(err->find("std"), std::string::npos);
129+
}
130+
131+
TEST(VerifyUnitExpectations, ExpectNoneMatchesEmptyUnit) {
132+
mcpp::dyndep::UnitInfo u;
133+
u.primaryOutput = "obj/plain.o";
134+
auto err = mcpp::dyndep::verify_unit_expectations(u, std::nullopt, {});
135+
EXPECT_FALSE(err.has_value());
136+
}

0 commit comments

Comments
 (0)