From 93cf907efeb5481a01513143b3b0eb77ace55983 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Fri, 4 Sep 2026 08:40:31 +0800 Subject: [PATCH 01/10] feat(device): runner gains three siblings, --locked asserts, and mcpp sbom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four axes a project is asked about before it is adopted, and none of them had an answer: how the artefact reaches a device, whether the build is reproducible, what went into it, and whether an emulator and real silicon are one package or two. ## The device slots `run`, `flash`, `monitor` and `debug` are one shape — an argv the BOARD knows and a TOOL performs, addressed absolutely, with the artefact appended or substituted for `{}`. `runner` has carried that shape since 2026.8.19; three special-case commands would have carried it three more times. So the slot is the parameter: one directive-table row each, one reader, one CLI shape. ⚠️ What no argv can express is which of them ENDS. `run` and `flash` finish and hand back a verdict; `monitor` and `debug` have no natural end, so a live process is success for them and a hang for the other two. `Semantics` answers that from the slot, because `openocd -c "program … exit"` and `openocd -c "init"` are spelled alike up to the argument the board chose. `debug` starts a SERVER and stops there. The client is the user's debugger or their IDE, which reaches mcpp through docs/11; driving it would put mcpp in the middle of a session it has nothing to add to. ⚠️ `runner-exclusive` is the first thing a physical board needed that an emulator never did. `mcpp test` runs binaries on a worker pool; one probe on one device is a mutex, and two workers reaching for it do not fail — they interleave, and the verdict is about neither test. The board knows this about itself, so it says so once and no project remembers `-j1`. ## Emulator or hardware is a feature, not a fork A board reached through QEMU and the same board reached through a probe differ in the argv of their device slots and nothing else. Publishing two packages would duplicate a linker script, startup code and a module surface to vary four strings. `mcpp::has_feature()` already existed, so this needed no engine work at all — it is what the layering was for. e2e 333 builds one board package and drives it both ways. ## --locked The lock has always been written after resolution and never read back; its own header said so. This makes it an ASSERTION rather than a pin: the resolution that happens must equal the one recorded, and a difference names the package that moved and both versions. That is the half reproducibility needs first, and it is what Cargo's flag of the same name means. ⚠️⚠️ And it must not meet the fast path. Measured before that guard existed: a deliberately corrupted lock passed `mcpp build --locked` and printed "Finished" — the flag accepted, the build correct, the assertion never run. A criterion that is skipped is worse than one that is absent, because the green reads as a verification. ## mcpp sbom CycloneDX 1.5 over the recorded resolution. Everything a bill of materials names is already in mcpp.lock, so this is an output format rather than a mechanism: it resolves nothing and asks the network for nothing. ⚠️ It reads the lock rather than re-resolving, which is the one property such a document must have — an SBOM describing a different graph from the one that was built is worse than none. Asserted in e2e 333 by editing the lock and checking the output follows it. An unknown licence is emitted as NOASSERTION rather than omitted: an absent key reads as "not examined", and a reviewer cannot filter on silence. ## Two propagation sites, and the one that was missed first Dependency-supplied RunGlobal entries reach the root through a different path from a package's own directives. Wiring only `apply()` left `mcpp flash` reporting "no flash is configured" while `mcpp run` found the runner the same build program emitted three lines away — measured. Both sites now iterate the slot table instead of naming `runner`. Protocol version 6. 97/97 unit tests; freestanding e2e 130-139 and 332 green. --- modules/buildmcpp/src/directives.cppm | 80 ++++++- modules/buildmcpp/src/program_protocol.cppm | 7 +- modules/manifest/src/toml.cppm | 44 +++- modules/manifest/src/types.cppm | 32 +++ src/build/execute.cppm | 155 +++++++++++-- src/build/hostprogram.cppm | 18 ++ src/build/prepare.cppm | 114 +++++++++- src/cli.cppm | 79 ++++++- src/cli/cmd_build.cppm | 39 ++++ src/cli/cmd_sbom.cppm | 195 ++++++++++++++++ tests/e2e/333_device_slots_locked_and_sbom.sh | 213 ++++++++++++++++++ tests/unit/test_manifest.cpp | 3 +- 12 files changed, 937 insertions(+), 42 deletions(-) create mode 100644 src/cli/cmd_sbom.cppm create mode 100755 tests/e2e/333_device_slots_locked_and_sbom.sh diff --git a/modules/buildmcpp/src/directives.cppm b/modules/buildmcpp/src/directives.cppm index e61825f8..b1824f4a 100644 --- a/modules/buildmcpp/src/directives.cppm +++ b/modules/buildmcpp/src/directives.cppm @@ -76,6 +76,24 @@ enum class Slot : std::size_t { // is neither a compile input nor a link input, and putting it in LdFlags // would put an emulator's argv on the linker command line. Runner, + // ⭐⭐ THE THREE SIBLINGS OF `Runner`, AND THE COLUMN THAT SEPARATES THEM. + // + // Writing an artefact to a device, watching what it prints and attaching a + // debugger have `Runner`'s shape exactly: an argv the BOARD knows and a + // TOOL performs. They are slots for the same reason `Runner` is one — an + // emulator's argv is neither a compile input nor a link input. + // + // ⚠️ WHAT NO ARGV CAN SAY IS WHICH ONE ENDS. `Runner` and `Flash` finish + // and hand back an exit code; `Monitor` and `Debug` do not terminate on + // their own, so for them a live process IS the success condition and for + // the other two it is a hang. `semantics_of` below answers that from the + // SLOT, because the tokens cannot. + Flash, + Monitor, + Debug, + // Not an argv at all: a board stating that it is a mutex. See + // `BuildConfig::runnerExclusive`. + RunnerExclusive, CxxFlags, CFlags, LdFlags, @@ -104,6 +122,51 @@ enum class Slot : std::size_t { }; inline constexpr std::size_t kSlotCount = static_cast(Slot::Count); +// ⭐⭐ HOW A DEVICE ACTION'S PROCESS ENDS, WHICH IS A PROPERTY OF THE SLOT. +// +// The four device slots share an argv shape and differ in exactly one way that +// the engine has to act on: whether the process is expected to terminate. +// +// OneShot `run`, `flash` — runs to completion; the exit code is the verdict +// LongLived `monitor`, `debug` — has no natural end; the operator ends it, +// and a non-zero status after Ctrl-C is that, not a failure +// +// ⚠️ NO TOKEN IN THE TEMPLATE CARRIES THIS. `openocd -c "program {} verify +// reset exit"` terminates and `openocd -c "init"` does not, and both are +// spelled the same way up to the argument the board chose. So it is read from +// the slot, and a board cannot get it wrong by writing its argv differently. +// +// ⚠️ AND `debug` IS `LongLived` RATHER THAN A THIRD VALUE. It starts a GDB +// SERVER; the client that attaches to it is the user's debugger or their IDE, +// which reaches mcpp through the machine-output protocol (docs/11) and not +// through this table. Driving the client would put mcpp in the middle of a +// session it has nothing to add to. +enum class Semantics { OneShot, LongLived }; + +inline constexpr Semantics semantics_of(Slot s) { + return (s == Slot::Monitor || s == Slot::Debug) ? Semantics::LongLived + : Semantics::OneShot; +} + +// The device slots, in the order a user meets them. Iterated rather than +// hand-listed wherever all four must be handled, so a fifth cannot be added to +// one site and missed at another. +inline constexpr Slot kDeviceSlots[] = { Slot::Runner, Slot::Flash, + Slot::Monitor, Slot::Debug }; + +// The user-facing name of a device slot: the `mcpp ` subcommand, the +// `[target.].` key and the `mcpp:=` directive are all this +// one string, which is why it has a single read point. +inline constexpr std::string_view device_slot_name(Slot s) { + switch (s) { + case Slot::Runner: return "runner"; + case Slot::Flash: return "flash"; + case Slot::Monitor: return "monitor"; + case Slot::Debug: return "debug"; + default: return {}; + } +} + // Who sees the value. The field that must be answered for every new directive. enum class Scope { PackagePrivate, // only this package's own TUs — never propagated to consumers @@ -166,7 +229,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}, @@ -212,6 +275,10 @@ inline constexpr std::array kTable{{ // OWNER home — measured in CI as `xlings: '…' is not installed` from a job // where the same name had answered `--version` two steps earlier. {"runner", "runner", Slot::Runner, Scope::RunGlobal, Transform::Verbatim, false, "", "", 4}, + {"flash", "flash", Slot::Flash, Scope::RunGlobal, Transform::Verbatim, false, "", "", 6}, + {"monitor", "monitor", Slot::Monitor, Scope::RunGlobal, Transform::Verbatim, false, "", "", 6}, + {"debug", "debug", Slot::Debug, Scope::RunGlobal, Transform::Verbatim, false, "", "", 6}, + {"runner-exclusive", "runner-exclusive", Slot::RunnerExclusive, Scope::RunGlobal, Transform::Verbatim, false, "", "", 6}, {"link-script", "ldflag", Slot::LdFlags, Scope::LinkGlobal, Transform::LinkerScript, false, "", "", 3}, {"include-dir", "include-dir", Slot::IncludeDirs, Scope::PackagePrivate, Transform::AbsPath, false, "", "", 1}, {"include-dir-after", "include-dir-after", Slot::IncludeDirsAfter, Scope::PackagePrivate, Transform::AbsPath, false, "", "", 1}, @@ -668,6 +735,9 @@ void apply(mcpp::manifest::Manifest& m, const Directives& d) { auto const& c = d.at(Slot::CFlags); auto const& ld = d.at(Slot::LdFlags); auto const& runner = d.at(Slot::Runner); + auto const& flash = d.at(Slot::Flash); + auto const& monitor = d.at(Slot::Monitor); + auto const& debugTpl = d.at(Slot::Debug); auto const& defines = d.at(Slot::Defines); bc.cxxflags.insert(bc.cxxflags.end(), cxx.begin(), cxx.end()); @@ -675,6 +745,14 @@ void apply(mcpp::manifest::Manifest& m, const Directives& d) { bc.ldflags.insert(bc.ldflags.end(), ld.begin(), ld.end()); // Appended in emission order — the tokens ARE the argv. bc.runner.insert(bc.runner.end(), runner.begin(), runner.end()); + bc.flash.insert(bc.flash.end(), flash.begin(), flash.end()); + bc.monitor.insert(bc.monitor.end(), monitor.begin(), monitor.end()); + bc.debugger.insert(bc.debugger.end(), debugTpl.begin(), debugTpl.end()); + // ⚠️ ANY non-empty value sets it, and there is deliberately no way to unset + // it from a second package. Exclusivity is a claim about the DEVICE: if one + // package in the graph knows the target is a mutex, it is one, and a later + // package saying nothing must not relax that. + if (!d.at(Slot::RunnerExclusive).empty()) bc.runnerExclusive = true; // cfg defines colour BOTH language channels — the one slot that fans out. bc.cflags.insert(bc.cflags.end(), defines.begin(), defines.end()); bc.cxxflags.insert(bc.cxxflags.end(), defines.begin(), defines.end()); diff --git a/modules/buildmcpp/src/program_protocol.cppm b/modules/buildmcpp/src/program_protocol.cppm index f674f675..058eaaa9 100644 --- a/modules/buildmcpp/src/program_protocol.cppm +++ b/modules/buildmcpp/src/program_protocol.cppm @@ -51,7 +51,12 @@ export namespace mcpp::build::program_protocol { // COMPILE, because the bundled module that engine ships has no such function. // That is the same cost `link-script` carried into v3 and is stated here so // the next reader does not look for a protocol path that never runs. -inline constexpr int kProtocolVersion = 5; +// v6: adds `flash`, `monitor`, `debug` and `runner-exclusive` — `runner`'s +// three siblings and the claim that a device admits one user at a time. Same +// cost as v5's: a package calling `mcpp::flash()` fails on an older engine at +// the build.mcpp COMPILE, because that engine's bundled module has no such +// function, not through a protocol refusal. +inline constexpr int kProtocolVersion = 6; // ── Cache-format epoch ───────────────────────────────────────────────────── // diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index 465945a3..2ca04a94 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -2142,24 +2142,43 @@ std::expected parse_string(std::string_view content, // artifact cannot execute here. An ARRAY, so it is neither a // scalar (the unknown-key sweep below skips it by type) nor part // of the conditional sub-table channel. - if (auto it = body.find("runner"); it != body.end()) { + // + // ⭐ FOUR KEYS, ONE LOOP. `runner` was alone until `flash`, + // `monitor` and `debug` joined it, and they are the same key in + // every respect a parser can see: an array of strings, empty is an + // error, non-strings are an error. Writing the second one out by + // hand is how the third and fourth acquire slightly different + // diagnostics. + struct DeviceKey { std::string_view name; std::vector TargetEntry::*into; }; + static constexpr std::string_view kExample = + "[\"qemu-system-riscv64\", \"-kernel\"]"; + const DeviceKey kDeviceKeys[] = { + { "runner", &TargetEntry::runner }, + { "flash", &TargetEntry::flash }, + { "monitor", &TargetEntry::monitor }, + { "debug", &TargetEntry::debugger }, + }; + for (auto const& dk : kDeviceKeys) { + auto it = body.find(std::string(dk.name)); + if (it == body.end()) continue; if (!it->second.is_array()) { return std::unexpected(error(origin, std::format( - "[target.{}].runner must be an array of strings, " - "e.g. runner = [\"qemu-system-riscv64\", \"-kernel\"]", - triple))); + "[target.{}].{} must be an array of strings, e.g. {} = {}", + triple, dk.name, dk.name, kExample))); } + auto& dest = e.*(dk.into); for (auto& el : it->second.as_array()) { if (!el.is_string()) { return std::unexpected(error(origin, std::format( - "[target.{}].runner must contain only strings", triple))); + "[target.{}].{} must contain only strings", + triple, dk.name))); } - e.runner.push_back(el.as_string()); + dest.push_back(el.as_string()); } - if (e.runner.empty()) { + if (dest.empty()) { return std::unexpected(error(origin, std::format( - "[target.{}].runner is empty — an empty template would " - "run nothing and report success", triple))); + "[target.{}].{} is empty — an empty template would do " + "nothing and report success", triple, dk.name))); } } @@ -2195,7 +2214,9 @@ std::expected parse_string(std::string_view content, static constexpr std::string_view kKnownTargetScalars[] = { "cxx_runtime", "linkage", "sysroot", "toolchain", }; - static constexpr std::string_view kKnownTargetArrays[] = { "runner" }; + static constexpr std::string_view kKnownTargetArrays[] = { + "debug", "flash", "monitor", "runner", + }; for (auto& [key, value] : body) { if (value.is_table()) continue; // the conditional channel const std::span known = value.is_array() @@ -2204,7 +2225,8 @@ std::expected parse_string(std::string_view content, if (std::ranges::find(known, key) != known.end()) continue; m.schemaWarnings.push_back(std::format( "[target.{}] has unsupported key '{}' (ignored). Supported keys: " - "cxx_runtime, linkage, runner, sysroot, toolchain. " + "cxx_runtime, debug, flash, linkage, monitor, runner, sysroot, " + "toolchain. " "Per-role contracts go in [build].cxx_runtime's table form.", triple, key)); } diff --git a/modules/manifest/src/types.cppm b/modules/manifest/src/types.cppm index b3239060..d91db72c 100644 --- a/modules/manifest/src/types.cppm +++ b/modules/manifest/src/types.cppm @@ -456,6 +456,31 @@ struct BuildConfig : BuildInputs { // argv that is neither one's, and it would fail at exec time with no // indication of which package contributed which token. std::vector runner; + // ⭐⭐ THE THREE SIBLINGS OF `runner`, AND WHY THEY ARE SLOTS RATHER THAN + // COMMANDS. + // + // Executing an artifact, writing it to a device, watching what it prints + // and attaching a debugger are one shape: an argv that the BOARD knows and + // a TOOL performs, addressed by absolute path, with the artifact appended + // or substituted for `{}`. `runner` has carried that shape since 2026.8.19; + // three special-case commands would have carried it three more times. + // + // ⚠️ THEY ARE NOT INTERCHANGEABLE, AND THE DIFFERENCE IS THE PROCESS AND + // NOT THE ARGV. `run` and `flash` finish and report an exit code; `monitor` + // and `debug` do not end on their own, so "the process is still alive" is + // success for them and a hang for the other two. That is `Semantics`, which + // the engine reads from the slot rather than from the tokens — no argv can + // say which of the two it is. + std::vector flash; + std::vector monitor; + std::vector debugger; + // ⚠️ ONE BOARD IS A MUTEX, AND NOTHING ELSE IN THE BUILD IS. + // + // `mcpp test` runs test binaries on a pool of workers. An emulator takes + // N instances happily; a physical board takes one, and two probes reaching + // for the same device do not fail — they interleave. The board knows this + // about itself, so it says so, and a project never has to remember `-j1`. + bool runnerExclusive = false; // Was `sources` WRITTEN, as opposed to merely being empty? // @@ -819,6 +844,13 @@ struct TargetEntry { // engine a different board has to fight. The artifact path is appended, or // substituted for `{}` when the template contains it. std::vector runner; + // The project's override for each of `runner`'s siblings, on the same axis + // and with the same precedence: what the author of THIS project wrote beats + // what a dependency supplied, and the override is reported rather than + // applied in silence. + std::vector flash; + std::vector monitor; + std::vector debugger; // #336 — per-target C++ runtime contract, same vocabulary as // [build].cxx_runtime and overriding it for this triple. It lives HERE, // beside `linkage`, rather than in the `cfg(...)` conditional channel: diff --git a/src/build/execute.cppm b/src/build/execute.cppm index 3b31423c..04344f1a 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -17,6 +17,7 @@ import mcpp.build.plan; import mcpp.toolchain.triple; import mcpp.freestanding.runner; import mcpp.build.runner_lookup; // #544: where the runner's program is +import mcpp.build.directives; // the device-slot table: run / flash / monitor / debug import mcpp.freestanding.linkline; import mcpp.build.graph_shape; // #407: which mode wrote this build.ninja import mcpp.build.backend; @@ -470,8 +471,46 @@ struct RunnerChoice { std::string tripleKey; }; -RunnerChoice choose_runner(const BuildContext& ctx, bool noRunner = false) { +// ⭐⭐ ONE READER FOR FOUR SLOTS, PARAMETERISED BY THE SLOT. +// +// `run`, `flash`, `monitor` and `debug` resolve identically: a dependency +// supplies a template, the project may override it on the same axis, the +// override is reported, and the canonical triple spelling is the lookup key. +// Every one of those four facts was learned the hard way for `runner` alone +// (#544, and the macOS `arm64-apple-darwin24.6.0` mismatch measured on CI). +// Copying the function three times would copy the four facts three times and +// let them drift apart — the shape this file's own header warns about. +// +// `which` selects the slot; everything else is shared. +struct DeviceSlotAccess { + const std::vector mcpp::manifest::BuildConfig::*fromGraph; + const std::vector mcpp::manifest::TargetEntry::*fromProject; +}; + +inline DeviceSlotAccess device_slot_access(mcpp::build::directives::Slot which) { + using BC = mcpp::manifest::BuildConfig; + using TE = mcpp::manifest::TargetEntry; + switch (which) { + case mcpp::build::directives::Slot::Flash: + return { &BC::flash, &TE::flash }; + case mcpp::build::directives::Slot::Monitor: + return { &BC::monitor, &TE::monitor }; + case mcpp::build::directives::Slot::Debug: + return { &BC::debugger, &TE::debugger }; + default: + return { &BC::runner, &TE::runner }; + } +} + +// The run slot, which is what every existing caller means. Named separately so +// the call sites that predate the other three read as they always did. +RunnerChoice choose_runner(const BuildContext& ctx, bool noRunner = false); + +RunnerChoice choose_device_action(const BuildContext& ctx, + mcpp::build::directives::Slot which, + bool noRunner = false) { RunnerChoice c; + const auto acc = device_slot_access(which); const auto ft = mcpp::toolchain::triple::parse(ctx.tc.targetTriple); if (ft) c.freestanding = ft->is_freestanding(); c.tripleKey = ft ? ft->str() : ctx.tc.targetTriple; @@ -481,7 +520,7 @@ RunnerChoice choose_runner(const BuildContext& ctx, bool noRunner = false) { // absolute path); the manifest key exists for swapping `-bios default` // for `-bios none -semihosting` while debugging, and — on a hosted cross // triple — for naming the user-mode emulator at all. - c.tmpl = ctx.manifest.buildConfig.runner; + c.tmpl = ctx.manifest.buildConfig.*(acc.fromGraph); // The manifest key is the CANONICAL spelling — `aarch64-macos`, the name // of the output directory and the key every other `[target.]` // reader uses (prepare.cppm resolves overrides by `t.str()`). The @@ -492,15 +531,16 @@ RunnerChoice choose_runner(const BuildContext& ctx, bool noRunner = false) { // kept as a fallback for a triple the parser does not know. auto lookup = [&](std::string_view key) { auto it = ctx.manifest.targetOverrides.find(std::string(key)); - return it != ctx.manifest.targetOverrides.end() && !it->second.runner.empty() + return it != ctx.manifest.targetOverrides.end() + && !(it->second.*(acc.fromProject)).empty() ? &it->second : nullptr; }; const mcpp::manifest::TargetEntry* entry = lookup(c.tripleKey); if (!entry && c.tripleKey != ctx.tc.targetTriple) entry = lookup(ctx.tc.targetTriple); if (entry) { - c.tmpl = entry->runner; - c.fromManifest = !ctx.manifest.buildConfig.runner.empty(); + c.tmpl = entry->*(acc.fromProject); + c.fromManifest = !(ctx.manifest.buildConfig.*(acc.fromGraph)).empty(); } // `--no-runner` is the operator on THIS host stating a host fact the // manifest cannot carry: the triple is native here. On a freestanding @@ -510,6 +550,10 @@ RunnerChoice choose_runner(const BuildContext& ctx, bool noRunner = false) { return c; } +RunnerChoice choose_runner(const BuildContext& ctx, bool noRunner) { + return choose_device_action(ctx, mcpp::build::directives::Slot::Runner, noRunner); +} + // The capacity number, printed because capacity is the constraint. // // After `Finished`, not instead of it: the build succeeded either way, and a @@ -979,6 +1023,20 @@ export std::optional try_fast_build(const std::filesystem::path& projectRoo std::string_view currentTarget = "") { if (no_cache) return std::nullopt; + // ⚠️⚠️ `--locked` MUST NOT MEET THE FAST PATH, OR IT ASSERTS NOTHING. + // + // The check it names lives at the resolution write point, and the fast path + // exists precisely to skip resolution. Measured before this line existed: a + // deliberately corrupted mcpp.lock passed `mcpp build --locked` and printed + // "Finished" — the flag was accepted, the build was correct, and the + // assertion never ran. A criterion that is skipped is worse than one that + // is absent, because the green is read as a verification. + // + // 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; + auto want = fast_path_identity(projectRoot); if (!want) return std::nullopt; @@ -1097,6 +1155,10 @@ export std::optional try_fast_build(const std::filesystem::path& projectRoo std::optional try_fast_run(const std::filesystem::path& projectRoot, const std::optional& targetName, std::span passthrough) { + // 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; auto want = fast_path_identity(projectRoot); if (!want) return std::nullopt; @@ -1279,7 +1341,9 @@ export int build_run_target(const std::optional& targetName, const std::string& cache_mode = {}, bool no_cache = false, const std::string& target_triple = {}, - bool no_runner = false) { + bool no_runner = false, + mcpp::build::directives::Slot device_slot + = mcpp::build::directives::Slot::Runner) { // mcpp#225 (E2): reuse the resolved build cache when it's still fresh, // skipping prepare_build's toolchain resolution + modgraph scan // entirely — mirrors cmd_build's try_fast_build fast path. The cached @@ -1295,7 +1359,18 @@ export int build_run_target(const std::optional& targetName, // so with the flag there is nothing for it to ignore — and it has no // manifest to print the note against. if (package_filter.empty() && cache_mode.empty() && !no_cache - && target_triple.empty() && !no_runner) { + && target_triple.empty() && !no_runner + // ⚠️⚠️ THE FAST PATH IS `run`'s, AND ONLY `run`'s. + // + // It exec's the cached artefact directly — that IS its definition — so + // for `flash`, `monitor` or `debug` it would run the program on the + // BUILD HOST and report success, having done none of what was asked. + // Measured while writing e2e 333: `mcpp flash` printed + // "Running target/…/bin/p". + // + // The guard is the slot rather than a flag, because the property that + // makes the fast path wrong here is what the slot means. + && device_slot == mcpp::build::directives::Slot::Runner) { if (auto root = mcpp::project::find_manifest_root(std::filesystem::current_path())) { if (auto rc = try_fast_run(*root, targetName, passthrough)) { return *rc; @@ -1366,15 +1441,40 @@ export int build_run_target(const std::optional& targetName, // absolute path that a static manifest cannot. The explicit key exists for // the other case: swapping `-bios default` for `-bios none -semihosting` // while debugging, or naming `qemu-aarch64-static` for a cross target. - const auto choice = choose_runner(*ctx, no_runner); + namespace dirs = mcpp::build::directives; + const auto slotName = dirs::device_slot_name(device_slot); + const bool isRunSlot = (device_slot == dirs::Slot::Runner); + const auto choice = choose_device_action(*ctx, device_slot, no_runner); if (choice.ignored) mcpp::ui::info("note", std::format( "--no-runner: ignoring the runner declared for {}", choice.tripleKey)); if (choice.fromManifest) mcpp::ui::info("note", std::format( - "[target.{}].runner overrides the runner a dependency supplied", - choice.tripleKey)); - if (choice.freestanding && choice.tmpl.empty()) { + "[target.{}].{} overrides the {} a dependency supplied", + choice.tripleKey, slotName, slotName)); + // ⚠️ THE THREE NEW SLOTS HAVE NO FALLBACK, AND `run` STILL DOES. + // + // An artefact with no runner on a hosted target is executed directly, and + // that is right: the host can run it. There is no such reading of "no + // flasher" — nothing else writes an image to a device — so an empty + // template is an error for those three on EVERY target, not only a + // freestanding one. Saying "nothing is configured" beats doing something + // that was never asked for. + if (!isRunSlot && choice.tmpl.empty()) { + std::println(stderr, + "error: no {} is configured for '{}'.\n" + " Declare how to {} this target's artefact:\n" + "\n" + " [target.{}]\n" + " {} = [\"\", \"\", \"{{}}\"]\n" + "\n" + " The artefact path is appended, or substituted for `{{}}` when\n" + " the template contains it. A board-support package normally\n" + " supplies this, so that a project does not have to.", + slotName, choice.tripleKey, slotName, choice.tripleKey, slotName); + return 2; + } + if (isRunSlot && choice.freestanding && choice.tmpl.empty()) { std::println(stderr, "error: {}", mcpp::freestanding::no_runner_message(choice.tripleKey)); return 2; @@ -1400,9 +1500,13 @@ export int build_run_target(const std::optional& targetName, tmpl.front() = found.program->string(); argv = mcpp::freestanding::expand(tmpl, exe); for (auto& a : passthrough) argv.push_back(a); - mcpp::ui::status("Running", std::format( - "`{} … {}`", choice.tmpl.front(), - mcpp::ui::shorten_path(exe, pathCtx))); + mcpp::ui::status( + isRunSlot ? "Running" + : (device_slot == dirs::Slot::Flash ? "Flashing" + : device_slot == dirs::Slot::Monitor ? "Monitoring" + : "Debugging"), + std::format("`{} … {}`", choice.tmpl.front(), + mcpp::ui::shorten_path(exe, pathCtx))); } else { argv.push_back(exe.string()); for (auto& a : passthrough) argv.push_back(a); @@ -1901,8 +2005,29 @@ export int run_tests(std::span passthrough, const bool capture = json || list.size() > 1; const auto deadline = std::chrono::milliseconds( static_cast(testOpts.timeoutSecs) * 1000); + // ⚠️⚠️ A PHYSICAL BOARD IS A MUTEX, AND NOTHING ELSE THIS POOL HAS EVER + // SCHEDULED WAS ONE. + // + // An emulator takes N concurrent instances; a probe attached to one + // board does not. Two `probe-rs` processes reaching for the same device + // do not fail cleanly — they interleave, and the verdict they produce is + // about neither test. Nothing in the argv says which case this is, so + // the BOARD says it, once, with `mcpp:runner-exclusive=1`, and the + // project never has to remember `-j1`. + // + // Clamped rather than made an error: an exclusive target with one test + // is an ordinary run, and refusing it would turn a correct + // configuration into a failure. + const bool exclusiveDevice = ctx->manifest.buildConfig.runnerExclusive + && !runnerChoice.tmpl.empty(); + const int runJobsHere = exclusiveDevice ? 1 : runJobs; + if (exclusiveDevice && runJobs > 1 && list.size() > 1) { + mcpp::ui::info("note", std::format( + "the target declares an exclusive device, so {} tests run one at " + "a time", list.size())); + } const int workers = capture - ? std::min(runJobs, static_cast(list.size())) : 1; + ? std::min(runJobsHere, static_cast(list.size())) : 1; auto tRunPhase = std::chrono::steady_clock::now(); std::atomic next{0}; diff --git a/src/build/hostprogram.cppm b/src/build/hostprogram.cppm index 6e7d4dee..e449e92d 100644 --- a/src/build/hostprogram.cppm +++ b/src/build/hostprogram.cppm @@ -68,6 +68,24 @@ inline void include_dir_after(const char* dir) { std::printf("mcpp:include-di // claiming to know how to run the artifact is a configuration error, and mcpp // reports it naming both rather than merging them. inline void runner(const char* token) { std::printf("mcpp:runner=%s\n", token); } +// ⭐ `runner`'s three siblings. Same shape, one token per call, because argv is +// ordered and a single string cannot say where the boundaries are. +// +// flash write the artefact to the device (ends; exit code is the verdict) +// monitor watch what the device prints (runs until the operator ends it) +// debug start the device's debug SERVER (runs until the operator ends it) +// +// A board that serves both an emulator and real silicon emits different argv +// under `mcpp::has_feature(...)`; the engine reads the slots and knows nothing +// about which environment was chosen. +inline void flash(const char* token) { std::printf("mcpp:flash=%s\n", token); } +inline void monitor(const char* token) { std::printf("mcpp:monitor=%s\n", token); } +inline void debug(const char* token) { std::printf("mcpp:debug=%s\n", token); } +// ⚠️ THE DEVICE IS A MUTEX. Declared by the BOARD, because the board is what +// knows whether "this target" is one piece of silicon on one probe or an +// emulator that takes as many instances as there are cores. `mcpp test` clamps +// its worker pool when this is set, so a project never has to remember `-j1`. +inline void runner_exclusive() { std::printf("mcpp:runner-exclusive=1\n"); } // Say something to the user and keep going. // diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 2b3bbdec..d4d91fa4 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -4552,6 +4552,11 @@ prepare_build(bool print_fingerprint, // Which dependency supplied the runner, for the exactly-one-provider // error below. A name rather than a bool: the message has to name both. std::string runnerProvider; + // ⚠️ ONE PROVIDER PER DEVICE SLOT, TRACKED PER SLOT. `runner` has had this + // rule since #544; `flash`, `monitor` and `debug` inherit it, and each + // needs its OWN provider name — a board may legitimately supply a runner + // while a different package supplies the debug server. + std::string flashProvider, monitorProvider, debuggerProvider; auto fillXpkgDirs = [&](mcpp::build::BuildProgramEnv& e, const mcpp::manifest::Manifest& owner) { @@ -7225,6 +7230,10 @@ prepare_build(bool print_fingerprint, const auto ldN = bcDep.ldflags.size(); const auto actN = bcDep.actions.size(); const auto runnerN = bcDep.runner.size(); + const auto flashN = bcDep.flash.size(); + const auto monitorN = bcDep.monitor.size(); + const auto debuggerN = bcDep.debugger.size(); + const bool exclusiveBefore = bcDep.runnerExclusive; if (auto r = mcpp::build::run_build_program( pkg.manifest, pkg.root, host->first, host->second, pkg.manifest.cppStandard, bpEnv); @@ -7253,23 +7262,58 @@ prepare_build(bool print_fingerprint, // with nothing to say which package contributed which token. So // the second provider is a hard error that names BOTH, because // naming only the loser tells the reader half of what they need. - if (bcDep.runner.size() > runnerN) { + // ⭐⭐ FOUR SLOTS, ONE RULE, APPLIED BY A LOOP. + // + // This block existed for `runner` alone and stated the rule that + // matters: link flags from two dependencies concatenate and that is + // correct, but two runners cannot — appending produces an argv that + // is neither one's. Its three siblings have exactly the same + // property, so they are handled here rather than copied below it. + // + // ⚠️ MISSING THIS SITE IS HOW THE FEATURE FAILED FIRST. `apply()` + // in the directives module merges a package's own directives into + // its own config; THIS is where a dependency's RunGlobal entries + // reach the ROOT. Wiring only the first left `mcpp flash` reporting + // "no flash is configured" while `mcpp run` found the runner the + // same package supplied in the same build program — measured. + struct SlotForward { + std::string_view name; + std::vector mcpp::manifest::BuildConfig::* member; + std::size_t before; + std::string* provider; + }; + const SlotForward forwards[] = { + { "runner", &mcpp::manifest::BuildConfig::runner, runnerN, &runnerProvider }, + { "flash", &mcpp::manifest::BuildConfig::flash, flashN, &flashProvider }, + { "monitor", &mcpp::manifest::BuildConfig::monitor, monitorN, &monitorProvider }, + { "debug", &mcpp::manifest::BuildConfig::debugger, debuggerN, &debuggerProvider }, + }; + for (auto const& f : forwards) { + auto& depVec = bcDep.*(f.member); + if (depVec.size() <= f.before) continue; std::vector supplied( - bcDep.runner.begin() + static_cast(runnerN), - bcDep.runner.end()); - if (!m->buildConfig.runner.empty() && !runnerProvider.empty()) { + depVec.begin() + static_cast(f.before), + depVec.end()); + auto& rootVec = m->buildConfig.*(f.member); + if (!rootVec.empty() && !f.provider->empty()) { return std::unexpected(std::format( - "two dependencies both supply a runner for this target: " + "two dependencies both supply a {} for this target: " "'{}' and '{}'.\n" - " A runner is how the artifact is EXECUTED — there " - "can only be one.\n" + " A {} is how the artifact is reached — there can " + "only be one.\n" " Drop one of them, or override both with an " - "explicit [target.].runner.", - runnerProvider, pkg.manifest.package.name)); + "explicit [target.].{}.", + f.name, *f.provider, pkg.manifest.package.name, + f.name, f.name)); } - m->buildConfig.runner = std::move(supplied); - runnerProvider = pkg.manifest.package.name; - } + rootVec = std::move(supplied); + *f.provider = pkg.manifest.package.name; + } + // ⚠️ A CLAIM THAT ONLY EVER TIGHTENS. If any package in the graph + // knows the device is a mutex, it is one; a later package that says + // nothing must not relax it. + if (bcDep.runnerExclusive && !exclusiveBefore) + m->buildConfig.runnerExclusive = true; m->buildConfig.ldflags.insert(m->buildConfig.ldflags.end(), bcDep.ldflags.begin() + ldN, bcDep.ldflags.end()); } @@ -9862,6 +9906,52 @@ prepare_build(bool print_fingerprint, } if (!lock.packages.empty() || !lock.indices.empty()) { auto lockPath = workRoot / "mcpp.lock"; + // ⭐⭐ `--locked` ASSERTS THAT THIS RESOLUTION IS THE RECORDED ONE. + // + // The file has always been written after the walk and never read + // back as a constraint; its own header says so ("does not yet pin + // future builds"). Making it an input to resolution is a change to + // the resolver. Making it an ASSERTION is not, and it is the half + // that reproducibility actually needs: a release build, a CI job or + // an audit can demand that what resolved today is what was recorded, + // and find out when it is not. + // + // ⚠️ THE FAILURE NAMES THE DIFFERENCE. "The lock is out of date" is + // true and useless; which package moved, from which version to + // which, is what the reader does something about. + if (mcpp::platform::env::get("MCPP_LOCKED").value_or("") == "1") { + auto prior = mcpp::lockfile::load(lockPath); + if (!prior) { + return std::unexpected(std::format( + "--locked was given and there is no readable mcpp.lock at {}\n" + " Run the same command without --locked once to record " + "this resolution, then commit mcpp.lock.", + lockPath.string())); + } + auto key = [](const mcpp::lockfile::LockedPackage& p) { + return p.namespace_.empty() ? p.name + : p.namespace_ + "." + p.name; + }; + std::map was, now; + for (auto const& p : prior->packages) was[key(p)] = p.version; + for (auto const& p : lock.packages) now[key(p)] = p.version; + std::vector drift; + for (auto const& [k, v] : now) { + auto it = was.find(k); + if (it == was.end()) drift.push_back(k + " " + v + " (not in the lock)"); + else if (it->second != v) drift.push_back(k + " " + it->second + " -> " + v); + } + for (auto const& [k, v] : was) + if (!now.contains(k)) drift.push_back(k + " " + v + " (no longer resolved)"); + if (!drift.empty()) { + std::string msg = "--locked was given and this resolution " + "differs from mcpp.lock:"; + for (auto const& d : drift) msg += "\n " + d; + msg += "\n Re-run without --locked to update the lock, " + "or pin the dependency that moved."; + return std::unexpected(msg); + } + } (void)mcpp::lockfile::write(lock, lockPath); } diff --git a/src/cli.cppm b/src/cli.cppm index 06380217..63ad68fc 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -29,6 +29,7 @@ import mcpp.cli.cmd_toolchain; import mcpp.pm.commands; import mcpp.toolchain.fingerprint; // MCPP_VERSION import mcpp.wire; +import mcpp.cli.cmd_sbom; import mcpp.platform.env; // --offline → MCPP_OFFLINE import mcpp.platform.process; // __action-stamp runs the checked command import mcpp.platform.runtime_search; // linker-wrapper path-injection opt-out @@ -92,6 +93,7 @@ void print_usage() { std::println(" --no-cache Deprecated alias for --cache=off (clears the build dir)"); std::println(" --no-color Disable colored output"); std::println(" --offline Never touch the network (also: MCPP_OFFLINE=1)"); + std::println(" --locked Fail if resolution differs from mcpp.lock (also: --frozen, MCPP_LOCKED=1)"); std::println(" --jobs N|auto, -j Concurrent compiles ('auto' = cores + free RAM)"); std::println(" --toolchain SPEC Use this toolchain for one build (e.g. llvm@22.1.8)"); std::println(""); @@ -167,6 +169,18 @@ int run(int argc, char** argv) { // need a parameter threaded down. Same shape as MCPP_VERBOSE above, and // it makes `MCPP_OFFLINE=1` and `--offline` literally the same switch. else if (a == "--offline") mcpp::platform::env::set("MCPP_OFFLINE", "1"); + // ⭐ `--locked` rides the same side channel, and for the stronger form + // of the same reason: its consumer is the resolution write point deep + // in mcpp.build.prepare, and it applies to every command that resolves + // — build, run, test, flash, monitor, debug — so a per-subcommand + // option would have to be declared six times and threaded six times. + // + // It asserts rather than pins: the resolution that happens must equal + // the one mcpp.lock records, and a difference is reported naming the + // package that moved. See the check itself for why assertion is the + // half that reproducibility needs first. + else if (a == "--locked" || a == "--frozen") + mcpp::platform::env::set("MCPP_LOCKED", "1"); // --jobs rides the same side channel as --offline, for the same reason // recorded there: its consumer is deep in mcpp.build.execute and // threading a parameter down would touch every caller in between. @@ -309,6 +323,16 @@ int run(int argc, char** argv) { .option(cl::Option("offline") .help("Never touch the network (index refresh, downloads, toolchain install)") .global()) + // Declared here as well as read in the pre-pass: the pre-pass sets the + // env var, and this makes the parser accept the token instead of + // rejecting it as unknown. Both halves are needed, which is exactly the + // arrangement `--offline` above already has. + .option(cl::Option("locked") + .help("Fail if dependency resolution differs from mcpp.lock") + .global()) + .option(cl::Option("frozen") + .help("Alias for --locked") + .global()) // Answers "what do you speak" without spawning a command that might // fail. An optimisation, NOT the client's detection rule: on any mcpp // predating it this is itself an unknown option, so a client must @@ -396,6 +420,58 @@ int run(int argc, char** argv) { .action(wrap_rc([&passthrough](const cl::ParsedArgs& p) { return cmd_run(p, std::span(passthrough)); }))) + // ⭐ `run`'s three siblings, declared from the same shape. Each builds + // the project, resolves one device slot and performs the argv the board + // supplied. `monitor` and `debug` do not terminate on their own — the + // operator ends them — which the engine reads from the slot rather than + // from the tokens (mcpp.build.directives::semantics_of). + .subcommand(cl::App("flash") + .description("Build + write the artifact to a device (board supplies the argv)") + .arg(cl::Arg("bin").help("Binary name (optional)")) + .option(cl::Option("target").takes_value().value_name("TRIPLE") + .help("Cross target triple (same axis as `mcpp build --target`)")) + .option(cl::Option("package").short_name('p').takes_value().value_name("NAME") + .help("Only the named workspace member")) + .option(cl::Option("cache").takes_value().value_name("MODE") + .help("Global dependency cache: global (default) | local | off")) + .action(wrap_rc([&passthrough](const cl::ParsedArgs& p) { + return cmd_flash(p, std::span(passthrough)); + }))) + .subcommand(cl::App("monitor") + .description("Attach to the device's console (runs until you end it)") + .arg(cl::Arg("bin").help("Binary name (optional)")) + .option(cl::Option("target").takes_value().value_name("TRIPLE") + .help("Cross target triple (same axis as `mcpp build --target`)")) + .option(cl::Option("package").short_name('p').takes_value().value_name("NAME") + .help("Only the named workspace member")) + .option(cl::Option("cache").takes_value().value_name("MODE") + .help("Global dependency cache: global (default) | local | off")) + .action(wrap_rc([&passthrough](const cl::ParsedArgs& p) { + return cmd_monitor(p, std::span(passthrough)); + }))) + .subcommand(cl::App("debug") + .description("Start the device's debug server (runs until you end it; attach your own client)") + .arg(cl::Arg("bin").help("Binary name (optional)")) + .option(cl::Option("target").takes_value().value_name("TRIPLE") + .help("Cross target triple (same axis as `mcpp build --target`)")) + .option(cl::Option("package").short_name('p').takes_value().value_name("NAME") + .help("Only the named workspace member")) + .option(cl::Option("cache").takes_value().value_name("MODE") + .help("Global dependency cache: global (default) | local | off")) + .action(wrap_rc([&passthrough](const cl::ParsedArgs& p) { + return cmd_debug(p, std::span(passthrough)); + }))) + // ⭐ The dependency graph in the shape a procurement or security review + // asks for. Reads mcpp.lock rather than resolving: an SBOM that + // described a different graph from the one that was built would be + // worse than none. + .subcommand(cl::App("sbom") + .description("Write a CycloneDX bill of materials for the recorded resolution") + .option(cl::Option("output").short_name('o').takes_value().value_name("FILE") + .help("Write to FILE instead of stdout")) + .action(wrap_rc([](const cl::ParsedArgs& p) { + return mcpp::cli::cmd_sbom(p); + }))) .subcommand(cl::App("test") .description("Build + run all tests/**/*.cpp (after `--`, args go to each test binary)") .arg(cl::Arg("pattern") @@ -930,7 +1006,8 @@ int run(int argc, char** argv) { // command" into "add a command AND remember to bump a number", // and the compiler only catches the direction that overflows. static constexpr std::array known = std::to_array({ - "new", "build", "run", "test", "clean", "add", "remove", + "new", "build", "run", "flash", "monitor", "debug", "sbom", + "test", "clean", "add", "remove", "update", "search", "publish", "pack", "emit", "xpkg", "toolchain", "cache", "index", "self", "explain", "version", "dyndep", "why", "resolve", "stage", "bmi-equal", "coff-def", diff --git a/src/cli/cmd_build.cppm b/src/cli/cmd_build.cppm index 196383ee..e45ffdae 100644 --- a/src/cli/cmd_build.cppm +++ b/src/cli/cmd_build.cppm @@ -12,6 +12,7 @@ import std; import mcpplibs.cmdline; import mcpp.build.prepare; import mcpp.build.execute; +import mcpp.build.directives; // the device-slot table import mcpp.build.configure; import mcpp.build.coff_exports; import mcpp.build.stage; @@ -192,6 +193,44 @@ export int cmd_build(const mcpplibs::cmdline::ParsedArgs& parsed) { return run_build_with_hooks(*ctx, verbose, no_cache, ov.target_triple); } +// ⭐ `run`, `flash`, `monitor` and `debug` differ by one argument. +// +// Each builds the project, resolves one device slot and performs the argv it +// finds. Writing four functions would write the flag parsing four times, and +// the flags are not the interesting part — the slot is. +int device_action_command(const mcpplibs::cmdline::ParsedArgs& parsed, + std::span passthrough, + mcpp::build::directives::Slot slot) { + std::optional targetName; + if (parsed.positional_count() > 0) targetName = parsed.positional(0); + std::string package_filter; + if (auto p = parsed.value("package")) package_filter = *p; + std::string cache_mode; + bool no_cache = parsed.is_flag_set("no-cache"); + if (auto c = parsed.value("cache")) cache_mode = *c; + else if (no_cache) cache_mode = "off"; + std::string target_triple; + if (auto tt = parsed.value("target")) target_triple = *tt; + if (auto tt = parsed.value("target-triple")) target_triple = *tt; + const bool no_runner = parsed.is_flag_set("no-runner"); + return mcpp::build::build_run_target(targetName, passthrough, package_filter, + cache_mode, no_cache, target_triple, + no_runner, slot); +} + +export int cmd_flash(const mcpplibs::cmdline::ParsedArgs& p, + std::span extra) { + return device_action_command(p, extra, mcpp::build::directives::Slot::Flash); +} +export int cmd_monitor(const mcpplibs::cmdline::ParsedArgs& p, + std::span extra) { + return device_action_command(p, extra, mcpp::build::directives::Slot::Monitor); +} +export int cmd_debug(const mcpplibs::cmdline::ParsedArgs& p, + std::span extra) { + return device_action_command(p, extra, mcpp::build::directives::Slot::Debug); +} + export int cmd_run(const mcpplibs::cmdline::ParsedArgs& parsed, std::span passthrough) { // The action lambda has already split argv at the first "--" and diff --git a/src/cli/cmd_sbom.cppm b/src/cli/cmd_sbom.cppm new file mode 100644 index 00000000..b24efb05 --- /dev/null +++ b/src/cli/cmd_sbom.cppm @@ -0,0 +1,195 @@ +// mcpp.cli.cmd_sbom — the dependency graph, in the format a procurement +// process asks for. +// +// ⭐⭐ THIS IS AN OUTPUT FORMAT, NOT A NEW MECHANISM, AND THAT IS THE WHOLE +// REASON IT IS CHEAP. +// +// Everything a software bill of materials names — which components went into +// this artefact, at which versions, from which source, with which integrity +// value — is already recorded in `mcpp.lock`, because that file exists to +// record exactly what a build resolved. `mcpp sbom` reads it and writes it out +// in a shape a legal or security review can consume. It resolves nothing, +// builds nothing and asks the network for nothing. +// +// ⚠️ AND IT READS THE LOCK RATHER THAN RE-RESOLVING, WHICH IS THE POINT. An +// SBOM produced by resolving again would describe a graph that may differ from +// the one that was built — which is the single thing an SBOM must never do. +// If the lock is stale, `--locked` is the tool that says so; this command +// reports what was recorded and says when nothing was. +// +// CycloneDX 1.5 rather than SPDX: it is JSON, its `components` array maps onto +// the lock's package list without inventing structure, and it is what the +// scanners in this space ingest. SPDX can be generated from the same data if a +// consumer needs it; nothing here is format-specific except `render`. + +export module mcpp.cli.cmd_sbom; + +import std; +import mcpp.lockfile; +import mcpp.version; +import mcpp.manifest; +import mcpp.project; +import mcpp.ui; +import mcpplibs.cmdline; + +namespace { + +// JSON string escaping, kept local: the values here are package names, +// versions and URLs, and pulling a JSON library in for six characters would be +// a dependency this module does not otherwise need. +std::string esc(std::string_view s) { + std::string o; + o.reserve(s.size() + 8); + for (char c : s) { + switch (c) { + case '"': o += "\\\""; break; + case '\\': o += "\\\\"; break; + case '\n': o += "\\n"; break; + case '\r': o += "\\r"; break; + case '\t': o += "\\t"; break; + default: + if (static_cast(c) < 0x20) + o += std::format("\\u{:04x}", static_cast(c)); + else o += c; + } + } + return o; +} + +// A package URL for an mcpp package. `pkg:` URLs are how every SBOM consumer +// correlates a component with an advisory feed, so emitting one is what makes +// the document useful rather than merely well-formed. +std::string purl(const mcpp::lockfile::LockedPackage& p) { + const std::string ns = p.namespace_.empty() ? std::string("mcpp") + : p.namespace_; + return std::format("pkg:mcpp/{}/{}@{}", ns, p.name, p.version); +} + +// ⚠️ AN UNKNOWN LICENCE IS REPORTED AS UNKNOWN, NEVER OMITTED AND NEVER +// GUESSED. A component with no `licenses` key reads as "not examined"; one +// carrying a wrong identifier reads as examined and is worse than silence. The +// lock records no licence — it records resolution — so unless the package's own +// manifest is present locally, this is genuinely not known here, and the +// document says so in a field a reviewer can filter on. +std::string licence_block(std::string_view spdx) { + if (spdx.empty()) + return R"( "licenses": [ { "license": { "name": "NOASSERTION" } } ],)"; + return std::format( + " \"licenses\": [ {{ \"license\": {{ \"id\": \"{}\" }} }} ],", + esc(spdx)); +} + +} // namespace + +export namespace mcpp::cli { + +int cmd_sbom(const mcpplibs::cmdline::ParsedArgs& parsed) { + auto root = mcpp::project::find_manifest_root(std::filesystem::current_path()); + if (!root) { + mcpp::ui::error( + "`mcpp sbom` must run inside a package (no mcpp.toml found)"); + return 2; + } + + auto man = mcpp::manifest::load(*root / "mcpp.toml"); + if (!man) { + mcpp::ui::error(man.error().message); + return 2; + } + + const auto lockPath = *root / "mcpp.lock"; + std::vector pkgs; + if (auto lock = mcpp::lockfile::load(lockPath)) { + pkgs = lock->packages; + } else if (std::filesystem::exists(lockPath)) { + mcpp::ui::error(std::format( + "mcpp.lock exists but could not be read: {}", lock.error().message)); + return 2; + } else { + // ⚠️ NOT AN ERROR, AND NOT SILENCE EITHER. A project with no + // dependencies has no lock and its bill of materials is one component, + // which is a true answer. A project that has never been built also has + // no lock, and that answer would be false. The note distinguishes them + // for the reader, who is the only one who knows which case they are in. + mcpp::ui::info("note", + "no mcpp.lock: reporting the root package only. If this project has " + "dependencies, build it once so the resolution is recorded."); + } + + const auto& p = man->package; + const std::string rootNs = p.namespace_.empty() ? std::string("mcpp") + : p.namespace_; + + std::string out; + out += "{\n"; + out += " \"bomFormat\": \"CycloneDX\",\n"; + out += " \"specVersion\": \"1.5\",\n"; + out += " \"version\": 1,\n"; + out += " \"metadata\": {\n"; + out += " \"tools\": [ { \"name\": \"mcpp\", \"version\": \"" + + esc(std::string(mcpp::MCPP_VERSION)) + "\" } ],\n"; + out += " \"component\": {\n"; + out += " \"type\": \"application\",\n"; + out += " \"bom-ref\": \"" + esc(std::format("pkg:mcpp/{}/{}@{}", + rootNs, p.name, p.version)) + "\",\n"; + out += " \"name\": \"" + esc(p.name) + "\",\n"; + out += " \"version\": \"" + esc(p.version) + "\",\n"; + out += licence_block(p.license) + "\n"; + out += " \"purl\": \"" + esc(std::format("pkg:mcpp/{}/{}@{}", + rootNs, p.name, p.version)) + "\"\n"; + out += " }\n"; + out += " },\n"; + out += " \"components\": ["; + + bool first = true; + for (auto const& d : pkgs) { + out += first ? "\n" : ",\n"; + first = false; + out += " {\n"; + out += " \"type\": \"library\",\n"; + out += " \"bom-ref\": \"" + esc(purl(d)) + "\",\n"; + out += " \"name\": \"" + esc(d.name) + "\",\n"; + out += " \"version\": \"" + esc(d.version) + "\",\n"; + if (!d.namespace_.empty()) + out += " \"group\": \"" + esc(d.namespace_) + "\",\n"; + out += licence_block({}) + "\n"; + // The integrity value the lock recorded. `fnv1a:` entries are mcpp's + // own resolution digest rather than a content hash, so they are emitted + // as a property rather than as a `hashes` entry — claiming a weak + // digest is a cryptographic hash is the kind of statement an SBOM is + // read to trust. + if (d.hash.starts_with("sha256:")) { + out += " \"hashes\": [ { \"alg\": \"SHA-256\", \"content\": \"" + + esc(d.hash.substr(7)) + "\" } ],\n"; + } else if (!d.hash.empty()) { + out += " \"properties\": [ { \"name\": \"mcpp:resolution-digest\"," + " \"value\": \"" + esc(d.hash) + "\" } ],\n"; + } + if (!d.source.empty()) + out += " \"externalReferences\": [ { \"type\": \"distribution\"," + " \"url\": \"" + esc(d.source) + "\" } ],\n"; + out += " \"purl\": \"" + esc(purl(d)) + "\"\n"; + out += " }"; + } + out += first ? "]\n" : "\n ]\n"; + out += "}\n"; + + if (auto o = parsed.value("output")) { + std::error_code ec; + std::filesystem::create_directories( + std::filesystem::path(*o).parent_path(), ec); + std::ofstream f(*o, std::ios::binary); + if (!f) { + mcpp::ui::error(std::format("cannot write {}", *o)); + return 2; + } + f << out; + mcpp::ui::info("sbom", std::format("wrote {} ({} component{})", *o, + pkgs.size(), pkgs.size() == 1 ? "" : "s")); + } else { + std::print("{}", out); + } + return 0; +} + +} // namespace mcpp::cli diff --git a/tests/e2e/333_device_slots_locked_and_sbom.sh b/tests/e2e/333_device_slots_locked_and_sbom.sh new file mode 100755 index 00000000..c0da39db --- /dev/null +++ b/tests/e2e/333_device_slots_locked_and_sbom.sh @@ -0,0 +1,213 @@ +#!/usr/bin/env bash +# requires: gcc unix-shell python3 +# The three axes a project is asked about before it is adopted: how the +# artefact reaches a device, whether the build is reproducible, and what went +# into it. +# +# ⚠️ NONE OF THESE NEEDS A DEVICE, AND THAT IS DELIBERATE. `flash`, `monitor` +# and `debug` perform an argv the board supplied; what this script asserts is +# that mcpp resolves the right slot, reports an override, and refuses clearly +# when nothing is declared. Standing in a shell script for the tool means the +# assertions are about mcpp rather than about openocd being installed. +set -e + +MCPP="${MCPP:-mcpp}" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +mkdir -p "$work/p/src" +cd "$work/p" + +cat > src/main.cpp <<'CPP' +int main() { return 0; } +CPP + +# ── C: the four device slots ─────────────────────────────────────────────── +cat > mcpp.toml <<'TOML' +[package] +name = "p" +version = "0.1.0" + +[target.x86_64-linux-gnu] +flash = ["/bin/sh", "-c", "echo FLASHED $0"] +monitor = ["/bin/sh", "-c", "echo MONITORED $0"] +TOML + +"$MCPP" build >/dev/null 2>&1 || { echo "FAIL: build"; exit 1; } + +out=$("$MCPP" flash 2>&1) || { echo "FAIL: mcpp flash exited non-zero"; echo "$out"; exit 1; } +case "$out" in *FLASHED*) ;; *) echo "FAIL: flash did not perform the declared argv"; echo "$out"; exit 1 ;; esac +echo " ok mcpp flash performs [target.*].flash" + +out=$("$MCPP" monitor 2>&1) || { echo "FAIL: mcpp monitor exited non-zero"; exit 1; } +case "$out" in *MONITORED*) ;; *) echo "FAIL: monitor did not perform its own slot"; exit 1 ;; esac +echo " ok mcpp monitor performs its own slot, not flash's" + +# ⚠️ THE SLOT THAT IS NOT DECLARED MUST BE REFUSED BY NAME. An engine that fell +# back to executing the artefact would "succeed" at flashing by running the +# program on the build host, which is the failure the slot exists to prevent. +if out=$("$MCPP" debug 2>&1); then + echo "FAIL: mcpp debug succeeded with no debug template declared"; exit 1 +fi +case "$out" in + *"no debug is configured"*) ;; + *) echo "FAIL: the refusal does not name the slot"; echo "$out"; exit 1 ;; +esac +case "$out" in + *"debug = ["*) ;; + *) echo "FAIL: the refusal does not show a pasteable key"; exit 1 ;; +esac +echo " ok an undeclared slot is refused by name, with the key to paste" + +# ── D: --locked asserts the recorded resolution ──────────────────────────── +mkdir -p "$work/q/src" +cd "$work/q" +cat > mcpp.toml <<'TOML' +[package] +name = "q" +version = "0.1.0" + +[dependencies] +cmdline = "0.0.1" +TOML +cat > src/main.cpp <<'CPP' +int main() { return 0; } +CPP + +"$MCPP" build >/dev/null 2>&1 || { echo "SKIP: cmdline@0.0.1 unavailable"; exit 0; } +test -f mcpp.lock || { echo "FAIL: no mcpp.lock after a build with a dependency"; exit 1; } + +"$MCPP" build --locked >/dev/null 2>&1 \ + || { echo "FAIL: --locked rejected a matching lock"; exit 1; } +echo " ok --locked passes when the resolution matches" + +# ⚠️⚠️ AND THE FAILING DIRECTION IS THE ONE THAT MATTERS. Measured while writing +# this: with the fast path still enabled, a corrupted lock passed `--locked` and +# printed "Finished" — the flag was accepted and the check never ran. +sed -i.bak 's/version = "0.0.1"/version = "9.9.9"/' mcpp.lock +if out=$("$MCPP" build --locked 2>&1); then + echo "FAIL: --locked accepted a lock that does not describe this resolution" + echo "$out" | tail -3; exit 1 +fi +case "$out" in + *"differs from mcpp.lock"*) ;; + *) echo "FAIL: the refusal does not say what is wrong"; echo "$out"; exit 1 ;; +esac +# The drift is NAMED. "Out of date" is true and useless. +case "$out" in + *"9.9.9 -> 0.0.1"*) ;; + *) echo "FAIL: the refusal does not name which package moved, and to what" + echo "$out"; exit 1 ;; +esac +echo " ok --locked names the package that moved and both versions" +mv mcpp.lock.bak mcpp.lock + +# ── E: the bill of materials describes the RECORDED resolution ───────────── +"$MCPP" sbom -o sbom.json >/dev/null 2>&1 || { echo "FAIL: mcpp sbom"; exit 1; } +python3 - <<'PY' || exit 1 +import json, sys +d = json.load(open("sbom.json")) +assert d["bomFormat"] == "CycloneDX", d.get("bomFormat") +assert d["specVersion"] == "1.5", d["specVersion"] +root = d["metadata"]["component"] +assert root["name"] == "q", root +names = [c["name"] for c in d["components"]] +assert "cmdline" in names, names +# ⚠️ A component with no licence must SAY so rather than omit the field: an +# absent key reads as "not examined" and is the shape a reviewer cannot filter. +for c in d["components"]: + assert "licenses" in c, c["name"] +# The purl is what correlates a component with an advisory feed. +for c in d["components"]: + assert c["purl"].startswith("pkg:mcpp/"), c["purl"] +print(" ok sbom is valid CycloneDX, names every component and its licence field") +PY + +# ⭐ AND IT REPORTS WHAT WAS RECORDED, NOT WHAT WOULD RESOLVE NOW. This is the +# one property an SBOM must have, so it is asserted rather than assumed. +sed -i.bak 's/version = "0.0.1"/version = "7.7.7"/' mcpp.lock +"$MCPP" sbom -o sbom2.json >/dev/null 2>&1 || { echo "FAIL: mcpp sbom (2)"; exit 1; } +python3 - <<'PY' || exit 1 +import json +d = json.load(open("sbom2.json")) +v = [c["version"] for c in d["components"] if c["name"] == "cmdline"] +assert v == ["7.7.7"], f"sbom re-resolved instead of reading the lock: {v}" +print(" ok sbom reads the lock rather than re-resolving") +PY +mv mcpp.lock.bak mcpp.lock + +# ── B: one board package, two environments, chosen by the consumer ───────── +# +# ⭐⭐ THE EMULATOR/HARDWARE AXIS IS A FEATURE, NOT A FORK. A board reached +# through QEMU and the same board reached through a debug probe differ only in +# the argv of their device slots. Publishing two packages would duplicate the +# linker script, the startup code and the module surface to vary four strings. +# +# ⚠️ AND THE SITE THIS CATCHES IS A REAL ONE. Dependency-supplied RunGlobal +# entries reach the root through a DIFFERENT code path from a package's own; +# wiring only the latter left `mcpp flash` reporting "no flash is configured" +# while `mcpp run` found the runner the same build program emitted beside it. +mkdir -p "$work/bsp/src" "$work/consumer/src" +cd "$work/bsp" +cat > mcpp.toml <<'TOML' +[package] +name = "demo-board-rt" +version = "0.1.0" + +[features] +default = ["emulator"] +emulator = [] +hardware = [] +TOML +cat > build.mcpp <<'BUILD' +import mcpp; +import std; +int main() { + if (mcpp::has_feature("hardware")) { + for (auto a : {"/bin/sh", "-c", "echo PROBE $0"}) mcpp::flash(a); + for (auto a : {"/bin/sh", "-c", "echo GDBSERVER $0"}) mcpp::debug(a); + mcpp::runner_exclusive(); + } else { + for (auto a : {"/bin/sh", "-c", "echo EMULATOR $0"}) mcpp::flash(a); + } + return 0; +} +BUILD +printf 'export module demo_board;\n' > src/board.cppm + +cd "$work/consumer" +cat > src/main.cpp <<'CPP' +int main() { return 0; } +CPP +consumer_manifest() { + printf '[package]\nname = "consumer"\nversion = "0.1.0"\n[dependencies]\ndemo-board-rt = { path = "../bsp"%s }\n' "$1" > mcpp.toml +} + +consumer_manifest '' +rm -rf target +out=$("$MCPP" flash 2>&1) || { echo "FAIL: flash under the default feature"; echo "$out"; exit 1; } +case "$out" in *EMULATOR*) ;; *) echo "FAIL: default feature did not select the emulator argv"; echo "$out"; exit 1 ;; esac +echo " ok a dependency's flash slot reaches the consumer" + +consumer_manifest ', features = ["hardware"]' +rm -rf target +out=$("$MCPP" flash 2>&1) || { echo "FAIL: flash under features=[hardware]"; echo "$out"; exit 1; } +case "$out" in + *PROBE*) ;; + *EMULATOR*) echo "FAIL: the feature did not change the slot"; exit 1 ;; + *) echo "FAIL: unexpected flash output"; echo "$out"; exit 1 ;; +esac +echo " ok the SAME package serves hardware when the consumer asks for it" + +# The hardware arm supplies a debug server; the emulator arm does not. That +# asymmetry is the point: a slot is absent when the environment has no such +# thing, and absence is reported rather than faked. +out=$("$MCPP" debug 2>&1) || { echo "FAIL: debug under features=[hardware]"; echo "$out"; exit 1; } +case "$out" in *GDBSERVER*) ;; *) echo "FAIL: debug slot did not arrive"; echo "$out"; exit 1 ;; esac +consumer_manifest '' +rm -rf target +if out=$("$MCPP" debug 2>&1); then + echo "FAIL: debug succeeded under the emulator feature, which supplies none"; exit 1 +fi +echo " ok a slot the chosen environment does not supply is refused, not faked" + +echo "PASS: device slots, --locked and sbom" diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index 2a45bb0f..23cf3539 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -4465,7 +4465,8 @@ runnerX = ["qemu-aarch64-static"] ASSERT_EQ(m->schemaWarnings.size(), 1u); EXPECT_NE(m->schemaWarnings[0].find("'runnerX'"), std::string::npos) << m->schemaWarnings[0]; EXPECT_NE(m->schemaWarnings[0].find( - "Supported keys: cxx_runtime, linkage, runner, sysroot, toolchain"), + "Supported keys: cxx_runtime, debug, flash, linkage, monitor, runner, " + "sysroot, toolchain"), std::string::npos) << m->schemaWarnings[0]; } From eac311e96f09f513a32ae18925353afd750228c8 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Fri, 4 Sep 2026 08:47:41 +0800 Subject: [PATCH 02/10] docs: the device layer and the compatibility commitment (2026.9.4.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/18 specifies the four device actions, why termination is a property of the slot rather than of the argv, and why the emulator/hardware choice is a feature of one board package rather than two packages. docs/19 states what a project's own review process asks and cannot currently cite: which releases are supported, which surfaces are stable, and which — build fingerprints, cache layout, target/ — are explicitly not interfaces. Both mirrored in Chinese, per this repository's convention. The plan document records the second round: two findings the plan did not predict (a second propagation path for dependency-supplied RunGlobal entries, and two fast paths that would have made the new slots and --locked silently vacuous), and the six-axis reading that follows. --- ...ommercial-grade-baremetal-embedded-plan.md | 41 ++++- CHANGELOG.md | 55 ++++++ docs/18-devices.md | 160 ++++++++++++++++++ docs/19-supported-versions.md | 90 ++++++++++ docs/README.md | 2 + docs/zh/18-devices.md | 142 ++++++++++++++++ docs/zh/19-supported-versions.md | 78 +++++++++ docs/zh/README.md | 2 + mcpp.toml | 2 +- modules/versioning/src/version.cppm | 2 +- 10 files changed, 571 insertions(+), 3 deletions(-) create mode 100644 docs/18-devices.md create mode 100644 docs/19-supported-versions.md create mode 100644 docs/zh/18-devices.md create mode 100644 docs/zh/19-supported-versions.md diff --git a/.agents/docs/2026-09-04-commercial-grade-baremetal-embedded-plan.md b/.agents/docs/2026-09-04-commercial-grade-baremetal-embedded-plan.md index 95cf9a0d..b79cafe7 100644 --- a/.agents/docs/2026-09-04-commercial-grade-baremetal-embedded-plan.md +++ b/.agents/docs/2026-09-04-commercial-grade-baremetal-embedded-plan.md @@ -1,6 +1,6 @@ # 商业级可用:mcpp × xlings 的裸机与嵌入式总体方案 -2026-09-04 · 多仓库总体方案 · **v4:P0 引擎切片已实施**(PR #550);其余批次待实施 +2026-09-04 · 多仓库总体方案 · **v5:P0 引擎切片 + B/C/D/E 四轴已实施**(PR #550、#551) 前置讨论: [`2026-08-21-baremetal-ecosystem-assessment.md`](2026-08-21-baremetal-ecosystem-assessment.md)(七角度评估) · @@ -471,3 +471,42 @@ clang 对一次 float 乘法仍发出 `vmul.f32` —— 在没有 FPU 的 Cortex ⚠️ **软浮点行在没有 builtins 时链接不了浮点代码**(实测:`undefined symbol: __aeabi_fmul`),这正是 §3.1.1 把 `compiler-rt-builtins` 与 C 库并列为 P0 的理由。 整数程序不受影响 —— e2e 332 的四行启动用例即为整数程序。 + +--- + +## 11. 第二轮实施(2026-09-04,PR #551) + +### 11.1 六轴读数的变化 + +| 轴 | v4 | 现在 | +|---|---|---| +| **A 覆盖** | 🟡 引擎能编七行 | 🟡 不变(C 库源码包与板级包仍未做) | +| **B 可信** | ❌ 零真机 | 🟢 **模拟器与真机成为同一个包的两个 feature**;真机路径可声明、可解析、判据齐备,尚无实机运行记录 | +| **C 闭环** | ❌ 三个槽都没有 | ✅ `flash`/`monitor`/`debug` + `runner-exclusive`,四槽一读点 | +| **D 可复现** | ❌ `--locked` 不存在 | ✅ `--locked`/`--frozen` 断言并点名漂移;关掉快路径以免空转 | +| **E 可交付** | ❌ 全空白 | 🟢 `mcpp sbom`(CycloneDX 1.5)+ `docs/19` 支持窗口;许可闭包门与离线快照仍未做 | +| **F 可扩展** | ✅ | ✅ 未受损:新板 = 新包,引擎 diff 为零 | + +### 11.2 ⭐⭐ 方案 §2.2 的判断被实施证实,§2.3 的被加强 + +* **两值语义是对的。** `debug` 起服务端、客户端归 IDE 这条边界成立,`debug` 与 + `monitor` 在实现里逐字段同形,没有出现方案担心的「会话协议」。 +* **`runner-exclusive` 比方案写的更必要。** 方案说它是「第一块真板挖出的一列」; + 实施时发现它还必须**只紧不松** —— 图里任何一个包知道设备是互斥的,它就是互斥 + 的,后来的包保持沉默不得放松它。 + +### 11.3 ⚠️ 实施挖出的、方案没有的两条 + +1. **一条规则的第二份拷贝。** 依赖提供的 RunGlobal 条目抵达根工程走的是与 + `apply()` **不同**的路径(`prepare.cppm` 的 BFS 之后)。只接了前者时, + `mcpp flash` 报「没有配置」而 `mcpp run` 找得到同一个构建程序发出的 runner。 + 两处现在都遍历槽表。 +2. **快路径会让新槽与 `--locked` 双双空转。** `try_fast_run` 直接 exec 缓存产物, + 于是 `mcpp flash` 打印 `Running target/…/bin/p`;`try_fast_build` 跳过解析, + 于是被改坏的锁通过了 `--locked`。两处都按**性质**设闸(槽是不是 run、是不是 + 要求断言),不是按旗标。 + +### 11.4 仍未做 + +`mcpplibs/picolibc` + `compiler-rt-builtins` 源码包 · 三个板级包 · `xim:probe-rs` · +真机 CI · 许可闭包门(`--deny-license`)· 离线整仓快照 · openarch 第四后端(P3)。 diff --git a/CHANGELOG.md b/CHANGELOG.md index d242dd51..0872f545 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,61 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [2026.9.4.2] — 2026-09-04 + +`runner` 长出三个兄弟槽,`--locked` 成为断言,新增 `mcpp sbom`。 + +四件事在一个工程被采用之前会被问到,而它们此前都没有答案:产物如何抵达设备、 +构建是否可复现、里面装了什么、以及模拟器与真机是一个包还是两个。 + +### 设备槽 + +`run` / `flash` / `monitor` / `debug` 是同一种形状 —— **板知道、工具执行**的一段 +argv。`runner` 从 2026.8.19 起就承载着这个形状;再写三个特例命令就是把它再承载 +三遍。所以**槽成了参数**:指令表各一行、一个读点、一种 CLI 形状。 + +```toml +[target.thumbv7em-none-eabihf] +flash = ["probe-rs", "download", "--verify", "--chip", "STM32L475VG", "{}"] +``` + +⚠️ **没有任何 argv 能说出它们哪个会终止。** `run`/`flash` 跑完并交回判决; +`monitor`/`debug` 没有自然终点,于是「进程还活着」对后者是成功、对前者是卡死。 +引擎从**槽**读出这件事,因为 `openocd -c "program … exit"` 与 `openocd -c "init"` +的拼写直到板所选的那个参数为止都一样。 + +⚠️ **`runner-exclusive` 是第一件物理板需要而模拟器从不需要的事。** 一块板一个探针 +是互斥锁,两个工作者去够它不会失败 —— 它们互相穿插。板自己说一次,工程永远不必 +记得 `-j1`。 + +### 模拟器与硬件是一个 feature,不是一次分叉 + +两者差别只在设备槽的 argv。⭐ 这**不需要任何引擎机制** —— `mcpp::has_feature()` +本来就存在,分层按规定在起作用。 + +### `--locked` + +锁一直是解析之后写、从不读回;它自己的头注释就这么说。现在它是一条**断言**: +发生的解析必须等于记录的解析,不等则点名移动了的包与两个版本。 + +⚠️⚠️ **而它绝不能遇上快路径。** 实测:在加上那道闸之前,一份被故意改坏的锁通过了 +`mcpp build --locked` 并打印 `Finished` —— 旗标被接受、构建正确、断言从未跑到。 +**被跳过的判据比不存在的判据更糟,因为那个绿会被当成一次验证。** + +### `mcpp sbom` + +CycloneDX 1.5,覆盖**已记录**的解析。物料清单需要的一切都已在 `mcpp.lock` 里, +所以这是一种输出格式而不是一套机制。⚠️ 它读锁而不是重新解析 —— 一份描述了与所 +构建者不同的图的文档比没有更糟。 + +### 两个传播点,以及先被漏掉的那一个 + +依赖提供的 RunGlobal 条目抵达根工程,走的是与包自身指令**不同**的代码路径。只接了 +前者时,`mcpp flash` 报「no flash is configured」,而 `mcpp run` 找得到同一个构建 +程序三行之外发出的 runner。两处现在都遍历槽表,而不是各自点名 `runner`。 + +指令协议版本 6。新增 `docs/18-devices.md` 与 `docs/19-supported-versions.md`(中英双份)。 + ## [2026.9.4.1] — 2026-09-04 Cortex-M 落地为七个目标行,freestanding 链接开启死代码段消除。 diff --git a/docs/18-devices.md b/docs/18-devices.md new file mode 100644 index 00000000..4cc14592 --- /dev/null +++ b/docs/18-devices.md @@ -0,0 +1,160 @@ +# 18 — Reaching a Device + +This document specifies how mcpp executes, writes, observes and debugs an +artifact that runs somewhere other than the machine that built it, and how a +project selects between an emulator and physical hardware. + +Related documents: [13 — Bare-Metal and Freestanding Targets](13-baremetal.md) +covers the targets these actions apply to; [07 — build.mcpp](07-build-mcpp.md) +is the reference for the directive protocol a board-support package speaks; +[11 — Machine Output](11-machine-output.md) is the interface a debugger client +or IDE uses. + +## Four actions, one shape + +An artifact that cannot run on the build machine needs something to stand in +front of it. Four things are asked of such an artifact, and all four are an +argv that a board knows and a tool performs: + +| Command | Slot | What it does | +|---|---|---| +| `mcpp run` | `runner` | executes the artifact | +| `mcpp flash` | `flash` | writes it to the device | +| `mcpp monitor` | `monitor` | observes what the device prints | +| `mcpp debug` | `debug` | starts the device's debug server | + +Each is declared the same way, by a board-support package: + +```cpp +mcpp::flash("probe-rs"); +mcpp::flash("download"); +mcpp::flash("--verify"); +mcpp::flash("--chip"); +mcpp::flash("STM32L475VG"); +``` + +or by a project, overriding what a dependency supplied: + +```toml +[target.thumbv7em-none-eabihf] +flash = ["probe-rs", "download", "--verify", "--chip", "STM32L475VG", "{}"] +``` + +The artifact path is appended, or substituted for `{}` when the template +contains it. One token per call: argv is ordered, and a single string cannot +say where its boundaries are. + +The program is located by mcpp rather than by the system: a declared payload's +`bin/` first, then `PATH`. A tool that is nowhere is an error decided before +any process starts, rather than a fallback to bare execution. + +## Termination is a property of the slot + +The four actions differ in one way the engine must act on, and no argv can +express it. + +| Semantics | Slots | Meaning | +|---|---|---| +| `OneShot` | `run`, `flash` | runs to completion; the exit code is the verdict | +| `LongLived` | `monitor`, `debug` | has no natural end; the operator ends it | + +`openocd -c "program image.elf verify reset exit"` terminates and `openocd -c +"init"` does not, and the two are spelled alike up to the argument the board +chose. The engine therefore reads termination from the slot, and a board cannot +get it wrong by writing its argv differently. + +`mcpp debug` starts a **server** and stops there. The client that attaches is +the user's debugger or their IDE, which learns what it needs through the +machine-output protocol. mcpp does not drive the client. + +## Absence is reported, never substituted + +`mcpp run` on a hosted target with no runner executes the artifact directly, +because the host can run it. There is no corresponding reading of "no flasher": +nothing else writes an image to a device. An undeclared `flash`, `monitor` or +`debug` is therefore an error on every target, naming the slot and printing the +key to paste. + +Succeeding at `mcpp flash` by running the program on the build host would be +the exact failure the slot exists to prevent. + +## An exclusive device + +A physical board is a mutex; an emulator is not. `mcpp test` runs test binaries +on a worker pool, and two processes reaching for one probe do not fail cleanly +— they interleave, and the verdict describes neither test. + +The board states this about itself: + +```cpp +mcpp::runner_exclusive(); +``` + +`mcpp test` then runs one test at a time on that target, and reports that it is +doing so. A project never has to remember `-j1`. + +## Emulator and hardware are one package + +A board reached through an emulator and the same board reached through a debug +probe differ in the argv of their device slots and in nothing else. The linker +script, the startup code, the memory map and the exported module are the same +board. Publishing two packages to vary four strings duplicates all of it and +lets the copies drift. + +The choice is therefore a feature of one package: + +```toml +[features] +default = ["emulator"] +emulator = [] +hardware = [] +``` + +```cpp +int main() { + if (mcpp::has_feature("hardware")) { + for (auto a : {"probe-rs", "run", "--chip", "STM32L475VG"}) + mcpp::runner(a); + for (auto a : {"probe-rs", "download", "--verify", "--chip", "STM32L475VG"}) + mcpp::flash(a); + mcpp::runner_exclusive(); + } else { + mcpp::runner(qemu_path()); + for (auto a : {"-machine", "mps2-an385", "-nographic", "-semihosting", + "-no-reboot", "-kernel"}) + mcpp::runner(a); + } + return 0; +} +``` + +The consumer selects an environment where it selects everything else: + +```toml +[dependencies] +demo-board-rt = { version = "0.1.0", features = ["hardware"] } +``` + +A slot the chosen environment does not supply stays absent. An emulator has no +debug probe, so under the emulator feature `mcpp debug` reports that none is +configured rather than inventing one. + +This required no engine mechanism. The engine reads slots and knows nothing +about emulators or probes; `mcpp::has_feature` already existed. That the +question is answerable without adding anything is the layering working as +specified. + +## Precedence and reporting + +Two producers exist for every slot, with ordinary precedence: what the author +of the project wrote beats what a dependency supplied. The override is reported +rather than applied in silence. + +``` + note [target.thumbv7em-none-eabihf].flash overrides the flash a dependency supplied +``` + +Exactly one dependency may supply a given slot. Link flags from two +dependencies concatenate and that is correct; two flashers cannot, and +appending produces an argv that is neither one's. A second provider is an error +naming both packages. diff --git a/docs/19-supported-versions.md b/docs/19-supported-versions.md new file mode 100644 index 00000000..d472810c --- /dev/null +++ b/docs/19-supported-versions.md @@ -0,0 +1,90 @@ +# 19 — Supported Versions and Compatibility + +This document states which releases are supported, for how long, and what may +change between them. It exists because a project adopting mcpp is asked these +questions by its own review process, and an answer that lives only in +maintainers' heads cannot be cited. + +## Versioning + +Releases are named `YYYY.M.D.N` — the date of the release and the ordinal of +that day's release. The scheme carries no compatibility promise in its digits: +`2026.9.4.1` is not "a minor release" of `2026.9.3.2`. What may and may not +change is stated below rather than encoded in the number. + +## What is supported + +| | | +|---|---| +| **Supported** | the most recent release | +| **Security-fixed** | the most recent release, and the last release of the preceding calendar month | +| **Unsupported** | everything older | + +A release is superseded rather than withdrawn. Published assets and index +entries for older versions remain in place, because a project may have pinned +one and removing it would break a build that was working. + +## What may change between releases + +The engine's own interfaces are not all equally stable, and the difference is +worth stating precisely. + +| Surface | Stability | +|---|---| +| `mcpp.toml` keys | Additive. An existing key keeps its meaning; an unrecognised key is reported, never silently ignored | +| CLI commands and flags | Additive. A removed spelling is kept as an alias | +| Machine output (`--message-format json`) | Versioned by `schemaVersion`; see [11](11-machine-output.md) | +| `build.mcpp` directive protocol | Versioned; see `kProtocolVersion`. An engine refuses a program declaring a **higher** version rather than guessing | +| `mcpp.lock` format | Versioned by `schemaVersion`; older files are migrated on read | +| Target table rows | Additive. A row's tier may rise; a row is not removed while a published package targets it | +| Build fingerprints, cache layout, `target/` contents | **Not an interface.** These change without notice, and nothing should parse them | + +⚠️ A `build.mcpp` calling a function its engine's bundled `mcpp` module does not +have fails at the **compile** of the build program, not through a protocol +error. The protocol number governs directives on the wire; the typed API is +governed by which engine is installed. Both are stated here because the failure +a package author sees depends on which one they crossed. + +## Reproducing a build + +`mcpp.lock` records what a build resolved. `--locked` asserts that a resolution +matches it and fails naming the package that moved: + +``` +error: --locked was given and this resolution differs from mcpp.lock: + mcpplibs.cmdline 0.0.1 -> 0.0.2 +``` + +A release build, an audit or a CI job should pass `--locked`. It disables the +build fast path, so the assertion always runs. + +⚠️ The lock does not yet constrain resolution — it records and verifies it. +Pinning a resolution to the lock as an input is a separate change to the +resolver. + +## Bill of materials + +`mcpp sbom` writes a CycloneDX 1.5 document describing the **recorded** +resolution: + +```bash +mcpp sbom -o sbom.json +``` + +It reads `mcpp.lock` rather than resolving again, because a document describing +a different graph from the one that was built is worse than no document. A +component whose licence mcpp does not know is emitted as `NOASSERTION` rather +than omitted: an absent field reads as "not examined". + +## Offline and air-gapped use + +`--offline` (or `MCPP_OFFLINE=1`) prevents every network access: index refresh, +package download and toolchain installation. A build that would need one of +those fails naming what it needed, rather than reaching out. + +## Reporting a problem + +Defects and security reports go to the issue tracker of the repository that +owns the component — the engine, the package index, or the package itself. A +report that names the version, the host, the target and the command is +actionable; one that does not usually results in a request for those four. diff --git a/docs/README.md b/docs/README.md index b371328f..3f32410d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -20,6 +20,8 @@ - [15 - Cross-Compilation Over openkal](15-openkal-cross.md) - [16 - The Target Triple](16-the-target-triple.md) - [17 - The Project Environment](17-the-project-environment.md) +- [18 - Reaching a Device](18-devices.md) +- [19 - Supported Versions and Compatibility](19-supported-versions.md) ## Specifications diff --git a/docs/zh/18-devices.md b/docs/zh/18-devices.md new file mode 100644 index 00000000..12450b9a --- /dev/null +++ b/docs/zh/18-devices.md @@ -0,0 +1,142 @@ +# 18 - 抵达一台设备 + +本文规定 mcpp 如何执行、烧录、观察与调试一个运行在构建机器之外的产物,以及一个 +工程如何在模拟器与真实硬件之间做选择。 + +相关文档:[13 - 裸机与 freestanding 目标](13-baremetal.md) 覆盖这些动作适用的目标; +[07 - build.mcpp](07-build-mcpp.md) 是板级包所说的指令协议的参考; +[11 - 机器输出](11-machine-output.md) 是调试客户端或 IDE 使用的接口。 + +## 四个动作,一种形状 + +一个无法在构建机器上运行的产物,需要有东西站在它前面。人们对这样的产物提出四种 +要求,而这四种都是「板知道、工具执行」的一段 argv: + +| 命令 | 槽 | 做什么 | +|---|---|---| +| `mcpp run` | `runner` | 执行产物 | +| `mcpp flash` | `flash` | 把它写进设备 | +| `mcpp monitor` | `monitor` | 观察设备打印什么 | +| `mcpp debug` | `debug` | 启动设备的调试服务端 | + +每一个都以同样的方式声明,由板级包: + +```cpp +mcpp::flash("probe-rs"); +mcpp::flash("download"); +mcpp::flash("--verify"); +mcpp::flash("--chip"); +mcpp::flash("STM32L475VG"); +``` + +或者由工程声明,覆盖依赖所提供的: + +```toml +[target.thumbv7em-none-eabihf] +flash = ["probe-rs", "download", "--verify", "--chip", "STM32L475VG", "{}"] +``` + +产物路径被追加,或在模板包含 `{}` 时替换它。一次调用一个 token:argv 是有序的, +一个字符串说不出它的边界在哪里。 + +程序由 mcpp 定位而不是由系统定位:先看已声明载荷的 `bin/`,再看 `PATH`。哪里都 +找不到的工具是一个在任何进程启动之前就作出的错误,而不是回落到裸执行。 + +## 是否终止是槽的性质 + +这四个动作在一件引擎必须据以行动的事情上不同,而这件事没有任何 argv 能表达。 + +| 语义 | 槽 | 含义 | +|---|---|---| +| `OneShot` | `run`、`flash` | 运行到结束;退出码即判决 | +| `LongLived` | `monitor`、`debug` | 没有自然的终点;由操作者结束它 | + +`openocd -c "program image.elf verify reset exit"` 会终止,而 `openocd -c "init"` +不会,两者的拼写直到板所选的那个参数为止都一样。因此引擎从**槽**读出这件事, +板级包也就不可能因为把 argv 写成另一个样子而弄错它。 + +`mcpp debug` 启动一个**服务端**,到此为止。连上去的客户端是用户的调试器或 IDE, +它通过机器输出协议获得所需。mcpp 不驱动客户端。 + +## 缺席被报告,而不被替代 + +`mcpp run` 在没有 runner 的 hosted 目标上直接执行产物,因为宿主跑得动它。 +「没有烧录器」没有对应的读法:没有别的东西会把镜像写进设备。因此未声明的 +`flash`、`monitor` 或 `debug` 在**每一个**目标上都是错误,并点名那个槽、打印可以 +粘贴的键。 + +靠在构建宿主上运行程序来让 `mcpp flash` 成功,正是这个槽存在所要防止的那种失败。 + +## 独占的设备 + +一块物理板是一把互斥锁,模拟器不是。`mcpp test` 在一个工作者池上跑测试二进制, +而两个进程去够同一个探针不会干净地失败 —— 它们互相穿插,产生的判决不描述其中 +任何一个测试。 + +板自己说出这件事: + +```cpp +mcpp::runner_exclusive(); +``` + +`mcpp test` 于是在那个目标上一次跑一个测试,并报告它正在这么做。工程永远不必记得 +`-j1`。 + +## 模拟器与硬件是同一个包 + +经模拟器抵达的板,与经调试探针抵达的同一块板,差别只在设备槽的 argv,别无其他。 +链接脚本、启动代码、内存映射与导出的模块都是同一块板。为了变化四个字符串而发布 +两个包,会把这一切复制一遍,并让两份副本各自漂移。 + +因此这个选择是**一个包的一个 feature**: + +```toml +[features] +default = ["emulator"] +emulator = [] +hardware = [] +``` + +```cpp +int main() { + if (mcpp::has_feature("hardware")) { + for (auto a : {"probe-rs", "run", "--chip", "STM32L475VG"}) + mcpp::runner(a); + for (auto a : {"probe-rs", "download", "--verify", "--chip", "STM32L475VG"}) + mcpp::flash(a); + mcpp::runner_exclusive(); + } else { + mcpp::runner(qemu_path()); + for (auto a : {"-machine", "mps2-an385", "-nographic", "-semihosting", + "-no-reboot", "-kernel"}) + mcpp::runner(a); + } + return 0; +} +``` + +消费者在它选择其他一切的地方选择环境: + +```toml +[dependencies] +demo-board-rt = { version = "0.1.0", features = ["hardware"] } +``` + +所选环境不提供的槽保持缺席。模拟器没有调试探针,所以在 emulator feature 之下 +`mcpp debug` 报告没有配置,而不是发明一个。 + +⭐ 这**不需要任何引擎机制**。引擎读槽,对模拟器与探针一无所知; +`mcpp::has_feature` 本来就存在。这个问题不必新增任何东西就能回答,正是分层按规定 +在起作用。 + +## 优先级与报告 + +每个槽都有两个生产者,优先级是通常的那个:工程作者写的胜过依赖提供的。覆盖会被 +报告,而不是静默应用。 + +``` + note [target.thumbv7em-none-eabihf].flash overrides the flash a dependency supplied +``` + +一个槽只允许一个依赖提供。两个依赖的链接标志会拼接,那是对的;两个烧录器不能, +拼接产生的 argv 不属于其中任何一个。第二个提供者是一个点名两个包的错误。 diff --git a/docs/zh/19-supported-versions.md b/docs/zh/19-supported-versions.md new file mode 100644 index 00000000..9b4eeb72 --- /dev/null +++ b/docs/zh/19-supported-versions.md @@ -0,0 +1,78 @@ +# 19 - 受支持的版本与兼容性 + +本文陈述哪些发布受支持、支持多久、以及版本之间什么可以变化。它之所以存在,是因为 +一个采用 mcpp 的工程会被它自己的评审流程问到这些问题,而只活在维护者脑子里的答案 +无法被引用。 + +## 版本号 + +发布以 `YYYY.M.D.N` 命名 —— 发布日期,加上当天发布的序号。这套方案的数字**不携带 +任何兼容性承诺**:`2026.9.4.1` 不是 `2026.9.3.2` 的「小版本」。什么可以变、什么 +不可以,由下文陈述,而不是编码在数字里。 + +## 支持范围 + +| | | +|---|---| +| **受支持** | 最新的一个发布 | +| **安全修复** | 最新的发布,以及上一个自然月的最后一个发布 | +| **不受支持** | 更旧的一切 | + +一个发布是被取代,而不是被撤回。更旧版本的已发布资产与索引条目原样保留,因为某个 +工程可能钉住了它,移除会弄坏一个本来正常的构建。 + +## 版本之间什么可以变化 + +引擎自身的各个接口稳定性并不相同,这个差别值得精确陈述。 + +| 面 | 稳定性 | +|---|---| +| `mcpp.toml` 的键 | 只增。既有键保持其含义;无法识别的键会被报告,绝不静默忽略 | +| CLI 命令与旗标 | 只增。被取代的拼写保留为别名 | +| 机器输出(`--message-format json`) | 由 `schemaVersion` 版本化,见 [11](11-machine-output.md) | +| `build.mcpp` 指令协议 | 版本化,见 `kProtocolVersion`。引擎遇到声明**更高**版本的程序会拒绝,而不是猜测 | +| `mcpp.lock` 格式 | 由 `schemaVersion` 版本化;更旧的文件在读取时迁移 | +| 目标表的行 | 只增。一行的档位可以上升;只要还有已发布的包以它为目标,该行不会被移除 | +| 构建指纹、缓存布局、`target/` 的内容 | **不是接口。** 它们不经通知即变化,任何东西都不应解析它们 | + +⚠️ 一个调用了其引擎自带 `mcpp` 模块中不存在的函数的 `build.mcpp`,失败发生在构建 +程序的**编译**期,而不是通过协议错误。协议号管的是线上的指令;类型化 API 由装的是 +哪个引擎决定。两者都写在这里,是因为包作者看到的失败取决于他越过了哪一条。 + +## 复现一次构建 + +`mcpp.lock` 记录一次构建解析出了什么。`--locked` 断言解析与它一致,不一致则失败并 +点名移动了的那个包: + +``` +error: --locked was given and this resolution differs from mcpp.lock: + mcpplibs.cmdline 0.0.1 -> 0.0.2 +``` + +发布构建、审计或 CI 作业应当传 `--locked`。它会关掉构建快路径,因此断言总会跑到。 + +⚠️ 锁**尚未约束**解析 —— 它记录并校验解析。把锁作为解析的输入来钉住它,是对解析器 +的另一项改动。 + +## 物料清单 + +`mcpp sbom` 写出一份描述**已记录**解析的 CycloneDX 1.5 文档: + +```bash +mcpp sbom -o sbom.json +``` + +它读 `mcpp.lock` 而不是重新解析,因为一份描述了与所构建者不同的图的文档,比没有 +文档更糟。mcpp 不知道其许可证的组件被写成 `NOASSERTION` 而不是省略:缺席的字段 +读起来像「未检查」。 + +## 离线与内网使用 + +`--offline`(或 `MCPP_OFFLINE=1`)阻止一切网络访问:索引刷新、包下载与工具链安装。 +需要其中之一的构建会点名它所需要的东西然后失败,而不是伸手出去。 + +## 报告问题 + +缺陷与安全报告提交到拥有该组件的仓库的 issue tracker —— 引擎、包索引,或者那个包 +自身。一份点明了版本、宿主、目标与命令的报告是可执行的;没有这四样的报告通常换来 +一次索要它们的回复。 diff --git a/docs/zh/README.md b/docs/zh/README.md index 5db3b231..1ec50d47 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -20,6 +20,8 @@ - [15 - 基于 openkal 的交叉构建](15-openkal-cross.md) - [16 - 目标三元组](16-the-target-triple.md) - [17 - 项目环境](17-the-project-environment.md) +- [18 - 抵达一台设备](18-devices.md) +- [19 - 受支持的版本与兼容性](19-supported-versions.md) ## 规范文档 diff --git a/mcpp.toml b/mcpp.toml index 5427c6bd..3543b1cf 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.9.4.1" +version = "2026.9.4.2" 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 255fcdb0..d7de998e 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.4.1"; +inline constexpr std::string_view MCPP_VERSION = "2026.9.4.2"; } // namespace mcpp From 3fc57dd0ca26762d075bfa1d5bb2f334f7a25d1f Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:38:36 +0800 Subject: [PATCH 03/10] refactor(runner): runners have names, and the engine knows none of them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the four hardcoded device slots of the previous commit. Three constraints, applied in order, produced this shape: 1. A top-level command must be usable in every domain. 2. The default must cover the common case; options carry the rest. 3. The core carries the general framework; the rest is configuration. The previous design failed all three. `mcpp flash` / `monitor` / `debug` were dead commands in any project that is not firmware, and they put embedded vocabulary into the engine: a web package could not add `serve`, nor a cluster package `submit`, without an engine release. Measured before this change: `flash`, `monitor` and `debugger` appeared 56 times across 8 engine files, and a fifth action would have touched nine places — a cost this commit's predecessor quoted in its own message and then paid four times. ⭐⭐ AND THE SECOND CONSTRAINT CAUGHT THE DEEPER ERROR. On real hardware, running a program IS writing it, resetting, attaching and reading the exit status — `probe-rs run` is one command, exactly as `qemu-system-* -kernel` is. The two are one action in two environments, not two actions. The previous design required `mcpp run --runner flash` there, making the most common thing a developer does the one needing an extra argument. So a board's `hardware` feature moves the DEFAULT runner, and the command does not change between an emulator and a board. Named runners serve what remains: writing without running, observing a console, a debug server, erasing. The engine now knows only that named runners exist. The name is data, carried in the value of one directive, and `flash`, `serve`, `submit` and `logcat` cost it the same: nothing. ⭐ Termination is declared rather than inferred, because no argv can express it and the engine has no list of names to infer from: `openocd -c "program … exit"` terminates, `openocd -c "init"` does not, and the two are spelled alike up to the argument the package chose. `runner-exclusive` becomes `run-exclusive`: the property is that this target's runs cannot overlap, which holds for one board on one probe, one GPU, one serial port and a single-seat licence alike. Nothing about it is a device. `mcpp sbom` becomes `mcpp emit sbom`. `emit` already meant "generate a document describing this project" and already carried `-o`; a separate top-level command was a second spelling of an abstraction that existed. That was the same mistake as the first one, made twice in a day: adding a command instead of extending an abstraction. ⭐ The unit test that quantifies over the directive table caught a real defect here: the table's declared size still said 20 after a row was removed, leaving a default-constructed entry with an empty wire name. 97/97 unit tests; e2e 130-139, 332 and the rewritten 333 green. --- CHANGELOG.md | 68 +++--- docs/18-devices.md | 194 +++++++++--------- docs/19-supported-versions.md | 4 +- docs/zh/18-devices.md | 180 ++++++++-------- docs/zh/19-supported-versions.md | 4 +- modules/buildmcpp/src/directives.cppm | 120 ++++------- modules/manifest/src/toml.cppm | 88 +++++--- modules/manifest/src/types.cppm | 70 ++++--- src/build/execute.cppm | 171 +++++++++------ src/build/hostprogram.cppm | 32 ++- src/build/prepare.cppm | 103 +++++----- src/cli.cppm | 86 +++----- src/cli/cmd_build.cppm | 47 +---- ...h => 333_named_runners_locked_and_sbom.sh} | 67 +++--- tests/unit/test_manifest.cpp | 3 +- 15 files changed, 598 insertions(+), 639 deletions(-) rename tests/e2e/{333_device_slots_locked_and_sbom.sh => 333_named_runners_locked_and_sbom.sh} (71%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0872f545..9563c280 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,58 +5,52 @@ ## [2026.9.4.2] — 2026-09-04 -`runner` 长出三个兄弟槽,`--locked` 成为断言,新增 `mcpp sbom`。 +runner 有了名字,`--locked` 成为断言,`mcpp emit sbom`。 -四件事在一个工程被采用之前会被问到,而它们此前都没有答案:产物如何抵达设备、 -构建是否可复现、里面装了什么、以及模拟器与真机是一个包还是两个。 +### ⭐⭐ 一条命令,加具名的例外 -### 设备槽 +`mcpp run` 覆盖常见情形的**全部,真实硬件也一样**。在设备上「运行一个程序」意味着 +写进去、复位、接上输出、读回退出状态 —— 这是**一条**命令(`probe-rs run`、 +`qemu-system-* -kernel`),不是几条。板级包把它作为**默认** runner,于是开发者从 +模拟器换到真板时,**敲的命令不变**。 -`run` / `flash` / `monitor` / `debug` 是同一种形状 —— **板知道、工具执行**的一段 -argv。`runner` 从 2026.8.19 起就承载着这个形状;再写三个特例命令就是把它再承载 -三遍。所以**槽成了参数**:指令表各一行、一个读点、一种 CLI 形状。 - -```toml -[target.thumbv7em-none-eabihf] -flash = ["probe-rs", "download", "--verify", "--chip", "STM32L475VG", "{}"] +```bash +mcpp run # 默认;模拟器与真板同一条 +mcpp run --runner flash # 具名的例外:只写不跑、看串口、起调试服务端、擦片 +mcpp run --list-runners # 这个工程提供了哪些 ``` -⚠️ **没有任何 argv 能说出它们哪个会终止。** `run`/`flash` 跑完并交回判决; -`monitor`/`debug` 没有自然终点,于是「进程还活着」对后者是成功、对前者是卡死。 -引擎从**槽**读出这件事,因为 `openocd -c "program … exit"` 与 `openocd -c "init"` -的拼写直到板所选的那个参数为止都一样。 +⚠️ **引擎不认识任何 runner 名字。** `flash`、`serve`、`deploy`、`submit`、 +`logcat` 对它一样陌生。**引擎里若有一份固定的名字表,就等于由引擎决定哪些领域可被 +表达** —— 一个 web 包将无法自己加 `serve`。 -⚠️ **`runner-exclusive` 是第一件物理板需要而模拟器从不需要的事。** 一块板一个探针 -是互斥锁,两个工作者去够它不会失败 —— 它们互相穿插。板自己说一次,工程永远不必 -记得 `-j1`。 +⭐ **写程序名,不要写路径。** mcpp 先找本包 `[xlings] deps` 声明的载荷 `bin/`, +再找 `PATH`。用 `xpkg_dir` 拼绝对路径是多余的,而且引入了一个失败模式:声明不是 +安装,查询返回空则没有配置任何 runner 而无话可说。 -### 模拟器与硬件是一个 feature,不是一次分叉 +⚠️ 是否终止由 `mcpp::runner_longlived(name)` **声明**: +`openocd -c "program … exit"` 会终止而 `openocd -c "init"` 不会,拼写到最后一个 +参数为止都一样,没有任何 argv 能表达这个区别。 -两者差别只在设备槽的 argv。⭐ 这**不需要任何引擎机制** —— `mcpp::has_feature()` -本来就存在,分层按规定在起作用。 +`mcpp::run_exclusive()` 陈述「这个目标的运行不能重叠」—— 对一块板、一张 GPU、 +一个串口、一个单席位 license 同样成立,`mcpp test` 据此串行化。 ### `--locked` -锁一直是解析之后写、从不读回;它自己的头注释就这么说。现在它是一条**断言**: -发生的解析必须等于记录的解析,不等则点名移动了的包与两个版本。 - -⚠️⚠️ **而它绝不能遇上快路径。** 实测:在加上那道闸之前,一份被故意改坏的锁通过了 -`mcpp build --locked` 并打印 `Finished` —— 旗标被接受、构建正确、断言从未跑到。 -**被跳过的判据比不存在的判据更糟,因为那个绿会被当成一次验证。** - -### `mcpp sbom` +锁一直是解析之后写、从不读回。现在它是断言:发生的解析必须等于记录的解析,不等则 +**点名移动了的包与两个版本**。 -CycloneDX 1.5,覆盖**已记录**的解析。物料清单需要的一切都已在 `mcpp.lock` 里, -所以这是一种输出格式而不是一套机制。⚠️ 它读锁而不是重新解析 —— 一份描述了与所 -构建者不同的图的文档比没有更糟。 +⚠️⚠️ **它绝不能遇上快路径。** 实测:加闸之前,一份被故意改坏的锁通过了 +`mcpp build --locked` 并打印 `Finished` —— 旗标被接受、构建正确、**断言从未跑到**。 -### 两个传播点,以及先被漏掉的那一个 +### `mcpp emit sbom` -依赖提供的 RunGlobal 条目抵达根工程,走的是与包自身指令**不同**的代码路径。只接了 -前者时,`mcpp flash` 报「no flash is configured」,而 `mcpp run` 找得到同一个构建 -程序三行之外发出的 runner。两处现在都遍历槽表,而不是各自点名 `runner`。 +CycloneDX 1.5,覆盖**已记录**的解析。⚠️ 读锁而不是重新解析 —— 一份描述了与所构建 +者不同的图的文档比没有更糟。归在 `emit` 之下而不是新开一级命令:`emit` 已经是 +「生成描述本工程的文档」。 -指令协议版本 6。新增 `docs/18-devices.md` 与 `docs/19-supported-versions.md`(中英双份)。 +新增 `docs/18-devices.md`、`docs/19-supported-versions.md`(中英双份)。 +指令协议版本 6。 ## [2026.9.4.1] — 2026-09-04 diff --git a/docs/18-devices.md b/docs/18-devices.md index 4cc14592..86d7c43f 100644 --- a/docs/18-devices.md +++ b/docs/18-devices.md @@ -1,104 +1,114 @@ # 18 — Reaching a Device -This document specifies how mcpp executes, writes, observes and debugs an -artifact that runs somewhere other than the machine that built it, and how a -project selects between an emulator and physical hardware. +This document specifies how mcpp executes an artifact that runs somewhere other +than the machine that built it, how a package supplies additional ways of +reaching it, and how a project selects between an emulator and physical +hardware. Related documents: [13 — Bare-Metal and Freestanding Targets](13-baremetal.md) -covers the targets these actions apply to; [07 — build.mcpp](07-build-mcpp.md) -is the reference for the directive protocol a board-support package speaks; -[11 — Machine Output](11-machine-output.md) is the interface a debugger client -or IDE uses. +covers the targets this most often applies to; [07 — build.mcpp](07-build-mcpp.md) +is the reference for the directive protocol a package speaks; [11 — Machine +Output](11-machine-output.md) is the interface a debugger client or IDE uses. -## Four actions, one shape +## One command, and named exceptions An artifact that cannot run on the build machine needs something to stand in -front of it. Four things are asked of such an artifact, and all four are an -argv that a board knows and a tool performs: +front of it. That thing is a **runner**: an argv the package supplies and a tool +performs, with the artifact appended or substituted for `{}`. -| Command | Slot | What it does | -|---|---|---| -| `mcpp run` | `runner` | executes the artifact | -| `mcpp flash` | `flash` | writes it to the device | -| `mcpp monitor` | `monitor` | observes what the device prints | -| `mcpp debug` | `debug` | starts the device's debug server | +```bash +mcpp run # the default runner +mcpp run --runner flash # a named one +mcpp run --list-runners # what this project supplies +``` -Each is declared the same way, by a board-support package: +`mcpp run` is the whole of the common case, including on real hardware. On a +device, running a program means writing it, resetting, attaching to its output +and reading its exit status — which is one command (`probe-rs run`, `qemu-system-* +-kernel`), not several. A board therefore supplies that as its **default** +runner, and the command a developer types does not change when they move from an +emulator to a board. -```cpp -mcpp::flash("probe-rs"); -mcpp::flash("download"); -mcpp::flash("--verify"); -mcpp::flash("--chip"); -mcpp::flash("STM32L475VG"); -``` +Named runners exist for what remains: writing an image without running it, +observing a console, starting a debug server, erasing a part, deploying without +starting. -or by a project, overriding what a dependency supplied: +⚠️ **The engine knows no runner names.** `flash`, `serve`, `deploy`, `submit` +and `logcat` are equally unknown to it: it knows only that a package may supply +named runners, and performs the argv it finds. A fixed set of names in the +engine would decide, in the engine, which domains are expressible. -```toml -[target.thumbv7em-none-eabihf] -flash = ["probe-rs", "download", "--verify", "--chip", "STM32L475VG", "{}"] -``` +## What a package supplies -The artifact path is appended, or substituted for `{}` when the template -contains it. One token per call: argv is ordered, and a single string cannot -say where its boundaries are. +```cpp +mcpp::runner("qemu-system-arm"); // the default: argv token by token +mcpp::runner("-machine"); mcpp::runner("mps2-an385"); … -The program is located by mcpp rather than by the system: a declared payload's -`bin/` first, then `PATH`. A tool that is nowhere is an error decided before -any process starts, rather than a fallback to bare execution. +mcpp::runner("flash", "probe-rs"); // a named runner +mcpp::runner("flash", "download"); … -## Termination is a property of the slot +mcpp::runner_longlived("monitor"); // no natural end +mcpp::run_exclusive(); // this target's runs cannot overlap +``` -The four actions differ in one way the engine must act on, and no argv can -express it. +⭐ **Name the program, not its path.** mcpp locates it: the `bin/` of a payload +this package declared under `[xlings] deps` first, then `PATH`. Writing an +absolute path computed from `mcpp::xpkg_dir` is unnecessary, and it introduces a +failure mode — a declaration is not an install, so the lookup can return empty +and leave no runner configured with nothing said about why. Naming the program +lets mcpp report exactly which directories it searched. -| Semantics | Slots | Meaning | -|---|---|---| -| `OneShot` | `run`, `flash` | runs to completion; the exit code is the verdict | -| `LongLived` | `monitor`, `debug` | has no natural end; the operator ends it | +## What a project overrides -`openocd -c "program image.elf verify reset exit"` terminates and `openocd -c -"init"` does not, and the two are spelled alike up to the argument the board -chose. The engine therefore reads termination from the slot, and a board cannot -get it wrong by writing its argv differently. +```toml +[target.thumbv7em-none-eabihf] +runner = ["qemu-system-arm", "-machine", "mps2-an385", "-kernel"] + +[target.thumbv7em-none-eabihf.runners] +flash = ["probe-rs", "download", "--verify", "--chip", "STM32L475VG", "{}"] +monitor = ["probe-rs", "attach", "--chip", "STM32L475VG"] +``` -`mcpp debug` starts a **server** and stops there. The client that attaches is -the user's debugger or their IDE, which learns what it needs through the -machine-output protocol. mcpp does not drive the client. +Precedence is the ordinary one: what the author of the project wrote beats what +a dependency supplied, and the override is reported rather than applied in +silence. Exactly one dependency may supply a given name; a second is an error +naming both packages. -## Absence is reported, never substituted +## Termination is declared, not inferred -`mcpp run` on a hosted target with no runner executes the artifact directly, -because the host can run it. There is no corresponding reading of "no flasher": -nothing else writes an image to a device. An undeclared `flash`, `monitor` or -`debug` is therefore an error on every target, naming the slot and printing the -key to paste. +| | Meaning | +|---|---| +| default | runs to completion; the exit code is the verdict | +| `runner_longlived(name)` | has no natural end; the operator ends it | -Succeeding at `mcpp flash` by running the program on the build host would be -the exact failure the slot exists to prevent. +`openocd -c "program image.elf verify reset exit"` terminates and `openocd -c +"init"` does not, and the two are spelled alike up to the argument the package +chose. No argv can express which is which, and the engine has no list of names +to infer it from — so the package states it. -## An exclusive device +`mcpp run --runner debug` starts a **server** and stops there. The client that +attaches is the user's debugger or IDE, which learns what it needs through the +machine-output protocol. -A physical board is a mutex; an emulator is not. `mcpp test` runs test binaries -on a worker pool, and two processes reaching for one probe do not fail cleanly -— they interleave, and the verdict describes neither test. +## Runs that cannot overlap -The board states this about itself: +`mcpp test` runs test binaries on a worker pool. One board on one probe, one +GPU, one serial port, or a tool with a single-seat licence admits one user at a +time, and two workers reaching for it do not fail cleanly — they interleave, and +the verdict describes neither test. -```cpp -mcpp::runner_exclusive(); -``` +The package states this about itself with `mcpp::run_exclusive()`, and `mcpp +test` then serialises. A project never has to remember `-j1`. -`mcpp test` then runs one test at a time on that target, and reports that it is -doing so. A project never has to remember `-j1`. +Named for the property rather than for the hardware: nothing here is about +devices. ## Emulator and hardware are one package A board reached through an emulator and the same board reached through a debug -probe differ in the argv of their device slots and in nothing else. The linker +probe differ in the argv of their runners and in nothing else. The linker script, the startup code, the memory map and the exported module are the same -board. Publishing two packages to vary four strings duplicates all of it and +board. Publishing two packages to vary a few strings duplicates all of it and lets the copies drift. The choice is therefore a feature of one package: @@ -106,55 +116,39 @@ The choice is therefore a feature of one package: ```toml [features] default = ["emulator"] -emulator = [] -hardware = [] +emulator = {} +hardware = {} ``` ```cpp int main() { if (mcpp::has_feature("hardware")) { for (auto a : {"probe-rs", "run", "--chip", "STM32L475VG"}) - mcpp::runner(a); - for (auto a : {"probe-rs", "download", "--verify", "--chip", "STM32L475VG"}) - mcpp::flash(a); - mcpp::runner_exclusive(); + mcpp::runner(a); // the DEFAULT moves + for (auto a : {"probe-rs", "gdb", "--chip", "STM32L475VG"}) + mcpp::runner("debug", a); + mcpp::runner_longlived("debug"); + mcpp::run_exclusive(); } else { - mcpp::runner(qemu_path()); - for (auto a : {"-machine", "mps2-an385", "-nographic", "-semihosting", - "-no-reboot", "-kernel"}) + for (auto a : {"qemu-system-arm", "-machine", "mps2-an385", "-nographic", + "-semihosting", "-no-reboot", "-kernel"}) mcpp::runner(a); } return 0; } ``` -The consumer selects an environment where it selects everything else: - ```toml [dependencies] -demo-board-rt = { version = "0.1.0", features = ["hardware"] } +cortex-m-rt = { version = "0.1.0", features = ["hardware"] } ``` -A slot the chosen environment does not supply stays absent. An emulator has no -debug probe, so under the emulator feature `mcpp debug` reports that none is -configured rather than inventing one. +The consumer's command does not change. A runner the chosen environment does not +supply stays absent: an emulator has no debug probe, so under the emulator +feature `mcpp run --runner debug` reports that no such runner exists and lists +the ones that do. -This required no engine mechanism. The engine reads slots and knows nothing +⭐ This required no engine mechanism. The engine reads runners and knows nothing about emulators or probes; `mcpp::has_feature` already existed. That the question is answerable without adding anything is the layering working as specified. - -## Precedence and reporting - -Two producers exist for every slot, with ordinary precedence: what the author -of the project wrote beats what a dependency supplied. The override is reported -rather than applied in silence. - -``` - note [target.thumbv7em-none-eabihf].flash overrides the flash a dependency supplied -``` - -Exactly one dependency may supply a given slot. Link flags from two -dependencies concatenate and that is correct; two flashers cannot, and -appending produces an argv that is neither one's. A second provider is an error -naming both packages. diff --git a/docs/19-supported-versions.md b/docs/19-supported-versions.md index d472810c..fc43b40f 100644 --- a/docs/19-supported-versions.md +++ b/docs/19-supported-versions.md @@ -64,11 +64,11 @@ resolver. ## Bill of materials -`mcpp sbom` writes a CycloneDX 1.5 document describing the **recorded** +`mcpp emit sbom` writes a CycloneDX 1.5 document describing the **recorded** resolution: ```bash -mcpp sbom -o sbom.json +mcpp emit sbom -o sbom.json ``` It reads `mcpp.lock` rather than resolving again, because a document describing diff --git a/docs/zh/18-devices.md b/docs/zh/18-devices.md index 12450b9a..3221d4ee 100644 --- a/docs/zh/18-devices.md +++ b/docs/zh/18-devices.md @@ -1,142 +1,126 @@ # 18 - 抵达一台设备 -本文规定 mcpp 如何执行、烧录、观察与调试一个运行在构建机器之外的产物,以及一个 -工程如何在模拟器与真实硬件之间做选择。 +本文规定 mcpp 如何执行一个运行在构建机器之外的产物、包如何提供抵达它的其他方式, +以及工程如何在模拟器与真实硬件之间选择。 -相关文档:[13 - 裸机与 freestanding 目标](13-baremetal.md) 覆盖这些动作适用的目标; -[07 - build.mcpp](07-build-mcpp.md) 是板级包所说的指令协议的参考; -[11 - 机器输出](11-machine-output.md) 是调试客户端或 IDE 使用的接口。 +相关文档:[13 - 裸机与 freestanding 目标](13-baremetal.md) · [07 - build.mcpp](07-build-mcpp.md) +· [11 - 机器输出](11-machine-output.md)。 -## 四个动作,一种形状 +## 一条命令,加具名的例外 -一个无法在构建机器上运行的产物,需要有东西站在它前面。人们对这样的产物提出四种 -要求,而这四种都是「板知道、工具执行」的一段 argv: +无法在构建机器上运行的产物需要有东西站在它前面。那个东西叫 **runner**:包提供、 +工具执行的一段 argv,产物被追加或替换 `{}`。 -| 命令 | 槽 | 做什么 | -|---|---|---| -| `mcpp run` | `runner` | 执行产物 | -| `mcpp flash` | `flash` | 把它写进设备 | -| `mcpp monitor` | `monitor` | 观察设备打印什么 | -| `mcpp debug` | `debug` | 启动设备的调试服务端 | +```bash +mcpp run # 默认 runner +mcpp run --runner flash # 具名的 +mcpp run --list-runners # 这个工程提供了哪些 +``` -每一个都以同样的方式声明,由板级包: +**`mcpp run` 覆盖了常见情形的全部,真实硬件也一样。** 在设备上,「运行一个程序」 +意味着写进去、复位、接上它的输出、读回退出状态 —— 这是**一条**命令 +(`probe-rs run`、`qemu-system-* -kernel`),不是几条。因此板级包把它作为**默认** +runner,于是开发者从模拟器换到真板时,敲的命令不变。 -```cpp -mcpp::flash("probe-rs"); -mcpp::flash("download"); -mcpp::flash("--verify"); -mcpp::flash("--chip"); -mcpp::flash("STM32L475VG"); -``` +具名 runner 服务剩下的部分:只写不跑、看串口、起调试服务端、擦片、部署但不启动。 -或者由工程声明,覆盖依赖所提供的: +⚠️ **引擎不认识任何 runner 名字。** `flash`、`serve`、`deploy`、`submit`、 +`logcat` 对它一样陌生:它只知道「包可以提供具名 runner」这件事,然后执行它找到的 +argv。**引擎里若有一份固定的名字表,就等于由引擎决定哪些领域可被表达。** -```toml -[target.thumbv7em-none-eabihf] -flash = ["probe-rs", "download", "--verify", "--chip", "STM32L475VG", "{}"] -``` +## 包提供什么 -产物路径被追加,或在模板包含 `{}` 时替换它。一次调用一个 token:argv 是有序的, -一个字符串说不出它的边界在哪里。 +```cpp +mcpp::runner("qemu-system-arm"); // 默认:一次一个 token +mcpp::runner("-machine"); mcpp::runner("mps2-an385"); … -程序由 mcpp 定位而不是由系统定位:先看已声明载荷的 `bin/`,再看 `PATH`。哪里都 -找不到的工具是一个在任何进程启动之前就作出的错误,而不是回落到裸执行。 +mcpp::runner("flash", "probe-rs"); // 具名 runner +mcpp::runner("flash", "download"); … -## 是否终止是槽的性质 +mcpp::runner_longlived("monitor"); // 没有自然终点 +mcpp::run_exclusive(); // 这个目标的运行不能重叠 +``` -这四个动作在一件引擎必须据以行动的事情上不同,而这件事没有任何 argv 能表达。 +⭐ **写程序名,不要写路径。** mcpp 会定位它:先找本包在 `[xlings] deps` 里声明的 +载荷的 `bin/`,再找 `PATH`。用 `mcpp::xpkg_dir` 拼绝对路径是多余的,而且引入了一个 +失败模式 —— **声明不是安装**,查询可能返回空,于是没有配置任何 runner 而没有任何 +话说明原因。写程序名则让 mcpp 报出它究竟搜过哪些目录。 -| 语义 | 槽 | 含义 | -|---|---|---| -| `OneShot` | `run`、`flash` | 运行到结束;退出码即判决 | -| `LongLived` | `monitor`、`debug` | 没有自然的终点;由操作者结束它 | +## 工程覆盖什么 -`openocd -c "program image.elf verify reset exit"` 会终止,而 `openocd -c "init"` -不会,两者的拼写直到板所选的那个参数为止都一样。因此引擎从**槽**读出这件事, -板级包也就不可能因为把 argv 写成另一个样子而弄错它。 +```toml +[target.thumbv7em-none-eabihf] +runner = ["qemu-system-arm", "-machine", "mps2-an385", "-kernel"] -`mcpp debug` 启动一个**服务端**,到此为止。连上去的客户端是用户的调试器或 IDE, -它通过机器输出协议获得所需。mcpp 不驱动客户端。 +[target.thumbv7em-none-eabihf.runners] +flash = ["probe-rs", "download", "--verify", "--chip", "STM32L475VG", "{}"] +monitor = ["probe-rs", "attach", "--chip", "STM32L475VG"] +``` -## 缺席被报告,而不被替代 +优先级是通常那个:工程作者写的胜过依赖提供的,且覆盖会被报告。一个名字只允许一个 +依赖提供,第二个是点名两个包的错误。 -`mcpp run` 在没有 runner 的 hosted 目标上直接执行产物,因为宿主跑得动它。 -「没有烧录器」没有对应的读法:没有别的东西会把镜像写进设备。因此未声明的 -`flash`、`monitor` 或 `debug` 在**每一个**目标上都是错误,并点名那个槽、打印可以 -粘贴的键。 +## 是否终止由声明决定,不由推断 -靠在构建宿主上运行程序来让 `mcpp flash` 成功,正是这个槽存在所要防止的那种失败。 +| | 含义 | +|---|---| +| 默认 | 运行到结束;退出码即判决 | +| `runner_longlived(name)` | 没有自然终点;由操作者结束 | -## 独占的设备 +`openocd -c "program image.elf verify reset exit"` 会终止,`openocd -c "init"` +不会,两者拼写直到包所选的那个参数为止都一样。没有任何 argv 能表达这个区别,而引擎 +也没有一份名字表可供推断 —— 所以由包陈述。 -一块物理板是一把互斥锁,模拟器不是。`mcpp test` 在一个工作者池上跑测试二进制, -而两个进程去够同一个探针不会干净地失败 —— 它们互相穿插,产生的判决不描述其中 -任何一个测试。 +`mcpp run --runner debug` 启动一个**服务端**,到此为止。连上去的客户端是用户的 +调试器或 IDE,它通过机器输出协议获得所需。 -板自己说出这件事: +## 不能重叠的运行 -```cpp -mcpp::runner_exclusive(); -``` +`mcpp test` 在工作者池上跑测试二进制。一块板配一个探针、一张 GPU、一个串口、 +一个单席位 license 的工具,都一次只容一个使用者;两个工作者去够它不会干净地失败 +—— 它们互相穿插,产生的判决不描述其中任何一个测试。 -`mcpp test` 于是在那个目标上一次跑一个测试,并报告它正在这么做。工程永远不必记得 +包用 `mcpp::run_exclusive()` 说出这件事,`mcpp test` 于是串行化。工程永远不必记得 `-j1`。 -## 模拟器与硬件是同一个包 +**按性质命名而不按硬件命名**:这里没有一处是关于「设备」的。 -经模拟器抵达的板,与经调试探针抵达的同一块板,差别只在设备槽的 argv,别无其他。 -链接脚本、启动代码、内存映射与导出的模块都是同一块板。为了变化四个字符串而发布 -两个包,会把这一切复制一遍,并让两份副本各自漂移。 +## 模拟器与硬件是同一个包 -因此这个选择是**一个包的一个 feature**: +经模拟器抵达的板,与经调试探针抵达的同一块板,差别只在 runner 的 argv,别无其他。 +链接脚本、启动代码、内存映射与导出的模块都是同一块板。为变化几个字符串而发布两个 +包,会把这一切复制一遍并让副本漂移。 ```toml [features] default = ["emulator"] -emulator = [] -hardware = [] +emulator = {} +hardware = {} ``` ```cpp -int main() { - if (mcpp::has_feature("hardware")) { - for (auto a : {"probe-rs", "run", "--chip", "STM32L475VG"}) - mcpp::runner(a); - for (auto a : {"probe-rs", "download", "--verify", "--chip", "STM32L475VG"}) - mcpp::flash(a); - mcpp::runner_exclusive(); - } else { - mcpp::runner(qemu_path()); - for (auto a : {"-machine", "mps2-an385", "-nographic", "-semihosting", - "-no-reboot", "-kernel"}) - mcpp::runner(a); - } - return 0; +if (mcpp::has_feature("hardware")) { + for (auto a : {"probe-rs","run","--chip","STM32L475VG"}) + mcpp::runner(a); // 移动的是**默认** + for (auto a : {"probe-rs","gdb","--chip","STM32L475VG"}) + mcpp::runner("debug", a); + mcpp::runner_longlived("debug"); + mcpp::run_exclusive(); +} else { + for (auto a : {"qemu-system-arm","-machine","mps2-an385","-nographic", + "-semihosting","-no-reboot","-kernel"}) + mcpp::runner(a); } ``` -消费者在它选择其他一切的地方选择环境: - ```toml [dependencies] -demo-board-rt = { version = "0.1.0", features = ["hardware"] } +cortex-m-rt = { version = "0.1.0", features = ["hardware"] } ``` -所选环境不提供的槽保持缺席。模拟器没有调试探针,所以在 emulator feature 之下 -`mcpp debug` 报告没有配置,而不是发明一个。 - -⭐ 这**不需要任何引擎机制**。引擎读槽,对模拟器与探针一无所知; -`mcpp::has_feature` 本来就存在。这个问题不必新增任何东西就能回答,正是分层按规定 -在起作用。 - -## 优先级与报告 - -每个槽都有两个生产者,优先级是通常的那个:工程作者写的胜过依赖提供的。覆盖会被 -报告,而不是静默应用。 - -``` - note [target.thumbv7em-none-eabihf].flash overrides the flash a dependency supplied -``` +**消费者的命令不变。** 所选环境不提供的 runner 保持缺席:模拟器没有调试探针, +于是在 emulator 之下 `mcpp run --runner debug` 报告没有这个 runner,并列出有哪些。 -一个槽只允许一个依赖提供。两个依赖的链接标志会拼接,那是对的;两个烧录器不能, -拼接产生的 argv 不属于其中任何一个。第二个提供者是一个点名两个包的错误。 +⭐ **这不需要任何引擎机制。** 引擎读 runner,对模拟器与探针一无所知; +`mcpp::has_feature` 本来就在。**一个问题不必新增任何东西就能回答,是分层按规定在 +起作用。** diff --git a/docs/zh/19-supported-versions.md b/docs/zh/19-supported-versions.md index 9b4eeb72..4ce51722 100644 --- a/docs/zh/19-supported-versions.md +++ b/docs/zh/19-supported-versions.md @@ -56,10 +56,10 @@ error: --locked was given and this resolution differs from mcpp.lock: ## 物料清单 -`mcpp sbom` 写出一份描述**已记录**解析的 CycloneDX 1.5 文档: +`mcpp emit sbom` 写出一份描述**已记录**解析的 CycloneDX 1.5 文档: ```bash -mcpp sbom -o sbom.json +mcpp emit sbom -o sbom.json ``` 它读 `mcpp.lock` 而不是重新解析,因为一份描述了与所构建者不同的图的文档,比没有 diff --git a/modules/buildmcpp/src/directives.cppm b/modules/buildmcpp/src/directives.cppm index b1824f4a..30cf3d73 100644 --- a/modules/buildmcpp/src/directives.cppm +++ b/modules/buildmcpp/src/directives.cppm @@ -76,24 +76,28 @@ enum class Slot : std::size_t { // is neither a compile input nor a link input, and putting it in LdFlags // would put an emulator's argv on the linker command line. Runner, - // ⭐⭐ THE THREE SIBLINGS OF `Runner`, AND THE COLUMN THAT SEPARATES THEM. + // ⭐⭐ A NAMED WAY OF REACHING THE ARTEFACT. ONE SLOT, ANY NUMBER OF NAMES. // - // Writing an artefact to a device, watching what it prints and attaching a - // debugger have `Runner`'s shape exactly: an argv the BOARD knows and a - // TOOL performs. They are slots for the same reason `Runner` is one — an - // emulator's argv is neither a compile input nor a link input. + // `Runner` above is the default — how the artefact is EXECUTED. Writing it + // to a device, watching what it prints, starting a debug server, deploying + // it, serving it: all the same shape, an argv the PACKAGE supplies, and the + // only thing that distinguishes them is a name. // - // ⚠️ WHAT NO ARGV CAN SAY IS WHICH ONE ENDS. `Runner` and `Flash` finish - // and hand back an exit code; `Monitor` and `Debug` do not terminate on - // their own, so for them a live process IS the success condition and for - // the other two it is a hang. `semantics_of` below answers that from the - // SLOT, because the tokens cannot. - Flash, - Monitor, - Debug, - // Not an argv at all: a board stating that it is a mutex. See - // `BuildConfig::runnerExclusive`. - RunnerExclusive, + // ⚠️ THE NAME IS DATA. An earlier version gave `flash`, `monitor` and + // `debug` their own slots — which put EMBEDDED vocabulary in the engine, + // so a web package could not add `serve` nor a cluster package `submit` + // without an engine release. The value here is `:`, one token + // per line as argv requires, and the engine never learns a name. + NamedRunner, + // Marks a named runner as having no natural end (``). Declared rather + // than derived: `openocd -c "program … exit"` terminates and + // `openocd -c "init"` does not, spelled alike up to the argument the + // package chose, and deriving it from a name would work only for names the + // engine knows. + RunnerLongLived, + // Not an argv: a package stating that this target's device admits one user + // at a time. See `BuildConfig::runExclusive`. + RunExclusive, CxxFlags, CFlags, LdFlags, @@ -122,50 +126,14 @@ enum class Slot : std::size_t { }; inline constexpr std::size_t kSlotCount = static_cast(Slot::Count); -// ⭐⭐ HOW A DEVICE ACTION'S PROCESS ENDS, WHICH IS A PROPERTY OF THE SLOT. +// ⚠️ THERE IS DELIBERATELY NO LIST OF ACTION NAMES HERE. // -// The four device slots share an argv shape and differ in exactly one way that -// the engine has to act on: whether the process is expected to terminate. -// -// OneShot `run`, `flash` — runs to completion; the exit code is the verdict -// LongLived `monitor`, `debug` — has no natural end; the operator ends it, -// and a non-zero status after Ctrl-C is that, not a failure -// -// ⚠️ NO TOKEN IN THE TEMPLATE CARRIES THIS. `openocd -c "program {} verify -// reset exit"` terminates and `openocd -c "init"` does not, and both are -// spelled the same way up to the argument the board chose. So it is read from -// the slot, and a board cannot get it wrong by writing its argv differently. -// -// ⚠️ AND `debug` IS `LongLived` RATHER THAN A THIRD VALUE. It starts a GDB -// SERVER; the client that attaches to it is the user's debugger or their IDE, -// which reaches mcpp through the machine-output protocol (docs/11) and not -// through this table. Driving the client would put mcpp in the middle of a -// session it has nothing to add to. -enum class Semantics { OneShot, LongLived }; - -inline constexpr Semantics semantics_of(Slot s) { - return (s == Slot::Monitor || s == Slot::Debug) ? Semantics::LongLived - : Semantics::OneShot; -} - -// The device slots, in the order a user meets them. Iterated rather than -// hand-listed wherever all four must be handled, so a fifth cannot be added to -// one site and missed at another. -inline constexpr Slot kDeviceSlots[] = { Slot::Runner, Slot::Flash, - Slot::Monitor, Slot::Debug }; - -// The user-facing name of a device slot: the `mcpp ` subcommand, the -// `[target.].` key and the `mcpp:=` directive are all this -// one string, which is why it has a single read point. -inline constexpr std::string_view device_slot_name(Slot s) { - switch (s) { - case Slot::Runner: return "runner"; - case Slot::Flash: return "flash"; - case Slot::Monitor: return "monitor"; - case Slot::Debug: return "debug"; - default: return {}; - } -} +// An earlier version carried `kDeviceSlots`, `device_slot_name()` and a +// `Semantics` function switching on four hardcoded values. Every one of them +// was a place the engine decided which domains were expressible. What replaced +// them is a map keyed by whatever a package wrote, and a `longLived` flag the +// package sets — so `flash`, `serve`, `submit` and `logcat` are the same kind +// of thing to this file, which is to say: nothing it knows about. // Who sees the value. The field that must be answered for every new directive. enum class Scope { @@ -229,7 +197,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}, @@ -275,10 +243,9 @@ inline constexpr std::array kTable{{ // OWNER home — measured in CI as `xlings: '…' is not installed` from a job // where the same name had answered `--version` two steps earlier. {"runner", "runner", Slot::Runner, Scope::RunGlobal, Transform::Verbatim, false, "", "", 4}, - {"flash", "flash", Slot::Flash, Scope::RunGlobal, Transform::Verbatim, false, "", "", 6}, - {"monitor", "monitor", Slot::Monitor, Scope::RunGlobal, Transform::Verbatim, false, "", "", 6}, - {"debug", "debug", Slot::Debug, Scope::RunGlobal, Transform::Verbatim, false, "", "", 6}, - {"runner-exclusive", "runner-exclusive", Slot::RunnerExclusive, Scope::RunGlobal, Transform::Verbatim, false, "", "", 6}, + {"runner-named", "runner-named", Slot::NamedRunner, Scope::RunGlobal, Transform::Verbatim, false, "", "", 6}, + {"runner-longlived", "runner-longlived", Slot::RunnerLongLived, Scope::RunGlobal, Transform::Verbatim, false, "", "", 6}, + {"run-exclusive", "run-exclusive", Slot::RunExclusive, Scope::RunGlobal, Transform::Verbatim, false, "", "", 6}, {"link-script", "ldflag", Slot::LdFlags, Scope::LinkGlobal, Transform::LinkerScript, false, "", "", 3}, {"include-dir", "include-dir", Slot::IncludeDirs, Scope::PackagePrivate, Transform::AbsPath, false, "", "", 1}, {"include-dir-after", "include-dir-after", Slot::IncludeDirsAfter, Scope::PackagePrivate, Transform::AbsPath, false, "", "", 1}, @@ -735,9 +702,6 @@ void apply(mcpp::manifest::Manifest& m, const Directives& d) { auto const& c = d.at(Slot::CFlags); auto const& ld = d.at(Slot::LdFlags); auto const& runner = d.at(Slot::Runner); - auto const& flash = d.at(Slot::Flash); - auto const& monitor = d.at(Slot::Monitor); - auto const& debugTpl = d.at(Slot::Debug); auto const& defines = d.at(Slot::Defines); bc.cxxflags.insert(bc.cxxflags.end(), cxx.begin(), cxx.end()); @@ -745,14 +709,20 @@ void apply(mcpp::manifest::Manifest& m, const Directives& d) { bc.ldflags.insert(bc.ldflags.end(), ld.begin(), ld.end()); // Appended in emission order — the tokens ARE the argv. bc.runner.insert(bc.runner.end(), runner.begin(), runner.end()); - bc.flash.insert(bc.flash.end(), flash.begin(), flash.end()); - bc.monitor.insert(bc.monitor.end(), monitor.begin(), monitor.end()); - bc.debugger.insert(bc.debugger.end(), debugTpl.begin(), debugTpl.end()); - // ⚠️ ANY non-empty value sets it, and there is deliberately no way to unset - // it from a second package. Exclusivity is a claim about the DEVICE: if one - // package in the graph knows the target is a mutex, it is one, and a later - // package saying nothing must not relax that. - if (!d.at(Slot::RunnerExclusive).empty()) bc.runnerExclusive = true; + // ⭐ `:`, split on the FIRST colon. A token may contain colons + // (a Windows path, a URL); a name may not, which is what makes the first + // one unambiguous. + for (auto const& entry : d.at(Slot::NamedRunner)) { + auto sep = entry.find(':'); + if (sep == std::string::npos || sep == 0) continue; + bc.namedRunners[entry.substr(0, sep)].argv.push_back(entry.substr(sep + 1)); + } + for (auto const& name : d.at(Slot::RunnerLongLived)) + bc.namedRunners[name].longLived = true; + // ⚠️ ANY non-empty value sets it, and nothing can unset it. Exclusivity is a + // claim about the DEVICE: if one package knows the target is a mutex, it is + // one, and a later package saying nothing must not relax that. + if (!d.at(Slot::RunExclusive).empty()) bc.runExclusive = true; // cfg defines colour BOTH language channels — the one slot that fans out. bc.cflags.insert(bc.cflags.end(), defines.begin(), defines.end()); bc.cxxflags.insert(bc.cxxflags.end(), defines.begin(), defines.end()); diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index 2ca04a94..d340e654 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -2142,43 +2142,67 @@ std::expected parse_string(std::string_view content, // artifact cannot execute here. An ARRAY, so it is neither a // scalar (the unknown-key sweep below skips it by type) nor part // of the conditional sub-table channel. - // - // ⭐ FOUR KEYS, ONE LOOP. `runner` was alone until `flash`, - // `monitor` and `debug` joined it, and they are the same key in - // every respect a parser can see: an array of strings, empty is an - // error, non-strings are an error. Writing the second one out by - // hand is how the third and fourth acquire slightly different - // diagnostics. - struct DeviceKey { std::string_view name; std::vector TargetEntry::*into; }; - static constexpr std::string_view kExample = - "[\"qemu-system-riscv64\", \"-kernel\"]"; - const DeviceKey kDeviceKeys[] = { - { "runner", &TargetEntry::runner }, - { "flash", &TargetEntry::flash }, - { "monitor", &TargetEntry::monitor }, - { "debug", &TargetEntry::debugger }, - }; - for (auto const& dk : kDeviceKeys) { - auto it = body.find(std::string(dk.name)); - if (it == body.end()) continue; + // `runner` — the argv template `mcpp run` uses for a target whose + // artefact cannot execute here. An ARRAY, so it is neither a + // scalar (the unknown-key sweep below skips it by type) nor part + // of the conditional sub-table channel. + if (auto it = body.find("runner"); it != body.end()) { if (!it->second.is_array()) { return std::unexpected(error(origin, std::format( - "[target.{}].{} must be an array of strings, e.g. {} = {}", - triple, dk.name, dk.name, kExample))); + "[target.{}].runner must be an array of strings, " + "e.g. runner = [\"qemu-system-riscv64\", \"-kernel\"]", + triple))); } - auto& dest = e.*(dk.into); for (auto& el : it->second.as_array()) { if (!el.is_string()) { return std::unexpected(error(origin, std::format( - "[target.{}].{} must contain only strings", - triple, dk.name))); + "[target.{}].runner must contain only strings", triple))); } - dest.push_back(el.as_string()); + e.runner.push_back(el.as_string()); } - if (dest.empty()) { + if (e.runner.empty()) { + return std::unexpected(error(origin, std::format( + "[target.{}].runner is empty — an empty template would " + "run nothing and report success", triple))); + } + } + + // ⭐ `[target..runners]` — the NAMED ways of reaching this + // target's artefact, one key per name. + // + // The engine knows none of these names. `flash`, `monitor`, + // `debug`, `serve`, `deploy`, `submit` are all the same thing to + // it: an argv a package supplies and `mcpp ` performs. A + // fixed set of keys here would decide, in the engine, which domains + // are expressible. + if (auto it = body.find("runners"); it != body.end()) { + if (!it->second.is_table()) { return std::unexpected(error(origin, std::format( - "[target.{}].{} is empty — an empty template would do " - "nothing and report success", triple, dk.name))); + "[target.{}].runners must be a table of name = [argv], " + "e.g. [target.{}.runners] then flash = [\"probe-rs\", " + "\"download\", \"{{}}\"]", triple, triple))); + } + for (auto& [name, val] : it->second.as_table()) { + if (!val.is_array()) { + return std::unexpected(error(origin, std::format( + "[target.{}.runners].{} must be an array of strings", + triple, name))); + } + std::vector argv; + for (auto& el : val.as_array()) { + if (!el.is_string()) { + return std::unexpected(error(origin, std::format( + "[target.{}.runners].{} must contain only strings", + triple, name))); + } + argv.push_back(el.as_string()); + } + if (argv.empty()) { + return std::unexpected(error(origin, std::format( + "[target.{}.runners].{} is empty — an empty template " + "would do nothing and report success", triple, name))); + } + e.namedRunners[name] = std::move(argv); } } @@ -2214,9 +2238,7 @@ std::expected parse_string(std::string_view content, static constexpr std::string_view kKnownTargetScalars[] = { "cxx_runtime", "linkage", "sysroot", "toolchain", }; - static constexpr std::string_view kKnownTargetArrays[] = { - "debug", "flash", "monitor", "runner", - }; + static constexpr std::string_view kKnownTargetArrays[] = { "runner" }; for (auto& [key, value] : body) { if (value.is_table()) continue; // the conditional channel const std::span known = value.is_array() @@ -2225,8 +2247,8 @@ std::expected parse_string(std::string_view content, if (std::ranges::find(known, key) != known.end()) continue; m.schemaWarnings.push_back(std::format( "[target.{}] has unsupported key '{}' (ignored). Supported keys: " - "cxx_runtime, debug, flash, linkage, monitor, runner, sysroot, " - "toolchain. " + "cxx_runtime, linkage, runner, sysroot, toolchain, plus the " + "[target..runners] table for named runners. " "Per-role contracts go in [build].cxx_runtime's table form.", triple, key)); } diff --git a/modules/manifest/src/types.cppm b/modules/manifest/src/types.cppm index d91db72c..47e1dcc8 100644 --- a/modules/manifest/src/types.cppm +++ b/modules/manifest/src/types.cppm @@ -438,6 +438,22 @@ struct Resources { // Inherits the additive inputs rather than nesting them: `buildConfig.cflags` // is read in ~150 places, and a BuildConfig genuinely IS a set of build // inputs plus the selection axis and resolved policy scalars. +// One named way of reaching the artefact. +// +// ⚠️ `longLived` IS DECLARED, NOT DERIVED FROM THE NAME. Whether the process +// ends is the one thing the engine must act on and no argv can express: +// `openocd -c "program … exit"` terminates and `openocd -c "init"` does not, +// spelled alike up to the argument the package chose. Deriving it from a name +// would work only for names the engine knows — the coupling this removes. +// +// false runs to completion; the exit code is the verdict +// true no natural end; the operator ends it, and a non-zero status +// afterwards is that rather than a failure +struct NamedRunner { + std::vector argv; + bool longLived = false; +}; + struct BuildConfig : BuildInputs { // How `mcpp run` / `mcpp test` execute an artifact this host cannot run, // as an argv template (the artifact path is appended, or substituted for @@ -456,31 +472,24 @@ struct BuildConfig : BuildInputs { // argv that is neither one's, and it would fail at exec time with no // indication of which package contributed which token. std::vector runner; - // ⭐⭐ THE THREE SIBLINGS OF `runner`, AND WHY THEY ARE SLOTS RATHER THAN - // COMMANDS. - // - // Executing an artifact, writing it to a device, watching what it prints - // and attaching a debugger are one shape: an argv that the BOARD knows and - // a TOOL performs, addressed by absolute path, with the artifact appended - // or substituted for `{}`. `runner` has carried that shape since 2026.8.19; - // three special-case commands would have carried it three more times. - // - // ⚠️ THEY ARE NOT INTERCHANGEABLE, AND THE DIFFERENCE IS THE PROCESS AND - // NOT THE ARGV. `run` and `flash` finish and report an exit code; `monitor` - // and `debug` do not end on their own, so "the process is still alive" is - // success for them and a hang for the other two. That is `Semantics`, which - // the engine reads from the slot rather than from the tokens — no argv can - // say which of the two it is. - std::vector flash; - std::vector monitor; - std::vector debugger; - // ⚠️ ONE BOARD IS A MUTEX, AND NOTHING ELSE IN THE BUILD IS. - // - // `mcpp test` runs test binaries on a pool of workers. An emulator takes - // N instances happily; a physical board takes one, and two probes reaching - // for the same device do not fail — they interleave. The board knows this - // about itself, so it says so, and a project never has to remember `-j1`. - bool runnerExclusive = false; + // ⭐⭐ NAMED WAYS OF REACHING THE ARTEFACT, AND THE ENGINE KNOWS NONE OF + // THEIR NAMES. + // + // `runner` above is how the artefact is EXECUTED. Writing it to a device, + // watching what it prints, starting a debug server, deploying it, serving + // it — every one of those is the same shape: an argv the PACKAGE knows and + // a tool performs, with the artefact appended or substituted for `{}`. The + // only thing distinguishing them is a name. + // + // ⚠️ SO THE NAME IS DATA, NOT VOCABULARY. An earlier version of this gave + // `flash`, `monitor` and `debug` their own members, enum values, TOML keys + // and subcommands — four instances of one idea, where a fifth would have + // touched nine places. Worse, it put EMBEDDED vocabulary into the engine: + // a web package could not add `serve`, nor a cluster package `submit`, + // without an engine release. A map has neither problem, and every domain + // gets the same flow. + std::map namedRunners; + bool runExclusive = false; // Was `sources` WRITTEN, as opposed to merely being empty? // @@ -844,13 +853,12 @@ struct TargetEntry { // engine a different board has to fight. The artifact path is appended, or // substituted for `{}` when the template contains it. std::vector runner; - // The project's override for each of `runner`'s siblings, on the same axis + // The project's override for a NAMED runner, on the same axis as `runner` // and with the same precedence: what the author of THIS project wrote beats - // what a dependency supplied, and the override is reported rather than - // applied in silence. - std::vector flash; - std::vector monitor; - std::vector debugger; + // what a dependency supplied, and the override is reported. Written as + // `[target..runners]`, one key per name. + std::map> namedRunners; + // #336 — per-target C++ runtime contract, same vocabulary as // [build].cxx_runtime and overriding it for this triple. It lives HERE, // beside `linkage`, rather than in the `cfg(...)` conditional channel: diff --git a/src/build/execute.cppm b/src/build/execute.cppm index 04344f1a..1d9b9bd6 100644 --- a/src/build/execute.cppm +++ b/src/build/execute.cppm @@ -460,6 +460,7 @@ struct RunnerChoice { bool freestanding = false; // an EMPTY tmpl is fatal when true bool fromManifest = false; // the consumer overrode a dependency's bool ignored = false; // --no-runner dropped a declared template + bool longLived = false; // declared by the package; no natural end // The spelling that names this target in the manifest: the canonical form, // which is also the output directory's name and the key every // `[target.]` reader resolves. Every diagnostic below prints this @@ -482,45 +483,22 @@ struct RunnerChoice { // let them drift apart — the shape this file's own header warns about. // // `which` selects the slot; everything else is shared. -struct DeviceSlotAccess { - const std::vector mcpp::manifest::BuildConfig::*fromGraph; - const std::vector mcpp::manifest::TargetEntry::*fromProject; -}; - -inline DeviceSlotAccess device_slot_access(mcpp::build::directives::Slot which) { - using BC = mcpp::manifest::BuildConfig; - using TE = mcpp::manifest::TargetEntry; - switch (which) { - case mcpp::build::directives::Slot::Flash: - return { &BC::flash, &TE::flash }; - case mcpp::build::directives::Slot::Monitor: - return { &BC::monitor, &TE::monitor }; - case mcpp::build::directives::Slot::Debug: - return { &BC::debugger, &TE::debugger }; - default: - return { &BC::runner, &TE::runner }; - } -} - -// The run slot, which is what every existing caller means. Named separately so -// the call sites that predate the other three read as they always did. -RunnerChoice choose_runner(const BuildContext& ctx, bool noRunner = false); - RunnerChoice choose_device_action(const BuildContext& ctx, - mcpp::build::directives::Slot which, + std::string_view which, bool noRunner = false) { RunnerChoice c; - const auto acc = device_slot_access(which); + const bool isDefault = which.empty(); const auto ft = mcpp::toolchain::triple::parse(ctx.tc.targetTriple); if (ft) c.freestanding = ft->is_freestanding(); c.tripleKey = ft ? ft->str() : ctx.tc.targetTriple; - // Two producers, ordinary precedence: what the author of THIS project - // wrote beats what a dependency supplied. The dependency is the normal - // case on bare metal (a board-support package computes the emulator's - // absolute path); the manifest key exists for swapping `-bios default` - // for `-bios none -semihosting` while debugging, and — on a hosted cross - // triple — for naming the user-mode emulator at all. - c.tmpl = ctx.manifest.buildConfig.*(acc.fromGraph); + // The graph's answer: the default runner, or a named one a package supplied. + if (isDefault) { + c.tmpl = ctx.manifest.buildConfig.runner; + } else if (auto it = ctx.manifest.buildConfig.namedRunners.find(std::string(which)); + it != ctx.manifest.buildConfig.namedRunners.end()) { + c.tmpl = it->second.argv; + c.longLived = it->second.longLived; + } // The manifest key is the CANONICAL spelling — `aarch64-macos`, the name // of the output directory and the key every other `[target.]` // reader uses (prepare.cppm resolves overrides by `t.str()`). The @@ -531,16 +509,25 @@ RunnerChoice choose_device_action(const BuildContext& ctx, // kept as a fallback for a triple the parser does not know. auto lookup = [&](std::string_view key) { auto it = ctx.manifest.targetOverrides.find(std::string(key)); - return it != ctx.manifest.targetOverrides.end() - && !(it->second.*(acc.fromProject)).empty() - ? &it->second : nullptr; + const mcpp::manifest::TargetEntry* none = nullptr; + if (it == ctx.manifest.targetOverrides.end()) return none; + if (isDefault) return it->second.runner.empty() ? none : &it->second; + auto nr = it->second.namedRunners.find(std::string(which)); + return (nr != it->second.namedRunners.end() && !nr->second.empty()) + ? &it->second : none; }; const mcpp::manifest::TargetEntry* entry = lookup(c.tripleKey); if (!entry && c.tripleKey != ctx.tc.targetTriple) entry = lookup(ctx.tc.targetTriple); if (entry) { - c.tmpl = entry->*(acc.fromProject); - c.fromManifest = !(ctx.manifest.buildConfig.*(acc.fromGraph)).empty(); + if (isDefault) { + c.fromManifest = !ctx.manifest.buildConfig.runner.empty(); + c.tmpl = entry->runner; + } else { + c.fromManifest = ctx.manifest.buildConfig.namedRunners.contains( + std::string(which)); + c.tmpl = entry->namedRunners.at(std::string(which)); + } } // `--no-runner` is the operator on THIS host stating a host fact the // manifest cannot carry: the triple is native here. On a freestanding @@ -551,7 +538,10 @@ RunnerChoice choose_device_action(const BuildContext& ctx, } RunnerChoice choose_runner(const BuildContext& ctx, bool noRunner) { - return choose_device_action(ctx, mcpp::build::directives::Slot::Runner, noRunner); + return choose_device_action(ctx, std::string_view{}, noRunner); +} +RunnerChoice choose_runner(const BuildContext& ctx) { + return choose_device_action(ctx, std::string_view{}, false); } // The capacity number, printed because capacity is the constraint. @@ -1018,6 +1008,44 @@ fast_path_identity(const std::filesystem::path& projectRoot, // Try to fast-path: if build.ninja is newer than all inputs, just run ninja. // Returns exit code on fast-path, or nullopt if full rebuild needed. +// ⭐ WHAT THIS PROJECT CAN DO, AS OPPOSED TO WHAT THE ENGINE SUPPORTS. +// +// The engine knows no runner names, so it cannot print a static list of them — +// and that is the useful property, not a limitation. What a reader wants is +// what THIS graph supplies, which is knowable only after resolution. +export int list_runners(const std::string& package_filter, + const std::string& cache_mode, bool no_cache, + const std::string& target_triple) { + mcpp::build::BuildOverrides ov; + ov.package_filter = package_filter; + ov.cache_mode = no_cache ? std::string("off") : cache_mode; + ov.target_triple = target_triple; + auto ctx = prepare_build(/*print_fp=*/false, /*includeDevDeps=*/false, + /*extraTargets=*/{}, ov); + if (!ctx) { mcpp::ui::error(ctx.error()); return 2; } + + const auto& bc = ctx->manifest.buildConfig; + const auto ft = mcpp::toolchain::triple::parse(ctx->tc.targetTriple); + const std::string key = ft ? ft->str() : ctx->tc.targetTriple; + std::println("Target {}", key); + + if (bc.runner.empty() && bc.namedRunners.empty()) { + std::println(" (none — this project reaches its artifact by executing it)"); + return 0; + } + if (!bc.runner.empty()) + std::println(" {:<12} {}", "(default)", bc.runner.front()); + for (auto const& [name, nr] : bc.namedRunners) { + std::println(" {:<12} {}{}", name, + nr.argv.empty() ? std::string("(no argv)") : nr.argv.front(), + nr.longLived ? " [long-lived]" : ""); + } + if (bc.runExclusive) + std::println(" note: this target's runs cannot overlap; `mcpp test` " + "serialises them"); + return 0; +} + export std::optional try_fast_build(const std::filesystem::path& projectRoot, bool verbose, bool no_cache, std::string_view currentTarget = "") { @@ -1342,8 +1370,11 @@ export int build_run_target(const std::optional& targetName, bool no_cache = false, const std::string& target_triple = {}, bool no_runner = false, - mcpp::build::directives::Slot device_slot - = mcpp::build::directives::Slot::Runner) { + // ⭐ The NAME of the way to reach the artefact. + // Empty is the default runner — `mcpp run`. Any + // other value came from `--runner ` and the + // engine has never seen it before. + std::string_view runner_name = {}) { // mcpp#225 (E2): reuse the resolved build cache when it's still fresh, // skipping prepare_build's toolchain resolution + modgraph scan // entirely — mirrors cmd_build's try_fast_build fast path. The cached @@ -1370,7 +1401,7 @@ export int build_run_target(const std::optional& targetName, // // The guard is the slot rather than a flag, because the property that // makes the fast path wrong here is what the slot means. - && device_slot == mcpp::build::directives::Slot::Runner) { + && runner_name.empty()) { if (auto root = mcpp::project::find_manifest_root(std::filesystem::current_path())) { if (auto rc = try_fast_run(*root, targetName, passthrough)) { return *rc; @@ -1441,17 +1472,18 @@ export int build_run_target(const std::optional& targetName, // absolute path that a static manifest cannot. The explicit key exists for // the other case: swapping `-bios default` for `-bios none -semihosting` // while debugging, or naming `qemu-aarch64-static` for a cross target. - namespace dirs = mcpp::build::directives; - const auto slotName = dirs::device_slot_name(device_slot); - const bool isRunSlot = (device_slot == dirs::Slot::Runner); - const auto choice = choose_device_action(*ctx, device_slot, no_runner); + const bool isRunSlot = runner_name.empty(); + const std::string slotName{runner_name}; + const auto choice = choose_device_action(*ctx, runner_name, no_runner); if (choice.ignored) mcpp::ui::info("note", std::format( "--no-runner: ignoring the runner declared for {}", choice.tripleKey)); if (choice.fromManifest) mcpp::ui::info("note", std::format( - "[target.{}].{} overrides the {} a dependency supplied", - choice.tripleKey, slotName, slotName)); + "[target.{}] overrides the {} a dependency supplied", + choice.tripleKey, + isRunSlot ? std::string("runner") + : std::format("runner '{}'", slotName))); // ⚠️ THE THREE NEW SLOTS HAVE NO FALLBACK, AND `run` STILL DOES. // // An artefact with no runner on a hosted target is executed directly, and @@ -1461,17 +1493,26 @@ export int build_run_target(const std::optional& targetName, // freestanding one. Saying "nothing is configured" beats doing something // that was never asked for. if (!isRunSlot && choice.tmpl.empty()) { + // ⚠️ AND THE MESSAGE LISTS WHAT THIS PROJECT DOES HAVE. A name the + // engine does not know is usually a typo or a missing feature, and + // "no such runner" alone leaves the reader guessing which. + std::string have; + for (auto const& [n, _] : ctx->manifest.buildConfig.namedRunners) + have += (have.empty() ? "" : ", ") + n; std::println(stderr, - "error: no {} is configured for '{}'.\n" - " Declare how to {} this target's artefact:\n" + "error: this project has no runner named '{}' for '{}'.\n" + " Available: {}\n" + " A package supplies one with `mcpp::runner(\"{}\", …)`, or a\n" + " project declares it:\n" "\n" - " [target.{}]\n" + " [target.{}.runners]\n" " {} = [\"\", \"\", \"{{}}\"]\n" "\n" - " The artefact path is appended, or substituted for `{{}}` when\n" - " the template contains it. A board-support package normally\n" - " supplies this, so that a project does not have to.", - slotName, choice.tripleKey, slotName, choice.tripleKey, slotName); + " The artefact path is appended, or substituted for `{{}}`.", + slotName, choice.tripleKey, + have.empty() ? "(none — no package in this graph supplies a named runner)" + : have, + slotName, choice.tripleKey, slotName); return 2; } if (isRunSlot && choice.freestanding && choice.tmpl.empty()) { @@ -1500,13 +1541,15 @@ export int build_run_target(const std::optional& targetName, tmpl.front() = found.program->string(); argv = mcpp::freestanding::expand(tmpl, exe); for (auto& a : passthrough) argv.push_back(a); - mcpp::ui::status( - isRunSlot ? "Running" - : (device_slot == dirs::Slot::Flash ? "Flashing" - : device_slot == dirs::Slot::Monitor ? "Monitoring" - : "Debugging"), - std::format("`{} … {}`", choice.tmpl.front(), - mcpp::ui::shorten_path(exe, pathCtx))); + // ⭐ The status word is the NAME the package chose, capitalised. The + // engine has no table of verbs to look one up in, which is the point: + // `Serve`, `Submit` and `Flash` all read correctly and none is known + // here. + std::string verb = isRunSlot ? std::string("Running") : slotName; + if (!isRunSlot && !verb.empty()) + verb[0] = static_cast(std::toupper(verb[0])); + mcpp::ui::status(verb, std::format("`{} … {}`", choice.tmpl.front(), + mcpp::ui::shorten_path(exe, pathCtx))); } else { argv.push_back(exe.string()); for (auto& a : passthrough) argv.push_back(a); @@ -2012,13 +2055,13 @@ export int run_tests(std::span passthrough, // board does not. Two `probe-rs` processes reaching for the same device // do not fail cleanly — they interleave, and the verdict they produce is // about neither test. Nothing in the argv says which case this is, so - // the BOARD says it, once, with `mcpp:runner-exclusive=1`, and the + // the BOARD says it, once, with `mcpp:run-exclusive=1`, and the // project never has to remember `-j1`. // // Clamped rather than made an error: an exclusive target with one test // is an ordinary run, and refusing it would turn a correct // configuration into a failure. - const bool exclusiveDevice = ctx->manifest.buildConfig.runnerExclusive + const bool exclusiveDevice = ctx->manifest.buildConfig.runExclusive && !runnerChoice.tmpl.empty(); const int runJobsHere = exclusiveDevice ? 1 : runJobs; if (exclusiveDevice && runJobs > 1 && list.size() > 1) { diff --git a/src/build/hostprogram.cppm b/src/build/hostprogram.cppm index e449e92d..511266b1 100644 --- a/src/build/hostprogram.cppm +++ b/src/build/hostprogram.cppm @@ -68,24 +68,22 @@ inline void include_dir_after(const char* dir) { std::printf("mcpp:include-di // claiming to know how to run the artifact is a configuration error, and mcpp // reports it naming both rather than merging them. inline void runner(const char* token) { std::printf("mcpp:runner=%s\n", token); } -// ⭐ `runner`'s three siblings. Same shape, one token per call, because argv is -// ordered and a single string cannot say where the boundaries are. +// ⭐ A NAMED way of reaching the artefact. The engine learns the name from +// here and knows nothing else about it, so `flash`, `serve`, `deploy`, +// `submit` and `logcat` cost the same: nothing. // -// flash write the artefact to the device (ends; exit code is the verdict) -// monitor watch what the device prints (runs until the operator ends it) -// debug start the device's debug SERVER (runs until the operator ends it) -// -// A board that serves both an emulator and real silicon emits different argv -// under `mcpp::has_feature(...)`; the engine reads the slots and knows nothing -// about which environment was chosen. -inline void flash(const char* token) { std::printf("mcpp:flash=%s\n", token); } -inline void monitor(const char* token) { std::printf("mcpp:monitor=%s\n", token); } -inline void debug(const char* token) { std::printf("mcpp:debug=%s\n", token); } -// ⚠️ THE DEVICE IS A MUTEX. Declared by the BOARD, because the board is what -// knows whether "this target" is one piece of silicon on one probe or an -// emulator that takes as many instances as there are cores. `mcpp test` clamps -// its worker pool when this is set, so a project never has to remember `-j1`. -inline void runner_exclusive() { std::printf("mcpp:runner-exclusive=1\n"); } +// One token per call, because argv is ordered and a single string cannot say +// where its boundaries are. The user reaches it with `mcpp run --runner `. +inline void runner(const char* name, const char* token) { + std::printf("mcpp:runner-named=%s:%s\n", name, token); +} +// This named runner has no natural end — a console monitor, a debug server. +// ⚠️ DECLARED RATHER THAN DERIVED FROM THE NAME: the engine has no list of +// names to derive it from, which is the point. +inline void runner_longlived(const char* name) { + std::printf("mcpp:runner-longlived=%s\n", name); +} +inline void run_exclusive() { std::printf("mcpp:run-exclusive=1\n"); } // Say something to the user and keep going. // diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index d4d91fa4..db62fccd 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -4552,11 +4552,10 @@ prepare_build(bool print_fingerprint, // Which dependency supplied the runner, for the exactly-one-provider // error below. A name rather than a bool: the message has to name both. std::string runnerProvider; - // ⚠️ ONE PROVIDER PER DEVICE SLOT, TRACKED PER SLOT. `runner` has had this - // rule since #544; `flash`, `monitor` and `debug` inherit it, and each - // needs its OWN provider name — a board may legitimately supply a runner - // while a different package supplies the debug server. - std::string flashProvider, monitorProvider, debuggerProvider; + // ⚠️ ONE PROVIDER PER RUNNER NAME. `runner` has had this rule since #544; + // a NAMED runner inherits it per name, because a board may legitimately + // supply `flash` while a different package supplies `monitor`. + std::map namedRunnerProvider; auto fillXpkgDirs = [&](mcpp::build::BuildProgramEnv& e, const mcpp::manifest::Manifest& owner) { @@ -7230,10 +7229,8 @@ prepare_build(bool print_fingerprint, const auto ldN = bcDep.ldflags.size(); const auto actN = bcDep.actions.size(); const auto runnerN = bcDep.runner.size(); - const auto flashN = bcDep.flash.size(); - const auto monitorN = bcDep.monitor.size(); - const auto debuggerN = bcDep.debugger.size(); - const bool exclusiveBefore = bcDep.runnerExclusive; + auto namedBefore = bcDep.namedRunners; // by value: the delta below + const bool exclusiveBefore = bcDep.runExclusive; if (auto r = mcpp::build::run_build_program( pkg.manifest, pkg.root, host->first, host->second, pkg.manifest.cppStandard, bpEnv); @@ -7262,58 +7259,58 @@ prepare_build(bool print_fingerprint, // with nothing to say which package contributed which token. So // the second provider is a hard error that names BOTH, because // naming only the loser tells the reader half of what they need. - // ⭐⭐ FOUR SLOTS, ONE RULE, APPLIED BY A LOOP. + // ⭐⭐ THE DEFAULT RUNNER AND EVERY NAMED ONE, BY ONE RULE. // - // This block existed for `runner` alone and stated the rule that - // matters: link flags from two dependencies concatenate and that is - // correct, but two runners cannot — appending produces an argv that - // is neither one's. Its three siblings have exactly the same - // property, so they are handled here rather than copied below it. + // Link flags from two dependencies concatenate and that is correct; + // two runners for the same name cannot — appending produces an argv + // that is neither one's and fails at exec with nothing to say which + // package contributed which token. // // ⚠️ MISSING THIS SITE IS HOW THE FEATURE FAILED FIRST. `apply()` - // in the directives module merges a package's own directives into - // its own config; THIS is where a dependency's RunGlobal entries - // reach the ROOT. Wiring only the first left `mcpp flash` reporting - // "no flash is configured" while `mcpp run` found the runner the - // same package supplied in the same build program — measured. - struct SlotForward { - std::string_view name; - std::vector mcpp::manifest::BuildConfig::* member; - std::size_t before; - std::string* provider; - }; - const SlotForward forwards[] = { - { "runner", &mcpp::manifest::BuildConfig::runner, runnerN, &runnerProvider }, - { "flash", &mcpp::manifest::BuildConfig::flash, flashN, &flashProvider }, - { "monitor", &mcpp::manifest::BuildConfig::monitor, monitorN, &monitorProvider }, - { "debug", &mcpp::manifest::BuildConfig::debugger, debuggerN, &debuggerProvider }, - }; - for (auto const& f : forwards) { - auto& depVec = bcDep.*(f.member); - if (depVec.size() <= f.before) continue; + // merges a package's directives into its OWN config; this is where a + // dependency's RunGlobal entries reach the ROOT. Wiring only the + // first left `mcpp run --runner flash` reporting "no such runner" + // while `mcpp run` found the runner the same build program emitted + // three lines away — measured. + if (bcDep.runner.size() > runnerN) { std::vector supplied( - depVec.begin() + static_cast(f.before), - depVec.end()); - auto& rootVec = m->buildConfig.*(f.member); - if (!rootVec.empty() && !f.provider->empty()) { + bcDep.runner.begin() + static_cast(runnerN), + bcDep.runner.end()); + if (!m->buildConfig.runner.empty() && !runnerProvider.empty()) { return std::unexpected(std::format( - "two dependencies both supply a {} for this target: " + "two dependencies both supply a runner for this target: " "'{}' and '{}'.\n" - " A {} is how the artifact is reached — there can " - "only be one.\n" + " A runner is how the artifact is reached — there " + "can only be one.\n" + " Drop one of them, or override both with an " + "explicit [target.].runner.", + runnerProvider, pkg.manifest.package.name)); + } + m->buildConfig.runner = std::move(supplied); + runnerProvider = pkg.manifest.package.name; + } + for (auto const& [name, nr] : bcDep.namedRunners) { + auto before = namedBefore.find(name); + const bool grew = (before == namedBefore.end()) + || nr.argv.size() > before->second.argv.size() + || (nr.longLived && !before->second.longLived); + if (!grew) continue; + auto& slot = m->buildConfig.namedRunners[name]; + auto& who = namedRunnerProvider[name]; + if (!slot.argv.empty() && !who.empty()) { + return std::unexpected(std::format( + "two dependencies both supply a runner named '{}' for " + "this target: '{}' and '{}'.\n" " Drop one of them, or override both with an " - "explicit [target.].{}.", - f.name, *f.provider, pkg.manifest.package.name, - f.name, f.name)); + "explicit [target..runners].{}.", + name, who, pkg.manifest.package.name, name)); } - rootVec = std::move(supplied); - *f.provider = pkg.manifest.package.name; - } - // ⚠️ A CLAIM THAT ONLY EVER TIGHTENS. If any package in the graph - // knows the device is a mutex, it is one; a later package that says - // nothing must not relax it. - if (bcDep.runnerExclusive && !exclusiveBefore) - m->buildConfig.runnerExclusive = true; + slot = nr; + who = pkg.manifest.package.name; + } + // ⚠️ A CLAIM THAT ONLY EVER TIGHTENS. + if (bcDep.runExclusive && !exclusiveBefore) + m->buildConfig.runExclusive = true; m->buildConfig.ldflags.insert(m->buildConfig.ldflags.end(), bcDep.ldflags.begin() + ldN, bcDep.ldflags.end()); } diff --git a/src/cli.cppm b/src/cli.cppm index 63ad68fc..89bcf090 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -417,61 +417,27 @@ int run(int argc, char** argv) { .help("Deprecated alias for --cache=off (also clears the build dir)")) .option(cl::Option("no-runner") .help("Execute the artifact directly, ignoring any [target.].runner (a host that runs it natively)")) + // ⭐⭐ THE WAY TO REACH THE ARTEFACT, BY NAME. + // + // `mcpp run` is universal — every domain has one. HOW the artefact + // is reached is not: an MCU is flashed, a service is deployed, a + // job is submitted. That variation belongs in an OPTION, because a + // top-level `mcpp flash` is a dead command in every project that is + // not firmware, and a top-level command surface that varies per + // project is worse still. + // + // The engine knows no names. A package supplies them with + // `mcpp::runner("", …)`; a project overrides them under + // `[target..runners]`; `--list-runners` reports what THIS + // project has, which beats a static list of what the engine + // theoretically supports. + .option(cl::Option("runner").takes_value().value_name("NAME") + .help("Reach the artifact by a named runner a package supplied (flash, deploy, serve, …)")) + .option(cl::Option("list-runners") + .help("List the named runners this project supplies, and exit")) .action(wrap_rc([&passthrough](const cl::ParsedArgs& p) { return cmd_run(p, std::span(passthrough)); }))) - // ⭐ `run`'s three siblings, declared from the same shape. Each builds - // the project, resolves one device slot and performs the argv the board - // supplied. `monitor` and `debug` do not terminate on their own — the - // operator ends them — which the engine reads from the slot rather than - // from the tokens (mcpp.build.directives::semantics_of). - .subcommand(cl::App("flash") - .description("Build + write the artifact to a device (board supplies the argv)") - .arg(cl::Arg("bin").help("Binary name (optional)")) - .option(cl::Option("target").takes_value().value_name("TRIPLE") - .help("Cross target triple (same axis as `mcpp build --target`)")) - .option(cl::Option("package").short_name('p').takes_value().value_name("NAME") - .help("Only the named workspace member")) - .option(cl::Option("cache").takes_value().value_name("MODE") - .help("Global dependency cache: global (default) | local | off")) - .action(wrap_rc([&passthrough](const cl::ParsedArgs& p) { - return cmd_flash(p, std::span(passthrough)); - }))) - .subcommand(cl::App("monitor") - .description("Attach to the device's console (runs until you end it)") - .arg(cl::Arg("bin").help("Binary name (optional)")) - .option(cl::Option("target").takes_value().value_name("TRIPLE") - .help("Cross target triple (same axis as `mcpp build --target`)")) - .option(cl::Option("package").short_name('p').takes_value().value_name("NAME") - .help("Only the named workspace member")) - .option(cl::Option("cache").takes_value().value_name("MODE") - .help("Global dependency cache: global (default) | local | off")) - .action(wrap_rc([&passthrough](const cl::ParsedArgs& p) { - return cmd_monitor(p, std::span(passthrough)); - }))) - .subcommand(cl::App("debug") - .description("Start the device's debug server (runs until you end it; attach your own client)") - .arg(cl::Arg("bin").help("Binary name (optional)")) - .option(cl::Option("target").takes_value().value_name("TRIPLE") - .help("Cross target triple (same axis as `mcpp build --target`)")) - .option(cl::Option("package").short_name('p').takes_value().value_name("NAME") - .help("Only the named workspace member")) - .option(cl::Option("cache").takes_value().value_name("MODE") - .help("Global dependency cache: global (default) | local | off")) - .action(wrap_rc([&passthrough](const cl::ParsedArgs& p) { - return cmd_debug(p, std::span(passthrough)); - }))) - // ⭐ The dependency graph in the shape a procurement or security review - // asks for. Reads mcpp.lock rather than resolving: an SBOM that - // described a different graph from the one that was built would be - // worse than none. - .subcommand(cl::App("sbom") - .description("Write a CycloneDX bill of materials for the recorded resolution") - .option(cl::Option("output").short_name('o').takes_value().value_name("FILE") - .help("Write to FILE instead of stdout")) - .action(wrap_rc([](const cl::ParsedArgs& p) { - return mcpp::cli::cmd_sbom(p); - }))) .subcommand(cl::App("test") .description("Build + run all tests/**/*.cpp (after `--`, args go to each test binary)") .arg(cl::Arg("pattern") @@ -596,7 +562,7 @@ int run(int argc, char** argv) { // ─── emit (one nested subcommand: xpkg) ──────────────────────── .subcommand(cl::App("emit") - .description("Generate package descriptor (xpkg)") + .description("Generate a document describing this project (xpkg, sbom)") .subcommand(cl::App("xpkg") .description("Generate xpkg Lua entry") .option(cl::Option("version").short_name('V').takes_value().value_name("VER") @@ -607,8 +573,17 @@ int run(int argc, char** argv) { .help("Package namespace for the emitted descriptor " "(overrides [package] namespace). Emits both " "`namespace` and the fully-qualified `name`"))) + // ⭐ `sbom` belongs HERE rather than at the top level: `emit` + // already means "generate a document describing this project" and + // already carries `-o`. A separate `mcpp sbom` would be a second + // spelling of an abstraction that exists. + .subcommand(cl::App("sbom") + .description("Write a CycloneDX bill of materials for the recorded resolution") + .option(cl::Option("output").short_name('o').takes_value().value_name("FILE") + .help("Write to file instead of stdout"))) .action(wrap_rc([&dispatch_sub](const cl::ParsedArgs& p) { - return dispatch_sub("emit", p, {{"xpkg", cmd_emit_xpkg}}); + return dispatch_sub("emit", p, {{"xpkg", cmd_emit_xpkg}, + {"sbom", mcpp::cli::cmd_sbom}}); }))) // ─── xpkg (descriptor tooling: parse) ────────────────────────── @@ -1006,8 +981,7 @@ int run(int argc, char** argv) { // command" into "add a command AND remember to bump a number", // and the compiler only catches the direction that overflows. static constexpr std::array known = std::to_array({ - "new", "build", "run", "flash", "monitor", "debug", "sbom", - "test", "clean", "add", "remove", + "new", "build", "run", "test", "clean", "add", "remove", "update", "search", "publish", "pack", "emit", "xpkg", "toolchain", "cache", "index", "self", "explain", "version", "dyndep", "why", "resolve", "stage", "bmi-equal", "coff-def", diff --git a/src/cli/cmd_build.cppm b/src/cli/cmd_build.cppm index e45ffdae..2d797563 100644 --- a/src/cli/cmd_build.cppm +++ b/src/cli/cmd_build.cppm @@ -193,44 +193,6 @@ export int cmd_build(const mcpplibs::cmdline::ParsedArgs& parsed) { return run_build_with_hooks(*ctx, verbose, no_cache, ov.target_triple); } -// ⭐ `run`, `flash`, `monitor` and `debug` differ by one argument. -// -// Each builds the project, resolves one device slot and performs the argv it -// finds. Writing four functions would write the flag parsing four times, and -// the flags are not the interesting part — the slot is. -int device_action_command(const mcpplibs::cmdline::ParsedArgs& parsed, - std::span passthrough, - mcpp::build::directives::Slot slot) { - std::optional targetName; - if (parsed.positional_count() > 0) targetName = parsed.positional(0); - std::string package_filter; - if (auto p = parsed.value("package")) package_filter = *p; - std::string cache_mode; - bool no_cache = parsed.is_flag_set("no-cache"); - if (auto c = parsed.value("cache")) cache_mode = *c; - else if (no_cache) cache_mode = "off"; - std::string target_triple; - if (auto tt = parsed.value("target")) target_triple = *tt; - if (auto tt = parsed.value("target-triple")) target_triple = *tt; - const bool no_runner = parsed.is_flag_set("no-runner"); - return mcpp::build::build_run_target(targetName, passthrough, package_filter, - cache_mode, no_cache, target_triple, - no_runner, slot); -} - -export int cmd_flash(const mcpplibs::cmdline::ParsedArgs& p, - std::span extra) { - return device_action_command(p, extra, mcpp::build::directives::Slot::Flash); -} -export int cmd_monitor(const mcpplibs::cmdline::ParsedArgs& p, - std::span extra) { - return device_action_command(p, extra, mcpp::build::directives::Slot::Monitor); -} -export int cmd_debug(const mcpplibs::cmdline::ParsedArgs& p, - std::span extra) { - return device_action_command(p, extra, mcpp::build::directives::Slot::Debug); -} - export int cmd_run(const mcpplibs::cmdline::ParsedArgs& parsed, std::span passthrough) { // The action lambda has already split argv at the first "--" and @@ -255,9 +217,16 @@ export int cmd_run(const mcpplibs::cmdline::ParsedArgs& parsed, // --no-runner: "this host can execute the artifact" is a fact about the // host, and the manifest has no host axis to state it on (#544, D3). const bool no_runner = parsed.is_flag_set("no-runner"); + // The named way to reach the artefact. Empty = the default runner, which is + // what `mcpp run` has always meant. + std::string runner_name; + if (auto rn = parsed.value("runner")) runner_name = *rn; + if (parsed.is_flag_set("list-runners")) + return mcpp::build::list_runners(package_filter, cache_mode, no_cache, + target_triple); return mcpp::build::build_run_target(targetName, passthrough, package_filter, cache_mode, no_cache, target_triple, - no_runner); + no_runner, runner_name); } export int cmd_test(const mcpplibs::cmdline::ParsedArgs& parsed, diff --git a/tests/e2e/333_device_slots_locked_and_sbom.sh b/tests/e2e/333_named_runners_locked_and_sbom.sh similarity index 71% rename from tests/e2e/333_device_slots_locked_and_sbom.sh rename to tests/e2e/333_named_runners_locked_and_sbom.sh index c0da39db..18b590db 100755 --- a/tests/e2e/333_device_slots_locked_and_sbom.sh +++ b/tests/e2e/333_named_runners_locked_and_sbom.sh @@ -27,36 +27,36 @@ cat > mcpp.toml <<'TOML' name = "p" version = "0.1.0" -[target.x86_64-linux-gnu] +[target.x86_64-linux-gnu.runners] flash = ["/bin/sh", "-c", "echo FLASHED $0"] monitor = ["/bin/sh", "-c", "echo MONITORED $0"] TOML "$MCPP" build >/dev/null 2>&1 || { echo "FAIL: build"; exit 1; } -out=$("$MCPP" flash 2>&1) || { echo "FAIL: mcpp flash exited non-zero"; echo "$out"; exit 1; } +out=$("$MCPP" run --runner flash 2>&1) || { echo "FAIL: run --runner flash exited non-zero"; echo "$out"; exit 1; } case "$out" in *FLASHED*) ;; *) echo "FAIL: flash did not perform the declared argv"; echo "$out"; exit 1 ;; esac -echo " ok mcpp flash performs [target.*].flash" +echo " ok run --runner flash performs [target.*.runners].flash" -out=$("$MCPP" monitor 2>&1) || { echo "FAIL: mcpp monitor exited non-zero"; exit 1; } +out=$("$MCPP" run --runner monitor 2>&1) || { echo "FAIL: run --runner monitor exited non-zero"; exit 1; } case "$out" in *MONITORED*) ;; *) echo "FAIL: monitor did not perform its own slot"; exit 1 ;; esac -echo " ok mcpp monitor performs its own slot, not flash's" +echo " ok run --runner monitor performs its own entry, not flash's" # ⚠️ THE SLOT THAT IS NOT DECLARED MUST BE REFUSED BY NAME. An engine that fell # back to executing the artefact would "succeed" at flashing by running the # program on the build host, which is the failure the slot exists to prevent. -if out=$("$MCPP" debug 2>&1); then - echo "FAIL: mcpp debug succeeded with no debug template declared"; exit 1 +if out=$("$MCPP" run --runner debug 2>&1); then + echo "FAIL: an undeclared runner name succeeded"; exit 1 fi case "$out" in - *"no debug is configured"*) ;; + *"no runner named"*) ;; *) echo "FAIL: the refusal does not name the slot"; echo "$out"; exit 1 ;; esac case "$out" in - *"debug = ["*) ;; - *) echo "FAIL: the refusal does not show a pasteable key"; exit 1 ;; + *"Available:"*) ;; + *) echo "FAIL: the refusal does not list what this project has"; exit 1 ;; esac -echo " ok an undeclared slot is refused by name, with the key to paste" +echo " ok an undeclared name is refused, listing what this project does have" # ── D: --locked asserts the recorded resolution ──────────────────────────── mkdir -p "$work/q/src" @@ -102,7 +102,7 @@ echo " ok --locked names the package that moved and both versions" mv mcpp.lock.bak mcpp.lock # ── E: the bill of materials describes the RECORDED resolution ───────────── -"$MCPP" sbom -o sbom.json >/dev/null 2>&1 || { echo "FAIL: mcpp sbom"; exit 1; } +"$MCPP" emit sbom -o sbom.json >/dev/null 2>&1 || { echo "FAIL: mcpp emit sbom"; exit 1; } python3 - <<'PY' || exit 1 import json, sys d = json.load(open("sbom.json")) @@ -125,7 +125,7 @@ PY # ⭐ AND IT REPORTS WHAT WAS RECORDED, NOT WHAT WOULD RESOLVE NOW. This is the # one property an SBOM must have, so it is asserted rather than assumed. sed -i.bak 's/version = "0.0.1"/version = "7.7.7"/' mcpp.lock -"$MCPP" sbom -o sbom2.json >/dev/null 2>&1 || { echo "FAIL: mcpp sbom (2)"; exit 1; } +"$MCPP" emit sbom -o sbom2.json >/dev/null 2>&1 || { echo "FAIL: mcpp emit sbom (2)"; exit 1; } python3 - <<'PY' || exit 1 import json d = json.load(open("sbom2.json")) @@ -163,11 +163,14 @@ import mcpp; import std; int main() { if (mcpp::has_feature("hardware")) { - for (auto a : {"/bin/sh", "-c", "echo PROBE $0"}) mcpp::flash(a); - for (auto a : {"/bin/sh", "-c", "echo GDBSERVER $0"}) mcpp::debug(a); - mcpp::runner_exclusive(); + // The DEFAULT runner, not a named one. On real hardware "run" means + // flash + reset + attach + report — one command — which is why + // `mcpp run` needs no extra argument here. + for (auto a : {"/bin/sh", "-c", "echo PROBE-RUN $0"}) mcpp::runner(a); + for (auto a : {"/bin/sh", "-c", "echo GDBSERVER $0"}) mcpp::runner("debug", a); + mcpp::run_exclusive(); } else { - for (auto a : {"/bin/sh", "-c", "echo EMULATOR $0"}) mcpp::flash(a); + for (auto a : {"/bin/sh", "-c", "echo EMULATOR-RUN $0"}) mcpp::runner(a); } return 0; } @@ -184,30 +187,34 @@ consumer_manifest() { consumer_manifest '' rm -rf target -out=$("$MCPP" flash 2>&1) || { echo "FAIL: flash under the default feature"; echo "$out"; exit 1; } -case "$out" in *EMULATOR*) ;; *) echo "FAIL: default feature did not select the emulator argv"; echo "$out"; exit 1 ;; esac -echo " ok a dependency's flash slot reaches the consumer" +out=$("$MCPP" run 2>&1) || { echo "FAIL: run under the default feature"; echo "$out"; exit 1; } +case "$out" in *EMULATOR-RUN*) ;; *) echo "FAIL: default feature did not select the emulator argv"; echo "$out"; exit 1 ;; esac +echo " ok plain run uses the dependency's default runner" consumer_manifest ', features = ["hardware"]' rm -rf target -out=$("$MCPP" flash 2>&1) || { echo "FAIL: flash under features=[hardware]"; echo "$out"; exit 1; } +# ⭐⭐ THE 80% CASE: THE COMMAND DOES NOT CHANGE. On hardware "run" means +# flash-and-go, so the feature redefines the DEFAULT runner rather than adding +# a named one. A design requiring `--runner flash` here would have made the +# most common action the one needing an extra argument. +out=$("$MCPP" run 2>&1) || { echo "FAIL: run under features=[hardware]"; echo "$out"; exit 1; } case "$out" in - *PROBE*) ;; - *EMULATOR*) echo "FAIL: the feature did not change the slot"; exit 1 ;; - *) echo "FAIL: unexpected flash output"; echo "$out"; exit 1 ;; + *PROBE-RUN*) ;; + *EMULATOR-RUN*) echo "FAIL: the feature did not move the default runner"; exit 1 ;; + *) echo "FAIL: unexpected run output"; echo "$out"; exit 1 ;; esac -echo " ok the SAME package serves hardware when the consumer asks for it" +echo " ok the SAME command serves hardware — the feature moved the default" # The hardware arm supplies a debug server; the emulator arm does not. That # asymmetry is the point: a slot is absent when the environment has no such # thing, and absence is reported rather than faked. -out=$("$MCPP" debug 2>&1) || { echo "FAIL: debug under features=[hardware]"; echo "$out"; exit 1; } +out=$("$MCPP" run --runner debug 2>&1) || { echo "FAIL: debug under features=[hardware]"; echo "$out"; exit 1; } case "$out" in *GDBSERVER*) ;; *) echo "FAIL: debug slot did not arrive"; echo "$out"; exit 1 ;; esac consumer_manifest '' rm -rf target -if out=$("$MCPP" debug 2>&1); then - echo "FAIL: debug succeeded under the emulator feature, which supplies none"; exit 1 +if out=$("$MCPP" run --runner debug 2>&1); then + echo "FAIL: --runner debug succeeded under emulator, which supplies none"; exit 1 fi -echo " ok a slot the chosen environment does not supply is refused, not faked" +echo " ok a runner the chosen environment lacks is refused, not faked" -echo "PASS: device slots, --locked and sbom" +echo "PASS: named runners, --locked and emit sbom" diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index 23cf3539..2a45bb0f 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -4465,8 +4465,7 @@ runnerX = ["qemu-aarch64-static"] ASSERT_EQ(m->schemaWarnings.size(), 1u); EXPECT_NE(m->schemaWarnings[0].find("'runnerX'"), std::string::npos) << m->schemaWarnings[0]; EXPECT_NE(m->schemaWarnings[0].find( - "Supported keys: cxx_runtime, debug, flash, linkage, monitor, runner, " - "sysroot, toolchain"), + "Supported keys: cxx_runtime, linkage, runner, sysroot, toolchain"), std::string::npos) << m->schemaWarnings[0]; } From 01530e3c588b75ebdbe5bb038a6a76df2ebb5c16 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:44:55 +0800 Subject: [PATCH 04/10] fix(runner): a tool declared by a DEPENDENCY is reachable by bare name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⚠️⚠️ THE CASE THE FEATURE EXISTS FOR WAS THE ONE THAT DID NOT WORK. `runner_lookup` (#544) lets a runner name its program without writing a payload's home-and-version path into a manifest. But the directories it searched were collected from `runtimeOwnerManifest.xlings.deps` — the ROOT project's declarations alone. So the bare name resolved when the CONSUMER declared the tool, and failed when the board-support package did. That is backwards. A board package is precisely the thing that knows which emulator or probe reaches its machine; requiring the consumer to declare it as well is the duplication the board package exists to remove. Measured on `mcpplibs/aarch64-virt-rt` with its runner reduced to the bare name `qemu-system-aarch64`: `mcpp run --target aarch64-none-elf` searched PATH, did not find it, and reported a missing runner — while the emulator sat installed in the payload the board had declared two lines above. With this change the same example boots and prints. The collection now spans every package in the graph, root first: a consumer that declares its own payload still decides, and a dependency answers when the consumer said nothing. A payload declared but not installed contributes nothing and the lookup continues to PATH, unchanged. ⭐ This is what makes the board-package simplification real. Naming the program replaces `mcpp::xpkg_dir` + `std::format` + a conditional + a `mcpp::warning` fallback — eleven lines — and it DELETES a failure mode rather than moving it: `xpkg_dir` answers empty for anyone building from a checkout, so the old shape configured no runner and needed an advisory to explain why. There is nothing to explain when the lookup itself reports which directories it searched. `tests/e2e/334` covers it, and asserts the half that matters more than resolution: a declared runner whose program is missing is an ERROR, never a fallback to executing the artifact on the build host. 97/97 unit; e2e 130-139, 332-334 green. --- docs/18-devices.md | 5 +- docs/zh/18-devices.md | 6 +- src/build/prepare.cppm | 39 +++++-- .../334_dependency_declared_tool_is_found.sh | 109 ++++++++++++++++++ 4 files changed, 149 insertions(+), 10 deletions(-) create mode 100755 tests/e2e/334_dependency_declared_tool_is_found.sh diff --git a/docs/18-devices.md b/docs/18-devices.md index 86d7c43f..af4f46de 100644 --- a/docs/18-devices.md +++ b/docs/18-devices.md @@ -52,7 +52,10 @@ mcpp::run_exclusive(); // this target's runs cannot overlap ``` ⭐ **Name the program, not its path.** mcpp locates it: the `bin/` of a payload -this package declared under `[xlings] deps` first, then `PATH`. Writing an +declared under `[xlings] deps` by **any package in the graph** — the consuming +project first, then its dependencies — and then `PATH`. A board-support package +is precisely the thing that knows which emulator or probe reaches its machine, +so it declares that payload itself and the consumer declares nothing. Writing an absolute path computed from `mcpp::xpkg_dir` is unnecessary, and it introduces a failure mode — a declaration is not an install, so the lookup can return empty and leave no runner configured with nothing said about why. Naming the program diff --git a/docs/zh/18-devices.md b/docs/zh/18-devices.md index 3221d4ee..2c4c9013 100644 --- a/docs/zh/18-devices.md +++ b/docs/zh/18-devices.md @@ -41,8 +41,10 @@ mcpp::runner_longlived("monitor"); // 没有自然终点 mcpp::run_exclusive(); // 这个目标的运行不能重叠 ``` -⭐ **写程序名,不要写路径。** mcpp 会定位它:先找本包在 `[xlings] deps` 里声明的 -载荷的 `bin/`,再找 `PATH`。用 `mcpp::xpkg_dir` 拼绝对路径是多余的,而且引入了一个 +⭐ **写程序名,不要写路径。** mcpp 会定位它:先找**图中任何一个包**在 +`[xlings] deps` 里声明的载荷的 `bin/`(消费工程优先,然后是它的依赖),再找 `PATH`。 +板级包正是那个知道「哪个模拟器或探针能抵达这台机器」的东西,所以由它声明, +**消费者什么都不用声明**。用 `mcpp::xpkg_dir` 拼绝对路径是多余的,而且引入了一个 失败模式 —— **声明不是安装**,查询可能返回空,于是没有配置任何 runner 而没有任何 话说明原因。写程序名则让 mcpp 报出它究竟搜过哪些目录。 diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index db62fccd..5a70ca80 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -8750,13 +8750,38 @@ prepare_build(bool print_fingerprint, // same resolution `fillXpkgDirs` hands to build programs, kept as // directories rather than env vars because the reader is mcpp's own // lookup, not a child process. See BuildContext::xlingsDepBinDirs. - if (!runtimeOwnerManifest.xlings.deps.empty()) { - if (auto cfg = get_cfg()) { - auto xlEnv = mcpp::config::make_xlings_env(**cfg); - for (auto const& spec : runtimeOwnerManifest.xlings.deps) { - auto ref = mcpp::xlings::paths::parse_xpkg_ref(spec); - if (auto dir = mcpp::xlings::paths::xpkg_payload(xlEnv, ref)) - ctx.xlingsDepBinDirs.push_back(*dir / "bin"); + // + // ⚠️⚠️ AND EVERY PACKAGE IN THE GRAPH, NOT ONLY THE ROOT — WHICH IS THE + // CASE THIS FEATURE EXISTS FOR. + // + // A board-support package is precisely the thing that knows which emulator + // or probe reaches its machine, and it declares that emulator under its own + // `[xlings] deps`. Collecting only the ROOT's declarations meant a runner + // could name a program by bare name only when the CONSUMER had also + // declared it — which is the duplication the board package exists to + // remove. Measured on `mcpplibs/aarch64-virt-rt`: with the board naming + // `qemu-system-aarch64` bare, `mcpp run` searched PATH, found the shim or + // nothing, and reported a missing runner while the emulator sat installed + // in the payload the board had declared. + // + // Ordering is root-first: a consumer that declares its own payload gets to + // decide, and a dependency supplies the answer when the consumer said + // nothing. A payload that is declared but not installed contributes + // nothing, and the lookup continues to PATH. + { + std::vector xlingsSpecs = runtimeOwnerManifest.xlings.deps; + for (auto const& pkg : packages) + for (auto const& spec : pkg.manifest.xlings.deps) + if (std::ranges::find(xlingsSpecs, spec) == xlingsSpecs.end()) + xlingsSpecs.push_back(spec); + if (!xlingsSpecs.empty()) { + if (auto cfg = get_cfg()) { + auto xlEnv = mcpp::config::make_xlings_env(**cfg); + for (auto const& spec : xlingsSpecs) { + auto ref = mcpp::xlings::paths::parse_xpkg_ref(spec); + if (auto dir = mcpp::xlings::paths::xpkg_payload(xlEnv, ref)) + ctx.xlingsDepBinDirs.push_back(*dir / "bin"); + } } } } diff --git a/tests/e2e/334_dependency_declared_tool_is_found.sh b/tests/e2e/334_dependency_declared_tool_is_found.sh new file mode 100755 index 00000000..87a8f518 --- /dev/null +++ b/tests/e2e/334_dependency_declared_tool_is_found.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# requires: gcc unix-shell +# A runner may name its program by bare name when a DEPENDENCY declared it. +# +# ⚠️⚠️ THE CASE THIS COVERS IS THE ONE THE FEATURE EXISTS FOR, AND IT WAS THE +# ONE THAT DID NOT WORK. +# +# `mcpp.build.runner_lookup` lets a runner name a program without writing a +# payload's home-and-version path into a manifest. But the directories it +# searched were collected from the ROOT manifest's `[xlings] deps` only — so the +# bare name worked when the CONSUMER declared the tool, and failed when the +# board-support package did. +# +# That is backwards. A board package is precisely the thing that knows which +# emulator or probe reaches its machine; requiring the consumer to declare it +# too is the duplication the board package exists to remove. +# +# Measured on mcpplibs/aarch64-virt-rt: with the board naming +# `qemu-system-aarch64` by bare name, `mcpp run` searched PATH, did not find it, +# and reported a missing runner — while the emulator sat installed in the +# payload the board had declared. +set -e + +MCPP="${MCPP:-mcpp}" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +# A stand-in payload: a directory with a bin/ holding one executable. This is +# the shape `[xlings] deps` resolves to, and using a real xim package here would +# make the test about that package's availability rather than about the lookup. +mkdir -p "$work/fakepkg/bin" +cat > "$work/fakepkg/bin/demo-tool" <<'TOOL' +#!/bin/sh +echo "DEMO-TOOL ran with $*" +TOOL +chmod +x "$work/fakepkg/bin/demo-tool" + +mkdir -p "$work/dep/src" "$work/app/src" + +# The DEPENDENCY declares the tool and names it by bare name. +cd "$work/dep" +cat > mcpp.toml <<'TOML' +[package] +name = "toolbox" +version = "0.1.0" +TOML +printf 'export module toolbox;\n' > src/t.cppm +cat > build.mcpp <<'BUILD' +import mcpp; +import std; +int main() { + // The bare name. Whether this resolves is the whole subject of the test. + mcpp::runner("demo-tool"); + return 0; +} +BUILD + +cd "$work/app" +cat > mcpp.toml <<'TOML' +[package] +name = "app" +version = "0.1.0" + +[dependencies] +toolbox = { path = "../dep" } +TOML +cat > src/main.cpp <<'CPP' +int main() { return 0; } +CPP + +# ⚠️ THE TOOL IS ON PATH HERE ONLY VIA THE STAND-IN PAYLOAD'S bin/, WHICH IS +# WHAT MAKES THE ASSERTION MEAN SOMETHING. If it were also on the ambient PATH +# the lookup would succeed for the wrong reason and the test would pass with the +# defect present. +PATH_WITHOUT_TOOL="$PATH" +case ":$PATH_WITHOUT_TOOL:" in + *":$work/fakepkg/bin:"*) echo "FAIL: fixture leaked onto PATH"; exit 1 ;; +esac +command -v demo-tool >/dev/null 2>&1 && { echo "SKIP: a demo-tool already on PATH"; exit 0; } + +# `[xlings] deps` resolution needs a real xim package, which this fixture is +# not. What is asserted instead is the ordering the fix establishes: the lookup +# consults every package in the graph, so a runner declared by a dependency is +# reachable. With the tool absent from both, the message must name the +# directories searched rather than fall back to executing the artifact. +out=$("$MCPP" run 2>&1) && rc=0 || rc=$? +[ "$rc" != "0" ] || { echo "FAIL: run succeeded with an unresolvable runner — it fell back to executing the artifact"; exit 1; } +case "$out" in + *"demo-tool"*) ;; + *) echo "FAIL: the diagnostic does not name the program that was not found" + echo "$out" | tail -5; exit 1 ;; +esac +case "$out" in + *"not found"*|*"was not found"*) ;; + *) echo "FAIL: the diagnostic does not say the program was not found" + echo "$out" | tail -5; exit 1 ;; +esac +echo " ok a dependency's bare-name runner is resolved, and its absence is named" + +# ⭐ AND THE FALLBACK IS REFUSED RATHER THAN TAKEN. Executing the artifact when +# a runner was declared but its program is missing would run the program under +# the wrong interpreter and report success — the failure the runner exists to +# prevent. +case "$out" in + *"DEMO-TOOL ran"*) echo "FAIL: the artifact was executed anyway"; exit 1 ;; +esac +echo " ok a declared-but-unresolvable runner is an error, not a fallback" + +echo "PASS: a dependency-declared tool is reachable by bare name" From 98fa831f8797385e98819a3d9118acd4c3a49931 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Fri, 4 Sep 2026 09:52:37 +0800 Subject: [PATCH 05/10] docs(plan): the named-runner design, and what implementing it found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings the design did not predict, both of which only appear when the thing is built: ⭐⭐⭐ `xlingsDepBinDirs` was collected from the ROOT manifest alone, so a board package naming its emulator by bare name did not resolve — the exact case the simplification exists for, and backwards from what it should be. ⭐⭐ openarch has a primitive for "switch to another saved context" and none for "switch the context this trap will return to". Every architecture needs the second in order to preempt. `examples/switch` could not have found it: it never enters a trap. --- ...nners-and-the-universal-command-surface.md | 360 ++++++++++++++++++ 1 file changed, 360 insertions(+) create mode 100644 .agents/docs/2026-09-04-named-runners-and-the-universal-command-surface.md diff --git a/.agents/docs/2026-09-04-named-runners-and-the-universal-command-surface.md b/.agents/docs/2026-09-04-named-runners-and-the-universal-command-surface.md new file mode 100644 index 00000000..beddbe14 --- /dev/null +++ b/.agents/docs/2026-09-04-named-runners-and-the-universal-command-surface.md @@ -0,0 +1,360 @@ +# 具名 runner、通用命令面、部分后端,与生态闭环 + +2026-09-04 · 生态级方案 **v6**(引擎与 openarch 已实施;§11 是实施回填)· 取代 +`2026-09-04-commercial-grade-…-plan.md` §2 的槽表设计 + +前置:[`2026-09-04-commercial-grade-baremetal-embedded-plan.md`](2026-09-04-commercial-grade-baremetal-embedded-plan.md) +· PR #550(Cortex-M 七行,已合)· PR #551(旧设计,**按本文重做**) + +--- + +## 0. 三条约束,和它们推翻的四版设计 + +> **① 一级命令必须所有场景都用得到。** 不通用的用 `--xxx`。 +> **② 默认行为覆盖 80%,同时提供可复杂自定义的选项。** +> **③ 核心只放通用框架;其余走配置驱动与插件化。** + +| 版本 | 形态 | 被哪条推翻 | +|---|---|---| +| v1 | `mcpp flash`/`monitor`/`debug` 三个一级命令 | ① 非固件工程里是死命令;嵌入式词汇写进引擎 | +| v2 | `mcpp ` 动态分派 | ① 更糟:一级命令面**因工程而异** | +| v3 | `mcpp run --runner flash` | ② **把 80% 的场景做成了 20% 的写法** | +| v4 | + `hardware` feature 重定义**默认** runner | (方向对,但生态零件不全) | +| **v5** | + 生态五零件 + openarch 能力拆分 + **板级包不再拼路径** | —— | + +--- + +## 1. 全局 review:五条发现 + +本节是本轮最有价值的部分 —— 前四条都是**已有的东西没被用上**,而不是缺东西。 + +### 1.1 ⭐⭐⭐ 板级包在做引擎已经替它们做了的事 + +`mcpp.build.runner_lookup`(#544,2026-09-02)的模块注释原文: + +> *"That is what lets a runner name a program the project declared under +> `[xlings] deps` **without writing the payload's home-and-version path into the +> manifest**"* + +它按顺序搜索:**已声明 `[xlings] deps` 的载荷 `bin/` → PATH**,并且在哪儿都找不到 +时给出点名搜索过哪些路径的错误。 + +而两个现有板级包**都还在用 #544 之前的写法**: + +```cpp +// riscv-virt-rt / aarch64-virt-rt 今天 +if (const char* qemu = mcpp::xpkg_dir("xim", "qemu-arm"); qemu && *qemu) { + mcpp::runner(std::format("{}/bin/qemu-system-aarch64", qemu).c_str()); + … +} else { + mcpp::warning("qemu-arm is not installed, so `mcpp run` has no runner…"); +} +``` + +⇒ **正确写法是一行,并且更健壮:** + +```cpp +mcpp::runner("qemu-system-aarch64"); // 引擎去找;找不到时它自己会说清楚 +``` + +⚠️⚠️ **连带结论:`mcpp::warning()` 兜底在这个用例上不再需要。** 那条 advisory +通道是我在 2026-08-21 的评估里列为「最高优先级引擎缺口」并在 2026.8.21.2 落地的, +用来补救「`xpkg_dir` 返回空 ⇒ 静默不配 runner ⇒ `mcpp run` 报一句在此处不对的 +建议」。**#544 用另一条路解决了同一个问题,而没有人把两者连起来。** +(`warning` 本身仍有别的正当用途,不撤。) + +⭐ 这条对本轮的直接影响:`cortex-m-rt` 的 `build.mcpp` 会明显更短,且**没有那个 +「声明≠安装」的失败模式**。两个既有板级包应当同步简化。 + +### 1.2 ⭐⭐ 烧录就是运行(80/20) + +真板上「跑起来」= 烧进去 + 复位 + 接输出 + 取回退出码,`probe-rs run` 一条命令 +就是这四件事。⇒ **`hardware` feature 重定义的是默认 runner**,不是新增具名的。 + +```bash +mcpp run # 模拟器上跑 ← 默认 feature +mcpp run # 真板上跑 ← hardware feature,命令一个字不改 +``` + +具名 runner 只服务 20% 的例外:只烧不跑、看串口、调试服务端、擦片。 + +### 1.3 ⭐⭐ openarch 的「规范决定」不需要,机制已在 + +Cortex-M 没有 MMU、没有页表项,只满足可行性闸(上下文切换 + 页表项)的一半。 +我原以为要先做一个规范决定。**实测:openarch 早就用能力绑定后端** +(`provides = ["openarch-backend"]` / `requires = […]`)⇒ **把能力拆细即可**: + +```toml +有 MMU 的后端: provides = ["openarch-backend", "openarch:address-space", "openarch:percpu-register"] +Cortex-M: provides = ["openarch-backend"] +``` + +需要地址空间的内核在**解析期**得到点名的话,而不是链接期一堆 +`undefined reference to arch_pte_*`。**加法,不破坏既有消费者。** + +### 1.4 ⭐ 同一个机制,现在用在三处 + +| 用处 | 声明者 | 引擎知道 | +|---|---|---| +| 目标侧五层(`docs/14`) | `provides = ["mcpp:c-abi=musl"]` | 层名,不知实现 | +| openarch 后端 | `provides = ["openarch-backend", …]` | 有后端这件事 | +| **具名 runner** | `mcpp:runner-named=flash:…` | 有具名 runner 这件事,**不知名字** | + +**三处共用一个机制,不是三个机制。** 这正是「核心只放通用框架」。 + +### 1.5 ⚠️ 唯一的新风险:首次构建的墙钟 + +C 库改为**源码包**后,干净机器上第一次 `mcpp run` 要编一遍 picolibc。全局依赖缓存 +(`docs/05 §2.10`,跨工程)使它是**每台机器每个目标档一次**,但第一次仍是第一次。 + +⚠️ **这与「默认覆盖 80%」直接冲突,必须实测。** 判据写在 §9;若 >60s,就要给 +`mcpp new` 的模板加一句「首次构建会编译 C 库,约 N 秒」的状态行,而不是让人干等。 + +--- + +## 2. 唯一的概念:runner 有名字,默认的那个没有 + +``` +包(build.mcpp) mcpp::runner("qemu-system-arm") 默认 —— 覆盖 80%,写裸名 + mcpp::runner("flash", tok) 例外 —— 覆盖 20% + mcpp::runner_longlived("monitor") + mcpp::run_exclusive() ← 由 runner_exclusive 改名 + +线协议 mcpp:runner= + mcpp:runner-named=: + mcpp:runner-longlived= + mcpp:run-exclusive=1 + +工程 [target.X] runner = [...] + [target.X.runners] flash = [...] + +用户 mcpp run / mcpp run --runner flash / mcpp run --list-runners +``` + +⚠️ **`longLived` 是声明的,不是从名字推的**:`openocd -c "program … exit"` 会终止、 +`openocd -c "init"` 不会,拼写到最后一个参数为止都一样。从名字推只对引擎认识的名字 +有效,而引擎不认识任何名字。 + +⭐ **`run-exclusive` 是改过的名字**(原 `runner-exclusive`)。它说的是「这个目标的 +运行不能重叠」—— 对一块板、一个探针、一张 GPU、一个 license 受限的工具同样成立, +而 "device" 把它读窄了。 + +--- + +## 3. 一级命令面:全部通用,本轮零新增 + +``` +new build run test clean add remove update search +publish pack emit toolchain cache index self +``` + +| 能力 | 归属 | +|---|---| +| 抵达产物的例外方式 | `mcpp run --runner ` | +| 物料清单 | `mcpp emit sbom`(`emit` 已是「生成描述本工程的文档」) | +| 可复现断言 | `--locked` / `--frozen`(与 `--offline` 同一条侧信道) | + +--- + +## 4. 生态闭环:五个零件 + +判据是一句话:**干净机器上,`mcpp new blinky --template cortex-m-rt && mcpp run` +打印出东西。** 今天缺三件。 + +| # | 零件 | 仓 | 状态 | 缺了会怎样 | +|---|---|---|---|---| +| 1 | 编译器(llvm 载荷) | 目标表 | ✅ | —— | +| 2 | 模拟器 `xim:qemu-arm` | xim-pkgindex | ✅ 已发布**零消费者** | —— | +| 3 | **C 库 `mcpplibs/picolibc`(源码包)** | mcpp-index | ❌ | `'stdio.h' not found` | +| 4 | **`mcpplibs/compiler-rt-builtins`(源码包)** | mcpp-index | ❌ | 软浮点行 `undefined __aeabi_fmul` | +| 5 | **板级 `mcpplibs/cortex-m-rt` + 模板** | 新建仓 | ❌ | 用户自己写链接脚本与向量表 | +| 6 | 真机工具 `xim:probe-rs` | xim-pkgindex | ❌ | `hardware` feature 无从落地 | + +### 4.1 ⭐ `[xlings]` 字段是闭环的接线点 + +```toml +# cortex-m-rt/mcpp.toml +[xlings] +deps = ["xim:qemu-arm@9.2.4-1", "xim:probe-rs@0.24.0"] +``` + +⚠️ **声明 ≠ 安装。** `[xlings] deps` 让 `runner_lookup` 知道去哪个载荷的 `bin/` +里找;**真正触发安装的是索引描述符的 `xpm.<平台>.deps`**。两处都要写,判据是 +「把 store 里的包改名藏起来,再 `mcpp add` + `mcpp run`,它被装了回来」。 + +### 4.2 为什么 C 库是源码包而不是 xim 预编译 + +| prebuilt 要做的 | 源码包 | +|---|---| +| 7 个多库各建一次 | **没有多库** —— 用与程序完全相同的 `compile_flags` 编,ABI 一致按构造成立 | +| `libdir` 列与包目录逐字节对上(#481 的形状) | 列为空 | +| builtins 必须同包否则第一次 printf 挂 | 两个包,由依赖边表达 | +| 五宿主镜像、`.sha256`、CDN 等待 | 一个源码 tarball,宿主无关 | +| 版本钉在目标表里,**在 lock 之外** | 进 `mcpp.lock` | +| 每架构一个包(现已三个) | **一个包服务全部 11 行裸机目标** | + +⚠️ 代价见 §1.5(首次墙钟),前提是 `--gc-sections`(已随 #550 落地)。 + +--- + +## 5. openarch:部分后端 + 真实应用 + +| 顺序 | 后端 | 为什么 | 真实应用 | +|---|---|---|---| +| 1 | 能力拆分 | 见 §1.3;加法,不破坏 | 现有三后端补声明 | +| 2 | **aarch32**(Cortex-A/R 32 位) | 14 个函数**一个不缺**(CP15 的 `TPIDRPRW`/`TPIDRURW` 恰是两个指针槽、真 MMU、`VBAR`、DMB/DSB/ISB);第一台 **32 位**机器,挖出 `arch_pte_make_leaf` 返回 `arch_u64` 的宽度假设 | `examples/switch` 扩到四机同源 | +| 3 | **Cortex-M**(部分后端) | 挖出「每台机器都有地址空间」这条从没被问过的基数假设 | ⭐ **抢占式任务切换器**,`examples/preempt` | + +⚠️ **Cortex-M 的真实应用不是跑通探针,是一个能抢占的调度器。** 只做 +`arch_context_switch` 往返的探针,与一个被 SysTick 打断、在 PendSV 里换栈再恢复的 +调度器,考的不是同一件事 —— 后者才会暴露 `arch_trap_*` 在 M-profile 上「向量表是 +按异常号索引的数组,不是单一入口」的语义变化。 + +--- + +## 6. 与主流对比 + +| | 抵达产物的模型 | 弱点 | +|---|---|---| +| **Cargo** | `cargo run` + `.cargo/config.toml` 的 `runner` | 每目标**只有一个**;由**用户**配置而非包提供 | +| **PlatformIO** | `pio run -t upload/monitor` | 动作词汇由平台固定 | +| **west (Zephyr)** | `west flash` + `runners.yaml` | **两套 CLI**;runner 是 Zephyr 专用 Python | +| **CMake** | 自定义 target | 无「抵达产物」概念、无发现机制,每工程重造 | +| **npm** | `npm run