feat(device): 具名 runner、工具档位、--locked 断言与 emit sbom (2026.9.4.2) - #551
Merged
Conversation
… sbom
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.
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.
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.
⚠️ ⚠️ 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.
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.
…run opposite
§15 records what was settled, including that batch 1's engine interfaces become
a compatibility contract on release, so their shape is frozen before it merges.
§16 re-orders the batches with picolibc parallel rather than queued, and writes
down a pair I had not stated together:
* the board packages must wait for the ENGINE to publish, because the bare-name
lookup they now rely on ships with it — the mirror of "consumers publish
first";
* picolibc must publish before the cortex-m-rt version that names it, because
a version reference cannot resolve to an unpublished package — the ordinary
case of the same rule.
⭐ Which is what the libc feature buys: cortex-m-rt 0.1.0 ships on the zero-libc
tier waiting for nobody, and gains the feature at 0.2.0. The near tier is not
held behind the far one.
…stalled
## Two halves of one defect
`[xlings.workspace]` had one list. A board-support package naming both an
emulator (to run) and a debug probe (to reach hardware) installed both for every
consumer, including one that only wanted the library to compile — while package
dependencies have had `[dependencies]` / `[build-dependencies]` /
`[dev-dependencies]` since the beginning.
[xlings.workspace]
"xim:qemu-arm" = "9.2.4-1" # unchanged behaviour
"xim:probe-rs" = { version = "0.24.0", when = "run" }
[feature-xlings.hardware]
"xim:probe-rs" = "0.24.0" # never fetched otherwise
| when | installed by | reaches a consumer |
|---|---|---|
| (omitted) | every verb that builds | yes |
| build | every verb that builds | yes |
| run | `mcpp run`, `mcpp test` | yes |
| dev | only the package that declared it, as the root | no |
Omitting `when` is the pre-2026.9.4.2 behaviour exactly, so no manifest changes.
The tier rides the entry rather than a second table, the shape `[dependencies]`
already uses; `[feature-xlings.<f>]` is `[feature-deps.<f>]`'s spelling.
## ⚠️ ⚠️ And provisioning now spans the graph, because looking did already
The previous release extended the runner's bare-name LOOKUP to every package in
the graph and left provisioning at the root. That is a search of directories
nothing installed into — a lookup that can only fail. Both are one expression
now, so they cannot drift.
Provisioning happens in two passes for an ordering reason: the root's list is
needed before the graph exists (a toolchain may depend on it), and a
dependency's is not known until resolution. The second pass provisions only what
the first did not, under its own stamp, so an unchanged project still pays for
no xlings round trip.
## ⚠️ The hazard a tier introduces, and the gate for it
`mcpp build` installs less than `mcpp run` needs, and the run fast path exists
precisely to skip the pass that would install the difference. The build cache
records whether a build left a run-tier tool unprovisioned; `try_fast_run`
declines such an entry, exactly as it declines one that records a runner.
## The criterion tests what mcpp REQUESTS, not what got installed
Verifying a tier by installing needs a clean machine and a network. It is also
the wrong object: what the tier changes is the SET, and `MCPP_NO_AUTO_INSTALL=1`
refuses to provision while naming exactly that set. `tests/e2e/335` reads both
commands on one project, so the criterion carries its own denominator — an mcpp
that provisioned nothing, or a manifest that never parsed, fails the `run` half.
## Discoverability
`mcpp why runners` lists what this project supplies, beside everything else it
reports resolving; `mcpp run --list-runners` is the same read alone. No stamped
one-time hint: a cache hit does not re-run, so it would be silent on the build
that needed it most.
97/97 unit (5 new); e2e 88, 130-131, 327, 332-335 green.
…unit Every other 32-bit row in the table is M-profile: an MPU that describes regions by base and limit, and no page-table entry at all. A-profile has a real MMU with a walker, so `armv7a-none-eabi` / `-eabihf` are the first targets on which an address-space abstraction can be asked what a 32-BIT machine's entry looks like — short descriptors are 32 bits wide, long (LPAE) ones 64. openarch's layer has never been able to put that question to a 32-bit machine. Both rows are `verified`, and the column records what was RUN. Measured 2026-09-04 under `xim:qemu-arm@9.2.4-1`: each built an image that booted on `-M virt -cpu cortex-a15`, printed over semihosting and reported its exit status. ##⚠️ The soft row needs `-mfpu=none`, measured on THIS architecture `armv7-a` is not `thumbv7em`, so the M-profile measurement says nothing about it. Measured on llvm 22.1.8, the same float multiply: 1 VFP instruction under the soft-float ABI, 0 with `-mfpu=none`. ##⚠️ And the semihosting exit call is spelled differently `SYS_EXIT` (0x18) on AArch32 takes the reason code in `r1` DIRECTLY; the `{reason, code}` block every Cortex-M board here passes is `SYS_EXIT_EXTENDED` (0x20), which exists because a 32-bit `r1` cannot carry both. Measured: passing the block to 0x18 prints correctly and reports the WRONG exit status — a program exiting 0 reported 1. `tests/e2e/336` asserts the status and not only the output, and takes it from qemu rather than from the tail of a pipeline. ## ⭐ The unit test's predicate was a spelling, not a property `SoftFloatMProfileRowsDisableTheFpu` filtered on `starts_with("thumb")`. The rule it states applies to every 32-bit ARM row with a float-ABI suffix, so adding these two left the rule in force and the test SILENTLY not covering them: the loop skipped the new rows and every assertion still passed. The predicate now asks the property, and the test counts A-profile rows so the denominator cannot go back to zero unnoticed. 97/97 unit; e2e 332, 336 green.
…emanded 336 declares `# requires: qemu-arm`, which no sharded runner has, so on a shard it exits 0 without running a single row. Adding it to the loop and not to the assertions below would have been that defect one layer in — the same shape the comment above 332 already records. Both the PASS line and a row count are demanded, because a fixture that stopped iterating still prints its PASS line.
⚠️ ⚠️ THE SUPPORT MATRIX IS A DECLARATION, AND ADDING A TARGET ROW WITHOUT ADDING IT HERE IS A RED SCAN RATHER THAN A SILENT GAP — which is what the table is for. `scan (linux-aarch64)` reported the two new targets as measured cells with no expectation. Twelve cells: four hosts × the compilers each declares. The shape follows the M-profile rows exactly, because the answer is the same one — llvm serves them from a payload that needs no per-host cross build, and every gcc/msvc cell is a `capability-pin` refusal. Measured locally on linux-x86_64: 42/42 payload and 16/16 graph cells match.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
方案:
.agents/docs/2026-09-04-named-runners-and-the-universal-command-surface.md(v5)三条约束,按顺序施加,产生了这个形状:
前一版三条都不满足
mcpp flash/monitor/debug在任何非固件工程里都是死命令,且把嵌入式词汇写进了引擎 —— web 包想加
serve得等引擎发版。实测:flash/monitor/debugger在引擎里散落 56 处、8 个文件,加第五个动作要动 9 处 —— 而这个代价是前一版
commit message 自己引用的,然后付了四遍。
⭐⭐ 第二条约束抓到更深的错:烧录就是运行
真板上「运行一个程序」= 写进去 + 复位 + 接输出 + 读回退出码 ——
probe-rs run是一条命令,
qemu-system-* -kernel也是。同一个动作在两个环境的两种实现,不是两个动作。 前一版却要求
mcpp run --runner flash,把开发者最常做的事变成了需要额外参数的事。
⇒ 板级包的
hardwarefeature 移动的是默认 runner:具名 runner 服务剩下的 20%:只写不跑、看串口、调试服务端、擦片。
引擎不认识任何名字
名字是数据,装在一条指令的值里。
flash、serve、submit、logcat对引擎一样陌生,代价都是零。
⭐ 是否终止由包声明,不由名字推:
openocd -c "program … exit"会终止而openocd -c "init"不会,拼写到最后一个参数为止都一样 —— 没有任何 argv 能表达它,而引擎也没有名字表可供推断。
⭐ 写程序名,不要写路径。⚠️ 那段兜底所补救的失败模式
runner_lookup(#544)先搜本包[xlings] deps声明的载荷
bin/,再搜 PATH。实测:mcpp::runner("qemu-system-arm")一行,直接从声明的载荷里解析并启动成功 —— 而两个现有板级包仍在用
xpkg_dir+std::format拼绝对路径、外加一段
mcpp::warning兜底(11 行)。(声明≠安装 ⇒ xpkg_dir 空 ⇒ 静默无 runner)在裸名写法下根本不存在。
改名
runner-exclusive→run-exclusive:性质是「这个目标的运行不能重叠」,对一块板、一张 GPU、一个串口、一个单席位 license 同样成立。没有一处是关于「设备」的。
mcpp sbom→mcpp emit sbomemit早就是「生成描述本工程的文档」,连-o形状都一样。加命令代替扩抽象,一天之内犯了两次 —— 这是第二次的修正。
判据
tests/e2e/333(十条):默认 runner、具名 runner、未声明的名字被拒并列出本工程实际有哪些、
--locked两个方向、sbom 是合法 CycloneDX 且读锁而非重解析、⭐同一个包在两个 feature 下
mcpp run命令不变而默认 runner 移动、所选环境没有的 runner 被拒而非伪造。
⭐ 对指令表量化的单测抓到一个真缺陷:删掉一行后
std::array<Def, 20>的大小没改,留下一个 wire 名为空的默认构造项。
回归
单测 97/97;e2e
130–139+332+ 重写的333共 12 个全绿。