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..1de25be8 --- /dev/null +++ b/.agents/docs/2026-09-03-xlings-workspace-as-the-one-table.md @@ -0,0 +1,614 @@ +# `[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), 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]` 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. + +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.~~ **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. + +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. The mechanism mcpp is speaking to + +xlings gives a directory its own environment when it finds a project +`.xlings.json`: + +| 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 | +|---|---|---| +| **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 | — | + +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 +``` + +`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. + +## 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 +{ + "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" } + } +} +``` + +### 2.2 The schema + +```toml +[xlings.workspace] +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" } +``` + +**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, 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 + +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. + +**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]` — **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 +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. + +## 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 + +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: + +```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; +}; +``` + +`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 +`[xlings.workspace]`: + +| 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, the two namespace +spellings **with the quotes shown**, `""` for an unconstrained version, and +`macosx` as the aligned platform key. + +**`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. + +**`docs/05` §2.13's `envs` sentence is corrected** (§4), independently and +first. + +`docs/13` needs no change: it declares packages, not versions. Each file with +its `docs/zh/` twin, which CI enforces. + +## 7. Axes + +**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. + +**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. + +**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. + +**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. + +**Consistency.** The general form gains what its shorthand already does, and +§2.13's claim about `[toolchain]` becomes true rather than aspirational. + +**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 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. + +## 8. Test criteria + +Each must be observed failing before the corresponding change. + +| # | 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. + +## 10. Rejected and withdrawn + +**Writing only `workspace` into the file.** xlings does not install from it +(§1); the project would build where the packages happen to be installed. + +**`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. + +**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. + +**A three-phase deprecation for `deps`.** The denominator is three manifests +(§2.4). + +**Wiring `envs`.** Neither xlings structure is what a consuming project should +write (§4). + +**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). + +## 11. Open questions + +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. + +## 12. Appendix: measurements + +| 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 | + +## 13. Two artifacts share the name `.xlings.json` + +They must not be measured for each other, and an earlier draft of this document +did so. + +**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. + +**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. + +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. + +## 14. `subos`, unchanged by this proposal and stated for completeness + +- **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`). + +## 15. What the review changed + +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. + +| 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 | + +### 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 +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 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 + +**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. 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..f7e2b5d3 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -1866,63 +1866,85 @@ 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.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" } +``` ```toml [xlings] -deps = ["make@4.4", "cmake@3.28", "python@3.13"] # host build-tools to provision -subos = "dev" # a named per-project sandbox +subos = "dev" # a named, isolated environment +``` -[xlings.workspace] # pin tool versions (general form of [toolchain]) -clang = "20.1.7" +`[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. -[xlings.envs] # env vars applied to the tool environment -OPENBLAS_NUM_THREADS = "1" -``` +**`[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. + +#### 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` 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 + +| 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 | -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. +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. -**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`: +An `xlings use` performed inside the project outranks this table until mcpp +rewrites the environment, because it is the layer merged last. -```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. +#### `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/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/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..ec33db69 100644 --- a/docs/zh/05-mcpp-toml.md +++ b/docs/zh/05-mcpp-toml.md @@ -1591,54 +1591,74 @@ platforms = ["linux", "macos", "windows"] 两者都只是 warning,绝不报错:覆盖度属于发布纪律,而能作判断的人看的是发布, 不是这一次构建。 -### 2.13 `[xlings]` — 构建环境 +### 2.13 `[xlings]` — 工程的环境 + +```toml +[xlings.workspace] # 这个工程的环境里有什么 +cmake = "3.28" +picolibc-riscv = "xim:1.8.12" # 命名空间写在版本上 +"xim:qemu-user-aarch64" = "7.2.0" # 或写在键上 —— 必须带引号 +code = "" # 存在即可,版本不限 +llvm = { macosx = "20", default = "22" } +``` ```toml [xlings] -deps = ["make@4.4", "cmake@3.28", "python@3.13"] # 要供给的 host 构建工具 -subos = "dev" # 命名的项目级沙箱 +subos = "dev" # 指名的隔离环境 +``` -[xlings.workspace] # 固定工具版本([toolchain] 的通用形式) -clang = "20.1.7" +`[xlings]` 是 mcpp 对 **xlings local project 机制**的书写面:让一个目录拥有自己 +环境的那份项目 `.xlings.json`。子段名与含义都是那份文件的,mcpp 原样物化进 +`/.mcpp/.xlings.json`,没有翻译层。 -[xlings.envs] # 应用到工具环境的环境变量 -OPENBLAS_NUM_THREADS = "1" -``` +**`[xlings.workspace]` 是唯一的表。** 一条条目写出工程用哪个包、用哪个版本。 +mcpp 既供给它——机器上没有就装,有就映射——也把它物化成解析用的钉,于是工程写下 +的版本就是它的工具解析到的版本。 -声明项目的**构建环境**,经 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]` 是其通用形式。 +#### 条目的形式 -**按宿主平台取值(2026.9.2.1+)。** `deps` 的一个条目与 `[xlings.workspace]` 的一个值可以是 -按平台为键的表,即 xlings 自身 `.xlings.json` 对 `workspace` 接受的形式: +| 形式 | 含义 | +|---|---| +| `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] -deps = ["xim:ninja", { linux = "qemu-user-aarch64" }, { windows = "nasm", default = "yasm" }] +命名空间写在哪一半都可以。写在键上**必须带引号**,因为 TOML 的裸键不能含冒号。 +两半都写且不一致是错误;同一个包用两种拼法出现两次也是错误。 -[xlings.workspace] -gcc = { linux = "15.1.0" } -llvm = { macos = "20", default = "22" } -``` +平台键是 xlings 自己的 —— `linux`、`macosx`、`windows`,外加 `default`。`macos` +与 `macosx` 是同一个平台的两套词汇(mcpp 的三元组说前者,描述符与 xlings 的项目 +文件说后者),**凡是点名平台的地方两者都接受**。表里既没有本机这一项也没有 +`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/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 格式化浮点值, 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/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" 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..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. // @@ -100,55 +101,145 @@ 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. +// +// ⚠️ `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 "macos"; - else return "linux"; + return mcpp::platform::xpkg_platform; } -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()}; +// 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)) + 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, macos, 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)); - 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::string(*canon), val.as_string()); + } + 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) { + // 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; + }; + 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; } - if (chosen) return chosen; - return fallback; // may be nullopt: not declared on this host +}; + +// 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. +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 +1495,108 @@ 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 : mcpp::platform::xpkg_platforms) { + 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 (!*r) continue; + 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()) { + // 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; + }; + 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, not merely that one exists. + replacement += std::format("\n {} = \"{}\"", + entry.target, entry.pin()); } + 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 (doc->get("xlings.subos")) { - m.xlings.subosDeclared = true; - if (auto v = doc->get_string("xlings.subos")) m.xlings.subos = *v; - } - 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/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/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..8cfb95c2 100644 --- a/src/pm/publisher.cppm +++ b/src/pm/publisher.cppm @@ -120,6 +120,42 @@ 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. +// +// ⚠️ 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) +{ + 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 +197,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/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/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/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 6e4f9a4c..f367db3a 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"); } @@ -4517,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, macos, 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; } @@ -4552,3 +4556,188 @@ 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; +} + +// `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()); +}