From abc494b2c26d4a3a5e91ae30edf8d08abc77d598 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 26 Jul 2026 14:12:49 +0800 Subject: [PATCH] fix: a bare dependency's wire address takes BOTH halves from its descriptor (0.0.109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main went 6-of-7 workflows red without a line of mcpp changing. The PR run for the very same commit was green 80 minutes earlier; in between, mcpp-index #120 migrated all 48 descriptors to SPEC-001 short names. The identity gate deliberately accepts a `compat` descriptor for a bare/default- namespace request — that is how `gtest = "1.15.2"` resolves to compat.gtest at all. But the wire address then took its NAME from that descriptor and its NAMESPACE from the request, emitting `mcpplibs:gtest`, which no index is keyed by. It only ever worked because the literal `package.name` used to read `compat.gtest`, so the hardcoded `compat.` retry caught it. The short-name migration removed that coincidence and 34 of the index's 48 packages became uninstallable by bare name. So the main path was always broken and a legacy retry had been carrying it. This is a latent defect coming due, not a new regression. Evidence, from one CI job (same index, same xlings, same process): compat:zlib@1.3.2 OK <- [dependencies.compat] zlib compat:libarchive@3.8.7 OK mcpplibs:ftxui@6.1.9 FAIL <- bare [dependencies] ftxui compat.ftxui@6.1.9 FAIL Fix: `mcpp::manifest::xpkg_wire_address` derives ns and name together from the descriptor the gate accepted, and prepare.cppm uses it. Blast radius, checked against every descriptor in the live index: the 8 mcpplibs packages address exactly as before, the 6 under other namespaces are unreachable by bare name anyway, and the 34 compat ones — all currently broken — start working. A descriptor that cannot be read keeps the historical derivation byte for byte, so an unsynced index still fails where it used to. The compat retry now tries `compat:` before the legacy `compat.`, so the no-descriptor path works against both index generations. Two more failures in the same batch, same trigger: * e2e 163 built its fixture by sed'ing the literal `name = "chriskohlhoff.asio"` out of the real registry descriptor. #120 renamed it, the rewrite silently stopped matching, and the fixture carried the wrong identity — a hermetic- looking test that had upstream's exact bytes compiled into it. Rewrites now match by shape and hard-fail if they miss. * bootstrap-mcpp ran `xlings install mcpp -y` with no pin. `.xlings.json` fed only the cache key, and restore-keys prefix-matches, so jobs ran 0.0.107 and 0.0.102 against an index whose floor is 0.0.108. Warm caches hid the E0006; a cold one would not have. It now installs the pin and addresses that version by path rather than through a shim that can point elsewhere. Verification: * 9 unit cases over xpkg_wire_address covering every row of the blast-radius table, including the ones that must NOT change. * New e2e 165 reproduces the production bug hermetically: a bare dep served by a compat descriptor from a local fixture index. Run against a binary built without the fix it reports the exact production symptom (mcpplibs:widget then compat.widget); with the fix it passes. * End-to-end against the live migrated index: a bare `eigen = "5.0.1"` now resolves in one shot as `compat:eigen@5.0.1`, no fallback. Design + plan: .agents/docs/2026-07-26-bare-name-wire-address-{design,implementation-plan}.md --- ...026-07-26-bare-name-wire-address-design.md | 261 ++++++++++++++++++ ...e-name-wire-address-implementation-plan.md | 71 +++++ .github/actions/bootstrap-mcpp/action.yml | 157 ++++++++++- .github/actions/setup-macos-llvm/action.yml | 50 +++- .github/workflows/bootstrap-macos.yml | 4 +- mcpp.toml | 2 +- src/build/prepare.cppm | 76 ++--- src/manifest/xpkg.cppm | 67 +++++ src/scaffold/create.cppm | 9 +- src/toolchain/fingerprint.cppm | 2 +- tests/e2e/163_identity_first_resolution.sh | 18 +- ..._bare_name_cross_namespace_wire_address.sh | 118 ++++++++ tests/e2e/69_package_templates.sh | 33 +++ tests/unit/test_manifest.cpp | 94 +++++++ 14 files changed, 901 insertions(+), 61 deletions(-) create mode 100644 .agents/docs/2026-07-26-bare-name-wire-address-design.md create mode 100644 .agents/docs/2026-07-26-bare-name-wire-address-implementation-plan.md create mode 100755 tests/e2e/165_bare_name_cross_namespace_wire_address.sh diff --git a/.agents/docs/2026-07-26-bare-name-wire-address-design.md b/.agents/docs/2026-07-26-bare-name-wire-address-design.md new file mode 100644 index 00000000..6379889c --- /dev/null +++ b/.agents/docs/2026-07-26-bare-name-wire-address-design.md @@ -0,0 +1,261 @@ +# 裸名依赖的 wire address 收敛 — Design(mcpp 0.0.109) + +> 触发事件:mcpp-index PR #120(SPEC-001 短名迁移,2026-07-25T20:10:37Z 合入)后, +> mcpp main 的 7 个 workflow 红了 6 个。诊断报告见本文 §1,那不是新引入的回归。 + +## 1. 事实与证据 + +### 1.1 时间线 + +| 时间 (UTC) | 事件 | 结果 | +|---|---|---| +| 2026-07-25 18:43 / 18:58 | PR #282 的 CI(**与 main 完全相同的 mcpp 代码**) | ✅ 全绿 | +| **2026-07-25 20:10:37** | **mcpp-index #120 合入**(48 个描述符 FQN → 短名,floor → 0.0.108) | — | +| 2026-07-25 20:18:22 | #282 squash 进 mcpp main,push 触发 CI | ❌ 7 个 workflow 红 6 个 | + +mcpp 侧 diff 只有 `.xlings.json` 一行。**代码没变,索引变了。** + +### 1.2 决定性对照(`integration: mcpp builds & runs xlings`,同一次运行、同一进程) + +`MCPP_VERBOSE=1` 抓到的真实 xlings 调用: + +``` +targets:["compat:zlib@1.3.2"] → ✅ +targets:["compat:lz4@1.10.0"] → ✅ +targets:["compat:xz@5.8.3"] → ✅ +targets:["compat:zstd@1.5.7"] → ✅ +targets:["compat:libarchive@3.8.7"] → ✅ ← 依赖写的是限定名 compat.xxx +targets:["mcpplibs:ftxui@6.1.9"] → ❌ +targets:["compat.ftxui@6.1.9"] → ❌ ← 依赖写的是裸名 ftxui +``` + +**限定名走 `compat:` 全通;裸名走 `mcpplibs:` 全挂。** 正确地址是 +`compat:ftxui@6.1.9`,两条尝试都没打中。 + +### 1.3 根因 + +裸名 `gtest = "1.15.2"` 解析为 `ns=kDefaultNamespace("mcpplibs")` + `short=gtest` +(`src/pm/dep_spec.cppm:58`)。 + +**第一步:身份门刻意放宽,接受 compat 描述符。** +`src/manifest/xpkg.cppm` `xpkg_lua_identity_matches`: + +```cpp +if (ns == kDefaultNamespace) { + return id.ns == kDefaultNamespace + || id.ns == kCompatNamespace // ← compat.gtest.lua 从这里被接受 + || (allowLegacyBareDefault && id.ns.empty()); +} +``` + +**第二步:拼 wire target 时,name 取自描述符,namespace 取自请求。** +`src/build/prepare.cppm:1864`: + +```cpp +auto wireName = luaContent ? extract_xpkg_name(*luaContent) : ""; // "gtest" ← 描述符 +auto target = std::format("{}:{}@{}", ns, wireName, version); // ns="mcpplibs" ← 请求 +``` + +身份门认下的是 `(compat, gtest)`,发出去的却是 `mcpplibs:gtest`。 +**同一次解析,身份有两个来源。** + +**第三步:兜底写死迁移前的字面名。** + +```cpp +auto compatTarget = std::format("compat.{}@{}", shortName, version); +``` + +### 1.4 为什么迁移前是绿的 + +迁移前 `extract_xpkg_name` 返回 `"compat.gtest"` → 首选 target `mcpplibs:compat.gtest` +(同样是错的,同样挂)→ 兜底 `compat.gtest@1.15.2` **恰好命中旧字面名,救了回来**。 + +**主路径一直是坏的,一直靠那条写死的 legacy 兜底在扛。** #120 抽掉了兜底命中的 +字面名,主路径的 bug 才现形。这是存量缺陷到期,不是新回归。 + +### 1.5 改动面(对现网索引 48 个描述符逐个核对) + +| 裸名请求能解析到的描述符 | 数量 | 修复后 | +|---|---|---| +| `namespace = "mcpplibs"`(cmdline / tinyhttps / opencv / imgui / ffmpeg / llmapi / templates / xpkg) | 8 | **无变化**(wireNs == 请求 ns) | +| `namespace = "compat"` | 34 | `mcpplibs:X` → `compat:X`,**这 34 个当前 100% 是坏的** | +| `namespace` 为空的 legacy 描述符 | **0** | 边界情况在真实索引里不存在 | +| 其他 ns(chriskohlhoff / fmtlib / marzer / nlohmann / aimol / mcpplibs.capi) | 6 | 身份门本就拒绝裸名请求,够不到 | + +**只有当前全坏的 34 个会变。没坏的一个都不动。** + +### 1.6 第二个独立失效面:e2e 163 的 fixture 是对上游描述符做 sed + +`tests/e2e/163_identity_first_resolution.sh:42` 把注册表里真实的 +`chriskohlhoff.asio.lua` sed 成测试 fixture: + +```bash +sed -e "s/name = \"chriskohlhoff.asio\"/name = \"$4\"/" +``` + +#120 把 asio 也迁了(`name = "chriskohlhoff.asio"` → `"asio"`),**第二条 sed 不再匹配**, +fixture 身份变成 `(acme, asio)`,而 app 请求 `acme:widget`: + +``` +error: dependency 'acme.widget': not found in local index at '.../idx1' +``` + +一个看起来 hermetic(`mktemp -d`、无网络)的测试,实际把上游描述符的**逐字文本** +编进了断言。linux shard2 / windows shard2 / macOS 三处同时挂。 + +### 1.7 第三个(潜伏):bootstrap 版本漂移 vs 索引 floor + +``` +error: index requires mcpp >= 0.0.108 but this is mcpp 0.0.107 [E0006] # linux +error: index requires mcpp >= 0.0.108 but this is mcpp 0.0.102 [E0006] # windows +``` + +`.github/actions/bootstrap-mcpp/action.yml` 跑的是 **`xlings install mcpp -y`——没有版本 +pin**。`.xlings.json` 里的 pin 只进了 cache key;而 `restore-keys` 前缀匹配会把旧 cache +捞回来,`install` 幂等地保留缓存里的任意旧版本。 + +当前被热 cache 掩盖(`mcpplibs.cmdline` 已缓存,不需要读索引)。**冷 cache 一来就是硬失败。** + +### 1.8 索引侧的流程盲区(为什么 #120 自己 8 个 check 全绿) + +mcpp-index 每个 example 用的都是限定名 + path 索引: + +```toml +[indices] +compat = { path = "../../.." } +[dependencies.compat] +gtest = { version = "1.15.2", features = ["main"] } +``` + +`[dependencies.compat]` 走 `compat:`,正是**唯一还能通的那条路**;而所有真实 +消费者(含 mcpp 自己的 `mcpp.toml`)写的是裸名。**验证矩阵与消费矩阵不同构** → +结构性假绿。(与 `xcb-link-regression` / `spec001` 两次是同一类。) + +--- + +## 2. 设计 + +### 2.1 原则 + +> **身份门认下谁,就用谁的两半。** + +`read_xpkg_lua*` 家族已经用 `xpkg_lua_identity_matches` 把一个描述符**认定为**这次请求 +的提供者。既然认定了,wire address 的 namespace 和 name 就都必须来自它——不能一半取 +描述符、一半取请求。 + +这与 SPEC-001 §6 一致:wire key 是字面 `package.name`,地址是 +`:`。 + +### 2.2 新增纯函数(可单测) + +`prepare.cppm` 里那段逻辑很难单测。把它抽成 `src/manifest/xpkg.cppm` 的纯函数—— +`canonical_xpkg_identity_from_lua` 就在隔壁,身份门也在那里: + +```cpp +struct XpkgWireAddress { + std::string ns; // effective namespace("" = 无命名空间包) + std::string name; // 字面 package.name(SPEC-001 §6 的 wire key) + std::string target; // ":" 或 "" +}; + +XpkgWireAddress xpkg_wire_address(std::string_view luaContent, // 可为空 + std::string_view requestNs, + std::string_view shortName); +``` + +语义: + +| luaContent | ns | name | +|---|---|---| +| 有,且声明了 namespace | 描述符的 effective ns | 字面 `package.name` | +| 有,但没声明 namespace | `requestNs`(= `indexDefaultNs` 语义) | 字面 `package.name` | +| 无 / 无 name | `requestNs` | `requestNs.empty() ? shortName : "."`(**保持历史推导不变**) | + +最后一行是关键:描述符读不到时**不改变任何现有行为**,失败点留在原地。 + +### 2.3 兜底链加宽 + +兜底只在首选 target 失败后触发。当前只试 `compat.`(迁移前字面名)。 +新索引下该字面名已不存在,因此补上 SPEC-001 拼法: + +1. `compat:@` — SPEC-001 短名形态(**新增**) +2. `compat.@` — legacy 字面名(保留) + +两条都只在与首选 target 不同时才发。这让「描述符读不到」的路径对新旧两种索引都成立。 + +### 2.4 不做的事 + +- **不改 `xpkg_lua_identity_matches` 的放宽规则。** 裸名能解析到 compat 是有意设计 + (`gtest = "1.15.2"` 必须能用),本次只修地址推导。 +- **不动 xlings 任何规范。** +- **不处理 `imgui`/`ffmpeg` 在 mcpplibs 与 compat 下各有一份描述符、靠候选文件名顺序 + 消歧这件事。** 那是独立的存量歧义(与 #278 同类),修复保持现有结果不变 + (`imgui.lua` 先于 `compat.imgui.lua`,mcpplibs 胜出),另开 issue。 + +--- + +## 3. 验证策略 + +### 3.1 单测(`tests/unit/test_manifest.cpp`) + +针对 `xpkg_wire_address` 的纯函数矩阵——覆盖 §1.5 表格的每一行: + +- compat 描述符 + 裸名请求 → `compat:gtest`(**当前 bug 的直接锁定**) +- mcpplibs 描述符 + 裸名请求 → `mcpplibs:cmdline`(不变) +- legacy FQN 描述符(`ns=compat, name=compat.zlib`)+ 裸名请求 → `compat:compat.zlib` +- 限定请求(`ns=compat`)+ compat 描述符 → `compat:gtest`(不变) +- 嵌套 ns(`ns=mcpplibs.capi, name=lua`)→ `mcpplibs.capi:lua`(不变) +- 无 luaContent → 历史推导 `mcpplibs:mcpplibs.gtest`(不变) +- 描述符无 namespace 声明 → 回落 requestNs + +### 3.2 e2e(新增 `165_bare_name_cross_namespace_wire_address.sh`) + +**必须 hermetic 地复现生产 bug**——这是本次最重要的一条,因为索引侧的假绿正是 +"验证矩阵与消费矩阵不同构"造成的。 + +用 `[indices] mcpplibs = { path = ... }` 把默认命名空间路由到本地 fixture 索引, +索引里放一个 `namespace = "compat", name = "widget"` 的描述符,app 写**裸名**依赖: + +```toml +[indices] +mcpplibs = { path = "../idx" } +[dependencies] +widget = "1.38.1" +``` + +- 修复前:target = `mcpplibs:widget` → 装不上 +- 修复后:target = `compat:widget` → 装上,落在 `compat-x-widget` + +断言 store 目录 `compat-x-widget` 存在——直接锁住 wire namespace 取值。 + +### 3.3 e2e 163 去耦合 + +sed pattern 改为不依赖旧拼写: + +```bash +sed -e "s/^\( *namespace *= *\)\"[^\"]*\"/\1\"$3\"/" \ + -e "s/^\( *name *= *\)\"[^\"]*\"/\1\"$4\"/" +``` + +(更彻底是 fixture 完全自造,但那会丢掉「用真实描述符跑通整条 install 路径」这个 +本来有价值的性质,故只做 pattern 去耦合。) + +### 3.4 bootstrap pin + +`xlings install mcpp@ -y`,pin 从 `.xlings.json` 读,装完断言 +`mcpp --version` 与 pin 相等——版本漂移立刻可见,而不是等冷 cache 才炸。 + +--- + +## 4. 交付 + +- 单 PR,版本 **0.0.109**(`mcpp.toml` + `src/toolchain/fingerprint.cppm` 两处同步)。 +- CI 全绿(7 workflow)→ bypass squash 合入。 +- release 0.0.109 → 镜像 xlings-res 双端 → xim-pkgindex → 真装验证 → bootstrap pin bump。 + +## 5. 后续(不在本 PR) + +- mcpp-index 补一个「裸名 + 远端 git 索引」的 example,堵住 §1.8 的盲区。 +- mcpp-index 清 `chriskohlhoff.asio.lua:41-48` 的陈旧注释(还写着 `name` MUST be FQN, + 第 49 行已是 `name = "asio"`)。 +- `imgui`/`ffmpeg` 双描述符靠文件名顺序消歧 → 独立 issue。 diff --git a/.agents/docs/2026-07-26-bare-name-wire-address-implementation-plan.md b/.agents/docs/2026-07-26-bare-name-wire-address-implementation-plan.md new file mode 100644 index 00000000..56e0189c --- /dev/null +++ b/.agents/docs/2026-07-26-bare-name-wire-address-implementation-plan.md @@ -0,0 +1,71 @@ +# 裸名 wire address 修复 — Implementation Plan(mcpp 0.0.109) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans。步骤用 `- [ ]` 跟踪。 + +**Design:** `.agents/docs/2026-07-26-bare-name-wire-address-design.md` + +**Goal:** 修复三个失效面(A 裸名 wire address / B e2e 163 fixture 耦合 / C bootstrap 版本 +漂移)→ 单 PR(0.0.109)→ CI 全绿 → bypass squash 合入 → release + xlings 全生态验证。 + +## Global Constraints + +- **不改 `xpkg_lua_identity_matches` 的放宽规则**(裸名解析到 compat 是有意设计)。 +- **不改 xlings 任何规范。** +- 描述符读不到时,行为**逐字节不变**(失败点留在原地)。 +- 现网 48 个描述符:8 个 mcpplibs 不变、6 个其他 ns 够不到、34 个 compat 当前全坏 → 修好。 +- 既有单测 + 164 例 e2e 全绿。 +- 版本两处同步:`mcpp.toml:3` + `src/toolchain/fingerprint.cppm:21`(0.0.105 踩过)。 + +## Tasks + +### P1 — 失效面 A:wire address 收敛 + +- [x] **A1** `src/manifest/xpkg.cppm`:新增 `struct XpkgWireAddress` + + `xpkg_wire_address(luaContent, requestNs, shortName)`,语义见 design §2.2。 + 导出到模块接口。 +- [x] **A2** `tests/unit/test_manifest.cpp`:`xpkg_wire_address` 七格矩阵(design §3.1)。 + **先写,先看它红。** +- [x] **A3** `src/build/prepare.cppm:1864` 起:改用 `xpkg_wire_address`, + target 由它给出。删掉就地的 `wireName` 推导。 +- [x] **A4** `src/build/prepare.cppm` 兜底链加宽:`compat:` 在前、 + `compat.` 在后,各自与首选 target 去重(design §2.3)。 +- [x] **A5** 新增 e2e `tests/e2e/165_bare_name_cross_namespace_wire_address.sh` + (design §3.2)——hermetic 复现生产 bug,断言 store 目录 `compat-x-widget`。 + +### P2 — 失效面 B:e2e 163 去耦合 + +- [x] **B1** `tests/e2e/163_identity_first_resolution.sh:42` sed pattern 改为 + 不依赖描述符旧拼写(design §3.3)。 +- [x] **B2** 本地跑 163 验证四个 sub-case(step 4 依赖 xlings >= 0.4.69,按现有逻辑 + 自动 skip 则接受)。 + +### P3 — 失效面 C:bootstrap pin + +- [x] **C1** `.github/actions/bootstrap-mcpp/action.yml`:unix + windows 两段 + `xlings install mcpp -y` → 读 `.xlings.json` 的 pin 装指定版本。 +- [x] **C2** 装完断言 `mcpp --version` == pin,不等则 fail(不再静默漂移)。 + +### P4 — 版本 + 收口 + +- [x] **D1** 版本 0.0.108 → 0.0.109,两处同步。 +- [x] **D2** 本地全量单测。 +- [ ] **D3** 本地 e2e(至少 100 / 163 / 165 / 78 / 79 + 全量能跑的部分)。 +- [ ] **D4** 单 PR(附版本)→ 7 个 workflow 全绿。 +- [ ] **D5** bypass squash 合入 main。 + +### P5 — xlings 全生态验证 + +- [ ] **E1** release v0.0.109(四平台)。 +- [ ] **E2** 镜像 xlings-res 双端(github + gitcode),sha256 独立核验。 +- [ ] **E3** xim-pkgindex PR。 +- [ ] **E4** 干净 XLINGS_HOME 真装 `xlings install mcpp@0.0.109` 并跑通。 +- [ ] **E5** bootstrap pin `.xlings.json` → 0.0.109(独立 PR)。 + +## 风险与对策 + +| 风险 | 对策 | +|---|---| +| `compat:compat.zlib`(legacy FQN + 裸名请求)xlings 不认 | e2e 163 step 2 已覆盖等价形态(`acme:acme.widget` → `acme-x-acme.widget`),迁移前它是绿的 | +| 改动波及 8 个 mcpplibs 包 | 单测显式锁 `mcpplibs:cmdline` 不变;e2e 全量兜底 | +| CI 热 cache 掩盖 C1 效果 | C2 的版本断言让漂移立刻可见 | +| 索引侧假绿复发 | A5 是 hermetic 的,不依赖远端索引内容 | diff --git a/.github/actions/bootstrap-mcpp/action.yml b/.github/actions/bootstrap-mcpp/action.yml index 25727485..393c8f7a 100644 --- a/.github/actions/bootstrap-mcpp/action.yml +++ b/.github/actions/bootstrap-mcpp/action.yml @@ -13,7 +13,14 @@ inputs: xlings-version: description: xlings release to bootstrap from required: false - default: '0.4.30' + # 0.4.69 is the floor the CURRENT index needs, not a routine bump. Its + # SPEC-001 short-name migration put two packages named `lua` in one repo + # (`compat:lua` and `mcpplibs.capi:lua`, both pulled in transitively by + # mcpplibs.xpkg). Before 0.4.69 (openxlings/xlings#381) a repo keyed its + # table by the bare `package.name`, so one of the two was simply + # unreachable — and which one depended on the machine, which is why CI + # failed on `compat:lua` on Windows and `mcpplibs.capi:lua` on Linux. + default: '0.4.69' cache-target: description: also restore/save target/ (build artifacts + BMIs) required: false @@ -30,17 +37,23 @@ runs: uses: actions/cache@v4 with: path: ~/.mcpp - key: mcpp-sandbox-${{ runner.os }}-ci-${{ hashFiles('mcpp.toml', '.xlings.json') }} + # The xlings version is part of the LINEAGE, not just the key: mcpp + # vendors xlings into ~/.mcpp/registry/bin once at `self init` and + # never revisits it (acquire_xlings_binary returns early when the file + # exists). With the version only in the key, restore-keys would hand a + # 0.4.30 sandbox to a 0.4.69 bootstrap and the sandbox — which is what + # actually resolves dependencies — would silently stay behind. + key: mcpp-sandbox-${{ runner.os }}-ci-xl${{ inputs.xlings-version }}-${{ hashFiles('mcpp.toml', '.xlings.json') }} restore-keys: | - mcpp-sandbox-${{ runner.os }}-ci- + mcpp-sandbox-${{ runner.os }}-ci-xl${{ inputs.xlings-version }}- - name: Cache xlings uses: actions/cache@v4 with: path: ~/.xlings - key: xlings-${{ runner.os }}-v2-${{ hashFiles('.xlings.json') }} + key: xlings-${{ runner.os }}-v2-xl${{ inputs.xlings-version }}-${{ hashFiles('.xlings.json') }} restore-keys: | - xlings-${{ runner.os }}-v2- + xlings-${{ runner.os }}-v2-xl${{ inputs.xlings-version }}- - name: Bootstrap mcpp via xlings (unix) if: runner.os != 'Windows' @@ -49,6 +62,7 @@ runs: XLINGS_NON_INTERACTIVE: '1' XLINGS_VERSION: ${{ inputs.xlings-version }} run: | + REPO_DIR="$(pwd)" # Always install the pinned version — the cache may hold an older # xlings whose sysroot/packages are incompatible. case "$(uname -s)" in @@ -63,12 +77,82 @@ runs: export PATH="$HOME/.xlings/subos/default/bin:$PATH" echo "$HOME/.xlings/subos/default/bin" >> "$GITHUB_PATH" xlings --version - # xim:mcpp — `xlings install` is idempotent, so cache hits skip the - # download. - xlings install mcpp -y - MCPP="$HOME/.xlings/subos/default/bin/mcpp" - test -x "$MCPP" + # xim:mcpp — pinned, NOT bare `xlings install mcpp`. + # + # A bare install is idempotent, so on a cache hit it keeps whatever + # version the cache already holds. `.xlings.json` only fed the cache + # KEY, and `restore-keys` prefix-matches, so a stale sandbox got + # restored and the pin was never applied: jobs ran 0.0.107 and 0.0.102 + # against an index whose floor was 0.0.108 (E0006). Warm caches hid it + # — a cold one would have been a hard failure. + # Absolute, POSIX-form path captured before any `cd`: the Windows leg + # cds into the unpack dir and never returns, so a relative + # `.xlings.json` resolves to nothing there — and GITHUB_WORKSPACE is a + # backslash Windows path that git-bash tools mangle. + XJSON="$REPO_DIR/.xlings.json" + MCPP_PIN=$(grep -oE '"mcpp"[[:space:]]*:[[:space:]]*"[^"]+"' "$XJSON" \ + | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) + test -n "$MCPP_PIN" || { echo "FAIL: no mcpp pin in $XJSON"; exit 1; } + echo "bootstrap pin: mcpp@${MCPP_PIN}" + # Address the pinned version through ITS OWN version directory rather + # than the shim in subos/default/bin (which is a symlink to xlings and + # resolves to whatever version is currently selected — exactly the + # drift this pin exists to prevent). + # + # Locate it by searching inside `xim-x-mcpp//` instead of assuming + # the layout underneath: it is `/mcpp` on Linux and `/bin/ + # mcpp.exe` on Windows, and the roots differ too. Anything found under + # that directory IS the pinned version, so this keeps the guarantee + # without hardcoding a shape that varies per platform. + find_pinned_mcpp() { + local d r + for d in "$HOME/.xlings/data/xpkgs/xim-x-mcpp/${MCPP_PIN}" \ + "$HOME/.xlings/subos/default/data/xpkgs/xim-x-mcpp/${MCPP_PIN}"; do + [ -d "$d" ] || continue + r=$(find "$d" -type f -name mcpp 2>/dev/null | head -1) + [ -n "$r" ] && { echo "$r"; return 0; } + done + # Last resort: the roots above are the two layouts seen in practice, + # but the VERSION DIRECTORY is the actual contract — find it wherever + # xlings put it rather than fail on a layout we have not met yet. + find "$HOME/.xlings" -type d -path "*xim-x-mcpp/${MCPP_PIN}" \ + -exec find {} -type f -name mcpp ';' 2>/dev/null | head -1 + } + xlings install "mcpp@${MCPP_PIN}" -y || true + MCPP=$(find_pinned_mcpp) + if [ -z "$MCPP" ]; then + # A restored ~/.xlings can carry a xim-pkgindex clone that predates + # this release, and the refresh marker can read fresh while the clone + # is behind. Refresh once and retry before calling it a failure. + echo "pinned install did not land; refreshing the index and retrying" + xlings update || true + xlings install "mcpp@${MCPP_PIN}" -y || true + MCPP=$(find_pinned_mcpp) + fi + test -n "$MCPP" || { + echo "FAIL: no mcpp binary under xim-x-mcpp/${MCPP_PIN}" + ls -d "$HOME"/.xlings/data/xpkgs/xim-x-mcpp/* \ + "$HOME"/.xlings/subos/default/data/xpkgs/xim-x-mcpp/* 2>/dev/null + exit 1; } "$MCPP" --version + # Compare the parsed version exactly: a substring match would let pin + # 0.0.10 be satisfied by a 0.0.109 binary. + MCPP_GOT=$("$MCPP" --version 2>/dev/null | head -1 \ + | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) + [ "$MCPP_GOT" = "$MCPP_PIN" ] || { + echo "FAIL: mcpp at $MCPP reports '$MCPP_GOT', pin is '$MCPP_PIN'"; exit 1; } + # The SANDBOX xlings is a separate copy: mcpp vendors it into + # ~/.mcpp/registry/bin at `self init` and never revisits it + # (acquire_xlings_binary returns early when the file exists), while + # ~/.mcpp is restored from cache by prefix. So it can lag the system + # one by a lot, and it — not the system binary — is what resolves + # dependencies. Print both; a mismatch is worth seeing in the log. + echo "system xlings: $(xlings --version 2>/dev/null | head -1)" + if [ -x "$HOME/.mcpp/registry/bin/xlings" ]; then + echo "sandbox xlings: $("$HOME/.mcpp/registry/bin/xlings" --version 2>/dev/null | head -1)" + else + echo "sandbox xlings: (not initialised yet)" + fi echo "MCPP=$MCPP" >> "$GITHUB_ENV" echo "XLINGS_BIN=$HOME/.xlings/subos/default/bin/xlings" >> "$GITHUB_ENV" @@ -79,6 +163,7 @@ runs: XLINGS_NON_INTERACTIVE: '1' XLINGS_VERSION: ${{ inputs.xlings-version }} run: | + REPO_DIR="$(pwd)" WORK=$(mktemp -d) zipfile="xlings-${XLINGS_VERSION}-windows-x86_64.zip" curl -fsSL -o "${WORK}/${zipfile}" \ @@ -89,13 +174,57 @@ runs: export PATH="$USERPROFILE/.xlings/subos/default/bin:$PATH" echo "$USERPROFILE/.xlings/subos/default/bin" >> "$GITHUB_PATH" xlings.exe --version - xlings.exe install mcpp -y - MCPP=$(find "$USERPROFILE/.xlings" -name "mcpp.exe" -path "*/bin/*" 2>/dev/null | head -1) + # Pinned install — see the unix leg for why a bare `install mcpp` let + # the version drift behind the index floor unnoticed. The old `find | + # head -1` here made it worse: with several versions in the sandbox it + # returned whichever the directory walk hit first. + # Absolute, POSIX-form path captured before any `cd`: the Windows leg + # cds into the unpack dir and never returns, so a relative + # `.xlings.json` resolves to nothing there — and GITHUB_WORKSPACE is a + # backslash Windows path that git-bash tools mangle. + XJSON="$REPO_DIR/.xlings.json" + MCPP_PIN=$(grep -oE '"mcpp"[[:space:]]*:[[:space:]]*"[^"]+"' "$XJSON" \ + | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) + test -n "$MCPP_PIN" || { echo "FAIL: no mcpp pin in $XJSON"; exit 1; } + echo "bootstrap pin: mcpp@${MCPP_PIN}" + # Search inside the pinned version's own directory rather than assuming + # the layout under it — see the unix leg. + find_pinned_mcpp() { + local d r + for d in "$USERPROFILE/.xlings/subos/default/data/xpkgs/xim-x-mcpp/${MCPP_PIN}" \ + "$USERPROFILE/.xlings/data/xpkgs/xim-x-mcpp/${MCPP_PIN}"; do + [ -d "$d" ] || continue + r=$(find "$d" -type f -name "mcpp.exe" 2>/dev/null | head -1) + [ -n "$r" ] && { echo "$r"; return 0; } + done + find "$USERPROFILE/.xlings" -type d -path "*xim-x-mcpp/${MCPP_PIN}" \ + -exec find {} -type f -name "mcpp.exe" ';' 2>/dev/null | head -1 + } + xlings.exe install "mcpp@${MCPP_PIN}" -y || true + MCPP=$(find_pinned_mcpp) if [ -z "$MCPP" ]; then - MCPP=$(find "$USERPROFILE/.xlings" -name "mcpp" -path "*/bin/*" 2>/dev/null | head -1) + echo "pinned install did not land; refreshing the index and retrying" + xlings.exe update || true + xlings.exe install "mcpp@${MCPP_PIN}" -y || true + MCPP=$(find_pinned_mcpp) fi - test -n "$MCPP" || { echo "FAIL: mcpp not found after xlings install"; exit 1; } + test -n "$MCPP" || { + echo "FAIL: no mcpp.exe under xim-x-mcpp/${MCPP_PIN}" + ls -d "$USERPROFILE"/.xlings/subos/default/data/xpkgs/xim-x-mcpp/* \ + "$USERPROFILE"/.xlings/data/xpkgs/xim-x-mcpp/* 2>/dev/null + exit 1; } "$MCPP" --version + MCPP_GOT=$("$MCPP" --version 2>/dev/null | head -1 \ + | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) + [ "$MCPP_GOT" = "$MCPP_PIN" ] || { + echo "FAIL: mcpp at $MCPP reports '$MCPP_GOT', pin is '$MCPP_PIN'"; exit 1; } + # See the unix leg: the sandbox keeps its own vendored xlings copy. + echo "system xlings: $(xlings.exe --version 2>/dev/null | head -1)" + if [ -x "$USERPROFILE/.mcpp/registry/bin/xlings.exe" ]; then + echo "sandbox xlings: $("$USERPROFILE/.mcpp/registry/bin/xlings.exe" --version 2>/dev/null | head -1)" + else + echo "sandbox xlings: (not initialised yet)" + fi echo "MCPP=$MCPP" >> "$GITHUB_ENV" echo "XLINGS_BIN=$(cygpath -w "$USERPROFILE/.xlings/subos/default/bin/xlings.exe")" >> "$GITHUB_ENV" diff --git a/.github/actions/setup-macos-llvm/action.yml b/.github/actions/setup-macos-llvm/action.yml index b31382ad..b8d0cbe9 100644 --- a/.github/actions/setup-macos-llvm/action.yml +++ b/.github/actions/setup-macos-llvm/action.yml @@ -12,7 +12,10 @@ inputs: xlings-version: description: xlings release to bootstrap from required: false - default: '0.4.30' + # Floor imposed by the index, not a routine bump — see + # .github/actions/bootstrap-mcpp/action.yml for why 0.4.69 is required + # (two packages named `lua` in one repo need openxlings/xlings#381). + default: '0.4.69' runs: using: composite @@ -21,9 +24,9 @@ runs: uses: actions/cache@v4 with: path: ~/.xlings - key: xlings-macos15-arm64-v3-${{ hashFiles('.xlings.json') }} + key: xlings-macos15-arm64-v3-xl${{ inputs.xlings-version }}-${{ hashFiles('.xlings.json') }} restore-keys: | - xlings-macos15-arm64-v3- + xlings-macos15-arm64-v3-xl${{ inputs.xlings-version }}- - name: Bootstrap xlings shell: bash @@ -58,10 +61,45 @@ runs: - name: Bootstrap mcpp via xlings shell: bash run: | - xlings install mcpp -y - MCPP="$HOME/.xlings/subos/default/bin/mcpp" - test -x "$MCPP" + # Pinned to .xlings.json, same as .github/actions/bootstrap-mcpp. + # A bare `xlings install mcpp` takes "newest in whatever index copy + # this runner happens to have", which on a cold cache resolved 0.0.105 + # — below the index's own floor (0.0.108). The bootstrap then could not + # read ANY descriptor (the floor check makes the read return nothing), + # so every dependency fell back to its legacy derived address and the + # build died on `mcpplibs.cmdline@0.0.1`. + MCPP_PIN=$(grep -oE '"mcpp"[[:space:]]*:[[:space:]]*"[^"]+"' "$GITHUB_WORKSPACE/.xlings.json" \ + | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) + test -n "$MCPP_PIN" || { echo "FAIL: no mcpp pin in .xlings.json"; exit 1; } + echo "bootstrap pin: mcpp@${MCPP_PIN}" + find_pinned_mcpp() { + local d r + for d in "$HOME/.xlings/data/xpkgs/xim-x-mcpp/${MCPP_PIN}" \ + "$HOME/.xlings/subos/default/data/xpkgs/xim-x-mcpp/${MCPP_PIN}"; do + [ -d "$d" ] || continue + r=$(find "$d" -type f -name mcpp 2>/dev/null | head -1) + [ -n "$r" ] && { echo "$r"; return 0; } + done + find "$HOME/.xlings" -type d -path "*xim-x-mcpp/${MCPP_PIN}" \ + -exec find {} -type f -name mcpp ';' 2>/dev/null | head -1 + } + xlings install "mcpp@${MCPP_PIN}" -y || true + MCPP=$(find_pinned_mcpp) + if [ -z "$MCPP" ]; then + echo "pinned install did not land; refreshing the index and retrying" + xlings update || true + xlings install "mcpp@${MCPP_PIN}" -y || true + MCPP=$(find_pinned_mcpp) + fi + test -n "$MCPP" || { + echo "FAIL: no mcpp binary under xim-x-mcpp/${MCPP_PIN}" + ls -d "$HOME"/.xlings/data/xpkgs/xim-x-mcpp/* 2>/dev/null + exit 1; } "$MCPP" --version + MCPP_GOT=$("$MCPP" --version 2>/dev/null | head -1 \ + | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) + [ "$MCPP_GOT" = "$MCPP_PIN" ] || { + echo "FAIL: mcpp at $MCPP reports '$MCPP_GOT', pin is '$MCPP_PIN'"; exit 1; } echo "MCPP=$MCPP" >> "$GITHUB_ENV" echo "XLINGS_BIN=$HOME/.xlings/subos/default/bin/xlings" >> "$GITHUB_ENV" diff --git a/.github/workflows/bootstrap-macos.yml b/.github/workflows/bootstrap-macos.yml index 33fffc4d..19e2594c 100644 --- a/.github/workflows/bootstrap-macos.yml +++ b/.github/workflows/bootstrap-macos.yml @@ -14,7 +14,9 @@ jobs: timeout-minutes: 30 env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '0.4.30' + # Dormant (workflow_dispatch only), but kept in step with the rest: the + # index needs 0.4.69 to resolve two packages that share a short name. + XLINGS_VERSION: '0.4.69' steps: - uses: actions/checkout@v4 diff --git a/mcpp.toml b/mcpp.toml index ea908d6a..a5f7e1f8 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "0.0.108" +version = "0.0.109" description = "Modern C++ build & package management tool" license = "Apache-2.0" authors = ["mcpp-community"] diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index e9f99c0a..e2a8bd06 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -1846,28 +1846,14 @@ prepare_build(bool print_fingerprint, } } - // The package name xlings is asked for is the descriptor's LITERAL - // `package.name` — never a value mcpp re-derives (SPEC-001 §6). - // - // xlings keys its index by that literal and addresses packages as - // `:`. mcpp already holds the - // descriptor here (`luaContent`, read above), so it can hand over - // the exact string instead of reconstructing one that only happens - // to match when the descriptor spells `name` as `.`. - // Re-deriving it is what made split-form descriptors uninstallable - // (#278) and what forced every index descriptor into the redundant - // fully-qualified spelling. - // - // Fallback: when the descriptor could not be read (network index - // not synced yet), keep the historical derivation so the failure - // stays where it was rather than moving earlier. - auto wireName = luaContent - ? mcpp::manifest::extract_xpkg_name(*luaContent) - : std::string{}; - if (wireName.empty()) { - wireName = ns.empty() ? shortName - : std::format("{}.{}", ns, shortName); - } + // The address xlings is asked for is `:` (SPEC-001 §6), and BOTH halves come from the + // descriptor the identity gate accepted — see + // `mcpp::manifest::xpkg_wire_address` for why splitting the two + // sources is the bug it is. + auto wireAddr = mcpp::manifest::xpkg_wire_address( + luaContent ? std::string_view(*luaContent) : std::string_view{}, + ns, shortName); if (luaContent) { if (auto violation = mcpp::manifest:: xpkg_name_form_violation_from_lua(*luaContent)) { @@ -1918,24 +1904,37 @@ prepare_build(bool print_fingerprint, // The colon prefix is xlings' *effective namespace*, matched against // the descriptor's own `package.namespace` (xlings issue-381 design // §2.2) — NOT the index name. mcpp's `[indices] = {...}` keys - // ARE namespaces, so the two coincided historically; spelling it - // from `ns` makes that intentional rather than accidental, and is - // what disambiguates two same-short-name packages living in one - // index (xlings >= 0.4.69). + // ARE namespaces, so the two coincide for a qualified request; for a + // bare one they do NOT, which is exactly why the namespace has to be + // read off the descriptor rather than off `ns`. // // A namespace-less upstream package (xim `opencv`) is addressed by // its bare literal name, with no prefix. - auto target = ns.empty() - ? std::format("{}@{}", wireName, version) - : std::format("{}:{}@{}", ns, wireName, version); + auto target = std::format("{}@{}", wireAddr.target, version); + // Keep every address we actually put on the wire. Diagnosing the + // 2026-07-25 breakage needed MCPP_VERBOSE=1 to discover that mcpp + // had asked for `mcpplibs:gtest` — the error itself only named the + // dependency, which is the one thing nobody doubts. + std::vector attempted{ target }; auto r = install_one(target); if (r && r->exitCode != 0 && (ns.empty() || ns == mcpp::pm::kDefaultNamespace)) { - auto compatTarget = std::format("compat.{}@{}", shortName, version); - if (compatTarget != target) { + // Compat retry for a bare/default-namespace request whose + // descriptor could not be read (no `wireAddr` to trust): the + // package may still be a `compat` one. Try BOTH spellings — a + // SPEC-001 index keys it `compat:`, a pre-SPEC-001 index + // keys it by the literal `compat.`. Sending only the + // latter is what left the retry pointing at a name the migrated + // index no longer has. + for (auto&& compatTarget : { + std::format("compat:{}@{}", shortName, version), + std::format("compat.{}@{}", shortName, version) }) { + if (compatTarget == target) continue; mcpp::ui::info("Downloading", std::format("{} v{}", - std::format("compat.{}", shortName), version)); + compatTarget.substr(0, compatTarget.rfind('@')), version)); + attempted.push_back(compatTarget); r = install_one(compatTarget); + if (!r || r->exitCode == 0) break; } } if (!r) return std::unexpected(std::format( @@ -1959,9 +1958,16 @@ prepare_build(bool print_fingerprint, childErr += r->error->message; } auto target = std::format("{}@{}", depName, version); - return std::unexpected( - mcpp::pm::format_install_failure_diagnostic( - target, r->exitCode, indexRepos, childErr)); + auto diag = mcpp::pm::format_install_failure_diagnostic( + target, r->exitCode, indexRepos, childErr); + std::string tried; + for (auto& a : attempted) { + if (!tried.empty()) tried += ", "; + tried += a; + } + diag += std::format("\n wire address{} tried: {}", + attempted.size() == 1 ? "" : "es", tried); + return std::unexpected(std::move(diag)); } // After install, check project data first for custom index packages. installed = findRawInstalled(); diff --git a/src/manifest/xpkg.cppm b/src/manifest/xpkg.cppm index 677d98be..b6451638 100644 --- a/src/manifest/xpkg.cppm +++ b/src/manifest/xpkg.cppm @@ -114,6 +114,42 @@ xpkg_name_form_violation(std::string_view declaredNs, // `xpkg_name_form_violation`. std::optional xpkg_name_form_violation_from_lua(std::string_view luaContent); +// The address xlings is asked to install (SPEC-001 §6): +// `:`, or the bare literal name for a +// namespace-less package. +// +// WHY THIS IS ONE FUNCTION — the rule it enforces is "the descriptor the +// identity gate accepted supplies BOTH halves of the address". Deriving the +// name from the descriptor while taking the namespace from the request is what +// made every bare-name dependency served by a `compat` descriptor +// uninstallable the moment the index migrated to short names: the gate +// accepted `(compat, gtest)` (a bare request resolves against the default +// namespace AND `compat`, see `xpkg_lua_identity_matches`), but the wire target +// went out as `mcpplibs:gtest`, which no index is keyed by. The old spelling +// only ever worked because the literal `name` happened to read `compat.gtest`, +// so a hardcoded `compat.` retry caught it. +// +// `requestNs` is the coordinate the caller resolved the dependency to. It is +// used in exactly two places: as the `indexDefaultNs` a descriptor that +// declares no namespace inherits, and as the whole address when there is no +// descriptor to read. +// +// luaContent declares a namespace → ns = descriptor's effective namespace +// luaContent declares none → ns = requestNs (index-default inheritance) +// no luaContent / no `name` → historical derivation, byte-for-byte: +// name = requestNs.empty() +// ? shortName : "." +// so a descriptor that could not be read +// fails exactly where it used to. +struct XpkgWireAddress { + std::string ns; // effective namespace ("" = namespace-less package) + std::string name; // LITERAL package.name — the index's wire key + std::string target; // ":", or "" when ns is empty + bool operator==(const XpkgWireAddress&) const = default; +}; +XpkgWireAddress xpkg_wire_address(std::string_view luaContent, + std::string_view requestNs, + std::string_view shortName); // Resolve the lib-root path for a manifest: // 1. `[lib].path` if explicitly set (cargo-style override), // 2. otherwise the convention `src/.cppm`, where @@ -756,6 +792,37 @@ bool xpkg_lua_identity_matches(std::string_view luaContent, return id.ns == ns; } +XpkgWireAddress xpkg_wire_address(std::string_view luaContent, + std::string_view requestNs, + std::string_view shortName) +{ + XpkgWireAddress addr; + + // The literal `name` is the wire key — never a value mcpp re-derives. + auto literal = luaContent.empty() ? std::string{} + : extract_xpkg_name(luaContent); + if (!literal.empty()) { + addr.name = std::move(literal); + // A descriptor that declares no namespace inherits the one owned by the + // index it was found in, which at this seam is the request coordinate — + // the same rule `xpkg_lua_identity_matches` used to accept it. + addr.ns = extract_xpkg_namespace(luaContent); + if (addr.ns.empty()) addr.ns = std::string(requestNs); + } else { + // No descriptor (index not synced yet): keep the historical derivation + // so the failure stays where it was rather than moving earlier. + addr.ns = std::string(requestNs); + addr.name = requestNs.empty() + ? std::string(shortName) + : std::format("{}.{}", requestNs, shortName); + } + + addr.target = addr.ns.empty() + ? addr.name + : std::format("{}:{}", addr.ns, addr.name); + return addr; +} + std::optional xpkg_name_form_violation(std::string_view declaredNs, std::string_view declaredName) diff --git a/src/scaffold/create.cppm b/src/scaffold/create.cppm index 4c359131..c11e8f64 100644 --- a/src/scaffold/create.cppm +++ b/src/scaffold/create.cppm @@ -84,10 +84,17 @@ fetch_template_package(const mcpp::scaffold::TemplateSpec& spec) { auto installed = fetcher.install_path(ns, shortName, version); if (!installed) { + // Human-facing name is the resolved identity; the WIRE address is the + // descriptor's own `:` (SPEC-001 §6). Deriving + // it as `.` only matched while every descriptor spelled + // `name` fully-qualified — the short-name migration killed that, the + // same way it killed the dependency path. auto fq = ns.empty() ? shortName : std::format("{}.{}", ns, shortName); + auto wireAddr = mcpp::manifest::xpkg_wire_address(*lua, ns, shortName); mcpp::ui::info("Downloading", std::format("{} v{}", fq, version)); mcpp::fetcher::InstallProgressHandler progress; - std::vector targets{ std::format("{}@{}", fq, version) }; + std::vector targets{ + std::format("{}@{}", wireAddr.target, version) }; auto r = fetcher.install(targets, &progress); if (!r) return std::unexpected(std::format( "fetch '{}@{}': {}", fq, version, r.error().message)); diff --git a/src/toolchain/fingerprint.cppm b/src/toolchain/fingerprint.cppm index 1b31aa44..85f4443c 100644 --- a/src/toolchain/fingerprint.cppm +++ b/src/toolchain/fingerprint.cppm @@ -18,7 +18,7 @@ import mcpp.toolchain.detect; export namespace mcpp::toolchain { -inline constexpr std::string_view MCPP_VERSION = "0.0.108"; +inline constexpr std::string_view MCPP_VERSION = "0.0.109"; struct FingerprintInputs { Toolchain toolchain; diff --git a/tests/e2e/163_identity_first_resolution.sh b/tests/e2e/163_identity_first_resolution.sh index 5acdbfaa..b1ad71e3 100755 --- a/tests/e2e/163_identity_first_resolution.sh +++ b/tests/e2e/163_identity_first_resolution.sh @@ -39,9 +39,23 @@ fi mkidx() { # mkidx mkdir -p "$1/pkgs/$(dirname "$2")" - sed -e "s/namespace = \"chriskohlhoff\"/namespace = \"$3\"/" \ - -e "s/name = \"chriskohlhoff.asio\"/name = \"$4\"/" \ + # Rewrite the identity fields by SHAPE, never by their current values. The + # first version of this matched the literal `name = "chriskohlhoff.asio"`, + # which silently stopped matching the day mcpp-index migrated that + # descriptor to its SPEC-001 short name. The fixture then carried the WRONG + # identity and this test failed on three platforms for a reason that had + # nothing to do with what it asserts. + sed -e "s/^\( *namespace *= *\)\"[^\"]*\"/\1\"$3\"/" \ + -e "s/^\( *name *= *\)\"[^\"]*\"/\1\"$4\"/" \ "$SRC" > "$1/pkgs/$2" + # Fail loudly rather than go on to test a fixture that isn't what we asked + # for — a missed rewrite must never masquerade as a resolution bug. + grep -qE "^ *namespace *= *\"$3\"" "$1/pkgs/$2" || { + echo "FAIL: fixture namespace rewrite missed (upstream descriptor shape changed?)" + exit 1; } + grep -qE "^ *name *= *\"$4\"" "$1/pkgs/$2" || { + echo "FAIL: fixture name rewrite missed (upstream descriptor shape changed?)" + exit 1; } } mkapp() { # mkapp diff --git a/tests/e2e/165_bare_name_cross_namespace_wire_address.sh b/tests/e2e/165_bare_name_cross_namespace_wire_address.sh new file mode 100755 index 00000000..c70bca1b --- /dev/null +++ b/tests/e2e/165_bare_name_cross_namespace_wire_address.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# requires: +# 165_bare_name_cross_namespace_wire_address.sh — SPEC-001 §6: the descriptor +# the identity gate accepted supplies BOTH halves of the wire address. +# +# THE INCIDENT THIS LOCKS DOWN (2026-07-25, 6 of 7 workflows red on main): +# +# A bare dependency (`gtest = "1.15.2"`) resolves to the DEFAULT namespace +# (`mcpplibs`), but the identity gate deliberately also accepts a `compat` +# descriptor for it — that is how every `compat.*` package is consumable +# without writing the namespace out. mcpp then took the wire NAME from that +# descriptor and the wire NAMESPACE from the request, emitting +# `mcpplibs:gtest`, which no index is keyed by. +# +# It passed for months by coincidence: the literal `package.name` used to read +# `compat.gtest`, so the hardcoded `compat.` retry caught it. When +# mcpp-index migrated to SPEC-001 short names (`name = "gtest"`), that +# coincidence vanished and every bare-name dependency served by a compat +# descriptor became uninstallable — 34 of the index's 48 packages. +# +# WHY THIS TEST IS HERMETIC. The index side validated that migration against +# its own examples, all of which spell the dependency `[dependencies.compat]` +# — the one form that was never broken. Verification matrix and consumption +# matrix were not the same shape, so 8 green checks meant nothing. This test +# builds its own index so it cannot drift with the live one, and it asserts on +# the address mcpp SENDS, not just on whether the install happened to work. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +# Reuse a real, downloadable descriptor so this exercises the whole install +# path rather than stopping at resolution. +SRC="$MCPP_HOME/registry/data/mcpplibs/pkgs/c/chriskohlhoff.asio.lua" +if [ ! -f "$SRC" ]; then + echo "SKIP 165_bare_name_cross_namespace_wire_address (no asio descriptor in registry)" + exit 0 +fi + +# A `compat`-namespaced package named by its SPEC-001 short name. Filed under +# the `compat..lua` filename a bare request probes as a fallback +# candidate — exactly the live index's layout for all 34 compat packages. +mkdir -p idx/pkgs/c +sed -e 's/^\( *namespace *= *\)"[^"]*"/\1"compat"/' \ + -e 's/^\( *name *= *\)"[^"]*"/\1"widget"/' \ + "$SRC" > idx/pkgs/c/compat.widget.lua +grep -qE '^ *namespace *= *"compat"' idx/pkgs/c/compat.widget.lua || { + echo "FAIL: fixture namespace rewrite missed (upstream descriptor shape changed?)"; exit 1; } +grep -qE '^ *name *= *"widget"' idx/pkgs/c/compat.widget.lua || { + echo "FAIL: fixture name rewrite missed (upstream descriptor shape changed?)"; exit 1; } + +# The consumer writes the dependency BARE — no namespace. `[indices] mcpplibs` +# points the default namespace at the fixture index (findIndexForNs routes the +# default namespace through this entry), so the whole thing stays offline. +mkdir -p app/src +cat > app/mcpp.toml <<'EOF' +[package] +name = "app" +version = "0.1.0" + +[indices] +mcpplibs = { path = "../idx" } + +[dependencies] +widget = "1.38.1" +EOF +echo 'int main() { return 0; }' > app/src/main.cpp + +set +e +(cd app && MCPP_VERBOSE=1 "$MCPP" build > out.txt 2>&1) +build_rc=$? +set -e + +# ── The assertion that matters: which address went on the wire ────── +# +# Checked independently of whether the install then succeeded, so this keeps +# reporting the real defect even when the network or the sandbox xlings is the +# thing that failed. +# +# Read the `"targets":[...]` payload specifically rather than grepping the log +# as a whole — the human-facing "Downloading …" and error lines carry the +# resolved identity `mcpplibs.widget`, which is correct there and would make a +# looser assertion answer the wrong question. +# Un-escape first: the interface command is single-quoted on unix +# (--args '{"targets":[...]}') but double-quoted with backslash-escaped quotes +# on Windows (--args "{\"targets\":[...]}"). Normalising makes one pattern +# work on both instead of silently matching nothing on one of them. +sed 's/\\"/"/g' app/out.txt | grep -oE '"targets":\["[^"]*"\]' > targets.txt || true +test -s targets.txt || { + echo "FAIL: no install target was emitted at all"; cat app/out.txt; exit 1; } +if grep -q '"mcpplibs:widget@1\.38\.1"' targets.txt; then + echo "FAIL: wire address took its namespace from the REQUEST (mcpplibs:widget)" + echo " The descriptor declares namespace=\"compat\"; the address must be compat:widget." + cat targets.txt + exit 1 +fi +grep -q '"compat:widget@1\.38\.1"' targets.txt || { + echo "FAIL: expected the wire address compat:widget@1.38.1" + cat targets.txt + exit 1; } + +# ── And that the package actually installs under that identity ────── +if [ $build_rc -ne 0 ]; then + echo "FAIL: build failed even though the wire address was correct" + cat app/out.txt + exit 1 +fi + +# xlings names an xpkg directory {namespace}-x-{literal name}. Search for it by +# NAME rather than at a fixed path — the store root differs per platform, and +# the directory's name is the whole point of the assertion. +find app/.mcpp -type d -name "compat-x-widget" 2>/dev/null | grep -q . || { + echo "FAIL: expected a store dir named compat-x-widget" + find app/.mcpp -type d -name "*-x-*" 2>/dev/null | head -10 + exit 1; } + +echo "PASS 165_bare_name_cross_namespace_wire_address" diff --git a/tests/e2e/69_package_templates.sh b/tests/e2e/69_package_templates.sh index 514ebc04..efdb85cc 100755 --- a/tests/e2e/69_package_templates.sh +++ b/tests/e2e/69_package_templates.sh @@ -160,4 +160,37 @@ if "$MCPP" new app7 --template tpl-repro > out7.log 2>&1; then fi grep -qi "not found in the index" out7.log || { cat out7.log; echo "L7: wrong error for Form B skip"; exit 1; } +# ── L8: the wire address when the package is NOT already installed ────── +# +# Every case above runs off a pre-seeded install cache, so `mcpp new` never +# reaches the install path — which is how that path came to build its xlings +# target as `.` (a re-derived FQN) unnoticed. That spelling only +# resolved while every descriptor repeated its namespace inside `package.name`; +# the SPEC-001 short-name migration killed it, exactly as it killed the +# dependency path (see e2e 165). +# +# Drop the seeded install and assert on the address that goes out. The xpm url +# is example.invalid, so the install is EXPECTED to fail — the address is the +# thing under test, and checking it separately keeps this honest without +# needing a real payload. +# Assert on the `"targets":[...]` payload specifically, NOT on the log as a +# whole: the human-facing "Downloading …" and "fetch '…' failed" lines carry the +# resolved identity `mcpplibs.tpl-demo`, which is correct there and would make a +# looser grep match no matter what went on the wire. +rm -rf "$MCPP_HOME/registry/data/xpkgs/mcpplibs-x-tpl-demo/1.0.0" +MCPP_VERBOSE=1 "$MCPP" new app8 --template tpl-demo@1.0.0:starter > out8.log 2>&1 || true +# Un-escape first — Windows spells the interface args with backslash-escaped +# quotes; see e2e 165. +sed 's/\\"/"/g' out8.log | grep -oE '"targets":\["[^"]*"\]' > targets8.txt || true +test -s targets8.txt || { echo "L8: no install target was emitted at all"; cat out8.log; exit 1; } +if grep -q '"mcpplibs\.tpl-demo@1\.0\.0"' targets8.txt; then + echo "L8: install target re-derived the FQN (mcpplibs.tpl-demo) instead of" + echo " addressing the descriptor's literal name as mcpplibs:tpl-demo" + cat targets8.txt + exit 1 +fi +grep -q '"mcpplibs:tpl-demo@1\.0\.0"' targets8.txt || { + echo "L8: expected the wire address mcpplibs:tpl-demo@1.0.0" + cat targets8.txt; exit 1; } + echo "OK" diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index 7092f246..fcc40362 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -1888,6 +1888,100 @@ TEST(XpkgIdentity, DefaultNamespaceRequestMatchesCompatAlias) { compatGtest, "mcpplibs", "zlib")); } +// ─── xpkg_wire_address — the address xlings is asked to install ───── +// +// REGRESSION LOCK for the 2026-07-25 CI break (6 of 7 workflows red on main). +// The identity gate accepts a `compat` descriptor for a bare/default-namespace +// request (`gtest = "1.15.2"` must resolve to compat.gtest). The wire address +// then has to come from THAT descriptor — both halves. Taking the name from the +// descriptor and the namespace from the request emitted `mcpplibs:gtest`, which +// no index is keyed by; it only ever worked because the literal name used to +// read `compat.gtest`, so a hardcoded `compat.` retry caught it. The +// index's SPEC-001 short-name migration removed that coincidence. + +TEST(XpkgWireAddress, CompatDescriptorUnderBareRequestAddressesCompat) { + // THE BUG. Bare `gtest` request, served by a short-name compat descriptor. + constexpr std::string_view compatGtest = + R"(package = { namespace = "compat", name = "gtest", version = "1.15.2" })"; + auto addr = mcpp::manifest::xpkg_wire_address(compatGtest, "mcpplibs", "gtest"); + EXPECT_EQ(addr.ns, "compat"); + EXPECT_EQ(addr.name, "gtest"); + EXPECT_EQ(addr.target, "compat:gtest"); // NOT "mcpplibs:gtest" +} + +TEST(XpkgWireAddress, DefaultNamespaceDescriptorIsUnchanged) { + // The 8 mcpplibs descriptors in the live index must address exactly as + // before — this fix moves only the packages that were already broken. + constexpr std::string_view cmdline = + R"(package = { namespace = "mcpplibs", name = "cmdline", version = "0.0.1" })"; + auto addr = mcpp::manifest::xpkg_wire_address(cmdline, "mcpplibs", "cmdline"); + EXPECT_EQ(addr.target, "mcpplibs:cmdline"); +} + +TEST(XpkgWireAddress, LegacyFqnDescriptorKeepsItsLiteralName) { + // Pre-SPEC-001 spelling. The literal `name` IS the index's key, so it goes + // out verbatim — only the namespace half is corrected. + constexpr std::string_view legacy = + R"(package = { namespace = "compat", name = "compat.zlib", version = "1.3.2" })"; + auto addr = mcpp::manifest::xpkg_wire_address(legacy, "mcpplibs", "zlib"); + EXPECT_EQ(addr.ns, "compat"); + EXPECT_EQ(addr.name, "compat.zlib"); + EXPECT_EQ(addr.target, "compat:compat.zlib"); +} + +TEST(XpkgWireAddress, QualifiedRequestIsUnchanged) { + // `[dependencies.compat] gtest = ...` already worked (this is the spelling + // every mcpp-index example uses, which is why the index CI stayed green). + constexpr std::string_view compatGtest = + R"(package = { namespace = "compat", name = "gtest", version = "1.15.2" })"; + auto addr = mcpp::manifest::xpkg_wire_address(compatGtest, "compat", "gtest"); + EXPECT_EQ(addr.target, "compat:gtest"); +} + +TEST(XpkgWireAddress, NestedNamespaceIsUnchanged) { + constexpr std::string_view capiLua = + R"(package = { namespace = "mcpplibs.capi", name = "lua", version = "5.4.7" })"; + auto addr = mcpp::manifest::xpkg_wire_address(capiLua, "mcpplibs.capi", "lua"); + EXPECT_EQ(addr.target, "mcpplibs.capi:lua"); +} + +TEST(XpkgWireAddress, DescriptorWithoutNamespaceInheritsTheRequest) { + // A no-namespace descriptor inherits the namespace owned by the index it + // was found in — at this seam, the request coordinate. Same rule the + // identity gate used to accept it. + constexpr std::string_view bare = R"(package = { name = "cmdline" })"; + auto addr = mcpp::manifest::xpkg_wire_address(bare, "mcpplibs", "cmdline"); + EXPECT_EQ(addr.ns, "mcpplibs"); + EXPECT_EQ(addr.target, "mcpplibs:cmdline"); +} + +TEST(XpkgWireAddress, NamespacelessRequestAddressesTheBareLiteral) { + // xim upstream packages (`opencv`) have no namespace on either side. + constexpr std::string_view opencv = R"(package = { name = "opencv" })"; + auto addr = mcpp::manifest::xpkg_wire_address(opencv, "", "opencv"); + EXPECT_EQ(addr.ns, ""); + EXPECT_EQ(addr.target, "opencv"); +} + +TEST(XpkgWireAddress, NoDescriptorKeepsTheHistoricalDerivation) { + // Index not synced yet: mcpp has nothing to read, so it must NOT invent a + // new address — the failure has to stay exactly where it was. + auto addr = mcpp::manifest::xpkg_wire_address("", "mcpplibs", "gtest"); + EXPECT_EQ(addr.name, "mcpplibs.gtest"); + EXPECT_EQ(addr.target, "mcpplibs:mcpplibs.gtest"); + + auto bare = mcpp::manifest::xpkg_wire_address("", "", "opencv"); + EXPECT_EQ(bare.target, "opencv"); +} + +TEST(XpkgWireAddress, DescriptorWithoutNameFallsBackToDerivation) { + // A descriptor that declares no `name` cannot supply a wire key (the + // identity gate accepts it leniently, so it can reach here). + constexpr std::string_view noName = R"(package = { version = "1.0.0" })"; + auto addr = mcpp::manifest::xpkg_wire_address(noName, "mcpplibs", "gtest"); + EXPECT_EQ(addr.target, "mcpplibs:mcpplibs.gtest"); +} + // ─── INV-NAME: xpkg_name_form_violation (#278) ────────────────────── // // `package.name` is a SINGLE ATOMIC SEGMENT; all hierarchy lives in