From c927106c313645dc4a2a5da0846647a4734bf0ee Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 3 Sep 2026 07:46:37 +0800 Subject: [PATCH 01/26] design: [xlings] converges on one table, and deps is retired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deps and workspace state the same thing about a project and differ only in what mcpp does with the statement. The document proposes that workspace become the one table, records the two facts that make the split indefensible — the general form installs nothing while its own documented shorthand installs, and nothing compares the two when both name one package — and carries the deprecation path, the packaging map that does not exist, and the loss the per-platform resolution introduced for it. Design only. Five questions in section 11 are xlings' to answer and are stated rather than assumed. --- ...09-03-xlings-workspace-as-the-one-table.md | 294 ++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 .agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md diff --git a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md new file mode 100644 index 00000000..5364e752 --- /dev/null +++ b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md @@ -0,0 +1,294 @@ +# `[xlings]`: one table, and `deps` retired + +Date: 2026-09-03. Status: design, awaiting review. Not implemented. +Baseline: `origin/main` at `4d99864` (2026.9.2.1). Every code reference below +was read at that commit. +Relates to: #531 (provisioning reads its result), #544 (per-platform values), +and the packaging map that does not exist yet (section 7). + +## 0. Summary + +`[xlings] deps` and `[xlings.workspace]` state the same thing about a project — +which package, at which version — and differ only in what mcpp then does with +the statement. That difference is not a property of the declaration, and the +proposal is to stop encoding it in the key: `[xlings.workspace]` becomes the +one table, an entry means "this project uses this at this version, provision it +if absent", and `deps` is retired through a deprecation path rather than a +removal. + +Two things the change is not. It is not a translation layer: `[xlings]` mirrors +xlings' own `.xlings.json` 1:1, and the merged semantics is the one xlings +already has, so mcpp is following the schema rather than inventing one. And it +does not weaken any existing behaviour: a declaration that cannot be satisfied +stays a hard build error, which is the property #531 was filed to obtain. + +## 1. What the two keys are today + +Facts, read at `4d99864`. + +| | `deps` | `[xlings.workspace]` | +|---|---|---| +| Shape | ordered array of package references | map, name to version | +| Reference form | `name`, `name@version`, `ns:name@version` | key is a bare name | +| Readers in mcpp | five | one | +| Provisioning | `install_packages`, result read, hard error, stamped on success | none | +| Reaches `build.mcpp` | `MCPP_XPKG__DIR` per installed payload | nothing | +| Reaches the runner lookup | payload `bin/`, in declaration order | nothing | + +The five readers of `deps`: the parser (`modules/manifest/src/toml.cppm:1410`), +the materialisation into `.mcpp/.xlings.json` +(`src/build/prepare.cppm:3196`), the provisioning pass +(`prepare.cppm:3420-3482`), `fillXpkgDirs` +(`prepare.cppm:4549`), and `BuildContext::xlingsDepBinDirs` +(`prepare.cppm:8707`, added in 2026.9.2.1). The one reader of `workspace` is +the materialisation, `prepare.cppm:3197-3198`; mcpp never acts on it. + +**The general form is weaker than its own shorthand.** `docs/05-mcpp-toml.md` +§2.13 states that `[toolchain]` is "the ergonomic shorthand for the compiler" +and `[xlings.workspace]` is "the general form". The shorthand installs: +`[toolchain]`'s spec reaches `resolve_xpkg_path(pkg.target(), +/*autoInstall=*/…)` at `prepare.cppm:2212`, and the `build.mcpp` host resolve +does the same at `:3067`. The general form installs nothing. A general form +that cannot express what its shorthand does is not general. + +**Nothing compares the two when both name one package.** A manifest may write +`deps = ["make@4.4"]` and `[xlings.workspace] make = "4.5"`. mcpp provisions +4.4 and writes both statements into `.xlings.json`; no code path in mcpp reads +the pair. This is the drift shape the repository has paid for repeatedly, and +merging the keys removes it by construction rather than by adding a check. + +## 2. Why they are one thing + +A version constraint and an installation are the same statement seen at two +moments. "This project uses cmake 3.28" is what the build environment must be; +whether cmake is already present decides whether anything has to be fetched, +and that is a fact about the machine, not about the project. xlings' own +`workspace` carries that reading, which is why the mcpp side has one reader: +there was nothing for mcpp to decide. + +Keeping two keys forces every author to answer a question the manifest should +not ask — "do I want this installed, or only pinned?" — whose honest answer is +always "installed if it is not there". The one case that looks like a +counterexample, pinning a tool the project may never invoke, is not one: an +entry naming a tool the project does not use is noise regardless of the key it +is written under. + +## 3. The schema after the change + +```toml +[xlings.workspace] +cmake = "3.28" +qemu-riscv = "9.2.4-1" +"xim:picolibc-riscv" = "1.8.12" +make = "*" +gcc = { linux = "15.1.0" } +llvm = { macos = "20", default = "22" } +``` + +Three decisions the table needs, listed for review. + +**W1. `"*"` means "any version, and it must be present".** `deps` accepts an +entry with no version (`deps = ["cmake"]`), and a map keyed by name has no way +to say "no constraint" other than a value that means it. Without `"*"` every +author who does not care about a version is forced to invent one, and a pinned +version nobody chose is worse than no pin. + +**W2. A key may carry a namespace prefix.** `deps` accepts `xim:name`, and the +namespace is load-bearing: `parse_xpkg_ref` splits it and `xpkg_payload` +resolves against it. If a workspace key cannot hold a colon, the namespace has +to move into the value, and the value position is already taken by the +per-platform table form. Whether xlings' workspace keys accept a colon is +question Q1 of section 11; the answer decides between the key form above and a +`{ namespace = "xim", version = "1.8.12" }` value form, which would be a second +table shape and is worse. + +**W3. The per-platform value form is unchanged.** It is already accepted on +both keys (2026.9.2.1) and it survives the merge unmodified. + +## 4. What mcpp writes into `.xlings.json` + +This is the decision the rest depends on, and it is xlings' to make. + +**Option A: mcpp writes only `workspace`.** The materialisation stops emitting +a `deps` array; every entry lands in the `workspace` object, and xlings +provisions from it. This is the shape the proposal is written to, and it holds +only if xlings provisions from `workspace`. If it does not, a project that +migrated would build on a machine where the packages happen to be installed and +fail on a clean one, which is the failure mode with the longest detection +delay. + +**Option B: mcpp derives a `deps` array from `workspace` when writing.** mcpp +keeps one table in `mcpp.toml` and emits both fields. This works whatever +xlings does, and it is a translation layer of exactly the kind §2.13 refuses. +It is acceptable only as a transitional step with a stated end. + +The recommendation is A, conditional on Q1 and Q2. B is the fallback and must +be labelled transitional in the code rather than left to look permanent. + +## 5. What provisioning means after the merge + +The provisioning pass keeps its current contract, with the input widened from +`deps` to the merged table: + +- The result is read, not assumed. `xlings::call` is in the value state + whenever the child ran, and the capability's status is inside `CallResult`; + the check stays `!called || childRc != 0` (`prepare.cppm:3448`). +- A failure is a hard build error naming the manual command, as today. +- The stamp is written only on success, and it is keyed on the hash of the + declared set, so the merged table changes the hash and every project + re-provisions once after upgrading. That is correct: the declared set is a + different set. +- `MCPP_OFFLINE` and `MCPP_NO_AUTO_INSTALL` gate the install action and not the + whole block, as today. + +**The one behaviour change is for `workspace` entries that exist now.** They +start being provisioned. The population is small and the direction is toward +the documented claim rather than away from it: an entry that was a pin becomes +a pin that is also honoured. Section 9 makes it a criterion rather than an +assumption. + +## 6. Migration, and what "retired" means + +`deps` is not deleted. Three phases, each with a criterion. + +**Phase 1 — the merged reader.** `workspace` gains provisioning, the payload +directory hand-off (`MCPP_XPKG_*_DIR`) and the runner lookup path. `deps` keeps +working exactly as it does and is documented as deprecated. A manifest that +names one package in both, with different versions, is a hard error naming both +lines: the drift of section 1 becomes unrepresentable at the moment the second +reader appears rather than later. + +**Phase 2 — the warning.** A manifest using `deps` builds and prints one +advisory naming the `[xlings.workspace]` line to write instead. The advisory is +per package, so the message is the edit. + +**Phase 3 — refusal, never silence.** `deps` stops being honoured and becomes a +hard error that names the replacement. It must not become an unknown key: +`[xlings]` has no unknown-key sweep (verified — no `kKnownXlings` list exists +in `toml.cppm`), so a removed key would be read by nobody and reported by +nobody, which is the shape #531 exists to prevent. Phase 3 is gated on an index +sweep showing no published manifest still uses `deps`, and on an mcpp floor in +the packages that migrate. + +**The ecosystem denominator is small.** Measured across the local `mcpplibs` +checkouts: three manifests declare `[xlings] deps` +(`aarch64-virt-rt`, `riscv-virt-rt`, `std-freestanding`), one entry each, all +of the form `xim:@`. mcpp's own `mcpp.toml` declares no +`[xlings]` section. The index has to be swept before Phase 3; the local +denominator is not the ecosystem. + +## 7. The packaging map, and a loss the current implementation has + +The reason a `deps`-shaped list exists in the first place is that a published +package's install-time edge lives in the descriptor, as +`xpm..deps`. That mapping does not exist in mcpp: +`src/publish/xpkg_emit.cppm` mentions neither `xlings` nor `deps`, and nothing +in `src/pack` or `src/publish` emits a platform `deps` table. A package that +declares `[xlings] deps` today gets a descriptor without it, and the edge is +written by hand — which is why `riscv-virt-rt` carries a thirty-line comment +about the release where the hand-written edge was removed and the C library +stopped being installed. + +**The per-platform resolution shipped in 2026.9.2.1 is lossy for this path.** +`XlingsConfig::deps` and `::workspace` hold values already resolved for the +running host (`modules/manifest/src/types.cppm`, and `resolve_host_value` in +`toml.cppm`), and the unresolved entries are discarded. An emitter needs all +platforms at once: `xpm.linux.deps` and `xpm.windows.deps` are two tables, and +a manifest loaded on Linux can no longer produce the second. Packing on macOS +would emit a descriptor missing the Linux edge, and nothing would say so. + +The fix is additive and belongs with this work because the merged table +inherits the same loss: + +```cpp +struct XlingsConfig { + std::map workspace; // resolved for THIS host + // The declaration as written, per platform, for consumers that are not + // this host: the descriptor emitter needs every platform's entries at + // once. The build path never reads this. + std::map> workspaceByPlatform; +}; +``` + +`resolve_host_value` already knows which platform each key belongs to; keeping +a second copy costs one insertion. The criterion is section 9's C4: packing on +one host emits every platform's edge, and it fails today because the emitter +does not exist. + +## 8. Axes + +**Structure.** One declaration site for "what this project's environment +contains", one reader set, one provisioning pass. The count of things that can +disagree about a package's version drops from two to zero. + +**Compatibility.** Phase 1 adds no manifest key and removes none, so a manifest +written for it loads on an older mcpp; there, a `workspace` entry is a pin that +installs nothing, which is what it means today. The reverse direction — +an older manifest on a newer mcpp — is unchanged through Phase 2. + +**Upgrading.** The provisioning stamp is keyed on the declared set, so the +first build after the merge re-provisions once per project and then behaves as +before. No cache is invalidated and no output path changes. + +**Consistency.** The general form gains what its shorthand already does. The +`[toolchain]`/`[xlings.workspace]` relationship stated in §2.13 becomes true +rather than aspirational. + +**Cross-platform.** The per-platform value form is unchanged; section 7 makes +it survive to the one consumer that needs the unresolved form. + +**What a person sees.** One table instead of two, and one question fewer to +answer when writing it. Every failure keeps naming the package and the manual +command. + +## 9. Test criteria + +Each must be observed failing before the corresponding change. + +| # | Criterion | Note | +|---|---|---| +| C1 | A `[xlings.workspace]` entry for a package that is not installed provisions it, and its payload directory reaches `build.mcpp` as `MCPP_XPKG__DIR` | Assert on the value the program read, not on a log line | +| C2 | A `workspace` entry that cannot be provisioned fails the build with the manual command in the message | The existing `deps` diagnostic, reached from the new input | +| C3 | One package named in both `deps` and `workspace` with different versions is a hard error naming both lines | Must be seen to fail on a manifest that today builds and silently provisions the `deps` version | +| C4 | Packing a project whose table has per-platform entries emits `xpm..deps` for every platform, from any host | Fails today because the emitter does not exist; the assertion is on the emitted descriptor, not on the manifest | +| C5 | A `workspace` entry with `"*"` provisions the package and pins nothing | Both halves; a test that only checks the install cannot tell a wildcard from a version | +| C6 | A migrated `riscv-virt-rt` resolves its emulator on a clean machine | The ecosystem case, run in a sandbox, because "installed already" is the state that hides this | +| C7 | The second build of an unchanged project provisions nothing and prints nothing | The stamp; and the whole-project fast path never reaches this code, so the test must touch a source first | + +C3 and C4 are the two that fail on the current engine. C7 is stated because +this repository has read that measurement wrongly twice. + +## 10. Implementation surface + +- `modules/manifest/src/toml.cppm`: `workspace` gains the reference forms + `deps` accepts (namespace prefix, `"*"`); `workspaceByPlatform` retained; + the `deps`/`workspace` conflict check; the Phase 2 advisory. +- `modules/manifest/src/types.cppm`: `XlingsConfig` fields and their comments. +- `src/build/prepare.cppm`: the provisioning pass, `fillXpkgDirs` and + `xlingsDepBinDirs` read the merged table; the materialisation emits what + section 4 decides. +- `src/xlings/xlings.cppm`: `ProjectEnv` and `seed_xlings_json`, per section 4. +- `src/publish/xpkg_emit.cppm`: the `xpm..deps` map (new). +- `docs/05-mcpp-toml.md` §2.13, `docs/13-baremetal.md`, `docs/17`, and each + `docs/zh/` twin, which CI enforces. +- `tests/unit/test_manifest.cpp`, a new e2e for C1/C2/C3, and the packing + criterion C4. +- Ecosystem: `aarch64-virt-rt`, `riscv-virt-rt`, `std-freestanding` migrate + after Phase 1 ships, each with an mcpp floor. + +## 11. Open questions + +1. **Does xlings provision from `workspace`?** Section 4 depends on it. If it + does not, the answer decides between adding it there and Option B here. +2. **Do xlings' workspace keys accept a namespace prefix (`xim:name`)?** W2 + depends on it. A workspace key becomes a shim name in xvm, which is the + reason to doubt it. +3. **Is `"*"` already spelled something else in xlings?** W1 should take the + existing spelling rather than introduce one. +4. **Does a workspace entry bind the package or one program?** The + `qemu-riscv` descriptor adds an umbrella node for the package name beside + the two program nodes, so both are addressable; whether binding the package + determines its programs' versions is the property the merged table relies on + when a package ships several programs. +5. **Phase 3's floor.** Which mcpp version the migrating packages declare, and + whether the index sweep is a release gate or a one-off. From 3ac07dacce6a065c0f79bdb37c1b2ec0c3248d60 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 3 Sep 2026 07:51:58 +0800 Subject: [PATCH 02/26] design: settle what is written, and state the section it is being changed against MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section 4 is decided rather than open: the correspondence is one to one in name and in meaning, so the merged table materialises as the file's own workspace object and no deps array is derived. The decision carries a consequence for W2 — mcpp appends the target's C library to that channel itself, as a namespaced reference with a version, so a key that cannot hold a namespace would leave mcpp unable to express its own entry. Section 12 records the section as a whole: field correspondence, the three places mcpp is not a pure mirror, the ownership rule and where the file is written, subos in full, and the two environment channels that are easy to confuse. --- ...09-03-xlings-workspace-as-the-one-table.md | 140 +++++++++++++++--- 1 file changed, 123 insertions(+), 17 deletions(-) diff --git a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md index 5364e752..37453564 100644 --- a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md +++ b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md @@ -107,23 +107,24 @@ both keys (2026.9.2.1) and it survives the merge unmodified. ## 4. What mcpp writes into `.xlings.json` -This is the decision the rest depends on, and it is xlings' to make. - -**Option A: mcpp writes only `workspace`.** The materialisation stops emitting -a `deps` array; every entry lands in the `workspace` object, and xlings -provisions from it. This is the shape the proposal is written to, and it holds -only if xlings provisions from `workspace`. If it does not, a project that -migrated would build on a machine where the packages happen to be installed and -fail on a clean one, which is the failure mode with the longest detection -delay. - -**Option B: mcpp derives a `deps` array from `workspace` when writing.** mcpp -keeps one table in `mcpp.toml` and emits both fields. This works whatever -xlings does, and it is a translation layer of exactly the kind §2.13 refuses. -It is acceptable only as a transitional step with a stated end. - -The recommendation is A, conditional on Q1 and Q2. B is the fallback and must -be labelled transitional in the code rather than left to look permanent. +**Decided (2026-09-03): mcpp writes `workspace`, and nothing else changes.** +The correspondence between `[xlings]` and `.xlings.json` is one to one in name +and in meaning, so `[xlings.workspace]` materialises as the file's `workspace` +object and the merged semantics is xlings' own. The `deps` array stops being +emitted when `deps` is retired. + +The alternative — deriving a `deps` array from `workspace` when writing — is +rejected. It is a translation layer, which §2.13 refuses, and it would put the +same statement in the file twice, which is the drift shape section 1 exists to +remove. + +One consequence for section 3's W2. mcpp does not only pass the author's +entries through: it appends the target's C library to the same channel +(`prepare.cppm:3186-3194`), and the value comes from the target row as +`xim:picolibc-riscv@1.8.12` — a namespaced reference with a version. So a +workspace key that cannot carry a namespace would make mcpp unable to express +its own injected entry, not merely inconvenience an author. W2 is a +prerequisite rather than a preference. ## 5. What provisioning means after the merge @@ -292,3 +293,108 @@ this repository has read that measurement wrongly twice. when a package ships several programs. 5. **Phase 3's floor.** Which mcpp version the migrating packages declare, and whether the index sweep is a release gate or a one-off. + +## 12. The section as a whole + +The proposal changes one field of a section whose other fields are unaffected. +This is what `[xlings]` is at `4d99864`, so that a review of the change can see +what it is being made against. + +### 12.1 Field correspondence + +| `mcpp.toml` | `.xlings.json` | Shape | Readers in mcpp | +|---|---|---|---| +| `[xlings] deps` | `deps` | array of package references | five (§1); retired by this proposal | +| `[xlings.workspace]` | `workspace` | object, name to version | one, the materialisation; five after this proposal | +| `[xlings] subos` | `subos` | string | the materialisation, and `select_runtime` | +| `[xlings.envs]` | `envs` | object, name to value | one, the materialisation | +| `[indices]` (not under `[xlings]`) | `index_repos` | array of repo objects | `ensure_project_index_dir` | +| — | `lang`, `mirror` | strings | written by mcpp unconditionally | + +Names and meanings correspond one to one, and mcpp adds no key of its own. The +file is written by `seed_xlings_json` (`src/xlings/xlings.cppm`), each field +emitted only when non-empty. + +### 12.2 Three places mcpp is not a pure mirror + +Stated because a "1:1, no translation layer" claim is checkable, and these are +the exceptions to it. + +1. **mcpp appends an entry the manifest did not write.** The target's C library + is added to the package channel, deduplicated, when the target row names one + (`prepare.cppm:3186-3194`). It rides that channel rather than having one of + its own so that one materialisation can be wrong instead of two. It is also + the reason §3's W2 is a prerequisite. +2. **What is written is already resolved for this host.** A per-platform value + is collapsed at manifest load (`resolve_host_value`), so the file is a + materialisation for this machine rather than a copy of the declaration. §7 + is the consequence. +3. **`lang` and `mirror` are mcpp's, not the manifest's.** They come from + mcpp's own configuration and are always present in the file. + +### 12.3 Ownership: who declares the environment + +One rule, in `mcpp.xlings.runtime_selection`, whose header states what it +deliberately does not read: the process environment, xlings' active or current +state, the compiler path, and dependency manifests. Allowing any of them would +make one `mcpp.toml` mean different ABIs in different shells. + +- In a workspace build the **workspace root** owns the declaration, even after + the package manifest switches to a selected member. An independently built + member is its own owner. +- A dependency's `[xlings]` is never consulted and never propagated. A + library's declaration applies when it is a root, not when its sources are + consumed by another root. +- The file is written under the owner's root + (`/.mcpp/.xlings.json`). When the owner is not the directory mcpp + writes into, two files are written: indices to the work root, the environment + to the owner root. +- Nothing is written at all unless the project declares indices, or declares + `[xlings]`, or the target row names a C library + (`materializeRootRuntime`, `prepare.cppm:3179-3181`). + +### 12.4 `subos`: presence is semantic, and mcpp only reads + +- **Absent** selects mcpp's initialised, release-verified `McppDefault`. + **`subos = "default"`** is an explicit `NamedSubos("default")`. A string alone + cannot distinguish absence from an empty value, which is why the manifest + carries `subosDeclared` beside it. +- The name is validated as a portable identifier (letters, digits, `.`, `_`, + `-`); anything else is a manifest error naming the value. +- There is **no CLI or environment override**, and no implicit following of + xlings' active or current SubOS. +- A named SubOS that does not exist is a **hard error**, never a fallback: + falling back would substitute a different environment for the one the + manifest named. Creating and populating one is xlings' layer + (`xlings subos new`); mcpp reads an environment and never creates one. +- An environment that exists but carries no `subos_info` **degrades**: the + runtime binding reports inconclusive, a note is printed, and the build + continues. +- On Linux the selection also fixes the loader and C library contract, so two + SubOS names produce separately fingerprinted objects. +- Only a **declared** SubOS puts its `bin/` at the front of `build.mcpp`'s + `PATH` (`projectSubosBin` is non-empty only for `Mode::NamedSubos`, + `prepare.cppm:1389`). A project that declares nothing inherits the `PATH` + mcpp was started with, byte for byte. + +### 12.5 `envs` is not the environment a program runs in + +Two channels are easy to confuse and are unrelated: + +- `[xlings.envs]` is materialised into `.xlings.json` and read by xlings for + the **tool** environment. mcpp has exactly one reader for it, the + materialisation. +- `compute_subos_env` (`src/build/execute.cppm:418`) builds the environment a + built program is **run** with, and it derives from `plan.runtimeBinding` — + the SubOS's own `subos_info` — not from `[xlings.envs]`. + +A value written under `[xlings.envs]` therefore does not reach `mcpp run`'s +child. Whether it should is a separate question from this proposal and is not +answered here. + +### 12.6 What this proposal does not touch + +`subos`, `envs`, `[indices]`, the ownership rule, the write conditions and the +`PATH` contract are unchanged. The change is confined to which of the two +package-shaped fields exists, and to the resolution loss §7 describes, which +the merged field inherits. From 158bfc32afcc2a69690a56559f11d9e62c8cbe28 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:00:24 +0800 Subject: [PATCH 03/26] design: what the four fields do, read in the xlings source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section 13 answers section 4 and Q1 from the implementation rather than from inference, and corrects two claims this document and docs/05 both made. xlings does not provision from workspace: deps is the only key any install path reads, so the merge belongs in mcpp.toml and the file keeps both fields — one statement materialised into the two places xlings reads its two halves. envs has no reader on either side. Every consumer in the xlings source is either xvm's per-program VData::envs or a SubOS's subos_info provider sections, and neither is the flat object mcpp writes; mcpp's own run environment comes from the runtime binding. The key is documented as having an effect it does not have, and the section proposes deciding its fate before this proposal ships. Also recorded: a named subos drops the global workspace layer entirely, which is a property of subos that nothing in mcpp states today. --- ...09-03-xlings-workspace-as-the-one-table.md | 132 ++++++++++++++++-- 1 file changed, 122 insertions(+), 10 deletions(-) diff --git a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md index 37453564..a9bd4c65 100644 --- a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md +++ b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md @@ -107,16 +107,28 @@ both keys (2026.9.2.1) and it survives the merge unmodified. ## 4. What mcpp writes into `.xlings.json` -**Decided (2026-09-03): mcpp writes `workspace`, and nothing else changes.** -The correspondence between `[xlings]` and `.xlings.json` is one to one in name -and in meaning, so `[xlings.workspace]` materialises as the file's `workspace` -object and the merged semantics is xlings' own. The `deps` array stops being -emitted when `deps` is retired. - -The alternative — deriving a `deps` array from `workspace` when writing — is -rejected. It is a translation layer, which §2.13 refuses, and it would put the -same statement in the file twice, which is the drift shape section 1 exists to -remove. +**Decided (2026-09-03), and revised the same day against the xlings source +(§13): the merge belongs in `mcpp.toml`, and the file keeps both fields.** + +One authoring key. When mcpp materialises it, an entry becomes a `deps` element +*and* a `workspace` member, because the file's two fields have two different +consumers in xlings and neither subsumes the other: `deps` is the install +trigger read by a bare `xlings install` (`src/core/cmdprocessor.cpp:163`), and +`workspace` is a version-resolution layer merged into the project's effective +pins (`src/core/config.cpp:660`, `:846`). Emitting both is not a translation +layer — it is the faithful materialisation of one statement, "use this at this +version, provision it if absent", into the two places xlings reads those two +halves. + +The earlier draft of this section proposed writing only `workspace` and letting +xlings provision from it. §13 measures that xlings does not: `deps` is the only +key its install path reads. Writing only `workspace` would have produced a +project that builds where the packages happen to be installed and fails on a +clean machine, which is the failure this document exists to avoid. + +If xlings later provisions from `workspace`, the `deps` half of the emission +can be dropped without touching `mcpp.toml` or any manifest. That is the +end-state, and it is a change on the xlings side, not here. One consequence for section 3's W2. mcpp does not only pass the author's entries through: it appends the target's C library to the same channel @@ -398,3 +410,103 @@ answered here. `PATH` contract are unchanged. The change is confined to which of the two package-shaped fields exists, and to the resolution loss §7 describes, which the merged field inherits. + +## 13. What the four fields actually do, measured in the xlings source + +Read at `/home/speak/workspace/github/openxlings/xlings`, 2026-09-03. Section 4 +and question Q1 of section 11 are answered here; section 12.5 is corrected. + +### 13.1 How the file is found at all + +xlings locates a project config by walking the current directory upward for a +`.xlings.json`, stopping at any directory that also contains a `subos/` — that +signature means "an xlings home", never a project — and, failing that, by +reading `XLINGS_PROJECT_DIR` (`src/core/config.cpp:765-799`). + +mcpp writes `/.mcpp/.xlings.json` and passes +`XLINGS_PROJECT_DIR=/.mcpp`, so the file is reached through the +environment variable, not the walk. A person standing in the project root and +running `xlings` does not see it: the walk looks for `/.xlings.json`, +one level up from where mcpp writes. Measured on a real materialisation +(`mcpplibs/riscv-virt-rt/.mcpp/.xlings.json`), whose `.mcpp/` holds no `subos/` +and therefore does not trip the home boundary. + +### 13.2 `deps` is the only install trigger, on both sides + +`install_from_project_config` (`src/core/cmdprocessor.cpp:163-196`) is the +no-argument `xlings install`. It reads `deps`, errors when the key is absent or +is not an array, and installs each entry through +`xmake xim -P -- -y`. It reads no other key. + +mcpp's own provisioning does not use that path: it calls the `install_packages` +capability with targets it read from `mcpp.toml` itself. So the `deps` array in +the file serves a different consumer — a person running bare `xlings install` — +than the pass that makes `mcpp build` work. + +### 13.3 `workspace` is a version layer, and a named subos drops the global one + +The project file's `workspace` object is read into `projectWorkspace_` +(`config.cpp:660-662`) and becomes one layer of the effective pins. +`merged_workspace` (`config.cpp:846-864`) resolves them: + +| Project subos mode | Layers merged, later winning | +|---|---| +| `Named` (the file declares `subos`) | project manifest, then that subos's own workspace | +| `Anonymous` (project file, no `subos`) | global, then project manifest, then the project subos | +| no project config | global only | + +**A named subos drops the global workspace entirely.** A project that declares +`[xlings] subos` therefore loses the machine's global pins for every tool it +does not pin itself, and nothing in mcpp says so today. That is a property of +`subos`, not of this proposal, and it belongs in `docs/17`. + +**A `workspace` entry installs nothing.** No install path reads it. This +answers Q1: xlings does not provision from `workspace`, which is why section 4 +was revised rather than kept. + +### 13.4 `envs` has no reader anywhere + +Every `envs` consumer in the xlings source is one of two structures, and +neither is the flat object mcpp writes: + +1. `xvm`'s `VData::envs` — environment variables attached to **one program's** + shim, stored in the version database and applied when the shim runs + (`src/core/xvm/db.cpp:724`, `src/core/xvm/shim.cpp:337`). Set through + `xvm add --env`, not through any project file. +2. A SubOS's `subos_info.envs` — "an object of **provider sections**" keyed by + binding (`src/core/subos/manifest.cpp:197-290`), part of the environment's + own metadata. + +Searching the whole source for `contains("envs")` and `["envs"]` outside those +two files and the doctor that checks them returns nothing. There is no reader +for a flat name-to-value `envs` object in a project or home `.xlings.json`. + +**So `[xlings.envs]` is written by mcpp and read by nobody.** It does not reach +a built program's environment either: `compute_subos_env` +(`src/build/execute.cppm:418`) derives that from `plan.runtimeBinding`, the +SubOS's own `subos_info`, and never consults `[xlings.envs]`. The sentence in +`docs/05-mcpp-toml.md` §2.13 that calls it "env vars applied to the tool +environment" describes an effect that does not occur. + +Three ways out, and the choice is not this document's to make: + +- **Wire it in xlings**: give the project file a flat `envs` object that the + tool environment applies. The key already exists in mcpp and in the file. +- **Map it onto `xvm`'s per-program `envs`**: possible only if the manifest + says which program each variable belongs to, which `[xlings.envs]` does not. +- **Retire it**, on the same three-phase path §6 gives `deps`, and for the same + reason: a key that is read by nobody is the shape #531 exists to prevent, and + it is worse here because the documentation states an effect. + +The recommendation is to decide before this proposal ships, because retiring +`deps` while leaving a second dead key in the same section would leave the +section half-audited. + +### 13.5 Corrections this section makes to the rest of the document + +| Where | Was | Is | +|---|---|---| +| §4 | mcpp writes only `workspace` | mcpp writes both fields; the merge is in `mcpp.toml` | +| §11 Q1 | open | answered: xlings does not provision from `workspace` | +| §12.1 | `envs` reader: "the materialisation" | no reader on either side | +| §12.5 | `[xlings.envs]` is read by xlings for the tool environment | it is read by nothing | From d4d58fa1a969dcdeb64789ff082129f26847a1e8 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:10:57 +0800 Subject: [PATCH 04/26] design: state the inheritance rule in full, not only the case that surprises The previous wording named the layer a declared subos drops and not the two rows where inheritance is what an author would expect. A project that pins a few tools and declares no subos starts from the global workspace, which is the common shape; a named subos inherits its own environment's workspace instead, because carrying the host's pins into a different installed set would name versions that are not there; and a fork copies its base's map once at creation. Also records that nothing falls back silently: with no layer naming a tool the shim diagnoses rather than choosing, which is the line the 2026.9.2.1 sandbox verification measured. --- ...09-03-xlings-workspace-as-the-one-table.md | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md index a9bd4c65..3da0ddbd 100644 --- a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md +++ b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md @@ -455,10 +455,31 @@ The project file's `workspace` object is read into `projectWorkspace_` | `Anonymous` (project file, no `subos`) | global, then project manifest, then the project subos | | no project config | global only | -**A named subos drops the global workspace entirely.** A project that declares -`[xlings] subos` therefore loses the machine's global pins for every tool it -does not pin itself, and nothing in mcpp says so today. That is a property of -`subos`, not of this proposal, and it belongs in `docs/17`. +**Pinning a few and inheriting the rest is what the Anonymous row does.** A +project that writes `[xlings.workspace]` and no `subos` starts from the global +workspace and merges its own entries on top, so every tool it does not name +keeps the machine's version. This is the common shape for an mcpp project and +it works as an author would expect. + +**A named subos does not inherit the global layer, and that is deliberate +rather than an omission.** A named SubOS is a different environment with its +own installed set; carrying the host's pins into it would name versions that +environment does not have. Its own `workspace` — stored in +`/.xlings.json` and merged as the last layer — is what it inherits from +instead. A SubOS created with `xlings subos new --from ` receives +the base's workspace map by copy at creation (`src/core/subos.cpp:978`); that +is a one-time inheritance, not a live link. + +**Nothing falls back silently when no layer names a tool.** The shim reads +`Config::effective_workspace()` and, finding no active version, produces a +diagnostic rather than choosing one (`src/core/xvm/shim.cpp:408-460`: +`xvm.no_active_version`, or the "installed in this subos, but no version is +active" form). The `qemu-aarch64-static is not installed in this subos (_)` +line measured during the 2026.9.2.1 verification is that path. + +What mcpp does not state today is the first two paragraphs: that declaring +`[xlings] subos` changes which pins apply, and that the environment's own +workspace replaces the global one. That belongs in `docs/17`. **A `workspace` entry installs nothing.** No install path reads it. This answers Q1: xlings does not provision from `workspace`, which is why section 4 From 87c67ebe2d5754038a78b651cf9954bdf498ec27 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:18:01 +0800 Subject: [PATCH 05/26] design: the documentation the rule needs, and a self-review that changes the proposal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section 14 assigns the inheritance rule to docs/05 §2.13 and docs/17 with their Chinese twins, as a table plus the reason the middle row is not an omission, and gives it a criterion so the paragraph is not the only record. Section 15 is the second read against the code. Its first finding changes the proposal: a project-file workspace entry is merged verbatim and the shim looks up a program's own name, so naming a package root pins a node nothing invokes while its programs resolve from the layer underneath. Group expansion happens in cmd_use, which a manifest never goes through. Three ways out are stated; expanding at read time is recommended and is xlings' to accept. Two smaller corrections: 'retired' has always meant the manifest key and never the file field, and section 12.6's claim to touch neither subos nor envs is withdrawn. --- ...09-03-xlings-workspace-as-the-one-table.md | 122 +++++++++++++++++- 1 file changed, 117 insertions(+), 5 deletions(-) diff --git a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md index 3da0ddbd..15182233 100644 --- a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md +++ b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md @@ -16,11 +16,15 @@ one table, an entry means "this project uses this at this version, provision it if absent", and `deps` is retired through a deprecation path rather than a removal. -Two things the change is not. It is not a translation layer: `[xlings]` mirrors -xlings' own `.xlings.json` 1:1, and the merged semantics is the one xlings -already has, so mcpp is following the schema rather than inventing one. And it -does not weaken any existing behaviour: a declaration that cannot be satisfied -stays a hard build error, which is the property #531 was filed to obtain. +The merge is in `mcpp.toml`, not in the file mcpp writes. `.xlings.json` keeps +both fields, because xlings reads them in two different places and neither +subsumes the other (section 13). One authored entry therefore materialises as +one `deps` element and one `workspace` member: not a translation, but one +statement written where each of its two halves is read. + +The change does not weaken any existing behaviour: a declaration that cannot be +satisfied stays a hard build error, which is the property #531 was filed to +obtain. ## 1. What the two keys are today @@ -531,3 +535,111 @@ section half-audited. | §11 Q1 | open | answered: xlings does not provision from `workspace` | | §12.1 | `envs` reader: "the materialisation" | no reader on either side | | §12.5 | `[xlings.envs]` is read by xlings for the tool environment | it is read by nothing | + +## 14. What mcpp's own documentation must say + +The inheritance rule of §13.3 is the behaviour an author most needs and the one +mcpp states nowhere. It is not introduced by this proposal; it is being written +down because the proposal makes `workspace` the key everybody writes. + +**`docs/05-mcpp-toml.md` §2.13** gains the rule as a table, next to the +`[xlings.workspace]` description: + +| The project declares | The version of a tool it did not name comes from | +|---|---| +| `[xlings.workspace]`, no `subos` | the machine's global workspace; the project's own entries win over it | +| `[xlings.workspace]` and `subos = ""` | that SubOS's own workspace; the global one does not apply | +| neither | the machine's global workspace | + +with one sentence for why the middle row is not an omission: a named SubOS is a +different environment with its own installed set, and carrying the host's pins +into it would name versions that are not there. + +**`docs/17-the-project-environment.md`** gains the consequence, because that +chapter is where `subos` is chosen: declaring a SubOS changes which version +pins apply, and a project that relied on the machine's pins has to state them +itself once it names an environment. The chapter already says mcpp reads an +environment and never creates one; this is the other half of what the +declaration decides. + +**`docs/13-baremetal.md`** needs no change: it declares packages, not versions. + +Each with its `docs/zh/` twin, which CI enforces. + +A criterion, so the paragraph is not the only record: a project that pins a +tool and declares a SubOS resolves that tool to its pin, and a tool it does not +pin resolves inside the SubOS rather than to the host's global choice. It is an +e2e over two SubOS environments and one tool installed at two versions. + +## 15. Self-review + +Read against the code a second time, 2026-09-03. Three findings; the first is +the one that changes the proposal. + +### 15.1 A project-file `workspace` entry does not expand a binding group + +Programs are resolved by name. The shim reads `Config::effective_workspace()` +and looks up **the program's own name** (`src/core/xvm/shim.cpp:409-412`), and +the merge that produces that map is a plain per-key merge +(`config.cpp:846-864`). Neither consults the version database, so neither +expands a package into its members. + +Expansion happens somewhere else: `cmd_use` resolves the whole release through +`resolve_binding_selection` and writes one workspace entry **per member** +(`src/core/xvm/commands.cpp:763-772`). That is why using either a package name +or one of its programs switches all of them — the group is expanded at the +moment of the switch, and what lands in the file is already per-member. + +A project manifest does not go through `cmd_use`. Its `workspace` object is +read as a layer, verbatim. So: + +```toml +[xlings.workspace] +qemu-riscv = "9.2.4-1" # the package, and the umbrella node +``` + +pins the node named `qemu-riscv`, which nothing invokes, and leaves +`qemu-system-riscv64` to be resolved by the layer underneath. On a machine with +two versions installed and the newer one active, the project's pin is silently +inert — the exact shape the merge was meant to remove. + +Three ways out, and this is the decision the proposal now needs most: + +1. **Expand at read time.** When a project layer names a group root, resolve + the group and apply the version to every member. Correct and invisible to + authors, and it puts version-database knowledge into the merge, which is + xlings' side to accept or refuse. +2. **Expand at materialisation.** mcpp writes one `workspace` member per group + member. mcpp does not have the version database, so it cannot. +3. **Require program names for pinning.** The author writes + `qemu-system-riscv64 = "9.2.4-1"`. This works today with no change anywhere + — and it re-opens the split the proposal closes, because provisioning needs + the package (`qemu-user-aarch64`) while pinning needs the program + (`qemu-aarch64-static`), and §13 measured that those names differ. + +Option 1 is the recommendation. Option 3 is honest about today and should be +what the documentation says until 1 exists. + +### 15.2 "Retired" means the manifest key, never the file field + +Section 6 reads as though `deps` disappears. It does not: §4 keeps emitting the +`deps` array into `.xlings.json`, because that is the only key xlings' install +path reads. What is retired is the key an author writes in `mcpp.toml`. Every +occurrence of "retired" in sections 0, 6 and 12 means that and only that, and +the phases apply to the manifest surface alone. + +### 15.3 Section 12.6 is no longer true as written + +It says the proposal touches neither `subos` nor `envs`. It touches both, +though not their behaviour: §13.4 asks for a decision on `envs`, which is +written by mcpp and read by nobody, and §14 adds documentation for what `subos` +does to the inheritance chain. Neither changes an effect; both change what the +project states about itself, which is the part this document is for. + +### 15.4 What survived the review unchanged + +The two facts section 1 rests on. The general form still installs nothing while +its documented shorthand installs, and nothing still compares the two keys when +both name one package. §13 strengthened rather than weakened them: xlings makes +the same split in the same direction, which is why the merge belongs in the +manifest and not in the file. From acd9cba15342c5d4d0a1da096e10a9809a3a7148 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:28:22 +0800 Subject: [PATCH 06/26] design: four questions answered from the source, and 15.1 withdrawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 15.1 claimed a manifest workspace entry cannot pin a package's programs. Both its premises hold and the conclusion does not: the expansion happens in the action mcpp triggers, not in the merge. Installing calls cmd_use, cmd_use resolves the release and writes one entry per member, and with a project config loaded those land in the project's own SubOS layer. What the read did find is one flag. Activation after install happens only when nothing is active yet, so on a machine already holding another version the declared one is installed and not run. The capability already takes useAfterInstall; D8 sends it. W1 and W2 are rewritten against the file format rather than inferred: stored values are always concrete, latest and a version prefix are the input spellings, and every key in three real subos files is a bare target name with no namespace. envs is decided — retired, with the documentation correction shippable on its own. --- ...09-03-xlings-workspace-as-the-one-table.md | 189 ++++++++++-------- 1 file changed, 109 insertions(+), 80 deletions(-) diff --git a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md index 15182233..f3e32188 100644 --- a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md +++ b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md @@ -91,20 +91,31 @@ llvm = { macos = "20", default = "22" } Three decisions the table needs, listed for review. -**W1. `"*"` means "any version, and it must be present".** `deps` accepts an -entry with no version (`deps = ["cmake"]`), and a map keyed by name has no way -to say "no constraint" other than a value that means it. Without `"*"` every -author who does not care about a version is forced to invent one, and a pinned -version nobody chose is worse than no pin. - -**W2. A key may carry a namespace prefix.** `deps` accepts `xim:name`, and the -namespace is load-bearing: `parse_xpkg_ref` splits it and `xpkg_payload` -resolves against it. If a workspace key cannot hold a colon, the namespace has -to move into the value, and the value position is already taken by the -per-platform table form. Whether xlings' workspace keys accept a colon is -question Q1 of section 11; the answer decides between the key form above and a -`{ namespace = "xim", version = "1.8.12" }` value form, which would be a second -table shape and is worse. +**W1. There is no `"*"`, and none should be invented.** xlings already has two +spellings for "not an exact version": a version **prefix**, which +`match_version` resolves to the highest match (`src/core/xvm/db.cpp:411` — `22` +selects `22.1.8`), and `latest`, which `cmd_use` resolves to the highest +installed version before writing (`commands.cpp:592-607`). Both are **input** +spellings: what lands in a workspace file is always a concrete version, which +is why a stored `latest` would fail at shim time rather than mean anything. The +manifest therefore accepts a version, a prefix, or `latest`, and "must exist, +version unconstrained" is spelled `latest`. Measured on three real subos files +on the development host: every stored value is concrete. + +**W2. The authoring key may carry a namespace; the file's key never does.** +Every workspace key in those same three files is a bare xvm target name — +`cc`, `ar`, `mcpp`, `binutils`, even `crt1.o` — and none contains a colon. That +is the name the shim looks up. A namespace belongs to the *install address* +(`xim:picolibc-riscv@1.8.12`), which is a different vocabulary: xlings itself +keeps them apart, and `parse_ns_version` handles a scope prefix on the +**version** (`local:0.4.47`), not a namespace on the key. + +So mcpp accepts `"xim:picolibc-riscv" = "1.8.12"` as an authoring key, uses the +full address when it provisions, and writes the bare `picolibc-riscv` into the +file's `workspace`. This is not a translation layer; it is the same split +xlings makes between what you install and what you resolve. It is also what +lets mcpp express its own injected entry, whose value from the target row is a +namespaced address (§4). **W3. The per-platform value form is unchanged.** It is already accepted on both keys (2026.9.2.1) and it survives the merge unmodified. @@ -295,20 +306,28 @@ this repository has read that measurement wrongly twice. ## 11. Open questions -1. **Does xlings provision from `workspace`?** Section 4 depends on it. If it - does not, the answer decides between adding it there and Option B here. -2. **Do xlings' workspace keys accept a namespace prefix (`xim:name`)?** W2 - depends on it. A workspace key becomes a shim name in xvm, which is the - reason to doubt it. -3. **Is `"*"` already spelled something else in xlings?** W1 should take the - existing spelling rather than introduce one. -4. **Does a workspace entry bind the package or one program?** The - `qemu-riscv` descriptor adds an umbrella node for the package name beside - the two program nodes, so both are addressable; whether binding the package - determines its programs' versions is the property the merged table relies on - when a package ships several programs. -5. **Phase 3's floor.** Which mcpp version the migrating packages declare, and - whether the index sweep is a release gate or a one-off. +Answered on 2026-09-03 and kept here with their answers, because a question +that was open is part of how the design was reached. + +1. **Does xlings provision from `workspace`?** No. `deps` is the only key any + install path reads (§13.2). Hence §4: the merge is in `mcpp.toml` and the + file keeps both fields. +2. **Do workspace keys accept a namespace prefix?** The file's keys never carry + one; the namespace belongs to the install address (§3 W2). mcpp accepts it + in the authored key and writes the bare target name. +3. **Is `"*"` already spelled something else?** Yes, two ways: a version prefix + and `latest`, both resolved before anything is stored (§3 W1). No new + spelling is introduced. +4. **Does binding a package determine its programs' versions?** Yes, and the + expansion happens when the entry is honoured rather than when it is merged + (§15.1). +5. **Phase 3's floor.** Open. Which mcpp version the migrating packages + declare, and whether the index sweep is a release gate or a one-off. + +The one decision left for review is D8 (§15.1): the provisioning pass sends +`useAfterInstall: true`, so a declared version becomes the active one in the +project's own layer rather than being installed beside whatever is already +active. ## 12. The section as a whole @@ -513,19 +532,18 @@ SubOS's own `subos_info`, and never consults `[xlings.envs]`. The sentence in `docs/05-mcpp-toml.md` §2.13 that calls it "env vars applied to the tool environment" describes an effect that does not occur. -Three ways out, and the choice is not this document's to make: +**Decided (2026-09-03): `envs` is retired, not wired.** xlings never supported +a project-level flat `envs` object and does not need to: `[xlings]` exists to +align with xlings' project-level isolated environment, and the two `envs` +structures that do exist there belong to a program's shim and to a SubOS's own +metadata. Neither is something a consuming project should be writing. -- **Wire it in xlings**: give the project file a flat `envs` object that the - tool environment applies. The key already exists in mcpp and in the file. -- **Map it onto `xvm`'s per-program `envs`**: possible only if the manifest - says which program each variable belongs to, which `[xlings.envs]` does not. -- **Retire it**, on the same three-phase path §6 gives `deps`, and for the same - reason: a key that is read by nobody is the shape #531 exists to prevent, and - it is worse here because the documentation states an effect. - -The recommendation is to decide before this proposal ships, because retiring -`deps` while leaving a second dead key in the same section would leave the -section half-audited. +It follows the same three phases §6 gives `deps`, and for a stronger reason: a +key read by nobody is the shape #531 exists to prevent, and this one is +additionally documented as having an effect. Phase 1 is the documentation +correction, which can ship immediately and independently of everything else in +this document — `docs/05-mcpp-toml.md` §2.13 and its Chinese twin currently +state an effect that does not occur. ### 13.5 Corrections this section makes to the rest of the document @@ -576,49 +594,60 @@ e2e over two SubOS environments and one tool installed at two versions. Read against the code a second time, 2026-09-03. Three findings; the first is the one that changes the proposal. -### 15.1 A project-file `workspace` entry does not expand a binding group - -Programs are resolved by name. The shim reads `Config::effective_workspace()` -and looks up **the program's own name** (`src/core/xvm/shim.cpp:409-412`), and -the merge that produces that map is a plain per-key merge -(`config.cpp:846-864`). Neither consults the version database, so neither -expands a package into its members. - -Expansion happens somewhere else: `cmd_use` resolves the whole release through -`resolve_binding_selection` and writes one workspace entry **per member** -(`src/core/xvm/commands.cpp:763-772`). That is why using either a package name -or one of its programs switches all of them — the group is expanded at the -moment of the switch, and what lands in the file is already per-member. - -A project manifest does not go through `cmd_use`. Its `workspace` object is -read as a layer, verbatim. So: +### 15.1 Withdrawn: the expansion happens, in the action rather than in the merge + +The first version of this section claimed that a project-file `workspace` entry +cannot pin a package's programs, because the merge is per-key and the shim +looks up a program's own name. Both halves are true and the conclusion does not +follow. It reads the declaration as something mcpp only writes down, and this +proposal is that mcpp acts on it. + +The mechanism, read through: + +1. mcpp provisions each entry through the `install_packages` capability. +2. `xim`'s installer, having installed, calls `xvm::cmd_use(name, version)` + for the requested target (`src/core/xim/commands.cpp:693`). +3. `cmd_use` resolves the whole release with `resolve_binding_selection` and + writes one workspace entry **per member** + (`src/core/xvm/commands.cpp:763-772`); the installer's own note says the + same — "`cmd_use` creates shims for every member of the release it switches + to" (`installer.cpp:1986`). +4. `Config::workspace_mut()` returns the **project's** SubOS workspace whenever + a project config is loaded (`config.cpp:1163-1168`), so those per-member + entries land in the project's own layer, which the merge applies last. The + machine's global choice is not disturbed. + +So naming a package root in the manifest does pin its programs: the group is +expanded when the entry is honoured, and what lands in the file is already +per-member. The declaration is the input to that action, not a layer expected +to expand itself. + +**What the review did find is one flag.** Activation after install is +conditional (`installer.cpp` `activate_requested_targets`): -```toml -[xlings.workspace] -qemu-riscv = "9.2.4-1" # the package, and the umbrella node +```cpp +auto active = xvm::get_active_version(Config::effective_workspace(), match.name); +if ((active.empty() || useAfterInstall) && has_version(db, match.name, match.version)) + cmd_use(match.name, match.version, stream); +else if (!active.empty() && active != match.version) + // declining to switch is a decision, and it used to be a silent one ``` -pins the node named `qemu-riscv`, which nothing invokes, and leaves -`qemu-system-riscv64` to be resolved by the layer underneath. On a machine with -two versions installed and the newer one active, the project's pin is silently -inert — the exact shape the merge was meant to remove. - -Three ways out, and this is the decision the proposal now needs most: - -1. **Expand at read time.** When a project layer names a group root, resolve - the group and apply the version to every member. Correct and invisible to - authors, and it puts version-database knowledge into the merge, which is - xlings' side to accept or refuse. -2. **Expand at materialisation.** mcpp writes one `workspace` member per group - member. mcpp does not have the version database, so it cannot. -3. **Require program names for pinning.** The author writes - `qemu-system-riscv64 = "9.2.4-1"`. This works today with no change anywhere - — and it re-opens the split the proposal closes, because provisioning needs - the package (`qemu-user-aarch64`) while pinning needs the program - (`qemu-aarch64-static`), and §13 measured that those names differ. - -Option 1 is the recommendation. Option 3 is honest about today and should be -what the documentation says until 1 exists. +Install activates only when **nothing is active yet**, unless the caller asks +otherwise. mcpp's provisioning pass sends `{"targets": […], "yes": true}` and +nothing else, so on a machine where another version of that name is already +active, the declared version is installed and not the one that runs. + +That is tolerable for `deps`, whose meaning is "must exist". It is not +tolerable for a table whose meaning is "at this version": a constraint that +installs without activating is not a constraint. The capability already takes +the flag — `useAfterInstall`, documented as "Activate the installed version +even if another version is currently active" (`src/capabilities.cpp:81`) — so +the change is one field in the request mcpp already sends. + +**Decision D8: the provisioning pass passes `useAfterInstall: true`.** Its +blast radius is the project's own SubOS layer, per point 4 above, which is what +makes it safe to do unconditionally rather than behind another key. ### 15.2 "Retired" means the manifest key, never the file field From 161aec28e2bbce79756b454a95bdb85b84537825 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:33:43 +0800 Subject: [PATCH 07/26] design: the key space measured, the namespace put where the file puts it, and the migration collapsed to one release Three corrections, all from data rather than inference. A workspace key is an xvm target of any kind. The default SubOS holds binutils beside ar, as and ld at one version, and gcc beside g++ and cc at another: package roots, programs and file assets share one namespace, and a root and its programs agree because cmd_use wrote them as one release. Calling the keys program names was wrong, and writing a package in a manifest is a legitimate entry whose programs receive the same version. The namespace rides the value, not the key: parse_ns_version splits the first colon of a version string and a real entry reads xim:2026.8.30.2. So the form is picolibc-riscv = "xim:1.8.12", which needs no rule mcpp has to invent. The migration is one release. Three manifests declare the key and mcpp declares none, so the deprecation window buys nothing; what does not bend is that deps is refused with a message rather than dropped in silence. --- ...09-03-xlings-workspace-as-the-one-table.md | 129 +++++++++++------- 1 file changed, 77 insertions(+), 52 deletions(-) diff --git a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md index f3e32188..80f2659c 100644 --- a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md +++ b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md @@ -102,20 +102,20 @@ manifest therefore accepts a version, a prefix, or `latest`, and "must exist, version unconstrained" is spelled `latest`. Measured on three real subos files on the development host: every stored value is concrete. -**W2. The authoring key may carry a namespace; the file's key never does.** -Every workspace key in those same three files is a bare xvm target name — -`cc`, `ar`, `mcpp`, `binutils`, even `crt1.o` — and none contains a colon. That -is the name the shim looks up. A namespace belongs to the *install address* -(`xim:picolibc-riscv@1.8.12`), which is a different vocabulary: xlings itself -keeps them apart, and `parse_ns_version` handles a scope prefix on the -**version** (`local:0.4.47`), not a namespace on the key. - -So mcpp accepts `"xim:picolibc-riscv" = "1.8.12"` as an authoring key, uses the -full address when it provisions, and writes the bare `picolibc-riscv` into the -file's `workspace`. This is not a translation layer; it is the same split -xlings makes between what you install and what you resolve. It is also what -lets mcpp express its own injected entry, whose value from the target row is a -namespaced address (§4). +**W2. The namespace goes on the version, and that needs no new rule.** +`.xlings.json` already spells a namespaced entry as `target = ":"`: +`parse_ns_version` splits on the first colon and `make_ns_version` builds it +(`src/core/xvm/db.cpp:10-20`), and a real subos on the development host holds +`"mcpp": {"active": "xim:2026.8.30.2", …}`. So the form is + +```toml +[xlings.workspace] +picolibc-riscv = "xim:1.8.12" +``` + +with the key being the xvm target and the namespace riding the value, exactly +as the file writes it. mcpp accepts what xlings already accepts, and the +target's C library that mcpp injects (§4) is expressible in the same shape. **W3. The per-platform value form is unchanged.** It is already accepted on both keys (2026.9.2.1) and it survives the merge unmodified. @@ -145,13 +145,12 @@ If xlings later provisions from `workspace`, the `deps` half of the emission can be dropped without touching `mcpp.toml` or any manifest. That is the end-state, and it is a change on the xlings side, not here. -One consequence for section 3's W2. mcpp does not only pass the author's -entries through: it appends the target's C library to the same channel -(`prepare.cppm:3186-3194`), and the value comes from the target row as -`xim:picolibc-riscv@1.8.12` — a namespaced reference with a version. So a -workspace key that cannot carry a namespace would make mcpp unable to express -its own injected entry, not merely inconvenience an author. W2 is a -prerequisite rather than a preference. +One consequence for section 3. The target's C library that mcpp appends +(`prepare.cppm:3186-3194`) arrives from the target row as +`xim:picolibc-riscv@1.8.12`, an install address. As a workspace entry it is +`picolibc-riscv = "xim:1.8.12"` — the same two facts, in the shape the file +already uses. mcpp splits the address once, at the point it builds the entry; +nothing downstream sees two spellings. ## 5. What provisioning means after the merge @@ -175,35 +174,36 @@ the documented claim rather than away from it: an entry that was a pin becomes a pin that is also honoured. Section 9 makes it a criterion rather than an assumption. -## 6. Migration, and what "retired" means - -`deps` is not deleted. Three phases, each with a criterion. - -**Phase 1 — the merged reader.** `workspace` gains provisioning, the payload -directory hand-off (`MCPP_XPKG_*_DIR`) and the runner lookup path. `deps` keeps -working exactly as it does and is documented as deprecated. A manifest that -names one package in both, with different versions, is a hard error naming both -lines: the drift of section 1 becomes unrepresentable at the moment the second -reader appears rather than later. - -**Phase 2 — the warning.** A manifest using `deps` builds and prints one -advisory naming the `[xlings.workspace]` line to write instead. The advisory is -per package, so the message is the edit. - -**Phase 3 — refusal, never silence.** `deps` stops being honoured and becomes a -hard error that names the replacement. It must not become an unknown key: -`[xlings]` has no unknown-key sweep (verified — no `kKnownXlings` list exists -in `toml.cppm`), so a removed key would be read by nobody and reported by -nobody, which is the shape #531 exists to prevent. Phase 3 is gated on an index -sweep showing no published manifest still uses `deps`, and on an mcpp floor in -the packages that migrate. - -**The ecosystem denominator is small.** Measured across the local `mcpplibs` -checkouts: three manifests declare `[xlings] deps` -(`aarch64-virt-rt`, `riscv-virt-rt`, `std-freestanding`), one entry each, all -of the form `xim:@`. mcpp's own `mcpp.toml` declares no -`[xlings]` section. The index has to be swept before Phase 3; the local -denominator is not the ecosystem. +## 6. Migration: one release, not a deprecation window + +The population is three manifests. `aarch64-virt-rt`, `riscv-virt-rt` and +`std-freestanding` each declare one `[xlings] deps` entry of the form +`xim:@`; mcpp's own `mcpp.toml` declares no `[xlings]` section +at all. A three-phase deprecation exists to give an ecosystem time it does not +need here, so the migration is a single release: the packages are edited and +republished with the new form, and `deps` is refused in the same version that +introduces the merged reader. + +What "refused" must mean, and this is the part that does not bend: `deps` stops +being honoured by becoming a **hard error that names the replacement**, never +by becoming a key nobody reads. `[xlings]` has no unknown-key sweep — no +`kKnownXlings` list exists in `toml.cppm` — so a silently dropped key would be +read by nobody and reported by nobody, which is the shape #531 exists to +prevent. + +Ordering, because the three packages are consumed by projects that may be built +with either engine: + +1. The merged reader ships, accepting `workspace` and refusing `deps` with a + message naming the line to write. +2. The three packages are republished with `[xlings.workspace]` and an mcpp + floor at that version. +3. An index sweep confirms no other published manifest declares `deps`. The + local checkouts are not the ecosystem; the sweep is what makes the claim. + +Step 3 gates nothing on the mcpp side — it is a check that the denominator was +what it looked like. If it turns up manifests nobody knew about, the refusal in +step 1 becomes an advisory for one release and the window opens after all. ## 7. The packaging map, and a loss the current implementation has @@ -321,8 +321,9 @@ that was open is part of how the design was reached. 4. **Does binding a package determine its programs' versions?** Yes, and the expansion happens when the entry is honoured rather than when it is merged (§15.1). -5. **Phase 3's floor.** Open. Which mcpp version the migrating packages - declare, and whether the index sweep is a release gate or a one-off. +5. **The migration window.** Answered by the denominator: three manifests, so + no window (§6). The index sweep confirms the denominator rather than gating + the change. The one decision left for review is D8 (§15.1): the provisioning pass sends `useAfterInstall: true`, so a declared version becomes the active one in the @@ -508,6 +509,30 @@ workspace replaces the global one. That belongs in `docs/17`. answers Q1: xlings does not provision from `workspace`, which is why section 4 was revised rather than kept. +### 13.3.1 What a workspace key is: an xvm target, of any kind + +Measured on the development host's default SubOS, 546 entries: + +``` +binutils = 2.42 ar = 2.42 as = 2.42 ld = 2.42 +gcc = 16.1.0 g++ = 16.1.0 cc = 16.1.0 +mcpp = xim:2026.8.30.2 +Scrt1.o, crt1.o, crti.o, crtn.o, glibc.files.1 … glibc.files.101 +``` + +Package roots, the programs of those packages, and file assets all live in one +namespace, each with `{active, installed}`. A package root and its programs +carry the **same version** because they are members of one release and +`cmd_use` wrote them together (§15.1) — that identity is the group expansion's +own footprint in the data. + +So "the workspace holds packages" and "the workspace holds programs" are both +half-right: it holds xvm targets, and a package's root is one of them. Writing +the package in a manifest is therefore a legitimate entry, and its programs +receive the same version when the entry is honoured. Writing a program is +equally legitimate and selects the same release. What a key never carries is a +namespace; that rides the value (§3 W2). + ### 13.4 `envs` has no reader anywhere Every `envs` consumer in the xlings source is one of two structures, and From f458495e58182090f35f722583a9e12234ac5dbc Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:37:12 +0800 Subject: [PATCH 08/26] design: the key form checked against the data, and ns:name rejected with its reason W2 asserted where the namespace goes; it now measures. 1635 version-database targets and 546 workspace entries on the development host, and not one key contains a colon. Two reasons ns:name cannot be the key, and neither is convention. A workspace key is looked up by the name a program is invoked as, and nothing is invoked as xim:picolibc-riscv, so the key would be read by nobody. And the namespace qualifies where a version came from rather than what the tool is: mcpp itself holds eight versions under one target, some scoped xim: and some not, which moving the namespace onto the key would split into two tools with one name. A colon in a key is therefore a hard error naming the correct form, not a second accepted spelling. The section 3 example is corrected in the same pass: the invented "*" becomes latest. --- ...09-03-xlings-workspace-as-the-one-table.md | 56 ++++++++++++++----- 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md index 80f2659c..7686e7f2 100644 --- a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md +++ b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md @@ -81,12 +81,12 @@ is written under. ```toml [xlings.workspace] -cmake = "3.28" -qemu-riscv = "9.2.4-1" -"xim:picolibc-riscv" = "1.8.12" -make = "*" -gcc = { linux = "15.1.0" } -llvm = { macos = "20", default = "22" } +cmake = "3.28" +qemu-riscv = "9.2.4-1" +picolibc-riscv = "xim:1.8.12" +make = "latest" +gcc = { linux = "15.1.0" } +llvm = { macos = "20", default = "22" } ``` Three decisions the table needs, listed for review. @@ -102,20 +102,48 @@ manifest therefore accepts a version, a prefix, or `latest`, and "must exist, version unconstrained" is spelled `latest`. Measured on three real subos files on the development host: every stored value is concrete. -**W2. The namespace goes on the version, and that needs no new rule.** -`.xlings.json` already spells a namespaced entry as `target = ":"`: -`parse_ns_version` splits on the first colon and `make_ns_version` builds it -(`src/core/xvm/db.cpp:10-20`), and a real subos on the development host holds -`"mcpp": {"active": "xim:2026.8.30.2", …}`. So the form is +**W2. The namespace goes on the version, not on the key — and the key form was +checked rather than assumed.** Measured on the development host: 1635 targets +in the version database and 546 workspace entries in the default SubOS, and +**not one key contains a colon**. The colon appears on the other side: +`"mcpp": {"active": "xim:2026.8.30.2", …}`. + +`ns:name` as a key was considered and does not work, for a reason stronger than +convention. A workspace key is looked up by the name a program is **invoked +as** (`get_active_version(workspace, program_name)`, +`src/core/xvm/shim.cpp:409-412`); nothing is ever invoked as +`xim:picolibc-riscv`, so such a key would be read by nobody — the shape §6 +refuses for `deps`. + +And the namespace is not a property of the tool. It qualifies **where a version +came from**, which is why one target legitimately carries both scoped and +unscoped versions at once. Measured, on this machine, for `mcpp` itself: + +``` +"mcpp": { "active": "xim:2026.8.30.2", + "installed": ["2026.8.21.1", …, "xim:2026.8.28.2", "xim:2026.8.30.1"] } +``` + +Eight versions of one target, some from the xim index and some not. Moving the +namespace onto the key would split that into two targets, and `mcpp` on `PATH` +would resolve to whichever half won — which is the same defect in the small +that `ar` from two providers would be in the large. + +So the form is: ```toml [xlings.workspace] picolibc-riscv = "xim:1.8.12" ``` -with the key being the xvm target and the namespace riding the value, exactly -as the file writes it. mcpp accepts what xlings already accepts, and the -target's C library that mcpp injects (§4) is expressible in the same shape. +the key being the xvm target and the namespace riding the value, exactly as the +file writes it. mcpp reconstructs the install address `xim:picolibc-riscv@1.8.12` +from the pair when it provisions, so nothing is lost, and the C library mcpp +injects (§4) is expressible in the same shape. + +A key containing a colon is a **hard error naming the correct form**, rather +than a second accepted spelling: one fact, one way to write it, is the whole +argument of this document applied to itself. **W3. The per-platform value form is unchanged.** It is already accepted on both keys (2026.9.2.1) and it survives the merge unmodified. From b9cb76ecd11f0819631a6bd8d2c1317fd21eba4c Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:43:34 +0800 Subject: [PATCH 09/26] design: the authored project file is the reference, not a SubOS state file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two artifacts share the name and this document measured one for the other. The authored file maps a name to a version string or a platform object; d2mcpp/.xlings.json is the published example, and it settles two of the three open items. Version unconstrained is the empty string. "code": "" is already in use, the resolver returns it unchanged, and an empty value reads downstream as claiming no version — which is what deps = ["cmake"] means today. No * and no latest is introduced. The namespace in the key position is undefined rather than forbidden: nothing derives an install address from a workspace entry, because nothing installs from workspace at all, so the question becomes real only when mcpp does. Both candidate forms are stated with what each costs, and the item is marked as a request rather than a finding. The native platform keys are recorded as linux, windows, macosx and default. --- ...09-03-xlings-workspace-as-the-one-table.md | 146 +++++++++--------- 1 file changed, 77 insertions(+), 69 deletions(-) diff --git a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md index 7686e7f2..06b49d6d 100644 --- a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md +++ b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md @@ -91,62 +91,62 @@ llvm = { macos = "20", default = "22" } Three decisions the table needs, listed for review. -**W1. There is no `"*"`, and none should be invented.** xlings already has two -spellings for "not an exact version": a version **prefix**, which -`match_version` resolves to the highest match (`src/core/xvm/db.cpp:411` — `22` -selects `22.1.8`), and `latest`, which `cmd_use` resolves to the highest -installed version before writing (`commands.cpp:592-607`). Both are **input** -spellings: what lands in a workspace file is always a concrete version, which -is why a stored `latest` would fail at shim time rather than mean anything. The -manifest therefore accepts a version, a prefix, or `latest`, and "must exist, -version unconstrained" is spelled `latest`. Measured on three real subos files -on the development host: every stored value is concrete. - -**W2. The namespace goes on the version, not on the key — and the key form was -checked rather than assumed.** Measured on the development host: 1635 targets -in the version database and 546 workspace entries in the default SubOS, and -**not one key contains a colon**. The colon appears on the other side: -`"mcpp": {"active": "xim:2026.8.30.2", …}`. - -`ns:name` as a key was considered and does not work, for a reason stronger than -convention. A workspace key is looked up by the name a program is **invoked -as** (`get_active_version(workspace, program_name)`, -`src/core/xvm/shim.cpp:409-412`); nothing is ever invoked as -`xim:picolibc-riscv`, so such a key would be read by nobody — the shape §6 -refuses for `deps`. - -And the namespace is not a property of the tool. It qualifies **where a version -came from**, which is why one target legitimately carries both scoped and -unscoped versions at once. Measured, on this machine, for `mcpp` itself: - -``` -"mcpp": { "active": "xim:2026.8.30.2", - "installed": ["2026.8.21.1", …, "xim:2026.8.28.2", "xim:2026.8.30.1"] } -``` - -Eight versions of one target, some from the xim index and some not. Moving the -namespace onto the key would split that into two targets, and `mcpp` on `PATH` -would resolve to whichever half won — which is the same defect in the small -that `ar` from two providers would be in the large. - -So the form is: - -```toml -[xlings.workspace] -picolibc-riscv = "xim:1.8.12" +These are settled against the **authored project file**, not against a SubOS +state file. The two are different artifacts: a state file is what `cmd_use` +writes (`{active, installed}` records, one per binding-group member), while a +project file is what a person writes. `mcpp-community/d2mcpp/.xlings.json` is a +published example of the latter: + +```json +{ + "workspace": { + "d2x": "2026.08.02.2", + "mdbook": "0.4.43", + "code": "", + "mcpp": { "linux": "2026.8.2.1", "macosx": "2026.8.2.1", "windows": "2026.8.2.1" } + } +} ``` -the key being the xvm target and the namespace riding the value, exactly as the -file writes it. mcpp reconstructs the install address `xim:picolibc-riscv@1.8.12` -from the pair when it provisions, so nothing is lost, and the C library mcpp -injects (§4) is expressible in the same shape. - -A key containing a colon is a **hard error naming the correct form**, rather -than a second accepted spelling: one fact, one way to write it, is the whole -argument of this document applied to itself. - -**W3. The per-platform value form is unchanged.** It is already accepted on -both keys (2026.9.2.1) and it survives the merge unmodified. +**W1. "Version unconstrained" is the empty string, and it is already in use.** +`"code": ""` in the example above is the spelling. `resolve_platform_workspace_value_` +returns it unchanged and `workspace_from_json` stores it +(`src/core/xvm/db.cppm:383`, `db.cpp:1100-1110`); downstream, an empty value is +read as "this entry claims no version" (`Config::version_origin`'s `claims` +predicate, `config.cpp:1108-1112`). So the entry names a member of the +environment and leaves its version open, which is exactly what `deps = ["cmake"]` +means today. No `*` and no `latest` is introduced: `""` is the form the file +already has, and it maps onto an install target with no `@version`. + +**W2. The namespace is undefined in this position today, and defining it is +xlings' call.** No workspace key anywhere carries one: not in the authored +example above, and not in 1635 version-database targets or 546 SubOS entries on +the development host. The colon appears only on the version side, where a +scope qualifies where a version came from — `"mcpp": {"active": +"xim:2026.8.30.2", …}`, one target holding both scoped and unscoped versions. + +But that is a statement about **resolution**, and the namespace is needed for +**installation**, and nothing derives an install address from a workspace entry +today, because nothing installs from `workspace` at all (§13.2). The moment +mcpp does, the question becomes real and has two candidate answers: + +| Form | Reads | Costs | +|---|---|---| +| `picolibc-riscv = "xim:1.8.12"` | key is the xvm target; the scope rides the version, as the version database already spells it | the address is assembled from two halves | +| `"xim:picolibc-riscv" = "1.8.12"` | key is the install address, as `deps` spells it | the key is no longer the name the shim looks up, so resolution has to strip it | + +The second is closer to `deps` and to how a person thinks about a package; the +first is closer to what the file already contains. Either works if the rule is +stated once — what must not happen is both being accepted, which would put one +fact in two spellings. **This is the one item in section 3 that is a request +rather than a finding.** + +**W3. The per-platform form is xlings' own, and its native keys are +`linux`, `windows`, `macosx` and `default`** (`platform::OS_NAME` per +`modules/platform/src/platform/*.cppm`, resolved by +`resolve_platform_workspace_value_`). mcpp additionally accepts `macos` as an +alias, which is a superset and stays; the documentation should show `macosx` +as the aligned spelling. ## 4. What mcpp writes into `.xlings.json` @@ -537,29 +537,37 @@ workspace replaces the global one. That belongs in `docs/17`. answers Q1: xlings does not provision from `workspace`, which is why section 4 was revised rather than kept. -### 13.3.1 What a workspace key is: an xvm target, of any kind +### 13.3.1 Two artifacts share the name `.xlings.json` + +They must not be measured for each other, and this document did so once. + +**The authored project file** is what a person writes and what mcpp +materialises: `workspace` maps a name to a version string or to a +platform-conditional object, and nothing else. `d2mcpp/.xlings.json` is the +published example (§3). -Measured on the development host's default SubOS, 546 entries: +**A SubOS state file** is what `cmd_use` writes: the same key space, but each +value is an `{active, installed[]}` record, and the keys are every member of +every release ever switched to. On the development host's default SubOS that is +546 entries, including package roots, their programs, and file assets: ``` binutils = 2.42 ar = 2.42 as = 2.42 ld = 2.42 gcc = 16.1.0 g++ = 16.1.0 cc = 16.1.0 -mcpp = xim:2026.8.30.2 Scrt1.o, crt1.o, crti.o, crtn.o, glibc.files.1 … glibc.files.101 ``` -Package roots, the programs of those packages, and file assets all live in one -namespace, each with `{active, installed}`. A package root and its programs -carry the **same version** because they are members of one release and -`cmd_use` wrote them together (§15.1) — that identity is the group expansion's -own footprint in the data. - -So "the workspace holds packages" and "the workspace holds programs" are both -half-right: it holds xvm targets, and a package's root is one of them. Writing -the package in a manifest is therefore a legitimate entry, and its programs -receive the same version when the entry is honoured. Writing a program is -equally legitimate and selects the same release. What a key never carries is a -namespace; that rides the value (§3 W2). +A package root and its programs carry the same version there because they are +members of one release and `cmd_use` wrote them together (§15.1). That identity +is the group expansion's footprint, and it is why writing a package root in a +project file pins its programs: the expansion happens when the entry is +honoured. + +The parser accepts both shapes for a value and disambiguates by reserved keys — +an `active` or `installed` key marks the state form, anything else is read as +the project form (`src/core/xvm/db.cppm:405-420`). So the two files are one +schema seen at two stages, and the project form is the one mcpp's `[xlings]` +mirrors. ### 13.4 `envs` has no reader anywhere From d088490d2f3676d9e6b6c8a97ec3247d21a7ac49 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:46:39 +0800 Subject: [PATCH 10/26] design: both namespace positions accepted, and mcpp normalises to the file's own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W2 was a request; it is now a decision. An author may put the namespace on the version or on the key, because the two are interconvertible and neither is more natural than the other — one matches how deps spells a package, the other matches what the file already contains. Four rules keep it one fact: mcpp materialises the file's own form only, the install address is assembled from whichever half carried the namespace, stating it twice and differently is a hard error, and the key form needs quotes. The quoting is measured rather than asserted. TOML bare keys are [A-Za-z0-9_-] and mcpp's lexer matches; on the 2026.9.2.1 binary the unquoted key fails at the colon with a parser message that mentions nothing about namespaces, so the documented example carries the quotes. The previous rule making a colon in a key an error is withdrawn. --- ...09-03-xlings-workspace-as-the-one-table.md | 60 ++++++++++++------- 1 file changed, 38 insertions(+), 22 deletions(-) diff --git a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md index 06b49d6d..00a33f3e 100644 --- a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md +++ b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md @@ -118,28 +118,44 @@ environment and leaves its version open, which is exactly what `deps = ["cmake"] means today. No `*` and no `latest` is introduced: `""` is the form the file already has, and it maps onto an install target with no `@version`. -**W2. The namespace is undefined in this position today, and defining it is -xlings' call.** No workspace key anywhere carries one: not in the authored -example above, and not in 1635 version-database targets or 546 SubOS entries on -the development host. The colon appears only on the version side, where a -scope qualifies where a version came from — `"mcpp": {"active": -"xim:2026.8.30.2", …}`, one target holding both scoped and unscoped versions. - -But that is a statement about **resolution**, and the namespace is needed for -**installation**, and nothing derives an install address from a workspace entry -today, because nothing installs from `workspace` at all (§13.2). The moment -mcpp does, the question becomes real and has two candidate answers: - -| Form | Reads | Costs | -|---|---|---| -| `picolibc-riscv = "xim:1.8.12"` | key is the xvm target; the scope rides the version, as the version database already spells it | the address is assembled from two halves | -| `"xim:picolibc-riscv" = "1.8.12"` | key is the install address, as `deps` spells it | the key is no longer the name the shim looks up, so resolution has to strip it | - -The second is closer to `deps` and to how a person thinks about a package; the -first is closer to what the file already contains. Either works if the rule is -stated once — what must not happen is both being accepted, which would put one -fact in two spellings. **This is the one item in section 3 that is a request -rather than a finding.** +**W2. Decided (2026-09-03): both positions are accepted, and mcpp normalises.** +The namespace can ride the version or the key, the two are mechanically +interconvertible, and an author should not have to remember which one this +table wants: + +```toml +[xlings.workspace] +picolibc-riscv = "xim:1.8.12" # scope on the version +"xim:picolibc-riscv" = "1.8.12" # namespace on the key — quotes REQUIRED +qemu-riscv = { linux = "xim:9.2.4-1" } # composes with W3 +``` + +Four rules make that one fact rather than two: + +1. **mcpp writes one form.** Whatever was authored, the materialised + `.xlings.json` carries the file's own convention: the key is the bare xvm + target and the scope rides the version. So the file never holds two + spellings, and everything downstream of it sees one. +2. **The install address is assembled from the pair**, whichever half carried + the namespace: `xim` + `picolibc-riscv` + `1.8.12`. +3. **Stating the namespace twice and differently is a hard error** naming both + halves — `"xim:foo" = "other:1.0"` is not a merge to resolve. So is naming + one package under both spellings in the same table. +4. **The key form needs quotes, and the documentation must show them.** TOML + bare keys are `[A-Za-z0-9_-]`; mcpp's own lexer matches the specification + (`is_bare_key_char`, `modules/libs/src/toml.cppm:150`). Measured on the + 2026.9.2.1 binary: + + ``` + xim:picolibc-riscv = "1.8.12" → error: mcpp.toml:6:4: error: expected … + "xim:picolibc-riscv" = "1.8.12" → loads + ``` + + The unquoted form fails with a parser message that says nothing about + namespaces, so the example in the documentation carries the quotes. + +The earlier rule that a colon in a key is an error is withdrawn: the colon is +now meaningful there. **W3. The per-platform form is xlings' own, and its native keys are `linux`, `windows`, `macosx` and `default`** (`platform::OS_NAME` per From 9ba86b466a5d76c313268f7c9935d4de4e12a171 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 3 Sep 2026 08:54:09 +0800 Subject: [PATCH 11/26] =?UTF-8?q?design:=20D8=20worked=20through=20?= =?UTF-8?q?=E2=80=94=20activation=20must=20be=20obtained,=20not=20requeste?= =?UTF-8?q?d?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three facts shape it. xlings auto-activates only when nothing is active for that name in the merged view, so a version active globally makes a project's declaration lose. Activation writes into the project's own SubOS layer, which config.cpp places under the project directory, so forcing it disturbs no other checkout and not the machine. And the two value spellings ask for different things: a version wants activation, an empty value wants presence. The deciding fact is the fourth. When a forced install fails to switch, the installer logs a warning and the call still exits zero, so useAfterInstall gives mcpp a request whose outcome it cannot read — #531 from the other side. use_version returns cmd_use's exit code instead. Hence the shape: one unforced install batch, then one use_version per versioned entry with its exit code checked, nothing extra for unconstrained entries, and the existing stamp covering both. Six criteria, one of which must be seen failing first. --- ...09-03-xlings-workspace-as-the-one-table.md | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md index 00a33f3e..1fb2584d 100644 --- a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md +++ b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md @@ -749,3 +749,112 @@ its documented shorthand installs, and nothing still compares the two keys when both name one package. §13 strengthened rather than weakened them: xlings makes the same split in the same direction, which is why the merge belongs in the manifest and not in the file. + +## 16. D8 in full: how mcpp makes a declared version the active one + +The requirement is one sentence — a declared tool at a declared version is +installed if absent and active afterwards — and it needs three facts before it +can be designed. + +### 16.1 xlings already auto-activates, and states when it will not + +`activate_requested_targets` in the installer: + +```cpp +auto active = xvm::get_active_version(Config::effective_workspace(), match.name); +if ((active.empty() || useAfterInstall) && has_version(db, match.name, match.version)) + cmd_use(match.name, match.version, stream); +else if (!active.empty() && active != match.version) + // declining to switch is a decision, and it used to be a silent one +``` + +So installing activates **when nothing is active for that name**, and otherwise +declines and says so. The declined case is the one that matters here: `active` +is read from the **merged** view, so a version active in the machine's global +layer is enough to make the project's own declaration lose. + +### 16.2 Activation lands inside the project, not on the machine + +`cmd_use` writes to `Config::workspace_mut()`, which returns the project's SubOS +workspace whenever a project config is loaded (`config.cpp:1163-1168`), and +that directory is under the project: + +```cpp +// config.cpp:348-353 +if (!projectSubosName_.empty()) return projectDir_ / ".xlings" / "subos" / projectSubosName_; +if (projectSubosMode_ == Anonymous) return projectDir_ / ".xlings" / "subos" / "_"; +``` + +For mcpp the project dir is `/.mcpp`, so the layer written is +`/.mcpp/.xlings/subos/{|_}/.xlings.json`. Two checkouts that +declare different versions of one tool cannot disturb each other, and the +machine's global choice is not touched. That is what makes forcing activation +safe to do without a further opt-in. + +### 16.3 The rule falls out of the two value spellings + +W1 gave the manifest two ways to write an entry, and they ask for different +things: + +| Entry | The project is asking for | Activation | +|---|---|---| +| `picolibc-riscv = "1.8.12"` | this version | force it | +| `code = ""` | presence, version unconstrained | do not force; xlings' own rule applies | + +Forcing on an unconstrained entry would change a version the project never +named, on a machine where something was already active. The empty spelling is +the author saying they do not care, and mcpp should not decide for them. + +### 16.4 Requesting activation is not the same as obtaining it + +This is the part that decides the shape. When `install_packages` forces the +switch and the switch fails, the installer logs a **warning** and the call +still exits zero: + +```cpp +if (useRet != 0) + log::warn("failed to activate {}@{} in current subos", match.name, match.version); +``` + +So `useAfterInstall: true` alone gives mcpp a request whose outcome it cannot +read — the exact shape #531 was filed for, arrived at from the other side. The +`use_version` capability, by contrast, returns `cmd_use`'s own exit code +(`src/capabilities.cpp:208-220`), which mcpp can check. + +### 16.5 The proposed shape + +1. **One `install_packages` batch, unforced**, for every declared entry, as + today. Its result is already read (`!called || childRc != 0`), and its + failure keeps naming the manual command. +2. **Then one `use_version` per entry that named a version**, exit code + checked. A failure is a hard build error naming the tool, the version and + the layer, because the manifest asked for something the environment did not + give. +3. **Nothing extra for `""` entries.** Presence was the whole request, and step + 1 satisfied it. +4. The provisioning **stamp covers both steps**, so the common build performs + neither. Its key is the hash of the declared set, so editing a version + re-runs the pair once. + +The cost is N calls for N versioned entries on the rare path. The alternative — +a second forced `install_packages` batch — is one call, and it buys that by +giving up the exit code, which is the thing being paid for. + +`use_version` takes a plain name ("Use plain name (e.g. gcc not xim:gcc)"), so +mcpp passes the target without its namespace and keeps the namespace for the +install address only (§3 W2). + +### 16.6 What to verify + +| # | Criterion | +|---|---| +| A1 | A project declaring a version different from the machine's active one builds and runs against the declared version | +| A2 | The machine's global workspace is unchanged after that build, measured on the file | +| A3 | Two checkouts declaring different versions of one tool each get their own, in one session | +| A4 | An entry with `""` on a machine where another version is active leaves that version active | +| A5 | A `use_version` that fails ends the build with a message naming tool, version and layer — not a warning | +| A6 | The second build of an unchanged project performs no install and no switch | + +A5 is the one that must be seen failing first: with `useAfterInstall` and +without the explicit switch, the same situation produces a warning inside a +successful build, which is what the design is choosing against. From c0cc681650c97d7a26458fd62c2d95cf3f971b9b Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:02:05 +0800 Subject: [PATCH 12/26] =?UTF-8?q?design:=20D8=20restated=20=E2=80=94=20the?= =?UTF-8?q?=20flag=20was=20the=20wrong=20question,=20the=20scope=20is=20th?= =?UTF-8?q?e=20right=20one?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correction first: provisioning does not write into the project's layer. It calls with make_xlings_env, which carries no projectDir, so the child runs in global scope against mcpp's registry home. The earlier claim that forcing was safely contained is withdrawn, and three measured facts follow from the real scope, including why the runner needed a payload lookup at all. xlings already implements both halves of the requirement: an existing payload is mapped rather than reinstalled, and install activates whenever nothing is active for that name. Whether mcpp receives that behaviour depends on the environment it asks in, not on a flag. Design P provisions in project scope, where the project's own SubOS is the layer written; with a named SubOS the global layer is not merged, so the declaration is what activates and no forcing is needed. Design G keeps the global scope and forces, which lets two checkouts fight and cannot be verified. P is recommended, and it carries one question that is not mcpp's alone: whether a project environment inherits the machine's. Both branches are stated with what they cost. --- ...09-03-xlings-workspace-as-the-one-table.md | 180 ++++++++++-------- 1 file changed, 99 insertions(+), 81 deletions(-) diff --git a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md index 1fb2584d..76191ad4 100644 --- a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md +++ b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md @@ -750,15 +750,22 @@ both name one package. §13 strengthened rather than weakened them: xlings makes the same split in the same direction, which is why the merge belongs in the manifest and not in the file. -## 16. D8 in full: how mcpp makes a declared version the active one +## 16. D8: the question is which environment mcpp provisions into The requirement is one sentence — a declared tool at a declared version is -installed if absent and active afterwards — and it needs three facts before it -can be designed. +installed if absent and active afterwards. It looked like a flag. It is not. -### 16.1 xlings already auto-activates, and states when it will not +### 16.1 xlings already does the whole job, correctly -`activate_requested_targets` in the installer: +Installing an already-present payload does not reinstall it. The installer +checks whether the payload exists and is registered to this package, sets +`payloadInstalled` and skips the install hook +(`src/core/xim/installer.cpp:2740-2775`), then proceeds to the mapping and +activation steps. So "map when the payload is there, install when it is not" is +the existing behaviour, not something to build. + +Activation is equally native. `activate_requested_targets` switches whenever +**nothing is active for that name**: ```cpp auto active = xvm::get_active_version(Config::effective_workspace(), match.name); @@ -768,93 +775,104 @@ else if (!active.empty() && active != match.version) // declining to switch is a decision, and it used to be a silent one ``` -So installing activates **when nothing is active for that name**, and otherwise -declines and says so. The declined case is the one that matters here: `active` -is read from the **merged** view, so a version active in the machine's global -layer is enough to make the project's own declaration lose. +Both halves of the requirement are therefore already implemented. What decides +whether mcpp gets them is the environment it asks in. -### 16.2 Activation lands inside the project, not on the machine +### 16.2 Corrected: mcpp provisions into the registry home, not into the project -`cmd_use` writes to `Config::workspace_mut()`, which returns the project's SubOS -workspace whenever a project config is loaded (`config.cpp:1163-1168`), and -that directory is under the project: +An earlier draft of this section claimed activation lands in the project's own +layer. It does not. The provisioning pass calls ```cpp -// config.cpp:348-353 -if (!projectSubosName_.empty()) return projectDir_ / ".xlings" / "subos" / projectSubosName_; -if (projectSubosMode_ == Anonymous) return projectDir_ / ".xlings" / "subos" / "_"; +mcpp::xlings::call(mcpp::config::make_xlings_env(**cfg2), "install_packages", …) ``` -For mcpp the project dir is `/.mcpp`, so the layer written is -`/.mcpp/.xlings/subos/{|_}/.xlings.json`. Two checkouts that -declare different versions of one tool cannot disturb each other, and the -machine's global choice is not touched. That is what makes forcing activation -safe to do without a further opt-in. - -### 16.3 The rule falls out of the two value spellings - -W1 gave the manifest two ways to write an entry, and they ask for different -things: - -| Entry | The project is asking for | Activation | -|---|---|---| -| `picolibc-riscv = "1.8.12"` | this version | force it | -| `code = ""` | presence, version unconstrained | do not force; xlings' own rule applies | - -Forcing on an unconstrained entry would change a version the project never -named, on a machine where something was already active. The empty spelling is -the author saying they do not care, and mcpp should not decide for them. - -### 16.4 Requesting activation is not the same as obtaining it - -This is the part that decides the shape. When `install_packages` forces the -switch and the switch fails, the installer logs a **warning** and the call -still exits zero: - -```cpp -if (useRet != 0) - log::warn("failed to activate {}@{} in current subos", match.name, match.version); -``` - -So `useAfterInstall: true` alone gives mcpp a request whose outcome it cannot -read — the exact shape #531 was filed for, arrived at from the other side. The -`use_version` capability, by contrast, returns `cmd_use`'s own exit code -(`src/capabilities.cpp:208-220`), which mcpp can check. - -### 16.5 The proposed shape - -1. **One `install_packages` batch, unforced**, for every declared entry, as - today. Its result is already read (`!called || childRc != 0`), and its - failure keeps naming the manual command. -2. **Then one `use_version` per entry that named a version**, exit code - checked. A failure is a hard build error naming the tool, the version and - the layer, because the manifest asked for something the environment did not - give. -3. **Nothing extra for `""` entries.** Presence was the whole request, and step - 1 satisfied it. -4. The provisioning **stamp covers both steps**, so the common build performs - neither. Its key is the hash of the declared set, so editing a version - re-runs the pair once. - -The cost is N calls for N versioned entries on the rare path. The alternative — -a second forced `install_packages` batch — is one call, and it buys that by -giving up the exit code, which is the thing being paid for. - -`use_version` takes a plain name ("Use plain name (e.g. gcc not xim:gcc)"), so -mcpp passes the target without its namespace and keeps the namespace for the -install address only (§3 W2). - -### 16.6 What to verify +(`prepare.cppm:3421`), and `make_xlings_env` carries no `projectDir` +(`src/config.cpp:129`), so no `XLINGS_PROJECT_DIR` reaches the child and the +cwd walk finds nothing — mcpp writes `/.mcpp/.xlings.json`, one level +below the directory a walk from the project root inspects. The child therefore +runs in **global** scope against mcpp's registry home, where +`Config::workspace_mut()` is the global workspace and `effective_workspace()` +is the global workspace. + +Three consequences follow, and they explain measurements this repository +already has: + +1. `active` is read from a workspace shared by **every mcpp project on the + machine**, so a version another project activated is enough to make this + project's declaration lose. +2. Forcing the switch there would let two checkouts flip each other. The + decline is protective in this scope, not obstructive. +3. The install's view and the shim's view disagree. The install activates in + the registry's global workspace; a program invoked from the project + directory resolves through the project's own layers. That is exactly the + `qemu-aarch64-static is not installed in this subos (_)` measured during the + 2026.9.2.1 verification, and the reason mcpp had to add a payload-directory + lookup for the runner at all (§13.2 of the runner design). + +### 16.3 The two designs + +**Design P — provision in the project's environment.** mcpp calls with +`make_project_xlings_env(cfg, root)`, so `XLINGS_PROJECT_DIR=/.mcpp` +and the child runs in project scope. `Config::workspace_mut()` is then the +project's own SubOS workspace at `/.mcpp/.xlings/subos/{name|_}` +(`config.cpp:348-353`), and `effective_workspace()` includes the project's +layers. + +Under P, xlings' native behaviour is what is wanted and no flag is needed — +with one condition. In **Anonymous** mode the merge still includes the global +layer (§13.3), so a globally active version still shadows the declaration. In +**Named** mode the global layer is dropped, `active` is empty for a name the +project has not pinned, and the install activates exactly what was declared. +So P is "provision in the project scope, and the project's environment is its +own SubOS". + +**Design G — keep provisioning globally, and force.** Pass +`useAfterInstall: true`. It makes the declaration win, and it makes two +checkouts fight over one shared workspace. It also cannot be verified: a forced +switch that fails is a `log::warn` inside a call that exits zero +(`installer.cpp`), so mcpp would hold a request whose outcome it cannot read. + +**P is the recommendation**, and it is the one that matches what `[xlings]` +claims to be — a project-level isolated environment. G's forcing is not needed +once the scope is right, and G's shared workspace is the thing the isolation +exists to avoid. + +### 16.4 What P costs, and what has to be decided with it + +P is not free, and the cost is the same fact §13.3 records: a Named SubOS does +not inherit the machine's global pins. A project that declares one gets an +environment holding what it declared and what was installed into it, and +nothing else. That is isolation working as specified, and it is a change for +every project that today relies on the machine's tools being visible. + +So P carries one decision that is not mcpp's alone: + +**Does an mcpp project's environment inherit the machine's, or not?** + +- Inheriting (Anonymous) keeps every existing project building and leaves the + declaration able to lose to a globally active version. +- Not inheriting (Named) makes the declaration authoritative and requires the + project to declare everything it uses. + +A middle position exists and should be considered rather than assumed away: +provision in project scope while staying Anonymous, and accept that a declared +version can still lose to a globally active one — then report that case rather +than silently accepting it. The installer already emits the sentence; mcpp +would have to read it, which returns to the verification problem G has. + +### 16.5 What to verify, whichever is chosen | # | Criterion | |---|---| | A1 | A project declaring a version different from the machine's active one builds and runs against the declared version | -| A2 | The machine's global workspace is unchanged after that build, measured on the file | +| A2 | The machine's global workspace file is unchanged after that build | | A3 | Two checkouts declaring different versions of one tool each get their own, in one session | | A4 | An entry with `""` on a machine where another version is active leaves that version active | -| A5 | A `use_version` that fails ends the build with a message naming tool, version and layer — not a warning | +| A5 | A declared version that could not be made active ends the build with a message naming tool, version and layer — never a warning inside a successful build | | A6 | The second build of an unchanged project performs no install and no switch | +| A7 | A program the project did not declare still resolves, or fails with a message naming the environment it was looked up in | -A5 is the one that must be seen failing first: with `useAfterInstall` and -without the explicit switch, the same situation produces a warning inside a -successful build, which is what the design is choosing against. +A2 and A3 are the ones that fail under G today. A7 is the one that changes +meaning between the two branches of §16.4, and it is where the decision becomes +visible to a user. From 92d219adee304e45f9b03c2e8e2e4ac39ee095ed Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:07:07 +0800 Subject: [PATCH 13/26] =?UTF-8?q?design:=20D8=20withdrawn=20=E2=80=94=20th?= =?UTF-8?q?e=20merge=20already=20does=20it,=20and=20the=20defect=20is=20th?= =?UTF-8?q?e=20provisioning=20scope?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit merge_workspace_into_ assigns rather than inserts, so the later layer wins: in Anonymous mode the project's declaration beats the machine's, and in Named mode the machine's layer is not merged at all. A project declaring a version therefore resolves to it without anything forcing a switch, and the developer picks the strength by naming a SubOS or not. What is wrong today is one argument. Provisioning calls with make_xlings_env, which carries no projectDir, so the child runs in global scope: the install's records land in the registry's shared workspace while programs invoked from the project resolve through the project's layers. That disagreement is the 'not installed in this subos' line the 2026.9.2.1 verification measured and the reason the runner needed a payload lookup. So the change is make_project_xlings_env, and useAfterInstall is not passed and no use_version call is added. Two consequences are written down rather than left to be discovered: the project SubOS layer outranks the manifest, and an uninstalled declared version already fails with wording that names the project. --- ...09-03-xlings-workspace-as-the-one-table.md | 177 ++++++++---------- 1 file changed, 75 insertions(+), 102 deletions(-) diff --git a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md index 76191ad4..d58545bd 100644 --- a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md +++ b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md @@ -750,38 +750,42 @@ both name one package. §13 strengthened rather than weakened them: xlings makes the same split in the same direction, which is why the merge belongs in the manifest and not in the file. -## 16. D8: the question is which environment mcpp provisions into +## 16. D8 withdrawn: the merge already does it, once the scope is right -The requirement is one sentence — a declared tool at a declared version is -installed if absent and active afterwards. It looked like a flag. It is not. +The requirement — a declared tool at a declared version is installed if absent +and active afterwards — needs no flag, no explicit switch and no new rule. It +needs mcpp to ask in the right environment. Everything else is already built. -### 16.1 xlings already does the whole job, correctly +### 16.1 Both halves are native -Installing an already-present payload does not reinstall it. The installer -checks whether the payload exists and is registered to this package, sets -`payloadInstalled` and skips the install hook -(`src/core/xim/installer.cpp:2740-2775`), then proceeds to the mapping and -activation steps. So "map when the payload is there, install when it is not" is -the existing behaviour, not something to build. +**Mapping rather than reinstalling.** The installer checks whether the payload +exists and is registered to this package, sets `payloadInstalled` and skips the +install hook (`src/core/xim/installer.cpp:2740-2775`). An already-present +payload is mapped; only a missing one is fetched. -Activation is equally native. `activate_requested_targets` switches whenever -**nothing is active for that name**: +**The declaration outranking the machine.** `merge_workspace_into_` assigns +rather than inserts (`src/core/config.cpp`), so in `merged_workspace` the later +layer wins: -```cpp -auto active = xvm::get_active_version(Config::effective_workspace(), match.name); -if ((active.empty() || useAfterInstall) && has_version(db, match.name, match.version)) - cmd_use(match.name, match.version, stream); -else if (!active.empty() && active != match.version) - // declining to switch is a decision, and it used to be a silent one -``` +| Mode | Layers, in merge order | What wins | +|---|---|---| +| Anonymous | global, project manifest, project SubOS | the project's declaration beats the machine's | +| Named | project manifest, project SubOS | the machine's layer is not present at all | + +So a project that declares `gcc = "15.1.0"` resolves `gcc` to 15.1.0 even on a +machine whose global workspace is on 16.1.0 — because mcpp materialises that +declaration into the project file's `workspace` object (§4), which is a layer +that beats global. The developer chooses the strength by choosing the mode: +name a SubOS for an isolated space, or leave it anonymous and get the machine's +environment with the project's own entries laid over it. -Both halves of the requirement are therefore already implemented. What decides -whether mcpp gets them is the environment it asks in. +Nothing has to force anything. `activate_requested_targets` does not fire in +this situation, and correctly: the merged view already answers with the +declared version, so there is nothing to switch. -### 16.2 Corrected: mcpp provisions into the registry home, not into the project +### 16.2 What is actually wrong today: mcpp provisions in the wrong scope -An earlier draft of this section claimed activation lands in the project's own -layer. It does not. The provisioning pass calls +The provisioning pass calls ```cpp mcpp::xlings::call(mcpp::config::make_xlings_env(**cfg2), "install_packages", …) @@ -790,89 +794,58 @@ mcpp::xlings::call(mcpp::config::make_xlings_env(**cfg2), "install_packages", (`prepare.cppm:3421`), and `make_xlings_env` carries no `projectDir` (`src/config.cpp:129`), so no `XLINGS_PROJECT_DIR` reaches the child and the cwd walk finds nothing — mcpp writes `/.mcpp/.xlings.json`, one level -below the directory a walk from the project root inspects. The child therefore -runs in **global** scope against mcpp's registry home, where -`Config::workspace_mut()` is the global workspace and `effective_workspace()` -is the global workspace. - -Three consequences follow, and they explain measurements this repository -already has: - -1. `active` is read from a workspace shared by **every mcpp project on the - machine**, so a version another project activated is enough to make this - project's declaration lose. -2. Forcing the switch there would let two checkouts flip each other. The - decline is protective in this scope, not obstructive. -3. The install's view and the shim's view disagree. The install activates in - the registry's global workspace; a program invoked from the project - directory resolves through the project's own layers. That is exactly the +below the directory a walk from the project root inspects. The child runs in +**global** scope against mcpp's registry home. + +That single fact produces every symptom in this section: + +1. The install's records land in the registry's global workspace, while a + program invoked from the project resolves through the project's layers. The + two views disagree, which is the `qemu-aarch64-static is not installed in this subos (_)` measured during the - 2026.9.2.1 verification, and the reason mcpp had to add a payload-directory - lookup for the runner at all (§13.2 of the runner design). - -### 16.3 The two designs - -**Design P — provision in the project's environment.** mcpp calls with -`make_project_xlings_env(cfg, root)`, so `XLINGS_PROJECT_DIR=/.mcpp` -and the child runs in project scope. `Config::workspace_mut()` is then the -project's own SubOS workspace at `/.mcpp/.xlings/subos/{name|_}` -(`config.cpp:348-353`), and `effective_workspace()` includes the project's -layers. - -Under P, xlings' native behaviour is what is wanted and no flag is needed — -with one condition. In **Anonymous** mode the merge still includes the global -layer (§13.3), so a globally active version still shadows the declaration. In -**Named** mode the global layer is dropped, `active` is empty for a name the -project has not pinned, and the install activates exactly what was declared. -So P is "provision in the project scope, and the project's environment is its -own SubOS". - -**Design G — keep provisioning globally, and force.** Pass -`useAfterInstall: true`. It makes the declaration win, and it makes two -checkouts fight over one shared workspace. It also cannot be verified: a forced -switch that fails is a `log::warn` inside a call that exits zero -(`installer.cpp`), so mcpp would hold a request whose outcome it cannot read. - -**P is the recommendation**, and it is the one that matches what `[xlings]` -claims to be — a project-level isolated environment. G's forcing is not needed -once the scope is right, and G's shared workspace is the thing the isolation -exists to avoid. - -### 16.4 What P costs, and what has to be decided with it - -P is not free, and the cost is the same fact §13.3 records: a Named SubOS does -not inherit the machine's global pins. A project that declares one gets an -environment holding what it declared and what was installed into it, and -nothing else. That is isolation working as specified, and it is a change for -every project that today relies on the machine's tools being visible. - -So P carries one decision that is not mcpp's alone: - -**Does an mcpp project's environment inherit the machine's, or not?** - -- Inheriting (Anonymous) keeps every existing project building and leaves the - declaration able to lose to a globally active version. -- Not inheriting (Named) makes the declaration authoritative and requires the - project to declare everything it uses. - -A middle position exists and should be considered rather than assumed away: -provision in project scope while staying Anonymous, and accept that a declared -version can still lose to a globally active one — then report that case rather -than silently accepting it. The installer already emits the sentence; mcpp -would have to read it, which returns to the verification problem G has. - -### 16.5 What to verify, whichever is chosen + 2026.9.2.1 verification — and the reason the runner needed a + payload-directory lookup at all. +2. `active` is read from a workspace shared by every mcpp project on the + machine, so the auto-activation decides against a state no project owns. +3. Any attempt to force the switch would write into that shared workspace, + letting two checkouts flip each other. + +**The fix is the scope, not a flag**: provision with +`make_project_xlings_env(cfg, root)`. The child then runs in project scope, the +installed set and activation records land in the project's own SubOS +(`config.cpp:348-353` puts it under `/.mcpp/.xlings/subos/…`), and the +view the install writes is the view the shim reads. + +**D8 is withdrawn.** `useAfterInstall` is not passed, and no `use_version` call +is added. The provisioning pass keeps its shape and changes one argument. + +### 16.3 Two consequences to write down rather than discover + +**The project SubOS layer outranks the project manifest.** It is merged last, +so an explicit `xlings use` performed inside the project beats what +`mcpp.toml` declares, until the manifest is re-materialised. That is defensible +— an action a person took beats a file — but it is not obvious, and it belongs +in the documentation next to the table above. + +**A declared version that is not installed fails at the shim, with the right +words.** Resolution finds the pin, `match_version` finds no such installed +version, and the diagnostic is already +`"{}@{} is the version this project asks for"` (`src/core/xvm/shim.cpp:495`). +Provisioning is what prevents it; the diagnostic is what happens if +provisioning was skipped, and it names the project rather than the machine. + +### 16.4 What to verify | # | Criterion | |---|---| -| A1 | A project declaring a version different from the machine's active one builds and runs against the declared version | +| A1 | A project declaring a version different from the machine's active one builds and runs against the declared version, in Anonymous mode | | A2 | The machine's global workspace file is unchanged after that build | | A3 | Two checkouts declaring different versions of one tool each get their own, in one session | | A4 | An entry with `""` on a machine where another version is active leaves that version active | -| A5 | A declared version that could not be made active ends the build with a message naming tool, version and layer — never a warning inside a successful build | -| A6 | The second build of an unchanged project performs no install and no switch | -| A7 | A program the project did not declare still resolves, or fails with a message naming the environment it was looked up in | +| A5 | A Named SubOS project resolves only what it declared plus what its environment holds, and a tool it did not declare fails naming that environment | +| A6 | The second build of an unchanged project performs no install | +| A7 | A declared version that was never installed fails at the shim with the "version this project asks for" wording, not with a bare "not found" | -A2 and A3 are the ones that fail under G today. A7 is the one that changes -meaning between the two branches of §16.4, and it is where the decision becomes -visible to a user. +A1, A2 and A3 fail today for one reason, the scope of `make_xlings_env`, and +pass together once it changes. A5 is the Named branch of the table in §16.1 and +is what makes the developer's choice visible. From bd1e0a78e3af39c04a447386cb2ac88c75510345 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:14:11 +0800 Subject: [PATCH 14/26] design: rewritten as one document, and the scope fix's precondition checked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The document had accumulated seven contradictions across the review rounds: the summary described a deprecation path the migration section had replaced with a single release, the open-questions section still named a decision a later section had withdrawn, and the axes referred to phases that no longer exist. Rewritten rather than patched, 851 lines to 493, with section 15 keeping every abandoned position and why it was abandoned. The framing is the one the review arrived at: [xlings] is mcpp's manifest surface for xlings' local project mechanism, and each decision is an answer to what that mechanism already does. Added a precondition the scope fix depends on and that nothing else would have caught: xlings refuses to treat a directory holding a bare subos/ as a project, and a project's SubOS lives one level deeper, so .mcpp qualifies today — measured — but a future bare subos/ under it would make mcpp's own project file invisible everywhere at once. C13 asserts it on an effect, because a skip degrades to today's behaviour and would look like success. --- ...09-03-xlings-workspace-as-the-one-table.md | 1183 ++++++----------- 1 file changed, 418 insertions(+), 765 deletions(-) diff --git a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md index d58545bd..e5ec14c7 100644 --- a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md +++ b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md @@ -1,101 +1,110 @@ -# `[xlings]`: one table, and `deps` retired +# `[xlings]` is mcpp's surface for xlings' local project mechanism Date: 2026-09-03. Status: design, awaiting review. Not implemented. -Baseline: `origin/main` at `4d99864` (2026.9.2.1). Every code reference below -was read at that commit. -Relates to: #531 (provisioning reads its result), #544 (per-platform values), -and the packaging map that does not exist yet (section 7). +Baseline: `origin/main` at `4d99864` (2026.9.2.1), and the xlings working tree +at `openxlings/xlings`. Every code reference was read at those trees. +Relates to: #531 (provisioning reads its result), #544 (per-platform values). + +This document was rewritten after the review discussion rather than amended. +Section 15 records what the discussion changed, so that positions this document +no longer holds are still findable. ## 0. Summary -`[xlings] deps` and `[xlings.workspace]` state the same thing about a project — -which package, at which version — and differ only in what mcpp then does with -the statement. That difference is not a property of the declaration, and the -proposal is to stop encoding it in the key: `[xlings.workspace]` becomes the -one table, an entry means "this project uses this at this version, provision it -if absent", and `deps` is retired through a deprecation path rather than a -removal. +`[xlings]` is not a section of mcpp's own invention. It is mcpp's manifest +surface for **xlings' local project mechanism**: the project `.xlings.json` +that gives a directory its own environment. Every question below has the same +form — what does that mechanism already do, and is mcpp asking it correctly. + +Three changes follow, and one of them is a defect rather than a design. -The merge is in `mcpp.toml`, not in the file mcpp writes. `.xlings.json` keeps -both fields, because xlings reads them in two different places and neither -subsumes the other (section 13). One authored entry therefore materialises as -one `deps` element and one `workspace` member: not a translation, but one -statement written where each of its two halves is read. +1. **`workspace` becomes the one table**; `deps` is retired from the manifest. + The two state the same thing and differ only in what mcpp then does with the + statement, and the mechanism they map onto has one authored surface. +2. **Provisioning moves into project scope.** mcpp calls xlings with a global + environment today, so the install writes into a workspace shared by every + mcpp project while programs resolve through the project's own layers. That + one argument is behind three measured symptoms. +3. **`envs` is retired.** It has no reader in xlings and none in mcpp, and + `docs/05` documents an effect it does not have. -The change does not weaken any existing behaviour: a declaration that cannot be -satisfied stays a hard build error, which is the property #531 was filed to -obtain. +Nothing here requires a new mechanism on either side. The manifest keeps its +correspondence with the file, and the file keeps both of its package-shaped +fields, because xlings reads them in two different places. -## 1. What the two keys are today +## 1. The mechanism mcpp is speaking to -Facts, read at `4d99864`. +xlings gives a directory its own environment when it finds a project +`.xlings.json`: -| | `deps` | `[xlings.workspace]` | +| Key | What xlings does with it | +|---|---| +| `deps` | the array a bare `xlings install` installs (`src/core/cmdprocessor.cpp:163-196`) | +| `workspace` | a version-resolution layer merged into the project's effective pins (`config.cpp:660`, `:846`) | +| `subos` | names the project's environment; absent means an anonymous one (`config.cpp:273-283`) | +| `index_repos`, `mirror`, `lang` | project overrides of the machine's settings | + +**Discovery.** xlings walks the current directory upward for a `.xlings.json`, +stops at any directory that also holds a `subos/` — that signature is an xlings +home, never a project — and otherwise falls back to `XLINGS_PROJECT_DIR` +(`config.cpp:765-799`). mcpp writes `/.mcpp/.xlings.json`, one level +below what a walk from the project root inspects, so the file is reached +through the environment variable. + +**Resolution layers.** `merge_workspace_into_` assigns rather than inserts, so +in `merged_workspace` (`config.cpp:846-864`) the later layer wins: + +| Project mode | Layers, in merge order | Effect | |---|---|---| -| Shape | ordered array of package references | map, name to version | -| Reference form | `name`, `name@version`, `ns:name@version` | key is a bare name | -| Readers in mcpp | five | one | -| Provisioning | `install_packages`, result read, hard error, stamped on success | none | -| Reaches `build.mcpp` | `MCPP_XPKG__DIR` per installed payload | nothing | -| Reaches the runner lookup | payload `bin/`, in declaration order | nothing | - -The five readers of `deps`: the parser (`modules/manifest/src/toml.cppm:1410`), -the materialisation into `.mcpp/.xlings.json` -(`src/build/prepare.cppm:3196`), the provisioning pass -(`prepare.cppm:3420-3482`), `fillXpkgDirs` -(`prepare.cppm:4549`), and `BuildContext::xlingsDepBinDirs` -(`prepare.cppm:8707`, added in 2026.9.2.1). The one reader of `workspace` is -the materialisation, `prepare.cppm:3197-3198`; mcpp never acts on it. - -**The general form is weaker than its own shorthand.** `docs/05-mcpp-toml.md` -§2.13 states that `[toolchain]` is "the ergonomic shorthand for the compiler" -and `[xlings.workspace]` is "the general form". The shorthand installs: -`[toolchain]`'s spec reaches `resolve_xpkg_path(pkg.target(), -/*autoInstall=*/…)` at `prepare.cppm:2212`, and the `build.mcpp` host resolve -does the same at `:3067`. The general form installs nothing. A general form -that cannot express what its shorthand does is not general. - -**Nothing compares the two when both name one package.** A manifest may write -`deps = ["make@4.4"]` and `[xlings.workspace] make = "4.5"`. mcpp provisions -4.4 and writes both statements into `.xlings.json`; no code path in mcpp reads -the pair. This is the drift shape the repository has paid for repeatedly, and -merging the keys removes it by construction rather than by adding a check. - -## 2. Why they are one thing - -A version constraint and an installation are the same statement seen at two -moments. "This project uses cmake 3.28" is what the build environment must be; -whether cmake is already present decides whether anything has to be fetched, -and that is a fact about the machine, not about the project. xlings' own -`workspace` carries that reading, which is why the mcpp side has one reader: -there was nothing for mcpp to decide. - -Keeping two keys forces every author to answer a question the manifest should -not ask — "do I want this installed, or only pinned?" — whose honest answer is -always "installed if it is not there". The one case that looks like a -counterexample, pinning a tool the project may never invoke, is not one: an -entry naming a tool the project does not use is noise regardless of the key it -is written under. - -## 3. The schema after the change +| **Anonymous** — a project file, no `subos` | global, project manifest, project SubOS | the machine's environment with the project's entries laid over it | +| **Named** — `subos = ""` | project manifest, project SubOS | an isolated space; the machine's layer is not merged | +| no project file | global | — | -```toml -[xlings.workspace] -cmake = "3.28" -qemu-riscv = "9.2.4-1" -picolibc-riscv = "xim:1.8.12" -make = "latest" -gcc = { linux = "15.1.0" } -llvm = { macos = "20", default = "22" } +The developer chooses the strength by naming a SubOS or not. Both rows are the +mechanism working as specified; neither is an omission. + +**Where a project's SubOS lives.** Under the project, always +(`config.cpp:348-353`): `/.xlings/subos/` when named, +`/.xlings/subos/_` when anonymous. For mcpp the project directory +is `/.mcpp`. + +**Installing an existing payload maps it rather than fetching it.** The +installer checks that the payload exists and is registered to the package, sets +`payloadInstalled` and skips the install hook +(`src/core/xim/installer.cpp:2740-2775`). + +**Activation is automatic when nothing is active for that name** +(`activate_requested_targets`, same file): + +```cpp +auto active = xvm::get_active_version(Config::effective_workspace(), match.name); +if ((active.empty() || useAfterInstall) && has_version(db, match.name, match.version)) + cmd_use(match.name, match.version, stream); +else if (!active.empty() && active != match.version) + // declining to switch is a decision, and it used to be a silent one ``` -Three decisions the table needs, listed for review. +`cmd_use` resolves the whole binding group through `resolve_binding_selection` +and writes one workspace entry **per member** (`xvm/commands.cpp:763-772`), +which is why using either a package root or one of its programs selects the +same release, and why a root and its programs carry one version. -These are settled against the **authored project file**, not against a SubOS -state file. The two are different artifacts: a state file is what `cmd_use` -writes (`{active, installed}` records, one per binding-group member), while a -project file is what a person writes. `mcpp-community/d2mcpp/.xlings.json` is a -published example of the latter: +## 2. Change one: `workspace` is the one table + +### 2.1 Why the split cannot be defended + +**The general form is weaker than its own shorthand.** `docs/05` §2.13 states +that `[toolchain]` is "the ergonomic shorthand for the compiler" and +`[xlings.workspace]` is "the general form". The shorthand installs +(`resolve_xpkg_path(…, autoInstall=…)`, `prepare.cppm:2212` and `:3067`); the +general form installs nothing. + +**Nothing compares the two when both name one package.** `deps = ["make@4.4"]` +beside `workspace.make = "4.5"` provisions 4.4, writes both into the file, and +no code path in mcpp reads the pair. + +**The authored surface of the mechanism is `workspace`.** A published example +is `mcpp-community/d2mcpp/.xlings.json`: ```json { @@ -108,537 +117,203 @@ published example of the latter: } ``` -**W1. "Version unconstrained" is the empty string, and it is already in use.** -`"code": ""` in the example above is the spelling. `resolve_platform_workspace_value_` -returns it unchanged and `workspace_from_json` stores it -(`src/core/xvm/db.cppm:383`, `db.cpp:1100-1110`); downstream, an empty value is -read as "this entry claims no version" (`Config::version_origin`'s `claims` -predicate, `config.cpp:1108-1112`). So the entry names a member of the -environment and leaves its version open, which is exactly what `deps = ["cmake"]` -means today. No `*` and no `latest` is introduced: `""` is the form the file -already has, and it maps onto an install target with no `@version`. - -**W2. Decided (2026-09-03): both positions are accepted, and mcpp normalises.** -The namespace can ride the version or the key, the two are mechanically -interconvertible, and an author should not have to remember which one this -table wants: +### 2.2 The schema ```toml [xlings.workspace] -picolibc-riscv = "xim:1.8.12" # scope on the version -"xim:picolibc-riscv" = "1.8.12" # namespace on the key — quotes REQUIRED -qemu-riscv = { linux = "xim:9.2.4-1" } # composes with W3 +cmake = "3.28" +code = "" # present; version unconstrained +picolibc-riscv = "xim:1.8.12" # namespace on the version +"xim:qemu-user-aarch64" = "7.2.0" # or on the key - quotes REQUIRED +gcc = { linux = "15.1.0" } +llvm = { macosx = "20", default = "22" } ``` -Four rules make that one fact rather than two: - -1. **mcpp writes one form.** Whatever was authored, the materialised - `.xlings.json` carries the file's own convention: the key is the bare xvm - target and the scope rides the version. So the file never holds two - spellings, and everything downstream of it sees one. -2. **The install address is assembled from the pair**, whichever half carried - the namespace: `xim` + `picolibc-riscv` + `1.8.12`. -3. **Stating the namespace twice and differently is a hard error** naming both - halves — `"xim:foo" = "other:1.0"` is not a merge to resolve. So is naming - one package under both spellings in the same table. -4. **The key form needs quotes, and the documentation must show them.** TOML - bare keys are `[A-Za-z0-9_-]`; mcpp's own lexer matches the specification - (`is_bare_key_char`, `modules/libs/src/toml.cppm:150`). Measured on the - 2026.9.2.1 binary: - - ``` - xim:picolibc-riscv = "1.8.12" → error: mcpp.toml:6:4: error: expected … - "xim:picolibc-riscv" = "1.8.12" → loads - ``` - - The unquoted form fails with a parser message that says nothing about - namespaces, so the example in the documentation carries the quotes. - -The earlier rule that a colon in a key is an error is withdrawn: the colon is -now meaningful there. - -**W3. The per-platform form is xlings' own, and its native keys are -`linux`, `windows`, `macosx` and `default`** (`platform::OS_NAME` per -`modules/platform/src/platform/*.cppm`, resolved by -`resolve_platform_workspace_value_`). mcpp additionally accepts `macos` as an -alias, which is a superset and stays; the documentation should show `macosx` -as the aligned spelling. - -## 4. What mcpp writes into `.xlings.json` - -**Decided (2026-09-03), and revised the same day against the xlings source -(§13): the merge belongs in `mcpp.toml`, and the file keeps both fields.** - -One authoring key. When mcpp materialises it, an entry becomes a `deps` element -*and* a `workspace` member, because the file's two fields have two different -consumers in xlings and neither subsumes the other: `deps` is the install -trigger read by a bare `xlings install` (`src/core/cmdprocessor.cpp:163`), and -`workspace` is a version-resolution layer merged into the project's effective -pins (`src/core/config.cpp:660`, `:846`). Emitting both is not a translation -layer — it is the faithful materialisation of one statement, "use this at this -version, provision it if absent", into the two places xlings reads those two -halves. - -The earlier draft of this section proposed writing only `workspace` and letting -xlings provision from it. §13 measures that xlings does not: `deps` is the only -key its install path reads. Writing only `workspace` would have produced a -project that builds where the packages happen to be installed and fails on a -clean machine, which is the failure this document exists to avoid. - -If xlings later provisions from `workspace`, the `deps` half of the emission -can be dropped without touching `mcpp.toml` or any manifest. That is the -end-state, and it is a change on the xlings side, not here. - -One consequence for section 3. The target's C library that mcpp appends -(`prepare.cppm:3186-3194`) arrives from the target row as -`xim:picolibc-riscv@1.8.12`, an install address. As a workspace entry it is -`picolibc-riscv = "xim:1.8.12"` — the same two facts, in the shape the file -already uses. mcpp splits the address once, at the point it builds the entry; -nothing downstream sees two spellings. - -## 5. What provisioning means after the merge - -The provisioning pass keeps its current contract, with the input widened from -`deps` to the merged table: - -- The result is read, not assumed. `xlings::call` is in the value state - whenever the child ran, and the capability's status is inside `CallResult`; - the check stays `!called || childRc != 0` (`prepare.cppm:3448`). -- A failure is a hard build error naming the manual command, as today. -- The stamp is written only on success, and it is keyed on the hash of the - declared set, so the merged table changes the hash and every project - re-provisions once after upgrading. That is correct: the declared set is a - different set. -- `MCPP_OFFLINE` and `MCPP_NO_AUTO_INSTALL` gate the install action and not the - whole block, as today. - -**The one behaviour change is for `workspace` entries that exist now.** They -start being provisioned. The population is small and the direction is toward -the documented claim rather than away from it: an entry that was a pin becomes -a pin that is also honoured. Section 9 makes it a criterion rather than an -assumption. - -## 6. Migration: one release, not a deprecation window - -The population is three manifests. `aarch64-virt-rt`, `riscv-virt-rt` and -`std-freestanding` each declare one `[xlings] deps` entry of the form -`xim:@`; mcpp's own `mcpp.toml` declares no `[xlings]` section -at all. A three-phase deprecation exists to give an ecosystem time it does not -need here, so the migration is a single release: the packages are edited and -republished with the new form, and `deps` is refused in the same version that -introduces the merged reader. - -What "refused" must mean, and this is the part that does not bend: `deps` stops -being honoured by becoming a **hard error that names the replacement**, never -by becoming a key nobody reads. `[xlings]` has no unknown-key sweep — no -`kKnownXlings` list exists in `toml.cppm` — so a silently dropped key would be -read by nobody and reported by nobody, which is the shape #531 exists to -prevent. - -Ordering, because the three packages are consumed by projects that may be built -with either engine: - -1. The merged reader ships, accepting `workspace` and refusing `deps` with a - message naming the line to write. +**W1. "Version unconstrained" is the empty string.** `"code": ""` is already in +use above. The resolver returns it unchanged (`src/core/xvm/db.cppm:383`) and an +empty value reads downstream as claiming no version (`Config::version_origin`'s +`claims`, `config.cpp:1108-1112`). It maps to an install target with no +`@version`, which is what `deps = ["cmake"]` means today. No `*` and no +`latest` is introduced: both are input spellings, resolved before anything is +stored. + +**W2. The namespace may ride either half, and mcpp normalises.** Four rules +keep that one fact rather than two: + +1. mcpp materialises the file's own convention only — bare target as the key, + scope on the version — so the file never holds two spellings. +2. The install address is assembled from the pair, whichever half carried it. +3. Stating it twice and differently (`"xim:foo" = "other:1.0"`) is a hard error + naming both halves, as is naming one package under both spellings. +4. The key form needs quotes. TOML bare keys are `[A-Za-z0-9_-]` and mcpp's + lexer matches (`modules/libs/src/toml.cppm:150`); measured on the 2026.9.2.1 + binary, `xim:picolibc-riscv = "1.8.12"` fails with + `mcpp.toml:6:4: error: expected …`, which says nothing about namespaces, so + the documentation shows the quotes. + +**W3. The per-platform value form is xlings' own.** Native keys are `linux`, +`windows`, `macosx` and `default` (`platform::OS_NAME`, resolved by +`resolve_platform_workspace_value_`). mcpp additionally accepts `macos`, a +superset that stays; the documentation shows `macosx` as the aligned spelling. + +### 2.3 What mcpp writes into the file + +One authored entry materialises as one `deps` element **and** one `workspace` +member. That is not a translation layer: the file's two fields have two +consumers in xlings — `deps` for a bare `xlings install`, `workspace` for +resolution — and neither subsumes the other. Writing only `workspace` would +leave a project that builds where the packages happen to be installed and fails +on a clean machine. + +If xlings later installs from `workspace`, the `deps` half of the emission can +be dropped without touching any manifest. + +### 2.4 Migration: one release + +Three manifests declare `[xlings] deps` — `aarch64-virt-rt`, `riscv-virt-rt`, +`std-freestanding`, one entry each of the form `xim:@` — and +mcpp's own `mcpp.toml` declares no `[xlings]` section. A deprecation window +buys nothing at that size. + +1. The merged reader ships, and `deps` becomes a hard error naming the line to + write instead. 2. The three packages are republished with `[xlings.workspace]` and an mcpp - floor at that version. -3. An index sweep confirms no other published manifest declares `deps`. The - local checkouts are not the ecosystem; the sweep is what makes the claim. - -Step 3 gates nothing on the mcpp side — it is a check that the denominator was -what it looked like. If it turns up manifests nobody knew about, the refusal in -step 1 becomes an advisory for one release and the window opens after all. - -## 7. The packaging map, and a loss the current implementation has - -The reason a `deps`-shaped list exists in the first place is that a published -package's install-time edge lives in the descriptor, as -`xpm..deps`. That mapping does not exist in mcpp: -`src/publish/xpkg_emit.cppm` mentions neither `xlings` nor `deps`, and nothing -in `src/pack` or `src/publish` emits a platform `deps` table. A package that -declares `[xlings] deps` today gets a descriptor without it, and the edge is -written by hand — which is why `riscv-virt-rt` carries a thirty-line comment -about the release where the hand-written edge was removed and the C library -stopped being installed. - -**The per-platform resolution shipped in 2026.9.2.1 is lossy for this path.** -`XlingsConfig::deps` and `::workspace` hold values already resolved for the -running host (`modules/manifest/src/types.cppm`, and `resolve_host_value` in -`toml.cppm`), and the unresolved entries are discarded. An emitter needs all -platforms at once: `xpm.linux.deps` and `xpm.windows.deps` are two tables, and -a manifest loaded on Linux can no longer produce the second. Packing on macOS -would emit a descriptor missing the Linux edge, and nothing would say so. - -The fix is additive and belongs with this work because the merged table -inherits the same loss: - -```cpp -struct XlingsConfig { - std::map workspace; // resolved for THIS host - // The declaration as written, per platform, for consumers that are not - // this host: the descriptor emitter needs every platform's entries at - // once. The build path never reads this. - std::map> workspaceByPlatform; -}; -``` - -`resolve_host_value` already knows which platform each key belongs to; keeping -a second copy costs one insertion. The criterion is section 9's C4: packing on -one host emits every platform's edge, and it fails today because the emitter -does not exist. - -## 8. Axes - -**Structure.** One declaration site for "what this project's environment -contains", one reader set, one provisioning pass. The count of things that can -disagree about a package's version drops from two to zero. - -**Compatibility.** Phase 1 adds no manifest key and removes none, so a manifest -written for it loads on an older mcpp; there, a `workspace` entry is a pin that -installs nothing, which is what it means today. The reverse direction — -an older manifest on a newer mcpp — is unchanged through Phase 2. - -**Upgrading.** The provisioning stamp is keyed on the declared set, so the -first build after the merge re-provisions once per project and then behaves as -before. No cache is invalidated and no output path changes. - -**Consistency.** The general form gains what its shorthand already does. The -`[toolchain]`/`[xlings.workspace]` relationship stated in §2.13 becomes true -rather than aspirational. - -**Cross-platform.** The per-platform value form is unchanged; section 7 makes -it survive to the one consumer that needs the unresolved form. - -**What a person sees.** One table instead of two, and one question fewer to -answer when writing it. Every failure keeps naming the package and the manual -command. - -## 9. Test criteria - -Each must be observed failing before the corresponding change. - -| # | Criterion | Note | -|---|---|---| -| C1 | A `[xlings.workspace]` entry for a package that is not installed provisions it, and its payload directory reaches `build.mcpp` as `MCPP_XPKG__DIR` | Assert on the value the program read, not on a log line | -| C2 | A `workspace` entry that cannot be provisioned fails the build with the manual command in the message | The existing `deps` diagnostic, reached from the new input | -| C3 | One package named in both `deps` and `workspace` with different versions is a hard error naming both lines | Must be seen to fail on a manifest that today builds and silently provisions the `deps` version | -| C4 | Packing a project whose table has per-platform entries emits `xpm..deps` for every platform, from any host | Fails today because the emitter does not exist; the assertion is on the emitted descriptor, not on the manifest | -| C5 | A `workspace` entry with `"*"` provisions the package and pins nothing | Both halves; a test that only checks the install cannot tell a wildcard from a version | -| C6 | A migrated `riscv-virt-rt` resolves its emulator on a clean machine | The ecosystem case, run in a sandbox, because "installed already" is the state that hides this | -| C7 | The second build of an unchanged project provisions nothing and prints nothing | The stamp; and the whole-project fast path never reaches this code, so the test must touch a source first | - -C3 and C4 are the two that fail on the current engine. C7 is stated because -this repository has read that measurement wrongly twice. - -## 10. Implementation surface - -- `modules/manifest/src/toml.cppm`: `workspace` gains the reference forms - `deps` accepts (namespace prefix, `"*"`); `workspaceByPlatform` retained; - the `deps`/`workspace` conflict check; the Phase 2 advisory. -- `modules/manifest/src/types.cppm`: `XlingsConfig` fields and their comments. -- `src/build/prepare.cppm`: the provisioning pass, `fillXpkgDirs` and - `xlingsDepBinDirs` read the merged table; the materialisation emits what - section 4 decides. -- `src/xlings/xlings.cppm`: `ProjectEnv` and `seed_xlings_json`, per section 4. -- `src/publish/xpkg_emit.cppm`: the `xpm..deps` map (new). -- `docs/05-mcpp-toml.md` §2.13, `docs/13-baremetal.md`, `docs/17`, and each - `docs/zh/` twin, which CI enforces. -- `tests/unit/test_manifest.cpp`, a new e2e for C1/C2/C3, and the packing - criterion C4. -- Ecosystem: `aarch64-virt-rt`, `riscv-virt-rt`, `std-freestanding` migrate - after Phase 1 ships, each with an mcpp floor. - -## 11. Open questions - -Answered on 2026-09-03 and kept here with their answers, because a question -that was open is part of how the design was reached. - -1. **Does xlings provision from `workspace`?** No. `deps` is the only key any - install path reads (§13.2). Hence §4: the merge is in `mcpp.toml` and the - file keeps both fields. -2. **Do workspace keys accept a namespace prefix?** The file's keys never carry - one; the namespace belongs to the install address (§3 W2). mcpp accepts it - in the authored key and writes the bare target name. -3. **Is `"*"` already spelled something else?** Yes, two ways: a version prefix - and `latest`, both resolved before anything is stored (§3 W1). No new - spelling is introduced. -4. **Does binding a package determine its programs' versions?** Yes, and the - expansion happens when the entry is honoured rather than when it is merged - (§15.1). -5. **The migration window.** Answered by the denominator: three manifests, so - no window (§6). The index sweep confirms the denominator rather than gating - the change. - -The one decision left for review is D8 (§15.1): the provisioning pass sends -`useAfterInstall: true`, so a declared version becomes the active one in the -project's own layer rather than being installed beside whatever is already -active. - -## 12. The section as a whole - -The proposal changes one field of a section whose other fields are unaffected. -This is what `[xlings]` is at `4d99864`, so that a review of the change can see -what it is being made against. - -### 12.1 Field correspondence - -| `mcpp.toml` | `.xlings.json` | Shape | Readers in mcpp | -|---|---|---|---| -| `[xlings] deps` | `deps` | array of package references | five (§1); retired by this proposal | -| `[xlings.workspace]` | `workspace` | object, name to version | one, the materialisation; five after this proposal | -| `[xlings] subos` | `subos` | string | the materialisation, and `select_runtime` | -| `[xlings.envs]` | `envs` | object, name to value | one, the materialisation | -| `[indices]` (not under `[xlings]`) | `index_repos` | array of repo objects | `ensure_project_index_dir` | -| — | `lang`, `mirror` | strings | written by mcpp unconditionally | - -Names and meanings correspond one to one, and mcpp adds no key of its own. The -file is written by `seed_xlings_json` (`src/xlings/xlings.cppm`), each field -emitted only when non-empty. - -### 12.2 Three places mcpp is not a pure mirror - -Stated because a "1:1, no translation layer" claim is checkable, and these are -the exceptions to it. - -1. **mcpp appends an entry the manifest did not write.** The target's C library - is added to the package channel, deduplicated, when the target row names one - (`prepare.cppm:3186-3194`). It rides that channel rather than having one of - its own so that one materialisation can be wrong instead of two. It is also - the reason §3's W2 is a prerequisite. -2. **What is written is already resolved for this host.** A per-platform value - is collapsed at manifest load (`resolve_host_value`), so the file is a - materialisation for this machine rather than a copy of the declaration. §7 - is the consequence. -3. **`lang` and `mirror` are mcpp's, not the manifest's.** They come from - mcpp's own configuration and are always present in the file. - -### 12.3 Ownership: who declares the environment - -One rule, in `mcpp.xlings.runtime_selection`, whose header states what it -deliberately does not read: the process environment, xlings' active or current -state, the compiler path, and dependency manifests. Allowing any of them would -make one `mcpp.toml` mean different ABIs in different shells. - -- In a workspace build the **workspace root** owns the declaration, even after - the package manifest switches to a selected member. An independently built - member is its own owner. -- A dependency's `[xlings]` is never consulted and never propagated. A - library's declaration applies when it is a root, not when its sources are - consumed by another root. -- The file is written under the owner's root - (`/.mcpp/.xlings.json`). When the owner is not the directory mcpp - writes into, two files are written: indices to the work root, the environment - to the owner root. -- Nothing is written at all unless the project declares indices, or declares - `[xlings]`, or the target row names a C library - (`materializeRootRuntime`, `prepare.cppm:3179-3181`). - -### 12.4 `subos`: presence is semantic, and mcpp only reads - -- **Absent** selects mcpp's initialised, release-verified `McppDefault`. - **`subos = "default"`** is an explicit `NamedSubos("default")`. A string alone - cannot distinguish absence from an empty value, which is why the manifest - carries `subosDeclared` beside it. -- The name is validated as a portable identifier (letters, digits, `.`, `_`, - `-`); anything else is a manifest error naming the value. -- There is **no CLI or environment override**, and no implicit following of - xlings' active or current SubOS. -- A named SubOS that does not exist is a **hard error**, never a fallback: - falling back would substitute a different environment for the one the - manifest named. Creating and populating one is xlings' layer - (`xlings subos new`); mcpp reads an environment and never creates one. -- An environment that exists but carries no `subos_info` **degrades**: the - runtime binding reports inconclusive, a note is printed, and the build - continues. -- On Linux the selection also fixes the loader and C library contract, so two - SubOS names produce separately fingerprinted objects. -- Only a **declared** SubOS puts its `bin/` at the front of `build.mcpp`'s - `PATH` (`projectSubosBin` is non-empty only for `Mode::NamedSubos`, - `prepare.cppm:1389`). A project that declares nothing inherits the `PATH` - mcpp was started with, byte for byte. - -### 12.5 `envs` is not the environment a program runs in - -Two channels are easy to confuse and are unrelated: - -- `[xlings.envs]` is materialised into `.xlings.json` and read by xlings for - the **tool** environment. mcpp has exactly one reader for it, the - materialisation. -- `compute_subos_env` (`src/build/execute.cppm:418`) builds the environment a - built program is **run** with, and it derives from `plan.runtimeBinding` — - the SubOS's own `subos_info` — not from `[xlings.envs]`. - -A value written under `[xlings.envs]` therefore does not reach `mcpp run`'s -child. Whether it should is a separate question from this proposal and is not -answered here. - -### 12.6 What this proposal does not touch - -`subos`, `envs`, `[indices]`, the ownership rule, the write conditions and the -`PATH` contract are unchanged. The change is confined to which of the two -package-shaped fields exists, and to the resolution loss §7 describes, which -the merged field inherits. - -## 13. What the four fields actually do, measured in the xlings source - -Read at `/home/speak/workspace/github/openxlings/xlings`, 2026-09-03. Section 4 -and question Q1 of section 11 are answered here; section 12.5 is corrected. - -### 13.1 How the file is found at all - -xlings locates a project config by walking the current directory upward for a -`.xlings.json`, stopping at any directory that also contains a `subos/` — that -signature means "an xlings home", never a project — and, failing that, by -reading `XLINGS_PROJECT_DIR` (`src/core/config.cpp:765-799`). + floor. +3. An index sweep confirms the denominator. It gates nothing; if it finds + manifests nobody knew about, step 1's refusal becomes an advisory for one + release and a window opens after all. -mcpp writes `/.mcpp/.xlings.json` and passes -`XLINGS_PROJECT_DIR=/.mcpp`, so the file is reached through the -environment variable, not the walk. A person standing in the project root and -running `xlings` does not see it: the walk looks for `/.xlings.json`, -one level up from where mcpp writes. Measured on a real materialisation -(`mcpplibs/riscv-virt-rt/.mcpp/.xlings.json`), whose `.mcpp/` holds no `subos/` -and therefore does not trip the home boundary. +**What does not bend:** `deps` is refused with a message, never dropped in +silence. `[xlings]` has no unknown-key sweep — no `kKnownXlings` list exists in +`toml.cppm` — so a removed key would be read by nobody and reported by nobody, +which is the shape #531 exists to prevent. -### 13.2 `deps` is the only install trigger, on both sides +## 3. Change two: provisioning runs in project scope -`install_from_project_config` (`src/core/cmdprocessor.cpp:163-196`) is the -no-argument `xlings install`. It reads `deps`, errors when the key is absent or -is not an array, and installs each entry through -`xmake xim -P -- -y`. It reads no other key. +### 3.1 The defect -mcpp's own provisioning does not use that path: it calls the `install_packages` -capability with targets it read from `mcpp.toml` itself. So the `deps` array in -the file serves a different consumer — a person running bare `xlings install` — -than the pass that makes `mcpp build` work. - -### 13.3 `workspace` is a version layer, and a named subos drops the global one - -The project file's `workspace` object is read into `projectWorkspace_` -(`config.cpp:660-662`) and becomes one layer of the effective pins. -`merged_workspace` (`config.cpp:846-864`) resolves them: - -| Project subos mode | Layers merged, later winning | -|---|---| -| `Named` (the file declares `subos`) | project manifest, then that subos's own workspace | -| `Anonymous` (project file, no `subos`) | global, then project manifest, then the project subos | -| no project config | global only | - -**Pinning a few and inheriting the rest is what the Anonymous row does.** A -project that writes `[xlings.workspace]` and no `subos` starts from the global -workspace and merges its own entries on top, so every tool it does not name -keeps the machine's version. This is the common shape for an mcpp project and -it works as an author would expect. - -**A named subos does not inherit the global layer, and that is deliberate -rather than an omission.** A named SubOS is a different environment with its -own installed set; carrying the host's pins into it would name versions that -environment does not have. Its own `workspace` — stored in -`/.xlings.json` and merged as the last layer — is what it inherits from -instead. A SubOS created with `xlings subos new --from ` receives -the base's workspace map by copy at creation (`src/core/subos.cpp:978`); that -is a one-time inheritance, not a live link. - -**Nothing falls back silently when no layer names a tool.** The shim reads -`Config::effective_workspace()` and, finding no active version, produces a -diagnostic rather than choosing one (`src/core/xvm/shim.cpp:408-460`: -`xvm.no_active_version`, or the "installed in this subos, but no version is -active" form). The `qemu-aarch64-static is not installed in this subos (_)` -line measured during the 2026.9.2.1 verification is that path. - -What mcpp does not state today is the first two paragraphs: that declaring -`[xlings] subos` changes which pins apply, and that the environment's own -workspace replaces the global one. That belongs in `docs/17`. - -**A `workspace` entry installs nothing.** No install path reads it. This -answers Q1: xlings does not provision from `workspace`, which is why section 4 -was revised rather than kept. - -### 13.3.1 Two artifacts share the name `.xlings.json` - -They must not be measured for each other, and this document did so once. +```cpp +mcpp::xlings::call(mcpp::config::make_xlings_env(**cfg2), "install_packages", …) +``` -**The authored project file** is what a person writes and what mcpp -materialises: `workspace` maps a name to a version string or to a -platform-conditional object, and nothing else. `d2mcpp/.xlings.json` is the -published example (§3). +`prepare.cppm:3421`. `make_xlings_env` carries no `projectDir` +(`src/config.cpp:129`), so no `XLINGS_PROJECT_DIR` reaches the child and the +cwd walk finds nothing. The child runs in **global** scope against mcpp's +registry home, and three symptoms follow: -**A SubOS state file** is what `cmd_use` writes: the same key space, but each -value is an `{active, installed[]}` record, and the keys are every member of -every release ever switched to. On the development host's default SubOS that is -546 entries, including package roots, their programs, and file assets: +1. **The install's view and the shim's view disagree.** Records land in the + registry's global workspace; a program invoked from the project resolves + through the project's layers. This is the + `qemu-aarch64-static is not installed in this subos (_)` measured during the + 2026.9.2.1 verification, and the reason the runner needed a + payload-directory lookup. +2. **`active` is read from a workspace shared by every mcpp project** on the + machine, so the auto-activation decides against a state no project owns. +3. **Forcing the switch there would let two checkouts flip each other**, which + is why the decline is protective in this scope rather than obstructive. + +### 3.2 The fix, and why nothing else is needed + +Call with `make_project_xlings_env(cfg, root)`. The child then runs in project +scope: the installed set and the activation records land in the project's own +SubOS, and the view the install writes is the view the shim reads. + +**No flag, and no explicit switch.** With the declaration materialised into the +project file's `workspace`, §1's merge already makes it authoritative — +Anonymous puts the project layer over the global one, Named does not merge the +global one at all. `activate_requested_targets` does not fire in that situation +and should not: the merged view already answers with the declared version. + +An earlier draft proposed `useAfterInstall: true` and an explicit `use_version` +per entry. Both are withdrawn (§15). + +**One precondition, checked.** The scope fix works only if xlings accepts +`/.mcpp` as a project directory, and §1's discovery rule rejects any +directory holding a `subos/`. It does not fire here: a project's SubOS lives at +`/.xlings/subos/`, so what would have to exist is +`/.mcpp/subos`, and nothing creates it — measured on +`mcpplibs/riscv-virt-rt/.mcpp/`, which holds only `.xlings.json`. It is worth +an assertion rather than a memory: anything that later writes a bare `subos/` +under `.mcpp` would make mcpp's own project file invisible to xlings, silently +and everywhere at once. + +### 3.3 Provisioning otherwise keeps its contract + +- The result is read, not assumed: `!called || childRc != 0` + (`prepare.cppm:3448`). +- A failure is a hard build error naming the manual command. +- The stamp is written only on success and keyed on the hash of the declared + set, so the merged table re-provisions once per project after the upgrade and + the common build performs nothing. +- `MCPP_OFFLINE` and `MCPP_NO_AUTO_INSTALL` gate the install action, not the + block. + +### 3.4 Two consequences to write down + +**The project SubOS layer outranks the project manifest.** It merges last, so +an explicit `xlings use` inside the project beats what `mcpp.toml` declares +until the manifest is re-materialised. Defensible — an action a person took +beats a file — but not obvious. + +**A declared version that was never installed already fails with the right +words**: `"{}@{} is the version this project asks for"` +(`src/core/xvm/shim.cpp:495`). Provisioning is what prevents it; that +diagnostic is what happens when provisioning was skipped, and it names the +project rather than the machine. + +## 4. Change three: `envs` is retired + +Every `envs` consumer in xlings is one of two structures, and neither is the +flat object mcpp writes: + +1. `xvm`'s `VData::envs` — variables attached to **one program's** shim, stored + in the version database (`xvm/db.cpp:724`, `xvm/shim.cpp:337`), set through + `xvm add --env`. +2. A SubOS's `subos_info.envs` — an object of **provider sections** keyed by + binding (`subos/manifest.cpp:197-290`). + +Searching the source for `contains("envs")` and `["envs"]` outside those files +and the doctor that checks them returns nothing. mcpp's own run environment +comes from `plan.runtimeBinding` (`execute.cppm:418`), never from +`[xlings.envs]`. + +So the key is written by mcpp and read by nobody, while `docs/05` §2.13 calls +it "env vars applied to the tool environment". It is retired on the path of +§2.4, and **the documentation correction ships first and independently**: a +sentence stating an effect that does not occur is the more urgent half. + +## 5. The packaging map, and the loss 2026.9.2.1 introduced for it + +A published package's install-time edge lives in its descriptor as +`xpm..deps`. mcpp does not emit it: `src/publish/xpkg_emit.cppm` +mentions neither `xlings` nor `deps`, and nothing in `src/pack` or +`src/publish` writes a platform `deps` table. That is why `riscv-virt-rt` +carries a thirty-line comment about the release where the hand-written edge was +removed and the C library stopped being installed. + +**The per-platform resolution shipped in 2026.9.2.1 is lossy for that path.** +`XlingsConfig` holds values already resolved for the running host, and the +unresolved entries are discarded. An emitter needs every platform at once, so a +manifest loaded on Linux cannot produce `xpm.windows.deps`, and packing on +macOS would emit a descriptor missing the Linux edge with nothing said. + +The fix is additive, and the merged table inherits the same loss: -``` -binutils = 2.42 ar = 2.42 as = 2.42 ld = 2.42 -gcc = 16.1.0 g++ = 16.1.0 cc = 16.1.0 -Scrt1.o, crt1.o, crti.o, crtn.o, glibc.files.1 … glibc.files.101 +```cpp +struct XlingsConfig { + std::map workspace; // resolved for THIS host + // The declaration as written, per platform, for the descriptor emitter, + // which needs every platform at once. The build path never reads this. + std::map> workspaceByPlatform; +}; ``` -A package root and its programs carry the same version there because they are -members of one release and `cmd_use` wrote them together (§15.1). That identity -is the group expansion's footprint, and it is why writing a package root in a -project file pins its programs: the expansion happens when the entry is -honoured. - -The parser accepts both shapes for a value and disambiguates by reserved keys — -an `active` or `installed` key marks the state form, anything else is read as -the project form (`src/core/xvm/db.cppm:405-420`). So the two files are one -schema seen at two stages, and the project form is the one mcpp's `[xlings]` -mirrors. - -### 13.4 `envs` has no reader anywhere - -Every `envs` consumer in the xlings source is one of two structures, and -neither is the flat object mcpp writes: - -1. `xvm`'s `VData::envs` — environment variables attached to **one program's** - shim, stored in the version database and applied when the shim runs - (`src/core/xvm/db.cpp:724`, `src/core/xvm/shim.cpp:337`). Set through - `xvm add --env`, not through any project file. -2. A SubOS's `subos_info.envs` — "an object of **provider sections**" keyed by - binding (`src/core/subos/manifest.cpp:197-290`), part of the environment's - own metadata. - -Searching the whole source for `contains("envs")` and `["envs"]` outside those -two files and the doctor that checks them returns nothing. There is no reader -for a flat name-to-value `envs` object in a project or home `.xlings.json`. - -**So `[xlings.envs]` is written by mcpp and read by nobody.** It does not reach -a built program's environment either: `compute_subos_env` -(`src/build/execute.cppm:418`) derives that from `plan.runtimeBinding`, the -SubOS's own `subos_info`, and never consults `[xlings.envs]`. The sentence in -`docs/05-mcpp-toml.md` §2.13 that calls it "env vars applied to the tool -environment" describes an effect that does not occur. - -**Decided (2026-09-03): `envs` is retired, not wired.** xlings never supported -a project-level flat `envs` object and does not need to: `[xlings]` exists to -align with xlings' project-level isolated environment, and the two `envs` -structures that do exist there belong to a program's shim and to a SubOS's own -metadata. Neither is something a consuming project should be writing. - -It follows the same three phases §6 gives `deps`, and for a stronger reason: a -key read by nobody is the shape #531 exists to prevent, and this one is -additionally documented as having an effect. Phase 1 is the documentation -correction, which can ship immediately and independently of everything else in -this document — `docs/05-mcpp-toml.md` §2.13 and its Chinese twin currently -state an effect that does not occur. - -### 13.5 Corrections this section makes to the rest of the document - -| Where | Was | Is | -|---|---|---| -| §4 | mcpp writes only `workspace` | mcpp writes both fields; the merge is in `mcpp.toml` | -| §11 Q1 | open | answered: xlings does not provision from `workspace` | -| §12.1 | `envs` reader: "the materialisation" | no reader on either side | -| §12.5 | `[xlings.envs]` is read by xlings for the tool environment | it is read by nothing | - -## 14. What mcpp's own documentation must say +`resolve_host_value` already knows which platform each value belongs to. -The inheritance rule of §13.3 is the behaviour an author most needs and the one -mcpp states nowhere. It is not introduced by this proposal; it is being written -down because the proposal makes `workspace` the key everybody writes. +## 6. Documentation -**`docs/05-mcpp-toml.md` §2.13** gains the rule as a table, next to the -`[xlings.workspace]` description: +**`docs/05` §2.13** gains the inheritance rule as a table, next to +`[xlings.workspace]`: | The project declares | The version of a tool it did not name comes from | |---|---| @@ -646,206 +321,184 @@ down because the proposal makes `workspace` the key everybody writes. | `[xlings.workspace]` and `subos = ""` | that SubOS's own workspace; the global one does not apply | | neither | the machine's global workspace | -with one sentence for why the middle row is not an omission: a named SubOS is a -different environment with its own installed set, and carrying the host's pins -into it would name versions that are not there. - -**`docs/17-the-project-environment.md`** gains the consequence, because that -chapter is where `subos` is chosen: declaring a SubOS changes which version -pins apply, and a project that relied on the machine's pins has to state them -itself once it names an environment. The chapter already says mcpp reads an -environment and never creates one; this is the other half of what the -declaration decides. +with one sentence for why the middle row is not an omission, the two namespace +spellings **with the quotes shown**, `""` for an unconstrained version, and +`macosx` as the aligned platform key. -**`docs/13-baremetal.md`** needs no change: it declares packages, not versions. +**`docs/17`** gains the consequence where `subos` is chosen: declaring a SubOS +changes which pins apply, and a project that relied on the machine's tools has +to declare them once it names an environment. It also gains §3.4's first +paragraph. -Each with its `docs/zh/` twin, which CI enforces. +**`docs/05` §2.13's `envs` sentence is corrected** (§4), independently and +first. -A criterion, so the paragraph is not the only record: a project that pins a -tool and declares a SubOS resolves that tool to its pin, and a tool it does not -pin resolves inside the SubOS rather than to the host's global choice. It is an -e2e over two SubOS environments and one tool installed at two versions. +`docs/13` needs no change: it declares packages, not versions. Each file with +its `docs/zh/` twin, which CI enforces. -## 15. Self-review +## 7. Axes -Read against the code a second time, 2026-09-03. Three findings; the first is -the one that changes the proposal. +**Structure.** One declaration site for what the project's environment +contains, one reader set, one provisioning pass — and the pass now runs in the +scope its effects are read from. The count of things that can disagree about a +package's version drops from two to zero. -### 15.1 Withdrawn: the expansion happens, in the action rather than in the merge +**Stability.** Every decision is a value the project wrote or an answer xlings +gave. The scope fix removes a shared mutable workspace from the path, which is +the class of state two projects can fight over. -The first version of this section claimed that a project-file `workspace` entry -cannot pin a package's programs, because the merge is per-key and the shim -looks up a program's own name. Both halves are true and the conclusion does not -follow. It reads the declaration as something mcpp only writes down, and this -proposal is that mcpp acts on it. +**Compatibility.** No manifest key is added. A manifest written for this loads +on an older mcpp, where a `workspace` entry is a pin that installs nothing — +what it means there today. -The mechanism, read through: +**Upgrading.** The stamp is keyed on the declared set, so the first build after +the change re-provisions once per project and then behaves as before. No cache +is invalidated and no output path changes. -1. mcpp provisions each entry through the `install_packages` capability. -2. `xim`'s installer, having installed, calls `xvm::cmd_use(name, version)` - for the requested target (`src/core/xim/commands.cpp:693`). -3. `cmd_use` resolves the whole release with `resolve_binding_selection` and - writes one workspace entry **per member** - (`src/core/xvm/commands.cpp:763-772`); the installer's own note says the - same — "`cmd_use` creates shims for every member of the release it switches - to" (`installer.cpp:1986`). -4. `Config::workspace_mut()` returns the **project's** SubOS workspace whenever - a project config is loaded (`config.cpp:1163-1168`), so those per-member - entries land in the project's own layer, which the merge applies last. The - machine's global choice is not disturbed. +**Consistency.** The general form gains what its shorthand already does, and +§2.13's claim about `[toolchain]` becomes true rather than aspirational. -So naming a package root in the manifest does pin its programs: the group is -expanded when the entry is honoured, and what lands in the file is already -per-member. The declaration is the input to that action, not a layer expected -to expand itself. +**Cross-platform.** The per-platform form is xlings' own and is unchanged; §5 +makes it survive to the one consumer that needs it unresolved. -**What the review did find is one flag.** Activation after install is -conditional (`installer.cpp` `activate_requested_targets`): +**What a person sees.** One table instead of two, one question fewer when +writing it, and a declared version that is honoured on a machine that had +another one active. -```cpp -auto active = xvm::get_active_version(Config::effective_workspace(), match.name); -if ((active.empty() || useAfterInstall) && has_version(db, match.name, match.version)) - cmd_use(match.name, match.version, stream); -else if (!active.empty() && active != match.version) - // declining to switch is a decision, and it used to be a silent one -``` +## 8. Test criteria -Install activates only when **nothing is active yet**, unless the caller asks -otherwise. mcpp's provisioning pass sends `{"targets": […], "yes": true}` and -nothing else, so on a machine where another version of that name is already -active, the declared version is installed and not the one that runs. - -That is tolerable for `deps`, whose meaning is "must exist". It is not -tolerable for a table whose meaning is "at this version": a constraint that -installs without activating is not a constraint. The capability already takes -the flag — `useAfterInstall`, documented as "Activate the installed version -even if another version is currently active" (`src/capabilities.cpp:81`) — so -the change is one field in the request mcpp already sends. - -**Decision D8: the provisioning pass passes `useAfterInstall: true`.** Its -blast radius is the project's own SubOS layer, per point 4 above, which is what -makes it safe to do unconditionally rather than behind another key. - -### 15.2 "Retired" means the manifest key, never the file field - -Section 6 reads as though `deps` disappears. It does not: §4 keeps emitting the -`deps` array into `.xlings.json`, because that is the only key xlings' install -path reads. What is retired is the key an author writes in `mcpp.toml`. Every -occurrence of "retired" in sections 0, 6 and 12 means that and only that, and -the phases apply to the manifest surface alone. - -### 15.3 Section 12.6 is no longer true as written - -It says the proposal touches neither `subos` nor `envs`. It touches both, -though not their behaviour: §13.4 asks for a decision on `envs`, which is -written by mcpp and read by nobody, and §14 adds documentation for what `subos` -does to the inheritance chain. Neither changes an effect; both change what the -project states about itself, which is the part this document is for. - -### 15.4 What survived the review unchanged +Each must be observed failing before the corresponding change. -The two facts section 1 rests on. The general form still installs nothing while -its documented shorthand installs, and nothing still compares the two keys when -both name one package. §13 strengthened rather than weakened them: xlings makes -the same split in the same direction, which is why the merge belongs in the -manifest and not in the file. +| # | Criterion | Note | +|---|---|---| +| C1 | A `workspace` entry for a package that is not installed provisions it, and its payload directory reaches `build.mcpp` as `MCPP_XPKG__DIR` | assert on the value the program read | +| C2 | A `workspace` entry that cannot be provisioned fails the build with the manual command in the message | the existing diagnostic, reached from the new input | +| C3 | One package named in both `deps` and `workspace` is refused naming both lines | must be seen failing on a manifest that today builds and silently provisions the `deps` version | +| C4 | Packing a project whose table has per-platform entries emits `xpm..deps` for every platform, from any host | fails today: no emitter | +| C5 | An entry with `""` provisions the package and pins nothing | both halves | +| C6 | A project declaring a version different from the machine's active one builds and runs against the declared version, in Anonymous mode | fails today: the scope | +| C7 | The machine's global workspace file is unchanged after C6 | fails today: the scope | +| C8 | Two checkouts declaring different versions of one tool each get their own, in one session | fails today: the scope | +| C9 | A Named SubOS project resolves what it declared plus what its environment holds; a tool it did not declare fails naming that environment | makes the developer's choice visible | +| C10 | A declared version never installed fails at the shim with the "version this project asks for" wording | not a bare "not found" | +| C11 | The second build of an unchanged project provisions nothing and prints nothing | the stamp; the whole-project fast path never reaches this code, so touch a source first | +| C12 | A migrated `riscv-virt-rt` resolves its emulator on a clean machine | the ecosystem case, in a sandbox | +| C13 | `/.mcpp` is loaded by xlings as a project directory, not skipped as an xlings home | §3.2's precondition; assert on an effect, since a skip degrades to the current behaviour and would otherwise look like success | + +C3, C4, C6, C7 and C8 fail on the current engine. C11 is stated because this +repository has read that measurement wrongly twice. + +## 9. Implementation surface + +- `modules/manifest/src/toml.cppm`: `workspace` accepts the reference forms + `deps` accepts (namespace on either half, `""`); `workspaceByPlatform` + retained; the `deps` refusal; the duplicate-namespace error. +- `modules/manifest/src/types.cppm`: `XlingsConfig` fields and comments. +- `src/build/prepare.cppm`: the provisioning pass reads the merged table and + calls with `make_project_xlings_env`; `fillXpkgDirs` and `xlingsDepBinDirs` + read the merged table; the materialisation emits both file fields. +- `src/xlings/xlings.cppm`: `ProjectEnv` and `seed_xlings_json`. +- `src/publish/xpkg_emit.cppm`: the `xpm..deps` map (new). +- `docs/05` §2.13, `docs/17`, and each `docs/zh/` twin. +- `tests/unit/test_manifest.cpp`; new e2e for C1, C2, C3, C5, C6, C7, C8, C9; + the packing criterion C4. +- Ecosystem: `aarch64-virt-rt`, `riscv-virt-rt`, `std-freestanding` migrate + after step 1, each with an mcpp floor. -## 16. D8 withdrawn: the merge already does it, once the scope is right +## 10. Rejected and withdrawn -The requirement — a declared tool at a declared version is installed if absent -and active afterwards — needs no flag, no explicit switch and no new rule. It -needs mcpp to ask in the right environment. Everything else is already built. +**Writing only `workspace` into the file.** xlings does not install from it +(§1); the project would build where the packages happen to be installed. -### 16.1 Both halves are native +**`useAfterInstall: true`.** Not needed once the scope is right (§3.2), and it +cannot be verified: a forced switch that fails is a `log::warn` inside a call +that exits zero. -**Mapping rather than reinstalling.** The installer checks whether the payload -exists and is registered to this package, sets `payloadInstalled` and skips the -install hook (`src/core/xim/installer.cpp:2740-2775`). An already-present -payload is mapped; only a missing one is fetched. +**An explicit `use_version` per entry.** Same reason. It was proposed to obtain +the exit code the forced install discards; with the merge doing the work there +is nothing to switch. -**The declaration outranking the machine.** `merge_workspace_into_` assigns -rather than inserts (`src/core/config.cpp`), so in `merged_workspace` the later -layer wins: +**A three-phase deprecation for `deps`.** The denominator is three manifests +(§2.4). -| Mode | Layers, in merge order | What wins | -|---|---|---| -| Anonymous | global, project manifest, project SubOS | the project's declaration beats the machine's | -| Named | project manifest, project SubOS | the machine's layer is not present at all | +**Wiring `envs`.** Neither xlings structure is what a consuming project should +write (§4). -So a project that declares `gcc = "15.1.0"` resolves `gcc` to 15.1.0 even on a -machine whose global workspace is on 16.1.0 — because mcpp materialises that -declaration into the project file's `workspace` object (§4), which is a layer -that beats global. The developer chooses the strength by choosing the mode: -name a SubOS for an isolated space, or leave it anonymous and get the machine's -environment with the project's own entries laid over it. +**A namespace-only key, or a namespace-only value.** Both are accepted, because +neither is more natural and the two are mechanically interconvertible; what is +refused is holding both at once with different values (§2.2 W2). -Nothing has to force anything. `activate_requested_targets` does not fire in -this situation, and correctly: the merged view already answers with the -declared version, so there is nothing to switch. +## 11. Open questions -### 16.2 What is actually wrong today: mcpp provisions in the wrong scope +None blocking. Two items are scheduling rather than design: which mcpp floor +the three migrating packages declare, and whether the index sweep of §2.4 is a +release gate or a one-off. -The provisioning pass calls +## 12. Appendix: measurements -```cpp -mcpp::xlings::call(mcpp::config::make_xlings_env(**cfg2), "install_packages", …) -``` +| Claim | How it was checked | +|---|---| +| A workspace key is an xvm target of any kind | the development host's default SubOS: `binutils`/`ar`/`as`/`ld` all 2.42, `gcc`/`g++`/`cc` all 16.1.0, plus `crt1.o` and `glibc.files.N`; 546 entries | +| A package root and its programs carry one version | same, and `cmd_use` writes one entry per binding-group member | +| No key carries a namespace | 1635 version-database targets and 546 workspace entries, zero colons; the authored `d2mcpp` file likewise | +| A namespace does appear on a version | `"mcpp": {"active": "xim:2026.8.30.2", …}`, one target holding scoped and unscoped versions at once | +| `""` is the unconstrained spelling | `d2mcpp/.xlings.json`, and `claims` treating an empty value as no claim | +| The unquoted `ns:name` key is a TOML error | run on the 2026.9.2.1 binary: `mcpp.toml:6:4: error: expected …` | +| Provisioning runs in global scope | `make_xlings_env` has no `projectDir`; the file mcpp writes is one level below the walk | +| `envs` has no reader | exhaustive search of the xlings source | -(`prepare.cppm:3421`), and `make_xlings_env` carries no `projectDir` -(`src/config.cpp:129`), so no `XLINGS_PROJECT_DIR` reaches the child and the -cwd walk finds nothing — mcpp writes `/.mcpp/.xlings.json`, one level -below the directory a walk from the project root inspects. The child runs in -**global** scope against mcpp's registry home. +## 13. Two artifacts share the name `.xlings.json` -That single fact produces every symptom in this section: +They must not be measured for each other, and an earlier draft of this document +did so. -1. The install's records land in the registry's global workspace, while a - program invoked from the project resolves through the project's layers. The - two views disagree, which is the - `qemu-aarch64-static is not installed in this subos (_)` measured during the - 2026.9.2.1 verification — and the reason the runner needed a - payload-directory lookup at all. -2. `active` is read from a workspace shared by every mcpp project on the - machine, so the auto-activation decides against a state no project owns. -3. Any attempt to force the switch would write into that shared workspace, - letting two checkouts flip each other. +**The authored project file** is what a person writes and what mcpp +materialises: `workspace` maps a name to a version string or to a +platform-conditional object. -**The fix is the scope, not a flag**: provision with -`make_project_xlings_env(cfg, root)`. The child then runs in project scope, the -installed set and activation records land in the project's own SubOS -(`config.cpp:348-353` puts it under `/.mcpp/.xlings/subos/…`), and the -view the install writes is the view the shim reads. +**A SubOS state file** is what `cmd_use` writes: the same key space, but each +value is an `{active, installed[]}` record and the keys are every member of +every release ever switched to. -**D8 is withdrawn.** `useAfterInstall` is not passed, and no `use_version` call -is added. The provisioning pass keeps its shape and changes one argument. +The parser accepts both shapes and disambiguates by reserved keys — an `active` +or `installed` key marks the state form (`xvm/db.cppm:405-420`). One schema, +two stages; the project form is the one `[xlings]` mirrors. -### 16.3 Two consequences to write down rather than discover +## 14. `subos`, unchanged by this proposal and stated for completeness -**The project SubOS layer outranks the project manifest.** It is merged last, -so an explicit `xlings use` performed inside the project beats what -`mcpp.toml` declares, until the manifest is re-materialised. That is defensible -— an action a person took beats a file — but it is not obvious, and it belongs -in the documentation next to the table above. +- **Absent** selects mcpp's initialised `McppDefault`; **`subos = "default"`** + is an explicit `NamedSubos("default")`. `subosDeclared` carries the + difference, because a string alone cannot. +- The name is validated as a portable identifier; anything else is a manifest + error naming the value. +- There is **no CLI or environment override**, and no implicit following of + xlings' active SubOS. +- A named SubOS that does not exist is a **hard error**, never a fallback. + Creating one is xlings' layer; mcpp reads an environment and never creates + one. +- An environment with no `subos_info` **degrades**: the runtime binding reports + inconclusive, a note is printed, the build continues. +- On Linux the selection also fixes the loader and C library contract. +- Only a **declared** SubOS puts its `bin/` at the front of `build.mcpp`'s + `PATH` (`prepare.cppm:1389`). -**A declared version that is not installed fails at the shim, with the right -words.** Resolution finds the pin, `match_version` finds no such installed -version, and the diagnostic is already -`"{}@{} is the version this project asks for"` (`src/core/xvm/shim.cpp:495`). -Provisioning is what prevents it; the diagnostic is what happens if -provisioning was skipped, and it names the project rather than the machine. +## 15. What the review changed -### 16.4 What to verify +Recorded because a position that was held and abandoned is part of how this was +reached, and because two of these were wrong in a way worth remembering. -| # | Criterion | -|---|---| -| A1 | A project declaring a version different from the machine's active one builds and runs against the declared version, in Anonymous mode | -| A2 | The machine's global workspace file is unchanged after that build | -| A3 | Two checkouts declaring different versions of one tool each get their own, in one session | -| A4 | An entry with `""` on a machine where another version is active leaves that version active | -| A5 | A Named SubOS project resolves only what it declared plus what its environment holds, and a tool it did not declare fails naming that environment | -| A6 | The second build of an unchanged project performs no install | -| A7 | A declared version that was never installed fails at the shim with the "version this project asks for" wording, not with a bare "not found" | - -A1, A2 and A3 fail today for one reason, the scope of `make_xlings_env`, and -pass together once it changes. A5 is the Named branch of the table in §16.1 and -is what makes the developer's choice visible. +| Held | Replaced by | Why | +|---|---|---| +| mcpp writes only `workspace` into the file | both fields (§2.3) | xlings installs from `deps` only | +| A manifest `workspace` entry cannot pin a package's programs | it can (§1) | expansion happens in `cmd_use`, which provisioning triggers; the merge was never where it happens | +| Activation lands in the project's own layer, so forcing is contained | it lands in the registry's global workspace (§3.1) | `make_xlings_env` carries no `projectDir` | +| `useAfterInstall: true`, then an explicit `use_version` per entry | neither (§3.2) | the merge already makes the declaration authoritative once the scope is right | +| `"*"` for an unconstrained version | `""` (§2.2 W1) | the authored form already exists and is in use | +| The namespace must go on the version; a colon in a key is an error | either half is accepted (§2.2 W2) | both are interconvertible; the error is holding two that disagree | +| A three-phase deprecation | one release (§2.4) | three manifests | +| Keys are program names | keys are xvm targets, roots included (§12) | measured | + +The two that mattered: reading a SubOS **state** file as though it were an +authored **project** file, and asserting a blast radius without checking which +environment the call runs in. Both were arguments from the shape of the code +rather than from what it does. From dcd975393cb33d5ac89e0027a06d97e2e5302bf3 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:29:59 +0800 Subject: [PATCH 15/26] design: the migration corrected against the consumer, and an implementation plan Step 1 of the migration refused deps everywhere. That counts the wrong denominator: the three manifests that declare the key are consumed, and a consumer pins an exact version, so a refusal reaching a dependency's manifest makes riscv-virt-rt@0.6.0 unbuildable for everyone who pinned it while no republished version reaches them. The refusal applies to the root manifest, where the author can act on it; a dependency's is honoured and reported once. Section 16 splits the work into eight tasks with their dependency order, and states what each review axis demands of the implementation rather than of the design. --- ...09-03-xlings-workspace-as-the-one-table.md | 85 +++++++++++++++++-- 1 file changed, 80 insertions(+), 5 deletions(-) diff --git a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md index e5ec14c7..6b290b41 100644 --- a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md +++ b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md @@ -175,13 +175,26 @@ Three manifests declare `[xlings] deps` — `aarch64-virt-rt`, `riscv-virt-rt`, mcpp's own `mcpp.toml` declares no `[xlings]` section. A deprecation window buys nothing at that size. -1. The merged reader ships, and `deps` becomes a hard error naming the line to - write instead. +**Corrected during implementation (2026-09-03): step 1 refuses in the root +manifest and advises in a dependency's.** The denominator argument counts +manifests that declare the key, and it is the wrong denominator: those three +packages are *consumed*, and a consumer pins an exact version. A refusal that +reached a dependency's manifest would make `riscv-virt-rt@0.6.0` unbuildable on +the new engine for everyone who pinned it, and no republished version reaches +them until each consumer re-pins. That is the shape this repository has already +paid for once — a consumer must ship before the thing it depends on moves. + +The asymmetry is not a hedge. A root manifest is the author's own file and they +can fix it in the same minute they read the message; a dependency's manifest is +not theirs to edit, and refusing it punishes the wrong person. + +1. The merged reader ships. `[xlings] deps` in the **root** manifest is a hard + error naming the line to write; in a **dependency's** manifest it is + honoured and reported once, naming the package. 2. The three packages are republished with `[xlings.workspace]` and an mcpp floor. -3. An index sweep confirms the denominator. It gates nothing; if it finds - manifests nobody knew about, step 1's refusal becomes an advisory for one - release and a window opens after all. +3. An index sweep confirms no other published manifest declares it. When the + advisory has been silent across a release, the dependency path refuses too. **What does not bend:** `deps` is refused with a message, never dropped in silence. `[xlings]` has no unknown-key sweep — no `kKnownXlings` list exists in @@ -502,3 +515,65 @@ The two that mattered: reading a SubOS **state** file as though it were an authored **project** file, and asserting a blast radius without checking which environment the call runs in. Both were arguments from the shape of the code rather than from what it does. + +## 16. Implementation plan + +Eight tasks. T1 is the only one everything else waits on; T2, T3 and T4 are +independent of each other; T7 runs after the release. + +``` +T1 manifest ──┬── T2 provisioning scope ──┐ + ├── T3 xlings module ├── T5 docs ── T6 tests ── T8 release + └── T4 descriptor emitter ──┘ │ + T7 ecosystem ──┘ +``` + +| # | Task | Files | Depends on | +|---|---|---|---| +| T1 | The merged table: parse, both namespace positions, `""`, per-platform, `workspaceByPlatform`, `deps` root-refusal and dependency-advisory, `envs` refusal | `modules/manifest/src/{toml,types}.cppm` | — | +| T2 | Provisioning in project scope; the materialisation feeds both file fields | `src/build/prepare.cppm` | T1 | +| T3 | `ProjectEnv` and `seed_xlings_json` lose `envs` | `src/xlings/xlings.cppm` | T1 | +| T4 | `xpm..deps` in the emitted descriptor | `src/pm/publisher.cppm` | T1 | +| T5 | `docs/05` §2.13, `docs/17`, both `docs/zh/` twins | docs | T1-T4 | +| T6 | Unit tests and one e2e | `tests/` | T1-T4 | +| T7 | The three packages republish | `mcpplibs/{aarch64-virt-rt,riscv-virt-rt,std-freestanding}` | T8 | +| T8 | Version, CI, self-review, merge, release, sandbox | — | T5, T6 | + +### 16.1 What each axis demands of the implementation + +**Architecture.** One authored key, one derived list, one provisioning pass in +one scope. `XlingsConfig::deps` becomes the derived install addresses, so every +existing reader — the pass, `fillXpkgDirs`, `xlingsDepBinDirs`, the file's +`deps` array — keeps reading the field it reads today and none of them learns +about namespaces. + +**Stability.** The scope change moves writes off a workspace shared by every +project on the machine. Nothing else about the pass changes: its result check, +its stamp, its offline gates. + +**Simplicity.** No new mechanism on either side, and the diff is smaller than +the design: the parse produces two projections and everything downstream is +untouched. + +**User experience.** A key that never worked stops being documented as working; +a key that did two jobs becomes one; and a version a project declares is the +one that runs. + +**Compatibility.** No manifest key is added. `[xlings] deps` keeps working in a +dependency and is reported once. `[xlings.envs]` is refused, and no manifest in +the ecosystem uses it — measured across the local checkouts. + +**Cross-platform.** `workspaceByPlatform` keeps the unresolved declaration, so +the descriptor a Linux host emits carries the Windows edge. + +**Consistency.** The platform vocabulary becomes xlings' own (`macosx` shown, +`macos` still accepted), and the namespace may be written in either position +because both are already spellings the ecosystem uses. + +**Upgrading without noticing.** The stamp key changes with the declared set, so +one re-provision per project and then nothing. No cache is invalidated, no +output path moves, and a manifest written for this loads on an older mcpp. + +**Test coverage.** Five of the twelve criteria fail on the current engine and +are the ones that prove the change; C11 exists because this repository has +twice measured a fast path instead of the thing under test. From 1dd2faf302e0a8bca781f5e31bfc368fcc15c307 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:49:17 +0800 Subject: [PATCH 16/26] xlings: one table, deps superseded, envs removed, and the install-time edge published MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `[xlings]` is mcpp's surface for xlings' local project mechanism, and this brings the manifest into line with what that mechanism has. `[xlings.workspace]` becomes the one table. An entry names a package and the version this project uses it at, and produces two projections: an install address for provisioning and a resolution pin for the materialised `.xlings.json`. The namespace may be written on the key or on the version — `"xim:picolibc-riscv" = "1.8.12"` and `picolibc-riscv = "xim:1.8.12"` are one entry — because both spellings already exist in the ecosystem and neither is more natural; writing it on both halves with different values is refused, as is naming one package twice. `""` means present with the version unconstrained, which is the spelling an authored project file already uses. Platform keys are xlings' own, with `macos` kept as an alias. `deps` is superseded and still honoured, reported once with the line to write instead. It is not refused: a refusal would reach a dependency's manifest that a consumer pinning that package cannot edit. One package in both tables with two versions is a hard error, because the two are provisioned in order while the last wins the pin — install one, resolve the other. `envs` is removed. It was materialised into the file and read by nothing: the two `envs` structures xlings has belong to a program's own shim record and to a SubOS's provider sections, and mcpp's run environment comes from the runtime binding. The documentation described an effect that did not occur, which is why this is an error rather than a warning. `mcpp emit xpkg` writes `xpm..deps`. Nothing emitted it before, so a package declaring an environment had its install-time edge written by hand — which is how riscv-virt-rt 0.3.0 shipped without the C library its own target row named. The declaration is kept unresolved per platform for it, since a descriptor has a block per platform and the host resolution has discarded two. Documented: the three-row inheritance rule, which has always held and which mcpp stated nowhere — no `subos` means the machine's environment with the project's entries over it, a named one means isolation, and an `xlings use` inside the project outranks both. Verified: e2e 331 asserts on the addresses the provisioning pass names with auto-install off, so every criterion runs on every shard with no network; it fails at its first assertion on 2026.9.2.1. Unit: test_manifest +8. Full unit suite 97 passed; e2e 06, 93, 130, 131, 132, 134 re-run. --- ...09-03-xlings-workspace-as-the-one-table.md | 126 ++++----- CHANGELOG.md | 48 ++++ docs/05-mcpp-toml.md | 117 ++++---- docs/17-the-project-environment.md | 20 ++ docs/zh/05-mcpp-toml.md | 89 +++--- docs/zh/17-the-project-environment.md | 17 ++ mcpp.toml | 2 +- modules/manifest/src/toml.cppm | 255 ++++++++++++++---- modules/manifest/src/types.cppm | 60 +++-- modules/versioning/src/version.cppm | 2 +- src/build/prepare.cppm | 2 - src/pm/publisher.cppm | 39 ++- src/xlings/xlings.cppm | 6 +- tests/e2e/331_xlings_workspace_one_table.sh | 88 ++++++ tests/unit/test_manifest.cpp | 153 ++++++++++- 15 files changed, 782 insertions(+), 242 deletions(-) create mode 100755 tests/e2e/331_xlings_workspace_one_table.sh diff --git a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md index 6b290b41..4de891db 100644 --- a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md +++ b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md @@ -21,10 +21,11 @@ Three changes follow, and one of them is a defect rather than a design. 1. **`workspace` becomes the one table**; `deps` is retired from the manifest. The two state the same thing and differ only in what mcpp then does with the statement, and the mechanism they map onto has one authored surface. -2. **Provisioning moves into project scope.** mcpp calls xlings with a global - environment today, so the install writes into a workspace shared by every - mcpp project while programs resolve through the project's own layers. That - one argument is behind three measured symptoms. +2. ~~Provisioning moves into project scope.~~ **Withdrawn during + implementation** (§3): the call site records a measurement showing that + project scope puts payloads in a SubOS the compiler's `--sysroot` does not + name. The scope stays global; what the declaration decides is unchanged, + because resolution reads the project's `workspace` layer either way. 3. **`envs` is retired.** It has no reader in xlings and none in mcpp, and `docs/05` documents an effect it does not have. @@ -201,78 +202,51 @@ silence. `[xlings]` has no unknown-key sweep — no `kKnownXlings` list exists i `toml.cppm` — so a removed key would be read by nobody and reported by nobody, which is the shape #531 exists to prevent. -## 3. Change two: provisioning runs in project scope - -### 3.1 The defect - -```cpp -mcpp::xlings::call(mcpp::config::make_xlings_env(**cfg2), "install_packages", …) -``` - -`prepare.cppm:3421`. `make_xlings_env` carries no `projectDir` -(`src/config.cpp:129`), so no `XLINGS_PROJECT_DIR` reaches the child and the -cwd walk finds nothing. The child runs in **global** scope against mcpp's -registry home, and three symptoms follow: - -1. **The install's view and the shim's view disagree.** Records land in the - registry's global workspace; a program invoked from the project resolves - through the project's layers. This is the - `qemu-aarch64-static is not installed in this subos (_)` measured during the - 2026.9.2.1 verification, and the reason the runner needed a - payload-directory lookup. -2. **`active` is read from a workspace shared by every mcpp project** on the - machine, so the auto-activation decides against a state no project owns. -3. **Forcing the switch there would let two checkouts flip each other**, which - is why the decline is protective in this scope rather than obstructive. - -### 3.2 The fix, and why nothing else is needed - -Call with `make_project_xlings_env(cfg, root)`. The child then runs in project -scope: the installed set and the activation records land in the project's own -SubOS, and the view the install writes is the view the shim reads. - -**No flag, and no explicit switch.** With the declaration materialised into the -project file's `workspace`, §1's merge already makes it authoritative — -Anonymous puts the project layer over the global one, Named does not merge the -global one at all. `activate_requested_targets` does not fire in that situation -and should not: the merged view already answers with the declared version. - -An earlier draft proposed `useAfterInstall: true` and an explicit `use_version` -per entry. Both are withdrawn (§15). - -**One precondition, checked.** The scope fix works only if xlings accepts -`/.mcpp` as a project directory, and §1's discovery rule rejects any -directory holding a `subos/`. It does not fire here: a project's SubOS lives at -`/.xlings/subos/`, so what would have to exist is -`/.mcpp/subos`, and nothing creates it — measured on -`mcpplibs/riscv-virt-rt/.mcpp/`, which holds only `.xlings.json`. It is worth -an assertion rather than a memory: anything that later writes a bare `subos/` -under `.mcpp` would make mcpp's own project file invisible to xlings, silently -and everywhere at once. - -### 3.3 Provisioning otherwise keeps its contract - -- The result is read, not assumed: `!called || childRc != 0` - (`prepare.cppm:3448`). -- A failure is a hard build error naming the manual command. -- The stamp is written only on success and keyed on the hash of the declared - set, so the merged table re-provisions once per project after the upgrade and - the common build performs nothing. -- `MCPP_OFFLINE` and `MCPP_NO_AUTO_INSTALL` gate the install action, not the - block. - -### 3.4 Two consequences to write down - -**The project SubOS layer outranks the project manifest.** It merges last, so -an explicit `xlings use` inside the project beats what `mcpp.toml` declares -until the manifest is re-materialised. Defensible — an action a person took -beats a file — but not obvious. - -**A declared version that was never installed already fails with the right -words**: `"{}@{} is the version this project asks for"` -(`src/core/xvm/shim.cpp:495`). Provisioning is what prevents it; that -diagnostic is what happens when provisioning was skipped, and it names the -project rather than the machine. +## 3. Change two, withdrawn during implementation: the scope stays global + +The design proposed moving the provisioning call from `make_xlings_env` to +`make_project_xlings_env`, on the reasoning that the install should write where +the shim reads. Implementing it turned up a comment at the call site recording +that this was tried and measured: + +> GLOBAL scope, and the scope is the whole point. The obvious alternative — +> `install_packages` against `make_project_xlings_env` — installs at PROJECT +> scope, and that measurably does not work: on a fresh `MCPP_HOME` the headers +> land in `/.mcpp/.xlings/subos/_/usr/include` while `--sysroot` names +> `/registry/subos/default`, so `#include ` still failed with +> the dependency installed and declared. Two SubOS views, and the payload in +> the one the compiler does not read. + +The premise the design rested on — that the install destination is chosen by +package scope rather than by transport — is contradicted by that measurement. +**Change two is withdrawn.** The call keeps `make_xlings_env`. + +### 3.1 What that leaves true, and what it leaves unsolved + +**The declaration still wins at resolution.** mcpp materialises +`[xlings.workspace]` into the project file's `workspace` object, and that is a +layer `merged_workspace` applies over the machine's (§1). So a project +declaring a version resolves to it inside the project regardless of where the +payload was installed. Provisioning only has to make the payload exist, which +global scope does. + +**Two views still disagree about the installed set.** A payload installed into +the registry is not in the project SubOS's `installed[]`, so a shim invoked in +the project can resolve the declared version and still report +`… is not installed in this subos (_)`. That is the line the 2026.9.2.1 +verification recorded, and the reason the runner resolves a program through the +declared payload's `bin/` before consulting `PATH`. + +That mismatch is real and is not addressed here. It is a question about which +environment mcpp's own `--sysroot` names — the registry SubOS today — and +answering it means changing where mcpp points the compiler, not where it points +an install. That is a larger change than this document, and the runner lookup +is a working compensation for its user-visible half. + +**What is not done, and why it is not a gap in this change:** nothing in the +merged table depends on the scope. `deps` and `workspace` are two projections +of one entry either way, and the entry provisions and resolves exactly as it +did before. ## 4. Change three: `envs` is retired diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ce4e6ec..a3a94ec8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,54 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [2026.9.3.1] — 2026-09-03 + +`[xlings]` 收敛成一张表。`[xlings.workspace]` 说出工程用哪个包、用哪个版本,mcpp +既供给它也把它物化成解析用的钉;`deps` 被取代;`envs` 移除;发布出去的描述符第一次 +带上安装期的边。 + +设计与全部实测见 +[`.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md`](.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md)。 + +> **一般形态比它自己的简写弱。** `docs/05` §2.13 写着 `[toolchain]` 是编译器那一项 +> 的便捷写法、`[xlings.workspace]` 是一般形态;而 `[toolchain]` 会装 +> (`resolve_xpkg_path(…, autoInstall=…)`),一般形态什么都不装。两个键说的是同一件 +> 事,差别只在 mcpp 拿这句话去做什么 —— 而它们对应的那套机制,书写面只有一个。 + +### 变更 + +- **`[xlings.workspace]` 成为唯一的表。** 一条条目产出两个投影:一个安装地址 + (`[:][@]`,供给用)与一份解析钉(`[:]`,写进 + `.xlings.json` 的 `workspace` 对象)。命名空间写在键上或版本上都接受—— + `"xim:picolibc-riscv" = "1.8.12"` 与 `picolibc-riscv = "xim:1.8.12"` 是同一条, + 写在键上必须带引号(TOML 裸键不能含冒号);两半都写且不一致是错误。`""` 表示 + 「存在即可,版本不限」,这是手写项目文件里已经在用的拼法。平台键取 xlings 自己的 + `linux` / `macosx` / `windows` / `default`,`macos` 作为别名保留。 + +- **`deps` 被取代,但仍然生效并被报告。** 报告里给出该写的那一行。**不拒绝**——拒绝 + 会落到依赖的 manifest 上,而钉了那个包精确版本的工程改不了它。同一个包在两张表里 + 给出两个版本则是硬错误:两者按顺序供给而后者赢得钉,接受它等于装一个、解析另一个。 + +- **`[xlings.envs]` 移除。** 它曾被物化进 `.xlings.json` 而没有任何东西读它: + xlings 里两处 `envs` 分别属于某个程序的 shim 记录和某个 SubOS 的 provider 段, + 都不是这个形状;mcpp 自己给程序的运行环境来自 runtime binding。现在这个键是错误 + ——**一个什么都不做的键,在有东西声称它有效果时更坏**。 + +- **发布的描述符带上 `xpm..deps`。** 此前 `mcpp emit xpkg` 一个字都不写, + 安装期的边只能手写进 `xpkg.lua` —— `riscv-virt-rt` 0.3.0 因此发出去时没有它自己 + 目标行指名的 C 库。声明按**未解析**的形态保留在 `workspaceByPlatform` 里,因为 + 描述符每个平台一块,而按本机解析已经丢掉了另外两个。 + +- **`[xlings]` 的三档继承写进文档。** 不写 `subos` 就是机器的环境加上工程自己的条目; + 写了 `subos` 就是隔离,机器那层不参与;工程内执行的 `xlings use` 压过两者。这条 + 行为一直如此,而 mcpp 此前一字未提。 + +### 行为变化 + +- `[xlings.envs]` 从被忽略变成硬错误。索引里没有任何包用过它。 +- `[xlings] deps` 仍然生效,但会打印一条指出替代写法的警告;`--strict` 下它是错误。 +- 同一个包在 `deps` 与 `[xlings.workspace]` 里给出不同版本,现在被拒绝。 + ## [2026.9.2.1] — 2026-09-02 `[target.].runner` 对每一个目标生效,启动失败不再无声,`mcpp test` 把跑不起来 diff --git a/docs/05-mcpp-toml.md b/docs/05-mcpp-toml.md index 39fbc217..61545806 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -1866,63 +1866,80 @@ built" means is the same question `--target` answers (docs/08 §7.4). Both are warnings, never errors: coverage is release discipline, and the person who can judge it is looking at the release, not at this build. -### 2.13 `[xlings]` — Build Environment +### 2.13 `[xlings]` — the project's environment ```toml -[xlings] -deps = ["make@4.4", "cmake@3.28", "python@3.13"] # host build-tools to provision -subos = "dev" # a named per-project sandbox - -[xlings.workspace] # pin tool versions (general form of [toolchain]) -clang = "20.1.7" +[xlings.workspace] # what this project's environment contains +cmake = "3.28" +picolibc-riscv = "xim:1.8.12" # namespace on the version +"xim:qemu-user-aarch64" = "7.2.0" # or on the key - quotes required +code = "" # present; version unconstrained +llvm = { macosx = "20", default = "22" } -[xlings.envs] # env vars applied to the tool environment -OPENBLAS_NUM_THREADS = "1" +subos = "dev" # a named, isolated environment ``` -Declares the project's **build environment**, provisioned through xlings (which mcpp -is built on). The subsection names mirror xlings' own `.xlings.json` schema **1:1**, so -mcpp materializes them verbatim into `/.mcpp/.xlings.json` (no translation -layer): `deps` (host build-tools), `[xlings.workspace]` (tool→version pins), -`subos` (a named sandbox), `[xlings.envs]` (env vars). Use it to declare host tools a -build needs (`make`/`cmake`/`protoc`/…), pin tool versions per project, or set -build-time env vars — without hand-editing `.xlings.json`. `[toolchain]` (§2.7) remains -the ergonomic shorthand for the compiler; `[xlings.workspace]` is the general form. +`[xlings]` is mcpp's surface for **xlings' local project mechanism**: the +project `.xlings.json` that gives a directory its own environment. The +subsection names and their meanings are that file's, and mcpp materializes them +into `/.mcpp/.xlings.json` with no translation layer. -**Values per host platform (2026.9.2.1+).** A `deps` entry and a -`[xlings.workspace]` value may be a table keyed by platform, the form xlings' -own `.xlings.json` accepts for `workspace`: +**`[xlings.workspace]` is the one table.** An entry names a package and the +version this project uses it at. mcpp provisions it — installing it when the +machine does not have it, mapping it when it does — and materializes it as a +resolution pin, so the version the project named is the one its tools resolve +to. -```toml -[xlings] -deps = ["xim:ninja", { linux = "qemu-user-aarch64" }, { windows = "nasm", default = "yasm" }] - -[xlings.workspace] -gcc = { linux = "15.1.0" } -llvm = { macos = "20", default = "22" } -``` - -The keys are `linux`, `macos`, `windows` and `default`; `macosx` is accepted as -xlings' spelling of `macos`. mcpp resolves the table against the host it runs -on when the manifest is loaded: the host's key wins, `default` is the fallback, -and a table with neither declares nothing on that host — the entry is absent, -not empty. An unknown key is an error rather than a dropped entry. The axis is -the host operating system only; a package that exists for the OS but not for -the architecture is still a provisioning error on that host. - -`subos` selects the root project's **local build/run OS environment**. If the -key is absent, mcpp uses its initialized, release-verified `McppDefault` SubOS; -`subos = "default"` is an explicit `NamedSubos("default")` selection. There is -no CLI/environment override and no implicit following of xlings active/current. - -On Linux the selected environment also fixes the loader/libc contract, so -`subos = "el8"` and `subos = "trixie"` can coexist and produce separately -fingerprinted objects. A workspace root overrides member declarations during a -workspace build. Dependency/member SubOS declarations are non-transitive: a -library's declaration applies when it is an independent root, not when its -sources are consumed by another root. A missing named SubOS or missing/ -incompatible runtime contract is an error, never a fallback. See -docs/08-toolchain-internals.md §2.1. +#### Writing an entry + +| Form | Means | +|---|---| +| `cmake = "3.28"` | that version | +| `llvm = "22"` | the highest installed `22.*`; a version prefix resolves | +| `code = ""` | present, version unconstrained | +| `picolibc-riscv = "xim:1.8.12"` | from the `xim` index | +| `"xim:picolibc-riscv" = "1.8.12"` | the same entry, namespace written on the key | +| `llvm = { macosx = "20", default = "22" }` | per host platform | + +The namespace may be written on either half. Writing it on the key requires +**quotes**, because a TOML bare key cannot contain a colon. Writing it on both +halves with different values is an error, and so is naming one package twice +under two spellings. + +Platform keys are xlings' own — `linux`, `macosx`, `windows` — plus `default`; +`macos` is accepted as an alias. A table with no key for this host and no +`default` declares nothing here. + +#### Which version a tool the project did not name resolves to + +| The project declares | The version comes from | +|---|---| +| `[xlings.workspace]`, no `subos` | the machine's environment, with the project's own entries laid over it | +| `[xlings.workspace]` and `subos = ""` | that environment's own workspace; the machine's does not apply | +| neither | the machine's environment | + +The middle row is not an omission. A named environment has its own installed +set, and carrying the machine's versions into it would name versions that are +not there. Naming one is how a project asks for isolation; leaving it out is +how it asks for the machine's environment with its own entries on top. + +An `xlings use` performed inside the project outranks this table until mcpp +rewrites the environment, because it is the layer merged last. + +#### `deps`, superseded + +`deps = ["xim:qemu-riscv@9.2.4-1"]` is the pre-2026.9.3 spelling of the same +statement. It is still honoured and is reported once, with the +`[xlings.workspace]` line to write instead. It is not refused, because a +refusal would reach a *dependency's* manifest, which a project that pinned an +exact version of that package cannot edit. + +#### `envs`, removed + +`[xlings.envs]` was materialized into `.xlings.json` and read by nothing: a +program's environment is declared by its own package, and an environment's by +that environment. The key is now an error naming both. Nothing in the index +used it. ### 2.14 Host tools from a dependency (mcpp 2026.8.5.1+) diff --git a/docs/17-the-project-environment.md b/docs/17-the-project-environment.md index e0b05330..e36e13b6 100644 --- a/docs/17-the-project-environment.md +++ b/docs/17-the-project-environment.md @@ -63,6 +63,26 @@ Declaring it is what puts it there. the declared environment the default answer; everything else stays reachable behind it. +### 2.1 Which version pins apply (2026.9.3+) + +Naming an environment also changes where a tool's version comes from. A +project's own `[xlings.workspace]` entries always win; what differs is what +they are laid over: + +| The project declares | The version of a tool it did not name comes from | +|---|---| +| `[xlings.workspace]`, no `subos` | the machine's environment | +| `[xlings.workspace]` and `subos = ""` | that environment's own workspace; the machine's does not apply | + +The second row is what isolation means. A named environment has its own +installed set, and carrying the machine's versions into it would name versions +that are not there — so a project that relied on the machine's tools has to +declare them once it names an environment. + +An `xlings use` performed inside the project outranks both, until mcpp rewrites +the environment: it is the layer merged last, and an action a person took +should beat a file. + ## 3. What the declaration does not decide `[xlings] deps` names packages to be present in the environment, and each one's diff --git a/docs/zh/05-mcpp-toml.md b/docs/zh/05-mcpp-toml.md index fa6125e9..444163df 100644 --- a/docs/zh/05-mcpp-toml.md +++ b/docs/zh/05-mcpp-toml.md @@ -1591,54 +1591,69 @@ platforms = ["linux", "macos", "windows"] 两者都只是 warning,绝不报错:覆盖度属于发布纪律,而能作判断的人看的是发布, 不是这一次构建。 -### 2.13 `[xlings]` — 构建环境 +### 2.13 `[xlings]` — 工程的环境 ```toml -[xlings] -deps = ["make@4.4", "cmake@3.28", "python@3.13"] # 要供给的 host 构建工具 -subos = "dev" # 命名的项目级沙箱 - -[xlings.workspace] # 固定工具版本([toolchain] 的通用形式) -clang = "20.1.7" +[xlings.workspace] # 这个工程的环境里有什么 +cmake = "3.28" +picolibc-riscv = "xim:1.8.12" # 命名空间写在版本上 +"xim:qemu-user-aarch64" = "7.2.0" # 或写在键上 —— 必须带引号 +code = "" # 存在即可,版本不限 +llvm = { macosx = "20", default = "22" } -[xlings.envs] # 应用到工具环境的环境变量 -OPENBLAS_NUM_THREADS = "1" +subos = "dev" # 指名的隔离环境 ``` -声明项目的**构建环境**,经 xlings(mcpp 的底座)供给。子段名与 xlings 自身的 -`.xlings.json` schema **1:1** 对齐,因此 mcpp 把它们**原样**物化进 -`<项目>/.mcpp/.xlings.json`(无翻译层):`deps`(host 构建工具)、`[xlings.workspace]` -(工具→版本固定)、`subos`(命名沙箱)、`[xlings.envs]`(环境变量)。用它声明构建所需的 -host 工具(`make`/`cmake`/`protoc`…)、按项目固定工具版本、或设构建期环境变量——无需手改 -`.xlings.json`。`[toolchain]`(§2.7)仍是编译器的便捷简写;`[xlings.workspace]` 是其通用形式。 +`[xlings]` 是 mcpp 对 **xlings local project 机制**的书写面:让一个目录拥有自己 +环境的那份项目 `.xlings.json`。子段名与含义都是那份文件的,mcpp 原样物化进 +`/.mcpp/.xlings.json`,没有翻译层。 -**按宿主平台取值(2026.9.2.1+)。** `deps` 的一个条目与 `[xlings.workspace]` 的一个值可以是 -按平台为键的表,即 xlings 自身 `.xlings.json` 对 `workspace` 接受的形式: +**`[xlings.workspace]` 是唯一的表。** 一条条目写出工程用哪个包、用哪个版本。 +mcpp 既供给它——机器上没有就装,有就映射——也把它物化成解析用的钉,于是工程写下 +的版本就是它的工具解析到的版本。 -```toml -[xlings] -deps = ["xim:ninja", { linux = "qemu-user-aarch64" }, { windows = "nasm", default = "yasm" }] +#### 条目的形式 -[xlings.workspace] -gcc = { linux = "15.1.0" } -llvm = { macos = "20", default = "22" } -``` +| 形式 | 含义 | +|---|---| +| `cmake = "3.28"` | 该版本 | +| `llvm = "22"` | 已装的最高 `22.*`;版本前缀会被解析 | +| `code = ""` | 存在即可,版本不限 | +| `picolibc-riscv = "xim:1.8.12"` | 来自 `xim` 索引 | +| `"xim:picolibc-riscv" = "1.8.12"` | 同一条,命名空间写在键上 | +| `llvm = { macosx = "20", default = "22" }` | 按宿主平台 | + +命名空间写在哪一半都可以。写在键上**必须带引号**,因为 TOML 的裸键不能含冒号。 +两半都写且不一致是错误;同一个包用两种拼法出现两次也是错误。 + +平台键是 xlings 自己的 —— `linux`、`macosx`、`windows`,外加 `default`;`macos` +作为别名接受。表里既没有本机这一项也没有 `default`,就表示在这里什么都不声明。 + +#### 工程没点名的工具,其版本的来源 + +| 工程声明了 | 版本来自 | +|---|---| +| `[xlings.workspace]`,无 `subos` | 机器的环境,工程自己的条目叠在上面 | +| `[xlings.workspace]` 与 `subos = "<名>"` | 那个环境自己的 workspace;机器的不适用 | +| 两者都没有 | 机器的环境 | + +中间那行不是遗漏。指名的环境有自己的已安装集合,把机器的版本带进去会指向那里不 +存在的版本。**写 subos 就是要隔离,不写就是要机器的环境加上自己的条目。** + +在工程内执行的 `xlings use` 压过这张表,直到 mcpp 重写环境为止——它是最后合并的 +那一层。 + +#### `deps`,已被取代 -键为 `linux`、`macos`、`windows` 与 `default`;`macosx` 作为 xlings 对 `macos` 的拼写也被 -接受。mcpp 在加载清单时按运行它的宿主解析这张表:宿主对应的键优先,`default` 兜底,两者 -都没有时该条目在本宿主上不作声明 —— 是缺席,不是空值。未知的键是错误,不是被丢弃的条目。 -这条轴只到宿主操作系统:一个包存在于该 OS 但不存在于该架构时,在那台宿主上仍是 -provisioning 错误。 +`deps = ["xim:qemu-riscv@9.2.4-1"]` 是同一句话在 2026.9.3 之前的拼法。它仍然生效, +并且会被报告一次,同时给出该写的 `[xlings.workspace]` 那一行。**不拒绝**——拒绝会 +落到**依赖**的 manifest 上,而钉了那个包精确版本的工程改不了它。 -`subos` 选择根项目用于 build/run 的**本地开发 OS 环境**。未声明该键时固定使用 mcpp 已初始化、 -经 release 验证的 `McppDefault`;`subos = "default"` 则仍是显式的 -`NamedSubos("default")`。没有 CLI/环境变量 override,也不会隐式跟随 xlings active/current。 +#### `envs`,已移除 -在 Linux 上,所选环境同时固定 loader/libc contract,所以 `el8`、`trixie` 可在同一机器共存, -并进入不同构建指纹。workspace 整体构建时由 workspace root 覆盖 member 声明;member/依赖中的 -SubOS 不传递——库只有作为独立 root 开发时才使用自己的声明,作为别人的源码依赖时使用消费者 -root 的环境。指定的命名 SubOS 不存在、缺少或使用不兼容 runtime contract 都会直接报错,不会 -回退 default/active/编译器烙入状态。参见 docs/08-toolchain-internals.md §2.1。 +`[xlings.envs]` 曾被物化进 `.xlings.json`,而没有任何东西读它:程序的环境由它自己 +的包声明,环境的环境由那个环境声明。现在这个键是错误,并同时点名这两者。索引里没有 +任何包用过它。 ### 2.14 依赖产出的 host 工具(mcpp 2026.8.5.1+) diff --git a/docs/zh/17-the-project-environment.md b/docs/zh/17-the-project-environment.md index 3964f4ce..42eff23c 100644 --- a/docs/zh/17-the-project-environment.md +++ b/docs/zh/17-the-project-environment.md @@ -53,6 +53,23 @@ PATH=<被声明环境的 bin>: ⚠️ **前置而非替换。** 构建程序理应会调 `git`、`python3` 或 shell,这些都不在 SubOS 里。前置让被声明的环境成为默认答案;其余的仍在它后面可达。 +### 2.1 哪些版本钉生效(2026.9.3+) + +指名一个环境,同时改变了工具的版本从哪来。工程自己 `[xlings.workspace]` 里的条目 +总是胜出;不同的是它们叠在什么之上: + +| 工程声明了 | 它没点名的工具,版本来自 | +|---|---| +| `[xlings.workspace]`,无 `subos` | 机器的环境 | +| `[xlings.workspace]` 与 `subos = "<名>"` | 那个环境自己的 workspace;机器的不适用 | + +第二行就是隔离的含义。指名的环境有自己的已安装集合,把机器的版本带进去会指向那里 +不存在的版本 —— 所以原本依赖「机器上装了就能用」的工程,一旦指名环境,就必须把用到 +的都声明出来。 + +在工程内执行的 `xlings use` 压过这两者,直到 mcpp 重写环境为止:它是最后合并的那 +一层,而人做出的动作应当压过一份文件。 + ## 3. 这条声明不决定什么 `[xlings] deps` 声明的是「环境里要有哪些包」,而每个包的载荷目录另有通道交付, diff --git a/mcpp.toml b/mcpp.toml index 9a527ee7..36d9df6b 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "2026.9.2.1" +version = "2026.9.3.1" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index 8226ed59..3f221d66 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -100,55 +100,129 @@ struct LoadContext { bool insideWorkspace = false; }; -// ─── `[xlings]` values per host platform (#544, D7) ───────────────────────── +// ─── `[xlings]`: mcpp's surface for xlings' local project mechanism ───────── // -// xlings' own `.xlings.json` lets a `workspace` value be an object keyed by -// platform — `{ "linux": "15.1.0", "default": "22" }` — and resolves it -// against the host it runs on: the host's key wins, `default` is the -// fallback, and no match at all means the entry is absent on that host. -// `[xlings]` claims to mirror that file 1:1, but its parser accepted only -// strings and dropped a table value in silence. `deps` has no xlings-side -// reader (mcpp reads the list and calls `install_packages` itself), so its -// per-platform form is mcpp's to define, and it takes the same one: an entry -// is a string or a `{ = "" }` table. +// `[xlings]` is not a schema of mcpp's own. It is what a project writes into +// xlings' project `.xlings.json`, and every rule below is that file's rule. // -// Resolved HERE, at load, against this host. `[xlings]` describes the -// environment of the machine mcpp runs on, so the host is the right axis, and -// resolving once keeps every downstream reader — the provisioning pass, its -// stamp, the build-program hand-off — on the flat list it already reads. +// A value is a string or an object keyed by platform, and xlings resolves the +// object against the host it runs on: the host's key wins, `default` is the +// fallback, and no match with no default means the entry is absent there +// (`resolve_platform_workspace_value_`, xlings `src/core/xvm/db.cppm`). The +// keys are xlings' own OS names — `linux`, `macosx`, `windows` — plus +// `default`; `macos` is accepted as an alias because mcpp spells it that way +// elsewhere. An unknown key is an error rather than a dropped entry: a +// mis-typed platform that silently declared nothing is the shape #531 was +// filed for. // -// The keys are mcpp's OS names, `linux` / `macos` / `windows`, plus `default`; -// `macosx` is accepted as xlings' own spelling of `macos`. An unknown key is a -// hard error, not a dropped entry: a typo'd platform that silently declared -// nothing is the shape #531 was filed for. +// Resolved for THIS host at load, so every downstream reader stays on a flat +// list. The unresolved declaration is kept beside it in +// `XlingsConfig::workspaceByPlatform`, because the descriptor emitter needs +// every platform at once and cannot re-derive what was already collapsed. inline std::string_view host_platform_key() { if constexpr (mcpp::platform::is_windows) return "windows"; - else if constexpr (mcpp::platform::is_macos) return "macos"; + else if constexpr (mcpp::platform::is_macos) return "macosx"; else return "linux"; } -inline std::expected, std::string> -resolve_host_value(const mcpp::libs::toml::Value& v, std::string_view host) { - if (v.is_string()) return std::optional{v.as_string()}; +// The three platforms a descriptor has a block for, in xlings' spelling. +inline constexpr std::string_view kXlingsPlatforms[] = {"linux", "macosx", "windows"}; + +// Split `:` on the FIRST colon. xlings writes a namespace this +// way on a version (`"mcpp": "xim:2026.8.30.2"` in a real subos file) and mcpp +// additionally accepts it on the key, so one splitter serves both halves. +inline std::pair split_scope(std::string_view s) { + auto pos = s.find(':'); + if (pos == std::string_view::npos) return {"", std::string(s)}; + return {std::string(s.substr(0, pos)), std::string(s.substr(pos + 1))}; +} + +// Every platform a value speaks for. A plain string yields the single key +// `"*"`, meaning "on every platform"; an object yields its own keys, canonical +// (`macos` folded to `macosx`), with `default` kept as itself. +inline std::expected>, std::string> +platform_values(const mcpp::libs::toml::Value& v) { + std::vector> out; + if (v.is_string()) { out.emplace_back("*", v.as_string()); return out; } if (!v.is_table()) return std::unexpected(std::string( "expected a string or a { = \"...\" } table")); - static constexpr std::string_view kKnown[] = { - "linux", "macos", "macosx", "windows", "default", - }; - std::optional chosen, fallback; for (auto& [k, val] : v.as_table()) { - if (std::ranges::find(kKnown, k) == std::ranges::end(kKnown)) + std::string canon = (k == "macos") ? "macosx" : k; + const bool known = canon == "default" + || std::ranges::find(kXlingsPlatforms, canon) != std::ranges::end(kXlingsPlatforms); + if (!known) return std::unexpected(std::format( - "unknown platform key '{}'; expected one of linux, macos, windows, default", k)); + "unknown platform key '{}'; expected one of linux, macosx, windows, default", k)); if (!val.is_string()) return std::unexpected(std::format("platform key '{}' must be a string", k)); - const std::string_view canon = (k == "macosx") ? "macos" : std::string_view(k); - if (canon == host) chosen = val.as_string(); - else if (canon == "default") fallback = val.as_string(); + out.emplace_back(std::move(canon), val.as_string()); } - if (chosen) return chosen; - return fallback; // may be nullopt: not declared on this host + return out; +} + +// The value that applies on `platform`, or nullopt when the entry is absent +// there. `"*"` outranks nothing: a plain string is the whole answer. +inline std::optional +value_for_platform(const std::vector>& vals, + std::string_view platform) { + // The alias is folded on BOTH sides: a caller may name the host `macos` + // (mcpp's spelling elsewhere) while the stored key is canonical. + const std::string_view want = (platform == "macos") ? "macosx" : platform; + auto pick = [&](std::string_view k) -> std::optional { + for (auto const& [key, v] : vals) if (key == k) return v; + return std::nullopt; + }; + if (auto v = pick("*")) return v; + if (auto v = pick(want)) return v; + return pick("default"); +} + +// Retained so the pre-#544 spelling of a `deps` entry keeps parsing while the +// key is deprecated. +inline std::expected, std::string> +resolve_host_value(const mcpp::libs::toml::Value& v, std::string_view host) { + auto vals = platform_values(v); + if (!vals) return std::unexpected(vals.error()); + return value_for_platform(*vals, host); +} + +// One `[xlings.workspace]` entry, normalised. +// +// `target` is the xvm target the shim looks up and the key the file carries; +// `ns` is the index namespace, which qualifies where a version comes from and +// may be written on either half; `version` is empty when the entry asks only +// for presence, which is what `""` means in an authored project file. +struct XlingsEntry { + std::string ns, target, version; + // `[:][@]` — what `install_packages` is asked for. + std::string address() const { + std::string a = ns.empty() ? target : ns + ":" + target; + if (!version.empty()) a += "@" + version; + return a; + } + // `[:]` — what the file's `workspace` object carries. A scope + // qualifies a version, so with no version there is nothing to qualify. + std::string pin() const { + if (version.empty()) return {}; + return ns.empty() ? version : ns + ":" + version; + } +}; + +// Combine the two halves a namespace may be written on. Both may carry it; +// disagreeing is an error rather than a precedence rule, because a precedence +// rule would make one of the two spellings silently ineffective. +inline std::expected +make_xlings_entry(std::string_view key, std::string_view value) { + auto [keyNs, target] = split_scope(key); + auto [valNs, version] = split_scope(value); + if (target.empty()) + return std::unexpected(std::string("names no package")); + if (!keyNs.empty() && !valNs.empty() && keyNs != valNs) + return std::unexpected(std::format( + "namespace '{}' on the key and '{}' on the version disagree; " + "write it once", keyNs, valNs)); + return XlingsEntry{ keyNs.empty() ? valNs : keyNs, target, version }; } std::expected parse_string(std::string_view content, @@ -1404,33 +1478,110 @@ std::expected parse_string(std::string_view content, m.buildConfig.dependencyLinkage = *v; } - // [xlings] — build environment (L-1). Subsections mirror .xlings.json 1:1, - // including its per-platform value form, resolved here for this host - // (see resolve_host_value above). + // [xlings] — the project's environment, in the vocabulary of xlings' local + // project mechanism. `[xlings.workspace]` is the one table: an entry names + // a package the project uses and the version it uses it at, and mcpp both + // provisions it and materialises it as a resolution pin. + if (auto* wt = doc->get_table("xlings.workspace")) { + // target → the spelling it was written under, so a duplicate can name + // both rather than pick one. + std::map writtenAs; + for (auto& [k, val] : *wt) { + auto vals = platform_values(val); + if (!vals) return std::unexpected(error(origin, + std::format("[xlings.workspace] {}: {}", k, vals.error()))); + // Every platform, for the descriptor emitter. Resolved per + // platform rather than stored raw: the emitter wants the answer, + // and `default` is part of producing it. + for (auto plat : kXlingsPlatforms) { + auto v = value_for_platform(*vals, plat); + if (!v) continue; + auto e = make_xlings_entry(k, *v); + if (!e) return std::unexpected(error(origin, + std::format("[xlings.workspace] {}: {}", k, e.error()))); + m.xlings.workspaceByPlatform[std::string(plat)].push_back(e->address()); + } + auto hostValue = value_for_platform(*vals, host_platform_key()); + if (!hostValue) continue; // not declared on this host + auto entry = make_xlings_entry(k, *hostValue); + if (!entry) return std::unexpected(error(origin, + std::format("[xlings.workspace] {}: {}", k, entry.error()))); + if (auto prev = writtenAs.find(entry->target); prev != writtenAs.end()) + return std::unexpected(error(origin, std::format( + "[xlings.workspace] names '{}' twice, as '{}' and as '{}'; " + "write it once", entry->target, prev->second, k))); + writtenAs.emplace(entry->target, k); + m.xlings.workspace[entry->target] = entry->pin(); + m.xlings.deps.push_back(entry->address()); + } + } + if (doc->get("xlings.subos")) { + m.xlings.subosDeclared = true; + if (auto v = doc->get_string("xlings.subos")) m.xlings.subos = *v; + } + // `deps` is the pre-2026.9.3 spelling of the same statement. It is still + // honoured, and reported: refusing it would reach a DEPENDENCY's manifest, + // and a consumer that pinned an exact version of that package cannot edit + // it. See .agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md §2.4. if (auto* arr = doc->get("xlings.deps"); arr && arr->is_array()) { std::size_t i = 0; + std::string replacement; for (auto& el : arr->as_array()) { auto r = resolve_host_value(el, host_platform_key()); if (!r) return std::unexpected(error(origin, std::format("[xlings] deps[{}]: {}", i, r.error()))); - if (*r) m.xlings.deps.push_back(**r); ++i; - } - } - if (doc->get("xlings.subos")) { - m.xlings.subosDeclared = true; - if (auto v = doc->get_string("xlings.subos")) m.xlings.subos = *v; + if (!*r) continue; + // One package in both tables with two versions is refused. The + // two are provisioned in list order and the LAST one wins the + // pin, so accepting it would install one version and resolve the + // other — the drift this release exists to remove, arrived at + // through the compatibility path. + { + auto at2 = (*r)->find('@'); + auto addr2 = at2 == std::string::npos ? **r : (*r)->substr(0, at2); + auto ver2 = at2 == std::string::npos ? std::string{} : (*r)->substr(at2 + 1); + auto [ns2, target2] = split_scope(addr2); + if (auto it2 = m.xlings.workspace.find(target2); + it2 != m.xlings.workspace.end()) { + auto [pinNs, pinVer] = split_scope(it2->second); + if (pinVer != ver2) + return std::unexpected(error(origin, std::format( + "'{}' is named in both [xlings] deps (version '{}') " + "and [xlings.workspace] (version '{}'); " + "keep the [xlings.workspace] line and delete the other", + target2, ver2.empty() ? "unconstrained" : ver2, + pinVer.empty() ? "unconstrained" : pinVer))); + } + } + m.xlings.deps.push_back(**r); + // Show the author the line to write. The address form + // `:@` becomes a key and a version. + auto at = (*r)->find('@'); + auto addr = at == std::string::npos ? **r : (*r)->substr(0, at); + auto ver = at == std::string::npos ? std::string{} : (*r)->substr(at + 1); + auto [ns, target] = split_scope(addr); + replacement += std::format("\n {} = \"{}{}\"", + target, ns.empty() ? "" : ns + ":", ver); + } + if (!replacement.empty()) + m.schemaWarnings.push_back(std::format( + "[xlings] deps is superseded by [xlings.workspace] and will stop " + "being read. It is honoured for now. Write instead:\n" + " [xlings.workspace]{}", replacement)); } - if (auto* wt = doc->get_table("xlings.workspace")) - for (auto& [k, val] : *wt) { - auto r = resolve_host_value(val, host_platform_key()); - if (!r) return std::unexpected(error(origin, - std::format("[xlings.workspace] {}: {}", k, r.error()))); - if (*r) m.xlings.workspace[k] = **r; - } - if (auto* et = doc->get_table("xlings.envs")) - for (auto& [k, val] : *et) - if (val.is_string()) m.xlings.envs[k] = val.as_string(); + // `envs` is refused. It was materialised into `.xlings.json` and read by + // nobody: every `envs` consumer in xlings is either a program's own shim + // record or a SubOS's provider sections, and neither is this shape. The + // documentation described an effect that did not occur, which is why this + // is an error rather than a warning — a key that does nothing is worse + // when something claims it does. + if (doc->get("xlings.envs")) + return std::unexpected(error(origin, + "[xlings.envs] is not read by anything and has been removed. It " + "never reached the tool environment:\n" + " a program's environment is declared by its own package, " + "and a SubOS's by that SubOS.")); if (auto v = doc->get_string("build.macos_deployment_target")) m.buildConfig.macosDeploymentTarget = *v; diff --git a/modules/manifest/src/types.cppm b/modules/manifest/src/types.cppm index 86b16e54..b3239060 100644 --- a/modules/manifest/src/types.cppm +++ b/modules/manifest/src/types.cppm @@ -752,30 +752,58 @@ struct RuntimeConfig { LinkIntent linkIntent; }; -// `[xlings]` — the project's build ENVIRONMENT (L-1). The subsection names mirror -// xlings' own `.xlings.json` schema 1:1, so mcpp materializes them verbatim into -// `/.mcpp/.xlings.json` (no translation layer): `deps` (host build-tools -// installed by xlings), `[xlings.workspace]` (tool→version pins, the general form -// of `[toolchain]`), `subos` (a named per-project sandbox), `[xlings.envs]` -// (env vars applied by xvm shims). See +// `[xlings]` — mcpp's manifest surface for xlings' LOCAL PROJECT MECHANISM. +// +// Not a schema of mcpp's own: it is what a project writes into the project +// `.xlings.json` that gives a directory its own environment, and mcpp +// materializes it into `/.mcpp/.xlings.json` verbatim. +// +// `[xlings.workspace]` is the one table an author writes: an entry names a +// package and the version the project uses it at. mcpp both provisions it and +// materializes it as a resolution pin, which are the file's two fields and +// xlings' two consumers. +// +// `deps` is the pre-2026.9.3 spelling of the same statement. It is still +// honoured and is reported, because refusing it would reach a DEPENDENCY's +// manifest that a consumer pinning that package cannot edit. +// +// `envs` is gone: it was materialized here and read by nothing, while the +// documentation described an effect it did not have. +// +// See .agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md, and // .agents/docs/2026-06-29-manifest-environment-and-platform-design.md (L-1). struct XlingsConfig { - // Both lists are already resolved for THIS host: a manifest may write an - // entry or a value as `{ linux = "...", default = "..." }` (the form - // xlings' own `.xlings.json` accepts), and the parser keeps what applies - // here and drops what does not. Readers see a flat list and need no - // platform logic of their own. See `resolve_host_value` in toml.cppm. - std::vector deps; // → .xlings.json "deps" - std::map workspace; // → "workspace" (tool → version) - std::string subos; // → "subos" (named project sandbox) + // The install addresses `[xlings.workspace]` asks for, resolved for THIS + // host: `[:][@]`. Derived rather than authored — the + // provisioning pass, `fillXpkgDirs`, `xlingsDepBinDirs` and the `deps` + // array of the materialised `.xlings.json` all read this, and none of them + // has to know that a namespace may be written on either half of an entry. + std::vector deps; + // The resolution layer, resolved for THIS host: target → `[:]`, + // empty when the entry asked only for presence. Materialised as the file's + // `workspace` object, which is a layer xlings merges over the machine's. + std::map workspace; + // The same declaration for every platform, resolved but not collapsed: + // platform → the install addresses that apply there. Only the descriptor + // emitter reads it, because `xpm..deps` needs all three at once + // and the host resolution above has already discarded two. + // + // ⚠️ A VECTOR, NOT A NESTED MAP, AND NOT A STYLE CHOICE. Spelling this + // `map>` compiles the module and then produces + // a TRUNCATED BMI under GCC 16: consumers fail with + // `failed to read compiled module cluster N: Bad file data` and + // `failed to load pendings for 'std::map'`, pointing at an unrelated file. + // Measured while implementing this. The emitter needs the addresses and + // never the keys, so the inner map bought nothing. + std::map> workspaceByPlatform; + std::string subos; // → .xlings.json "subos" // Presence is semantic: an absent key selects McppDefault, while an // explicitly written `subos = "default"` selects NamedSubos("default"). // A string alone cannot distinguish absence from an invalid empty value. bool subosDeclared = false; - std::map envs; // → "envs" (env var → value) bool empty() const { - return deps.empty() && workspace.empty() && !subosDeclared && envs.empty(); + return deps.empty() && workspace.empty() && !subosDeclared; } }; diff --git a/modules/versioning/src/version.cppm b/modules/versioning/src/version.cppm index d44d591a..3f3f7853 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.2.1"; +inline constexpr std::string_view MCPP_VERSION = "2026.9.3.1"; } // namespace mcpp diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 4382782a..2b3bbdec 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -3196,8 +3196,6 @@ prepare_build(bool print_fingerprint, penv.subos = runtimeOwnerManifest.xlings.subos; for (auto const& [k, v] : runtimeOwnerManifest.xlings.workspace) penv.workspace.emplace_back(k, v); - for (auto const& [k, v] : runtimeOwnerManifest.xlings.envs) - penv.envs.emplace_back(k, v); } if (runtimeSelection.ownerRoot == workRoot) { mcpp::config::ensure_project_index_dir( diff --git a/src/pm/publisher.cppm b/src/pm/publisher.cppm index d39e0e99..2d27ee02 100644 --- a/src/pm/publisher.cppm +++ b/src/pm/publisher.cppm @@ -120,6 +120,35 @@ std::string platform_block(std::string_view version, const ReleaseInfo::PerPlatf version, lua_escape(pp.url), lua_escape(pp.sha256)); } +// The install-time edge of a published package, per platform. +// +// A consumer that runs `mcpp add ` gets whatever this names installed +// alongside it. Nothing emitted it before, so a package declaring an +// environment had its edge written into the descriptor by hand — which is how +// `riscv-virt-rt` 0.3.0 shipped without the C library its own target row named, +// and why that file now carries thirty lines explaining the removal. +// +// The declaration is read UNRESOLVED (`workspaceByPlatform`), because a +// descriptor has a block per platform and the host resolution has already +// discarded two of the three. Emitting from the host-resolved list instead +// would produce a descriptor whose edges depend on which machine packed it, +// and nothing downstream could tell. +std::string platform_deps_block( + const std::map>& byPlatform, + std::string_view platform) +{ + auto it = byPlatform.find(std::string(platform)); + if (it == byPlatform.end() || it->second.empty()) return {}; + std::string out = " deps = {"; + bool first = true; + for (auto const& address : it->second) { + out += std::format("{} {}", first ? "" : ",", lua_escape(address)); + first = false; + } + out += " },\n"; + return out; +} + } // namespace std::string emit_xpkg(const mcpp::manifest::Manifest& manifest, @@ -161,9 +190,13 @@ std::string emit_xpkg(const mcpp::manifest::Manifest& manifest, out += " type = \"package\",\n\n"; out += " xpm = {\n"; - out += " linux = {\n" + platform_block(release.version, release.linux) + " },\n"; - out += " macosx = {\n" + platform_block(release.version, release.macosx) + " },\n"; - out += " windows = {\n" + platform_block(release.version, release.windows) + " },\n"; + const auto& byPlatform = manifest.xlings.workspaceByPlatform; + out += " linux = {\n" + platform_deps_block(byPlatform, "linux") + + platform_block(release.version, release.linux) + " },\n"; + out += " macosx = {\n" + platform_deps_block(byPlatform, "macosx") + + platform_block(release.version, release.macosx) + " },\n"; + out += " windows = {\n" + platform_deps_block(byPlatform, "windows") + + platform_block(release.version, release.windows) + " },\n"; out += " },\n\n"; out += " mcpp = {\n"; diff --git a/src/xlings/xlings.cppm b/src/xlings/xlings.cppm index f4ead099..c131a545 100644 --- a/src/xlings/xlings.cppm +++ b/src/xlings/xlings.cppm @@ -375,9 +375,10 @@ struct ProjectEnv { std::vector deps; // → "deps" std::vector> workspace; // → "workspace" std::string subos; // → "subos" - std::vector> envs; // → "envs" + // No `envs`: the key was materialised here and read by nothing. See + // .agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md §4. bool empty() const { - return deps.empty() && workspace.empty() && subos.empty() && envs.empty(); + return deps.empty() && workspace.empty() && subos.empty(); } }; @@ -1509,7 +1510,6 @@ void seed_xlings_json(const Env& env, if (!penv.workspace.empty()) emit_obj("workspace", penv.workspace); if (!penv.subos.empty()) json += std::format(" \"subos\": \"{}\",\n", json_escape(penv.subos)); - if (!penv.envs.empty()) emit_obj("envs", penv.envs); json += " \"lang\": \"en\",\n"; json += std::format(" \"mirror\": \"{}\"\n", json_escape(mirror)); json += "}\n"; diff --git a/tests/e2e/331_xlings_workspace_one_table.sh b/tests/e2e/331_xlings_workspace_one_table.sh new file mode 100755 index 00000000..0c4e10b9 --- /dev/null +++ b/tests/e2e/331_xlings_workspace_one_table.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# requires: unix-shell +# 331_xlings_workspace_one_table.sh — `[xlings.workspace]` is the one table. +# +# Design: .agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md. +# +# NO NETWORK, and that is the whole technique. Every entry reaches the +# provisioning pass, and with auto-install off that pass refuses and PRINTS THE +# ADDRESSES IT WAS GOING TO ASK FOR. So the refusal is the assertion: it shows +# what an entry was assembled into, which is the thing the manifest change is +# about, without installing anything on any shard. +set -uo pipefail + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +fail() { echo "FAIL: $*"; exit 1; } +MCPP="${MCPP:?set MCPP to the mcpp binary under test}" + +"$MCPP" new app >/dev/null 2>&1 || fail "mcpp new" +cd app +cp mcpp.toml mcpp.toml.base + +# `MCPP_NO_AUTO_INSTALL` makes the provisioning pass refuse and name the +# addresses; `--offline` would do as well. Either way nothing is downloaded. +declare_and_build() { # $1 = the [xlings] block + cp mcpp.toml.base mcpp.toml + printf '\n%s\n' "$1" >> mcpp.toml + MCPP_NO_AUTO_INSTALL=1 "$MCPP" build 2>&1 +} + +# ── 1. an entry becomes an install address ───────────────────────────────── +out=$(declare_and_build '[xlings.workspace] +mcpp-e2e-absent = "1.0"') +grep -q "mcpp-e2e-absent@1.0" <<<"$out" \ + || fail "the address the entry assembles into is missing: $out" + +# ── 2. the namespace may be written on either half, and means one entry ──── +onValue=$(declare_and_build '[xlings.workspace] +mcpp-e2e-absent = "xim:1.0"') +onKey=$(declare_and_build '[xlings.workspace] +"xim:mcpp-e2e-absent" = "1.0"') +for o in "$onValue" "$onKey"; do + grep -q "xim:mcpp-e2e-absent@1.0" <<<"$o" \ + || fail "namespaced address missing: $o" +done + +# The unquoted key form is a TOML error, and the message must not be silence. +out=$(declare_and_build '[xlings.workspace] +xim:mcpp-e2e-absent = "1.0"') +grep -qi "error" <<<"$out" || fail "an unquoted namespaced key was accepted: $out" + +# ── 3. `""` asks for presence only ───────────────────────────────────────── +out=$(declare_and_build '[xlings.workspace] +mcpp-e2e-absent = ""') +grep -q "mcpp-e2e-absent" <<<"$out" || fail "unconstrained entry missing: $out" +grep -q "mcpp-e2e-absent@" <<<"$out" && fail "unconstrained entry carries a version: $out" + +# ── 4. `deps` is honoured and reported, with the line to write ───────────── +out=$(declare_and_build '[xlings] +deps = ["xim:mcpp-e2e-absent@1.0"]') +grep -q "xim:mcpp-e2e-absent@1.0" <<<"$out" || fail "deps entry not provisioned: $out" +grep -q "\[xlings.workspace\]" <<<"$out" || fail "no advisory naming the new table: $out" +grep -q 'mcpp-e2e-absent = "xim:1.0"' <<<"$out" \ + || fail "the advisory does not show the line to write: $out" + +# ── 5. one package in both tables, two versions, is refused ─────────────── +out=$(declare_and_build '[xlings] +deps = ["mcpp-e2e-absent@2.0"] + +[xlings.workspace] +mcpp-e2e-absent = "1.0"') +grep -q "named in both" <<<"$out" || fail "the conflict was accepted: $out" +grep -q "1.0" <<<"$out" || fail "the conflict message omits a version: $out" +grep -q "2.0" <<<"$out" || fail "the conflict message omits a version: $out" + +# ── 6. `envs` is refused, naming the key ────────────────────────────────── +out=$(declare_and_build '[xlings.envs] +OPENBLAS_NUM_THREADS = "1"') +grep -q "\[xlings.envs\]" <<<"$out" || fail "envs was accepted: $out" + +# ── 7. a project declaring none of it is untouched ──────────────────────── +cp mcpp.toml.base mcpp.toml +out=$("$MCPP" build 2>&1) || fail "a project with no [xlings] section: $out" +grep -qi "xlings" <<<"$out" && fail "an undeclared project heard about xlings: $out" + +echo "PASS: 331_xlings_workspace_one_table" diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index 6e4f9a4c..3eb445d3 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -4517,7 +4517,7 @@ deps = [{ linxu = "qemu-user-aarch64" }] )"); ASSERT_FALSE(m.has_value()); EXPECT_NE(m.error().message.find("linxu"), std::string::npos) << m.error().message; - EXPECT_NE(m.error().message.find("linux, macos, windows, default"), std::string::npos) + EXPECT_NE(m.error().message.find("linux, macosx, windows, default"), std::string::npos) << m.error().message; EXPECT_NE(m.error().message.find("deps[0]"), std::string::npos) << m.error().message; } @@ -4552,3 +4552,154 @@ TEST(Manifest, ResolveHostValueTable) { ASSERT_TRUE(s.has_value()); EXPECT_EQ(resolve_host_value(s->root().at("v"), "windows").value().value(), "plain"); } + +// ── `[xlings.workspace]` as the one table (2026-09-03) ───────────────────── +// +// The design is `.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md`. +// Each entry produces two projections — an install address and a resolution +// pin — and the assertions are on both, because a test that checked only one +// could not tell a dropped namespace from a dropped version. + +TEST(Manifest, XlingsWorkspaceProducesAnAddressAndAPin) { + auto m = mcpp::manifest::parse_string(R"( +[package] +name = "app" +version = "0.1.0" +[xlings.workspace] +cmake = "3.28" +)"); + ASSERT_TRUE(m.has_value()) << m.error().format(); + EXPECT_EQ(m->xlings.workspace.at("cmake"), "3.28"); + ASSERT_EQ(m->xlings.deps.size(), 1u); + EXPECT_EQ(m->xlings.deps[0], "cmake@3.28"); +} + +TEST(Manifest, XlingsWorkspaceNamespaceOnEitherHalfIsTheSameEntry) { + auto onValue = mcpp::manifest::parse_string(R"( +[package] +name = "app" +version = "0.1.0" +[xlings.workspace] +picolibc-riscv = "xim:1.8.12" +)"); + ASSERT_TRUE(onValue.has_value()) << onValue.error().format(); + auto onKey = mcpp::manifest::parse_string(R"( +[package] +name = "app" +version = "0.1.0" +[xlings.workspace] +"xim:picolibc-riscv" = "1.8.12" +)"); + ASSERT_TRUE(onKey.has_value()) << onKey.error().format(); + EXPECT_EQ(onValue->xlings.deps, onKey->xlings.deps); + EXPECT_EQ(onValue->xlings.workspace, onKey->xlings.workspace); + EXPECT_EQ(onValue->xlings.deps.at(0), "xim:picolibc-riscv@1.8.12"); + EXPECT_EQ(onValue->xlings.workspace.at("picolibc-riscv"), "xim:1.8.12"); +} + +TEST(Manifest, XlingsWorkspaceDisagreeingNamespacesAreRefused) { + auto m = mcpp::manifest::parse_string(R"( +[package] +name = "app" +version = "0.1.0" +[xlings.workspace] +"xim:foo" = "other:1.0" +)"); + ASSERT_FALSE(m.has_value()); + EXPECT_NE(m.error().message.find("xim"), std::string::npos) << m.error().message; + EXPECT_NE(m.error().message.find("other"), std::string::npos) << m.error().message; +} + +TEST(Manifest, XlingsWorkspaceNamingOnePackageTwiceIsRefused) { + auto m = mcpp::manifest::parse_string(R"( +[package] +name = "app" +version = "0.1.0" +[xlings.workspace] +foo = "1.0" +"xim:foo" = "2.0" +)"); + ASSERT_FALSE(m.has_value()); + EXPECT_NE(m.error().message.find("twice"), std::string::npos) << m.error().message; + EXPECT_NE(m.error().message.find("xim:foo"), std::string::npos) << m.error().message; +} + +// `""` is the authored spelling for "present, version unconstrained"; it is +// what `d2mcpp/.xlings.json` already writes. Both halves are asserted: an +// address with no `@`, and no pin at all. +TEST(Manifest, XlingsWorkspaceEmptyVersionAsksOnlyForPresence) { + auto m = mcpp::manifest::parse_string(R"( +[package] +name = "app" +version = "0.1.0" +[xlings.workspace] +code = "" +)"); + ASSERT_TRUE(m.has_value()) << m.error().format(); + EXPECT_EQ(m->xlings.deps.at(0), "code"); + EXPECT_TRUE(m->xlings.workspace.at("code").empty()); +} + +// The descriptor emitter needs every platform, and the host resolution has +// already discarded two of them — so the unresolved declaration is kept. +TEST(Manifest, XlingsWorkspaceKeepsEveryPlatformForTheEmitter) { + auto m = mcpp::manifest::parse_string(R"( +[package] +name = "app" +version = "0.1.0" +[xlings.workspace] +qemu = { linux = "9.2.4-1", windows = "9.2.4-2" } +ninja = "1.12.1" +)"); + ASSERT_TRUE(m.has_value()) << m.error().format(); + const auto& byPlat = m->xlings.workspaceByPlatform; + ASSERT_TRUE(byPlat.contains("linux")); + ASSERT_TRUE(byPlat.contains("windows")); + EXPECT_NE(std::ranges::find(byPlat.at("linux"), "qemu@9.2.4-1"), + byPlat.at("linux").end()); + EXPECT_NE(std::ranges::find(byPlat.at("windows"), "qemu@9.2.4-2"), + byPlat.at("windows").end()); + // A plain string speaks for every platform, macosx included, even though + // `qemu` named none there. + ASSERT_TRUE(byPlat.contains("macosx")); + EXPECT_NE(std::ranges::find(byPlat.at("macosx"), "ninja@1.12.1"), + byPlat.at("macosx").end()); + EXPECT_EQ(std::ranges::find(byPlat.at("macosx"), "qemu@9.2.4-1"), + byPlat.at("macosx").end()); +} + +// `deps` is honoured and reported. Refusing it would reach a DEPENDENCY's +// manifest, which a consumer that pinned that package cannot edit. +TEST(Manifest, XlingsDepsIsHonouredAndReported) { + auto m = mcpp::manifest::parse_string(R"( +[package] +name = "app" +version = "0.1.0" +[xlings] +deps = ["xim:qemu-riscv@9.2.4-1"] +)"); + ASSERT_TRUE(m.has_value()) << m.error().format(); + EXPECT_EQ(m->xlings.deps.at(0), "xim:qemu-riscv@9.2.4-1"); + ASSERT_EQ(m->schemaWarnings.size(), 1u); + EXPECT_NE(m->schemaWarnings[0].find("[xlings.workspace]"), std::string::npos) + << m->schemaWarnings[0]; + // The message shows the line to write, not merely that a line exists. + EXPECT_NE(m->schemaWarnings[0].find("qemu-riscv = \"xim:9.2.4-1\""), + std::string::npos) << m->schemaWarnings[0]; +} + +// `envs` was materialised and read by nothing, while the documentation said it +// reached the tool environment. A key that does nothing is worse when +// something claims it does, so this is an error rather than a warning. +TEST(Manifest, XlingsEnvsIsRefused) { + auto m = mcpp::manifest::parse_string(R"( +[package] +name = "app" +version = "0.1.0" +[xlings.envs] +OPENBLAS_NUM_THREADS = "1" +)"); + ASSERT_FALSE(m.has_value()); + EXPECT_NE(m.error().message.find("[xlings.envs]"), std::string::npos) + << m.error().message; +} From d096068649819df946dfb582aed909743f98537c Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:53:07 +0800 Subject: [PATCH 17/26] manifest: one address parser, and the conflict compared on the whole pin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deps compatibility path derived (namespace, target, version) from an address twice — once to compare against a workspace pin and once to phrase the advisory — which is the shape where two copies of one rule drift. parse_address is the inverse of XlingsEntry::address, and both sites read it. The comparison also widened. It tested versions alone, so 'other:foo@1.0' beside 'foo = "xim:1.0"' passed as agreement: two packages sharing a name at one version. Comparing the pin covers both halves. --- modules/manifest/src/toml.cppm | 61 ++++++++++++++++++---------------- 1 file changed, 32 insertions(+), 29 deletions(-) diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index 3f221d66..e358092a 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -209,6 +209,18 @@ struct XlingsEntry { } }; +// The inverse: `[:][@]` back into its parts. Used by the +// `deps` compatibility path, which receives an address and has to say what the +// equivalent `[xlings.workspace]` line is — and to compare it against one. +inline XlingsEntry parse_address(std::string_view address) { + auto at = address.find('@'); + auto head = at == std::string_view::npos ? address : address.substr(0, at); + auto version = at == std::string_view::npos ? std::string_view{} + : address.substr(at + 1); + auto [ns, target] = split_scope(head); + return XlingsEntry{ ns, target, std::string(version) }; +} + // Combine the two halves a namespace may be written on. Both may carry it; // disagreeing is an error rather than a precedence rule, because a precedence // rule would make one of the two spellings silently ineffective. @@ -1532,37 +1544,28 @@ std::expected parse_string(std::string_view content, std::format("[xlings] deps[{}]: {}", i, r.error()))); ++i; if (!*r) continue; - // One package in both tables with two versions is refused. The - // two are provisioned in list order and the LAST one wins the - // pin, so accepting it would install one version and resolve the - // other — the drift this release exists to remove, arrived at - // through the compatibility path. - { - auto at2 = (*r)->find('@'); - auto addr2 = at2 == std::string::npos ? **r : (*r)->substr(0, at2); - auto ver2 = at2 == std::string::npos ? std::string{} : (*r)->substr(at2 + 1); - auto [ns2, target2] = split_scope(addr2); - if (auto it2 = m.xlings.workspace.find(target2); - it2 != m.xlings.workspace.end()) { - auto [pinNs, pinVer] = split_scope(it2->second); - if (pinVer != ver2) - return std::unexpected(error(origin, std::format( - "'{}' is named in both [xlings] deps (version '{}') " - "and [xlings.workspace] (version '{}'); " - "keep the [xlings.workspace] line and delete the other", - target2, ver2.empty() ? "unconstrained" : ver2, - pinVer.empty() ? "unconstrained" : pinVer))); - } + const auto entry = parse_address(**r); + // One package in both tables, said two ways, is refused. They are + // provisioned in list order while the LAST wins the pin, so + // accepting it would install one and resolve the other — the drift + // this release removes, reached through the compatibility path. + // Compared on the whole pin, not on the version: two namespaces at + // one version are two packages sharing a name. + if (auto pinned = m.xlings.workspace.find(entry.target); + pinned != m.xlings.workspace.end() && pinned->second != entry.pin()) { + auto say = [](const std::string& p) { + return p.empty() ? std::string("unconstrained") : p; + }; + return std::unexpected(error(origin, std::format( + "'{}' is named in both [xlings] deps ({}) and " + "[xlings.workspace] ({}); keep the [xlings.workspace] line " + "and delete the other", + entry.target, say(entry.pin()), say(pinned->second)))); } m.xlings.deps.push_back(**r); - // Show the author the line to write. The address form - // `:@` becomes a key and a version. - auto at = (*r)->find('@'); - auto addr = at == std::string::npos ? **r : (*r)->substr(0, at); - auto ver = at == std::string::npos ? std::string{} : (*r)->substr(at + 1); - auto [ns, target] = split_scope(addr); - replacement += std::format("\n {} = \"{}{}\"", - target, ns.empty() ? "" : ns + ":", ver); + // Show the author the line to write, not merely that one exists. + replacement += std::format("\n {} = \"{}\"", + entry.target, entry.pin()); } if (!replacement.empty()) m.schemaWarnings.push_back(std::format( From a669f37fafedb9279393f95d4d1bdaa42f4e39ad Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:53:42 +0800 Subject: [PATCH 18/26] design: state where the advisory does not reach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It rides schemaWarnings, which prepare prints for the root manifest and escalates under --strict. A dependency's are attached and not printed, which is a pre-existing gap: surfacing them would also surface every unrelated warning those manifests carry, and that is a change of its own. So the package's author is told the moment they build it, and a consumer of an unmigrated package is not — stated here rather than left to be discovered. --- .../2026-09-03-xlings-workspace-as-the-one-table.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md index 4de891db..a481dbc8 100644 --- a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md +++ b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md @@ -197,8 +197,17 @@ not theirs to edit, and refusing it punishes the wrong person. 3. An index sweep confirms no other published manifest declares it. When the advisory has been silent across a release, the dependency path refuses too. -**What does not bend:** `deps` is refused with a message, never dropped in -silence. `[xlings]` has no unknown-key sweep — no `kKnownXlings` list exists in +**A limit of the advisory, stated rather than implied.** It rides +`schemaWarnings`, which `prepare` prints for the ROOT manifest and escalates +under `--strict`. A dependency's schema warnings are attached and not printed +today — a pre-existing gap in how mcpp surfaces them, not one this change +introduces. So a consumer of an unmigrated package is not told; the package's +own author is, the moment they build it. Surfacing dependency schema warnings +is worth doing and is a change of its own, because it would also surface every +unrelated warning those manifests carry. + +**What does not bend:** `deps` is honoured or refused with a message, never +dropped in silence. `[xlings]` has no unknown-key sweep — no `kKnownXlings` list exists in `toml.cppm` — so a removed key would be read by nobody and reported by nobody, which is the shape #531 exists to prevent. From e790ea4d55c0765aa7fcd338b39d13e7bc3d12d1 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:54:32 +0800 Subject: [PATCH 19/26] design: the ecosystem migration waits, and the reason is a silent regression Republishing the three packages with the new table before their consumers move would stop provisioning on every older engine, silently: an older mcpp parses [xlings.workspace] and provisions nothing from it. That is worse than the advisory it would silence. This cycle verifies the three packages on the new engine instead; the republish waits for a floor that makes the older engine refuse rather than degrade. --- ...026-09-03-xlings-workspace-as-the-one-table.md | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md index a481dbc8..964c3349 100644 --- a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md +++ b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md @@ -192,11 +192,20 @@ not theirs to edit, and refusing it punishes the wrong person. 1. The merged reader ships. `[xlings] deps` in the **root** manifest is a hard error naming the line to write; in a **dependency's** manifest it is honoured and reported once, naming the package. -2. The three packages are republished with `[xlings.workspace]` and an mcpp - floor. +2. The three packages are republished with `[xlings.workspace]` — **not in + this cycle, and the reason is a silent regression rather than caution.** An + older mcpp parses `[xlings.workspace]` perfectly well and provisions nothing + from it, so a package that migrated before its consumers moved would stop + installing its emulator on every older engine, with no message anywhere. + That is worse than the advisory it would silence. The migration is safe once + the index's `latest` mcpp provisions from the table, and a floor in the + package makes the older engine refuse instead of degrade. 3. An index sweep confirms no other published manifest declares it. When the advisory has been silent across a release, the dependency path refuses too. +What this cycle does instead is verify: the three packages build on the new +engine, produce the advisory, and provision exactly as before. + **A limit of the advisory, stated rather than implied.** It rides `schemaWarnings`, which `prepare` prints for the ROOT manifest and escalates under `--strict`. A dependency's schema warnings are attached and not printed @@ -519,7 +528,7 @@ T1 manifest ──┬── T2 provisioning scope ──┐ | T4 | `xpm..deps` in the emitted descriptor | `src/pm/publisher.cppm` | T1 | | T5 | `docs/05` §2.13, `docs/17`, both `docs/zh/` twins | docs | T1-T4 | | T6 | Unit tests and one e2e | `tests/` | T1-T4 | -| T7 | The three packages republish | `mcpplibs/{aarch64-virt-rt,riscv-virt-rt,std-freestanding}` | T8 | +| T7 | The three packages are verified on the new engine; the republish waits for the floor (§2.4) | `mcpplibs/{aarch64-virt-rt,riscv-virt-rt,std-freestanding}` | T8 | | T8 | Version, CI, self-review, merge, release, sandbox | — | T5, T6 | ### 16.1 What each axis demands of the implementation From 28160897366f2f83d3f250f59b39fe979fb1d218 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:55:15 +0800 Subject: [PATCH 20/26] docs(13): the board-support example uses the one table The chapter taught [xlings] deps as the way a board package declares its emulator. The declaration is the same statement; only the table changed, and the older spelling still works and says so. --- docs/13-baremetal.md | 13 +++++++------ docs/zh/13-baremetal.md | 9 +++++---- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/docs/13-baremetal.md b/docs/13-baremetal.md index 14032086..b20062b4 100644 --- a/docs/13-baremetal.md +++ b/docs/13-baremetal.md @@ -560,11 +560,12 @@ for hosted targets, the `--no-runner` escape and the not-run reporting of ## Writing a board-support package A board-support package is an ordinary mcpp package. It declares the emulator -it needs under `[xlings] deps`, exports one C++ module for consumers, and emits -its board facts from `build.mcpp`. +it needs under `[xlings.workspace]`, exports one C++ module for consumers, and +emits its board facts from `build.mcpp`. -**A declaration under `[xlings] deps` provisions the package on the first -build** (since 2026.8.29). It is also what lets `mcpp::xpkg_dir` answer *"where +**A declaration there provisions the package on the first build** (since +2026.8.29; the table is `[xlings.workspace]` since 2026.9.3.1, and the older +`[xlings] deps` still works and says so). It is also what lets `mcpp::xpkg_dir` answer *"where did that package land"*. Both halves matter: the same declaration installs the emulator and tells the build program where it went. @@ -669,8 +670,8 @@ int main() { The package's manifest declares the emulator and nothing else: ```toml -[xlings] -deps = ["xim:qemu-riscv@9.2.4-1"] +[xlings.workspace] +qemu-riscv = "xim:9.2.4-1" ``` Linking `clang_rt.builtins` is not optional on this board. picolibc formats diff --git a/docs/zh/13-baremetal.md b/docs/zh/13-baremetal.md index 15931691..4294bab3 100644 --- a/docs/zh/13-baremetal.md +++ b/docs/zh/13-baremetal.md @@ -500,10 +500,11 @@ error: no runner is configured for 'riscv64-none-elf' — a freestanding artifac ## 编写板级支持包 -板级支持包是一个普通的 mcpp 包。它在 `[xlings] deps` 下声明所需的模拟器,为消费者 +板级支持包是一个普通的 mcpp 包。它在 `[xlings.workspace]` 下声明所需的模拟器,为消费者 导出一个 C++ 模块,并从 `build.mcpp` 发出它的板级事实。 -**`[xlings] deps` 里的声明会在首次构建时供给该包**(2026.8.29 起)。它同时让 +**那里的声明会在首次构建时供给该包**(2026.8.29 起;这张表自 2026.9.3.1 起是 +`[xlings.workspace]`,旧的 `[xlings] deps` 仍然生效并会说明这一点)。它同时让 `mcpp::xpkg_dir` 能回答「那个包落在哪」。两半都要紧:同一条声明既装上模拟器,也告诉 构建程序它装到了哪。 @@ -601,8 +602,8 @@ int main() { 该包的清单只声明模拟器,别无其他: ```toml -[xlings] -deps = ["xim:qemu-riscv@9.2.4-1"] +[xlings.workspace] +qemu-riscv = "xim:9.2.4-1" ``` 在这块板子上链接 `clang_rt.builtins` 不是可选项。picolibc 通过 ryu 格式化浮点值, From 602002763666b07f8c292964d39600b4c92ecbd4 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:56:54 +0800 Subject: [PATCH 21/26] docs+publisher: the subos example was nested under the wrong table, and deps emits no edge by design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2.13 example put `subos = "dev"` after `[xlings.workspace]`, which in TOML makes it `xlings.workspace.subos` — a key nothing reads. Two blocks, each under the table it belongs to. The emitter gains the reason it skips a package still on `deps`: that key is host-resolved at load, so the per-platform declaration a descriptor needs is already gone, and writing the host's answer into all three blocks would be the machine-dependent descriptor the function exists to avoid. --- .../docs/2026-09-03-xlings-workspace-as-the-one-table.md | 6 ++++++ docs/05-mcpp-toml.md | 3 +++ docs/zh/05-mcpp-toml.md | 3 +++ src/pm/publisher.cppm | 7 +++++++ 4 files changed, 19 insertions(+) diff --git a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md index 964c3349..e0cbd358 100644 --- a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md +++ b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md @@ -315,6 +315,12 @@ struct XlingsConfig { `resolve_host_value` already knows which platform each value belongs to. +A package still on `deps` emits no edge, as before. Its declaration is +host-resolved at load, so the per-platform information a descriptor needs is +gone by the time the emitter runs, and writing the host's answer into all three +blocks would be the machine-dependent descriptor this section exists to +prevent. The advisory tells its author how to obtain one. + ## 6. Documentation **`docs/05` §2.13** gains the inheritance rule as a table, next to diff --git a/docs/05-mcpp-toml.md b/docs/05-mcpp-toml.md index 61545806..e96c84df 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -1875,7 +1875,10 @@ picolibc-riscv = "xim:1.8.12" # namespace on the version "xim:qemu-user-aarch64" = "7.2.0" # or on the key - quotes required code = "" # present; version unconstrained llvm = { macosx = "20", default = "22" } +``` +```toml +[xlings] subos = "dev" # a named, isolated environment ``` diff --git a/docs/zh/05-mcpp-toml.md b/docs/zh/05-mcpp-toml.md index 444163df..2493f7c5 100644 --- a/docs/zh/05-mcpp-toml.md +++ b/docs/zh/05-mcpp-toml.md @@ -1600,7 +1600,10 @@ picolibc-riscv = "xim:1.8.12" # 命名空间写在版本上 "xim:qemu-user-aarch64" = "7.2.0" # 或写在键上 —— 必须带引号 code = "" # 存在即可,版本不限 llvm = { macosx = "20", default = "22" } +``` +```toml +[xlings] subos = "dev" # 指名的隔离环境 ``` diff --git a/src/pm/publisher.cppm b/src/pm/publisher.cppm index 2d27ee02..8cfb95c2 100644 --- a/src/pm/publisher.cppm +++ b/src/pm/publisher.cppm @@ -133,6 +133,13 @@ std::string platform_block(std::string_view version, const ReleaseInfo::PerPlatf // discarded two of the three. Emitting from the host-resolved list instead // would produce a descriptor whose edges depend on which machine packed it, // and nothing downstream could tell. +// +// ⚠️ A package still on the superseded `[xlings] deps` emits NO edge, exactly +// as before. That is not an oversight: `deps` is resolved for the host at +// load, so the per-platform declaration a descriptor needs is already gone by +// the time this runs, and writing the host's answer into all three blocks +// would be the machine-dependent descriptor this comment rejects. The +// advisory that key raises tells its author how to obtain an edge. std::string platform_deps_block( const std::map>& byPlatform, std::string_view platform) From 7845da1a2ba6b2ab21334a9959f3c7e6987eab8e Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:23:45 +0800 Subject: [PATCH 22/26] tests: the fixtures that used the removed key, and the one assertion only macOS could fail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three CI-only failures, all from this change and each in a fixture rather than in the engine. e2e 88 and 205 declared [xlings.envs], which is now refused. 205 drops it; 88 asserted the key was materialized and now asserts it is not, and gains two criteria the merge introduced: the same statement in both tables collapses to one entry, and the superseded key is reported with the line to write. The manifest parser gained that collapse. deps and workspace agreeing about one package is not an error and is not two entries — appending it twice would ask xlings to install one package twice. Manifest.XlingsWorkspaceAcceptsPerPlatformValues compared host_platform_key() against "macos", and this change made that key xlings' own spelling, "macosx". The assertion was written in terms of the function precisely so it would run on every host, and it was the one line in the file that could only fail on a macOS runner. It did. --- modules/manifest/src/toml.cppm | 9 ++++++++- tests/e2e/205_root_local_subos.sh | 3 --- tests/e2e/88_xlings_environment.sh | 17 ++++++++++------- tests/unit/test_manifest.cpp | 5 ++++- 4 files changed, 22 insertions(+), 12 deletions(-) diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index e358092a..fd686db5 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -1552,7 +1552,14 @@ std::expected parse_string(std::string_view content, // Compared on the whole pin, not on the version: two namespaces at // one version are two packages sharing a name. if (auto pinned = m.xlings.workspace.find(entry.target); - pinned != m.xlings.workspace.end() && pinned->second != entry.pin()) { + pinned != m.xlings.workspace.end() && pinned->second == entry.pin()) { + // The same statement in both tables. Not an error and not two + // entries: appending it again would ask xlings to install one + // package twice, so only the advisory below is produced. + replacement += std::format("\n {} = \"{}\"", + entry.target, entry.pin()); + continue; + } else if (pinned != m.xlings.workspace.end()) { auto say = [](const std::string& p) { return p.empty() ? std::string("unconstrained") : p; }; diff --git a/tests/e2e/205_root_local_subos.sh b/tests/e2e/205_root_local_subos.sh index d59e3657..8806a8c6 100755 --- a/tests/e2e/205_root_local_subos.sh +++ b/tests/e2e/205_root_local_subos.sh @@ -79,9 +79,6 @@ members = ["app"] [xlings] subos = "root-el8" - -[xlings.envs] -ROOT_ONLY = "1" EOF cat >"$ws/app/mcpp.toml" <<'EOF' [package] diff --git a/tests/e2e/88_xlings_environment.sh b/tests/e2e/88_xlings_environment.sh index 322e19fc..05e3f26d 100755 --- a/tests/e2e/88_xlings_environment.sh +++ b/tests/e2e/88_xlings_environment.sh @@ -37,12 +37,10 @@ deps = ["ninja@1.12.1"] subos = "dev" [xlings.workspace] -# Pin a tool version (the general form of [toolchain]). +# The one table (2026.9.3.1+). The same statement as the `deps` line above, +# which is deliberate: the two must collapse to one entry rather than ask +# xlings to install one package twice. ninja = "1.12.1" - -[xlings.envs] -# Env vars applied by xvm shims. -APP_BUILD_ENV = "1" EOF echo 'int main() { return 0; }' > app/src/main.cpp @@ -65,7 +63,12 @@ grep -q 'ninja@1.12.1' "$J" || { echo "FAIL: deps entry missing"; exit 1; } grep -q '"subos": "dev"' "$J" || { echo "FAIL: subos not materialized"; exit 1; } grep -q '"workspace"' "$J" || { echo "FAIL: workspace not materialized"; exit 1; } grep -q '"ninja": "1.12.1"' "$J" || { echo "FAIL: workspace pin missing"; exit 1; } -grep -q '"envs"' "$J" || { echo "FAIL: envs not materialized"; exit 1; } -grep -q '"APP_BUILD_ENV": "1"' "$J" || { echo "FAIL: env var missing"; exit 1; } +grep -q '"envs"' "$J" && { echo "FAIL: envs is materialized and nothing reads it"; exit 1; } +# One statement, one entry: `deps` and `[xlings.workspace]` agree here, and the +# materialized list must not name the package twice. +[ "$(grep -o 'ninja@1.12.1' "$J" | wc -l)" -eq 1 ] \ + || { echo "FAIL: the same statement was materialized twice"; exit 1; } +# The superseded key is reported, with the line to write instead. +grep -q '\[xlings.workspace\]' b.log || { echo "FAIL: no advisory for [xlings] deps"; cat b.log; exit 1; } echo "OK" diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index 3eb445d3..a103a013 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -4503,7 +4503,10 @@ xmake = "3.0.7" const auto host = mcpp::manifest::host_platform_key(); if (host == "linux") EXPECT_EQ(m->xlings.workspace.at("gcc"), "15.1.0"); else EXPECT_EQ(m->xlings.workspace.count("gcc"), 0u); - EXPECT_EQ(m->xlings.workspace.at("llvm"), host == "macos" ? "20" : "22"); + // `host_platform_key()` is xlings' spelling, `macosx`, since 2026.9.3.1. + // Comparing against "macos" here made this the one assertion in the file + // that could only fail on a macOS runner — and it did. + EXPECT_EQ(m->xlings.workspace.at("llvm"), host == "macosx" ? "20" : "22"); EXPECT_EQ(m->xlings.workspace.at("xmake"), "3.0.7"); } From 7088e118a4f4c79d81cf1cbb5a0b0298aa7d418a Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:26:23 +0800 Subject: [PATCH 23/26] examples: the project-subos example writes the one table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It declared its emulator with [xlings] deps, which now reports itself. The address the new form assembles is byte-identical — measured with auto-install off, which prints what the pass was going to ask for: 'xlings install xim:qemu-riscv@9.2.4-1' either way. --- examples/07-project-subos/README.md | 4 +++- examples/07-project-subos/mcpp.toml | 7 ++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/examples/07-project-subos/README.md b/examples/07-project-subos/README.md index 517d983b..ae075367 100644 --- a/examples/07-project-subos/README.md +++ b/examples/07-project-subos/README.md @@ -17,7 +17,9 @@ warning: project-subos: this project's subos is populated ```toml [xlings] subos = "default" -deps = ["xim:qemu-riscv@9.2.4-1"] + +[xlings.workspace] +qemu-riscv = "xim:9.2.4-1" ``` `subos` names the environment this project builds in. mcpp already used that diff --git a/examples/07-project-subos/mcpp.toml b/examples/07-project-subos/mcpp.toml index e0c4be4b..5b0953c3 100644 --- a/examples/07-project-subos/mcpp.toml +++ b/examples/07-project-subos/mcpp.toml @@ -22,4 +22,9 @@ description = "A build program that finds its tools in an environment the projec # design. See README.md. [xlings] subos = "default" -deps = ["xim:qemu-riscv@9.2.4-1"] + +# The one table (2026.9.3.1+): an entry names a package and the version this +# project uses it at. mcpp provisions it and pins it, which is why the emulator +# below is both installed on the first build and the one the project resolves. +[xlings.workspace] +qemu-riscv = "xim:9.2.4-1" From 84423bd61921b0ed95d24fdd1bfc2e7c83afea50 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:27:02 +0800 Subject: [PATCH 24/26] design: what implementation found that the design did not Four items, three visible only once the code ran: a nested std::map in an exported module truncating the BMI and reporting it in an unrelated file; the removed key's first casualties being this repository's own fixtures; one assertion that could only fail on macOS doing so; and the same statement in both tables collapsing rather than installing one package twice. --- ...09-03-xlings-workspace-as-the-one-table.md | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md index e0cbd358..1b2b316a 100644 --- a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md +++ b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md @@ -509,6 +509,35 @@ reached, and because two of these were wrong in a way worth remembering. | A three-phase deprecation | one release (§2.4) | three manifests | | Keys are program names | keys are xvm targets, roots included (§12) | measured | +### 15.1 What implementation found that the design did not + +Four things, and three of them were only visible once the code ran. + +**A nested `std::map` in an exported module truncates the BMI.** `XlingsConfig` +first carried `map>`. The module compiled and the +BMI it wrote was unreadable: consumers failed with `Bad file data` and +`failed to load pendings for 'std::map'`, pointing at an unrelated file's +ordinary `std::map` alias. The emitter needs the addresses and never the inner +keys, so a `vector` costs nothing and the field is one. xlings' own source +carries a note about the same GCC 16 shape, with a different workaround. + +**The removed key's first casualties were this repository's own fixtures.** +e2e 88 and 205 declared `[xlings.envs]` and went red on CI, not locally, +because the local run had not reached them. That is the expected shape of +removing a key that did nothing: the things that used it were the things that +did not depend on it working. + +**One assertion could only fail on macOS, and did.** +`Manifest.XlingsWorkspaceAcceptsPerPlatformValues` compared +`host_platform_key()` against `"macos"`, and aligning that function with +xlings' `macosx` made the comparison false on exactly one of the three hosts. +The test was written in terms of the function so it would run everywhere; the +literal on the other side of the comparison is what defeated that. + +**`deps` and `workspace` agreeing is not two entries.** Both feed the same +derived list, so the same statement written twice asked xlings to install one +package twice. It collapses, and only the advisory is produced. + The two that mattered: reading a SubOS **state** file as though it were an authored **project** file, and asserting a blast radius without checking which environment the call runs in. Both were arguments from the shape of the code From d92a1b9258a665e89c035882ac2f206faed2c2cf Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:38:27 +0800 Subject: [PATCH 25/26] platform: one macos/macosx spelling rule, and the manifest asks it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two names for one platform: mcpp's triple vocabulary says macos, an xpkg descriptor and xlings' project file say macosx. The manifest parser had a second copy of that fold and a second copy of the host key, both hand-rolled beside a rule modules/platform already had. mcpp.platform.axis gains xpkg_platform_key_for, which is now the only place that knows, and TargetPlatform::for_os is written in terms of it rather than repeating the table. The manifest reads it for both directions — canonicalising a written key and folding a requested platform — and takes the host from xpkg_platform instead of its own #if. Both spellings are therefore accepted wherever a platform is named, and a new unit test asserts that the two write the same declaration on every host by resolving explicitly rather than against the one it runs on. --- docs/05-mcpp-toml.md | 8 +++++--- docs/zh/05-mcpp-toml.md | 6 ++++-- modules/manifest/src/toml.cppm | 37 +++++++++++++++++++--------------- modules/platform/src/axis.cppm | 26 ++++++++++++++++++++---- tests/unit/test_manifest.cpp | 37 +++++++++++++++++++++++++++++++++- 5 files changed, 88 insertions(+), 26 deletions(-) diff --git a/docs/05-mcpp-toml.md b/docs/05-mcpp-toml.md index e96c84df..f7e2b5d3 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -1909,9 +1909,11 @@ The namespace may be written on either half. Writing it on the key requires halves with different values is an error, and so is naming one package twice under two spellings. -Platform keys are xlings' own — `linux`, `macosx`, `windows` — plus `default`; -`macos` is accepted as an alias. A table with no key for this host and no -`default` declares nothing here. +Platform keys are xlings' own — `linux`, `macosx`, `windows` — plus `default`. +`macos` and `macosx` are the same platform written in two vocabularies (mcpp's +triples say one, descriptors and xlings' project file say the other) and both +are accepted wherever a platform is named. A table with no key for this host +and no `default` declares nothing here. #### Which version a tool the project did not name resolves to diff --git a/docs/zh/05-mcpp-toml.md b/docs/zh/05-mcpp-toml.md index 2493f7c5..ec33db69 100644 --- a/docs/zh/05-mcpp-toml.md +++ b/docs/zh/05-mcpp-toml.md @@ -1629,8 +1629,10 @@ mcpp 既供给它——机器上没有就装,有就映射——也把它物化 命名空间写在哪一半都可以。写在键上**必须带引号**,因为 TOML 的裸键不能含冒号。 两半都写且不一致是错误;同一个包用两种拼法出现两次也是错误。 -平台键是 xlings 自己的 —— `linux`、`macosx`、`windows`,外加 `default`;`macos` -作为别名接受。表里既没有本机这一项也没有 `default`,就表示在这里什么都不声明。 +平台键是 xlings 自己的 —— `linux`、`macosx`、`windows`,外加 `default`。`macos` +与 `macosx` 是同一个平台的两套词汇(mcpp 的三元组说前者,描述符与 xlings 的项目 +文件说后者),**凡是点名平台的地方两者都接受**。表里既没有本机这一项也没有 +`default`,就表示在这里什么都不声明。 #### 工程没点名的工具,其版本的来源 diff --git a/modules/manifest/src/toml.cppm b/modules/manifest/src/toml.cppm index fd686db5..8fe8053f 100644 --- a/modules/manifest/src/toml.cppm +++ b/modules/manifest/src/toml.cppm @@ -12,6 +12,7 @@ import mcpp.version_req; import mcpp.pm.dependency_selector; import mcpp.pm.index_spec; import mcpp.platform; +import mcpp.platform.axis; // the one macos/macosx spelling rule // ⚠️ ANONYMOUS NAMESPACE, AND THIS COST TWO WINDOWS JOBS TO LEARN. // @@ -119,15 +120,17 @@ struct LoadContext { // list. The unresolved declaration is kept beside it in // `XlingsConfig::workspaceByPlatform`, because the descriptor emitter needs // every platform at once and cannot re-derive what was already collapsed. +// +// ⚠️ `macos` AND `macosx` ARE ONE PLATFORM, AND THE RULE IS NOT WRITTEN HERE. +// mcpp's triple vocabulary says `macos`; a descriptor and xlings' project file +// say `macosx`. `mcpp::platform::xpkg_platform_key_for` is the one place that +// knows, and `xpkg_platform` is the host in the same vocabulary — an earlier +// draft of this section hand-rolled both, which is the second copy of a rule +// this file exists to avoid. inline std::string_view host_platform_key() { - if constexpr (mcpp::platform::is_windows) return "windows"; - else if constexpr (mcpp::platform::is_macos) return "macosx"; - else return "linux"; + return mcpp::platform::xpkg_platform; } -// The three platforms a descriptor has a block for, in xlings' spelling. -inline constexpr std::string_view kXlingsPlatforms[] = {"linux", "macosx", "windows"}; - // Split `:` on the FIRST colon. xlings writes a namespace this // way on a version (`"mcpp": "xim:2026.8.30.2"` in a real subos file) and mcpp // additionally accepts it on the key, so one splitter serves both halves. @@ -148,15 +151,16 @@ platform_values(const mcpp::libs::toml::Value& v) { return std::unexpected(std::string( "expected a string or a { = \"...\" } table")); for (auto& [k, val] : v.as_table()) { - std::string canon = (k == "macos") ? "macosx" : k; - const bool known = canon == "default" - || std::ranges::find(kXlingsPlatforms, canon) != std::ranges::end(kXlingsPlatforms); - if (!known) + auto canon = k == "default" + ? std::optional("default") + : mcpp::platform::xpkg_platform_key_for(k); + if (!canon) return std::unexpected(std::format( - "unknown platform key '{}'; expected one of linux, macosx, windows, default", k)); + "unknown platform key '{}'; expected one of linux, macosx " + "(or macos), windows, default", k)); if (!val.is_string()) return std::unexpected(std::format("platform key '{}' must be a string", k)); - out.emplace_back(std::move(canon), val.as_string()); + out.emplace_back(std::string(*canon), val.as_string()); } return out; } @@ -166,9 +170,10 @@ platform_values(const mcpp::libs::toml::Value& v) { inline std::optional value_for_platform(const std::vector>& vals, std::string_view platform) { - // The alias is folded on BOTH sides: a caller may name the host `macos` - // (mcpp's spelling elsewhere) while the stored key is canonical. - const std::string_view want = (platform == "macos") ? "macosx" : platform; + // Folded on BOTH sides: a caller may name the host `macos` (mcpp's triple + // spelling) while the stored key is already canonical. + const std::string_view want = + mcpp::platform::xpkg_platform_key_for(platform).value_or(platform); auto pick = [&](std::string_view k) -> std::optional { for (auto const& [key, v] : vals) if (key == k) return v; return std::nullopt; @@ -1505,7 +1510,7 @@ std::expected parse_string(std::string_view content, // Every platform, for the descriptor emitter. Resolved per // platform rather than stored raw: the emitter wants the answer, // and `default` is part of producing it. - for (auto plat : kXlingsPlatforms) { + for (auto plat : mcpp::platform::xpkg_platforms) { auto v = value_for_platform(*vals, plat); if (!v) continue; auto e = make_xlings_entry(k, *v); diff --git a/modules/platform/src/axis.cppm b/modules/platform/src/axis.cppm index 47323610..0d6e38e0 100644 --- a/modules/platform/src/axis.cppm +++ b/modules/platform/src/axis.cppm @@ -32,6 +32,25 @@ import mcpp.platform; export namespace mcpp::platform { +// THE spelling rule, and the only one. +// +// One platform has two names: the triple vocabulary says `macos`, an xpkg +// descriptor and xlings' own project file say `macosx`. Every place that has +// to accept both — a target's `os` token, a `[xlings.workspace]` platform +// table, a descriptor's per-OS section — asks here, so the alias cannot be +// half-known. `nullopt` means the token names no platform at all, which the +// caller decides how to treat: `for_os` falls back to the host, a manifest +// parser reports it. +inline std::optional xpkg_platform_key_for(std::string_view os) { + if (os == "macos" || os == "macosx") return "macosx"; + if (os == "windows") return "windows"; + if (os == "linux") return "linux"; + return std::nullopt; +} + +// The three keys a descriptor has a block for, in that vocabulary. +inline constexpr std::string_view xpkg_platforms[] = {"linux", "macosx", "windows"}; + // A resolved xpkg platform key. Only obtainable through one of the axis // types below, so possession of one implies somebody decided which axis it // came from. @@ -66,12 +85,11 @@ public: // "macos" where xpkg descriptors say "macosx"; that translation lives // here so no caller has to remember it. static TargetPlatform for_os(std::string_view tripleOs) { - if (tripleOs == "macos" || tripleOs == "macosx") return TargetPlatform("macosx"); - if (tripleOs == "windows") return TargetPlatform("windows"); - if (tripleOs == "linux") return TargetPlatform("linux"); // Unknown/absent os token: fall back to the host, which is what the // whole code base did unconditionally before #254. - return TargetPlatform(std::string(mcpp::platform::xpkg_platform)); + return TargetPlatform(std::string( + xpkg_platform_key_for(tripleOs) + .value_or(mcpp::platform::xpkg_platform))); } // For tooling that walks platforms it is not running on and is not diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index a103a013..f367db3a 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -4520,7 +4520,8 @@ deps = [{ linxu = "qemu-user-aarch64" }] )"); ASSERT_FALSE(m.has_value()); EXPECT_NE(m.error().message.find("linxu"), std::string::npos) << m.error().message; - EXPECT_NE(m.error().message.find("linux, macosx, windows, default"), std::string::npos) + EXPECT_NE(m.error().message.find("linux, macosx (or macos), windows, default"), + std::string::npos) << m.error().message; EXPECT_NE(m.error().message.find("deps[0]"), std::string::npos) << m.error().message; } @@ -4706,3 +4707,37 @@ OPENBLAS_NUM_THREADS = "1" EXPECT_NE(m.error().message.find("[xlings.envs]"), std::string::npos) << m.error().message; } + +// `macos` and `macosx` are one platform under two vocabularies — mcpp's triple +// says the first, a descriptor and xlings' project file say the second. The +// rule lives in mcpp.platform.axis; this asserts the manifest reaches it, +// on every host, by resolving explicitly rather than against this one. +TEST(Manifest, XlingsWorkspaceAcceptsBothMacosSpellings) { + auto with_macos = mcpp::manifest::parse_string(R"( +[package] +name = "app" +version = "0.1.0" +[xlings.workspace] +llvm = { macos = "20", default = "22" } +)"); + ASSERT_TRUE(with_macos.has_value()) << with_macos.error().format(); + auto with_macosx = mcpp::manifest::parse_string(R"( +[package] +name = "app" +version = "0.1.0" +[xlings.workspace] +llvm = { macosx = "20", default = "22" } +)"); + ASSERT_TRUE(with_macosx.has_value()) << with_macosx.error().format(); + // Same platform, so the same per-platform declaration and the same + // host-resolved answer, whichever way it was written. + EXPECT_EQ(with_macos->xlings.workspaceByPlatform, + with_macosx->xlings.workspaceByPlatform); + EXPECT_EQ(with_macos->xlings.workspace, with_macosx->xlings.workspace); + EXPECT_NE(std::ranges::find(with_macos->xlings.workspaceByPlatform.at("macosx"), + "llvm@20"), + with_macos->xlings.workspaceByPlatform.at("macosx").end()); + EXPECT_NE(std::ranges::find(with_macos->xlings.workspaceByPlatform.at("linux"), + "llvm@22"), + with_macos->xlings.workspaceByPlatform.at("linux").end()); +} From bc4c2c97c732e728232e7b749451a23bf56f8a15 Mon Sep 17 00:00:00 2001 From: speak-agent <248744407+speak-agent@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:39:12 +0800 Subject: [PATCH 26/26] design: the macos alias is one rule, and mcpp already had it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W3 said mcpp accepts the alias; it did not say where the fold lives. The implementation lifted it into mcpp::platform::xpkg_platform_key_for, which TargetPlatform::for_os and the manifest parser both call, and takes the host key from xpkg_platform rather than a second #if — an earlier draft hand-rolled both, which would have been a third copy of a two-line table. --- ...26-09-03-xlings-workspace-as-the-one-table.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md index 1b2b316a..1de25be8 100644 --- a/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md +++ b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md @@ -152,10 +152,18 @@ keep that one fact rather than two: `mcpp.toml:6:4: error: expected …`, which says nothing about namespaces, so the documentation shows the quotes. -**W3. The per-platform value form is xlings' own.** Native keys are `linux`, -`windows`, `macosx` and `default` (`platform::OS_NAME`, resolved by -`resolve_platform_workspace_value_`). mcpp additionally accepts `macos`, a -superset that stays; the documentation shows `macosx` as the aligned spelling. +**W3. The per-platform value form is xlings' own, and the alias is one rule.** +Native keys are `linux`, `windows`, `macosx` and `default` +(`platform::OS_NAME`, resolved by `resolve_platform_workspace_value_`). mcpp +accepts `macos` as well, because that is what its own triples say — one +platform under two vocabularies. + +The fold belongs in one place, and mcpp already had one: `TargetPlatform::for_os` +knew it. The implementation therefore lifts it into +`mcpp::platform::xpkg_platform_key_for`, which `for_os` and the manifest parser +both call, and takes the host key from `mcpp::platform::xpkg_platform` rather +than a second `#if`. An earlier draft of this section hand-rolled both, which +would have been the third copy of a two-line table. ### 2.3 What mcpp writes into the file