From bca554240160f12809348264f1e8117c06419a1a Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:59:59 +0800 Subject: [PATCH 01/18] manifest: [package] mcpp = ">=V" states the oldest mcpp a package supports; [lib] names an unknown key (#734 E9, E12) --- modules/manifest/src/toml.cppm | 65 +++++++++++++++- modules/manifest/src/types.cppm | 7 ++ src/build/prepare/driver.cpp | 2 + src/build/prepare/manifest.cpp | 29 ++++++++ src/build/prepare/state.cppm | 5 ++ src/project.cppm | 1 + ...kage_states_the_oldest_mcpp_it_supports.sh | 74 +++++++++++++++++++ 7 files changed, 181 insertions(+), 2 deletions(-) create mode 100755 tests/e2e/822_a_package_states_the_oldest_mcpp_it_supports.sh diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index ace3244d..6ca25db5 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -14,6 +14,7 @@ import mcpp.pm.dependency_selector; import mcpp.pm.index_spec; import mcpp.platform; import mcpp.platform.axis; // the one macos/macosx spelling rule +import mcpp.xpkg_version; // the release grammar of `mcpp = ">=V"` // ANONYMOUS NAMESPACE, AND THIS COST TWO WINDOWS JOBS TO LEARN. // @@ -35,6 +36,32 @@ import mcpp.platform.axis; // the one macos/macosx spelling rule // able to see it. namespace { +// `mcpp = ">=V"` (#734, E9): the oldest mcpp release a package supports. +// Returns the version after `>=`. Only the floor form is accepted: a bare +// version means "exactly this one" everywhere else in mcpp, and a range with an +// upper bound would state that a newer engine cannot build the package, which +// the engine's compatibility promise makes false. +std::expected parse_mcpp_floor(std::string_view text) { + auto trim = [](std::string_view s) { + while (!s.empty() && (s.front() == ' ' || s.front() == '\t')) s.remove_prefix(1); + while (!s.empty() && (s.back() == ' ' || s.back() == '\t')) s.remove_suffix(1); + return s; + }; + auto t = trim(text); + if (!t.starts_with(">=")) { + if (mcpp::xpkg_version::parse(t)) + return std::unexpected(std::format( + "'{}' names one release exactly; a floor is written \">={}\"", t, t)); + return std::unexpected(std::format( + "'{}' is not a floor; write \">=\", for example \">=2026.9.29.1\"", t)); + } + auto ver = trim(t.substr(2)); + if (ver.empty() || !mcpp::xpkg_version::parse(ver)) + return std::unexpected(std::format( + "'{}' is not an mcpp release; write \">=\", for example \">=2026.9.29.1\"", ver)); + return std::string(ver); +} + // A dependency's version requirement, checked with the parser that will later // be asked to match it. // @@ -1489,7 +1516,7 @@ std::expected parse_string(std::string_view content, // MUST stay in sync with the `doc->get_*("package.")` reads above. static constexpr std::string_view kKnownPackageKeys[] = { "accelerators", "authors", "c-environment", "description", "exclusive", - "license", "metadata", "name", "namespace", "platforms", "provides", + "license", "mcpp", "metadata", "name", "namespace", "platforms", "provides", "repo", "requires", "requires_abi", "standard", "std-compat-module", "std-module", "std-module-flags", "version", }; @@ -1510,6 +1537,19 @@ std::expected parse_string(std::string_view content, } } + // `[package] mcpp = ">=V"` — the engine floor (#734, E9). Refused rather + // than ignored when malformed: a floor that silently reads as no floor is + // the failure it exists to prevent. + if (auto* pt = doc->get_table("package"); pt && pt->contains("mcpp")) { + auto v = doc->get_string("package.mcpp"); + if (!v) return std::unexpected(error(origin, + "[package].mcpp must be a string such as \">=2026.9.29.1\"")); + auto floor = parse_mcpp_floor(*v); + if (!floor) return std::unexpected(error(origin, + std::format("[package].mcpp: {}", floor.error()))); + m.package.mcppFloor = *floor; + } + // [capabilities] cap = "provider" — root-only provider pins. if (auto* caps = doc->get_table("capabilities"); caps && !caps->empty()) { for (auto& [cap, cval] : *caps) @@ -3215,6 +3255,17 @@ std::expected parse_string(std::string_view content, if (auto v = doc->get_string("lib.path")) { m.lib.path = *v; } + // Reported like `[build]` and `[package]` do (#734, E12). Before this a + // misspelt `path` was accepted without a word, and the lib root silently + // fell back to the convention. + if (auto* lt = doc->get_table("lib")) { + for (auto& [key, ignored] : *lt) { + (void)ignored; + if (key == "path") continue; + m.schemaWarnings.push_back(std::format( + "[lib] has unsupported key '{}' (ignored). Supported keys: path.", key)); + } + } // [pack] — `mcpp pack` configuration. See docs/10-pack-and-release.md. if (auto v = doc->get_string("pack.default_mode")) { @@ -4023,8 +4074,18 @@ std::expected parse_string(std::string_view content, inh.repo = *v; if (auto v = doc->get_string_array("workspace.package.authors")) inh.authors = *v; + if (wpkg->contains("mcpp")) { + auto v = doc->get_string("workspace.package.mcpp"); + if (!v) return std::unexpected(error(origin, + "[workspace.package].mcpp must be a string such as \">=2026.9.29.1\"")); + auto floor = parse_mcpp_floor(*v); + if (!floor) return std::unexpected(error(origin, + std::format("[workspace.package].mcpp: {}", floor.error()))); + inh.mcppFloor = *floor; + } static constexpr std::string_view kKnown[] = { "standard", "version", "license", "description", "repo", "authors", + "mcpp", }; for (auto& [key, ignored] : *wpkg) { (void)ignored; @@ -4035,7 +4096,7 @@ std::expected parse_string(std::string_view content, // is not, which is the defect this table was added to fix. return std::unexpected(error(origin, std::format( "[workspace.package] has no key '{}'. Supported: " - "standard, version, license, description, repo, authors. " + "standard, version, license, description, repo, authors, mcpp. " "`name` is per-member by definition.", key))); } } diff --git a/modules/manifest/src/types.cppm b/modules/manifest/src/types.cppm index e65efbd4..7b6efd8e 100644 --- a/modules/manifest/src/types.cppm +++ b/modules/manifest/src/types.cppm @@ -71,6 +71,12 @@ struct Package { std::string license; std::vector authors; std::string repo; + // `mcpp = ">=V"`: the oldest mcpp release this package supports, stored as + // the version after `>=`. Empty when the package states none. A floor, not + // a pin: the pin a project installs is `.xlings.json`'s. The parser accepts + // only the `>=` form, because a bare version means "exactly" everywhere + // else in mcpp. + std::string mcppFloor; std::vector platforms; // declared supported platforms (CI matrix hint) // Accelerator backends this package supports, declared in the same spirit // as `platforms`: a statement of intent and a CI-matrix hint, not a gate. @@ -1674,6 +1680,7 @@ struct WorkspaceInherited { std::string description; std::string repo; std::vector authors; + std::string mcppFloor; // `[workspace.package] mcpp`, as `Package::mcppFloor` // `[workspace.build]` — the INHERITABLE SUBSET of `[build]`, and the subset // is a stated list rather than "whatever [build] happens to carry". A key // that is not in it is refused at parse time with the reason, because diff --git a/src/build/prepare/driver.cpp b/src/build/prepare/driver.cpp index 40fadda9..f8b32e4a 100644 --- a/src/build/prepare/driver.cpp +++ b/src/build/prepare/driver.cpp @@ -60,11 +60,13 @@ prepare_build(bool print_fingerprint, }; if (auto r = phase0_manifest_and_workspace(state); !r) return fail(r.error()); + if (auto r = check_engine_floors(state, /*rootOnly=*/true); !r) return fail(r.error()); if (auto r = phase1_toolchain_spec_and_axes(state); !r) return fail(r.error()); if (auto r = phase2_define_toolchain_resolver(state); !r) return fail(r.error()); if (auto r = phase3_xlings_before_graph(state); !r) return fail(r.error()); if (auto r = phase4a_graph_load(state); !r) return fail(r.error()); if (auto r = phase4b_graph_worklist(state); !r) return fail(r.error()); + if (auto r = check_engine_floors(state, /*rootOnly=*/false); !r) return fail(r.error()); if (auto r = phase5_toolchain_after_graph(state); !r) return fail(r.error()); if (auto r = phase6_features_and_host_tools(state); !r) return fail(r.error()); if (auto r = phase9_target_side(state); !r) return fail(r.error()); diff --git a/src/build/prepare/manifest.cpp b/src/build/prepare/manifest.cpp index 62cb68f8..d624670e 100644 --- a/src/build/prepare/manifest.cpp +++ b/src/build/prepare/manifest.cpp @@ -11,6 +11,8 @@ import mcpp.targetside; import mcpp.diag; import mcpp.build.version_floor; import mcpp.manifest; +import mcpp.version; // this binary's release, for E9's floor +import mcpp.xpkg_version; // the release ordering import mcpp.source_kind; import mcpp.modgraph.glob; import mcpp.modgraph.graph; @@ -155,6 +157,33 @@ static std::expected step0_workspace_handling(PrepareState& s return {}; } +std::expected check_engine_floors(const PrepareState& state, + bool rootOnly) { + const auto have = mcpp::xpkg_version::parse(mcpp::MCPP_VERSION); + if (!have) return {}; // a development build with an unparsable version states no order + auto check = [&](const mcpp::manifest::Manifest& m, + std::string_view where) -> std::expected { + if (m.package.mcppFloor.empty()) return {}; + const auto need = mcpp::xpkg_version::parse(m.package.mcppFloor); + if (!need || mcpp::xpkg_version::compare(*have, *need) >= 0) return {}; + const std::string who = m.package.namespace_.empty() + ? m.package.name : m.package.namespace_ + "." + m.package.name; + return std::unexpected(std::format( + "package '{}' ({}) requires mcpp >= {}; this is mcpp {}.\n" + " hint: pin \"mcpp\": \"{}\" (or newer) in .xlings.json and run " + "`xlings install`, or run `xlings install mcpp@{}`", + who, where, m.package.mcppFloor, mcpp::MCPP_VERSION, + m.package.mcppFloor, m.package.mcppFloor)); + }; + if (rootOnly) { + if (!state.m) return {}; + return check(*state.m, state.root ? state.root->generic_string() : "the project"); + } + for (auto const& p : state.packages) + if (auto r = check(p.manifest, p.root.generic_string()); !r) return r; + return {}; +} + std::expected phase0_manifest_and_workspace(PrepareState& state) { // A refusal decided early and released late. `host_can_serve` answers // "does a payload on this machine produce this target", which is knowable diff --git a/src/build/prepare/state.cppm b/src/build/prepare/state.cppm index b242da3e..a23685ba 100644 --- a/src/build/prepare/state.cppm +++ b/src/build/prepare/state.cppm @@ -508,6 +508,11 @@ std::expected phase5_toolchain_after_graph(PrepareState& stat std::expected phase6_features_and_host_tools(PrepareState& state); std::expected phase9_target_side(PrepareState& state); std::expected phase11_scan(PrepareState& state); +// E9 (#734): every package's `mcpp = ">=V"` against this binary. Checked for the +// root right after its manifest is final and for the whole graph after loading, +// so a too-new root fails before any later phase can fail on a key it uses. +std::expected check_engine_floors(const PrepareState& state, + bool rootOnly); std::expected phase13_finish(PrepareState& state); // P13's records half (records.cpp), called by phase13_finish. std::expected step13_lockfile(PrepareState& state, BuildContext& ctx); diff --git a/src/project.cppm b/src/project.cppm index 995fbc79..8f4163df 100644 --- a/src/project.cppm +++ b/src/project.cppm @@ -180,6 +180,7 @@ export void inherit_workspace_package(mcpp::manifest::Manifest& member, if (member.package.description.empty()) member.package.description = inh.description; if (member.package.repo.empty()) member.package.repo = inh.repo; if (member.package.authors.empty()) member.package.authors = inh.authors; + if (member.package.mcppFloor.empty()) member.package.mcppFloor = inh.mcppFloor; } // EVERYTHING A MEMBER INHERITS FROM ITS WORKSPACE ROOT, IN ONE FUNCTION. diff --git a/tests/e2e/822_a_package_states_the_oldest_mcpp_it_supports.sh b/tests/e2e/822_a_package_states_the_oldest_mcpp_it_supports.sh new file mode 100755 index 00000000..730b49a4 --- /dev/null +++ b/tests/e2e/822_a_package_states_the_oldest_mcpp_it_supports.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# requires: unix-shell +# 822_a_package_states_the_oldest_mcpp_it_supports.sh -- #734 E9 and E12. +# +# `[package] mcpp = ">=V"` states the oldest mcpp release a package supports. +# An engine below the floor stops before any other phase, naming the package, +# the floor, its own release and the way to upgrade. The floor is written with +# `>=` only: a bare release means "exactly" elsewhere in mcpp, so it is refused +# with the spelling that is meant. `[workspace.package] mcpp` is inherited by +# members. `[lib]` reports an unknown key, as `[build]` and `[package]` do. +# +# F1 a floor above this release stops the build with the hint; +# F2 a floor at or below this release builds; +# F3 a bare release and a non-release are refused, each with its reason; +# F4 a workspace floor reaches a member built with -p; +# F5 `[lib]` names an unknown key. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } +MCPP="${MCPP:-mcpp}" +HAVE=$("$MCPP" --version | awk '{print $2}') + +mkdir -p p/src +cd p +printf 'int main() { return 0; }\n' > src/main.cpp +manifest() { printf '[package]\nname = "floor822"\nversion = "0.1.0"\nmcpp = %s\n' "$1" > mcpp.toml; } + +# F1 +manifest '">=2999.1.1"' +if "$MCPP" build > f1.log 2>&1; then fail "F1: a floor above $HAVE built" f1.log; fi +grep -q "requires mcpp >= 2999.1.1; this is mcpp $HAVE" f1.log || fail "F1: the message does not name the floor and this release" f1.log +grep -q "xlings install mcpp@2999.1.1" f1.log || fail "F1: the message does not say how to upgrade" f1.log + +# F2 +manifest "\">=$HAVE\"" +"$MCPP" build > f2.log 2>&1 || fail "F2: a floor equal to this release did not build" f2.log +manifest '">=2026.1.1"' +"$MCPP" build > f2b.log 2>&1 || fail "F2: a floor below this release did not build" f2b.log + +# F3 +manifest "\"$HAVE\"" +if "$MCPP" build > f3.log 2>&1; then fail "F3: a bare release was accepted" f3.log; fi +grep -q "names one release exactly; a floor is written \">=$HAVE\"" f3.log || fail "F3: the bare release is not explained" f3.log +manifest '">=banana"' +if "$MCPP" build > f3b.log 2>&1; then fail "F3: a non-release was accepted" f3b.log; fi +grep -q "'banana' is not an mcpp release" f3b.log || fail "F3: the non-release is not explained" f3b.log + +# F4 +cd "$TMP" +mkdir -p ws/app/src +cat > ws/mcpp.toml <<'EOF' +[workspace] +members = ["app"] + +[workspace.package] +version = "0.1.0" +mcpp = ">=2999.1.1" +EOF +printf '[package]\nname = "app822"\n' > ws/app/mcpp.toml +printf 'int main() { return 0; }\n' > ws/app/src/main.cpp +cd ws +if "$MCPP" build -p app > f4.log 2>&1; then fail "F4: the workspace floor did not reach the member" f4.log; fi +grep -q "package 'app822'.*requires mcpp >= 2999.1.1" f4.log || fail "F4: the member is not named with the inherited floor" f4.log + +# F5 +cd "$TMP/p" +printf '[package]\nname = "floor822"\nversion = "0.1.0"\n\n[lib]\npth = "src/x.cppm"\n' > mcpp.toml +"$MCPP" build > f5.log 2>&1 || fail "F5: the build failed" f5.log +grep -q "\[lib\] has unsupported key 'pth' (ignored). Supported keys: path." f5.log || fail "F5: [lib] did not name the unknown key" f5.log + +echo "OK" From d77d523c4e24f82d246acbddfc0c5d43ccba7163 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:03:20 +0800 Subject: [PATCH 02/18] build program: mcpp.core names the engine interface, and mcpp stays its permanent equivalent (#734 E8) --- src/build/build_program.cppm | 5 +- src/build/hostprogram.cppm | 57 +++++++++++++++++++ ...3_mcpp_core_and_mcpp_name_one_interface.sh | 36 ++++++++++++ 3 files changed, 97 insertions(+), 1 deletion(-) create mode 100755 tests/e2e/823_mcpp_core_and_mcpp_name_one_interface.sh diff --git a/src/build/build_program.cppm b/src/build/build_program.cppm index 7be7e405..807f09cb 100644 --- a/src/build/build_program.cppm +++ b/src/build/build_program.cppm @@ -1092,7 +1092,7 @@ std::expected run_build_program( // import a module that is present as a prerequisite and not importable // here, which is the case the check above states. { - std::set available{"std", "std.compat", "mcpp"}; + std::set available{"std", "std.compat", "mcpp", "mcpp.core"}; for (auto const& hm : env.hostModules) if (hm.importable) available.insert(hm.logical); for (auto const& want : mcpp::pm::imported_module_names(srcText)) { @@ -1271,12 +1271,14 @@ std::expected run_build_program( // because build.mcpp grew a second implementation of it. std::vector moduleFlags; fs::path mcppModuleObject; + fs::path mcppCoreObject; // `mcpp.core`, the same interface (#734, E8) if (usesModule) { auto mf = build_mcpp_module(bdir, hostCompiler, base, std_flag, tc, compileEnv); if (!mf) return std::unexpected(mf.error()); moduleFlags = std::move(mf->useFlags); mcppModuleObject = std::move(mf->object); + mcppCoreObject = std::move(mf->aliasObject); } // ── `import std;` in build.mcpp ───────────────────────────────────────── @@ -1461,6 +1463,7 @@ std::expected run_build_program( // answered with `D9002: ignoring unknown option '-x'`. if (!msvcHost) { compileArgv.push_back("-x"); compileArgv.push_back("none"); } if (usesModule) compileArgv.push_back(mcppModuleObject.string()); + if (usesModule && !mcppCoreObject.empty()) compileArgv.push_back(mcppCoreObject.string()); for (auto& hmo : hostModuleObjects) compileArgv.push_back(hmo.string()); for (auto& so : stdObjects) compileArgv.push_back(so); } diff --git a/src/build/hostprogram.cppm b/src/build/hostprogram.cppm index f17f7e24..d0484e7f 100644 --- a/src/build/hostprogram.cppm +++ b/src/build/hostprogram.cppm @@ -829,8 +829,19 @@ bool imports_module(std::string_view src, std::string_view name) { struct McppModule { std::vector useFlags; // how the consumer names the BMI fs::path object; // linked alongside build.mcpp + // `mcpp.core` (#734, E8): the same interface under the layer's name, a unit + // whose whole body re-exports `mcpp`. Empty for a host module. + fs::path aliasObject; }; +// The unit that makes `import mcpp.core;` and `import mcpp;` name one +// interface. Written beside `mcpp.cppm` and compiled after it, so either +// spelling -- or both in one program -- reaches the same symbols. Placeholders +// for the keywords, for the reason `kMcppModuleSource` gives: mcpp's own +// line-based scanner must not read this literal as a second module of this file. +inline constexpr std::string_view kMcppCoreAliasSource = + "@MODULE@ mcpp.core;\n@EXPORT@ import mcpp;\n"; + // Compile ONE dependency-provided module interface for the host, into `bdir`, // with the SAME flags build.mcpp itself gets. Returns how to name its BMI plus // the object to link. @@ -870,6 +881,16 @@ build_mcpp_module(const fs::path& bdir, const fs::path& compiler, { std::ofstream os(cppm, std::ios::trunc); os << moduleSrc; if (!os) return std::unexpected(std::string("could not write mcpp module source")); } + { + std::string aliasSrc(kMcppCoreAliasSource); + if (auto p = aliasSrc.find("@MODULE@"); p != std::string::npos) + aliasSrc.replace(p, std::string_view("@MODULE@").size(), "export module"); + if (auto p = aliasSrc.find("@EXPORT@"); p != std::string::npos) + aliasSrc.replace(p, std::string_view("@EXPORT@").size(), "export"); + std::ofstream os(bdir / "mcpp_core.cppm", std::ios::trunc); + os << aliasSrc; + if (!os) return std::unexpected(std::string("could not write the mcpp.core unit")); + } auto run = [&](std::vector argv, const char* what) -> std::expected { @@ -920,6 +941,22 @@ build_mcpp_module(const fs::path& bdir, const fs::path& compiler, if (auto r = run(with_base(std::move(argv)), "compile"); !r) return std::unexpected(r.error()); out.useFlags = mcpp::toolchain::bmi_reference_tokens(" /reference mcpp=", ifc); + fs::path coreIfc = bdir / ("mcpp.core" + std::string(traits.bmiExt)); + out.aliasObject = bdir / ("mcpp_core" + std::string(dial.objExt)); + std::vector av{compiler.string()}; + for (auto f : dial.alwaysFlagsArgv) av.emplace_back(f); + av.push_back(stdFlag); + av.push_back("/interface"); + for (auto f : dial.forceCxxLangArgv) av.emplace_back(f); + av.push_back(dial.compileOnly == std::string_view("/c") ? "/c" : "-c"); + av.push_back("mcpp_core.cppm"); + av.push_back("/ifcOutput"); av.push_back(coreIfc.string()); + av.push_back(std::string(dial.outputObjPrefix) + out.aliasObject.string()); + for (auto& f : out.useFlags) av.push_back(f); + if (auto r = run(with_base(std::move(av)), "mcpp.core compile"); !r) + return std::unexpected(r.error()); + for (auto& f : mcpp::toolchain::bmi_reference_tokens(" /reference mcpp.core=", coreIfc)) + out.useFlags.push_back(f); return out; } @@ -933,6 +970,20 @@ build_mcpp_module(const fs::path& bdir, const fs::path& compiler, pcm.string(), "-o", out.object.string()}), "object"); !r) return std::unexpected(r.error()); out.useFlags = mcpp::toolchain::bmi_reference_tokens("-fmodule-file=mcpp=", pcm); + fs::path corePcm = bdir / ("mcpp.core" + std::string(traits.bmiExt)); + out.aliasObject = bdir / ("mcpp_core" + std::string(dial.objExt)); + std::vector pre{compiler.string(), stdFlag, "--precompile", + "mcpp_core.cppm", "-o", corePcm.string()}; + for (auto& f : out.useFlags) pre.push_back(f); + if (auto r = run(with_base(std::move(pre)), "mcpp.core precompile"); !r) + return std::unexpected(r.error()); + std::vector obj{compiler.string(), stdFlag, "-c", + corePcm.string(), "-o", out.aliasObject.string()}; + for (auto& f : out.useFlags) obj.push_back(f); + if (auto r = run(with_base(std::move(obj)), "mcpp.core object"); !r) + return std::unexpected(r.error()); + for (auto& f : mcpp::toolchain::bmi_reference_tokens("-fmodule-file=mcpp.core=", corePcm)) + out.useFlags.push_back(f); return out; } @@ -943,6 +994,12 @@ build_mcpp_module(const fs::path& bdir, const fs::path& compiler, "-c", "mcpp.cppm", "-o", out.object.string()}), "compile"); !r) return std::unexpected(r.error()); out.useFlags = {"-fmodules"}; + // GCC finds both BMIs under /gcm.cache; the alias only has to exist. + out.aliasObject = bdir / ("mcpp_core" + std::string(dial.objExt)); + if (auto r = run(with_base({compiler.string(), stdFlag, "-fmodules", + "-c", "mcpp_core.cppm", "-o", out.aliasObject.string()}), + "mcpp.core compile"); !r) + return std::unexpected(r.error()); return out; } diff --git a/tests/e2e/823_mcpp_core_and_mcpp_name_one_interface.sh b/tests/e2e/823_mcpp_core_and_mcpp_name_one_interface.sh new file mode 100755 index 00000000..98f51bd5 --- /dev/null +++ b/tests/e2e/823_mcpp_core_and_mcpp_name_one_interface.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# requires: unix-shell +# 823_mcpp_core_and_mcpp_name_one_interface.sh -- #734 E8. +# +# The engine's build-program interface is named `mcpp.core`, the layer name the +# specification uses (SPEC-007), and `mcpp` is its permanent equivalent: the +# engine embeds `mcpp` and a second unit whose body is `export import mcpp;`. +# A build program may use either spelling, or both. +# +# C1 `import mcpp.core;` builds and the program's directive takes effect; +# C2 `import mcpp;` behaves the same; +# C3 a program importing both compiles and behaves the same. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } +MCPP="${MCPP:-mcpp}" + +leg() { # $1 = label, $2 = the import lines + rm -rf "$TMP/$1"; mkdir -p "$TMP/$1/src"; cd "$TMP/$1" + printf '[package]\nname = "core823"\nversion = "0.1.0"\n' > mcpp.toml + printf '#include \nint main() { std::printf("%%d\\n", CORE823); return 0; }\n' > src/main.cpp + printf '%s\nint main() {\n mcpp::cxxflag("-DCORE823=823");\n return 0;\n}\n' "$2" > build.mcpp + "$MCPP" build > build.log 2>&1 || fail "$1: the build failed" build.log build.mcpp + "$MCPP" run > run.log 2>&1 || fail "$1: the program did not run" run.log + grep -qx "823" run.log || fail "$1: the directive did not take effect" run.log +} + +leg C1 'import mcpp.core;' +leg C2 'import mcpp;' +leg C3 'import mcpp.core; +import mcpp;' + +echo "OK" From 065e4e7e99435dd195701d006bffed6dfada7b4b Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:14:56 +0800 Subject: [PATCH 03/18] build program: the resolved toolchain as build information -- tools, the ABI's tools, their environment, identity, instance, ninja, the runtime contract (#734 E2, protocol 14) --- modules/buildmcpp/src/program_protocol.cppm | 9 +- src/build/build_program.cppm | 24 ++++ src/build/flags.cppm | 65 +++++++-- src/build/hostprogram.cppm | 42 ++++++ src/build/ninja_backend.cppm | 22 +-- src/build/prepare/toolchain_env.cpp | 130 ++++++++++++++++++ ...ld_program_reads_the_resolved_toolchain.sh | 81 +++++++++++ ..._a_build_program_reads_the_msvc_toolset.sh | 63 +++++++++ tests/unit/test_build_directives.cpp | 8 +- 9 files changed, 422 insertions(+), 22 deletions(-) create mode 100755 tests/e2e/824_a_build_program_reads_the_resolved_toolchain.sh create mode 100755 tests/e2e/825_a_build_program_reads_the_msvc_toolset.sh diff --git a/modules/buildmcpp/src/program_protocol.cppm b/modules/buildmcpp/src/program_protocol.cppm index a4a7a9c8..92bdd5fe 100644 --- a/modules/buildmcpp/src/program_protocol.cppm +++ b/modules/buildmcpp/src/program_protocol.cppm @@ -104,7 +104,14 @@ export namespace mcpp::build::program_protocol { // it did under v12 and no cached entry changes meaning. Same cost as v5's: a // package calling `env()` fails on an older engine at the build.mcpp COMPILE, // because that engine's bundled module has no such method. -inline constexpr int kProtocolVersion = 13; +// v14 (mcpp#734): the interface is also named `mcpp.core` (the module `mcpp` +// stays its permanent equivalent), and it states the resolved toolchain's +// build information -- `tool`, `abi_tool`, `tool_env`, `toolset_identity`, +// `msvc_instance_dir`, `ninja_program`, `cxx_runtime`, `msvc_crt_linkage` -- +// together with the batched placement and structured diagnostics of the same +// release. No directive of v13 changes spelling, so a program that uses none of +// these serialises to the bytes it did under v13. +inline constexpr int kProtocolVersion = 14; // ── Cache-format epoch ───────────────────────────────────────────────────── // diff --git a/src/build/build_program.cppm b/src/build/build_program.cppm index 807f09cb..639a0ad2 100644 --- a/src/build/build_program.cppm +++ b/src/build/build_program.cppm @@ -39,6 +39,19 @@ export namespace mcpp::build { // see, mirroring Cargo's env family. Injected as MCPP_* variables into the // child ONLY (never the calling process), and folded into the cache key so a // target/profile/feature change re-runs the program. +// #734 E2: the build information a build program reads through +// `mcpp::tool`, `mcpp::abi_tool`, `mcpp::tool_env`, `mcpp::toolset_identity`, +// `mcpp::msvc_instance_dir`, `mcpp::ninja_program`, `mcpp::cxx_runtime` and +// `mcpp::msvc_crt_linkage`. The roles are cc, cxx, ld, ar, rc, as, mt. +inline constexpr std::string_view kBuildInformationKeys[] = { + "MCPP_TOOL_CC", "MCPP_TOOL_CXX", "MCPP_TOOL_LD", "MCPP_TOOL_AR", + "MCPP_TOOL_RC", "MCPP_TOOL_AS", "MCPP_TOOL_MT", + "MCPP_ABI_TOOL_CC", "MCPP_ABI_TOOL_CXX", "MCPP_ABI_TOOL_LD", "MCPP_ABI_TOOL_AR", + "MCPP_ABI_TOOL_RC", "MCPP_ABI_TOOL_AS", "MCPP_ABI_TOOL_MT", + "MCPP_TOOL_ENV", "MCPP_TOOLSET_IDENTITY", "MCPP_MSVC_INSTANCE_DIR", + "MCPP_NINJA", "MCPP_CXX_RUNTIME", "MCPP_MSVC_CRT_LINKAGE", +}; + struct BuildProgramEnv { std::string targetTriple; // resolved canonical triple; "" = host // The resolved toolchain's payload root and the target's own C library @@ -69,6 +82,11 @@ struct BuildProgramEnv { // emits — so the answer belongs to the engine and is stated once here. std::string toolchainSysroot; std::string toolchainBinutilsDir; + // #734 E2: the build information of the resolved toolchain, keyed by the + // variable names of `kBuildInformationKeys`. Filled by prepare from the + // same producers the engine's own command lines read; a key absent here is + // emitted empty. + std::map buildInfo; // WHICH COMPILER RESOLVED — "gcc" | "clang" | "msvc" | "". // // A package should never have to guess this, and until this field existed @@ -613,6 +631,12 @@ contract_env(const fs::path& root, const fs::path& outDir, const BuildProgramEnv e.emplace_back("MCPP_COMPILER", shared_value("MCPP_COMPILER")); e.emplace_back("MCPP_CXX_STDLIB", shared_value("MCPP_CXX_STDLIB")); e.emplace_back("MCPP_TARGET_SYSROOT", env.targetSysroot); + // #734 E2. Every key always, empty when it does not apply, for the reason + // the lines above give. + for (auto key : kBuildInformationKeys) { + auto it = env.buildInfo.find(std::string(key)); + e.emplace_back(std::string(key), it == env.buildInfo.end() ? std::string{} : it->second); + } e.emplace_back("MCPP_TARGET_BUILTINS_LIB", env.targetBuiltinsLib); e.emplace_back("MCPP_TARGET_LIBC_PROFILE", env.targetLibcProfile); e.emplace_back("MCPP_TARGET_LIBC", env.targetLibc); diff --git a/src/build/flags.cppm b/src/build/flags.cppm index cf9ddb21..6f07d44d 100644 --- a/src/build/flags.cppm +++ b/src/build/flags.cppm @@ -167,6 +167,24 @@ std::string render_link_intent_flags( CompileFlags compute_flags(const BuildPlan& plan); +// The object format the TARGET produces (#647 E3). One derivation, read by +// `compute_flags` for the runtime contract table and the link-line shape, and +// by the build information a build program receives (#734 E2). +dist::Format target_object_format(const mcpp::toolchain::Toolchain& tc); + +// #734 E2: the C++ runtime contract of the package's programs as the manifest +// states it or its default gives it, spelled as `cxx_runtime` is written; and +// on the MSVC ABI the CRT linkage that contract compiles with ("static" or +// "dynamic"; empty off that ABI). Read by the build information before any +// plan exists, from the same producers `compute_flags` reads, so a plugin that +// builds foreign code for the program agrees with it. The one input a plan +// adds -- whether an ELF program loads a C++ shared library of this build -- +// does not change the MSVC answer and is not known here. +std::string program_cxx_runtime(const mcpp::manifest::Manifest& m, + const mcpp::toolchain::Toolchain& tc); +std::string program_msvc_crt_linkage(const mcpp::manifest::Manifest& m, + const mcpp::toolchain::Toolchain& tc); + // THE OPTIMIZATION LEVEL A BUILD REALISES, STATED ONCE (#694). // // `compute_flags` spells it and the `Finished` line names it. There used to be @@ -592,6 +610,44 @@ std::filesystem::path graph_link_sysroot(const std::filesystem::path& outputDir) return outputDir / kGraphLinkSysrootDir; } +dist::Format target_object_format(const mcpp::toolchain::Toolchain& tc) { + return mcpp::toolchain::is_mingw_target(tc) + ? dist::Format::Pe + : dist::format_for(tc.targetTriple, + mcpp::platform::needs_explicit_libcxx ? dist::Format::MachO + : mcpp::platform::is_windows ? dist::Format::Pe + : dist::Format::Elf); +} + +std::string program_cxx_runtime(const mcpp::manifest::Manifest& m, + const mcpp::toolchain::Toolchain& tc) { + const auto& bc = m.buildConfig; + const std::optional msvcAbiDefault = + mcpp::toolchain::is_msvc_target(tc) + ? std::optional(dist::msvc_abi_default_contract( + mcpp::toolchain::msvc_wants_static_crt(bc.linkage, bc.cxxRuntime), + !tc.msvcRedistDir.empty())) + : std::nullopt; + const auto contracts = dist::role_contracts( + dist::ContractStatement{ + .cxxRuntime = bc.cxxRuntime, + .cxxRuntimeTests = bc.cxxRuntimeTests, + .cxxRuntimeShared = bc.cxxRuntimeShared, + .staticStdlib = bc.staticStdlib, + .msvcAbiDefault = msvcAbiDefault, + }, + target_object_format(tc), dist::CxxSharedLoad{}); + return std::string(dist::to_string(contracts.program)); +} + +std::string program_msvc_crt_linkage(const mcpp::manifest::Manifest& m, + const mcpp::toolchain::Toolchain& tc) { + if (!mcpp::toolchain::is_msvc_target(tc)) return {}; + return mcpp::toolchain::msvc_wants_static_crt(m.buildConfig.linkage, + m.buildConfig.cxxRuntime) + ? "static" : "dynamic"; +} + CompileFlags compute_flags(const BuildPlan& plan) { CompileFlags f; @@ -943,14 +999,7 @@ CompileFlags compute_flags(const BuildPlan& plan) { // table and the link-line shape both read it (#647 E3). Target-keyed, with // the host's format only as the fallback for a triple that names none; a // MinGW toolchain is a PE whatever its triple spelling says. - const mcpp::build::dist::Format targetObjectFormat = - isMingwTc ? mcpp::build::dist::Format::Pe - : mcpp::build::dist::format_for(plan.toolchain.targetTriple, - mcpp::platform::needs_explicit_libcxx - ? mcpp::build::dist::Format::MachO - : mcpp::platform::is_windows - ? mcpp::build::dist::Format::Pe - : mcpp::build::dist::Format::Elf); + const mcpp::build::dist::Format targetObjectFormat = target_object_format(plan.toolchain); const auto linkIntentFlavor = [&] { if (isMingwTc) return LinkIntentFlavor::PeGnu; if (isMsvcDialect) return LinkIntentFlavor::PeMsvc; diff --git a/src/build/hostprogram.cppm b/src/build/hostprogram.cppm index d0484e7f..059b5248 100644 --- a/src/build/hostprogram.cppm +++ b/src/build/hostprogram.cppm @@ -521,6 +521,48 @@ inline const char* cxx_stdlib() { return env_or("MCPP_CXX_STDL // through the runtime binding, and nothing has to look for it. inline const char* sysroot_dir() { return env_or("MCPP_TARGET_SYSROOT"); } +// ── The build information of the resolved toolchain (#734 E2, protocol 14) ── +// +// Facts, never interpretations: which tools this row runs, which tools are the +// target ABI's own, the environment the engine runs them with, and the C++ +// runtime contract the program compiles with. A plugin that drives CMake, +// vcpkg, Meson or make translates them for that system; mcpp knows none of +// them. Every value is empty when it does not apply, and a role is one of +// "cc", "cxx", "ld", "ar", "rc", "as", "mt". +inline const char* build_info_key_(const char* prefix, const char* role) { + static char name[64]; + int n = 0; + for (const char* p = prefix; *p && n < 48; ++p) name[n++] = *p; + for (const char* p = role; *p && n < 63; ++p) + name[n++] = (*p >= 'a' && *p <= 'z') ? static_cast(*p - 'a' + 'A') : *p; + name[n] = '\0'; + return name; +} +// The row's tool for a role: the driver on GNU-style rows (it links and +// assembles), the toolset's own tools on the cl.exe row. +inline const char* tool(const char* role) { return env_or(build_info_key_("MCPP_TOOL_", role)); } +// The target ABI's native tool: on the MSVC ABI `cl`, `link`, `lib`, `rc`, +// `ml64` and `mt` of the resolved toolset and SDK, whichever driver the row +// uses; elsewhere the same as `tool(role)`. +inline const char* abi_tool(const char* role) { return env_or(build_info_key_("MCPP_ABI_TOOL_", role)); } +// The environment the engine runs the ABI's tools with, one KEY=value per +// line (INCLUDE, LIB, PATH, ... on the MSVC ABI); empty elsewhere. +inline const char* tool_env() { return env_or("MCPP_TOOL_ENV"); } +// A path-free identity of the toolset, for keying a cache by version: +// "msvc 14.44.35207; sdk 10.0.26100.0", "clang 22.1.8", "gcc 16.1.0". +inline const char* toolset_identity() { return env_or("MCPP_TOOLSET_IDENTITY"); } +// The Visual Studio instance the MSVC toolset belongs to, when it came from +// one; empty for a managed toolset and off the MSVC ABI. +inline const char* msvc_instance_dir() { return env_or("MCPP_MSVC_INSTANCE_DIR"); } +// The ninja mcpp itself runs, so a foreign build system needs none on PATH. +inline const char* ninja_program() { return env_or("MCPP_NINJA"); } +// The program's C++ runtime contract: "self-contained", "toolchain-coupled" +// or "host-coupled". +inline const char* cxx_runtime() { return env_or("MCPP_CXX_RUNTIME"); } +// On the MSVC ABI the CRT the program compiles with, "static" (/MT) or +// "dynamic" (/MD); empty elsewhere. The value `place-dlls --crt` reads. +inline const char* msvc_crt_linkage() { return env_or("MCPP_MSVC_CRT_LINKAGE"); } + // ── Three answers a board-support package would otherwise hardcode ─────────── // // The coupling these remove does not appear in any manifest. A board package diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index e9e05ae1..d96b98a3 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -62,6 +62,11 @@ public: // Factory for this backend implementation. std::unique_ptr make_ninja_backend(); +// The ninja mcpp runs for a toolchain: the sandbox-local ninja beside the +// toolchain when there is one, else `ninja` from PATH. One answer for the +// engine's own builds and for the build information (#734 E2). +std::string ninja_program_for(const mcpp::toolchain::Toolchain& tc); + // Helper exposed for testing / debugging std::string emit_ninja_string(const BuildPlan& plan); std::string filter_ninja_output(std::string_view output, @@ -3827,18 +3832,10 @@ std::expected NinjaBackend::build(const BuildPlan& plan // The compiler's internal `as`/`ld` lookup is handled via the // -B flag we emit into cxxflags/ldflags (see // emit_ninja_string). No PATH injection needed here. - std::filesystem::path ninjaBin; - auto ninja_name = std::string("ninja") + std::string(mcpp::platform::exe_suffix); - if (auto nb = mcpp::xlings::paths::find_sibling_binary( - plan.toolchain.binaryPath, "ninja", ninja_name)) { - ninjaBin = *nb; - } - // Raw program path (no shell quoting): recorded in the fast-path cache and // exec'd directly via capture_exec/execvp, which take argv (not a shell // string). Shell-using call sites must quote it locally. - std::string ninjaProgram = ninjaBin.empty() ? std::string("ninja") - : ninjaBin.string(); + std::string ninjaProgram = ninja_program_for(plan.toolchain); // THE BUILD FILE IN THE ENCODING NINJA READS IT IN (#693, M4). Ninja reads // UTF-8 when it declares the UTF-8 code page and the host honours it, and @@ -4119,6 +4116,13 @@ std::expected NinjaBackend::build(const BuildPlan& plan return r; } +std::string ninja_program_for(const mcpp::toolchain::Toolchain& tc) { + auto ninja_name = std::string("ninja") + std::string(mcpp::platform::exe_suffix); + if (auto nb = mcpp::xlings::paths::find_sibling_binary(tc.binaryPath, "ninja", ninja_name)) + return nb->string(); + return "ninja"; +} + std::unique_ptr make_ninja_backend() { return std::make_unique(); } diff --git a/src/build/prepare/toolchain_env.cpp b/src/build/prepare/toolchain_env.cpp index 4e1e5bf5..e558814c 100644 --- a/src/build/prepare/toolchain_env.cpp +++ b/src/build/prepare/toolchain_env.cpp @@ -39,6 +39,7 @@ import mcpp.build.backend; // BuildOptions for the tool sub-build import mcpp.build.ninja; // make_ninja_backend — driving that sub-build import mcpp.config; import mcpp.xlings; +import mcpp.build.resources; // the resource compiler the row uses (#734 E2) import mcpp.toolchain.post_install; import mcpp.platform; import mcpp.platform.macos; @@ -290,6 +291,134 @@ void fill_package_build_env(mcpp::build::BuildProgramEnv& e, } } +namespace { + +// #734 E2: the MSVC architecture directory name of a target triple. +std::string msvc_arch_of(std::string_view triple) { + if (triple.starts_with("aarch64") || triple.starts_with("arm64")) return "arm64"; + if (triple.starts_with("i686") || triple.starts_with("i386") || triple.starts_with("x86-")) return "x86"; + return "x64"; +} + +// The `bin/Host/` directory of an MSVC toolset that holds cl.exe, +// preferring the host's own architecture as the engine's toolset search does. +std::filesystem::path msvc_bin_dir(const std::filesystem::path& tools, std::string_view arch) { + std::error_code ec; + const bool armHost = mcpp::platform::host_arch == std::string_view("aarch64") + || mcpp::platform::host_arch == std::string_view("arm64"); + const std::array hosts = armHost + ? std::array{"Hostarm64", "Hostx64", "Hostx86"} + : std::array{"Hostx64", "Hostarm64", "Hostx86"}; + for (auto h : hosts) { + auto dir = tools / "bin" / std::string(h) / std::string(arch); + if (std::filesystem::exists(dir / "cl.exe", ec)) return dir; + } + return {}; +} + +// #734 E2: the build information of the resolved toolchain. Each value comes +// from the producer the engine's own command lines read: the C compiler from +// `derive_c_compiler`, the archiver from `archive_tool`, the resource compiler +// from `find_rc_tool`, the MSVC environment from `build_env_for_cl`, the ninja +// from `ninja_program_for`, the runtime contract from `program_cxx_runtime`. +std::map +build_information(const mcpp::manifest::Manifest& m, const mcpp::toolchain::Toolchain& tc) { + std::map info; + auto put = [&](std::string_view k, const std::filesystem::path& v) { + if (!v.empty()) info[std::string(k)] = v.string(); + }; + const bool msvcCompiler = tc.compiler == mcpp::toolchain::CompilerId::MSVC; + const bool msvcAbi = mcpp::toolchain::is_msvc_target(tc); + const auto& dial = mcpp::toolchain::dialect_for(tc); + + // The row's tools. + const auto cxx = tc.binaryPath; + const auto cc = mcpp::toolchain::derive_c_compiler(tc); + put("MCPP_TOOL_CXX", cxx); + put("MCPP_TOOL_CC", cc.empty() ? cxx : cc); + put("MCPP_TOOL_AR", mcpp::toolchain::archive_tool(tc)); + if (auto rc = mcpp::build::resources::find_rc_tool(tc, dial.id)) put("MCPP_TOOL_RC", rc->path); + + // The MSVC ABI's native tools, from the resolved toolset and its SDK. + std::filesystem::path clBin; + if (msvcAbi) { + const auto arch = msvc_arch_of(tc.targetTriple); + clBin = msvcCompiler ? tc.binaryPath.parent_path() + : (tc.msvcToolsDir.empty() ? std::filesystem::path{} + : msvc_bin_dir(tc.msvcToolsDir, arch)); + if (!clBin.empty()) { + const auto cl = clBin / "cl.exe"; + put("MCPP_ABI_TOOL_CXX", cl); + put("MCPP_ABI_TOOL_CC", cl); + put("MCPP_ABI_TOOL_LD", clBin / "link.exe"); + put("MCPP_ABI_TOOL_AR", clBin / "lib.exe"); + put("MCPP_ABI_TOOL_AS", clBin / (arch == "arm64" ? "armasm64.exe" + : arch == "x86" ? "ml.exe" : "ml64.exe")); + auto sdk = mcpp::toolchain::msvc::resolve_sdk_for(cl); + if (sdk.sdk) { + const auto sdkBin = sdk.sdk->root / "bin" / sdk.sdk->version / arch; + put("MCPP_ABI_TOOL_RC", sdkBin / "rc.exe"); + put("MCPP_ABI_TOOL_MT", sdkBin / "mt.exe"); + // The environment the engine itself runs this toolset with. + std::string env; + for (auto const& ev : msvcCompiler && !tc.envOverrides.empty() + ? tc.envOverrides + : mcpp::toolchain::msvc::build_env_for_cl(cl, arch, *sdk.sdk)) { + if (!env.empty()) env += '\n'; + env += ev.key + "=" + ev.value; + } + info["MCPP_TOOL_ENV"] = env; + info["MCPP_TOOLSET_IDENTITY"] = std::format( + "msvc {}; sdk {}", + tc.msvcToolsVersion.empty() ? clBin.parent_path().parent_path() + .parent_path().filename().string() + : tc.msvcToolsVersion, + sdk.sdk->version); + } + // A Visual Studio instance, when the toolset came from one: the + // tools directory is /VC/Tools/MSVC/. + const auto tools = clBin.parent_path().parent_path().parent_path(); + const auto vc = tools.parent_path().parent_path().parent_path(); + std::error_code ec; + if (tc.msvcOrigin != "managed" && vc.filename() == "VC" + && std::filesystem::exists(vc / "Auxiliary" / "Build", ec)) + put("MCPP_MSVC_INSTANCE_DIR", vc.parent_path()); + } + // On the cl.exe row the row's tools are the toolset's. + if (msvcCompiler) { + for (auto role : {"LD", "AS", "MT"}) { + auto it = info.find(std::string("MCPP_ABI_TOOL_") + role); + if (it != info.end()) info[std::string("MCPP_TOOL_") + role] = it->second; + } + if (auto it = info.find("MCPP_ABI_TOOL_AR"); it != info.end()) info["MCPP_TOOL_AR"] = it->second; + } else { + put("MCPP_TOOL_LD", cxx); + put("MCPP_TOOL_AS", cc.empty() ? cxx : cc); + if (auto it = info.find("MCPP_ABI_TOOL_MT"); it != info.end()) info["MCPP_TOOL_MT"] = it->second; + } + } else { + // Off the MSVC ABI the driver links and assembles, and the ABI's tools + // are the row's. + put("MCPP_TOOL_LD", cxx); + put("MCPP_TOOL_AS", cc.empty() ? cxx : cc); + for (auto role : {"CC", "CXX", "LD", "AR", "RC", "AS", "MT"}) { + auto it = info.find(std::string("MCPP_TOOL_") + role); + if (it != info.end()) info[std::string("MCPP_ABI_TOOL_") + role] = it->second; + } + const std::string_view family = + tc.compiler == mcpp::toolchain::CompilerId::GCC ? "gcc" + : tc.compiler == mcpp::toolchain::CompilerId::Clang ? "clang" : "unknown"; + info["MCPP_TOOLSET_IDENTITY"] = std::format("{} {}", family, tc.version); + } + + info["MCPP_NINJA"] = mcpp::build::ninja_program_for(tc); + info["MCPP_CXX_RUNTIME"] = mcpp::build::program_cxx_runtime(m, tc); + info["MCPP_MSVC_CRT_LINKAGE"] = mcpp::build::program_msvc_crt_linkage(m, tc); + return info; +} + +} // namespace + void fill_target_build_env(mcpp::build::BuildProgramEnv& e, const mcpp::manifest::Manifest& m, const mcpp::toolchain::Toolchain* tc, @@ -319,6 +448,7 @@ void fill_target_build_env(mcpp::build::BuildProgramEnv& e, // the resolver already measured. e.cxxStdlib = tc ? tc->stdlibId : std::string{}; if (!tc) return; + e.buildInfo = build_information(m, *tc); // The two flags mcpp passes to ITS OWN compiler, so a rule package driving // a second compiler passes the same two. Both read from the single diff --git a/tests/e2e/824_a_build_program_reads_the_resolved_toolchain.sh b/tests/e2e/824_a_build_program_reads_the_resolved_toolchain.sh new file mode 100755 index 00000000..b2345901 --- /dev/null +++ b/tests/e2e/824_a_build_program_reads_the_resolved_toolchain.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# requires: unix-shell +# 824_a_build_program_reads_the_resolved_toolchain.sh -- #734 E2 (protocol 14). +# +# A build program reads the resolved toolchain as facts: the row's tools, the +# target ABI's native tools, the environment the engine runs them with, a +# path-free identity, the ninja mcpp runs, and the program's C++ runtime +# contract. A plugin driving a foreign build system translates them; mcpp +# interprets nothing. On the MSVC ABI the Windows rows of CI read the toolset's +# `cl`, `link` and SDK tools and a non-empty environment; on this row: +# +# B1 `tool("cxx")` is the resolved compiler and `tool("cc")` its C driver, +# both existing files; `tool("ld")` is the driver; +# B2 `abi_tool(role)` equals `tool(role)` off the MSVC ABI; +# B3 `tool_env()`, `msvc_instance_dir()` and `msvc_crt_linkage()` are empty; +# B4 `toolset_identity()` names the family and version, and no path; +# B5 `ninja_program()` runs; +# B6 `cxx_runtime()` follows the manifest: the default, then a stated +# `host-coupled`. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } +MCPP="${MCPP:-mcpp}" + +mkdir -p p/src +cd p +printf 'int main() { return 0; }\n' > src/main.cpp +cat > build.mcpp <<'EOF' +import std; +import mcpp.core; +int main() { + std::ofstream out(std::string(mcpp::out_dir()) + "/info.txt"); + for (const char* r : {"cc", "cxx", "ld", "ar", "rc", "as", "mt"}) + out << "tool." << r << "=" << mcpp::tool(r) << "\n" + << "abi." << r << "=" << mcpp::abi_tool(r) << "\n"; + out << "env=" << mcpp::tool_env() << "\n" + << "identity=" << mcpp::toolset_identity() << "\n" + << "instance=" << mcpp::msvc_instance_dir() << "\n" + << "ninja=" << mcpp::ninja_program() << "\n" + << "runtime=" << mcpp::cxx_runtime() << "\n" + << "crt=" << mcpp::msvc_crt_linkage() << "\n"; + return 0; +} +EOF +printf '[package]\nname = "info824"\nversion = "0.1.0"\n' > mcpp.toml +"$MCPP" build > b1.log 2>&1 || fail "the build failed" b1.log +INFO=$(find target -name info.txt | head -1) +test -s "$INFO" || fail "the build program wrote no info.txt" b1.log +val() { grep "^$1=" "$INFO" | head -1 | cut -d= -f2-; } + +# B1 +for r in cxx cc; do + [ -x "$(val tool.$r)" ] || fail "B1: tool($r) is not an executable file" "$INFO" +done +[ "$(val tool.ld)" = "$(val tool.cxx)" ] || fail "B1: tool(ld) is not the driver" "$INFO" +# B2 +for r in cc cxx ld ar rc as mt; do + [ "$(val abi.$r)" = "$(val tool.$r)" ] || fail "B2: abi_tool($r) differs from tool($r) off the MSVC ABI" "$INFO" +done +# B3 +for k in env instance crt; do + [ -z "$(val $k)" ] || fail "B3: $k is not empty off the MSVC ABI" "$INFO" +done +# B4 +id=$(val identity) +case "$id" in clang\ [0-9]*|gcc\ [0-9]*) ;; *) fail "B4: identity '$id' is not ' '" "$INFO" ;; esac +case "$id" in */*) fail "B4: identity '$id' holds a path" "$INFO" ;; esac +# B5 +"$(val ninja)" --version > /dev/null 2>&1 || fail "B5: ninja_program() does not run" "$INFO" +# B6 +[ "$(val runtime)" = "self-contained" ] || fail "B6: the default contract is '$(val runtime)'" "$INFO" +printf '[package]\nname = "info824"\nversion = "0.1.0"\n\n[build]\ncxx_runtime = "host-coupled"\n' > mcpp.toml +"$MCPP" build > b2.log 2>&1 || fail "the second build failed" b2.log +INFO=$(find target -name info.txt -newer b1.log | head -1) +[ -n "$INFO" ] || INFO=$(find target -name info.txt | head -1) +[ "$(val runtime)" = "host-coupled" ] || fail "B6: the stated contract is '$(val runtime)'" "$INFO" + +echo "OK" diff --git a/tests/e2e/825_a_build_program_reads_the_msvc_toolset.sh b/tests/e2e/825_a_build_program_reads_the_msvc_toolset.sh new file mode 100755 index 00000000..2cd8acb1 --- /dev/null +++ b/tests/e2e/825_a_build_program_reads_the_msvc_toolset.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# requires: windows +# 825_a_build_program_reads_the_msvc_toolset.sh -- #734 E2 on the MSVC ABI. +# +# On the MSVC ABI a build program reads the resolved toolset as facts, whether +# the row's driver is cl.exe or clang++: the toolset's `cl`, `link` and `lib`, +# the SDK's `rc` and `mt`, the environment the engine runs them with, a +# path-free identity, and the CRT linkage the program compiles with. +# +# W1 `abi_tool("cxx")` is an existing cl.exe, `abi_tool("ld")` a link.exe; +# W2 `abi_tool("rc")` and `abi_tool("mt")` are existing SDK tools; +# W3 `tool_env()` carries INCLUDE and LIB; +# W4 `toolset_identity()` reads "msvc ; sdk "; +# W5 `msvc_crt_linkage()` is "dynamic" by default and "static" under +# `cxx_runtime = "self-contained"`. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } +MCPP="${MCPP:-mcpp}" + +mkdir -p p/src +cd p +printf 'int main() { return 0; }\n' > src/main.cpp +cat > build.mcpp <<'EOF' +import std; +import mcpp.core; +int main() { + std::ofstream out(std::string(mcpp::out_dir()) + "/info.txt"); + for (const char* r : {"cxx", "ld", "ar", "rc", "mt"}) + out << "abi." << r << "=" << mcpp::abi_tool(r) << "\n"; + std::string env = mcpp::tool_env(); + out << "include=" << (env.find("INCLUDE=") != std::string::npos ? "yes" : "no") << "\n" + << "lib=" << (env.find("\nLIB=") != std::string::npos || env.starts_with("LIB=") ? "yes" : "no") << "\n" + << "identity=" << mcpp::toolset_identity() << "\n" + << "crt=" << mcpp::msvc_crt_linkage() << "\n"; + return 0; +} +EOF +printf '[package]\nname = "msvc825"\nversion = "0.1.0"\n' > mcpp.toml +"$MCPP" build > b1.log 2>&1 || fail "the build failed" b1.log +INFO=$(find target -name info.txt | head -1) +test -s "$INFO" || fail "the build program wrote no info.txt" b1.log +val() { grep "^$1=" "$INFO" | head -1 | cut -d= -f2- | tr -d '\r'; } +exists() { [ -f "$(cygpath -u "$1" 2>/dev/null || echo "$1")" ]; } + +case "$(val abi.cxx)" in *[Cc][Ll].exe) ;; *) fail "W1: abi_tool(cxx) is '$(val abi.cxx)'" "$INFO" ;; esac +exists "$(val abi.cxx)" || fail "W1: abi_tool(cxx) does not exist" "$INFO" +case "$(val abi.ld)" in *[Ll]ink.exe) ;; *) fail "W1: abi_tool(ld) is '$(val abi.ld)'" "$INFO" ;; esac +for r in rc mt; do exists "$(val abi.$r)" || fail "W2: abi_tool($r) does not exist" "$INFO"; done +[ "$(val include)" = yes ] || fail "W3: tool_env() has no INCLUDE" "$INFO" +[ "$(val lib)" = yes ] || fail "W3: tool_env() has no LIB" "$INFO" +case "$(val identity)" in msvc\ *\;\ sdk\ *) ;; *) fail "W4: identity is '$(val identity)'" "$INFO" ;; esac +[ "$(val crt)" = dynamic ] || fail "W5: the default CRT linkage is '$(val crt)'" "$INFO" + +printf '[package]\nname = "msvc825"\nversion = "0.1.0"\n\n[build]\ncxx_runtime = "self-contained"\n' > mcpp.toml +"$MCPP" build > b2.log 2>&1 || fail "the self-contained build failed" b2.log +INFO=$(find target -name info.txt | head -1) +[ "$(val crt)" = static ] || fail "W5: the self-contained CRT linkage is '$(val crt)'" "$INFO" + +echo "OK" diff --git a/tests/unit/test_build_directives.cpp b/tests/unit/test_build_directives.cpp index be6d0c5d..33941d70 100644 --- a/tests/unit/test_build_directives.cpp +++ b/tests/unit/test_build_directives.cpp @@ -1062,13 +1062,13 @@ TEST(BuildDirectives, DeployRowIsProtocolElevenWithLinkGlobalScopeAndATag) { EXPECT_EQ(def->scope, dirs::Scope::LinkGlobal); EXPECT_EQ(def->sinceProtocol, 11); EXPECT_FALSE(def->tag.empty()); - EXPECT_EQ(dirs::kProtocolVersion, 13); + EXPECT_EQ(dirs::kProtocolVersion, 14); } -TEST(BuildDirectives, ProtocolThirteenIsAcceptedAndFourteenIsNot) { - auto ok = parse("mcpp:protocol=13\n"); +TEST(BuildDirectives, ProtocolFourteenIsAcceptedAndFifteenIsNot) { + auto ok = parse("mcpp:protocol=14\n"); EXPECT_FALSE(dirs::protocol_error(ok).has_value()); - auto no = parse("mcpp:protocol=14\n"); + auto no = parse("mcpp:protocol=15\n"); EXPECT_TRUE(dirs::protocol_error(no).has_value()); } From 4fc54eac08e4d3495aa45f1b83410557c5cd2993 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:21:26 +0800 Subject: [PATCH 04/18] build program: structured diagnostics, the feature that provides a missing module, and the plugin naming rule under mcpp. (#734 E11, E7, E10) --- modules/buildmcpp/src/directives.cppm | 38 +++++++- modules/buildmcpp/src/provisions.cppm | 42 +++++++-- src/build/build_program.cppm | 62 +++++++++++++ src/build/hostprogram.cppm | 23 +++++ src/build/prepare/features.cpp | 24 +++++ src/build/prepare/state.cppm | 3 + src/build/prepare/target_side.cpp | 3 + src/diag.cppm | 13 +++ ...6_plugin_diagnostics_features_and_names.sh | 90 +++++++++++++++++++ tests/unit/test_provisions.cpp | 28 ++++++ 10 files changed, 319 insertions(+), 7 deletions(-) create mode 100755 tests/e2e/826_plugin_diagnostics_features_and_names.sh diff --git a/modules/buildmcpp/src/directives.cppm b/modules/buildmcpp/src/directives.cppm index 04aace31..cfa60adf 100644 --- a/modules/buildmcpp/src/directives.cppm +++ b/modules/buildmcpp/src/directives.cppm @@ -181,6 +181,12 @@ enum class Slot : std::size_t { // second path to keep in sync. The directory need not exist when the // program runs: a `prepare` action may populate it later, at build time. RuntimeSearchDir, + // A STRUCTURED DIAGNOSTIC (#734 E11), as `\t\t\t`. + // The engine renders it through its own diagnostics model, so a plugin's + // message has the engine's form (impact and hint lines, the JSON record, + // `--strict` for a degradation) and is replayed on a cache hit as a + // warning is. + Diagnostics, Count }; inline constexpr std::size_t kSlotCount = static_cast(Slot::Count); @@ -279,7 +285,7 @@ struct Def { int sinceProtocol; }; -inline constexpr std::array kTable{{ +inline constexpr std::array kTable{{ // wire tag slot scope transform must missingPrefix missingSuffix since {"cxxflag", "cxxflag", Slot::CxxFlags, Scope::PackagePrivate, Transform::Verbatim, false, "", "", 1}, {"cflag", "cflag", Slot::CFlags, Scope::PackagePrivate, Transform::Verbatim, false, "", "", 1}, @@ -393,6 +399,7 @@ inline constexpr std::array kTable{{ // direction is already safe: an older engine reading a newer entry hits // the unknown-tag path and discards the whole record. {"warning", "warning", Slot::Warnings, Scope::Advisory, Transform::Verbatim, false, "", "", 5}, + {"diagnostic", "diagnostic", Slot::Diagnostics, Scope::Advisory, Transform::Verbatim, false, "", "", 14}, {"action", "action", Slot::Actions, Scope::GraphNode, Transform::Verbatim, false, "", "", 1}, // The probe channel: a rule package measures, the engine compares. See // Slot::Facts for the shape of each value. @@ -536,6 +543,17 @@ std::optional encoding_error(const Directives& d); // it reliably — in a workspace it would have to know which member it is. std::vector advisories(std::string_view packageName, const Directives& d); +// #734 E11: the structured diagnostics a build program stated, parsed from +// their `\t\t\t` wire form. The severity is +// one of "note", "warning", "degraded"; anything else reads as "warning". +struct StatedDiagnostic { + std::string severity; + std::string message; + std::string impact; + std::string hint; +}; +std::vector stated_diagnostics(const Directives& d); + // ── Cache serialization ──────────────────────────────────────────────────── // `d ` lines, in table order. @@ -932,6 +950,24 @@ std::string glob_fingerprint(const std::filesystem::path& root, return mcpp::toolchain::hash_string(joined); } +std::vector stated_diagnostics(const Directives& d) { + std::vector out; + for (auto const& raw : d.at(Slot::Diagnostics)) { + std::array f; + std::size_t i = 0, start = 0; + for (std::size_t k = 0; k <= raw.size() && i < f.size(); ++k) { + if (k == raw.size() || (raw[k] == '\t' && i + 1 < f.size())) { + f[i++] = raw.substr(start, k - start); + start = k + 1; + } + } + StatedDiagnostic sd{f[0], f[1], f[2], f[3]}; + if (sd.severity != "note" && sd.severity != "degraded") sd.severity = "warning"; + out.push_back(std::move(sd)); + } + return out; +} + std::vector advisories(std::string_view packageName, const Directives& d) { std::vector out; for (auto const& w : d.at(Slot::Warnings)) { diff --git a/modules/buildmcpp/src/provisions.cppm b/modules/buildmcpp/src/provisions.cppm index 0b5bf75e..1596f3b9 100644 --- a/modules/buildmcpp/src/provisions.cppm +++ b/modules/buildmcpp/src/provisions.cppm @@ -483,11 +483,22 @@ host_module_collision(const std::vector& mods) // out that impression is a supply-chain statement. It is a WARNING rather than // an error because the engine cannot decide who is official: a path // dependency, a private mirror and an internal fork are all legitimate and all -// indistinguishable from here. The message names both halves — the module name -// and the package identity — because exactly one of them is the surprising one +// indistinguishable from here. The message names both halves -- the module name +// and the package identity -- because exactly one of them is the surprising one // and the reader is better placed to say which. +// +// THE RULE (#734 E10, SPEC-007). A build-program module may use the prefix to +// say that it is an mcpp plugin, under its package's own namespace: +// `mcpp..*` (`mcpp.acme.protobuf`, `mcpp.mcpplibs.capi.lua`). The +// second segments below belong to the official families and to the engine's +// own interface; only packages in namespace `mcpp` provide them. mcpp-index +// applies the same list as an admission rule, so the two enforcers state one +// rule. inline constexpr std::string_view kReservedModulePrefix = "mcpp."; inline constexpr std::string_view kOfficialNamespace = "mcpp"; +inline constexpr std::string_view kReservedSecondSegments[] = { + "core", "plugins", "deps", "rules", "dist", "tools", +}; inline std::optional reserved_prefix_warning(std::string_view moduleName, @@ -496,11 +507,30 @@ reserved_prefix_warning(std::string_view moduleName, { if (!moduleName.starts_with(kReservedModulePrefix)) return std::nullopt; if (packageNamespace == kOfficialNamespace) return std::nullopt; + const auto rest = moduleName.substr(kReservedModulePrefix.size()); + const auto second = rest.substr(0, rest.find('.')); + for (auto r : kReservedSecondSegments) { + if (second != r) continue; + return std::format( + "build-program module '{}' of '{}' uses 'mcpp.{}', which belongs to the " + "modules maintained by the mcpp project. Nothing breaks -- the name " + "claims an origin the package does not have. A plugin names its modules " + "'mcpp.{}.*' under its own namespace.", + moduleName, packageFqn, r, + packageNamespace.empty() ? std::string_view("") : packageNamespace); + } + if (!packageNamespace.empty() + && (rest == packageNamespace + || (rest.starts_with(packageNamespace) + && rest.size() > packageNamespace.size() + && rest[packageNamespace.size()] == '.'))) + return std::nullopt; return std::format( - "build rule '{}' declares the module '{}'; the '{}' prefix is reserved " - "for rules maintained by the mcpp project. Nothing breaks — the name " - "simply claims an origin the package does not have.", - packageFqn, moduleName, kReservedModulePrefix); + "build-program module '{}' of '{}' is under 'mcpp.' but not under its own " + "namespace. Nothing breaks; a plugin names its modules 'mcpp.{}.*', so that " + "two plugins cannot choose one name.", + moduleName, packageFqn, + packageNamespace.empty() ? std::string_view("") : packageNamespace); } } // namespace mcpp::build::provisions diff --git a/src/build/build_program.cppm b/src/build/build_program.cppm index 639a0ad2..e333934d 100644 --- a/src/build/build_program.cppm +++ b/src/build/build_program.cppm @@ -13,6 +13,7 @@ module; export module mcpp.build.build_program; import std; +import mcpp.diag; // structured diagnostics of build programs (#734 E11) import mcpp.manifest; import mcpp.platform; import mcpp.pm.mangle; // imported_module_names -- what build.mcpp asks for @@ -33,6 +34,26 @@ import mcpp.toolchain.triple; // host_triple (MCPP_HOST contract value) import mcpp.ui; import mcpp.version; // MCPP_VERSION — the hint names the engine the reader is on +namespace { +// #734 E11: a build program's structured diagnostics, in the engine's own form. +// Called at both sites that report advisories -- the run path and the cache +// hit -- for the reason stated there: a replayed record must say what the run +// said. +void report_stated_diagnostics(std::string_view packageName, + const mcpp::build::directives::Directives& d) { + namespace dirs = mcpp::build::directives; + for (auto const& sd : dirs::stated_diagnostics(d)) { + const auto sev = sd.severity == "note" ? mcpp::diag::Severity::Note + : sd.severity == "degraded" ? mcpp::diag::Severity::Degraded + : mcpp::diag::Severity::Warning; + mcpp::diag::report(sev, std::format("build.mcpp/{}", packageName), + packageName.empty() ? sd.message + : std::format("{}: {}", packageName, sd.message), + sd.impact, sd.hint); + } +} +} // namespace + export namespace mcpp::build { // Build-program environment contract (G3) — what the running build.mcpp can @@ -87,6 +108,16 @@ struct BuildProgramEnv { // same producers the engine's own command lines read; a key absent here is // emitted empty. std::map buildInfo; + // #734 E7: the features of this program's host-module providers that are + // NOT enabled, with the files each would add. Read only when build.mcpp + // imports a module nothing provides, to name the feature that would; the + // files are not opened on a successful plan. + struct DormantFeature { + std::string package; + std::string feature; + std::vector files; + }; + std::vector dormantFeatures; // WHICH COMPILER RESOLVED — "gcc" | "clang" | "msvc" | "". // // A package should never have to guess this, and until this field existed @@ -1155,6 +1186,35 @@ std::expected run_build_program( if (!importable.empty()) importable += ", "; importable += hm.logical; } + // #734 E7: the module may be one a provider offers behind a + // feature this build has not enabled. The files were listed while + // planning; they are read only now, on the way to an error. + for (auto const& df : env.dormantFeatures) { + for (auto const& f : df.files) { + std::ifstream in(f, std::ios::binary); + std::string text{std::istreambuf_iterator(in), {}}; + std::size_t at = 0; + bool declares = false; + while ((at = text.find("export module", at)) != std::string::npos) { + std::size_t i = at + std::string_view("export module").size(); + while (i < text.size() && (text[i] == ' ' || text[i] == '\t')) ++i; + std::size_t j = i; + while (j < text.size() && text[j] != ';' && text[j] != ' ' + && text[j] != '\t' && text[j] != '\n' && text[j] != '\r') ++j; + if (text.compare(i, j - i, want) == 0) { declares = true; break; } + at = j; + } + if (!declares) continue; + mcpp::build::refusal::record( + mcpp::build::refusal::Code::HostModuleMissing); + return std::unexpected(std::format( + "build.mcpp imports '{}'\n" + " provided by: {}, feature \"{}\" (not enabled)\n" + " hint: enable it on the dependency edge: {} = {{ ..., " + "features = [\"{}\"] }}", + want, df.package, df.feature, df.package, df.feature)); + } + } mcpp::build::refusal::record( mcpp::build::refusal::Code::HostModuleMissing); return std::unexpected(std::format( @@ -1200,6 +1260,7 @@ std::expected run_build_program( // asserts the second build, not the first. for (auto const& a : dirs::advisories(m.package.name, cache.directives)) mcpp::ui::warning(a); + report_stated_diagnostics(m.package.name, cache.directives); mcpp::ui::info("build.mcpp", "up to date (cached)"); return {}; } @@ -1702,6 +1763,7 @@ std::expected run_build_program( // The second of the two sites. See the note on the cache-hit path above. for (auto const& a : dirs::advisories(m.package.name, d)) mcpp::ui::warning(a); + report_stated_diagnostics(m.package.name, d); write_cache(bdir, root, programHash, compilerHash, ctxHash, d); return {}; } diff --git a/src/build/hostprogram.cppm b/src/build/hostprogram.cppm index 059b5248..1724837e 100644 --- a/src/build/hostprogram.cppm +++ b/src/build/hostprogram.cppm @@ -102,6 +102,29 @@ inline void run_exclusive() { std::printf("mcpp:run-exclusive // program, and an advisory that appeared once and then vanished would read as // "resolved". mcpp replays it on every hit. inline void warning(const char* message) { std::printf("mcpp:warning=%s\n", message); } +// A structured diagnostic (#734 E11, protocol 14), rendered by the engine in +// its own form: the message, then `impact:` and `hint:` lines, in the JSON +// stream with the same fields, and replayed on a cached run as `warning` is. +// `severity` is "note", "warning" or "degraded" (a degradation fails the +// build under `--strict`). `warning(message)` stays, and equals a diagnostic +// with a message only. +struct diagnostic { + const char* severity = "warning"; + const char* message = ""; + const char* impact = ""; + const char* hint = ""; +}; +inline void diagnostic_field_(const char* s) { + for (const char* p = s ? s : ""; *p; ++p) + std::fputc((*p == '\t' || *p == '\n' || *p == '\r') ? ' ' : *p, stdout); +} +inline void report(const diagnostic& d) { + std::fputs("mcpp:diagnostic=", stdout); + diagnostic_field_(d.severity); std::fputc('\t', stdout); + diagnostic_field_(d.message); std::fputc('\t', stdout); + diagnostic_field_(d.impact); std::fputc('\t', stdout); + diagnostic_field_(d.hint); std::fputc('\n', stdout); +} // ── The probe channel (mcpp 2026.9.5.2+) ──────────────────────────────────── // diff --git a/src/build/prepare/features.cpp b/src/build/prepare/features.cpp index cec91d43..142bb9e6 100644 --- a/src/build/prepare/features.cpp +++ b/src/build/prepare/features.cpp @@ -1132,6 +1132,27 @@ step6_host_module_registration(PrepareState& state) { if (direct.empty()) continue; std::set isDirect(direct.begin(), direct.end()); + // #734 E7: what each direct provider could offer with a feature + // it does not have enabled here. Globs are expanded, nothing is + // read: the module names are looked up only if build.mcpp + // imports something no provider offers. + for (auto p : direct) { + auto const& dm = state.packages[p].manifest; + const auto& active = p < state.activeFeaturesByPackage.size() + ? state.activeFeaturesByPackage[p] : std::vector{}; + for (auto const& [fname, globs] : dm.buildConfig.featureSources) { + if (std::ranges::find(active, fname) != active.end()) continue; + mcpp::build::BuildProgramEnv::DormantFeature df; + df.package = identity(p); + df.feature = fname; + for (auto const& g : globs) + for (auto& f : mcpp::modgraph::expand_glob(state.packages[p].root, g)) + df.files.push_back(f); + if (!df.files.empty()) + state.dormantFeaturesByConsumer[c].push_back(std::move(df)); + } + } + // Post-order DFS, so a rule's own host modules are compiled // BEFORE it. That ordering is the entire mechanism: the // compile loop in build_program.cppm accumulates the module @@ -1890,6 +1911,9 @@ static std::expected step6_dependency_build_programs(PrepareS bpEnv.hostModules = state.hostModulesByConsumer.count(i) ? state.hostModulesByConsumer.at(i) : decltype(bpEnv.hostModules){}; + bpEnv.dormantFeatures = state.dormantFeaturesByConsumer.count(i) + ? state.dormantFeaturesByConsumer.at(i) + : decltype(bpEnv.dormantFeatures){}; auto& bcDep = pkg.manifest.buildConfig; const auto mark = state.markDirectiveTail(pkg.manifest); const auto ldN = bcDep.ldflags.size(); diff --git a/src/build/prepare/state.cppm b/src/build/prepare/state.cppm index a23685ba..817f9504 100644 --- a/src/build/prepare/state.cppm +++ b/src/build/prepare/state.cppm @@ -367,6 +367,9 @@ struct PrepareState { // detect (nothing outside the escaping closure's own body names them). std::vector packages; std::vector> activeFeaturesByPackage; + // #734 E7: per consumer, the dormant features of its host-module providers. + std::map> + dormantFeaturesByConsumer; std::map xlingsWinner; std::vector> dep_manifests; std::vector dep_cache_identities; diff --git a/src/build/prepare/target_side.cpp b/src/build/prepare/target_side.cpp index 2b4b35de..d156d7bc 100644 --- a/src/build/prepare/target_side.cpp +++ b/src/build/prepare/target_side.cpp @@ -1582,6 +1582,9 @@ static std::expected step9_root_build_program(PrepareState& s bpEnv.hostModules = state.hostModulesByConsumer.count(0u) ? state.hostModulesByConsumer.at(0u) : decltype(bpEnv.hostModules){}; + bpEnv.dormantFeatures = state.dormantFeaturesByConsumer.count(0u) + ? state.dormantFeaturesByConsumer.at(0u) + : decltype(bpEnv.dormantFeatures){}; // #649 E5: the packaging pass's strip decision, beside its format. bpEnv.packStrip = state.overrides.pack_strip; bpEnv.packDebugSymbolsDir = state.overrides.pack_debug_symbols_dir; diff --git a/src/diag.cppm b/src/diag.cppm index 5fae1c3a..cd9bb8e4 100644 --- a/src/diag.cppm +++ b/src/diag.cppm @@ -65,6 +65,13 @@ void warning(std::string_view domain, std::string_view what, // `--strict`. void note(std::string_view domain, std::string_view what); +// A diagnostic whose severity arrives as data: a build program's structured +// diagnostic (#734 E11), stated in the engine's own form. Rendered and recorded +// exactly as the three calls above would, so `--strict` and the JSON stream +// treat a plugin's diagnostic as they treat the engine's. +void report(Severity severity, std::string_view domain, std::string_view what, + std::string_view impact, std::string_view hint); + // The records of this run, cleared: what a command that writes an envelope // reports for one member before planning the next. What was printed stays // printed -- the once-per-process rule is about the terminal. @@ -142,6 +149,12 @@ void warning(std::string_view domain, std::string_view what, std::string{}, std::string(hint)}); } +void report(Severity severity, std::string_view domain, std::string_view what, + std::string_view impact, std::string_view hint) { + push(Record{severity, std::string(domain), std::string(what), + std::string(impact), std::string(hint)}); +} + void note(std::string_view domain, std::string_view what) { push(Record{Severity::Note, std::string(domain), std::string(what), std::string{}, std::string{}}); diff --git a/tests/e2e/826_plugin_diagnostics_features_and_names.sh b/tests/e2e/826_plugin_diagnostics_features_and_names.sh new file mode 100755 index 00000000..ae0adbfd --- /dev/null +++ b/tests/e2e/826_plugin_diagnostics_features_and_names.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# requires: unix-shell +# 826_plugin_diagnostics_features_and_names.sh -- #734 E11, E7 and E10. +# +# A third-party build plugin, `acme.gen`, offers `mcpp.acme.gen` from its lib +# root and two modules behind features. A consumer's build program uses it. +# +# D1 a structured diagnostic is rendered in the engine's form, with its +# impact and hint lines (E11); +# D2 after a source changes, the next build is a cache hit that does not +# run the program, and it reports the diagnostic again (E11); +# N1 importing a module that sits behind a feature the edge did not enable +# fails naming the package and the feature (E7); +# R1 `mcpp.acme.*` from namespace `acme` draws no naming warning (E10); +# R2 `mcpp.rules.*` from namespace `acme` is warned about, and the warning +# names the accepted form `mcpp.acme.*` (E10). +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } +MCPP="${MCPP:-mcpp}" + +mkdir -p gen/src app/src +cat > gen/mcpp.toml <<'EOF' +[package] +namespace = "acme" +name = "gen" +version = "0.1.0" + +[lib] +path = "src/gen.cppm" + +[features.extra] +sources = ["src/extra.cppm"] + +[features.bad] +sources = ["src/bad.cppm"] +EOF +cat > gen/src/gen.cppm <<'EOF' +export module mcpp.acme.gen; +import mcpp.core; +export namespace acme { +inline void tell() { + mcpp::report({.severity = "warning", + .message = "the generator found no schema", + .impact = "no bindings are generated", + .hint = "add schema/*.proto"}); +} +} +EOF +printf 'export module mcpp.acme.gen.extra;\nexport namespace acme { inline int extra() { return 1; } }\n' > gen/src/extra.cppm +printf 'export module mcpp.rules.bad;\nexport namespace acme { inline int bad() { return 2; } }\n' > gen/src/bad.cppm + +cd app +printf 'int main() { return 0; }\n' > src/main.cpp +edge() { printf '[package]\nname = "app826"\nversion = "0.1.0"\n\n[build-dependencies]\n"acme.gen" = { path = "../gen", host-module = true%s }\n' "$1" > mcpp.toml; } + +# D1, R1 +edge '' +printf 'import mcpp.core;\nimport mcpp.acme.gen;\nint main() { acme::tell(); return 0; }\n' > build.mcpp +"$MCPP" build > d1.log 2>&1 || fail "D1: the build failed" d1.log +grep -q "the generator found no schema" d1.log || fail "D1: the message is missing" d1.log +grep -q "impact: no bindings are generated" d1.log || fail "D1: the impact line is missing" d1.log +grep -q "hint: add schema/\*.proto" d1.log || fail "D1: the hint line is missing" d1.log +grep -q "mcpp.acme.gen'.*mcpp\.\|claims an origin" d1.log && fail "R1: an own-namespace module was warned about" d1.log + +# D2. `touch` first, as e2e 139 does: an unmodified build takes the project +# fast path and reaches no build program, so it would measure neither path. +touch src/main.cpp +"$MCPP" build > d2.log 2>&1 || fail "D2: the second build failed" d2.log +grep -q "up to date (cached)" d2.log || fail "D2: the second build ran the program; the replay is not measured" d2.log +grep -q "impact: no bindings are generated" d2.log || fail "D2: the cached run did not report the diagnostic" d2.log + +# N1 +printf 'import mcpp.core;\nimport mcpp.acme.gen.extra;\nint main() { return acme::extra() == 1 ? 0 : 1; }\n' > build.mcpp +if "$MCPP" build > n1.log 2>&1; then fail "N1: a module behind a disabled feature was importable" n1.log; fi +grep -q "provided by: acme.gen, feature \"extra\" (not enabled)" n1.log || fail "N1: the error does not name the feature" n1.log +edge ', features = ["extra"]' +"$MCPP" build > n1b.log 2>&1 || fail "N1: enabling the named feature did not fix the build" n1b.log + +# R2 +edge ', features = ["bad"]' +printf 'import mcpp.core;\nimport mcpp.rules.bad;\nint main() { return acme::bad() == 2 ? 0 : 1; }\n' > build.mcpp +"$MCPP" build > r2.log 2>&1 || fail "R2: the build failed" r2.log +grep -q "uses 'mcpp.rules'" r2.log || fail "R2: a reserved segment was not warned about" r2.log +grep -q "'mcpp.acme.\*'" r2.log || fail "R2: the warning does not name the accepted form" r2.log + +echo "OK" diff --git a/tests/unit/test_provisions.cpp b/tests/unit/test_provisions.cpp index bf534be8..907bf503 100644 --- a/tests/unit/test_provisions.cpp +++ b/tests/unit/test_provisions.cpp @@ -318,6 +318,34 @@ TEST(ReservedPrefix, AnOrdinaryNameIsSilent) { "acme.mcppish").has_value()); } +// #734 E10: a plugin may say it is an mcpp plugin under its own namespace; +// the official second segments stay the mcpp project's. +TEST(ReservedPrefix, AnOwnNamespaceUnderMcppIsSilent) { + EXPECT_FALSE(prov::reserved_prefix_warning("mcpp.acme.protobuf", "acme", + "acme.protobufgen").has_value()); + EXPECT_FALSE(prov::reserved_prefix_warning("mcpp.acme", "acme", + "acme.gen").has_value()); + EXPECT_FALSE(prov::reserved_prefix_warning("mcpp.mcpplibs.capi.lua", "mcpplibs.capi", + "mcpplibs.capi.lua").has_value()); +} + +TEST(ReservedPrefix, EveryReservedSecondSegmentIsWarnedAbout) { + for (auto seg : prov::kReservedSecondSegments) { + auto name = std::string("mcpp.") + std::string(seg) + ".x"; + auto w = prov::reserved_prefix_warning(name, "acme", "acme.x"); + ASSERT_TRUE(w.has_value()) << name; + EXPECT_NE(w->find("mcpp.acme.*"), std::string::npos) << *w; + } +} + +TEST(ReservedPrefix, AnotherNamespaceUnderMcppIsWarnedAbout) { + auto w = prov::reserved_prefix_warning("mcpp.other.x", "acme", "acme.x"); + ASSERT_TRUE(w.has_value()); + EXPECT_NE(w->find("mcpp.acme.*"), std::string::npos) << *w; + // `mcpp.acmeish` is not under `mcpp.acme.`: the segment boundary decides. + EXPECT_TRUE(prov::reserved_prefix_warning("mcpp.acmeish.x", "acme", "acme.x").has_value()); +} + // A package that offers several rules through features (mcpp 2026.9.5.3+) // contributes every module INTERFACE unit among its resolved sources. The // detector decides what counts as one, and the cases below are the ones a From 529c0ba4464b28edc9856facca090428a0c0fb54 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:24:31 +0800 Subject: [PATCH 05/18] build: the files beside a program are placed by one process, from a list the plan writes (#734 E4) --- src/build/ninja_backend.cppm | 61 ++++++++++++-- src/cli.cppm | 2 + src/cli/cmd_build.cppm | 42 ++++++++++ .../827_many_placements_run_as_one_process.sh | 80 +++++++++++++++++++ 4 files changed, 179 insertions(+), 6 deletions(-) create mode 100755 tests/e2e/827_many_placements_run_as_one_process.sh diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index d96b98a3..e10e505e 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -69,6 +69,10 @@ std::string ninja_program_for(const mcpp::toolchain::Toolchain& tc); // Helper exposed for testing / debugging std::string emit_ninja_string(const BuildPlan& plan); +// The same, and when the plan places two or more files beside its programs, +// the placement list the one `stage_list` edge reads (#734 E4), for the caller +// to write as `/placements.list`. Empty when there is no such edge. +std::string emit_ninja_string(const BuildPlan& plan, std::string* placements); std::string filter_ninja_output(std::string_view output, std::span commandPrefixes); @@ -1138,6 +1142,10 @@ std::string filter_ninja_output(std::string_view output, } std::string emit_ninja_string(const BuildPlan& plan) { + return emit_ninja_string(plan, nullptr); +} + +std::string emit_ninja_string(const BuildPlan& plan, std::string* placements) { // dyndep requires P1689 scanning capability: // GCC: built-in -fdeps-format=p1689r5 // Clang: external clang-scan-deps tool (same P1689 output format) @@ -1328,6 +1336,16 @@ std::string emit_ninja_string(const BuildPlan& plan) { append(" command = $mcpp stage $verify --output $out $in\n"); append(" description = STAGE $out\n"); append(" restat = 1\n\n"); + // #734 E4: every file a program's runtime needs beside it, placed by ONE + // process. A process per file cost 4.5 s for 1270 files on a first + // Windows build, against 0.5 s for one copying process. The list is a + // file of the build directory, rewritten only when it changes, and an + // input of the edge, so adding or removing an entry re-runs it; `restat` + // keeps an unchanged destination's time stamp. + append("rule stage_list\n"); + append(" command = $mcpp stage --list placements.list\n"); + append(" description = STAGE $count files\n"); + append(" restat = 1\n\n"); // P1: per-file dyndep rule. Converts one .ddi → .dd independently. // @@ -3017,11 +3035,27 @@ std::string emit_ninja_string(const BuildPlan& plan) { // exactly one source (every project before this feature, and most // packages after it) emits the exact same line as always: the loop below // reduces to the one-word case with no change in spelling. - for (auto const& d : deployFiles) { - std::string ins; - for (auto const& s : d.sources) ins += " " + escape_ninja_path(s); - append(std::format("build {} : stage_file{}\n", - escape_ninja_path(d.dest), ins)); + if (deployFiles.size() >= 2) { + // #734 E4: one edge for the whole list. One entry keeps the per-file + // edge below, byte for byte. + std::string outs, ins, list; + for (auto const& d : deployFiles) { + outs += " " + escape_ninja_path(d.dest); + for (auto const& s : d.sources) { + ins += " " + escape_ninja_path(s); + list += std::format("{}\t{}\n", s.string(), d.dest.string()); + } + } + append(std::format("build{} : stage_list{} | placements.list\n count = {}\n", + outs, ins, deployFiles.size())); + if (placements) *placements = std::move(list); + } else { + for (auto const& d : deployFiles) { + std::string ins; + for (auto const& s : d.sources) ins += " " + escape_ninja_path(s); + append(std::format("build {} : stage_file{}\n", + escape_ninja_path(d.dest), ins)); + } } if (!deployFiles.empty()) append("\n"); @@ -3677,8 +3711,23 @@ std::expected NinjaBackend::build(const BuildPlan& plan plan.targetSide.cAbiDecl ? plan.targetSide.cAbiDecl->absent : std::vector{}); write_consumer_include_sidecar(plan.outputDir, root_include_dirs_of(plan)); - auto manifest = emit_ninja_string(plan); + std::string placements; + auto manifest = emit_ninja_string(plan, &placements); stage("emit-ninja"); + if (!placements.empty()) { + // Written only when it changes: it is an input of the placement edge. + const auto listPath = plan.outputDir / "placements.list"; + std::error_code lec; + std::string old; + if (std::filesystem::exists(listPath, lec)) { + std::ifstream in(listPath, std::ios::binary); + old.assign(std::istreambuf_iterator(in), {}); + } + if (old != placements) { + std::filesystem::create_directories(plan.outputDir, lec); + std::ofstream(listPath, std::ios::binary | std::ios::trunc) << placements; + } + } // Command-length backstop (see // .agents/docs/2026-08-06-command-length-architecture.md). The structural diff --git a/src/cli.cppm b/src/cli.cppm index 3905cf6e..e852894e 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -930,6 +930,8 @@ int run(int argc, char** argv) { .help("Destination path; its parent directory is created")) .option(cl::Option("verify").takes_value().value_name("MODE") .help("How an existing destination is judged up to date: content (default) | size")) + .option(cl::Option("list").takes_value().value_name("FILE") + .help("Place every `\\t` pair of FILE in one process (#734 E4); several lines naming one destination are its sources")) .action(wrap_rc(cmd_stage))) .subcommand(cl::App("place-dlls") .description("(internal: invoked by ninja) Place beside a Windows program the DLLs it imports from its runtime search directories") diff --git a/src/cli/cmd_build.cppm b/src/cli/cmd_build.cppm index 3ff8522d..0b6e079b 100644 --- a/src/cli/cmd_build.cppm +++ b/src/cli/cmd_build.cppm @@ -962,6 +962,48 @@ export int cmd_dyndep(const mcpplibs::cmdline::ParsedArgs& parsed) { // and the destination. One source — every invocation before this feature — // takes the exact path it always has. export int cmd_stage(const mcpplibs::cmdline::ParsedArgs& parsed) { + // `--list FILE` (#734 E4): many placements, one process. Each destination + // keeps the single-file semantics below -- content comparison, an + // out-of-place write, the check that several sources agree -- because each + // group is handed to the same `stage_files`. + if (auto listFile = parsed.option_or_empty("list").value(); !listFile.empty()) { + mcpp::build::stage::StageOptions opts; + std::string verify = parsed.option_or_empty("verify").value(); + if (verify.empty()) + if (const char* e = std::getenv("MCPP_STAGE_VERIFY"); e && *e) verify = e; + if (!verify.empty()) opts.verify = mcpp::build::stage::parse_verify(verify); + std::ifstream in(std::filesystem::path{listFile}, std::ios::binary); + if (!in) { + std::println(stderr, "error: cannot read the placement list {}", listFile); + return 1; + } + std::vector>> groups; + std::map index; + std::string line; + while (std::getline(in, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + if (line.empty()) continue; + const auto tab = line.find('\t'); + if (tab == std::string::npos) { + std::println(stderr, "error: placement list line without a tab: {}", line); + return 2; + } + std::string src = line.substr(0, tab), dst = line.substr(tab + 1); + auto [it, fresh] = index.emplace(dst, groups.size()); + if (fresh) groups.push_back({dst, {}}); + groups[it->second].second.push_back( + mcpp::platform::fs::extended_length(std::filesystem::path{src})); + } + for (auto const& [dst, srcs] : groups) { + auto r = mcpp::build::stage::stage_files( + srcs, mcpp::platform::fs::extended_length(std::filesystem::path{dst}), opts); + if (!r) { + std::println(stderr, "error: {}", r.error().message); + return 1; + } + } + return 0; + } std::filesystem::path outPath = parsed.option_or_empty("output").value(); if (outPath.empty()) { std::println(stderr, "error: --output required"); diff --git a/tests/e2e/827_many_placements_run_as_one_process.sh b/tests/e2e/827_many_placements_run_as_one_process.sh new file mode 100755 index 00000000..6c62fa83 --- /dev/null +++ b/tests/e2e/827_many_placements_run_as_one_process.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# requires: unix-shell +# 827_many_placements_run_as_one_process.sh -- #734 E4. +# +# The files a program's runtime needs beside it are placed by one process, not +# one per file: a first Windows build spent 4.5 s on 1270 per-file placements, +# against 0.5 s for one copying process. `mcpp stage --list` reads the +# `\t` pairs of a list the plan writes, and keeps the +# single-file semantics for each destination. +# +# S1 50 deploy entries produce one `stage_list` edge and no `stage_file` +# edge, and every file arrives; +# S2 a build with nothing changed runs no placement; +# S3 changing one source rewrites its destination only: every other +# destination keeps its time stamp; +# S4 one deploy entry keeps the per-file edge, byte for byte. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } +MCPP="${MCPP:-mcpp}" + +mkdir -p p/src p/data +cd p +for i in $(seq 1 50); do echo "$i" > data/f$i.txt; done +printf 'int main() { return 0; }\n' > src/main.cpp +printf '[package]\nname = "place827"\nversion = "0.1.0"\n' > mcpp.toml +program() { # $1 = how many entries + cat > build.mcpp < s1.log 2>&1 || fail "S1: the build failed" s1.log +NINJA=$(find target -name build.ninja | head -1) +[ "$(grep -c ': stage_list ' "$NINJA")" = 1 ] || fail "S1: not exactly one stage_list edge" "$NINJA" +grep -q ': stage_file .*data' "$NINJA" && fail "S1: a per-file placement edge remains" "$NINJA" +OUT=$(dirname "$NINJA") +[ "$(ls "$OUT"/bin/data 2>/dev/null | wc -l)" -eq 50 ] || [ "$(find "$OUT" -path '*data/f*.txt' | wc -l)" -ge 50 ] \ + || fail "S1: not every file was placed" s1.log +DEST1=$(find "$OUT" -path '*/data/f1.txt' | head -1) +DEST2=$(find "$OUT" -path '*/data/f2.txt' | head -1) +[ -n "$DEST1" ] && [ -n "$DEST2" ] || fail "S1: the placed files are not found under $OUT" s1.log + +# S2 +before=$(grep -c "placements\|data/f" "$OUT/.ninja_log" || true) +touch src/main.cpp +"$MCPP" build > s2.log 2>&1 || fail "S2: the build failed" s2.log +after=$(grep -c "placements\|data/f" "$OUT/.ninja_log" || true) +[ "$before" = "$after" ] || fail "S2: a build with no placement change ran the placement edge" "$OUT/.ninja_log" + +# S3 +t2=$(stat -c %Y "$DEST2") +sleep 1.1 +echo changed > data/f1.txt +touch src/main.cpp +"$MCPP" build > s3.log 2>&1 || fail "S3: the build failed" s3.log +grep -qx changed "$DEST1" || fail "S3: the changed source did not arrive" "$DEST1" +[ "$(stat -c %Y "$DEST2")" = "$t2" ] || fail "S3: an unchanged destination was rewritten" s3.log + +# S4 +program 1 +"$MCPP" build > s4.log 2>&1 || fail "S4: the build failed" s4.log +NINJA=$(find target -name build.ninja | head -1) +grep -q ': stage_list ' "$NINJA" && fail "S4: one entry produced a list edge" "$NINJA" +grep -q ': stage_file .*f1.txt' "$NINJA" || fail "S4: the single entry lost its per-file edge" "$NINJA" + +echo "OK" From a57ef57ec3ec9b01baf4da6827fb9681484d9232 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:30:58 +0800 Subject: [PATCH 06/18] pack: -p selects a member; a library states its interface -- W1 to W3 and the complete Withheld report (#734 E3, E6) --- src/build/prepare/scan.cpp | 76 ++++++++++++++++ src/cli.cppm | 2 + src/cli/cmd_publish.cppm | 33 ++++++- src/modgraph/graph.cppm | 3 + src/modgraph/scanner.cppm | 4 + src/modgraph/validate.cppm | 13 ++- src/pack/library_pipeline.cppm | 48 ++++++++++ .../e2e/828_a_library_states_its_interface.sh | 90 +++++++++++++++++++ tests/e2e/829_pack_takes_a_member.sh | 61 +++++++++++++ 9 files changed, 326 insertions(+), 4 deletions(-) create mode 100755 tests/e2e/828_a_library_states_its_interface.sh create mode 100755 tests/e2e/829_pack_takes_a_member.sh diff --git a/src/build/prepare/scan.cpp b/src/build/prepare/scan.cpp index 4404b720..e3a895b4 100644 --- a/src/build/prepare/scan.cpp +++ b/src/build/prepare/scan.cpp @@ -874,9 +874,85 @@ step11_prebuild_std_module(PrepareState& state, bool needsStdModule) { return {}; } +// SPEC-008 W3 (#734 E6): the package being built imports a module of a +// dependency that states an interface root, and that module is not one of the +// dependency's public modules (the root and what it re-exports with +// `export import`, transitively). The build succeeds from source; against the +// dependency's packed form it would not, because a packed library ships its +// public modules' closure only. Warned once per module, and never for a +// dependency without a root, which states no interface. +static void step11_public_module_check(PrepareState& state) { + if (state.packages.empty()) return; + const auto& g = state.scan.graph; + auto qualified = [](const mcpp::manifest::Manifest& m) { + return m.package.namespace_.empty() ? m.package.name + : m.package.namespace_ + "." + m.package.name; + }; + auto primary = [](std::string_view name) { + return std::string(name.substr(0, name.find(':'))); + }; + const std::string rootName = qualified(state.packages[0].manifest); + + std::map providerOf; // primary module -> package + std::map> unitsOf; + for (auto const& u : g.units) { + if (!u.provides) continue; + const auto prim = primary(u.provides->logicalName); + unitsOf[prim].push_back(&u); + if (u.provides->logicalName.find(':') == std::string::npos) + providerOf.emplace(prim, u.packageName); + } + + std::map> publicOf; + for (std::size_t i = 1; i < state.packages.size(); ++i) { + auto const& pr = state.packages[i]; + const auto rootFile = (pr.root / mcpp::manifest::resolve_lib_root_path(pr.manifest, pr.root)) + .lexically_normal(); + std::error_code ec; + if (!std::filesystem::is_regular_file(rootFile, ec)) continue; + const mcpp::modgraph::SourceUnit* rootUnit = nullptr; + for (auto const& u : g.units) + if (u.path.lexically_normal() == rootFile && u.provides) { rootUnit = &u; break; } + if (!rootUnit) continue; + std::set pub{primary(rootUnit->provides->logicalName)}; + std::vector work(pub.begin(), pub.end()); + while (!work.empty()) { + auto mod = work.back(); work.pop_back(); + for (auto const* u : unitsOf[mod]) + for (auto const& re : u->reexports) + if (auto p = primary(re.logicalName); pub.insert(p).second) work.push_back(p); + } + publicOf[qualified(pr.manifest)] = std::move(pub); + } + + std::set warned; + for (auto const& u : g.units) { + if (u.packageName != rootName) continue; + for (auto const& req : u.requires_) { + const auto prim = primary(req.logicalName); + auto prov = providerOf.find(prim); + if (prov == providerOf.end() || prov->second == rootName) continue; + auto pub = publicOf.find(prov->second); + if (pub == publicOf.end() || pub->second.contains(prim)) continue; + if (!warned.insert(prim).second) continue; + std::string names; + for (auto const& n : pub->second) { if (!names.empty()) names += ", "; names += n; } + mcpp::diag::report( + mcpp::diag::Severity::Warning, "build/interface", + std::format("'{}' imports '{}' of '{}', which is not one of that " + "package's public modules", u.relPath.generic_string(), + prim, prov->second), + std::format("the build succeeds from source, and fails against the " + "packed form of '{}'", prov->second), + std::format("import a public module of '{}': {}", prov->second, names)); + } + } +} + std::expected phase11_scan(PrepareState& state) { auto needsStdModule = step11_scan_sources(state); if (!needsStdModule) return std::unexpected(needsStdModule.error()); + step11_public_module_check(state); if (auto r = step11_dependency_standard_scope_check(state); !r) return std::unexpected(r.error()); diff --git a/src/cli.cppm b/src/cli.cppm index e852894e..4508092b 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -640,6 +640,8 @@ int run(int argc, char** argv) { .help("Output format: human (default) | json (one mcpp.pack envelope on stdout; narration on stderr)")) .option(cl::Option("no-strip") .help("Ship the artifacts as built (default: strip debug info)")) + .option(cl::Option("package").short_name('p').takes_value().value_name("NAME") + .help("Pack the named workspace member (namespace.name or package name, then directory), as if run in its directory")) .option(cl::Option("debug-symbols").takes_value().value_name("DIR") .help("Write the separated *.debug files here (default: discard)")) .action(wrap_rc(cmd_pack))) diff --git a/src/cli/cmd_publish.cppm b/src/cli/cmd_publish.cppm index 0855097c..bbc8d457 100644 --- a/src/cli/cmd_publish.cppm +++ b/src/cli/cmd_publish.cppm @@ -17,6 +17,8 @@ import mcpp.pack.library_pipeline; import mcpp.pack.pipeline; import mcpp.pack.route; import mcpp.platform.terminal; +import mcpp.project; // resolve_member_dir, for `pack -p` (#734 E3) +import mcpp.manifest; import mcpp.publish.pipeline; import mcpp.ui; import mcpp.wire; @@ -258,6 +260,35 @@ int cmd_pack_body(const mcpplibs::cmdline::ParsedArgs& parsed, mcpp::pack::PackOutcome* report, mcpp::pack::LibraryPackReport* libraryReport, bool* libraryRoute) { + // `-p ` (#734 E3): the member is resolved by the resolver every + // other `-p` uses, and the pack then runs in its directory, so the result + // is by construction the one `mcpp pack` in that directory produces. A + // relative `--output` keeps meaning the directory the user typed it in. + std::optional outputFromUser; + if (auto pkg = parsed.option_or_empty("package").value(); !pkg.empty()) { + auto root = mcpp::project::find_manifest_root(std::filesystem::current_path()); + if (!root) { + mcpp::ui::error("-p needs a workspace; no mcpp.toml was found here or above"); + return 2; + } + auto rm = mcpp::manifest::load(*root / "mcpp.toml"); + if (!rm) { mcpp::ui::error(rm.error().format()); return 2; } + auto member = mcpp::project::resolve_member_dir(*rm, *root, pkg); + if (!member) { mcpp::ui::error(member.error()); return 2; } + if (member->empty()) { + mcpp::ui::error(std::format("-p {}: {} is not a workspace", pkg, root->string())); + return 2; + } + if (auto v = parsed.value("output")) + outputFromUser = std::filesystem::absolute(*v); + std::error_code ec; + std::filesystem::current_path(*member, ec); + if (ec) { + mcpp::ui::error(std::format("-p {}: cannot enter {}: {}", pkg, + member->string(), ec.message())); + return 2; + } + } // ─── Resolve mode ──────────────────────────────────────────────── mcpp::pack::Options opts; bool modeFromUser = false; @@ -293,7 +324,7 @@ int cmd_pack_body(const mcpplibs::cmdline::ParsedArgs& parsed, opts.formatName = *v; } } - if (auto v = parsed.value("output")) opts.output = *v; + if (auto v = parsed.value("output")) opts.output = outputFromUser ? outputFromUser->string() : *v; // `value()`, not `option_or_empty()`, for `profile` — and NOT for // anything whose name a positional shares. `ParsedArgs::value()` falls back diff --git a/src/modgraph/graph.cppm b/src/modgraph/graph.cppm index 01899c1c..4c391333 100644 --- a/src/modgraph/graph.cppm +++ b/src/modgraph/graph.cppm @@ -87,6 +87,9 @@ struct SourceUnit { // to prevent. Unknown now warns, naming the file and the reason. std::optional providesInterface; std::vector requires_; + // The subset of `requires_` imported with `export import` (the text + // scanner; a P1689 scan does not report it and leaves this empty). + std::vector reexports; // The declaration form (see ModuleDeclaration). Read by the build database // renderer (mcpp.build.build_database), which states it as the unit's role. ModuleDeclaration declaration = ModuleDeclaration::None; diff --git a/src/modgraph/scanner.cppm b/src/modgraph/scanner.cppm index 02cfa55d..704aa3af 100644 --- a/src/modgraph/scanner.cppm +++ b/src/modgraph/scanner.cppm @@ -1054,6 +1054,10 @@ std::expected scan_file(const std::filesystem::path& file name = owningModule + name; } u.requires_.push_back(ModuleId{name}); + // `export import X;` re-exports X to whoever imports this unit + // (SPEC-008: a package's public modules are its interface root and + // what the root re-exports, transitively). #734 E6 W3 reads it. + if (is_export) u.reexports.push_back(ModuleId{name}); continue; } } diff --git a/src/modgraph/validate.cppm b/src/modgraph/validate.cppm index f00fb3a5..ae604c38 100644 --- a/src/modgraph/validate.cppm +++ b/src/modgraph/validate.cppm @@ -169,10 +169,17 @@ ValidateReport validate(const Graph& g, // root by construction. One that has some, but not the // conventional one, is the case the warning exists for and // still gets it. + // SPEC-008 W1 (#734 E6): the consequence is stated, + // because the reader of this property is `mcpp pack`, + // not this build. The first line keeps its words. r.warnings.push_back({lib_root_rel, std::format( - "lib target without conventional lib root '{}' " - "(create the file or set [lib].path)", - lib_root_rel.string())}); + "lib target without conventional lib root '{}'\n" + " impact: `mcpp pack` publishes this library without a module " + "interface\n" + " hint: add '{}' re-exporting the public modules with " + "`export import`, or set [lib].path; a library that " + "publishes headers only can ignore this", + lib_root_rel.string(), lib_root_rel.string())}); } } } diff --git a/src/pack/library_pipeline.cppm b/src/pack/library_pipeline.cppm index d3bf1599..5dd669df 100644 --- a/src/pack/library_pipeline.cppm +++ b/src/pack/library_pipeline.cppm @@ -24,6 +24,7 @@ module; export module mcpp.pack.library_pipeline; import std; +import mcpp.diag; // SPEC-008 W2 (#734 E6) import mcpp.build.backend; import mcpp.build.ninja; import mcpp.build.plan; @@ -132,6 +133,7 @@ export int build_and_pack_library(const std::string& targetName, InterfaceClosure closure; bool haveClosure = false; + bool warnedUnshipped = false; // #734 E6 W2, once per pack std::string firstTriple; // `[package] platforms` — the support CLAIM, read once. Checked against the // legs after the loop; see there for why only two of the four comparisons @@ -239,6 +241,52 @@ export int build_and_pack_library(const std::string& targetName, // else: a header-only package. `sources = []` in the emitted manifest // says so explicitly, which is a thing a manifest can say now. + // #734 E6 (SPEC-008): every unit of this package that is not shipped is + // withheld, and the report says so -- including when there is no + // interface root, where the closure is empty and the report used to + // read "(nothing)" over a package that exported modules. An exported + // primary module that is not shipped is named (W2): a consumer of the + // packed form cannot import it. A warning, not an error: a library may + // implement itself in modules and publish only headers on purpose, and + // only the author can say which it is. + { + std::set shipped(here.published.begin(), here.published.end()); + std::set held(here.withheld.begin(), here.withheld.end()); + std::vector unshipped; + const auto proj = ctx->projectRoot.lexically_normal(); + for (auto const& u : ctx->graph.units) { + const auto rel = u.path.lexically_normal().lexically_relative(proj); + if (rel.empty()) continue; + const auto head = rel.begin()->string(); + if (head == ".." || head == "target") continue; // another package, or generated + if (shipped.contains(u.path)) continue; + if (held.insert(u.path).second) here.withheld.push_back(u.path); + if (u.provides && u.providesInterface.value_or(false) + && u.provides->logicalName.find(':') == std::string::npos) + unshipped.push_back(u.provides->logicalName); + } + if (!unshipped.empty() && !warnedUnshipped) { + warnedUnshipped = true; + std::string names; + for (auto const& n : unshipped) { + if (!names.empty()) names += ", "; + names += n; + } + mcpp::diag::report( + mcpp::diag::Severity::Warning, "pack/interface", + std::format("the package exports module{} {} that the packed form " + "does not ship", unshipped.size() == 1 ? "" : "s", names), + "a consumer of the packed form cannot import " + + std::string(unshipped.size() == 1 ? "it" : "them"), + here.published.empty() + ? "add an interface root that re-exports the public modules " + "(`export import`), or set [lib].path; a library that " + "publishes headers only can ignore this" + : "re-export it from the interface root with `export import`, " + "or keep it internal on purpose"); + } + } + if (!here.unresolvedImports.empty()) { std::string list; for (auto const& m : here.unresolvedImports) { diff --git a/tests/e2e/828_a_library_states_its_interface.sh b/tests/e2e/828_a_library_states_its_interface.sh new file mode 100755 index 00000000..c402d099 --- /dev/null +++ b/tests/e2e/828_a_library_states_its_interface.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# requires: unix-shell +# 828_a_library_states_its_interface.sh -- #734 E6, SPEC-008 phase 1. +# +# A library's interface is its root module and what the root re-exports with +# `export import`, transitively. `mcpp pack` ships that closure. Phase 1 warns +# and never refuses: a library may implement itself in modules and publish only +# headers, and only its author can say which it means. +# +# I1 a library exporting `Alpha` and `Beta` with no root: the build states +# W1 with its impact line; the pack exits 0, names both modules (W2), and +# its "Withheld" row lists both units instead of "(nothing)"; +# I2 the same library with a facade root re-exporting both: both are shipped +# and no W2 appears; +# I3 a consumer importing `Beta` from a library whose root re-exports only +# `Alpha` is warned (W3); importing `Alpha` is not. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } +MCPP="${MCPP:-mcpp}" + +mkdir -p lib/src +cd lib +cat > mcpp.toml <<'EOF' +[package] +namespace = "probe" +name = "flatlib" +version = "0.1.0" + +[build] +sources = ["src/alpha.cppm", "src/beta.cppm"] + +[targets.flatlib] +kind = "lib" +EOF +printf 'export module Alpha;\nexport int alpha() { return 1; }\n' > src/alpha.cppm +printf 'export module Beta;\nimport Alpha;\nexport int beta() { return alpha() + 1; }\n' > src/beta.cppm + +# I1 +"$MCPP" build > i1b.log 2>&1 || fail "I1: the build failed" i1b.log +grep -q "without conventional lib root" i1b.log || fail "I1: W1 is missing" i1b.log +grep -q "impact: \`mcpp pack\` publishes this library without a module interface" i1b.log \ + || fail "I1: W1 has no impact line" i1b.log +"$MCPP" pack flatlib > i1.log 2>&1 || fail "I1: the pack failed" i1.log +grep -q "exports modules Alpha, Beta that the packed form does not ship" i1.log \ + || grep -q "exports modules Beta, Alpha that the packed form does not ship" i1.log \ + || fail "I1: W2 does not name both modules" i1.log +grep -q "Withheld.*alpha.cppm" i1.log && grep -q "Withheld.*beta.cppm" i1.log \ + || fail "I1: the Withheld row does not list both units" i1.log +grep -q "Withheld (nothing)" i1.log && fail "I1: the Withheld row still reads (nothing)" i1.log + +# I2 +cat >> mcpp.toml <<'EOF' + +[lib] +path = "src/flatlib.cppm" +EOF +sed -i 's|sources = \["src/alpha.cppm", "src/beta.cppm"\]|sources = ["src/flatlib.cppm", "src/alpha.cppm", "src/beta.cppm"]|' mcpp.toml +printf 'export module probe.flatlib;\nexport import Alpha;\nexport import Beta;\n' > src/flatlib.cppm +"$MCPP" pack flatlib > i2.log 2>&1 || fail "I2: the pack failed" i2.log +grep -q "does not ship" i2.log && fail "I2: W2 appeared with a facade" i2.log +grep -q "Interface.*alpha.cppm" i2.log && grep -q "Interface.*beta.cppm" i2.log \ + || fail "I2: the facade's modules are not both shipped" i2.log + +# I3 +printf 'export module probe.flatlib;\nexport import Alpha;\nimport Beta;\n' > src/flatlib.cppm +cd "$TMP" +mkdir -p app/src +cat > app/mcpp.toml <<'EOF' +[package] +name = "app828" +version = "0.1.0" + +[dependencies] +"probe.flatlib" = { path = "../lib" } +EOF +printf 'import Alpha;\nint main() { return alpha() == 1 ? 0 : 1; }\n' > app/src/main.cpp +cd app +"$MCPP" build > i3a.log 2>&1 || fail "I3: the build importing a public module failed" i3a.log +grep -q "not one of that package's public modules" i3a.log && fail "I3: a public module was warned about" i3a.log +printf 'import Beta;\nint main() { return beta() == 2 ? 0 : 1; }\n' > src/main.cpp +"$MCPP" build > i3b.log 2>&1 || fail "I3: the build importing a non-public module failed" i3b.log +grep -q "imports 'Beta' of 'probe.flatlib', which is not one of that package's public modules" i3b.log \ + || fail "I3: W3 is missing" i3b.log +grep -q "import a public module of 'probe.flatlib': Alpha" i3b.log || fail "I3: W3 does not name the public modules" i3b.log + +echo "OK" diff --git a/tests/e2e/829_pack_takes_a_member.sh b/tests/e2e/829_pack_takes_a_member.sh new file mode 100755 index 00000000..61e1ae14 --- /dev/null +++ b/tests/e2e/829_pack_takes_a_member.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# requires: unix-shell +# 829_pack_takes_a_member.sh -- #734 E3. +# +# `build`, `run` and `test` select a workspace member with `-p`, and so does +# `pack`: the member is resolved by the resolver every `-p` shares, and the pack +# runs in its directory. +# +# P1 `mcpp pack -p lib` at the workspace root produces the archive that +# `mcpp pack` in `lib/` produces, with the same contents; +# P2 a relative `-o` keeps meaning the directory the command was typed in; +# P3 a name that is no member is refused, naming the members. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } +MCPP="${MCPP:-mcpp}" + +mkdir -p ws/lib/src ws/app/src +cat > ws/mcpp.toml <<'EOF' +[workspace] +members = ["lib", "app"] +EOF +cat > ws/lib/mcpp.toml <<'EOF' +[package] +namespace = "probe" +name = "packlib" +version = "0.1.0" + +[targets.packlib] +kind = "lib" +EOF +printf 'export module probe.packlib;\nexport int one() { return 1; }\n' > ws/lib/src/packlib.cppm +printf '[package]\nname = "app829"\nversion = "0.1.0"\n' > ws/app/mcpp.toml +printf 'int main() { return 0; }\n' > ws/app/src/main.cpp + +# P1 +cd "$TMP/ws/lib" +"$MCPP" pack > inside.log 2>&1 || fail "P1: pack inside the member failed" inside.log +A=$(ls target/dist/*.tar.gz | head -1) +tar tzf "$A" | sort > "$TMP/inside.list" +rm -rf target/dist +cd "$TMP/ws" +"$MCPP" pack -p lib > root.log 2>&1 || fail "P1: pack -p lib at the root failed" root.log +B=$(ls lib/target/dist/*.tar.gz 2>/dev/null | head -1) +[ -n "$B" ] || fail "P1: pack -p did not write the member's archive" root.log +[ "$(basename "$A")" = "$(basename "$B")" ] || fail "P1: the archives are named differently: $(basename "$A") vs $(basename "$B")" root.log +tar tzf "$B" | sort > "$TMP/root.list" +diff -u "$TMP/inside.list" "$TMP/root.list" > "$TMP/diff.txt" || fail "P1: the archives differ" "$TMP/diff.txt" + +# P2 +"$MCPP" pack -p lib -o out829.tar.gz > p2.log 2>&1 || fail "P2: pack -p with -o failed" p2.log +[ -f "$TMP/ws/out829.tar.gz" ] || fail "P2: a relative -o did not land in the directory it was typed in" p2.log + +# P3 +if "$MCPP" pack -p nosuch > p3.log 2>&1; then fail "P3: an unknown member was accepted" p3.log; fi +grep -qi "nosuch" p3.log || fail "P3: the refusal does not name the value" p3.log + +echo "OK" From 0e46a682b388175009512a0ea9fb9f86ea652261 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:34:01 +0800 Subject: [PATCH 07/18] build: the project fast path states why it declined, under -v (#734 E5, the reading first) --- src/build/execute.cppm | 92 ++++++++++--------- ...ast_path_compares_the_toolchain_request.sh | 3 + 2 files changed, 53 insertions(+), 42 deletions(-) diff --git a/src/build/execute.cppm b/src/build/execute.cppm index 1a3a8f88..1f869c3b 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -1487,10 +1487,18 @@ bool xlings_payloads_present(const BuildCacheEntry& e) { }); } +// Why the project fast path declined, under `-v` (#734 E5). Each refusal names +// its condition, so a platform on which the fast path never serves shows which +// precondition it fails instead of only the slower build. +std::optional fast_path_declined(std::string_view path, std::string_view why) { + mcpp::log::verbose("fast-path", std::format("{} declined: {}", path, why)); + return std::nullopt; +} + export std::optional try_fast_build(const std::filesystem::path& projectRoot, bool verbose, bool no_cache, std::string_view currentTarget = "") { - if (no_cache) return std::nullopt; + if (no_cache) return fast_path_declined("build", "no_cache"); // `--locked` MUST NOT MEET THE FAST PATH, OR IT ASSERTS NOTHING. // @@ -1504,10 +1512,10 @@ export std::optional try_fast_build(const std::filesystem::path& projectRoo // Declining the fast path is the whole fix: `--locked` is for release // builds, audits and CI, none of which are the case the fast path serves. if (mcpp::platform::env::get("MCPP_LOCKED").value_or("") == "1") - return std::nullopt; + return fast_path_declined("build", "if (mcpp::platform::env::get(\"MCPP_LOCKED\").value_or(\"\") == \"1\")"); auto want = fast_path_identity(projectRoot); - if (!want) return std::nullopt; + if (!want) return fast_path_declined("build", "!want"); // #496. A project with build hooks always takes the full path. The fast // path is defined as "skip preparation", and `build_start` is specified to @@ -1515,7 +1523,7 @@ export std::optional try_fast_build(const std::filesystem::path& projectRoo // not exist until preparation has run. Declining here rather than in // cmd_build keeps the decision next to the manifest that answers it; the // full path then runs the hooks around run_build_plan. - if (want->hooksActive) return std::nullopt; + if (want->hooksActive) return fast_path_declined("build", "want->hooksActive"); // P3: read multi-entry cache and find the entry matching this // (target, profile, cache mode) triple. Matching on the target alone served @@ -1531,8 +1539,8 @@ export std::optional try_fast_build(const std::filesystem::path& projectRoo break; } } - if (!match) return std::nullopt; - if (!match->runtimeBinding) return std::nullopt; + if (!match) return fast_path_declined("build", "!match"); + if (!match->runtimeBinding) return fast_path_declined("build", "!match->runtimeBinding"); auto outputDirStr = match->outputDir; auto ninjaProgram = match->ninjaProgram; @@ -1544,13 +1552,13 @@ export std::optional try_fast_build(const std::filesystem::path& projectRoo auto runtimeEnvKey = match->runtimeEnvKey; auto runtimeEnvValue = match->runtimeEnvValue; if (runtimeEnvKey.empty()) - return std::nullopt; // old cache entry; regenerate build.ninja once + return fast_path_declined("build", "if (runtimeEnvKey.empty())"); // old cache entry; regenerate build.ninja once // P1: verify fingerprint matches the outputDir basename. if (!cachedFingerprint.empty()) { auto dirBasename = std::filesystem::path(outputDirStr).filename().string(); if (dirBasename != cachedFingerprint) { - return std::nullopt; + return fast_path_declined("build", "if (dirBasename != cachedFingerprint) {"); } } @@ -1558,7 +1566,7 @@ export std::optional try_fast_build(const std::filesystem::path& projectRoo std::filesystem::path outputDir(outputDirStr); auto ninjaPath = outputDir / "build.ninja"; - if (!std::filesystem::exists(ninjaPath, ec)) return std::nullopt; + if (!std::filesystem::exists(ninjaPath, ec)) return fast_path_declined("build", "!std::filesystem::exists(ninjaPath, ec)"); // #407. Freshness is measured against the SOURCES, which says nothing // about what kind of graph this is. `mcpp test` and @@ -1568,38 +1576,38 @@ export std::optional try_fast_build(const std::filesystem::path& projectRoo // that for a plain build linked the tests, never linked the target, and // printed `Finished`; and a broken file under tests/ (never scanned here) // failed a plain `mcpp build` outright. - if (!mcpp::build::is_plain_build_graph(ninjaPath)) return std::nullopt; + if (!mcpp::build::is_plain_build_graph(ninjaPath)) return fast_path_declined("build", "!mcpp::build::is_plain_build_graph(ninjaPath)"); auto ninjaTime = std::filesystem::last_write_time(ninjaPath, ec); - if (ec) return std::nullopt; + if (ec) return fast_path_declined("build", "ec"); auto runtimeManifest = match->runtimeBinding->subosDir / ".xlings.json"; auto runtimeTime = std::filesystem::last_write_time(runtimeManifest, ec); - if (ec || runtimeTime > ninjaTime) return std::nullopt; + if (ec || runtimeTime > ninjaTime) return fast_path_declined("build", "ec || runtimeTime > ninjaTime"); // Check mcpp.toml auto tomlPath = projectRoot / "mcpp.toml"; auto tomlTime = std::filesystem::last_write_time(tomlPath, ec); - if (ec || tomlTime > ninjaTime) return std::nullopt; + if (ec || tomlTime > ninjaTime) return fast_path_declined("build", "ec || tomlTime > ninjaTime"); // mcpp#225: bounded + vcs/build-dir-excluded walk (see sources_newer_than) // instead of a hand-rolled recursive_directory_iterator over src/. if (sources_newer_than(projectRoot, ninjaTime, want->resourceScripts, - want->extTable)) return std::nullopt; + want->extTable)) return fast_path_declined("build", "if (sources_newer_than(projectRoot, ninjaTime, want->resourceScripts,"); // A cache written before this field existed cannot say whether the build // had `path` dependencies, and answering "assume none" is the wrong // half of that guess: it would keep replaying a stale graph for exactly // the projects the field was added for. Decline once; the write below // records the list and every later invocation is fast again. - if (!match->depSourceRootsRecorded) return std::nullopt; + if (!match->depSourceRootsRecorded) return fast_path_declined("build", "!match->depSourceRootsRecorded"); if (dep_sources_newer_than(match->depSourceRoots, ninjaTime, want->extTable)) - return std::nullopt; - if (!xlings_payloads_present(*match)) return std::nullopt; + return fast_path_declined("build", "if (dep_sources_newer_than(match->depSourceRoots, ninjaTime, want->extTable))"); + if (!xlings_payloads_present(*match)) return fast_path_declined("build", "!xlings_payloads_present(*match)"); auto validatedBefore = mcpp::build::runtime_validation::validated_artifact_snapshot( outputDir, *match->runtimeBinding); - if (!validatedBefore) return std::nullopt; + if (!validatedBefore) return fast_path_declined("build", "!validatedBefore"); // All inputs are older than build.ninja → fast-path: just run ninja. // C1: this configuration is confirmed current, so the root database is @@ -1610,11 +1618,11 @@ export std::optional try_fast_build(const std::filesystem::path& projectRoo std::chrono::milliseconds elapsed{}; auto rc = run_ninja_fast(ninjaProgram, outputDir, ninjaPath, verbose, runtimeEnvKey, runtimeEnvValue, &elapsed); - if (!rc) return std::nullopt; + if (!rc) return fast_path_declined("build", "!rc"); if (*rc != 0) return rc; if (!mcpp::build::runtime_validation::artifact_snapshot_unchanged( *validatedBefore)) - return std::nullopt; // relinked: full path reconstructs + validates closure + return fast_path_declined("build", "*validatedBefore))"); // relinked: full path reconstructs + validates closure mcpp::ui::finished(want->profile, elapsed); return 0; @@ -1634,9 +1642,9 @@ std::optional try_fast_run(const std::filesystem::path& projectRoot, // Same reason as try_fast_build's: this path skips resolution, and // `--locked` is an assertion about resolution. if (mcpp::platform::env::get("MCPP_LOCKED").value_or("") == "1") - return std::nullopt; + return fast_path_declined("run", "if (mcpp::platform::env::get(\"MCPP_LOCKED\").value_or(\"\") == \"1\")"); auto want = fast_path_identity(projectRoot); - if (!want) return std::nullopt; + if (!want) return fast_path_declined("run", "!want"); // THE precondition of this whole function: it exec's the cached // artifact itself, so it is only ever valid when that artifact is for THIS @@ -1658,7 +1666,7 @@ std::optional try_fast_run(const std::filesystem::path& projectRoot, // `mcpp run` alone was correct; only build-then-run reached the cache. So // the fast path is off whenever a default target is declared, and the full // prepare — which is what resolves the runner — takes over. - if (!want->defaultTarget.empty()) return std::nullopt; + if (!want->defaultTarget.empty()) return fast_path_declined("run", "!want->defaultTarget.empty()"); auto entries = read_build_cache(projectRoot); const BuildCacheEntry* match = nullptr; @@ -1670,18 +1678,18 @@ std::optional try_fast_run(const std::filesystem::path& projectRoot, break; } } - if (!match || match->runTargets.empty()) return std::nullopt; + if (!match || match->runTargets.empty()) return fast_path_declined("run", "!match || match->runTargets.empty()"); // A runner declared for the host target (a wrapper such as valgrind, or // a triple that is native here but carries an emulator) is consulted on // the prepare path through choose_runner. This path has no manifest to // read the template from, and executing the artifact bare here while the // other door wraps it would make the second `mcpp run` behave differently // from the first. The entry records the fact; the fast path declines. - if (match->runnerDeclared) return std::nullopt; + if (match->runnerDeclared) return fast_path_declined("run", "match->runnerDeclared"); // The same reasoning one axis over: this entry was written by a verb that // installed less than a run needs, so taking it would execute with a // declared tool absent. prepare_build provisions the difference. - if (match->runTierPending) return std::nullopt; + if (match->runTierPending) return fast_path_declined("run", "match->runTierPending"); auto outputDirStr = match->outputDir; auto ninjaProgram = match->ninjaProgram; @@ -1690,7 +1698,7 @@ std::optional try_fast_run(const std::filesystem::path& projectRoot, && ninjaProgram.back() == '\'') ninjaProgram = ninjaProgram.substr(1, ninjaProgram.size() - 2); if (match->runtimeEnvKey.empty()) - return std::nullopt; // old cache entry; go through prepare_build once + return fast_path_declined("run", "if (match->runtimeEnvKey.empty())"); // old cache entry; go through prepare_build once // Written before this mcpp knew about subos environments (mcpp#352). Taking // the fast path here would run the program without them -- which is the // defect this field exists to fix, surviving an upgrade. @@ -1702,12 +1710,12 @@ std::optional try_fast_run(const std::filesystem::path& projectRoot, // pre-upgrade build until something else happened to invalidate it. Measured // on a real upgrade from 2026.8.7.1, not reasoned about. if (!match->runtimeBinding) - return std::nullopt; // predates the immutable snapshot; rebuild once + return fast_path_declined("run", "if (!match->runtimeBinding)"); // predates the immutable snapshot; rebuild once // P1: verify fingerprint matches the outputDir basename. if (!match->fingerprint.empty()) { auto dirBasename = std::filesystem::path(outputDirStr).filename().string(); - if (dirBasename != match->fingerprint) return std::nullopt; + if (dirBasename != match->fingerprint) return fast_path_declined("run", "dirBasename != match->fingerprint"); } // Locate the requested run-target before doing any filesystem freshness @@ -1719,52 +1727,52 @@ std::optional try_fast_run(const std::filesystem::path& projectRoot, chosen = &rt; if (targetName) break; } - if (!chosen) return std::nullopt; + if (!chosen) return fast_path_declined("run", "!chosen"); std::error_code ec; std::filesystem::path outputDir(outputDirStr); auto ninjaPath = outputDir / "build.ninja"; - if (!std::filesystem::exists(ninjaPath, ec)) return std::nullopt; + if (!std::filesystem::exists(ninjaPath, ec)) return fast_path_declined("run", "!std::filesystem::exists(ninjaPath, ec)"); // #407, same reason as try_fast_build: a test-shaped graph does not build // the run target at all, so running ninja against it would report success // and then exec a stale (or absent) binary. - if (!mcpp::build::is_plain_build_graph(ninjaPath)) return std::nullopt; + if (!mcpp::build::is_plain_build_graph(ninjaPath)) return fast_path_declined("run", "!mcpp::build::is_plain_build_graph(ninjaPath)"); auto ninjaTime = std::filesystem::last_write_time(ninjaPath, ec); - if (ec) return std::nullopt; + if (ec) return fast_path_declined("run", "ec"); auto runtimeManifest = match->runtimeBinding->subosDir / ".xlings.json"; auto runtimeTime = std::filesystem::last_write_time(runtimeManifest, ec); - if (ec || runtimeTime > ninjaTime) return std::nullopt; + if (ec || runtimeTime > ninjaTime) return fast_path_declined("run", "ec || runtimeTime > ninjaTime"); auto tomlPath = projectRoot / "mcpp.toml"; auto tomlTime = std::filesystem::last_write_time(tomlPath, ec); - if (ec || tomlTime > ninjaTime) return std::nullopt; + if (ec || tomlTime > ninjaTime) return fast_path_declined("run", "ec || tomlTime > ninjaTime"); if (sources_newer_than(projectRoot, ninjaTime, want->resourceScripts, - want->extTable)) return std::nullopt; + want->extTable)) return fast_path_declined("run", "if (sources_newer_than(projectRoot, ninjaTime, want->resourceScripts,"); // Same gate as try_fast_build's, and it has to be BOTH places: `mcpp run` // reaches its binary through this path, so a `run` that skipped the check // would execute an artifact built from a source set that no longer exists. - if (!match->depSourceRootsRecorded) return std::nullopt; + if (!match->depSourceRootsRecorded) return fast_path_declined("run", "!match->depSourceRootsRecorded"); if (dep_sources_newer_than(match->depSourceRoots, ninjaTime, want->extTable)) - return std::nullopt; - if (!xlings_payloads_present(*match)) return std::nullopt; + return fast_path_declined("run", "if (dep_sources_newer_than(match->depSourceRoots, ninjaTime, want->extTable))"); + if (!xlings_payloads_present(*match)) return fast_path_declined("run", "!xlings_payloads_present(*match)"); auto validatedBefore = mcpp::build::runtime_validation::validated_artifact_snapshot( outputDir, *match->runtimeBinding); - if (!validatedBefore) return std::nullopt; + if (!validatedBefore) return fast_path_declined("run", "!validatedBefore"); // Fresh → run ninja (picks up any incremental object/link work) then // exec the cached exe path directly. C1, same reason as try_fast_build's. restore_root_compile_commands(projectRoot, outputDir); auto rc = run_ninja_fast(ninjaProgram, outputDir, ninjaPath, /*verbose=*/false, match->runtimeEnvKey, match->runtimeEnvValue); - if (!rc) return std::nullopt; + if (!rc) return fast_path_declined("run", "!rc"); if (*rc != 0) return rc; if (!mcpp::build::runtime_validation::artifact_snapshot_unchanged( *validatedBefore)) - return std::nullopt; // never execute an artifact not validated for this binding + return fast_path_declined("run", "*validatedBefore))"); // never execute an artifact not validated for this binding auto exe = outputDir / chosen->second; auto pathCtx = mcpp::fetcher::make_path_ctx(/*cfg=*/nullptr, projectRoot); diff --git a/tests/e2e/645_the_fast_path_compares_the_toolchain_request.sh b/tests/e2e/645_the_fast_path_compares_the_toolchain_request.sh index dcef102f..5705c886 100755 --- a/tests/e2e/645_the_fast_path_compares_the_toolchain_request.sh +++ b/tests/e2e/645_the_fast_path_compares_the_toolchain_request.sh @@ -55,6 +55,9 @@ if [ "$(resolutions a2.log)" != 0 ]; then if [ -n "$first_request" ] && [ "$first_request" = "$second_request" ]; then echo "NOT MEASURED: an unchanged second build did not take the fast path on this host, and both builds recorded the same request ($first_request)" echo "--- the recorded entry ---"; cat target/.build_cache + # #734 E5: the fast path states why it declined under -v. + "$MCPP" build -v > a3.log 2>&1 || true + echo "--- why the fast path declined ---"; grep "fast-path" a3.log || echo "(no reason printed)" exit 0 fi fail "control: an unchanged second build resolved the toolchain, and the recorded requests differ ('$first_request', then '$second_request')" a2.log From a6a2d6fafdc9921e01424061aa7d25486ab4a39c Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:47:32 +0800 Subject: [PATCH 08/18] workspace: a member used as a path dependency is built once, in its own directory, and consumers take its objects from there (#734 E1) --- src/build/ninja_backend.cppm | 6 +- src/build/plan.cppm | 10 + src/build/prepare/plan.cpp | 184 ++++++++++++++++++ .../830_a_shared_member_is_compiled_once.sh | 118 +++++++++++ 4 files changed, 316 insertions(+), 2 deletions(-) create mode 100755 tests/e2e/830_a_shared_member_is_compiled_once.sh diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index e10e505e..7fcf8305 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -2283,13 +2283,15 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements) { auto obj = escape_ninja_path(cu.object); append(std::format("build {} : stage_file {}\n", obj, escape_ninja_path(cu.cachedObject))); - append(" verify = --verify size\n"); + // A member's own build rewrites its outputs in place, and an + // object of equal size is no evidence of equal content: content. + if (!cu.servedFromMember) append(" verify = --verify size\n"); staged.push_back(obj); if (!cu.providesModule.empty() && !cu.cachedBmi.empty()) { auto bmi = bmi_path(cu.providesModule); append(std::format("build {} : stage_file {}\n", bmi, escape_ninja_path(cu.cachedBmi))); - append(" verify = --verify size\n"); + if (!cu.servedFromMember) append(" verify = --verify size\n"); staged.push_back(bmi); } } diff --git a/src/build/plan.cppm b/src/build/plan.cppm index 7231121c..6536aacf 100644 --- a/src/build/plan.cppm +++ b/src/build/plan.cppm @@ -71,6 +71,10 @@ struct CompileUnit { bool servedFromCache = false; std::filesystem::path cachedObject; // absolute, inside the cache std::filesystem::path cachedBmi; // absolute; empty if no module + // #734 E1: served from a workspace member's own build directory rather + // than from an immutable cache entry. Its bytes change under an unchanged + // name, so its stage edges compare content, not size. + bool servedFromMember = false; // mcpp#344: this object's address INSIDE a global-cache entry — relative to // `/obj/`, and a pure function of the owning package (its source's // path relative to its own package root). Distinct from `object`, which is @@ -384,6 +388,12 @@ struct BuildPlan { std::vector rcFlags; // -I / -D, target-shaped std::vector compileUnits; // topologically sorted + // Each package's build key (`cache_key::key_hex`), indexed as the graph's + // packages, [0] the root. Set in the global cache mode. #734 E1 compares a + // workspace member's key in a consumer's graph with the member's key as the + // root of its own build: equal keys are equal compile commands. + std::vector packageKeys; + std::vector packageKeyInputs; // each key's inputs, as JSON text std::vector linkUnits; // Build-graph nodes declared by build programs (`mcpp:action=`). Paths are // absolute and engine variables already substituted by the time they get diff --git a/src/build/prepare/plan.cpp b/src/build/prepare/plan.cpp index b9e39dc6..04f166a1 100644 --- a/src/build/prepare/plan.cpp +++ b/src/build/prepare/plan.cpp @@ -1796,6 +1796,190 @@ static std::expected step13_dependency_cache(PrepareState& st for (std::size_t i = 0; i < state.packages.size(); ++i) (void)compute_key(compute_key, i); if (!keyCycleError.empty()) return std::unexpected(keyCycleError); + ctx.plan.packageKeys = pkgKeys; + ctx.plan.packageKeyInputs.clear(); + for (auto const& j : pkgInputs) ctx.plan.packageKeyInputs.push_back(j.dump()); + + // ── #734 E1: a workspace member used as a path dependency ───────── + // + // Built once, in its own directory, as the root of its own build, and + // taken from there by every member that consumes it. The member's own + // ninja decides what is stale, so an input outside the member's root + // (a header under `../3rdParty`) counts as it does for the member's + // own build; nothing here stamps files. The member's objects and + // module interfaces reach this graph through the stage edges the + // global cache uses. Equal build keys are the admission rule: the key + // holds everything that decides a unit's compile command, so a member + // whose key differs here (other features, another profile) is compiled + // in this graph as before. A host-tool sub-build and a planning-only + // command (`mcpp emit build-database`) never build a member. + if (state.overrides.tool_depth == 0 && !state.overrides.plan_only + && state.wsManifest && !state.runtimeWorkspaceRoot.empty()) { + const auto wsRoot = state.runtimeWorkspaceRoot.lexically_normal(); + auto memberPath = [&](const std::filesystem::path& root) -> std::string { + const auto rel = root.lexically_normal().lexically_relative(wsRoot).generic_string(); + if (rel.empty() || rel == "." || rel.starts_with("..")) return {}; + for (auto const& m : state.wsManifest->workspace.members) { + if (m == rel) return rel; + if (m.ends_with("/*") && rel.starts_with(m.substr(0, m.size() - 1)) + && rel.find('/', m.size() - 1) == std::string::npos) + return rel; + } + return {}; + }; + auto qualified = [](const mcpp::manifest::Manifest& mm) { + return mm.package.namespace_.empty() ? mm.package.name + : mm.package.namespace_ + "." + mm.package.name; + }; + auto bmiT = mcpp::toolchain::bmi_traits(*state.tc); + for (std::size_t i = 1; i < state.packages.size(); ++i) { + const auto* depIdent = i - 1 < state.dep_cache_identities.size() + ? &state.dep_cache_identities[i - 1] : nullptr; + if (!depIdent || depIdent->sourceKind != "path") continue; + const auto& pkgRoot = state.packages[i]; + const auto member = memberPath(pkgRoot.root); + if (member.empty()) continue; + + BuildOverrides sub; + sub.project_root = state.runtimeWorkspaceRoot; + sub.package_filter = member; + sub.target_triple = state.overrides.target_triple; + sub.accel = state.overrides.accel; + sub.force_static = state.overrides.force_static; + sub.profile = state.overrides.profile; + sub.profile_fallback = state.overrides.profile_fallback; + sub.capabilities = state.overrides.capabilities; + sub.toolchain = state.overrides.toolchain; + sub.cache_mode = state.overrides.cache_mode; + if (i < state.activeFeaturesByPackage.size()) + for (auto const& f : state.activeFeaturesByPackage[i]) { + if (!sub.features.empty()) sub.features += ","; + sub.features += f; + } + auto subCtx = prepare_build(/*print_fingerprint=*/false, + /*includeDevDeps=*/false, /*extraTargets=*/{}, sub); + const auto who = qualified(pkgRoot.manifest); + if (!subCtx) { + mcpp::log::verbose("workspace-member", std::format( + "{} is compiled in this graph: its own build could not be " + "planned: {}", who, subCtx.error())); + continue; + } + // The admission compares the key's INPUTS, less `package.index`: + // that field names where a package came from (the namespace a + // dependency is reached under; empty for a root), not how it + // compiles. Every other input must be equal. + auto compile_inputs = [](nlohmann::json j) { + if (j.is_object() && j.contains("package") && j["package"].is_object()) + j["package"].erase("index"); + return j; + }; + const bool sameCompile = !subCtx->plan.packageKeyInputs.empty() + && compile_inputs(nlohmann::json::parse(subCtx->plan.packageKeyInputs[0], nullptr, false)) + == compile_inputs(pkgInputs[i]); + if (!sameCompile) { + // Name the inputs that differ: a mismatch is either a + // real difference (another feature set) or a key input + // that depends on the position, and only the field names + // tell which. + std::string fields; + if (!subCtx->plan.packageKeyInputs.empty()) { + auto own = nlohmann::json::parse(subCtx->plan.packageKeyInputs[0], nullptr, false); + auto here = pkgInputs[i]; + if (own.is_object() && here.is_object()) + for (auto it = here.begin(); it != here.end(); ++it) { + if (own.contains(it.key()) && own[it.key()] == it.value()) continue; + if (it.value().is_object() && own.contains(it.key()) + && own[it.key()].is_object()) { + auto const& o = own[it.key()]; + for (auto jt = it.value().begin(); jt != it.value().end(); ++jt) + if (!o.contains(jt.key()) || o[jt.key()] != jt.value()) { + if (!fields.empty()) fields += ", "; + fields += std::format("{}.{} (here {}, own {})", it.key(), + jt.key(), jt.value().dump(), + o.contains(jt.key()) ? o[jt.key()].dump() : "absent"); + } + continue; + } + if (!fields.empty()) fields += ", "; + fields += it.key(); + } + } + mcpp::log::verbose("workspace-member", std::format( + "{} is compiled in this graph: its build key here ({}) differs " + "from its own ({}); differing inputs: {}", who, pkgKeys[i], + subCtx->plan.packageKeys.empty() ? std::string("none") + : subCtx->plan.packageKeys[0], + fields.empty() ? std::string("(unknown)") : fields)); + continue; + } + // Every unit of the member in this graph must have its + // counterpart in the member's own build, or none is taken: + // a half-served package is the mixed state the global cache + // refuses for the same reason. + std::map own; + for (auto const& scu : subCtx->plan.compileUnits) + own.emplace(scu.source.lexically_normal(), &scu); + std::vector> pairs; + bool complete = true; + for (std::size_t u = 0; u < ctx.plan.compileUnits.size(); ++u) { + auto const& cu = ctx.plan.compileUnits[u]; + if (cu.packageName != who) continue; + auto it = own.find(cu.source.lexically_normal()); + if (it == own.end()) { complete = false; break; } + pairs.push_back({u, it->second}); + } + if (!complete || pairs.empty()) { + mcpp::log::verbose("workspace-member", std::format( + "{} is compiled in this graph: its units here and in its own " + "build are not the same set", who)); + continue; + } + + mcpp::ui::status("Building", std::format( + "workspace member {} in its own directory, once for every " + "member that uses it", who)); + // Two consumers built at once (two `-p` commands) share this + // directory; one ninja runs in it at a time. + std::error_code lockEc; + std::filesystem::create_directories(subCtx->plan.outputDir, lockEc); + std::optional memberLock; + for (int waited = 0; + !(memberLock = mcpp::platform::fs::FileLock::try_acquire(subCtx->plan.outputDir)); + ++waited) { + if (waited == 0) + mcpp::ui::status("Waiting", std::format( + "for another build of workspace member {}", who)); + if (waited >= 3000) return std::unexpected(std::format( + "workspace member {}: another build has held {} for ten minutes", + who, subCtx->plan.outputDir.string())); + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + } + auto be = mcpp::build::make_ninja_backend(); + mcpp::build::BuildOptions bopt; + auto br = be->build(subCtx->plan, bopt); + memberLock.reset(); + if (!br) return std::unexpected(std::format( + "building workspace member {} failed: {}\n{}", who, + br.error().message, br.error().diagnosticOutput)); + if (br->exitCode != 0) return std::unexpected(std::format( + "building workspace member {} failed (exit {})", who, br->exitCode)); + + const auto subOut = subCtx->plan.outputDir; + for (auto const& [u, scu] : pairs) { + auto& cu = ctx.plan.compileUnits[u]; + cu.servedFromCache = true; + cu.servedFromMember = true; + cu.cachedObject = subOut / scu->object; + if (!cu.providesModule.empty()) { + std::string bmi; + for (char c : cu.providesModule) bmi.push_back(c == ':' ? '-' : c); + bmi += std::string(bmiT.bmiExt); + cu.cachedBmi = subOut / std::string(bmiT.bmiDir) / bmi; + } + } + } + } for (std::size_t i = 1; i < state.packages.size(); ++i) { // skip [0] = main const auto& pkgRoot = state.packages[i]; diff --git a/tests/e2e/830_a_shared_member_is_compiled_once.sh b/tests/e2e/830_a_shared_member_is_compiled_once.sh new file mode 100755 index 00000000..506865ff --- /dev/null +++ b/tests/e2e/830_a_shared_member_is_compiled_once.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# requires: unix-shell +# 830_a_shared_member_is_compiled_once.sh -- #734 E1. +# +# A workspace member that other members use as a path dependency is built once, +# in its own directory, and every consumer takes its objects and module +# interfaces from there. Its own ninja decides what is stale, including inputs +# outside its root. The fixture has the shape that measured the defect: a +# library member with module units, one unit outside its root, and a header +# outside its root; two program members use it. +# +# W1 `mcpp build --workspace` compiles each library unit once in total +# (counted from every build directory's .ninja_log); +# W2 a following `mcpp build -p app2` compiles no library unit; +# W3 a header outside the library's root changes: the next build compiles +# its includer once and no other library unit, and both programs run the +# new value; +# W4 after another change, `-p app1` and `-p app2` run at the same time: +# both succeed, the includer is compiled once, and both run the new +# value (one build of the member directory at a time). +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } +MCPP="${MCPP:-mcpp}" + +mkdir -p ws/lib/src ws/shared ws/app1/src ws/app2/src +cd ws +cat > mcpp.toml <<'EOF' +[workspace] +members = ["lib", "app1", "app2"] + +[workspace.package] +version = "0.1.0" +EOF +cat > lib/mcpp.toml <<'EOF' +[package] +namespace = "probe" +name = "core830" + +[build] +sources = ["src/*.cppm", "src/*.cpp", "../shared/extra.cpp"] + +[targets.core830] +kind = "lib" +EOF +printf '#pragma once\ninline int shared_value() { return 7; }\n' > shared/value.h +printf 'export module probe.core830;\nexport int core_one();\nexport int core_extra();\n' > lib/src/core830.cppm +printf 'module probe.core830;\nint core_one() { return 1; }\n' > lib/src/one.cpp +printf 'module;\n#include "value.h"\nmodule probe.core830;\nint core_extra() { return shared_value(); }\n' > shared/extra.cpp +for a in app1 app2; do + printf '[package]\nname = "%s"\n\n[dependencies]\n"probe.core830" = { path = "../lib" }\n' "$a" > $a/mcpp.toml + printf 'import probe.core830;\n#include \nint main() { std::printf("%%d\\n", core_one() + core_extra()); return 0; }\n' > $a/src/main.cpp +done + +lib_compiles() { # compile edges that produced a library object, in every build directory + local n=0 + for log in $(find . -name .ninja_log -path '*target*' 2>/dev/null); do + local dir; dir=$(dirname "$log") + # The rule of each output the log records, read from that directory's + # build.ninja: a library object staged from the member's directory is + # a `stage_file` edge, a compile is a `cxx_module` / `cxx_object` edge. + c=$(awk -F'\t' 'NR>1 {print $4}' "$log" | grep -E '(core830|one|extra)\.(m\.)?o$' | + while read -r out; do + grep -E "^build [^:]*${out//./\\.}[ |:]" "$dir/build.ninja" | head -1 | + sed -E 's/^build [^:]*: ([a-z_]+).*/\1/' + done | grep -cE '^(cxx_module|cxx_object|c_object)$' || true) + n=$((n + c)) + done + echo "$n" +} + +# W1 +"$MCPP" build --workspace > w1.log 2>&1 || fail "W1: the workspace build failed" w1.log +units=3 # core830.cppm, one.cpp, ../shared/extra.cpp +got=$(lib_compiles) +[ "$got" -eq "$units" ] || { + for log in $(find . -name .ninja_log -path '*target*'); do echo "== $log"; awk -F'\t' 'NR>1 {print $4}' "$log" | grep -E "core830|one\.o|extra\.o"; done > w1.edges + fail "W1: the library's $units units were compiled $got times" w1.log w1.edges +} +grep -q "workspace member probe.core830 in its own directory" w1.log || fail "W1: the shared build was not taken" w1.log + +# W2 +before=$(lib_compiles) +"$MCPP" build -p app2 > w2.log 2>&1 || fail "W2: the build of app2 failed" w2.log +after=$(lib_compiles) +[ "$before" = "$after" ] || fail "W2: building app2 compiled library units again ($before -> $after)" w2.log + +# W3 +sleep 1.1 +printf '#pragma once\ninline int shared_value() { return 40; }\n' > shared/value.h +before=$(lib_compiles) +"$MCPP" build --workspace > w3.log 2>&1 || fail "W3: the rebuild failed" w3.log +after=$(lib_compiles) +[ $((after - before)) -eq 1 ] || fail "W3: a header outside the root caused $((after - before)) library compiles, not 1" w3.log +for a in app1 app2; do + "$MCPP" run -p $a > run-$a.log 2>&1 || fail "W3: $a did not run" run-$a.log + grep -qx 41 run-$a.log || fail "W3: $a did not take the changed header" run-$a.log +done + +# W4 +sleep 1.1 +printf '#pragma once\ninline int shared_value() { return 90; }\n' > shared/value.h +before=$(lib_compiles) +"$MCPP" build -p app1 > w4a.log 2>&1 & p1=$! +"$MCPP" build -p app2 > w4b.log 2>&1 & p2=$! +wait $p1 || fail "W4: the concurrent build of app1 failed" w4a.log +wait $p2 || fail "W4: the concurrent build of app2 failed" w4b.log +after=$(lib_compiles) +[ $((after - before)) -eq 1 ] || fail "W4: two concurrent builds compiled the includer $((after - before)) times" w4a.log w4b.log +for a in app1 app2; do + "$MCPP" run -p $a > run4-$a.log 2>&1 || fail "W4: $a did not run" run4-$a.log + grep -qx 91 run4-$a.log || fail "W4: $a did not take the changed header" run4-$a.log +done + +echo "OK" From c49d1275dbef5d1fe1476fdd1c5d917ff41ed7af Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:58:24 +0800 Subject: [PATCH 09/18] tests: the placement list edge, the plugin naming wording, and the protocol table check; W3 needs the text scanner's re-exports (#734) --- .github/workflows/ci-linux.yml | 3 ++ src/build/prepare/scan.cpp | 5 +++ .../610_feature_controlled_rule_collection.sh | 4 +- tests/scripts/test_protocol_table.py | 45 +++++++++++++++++++ tests/unit/test_ninja_backend.cpp | 16 +++++-- 5 files changed, 67 insertions(+), 6 deletions(-) create mode 100644 tests/scripts/test_protocol_table.py diff --git a/.github/workflows/ci-linux.yml b/.github/workflows/ci-linux.yml index a37baafb..448787e3 100644 --- a/.github/workflows/ci-linux.yml +++ b/.github/workflows/ci-linux.yml @@ -110,6 +110,9 @@ jobs: - name: The release canary runner runs each command under the named bash run: python3 tests/scripts/test_release_canaries.py + - name: The protocol table of SPEC-007 names the engine's protocol + run: python3 tests/scripts/test_protocol_table.py + # Text-only, like the two steps around it, and it belongs here rather # than in the target-matrix workflow: that workflow runs the matrix, and # this asserts a property of the TABLE, which is readable without a diff --git a/src/build/prepare/scan.cpp b/src/build/prepare/scan.cpp index e3a895b4..8c53b8a9 100644 --- a/src/build/prepare/scan.cpp +++ b/src/build/prepare/scan.cpp @@ -883,6 +883,11 @@ step11_prebuild_std_module(PrepareState& state, bool needsStdModule) { // dependency without a root, which states no interface. static void step11_public_module_check(PrepareState& state) { if (state.packages.empty()) return; + // The re-exports are read by the text scanner only; a P1689 scan reports + // imports without saying which are `export import`, and every re-exported + // module would then read as private. No reading, no warning. + if (const char* sel = std::getenv("MCPP_SCANNER"); sel && std::string_view(sel) == "p1689") + return; const auto& g = state.scan.graph; auto qualified = [](const mcpp::manifest::Manifest& m) { return m.package.namespace_.empty() ? m.package.name diff --git a/tests/e2e/610_feature_controlled_rule_collection.sh b/tests/e2e/610_feature_controlled_rule_collection.sh index 55df8481..685ec6b6 100755 --- a/tests/e2e/610_feature_controlled_rule_collection.sh +++ b/tests/e2e/610_feature_controlled_rule_collection.sh @@ -127,7 +127,7 @@ cd app out="$("$MCPP" run 2>&1 | grep '^A=' | tail -1)" [[ "$out" == 'A=1 B=0 V=0.1.0' ]] || { echo "FAIL: expected A=1 B=0 V=0.1.0, got: $out"; exit 1; } echo "PASS: a feature the consumer activates makes its unit importable under its declared name" -if grep -q 'prefix is reserved for rules maintained by the mcpp project' b1.log; then +if grep -q 'belongs to the modules maintained by the mcpp project' b1.log; then echo "FAIL: the mcpp namespace drew a reserved-prefix warning"; cat b1.log; exit 1; fi echo "PASS: a collection in the mcpp namespace draws no reserved-prefix warning" cd .. @@ -155,7 +155,7 @@ write_collection acme write_app acme '"rules-a", "rules-b"' 'mcpp.rules.a mcpp.rules.b' cd app "$MCPP" build > b4.log 2>&1 || { cat b4.log; echo "FAIL: the acme collection did not build"; exit 1; } -n=$(grep -c 'prefix is reserved for rules maintained by the mcpp project' b4.log || true) +n=$(grep -c 'belongs to the modules maintained by the mcpp project' b4.log || true) # three units -- the lib root `mcpp.plugins` and the two rules -- each claims the prefix [[ "$n" -eq 3 ]] || { echo "FAIL: expected 3 reserved-prefix warnings (one per unit), got $n"; cat b4.log; exit 1; } echo "PASS: the same collection under another namespace draws one warning per unit" diff --git a/tests/scripts/test_protocol_table.py b/tests/scripts/test_protocol_table.py new file mode 100644 index 00000000..2f5b0066 --- /dev/null +++ b/tests/scripts/test_protocol_table.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""SPEC-007 R9.2 (#734 E8): the protocol table of the specification names the +protocol the engine speaks. + +The table maps each build-program protocol to the first mcpp release that +carries it, and plugins state their needs as releases (R9.8). A protocol bump +that does not add its row leaves plugin authors with no release to name, so the +table's newest row must be the engine's `kProtocolVersion`. +""" +import pathlib +import re +import sys + +ROOT = pathlib.Path(__file__).resolve().parents[2] + + +def engine_protocol() -> int: + text = (ROOT / "modules/buildmcpp/src/program_protocol.cppm").read_text() + m = re.search(r"inline constexpr int kProtocolVersion = (\d+);", text) + assert m, "kProtocolVersion not found" + return int(m.group(1)) + + +def table_protocols() -> list[tuple[int, str]]: + text = (ROOT / "docs/specs/build-plugins.md").read_text() + rows = re.findall(r"^\s*\|\s*(\d+)\s*\|\s*(\d{4}\.\d+\.\d+\.\d+)\s*\|", text, re.M) + return [(int(p), r) for p, r in rows] + + +def main() -> int: + have = engine_protocol() + rows = table_protocols() + if not rows: + print("FAIL: SPEC-007 has no protocol table") + return 1 + newest = max(p for p, _ in rows) + if newest != have: + print(f"FAIL: SPEC-007's newest protocol row is {newest}; the engine speaks {have}") + return 1 + print(f"OK: SPEC-007 names protocol {have} and its first release") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/unit/test_ninja_backend.cpp b/tests/unit/test_ninja_backend.cpp index 01e37e84..d41e09b9 100644 --- a/tests/unit/test_ninja_backend.cpp +++ b/tests/unit/test_ninja_backend.cpp @@ -1574,11 +1574,19 @@ TEST(NinjaBackendPeRuntime, ToolchainCoupledStagesTheToolsetCrtBesideTheExe) { EXPECT_EQ(d.sources.front().extension(), ".dll") << d.sources.front().string(); } - auto ninja = emit_ninja_string(plan); + std::string placements; + auto ninja = emit_ninja_string(plan, &placements); + // #734 E4: two or more placements are one `stage_list` edge whose outputs + // are every destination, and the list the edge reads names each pair. + const auto listAt = ninja.find(": stage_list "); + ASSERT_NE(listAt, std::string::npos) << ninja; + const auto listLine = ninja.substr(ninja.rfind("\nbuild ", listAt) + 1, + ninja.find('\n', listAt) - ninja.rfind("\nbuild ", listAt) - 1); for (auto name : {"vcruntime140.dll", "msvcp140.dll", "vcruntime140_1.dll"}) { - EXPECT_NE(ninja.find(std::format("build bin/{} : stage_file", name)), - std::string::npos) - << name << " has no copy edge\n" << ninja; + EXPECT_NE(listLine.find(std::format("bin/{}", name)), std::string::npos) + << name << " is not an output of the placement edge\n" << ninja; + EXPECT_NE(placements.find(std::format("\tbin/{}", name)), std::string::npos) + << name << " is not in the placement list\n" << placements; } EXPECT_EQ(ninja.find("Microsoft.VC143.CRT.manifest"), std::string::npos) << "copied something that is not a runtime DLL\n" << ninja; From 0de80d720b47245939247fc291191a1d63b43b29 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:58:24 +0800 Subject: [PATCH 10/18] 2026.9.28.3: SPEC-007 section 9, SPEC-008, the chapters and the changelog for #734; the design record --- ...ign-toolsets-and-library-surface-design.md | 1221 +++++++++++++++++ .agents/docs/README.md | 4 +- CHANGELOG.md | 50 + docs/04-mcpp-toml.md | 20 + docs/07-workspace.md | 22 + docs/10-pack-and-release.md | 6 + docs/12-binary-distribution.md | 17 + docs/30-build-mcpp.md | 95 +- docs/README.md | 1 + docs/specs/README.md | 3 +- docs/specs/build-plugins.md | 59 +- docs/specs/library-interface.md | 85 ++ docs/zh/04-mcpp-toml.md | 15 + docs/zh/07-workspace.md | 15 + docs/zh/10-pack-and-release.md | 5 + docs/zh/12-binary-distribution.md | 13 + docs/zh/30-build-mcpp.md | 81 +- docs/zh/README.md | 1 + mcpp.toml | 2 +- modules/versioning/src/version.cppm | 2 +- 20 files changed, 1699 insertions(+), 18 deletions(-) create mode 100644 .agents/docs/2026-09-28-build-cost-foreign-toolsets-and-library-surface-design.md create mode 100644 docs/specs/library-interface.md diff --git a/.agents/docs/2026-09-28-build-cost-foreign-toolsets-and-library-surface-design.md b/.agents/docs/2026-09-28-build-cost-foreign-toolsets-and-library-surface-design.md new file mode 100644 index 00000000..fdaa9a30 --- /dev/null +++ b/.agents/docs/2026-09-28-build-cost-foreign-toolsets-and-library-surface-design.md @@ -0,0 +1,1221 @@ +--- +subject: design +status: active +--- + +# Build cost, foreign toolsets, the build-plugin architecture and the library surface: engine, plugin and index design (#734) + +**Status:** active, revision 5 (2026-09-28). The design is settled (§11); §13 +divides it into tasks per repository, with their dependencies and criteria, and +records their implementation. + +- **Revision 1** proposed engine items E1 to E6 and plugin items P1 to P5. +- **Revision 2** records the first review: + - D1 (keyed sub-build) and D2 (general build information) are settled. + - E6 is settled as warnings only. + - It designs the library interface specification and the plugin implementation. +- **Revision 3** records the Windows readings of §2.2. E2b is confirmed, E4 is + adopted, and P1's default is designed again as `resolved`, with a mechanism + chosen by the toolset's origin. +- **Revision 5** settles D11 and D16 and adds §13: the tasks, their order across + the three repositories and the validation project, and the record of their + implementation. +- **Revision 4** records three rounds of discussion and a global review (§12): + - **The build-plugin architecture (§3).** It has three layers: `mcpp.core` + (engine), `mcpp.plugins` (official general library) and plugins (official + families and third parties). `import mcpp` and `import mcpp.core` are + permanently equivalent. + - **Module naming (§3.5).** It extends the engine's existing reserved-prefix + warning and becomes an admission rule of mcpp-index. + - **A per-package engine floor (§3.6).** `[package] mcpp = ">=V"`. + - **Compatibility units (§3.7).** They carry a six-month retirement date. + D3 is settled on that basis: `detected` moves to a compatibility unit. + - **L2 behind one feature.** The general library is reached through one + feature, `plugins-core`, which every family feature implies. This replaces + revision 3's E7 (a `[host-module]` table), which is withdrawn. The new E7 is a + general improvement: a missing build-program module names the feature that + provides it. + - **Corrections found by the global review.** Among them: an older engine does + **not** refuse an unknown key in `[lib]`, `[build]` or `[package]` (measured), + so revision 2's compatibility argument is corrected. `[lib]` accepts an + unknown key without any warning, which is a defect (E12). + +**Input.** The Windows CI of Sunrisepeak/GalTranslPP#3 (runs 36324593343, +36378870254 and 36396398845; windows-2025, 4 vCPU, mcpp 2026.9.28.2, mcpp:plugins +0.16.0), the sources of mcpp at `dbf71941`, of mcpp-plugins at `8e0362d`, of +vcpkg-tool and of mcpp-index at `e9b80c5`, vcpkg's documentation of triplets and +binary caching, local measurements with mcpp 2026.9.28.2, and the Windows readings +of §2.2. + +## 0. Scope and the rule that selects the items + +GalTranslPP is a validation project: a five-member workspace on Windows with a core +library of 76 translation units, a Qt GUI, 22 vcpkg ports and one CMake project. +It is evidence, not a requirement. + +An item enters this design only if its need survives the removal of that project, +that is, only if it can be stated for every workspace, every foreign build system +or every platform. The official plugins are general plugins in the same sense. +Their behaviour is chosen through options, and nothing in them names a project. +Project-specific items are listed in §9 with the reason they stay with the project. + +## 1. Principles + +1. **The engine provides general mechanisms only.** It states facts about the + resolved build in a form that belongs to no foreign build system, and it never + learns CMake, vcpkg or Qt. +2. **A plugin behaviour is an option set from `build.mcpp`, and its default is + the best design that readings support.** A default that differs from the + previous release is adopted only when readings show which builds it changes. + Each such build must either gain consistency (the same toolset, the same + runtime) at the cost of one rebuild, or stop with a named error where the + previous release produced an inconsistent result. The previous behaviour stays + available for six months in a compatibility unit (§3.7). +3. **A check is made by the reader of the property it protects.** A warning that + no step reads is noise. A step that reads a property and does not check it + produces silently wrong output. +4. **No silent wrong output.** Where the engine cannot do what was asked, it says + so. An explicit statement that cannot be met is an error; an undeclared + default may fall back, and says so once. +5. **Every change states its upgrade cost.** One re-run of the build programs, one + recompilation of path dependencies or one rebuild of vcpkg ports is acceptable + when the changelog states it. A changed result is not acceptable. +6. **One authority per fact.** Which toolset builds a graph is decided by mcpp's + toolchain selection alone. A plugin does not keep a second answer. + Equivalent spellings of one name (`mcpp` and `mcpp.core`; `host-module`) are + permanent, and neither is deprecated. + +## 2. Readings + +### 2.1 From the validation project's CI and from the sources + +| Reading | Value | Source | +|---|---|---| +| `mcpp build --workspace`, vcpkg binaries cached | 1726 s | run 36378870254 | +| of which: member `GalTranslPP` (core) | 285 s | same | +| of which: `GPPCLI`, including a second compilation of core | 315 s | same | +| of which: `GPPGUI`, including a third compilation of core | 1039 s | same | +| core's 76 compile commands in the three positions | identical except the output directory (76/76) | `mcpp emit build-database`, same run | +| `mcpp build --workspace`, no vcpkg cache | 69.7 min, of which about 43 min are the 22 ports | run 36324593343 | +| `mcpp pack --format release`, two members | 168 s, of which about 70 s are 2675 single-file copy actions | run 36378870254 | +| files deployed beside each program by `mcpp build` | about 1270 per program, each one `stage_file` edge | `release-files.txt`, run 36396398845; `ninja_backend.cppm` | +| a no-op `mcpp run -p GPPCLI` on Windows | about 3 s; the project fast path is not taken | run 36378870254 | +| build systems of the 22 ports at baseline `ee6a47d` | 18 CMake, 3 header-only, 1 make under msys (icu); none MSBuild | the ports' `portfile.cmake` | +| `mcpp pack` of a library exporting `Alpha` and `Beta`, no lib root | exit 0, "Interface (headers only)", "Withheld (nothing)", `sources = []` | local, mcpp 2026.9.28.2 | +| mcpp-plugins with `deps-*` among its default features | its own build fails: `deps/deps.cppm: module 'mcpp' not found` (and the same for `src/declare.cppm`) | local copy of mcpp-plugins `8e0362d`, mcpp 2026.9.28.2 | +| the build program's compile with the `deps` family added | 0.92 s without, 1.56 s with (+0.66 s), two runs each | local, Linux | +| what the `deps-*` features provision | `deps-vcpkg`: `xim:vcpkg`; `deps-cmake` and `deps-archive`: `xim:cmake` (provisioned before the build program runs) | mcpp-plugins `mcpp.toml` | +| vcpkg under a chain-loaded triplet without `VCPKG_LOAD_VCVARS_ENV` | the toolset is `"external"`; `VCPKG_PLATFORM_TOOLSET=external` | vcpkg-tool `get_toolset`, `commands.build.cpp` | +| vcpkg's build environment | a clean environment whose `PATH` holds only system directories; the caller's `PATH` survives only through `VCPKG_KEEP_ENV_VARS=PATH`; `VCPKG_VISUAL_STUDIO_PATH` in the environment selects the instance | vcpkg-tool `system.process.cpp`, `vcpkgpaths.cpp` | +| ports built with MSBuild in vcpkg's registry | 31, among them `libsodium`, `libusb`, `python3`, `libvpx`, `mp3lame` | `vcpkg_msbuild_install` / `vcpkg_install_msbuild` in `ports/` | +| what a host-module edge offers a build program | the lib root and the units of enabled features only: a consumer without features that imports `mcpp.deps` is told `importable here: mcpp.plugins` | local, mcpp 2026.9.28.2 | +| a unit that imports `mcpp` in an ordinary `[build] sources` list | the ordinary build fails: `module 'mcpp' not found` (a unit also listed by a disabled feature is left out) | local, mcpp 2026.9.28.2 | +| an unknown key in `[package]`, `[build]`, `[lib]`, a dependency spec | `[package]`, `[build]`: warned and ignored; `[lib]`: ignored without a message; dependency spec: warned and ignored | local, mcpp 2026.9.28.2 | +| the engine's reserved module prefix | `kReservedModulePrefix = "mcpp."`: a build rule's module under it from a package outside namespace `mcpp` is warned about | `modules/buildmcpp/src/provisions.cppm` | +| namespaces in mcpp-index | 20; the most used are `compat` (152 packages), `mcpplibs` (32), `freedesktop` (18); none equals `core`, `plugins`, `deps`, `rules`, `dist` or `tools` | mcpp-index `e9b80c5` | + +### 2.2 Windows readings taken for this design + +Runs 36405747279, 36406515321, 36407317373 and 36408145450 on windows-2025 (Visual Studio 18, +MSVC 14.51.36231; vcpkg 2026-07-27). Each leg is listed with the fault it could +have shown, so that a success is not read as a leg that never ran. + +| Id | Leg | Result | +|---|---|---| +| M1 | zlib installed twice; the second time with `VCPKG_VISUAL_STUDIO_PATH` naming the same instance, and a fresh install root | same `vcpkg_abi_info` hash (`80eb9d4a…`); the second install restored 3 packages from the binary cache and built nothing | +| M2a | control: libusb (MSBuild) under `x64-windows` | builds | +| M2b | zlib (CMake) under a chain-loaded triplet naming the instance's `cl.exe`, without vcvars | builds | +| M2c | libusb (MSBuild) under the same triplet | **fails**: `msbuild ... /p:PlatformToolset=external` | +| M2d | libusb and zlib under a chain-loaded triplet with `VCPKG_LOAD_VCVARS_ENV ON` | both build | +| M3a | Visual Studio masked, the managed `msvc@14.44.35207` named by a chain-loaded triplet; zlib (CMake) | builds | +| M3b | the same; icu (make under msys) | **fails**: `configure: error: link.exe is not a valid linker. Your PATH is incorrect.` | +| M3c | the same; icu with the toolset's `bin` first on a `PATH` kept through `VCPKG_KEEP_ENV_VARS=PATH` | builds | +| P3 | the managed toolset copied to another path, with the environment pointing at the copy | zlib restored 3 packages from the cache: the ABI hash does not depend on the toolset's path | +| E2b-1 | mcpp 2026.9.28.2, plugins 0.16.0, fmt; `toolchain-coupled` with `x64-windows` and with `x64-windows-static-md` | both build and run | +| E2b-2 | `self-contained` with `x64-windows-static-md` | **fails**: `lld-link: error: /failifmismatch: mismatch detected for 'RuntimeLibrary'` | +| E2b-3 | `self-contained` with `x64-windows` (fmt as a DLL) | builds and runs: two C++ runtimes in one process, and nothing says so | +| E4 | a first `mcpp build` deploying 1270 files, twice | 1270 `stage_file` edges; 4.5 s from the first to the last edge (26.8 s of edge time, four at a time); the build 6.3 s against 1.3 s without them. One process copying the same files: 0.5 s | + +Three harness faults were found and corrected while taking these readings. Each is a +fact the plugin design must respect, and §5 does: + +- a toolchain file that writes a Windows path from the environment into + `CMAKE_MT` fails (`Invalid character escape '\P'`), so paths go through + `file(TO_CMAKE_PATH)`; +- the host triplet must be the derived triplet as well, or vcpkg builds its host + ports (`vcpkg-cmake`) with the standard triplet and looks for Visual Studio; +- the SDK's header is `Windows.h`, which a case-sensitive search misses. + + + +## 3. The build-plugin architecture + +### 3.1 Three layers + +| Layer | Module names | Provided by | Contents | Stability and specification | How a build program reaches it | +|---|---|---|---|---|---| +| **L1 core** | `mcpp.core`; `mcpp` is a permanent equivalent spelling | the engine: embedded in the binary, versioned by the build-program protocol (13 today) | mechanisms only: **build information** (target, toolchain and its tools, environment, contracts, package identity, directories, dependency facts) and **control** (directives, actions, placement, pack formats, diagnostics) | SPEC-007, each symbol with the protocol that introduced it; additive; a removal only after a deprecation period | `import mcpp.core;` (or `import mcpp;`) | +| **L2 official general library** | `mcpp.plugins` (facade), `mcpp.plugins.declare`, `mcpp.plugins.toolset`, `mcpp.plugins.fs`; `mcpp.plugins.testing` separately | mcpp-plugins | general building blocks written against L1 only; no knowledge of a particular foreign tool | the package's version; breaking changes behind compatibility units for six months | the feature `plugins-core` (implied by every family feature); `plugins-testing` for the test kit | +| **L3 plugins** | official families `mcpp.deps.*`, `mcpp.rules.*`, `mcpp.dist.*`, `mcpp.tools.*`; third parties `mcpp..*` | mcpp-plugins; any package | concrete plugins built on L1, or on L1 and L2 | their packages' versions | official families by feature (code and payload together); third parties by dependency | + +The layers depend downward only: L3 on L2 or L1, L2 on L1. L1 knows neither. + +### 3.2 L1: `mcpp.core` + +- **Content.** + - **Build information.** The accessors of today, plus E2's (§4.2). + - **Control.** The directives, actions and pack formats of today, plus + batched placement (E4, §4.5) and structured diagnostics (E11, §4.12). +- **Names.** The engine embeds two module units: + - `mcpp.core`, which holds the whole interface; + - `mcpp`, whose only content is `export import mcpp.core;`. + + Both export the same symbols, so they cannot diverge. SPEC-007 and the + documentation use the name `mcpp.core`, and state that the two spellings are + equivalent. The C++ namespace stays `mcpp::`, so no code changes. +- **Specification.** SPEC-007 gains a section "`mcpp.core`". It lists every + accessor and directive with the protocol that introduced it and its availability + per row. It states the stability policy: additions only; a symbol is removed only + after it has been deprecated for six months; a changed meaning is a new symbol. + It also gives the table mapping mcpp releases to protocol numbers, which is the + only place a protocol number appears for a user. + +### 3.3 L2: `mcpp.plugins` + +| Module | Contents | Source today | +|---|---|---| +| `mcpp.plugins` | the facade and the std-only surface shared with `mcpp-embed` | `src/plugins.cppm` (unchanged) | +| `mcpp.plugins.declare` | the build-program half of the surface | `src/declare.cppm` (unchanged) | +| `mcpp.plugins.toolset` | the translation of E2's facts for foreign build systems (§6.2) | new; revision 3 called it `mcpp.deps.toolset` | +| `mcpp.plugins.fs` | deterministic file generation (`write_if_changed`) and placement helpers | moved from `mcpp.deps` | +| `mcpp.plugins.testing` | the test kit for plugins (P6, §6.8) | new | + +- **Why L2 sits behind a feature.** The plugins package also has an ordinary build. + It produces `mcpp-embed`, and docs/05 §2.14 places that tool in the same package + as the rules on purpose, so that the tool and the rules are one version. A unit + that imports `mcpp.core` cannot enter that ordinary build: its source list fails + with `module 'mcpp' not found` (§2.1). A host-module edge offers a build program + only the lib root and the units of enabled features (§2.1). Today's `surface` + feature exists for exactly this reason. +- **The feature.** `surface` is therefore renamed `plugins-core` and documented as + the entry to L2. Every family feature implies it. The old name stays as an + alias in a compatibility unit. +- **Alternatives considered and rejected.** + - Deriving from a unit's imports which build it belongs to. + - A separate table for build-program units. + + Both change the engine's build semantics for the sake of one package (§12, + finding 1). +- **Usage.** A project that uses any official plugin writes nothing more than + today. A build program that uses only L2, and a third-party plugin built on L2, + add `features = ["plugins-core"]`. + +### 3.4 L3: plugins + +- **Official families.** Each family is enabled by feature. A family feature + enables the code and provisions the family's payloads, which is why it is a + feature: provisioning runs before the build program, so it cannot follow use. + - `deps-vcpkg` provisions `xim:vcpkg`; + - `deps-cmake` and `deps-archive` provision `xim:cmake`; + - `rules-*` also claim source extensions; + - `dist-*` provision packaging tools per target; + - `tools-*` provision none. + + `mcpp.deps` remains the deps family's own base module (prefix types, placement + of prefix files). +- **Third parties.** A third-party plugin is a package whose modules import + `mcpp.core`. It depends on `mcpp.plugins` with `host-module = true`, + `features = ["plugins-core"]` and, when its consumers' build programs import L2 + directly, `reexport = true`. It names its modules by the rule of §3.5. + +### 3.5 Module names (the engine's warning, and the index's admission rule) + +| Module name | Who may provide it | +|---|---| +| `mcpp`, `mcpp.core` | the engine only | +| `mcpp.plugins.*`, `mcpp.deps.*`, `mcpp.rules.*`, `mcpp.dist.*`, `mcpp.tools.*` | packages in namespace `mcpp`; the reserved second segments are listed in SPEC-007 and may be extended | +| `mcpp..*`, for example `mcpp.acme.protobuf`, `mcpp.mcpplibs.capi.lua` | packages in namespace `` | +| any other module of a plugin | the library rule of SPEC-008 I3: `..*` | + +The rule gives three things: + +- A third-party plugin is recognisable as an mcpp plugin by its name. +- Third parties cannot collide with each other, because index namespaces are + unique. +- The official families cannot be impersonated, because a reserved second segment + cannot be registered as a namespace. None of them is one today: mcpp-index + `e9b80c5` has 20 namespaces, and `compat`, `mcpplibs` and `freedesktop` are the + most used. + +**Enforcement.** + +- **The engine.** It already warns when a build rule's module starts with `mcpp.` + and its package is not in namespace `mcpp` (`kReservedModulePrefix`, + `provisions.cppm`). E10 extends that warning to every module of a + build-program surface and accepts `mcpp..*` (§4.11). The engine + cannot tell who is official (a fork or a private mirror is legitimate), so this + stays a warning. +- **The index.** mcpp-index makes it an admission rule (I1, §7). + +### 3.6 A package's engine floor: `[package] mcpp = ">=V"` + +**Three versions.** + +- The **mcpp release** (`2026.9.28.2`) is what a user pins in `.xlings.json` and + reads in a message. +- The **`mcpp.core` protocol** (13) is stated in SPEC-007 only. +- The **manifest vocabulary** grows with releases. + +A user sees only the first. + +**Pin and floor answer different questions.** + +- **Pin.** Which mcpp this project uses: `.xlings.json`, installed by + `xlings install`, unchanged. +- **Floor.** Which engines this package supports: `[package] mcpp = ">=V"` in + `mcpp.toml`, inheritable through `[workspace.package]`. Cargo's + `package.rust-version` is the precedent. + +**Rules.** + +- **Only `>=` is accepted.** A bare version is exact everywhere else in mcpp, so a + bare version here is refused with a hint to write `>=`. +- **An engine below the floor stops.** It names the package, the floor, its own + version and the way to upgrade (`.xlings.json`, or `xlings install mcpp@…`). +- **An older engine warns and continues.** 2026.9.28.2 says `[package] has + unsupported key 'mcpp' (ignored)` (§2.1), so an older client can still load the + package. +- **Relation to the protocol.** A plugin that needs protocol 14 declares the first + release that carries it. No separate protocol key exists. + +**Follow-up, outside this design.** An index descriptor may carry the floor, so +that a resolver skips versions its engine cannot build (as Cargo resolves by +`rust-version`). + +### 3.7 Compatibility units + +The convention is the same for the engine and for the plugins. + +- **Place.** Compatibility code lives in a `compat/` directory, one unit per kept + behaviour. +- **Header.** Each unit's header states five things: what it keeps, since which + release, its retirement date (six months after its introduction), what replaces + it, and the note it prints. +- **The note.** It is printed once per build when the unit's behaviour is used. +- **Retirement.** A CI check lists the units past their date and fails, so that a + deferral retires itself. + +**The first units** (retirement 2027-03-28): + +| Unit | Keeps | Replacement | +|---|---|---| +| `deps/compat/detected_toolset.cppm` | `toolset = detected` for deps-vcpkg and deps-cmake (D3) | the engine's toolchain selection: `msvc@system` makes `resolved` use that instance | +| `deps/compat/program_compilers.cppm` | the name `mcpp::deps::program_compilers` | `mcpp::plugins::toolset::resolve` | +| the `surface` feature alias | the old name of `plugins-core` | `plugins-core` | +| `mcpp.deps` re-exports of `write_if_changed` and the placement helpers | the old module of those functions | `mcpp.plugins.fs` | + +The equivalence of `mcpp` and `mcpp.core` is not a compatibility unit. It is +permanent (principle 6). + +### 3.8 What makes the plugin system strong + +| Capability | Today | This design | +|---|---|---| +| Read: target, profile, features, package identity, directories, dependency paths | present | — | +| Read: the toolchain's tools, their environment, the C++ runtime contract, the toolset's identity and instance, mcpp's ninja | absent | E2 | +| Write: compile and link directives, placement, runtime search directories, generated sources, pack formats | present | — | +| Write: actions with roles, inputs, outputs, depfile, env, cwd, `PATH` prefix | present | — | +| Write: batched placement | absent | E4 | +| Diagnose: warnings in the engine's own form (impact, hint) | plain text only | E11 | +| Contract: interface specification, stability policy, engine floor | the protocol is described in docs/30 only | §3.2 (SPEC-007), §3.6 (E9) | +| Compose: L2 by one feature, families by feature, re-export chains | present except L2's name | §3.3 (P0) | +| Guide: a missing module names the feature that provides it | absent | E7 | +| Name: a plugin's modules are recognisable and cannot be impersonated in the index | a warning for rule modules only | E10, I1 | +| Test: a plugin's logic runs against a stated build context, and its directives are compared | absent | P6 | + +## 4. Engine items + +### 4.1 E1 · A workspace member used as a path dependency is built once (D1 settled) + +**Problem.** `mcpp build --workspace` fans out over the members, and each member +is a separate graph with its own `target///`. A library member reached +as a path dependency is compiled in its own build and again in every member that +consumes it. Two `-p` invocations behave the same way. This holds for any +workspace in which several members share a library. + +**Why neither existing store serves it.** + +- **The global dependency cache** stores only index packages + (`DepCacheIdentity::sourceKind == "version"`), because a path package's sources + can change while its identity does not. That is correct for a cache shared + across projects. +- **The tool store** keys a path package by `tree_stamp`, which covers the files + under the package root. A library's inputs are not confined to its root: + GalTranslPP's core compiles `../3rdParty/3rdModule/*.ixx` and includes headers + from `../3rdParty/...`. A key that sees only the root would serve stale objects. + +**Design.** + +- **Scope.** The design applies to workspace members reached as path dependencies. + A path dependency outside the workspace has a single consumer and keeps today's + behaviour. +- **Directory and key.** The member is built as a keyed sub-build, as host tools + are, in `/target/.members///`. The key is the + package's existing per-package build key (`cache_key::key_hex`: toolchain, flags, + profile, features and the keys of its upstreams). The key excludes the consumer, + so consumers that resolve the member identically compute the same key, and + consumers that request other features or another profile compute another one. +- **Staleness.** The key selects a configuration, never freshness. Before its own + ninja runs, every consumer runs the sub-build's ninja, which is a no-op when + nothing changed. Ninja's time stamps and depfiles therefore judge staleness, + including for inputs outside the root. +- **Handover.** The member's build program runs in the sub-build. Its directives + (link libraries, deploys, runtime search directories) reach each consumer from + the sub-build's cached record, as a dependency's directives do today. Its + actions, such as a vcpkg install, run in the sub-build's ninja. Objects and BMIs + reach the consumer through the stage edges the global cache already uses, + including the phony order-only prerequisite that keeps partition order. +- **Concurrency.** Concurrent consumers take a lock on the sub-build directory. + +**Compatibility.** After the upgrade each such member is compiled once more, into +the new directory. The root package's outputs and command lines are unchanged. + +**Criterion.** Compilations are counted from the sub-build's and the consumers' +`.ninja_log`, with the number of library units as the denominator. + +- A workspace whose two program members depend on one library member: + `mcpp build --workspace` compiles each library unit once, and a following + `mcpp build -p ` compiles none of them. +- A header outside the library's root is changed: the next build recompiles its + includers and no other library unit. +- Two concurrent `-p` builds of different programs both succeed, and each library + unit is compiled once. +- The no-op ninja of the sub-build is timed on the Windows row (§12, finding 11). + +### 4.2 E2 · Build information for build programs (D2 settled) + +**Problem.** A build program can read `toolchain_dir()`, `compiler()`, +`cxx_stdlib()` and `sysroot_dir()`. It cannot name the tools of the resolved row, the +environment they need, or the runtime contract. A plugin that drives a foreign +build system therefore lets that system detect a toolset of its own, or +reconstructs mcpp's toolset privately. deps-vcpkg and deps-cmake do the latter on +Linux (`mcpp::deps::program_compilers`), for one row only. + +**What the engine holds today.** + +- **The cl.exe row.** Detection synthesises `INCLUDE`, `LIB` and `PATH` into + `Toolchain::envOverrides`, and mcpp applies them to its own compiles and to + ninja. +- **The llvm row with an MSVC sysroot.** `bind_msvc_sysroot` records + `msvcToolsDir`, `msvcToolsVersion`, `msvcRedistDir`, `windowsSdkRoot` and + `windowsSdkVersion`. clang locates the headers itself, and no environment is + synthesised. + +**Design.** The build-program framework states the resolved build as a set of +facts, carried as `MCPP_*` variables in the way the existing accessors are. How to +use them is the plugins' concern. + +| Accessor | Answers | +|---|---| +| `mcpp::tool(role)` | the row's tool for `cc`, `cxx`, `ld`, `ar`, `rc`, `as`, `mt` | +| `mcpp::abi_tool(role)` | the target ABI's native tool for the same roles. On the MSVC ABI these are `cl`, `link`, `lib`, `rc`, `ml64` and `mt` of the resolved toolset and SDK; on other rows they equal `tool(role)` | +| `mcpp::tool_env()` | the environment the ABI's tools read, one `KEY=value` per line. On the MSVC ABI this is `INCLUDE`, `LIB`, `LIBPATH` and the directory variables (`VCToolsInstallDir`, `WindowsSdkDir`, `WindowsSDKVersion`, `UniversalCRTSdkDir`, `UCRTVersion`), synthesised for the llvm row by the function the cl.exe row uses. It is empty elsewhere | +| `mcpp::toolset_identity()` | a path-free identity of the ABI toolset, for example `msvc 14.44.35207; sdk 10.0.26100.0` | +| `mcpp::msvc_instance_dir()` | the Visual Studio instance the MSVC toolset belongs to when it comes from one (`msvc@system`), and empty for a managed toolset. `bind_msvc_sysroot` already records the origin | +| `mcpp::ninja_program()` | the ninja that mcpp itself runs, so that a foreign build system can use it rather than require one on the host | +| `mcpp::cxx_runtime()` | the resolved contract: `self-contained`, `toolchain-coupled` or `host-coupled` (E2b) | +| `mcpp::msvc_crt_linkage()` | `static`, `dynamic`, or empty off the MSVC ABI (E2b). These are the values `place-dlls --crt` already receives | + +The facts belong to `mcpp.core` (§3.2) and are listed in SPEC-007 with the +protocol that introduced each and its availability per row. They are stated, never interpreted. `toolset_identity()` lets +a consumer key a cache by version rather than by path. + +**Compatibility.** The new variables enter every build program's context hash, so +every build program runs once more after the upgrade and then returns to its +cache. The changelog lists this among the compatibility effects. + +**Criterion.** + +- On the Windows row with Visual Studio masked and `msvc@14.44.35207` resolved + (`measure-windows-tool-crt.yml`), a build program reads an `abi_tool("cxx")` + under the managed toolset and a `tool_env()` whose `INCLUDE` names that toolset + and its SDK. +- On the llvm MSVC-ABI row, `tool("cxx")` is clang++ and `abi_tool("cxx")` is the + sysroot's `cl.exe`. +- On the Linux rows, both accessors name the payload's tools and `tool_env()` is + empty. +- A unit test compares `msvc_crt_linkage()` with the `--crt` value of the same plan + for each of the three contracts. +- On the Visual Studio row `msvc_instance_dir()` names the instance; on the masked + row with the managed toolset it is empty. + +### 4.3 E2b · The C++ runtime contract (confirmed by reading E2b) + +Objects a plugin produces are linked into the program, so they must follow the +program's C++ runtime contract. The readings E2b-1 to E2b-3 show both ways a +mismatch appears on the MSVC ABI: + +- **With a static library.** A static library built with `/MD` and a program + built with `/MT` fail the link (`/failifmismatch ... 'RuntimeLibrary'`). This + is loud, and correct. +- **With a DLL.** A DLL built with `/MD` and a program built with `/MT` link and + run, and the process holds two C++ runtimes. Nothing reports it. This is the + silent case, and the more dangerous one: memory or a standard-library object + that crosses the boundary is owned by two heaps. + +The accessors `cxx_runtime()` and `msvc_crt_linkage()` of E2 state the contract. +P2 (§6.5) acts on them, including the silent case. + +### 4.4 E3 · `mcpp pack -p` + +`build`, `run` and `test` select a workspace member with `-p`; `pack` does not. +`pack` takes `-p` with the same resolution order (qualified name, package name, +member path). + +**Criterion.** `mcpp pack -p --format ` at the workspace root produces +the same stage and output as `mcpp pack --format ` in the member's directory. + +### 4.5 E4 · Batched file placement with `mcpp stage --list` (D5 settled: adopted) + +**What it does.** It places many files with one process. Today every placed file is +one process: + +- each `mcpp::deploy` entry becomes one `stage_file` edge running `mcpp stage` for + one file; +- a build program's copy action runs `${mcpp.self} stage` for one file. + +Reading E4: 1270 deploy edges take 4.5 s of a first build on Windows, and one process +places the same files in 0.5 s. The release layout of the validation project adds +2675 such actions to a pack. A no-op build runs none of these edges (`restat`), so +the gain is on a first build, a changed input and a pack. + +**Why it belongs to the engine.** File placement is a general build step, and mcpp +already owns it through `mcpp stage`, an internal subcommand that generated edges +reach as `$mcpp` and actions reach as `${mcpp.self}`. The item extends that +subcommand, and no new subcommand is added. + +**Design.** + +- **The command.** `mcpp stage --list [--verify content|size]`. The file + holds one entry per line, `\t`, separated by a tab as the + `deploy` directive is, because a Windows path contains `:`. Each entry keeps + today's single-file semantics: an equivalent destination is not written, a + write goes out of place and is renamed, sharing violations are retried, and a + destination with several sources compares their bytes (SPEC-007 R4.2). +- **The engine's own use.** The deploy entries of one program become one edge. Its + inputs are every source and the list file, and its outputs are every + destination. The rule has `restat = 1`, so a destination whose bytes did not + change keeps its time stamp and dirties nothing downstream. The list file is + written only when its content changes, so the edge re-runs only when an entry is + added, removed or changed. +- **Actions.** A build program may pass a list file to `${mcpp.self} stage + --list`. docs/30 documents the form, and the single-file form stays. +- **Rejected alternatives.** A tree-mirror mode cannot express the renames and + exclusions a release layout needs. A layout stated in the pack format would put + each project's layout policy into the engine's format. + +**Compatibility.** The deploy edges of `build.ninja` change shape, so after the +upgrade the placement edge runs once. Its content comparison writes nothing that is +already equal. + +**Criterion.** +- 1270 deploy entries produce one placement edge, and on the Windows row it takes + at most 1 s (4.5 s before, by reading E4). +- A no-change build runs no placement edge. +- One changed source rewrites its destination only; the other destinations keep + their time stamps. +- Two packages that place the same destination with different bytes fail as they + do today. + +### 4.6 E5 · The project fast path on PE and Mach-O + +**Problem.** `try_fast_build` accepts a build only when `validated_artifact_snapshot` +finds a stored `Pass` verdict for every artifact. Only the ELF run-time validator +writes such a verdict, so on Windows and macOS every `mcpp build` and `mcpp run` +plans again. This follows from #400 and is recorded in e2e 645 and 821, but no +issue tracks it. + +**Design.** + +- An artifact whose format has no run-time validator is recorded with the verdict + `NotApplicable`, together with the same contract hash and stat fingerprint. +- The fast path accepts `Pass` and `NotApplicable`. +- This is sound on PE, because the check that matters there is an edge + (`place-dlls` with its depfile), which ninja runs on every relink, including + under the fast path. +- On Mach-O no check is lost, because none exists today. + +**Criterion.** + +- e2e 645 reads MEASURED on the Windows and macOS rows. +- An A-B-A test in the form of e2e 611 (build A, build B, build A; the third runs + A) passes on both rows. + +### 4.7 E6 · The library interface: a specification, and warnings at its readers (settled: warnings only) + +The specification is designed in §5. The engine changes of phase 1 are these: + +- `mcpp pack` warns when an exported module is not shipped, and names each such + module (§5.4, W2). +- The "Withheld" row lists every unit that is not shipped (§5.4). This is a + correction of a report, not an enforcement. +- `mcpp build` keeps its warning for the package being built, reworded (§5.4, W1). +- `mcpp build` warns when the package being built imports a module of a dependency + that is outside that dependency's interface (§5.4, W3). + +Nothing becomes an error in this design. + +### 4.8 E7 · A missing build-program module names the feature that provides it + +**Problem.** When a build program imports a module that no dependency offers, the +engine lists what is importable and which dependencies were declared without +`host-module = true`. It does not say that the module exists in a feature of a +declared dependency that is not enabled. This applies to every package with +features: `rules-*`, `dist-*`, `tools-*`, and L2 under §3.3. + +**Design.** On that error path only, the engine reads the module declarations of +the units listed by the features that are not enabled, in the dependencies that +the build program may reach. When one of them provides the missing module, the +error names it: + +``` +error: build.mcpp imports 'mcpp.plugins.toolset' + provided by: mcpp.plugins, feature "plugins-core" (not enabled) + hint: [build-dependencies] mcpp.plugins = { ..., features = ["plugins-core"] } +``` + +The declarations are read as text. The feature's units are not compiled, and a +successful build does no additional work. + +**Criterion.** +- With `mcpp.plugins` declared without features, `import mcpp.plugins.toolset;` + fails with a message that names `plugins-core`. +- The same for `import mcpp.rules.qt;` and `rules-qt`. +- A successful build's plan is unchanged. + +### 4.9 E8 · `mcpp.core`, and `mcpp` as its permanent equivalent + +As §3.2 describes: two embedded units, one re-exporting the other, and SPEC-007's +`mcpp.core` section with the protocol table and the stability policy. + +**Criterion.** +- A build program with `import mcpp.core;` and one with `import mcpp;` produce the + same directives. +- A program that imports both compiles. +- The protocol table of SPEC-007 is checked against `kProtocolVersion` by a unit + test. + +### 4.10 E9 · The per-package engine floor + +As §3.6 describes. + +**Criterion.** +- A package with `mcpp = ">=2099.1.1"` stops with a message naming the package, + the floor and the running version. +- `mcpp = "2026.9.28.2"` (bare) is refused with the `>=` hint. +- A floor at or below the running version builds. +- A workspace member inherits `[workspace.package] mcpp`. + +### 4.11 E10 · The module-name warning covers every build-program module + +**What exists.** `reserved_prefix_warning` covers only a build rule's module. +**What changes.** + +- **Scope.** The warning applies to every module a package offers to build + programs: its lib root and its feature units when it is reached with + `host-module = true`. +- **Accepted names.** `mcpp..*` is accepted. +- **Reserved names.** The reserved second segments of §3.5 are refused for + packages outside namespace `mcpp`, as a warning. + +**Criterion.** +- `mcpp.acme.x` from namespace `acme` is silent. +- `mcpp.rules.x` from namespace `acme` warns. +- `mcpp.other.x` from namespace `acme` warns and names the accepted form. + +### 4.12 E11 · Structured diagnostics from build programs + +**What exists.** A build program's `mcpp::warning(text)` reaches the user as plain +text. + +**What changes.** + +- **The directive.** `mcpp::diagnostic{severity, message, impact, hint, path}` + is carried by one directive and rendered in the engine's own form. It also + reaches `--message-format json` with the same fields as the engine's + diagnostics (SPEC-003 codes do not apply; a plugin names its own code). +- **The old call.** `mcpp::warning(text)` remains, equivalent to a diagnostic + with a message only. + +**Criterion.** +- A plugin's diagnostic renders with its impact and hint lines. +- It appears in the JSON stream with its fields. +- It is shown on a cached replay as the engine's own advice is (e2e 139's shape). + +### 4.13 E12 · `[lib]` reports unknown keys + +`[lib]` accepts an unknown key without any message (measured, §2.1), while +`[build]` and `[package]` warn and ignore it. A misspelt `path` in `[lib]` +therefore changes the lib root silently. + +**Design.** `[lib]` reports unknown keys as `[build]` does. + +**Criterion.** `[lib] pth = "src/x.cppm"` produces the warning naming the +supported keys. + +## 5. The library interface specification (new SPEC-008, phase 1) + +The specification is written in `docs/specs/` in Chinese, following that +directory's conventions. This section fixes its content. + +### 5.1 What the specification must achieve + +| Property | What it means here | +|---|---| +| Clear semantics | one term for what a consumer may import, one for what a package ships, one for what it withholds | +| Consistency | a consumer sees the same interface whether the package reaches it as source or as a packed distribution | +| Stability | the interface is a named set that changes only through the package's own declaration, so a consumer can depend on it across versions | +| Distribution | the packed form is complete (a consumer can compile against it) and minimal (nothing outside the interface's closure leaks) | +| Elegance | one declaration per package, expressed in C++ itself (a module and its re-exports), not in a list maintained beside the code | +| Compatibility | no manifest that builds today stops building | + +### 5.2 Definitions + +- **Interface root.** The unit found at `[lib].path`, or at + `src/.` by + convention. It must declare a primary module interface (`export module ;`, + not a partition). This rule exists today. +- **Public modules.** The interface root's module, and every module and partition + that it re-exports with `export import`, transitively. These are the names a + consumer may import. +- **Public headers.** The files under `include/` (docs/12; unchanged). +- **Interface.** The public modules together with the public headers. +- **Shipped closure.** Every unit that the public modules' interfaces import, + transitively, whether re-exported or not. A consumer needs these units to build + the public modules' BMIs. `mcpp pack` ships the shipped closure, and names in its + report any implementation partition that the closure contains, as it does today. +- **Withheld units.** Every other unit of the package. They are compiled into the + library and are not shipped. + +The distinction between public modules and the shipped closure is the one part +that is new. It separates what a consumer may import, which is the contract, from +what a consumer must be able to compile, which is a necessity of BMIs. + +### 5.3 Rules + +- **I1. One interface for every form.** A package's interface is the same whether a + consumer builds it from source or uses its packed form. *Phase 1: stated, and + warned about (W3); source builds are not restricted.* +- **I2. One root per package.** A package has at most one interface root, so it + has one importable name that corresponds to its identity `(namespace, name)`. A + package whose code is organised as several modules expresses them through the + root with `export import`; this is the facade form. No list of roots exists, and + none is planned. +- **I3. Public module names SHOULD be qualified by the package.** Module names are + global in a program, so a public module SHOULD be named `.` or + below it (`..`). *Phase 1: a recommendation; + `mcpp pack` reports public modules outside the prefix as a note.* The engine + already refuses two modules of the same name in one graph. This rule covers a + package's library interface. The modules a package offers to build programs + follow §3.5 instead. +- **I4. A headers-only interface is legitimate.** A library may implement itself in + modules and publish only headers, for example a C API. Such a library has no + interface root, and its public modules are empty. *Phase 1: `mcpp pack` treats a + library without a root as headers-only, as today, and warns (W2) because it + cannot tell intent from omission.* +- **I5. The packed form ships exactly the shipped closure and the public headers.** + It is complete, because every public module's BMI can be built, and minimal, + because no withheld unit is shipped. This is today's behaviour. + +### 5.4 Diagnostics (phase 1: warnings only) + +Each diagnostic is emitted by a step that reads the interface, and only for the +package whose author can act on it. + +| | Emitted by | Condition | Text (impact and hint form) | +|---|---|---|---| +| W1 | `mcpp build`, for the package being built (a dependency is silent, as today) | a `lib` target exports modules and has no interface root | impact: `mcpp pack` will publish this library without a module interface. hint: add `src/.` re-exporting the public modules, or set `[lib].path`; a headers-only library can ignore this | +| W2 | `mcpp pack` | exported modules exist that are not public: all of them when there is no root, or those not reachable from the root | names each such module. impact: a consumer of the packed form cannot import it. hint: re-export it from the root, or keep it internal on purpose | +| W3 | `mcpp build`, for the package being built | one of its units imports a module of a dependency that declares a root, and that module is not among the dependency's public modules | names the module and the dependency. impact: the build succeeds from source, but fails against the dependency's packed form. hint: import a public module of the dependency | +| Report | `mcpp pack` | always | "Interface" lists the shipped closure, and "Withheld" lists every other unit. This replaces the "(nothing)" that the local reading of §2 shows | + +W3 fires only for dependencies that declare a root, so libraries without a root, +such as GalTranslPP's core, cause none. + +### 5.5 Phase 2 (conditions, no design) + +W1 and W2 become errors, with an explicit declaration for a headers-only library, +when three conditions hold: + +1. An mcpp-index sweep reports the libraries that export modules without a root, + and which of them publish headers only on purpose. +2. The engine release named by the index `min_mcpp` reads the headers-only + declaration. An older engine ignores an unknown key in `[lib]` (§2.1), so the + declaration breaks no older client, but it has no effect there. Phase 2's + error therefore applies only from the floor on. +3. The specification has reached the review state. + +When the sweep reports none, W1 and W2 have served their purpose, and phase 2 +reduces to the report. + +## 6. Plugin design (mcpp-plugins) + +The official plugins are general plugins. Every behaviour below is an option on a +plugin's `options` structure, set from `build.mcpp`, and none of it names a +project. + +### 6.1 P0 · The package's structure after §3 + +- **L2.** `plugins-core` (renamed from `surface`) lists `src/declare.cppm`, + `src/toolset.cppm` and `src/fs.cppm`. `plugins-testing` lists + `src/testing.cppm`. `[build] sources` keeps `src/plugins.cppm`, the std-only + unit that `mcpp-embed` shares. +- **L3.** Every family feature implies `plugins-core`, as every member implies + `surface` today. +- **The floor.** `[package] mcpp = ">=V"` names the first release carrying E2, + E4, E7 and E11. +- **Compatibility.** The units of §3.7. + +### 6.2 P1 · One shared module: `mcpp.plugins.toolset` (L2) + +A new L2 module turns E2's facts into what a foreign build system needs, once +for every plugin: + +```cpp +namespace mcpp::plugins::toolset { +enum class source { resolved, detected }; // who chooses the tools +enum class compiler { abi_native, row }; // which compiler `resolved` hands over + +struct choice { // embedded in each plugin's options + source toolset = source::resolved; // D3 + compiler cc = compiler::abi_native; +}; + +enum class mechanism { // how `resolved` reaches the foreign system + instance, // an MSVC toolset from a Visual Studio instance: that instance, selected + chain, // any other toolset: compilers named in a chain-loaded toolchain + detected, // `source::detected`: nothing is named (compatibility unit, until 2027-03-28) +}; + +struct resolved_tools { + mechanism how = mechanism::detected; + std::string instance_dir; // msvc_instance_dir(), for `instance` + std::string toolset_version; // e.g. 14.44.35207, for `instance` + std::string cc, cxx, ld, ar, rc, mt; // absolute paths, for `chain` + std::vector> env; // tool_env(), for `chain` + std::string path_prefix; // the tools' directories, for `chain` + std::string identity; // toolset_identity() + std::string crt; // msvc_crt_linkage() +}; + +std::expected resolve(const choice&); +} +``` + +**The mechanism follows the toolset's origin.** + +- **`instance`.** An MSVC toolset from a Visual Studio instance (`msvc@system`, + which is what mcpp resolves on a machine with Visual Studio) is used through the + instance. The foreign system's own toolset loading stays in place, pointed at the + instance mcpp resolved. MSBuild is present, and every port kind builds (M2a). +- **`chain`.** A managed MSVC toolset, and every toolset on the other rows, is named + in a chain-loaded toolchain file. On the MSVC ABI there is no Visual Studio + instance to load, and no MSBuild exists. +- **`compiler = row` on the MSVC ABI.** It always uses `chain`: the instance + mechanism can hand over only the instance's own `cl`, so a request for the row's + clang is met by naming it. +- **Linux.** `program_compilers` becomes `resolve({resolved, row})` on the Linux + libc++ row, and the plugin-private reconstruction is removed. + +### 6.3 P1 · deps-vcpkg + +**Options.** `options.toolset` (the `choice`) and `options.crt_linkage` (§6.5). + +**Under `instance`.** +- The plugin runs vcpkg with `VCPKG_VISUAL_STUDIO_PATH=` in the + action's environment. Reading M1: when the instance is the one vcpkg would choose + anyway, the ABI hash is unchanged and nothing is rebuilt. +- When the resolved toolset is not the instance's newest, the plugin derives a + triplet that adds `VCPKG_PLATFORM_TOOLSET_VERSION `. That triplet has + its own hash, so its ports build once. +- MSBuild ports build (M2a). + +**Under `chain`.** +- **The derived triplet.** It is named `-mcpp-`. The base triplet + (the project's, or the default) is inlined, not `include()`d, because vcpkg's + documented hash covers the triplet file's content and not a file it includes. + The plugin appends to it: + - `VCPKG_CHAINLOAD_TOOLCHAIN_FILE`; + - `VCPKG_ENV_PASSTHROUGH_UNTRACKED` for the variables of `tool_env()` and the + tool paths; + - a comment carrying `toolset_identity()`; + - `VCPKG_CRT_LINKAGE` from §6.5. +- **The host triplet.** The derived triplet is passed as the host triplet too + (`--host-triplet`). Otherwise vcpkg builds its host ports with a standard + triplet and looks for Visual Studio (§2.2). +- **The toolchain file.** It reads each tool path from the environment through + `file(TO_CMAKE_PATH)` and sets `CMAKE_C_COMPILER`, `CMAKE_CXX_COMPILER`, + `CMAKE_RC_COMPILER` and `CMAKE_MT`. It then includes vcpkg's own + `scripts/toolchains/windows.cmake` on the MSVC ABI (or `linux.cmake`, + `osx.cmake`), so that ports keep vcpkg's standard flags. Its text holds no path. +- **`PATH`.** The action runs vcpkg with the tools' directories first on `PATH` + and `VCPKG_KEEP_ENV_VARS=PATH`, because make-based ports find `link.exe` on + `PATH` (M3b, M3c). vcpkg does not hash that variable. +- **A port that needs MSBuild.** It cannot build without Visual Studio, and under + `chain` it fails with `/p:PlatformToolset=external` (M2c). The plugin recognises + that failure in vcpkg's output and reports it by name. The report says that the + port needs MSBuild, which the managed toolset does not contain, and suggests + `msvc@system` or `toolset = detected` for this project. + +**Under `detected`.** The behaviour of 0.16.0, except that the CRT linkage of +§6.5 applies. It lives in the compatibility unit `deps/compat/detected_toolset.cppm` +until 2027-03-28 (§3.7) and prints its note once per build. + +### 6.4 P1 · deps-cmake + +**Options.** `options.toolset`, `options.generator` (`default`, `ninja`) and +`options.crt_linkage`. + +- **Under `instance`.** CMake's default generator (Visual Studio) is kept and + pointed at the resolved toolset through two mechanisms CMake documents: + `CMAKE_GENERATOR_INSTANCE=` and `-T version=`. + `generator = ninja` switches to the `chain` form below. +- **Under `chain`.** The Ninja generator, with mcpp's own ninja + (`ninja_program()`) as `CMAKE_MAKE_PROGRAM`, and the tools by absolute path. The + action's environment is `tool_env()`, with the tools' directories first on + `PATH`. +- **In both.** `CMAKE_MSVC_RUNTIME_LIBRARY` follows §6.5. +- **Under `detected`.** CMake's default generator and the toolset it finds, as in + 0.16.0, with `CMAKE_MSVC_RUNTIME_LIBRARY` from §6.5. It lives in the same + compatibility unit as deps-vcpkg's, until 2027-03-28. + +### 6.5 P2 · The C++ runtime linkage follows the contract (E2b confirmed) + +- **Deriving the linkage.** `VCPKG_CRT_LINKAGE` and `CMAKE_MSVC_RUNTIME_LIBRARY` + follow `msvc_crt_linkage()`. Under `self-contained`, the default vcpkg triplet + becomes `-windows-static`. +- **A project triplet that contradicts the contract.** If a triplet named by the + project has a CRT linkage that contradicts the contract, the plugin stops with + an error naming both statements. Two explicit statements that cannot both hold + are an error (principle 4). This includes the silent DLL case of reading E2b-3, + which no linker reports. +- **Override.** `options.crt_linkage` overrides the derived value; the override is + the project's explicit statement, and it wins. + +**Criterion.** Reading E2b repeated after P2: +- `self-contained` with the default triplet builds and runs with `/MT` + throughout; +- `self-contained` with an explicit `x64-windows` fails with the plugin's error; +- `toolchain-coupled` is unchanged. + +### 6.6 P3 · vcpkg's ABI hash across machines + +Under `chain`, the hash depends on `toolset_identity()` and on the compilers' +executables, never on a path. Reading P3 confirms it: the same toolset at another +path restored every package from the cache. + +### 6.7 P4 · Binary sources, and P5 + +- **P4.** vcpkg reads `VCPKG_BINARY_SOURCES` and `VCPKG_DEFAULT_BINARY_CACHE` + from the environment, and the plugin passes them through. The documentation + states it; nothing is added. Hosting a cache is a project's decision (§9). +- **P5.** No design exists until a reading gives a CMake dependency's share of a + consumer's build. + +### 6.8 P6 · The plugin test kit (`mcpp.plugins.testing`) + +A plugin's logic is a function of the build context. Its effect is the set of +directives and actions it emits, which are structured `mcpp:` lines. + +- **What the kit does.** It runs a plugin function against a stated context: the + target, the toolchain facts of E2, the profile and the features. It then + compares the directives and actions the function emitted with the expected + ones. +- **Where the tests run.** In the plugin's own `tests/`, on every row, without a + foreign toolchain installed. A foreign tool's behaviour stays with the CI rows of + §6.9. +- **Feature.** `plugins-testing`, so that no build program compiles it unless it + asks. + +**Criterion.** +- deps-vcpkg's selection of mechanism (`instance`, `chain`, `detected`) is tested + on Linux against contexts that describe a Visual Studio row, a managed row and a + libc++ row. +- A deliberate change to the emitted triplet fails such a test. + +### 6.9 Plugin CI and documentation + +**CI.** The legs of §2.2 become the plugins' CI rows: +- the Visual Studio row with `resolved` (M1, M2a); +- the masked row with the managed toolset: a CMake port, icu, a CMake project; +- the row that expects the MSBuild error by name (M2c); +- E2b with P2. + +The existing Linux libc++ row covers `resolve({resolved, row})`. + +**Documentation.** +- Each option is documented with its values, default and upgrade effect, and + which port kinds each mechanism builds. +- L2 is documented as `plugins-core`. +- The naming rule of §3.5 is documented for plugin authors. + +## 7. Index items (mcpp-index) + +- **I1 · The naming rule as an admission rule.** Four places change: + - `docs/package-types.md` (both languages) gains a section on build-plugin + packages: the naming rule of §3.5, the manifest form (`plugins-core`, and + `reexport` where consumers need L2), and the engine floor of §3.6. + - `docs/repository-and-schema.md` states the rule among the validation rules. + - `.agents/skills/add-mcpp-index-package/SKILL.md` adds the rule to its + checklist. + - `validate.yml` checks every member's provided modules (the `provides` fields + of `mcpp emit build-database`) against its namespace. The reserved second + segments are refused as new namespaces. +- **I2 · The sweep for E6 phase 2.** It reports the libraries that export modules + without a root, and which of them publish headers only on purpose. + +## 8. Order, repositories and versions + +| Step | Repository | Depends on | Version | +|---|---|---|---| +| E2, E3, E4, E5, E6 phase 1, E7, E8, E9, E10, E11, E12; SPEC-007 (`mcpp.core`, build information, reserved segments); SPEC-008 | mcpp | none | next mcpp release (V) | +| E1 | mcpp | none; can ship alone | V or a following release | +| P0 to P4, P6 | mcpp-plugins | V released; `[package] mcpp = ">=V"` | plugins 0.17.0 | +| I1 | mcpp-index | E10 released (the engine and the index state one rule) | none | +| Validation | Sunrisepeak/GalTranslPP | plugins 0.17.0 | `resolved` on the masked row; `pack -p` | +| I2 | mcpp-index | E6 phase 1 released | input to E6 phase 2 | + +## 9. Outside this design, and why + +| Item | Why | +|---|---| +| Hosting a vcpkg binary cache (release assets, GitCode, NuGet) | a project decides whether first builds justify a feed; vcpkg already reads the sources (P4) | +| Two `-p` invocations instead of `--workspace`; Updater as an `artifacts` dependency; hoisting repeated `[target.windows.build]` values to the root | the project's manifests; each is available today | +| A CI job for the `release` profile | the project's CI | +| Re-running build programs under `mcpp pack` | by design: the pack context (`pack_format`) is an input of the build program; the recompilation it prints costs about 1 s | +| Flat module names (`Tool`, `Dictionary`) in GalTranslPP's core | the project's naming; I3 and the facade form of I2 are the remedy when the library is published | +| Deriving `host-module` from a build program's imports (a consumer writes no `host-module = true` when `build.mcpp` imports the module) | a usability improvement, independent of every item here; it has its own evaluation, with its own criterion, so that it is not lost by being folded into another item | +| Payloads provisioned when a build program uses them | unnecessary once families are enabled by feature (§3.4) | +| An index resolver that skips versions above the engine's floor | a follow-up of E9 in mcpp-index and xlings | + +## 10. Compatibility and upgrade + +| Change | Effect on an existing project | +|---|---| +| E1 | each workspace member used as a path dependency is compiled once more, into the workspace-scoped directory | +| E2 | every build program runs once more, because its context gains variables | +| E4 | the placement edge runs once; its content comparison writes nothing that is equal | +| E5 | the first build records the new verdicts; later no-op builds take the fast path on Windows and macOS | +| E6 phase 1, E10, E12 | new warnings only; the only output change is the corrected "Withheld" row | +| E7, E8, E11 | none; `import mcpp` keeps working permanently | +| E9 | none, until a package declares a floor | +| P0 | none; `surface` is an alias for six months | +| P1 default `resolved` | see D3 in §11 for each case; `detected` stays in a compatibility unit until 2027-03-28 | +| P2 | none on the dynamic CRT; `self-contained` projects change from a mismatch (loud or silent) to a consistent link, or to a named error when their own triplet contradicts the contract | + +The first build after upgrading to V pays E1, E2 and E4 at once. The changelog +states them together, so that one slow build is not read as a regression. + +## 11. Decisions + +| | Decision | State | +|---|---|---| +| D1 | E1 as a keyed sub-build | settled | +| D2 | E2 as general build information; use belongs to plugins | settled | +| D3 | P1's default is `resolved`, with the mechanism chosen by the toolset's origin; `detected` in a compatibility unit until 2027-03-28 | settled | +| D4 | E2b first, then P2 | settled; E2b confirmed | +| D5 | E4 as `mcpp stage --list` | settled | +| D6 | E6 as warnings only | settled | +| D7 | E5 `NotApplicable` verdict | settled | +| D8 | W3 in phase 1 | settled | +| D9 | I3 as a recommendation, with a note from pack | settled | +| D10 | the three layers of §3.1, with L2 behind `plugins-core` and the families behind their features | settled | +| D11 | P2 refuses a project triplet that contradicts the contract, including the silent DLL case | settled (revision 5) | +| D12 | `mcpp` and `mcpp.core` permanently equivalent | settled | +| D13 | module names by §3.5: engine warning (E10) and index admission rule (I1) | settled | +| D14 | the per-package engine floor `[package] mcpp = ">=V"` (E9) | settled | +| D15 | compatibility units with a six-month retirement date and a CI check (§3.7) | settled | +| D16 | E7 (the missing module names its feature), E11 (structured diagnostics), E12 (`[lib]` unknown keys), P6 (test kit) | settled (revision 5) | + +**D3 in detail: what the default `resolved` changes, case by case.** + +| Case | 0.16.0 (`detected`) | 0.17.0 default (`resolved`) | Evidence | +|---|---|---|---| +| Windows with Visual Studio; mcpp resolves `msvc@system` (the usual machine, and GalTranslPP's CI) | vcpkg picks an instance itself | the same instance, named; **no rebuild**; MSBuild ports build | M1, M2a | +| the same, but the machine has several instances or toolsets and mcpp resolves another one than vcpkg would | ports built by a toolset other than the program's | ports built by the program's toolset; **one rebuild** | M1 (mechanism), vcpkg `get_toolset` | +| Windows without Visual Studio; managed toolset | deps-vcpkg fails: no instance | CMake and make ports build; MSBuild ports fail with the named error | M3a, M3c, M2c | +| Windows with Visual Studio, and the project pins a managed toolset | ports built by Visual Studio's toolset, the program by the managed one (inconsistent) | CMake and make ports rebuilt once by the managed toolset; **an MSBuild port now stops with the named error** | M2c | +| Linux, gcc or clang row | ports built by the host's `cc`/`c++` | ports built by the row's compilers; one rebuild | the libc++ row already works this way | +| macOS | ports built by Apple clang | ports built by the row's clang; one rebuild | as above | + +The fourth row is the only one where the new default stops a build that 0.16.0 +completed. What 0.16.0 produced there was a program and its dependencies compiled +by two toolsets. The named error gives two remedies: `msvc@system`, or +`toolset = detected` while the compatibility unit exists. + +## 12. Global review (revision 4) + +The whole design was read again against its principles, against the readings, and +against the recorded failure shapes of earlier rounds. Findings 1 to 14 of +revisions 2 and 3 stand as recorded there unless a finding below revises them. + +1. **A withdrawn item was replaced, not patched.** Revision 3's E7 (a `[host-module]` + table) and its intermediate successor (deriving a unit's build from its + imports) both changed the engine's build semantics. The only package that needs + them is mcpp-plugins, which has an ordinary build (`mcpp-embed`) beside its + build-program modules on purpose. A general engine change for one package's + layout contradicts principle 1. L2 behind `plugins-core` needs no engine + change; the new E7 improves the one usability gap it leaves, for every package + with features. Every mention of `[host-module]` as a table was removed. +2. **A compatibility argument was wrong (corrected in §5.5 and §3.6).** + Revisions 2 and 3 said an older engine refuses a manifest with an unknown key + inside a known table. That was measured on 2026-09-20 for `[c-abi]` and + generalised. Measured now on 2026.9.28.2: + - `[package]` and `[build]` warn and ignore an unknown key; + - `[lib]` ignores it silently; + - a dependency spec warns and ignores it. + + The phase-2 condition of E6 and the choice of `[package] mcpp` rest on this + reading, not on the older one. +3. **Measuring the correction found a defect (E12).** `[lib]` gives no message for + an unknown key, so a misspelt `path` goes unnoticed. This is the silent half of + a behaviour whose loud half (`[build]`) already exists. +4. **A requirement had been folded away (now recorded in §9).** Deriving + `host-module` from a build program's imports appeared in the discussion as half + of item B. When B changed, it had no entry of its own. It is recorded as a + separate evaluation with its own criterion, so that no later item carries it + implicitly. +5. **The architecture needs its contract written.** Without SPEC-007's `mcpp.core` + section, the stability policy and the protocol table exist only in this design. + E8's criterion checks the table against `kProtocolVersion`, so that the + specification cannot drift from the code. +6. **The naming rule has two enforcers, and they must state one rule.** E10 + (engine) and I1 (index) read the same reserved list. SPEC-007 is its single + source, and I1 is ordered after E10 (§8). +7. **The reserved second segments must not collide with existing namespaces.** + Checked against mcpp-index `e9b80c5`: none of `core`, `plugins`, `deps`, + `rules`, `dist`, `tools` is a namespace. A dotted namespace such as + `mcpplibs.capi` maps to `mcpp.mcpplibs.capi.*`, which the rule accepts. +8. **The floor cannot help an engine that does not read it.** An engine below V + warns about `[package] mcpp` and continues, and a plugin needing E2 then fails + to compile with a missing symbol. This is today's behaviour, not a regression. + The index follow-up of §9 (a resolver that skips versions above the engine's + floor) is the complete answer and is recorded, not designed. +9. **Compatibility units need their own check (D15).** A retirement date that + nothing checks is not a retirement date. The CI check of §3.7 is part of the + convention, and each first unit has its date. +10. **Upgrade costs coincide.** E1, E2 and E4 each cost one run after upgrading to + V. §10 states them together. +11. **Items not measured.** These rest on documentation or on reasoning, and each + is marked as such where it appears: + - `CMAKE_GENERATOR_INSTANCE` with `-T version=` under `instance`; + - the default `resolved` on the Linux gcc and macOS rows; + - E1's extra ninja run on Windows; + - E7's reading of inactive feature units. + + Each has a criterion that measures it. + +Items checked and found in order: + +- no item makes the engine learn a foreign tool; +- every warning has a reader; +- every criterion names its denominator or its reading; +- no item is justified by GalTranslPP alone; +- every changed generated command line has a stated one-time cost; +- every decision in §11 has a state. + +## 13. Tasks, dependencies and criteria (revision 5) + +Each repository receives one pull request that carries all of its tasks. The +order follows the dependencies: the engine first, because the plugins' floor +names its release; the plugins next; the index and the validation project last. + +### 13.1 mcpp (one pull request, release V = 2026.9.29.1) + +| Task | Items | Criterion (test) | +|---|---|---| +| T1 manifest | E9 `[package] mcpp` and `[workspace.package] mcpp`; E12 `[lib]` unknown keys | unit tests of the parser; an e2e that stops below the floor and builds at it | +| T2 `mcpp.core` | E8 the two embedded units and SPEC-007's `mcpp.core` section with the protocol table | an e2e with `import mcpp.core;`, `import mcpp;`, and both; a unit test comparing the table with `kProtocolVersion` | +| T3 build information | E2 accessors, including E2b's two contract accessors, `msvc_instance_dir`, `ninja_program`; protocol 14 | an e2e reading every accessor on the Linux row; the Windows rows of CI read the MSVC ones | +| T4 diagnostics | E11 structured diagnostics; E7 the missing module names its feature; E10 the module-name warning | e2e for each, including the cached replay of a diagnostic | +| T5 placement | E4 `mcpp stage --list` and one placement edge per program | a unit test of the list format; an e2e with many deploy entries: one edge, a no-change build runs nothing, one change rewrites one file | +| T6 packaging | E3 `mcpp pack -p`; E6 phase 1 (W1 to W3, the corrected "Withheld" row) and SPEC-008 | e2e for `pack -p`, and for the `Alpha`/`Beta` library with and without a facade | +| T7 fast path | E5 `NotApplicable` verdicts | e2e 645 reads MEASURED on Windows and macOS | +| T8 workspace | E1 keyed sub-builds for workspace members used as path dependencies | the e2e criteria of §4.1, counted from `.ninja_log` | +| T9 records | docs (04, 05, 07, 10, 12, 30 and their Chinese copies), CHANGELOG with the compatibility list, SPEC-007, SPEC-008 | the documentation checks of CI | + +T1 to T7 are independent of each other and of T8. T3 precedes the plugins. + +### 13.2 mcpp-plugins (one pull request, release 0.17.0) + +| Task | Items | Depends on | Criterion | +|---|---|---|---| +| U1 structure | P0: `plugins-core` (was `surface`), `plugins-testing`, `mcpp.plugins.fs`, compatibility units with their CI check, `[package] mcpp = ">=V"` | V | the package builds; a consumer with `plugins-core` imports L2 | +| U2 toolset | P1: `mcpp.plugins.toolset` with `instance`, `chain`, `detected` (compat) | T3 | P6 tests of mechanism selection | +| U3 deps | P1 and P2 in deps-vcpkg and deps-cmake, P3, P4 documentation | U2 | the CI rows of §6.9 | +| U4 test kit | P6 `mcpp.plugins.testing` | T3 | the plugins' own tests use it | + +### 13.3 mcpp-index (one pull request) + +I1 (documentation, skill, `validate.yml` check) and the registration of mcpp V and +plugins 0.17.0; then I2 as a report of the sweep. Criterion: the full sweep is +green with V. + +### 13.4 Validation + +- **Sandbox.** A fresh xlings sandbox with the CN mirror configured for mcpp and + xlings. It checks that the released V and plugins 0.17.0 install, build and run + the index members and the examples of this design. +- **GalTranslPP.** PR #3 is rebased onto the latest upstream and adopts the + results: `plugins-core` through the family features, `mcpp pack -p` from the + root, and `resolved` in its CI. Its Windows CI measures the build again against + the readings of §2. + +### 13.5 Implementation record + +**mcpp (release 2026.9.28.3).** T1 to T9 landed on one branch, one commit per task. +Each item has an e2e: + +| Item | Test | +|---|---| +| E9, E12 | e2e 822 | +| E8 | e2e 823, plus `tests/scripts/test_protocol_table.py` | +| E2 | e2e 824 on Linux; e2e 825 on the Windows rows | +| E11, E7, E10 | e2e 826, plus unit tests in `test_provisions` | +| E4 | e2e 827, plus the updated `test_ninja_backend` | +| E6 | e2e 828 | +| E3 | e2e 829 | +| E1 | e2e 830 | + +**Departures from the design, each for a reason found while implementing.** + +1. **E1's shared directory is the member's own build directory.** The design + planned a separate keyed directory `target/.members//`. The member's + own root build is used instead, so a `--workspace` build, which builds the + member anyway, compiles it exactly once, and no second layout exists. +2. **E1 admits by key inputs, not by key hash.** The key hash of the member as a + dependency differed from its key as the root in one input only, + `package.index`: the namespace under which a dependency is reached, which is + empty for a root. The admission therefore compares the key's inputs with that + field removed. +3. **E1's stage edges compare content.** The global cache's stage edges compare + sizes, which is sound for an immutable entry. e2e 830 measured that it is not + sound for a member: a header change produced an object of the same size, and + the consumer kept the old one. Units served from a member carry + `servedFromMember`, and their edges compare content. +4. **E1 applies in the global cache mode.** The build keys are computed only + there; `--cache off` compiles everything in the graph that asks for it. +5. **E2's `tool_env()` is the environment the engine itself runs the toolset + with** (`INCLUDE`, `LIB`, `PATH`, `VSLANG` from `build_env_for_cl`), not the + list of directory variables §3.2 named. One environment, one producer. +6. **E11's diagnostic has no `path` field.** No reader of a path was found. +7. **E5 is a reading first.** The verdict a PE or Mach-O artifact records on + those platforms is already `Pass` (the ELF rules state "not applicable" and + pass), so revision 1's attribution was not right. Every refusal point of both + fast paths now states its condition under `-v`, e2e 645 prints it on the rows + where the fast path declines, and the fix follows the CI reading. +8. **W3 is skipped under `MCPP_SCANNER=p1689`.** That scanner does not report + which imports are `export import`, so every re-exported module would read as + private. + +**mcpp-plugins, mcpp-index, validation.** Recorded below as they land. diff --git a/.agents/docs/README.md b/.agents/docs/README.md index 020051da..6771e4e2 100644 --- a/.agents/docs/README.md +++ b/.agents/docs/README.md @@ -18,7 +18,7 @@ superseded_by: 2026-09-07-....md # when status is superseded --- ``` -315 records. +316 records. ## By subject @@ -31,6 +31,7 @@ Records that declare one. Everything else is listed by date below. ### design - [An ecosystem design for mcpp and xlings: one authority per fact, and the work that follows from it](2026-09-28-ecosystem-design-and-optimisation-plan.md) — landed +- [Build cost, foreign toolsets, the build-plugin architecture and the library surface: engine, plugin and index design (#734)](2026-09-28-build-cost-foreign-toolsets-and-library-surface-design.md) — active - [The compile database, `emit build-database`, and #701/#702: triage against the specifications, and one design](2026-09-26-compile-database-and-issue-699-design.md) — landed - [Issues #693 to #696: triage against mcpp's contracts, and one repair plan](2026-09-25-issues-693-696-triage-and-repair-plan.md) — landed - [Workspace inheritance, flag scoping and the published form: a unified repair plan (#690)](2026-09-25-issue-690-workspace-build-inheritance-consistency.md) — landed @@ -108,6 +109,7 @@ Records that declare one. Everything else is listed by date below. - [Two days of mcpp and xlings: a review of what merged, what is known, and what is open](2026-09-28-ecosystem-review-of-two-days-of-mcpp-and-xlings.md) — active - [An ecosystem design for mcpp and xlings: one authority per fact, and the work that follows from it](2026-09-28-ecosystem-design-and-optimisation-plan.md) — landed +- [Build cost, foreign toolsets, the build-plugin architecture and the library surface: engine, plugin and index design (#734)](2026-09-28-build-cost-foreign-toolsets-and-library-surface-design.md) — active - [Eight reports after 2026.9.27.1: implementation plan](2026-09-27-eight-reports-implementation-plan.md) — active - [Eight reports after 2026.9.27.1: what each one is, where it belongs, and one optimisation plan](2026-09-27-eight-reports-by-home-and-one-optimisation-plan.md) — active - [The compile database, `emit build-database`, and #701/#702: triage against the specifications, and one design](2026-09-26-compile-database-and-issue-699-design.md) — landed diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d25f38a..54117b3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,56 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [2026.9.28.3] - 2026-09-28 + +本版本实施 #734 设计中 mcpp 的部分:构建插件体系的三层(`mcpp.core`、官方通用库、插件)、 +构建信息、工作区成员只构建一次、批量放置、`pack -p`、库接口规范的第一阶段与包的版本下限。 +设计、测量、任务划分与实施记录见 +`.agents/docs/2026-09-28-build-cost-foreign-toolsets-and-library-surface-design.md`,规范见 +SPEC-007 §9 与新的 SPEC-008。配套的 mcpp-plugins 0.17.0 以本版本为下限。 + +### 行为变化 + +- **被其他成员以 path 依赖使用的工作区成员只构建一次(E1)。** 消费方构建之前,成员在自己的目录中 + 作为自身构建的根构建一次,由它的 ninja 判断过期(包括成员根目录之外的头文件);消费方经 stage + 边取得其对象与 BMI,按内容比较。条件是成员在消费方图中的构建键输入与它作为根时相等 + (`package.index` 除外,它只记来源);不相等时照旧在消费方图中编译,`-v` 写出不同的输入。同时 + 构建的两个消费方以文件锁轮流使用成员目录。一个五成员 Windows 工作区的核心库此前被编译三次。 +- **程序旁的文件由一个进程放置(E4)。** 程序的 deploy 条目在两条及以上时成为一条 `stage_list` + 边,读取规划写出的 `placements.list`;`mcpp stage --list` 对每个目的地保持单文件语义。在 + Windows 首次构建上,1270 条单文件放置耗时 4.5 s,一个进程复制同样的文件耗时 0.5 s。 +- **库接口的第一阶段(E6,SPEC-008)。** `mcpp pack` 写出未进入发布闭包的导出模块(W2),"Withheld" + 一行列出每个未发布的单元(此前没有接口根时显示 "(nothing)",而两个导出模块既未发布也未列出); + `mcpp build` 在包导入依赖的非公开模块时警告(W3);缺少接口根的警告写明对 `mcpp pack` 的后果 + (W1)。全部为警告。 + +### 特性 + +- **`mcpp.core`(E8,协议 14)。** 引擎接口以 `mcpp.core` 为名,`mcpp` 是永久等价的写法。 +- **构建信息(E2,协议 14)。** `mcpp::tool(role)`、`abi_tool(role)`、`tool_env()`、 + `toolset_identity()`、`msvc_instance_dir()`、`ninja_program()`、`cxx_runtime()`、 + `msvc_crt_linkage()` 以事实陈述解析出的工具链与程序的 C++ 运行时契约,取自引擎自身命令行所读的 + 同一来源。 +- **结构化诊断(E11,协议 14)。** `mcpp::report({severity, message, impact, hint})` 以引擎的形式 + 呈现,进入 JSON 输出,缓存命中时重放。 +- **缺失的构建程序模块指出 feature(E7)。** 模块只在依赖未启用的 feature 之后提供时,错误写出包名、 + feature 名与应加的一行。 +- **插件模块的名字(E10)。** `mcpp.<自己的命名空间>.*` 不再被警告;保留的第二段(`core`、 + `plugins`、`deps`、`rules`、`dist`、`tools`)只属于命名空间 `mcpp` 的包。 +- **`mcpp pack -p `(E3)。** 在工作区根目录打包某个成员,结果与在成员目录中打包相同。 +- **包的版本下限(E9)。** `[package] mcpp = ">="` 与 `[workspace.package] mcpp`; + 低于下限的引擎在其他工作之前停止,写出升级命令;只接受 `>=`。 +- **`[lib]` 报告未知键(E12)。** 此前拼错的 `path` 被静默接受。 +- **快路径说明拒绝原因(E5 的测量部分)。** `-v` 下每个拒绝点写出其条件。 + +### 兼容性 + +- 协议升至 14:使用 §9 新接口的构建程序在旧引擎上编译失败并指出缺少的名字;以 + `[package] mcpp` 声明下限的包在旧引擎上得到一条"不支持的键"警告。 +- 升级后的第一次构建各付一次代价:每个构建程序因上下文新增变量而重新运行一次;被共享的工作区 + 成员在消费方中改为暂存,其对象在成员目录中编译一次;程序旁的放置边形状改变而运行一次(内容相同 + 的文件不重写)。 + ## [2026.9.28.2] - 2026-09-28 本版本实施 2026-09-28 生态设计中 mcpp 的部分(WS1、WS2、WS3、WS7、WS8、WS10 与决定 D7),关闭 diff --git a/docs/04-mcpp-toml.md b/docs/04-mcpp-toml.md index d52b8abf..f8d58a02 100644 --- a/docs/04-mcpp-toml.md +++ b/docs/04-mcpp-toml.md @@ -103,6 +103,22 @@ mcpp does not read is reported, as in `[build]`: a warning, and an error under resources = "res" ``` +`mcpp = ">="` (mcpp 2026.9.28.3+) states the oldest mcpp release the +package supports. It is a floor, not a pin: the release a project installs is +the one `.xlings.json` names. Only the `>=` form is accepted, because a bare +release means "exactly" elsewhere in mcpp; a bare release and a value that is +not a release are refused with the spelling that is meant. An engine below the +floor stops before any other work, naming the package, the floor, its own +release and the command that installs a newer one. `[workspace.package] mcpp` +states it once for every member. An engine older than 2026.9.28.3 ignores the +key with a warning. + +```toml +[package] +name = "myplugin" +mcpp = ">=2026.9.28.3" +``` + #### Dialect flags and the `import std` BMI Some flags change what the standard library's headers declare, so the precompiled `import std` @@ -1022,6 +1038,10 @@ path = "src/capi/lua.cppm" # Override the default lib-root location ``` Default convention: `src/.cppm` (e.g. package name `mcpplibs.cmdline` → `src/cmdline.cppm`). +`path` is the only key of the table; any other key is reported, as in `[build]` +(mcpp 2026.9.28.3+). The lib root is the interface root of SPEC-008: the module a +packed library publishes, with what it re-exports, and the module a build +program imports from a host-module dependency. ### 2.5 `[dependencies]`, `[dev-dependencies]`, `[build-dependencies]` Moved to [05 — Dependencies and Resolution](05-dependencies.md). diff --git a/docs/07-workspace.md b/docs/07-workspace.md index 9b587aad..db87a023 100644 --- a/docs/07-workspace.md +++ b/docs/07-workspace.md @@ -413,6 +413,28 @@ fan-out continues; a timed-out build fails that member; `--workspace-timeout` st the fan-out and lists what did not run instead of leaving the CI job to kill the process (which discards everything it had to say). +### 5.4 A member used by other members is built once (mcpp 2026.9.28.3+) + +A member that other members use as a path dependency is built once, in its own +directory, and every member that uses it takes its objects and module +interfaces from there. A `--workspace` build and separate `-p` builds of two +programs therefore compile a shared library member once, where each program +used to compile it again in its own directory. + +- **Staleness is the member's own.** Before a consumer builds, the member's + own build runs, and its ninja decides what is stale, including an input + outside the member's root (a header under `../3rdParty`). +- **Equal build keys are the condition.** The member's build key in the + consumer's graph must equal its key as the root of its own build; the key + covers the toolchain, the flags, the profile and the features. A member that + a consumer builds differently (another feature set, for example) is compiled + in that consumer's graph, as before; `-v` states the input that differs. +- **One build at a time.** Two consumers built at once take turns on the + member's directory. +- **Scope.** The rule applies in the default cache mode (`--cache off` compiles + everything in the graph that asks for it) and to members only; a path + dependency outside the workspace has one consumer and keeps its behaviour. + ## 6. Directory Layout The recommended directory layout for a workspace: diff --git a/docs/10-pack-and-release.md b/docs/10-pack-and-release.md index 3dc570a3..31321753 100644 --- a/docs/10-pack-and-release.md +++ b/docs/10-pack-and-release.md @@ -147,6 +147,7 @@ mcpp pack --profile dev # build with a different profile (default mcpp pack --dev # the same, as `build` and `run` spell it; --profile wins over it mcpp pack --message-format json # one mcpp.pack envelope on stdout (mcpp 2026.9.16.1+) mcpp pack --no-strip # ship the artifacts as built +mcpp pack -p app --format release # a workspace member, as if run in its directory mcpp pack --debug-symbols dbg/ # write the separated *.debug files under dbg/ mcpp pack --format msi --features installer # activate root-package features for the pack ``` @@ -158,6 +159,11 @@ one distribution is declared under `[feature-deps.]` with `tools = [...]` and only by the pack that names ``. `mcpp run --format --features ` hands the same features to the pack it performs. +`-p ` (mcpp 2026.9.28.3+) packs a workspace member from the workspace +root: the member is resolved as every other `-p` resolves it, and the pack runs in +its directory, so the result is the one `mcpp pack` in that directory produces. A +relative `-o` keeps meaning the directory the command was typed in. + `--release` and `--dev` (mcpp 2026.9.16.1+) are the shorthands `build` and `run` take, with the same precedence: `--profile` wins over either, on all three commands. diff --git a/docs/12-binary-distribution.md b/docs/12-binary-distribution.md index 0d7d6642..4415c7ef 100644 --- a/docs/12-binary-distribution.md +++ b/docs/12-binary-distribution.md @@ -137,6 +137,23 @@ error: the published interface imports mathkit:secret , which no unit in this Restructure so the interface does not reach it, or make it an `export module` partition and accept that its source is published. +### Public modules, and what `mcpp pack` and `mcpp build` report (mcpp 2026.9.28.3+) + +A package's public modules are the lib root's module and what it re-exports +with `export import`, transitively (SPEC-008). They are what a consumer may +import; the published closure also carries what they import without +re-exporting, because a consumer needs it to build their interfaces. The +"Withheld" row lists every unit that is not published, including when the +package has no lib root. The three conditions below are warnings: a library may +implement itself in modules and publish only headers, and only its author can +state which it means. + +| Condition | Reported by | Consequence stated | +|---|---|---| +| a `lib` target exports modules and has no lib root | `mcpp build`, for the package being built | `mcpp pack` publishes the library without a module interface | +| exported modules that the packed form does not ship | `mcpp pack`, naming each module | a consumer of the packed form cannot import them | +| the package being built imports a module of a dependency that has a lib root, outside its public modules | `mcpp build`, naming the module and the public ones | the build succeeds from source and fails against the packed form | + ## The compatibility tag Every artifact records the toolchain it was built for: diff --git a/docs/30-build-mcpp.md b/docs/30-build-mcpp.md index 49baf5b0..41e0db38 100644 --- a/docs/30-build-mcpp.md +++ b/docs/30-build-mcpp.md @@ -1056,6 +1056,91 @@ one. `mcpp::graph_file()` names a JSON document that states the resolved graph: - The entries are the `graph` section of `resolution.json` with four additions (`manifest_dir`, `features`, `targets`, `metadata`), from one derivation. +### The interface's name: `mcpp.core` (protocol 14) + +The engine's build-program interface is named `mcpp.core`, which is the name the +specification (SPEC-007) gives to the layer. `mcpp` is its permanent equivalent: +the engine embeds both units, and the second consists of `export import mcpp;`. +A program may use either spelling, or both. + +```cpp +import mcpp.core; // the same symbols as `import mcpp;` +``` + +### Build information: the resolved toolchain (protocol 14) + +A build program reads the resolved toolchain as a set of facts. mcpp does not +translate them for any foreign build system; a plugin that drives CMake, vcpkg, +Meson or make does. Every value is empty when it does not apply, and a role is +one of `cc`, `cxx`, `ld`, `ar`, `rc`, `as`, `mt`. + +| Accessor | Value | +|---|---| +| `mcpp::tool(role)` | the row's tool for the role: the driver on a GNU-style row, the toolset's tools on the cl.exe row | +| `mcpp::abi_tool(role)` | the target ABI's native tool: on the MSVC ABI `cl`, `link`, `lib`, `ml64` and the SDK's `rc` and `mt`, whichever driver the row uses; elsewhere the same as `tool(role)` | +| `mcpp::tool_env()` | the environment the engine runs the ABI's tools with, one `KEY=value` per line (`INCLUDE`, `LIB`, `PATH` on the MSVC ABI) | +| `mcpp::toolset_identity()` | a path-free identity such as `msvc 14.44.35207; sdk 10.0.26100.0` or `clang 22.1.8` | +| `mcpp::msvc_instance_dir()` | the Visual Studio instance the MSVC toolset belongs to; empty for a managed toolset | +| `mcpp::ninja_program()` | the ninja mcpp itself runs | +| `mcpp::cxx_runtime()` | the program's C++ runtime contract: `self-contained`, `toolchain-coupled` or `host-coupled` | +| `mcpp::msvc_crt_linkage()` | on the MSVC ABI `static` (`/MT`) or `dynamic` (`/MD`), the value `place-dlls` reads | + +The values enter the build program's context, so every build program runs once +more after an upgrade to the release that introduced them. + +### Structured diagnostics: `mcpp::report` (protocol 14) + +A diagnostic stated with `mcpp::report` is rendered in the engine's form: the +message, then an `impact:` line and a `hint:` line. It reaches +`--message-format json` with the same fields, a `degraded` one fails the build +under `--strict`, and a cached run reports it again. `mcpp::warning(text)` stays, +and equals a diagnostic with a message only. + +```cpp +mcpp::report({.severity = "warning", + .message = "the generator found no schema", + .impact = "no bindings are generated", + .hint = "add schema/*.proto"}); +``` + +### Placing many files at once: `mcpp stage --list` + +`mcpp stage --list ` places every `\t` pair of the +file in one process. Each destination keeps the single-file semantics: an equal +destination is not written, a write goes out of place, and several sources for +one destination must agree. The engine places the deploy entries of a program +with one such edge when there are two or more; an action may call +`${mcpp.self} stage --list` as well. + +```text +data/a.txt bin/data/a.txt +data/b.txt bin/data/b.txt +``` + +### The names of a plugin's modules + +A module a build program imports may use the `mcpp.` prefix to state that it is +an mcpp plugin, under its package's own namespace. The engine warns when a +module outside namespace `mcpp` uses a reserved second segment or another +namespace; mcpp-index applies the same rule when it admits a package. + +| Module name | Provided by | +|---|---| +| `mcpp`, `mcpp.core` | the engine | +| `mcpp.plugins.*`, `mcpp.deps.*`, `mcpp.rules.*`, `mcpp.dist.*`, `mcpp.tools.*` | packages in namespace `mcpp` | +| `mcpp..*` | packages in that namespace | + +### A module behind a feature that is not enabled + +When a build program imports a module that a dependency offers only behind a +feature, the error names the package and the feature: + +```text +error: build.mcpp imports 'mcpp.rules.qt' + provided by: mcpp.plugins, feature "rules-qt" (not enabled) + hint: enable it on the dependency edge: mcpp.plugins = { ..., features = ["rules-qt"] } +``` + ### `import mcpp;` is the surface that evolves (mcpp 2026.8.5.1+) Two ways to talk to mcpp, and they carry **different compatibility promises**: @@ -1107,10 +1192,12 @@ if constexpr (requires { mcpp::runner("qemu"); }) // hard error when absent ``` A `requires`-expression over a **qualified name that does not exist** is -ill-formed, not `false`. So there is no in-language feature probe, and a -package that adopts a new directive states its floor in prose (its README) and -relies on the diagnostic above. Such a package should name the mcpp version it -requires. +ill-formed, not `false`. So there is no in-language feature probe. A package +that adopts a new directive states its floor in its manifest, +`[package] mcpp = ">="` (mcpp 2026.9.28.3+, docs/04): an engine below +the floor stops before compiling anything and names the release to install. An +older engine ignores the key with a warning, and the diagnostic above remains +what it reports. ### `import std;` (mcpp 2026.8.2.1+) diff --git a/docs/README.md b/docs/README.md index b1b533e0..1ad37ccc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -163,3 +163,4 @@ downstream tooling. - [SPEC-005 — The build database `mcpp emit build-database` prints](specs/build-database.md) - [SPEC-006 — Toolchain management: identity, origin, selection and the payload contract](specs/toolchain-management.md) - [SPEC-007 — Build plugins: configuration, construction and verification, and the runtime and planning obligations](specs/build-plugins.md) + - [SPEC-008 — A library's interface: public modules, the published closure, and one interface in both forms](specs/library-interface.md) diff --git a/docs/specs/README.md b/docs/specs/README.md index 40df1855..f74dd422 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -36,7 +36,8 @@ | [SPEC-004](manifest-semantics.md) | `mcpp.toml` 的平面划分、条件化形状、解析轴与命名规约 | 草案 v1.10 | 2026-09-28 | 条件化形状 mcpp >= 2026.8.29.1;目标轴 mcpp >= 2026.9.6.4;`linkage` 默认值 mcpp >= 2026.9.15.2;链接 flag 的词读法 mcpp >= 2026.9.26.2;条件化的 `dialect_cxxflags` 与 `-p` 的包身份 mcpp >= 2026.9.28.1;条件表按具体程度生效 mcpp >= 2026.9.28.2 | | [SPEC-005](build-database.md) | 构建数据库:`mcpp emit build-database` 的内容、取值规则与不写工程目录的保证 | 评审中 v1.5 | 2026-09-28 | mcpp >= 2026.9.15.1;v1.3 条款 mcpp >= 2026.9.26.2;v1.4 条款 mcpp >= 2026.9.27.1;v1.5 条款 mcpp >= 2026.9.28.1 | | [SPEC-006](toolchain-management.md) | 工具链管理:身份、来源、选择与载荷契约 | 草案 v0.4 | 2026-09-28 | 逐条标注;已实现条款 mcpp >= 2026.9.24.1;§3.7 mcpp >= 2026.9.28.1;§3.7.1 mcpp >= 2026.9.28.2 | -| [SPEC-007](build-plugins.md) | 构建插件:配置、施工与校验的分工,运行时与规划期的义务 | 草案 v0.5 | 2026-09-28 | 逐条标注;mcpp >= 2026.9.26.2;v0.3 条款 mcpp >= 2026.9.27.1;v0.4 条款 mcpp >= 2026.9.28.1;v0.5 条款 mcpp >= 2026.9.28.2 | +| [SPEC-007](build-plugins.md) | 构建插件:配置、施工与校验的分工,运行时与规划期的义务 | 草案 v0.6 | 2026-09-28 | 逐条标注;mcpp >= 2026.9.26.2;v0.3 条款 mcpp >= 2026.9.27.1;v0.4 条款 mcpp >= 2026.9.28.1;v0.5 条款 mcpp >= 2026.9.28.2;v0.6(§9)mcpp >= 2026.9.28.3 | +| [SPEC-008](library-interface.md) | 库的接口:公开模块、发布闭包与两种形态的一致 | 草案 v0.1 | 2026-09-28 | 第一阶段(只警告)mcpp >= 2026.9.28.3 | ## 文档约定 diff --git a/docs/specs/build-plugins.md b/docs/specs/build-plugins.md index d6b1948d..586e3137 100644 --- a/docs/specs/build-plugins.md +++ b/docs/specs/build-plugins.md @@ -4,12 +4,12 @@ |---|---| | 规范编号 | SPEC-007 | | 标题 | 构建插件:配置、施工与校验的分工,运行时与规划期的义务 | -| 状态 | 草案 v0.5 | -| 版本 | 0.5 | +| 状态 | 草案 v0.6 | +| 版本 | 0.6 | | 最后修改 | 2026-09-28 | -| 对应实现 | 逐条标注。未注明版本的「已实现」条款对应 mcpp >= 2026.9.26.1;注明 mcpp#702 的条款对应 mcpp >= 2026.9.26.2;注明 mcpp#707、#708、#709、#711 的条款对应 mcpp >= 2026.9.27.1;注明 mcpp#723 的条款对应 mcpp >= 2026.9.28.1;注明 mcpp 2026.9.28.2 的条款对应该版本 | -| 相关设计文档 | `.agents/docs/2026-09-26-compile-database-and-issue-699-design.md`(§5)、`.agents/docs/2026-09-28-ecosystem-design-and-optimisation-plan.md`(WS1、WS3) | -| 相关 issue | mcpp#699、mcpp#701、mcpp#702、mcpp#703、mcpp#707、mcpp#708、mcpp#709、mcpp#711 | +| 对应实现 | 逐条标注。未注明版本的「已实现」条款对应 mcpp >= 2026.9.26.1;注明 mcpp#702 的条款对应 mcpp >= 2026.9.26.2;注明 mcpp#707、#708、#709、#711 的条款对应 mcpp >= 2026.9.27.1;注明 mcpp#723 的条款对应 mcpp >= 2026.9.28.1;注明 mcpp 2026.9.28.2 的条款对应该版本;§9 与注明 mcpp#734 的条款对应 mcpp >= 2026.9.28.3 | +| 相关设计文档 | `.agents/docs/2026-09-26-compile-database-and-issue-699-design.md`(§5)、`.agents/docs/2026-09-28-ecosystem-design-and-optimisation-plan.md`(WS1、WS3)、`.agents/docs/2026-09-28-build-cost-foreign-toolsets-and-library-surface-design.md`(§3、§4) | +| 相关 issue | mcpp#699、mcpp#701、mcpp#702、mcpp#703、mcpp#707、mcpp#708、mcpp#709、mcpp#711、mcpp#734 | | 使用文档 | [docs/30 - build.mcpp](../30-build-mcpp.md)、[docs/31 - 编写规则包](../31-authoring-a-rule-package.md) | 本规范规定构建插件对引擎和对消费方承担的义务,以及引擎为此提供的机制。docs/31 说明怎样编写 @@ -195,8 +195,8 @@ ## 7. 版本与兼容 -- **R7.1** 使用协议 N 的指令或角色的插件,**必须**在其文档中写明第一个支持协议 N 的 mcpp - 版本。索引测量该插件时,CI 所用的 mcpp 版本移到该版本;索引的 `min_mcpp` 不因此改变。旧引擎 +- **R7.1** 使用协议 N 的指令或角色的插件,**必须**写明第一个支持协议 N 的 mcpp 版本: + mcpp 2026.9.28.3 起写在清单中,`[package] mcpp = ">="`(R9.8),此前写在文档中。索引测量该插件时,CI 所用的 mcpp 版本移到该版本;索引的 `min_mcpp` 不因此改变。旧引擎 编译该构建程序时因缺少函数或常量而失败,并指出其名称。(编译期失败 **已实现**) - **R7.2** 插件**禁止**依赖引擎内部的拼写与未写入文档的行为(R2.3、R2.4)。(作者义务) @@ -209,7 +209,49 @@ `mcpp emit build-database`,得到一份文档,其中该插件只贡献警告,或只使声明它的包缺少 构建程序的指令(R5.2),而不是整次失败。(作者义务) -## 9. 变更记录 +## 9. 引擎接口 `mcpp.core`、构建信息与插件的名字(mcpp#734) + +插件分三层:引擎提供的 `mcpp.core`(L1);官方通用库 `mcpp.plugins`(L2),只基于 L1 编写, +由 feature `plugins-core` 提供;具体插件(L3),即官方的 `mcpp.deps.*`、`mcpp.rules.*`、 +`mcpp.dist.*`、`mcpp.tools.*` 与第三方的 `mcpp..*`,基于 L1 或 L1 与 L2。依赖只 +向下:L3 依赖 L2 或 L1,L2 依赖 L1,L1 不依赖任何一层。 + +- **R9.1** 引擎提供给构建程序的接口名为 `mcpp.core`。`mcpp` 是它永久等价的写法:两者导出相同 + 的符号,构建程序可以使用任一写法或同时使用。等价不是兼容措施,不会被移除。(**已实现**) +- **R9.2** `mcpp.core` 只增不删。一个符号只有在弃用六个月之后才可以移除;含义的改变以新的符号 + 表达。每个符号标注引入它的协议版本。协议版本与 mcpp 版本的对应如下,构建插件以 mcpp 版本 + 表达需求(R9.8)。(**已实现**) + + | 协议 | 首个 mcpp 版本 | 引入的内容 | + |---|---|---| + | 11 | 2026.9.12.3 | `deploy` | + | 12 | 2026.9.26.2 | `prepare` 角色、`runtime_search_dir` | + | 13 | 2026.9.27.1 | action 的 `env` 与 `cwd` | + | 14 | 2026.9.28.3 | `mcpp.core`、构建信息(R9.3)、`mcpp::report`(R9.4) | + +- **R9.3** 构建信息以事实陈述解析出的工具链,不针对任何外部构建系统:`tool(role)`(这一行的 + 工具)、`abi_tool(role)`(目标 ABI 的原生工具,MSVC ABI 上为工具集的 `cl`、`link`、`lib`、 + `ml64` 与 SDK 的 `rc`、`mt`)、`tool_env()`(引擎运行这些工具时的环境变量)、 + `toolset_identity()`(不含路径的标识)、`msvc_instance_dir()`、`ninja_program()`、 + `cxx_runtime()` 与 `msvc_crt_linkage()`。每个值取自引擎自身命令行所读的同一来源;不适用时 + 为空。把这些事实翻译成 CMake、vcpkg 等外部系统的写法是插件的职责。(**已实现**) +- **R9.4** 构建程序以 `mcpp::report({severity, message, impact, hint})` 陈述诊断。引擎按自身 + 诊断的形式呈现它,记入 `--message-format json`,`degraded` 在 `--strict` 下使构建失败,缓存 + 命中的运行再次报告它。`mcpp::warning(text)` 等价于只有消息的诊断。(**已实现**) +- **R9.5** `mcpp stage --list ` 在一个进程内放置 `<源>\t<目的>` 列表中的每一项,每个目的 + 地保持单文件放置的语义(R4.2)。程序的 deploy 条目在两条及以上时由一条这样的边放置。 + (**已实现**) +- **R9.6** 构建程序可导入的模块若以 `mcpp.` 开头,第二段为 `core`、`plugins`、`deps`、`rules`、 + `dist`、`tools` 的名字只由命名空间为 `mcpp` 的包提供;其他包使用 `mcpp.<自己的命名空间>.*`。 + 引擎对违反者发出警告,因为它无法判断谁是官方(分支、私有镜像均属正当);mcpp-index 把同一 + 规则作为收录条件。保留的第二段只在本条增加。(引擎警告 **已实现**;索引收录条件见 mcpp-index) +- **R9.7** 构建程序导入的模块若由某个依赖在未启用的 feature 之后提供,错误**必须**写出包名与 + feature 名。只在出错时读取这些单元的模块声明。(**已实现**) +- **R9.8** 包以 `[package] mcpp = ">="` 声明支持的最旧 mcpp 版本, + `[workspace.package] mcpp` 为全部成员声明。低于下限的引擎在其他工作之前停止,写出包名、下限、 + 自身版本与升级命令;只接受 `>=` 形式。(**已实现**) + +## 10. 变更记录 | 版本 | 日期 | 变更 | |---|---|---| @@ -217,4 +259,5 @@ | 0.3 | 2026-09-27 | 随 mcpp 2026.9.27.1:新增 R3.8(action 的 `env` 与 `cwd`,协议 13,mcpp#708);R5.3 改为规划不构建宿主工具、缺失的工具以 note 推迟(mcpp#707);新增 R6.3(特性的 `tools`,mcpp#709)与 R6.4(`artifacts` 与 `${mcpp.artifact:}`,mcpp#711)。 | | 0.4 | 2026-09-28 | 随 mcpp 2026.9.28.1:R4.2 同一目标的多个来源在放置时按内容核对,相同则放置一份,不同则失败并点名全部来源;R4.3 一个目标一个写入者,链接后的放置不覆盖另一写入者放在程序旁的文件(mcpp#723)。 | | 0.5 | 2026-09-28 | 随 mcpp 2026.9.28.2:R4.3 的部署清单是运行时放置解析器的答案(SPEC-006 §3.7.1),MSVC C++ 运行时按集合规则决定,`prepare` 填充的目录中的运行时名字由同一解析器决定;新增 R4.5,构建边在成功时的通告,构建后报告一次(2026-09-28 设计 WS1、WS3)。 | +| 0.6 | 2026-09-28 | 随 mcpp 2026.9.28.3:新增 §9(mcpp#734),即 `mcpp.core` 与 `mcpp` 的永久等价、接口的稳定性与协议表、构建信息、结构化诊断、批量放置、插件模块的名字、缺失模块指出 feature、包的版本下限;R7.1 的版本写在清单中;变更记录移为 §10。 | | 0.2 | 2026-09-26 | 随 mcpp 2026.9.26.2 落地:R1.3 的警告、R2.1 的 `runtime_search_dir`、R2.4、R3.3 的 `prepare`(目录须含文件;链接边等待所有 `prepare`)、R3.5、R3.6、R4.1、R4.3、R5.2、R5.3 标为已实现。 | diff --git a/docs/specs/library-interface.md b/docs/specs/library-interface.md new file mode 100644 index 00000000..f6946def --- /dev/null +++ b/docs/specs/library-interface.md @@ -0,0 +1,85 @@ +# SPEC-008:库的接口:公开模块、发布闭包与两种形态的一致 + +| 项 | 值 | +|---|---| +| 规范编号 | SPEC-008 | +| 标题 | 库的接口:公开模块、发布闭包与两种形态的一致 | +| 状态 | 草案 v0.1 | +| 版本 | 0.1 | +| 最后修改 | 2026-09-28 | +| 对应实现 | 第一阶段(只警告)mcpp >= 2026.9.28.3;第二阶段未实现 | +| 相关设计文档 | `.agents/docs/2026-09-28-build-cost-foreign-toolsets-and-library-surface-design.md`(§5) | +| 相关 issue | mcpp#734 | +| 使用文档 | [docs/04 - mcpp.toml](../04-mcpp-toml.md)(`[lib]`)、[docs/12 - 二进制分发](../12-binary-distribution.md) | + +本规范规定一个库包对消费方公开什么、`mcpp pack` 发布什么,以及同一个包以源码形态和打包形态 +到达消费方时接口保持一致的条件。库包提供给构建程序的模块(构建插件的模块)由 SPEC-007 §9 +规定,不在本规范范围内。 + +规范用语与实现状态标记见 [规范索引](README.md)。 + +## 1. 目标 + +| 性质 | 含义 | +|---|---| +| 语义清楚 | 消费方可以导入的、包随包发布的、包保留的,各有一个名字 | +| 一致 | 无论包以源码还是以打包形态到达,消费方看到同一个接口 | +| 稳定 | 接口是一个由包自己的声明决定的集合,消费方可以跨版本依赖它 | +| 可分发 | 打包形态完整(消费方可以基于它编译)且最小(接口闭包之外的单元不外泄) | +| 简洁 | 每个包一处声明,以 C++ 本身表达(一个模块及其转出),不另维护列表 | +| 兼容 | 今天能构建的清单不因本规范停止构建 | + +## 2. 定义 + +- **接口根**:`[lib].path` 所指的单元;未写时按约定为 `src/<包名最后一段>.<模块接口扩展名>`。 + 它必须声明主模块接口(`export module ;`),不能是分区。 +- **公开模块**:接口根的模块,以及它用 `export import` 传递地转出的模块与分区。消费方可以 + 导入的就是这些名字。 +- **公开头文件**:`include/` 下的文件(docs/12)。 +- **接口**:公开模块与公开头文件的合集。 +- **发布闭包**:公开模块的接口传递地导入的每个单元,无论是否转出。消费方构建公开模块的 BMI + 需要它们。 +- **保留单元**:包的其余单元。它们编入库中,但不随包发布。 + +公开模块与发布闭包的区分是本规范的核心:前者是消费方可以依赖的契约,后者是 BMI 的构建需要。 + +## 3. 规则 + +- **I1** 一个包的接口与它到达消费方的形态无关。(第一阶段:由 W3 警告陈述,源码构建不受限制) +- **I2** 一个包至多有一个接口根,因此它有一个与身份 `(namespace, name)` 对应的可导入名字。代码 + 组织为多个模块的包,由接口根以 `export import` 转出它们(门面形式)。不设接口根的列表。 +- **I3** 公开模块的名字**应当**以包的身份为前缀:`.` 或其下的 + `..`,因为模块名在一个程序中是全局的。(第一阶段:建议;引擎已拒绝同一 + 图中的同名模块) +- **I4** 只发布头文件的接口是正当的。库可以用模块实现自己而只发布头文件,例如 C API。这样的库 + 没有接口根,公开模块为空。 +- **I5** 打包形态恰好包含发布闭包与公开头文件。(**已实现**) + +## 4. 诊断(第一阶段:只警告,mcpp >= 2026.9.28.3) + +每条诊断由读取接口的步骤发出,且只对能修改它的包发出。 + +| 编号 | 发出者 | 条件 | 陈述的后果 | +|---|---|---|---| +| W1 | `mcpp build`,只对当前构建的包 | `lib` 目标导出模块且没有接口根 | `mcpp pack` 发布的库不带模块接口 | +| W2 | `mcpp pack` | 有未进入发布闭包的导出主模块,逐个写出 | 使用打包形态的消费方无法导入它们 | +| W3 | `mcpp build`,只对当前构建的包 | 包导入了某个有接口根的依赖的非公开模块 | 从源码构建成功,对打包形态会失败 | + +`mcpp pack` 的 "Withheld" 一行列出每个未发布的单元,包括包没有接口根的情形。(**已实现**) + +## 5. 第二阶段(条件,未设计) + +W1 与 W2 在三个条件同时成立时成为错误,并提供声明"只发布头文件"的写法: + +1. mcpp-index 的扫描报告了导出模块而没有接口根的库,以及其中有意只发布头文件的库; +2. 索引 `min_mcpp` 所指的引擎版本能读取该声明;更旧的引擎忽略 `[lib]` 中的未知键,因此声明不会 + 使旧客户端失败,但也不会在旧客户端上生效; +3. 本规范进入评审状态。 + +扫描结果为零时,W1 与 W2 已完成它们的作用,第二阶段只剩报告。 + +## 6. 变更记录 + +| 版本 | 日期 | 变更 | +|---|---|---| +| 0.1 | 2026-09-28 | 首版草案(mcpp#734):定义、I1 至 I5、第一阶段的 W1 至 W3 与完整的 Withheld 报告。 | diff --git a/docs/zh/04-mcpp-toml.md b/docs/zh/04-mcpp-toml.md index 3e4ed97a..42ab3209 100644 --- a/docs/zh/04-mcpp-toml.md +++ b/docs/zh/04-mcpp-toml.md @@ -115,6 +115,18 @@ manifest 目录解析。`[package]` 中 mcpp 不读取的任何其它键都会 resources = "res" ``` +`mcpp = ">="`(mcpp 2026.9.28.3+)写明包支持的最旧 mcpp 版本。它是下限而不是固定: +项目安装哪个版本仍由 `.xlings.json` 决定。只接受 `>=` 写法,因为裸版本号在 mcpp 其他地方都 +表示"恰好这一版";裸版本号和不是版本号的值都会被拒绝,并给出应写的形式。低于下限的引擎在 +做任何其他工作之前停止,写出包名、下限、自身版本以及安装新版本的命令。`[workspace.package] +mcpp` 为所有成员统一声明一次。早于 2026.9.28.3 的引擎以警告忽略这个键。 + +```toml +[package] +name = "myplugin" +mcpp = ">=2026.9.28.3" +``` + #### 方言标志与 `import std` BMI 有些标志会改变标准库头文件的声明内容,所以预编译的 `import std` BMI 也 @@ -973,6 +985,9 @@ path = "src/capi/lua.cppm" # Override the default lib-root location 默认约定:`src/<包名的最后一段>.cppm`(例如包名 `mcpplibs.cmdline` → `src/cmdline.cppm`)。 +`path` 是这张表唯一的键;其他键像 `[build]` 中一样被报告(mcpp 2026.9.28.3+)。lib root 即 +SPEC-008 的接口根:打包后的库发布的模块(连同它转出的模块),以及构建程序从 host-module +依赖中导入的模块。 ### 2.5 `[dependencies]`、`[dev-dependencies]`、`[build-dependencies]` diff --git a/docs/zh/07-workspace.md b/docs/zh/07-workspace.md index 06203ccc..0f1ecf65 100644 --- a/docs/zh/07-workspace.md +++ b/docs/zh/07-workspace.md @@ -386,6 +386,21 @@ mcpp test --workspace --workspace-timeout 1800 # whole fan-out (default 0 = no `--workspace-timeout` 停止扇出并列出未运行的成员,而不是把进程留给 CI 去 kill—— 那样会把进程本该说出的话一并丢掉。 +### 5.4 被其他成员使用的成员只构建一次(mcpp 2026.9.28.3+) + +被其他成员以 path 依赖使用的成员只构建一次,在它自己的目录中构建,每个使用它的成员从那里取得 +其对象与模块接口。因此一次 `--workspace` 构建,或分别对两个程序执行的 `-p` 构建,只编译共享的 +库成员一次;此前每个程序都在自己的目录中再编译一遍。 + +- **是否过期由成员自己判断。** 消费方构建之前,成员自己的构建先运行,由它的 ninja 判断哪些 + 产物过期,包括成员根目录之外的输入(例如 `../3rdParty` 下的头文件)。 +- **条件是构建键相等。** 成员在消费方图中的构建键必须等于它作为自身构建的根时的键;键覆盖 + 工具链、编译参数、配置档与 feature。消费方以不同方式构建的成员(例如启用了另一组 feature) + 仍在该消费方的图中编译,与此前相同;`-v` 会写出不同的那一项输入。 +- **同一时间只有一个构建。** 同时构建的两个消费方轮流使用成员的目录。 +- **适用范围。** 该规则在默认缓存模式下生效(`--cache off` 在发起构建的图中编译全部内容), + 且只适用于成员;工作区之外的 path 依赖只有一个消费方,行为不变。 + ## 6. 目录布局 工作空间推荐的目录布局: diff --git a/docs/zh/10-pack-and-release.md b/docs/zh/10-pack-and-release.md index f02a56cb..15bc883e 100644 --- a/docs/zh/10-pack-and-release.md +++ b/docs/zh/10-pack-and-release.md @@ -135,6 +135,7 @@ mcpp pack --profile dev # build with a different profile (default mcpp pack --dev # the same, as `build` and `run` spell it; --profile wins over it mcpp pack --message-format json # one mcpp.pack envelope on stdout (mcpp 2026.9.16.1+) mcpp pack --no-strip # ship the artifacts as built +mcpp pack -p app --format release # a workspace member, as if run in its directory mcpp pack --debug-symbols dbg/ # write the separated *.debug files under dbg/ mcpp pack --format msi --features installer # activate root-package features for the pack ``` @@ -146,6 +147,10 @@ feature:每一条 `--target` 腿,以及被分派格式的两遍构建。它 出来。`mcpp run --format --features ` 把同样的 feature 交给它 执行的那次打包。 +`-p `(mcpp 2026.9.28.3+)在工作区根目录打包某个成员:成员的解析方式与其他 `-p` +相同,打包在该成员目录中进行,因此结果与在该目录执行 `mcpp pack` 相同。相对路径的 `-o` 仍以 +执行命令时所在的目录为基准。 + `--release` 与 `--dev`(mcpp 2026.9.16.1+)是 `build`、`run` 已接受的简写, 优先级相同:三条命令上都是 `--profile` 优先于它们。 diff --git a/docs/zh/12-binary-distribution.md b/docs/zh/12-binary-distribution.md index 3cdbc82b..3f43ba86 100644 --- a/docs/zh/12-binary-distribution.md +++ b/docs/zh/12-binary-distribution.md @@ -125,6 +125,19 @@ error: the published interface imports mathkit:secret , which no unit in this 要么重构,让接口够不到它;要么把它改成 `export module` 分区,并接受它的源码 被发布。 +### 公开模块,以及 `mcpp pack` 与 `mcpp build` 的报告(mcpp 2026.9.28.3+) + +包的公开模块是 lib root 的模块,以及它用 `export import` 传递地转出的模块(SPEC-008)。消费方 +可以 import 的就是这些;发布的闭包还包含它们 import 但未转出的单元,因为消费方构建这些接口 +需要它们。"Withheld" 一行列出每个未发布的单元,包括包没有 lib root 的情形。下面三种情况都是 +警告:库可以用模块实现自己而只发布头文件,是否有意如此只有作者能说明。 + +| 情况 | 报告者 | 写明的后果 | +|---|---|---| +| `lib` 目标导出了模块但没有 lib root | `mcpp build`,只对当前构建的包 | `mcpp pack` 发布的库不带模块接口 | +| 打包形态不包含的导出模块 | `mcpp pack`,逐个写出模块名 | 使用打包形态的消费方无法 import 它们 | +| 当前构建的包 import 了某个有 lib root 的依赖的非公开模块 | `mcpp build`,写出该模块与公开模块 | 从源码构建成功,但对打包形态会失败 | + ## 兼容性 tag 每一个产物都记录它是为哪一套工具链构建的: diff --git a/docs/zh/30-build-mcpp.md b/docs/zh/30-build-mcpp.md index 6a2a5be2..b8edd2bb 100644 --- a/docs/zh/30-build-mcpp.md +++ b/docs/zh/30-build-mcpp.md @@ -893,6 +893,82 @@ mcpp 会写出 `<暂存树>.stage-manifest` —— 一个兄弟文件,永不 - 这些条目就是 `resolution.json` 的 `graph` 一节再加四项(`manifest_dir`、`features`、 `targets`、`metadata`),出自同一次推导。 +### 接口的名字:`mcpp.core`(协议 14) + +引擎提供给构建程序的接口名为 `mcpp.core`,与规范(SPEC-007)对这一层的称呼一致。`mcpp` +是它永久等价的写法:引擎内嵌这两个单元,后者只含 `export import mcpp;`。构建程序可以使用 +任一写法,也可以同时使用。 + +```cpp +import mcpp.core; // 与 `import mcpp;` 导出相同的符号 +``` + +### 构建信息:解析出的工具链(协议 14) + +构建程序以事实的形式读取解析出的工具链。mcpp 不把这些事实翻译成任何外部构建系统的写法; +驱动 CMake、vcpkg、Meson 或 make 的插件负责翻译。不适用时值为空;角色取 `cc`、`cxx`、`ld`、 +`ar`、`rc`、`as`、`mt` 之一。 + +| 访问函数 | 取值 | +|---|---| +| `mcpp::tool(role)` | 这一行在该角色上的工具:GNU 风格的行上是驱动程序,cl.exe 行上是工具集自己的工具 | +| `mcpp::abi_tool(role)` | 目标 ABI 的原生工具:MSVC ABI 上是 `cl`、`link`、`lib`、`ml64` 以及 SDK 的 `rc`、`mt`,与这一行用哪个驱动无关;其他 ABI 上与 `tool(role)` 相同 | +| `mcpp::tool_env()` | 引擎运行这些工具时使用的环境变量,每行一个 `KEY=value`(MSVC ABI 上为 `INCLUDE`、`LIB`、`PATH`) | +| `mcpp::toolset_identity()` | 不含路径的工具链标识,例如 `msvc 14.44.35207; sdk 10.0.26100.0` 或 `clang 22.1.8` | +| `mcpp::msvc_instance_dir()` | MSVC 工具集所属的 Visual Studio 实例;受管工具集为空 | +| `mcpp::ninja_program()` | mcpp 自己运行的 ninja | +| `mcpp::cxx_runtime()` | 程序的 C++ 运行时契约:`self-contained`、`toolchain-coupled` 或 `host-coupled` | +| `mcpp::msvc_crt_linkage()` | MSVC ABI 上为 `static`(`/MT`)或 `dynamic`(`/MD`),即 `place-dlls` 读取的值 | + +这些值进入构建程序的上下文,因此升级到引入它们的版本之后,每个构建程序会多运行一次。 + +### 结构化诊断:`mcpp::report`(协议 14) + +用 `mcpp::report` 发出的诊断按引擎自己的形式呈现:先是消息,然后是 `impact:` 行与 `hint:` +行。它以相同的字段进入 `--message-format json`;`degraded` 级别在 `--strict` 下使构建失败; +缓存命中的运行会再次报告它。`mcpp::warning(text)` 保留,等价于只有消息的诊断。 + +```cpp +mcpp::report({.severity = "warning", + .message = "the generator found no schema", + .impact = "no bindings are generated", + .hint = "add schema/*.proto"}); +``` + +### 一次放置多个文件:`mcpp stage --list` + +`mcpp stage --list ` 在一个进程内放置文件中的每一对 `<源>\t<目的>`。每个目的地保持 +单文件时的语义:内容相同的目的地不重写,写入在临时位置完成后再改名,同一目的地的多个来源必须 +一致。程序的 deploy 条目在两条及以上时,引擎用一条这样的边完成放置;action 也可以调用 +`${mcpp.self} stage --list`。 + +```text +data/a.txt bin/data/a.txt +data/b.txt bin/data/b.txt +``` + +### 插件模块的名字 + +构建程序导入的模块可以用 `mcpp.` 前缀表明自己是 mcpp 插件,前提是位于所属包自己的命名空间 +之下。命名空间不是 `mcpp` 的包若使用保留的第二段或其他命名空间,引擎发出警告;mcpp-index 在 +收录包时施行同一条规则。 + +| 模块名 | 提供者 | +|---|---| +| `mcpp`、`mcpp.core` | 引擎 | +| `mcpp.plugins.*`、`mcpp.deps.*`、`mcpp.rules.*`、`mcpp.dist.*`、`mcpp.tools.*` | 命名空间为 `mcpp` 的包 | +| `mcpp..*` | 该命名空间的包 | + +### 未启用的 feature 之后的模块 + +构建程序导入的模块若只在某个依赖的某个 feature 之后提供,报错写出包名与 feature 名: + +```text +error: build.mcpp imports 'mcpp.rules.qt' + provided by: mcpp.plugins, feature "rules-qt" (not enabled) + hint: enable it on the dependency edge: mcpp.plugins = { ..., features = ["rules-qt"] } +``` + ### `import mcpp;` 才是会演进的那一面(mcpp 2026.8.5.1+) 和 mcpp 对话有两条路,它们的**兼容性承诺不同**: @@ -935,8 +1011,9 @@ if constexpr (requires { mcpp::runner("qemu"); }) // hard error when absent ``` `requires` 表达式作用在一个**不存在的限定名**上时是 ill-formed,而**不是求值为 -`false`**。所以语言内没有特性探测这条路:采用了新指令的包只能在自己的 README 里 -用文字写明版本下限,并依赖上面那条诊断。**这类包应当写清楚它需要哪个版本的 mcpp。** +`false`**。所以语言内没有特性探测这条路。采用了新指令的包在清单中写明版本下限 +`[package] mcpp = ">="`(mcpp 2026.9.28.3+,docs/04):低于下限的引擎在编译任何 +内容之前停止,并写出应安装的版本。更旧的引擎以警告忽略这个键,报告的仍是上面那条诊断。 ### `import std;`(mcpp 2026.8.2.1+) diff --git a/docs/zh/README.md b/docs/zh/README.md index d1207b33..8fac9f6b 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -158,3 +158,4 @@ - [SPEC-005 —— `mcpp emit build-database` 输出的构建数据库](../specs/build-database.md) - [SPEC-006 —— 工具链管理:身份、来源、选择与载荷契约](../specs/toolchain-management.md) - [SPEC-007 —— 构建插件:配置、施工与校验的分工,运行时与规划期的义务](../specs/build-plugins.md) + - [SPEC-008 —— 库的接口:公开模块、发布闭包与两种形态的一致](../specs/library-interface.md) diff --git a/mcpp.toml b/mcpp.toml index db1ba749..e75f4d68 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.9.28.2" +version = "2026.9.28.3" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/modules/versioning/src/version.cppm b/modules/versioning/src/version.cppm index acc489e0..d8849330 100644 --- a/modules/versioning/src/version.cppm +++ b/modules/versioning/src/version.cppm @@ -31,6 +31,6 @@ import std; export namespace mcpp { -inline constexpr std::string_view MCPP_VERSION = "2026.9.28.2"; +inline constexpr std::string_view MCPP_VERSION = "2026.9.28.3"; } // namespace mcpp From 19bee651cbfeef7a98cd6ade91ee2d4334ab4ab0 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:06:34 +0800 Subject: [PATCH 11/18] mcpp module: report() writes through printf, so no FILE reaches the interface (#734 E11) GCC rejected a build program that includes after 'import mcpp;' once an inline function of the interface named stdout and fputc: the BMI then carried struct _IO_FILE, and the textual include conflicted with it. e2e 651 read the defect on the GCC row. --- src/build/hostprogram.cppm | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/build/hostprogram.cppm b/src/build/hostprogram.cppm index 1724837e..8c5a0570 100644 --- a/src/build/hostprogram.cppm +++ b/src/build/hostprogram.cppm @@ -114,16 +114,20 @@ struct diagnostic { const char* impact = ""; const char* hint = ""; }; -inline void diagnostic_field_(const char* s) { +// Output goes through printf only: an inline function of this interface that +// names `stdout` or `fputc` carries `FILE` into the module, and GCC then +// rejects a build program that includes after `import mcpp;`. +inline void diagnostic_field_(const char* s, char end) { for (const char* p = s ? s : ""; *p; ++p) - std::fputc((*p == '\t' || *p == '\n' || *p == '\r') ? ' ' : *p, stdout); + std::printf("%c", (*p == '\t' || *p == '\n' || *p == '\r') ? ' ' : *p); + std::printf("%c", end); } inline void report(const diagnostic& d) { - std::fputs("mcpp:diagnostic=", stdout); - diagnostic_field_(d.severity); std::fputc('\t', stdout); - diagnostic_field_(d.message); std::fputc('\t', stdout); - diagnostic_field_(d.impact); std::fputc('\t', stdout); - diagnostic_field_(d.hint); std::fputc('\n', stdout); + std::printf("mcpp:diagnostic="); + diagnostic_field_(d.severity, '\t'); + diagnostic_field_(d.message, '\t'); + diagnostic_field_(d.impact, '\t'); + diagnostic_field_(d.hint, '\n'); } // ── The probe channel (mcpp 2026.9.5.2+) ──────────────────────────────────── From 55d2d510963e3fd322e77eee0f673e6dea8e5162 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:35:05 +0800 Subject: [PATCH 12/18] build: the fast path sees a path dependency's whole tree and resumes after a confirmed edit; its reasons are statements (#734 E5) The dependency sweep read only src/, so an edit to a host module elsewhere in a path dependency (mcpp-plugins keeps its members in deps/, rules/, dist/ and tools/) was replayed as no work: such a unit is compiled into the consumer's build program, which no edge of build.ninja names. The sweep now walks the dependency's tree, skipping hidden directories, target and nested packages. A full build that confirms the graph left an unchanged build.ninja unwritten, and the fast path compares every source with its time, so after any edit every later build was planned in full until the graph's text changed. The backend now moves the file's time when it confirms the graph. 2026.9.28.2 shows both defects on Linux; e2e 831 and 832 fail with it and pass with this change. Under -v each refusal is now a statement of its condition rather than the condition's code. --- CHANGELOG.md | 10 +- src/build/execute.cppm | 112 +++++++++++------- src/build/ninja_backend.cppm | 17 ++- ...path_dependency_host_module_outside_src.sh | 86 ++++++++++++++ ...832_the_fast_path_resumes_after_an_edit.sh | 42 +++++++ 5 files changed, 220 insertions(+), 47 deletions(-) create mode 100755 tests/e2e/831_a_path_dependency_host_module_outside_src.sh create mode 100755 tests/e2e/832_the_fast_path_resumes_after_an_edit.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 54117b3e..ebd05077 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,14 @@ SPEC-007 §9 与新的 SPEC-008。配套的 mcpp-plugins 0.17.0 以本版本为 一行列出每个未发布的单元(此前没有接口根时显示 "(nothing)",而两个导出模块既未发布也未列出); `mcpp build` 在包导入依赖的非公开模块时警告(W3);缺少接口根的警告写明对 `mcpp pack` 的后果 (W1)。全部为警告。 +- **快路径在一次确认之后恢复。** 编辑源码后的那次构建经完整路径确认了图,却不重写内容未变的 + build.ninja,而快路径以 build.ninja 的时间比较每个源码;此前编辑之后的每次构建都被拒绝,直到图的 + 文本改变。现在确认时移动 build.ninja 的时间(e2e 832,2026.9.28.2 在 Linux 上同样复现)。 +- **快路径看见 path 依赖的整棵源码树。** 此前只扫描依赖的 `src/`;依赖在别处的 host module + (例如 mcpp-plugins 的 `deps/vcpkg.cppm`)编入消费方的构建程序,不在任何 ninja 边上,被编辑后 + 构建报告"无事可做"。现在扫描依赖的整棵树,跳过隐藏目录、`target` 与嵌套的包(e2e 831)。 +- **`mcpp.core` 的输出不再把 `FILE` 带入模块接口。** `mcpp::report` 只经 `printf` 输出;此前 GCC + 拒绝在 `import mcpp;` 之后 `#include ` 的构建程序(e2e 651)。 ### 特性 @@ -43,7 +51,7 @@ SPEC-007 §9 与新的 SPEC-008。配套的 mcpp-plugins 0.17.0 以本版本为 - **包的版本下限(E9)。** `[package] mcpp = ">="` 与 `[workspace.package] mcpp`; 低于下限的引擎在其他工作之前停止,写出升级命令;只接受 `>=`。 - **`[lib]` 报告未知键(E12)。** 此前拼错的 `path` 被静默接受。 -- **快路径说明拒绝原因(E5 的测量部分)。** `-v` 下每个拒绝点写出其条件。 +- **快路径说明拒绝原因(E5 的测量部分)。** `-v` 下每个拒绝点以一句话写出其条件。 ### 兼容性 diff --git a/src/build/execute.cppm b/src/build/execute.cppm index 1f869c3b..ef934fca 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -1204,9 +1204,31 @@ bool dep_sources_newer_than(const std::vector& depSourceRoots, auto tomlTime = std::filesystem::last_write_time(depRoot / "mcpp.toml", ec); if (ec) { ec.clear(); return true; } if (tomlTime > ninjaTime) return true; - for (auto& f : mcpp::modgraph::expand_glob(depRoot, "src/**/*")) { - if (!mcpp::affects_graph_shape(mcpp::classify(f, extTable))) continue; - auto ft = std::filesystem::last_write_time(f, ec); + // THE WHOLE TREE, NOT `src/`. A dependency's units outside `src/` -- + // a feature's `rules/x.cppm`, a `[build] sources` glob elsewhere -- + // are as much its sources as those under it, and a host module among + // them is compiled into the consumer's BUILD PROGRAM, which no edge of + // this build.ninja names: an edit to one was replayed as "no work" + // (#734, measured on mcpp-plugins' `deps/vcpkg.cppm`). Version-control, + // hidden and build-output directories are skipped, and so is a nested + // package (a directory with its own mcpp.toml), whose files are its + // own package's sources, not this one's. + auto it = std::filesystem::recursive_directory_iterator( + depRoot, std::filesystem::directory_options::skip_permission_denied, ec); + if (ec) { ec.clear(); return true; } + for (; it != std::filesystem::recursive_directory_iterator(); it.increment(ec)) { + if (ec) { ec.clear(); return true; } + const auto& path = it->path(); + if (it->is_directory(ec)) { + const auto name = path.filename().string(); + if (name.starts_with(".") || name == "target" + || std::filesystem::exists(path / "mcpp.toml", ec)) + it.disable_recursion_pending(); + ec.clear(); + continue; + } + if (!mcpp::affects_graph_shape(mcpp::classify(path, extTable))) continue; + auto ft = std::filesystem::last_write_time(path, ec); if (ec || ft > ninjaTime) return true; } } @@ -1498,7 +1520,7 @@ std::optional fast_path_declined(std::string_view path, std::string_view wh export std::optional try_fast_build(const std::filesystem::path& projectRoot, bool verbose, bool no_cache, std::string_view currentTarget = "") { - if (no_cache) return fast_path_declined("build", "no_cache"); + if (no_cache) return fast_path_declined("build", "the build cache is off (--no-cache)"); // `--locked` MUST NOT MEET THE FAST PATH, OR IT ASSERTS NOTHING. // @@ -1512,10 +1534,10 @@ export std::optional try_fast_build(const std::filesystem::path& projectRoo // Declining the fast path is the whole fix: `--locked` is for release // builds, audits and CI, none of which are the case the fast path serves. if (mcpp::platform::env::get("MCPP_LOCKED").value_or("") == "1") - return fast_path_declined("build", "if (mcpp::platform::env::get(\"MCPP_LOCKED\").value_or(\"\") == \"1\")"); + return fast_path_declined("build", "MCPP_LOCKED=1 asks for a locked resolution"); auto want = fast_path_identity(projectRoot); - if (!want) return fast_path_declined("build", "!want"); + if (!want) return fast_path_declined("build", "the manifest or the request could not be read"); // #496. A project with build hooks always takes the full path. The fast // path is defined as "skip preparation", and `build_start` is specified to @@ -1523,7 +1545,7 @@ export std::optional try_fast_build(const std::filesystem::path& projectRoo // not exist until preparation has run. Declining here rather than in // cmd_build keeps the decision next to the manifest that answers it; the // full path then runs the hooks around run_build_plan. - if (want->hooksActive) return fast_path_declined("build", "want->hooksActive"); + if (want->hooksActive) return fast_path_declined("build", "the project declares [hooks], which run on every build"); // P3: read multi-entry cache and find the entry matching this // (target, profile, cache mode) triple. Matching on the target alone served @@ -1539,8 +1561,8 @@ export std::optional try_fast_build(const std::filesystem::path& projectRoo break; } } - if (!match) return fast_path_declined("build", "!match"); - if (!match->runtimeBinding) return fast_path_declined("build", "!match->runtimeBinding"); + if (!match) return fast_path_declined("build", "no recorded build matches this request"); + if (!match->runtimeBinding) return fast_path_declined("build", "the recorded build predates the runtime binding"); auto outputDirStr = match->outputDir; auto ninjaProgram = match->ninjaProgram; @@ -1552,13 +1574,13 @@ export std::optional try_fast_build(const std::filesystem::path& projectRoo auto runtimeEnvKey = match->runtimeEnvKey; auto runtimeEnvValue = match->runtimeEnvValue; if (runtimeEnvKey.empty()) - return fast_path_declined("build", "if (runtimeEnvKey.empty())"); // old cache entry; regenerate build.ninja once + return fast_path_declined("build", "the recorded build predates the runtime environment key"); // old cache entry; regenerate build.ninja once // P1: verify fingerprint matches the outputDir basename. if (!cachedFingerprint.empty()) { auto dirBasename = std::filesystem::path(outputDirStr).filename().string(); if (dirBasename != cachedFingerprint) { - return fast_path_declined("build", "if (dirBasename != cachedFingerprint) {"); + return fast_path_declined("build", "the recorded build directory is not the one for this fingerprint"); } } @@ -1566,7 +1588,7 @@ export std::optional try_fast_build(const std::filesystem::path& projectRoo std::filesystem::path outputDir(outputDirStr); auto ninjaPath = outputDir / "build.ninja"; - if (!std::filesystem::exists(ninjaPath, ec)) return fast_path_declined("build", "!std::filesystem::exists(ninjaPath, ec)"); + if (!std::filesystem::exists(ninjaPath, ec)) return fast_path_declined("build", "build.ninja does not exist"); // #407. Freshness is measured against the SOURCES, which says nothing // about what kind of graph this is. `mcpp test` and @@ -1576,38 +1598,38 @@ export std::optional try_fast_build(const std::filesystem::path& projectRoo // that for a plain build linked the tests, never linked the target, and // printed `Finished`; and a broken file under tests/ (never scanned here) // failed a plain `mcpp build` outright. - if (!mcpp::build::is_plain_build_graph(ninjaPath)) return fast_path_declined("build", "!mcpp::build::is_plain_build_graph(ninjaPath)"); + if (!mcpp::build::is_plain_build_graph(ninjaPath)) return fast_path_declined("build", "build.ninja was written by another mode (test, pack or a named target)"); auto ninjaTime = std::filesystem::last_write_time(ninjaPath, ec); - if (ec) return fast_path_declined("build", "ec"); + if (ec) return fast_path_declined("build", "the time of build.ninja cannot be read"); auto runtimeManifest = match->runtimeBinding->subosDir / ".xlings.json"; auto runtimeTime = std::filesystem::last_write_time(runtimeManifest, ec); - if (ec || runtimeTime > ninjaTime) return fast_path_declined("build", "ec || runtimeTime > ninjaTime"); + if (ec || runtimeTime > ninjaTime) return fast_path_declined("build", "the runtime's .xlings.json is newer than build.ninja"); // Check mcpp.toml auto tomlPath = projectRoot / "mcpp.toml"; auto tomlTime = std::filesystem::last_write_time(tomlPath, ec); - if (ec || tomlTime > ninjaTime) return fast_path_declined("build", "ec || tomlTime > ninjaTime"); + if (ec || tomlTime > ninjaTime) return fast_path_declined("build", "mcpp.toml is newer than build.ninja"); // mcpp#225: bounded + vcs/build-dir-excluded walk (see sources_newer_than) // instead of a hand-rolled recursive_directory_iterator over src/. if (sources_newer_than(projectRoot, ninjaTime, want->resourceScripts, - want->extTable)) return fast_path_declined("build", "if (sources_newer_than(projectRoot, ninjaTime, want->resourceScripts,"); + want->extTable)) return fast_path_declined("build", "a project source, build.mcpp, a build-program input or a resource script is newer than build.ninja"); // A cache written before this field existed cannot say whether the build // had `path` dependencies, and answering "assume none" is the wrong // half of that guess: it would keep replaying a stale graph for exactly // the projects the field was added for. Decline once; the write below // records the list and every later invocation is fast again. - if (!match->depSourceRootsRecorded) return fast_path_declined("build", "!match->depSourceRootsRecorded"); + if (!match->depSourceRootsRecorded) return fast_path_declined("build", "the recorded build predates the list of path-dependency roots"); if (dep_sources_newer_than(match->depSourceRoots, ninjaTime, want->extTable)) - return fast_path_declined("build", "if (dep_sources_newer_than(match->depSourceRoots, ninjaTime, want->extTable))"); - if (!xlings_payloads_present(*match)) return fast_path_declined("build", "!xlings_payloads_present(*match)"); + return fast_path_declined("build", "a path dependency's manifest or source is newer than build.ninja"); + if (!xlings_payloads_present(*match)) return fast_path_declined("build", "a recorded xlings payload is missing"); auto validatedBefore = mcpp::build::runtime_validation::validated_artifact_snapshot( outputDir, *match->runtimeBinding); - if (!validatedBefore) return fast_path_declined("build", "!validatedBefore"); + if (!validatedBefore) return fast_path_declined("build", "no validated artifact snapshot is recorded for this build"); // All inputs are older than build.ninja → fast-path: just run ninja. // C1: this configuration is confirmed current, so the root database is @@ -1618,11 +1640,11 @@ export std::optional try_fast_build(const std::filesystem::path& projectRoo std::chrono::milliseconds elapsed{}; auto rc = run_ninja_fast(ninjaProgram, outputDir, ninjaPath, verbose, runtimeEnvKey, runtimeEnvValue, &elapsed); - if (!rc) return fast_path_declined("build", "!rc"); + if (!rc) return fast_path_declined("build", "ninja reported a stale graph"); if (*rc != 0) return rc; if (!mcpp::build::runtime_validation::artifact_snapshot_unchanged( *validatedBefore)) - return fast_path_declined("build", "*validatedBefore))"); // relinked: full path reconstructs + validates closure + return fast_path_declined("build", "ninja relinked an artifact, whose closure the full path validates"); // relinked: full path reconstructs + validates closure mcpp::ui::finished(want->profile, elapsed); return 0; @@ -1642,9 +1664,9 @@ std::optional try_fast_run(const std::filesystem::path& projectRoot, // Same reason as try_fast_build's: this path skips resolution, and // `--locked` is an assertion about resolution. if (mcpp::platform::env::get("MCPP_LOCKED").value_or("") == "1") - return fast_path_declined("run", "if (mcpp::platform::env::get(\"MCPP_LOCKED\").value_or(\"\") == \"1\")"); + return fast_path_declined("run", "MCPP_LOCKED=1 asks for a locked resolution"); auto want = fast_path_identity(projectRoot); - if (!want) return fast_path_declined("run", "!want"); + if (!want) return fast_path_declined("run", "the manifest or the request could not be read"); // THE precondition of this whole function: it exec's the cached // artifact itself, so it is only ever valid when that artifact is for THIS @@ -1666,7 +1688,7 @@ std::optional try_fast_run(const std::filesystem::path& projectRoot, // `mcpp run` alone was correct; only build-then-run reached the cache. So // the fast path is off whenever a default target is declared, and the full // prepare — which is what resolves the runner — takes over. - if (!want->defaultTarget.empty()) return fast_path_declined("run", "!want->defaultTarget.empty()"); + if (!want->defaultTarget.empty()) return fast_path_declined("run", "the manifest names a default target"); auto entries = read_build_cache(projectRoot); const BuildCacheEntry* match = nullptr; @@ -1678,18 +1700,18 @@ std::optional try_fast_run(const std::filesystem::path& projectRoot, break; } } - if (!match || match->runTargets.empty()) return fast_path_declined("run", "!match || match->runTargets.empty()"); + if (!match || match->runTargets.empty()) return fast_path_declined("run", "no recorded build has a program to run"); // A runner declared for the host target (a wrapper such as valgrind, or // a triple that is native here but carries an emulator) is consulted on // the prepare path through choose_runner. This path has no manifest to // read the template from, and executing the artifact bare here while the // other door wraps it would make the second `mcpp run` behave differently // from the first. The entry records the fact; the fast path declines. - if (match->runnerDeclared) return fast_path_declined("run", "match->runnerDeclared"); + if (match->runnerDeclared) return fast_path_declined("run", "a runner is declared"); // The same reasoning one axis over: this entry was written by a verb that // installed less than a run needs, so taking it would execute with a // declared tool absent. prepare_build provisions the difference. - if (match->runTierPending) return fast_path_declined("run", "match->runTierPending"); + if (match->runTierPending) return fast_path_declined("run", "the run tier is not yet decided"); auto outputDirStr = match->outputDir; auto ninjaProgram = match->ninjaProgram; @@ -1698,7 +1720,7 @@ std::optional try_fast_run(const std::filesystem::path& projectRoot, && ninjaProgram.back() == '\'') ninjaProgram = ninjaProgram.substr(1, ninjaProgram.size() - 2); if (match->runtimeEnvKey.empty()) - return fast_path_declined("run", "if (match->runtimeEnvKey.empty())"); // old cache entry; go through prepare_build once + return fast_path_declined("run", "the recorded build predates the runtime environment key"); // old cache entry; go through prepare_build once // Written before this mcpp knew about subos environments (mcpp#352). Taking // the fast path here would run the program without them -- which is the // defect this field exists to fix, surviving an upgrade. @@ -1710,12 +1732,12 @@ std::optional try_fast_run(const std::filesystem::path& projectRoot, // pre-upgrade build until something else happened to invalidate it. Measured // on a real upgrade from 2026.8.7.1, not reasoned about. if (!match->runtimeBinding) - return fast_path_declined("run", "if (!match->runtimeBinding)"); // predates the immutable snapshot; rebuild once + return fast_path_declined("run", "the recorded build predates the runtime binding"); // predates the immutable snapshot; rebuild once // P1: verify fingerprint matches the outputDir basename. if (!match->fingerprint.empty()) { auto dirBasename = std::filesystem::path(outputDirStr).filename().string(); - if (dirBasename != match->fingerprint) return fast_path_declined("run", "dirBasename != match->fingerprint"); + if (dirBasename != match->fingerprint) return fast_path_declined("run", "the recorded build directory is not the one for this fingerprint"); } // Locate the requested run-target before doing any filesystem freshness @@ -1727,52 +1749,52 @@ std::optional try_fast_run(const std::filesystem::path& projectRoot, chosen = &rt; if (targetName) break; } - if (!chosen) return fast_path_declined("run", "!chosen"); + if (!chosen) return fast_path_declined("run", "the requested program is not among the recorded ones"); std::error_code ec; std::filesystem::path outputDir(outputDirStr); auto ninjaPath = outputDir / "build.ninja"; - if (!std::filesystem::exists(ninjaPath, ec)) return fast_path_declined("run", "!std::filesystem::exists(ninjaPath, ec)"); + if (!std::filesystem::exists(ninjaPath, ec)) return fast_path_declined("run", "build.ninja does not exist"); // #407, same reason as try_fast_build: a test-shaped graph does not build // the run target at all, so running ninja against it would report success // and then exec a stale (or absent) binary. - if (!mcpp::build::is_plain_build_graph(ninjaPath)) return fast_path_declined("run", "!mcpp::build::is_plain_build_graph(ninjaPath)"); + if (!mcpp::build::is_plain_build_graph(ninjaPath)) return fast_path_declined("run", "build.ninja was written by another mode (test, pack or a named target)"); auto ninjaTime = std::filesystem::last_write_time(ninjaPath, ec); - if (ec) return fast_path_declined("run", "ec"); + if (ec) return fast_path_declined("run", "the time of build.ninja cannot be read"); auto runtimeManifest = match->runtimeBinding->subosDir / ".xlings.json"; auto runtimeTime = std::filesystem::last_write_time(runtimeManifest, ec); - if (ec || runtimeTime > ninjaTime) return fast_path_declined("run", "ec || runtimeTime > ninjaTime"); + if (ec || runtimeTime > ninjaTime) return fast_path_declined("run", "the runtime's .xlings.json is newer than build.ninja"); auto tomlPath = projectRoot / "mcpp.toml"; auto tomlTime = std::filesystem::last_write_time(tomlPath, ec); - if (ec || tomlTime > ninjaTime) return fast_path_declined("run", "ec || tomlTime > ninjaTime"); + if (ec || tomlTime > ninjaTime) return fast_path_declined("run", "mcpp.toml is newer than build.ninja"); if (sources_newer_than(projectRoot, ninjaTime, want->resourceScripts, - want->extTable)) return fast_path_declined("run", "if (sources_newer_than(projectRoot, ninjaTime, want->resourceScripts,"); + want->extTable)) return fast_path_declined("run", "a project source, build.mcpp, a build-program input or a resource script is newer than build.ninja"); // Same gate as try_fast_build's, and it has to be BOTH places: `mcpp run` // reaches its binary through this path, so a `run` that skipped the check // would execute an artifact built from a source set that no longer exists. - if (!match->depSourceRootsRecorded) return fast_path_declined("run", "!match->depSourceRootsRecorded"); + if (!match->depSourceRootsRecorded) return fast_path_declined("run", "the recorded build predates the list of path-dependency roots"); if (dep_sources_newer_than(match->depSourceRoots, ninjaTime, want->extTable)) - return fast_path_declined("run", "if (dep_sources_newer_than(match->depSourceRoots, ninjaTime, want->extTable))"); - if (!xlings_payloads_present(*match)) return fast_path_declined("run", "!xlings_payloads_present(*match)"); + return fast_path_declined("run", "a path dependency's manifest or source is newer than build.ninja"); + if (!xlings_payloads_present(*match)) return fast_path_declined("run", "a recorded xlings payload is missing"); auto validatedBefore = mcpp::build::runtime_validation::validated_artifact_snapshot( outputDir, *match->runtimeBinding); - if (!validatedBefore) return fast_path_declined("run", "!validatedBefore"); + if (!validatedBefore) return fast_path_declined("run", "no validated artifact snapshot is recorded for this build"); // Fresh → run ninja (picks up any incremental object/link work) then // exec the cached exe path directly. C1, same reason as try_fast_build's. restore_root_compile_commands(projectRoot, outputDir); auto rc = run_ninja_fast(ninjaProgram, outputDir, ninjaPath, /*verbose=*/false, match->runtimeEnvKey, match->runtimeEnvValue); - if (!rc) return fast_path_declined("run", "!rc"); + if (!rc) return fast_path_declined("run", "ninja reported a stale graph"); if (*rc != 0) return rc; if (!mcpp::build::runtime_validation::artifact_snapshot_unchanged( *validatedBefore)) - return fast_path_declined("run", "*validatedBefore))"); // never execute an artifact not validated for this binding + return fast_path_declined("run", "ninja relinked an artifact, whose closure the full path validates"); // never execute an artifact not validated for this binding auto exe = outputDir / chosen->second; auto pathCtx = mcpp::fetcher::make_path_ctx(/*cfg=*/nullptr, projectRoot); diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index 7fcf8305..cd910194 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -3752,7 +3752,22 @@ std::expected NinjaBackend::build(const BuildPlan& plan if (auto bad = check_action_ordering(manifest, plan)) return std::unexpected(BuildError{*bad, ninja_path}); auto goalArg = append_goal_phony(manifest, opts.ninjaTargets); - write_file(ninja_path, manifest); + // An unchanged build.ninja is not rewritten, but its TIME is moved: the + // project fast path compares every source with it, and reads it as "the + // graph was confirmed current then". A source edited since the last + // rewrite would otherwise stay newer after this build confirmed the graph, + // and every later build declined the fast path until the graph's text + // changed (#734, measured on Linux: an edit to src/main.cpp, then two + // builds, the second planned in full). No edge reads build.ninja's time. + { + std::error_code tec; + const bool existed = std::filesystem::exists(ninja_path, tec); + const auto before = existed ? std::filesystem::last_write_time(ninja_path, tec) + : std::filesystem::file_time_type{}; + write_file(ninja_path, manifest); + if (existed && !tec && std::filesystem::last_write_time(ninja_path, tec) == before && !tec) + std::filesystem::last_write_time(ninja_path, std::filesystem::file_time_type::clock::now(), tec); + } stage("write-ninja"); // compile_commands.json — via the dedicated module. diff --git a/tests/e2e/831_a_path_dependency_host_module_outside_src.sh b/tests/e2e/831_a_path_dependency_host_module_outside_src.sh new file mode 100755 index 00000000..6672623c --- /dev/null +++ b/tests/e2e/831_a_path_dependency_host_module_outside_src.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# requires: unix-shell +# 831_a_path_dependency_host_module_outside_src.sh -- #734. +# +# A path dependency's host module is compiled into the consumer's build +# program, which no edge of the consumer's build.ninja names. The project fast +# path therefore has to see an edit to it itself. It swept only the +# dependency's `src/`, so a feature unit elsewhere -- mcpp-plugins keeps its +# members in `deps/`, `rules/`, `dist/` and `tools/` -- was replayed as "no +# work" after an edit. +# +# S1 an edit to `rules/hm.cppm` of the dependency re-runs the build program, +# which reads the new value; +# S2 a newer file inside a package nested in the dependency (a test fixture +# with its own mcpp.toml) does not decline the fast path. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } +MCPP="${MCPP:-mcpp}" + +mkdir -p plug/src plug/rules plug/tests/fixture/src app/src +cat > plug/mcpp.toml <<'EOF' +[package] +namespace = "probe" +name = "plug831" +version = "0.1.0" + +[build] +sources = ["src/plug831.cppm"] + +[features.hm] +sources = ["rules/hm.cppm"] +EOF +printf 'export module probe.plug831;\nexport int plug_root() { return 0; }\n' > plug/src/plug831.cppm +printf 'export module probe.hm831;\nexport inline int value() { return 1; }\n' > plug/rules/hm.cppm +printf '[package]\nname = "fixture831"\nversion = "0.1.0"\n' > plug/tests/fixture/mcpp.toml +printf 'int main() { return 0; }\n' > plug/tests/fixture/src/main.cpp + +cat > app/mcpp.toml <<'EOF' +[package] +name = "app831" +version = "0.1.0" + +[build-dependencies.probe] +plug831 = { path = "../plug", features = ["hm"], host-module = true } +EOF +cat > app/build.mcpp <<'EOF' +import std; +import mcpp; +import probe.hm831; +int main() { + if (value() != 1) { std::println("host module value {}", value()); return 1; } + return 0; +} +EOF +printf 'int main() { return 0; }\n' > app/src/main.cpp +cd app + +"$MCPP" build > b1.log 2>&1 || fail "the first build failed" b1.log +"$MCPP" build -v > b1b.log 2>&1 || fail "the second build failed" b1b.log + +# S1 +sleep 1.1 +printf 'export module probe.hm831;\nexport inline int value() { return 2; }\n' > ../plug/rules/hm.cppm +if "$MCPP" build > s1.log 2>&1; then + fail "S1: the build after an edit to the dependency's rules/hm.cppm succeeded without re-running the build program" s1.log +fi +grep -q "host module value 2" s1.log || fail "S1: the build program did not read the edited host module" s1.log + +# S2 +printf 'export module probe.hm831;\nexport inline int value() { return 1; }\n' > ../plug/rules/hm.cppm +"$MCPP" build > s2a.log 2>&1 || fail "S2: the build after restoring the host module failed" s2a.log +sleep 1.1 +touch ../plug/tests/fixture/src/main.cpp +"$MCPP" build -v > s2.log 2>&1 || fail "S2: the build failed" s2.log +if grep -q "declined" s2.log; then + fail "S2: the fast path declined after a file of a package nested in the dependency changed" s2.log +fi +if grep -q "Resolving toolchain" s2.log; then + fail "S2: the build was planned instead of replayed" s2.log +fi + +echo "OK" diff --git a/tests/e2e/832_the_fast_path_resumes_after_an_edit.sh b/tests/e2e/832_the_fast_path_resumes_after_an_edit.sh new file mode 100755 index 00000000..36a4957f --- /dev/null +++ b/tests/e2e/832_the_fast_path_resumes_after_an_edit.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# requires: unix-shell +# 832_the_fast_path_resumes_after_an_edit.sh -- #734. +# +# The project fast path compares every source with build.ninja's time. A build +# after an edit is planned in full, confirms the graph, and leaves an unchanged +# build.ninja unwritten; its time then stayed older than the edited source, and +# every later build declined the fast path until the graph's text changed. +# +# R1 after an edit and one build, the next build is replayed by the fast +# path (under -v: no "declined", no "Resolving toolchain"); +# R2 the replay still sees the next edit: the program prints the new value. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" +fail() { echo "FAIL: $1"; shift; for f in "$@"; do echo "--- $f ---"; cat "$f" 2>/dev/null; done; exit 1; } +MCPP="${MCPP:-mcpp}" + +mkdir -p src +printf '[package]\nname = "resume832"\nversion = "0.1.0"\n' > mcpp.toml +printf '#include \nint main() { std::puts("one"); return 0; }\n' > src/main.cpp +"$MCPP" build > b0.log 2>&1 || fail "the first build failed" b0.log + +sleep 1.1 +printf '#include \nint main() { std::puts("two"); return 0; }\n' > src/main.cpp +"$MCPP" build > b1.log 2>&1 || fail "the build after the edit failed" b1.log + +# R1 +"$MCPP" build -v > r1.log 2>&1 || fail "R1: the build failed" r1.log +if grep -q "declined" r1.log || grep -q "Resolving toolchain" r1.log; then + fail "R1: the build after a confirmed edit was not replayed by the fast path" r1.log +fi + +# R2 +sleep 1.1 +printf '#include \nint main() { std::puts("three"); return 0; }\n' > src/main.cpp +"$MCPP" run > r2.log 2>&1 || fail "R2: the run failed" r2.log +grep -qx three r2.log || fail "R2: the next edit did not reach the program" r2.log + +echo "OK" From 9ab73d79aa63e1bc5ae0fb1fffd9d6d8dd3d26f8 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:45:59 +0800 Subject: [PATCH 13/18] ci(measure): an absent runtime copy in qt-base is not a failed install qt-base revision 1 no longer ships the VC++ runtime DLLs the step removes, so its listing exited 2 and bash -e failed the step before any measurement. --- .github/workflows/measure-windows-tool-crt.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/measure-windows-tool-crt.yml b/.github/workflows/measure-windows-tool-crt.yml index 7c13e528..f3a95551 100644 --- a/.github/workflows/measure-windows-tool-crt.yml +++ b/.github/workflows/measure-windows-tool-crt.yml @@ -113,8 +113,10 @@ jobs: "$XLINGS_BIN" install qt-base@6.11.1 -y QT="$(cygpath -u "$USERPROFILE")/.xlings/data/xpkgs/xim-x-qt-base/6.11.1" test -x "$QT/bin/moc.exe" || { echo "::error::no moc.exe in $QT/bin"; exit 1; } - # What revision 1 of the recipe removes (task I2). - ( cd "$QT/bin" && ls vcruntime140*.dll msvcp140*.dll concrt140.dll vccorlib140.dll 2>/dev/null; \ + # What revision 1 of the recipe removes (task I2). The published + # revision 1 no longer carries them, and `ls` of absent files exits 2, + # which `bash -e` would read as a failed install. + ( cd "$QT/bin" && { ls vcruntime140*.dll msvcp140*.dll concrt140.dll vccorlib140.dll 2>/dev/null || true; }; \ rm -f vcruntime140*.dll msvcp140*.dll concrt140.dll vccorlib140.dll ) echo "QT=$QT" >> "$GITHUB_ENV" From d16745020c3ae756c25b3780db1426798b23319e Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:49:39 +0800 Subject: [PATCH 14/18] docs(agents): the #734 record -- the release number, two fast-path departures, and the plugins' implementation --- ...ign-toolsets-and-library-surface-design.md | 51 +++++++++++++++++-- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/.agents/docs/2026-09-28-build-cost-foreign-toolsets-and-library-surface-design.md b/.agents/docs/2026-09-28-build-cost-foreign-toolsets-and-library-surface-design.md index fdaa9a30..39631c11 100644 --- a/.agents/docs/2026-09-28-build-cost-foreign-toolsets-and-library-surface-design.md +++ b/.agents/docs/2026-09-28-build-cost-foreign-toolsets-and-library-surface-design.md @@ -1130,7 +1130,7 @@ Each repository receives one pull request that carries all of its tasks. The order follows the dependencies: the engine first, because the plugins' floor names its release; the plugins next; the index and the validation project last. -### 13.1 mcpp (one pull request, release V = 2026.9.29.1) +### 13.1 mcpp (one pull request, release V = 2026.9.28.3) | Task | Items | Criterion (test) | |---|---|---| @@ -1217,5 +1217,50 @@ Each item has an e2e: 8. **W3 is skipped under `MCPP_SCANNER=p1689`.** That scanner does not report which imports are `export import`, so every re-exported module would read as private. - -**mcpp-plugins, mcpp-index, validation.** Recorded below as they land. +9. **`mcpp::report` writes through `printf` only.** Its first form named + `stdout` and `fputc`, which carried `FILE` into the module interface; GCC + then rejected a build program that includes `` after `import mcpp;` + (e2e 651 on the GCC row). +10. **Two fast-path defects outside E5's reading, found by the plugin test kit's + revert probe.** Both are present in 2026.9.28.2 on Linux. + - The dependency sweep read only a path dependency's `src/`. An edit to a + host module elsewhere (mcpp-plugins' `deps/vcpkg.cppm`) was replayed as + "no work", because such a unit is compiled into the consumer's build + program and no edge of build.ninja names it. The sweep now walks the + dependency's tree, skipping hidden directories, `target` and nested + packages (e2e 831). + - A full build that confirms the graph did not rewrite an unchanged + build.ninja, and the fast path compares every source with its time. After + any edit, every later build was therefore planned in full until the + graph's text changed. The backend now moves the file's time when it + confirms the graph (e2e 832). + Under `-v` each refusal is a sentence rather than the code of its condition. + +**mcpp-plugins 0.17.0.** U1 to U4 on one branch (`feat/734-plugins-0.17.0`). + +| Item | Where | Criterion | +|---|---|---| +| U1 | `plugins-core` (with `surface` as an alias), `plugins-testing`, `src/fs.cppm`, `[package] mcpp = ">=2026.9.28.3"`, `src/compat/`, `deps/compat/`, `.github/scripts/check-compat-retirement.sh` | the package and `all-rules-compile` build on the LLVM and GCC rows; the retirement check fails with `COMPAT_TODAY=2027-03-29` | +| U2 | `src/toolset.cppm` | `tests/plugin-logic`, the mechanism cases | +| U3 | `deps/vcpkg.cppm`, `deps/cmake.cppm`, docs/deps.md | the Linux rows locally (LLVM and GCC: derived triplets, ports against the program's C++ library, two prefixes coexisting); the Windows rows in CI | +| U4 | `src/testing.cppm` | `tests/plugin-logic`: 14 cases; removing the host-triplet argument fails exactly the managed-row case | + +Departures from §6: + +1. **`resolve_named`.** `generator = ninja` under an instance needs the tools + named; `mcpp.plugins.toolset` gained `resolve_named`, which answers `chain` + where an instance exists. +2. **The MSBuild refusal lives in the derived triplet.** The installation's + command is vcpkg itself, so the plugin cannot read vcpkg's output. The + derived triplet watches `VCPKG_PLATFORM_TOOLSET`, which vcpkg's MSBuild + helpers read to form `/p:PlatformToolset`, and stops the port there with the + plugin's message. +3. **`detected` on the Linux libc++ row names mcpp's clang,** as 0.16.0 did; the + derived triplet's name replaces `-linux-libcxx`. +4. **The `surface` alias prints no note.** A feature name is resolved before any + build program runs; its header records the retirement date, and the CI + check reads it. +5. **Each deps-cmake toolset statement has its own build directory,** because + CMake refuses a cache made with another generator or instance. + +**mcpp-index, validation.** Recorded below as they land. From af81f62f4b360fd553a85e4ac7d31d43a46c783c Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:00:36 +0800 Subject: [PATCH 15/18] W3 leaves members of the package's own workspace alone; clang on the MSVC ABI says when msvc@system finds no toolset (#734) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W3 warned on every import of a non-public module of a sibling workspace member: mcpp's own build printed seventeen. Such a member is built from source together with the package, so the packed-form consequence the warning states does not arise; SPEC-008 §4 and docs/12 state the exception. With no Visual Studio instance, the default `msvc@system` selection returned nothing and clang went on to fail at `'cstdio' file not found` while the `mcpp` module precompiled. The resolution now warns, naming the managed toolset commands. Measured on the plugins' CI row whose Visual Studio is masked. --- CHANGELOG.md | 5 ++++- docs/12-binary-distribution.md | 2 +- docs/specs/library-interface.md | 2 +- docs/zh/12-binary-distribution.md | 2 +- src/build/prepare/scan.cpp | 19 +++++++++++++++++++ src/build/prepare/toolchain_env.cpp | 19 ++++++++++++++++++- 6 files changed, 44 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebd05077..a8293037 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ SPEC-007 §9 与新的 SPEC-008。配套的 mcpp-plugins 0.17.0 以本版本为 Windows 首次构建上,1270 条单文件放置耗时 4.5 s,一个进程复制同样的文件耗时 0.5 s。 - **库接口的第一阶段(E6,SPEC-008)。** `mcpp pack` 写出未进入发布闭包的导出模块(W2),"Withheld" 一行列出每个未发布的单元(此前没有接口根时显示 "(nothing)",而两个导出模块既未发布也未列出); - `mcpp build` 在包导入依赖的非公开模块时警告(W3);缺少接口根的警告写明对 `mcpp pack` 的后果 + `mcpp build` 在包导入依赖(本工作区成员除外)的非公开模块时警告(W3);缺少接口根的警告写明对 `mcpp pack` 的后果 (W1)。全部为警告。 - **快路径在一次确认之后恢复。** 编辑源码后的那次构建经完整路径确认了图,却不重写内容未变的 build.ninja,而快路径以 build.ninja 的时间比较每个源码;此前编辑之后的每次构建都被拒绝,直到图的 @@ -31,6 +31,9 @@ SPEC-007 §9 与新的 SPEC-008。配套的 mcpp-plugins 0.17.0 以本版本为 - **快路径看见 path 依赖的整棵源码树。** 此前只扫描依赖的 `src/`;依赖在别处的 host module (例如 mcpp-plugins 的 `deps/vcpkg.cppm`)编入消费方的构建程序,不在任何 ninja 边上,被编辑后 构建报告"无事可做"。现在扫描依赖的整棵树,跳过隐藏目录、`target` 与嵌套的包(e2e 831)。 +- **MSVC ABI 上的 clang 找不到工具集时说明原因。** 默认的 `msvc@system` 在没有带 C++ 工具的 + Visual Studio 实例时,此前静默继续,随后在预编译 `mcpp` 模块时以 `'cstdio' file not found` 失败; + 现在解析时给出警告,写出安装与指定托管工具集的命令(在屏蔽 Visual Studio 的 runner 上测得)。 - **`mcpp.core` 的输出不再把 `FILE` 带入模块接口。** `mcpp::report` 只经 `printf` 输出;此前 GCC 拒绝在 `import mcpp;` 之后 `#include ` 的构建程序(e2e 651)。 diff --git a/docs/12-binary-distribution.md b/docs/12-binary-distribution.md index 4415c7ef..4375609e 100644 --- a/docs/12-binary-distribution.md +++ b/docs/12-binary-distribution.md @@ -152,7 +152,7 @@ state which it means. |---|---|---| | a `lib` target exports modules and has no lib root | `mcpp build`, for the package being built | `mcpp pack` publishes the library without a module interface | | exported modules that the packed form does not ship | `mcpp pack`, naming each module | a consumer of the packed form cannot import them | -| the package being built imports a module of a dependency that has a lib root, outside its public modules | `mcpp build`, naming the module and the public ones | the build succeeds from source and fails against the packed form | +| the package being built imports a module of a dependency that has a lib root, outside its public modules; the dependency is not a member of the package's own workspace, whose members are built from source with it | `mcpp build`, naming the module and the public ones | the build succeeds from source and fails against the packed form | ## The compatibility tag diff --git a/docs/specs/library-interface.md b/docs/specs/library-interface.md index f6946def..fce11dcd 100644 --- a/docs/specs/library-interface.md +++ b/docs/specs/library-interface.md @@ -63,7 +63,7 @@ |---|---|---|---| | W1 | `mcpp build`,只对当前构建的包 | `lib` 目标导出模块且没有接口根 | `mcpp pack` 发布的库不带模块接口 | | W2 | `mcpp pack` | 有未进入发布闭包的导出主模块,逐个写出 | 使用打包形态的消费方无法导入它们 | -| W3 | `mcpp build`,只对当前构建的包 | 包导入了某个有接口根的依赖的非公开模块 | 从源码构建成功,对打包形态会失败 | +| W3 | `mcpp build`,只对当前构建的包 | 包导入了某个有接口根的依赖的非公开模块;该依赖不是本包所在工作区的成员(同一工作区的成员总是与本包一起从源码构建) | 从源码构建成功,对打包形态会失败 | `mcpp pack` 的 "Withheld" 一行列出每个未发布的单元,包括包没有接口根的情形。(**已实现**) diff --git a/docs/zh/12-binary-distribution.md b/docs/zh/12-binary-distribution.md index 3f43ba86..efc70d32 100644 --- a/docs/zh/12-binary-distribution.md +++ b/docs/zh/12-binary-distribution.md @@ -136,7 +136,7 @@ error: the published interface imports mathkit:secret , which no unit in this |---|---|---| | `lib` 目标导出了模块但没有 lib root | `mcpp build`,只对当前构建的包 | `mcpp pack` 发布的库不带模块接口 | | 打包形态不包含的导出模块 | `mcpp pack`,逐个写出模块名 | 使用打包形态的消费方无法 import 它们 | -| 当前构建的包 import 了某个有 lib root 的依赖的非公开模块 | `mcpp build`,写出该模块与公开模块 | 从源码构建成功,但对打包形态会失败 | +| 当前构建的包 import 了某个有 lib root 的依赖的非公开模块;该依赖不是本包所在工作区的成员(工作区成员总与本包一起从源码构建) | `mcpp build`,写出该模块与公开模块 | 从源码构建成功,但对打包形态会失败 | ## 兼容性 tag diff --git a/src/build/prepare/scan.cpp b/src/build/prepare/scan.cpp index 8c53b8a9..4c3cda96 100644 --- a/src/build/prepare/scan.cpp +++ b/src/build/prepare/scan.cpp @@ -908,9 +908,28 @@ static void step11_public_module_check(PrepareState& state) { providerOf.emplace(prim, u.packageName); } + // A member of the root's own workspace is built from source together with + // the root, whichever form it is published in, so the packed-form + // consequence W3 states does not arise for it (SPEC-008 §4). + auto sameWorkspace = [&](const std::filesystem::path& root) { + if (!state.wsManifest || state.runtimeWorkspaceRoot.empty()) return false; + const auto rel = root.lexically_normal() + .lexically_relative(state.runtimeWorkspaceRoot.lexically_normal()) + .generic_string(); + if (rel.empty() || rel == "." || rel.starts_with("..")) return false; + for (auto const& m : state.wsManifest->workspace.members) { + if (m == rel) return true; + if (m.ends_with("/*") && rel.starts_with(m.substr(0, m.size() - 1)) + && rel.find('/', m.size() - 1) == std::string::npos) + return true; + } + return false; + }; + std::map> publicOf; for (std::size_t i = 1; i < state.packages.size(); ++i) { auto const& pr = state.packages[i]; + if (sameWorkspace(pr.root)) continue; const auto rootFile = (pr.root / mcpp::manifest::resolve_lib_root_path(pr.manifest, pr.root)) .lexically_normal(); std::error_code ec; diff --git a/src/build/prepare/toolchain_env.cpp b/src/build/prepare/toolchain_env.cpp index e558814c..554c154c 100644 --- a/src/build/prepare/toolchain_env.cpp +++ b/src/build/prepare/toolchain_env.cpp @@ -7,6 +7,7 @@ module mcpp.build.prepare; import :state; import mcpp.build.prepare_inputs; +import mcpp.diag; import std; import mcpp.build.version_floor; @@ -136,7 +137,23 @@ bind_msvc_sysroot(mcpp::toolchain::Toolchain& tc, instances, msvc::msvc_env_snapshot(), systemSel ? std::string_view("system") : std::string_view(spec->version), needs); - if (!choice && systemSel) return {}; + if (!choice && systemSel) { + // Not a refusal: clang's own detection may still find a toolset + // (a developer prompt's INCLUDE and LIB). Without one the first + // standard header fails -- `'cstdio' file not found` while the + // `mcpp` module precompiles -- which names neither the cause nor + // the remedy, so the resolution states both here (#734, measured + // on a runner whose Visual Studio was masked). + mcpp::diag::warning("toolchain/msvc", + std::format("clang on the MSVC ABI compiles against an MSVC toolset, and " + "`msvc@system` found none on this machine (no Visual Studio " + "instance with the C++ tools)"), + std::format("install one and name it: `mcpp toolchain install msvc " + "14.44.35207`, then `[target.{}] sysroot = \"xim:msvc@14.44.35207\"` " + "or `--toolchain xim:msvc@14.44.35207`", + tt->str())); + return {}; + } } std::string origin = "system"; From 2420a1ff8e173d9d23aa56aa8d9d7270ce94f7c2 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:36:56 +0800 Subject: [PATCH 16/18] stage --list: generic spellings, and a refusal that names its entries; two prepare steps split under the size gate (#734) - The placement list is written with forward slashes on every host, which is its stated format; on Windows the unit test read `bin\msvcp140.dll`. - A refusal from `mcpp stage --list` names the failing group's entries as the list writes them. With one edge for the whole list, ninja's echo of the command no longer shows which files were involved (e2e 646 on Windows). - step13_serve_workspace_members (E1) and host_module_units are split out of step13_dependency_cache and step6_host_module_registration, which the function-size gate reported over 400 lines. `workspace_member_of` is one function shared by E1 and W3. - The design record states three further plugin departures. --- ...ign-toolsets-and-library-surface-design.md | 18 + src/build/ninja_backend.cppm | 2 +- src/build/prepare/features.cpp | 316 ++++++++------- src/build/prepare/plan.cpp | 379 +++++++++--------- src/build/prepare/scan.cpp | 17 +- src/build/prepare/state.cppm | 4 + src/cli/cmd_build.cppm | 12 +- 7 files changed, 395 insertions(+), 353 deletions(-) diff --git a/.agents/docs/2026-09-28-build-cost-foreign-toolsets-and-library-surface-design.md b/.agents/docs/2026-09-28-build-cost-foreign-toolsets-and-library-surface-design.md index 39631c11..0579560a 100644 --- a/.agents/docs/2026-09-28-build-cost-foreign-toolsets-and-library-surface-design.md +++ b/.agents/docs/2026-09-28-build-cost-foreign-toolsets-and-library-surface-design.md @@ -1262,5 +1262,23 @@ Departures from §6: check reads it. 5. **Each deps-cmake toolset statement has its own build directory,** because CMake refuses a cache made with another generator or instance. +6. **The Linux GCC row keeps the foreign system's detection under `resolved`.** + §6.2 named every non-MSVC toolset through `chain`. The plugins' CI measured + that the GCC payload's driver is not a complete handover: mcpp runs it with a + sysroot, a binutils directory and a link model that only its own command + lines carry, and vcpkg's compiler detection failed with the driver alone. + The clang payloads are complete through their `.cfg` files. On the GCC row + the host compiler's libstdc++ is the program's C++ library, so + `mcpp.plugins.toolset` answers `detected` there and states why. +7. **The MSBuild refusal matches the call stack.** A read inside a function + reports the caller's list file to a `variable_watch` callback, so a match on + the current file never fired (measured on the masked row: MSBuild failed + with "no such file or directory" and no reason). The stack names the + helper's defining file. +8. **E2 has no "tool flags" accessor.** Deviation 6 is where one would be + read. The engine states `toolchain_sysroot()` and + `toolchain_binutils_dir()` already, but not the link model; handing a + foreign system the GCC payload completely is left to a later design with its + own criterion (a port that builds and runs a host tool). **mcpp-index, validation.** Recorded below as they land. diff --git a/src/build/ninja_backend.cppm b/src/build/ninja_backend.cppm index cd910194..3289f554 100644 --- a/src/build/ninja_backend.cppm +++ b/src/build/ninja_backend.cppm @@ -3045,7 +3045,7 @@ std::string emit_ninja_string(const BuildPlan& plan, std::string* placements) { outs += " " + escape_ninja_path(d.dest); for (auto const& s : d.sources) { ins += " " + escape_ninja_path(s); - list += std::format("{}\t{}\n", s.string(), d.dest.string()); + list += std::format("{}\t{}\n", s.generic_string(), d.dest.generic_string()); } } append(std::format("build{} : stage_list{} | placements.list\n count = {}\n", diff --git a/src/build/prepare/features.cpp b/src/build/prepare/features.cpp index 142bb9e6..575f21ae 100644 --- a/src/build/prepare/features.cpp +++ b/src/build/prepare/features.cpp @@ -889,6 +889,167 @@ static std::expected step6_xlings_workspace_from_graph(Prepar return {}; } +// Every host module one package contributes, the lib root first. +// +// The lib root is what a rule package has always been: one unit, +// compiled alone, registered under the name it declares. A package +// that offers several rules through features (mcpp 2026.9.5.3+) +// lists their sources under `[features.] sources`, and those +// globs have been folded into `buildConfig.sources` by now for +// exactly the features the consumer activated. Every module +// INTERFACE unit among them is therefore a host module of its own, +// under its own declared name, and nothing else in the host-module +// path assumes one unit per package: `build_host_module` is per +// unit and the compile loop accumulates BMIs in list order, so a +// feature unit may import the lib root, which precedes it. +// +// Only sources the manifest LISTS take part. The inferred `src/**` +// of a package with no `sources` is not consulted, so a rule +// package published before this round exposes exactly what it +// exposed then; widening that implicitly would compile units that +// were written to be part of an ordinary library, alone. +// +// Split out of step6_host_module_registration (#734). +static std::vector host_module_units(const PrepareState& st, std::size_t p) { + auto identity = [&](std::size_t q) { + auto const& pkg = st.packages[q].manifest.package; + return pkg.namespace_.empty() ? pkg.name : pkg.namespace_ + "." + pkg.name; + }; + auto const& depPkg = st.packages[p]; + auto const& pkg = depPkg.manifest.package; + std::vector out; + auto push = [&](std::filesystem::path iface, std::string name) { + prov::HostModule hm; + hm.module = std::move(name); + hm.package = identity(p); + hm.nameSpace = pkg.namespace_; + hm.interface = std::move(iface); + out.push_back(std::move(hm)); + }; + // PROBING form: a host-module dependency whose interface is + // `.ixx` resolves to a `src/.cppm` that does not exist, + // and the consumer's build.mcpp is then handed a path to + // nothing. + auto rel = mcpp::manifest::resolve_lib_root_path( + depPkg.manifest, depPkg.root); + auto iface = depPkg.root / rel; + auto rootName = prov::host_module_name(iface, pkg.name); + // A missing lib root is reported as such by build_host_module, + // and that has to stay the diagnostic. Enumerating the listed + // units first would let one of them collide with the missing + // root's fallback name and report a collision between a file + // and a file that does not exist. + std::error_code ec; + if (!std::filesystem::exists(iface, ec)) { + push(iface, std::move(rootName)); + return out; + } + + std::set matched, dropped; + for (auto const& g : depPkg.manifest.buildConfig.sources) { + if (g.empty()) continue; + if (g[0] == '!') { + for (auto& f : mcpp::modgraph::expand_glob(depPkg.root, g.substr(1))) + dropped.insert(f.lexically_normal()); + } else { + for (auto& f : mcpp::modgraph::expand_glob(depPkg.root, g)) + matched.insert(f.lexically_normal()); + } + } + 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 imports; + }; + // The lib root is the first node of the same sort (mcpp#720). + // Placing it ahead of the sort assumed that it imports no + // other unit of its package; a root that does was compiled + // before the unit it imports and failed with "module not + // found". As the first node it is still emitted first whenever + // it imports nothing of its own package, so the order of every + // package that built before is unchanged. + std::vector pending; + { + std::ifstream is(root); + std::stringstream buf; + if (is) buf << is.rdbuf(); + pending.push_back({root, std::move(rootName), + prov::declared_imports(buf.str())}); + } + 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 text = buf.str(); + auto name = prov::declared_interface_name(text); + if (name.empty()) continue; + pending.push_back({f, std::move(name), prov::declared_imports(text)}); + } + + // Only names this package itself declares constrain anything. + // `import std;` is 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 byName; + for (std::size_t i = 0; i < pending.size(); ++i) + byName.emplace(pending[i].name, i); + + std::vector state(pending.size(), 0); // 0 new, 1 open, 2 done + std::vector 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> 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; +} + static std::expected>, std::string> step6_host_module_registration(PrepareState& state) { // ── #355: HOST tool provisioning ──────────────────────────────────── @@ -973,160 +1134,7 @@ step6_host_module_registration(PrepareState& state) { return pkg.namespace_.empty() ? pkg.name : pkg.namespace_ + "." + pkg.name; }; - // Every host module one package contributes, the lib root first. - // - // The lib root is what a rule package has always been: one unit, - // compiled alone, registered under the name it declares. A package - // that offers several rules through features (mcpp 2026.9.5.3+) - // lists their sources under `[features.] sources`, and those - // globs have been folded into `buildConfig.sources` by now for - // exactly the features the consumer activated. Every module - // INTERFACE unit among them is therefore a host module of its own, - // under its own declared name, and nothing else in the host-module - // path assumes one unit per package: `build_host_module` is per - // unit and the compile loop accumulates BMIs in list order, so a - // feature unit may import the lib root, which precedes it. - // - // Only sources the manifest LISTS take part. The inferred `src/**` - // of a package with no `sources` is not consulted, so a rule - // package published before this round exposes exactly what it - // exposed then; widening that implicitly would compile units that - // were written to be part of an ordinary library, alone. - auto units = [&](std::size_t p) { - auto const& depPkg = state.packages[p]; - auto const& pkg = depPkg.manifest.package; - std::vector out; - auto push = [&](std::filesystem::path iface, std::string name) { - prov::HostModule hm; - hm.module = std::move(name); - hm.package = identity(p); - hm.nameSpace = pkg.namespace_; - hm.interface = std::move(iface); - out.push_back(std::move(hm)); - }; - // PROBING form: a host-module dependency whose interface is - // `.ixx` resolves to a `src/.cppm` that does not exist, - // and the consumer's build.mcpp is then handed a path to - // nothing. - auto rel = mcpp::manifest::resolve_lib_root_path( - depPkg.manifest, depPkg.root); - auto iface = depPkg.root / rel; - auto rootName = prov::host_module_name(iface, pkg.name); - // A missing lib root is reported as such by build_host_module, - // and that has to stay the diagnostic. Enumerating the listed - // units first would let one of them collide with the missing - // root's fallback name and report a collision between a file - // and a file that does not exist. - std::error_code ec; - if (!std::filesystem::exists(iface, ec)) { - push(iface, std::move(rootName)); - return out; - } - - std::set matched, dropped; - for (auto const& g : depPkg.manifest.buildConfig.sources) { - if (g.empty()) continue; - if (g[0] == '!') { - for (auto& f : mcpp::modgraph::expand_glob(depPkg.root, g.substr(1))) - dropped.insert(f.lexically_normal()); - } else { - for (auto& f : mcpp::modgraph::expand_glob(depPkg.root, g)) - matched.insert(f.lexically_normal()); - } - } - 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 imports; - }; - // The lib root is the first node of the same sort (mcpp#720). - // Placing it ahead of the sort assumed that it imports no - // other unit of its package; a root that does was compiled - // before the unit it imports and failed with "module not - // found". As the first node it is still emitted first whenever - // it imports nothing of its own package, so the order of every - // package that built before is unchanged. - std::vector pending; - { - std::ifstream is(root); - std::stringstream buf; - if (is) buf << is.rdbuf(); - pending.push_back({root, std::move(rootName), - prov::declared_imports(buf.str())}); - } - 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 text = buf.str(); - auto name = prov::declared_interface_name(text); - if (name.empty()) continue; - pending.push_back({f, std::move(name), prov::declared_imports(text)}); - } - - // Only names this package itself declares constrain anything. - // `import std;` is 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 byName; - for (std::size_t i = 0; i < pending.size(); ++i) - byName.emplace(pending[i].name, i); - - std::vector state(pending.size(), 0); // 0 new, 1 open, 2 done - std::vector 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> 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; - }; + auto units = [&](std::size_t p) { return host_module_units(state, p); }; for (std::size_t c = 0; c < state.provisionGraph.visible.size(); ++c) { const auto direct = directHostProviders(c); if (direct.empty()) continue; diff --git a/src/build/prepare/plan.cpp b/src/build/prepare/plan.cpp index 04f166a1..a2247317 100644 --- a/src/build/prepare/plan.cpp +++ b/src/build/prepare/plan.cpp @@ -1627,6 +1627,203 @@ static std::expected step13_windows_resources(PrepareState& s return {}; } +// The member path (relative to the workspace root) of a package root, when the +// root is a member of the workspace this build runs in; empty otherwise. +// Shared by E1 (members built once) and W3 (a member's non-public modules). +std::string workspace_member_of(const PrepareState& state, const std::filesystem::path& root) { + if (!state.wsManifest || state.runtimeWorkspaceRoot.empty()) return {}; + const auto rel = root.lexically_normal() + .lexically_relative(state.runtimeWorkspaceRoot.lexically_normal()) + .generic_string(); + if (rel.empty() || rel == "." || rel.starts_with("..")) return {}; + for (auto const& m : state.wsManifest->workspace.members) { + if (m == rel) return rel; + if (m.ends_with("/*") && rel.starts_with(m.substr(0, m.size() - 1)) + && rel.find('/', m.size() - 1) == std::string::npos) + return rel; + } + return {}; +} + +// #734 E1, split out of step13_dependency_cache: the workspace members this +// graph reaches by `path` are built in their own directories and served to it +// through stage edges. `pkgKeys` and `pkgInputs` are this graph's build keys +// and their inputs, index-aligned with `state.packages`. +static std::expected +step13_serve_workspace_members(PrepareState& state, BuildContext& ctx, + const std::vector& pkgKeys, + const std::vector& pkgInputs) { + // ── #734 E1: a workspace member used as a path dependency ───────── + // + // Built once, in its own directory, as the root of its own build, and + // taken from there by every member that consumes it. The member's own + // ninja decides what is stale, so an input outside the member's root + // (a header under `../3rdParty`) counts as it does for the member's + // own build; nothing here stamps files. The member's objects and + // module interfaces reach this graph through the stage edges the + // global cache uses. Equal build keys are the admission rule: the key + // holds everything that decides a unit's compile command, so a member + // whose key differs here (other features, another profile) is compiled + // in this graph as before. A host-tool sub-build and a planning-only + // command (`mcpp emit build-database`) never build a member. + if (state.overrides.tool_depth == 0 && !state.overrides.plan_only + && state.wsManifest && !state.runtimeWorkspaceRoot.empty()) { + auto qualified = [](const mcpp::manifest::Manifest& mm) { + return mm.package.namespace_.empty() ? mm.package.name + : mm.package.namespace_ + "." + mm.package.name; + }; + auto bmiT = mcpp::toolchain::bmi_traits(*state.tc); + for (std::size_t i = 1; i < state.packages.size(); ++i) { + const auto* depIdent = i - 1 < state.dep_cache_identities.size() + ? &state.dep_cache_identities[i - 1] : nullptr; + if (!depIdent || depIdent->sourceKind != "path") continue; + const auto& pkgRoot = state.packages[i]; + const auto member = workspace_member_of(state, pkgRoot.root); + if (member.empty()) continue; + + BuildOverrides sub; + sub.project_root = state.runtimeWorkspaceRoot; + sub.package_filter = member; + sub.target_triple = state.overrides.target_triple; + sub.accel = state.overrides.accel; + sub.force_static = state.overrides.force_static; + sub.profile = state.overrides.profile; + sub.profile_fallback = state.overrides.profile_fallback; + sub.capabilities = state.overrides.capabilities; + sub.toolchain = state.overrides.toolchain; + sub.cache_mode = state.overrides.cache_mode; + if (i < state.activeFeaturesByPackage.size()) + for (auto const& f : state.activeFeaturesByPackage[i]) { + if (!sub.features.empty()) sub.features += ","; + sub.features += f; + } + auto subCtx = prepare_build(/*print_fingerprint=*/false, + /*includeDevDeps=*/false, /*extraTargets=*/{}, sub); + const auto who = qualified(pkgRoot.manifest); + if (!subCtx) { + mcpp::log::verbose("workspace-member", std::format( + "{} is compiled in this graph: its own build could not be " + "planned: {}", who, subCtx.error())); + continue; + } + // The admission compares the key's INPUTS, less `package.index`: + // that field names where a package came from (the namespace a + // dependency is reached under; empty for a root), not how it + // compiles. Every other input must be equal. + auto compile_inputs = [](nlohmann::json j) { + if (j.is_object() && j.contains("package") && j["package"].is_object()) + j["package"].erase("index"); + return j; + }; + const bool sameCompile = !subCtx->plan.packageKeyInputs.empty() + && compile_inputs(nlohmann::json::parse(subCtx->plan.packageKeyInputs[0], nullptr, false)) + == compile_inputs(pkgInputs[i]); + if (!sameCompile) { + // Name the inputs that differ: a mismatch is either a + // real difference (another feature set) or a key input + // that depends on the position, and only the field names + // tell which. + std::string fields; + if (!subCtx->plan.packageKeyInputs.empty()) { + auto own = nlohmann::json::parse(subCtx->plan.packageKeyInputs[0], nullptr, false); + auto here = pkgInputs[i]; + if (own.is_object() && here.is_object()) + for (auto it = here.begin(); it != here.end(); ++it) { + if (own.contains(it.key()) && own[it.key()] == it.value()) continue; + if (it.value().is_object() && own.contains(it.key()) + && own[it.key()].is_object()) { + auto const& o = own[it.key()]; + for (auto jt = it.value().begin(); jt != it.value().end(); ++jt) + if (!o.contains(jt.key()) || o[jt.key()] != jt.value()) { + if (!fields.empty()) fields += ", "; + fields += std::format("{}.{} (here {}, own {})", it.key(), + jt.key(), jt.value().dump(), + o.contains(jt.key()) ? o[jt.key()].dump() : "absent"); + } + continue; + } + if (!fields.empty()) fields += ", "; + fields += it.key(); + } + } + mcpp::log::verbose("workspace-member", std::format( + "{} is compiled in this graph: its build key here ({}) differs " + "from its own ({}); differing inputs: {}", who, pkgKeys[i], + subCtx->plan.packageKeys.empty() ? std::string("none") + : subCtx->plan.packageKeys[0], + fields.empty() ? std::string("(unknown)") : fields)); + continue; + } + // Every unit of the member in this graph must have its + // counterpart in the member's own build, or none is taken: + // a half-served package is the mixed state the global cache + // refuses for the same reason. + std::map own; + for (auto const& scu : subCtx->plan.compileUnits) + own.emplace(scu.source.lexically_normal(), &scu); + std::vector> pairs; + bool complete = true; + for (std::size_t u = 0; u < ctx.plan.compileUnits.size(); ++u) { + auto const& cu = ctx.plan.compileUnits[u]; + if (cu.packageName != who) continue; + auto it = own.find(cu.source.lexically_normal()); + if (it == own.end()) { complete = false; break; } + pairs.push_back({u, it->second}); + } + if (!complete || pairs.empty()) { + mcpp::log::verbose("workspace-member", std::format( + "{} is compiled in this graph: its units here and in its own " + "build are not the same set", who)); + continue; + } + + mcpp::ui::status("Building", std::format( + "workspace member {} in its own directory, once for every " + "member that uses it", who)); + // Two consumers built at once (two `-p` commands) share this + // directory; one ninja runs in it at a time. + std::error_code lockEc; + std::filesystem::create_directories(subCtx->plan.outputDir, lockEc); + std::optional memberLock; + for (int waited = 0; + !(memberLock = mcpp::platform::fs::FileLock::try_acquire(subCtx->plan.outputDir)); + ++waited) { + if (waited == 0) + mcpp::ui::status("Waiting", std::format( + "for another build of workspace member {}", who)); + if (waited >= 3000) return std::unexpected(std::format( + "workspace member {}: another build has held {} for ten minutes", + who, subCtx->plan.outputDir.string())); + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + } + auto be = mcpp::build::make_ninja_backend(); + mcpp::build::BuildOptions bopt; + auto br = be->build(subCtx->plan, bopt); + memberLock.reset(); + if (!br) return std::unexpected(std::format( + "building workspace member {} failed: {}\n{}", who, + br.error().message, br.error().diagnosticOutput)); + if (br->exitCode != 0) return std::unexpected(std::format( + "building workspace member {} failed (exit {})", who, br->exitCode)); + + const auto subOut = subCtx->plan.outputDir; + for (auto const& [u, scu] : pairs) { + auto& cu = ctx.plan.compileUnits[u]; + cu.servedFromCache = true; + cu.servedFromMember = true; + cu.cachedObject = subOut / scu->object; + if (!cu.providesModule.empty()) { + std::string bmi; + for (char c : cu.providesModule) bmi.push_back(c == ':' ? '-' : c); + bmi += std::string(bmiT.bmiExt); + cu.cachedBmi = subOut / std::string(bmiT.bmiDir) / bmi; + } + } + } + } + return {}; +} + static std::expected step13_dependency_cache(PrepareState& state, BuildContext& ctx) { // ─── Global dependency cache: per-package keys, hit → stage edges ── // @@ -1800,186 +1997,8 @@ static std::expected step13_dependency_cache(PrepareState& st ctx.plan.packageKeyInputs.clear(); for (auto const& j : pkgInputs) ctx.plan.packageKeyInputs.push_back(j.dump()); - // ── #734 E1: a workspace member used as a path dependency ───────── - // - // Built once, in its own directory, as the root of its own build, and - // taken from there by every member that consumes it. The member's own - // ninja decides what is stale, so an input outside the member's root - // (a header under `../3rdParty`) counts as it does for the member's - // own build; nothing here stamps files. The member's objects and - // module interfaces reach this graph through the stage edges the - // global cache uses. Equal build keys are the admission rule: the key - // holds everything that decides a unit's compile command, so a member - // whose key differs here (other features, another profile) is compiled - // in this graph as before. A host-tool sub-build and a planning-only - // command (`mcpp emit build-database`) never build a member. - if (state.overrides.tool_depth == 0 && !state.overrides.plan_only - && state.wsManifest && !state.runtimeWorkspaceRoot.empty()) { - const auto wsRoot = state.runtimeWorkspaceRoot.lexically_normal(); - auto memberPath = [&](const std::filesystem::path& root) -> std::string { - const auto rel = root.lexically_normal().lexically_relative(wsRoot).generic_string(); - if (rel.empty() || rel == "." || rel.starts_with("..")) return {}; - for (auto const& m : state.wsManifest->workspace.members) { - if (m == rel) return rel; - if (m.ends_with("/*") && rel.starts_with(m.substr(0, m.size() - 1)) - && rel.find('/', m.size() - 1) == std::string::npos) - return rel; - } - return {}; - }; - auto qualified = [](const mcpp::manifest::Manifest& mm) { - return mm.package.namespace_.empty() ? mm.package.name - : mm.package.namespace_ + "." + mm.package.name; - }; - auto bmiT = mcpp::toolchain::bmi_traits(*state.tc); - for (std::size_t i = 1; i < state.packages.size(); ++i) { - const auto* depIdent = i - 1 < state.dep_cache_identities.size() - ? &state.dep_cache_identities[i - 1] : nullptr; - if (!depIdent || depIdent->sourceKind != "path") continue; - const auto& pkgRoot = state.packages[i]; - const auto member = memberPath(pkgRoot.root); - if (member.empty()) continue; - - BuildOverrides sub; - sub.project_root = state.runtimeWorkspaceRoot; - sub.package_filter = member; - sub.target_triple = state.overrides.target_triple; - sub.accel = state.overrides.accel; - sub.force_static = state.overrides.force_static; - sub.profile = state.overrides.profile; - sub.profile_fallback = state.overrides.profile_fallback; - sub.capabilities = state.overrides.capabilities; - sub.toolchain = state.overrides.toolchain; - sub.cache_mode = state.overrides.cache_mode; - if (i < state.activeFeaturesByPackage.size()) - for (auto const& f : state.activeFeaturesByPackage[i]) { - if (!sub.features.empty()) sub.features += ","; - sub.features += f; - } - auto subCtx = prepare_build(/*print_fingerprint=*/false, - /*includeDevDeps=*/false, /*extraTargets=*/{}, sub); - const auto who = qualified(pkgRoot.manifest); - if (!subCtx) { - mcpp::log::verbose("workspace-member", std::format( - "{} is compiled in this graph: its own build could not be " - "planned: {}", who, subCtx.error())); - continue; - } - // The admission compares the key's INPUTS, less `package.index`: - // that field names where a package came from (the namespace a - // dependency is reached under; empty for a root), not how it - // compiles. Every other input must be equal. - auto compile_inputs = [](nlohmann::json j) { - if (j.is_object() && j.contains("package") && j["package"].is_object()) - j["package"].erase("index"); - return j; - }; - const bool sameCompile = !subCtx->plan.packageKeyInputs.empty() - && compile_inputs(nlohmann::json::parse(subCtx->plan.packageKeyInputs[0], nullptr, false)) - == compile_inputs(pkgInputs[i]); - if (!sameCompile) { - // Name the inputs that differ: a mismatch is either a - // real difference (another feature set) or a key input - // that depends on the position, and only the field names - // tell which. - std::string fields; - if (!subCtx->plan.packageKeyInputs.empty()) { - auto own = nlohmann::json::parse(subCtx->plan.packageKeyInputs[0], nullptr, false); - auto here = pkgInputs[i]; - if (own.is_object() && here.is_object()) - for (auto it = here.begin(); it != here.end(); ++it) { - if (own.contains(it.key()) && own[it.key()] == it.value()) continue; - if (it.value().is_object() && own.contains(it.key()) - && own[it.key()].is_object()) { - auto const& o = own[it.key()]; - for (auto jt = it.value().begin(); jt != it.value().end(); ++jt) - if (!o.contains(jt.key()) || o[jt.key()] != jt.value()) { - if (!fields.empty()) fields += ", "; - fields += std::format("{}.{} (here {}, own {})", it.key(), - jt.key(), jt.value().dump(), - o.contains(jt.key()) ? o[jt.key()].dump() : "absent"); - } - continue; - } - if (!fields.empty()) fields += ", "; - fields += it.key(); - } - } - mcpp::log::verbose("workspace-member", std::format( - "{} is compiled in this graph: its build key here ({}) differs " - "from its own ({}); differing inputs: {}", who, pkgKeys[i], - subCtx->plan.packageKeys.empty() ? std::string("none") - : subCtx->plan.packageKeys[0], - fields.empty() ? std::string("(unknown)") : fields)); - continue; - } - // Every unit of the member in this graph must have its - // counterpart in the member's own build, or none is taken: - // a half-served package is the mixed state the global cache - // refuses for the same reason. - std::map own; - for (auto const& scu : subCtx->plan.compileUnits) - own.emplace(scu.source.lexically_normal(), &scu); - std::vector> pairs; - bool complete = true; - for (std::size_t u = 0; u < ctx.plan.compileUnits.size(); ++u) { - auto const& cu = ctx.plan.compileUnits[u]; - if (cu.packageName != who) continue; - auto it = own.find(cu.source.lexically_normal()); - if (it == own.end()) { complete = false; break; } - pairs.push_back({u, it->second}); - } - if (!complete || pairs.empty()) { - mcpp::log::verbose("workspace-member", std::format( - "{} is compiled in this graph: its units here and in its own " - "build are not the same set", who)); - continue; - } - - mcpp::ui::status("Building", std::format( - "workspace member {} in its own directory, once for every " - "member that uses it", who)); - // Two consumers built at once (two `-p` commands) share this - // directory; one ninja runs in it at a time. - std::error_code lockEc; - std::filesystem::create_directories(subCtx->plan.outputDir, lockEc); - std::optional memberLock; - for (int waited = 0; - !(memberLock = mcpp::platform::fs::FileLock::try_acquire(subCtx->plan.outputDir)); - ++waited) { - if (waited == 0) - mcpp::ui::status("Waiting", std::format( - "for another build of workspace member {}", who)); - if (waited >= 3000) return std::unexpected(std::format( - "workspace member {}: another build has held {} for ten minutes", - who, subCtx->plan.outputDir.string())); - std::this_thread::sleep_for(std::chrono::milliseconds(200)); - } - auto be = mcpp::build::make_ninja_backend(); - mcpp::build::BuildOptions bopt; - auto br = be->build(subCtx->plan, bopt); - memberLock.reset(); - if (!br) return std::unexpected(std::format( - "building workspace member {} failed: {}\n{}", who, - br.error().message, br.error().diagnosticOutput)); - if (br->exitCode != 0) return std::unexpected(std::format( - "building workspace member {} failed (exit {})", who, br->exitCode)); - - const auto subOut = subCtx->plan.outputDir; - for (auto const& [u, scu] : pairs) { - auto& cu = ctx.plan.compileUnits[u]; - cu.servedFromCache = true; - cu.servedFromMember = true; - cu.cachedObject = subOut / scu->object; - if (!cu.providesModule.empty()) { - std::string bmi; - for (char c : cu.providesModule) bmi.push_back(c == ':' ? '-' : c); - bmi += std::string(bmiT.bmiExt); - cu.cachedBmi = subOut / std::string(bmiT.bmiDir) / bmi; - } - } - } - } + if (auto served = step13_serve_workspace_members(state, ctx, pkgKeys, pkgInputs); !served) + return std::unexpected(served.error()); for (std::size_t i = 1; i < state.packages.size(); ++i) { // skip [0] = main const auto& pkgRoot = state.packages[i]; diff --git a/src/build/prepare/scan.cpp b/src/build/prepare/scan.cpp index 4c3cda96..75431c55 100644 --- a/src/build/prepare/scan.cpp +++ b/src/build/prepare/scan.cpp @@ -911,25 +911,10 @@ static void step11_public_module_check(PrepareState& state) { // A member of the root's own workspace is built from source together with // the root, whichever form it is published in, so the packed-form // consequence W3 states does not arise for it (SPEC-008 §4). - auto sameWorkspace = [&](const std::filesystem::path& root) { - if (!state.wsManifest || state.runtimeWorkspaceRoot.empty()) return false; - const auto rel = root.lexically_normal() - .lexically_relative(state.runtimeWorkspaceRoot.lexically_normal()) - .generic_string(); - if (rel.empty() || rel == "." || rel.starts_with("..")) return false; - for (auto const& m : state.wsManifest->workspace.members) { - if (m == rel) return true; - if (m.ends_with("/*") && rel.starts_with(m.substr(0, m.size() - 1)) - && rel.find('/', m.size() - 1) == std::string::npos) - return true; - } - return false; - }; - std::map> publicOf; for (std::size_t i = 1; i < state.packages.size(); ++i) { auto const& pr = state.packages[i]; - if (sameWorkspace(pr.root)) continue; + if (!workspace_member_of(state, pr.root).empty()) continue; const auto rootFile = (pr.root / mcpp::manifest::resolve_lib_root_path(pr.manifest, pr.root)) .lexically_normal(); std::error_code ec; diff --git a/src/build/prepare/state.cppm b/src/build/prepare/state.cppm index 817f9504..d615cbbc 100644 --- a/src/build/prepare/state.cppm +++ b/src/build/prepare/state.cppm @@ -502,6 +502,10 @@ struct PrepareState { // static would give each definition internal linkage, invisible outside // its own file. std::expected phase0_manifest_and_workspace(PrepareState& state); + +// plan.cpp: the member path of a package root within the workspace this build +// runs in, or empty (#734 E1, W3). +std::string workspace_member_of(const PrepareState& state, const std::filesystem::path& root); std::expected phase1_toolchain_spec_and_axes(PrepareState& state); std::expected phase2_define_toolchain_resolver(PrepareState& state); std::expected phase3_xlings_before_graph(PrepareState& state); diff --git a/src/cli/cmd_build.cppm b/src/cli/cmd_build.cppm index 0b6e079b..a13e9f69 100644 --- a/src/cli/cmd_build.cppm +++ b/src/cli/cmd_build.cppm @@ -978,6 +978,7 @@ export int cmd_stage(const mcpplibs::cmdline::ParsedArgs& parsed) { return 1; } std::vector>> groups; + std::vector> spelled; // each group's sources as the list writes them std::map index; std::string line; while (std::getline(in, line)) { @@ -990,15 +991,22 @@ export int cmd_stage(const mcpplibs::cmdline::ParsedArgs& parsed) { } std::string src = line.substr(0, tab), dst = line.substr(tab + 1); auto [it, fresh] = index.emplace(dst, groups.size()); - if (fresh) groups.push_back({dst, {}}); + if (fresh) { groups.push_back({dst, {}}); spelled.emplace_back(); } groups[it->second].second.push_back( mcpp::platform::fs::extended_length(std::filesystem::path{src})); + spelled[it->second].push_back(src); } - for (auto const& [dst, srcs] : groups) { + for (std::size_t g = 0; g < groups.size(); ++g) { + auto const& [dst, srcs] = groups[g]; auto r = mcpp::build::stage::stage_files( srcs, mcpp::platform::fs::extended_length(std::filesystem::path{dst}), opts); if (!r) { + // One edge places the whole list, so ninja's echo of the + // command no longer shows which files were involved; the + // entries are named here as the list writes them. std::println(stderr, "error: {}", r.error().message); + std::println(stderr, " placement list entries ({}):", listFile); + for (auto const& src : spelled[g]) std::println(stderr, " {} -> {}", src, dst); return 1; } } From e7a9b4bf736e5f04560ea986abe849aaf3796904 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Mon, 28 Sep 2026 22:52:29 +0800 Subject: [PATCH 17/18] ui: one line per download -- the completion line only, with its size and time, in both modes (#734) Output that is not a terminal printed a start line and a completion line for every item; it now prints the completion line (or the line saying the item did not complete). A terminal redraws the bar in place, as before, and ends it with the same completion line, which now states the size and the time. --- CHANGELOG.md | 3 +++ src/ui.cppm | 32 ++++++++++++++++------------- tests/unit/test_progress_render.cpp | 10 ++++----- 3 files changed, 26 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8293037..94ddcb78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,9 @@ SPEC-007 §9 与新的 SPEC-008。配套的 mcpp-plugins 0.17.0 以本版本为 - **快路径看见 path 依赖的整棵源码树。** 此前只扫描依赖的 `src/`;依赖在别处的 host module (例如 mcpp-plugins 的 `deps/vcpkg.cppm`)编入消费方的构建程序,不在任何 ninja 边上,被编辑后 构建报告"无事可做"。现在扫描依赖的整棵树,跳过隐藏目录、`target` 与嵌套的包(e2e 831)。 +- **每个下载只占一行。** 非终端输出此前在开始时写一行 `Downloading ()`,完成时再写一行 + `... done, in