From 436fca6ef5a5860226ca74514c768743a8eae685 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 25 Jul 2026 18:35:52 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20SPEC-001=20package=20identity=20?= =?UTF-8?q?=E2=80=94=20name=20is=20one=20segment,=20wire=20key=20is=20the?= =?UTF-8?q?=20literal=20(0.0.106)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.0.105 fixed #278 by REQUIRING `package.name` to be the fully-qualified `.`. That rule was an encoding constraint dressed up as a design rule. Its only cause: mcpp threw away the literal `package.name` it had already read and re-rendered `.` to address the package, so the two only matched when the descriptor repeated its namespace inside `name`. Use the literal instead and the constraint disappears. The canonical form goes back to what the identity model always said: `namespace` carries the hierarchy, `name` is a single atomic segment. Paired with xlings 0.4.69 (openxlings/xlings#381), which keys its index by (namespace, name) so two packages sharing a short name in ONE index are both addressable. mcpp's part is sending `:` rather than a reconstructed string. Three subtractions, one addition: - identity normalization no longer splits on the last dot. It used to infer a namespace nobody declared — `ns="a"` + `name="a.b.c"` silently became `(a.b, c)`. Identity is now exactly the two declared fields; a leftover dot is rejected rather than reinterpreted. - the install target is the descriptor's literal `package.name`, prefixed with the package NAMESPACE (xlings' effective namespace), not the index name. Namespace-less upstream packages keep their bare name. - the store dir formula `{ns}-x-{name}` is first-class for both spellings instead of buried among six guesses. - discovery no longer stops at a fixed candidate-filename list. Recommended names stay the fast path; when they all miss, mcpp scans by declared identity. This finally delivers the "filename is not a key" half that identity-first resolution only ever promised — verification was implemented, discovery was not. Compatibility: the legacy fully-qualified spelling is still accepted (the prefix is stripped before judging), so every published descriptor keeps working unchanged. A full-registry sweep — 245 descriptors, mcpp indices and xlings-native ones alike — reports zero form violations. Notably this includes all 30 descriptors that needed `--allow-split-name` under 0.0.105; that flag is now obsolete and kept accepted only so 0.0.105-era index CI keeps running. Adds docs/spec/ for normative documents, with SPEC-001 covering identity, the four [dependencies] spellings and their candidate ladders, the matching pipeline, derived-value formulas and worked examples — each rule tagged with its implementation status. Verified: 37/37 unit binaries; e2e 161 inverted; new e2e 163 covers short-name install, legacy FQN install, arbitrary filename discovery, and two same-short-name packages in one index resolving to alpha-x-widget / beta-x-widget. Spec: docs/spec/package-identity.md Plan: .agents/docs/2026-07-25-spec001-implementation-plan.md --- ...space-bidirectional-verification-report.md | 178 ++++++++ ...namespace-canonical-implementation-spec.md | 173 ++++++++ .../2026-07-25-spec001-implementation-plan.md | 93 +++++ ...xlings381-index-namespace-keying-design.md | 229 ++++++++++ .github/workflows/ci-linux-e2e.yml | 2 +- .github/workflows/cross-build-test.yml | 11 +- .github/workflows/release.yml | 18 +- CHANGELOG.md | 21 + docs/05-mcpp-toml.md | 26 +- docs/README.md | 8 + docs/spec/README.md | 39 ++ docs/spec/package-identity.md | 393 ++++++++++++++++++ docs/zh/05-mcpp-toml.md | 88 +--- mcpp.toml | 2 +- src/build/prepare.cppm | 68 +-- src/cli.cppm | 8 +- src/manifest/xpkg.cppm | 132 +++--- src/pm/compat.cppm | 15 +- src/pm/package_fetcher.cppm | 40 ++ src/toolchain/fingerprint.cppm | 2 +- src/xlings.cppm | 2 +- tests/e2e/161_xpkg_name_form.sh | 109 ++--- tests/e2e/163_identity_first_resolution.sh | 105 +++++ tests/unit/test_manifest.cpp | 58 ++- 24 files changed, 1559 insertions(+), 261 deletions(-) create mode 100644 .agents/docs/2026-07-25-name-namespace-bidirectional-verification-report.md create mode 100644 .agents/docs/2026-07-25-name-namespace-canonical-implementation-spec.md create mode 100644 .agents/docs/2026-07-25-spec001-implementation-plan.md create mode 100644 .agents/docs/2026-07-25-xlings381-index-namespace-keying-design.md create mode 100644 docs/spec/README.md create mode 100644 docs/spec/package-identity.md create mode 100755 tests/e2e/163_identity_first_resolution.sh diff --git a/.agents/docs/2026-07-25-name-namespace-bidirectional-verification-report.md b/.agents/docs/2026-07-25-name-namespace-bidirectional-verification-report.md new file mode 100644 index 00000000..69078be4 --- /dev/null +++ b/.agents/docs/2026-07-25-name-namespace-bidirectional-verification-report.md @@ -0,0 +1,178 @@ +# `package.name` / `package.namespace` 双向验证报告 + +> 触发:mcpp-index PR#117 的文档把「`name` 必须写成 FQN」写成了**设计规则**,评审指出这只是兼容写法,层级应当全在 `namespace`、`name` 只留短名。 +> 目的:用当前源码 + 实机双向验证,判定哪一方成立、代价在哪、以及 PR#117 该怎么写。 +> 基线:mcpp `main` @ `cee8130`(0.0.105),xlings 0.4.68 / libxpkg 当前 HEAD。 + +--- + +## 0. 结论先行 + +| 命题 | 判定 | +|---|---| +| 「层级属于 `namespace`,`name` 概念上是短名」 | ✅ **成立**,且与 mcpp §4.2 一致 | +| 「`name` 必须写 FQN」是 mcpp 的**设计规则** | ❌ **不成立** —— 这是我写错了 | +| 「`name` 必须写 FQN」是**当前实现的编码约束** | ✅ 成立,但根因是**一行可改的代码**,不是架构 | +| 我此前「根因在 libxpkg 的 key 空间」的判断 | ❌ **错误**,已实机证伪(见 §3) | +| 短名形式需要改 libxpkg / xlings | ❌ **不需要**,mcpp 侧一行即可 | + +**修正后的根因**:`prepare.cppm:1861` 把已经读到手的字面 `name` **丢弃**,改用 `ns + "." + short` 重新渲染一遍去当 wire key。改成直接用字面 `name`,短名形式立即可用,且**现网 FQN 形式继续可用**。 + +--- + +## 1. 方向一:从源码看 mcpp 如何处理 name/namespace + +### 1.1 读取 —— mcpp 完全自理,不依赖 xlings + +| 环节 | 位置 | 是否依赖 xlings | +|---|---|---| +| 定位描述符文件 | `package_fetcher.cppm` `read_xpkg_lua*` | ❌ 自己扫盘 | +| 解析 `namespace` | `xpkg.cppm:670` `extract_xpkg_namespace` | ❌ 自己解析 | +| 解析 `name` | `xpkg.cppm:676` `extract_xpkg_name` | ❌ 自己解析 | +| 归一化为身份 | `xpkg.cppm` `canonical_xpkg_identity` | ❌ 自己归一 | +| 身份校验 | `xpkg_lua_identity_matches` | ❌ 自己校验 | + +**评审意见在这一点上完全正确**:name/namespace 的处理是 mcpp 的自有机制,xlings 只被用于**取值/装包**。 + +### 1.2 身份归一化对两种写法**已经等价** + +```cpp +// xpkg.cppm — canonical_xpkg_identity +std::string prefix = ns + "."; +fqn = name.starts_with(prefix) ? name : ns + "." + name; // 两种写法在此合流 +auto pos = fqn.rfind('.'); +return XpkgIdentity{ fqn.substr(0, pos), fqn.substr(pos + 1) }; +``` + +`(acme, asio)` 与 `(acme, acme.asio)` 归一化到**同一身份** `(acme, asio)`。身份层对写法**无偏好**。 + +### 1.3 唯一的偏好点:目标构造丢弃了字面值 + +```cpp +// prepare.cppm:1861 —— 唯一把写法变成"有对错"的一行 +auto fqname = ns.empty() ? shortName : std::format("{}.{}", ns, shortName); +... +// :1906 / :1911 +auto target = std::format("{}@{}", fqname, version); +target = std::format("{}:{}@{}", idxSpec->name, fqname, version); +``` + +而 `luaContent`(含字面 `name`)**就在同一作用域**——`:1781` 与 `:1865` 的 INV-NAME 检查用的正是它。**信息在手,只是没用。** + +### 1.4 store 目录:mcpp 自己猜,且**两种形式都已覆盖** + +```cpp +// compat.cppm install_dir_candidates +candidates.push_back(std::format("{}-x-{}", ns, fqname)); // -x-. +... +candidates.push_back(std::format("{}-x-{}", ns, shortName)); // -x- ← 短名形式 +``` + +短名形式的 store 目录**早已在候选表里**(现标注为 COMPAT 兜底)。 + +--- + +## 2. 方向二:实机验证(真实包 `asio` 1.38.1,path index,mcpp 0.0.104 避开 INV-NAME 干扰) + +固定 `namespace = "acme"`,只改 `name` 字段: + +| # | `name` | mcpp 发出的 target | 结果 | store 目录 | +|---|---|---|---|---| +| 1 | `acme.asio` | `acme:acme.asio@1.38.1` | ✅ 装上并编译 | `acme-x-acme.asio` | +| 2 | `asio` | `acme:acme.asio@1.38.1` | ❌ `E_NOT_FOUND` | — | + +第 2 行即 **#278 事故的最小复现**:索引 key 是 `asio`,mcpp 却要 `acme.asio`。 + +### 2.1 关键实验:改一行后重测 + +实验补丁(**未合入**,已复原):目标构造改用描述符字面 `name`。 + +```cpp +if (luaContent) { + auto lit = mcpp::manifest::extract_xpkg_name(*luaContent); + if (!lit.empty()) fqname = lit; +} +``` + +| # | `name` | 结果 | store 目录 | +|---|---|---|---| +| 3 | `asio`(短名) | ✅ **装上并编译** | `acme-x-asio` | +| 4 | `acme.asio`(FQN) | ✅ 装上并编译 | `acme-x-acme.asio` | +| 5 | mcpp 自身依赖(真实生态) | ✅ 无回归 | — | + +**两种写法同时可用**,因为字面 `name` **恒等于**索引 key。下游(payload 定位、编译)零改动即работ——`install_dir_candidates` 已含短名候选。 + +--- + +## 3. 证伪:我此前「根因在 libxpkg」的判断是错的 + +我曾主张「libxpkg 的 key 空间没有 namespace 维度,所以 `name` 必须自带区分度」。**实验 3 直接证伪**:key 空间没变、libxpkg 一行没动,短名形式照样装上。 + +正确表述:key 空间确实是扁平的、以 `pkg.name` 字面值为键 —— 但这**不构成对 `name` 写法的约束**,因为 mcpp 完全可以把字面值原样送过去。真正的约束是 mcpp 自己**重新渲染**了一个可能不同的字符串。 + +**撞键顾虑同样不成立**:`compat` 的 `zlib` 与 `mcpplibs` 的 `zlib` 若都写 `name = "zlib"`,确实会在**同一索引内**撞键 —— 但这是**索引内命名唯一性**问题,可由 lint 保证(同一索引内 `name` 唯一),不必让每个包都背 FQN。跨索引不撞,因为每个索引一张表。 + +--- + +## 4. 兼容矩阵 + +| | 现网描述符(FQN) | 新描述符(短名) | +|---|---|---| +| 现有 mcpp(≤0.0.105) | ✅ | ❌ `E_NOT_FOUND` | +| 改后 mcpp(用字面 name) | ✅ | ✅ | + +**只有一个破损格**:旧 mcpp + 新描述符。含义: + +- 迁移**不是**一次性切换。改后的 mcpp 对两种写法都工作,所以**先发 mcpp、再按节奏迁描述符**。 +- 描述符一旦改短名,消费它的最低 mcpp 版本就抬高了 → 迁移时 `index.toml` 的 `min_mcpp` **必须**同步抬到含该修复的版本(这次是硬性的,不像 0.0.105 那次可选)。 + +--- + +## 5. 对 PR#117 的处置建议 + +PR#117 现有文档把**编码约束**写成了**设计规则**,方向与上述结论相反,**不应按现状合入**。三选一: + +**A. 拆分(推荐)** —— PR#117 只保留无争议部分:去重(字节级重复的 `capi.lua.lua`)、规范路径(`tensorvia-cpu.lua` → `aimol.tensorvia-cpu.lua`)、规则 2(短名原子)、规则 3(规范路径)、CI 升 0.0.105。**删掉全部关于 `name` 形式的表述与规则 1 lint**,留给迁移 PR。 + +**B. 并入迁移** —— PR#117 改造成迁移的一部分:等 mcpp 侧修复发布后,49 个描述符全改短名、lint 规则 1 反转、`min_mcpp` 抬版。 + +**C. 维持现状** —— 只在文档里把 FQN 标注为「当前编码约束、非设计理想」。**不推荐**:等于把一条要回滚的约定写进 49 个包的规范。 + +--- + +## 6. 若走迁移,工作项清单 + +**mcpp 侧(一次发版,例如 0.0.106):** + +- [ ] `prepare.cppm` 目标构造改用描述符字面 `name`(字面为空时回落到现有渲染) +- [ ] `compat.cppm` `install_dir_candidates` 把 `{ns}-x-{short}` 提为**首选**,`{ns}-x-{fqname}` 降为兼容项 +- [ ] **INV-NAME 谓词的语义反转**:从「必须是 FQN」改为「`name` 不得含点(层级归 `namespace`)」;`xpkg_name_form_violation` 及 `mcpp xpkg parse` 同步 +- [ ] e2e 161 断言反转;新增「短名描述符可安装」正向 e2e +- [ ] 文档 `docs/05-mcpp-toml.md` §2.5 的 xpkg 作者段改写 + +**mcpp-index 侧(mcpp 发布后):** + +- [ ] 49 个描述符 `name` 去掉 namespace 前缀 +- [ ] `check_package_name.lua` 规则 1 反转 +- [ ] `check_package_filename.lua` 的规范路径公式随之调整(FQN 文件名 → ?需重新定义) +- [ ] `index.toml` `min_mcpp` / `latest_mcpp` 抬到 0.0.106 +- [ ] 一次全量 `mcpp test --workspace` 三平台验证 + +**未决问题(需拍板):** + +1. **规范文件名怎么定?** 现行公式对非默认命名空间用 `.lua`。若 `name` 变短名,是继续用 `..lua`(与 `name` 不再字面相等)还是改 `.lua`(跨命名空间会重名)? +2. **同索引内 `name` 唯一性**由谁保证?建议新增 lint:同一索引内 `name` 字面值不得重复(替代 FQN 天然提供的唯一性)。 +3. **是否需要过渡期双读?** 即改后的 mcpp 已同时支持两种写法,故不需要;但若希望旧 mcpp 也能读新描述符,只能靠 `min_mcpp` 挡住,无技术手段。 + +--- + +## 附:我为什么把 PR#117 写成那样 + +按时间顺序,证据链是这样的,错在**没有做本报告 §2.1 这一步实验**: + +1. #278 事故现场:`name` 从 FQN 改成短名 → 三平台 `E_NOT_FOUND`。→ 得出「短名不可用」。**正确但不完整**——只证明了"当前 mcpp 下不可用",没证明"必然不可用"。 +2. 读 libxpkg:`entries[pkg.name]` 精确匹配、`build_index` 不传 namespace。→ 得出「key 空间无 namespace 维度,`name` 必须自带区分度」。**这一步是错的**:key 空间的形状不约束 `name` 的写法,只要 mcpp 把字面值原样送过去即可。 +3. 全索引扫描:46/49 用 FQN,2 个短名的都坏了。→ 强化了「FQN 是约定」。**幸存者偏差**:它们坏是因为 mcpp 的渲染,不是因为写法本身。 +4. 于是把「FQN」当作规则写进 lint 与文档。 + +**根本失误**:我把「当前实现的行为」当成了「架构的约束」,而没有去问"这一行改掉会怎样"。评审的两次纠正——先是「a.b.c 应在 namespace」,再是「mcpp 不依赖 xlings 的 name 机制」——正是指向这个缺口。§2.1 的实验本应在写 lint **之前**做。 diff --git a/.agents/docs/2026-07-25-name-namespace-canonical-implementation-spec.md b/.agents/docs/2026-07-25-name-namespace-canonical-implementation-spec.md new file mode 100644 index 00000000..f87c085f --- /dev/null +++ b/.agents/docs/2026-07-25-name-namespace-canonical-implementation-spec.md @@ -0,0 +1,173 @@ +# mcpp `name` / `namespace` 规范实现(定稿) + +> 依据:`2026-07-25-name-namespace-bidirectional-verification-report.md` 的双向验证 + 评审给定的规格 +> 基线:mcpp `main` @ 0.0.105 +> 状态:**待实现**。本文定的是目标实现,不是现状描述。 + +--- + +## 0. 边界约束(先于一切) + +**mcpp 不改动 xlings 的任何规范,只基于 xlings 已定的机制去实现。** + +xlings 侧属于**既定事实**,mcpp 只能遵守,不能重新定义: + +| xlings 既定机制 | 内容 | +|---|---| +| 索引键 | `entries[package.name]`,以**字面 `name`** 为键,精确匹配 | +| xpkg 目录 | `{namespace}-x-{name}` —— `name` 是什么就拼什么 | + +**由此推出 mcpp 唯一正确的做法:把描述符里读到的字面值原样使用,不要在 mcpp 侧重新推导一遍。** 本文全部改动都是这一句话的展开。 + +> **勘误**:先前曾把「FQN 形式产出 `acme-x-acme.asio`」当作"不符合 `{ns}-x-{name}` 规范"、并当成短名为正解的佐证 —— **该推论是错的**。`{ns}-x-{name}` 对两种形式都成立(`name="acme.asio"` → `acme-x-acme.asio`;`name="asio"` → `acme-x-asio`),它是确定的 xlings 机制,不偏袒任何一种写法。短名为规范形态的依据是数据模型本身(层级归 `namespace`),不是目录名。 + +--- + +## 1. 数据模型 + +包的身份是二元组,**两个字段都直接取自描述符声明,不做任何重新渲染**: + +| 字段 | 含义 | 形态 | +|---|---|---| +| `package.namespace` | 命名空间,**点分层级路径** | 可空;`compat` / `mcpplibs.capi` / `a.b.c` | +| `package.name` | 包名,**单一原子段** | **不含点**;`zlib` / `asio` / `lua` | + +> **层级一律放 `namespace`,`name` 只留最后一段。** +> `(mcpplibs.capi, lua)` 是规范形态;`namespace="mcpplibs", name="capi.lua"` 不是。 + +### 1.1 三个派生量,全部源自**字面 `name`** + +| 派生量 | 公式 | 实测依据 | +|---|---|---| +| xlings 索引 key | `<字面 name>` | libxpkg `entries[pkg.name]`,精确匹配 | +| store 目录 | `{namespace}-x-{字面 name}` | 全量实测 48/48 相符(FQN 与短名两种形式) | +| 安装 target | `:<字面 name>@` | 实验证实短名/FQN 均可解析 | + +**关键性质:三者都只依赖字面 `name`,与「短名形式 / 遗留 FQN 形式」无关。** 因此实现里**不需要按形式分支** —— 这是本方案能同时兼容新旧描述符的根本原因。 + +### 1.2 文件名不参与任何解析 + +描述符文件名**自由**。推荐(非强制)`.lua` 或 `..lua`。 + +这是 `2026-06-26 identity-first` 的原意("Filename Is Not a Key")的完整落实 —— 此前只兑现了「命中后按声明身份复核」,**发现仍受候选文件名约束**,本方案一并补齐(§2.5)。 + +--- + +## 2. mcpp 侧实现 + +### 2.1 身份归一化 —— 去掉 split-on-last-dot + +```cpp +// 现状(xpkg.cppm canonical_xpkg_identity):把 name 拼成 FQN 再按最后一个点切开 +fqn = name.starts_with(ns + ".") ? name : ns + "." + name; +auto pos = fqn.rfind('.'); +return { fqn.substr(0, pos), fqn.substr(pos + 1) }; // ← 会把 ns 从 name 里"猜"出来 + +// 目标:namespace 就是 namespace,name 就是 name;只为遗留 FQN 形式剥前缀 +std::string short_ = name.starts_with(ns + ".") ? name.substr(ns.size() + 1) : name; +return XpkgIdentity{ ns, short_ }; +``` + +差别在于**不再从 `name` 反推命名空间**。`namespace="a", name="a.b.c"` 这类自相矛盾的写法,现状会静默解析成 `(a.b, c)`(一个描述符从未声明的命名空间),新实现直接由 §2.4 的校验拒绝。 + +### 2.2 安装 target —— 用字面 `name`,不再重新渲染 + +```cpp +// prepare.cppm:1861 现状 —— 丢弃已读到的字面值,重新渲染 +auto fqname = ns.empty() ? shortName : std::format("{}.{}", ns, shortName); + +// 目标:字面 name 就是 wire key(luaContent 已在同一作用域) +auto wireName = luaContent ? mcpp::manifest::extract_xpkg_name(*luaContent) : std::string{}; +if (wireName.empty()) wireName = ns.empty() ? shortName // 兜底:读不到描述符时 + : std::format("{}.{}", ns, shortName); +``` + +**已实验验证**(报告 §2.1):改此一行后短名形式立即可装(`acme-x-asio`),且现网 FQN 形式继续可装(`acme-x-acme.asio`),mcpp 自身依赖无回归。 + +### 2.3 store 目录 —— 收敛为单一公式 + +```cpp +// compat.cppm install_dir_candidates 现状:6 个猜测候选,{ns}-x-{short} 被标为 COMPAT 兜底 +// 目标:{namespace}-x-{字面 name} 单一公式(全量实测 48/48 命中),其余降为过渡期兼容项 +``` + +### 2.4 `name` 形态校验 —— INV-NAME 语义反转 + +| | 现状(0.0.105) | 目标 | +|---|---|---| +| 规则 | `namespace` 非空时 `name` **必须**是 `.` | `name` **不得含点**;层级归 `namespace` | +| 违规示例 | `ns="chriskohlhoff", name="asio"` | `ns="mcpplibs", name="capi.lua"` | +| 落点 | `xpkg_name_form_violation` + `mcpp xpkg parse` + 安装路径 | 同左 | + +过渡期:遗留 FQN 形式(`name` 恰以 `namespace + "."` 开头)**降级为 warning**,不阻断;其他含点写法直接报错。 + +### 2.5 描述符发现 —— IdentityIndex(文件名自由的必要前提) + +文件名一旦自由,「候选文件名探测」就不再可靠:`read_xpkg_lua*` 三个入口目前只探测 `compat::xpkg_lua_candidates` 生成的固定名单,叫别的名字的描述符**根本看不见**。 + +因此必须落地 `2026-06-26 §5` 一直推迟的 `IdentityIndex`:扫描 `pkgs/*/*.lua`,读每个描述符声明的 `(namespace, name)` 建表,文件名仅作可选加速提示。 + +> **与 INV-RESOLVE 不冲突。** 被否决的是「裸名跨命名空间发现」(解析结果取决于装了哪些索引);IdentityIndex 是「按**已知的精确身份**查表」,确定性不受影响。消费端解析规则(裸名只解析 `mcpplibs` / `compat` / 无 ns 上游)**保持不变**。 + +--- + +## 3. 遗留约束:同索引内 `name` 必须唯一(索引侧承担) + +xlings 的索引是**每索引一张扁平表、以字面 `name` 为键**(§0 的既定机制)。短名形式下,同一索引里 `compat`/`zlib` 与 `mcpplibs`/`zlib` 会落到同一个键 `zlib` 上而**互相覆盖** —— 现状靠 FQN 写法天然错开。 + +按 §0,这**不是 mcpp 能解决的问题**:改键空间等于改 xlings 规范,越界。因此约束由**索引侧承担**: + +> **同一索引内,`package.name` 的字面值必须唯一。** + +由 mcpp-index 的 lint 强制(遍历 `pkgs/*/*.lua`,`name` 字面值不得重复)。 + +**现网检查:mcpplibs 索引 48 个描述符,短名化后无冲突**(`compat.*` 与 `mcpplibs.*` 之间无同短名者),故该约束立即可满足,不需要为它改任何包名。 + +若将来某个索引确实需要容纳同短名的两个包,那是 xlings 键空间的能力缺口,应作为**上游需求**提给 xlings,而不是在 mcpp 侧绕开。 + +--- + +## 4. 兼容矩阵与迁移 + +| | 现网描述符(FQN) | 新描述符(短名) | +|---|---|---| +| 现有 mcpp ≤0.0.105 | ✅ | ❌ `E_NOT_FOUND` | +| 本方案实现后 | ✅ | ✅ | + +**只有一个破损格**,含义: + +1. **先发 mcpp**(对两种写法都工作),**再按节奏迁描述符** —— 不是一次性切换。 +2. 描述符改短名时,该索引的 `index.toml` `min_mcpp` **必须**同步抬到含本实现的版本(硬性,无技术手段绕过)。 +3. 迁移可**逐包**进行,索引内可长期混存两种形式。 + +--- + +## 5. 实施清单 + +**mcpp(一次发版):** + +- [ ] `canonical_xpkg_identity` 去 split-on-last-dot(§2.1) +- [ ] `prepare.cppm` target 用字面 `name`(§2.2) +- [ ] `install_dir_candidates` 收敛为 `{ns}-x-{字面 name}`(§2.3) +- [ ] `xpkg_name_form_violation` 语义反转 + `mcpp xpkg parse` 同步(§2.4) +- [ ] `IdentityIndex` 落地,`read_xpkg_lua*` 改走它(§2.5) +- [ ] e2e 161 断言反转;新增「短名描述符可安装」「任意文件名可发现」正向 e2e +- [ ] `docs/05-mcpp-toml.md` §2.5 xpkg 作者段改写 + +**mcpp-index(mcpp 发布后,可逐包):** + +- [ ] 新增 lint:同索引内 `name` 字面值唯一(§3) +- [ ] 描述符逐步短名化;`min_mcpp` 抬版 +- [ ] 现有「文件名必须规范」的 lint **不要加**(文件名自由) + +--- + +## 附:本方案相对现状的三处"减法" + +值得强调的是,这不是加复杂度,而是**去掉三处多余的推导**: + +1. 不再从 `name` 反推命名空间(split-on-last-dot) +2. 不再重新渲染 wire key(直接用字面值) +3. 不再猜 store 目录(6 个候选 → 1 个公式) + +三处都是「mcpp 手里已有准确值、却选择重新推导一遍」—— 与 #278 的根因同型。 diff --git a/.agents/docs/2026-07-25-spec001-implementation-plan.md b/.agents/docs/2026-07-25-spec001-implementation-plan.md new file mode 100644 index 00000000..07f0edd3 --- /dev/null +++ b/.agents/docs/2026-07-25-spec001-implementation-plan.md @@ -0,0 +1,93 @@ +# SPEC-001 落地 — Implementation Plan(mcpp 0.0.106) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans。步骤用 `- [ ]` 跟踪。 + +**Goal:** 实现 `docs/spec/package-identity.md`(SPEC-001)与 `.agents/docs/2026-07-25-name-namespace-canonical-implementation-spec.md` 的全部待实现项 → 单 PR(附版本)→ CI 全绿 → bypass squash 合入 → 0.0.106 release + xlings 全生态验证 → mcpp-index 全仓迁移 + 文档 + PR + CI。 + +## 前置事实(已核实) + +**xlings 侧基础设施已就绪**:`openxlings/xlings#381` 已关闭,**0.4.69** 已发布(PR #382,libxpkg 0.0.46 身份模型)。隔离环境实测 0.4.69: + +| 场景(同一索引仓,两个包 `name="demo"`,ns 分别 alpha/beta,**短名形态**) | 结果 | +|---|---| +| `xlings search demo` | ✅ `alpha:demo` 与 `beta:demo` 都列出 | +| `xlings info alpha:demo` / `info beta:demo` | ✅ 各自精确命中 | +| `xlings info demo`(裸名) | ✅ ambiguous 并列出两个候选 | + +**新契约(来自 xlings 设计文档 §2.2,已实测印证)**: + +``` +effectiveNamespace = package.namespace 非空 ? package.namespace : repo.defaultNamespace +PackageIdentity = (effectiveNamespace, package.name) +canonicalName = effectiveNamespace 非空 ? effectiveNamespace + ":" + package.name + : package.name +``` + +**注意分隔符是 `:` 而非 `.`,且 `name` 是字面值。** 这直接决定 mcpp 该发的 target 形态。 + +## Global Constraints + +- **不改动 xlings 任何规范**(SPEC-001 §1.2),只基于其机制实现。 +- 现网 48 个 FQN 形态描述符**必须继续可装**(兼容矩阵只有「旧 mcpp + 新短名」一格破损)。 +- 既有单测 + e2e(162 例)全绿。 +- 版本两处同步:`mcpp.toml` + `src/toolchain/fingerprint.cppm`(0.0.105 踩过)。 + +## Tasks + +### P1 身份层(spec §7.1 §7.5) + +- [x] **T1.1** `canonical_xpkg_identity` 去 split-on-last-dot:身份直接取 `(declaredNs, declaredName)`,仅为 legacy FQN 形态剥 `.` 前缀。不再从 `name` 反推 ns。 +- [x] **T1.2** `xpkg_name_form_violation` 语义反转:`name` **不得含点**;legacy FQN(恰以 `.` 开头)降级为可识别的兼容形态(不报错)。 +- [x] **T1.3** `mcpp xpkg parse` 同步(错误文案 + `--json`);`--allow-split-name` 语义随之调整或退役。 + +### P2 wire 层(spec §7.2 §7.3) + +- [x] **T2.1** `prepare.cppm` target 构造改为 `:<字面 name>@`;字面值取自已在作用域的 `luaContent`,读不到时回落现有渲染。 +- [x] **T2.2** `install_dir_candidates` 以 `{ns}-x-{字面 name}` 为**首选**,其余降为过渡兼容项。 +- [x] **T2.3** 运行期 fail-fast 谓词随 T1.2 反转。 + +### P3 发现层(spec §7.4) + +- [x] **T3.1** `IdentityIndex`:扫描 `pkgs/*/*.lua` 读声明身份建表(结果按身份索引,文件名不参与)。 +- [x] **T3.2** `read_xpkg_lua` / `read_xpkg_lua_from_path` / `read_xpkg_lua_from_project_data` 三入口改走 IdentityIndex;候选文件名降为可选加速探测。 +- [x] **T3.3** 同一索引内 `(ns,name)` 重复 → 明确报错并列出两个描述符路径(对齐 xlings 行为)。 + +### P4 依赖升级 + +- [x] **T4.1** `kXlingsVersion` 0.4.68 → **0.4.69** +- [x] **T4.2** `release.yml` / `ci-linux-e2e.yml` / `cross-build-test.yml` 的 `XLINGS_VERSION` 同步 +- [x] **T4.3** 复核 `index_spec.cppm` / `config_migration.cppm` 里 "xlings >= 0.4.68" 注释语义是否需更新 + +### P5 测试 + +- [x] **T5.1** 单测:`XpkgNameForm` 断言反转 + 身份归一化新用例(嵌套 ns、legacy FQN、短名) +- [x] **T5.2** e2e 161 断言反转(split 形态从「拒绝」变「接受」,含点短名变「拒绝」) +- [x] **T5.3** 新 e2e:**短名描述符可安装** +- [x] **T5.4** 新 e2e:**任意文件名可发现**(文件名与身份完全无关) +- [x] **T5.5** 新 e2e:**同索引内两个同短名不同 ns 的包各自可装**(端到端印证 xlings 0.4.69 契约) +- [x] **T5.6** 回归:现网 FQN 形态描述符仍可装(裸 `gtest` → `compat.gtest`) + +### P6 文档 · 版本 · PR + +- [x] **T6.1** SPEC-001 实现状态标记全面更新(❌→✅),删除过渡期条款,状态 `草案 → 评审中` +- [x] **T6.2** `docs/05-mcpp-toml.md` §2.5 + `docs/zh/` 的 xpkg 作者段改写为短名形态 +- [x] **T6.3** CHANGELOG 0.0.106 +- [x] **T6.4** 版本两处同步 0.0.106 +- [ ] **T6.5** 单 PR → CI 全绿 → `gh pr merge --squash --admin` + +### P7 生态 + +- [ ] **T7.1** tag v0.0.106 → 四平台 release → publish-ecosystem(gtc 大件预备手工补传 + GET 核验) +- [ ] **T7.2** xim-pkgindex bump PR → CI → 合入 +- [ ] **T7.3** `xlings install mcpp@0.0.106` 真装验证 + bootstrap pin + +### P8 mcpp-index 迁移 + +- [ ] **T8.1** 48 个描述符 `name` 去命名空间前缀(短名化) +- [ ] **T8.2** `check_package_name.lua` 规则反转;文件名 lint 不加(文件名自由) +- [ ] **T8.3** `index.toml` `min_mcpp`/`latest_mcpp` 抬到 0.0.106(**硬性**:低版本读新描述符会静默 E_NOT_FOUND) +- [ ] **T8.4** `validate.yml` MCPP_VERSION + 三个 matrix 条目 → 0.0.106 +- [ ] **T8.5** `docs/repository-and-schema.md` 身份章节按 SPEC-001 改写 +- [ ] **T8.6** PR → CI 全绿 → 合入 + +**⚠️ 已知阻塞**:mcpp-index CI 升版本时会撞上一个 **0.0.103–0.0.105 区间引入的 libxcb 链接回归**(`libX11.so: undefined reference to xcb_*`,0.0.102 同样冷装 xcb 却通过)。需在 P8 前二分定位(钉 0.0.103 / 0.0.104 各跑一次 linux workspace job)。记录见 memory `xcb-link-regression-0103-0105`。 diff --git a/.agents/docs/2026-07-25-xlings381-index-namespace-keying-design.md b/.agents/docs/2026-07-25-xlings381-index-namespace-keying-design.md new file mode 100644 index 00000000..63c0f39a --- /dev/null +++ b/.agents/docs/2026-07-25-xlings381-index-namespace-keying-design.md @@ -0,0 +1,229 @@ +# xlings#381 索引键补齐命名空间维度 — 设计/优化方案 + +> Issue: [openxlings/xlings#381](https://github.com/openxlings/xlings/issues/381) +> 基线:xlings 0.4.68 / libxpkg 当前 HEAD +> 影响仓:`openxlings/xlings`(catalog 层)、`mcpplibs/libxpkg`(index 层) +> 关联:mcpp SPEC-001 §3.3(`docs/spec/package-identity.md`) + +--- + +## 1. 一句话结论 + +`IndexEntry` **不携带 `namespace`** —— 这是本 bug 的结构性成因,也是任何修法的第一块砖。补上它之后,**修复几乎不需要新机制**:xlings 早已有「多候选 + ambiguous 报错」的完整机器,只是被 `build_match_` 每仓只产一个 match 的瓶颈卡住了。 + +推荐 **方案 B:`entries` 改为 `name → vector`**,把仓内同名包变成候选项,交给既有聚合层处理。 + +--- + +## 2. 根因:两处缺陷,一处是数据丢失、一处是传导瓶颈 + +### 2.1 数据丢失 —— 建索引期静默覆盖 + +```cpp +// libxpkg/src/xpkg-loader.cppm:587 +std::string key = (namespace_.empty() ? "" : namespace_ + "-x-") + pkg.name; +index.entries[key] = std::move(ie); // ← 同名直接覆盖,无任何提示 +``` + +`namespace_` 是**函数参数**(唯一调用点 `xlings/src/core/xim/index.cppm:153` 不传值),不是 `pkg.namespace_`。故键 = 裸 `pkg.name`,同名包互相覆盖。 + +### 2.2 关键结构事实 —— `IndexEntry` 里没有 namespace + +```cpp +// libxpkg/src/xpkg.cppm:94 +struct IndexEntry { + std::string name; // "vscode@1.85.0" + std::string version; + std::filesystem::path path; + PackageType type; + std::string description; + bool installed; + std::string ref; // alias target +}; // ← 没有 namespace +``` + +所以查询路径必须**重新加载并解析 `.lua`** 才知道命名空间: + +```cpp +// xlings/src/core/xim/catalog.cppm:285-300 +if (auto* entry = state.index.find_entry(resolved)) { matched = entry->name; } +auto pkg = state.index.load_package(*matched); // ← 到这一步才拿到 namespace +auto ns = pkg->namespace_.empty() ? state.spec.defaultNamespace : pkg->namespace_; +if (parsed.explicitNamespace && parsed.namespaceName != ns) return {}; // 命中后过滤 +``` + +**这既是本 bug 的成因,也是一个顺带的性能问题**(每次查询都读盘解析 Lua)。补 `namespace` 字段后两者一并解决。 + +### 2.3 传导瓶颈 —— 每仓只产一个 match + +```cpp +// catalog.cppm:328 +std::vector collect_matches_(...) { + for (auto& repo : repos) { + auto match = build_match_(repo, parsed, platform); // ← 单数 + ... + } +} +``` + +**xlings 已经具备处理多候选的全部机器**:`primaryMatches` / `subMatches` 分层、`dedupe_matches_`、`format_ambiguous_candidates`(`catalog.cppm:65`)、显式 ns 过滤。跨仓同名之所以工作正常,正是因为走了这条路。仓**内**同名只是从未有机会进入这条路。 + +> **设计要点:修复不是加机制,是把仓内候选接进已有机制。** + +--- + +## 3. 方案对比 + +| | 方案 | 键形态 | 评价 | +|---|---|---|---| +| A | 复合键 `(namespace, name)` | `map, IndexEntry>` 或拼接字符串 | ❌ 拼接需分隔符,而 `-x-` 已被 `merge()` 用作 sub-index 前缀语义,复用会混淆;无 ns 查询退化为全表扫描;`search`/日志里的键不再是人类可读的包名 | +| **B** | **`name → vector`** | `unordered_map>` | ✅ **推荐**。键仍是包名(`search`、`match_version` 前缀扫描、日志全部不变);无 ns 查询天然拿到全部候选;有 ns 查询在向量内过滤 | +| C | 仅冲突时把 ns 折进键 | 混合 | ❌ 非一致表示,查找要分两种情况,复杂度最高 | + +### 3.1 为什么 B 的改动面最小 + +现有 API 与其在 B 下的对应: + +| 现有 | B 下 | 改动 | +|---|---|---| +| `find_entry(name) → IndexEntry*` | `find_entries(name) → span` | 调用点由「取一个」改为「遍历」 | +| `resolve(name)`(alias 解引用) | 同名多条时按 ns 过滤后解引用 | 需要 ns 参数(可选) | +| `match_version(name)`(`name@ver` 前缀扫描) | 逻辑不变,键仍是 `name`/`name@ver` | **无改动** | +| `search(query)` | 遍历 vector | 小改 | +| `merge(base, overlay, ns)` | 按 `(ns,name)` 去重而非覆盖 | 小改 | +| `mark_installed(name)` | 需要 ns 定位具体条目 | 需要 ns 参数 | + +--- + +## 4. 推荐实现(方案 B) + +### 4.1 第一步:`IndexEntry` 携带 namespace + +```cpp +struct IndexEntry { + std::string namespace_; // ← 新增,建索引期即填,查询无需再解析 .lua + std::string name; + … +}; +``` + +`build_index` 里 `load_package(entry.path)` 已经拿到完整 `Package`,直接填 `ie.namespace_ = pkg.namespace_;` 即可,零额外开销。 + +**这一步单独就有价值**:`build_match_` 不必再 `load_package` 才能判断命名空间,把「每次查询读盘解析 Lua」降为「仅在确定要用该包时才解析」。 + +### 4.2 第二步:`entries` 改为多值 + +```cpp +struct PackageIndex { + std::unordered_map> entries; + … +}; + +// build_index:同 (ns,name) 才算重复,否则并列 +auto& bucket = index.entries[pkg.name]; +auto dup = std::ranges::find_if(bucket, + [&](const IndexEntry& e){ return e.namespace_ == pkg.namespace_; }); +if (dup != bucket.end()) { + log::warn("duplicate package identity ({}, {}) in {} — {} overrides {}", + pkg.namespace_, pkg.name, repo_dir.string(), + entry.path.string(), dup->path.string()); + *dup = std::move(ie); // 真重复才覆盖,且**出声** +} else { + bucket.push_back(std::move(ie)); +} +``` + +> **注意这里同时消灭了「静默」**:真正的 `(ns,name)` 重复现在会告警。原 issue 里那条「最小改进:建索引期冲突告警」被自然吸收。 + +### 4.3 第三步:`build_match_` 产出多个 match + +```cpp +// 由 build_match_ → build_matches_,返回 vector +std::vector build_matches_(RepoState& state, + const ParsedTarget_& parsed, …) { + std::vector out; + for (auto& entry : state.index.find_entries(resolved)) { + auto ns = entry.namespace_.empty() ? state.spec.defaultNamespace + : entry.namespace_; + if (parsed.explicitNamespace && parsed.namespaceName != ns) continue; // ← 过滤前置 + … // 版本选择、storeRoot、installed 判定不变 + out.push_back(std::move(match)); + } + return out; +} +``` + +`collect_matches_` 由 `push_back(match)` 改为 `append_range(matches)`。**下游一行不用改** —— `dedupe_matches_`、`primaryMatches`/`subMatches` 分层、`format_ambiguous_candidates` 全部照常工作。 + +于是: + +- `alpha:demo` / `beta:demo`(仓内同名)→ 各自精确命中 ✅ +- `demo`(不指定 ns,仓内两个同名)→ 走已有 ambiguous 报错,列出两个候选 ✅ —— **与今天跨仓同名的体验完全一致** + +### 4.4 第四步:缓存格式 + +`.xlings-index-cache.json` 的每条 entry 增加 `namespace` 字段,并 **bump `CACHE_FORMAT_VERSION`**。旧缓存因版本不匹配自动失效重建,无需迁移代码。 + +--- + +## 5. 兼容性与风险 + +| 项 | 判断 | +|---|---| +| **索引数据格式** | 无变化 —— 描述符 `.lua` 一个字不用改 | +| **现网索引** | 无同 `name` 冲突(FQN 写法天然错开),修复后行为**完全不变** | +| **缓存** | 版本 bump → 首次运行重建一次,秒级 | +| **公开 API** | `libxpkg` 的 `find_entry`/`resolve`/`mark_installed` 签名有变;消费方仅 xlings 自身,可同批次改 | +| **`merge()` 的 `-x-` 前缀语义** | **保持不动**。它表达的是 sub-index 归属,与包命名空间是两件事,本方案不触碰 | +| **行为变化** | 仅在「仓内同名」这一此前不可用的场景;其余路径逐字节等价 | + +**最大风险点**:`match_version` 的 `name@version` 前缀扫描现在要在 vector 上做。需确认版本化条目(`vscode@1.85.0` 作为**键**)与命名空间维度不打架 —— 建议保持「版本化键仍是独立 bucket」,即 `entries["vscode@1.85.0"]` 与 `entries["vscode"]` 各自成桶,与今天一致。 + +--- + +## 6. 分阶段落地 + +**Phase 1(独立可发,低风险)** +- `IndexEntry` 加 `namespace_`,`build_index` 填值;缓存字段 + 版本 bump +- `build_match_` 改用 `entry.namespace_` 而非 `load_package()` 取 ns → **顺带优化查询性能** +- `build_index` 对真 `(ns,name)` 重复告警 +- 此阶段**行为不变**,纯结构准备 + 一个新告警 + +**Phase 2(修复本 issue)** +- `entries` 改多值;`find_entries`;`build_matches_`;`collect_matches_` 接入 +- 回归:issue 里的最小复现应产出「两个都可见 / 显式 ns 各自命中 / 裸名报 ambiguous 列两候选」 + +**Phase 3(可选清理)** +- 审视 `merge()` 的 `-x-` 前缀是否仍必要(多值表后,sub-index 归属或可改由 `IndexEntry` 字段表达,而非污染键) + +--- + +## 7. 验收标准 + +以 issue 的最小复现为准(纯 xlings,隔离 `XLINGS_HOME`): + +``` +$ xlings search demo + ◆ alpha:demo alpha's demo package + ◆ beta:demo beta's demo package # ← 修复前缺失 + +$ xlings info alpha:demo → ✅ +$ xlings info beta:demo → ✅ # ← 修复前 not found + +$ xlings install demo +[error] package 'demo' is ambiguous, candidates: +1. alpha:demo@1.0.0 from repo 'demoidx' +2. beta:demo@1.0.0 from repo 'demoidx' # ← 与跨仓同名体验一致 +``` + +外加:跨仓同名(`xim:mcpp` / `local:mcpp`)行为**不得**变化;现网索引全量 `xlings search` / `install` 无回归。 + +--- + +## 8. 对 mcpp 的影响 + +**mcpp 侧不需要任何改动。** 按 SPEC-001 §1.2 的边界,mcpp 基于 xlings 机制实现、不重定义它们。本修复落地后: + +- SPEC-001 §3.3 的「`(namespace, name)` 唯一」自然成立,其中的「≤0.4.68 已知缺口」小节可删除; +- mcpp-index **无需**任何 lint 规避; +- mcpp 若将来迁到短名形态(`name = "zlib"`),同一索引内 `compat`/`mcpplibs` 同短名不再有障碍 —— 本修复是那次迁移的**前置条件之一**。 diff --git a/.github/workflows/ci-linux-e2e.yml b/.github/workflows/ci-linux-e2e.yml index 0cc37bac..97cdd8ca 100644 --- a/.github/workflows/ci-linux-e2e.yml +++ b/.github/workflows/ci-linux-e2e.yml @@ -123,7 +123,7 @@ jobs: - name: Bootstrap xlings + released mcpp run: | - curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v0.4.68 + curl -fsSL https://raw.githubusercontent.com/openxlings/xlings/main/tools/other/quick_install.sh | bash -s v0.4.69 export PATH="$HOME/.xlings/subos/current/bin:$PATH" xlings update xlings install mcpp -y -g diff --git a/.github/workflows/cross-build-test.yml b/.github/workflows/cross-build-test.yml index 516b3a84..be5c73f1 100644 --- a/.github/workflows/cross-build-test.yml +++ b/.github/workflows/cross-build-test.yml @@ -92,17 +92,20 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - # 0.4.68 (current) — kept in lock-step with the xlings the release + # 0.4.69 (current) — kept in lock-step with the xlings the release # is built/bundled against (release.yml). 0.4.67 carried the # multi-index_repo install fix (openxlings/xlings#374); 0.4.68 adds # per-repo index artifact sources (openxlings/xlings#377) so the - # mcpplibs index syncs via artifact with git as fallback (mcpp#269). + # mcpplibs index syncs via artifact with git as fallback (mcpp#269); + # 0.4.69 keys the index by (namespace, name) so two packages sharing + # a short name in ONE index are both addressable (openxlings/xlings#381) + # — the floor for SPEC-001 short-name descriptors. # A past 0.4.61 "download 404 # for mcpp@" was NOT a version bug — the xlings-res/mcpp GitHub # release assets were uploaded in a broken state (records present, # blobs missing → 404 on GET); re-uploaded clean. The stale-INDEX # half is handled by the marker-clear below. - XLINGS_VERSION: '0.4.68' + XLINGS_VERSION: '0.4.69' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" curl -fsSL -o "/tmp/${tarball}" \ @@ -233,7 +236,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '0.4.68' + XLINGS_VERSION: '0.4.69' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" curl -fsSL -o "/tmp/${tarball}" \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6ff6a9a3..e3652403 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,7 +96,7 @@ jobs: # Pin xlings to a known-good version. The upstream install # script always grabs `latest` (no version override), so we # download + self-install manually to avoid broken releases. - XLINGS_VERSION: '0.4.68' + XLINGS_VERSION: '0.4.69' run: | if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" @@ -278,10 +278,10 @@ jobs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" - - name: Bootstrap mcpp via xlings (latest 0.4.68) + - name: Bootstrap mcpp via xlings (latest 0.4.69) env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '0.4.68' + XLINGS_VERSION: '0.4.69' run: | tarball="xlings-${XLINGS_VERSION}-linux-x86_64.tar.gz" curl -fsSL -o "/tmp/${tarball}" \ @@ -336,13 +336,13 @@ jobs: exec "$(dirname "$0")/bin/mcpp" "$@" LAUNCHER chmod +x "$STAGING/$WRAPPER/mcpp" - # Bundle the aarch64 xlings (0.4.68) so install.sh consumers on aarch64 + # Bundle the aarch64 xlings (0.4.69) so install.sh consumers on aarch64 # get an aarch64 xlings, not the x86_64 bootstrap one. - XLA="xlings-0.4.68-linux-aarch64.tar.gz" + XLA="xlings-0.4.69-linux-aarch64.tar.gz" if curl -fsSL -o "/tmp/$XLA" \ - "https://github.com/openxlings/xlings/releases/download/v0.4.68/$XLA"; then + "https://github.com/openxlings/xlings/releases/download/v0.4.69/$XLA"; then tar -xzf "/tmp/$XLA" -C /tmp - XLBIN=$(find /tmp/xlings-0.4.68-linux-aarch64 -path '*/bin/xlings' -type f | head -1) + XLBIN=$(find /tmp/xlings-0.4.69-linux-aarch64 -path '*/bin/xlings' -type f | head -1) if [ -n "$XLBIN" ]; then mkdir -p "$STAGING/$WRAPPER/registry/bin" cp "$XLBIN" "$STAGING/$WRAPPER/registry/bin/xlings" @@ -417,7 +417,7 @@ jobs: - name: Bootstrap mcpp via xlings env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '0.4.68' + XLINGS_VERSION: '0.4.69' run: | if [ ! -x "$HOME/.xlings/subos/default/bin/xlings" ]; then WORK=$(mktemp -d) @@ -599,7 +599,7 @@ jobs: shell: bash env: XLINGS_NON_INTERACTIVE: '1' - XLINGS_VERSION: '0.4.68' + XLINGS_VERSION: '0.4.69' run: | WORK=$(mktemp -d) zipfile="xlings-${XLINGS_VERSION}-windows-x86_64.zip" diff --git a/CHANGELOG.md b/CHANGELOG.md index 458add8c..a6111f94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,27 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [0.0.106] — 2026-07-25 + +> 落地 **SPEC-001**(`docs/spec/package-identity.md`)—— 包身份的规范形态。0.0.105 为修 #278 引入的「`name` 必须写成完全限定名」是**编码约束而非设计规则**:它的唯一成因是 mcpp 构造安装目标时丢弃了已读到的字面 `name`、改用 `.<短名>` 重新渲染一遍。本版本改为直接使用字面值,规范形态回归 **`namespace` 承载层级、`name` 是单一原子段**。配套 xlings 0.4.69([#381](https://github.com/openxlings/xlings/issues/381),索引改按 `(namespace, name)` 建键)。 + +### 新增 + +- **规范文档目录 `docs/spec/`**:存放规范性文档(语义、约束、匹配机制),每条规则标注实现状态,与使用文档 `docs/*.md`、设计文档 `.agents/docs/*.md` 分工明确。首篇 **SPEC-001** 覆盖 `package.namespace`/`package.name` 形态、`[dependencies]` 四种书写文法及其候选阶梯、完整匹配流程、派生量公式与端到端示例。 +- **描述符文件名自由**:描述符按**声明的身份**被发现,文件叫什么都行。推荐文件名仍作为**快路径**优先探测,全部落空时才回落到按身份扫描 `pkgs/**/*.lua` —— 符合推荐命名的索引**零扫描开销**。这补齐了 identity-first 解析长期只兑现「验证」半边、「发现」半边受固定候选文件名约束的缺口。 +- **同一索引内同短名不同命名空间的包各自可寻址**:`(alpha, widget)` 与 `(beta, widget)` 共存于一个索引,分别安装到 `alpha-x-widget` / `beta-x-widget`。需要 xlings >= 0.4.69。 + +### 变更 + +- **`package.name` 规范形态改为单一原子段**,层级一律放 `namespace`:`namespace = "chriskohlhoff", name = "asio"`。**已发布的完全限定拼写(`name = "chriskohlhoff.asio"`)仍被接受**,前缀会被剥离后再判定 —— 现网全部描述符无需改动即可继续工作。反过来,`namespace = "mcpplibs", name = "capi.lua"` 这类**短名仍带点**的写法被拒绝,因为它描述的是一个没人声明过的命名空间。 +- **安装目标改为 `:<字面 name>@<版本>`**。冒号前缀是包的命名空间(xlings 的 *effective namespace*),不是索引名 —— 这正是同索引同短名得以消歧的原因;无命名空间的上游包用裸字面名。 +- **身份归一化不再 split-on-last-dot**:身份就是描述符声明的两个字段。旧规则会从 `name` 反推命名空间(`ns="a"` + `name="a.b.c"` 静默变成 `(a.b, c)`),现在改为拒绝而非猜测。 +- **xlings 依赖升到 0.4.69**(`kXlingsVersion` 与 release/CI 的 `XLINGS_VERSION` 同步)。 + +### 修复 + +- `mcpp xpkg parse` 与安装路径的 `name` 形态校验语义随规范反转,共用同一谓词,lint 与运行期不会漂移。 + ## [0.0.105] — 2026-07-25 > 包身份口径双侧收敛(#278)。事故:mcpp-index 把 `chriskohlhoff.asio` 的 `name` 从 `"chriskohlhoff.asio"` 改成 `"asio"`(namespace 不变),lint 全绿,三个平台的 workspace job 跑满 20~58 分钟后全挂 `E_NOT_FOUND` —— 描述符能解析、能过 mcpp 的身份闸门,却没有任何消费写法能装上。根因是身份归一化(容忍三种拼写)与安装目标构造(只支持一种)口径断层,契约只写在注释里、无人执行。设计见 `.agents/docs/2026-07-25-issue278-descriptor-name-form-canonicalization-design.md`。 diff --git a/docs/05-mcpp-toml.md b/docs/05-mcpp-toml.md index 5dcd347c..740f8157 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -360,21 +360,31 @@ owning the same short name are settled by index ordering, and (b) **adding an in could silently change which package an existing dependency resolves to**. Requiring the namespace keeps a `mcpp.toml` resolving to the same packages on every machine. -**For xpkg authors:** in an index descriptor, `package.name` must be the *fully -qualified* name — it has to start with `package.namespace` followed by a dot: +**For xpkg authors:** in an index descriptor, identity is the pair +`(package.namespace, package.name)`. The namespace is the dotted path; **`name` is +a single atomic segment**: ```lua package = { namespace = "chriskohlhoff", - name = "chriskohlhoff.asio", -- NOT "asio" - ... + name = "asio", -- one segment; NOT "chriskohlhoff.asio" +} + +package = { + namespace = "mcpplibs.capi", -- depth belongs here + name = "lua", } ``` -The package index is a flat key space keyed by the literal `package.name`, so the -namespace has to be carried in the name itself — otherwise `mcpplibs`'s `zlib` and -`compat`'s `zlib` would collide. A descriptor that gets this wrong parses fine but can -never be installed. `mcpp xpkg parse` checks it, so run it in your index CI. +The filename is only a hint — a descriptor is found by its declared identity, so +`pkgs/c/chriskohlhoff.asio.lua` and `pkgs/z/anything.lua` resolve identically. +`.lua` or `..lua` are recommended (they hit mcpp's fast +path) but not required. + +The older fully-qualified spelling (`name = "chriskohlhoff.asio"`) is still +accepted, so already-published descriptors keep working. `mcpp xpkg parse` +enforces the rule — run it in your index CI. Requires mcpp >= 0.0.106 and +xlings >= 0.4.69; full normative text in `docs/spec/package-identity.md`. ### 2.6 `[dev-dependencies]` — Test Dependencies diff --git a/docs/README.md b/docs/README.md index f1b60ac0..d68b45d1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,3 +11,11 @@ - [06 - Workspaces](06-workspace.md) - [07 - build.mcpp Build Program](07-build-mcpp.md) - [08 - Toolchain Internals](08-toolchain-internals.md) + +## Specifications + +Normative documents — semantics, constraints and matching rules, each rule tagged +with its implementation status. For index authors, contributors and downstream tooling. + +- [spec/](spec/README.md) — index of all specs + - [SPEC-001 — Package identity, `[dependencies]` selectors and matching](spec/package-identity.md) diff --git a/docs/spec/README.md b/docs/spec/README.md new file mode 100644 index 00000000..f9ffdc74 --- /dev/null +++ b/docs/spec/README.md @@ -0,0 +1,39 @@ +# mcpp 规范文档(SPEC) + +本目录存放 mcpp 的**规范性文档** —— 定义机制的语义与约束,而不是使用教程。 + +## 与其他文档的分工 + +| 目录 | 性质 | 读者 | +|---|---|---| +| `docs/*.md` | **使用文档**:怎么用、有哪些字段 | 使用者 | +| `docs/spec/*.md` | **规范文档**:语义、约束、匹配机制、实现状态 | 索引作者、贡献者、下游工具作者 | +| `.agents/docs/*.md` | **设计文档**:某次改动的调研、取舍、实施计划 | 参与该次改动的人 | + +规范文档**同时描述目标规范与当前实现**,每条规则都标注实现状态,因此可以直接用来判断「现在能不能这么写」。 + +## 规范用语 + +按 RFC 2119:**必须 / 禁止**(强制)、**应当**(强烈建议)、**可以**(可选)。 + +## 实现状态标记 + +| 标记 | 含义 | +|---|---| +| ✅ **已实现** | 当前实现与规范一致 | +| ⚠️ **部分实现** | 已有实现,但语义或覆盖面有差异(差异已注明) | +| ❌ **未实现** | 规范要求但尚未支持;当前行为已注明 | + +> 标注 ❌ 的条款**尚未生效**。在其落地前,请按各文档中「当前应当怎么写」一节书写。 + +## 索引 + +| 编号 | 标题 | 状态 | 最后修改 | 对应实现 | +|---|---|---|---|---| +| [SPEC-001](package-identity.md) | 包身份(`package.namespace` / `package.name`)、`[dependencies]` 选择器与匹配机制 | 评审中 v1.0 | 2026-07-25 | mcpp 0.0.106 | + +## 文档约定 + +每份规范**必须**在开头提供元数据表:规范编号、标题、状态(草案 / 评审中 / 已接受)、版本、**最后修改日期**、对应实现版本、相关设计文档与 issue;并**必须**在结尾提供**变更记录**。 + +状态流转:`草案(Draft)` → `评审中(Review)` → `已接受(Accepted)`;被后续规范取代时标 `已废弃(Superseded by SPEC-NNN)`。 diff --git a/docs/spec/package-identity.md b/docs/spec/package-identity.md new file mode 100644 index 00000000..a261165c --- /dev/null +++ b/docs/spec/package-identity.md @@ -0,0 +1,393 @@ +# SPEC-001:包身份、依赖选择器与匹配机制 + +| 项 | 值 | +|---|---| +| **规范编号** | SPEC-001 | +| **标题** | 包身份(`package.namespace` / `package.name`)、`[dependencies]` 选择器与匹配机制 | +| **状态** | **评审中(Review)** —— 已实现 | +| **版本** | 1.0 | +| **最后修改** | 2026-07-25 | +| **对应实现** | mcpp **0.0.106**(xlings >= 0.4.69) | +| **作者/维护** | mcpp-community | +| **相关设计文档** | `.agents/docs/2026-06-20-package-resolution-architecture.md` §4
`.agents/docs/2026-06-26-identity-first-resolution-no-filename.md`
`.agents/docs/2026-07-25-issue278-descriptor-name-form-canonicalization-design.md`
`.agents/docs/2026-07-25-name-namespace-bidirectional-verification-report.md`
`.agents/docs/2026-07-25-name-namespace-canonical-implementation-spec.md` | +| **相关 issue** | [mcpp#278](https://github.com/mcpp-community/mcpp/issues/278)
[xlings#381](https://github.com/openxlings/xlings/issues/381) —— 索引键缺命名空间维度(§3.3) | + +## 规范用语 + +本文中的 **必须 / 禁止 / 应当 / 可以** 按 RFC 2119 的含义使用: + +- **必须(MUST)/ 禁止(MUST NOT)**:强制要求,违反即为错误,由 lint 或运行期拦截。 +- **应当(SHOULD)**:强烈建议;偏离需有充分理由。 +- **可以(MAY)**:可选。 + +## 实现状态标记 + +本文同时描述**目标规范**与**当前实现**。每条规则标注状态: + +| 标记 | 含义 | +|---|---| +| ✅ **已实现** | 0.0.106 的行为与本规范一致 | +| ⚠️ **部分实现** | 已有实现,但语义或覆盖面与本规范有差异(差异已注明) | +| ❌ **未实现** | 本规范要求但尚未支持;当前行为已注明 | + +> **本规范已在 mcpp 0.0.106 全部实现。** 索引作者按 §3 书写即可。0.0.105 及更早版本要求的过渡形态(`name` 必须写成 `.`)仍被接受为**兼容写法**,见 §8。 + +--- + +## 1. 范围与边界 + +### 1.1 本规范覆盖 + +- 索引描述符(`.lua`)中 `package.namespace` / `package.name` 的语义与形态 +- `mcpp.toml` 中 `[dependencies]`(及 `[dev-dependencies]` / `[build-dependencies]` / `[feature-deps.*]`)的书写文法 +- 从用户书写 → 候选身份 → 描述符发现 → 身份校验 → 安装目标的完整匹配机制 + +### 1.2 边界:mcpp 不改动 xlings 规范 + +**mcpp 基于 xlings 已定的机制实现,不重新定义它们。** 下列属于 xlings 侧既定事实: + +| xlings 既定机制 | 内容 | +|---|---| +| **寻址模型** | `:` —— 冒号前比对包声明的 `package.namespace`(**不是**索引名) | +| 索引作用域 | **每个索引一张表**;不同索引之间不共享键空间 | +| xpkg 安装目录 | `{package.namespace}-x-{package.name}` | + +> ≤0.4.68 的索引**存储**实现只按裸 `name` 建键、`namespace` 退化为命中后过滤,与上面的寻址模型有落差。该缺口已由 [xlings#381](https://github.com/openxlings/xlings/issues/381) 在 **0.4.69** 修复,索引改按 `(effectiveNamespace, name)` 建键。**mcpp 0.0.106 起要求 xlings >= 0.4.69。** + +mcpp 的正确做法是**把描述符里读到的字面值原样使用**,而不是在自己这边重新推导一遍。本规范的多数条款是这句话的展开。 + +--- + +## 2. 数据模型:身份是二元组 + +> **一个包的身份是 `(namespace, name)` 二元组。文件名、安装目录名、用户的书写方式都不是身份,而是身份的派生或输入。** + +| 字段 | 角色 | 形态 | +|---|---|---| +| `package.namespace` | 命名空间,**点分层级路径** | 可为空;`compat` / `mcpplibs` / `mcpplibs.capi` / `a.b.c` | +| `package.name` | 包名,**单一原子段** | `zlib` / `asio` / `lua` | + +**层级一律属于 `namespace`。** `(mcpplibs.capi, lua)` 是规范形态。 + +✅ **已实现**:身份二元组模型贯穿 mcpp 全链路(`XpkgIdentity`、`canonical_xpkg_identity`、`xpkg_lua_identity_matches`)。 + +--- + +## 3. 描述符侧规范 + +### 3.1 `package.namespace` + +- **可以**为空。空表示该包属于**默认命名空间的公共包**(如 `imgui` / `ffmpeg` / `opencv`),其裸 `name` 即为完整身份。 +- 非空时,**必须**是点分路径,每段由字母、数字、`-`、`_` 组成。 +- **可以**多级(`mcpplibs.capi`),多级用于表达真实的层级归属。 + +✅ **已实现**。 + +### 3.2 `package.name` + +> **规范:`name` 必须是单一原子段,禁止包含 `.`。任何层级都必须放进 `namespace`。** + +```lua +-- ✅ 规范形态 +package = { namespace = "chriskohlhoff", name = "asio" } +package = { namespace = "mcpplibs.capi", name = "lua" } +package = { namespace = "", name = "imgui" } + +-- ❌ 层级留在 name 里 +package = { namespace = "mcpplibs", name = "capi.lua" } +``` + +✅ **已实现**(0.0.106)。`mcpp xpkg parse` 与安装路径共用同一谓词校验。 + +**兼容写法**:0.0.105 及更早版本要求的完全限定拼写仍被接受 —— + +```lua +package = { namespace = "compat", name = "compat.zlib" } -- legacy,短名 = "zlib" +``` + +命名空间前缀会被剥离后再判定,只有**剩余部分**必须是原子段。现网全部已发布描述符都是这个形态,因此**不会**因本规范而失效。 + +### 3.3 `(namespace, name)` 唯一性 + +> **同一个索引内,`(namespace, name)` 必须唯一。** + +身份是二元组(§2),唯一性因此也按二元组要求 —— `(alpha, asio)` 与 `(beta, asio)` 是两个不同的包,**可以共存于同一个索引**。 + +这与 xlings 的寻址模型一致:目标写作 `:`,冒号前比对包声明的 `package.namespace`,显式写出命名空间即可消歧。 + +✅ **规范如此**。 + +✅ **已实现**。xlings **0.4.69** 起索引按 `(effectiveNamespace, name)` 建键([#381](https://github.com/openxlings/xlings/issues/381)),同一索引内两个同短名不同命名空间的包各自可寻址;裸名请求多候选时报 ambiguity 并列出候选。 + +e2e `163_identity_first_resolution.sh` 端到端锁住:同一 path 索引内 `(alpha, widget)` 与 `(beta, widget)` 各自安装到 `alpha-x-widget` / `beta-x-widget`。 + +> ≤0.4.68 的环境下该场景不可用(其中一个包不可达)。mcpp 0.0.106 因此要求 xlings >= 0.4.69。 + +### 3.4 描述符文件名 + +> **文件名不参与任何解析,可以任意。应当使用 `.lua` 或 `..lua`。** + +身份来自文件**内容**声明的 `package.{namespace,name}`,与文件叫什么无关。 + +✅ **已实现**(0.0.106)。两个半边都已兑现: +- **命中后的校验**:任何候选都必须通过 `xpkg_lua_identity_matches` 复核声明身份(防止 `compat.zlib` 请求被外来的裸 `zlib.lua` 满足)。 +- **发现**:推荐文件名作为**快路径**先探测;全部落空时回落到按声明身份扫描 `pkgs/**/*.lua`。因此叫任何名字、放在任何字母目录下的描述符都能被找到,而符合推荐命名的索引**不付出任何扫描开销**。 + +e2e `163_identity_first_resolution.sh` 锁住:身份为 `(acme, widget)` 的描述符命名为 `pkgs/z/totally-unrelated-name.lua` 仍可解析安装。 + +推荐文件名(快路径探测顺序,非规范): + +| 请求身份 | 探测的文件名(按序) | +|---|---| +| `(mcpplibs, cmdline)` | `cmdline.lua`, `mcpplibs.cmdline.lua`, `compat.cmdline.lua` | +| `(compat, zlib)` | `compat.zlib.lua`, `zlib.lua` | +| `(∅, imgui)` | `imgui.lua`, `compat.imgui.lua` | + +--- + +## 4. 消费侧规范:`[dependencies]` 书写文法 + +用户写的**不是身份,而是选择器(selector)** —— 它展开为一个**有序候选身份列表**,按序尝试,首个满足者胜出。 + +### 4.1 四种书写形式 + +| # | 写法 | 展开为候选(按序) | 语义 | +|---|---|---|---| +| 1 | `[dependencies]`
`gtest = "1.15.2"` | ① `(mcpplibs, gtest)`
② `(∅, gtest)` | **裸名**。只解析默认命名空间搜索路径(§5.2) | +| 2 | `[dependencies]`
`acme.widget = "1.0"` | ① `(mcpplibs.acme, widget)`
② `(acme, widget)` | **点式选择器**。先试默认命名空间下的同名子空间,再试同级 peer root | +| 3 | `[dependencies.acme]`
`widget = "1.0"` | ① `(acme, widget)` | **命名空间子表**。权威、单候选、无猜测 —— **推荐用于第三方命名空间** | +| 4 | `[dependencies]`
`"acme.widget" = "1.0"` | ① `(acme, widget)` | **引号点式键**(legacy)。单候选,等价于 #3 | + +✅ **已实现**(`resolve_dependency_selector` / `make_direct_dependency_selector`)。 + +**#2 与 #3 的区别很重要**:`[dependencies.acme]` 是**显式 TOML 表**,mcpp 视其为命名空间根,直接产出单候选;而 `[dependencies]` 里的点式键是**有序猜测**,会先试 `mcpplibs.` 前缀。 + +判定「是否命名空间根」的条件:该路径是 TOML **显式表**(`[dependencies.acme]` 被真实书写过),**或**键名恰为默认命名空间 `mcpplibs`。 + +多级命名空间同理,`ns` 逐层累积:`[dependencies.mcpplibs.capi]` + `lua = "0.0.3"` → 单候选 `(mcpplibs.capi, lua)`。 + +### 4.2 裸名的解析域是封闭的 + +> **裸名(#1)只解析三类包:`mcpplibs`(默认命名空间)、`compat`(包装命名空间)、无 `namespace` 声明的上游包。任何声明了第三方命名空间的包,禁止用裸名请求。** + +✅ **已实现**(0.0.105)。裸名请求命中一个声明了非空第三方 `namespace` 的描述符时,该候选被拒绝。 + +**设计理由**:全域按短名搜索会让解析结果取决于「本机装了哪些索引」—— +1. 两个命名空间拥有同名包时,胜负由索引顺序决定,而用户 `[indices]` 添加的索引之间**没有全序**; +2. **新增一个索引可能悄悄改变某个既有依赖解析到的包**(供应链隐患); +3. 同一份 `mcpp.toml` 在不同机器上可能解析到不同的包。 + +依赖解析的**可复现性**优先于书写便捷性。 + +### 4.3 解析失败时的诊断 + +候选全部落空时,mcpp **必须**明确失败,并列出尝试过的身份;若该短名存在于其他命名空间,**应当**给出可直接抄写的正确写法。 + +✅ **已实现**(0.0.105): + +``` +error: dependency 'asio': no package found under the namespaces mcpp searched + tried: (mcpplibs, asio), (no namespace, asio) + a package with this name exists under another namespace: + chriskohlhoff.asio + bare names only resolve to the `mcpplibs` / `compat` namespaces. write it out: + [dependencies] + "chriskohlhoff.asio" = "1.38.1" + or: + [dependencies.chriskohlhoff] + asio = "1.38.1" +``` + +该 did-you-mean 扫描**仅**在已失败路径触发,结果**只进错误文案**,禁止回灌解析、lockfile 或安装层 —— 否则就退化成 §4.2 否决的全域模糊匹配。 + +--- + +## 5. 匹配机制 + +### 5.1 完整流程 + +``` +用户书写(selector) + │ §4.1 展开 + ▼ +有序候选身份列表 [(ns₁,n₁), (ns₂,n₂), …] + │ 逐个尝试 + ▼ +① 发现:在候选所属索引里定位描述符文件 ← 当前受文件名约束(§3.4) +② 校验:xpkg_lua_identity_matches 复核声明身份 ← §5.2 +③ 收敛:INV-RESOLVE 拒绝裸名命中第三方 ns ← §4.2 +④ 回填:用描述符**声明的** namespace 定身份 ← §5.3 + │ 首个通过者胜出;全部落空 → §4.3 报错 + ▼ +选定身份 (ns, name) → 派生 wire key / store dir(§6) +``` + +### 5.2 身份校验规则 + +给定候选 `(ns, shortName)` 与一个描述符,`xpkg_lua_identity_matches` 的判定: + +| 候选 `ns` | 判定 | +|---|---| +| 描述符无 `name` | **接受**(无从校验,宽松) | +| `ns` 为空(discovery) | 短名相等即可 | +| `ns == "mcpplibs"`(默认命名空间) | 描述符身份的 ns ∈ **{`mcpplibs`, `compat`}**,或(旧式)无 ns | +| 其他具体 ns | **精确相等** | + +✅ **已实现**。第三行即**默认命名空间搜索路径**:裸 `gtest` 能命中 `compat.gtest`,正是这条。 + +### 5.3 空命名空间的回填(P3) + +候选 `(∅, name)` 命中后,**必须**用描述符**声明的** `namespace` 作为最终身份,而不是候选的空值。 + +若描述符本身未声明 `namespace`(上游裸包如 `opencv`),则**空命名空间就是它的合法身份**,不得强行填充。 + +✅ **已实现**(0.0.105)。此前空命名空间会流入 lockfile 与安装层。 + +--- + +## 6. 派生量 + +三个派生量**全部**由描述符的字面 `name` 与 `namespace` 决定: + +| 派生量 | 规范公式 | 状态 | +|---|---|---| +| xlings 索引键 | `<字面 name>` | ✅ | +| xpkg 安装目录 | `{namespace}-x-{字面 name}` | ✅ | +| 安装 target | `:<字面 name>@<版本>` | ✅ | + +**注意 target 的冒号前缀是包的命名空间**(xlings 的 *effective namespace*),不是索引名 —— 这正是同一索引内两个同短名包得以各自寻址的原因。无命名空间的上游包用裸字面名寻址,无前缀。 + +**关键性质**:三者只依赖字面值,**与 `name` 写成短名还是 legacy FQN 无关**。因此实现无需按形态分支,新旧描述符可长期混存。 + +实测佐证:`{namespace}-x-{字面 name}` 对现网全部 48 个描述符成立 —— `compat` + `compat.zlib` → `compat-x-compat.zlib`;`acme` + `asio` → `acme-x-asio`。两者都是正确的 xlings 行为。 + +--- + +## 7. 实现记录(0.0.106) + +| # | 项 | 规范条款 | 落地方式 | +|---|---|---|---| +| 7.1 | 身份归一化去掉 split-on-last-dot | §2 | `canonical_xpkg_identity` 直接取声明的两个字段,仅为 legacy FQN 剥前缀。不再从 `name` 反推描述符未声明的命名空间 | +| 7.2 | 安装 target 用字面 `name` | §3.2 §6 | `prepare.cppm` 用 `extract_xpkg_name(luaContent)`(描述符已在作用域,零额外 I/O);读不到时回落旧渲染 | +| 7.3 | store 目录 | §6 | `{ns}-x-{短名}` 与 `{ns}-x-{legacy FQN}` 并列为一等候选,其余降为过渡兼容 | +| 7.4 | 文件名自由 | §3.4 | 推荐文件名为快路径;落空后按声明身份扫描 `pkgs/**/*.lua`。符合推荐命名者零扫描开销 | +| 7.5 | `name` 形态校验语义反转 | §3.2 | `xpkg_name_form_violation` 改判「短名必须原子」;legacy FQN 前缀先剥离后再判 | + +**核心变化是三处「减法」**:不再从 `name` 反推命名空间、不再重新渲染 wire key、不再把规范 store 目录埋在猜测候选里。三处都是「值已在手却重新推导一遍」—— 与 #278 的根因同型。 + +--- + +## 8. 迁移 + +### 8.1 兼容矩阵 + +| | FQN 形态描述符(现网) | 短名形态描述符 | +|---|---|---| +| mcpp ≤ 0.0.105 | ✅ | ❌ `E_NOT_FOUND` | +| 实现 §7.2 之后 | ✅ | ✅ | + +**只有一个破损格。** 因此: + +1. mcpp 0.0.106 已发布,对两种形态都工作,**描述符可逐包迁移** —— 不是一次性切换。 +2. 某个索引开始使用短名形态时,其 `index.toml` 的 `min_mcpp` **必须**同步抬到 **0.0.106**。这是硬性的:更低版本的客户端会静默 `E_NOT_FOUND`,无技术手段绕过。 +3. 同一索引内**可以**长期混存两种形态。 + +### 8.2 索引作者应当怎么写 + +```lua +package = { + namespace = "chriskohlhoff", + name = "asio", -- ✅ 规范形态 +} +``` + +已发布的 legacy 形态(`name = "chriskohlhoff.asio"`)**无需改动即可继续工作**;迁移到短名形态时,把该索引的 `min_mcpp` 抬到 0.0.106。 + +--- + +## 9. 端到端示例 + +### 9.1 第三方命名空间包 + +**描述符**(`pkgs/c/chriskohlhoff.asio.lua`,文件名仅为推荐): + +```lua +package = { + spec = "1", + namespace = "chriskohlhoff", + name = "chriskohlhoff.asio", -- 过渡形态;规范形态为 "asio" + xpm = { linux = { ["1.38.1"] = { url = "…", sha256 = "…" } } }, +} +``` + +**消费**: + +```toml +[dependencies.chriskohlhoff] +asio = "1.38.1" +``` + +**匹配过程**: + +``` +选择器 [dependencies.chriskohlhoff] + asio + → 显式命名空间表 → 单候选 (chriskohlhoff, asio) + → 发现:探测 pkgs/c/chriskohlhoff.asio.lua ✓ + → 校验:声明 (chriskohlhoff, chriskohlhoff.asio) + 归一化 → (chriskohlhoff, asio) == 候选 ✓ + → 身份 (chriskohlhoff, asio) + → wire key chriskohlhoff.asio (§7.2 后:asio) + → target chriskohlhoff:chriskohlhoff.asio@1.38.1 + → store dir chriskohlhoff-x-chriskohlhoff.asio (§7.2 后:chriskohlhoff-x-asio) +``` + +**等价写法**:`[dependencies]` + `"chriskohlhoff.asio" = "1.38.1"`。 + +**错误写法**:`[dependencies]` + `asio = "1.38.1"` —— 裸名不解析第三方命名空间(§4.2),报错并给出上述两种正确写法。 + +### 9.2 裸名经 compat 搜索路径 + +```toml +[dependencies] +gtest = "1.15.2" +``` + +``` +选择器 gtest → 候选 ① (mcpplibs, gtest) ② (∅, gtest) +候选①:探测 gtest.lua / mcpplibs.gtest.lua / compat.gtest.lua + 命中 compat.gtest.lua,声明 (compat, compat.gtest) → 归一 (compat, gtest) + 校验:候选 ns 为默认命名空间 → 允许 ns ∈ {mcpplibs, compat} → ✓ +身份 (compat, gtest) → store dir compat-x-compat.gtest +``` + +### 9.3 多级命名空间 + +```lua +package = { namespace = "mcpplibs.capi", name = "mcpplibs.capi.lua" } -- 规范形态:name = "lua" +``` + +```toml +[dependencies.mcpplibs.capi] +lua = "0.0.3" +``` + +身份 `(mcpplibs.capi, lua)` —— 层级在 `namespace`,`name` 是原子段 `lua`。 + +--- + +## 10. 变更记录 + +| 版本 | 日期 | 变更 | +|---|---|---| +| 0.1 | 2026-07-25 | 首版草案。整合 #278 的双向验证结论:确立「身份 = `(namespace, name)`、层级归 `namespace`、`name` 为原子段」为规范形态,并如实标注 0.0.105 的过渡形态(强制 FQN)与全部待实现项 | +| 1.0 | 2026-07-25 | **mcpp 0.0.106 全部实现**:身份归一化去 split-on-last-dot、target 用字面 `name`、store 目录、文件名自由(快路径+身份扫描)、`name` 形态校验反转。xlings 0.4.69 修好 #381 后 §3.3 的 `(namespace, name)` 唯一自然成立。状态 草案 → 评审中 | +| 0.6 | 2026-07-25 | §3.3 改按 **`(namespace, name)` 唯一**表述(与身份数据模型、xlings 寻址模型一致);单仓同名冲突重新定位为 xlings ≤0.4.68 的**实现缺口**(xlings#381 修复中),不再作为规范约束或索引侧 lint 要求 | +| 0.5 | 2026-07-25 | §3.3 补「跨索引仓同名正常工作」的对照:约束**仅**存在于单个索引仓内(每仓一张独立表),跨仓由聚合产生多候选、显式 ns 即可消歧。同步补进 xlings#381 | +| 0.4 | 2026-07-25 | §3.3 关联上游 issue [xlings#381](https://github.com/openxlings/xlings/issues/381)(已提,含纯 xlings 最小复现) | +| 0.3 | 2026-07-25 | **修正 §1.2 与 §3.3 的定性**:此前把「索引以裸 `name` 为键」表述成 xlings 的既定设计,不准确。xlings 的**寻址模型本就是 `ns:name`**(`catalog.cppm build_match_` 用 `pkg->namespace_` 比对冒号前缀);问题在**存储实现**只按 `name` 建键、命名空间退化为命中后的过滤器。据此把该约束重新定性为 **xlings 的实现缺口(应提上游 issue)**,而非设计约束;修好后约束自动消失 | +| 0.2 | 2026-07-25 | §3.3 补充实证与成因说明:澄清 `(namespace, name)` 唯一才是数据模型的要求,`name` 唯一是 xlings 索引键空间缺少命名空间维度所致的**外部约束**;补同索引同名包的实测(一个装上、另一个 `E_NOT_FOUND`)、与 `name` 形态的关系(FQN 形态顺带规避、短名形态暴露)、以及短期 lint / 长期上游需求的承接方式 | + +--- + +> **本规范已在 mcpp 0.0.106 全部实现**,状态为「评审中」。 +> 英文版待补(`docs/spec/` 顶层按仓库惯例为英文,本文档先以中文成稿)。 diff --git a/docs/zh/05-mcpp-toml.md b/docs/zh/05-mcpp-toml.md index 578bcc5c..04c5c318 100644 --- a/docs/zh/05-mcpp-toml.md +++ b/docs/zh/05-mcpp-toml.md @@ -325,95 +325,23 @@ asio = "1.38.1" **为什么不让裸名跨所有命名空间去找?** 因为依赖解析必须可复现。全域短名搜索意味着:(a) 两个命名空间拥有同名包时,胜负由索引顺序决定;(b) **新增一个索引可能悄悄改变某个既有依赖解析到的包**。要求写出命名空间,才能让同一份 `mcpp.toml` 在每台机器上解析到相同的包。 -**给 xpkg 作者:** 索引描述符里的 `package.name` 必须是**完全限定名** —— 以 `package.namespace` 加一个点开头: +**给 xpkg 作者:** 索引描述符里,身份是 `(package.namespace, package.name)` 二元组。命名空间是点分路径,**`name` 是单一原子段**: ```lua package = { namespace = "chriskohlhoff", - name = "chriskohlhoff.asio", -- 不是 "asio" - ... + name = "asio", -- 单一段;不是 "chriskohlhoff.asio" } -``` - -包索引是以 `package.name` 字面值为键的**扁平键空间**,命名空间的区分度必须由名字本身携带 —— 否则 `mcpplibs` 的 `zlib` 与 `compat` 的 `zlib` 会撞键。写错的描述符能被正常解析,却永远装不上。`mcpp xpkg parse` 会校验这一点,请在索引 CI 里跑它。 - -### 2.6 `[dev-dependencies]` — 测试依赖 - -```toml -[dev-dependencies] -gtest = "1.15.2" -``` - -`mcpp build` 忽略这些;`mcpp test` 解析并使用。`mcpp test` 会自动发现 `tests/**/*.cpp` 并编译为测试二进制。运行器与断言框架无关:每个文件是一个以退出码判定的独立二进制——裸 `main`、gtest(经 `[dev-dependencies]` + `gtest_main`)或其他框架均等价,`-- args` 会透传给每个测试二进制(例如 `-- --gtest_filter=...`)。注意:合成的测试 target 名可含 `/`(`tests/00-a/0.cpp` → `00-a/0`),与 `[targets.*]` 的命名文法不同——两套命名空间刻意分离(测试 target 不进清单、不参与发布)。 - -### 2.7 `[toolchain]` — 工具链配置 - -```toml -[toolchain] -default = "gcc@16.1.0" - -# 跨编译目标覆盖 -[target.x86_64-linux-musl] -toolchain = "gcc@15.1.0-musl" -linkage = "static" -``` - -### 2.7.1 `[target.*]` — 平台条件依赖与编译旗标 - -用 `[target.]` 表把依赖和编译旗标限定到某平台。选择子 `` 有三种形式: - -| 选择子 | 含义 | 例子 | -|---|---|---| -| **裸 OS 别名** | 单个 OS / 家族——简洁、最常用 | `[target.windows]`、`[target.unix]` | -| **`cfg(...)` 谓词** | 复合条件(arch / env / 组合子) | `[target.'cfg(all(linux, not(arch = "aarch64")))']` | -| **精确三元组** | 某个具体目标(还承载 `toolchain` / `linkage`) | `[target.x86_64-linux-musl]` | -任一选择子下都可放平台条件的**依赖**与**编译旗标**: - -```toml -# 简洁的裸别名形式——仅在 Windows 上拉取并链接 OpenBLAS。 -[target.windows.dependencies.compat] -openblas = "0.3.33" -[target.windows.build] -ldflags = ["-Llib", "-llibopenblas"] - -# 复合谓词用 cfg(...)(语法:all/any/not 作用在 os/arch/family/env 上, -# 外加裸别名 windows/unix/linux/macos)。 -[target.'cfg(all(linux, not(arch = "aarch64")))'.build] -cxxflags = ["-march=x86-64-v2"] -``` - -`[target.windows]` 与 `[target.'cfg(windows)']` 完全等价——裸别名 -`windows` / `linux` / `macos` / `unix` 永远不是合法的目标三元组,故无歧义。单个 -OS/家族用裸形式;需要 arch/env 条件或组合子时用 `cfg(...)`。 - -- **可放的键**:`dependencies` / `dev-dependencies` / `build-dependencies`,以及 - `build` 下的 `cflags` / `cxxflags` / `ldflags` / `sources`(mcpp 0.0.95+—— - 条件源 glob,如把 `src/x86/**/*.asm` 门控在 `cfg(arch = "x86_64")` 之后; - `!` 排除 glob 同样可用)。 -- **按解析后的目标求值**——交叉构建时是 `--target` 三元组,否则是 host。所以原生 - Linux 构建**根本不会下载** `[target.windows]` 的依赖。 -- **优先级**:精确三元组表压过 `cfg`/别名表;多个命中的谓词表,其旗标会拼接。 -- **`toolchain` / `linkage` 仅限精确三元组**——它们描述某个具体交叉目标,故应放在 - `[target.]`(见上)下,而非裸别名或 `cfg(...)` 下。 - -### 2.8 `[features]` — 特性(Cargo 式,加性) - -```toml -[features] -default = ["base"] # 默认激活集 -base = [] -docking = ["extra"] # 激活 docking 时隐含激活 extra(传递闭包) -extra = [] +package = { + namespace = "mcpplibs.capi", -- 层级放这里 + name = "lua", +} ``` -- 激活来源:包自身 `default` 集 ∪ 显式请求(根包 `mcpp build --features a,b`; - 依赖经长式 dep spec `features = [...]` / `backend = "..."` 糖)。 -- 每个激活的 feature 在该包的编译中得到宏 `-DMCPP_FEATURE_` - (名字转大写,非字母数字转 `_`,如 `backend-a` → `MCPP_FEATURE_BACKEND_A`)。 -- **strict 校验**:目标包声明了 `[features]` 表时,请求未声明的 feature 给出 - warning;`--strict` 下报错。未声明 `[features]` 的包接受任意请求(纯宏用法)。 +文件名只是提示 —— 描述符按声明的身份被发现,所以 `pkgs/c/chriskohlhoff.asio.lua` 与 `pkgs/z/anything.lua` 解析结果完全相同。推荐 `.lua` 或 `..lua`(命中 mcpp 的快路径),但不强制。 +旧的完全限定拼写(`name = "chriskohlhoff.asio"`)仍被接受,已发布的描述符无需改动。`mcpp xpkg parse` 会校验该规则,请在索引 CI 里跑它。需要 mcpp >= 0.0.106 与 xlings >= 0.4.69;规范全文见 `docs/spec/package-identity.md`。 #### 表形式 —— 让 feature 贡献的不止是隐含 feature `[features]` 的条目除了写成数组,还可写成**表**,从而让该 feature 在隐含 feature diff --git a/mcpp.toml b/mcpp.toml index 5694c8f5..42294de8 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "0.0.105" +version = "0.0.106" 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 accc0657..e9f99c0a 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -1846,31 +1846,41 @@ prepare_build(bool print_fingerprint, } } - // xlings resolves packages by the full qualified name (ns.shortName) - // as it appears in the index's name field. Use fqname, not the - // map key (which may be a bare short name for default-ns deps). + // The package name xlings is asked for is the descriptor's LITERAL + // `package.name` — never a value mcpp re-derives (SPEC-001 §6). // - // #278: that sentence used to be a prose contract with no assertion - // behind it — a descriptor written in split form (`namespace="a"`, - // `name="b"`) satisfies mcpp's identity gate but is keyed by the - // index as `b`, so this derived `a.b` could never resolve and the - // user got an opaque E_NOT_FOUND after a full three-platform CI run. - // The contract is now checked against the descriptor mcpp already - // holds (`luaContent`, read above — zero extra I/O), with the SAME - // predicate `mcpp xpkg parse` uses, so lint and runtime cannot drift. - auto fqname = ns.empty() ? shortName - : std::format("{}.{}", ns, shortName); + // 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); + } if (luaContent) { if (auto violation = mcpp::manifest:: xpkg_name_form_violation_from_lua(*luaContent)) { return std::unexpected(std::format( - "dependency '{}': {}\n" - " (mcpp addresses it as '{}', but the index keys " - "it by the literal package.name)", - depName, *violation, fqname)); + "dependency '{}': {}", depName, *violation)); } } - mcpp::ui::info("Downloading", std::format("{} v{}", fqname, version)); + // Human-facing name stays the resolved identity `.` — + // that is what the user wrote in [dependencies], so it is what the + // progress line and errors should echo back. + auto displayName = ns.empty() ? shortName + : std::format("{}.{}", ns, shortName); + mcpp::ui::info("Downloading", std::format("{} v{}", displayName, version)); // #238: retain whatever error/warn text the child DID emit so we // can fold it into a diagnostic if install_packages exits non-zero. @@ -1903,13 +1913,21 @@ prepare_build(bool print_fingerprint, capturedChildError = progress.captured_error(); return r; }; - auto target = std::format("{}@{}", fqname, version); - // For custom indices, use indexName:fullPackageName@version so - // xlings resolves the package by the descriptor's name field while - // still selecting the project-added index. - if (useProjectEnv) { - target = std::format("{}:{}@{}", idxSpec->name, fqname, version); - } + // Target = `:@` (SPEC-001 §6). + // + // 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). + // + // 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 r = install_one(target); if (r && r->exitCode != 0 && (ns.empty() || ns == mcpp::pm::kDefaultNamespace)) { diff --git a/src/cli.cppm b/src/cli.cppm index 11d5603a..622b67fc 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -350,10 +350,10 @@ int run(int argc, char** argv) { .help("Validate every per-OS section (linux/macosx/windows), " "not just the running host's")) .option(cl::Option("allow-split-name") - .help("Skip the INV-NAME check (package.name must be the " - "fully-qualified .). For " - "xlings-native indices, where package.namespace is an " - "install-dir category rather than a package namespace"))) + .help("OBSOLETE (kept accepted so 0.0.105-era index CI keeps " + "working): skip the package.name form check. Since " + "0.0.106 the canonical form IS the short name, so " + "xlings-native descriptors pass without this flag"))) .action(wrap_rc([&dispatch_sub](const cl::ParsedArgs& p) { return dispatch_sub("xpkg", p, {{"parse", cmd_xpkg_parse}}); }))) diff --git a/src/manifest/xpkg.cppm b/src/manifest/xpkg.cppm index 023502c4..677d98be 100644 --- a/src/manifest/xpkg.cppm +++ b/src/manifest/xpkg.cppm @@ -80,30 +80,31 @@ bool xpkg_lua_identity_matches(std::string_view luaContent, std::string_view shortName, bool allowLegacyBareDefault = true, std::string_view indexDefaultNs = {}); -// INV-NAME (#278) — a descriptor's `package.name` literal IS its fully-qualified -// name. When `package.namespace` is declared non-empty, `name` MUST start with -// `.` and carry a non-empty short segment after it. +// INV-NAME (SPEC-001 §3.2) — `package.name` is a SINGLE ATOMIC SEGMENT. All +// hierarchy belongs in `package.namespace`, which is the dotted path. // -// This is not a style convention, it is structurally forced. The package index -// is a FLAT key space keyed by the literal `package.name` -// (libxpkg `xpkg-loader.cppm`: `key = -x-`, looked up with -// exact `entries.find(name)` — zero normalization). The index prefix -// distinguishes INDICES, not package namespaces, so the namespace distinction -// has to be carried by the name itself; otherwise `mcpplibs`'s `zlib` and -// `compat`'s `zlib` collide on one key. mcpp derives the install target as -// `ns + "." + shortName`, which is correct-by-construction under INV-NAME and -// wrong for any other spelling — a split-form descriptor (`namespace="a"`, -// `name="b"`) parses, passes the identity gate, and can never be installed. +// ✅ namespace = "chriskohlhoff", name = "asio" +// ✅ namespace = "mcpplibs.capi", name = "lua" +// ❌ namespace = "mcpplibs", name = "capi.lua" (short name has a dot) // -// DELIBERATELY NARROW: the check fires only when a non-empty namespace is -// DECLARED. It must NOT be written as the general comparison -// "literal name != derived fqname" — that would flag the working compat-alias -// path (a bare `gtest` request reads `compat.gtest.lua`, whose literal name -// `compat.gtest` differs from the derived `gtest`, yet resolves fine via the -// `compat.` retry in prepare.cppm). Cases that return nullopt: -// • no `name` → cannot verify (matches the gate's lenient stance) -// • no `namespace` → xim upstream bare packages are legal -// • `name` starts with `.` → conforming +// Why it matters: identity is the pair (namespace, name), and xlings addresses +// packages as `:` with the LITERAL name. A +// `name` carrying dots the namespace does not account for describes a package +// whose namespace nobody declared — mcpp refuses to guess where the split +// belongs rather than silently inventing one (which the old +// split-on-last-dot normalization did). +// +// ACCEPTED, NOT A VIOLATION — the legacy fully-qualified spelling: +// +// namespace = "compat", name = "compat.zlib" → short name "zlib" +// +// Descriptors written before SPEC-001 repeat the namespace inside `name`. They +// stay installable (the literal name is what the index is keyed by either way), +// so the check strips that prefix before judging. Only what remains has to be +// atomic. Cases that return nullopt: +// • no `name` → cannot verify (gate's lenient stance) +// • short name has no '.' → conforming +// • legacy `.` spelling → conforming (compat) // // Returns a ready-to-display diagnostic body on violation, nullopt otherwise. std::optional @@ -682,26 +683,33 @@ std::string extract_xpkg_name(std::string_view luaContent) { XpkgIdentity canonical_xpkg_identity(std::string_view declaredNs, std::string_view declaredName, std::string_view indexDefaultNs) { - // Step 1 — owning-index namespace: a descriptor that declares no namespace - // inherits the namespace of the index it lives in. + // Step 1 — effective namespace: a descriptor that declares no namespace + // inherits the namespace of the index it lives in. Matches xlings' + // `effectiveNamespace` (its issue-381 design §2.2), so mcpp and xlings + // agree on what a descriptor's namespace IS. std::string ns(declaredNs.empty() ? indexDefaultNs : declaredNs); std::string name(declaredName); - // Step 2 — fully-qualified name. A declared name already prefixed with the - // namespace IS the FQN; otherwise the namespace is prepended. With no - // namespace at all, the declared name stands alone. - std::string fqn; - if (ns.empty()) { - fqn = name; - } else { + // Step 2 — the short name. `namespace` owns the hierarchy and `name` is a + // single atomic segment (SPEC-001 §2), so the identity is simply the two + // declared fields. The only transformation is stripping a legacy + // fully-qualified spelling: descriptors written before SPEC-001 repeat the + // namespace inside `name` (`namespace="compat", name="compat.zlib"`), and + // that prefix is not part of the short name. + // + // NOTE — this deliberately no longer splits on the LAST dot. That old rule + // inferred a namespace the descriptor never declared: `namespace="a"` with + // `name="a.b.c"` silently became `(a.b, c)`. Identity is now exactly what + // the descriptor says; a `name` that still carries dots after the legacy + // prefix is rejected by `xpkg_name_form_violation` instead of being + // reinterpreted here. + if (!ns.empty()) { std::string prefix = ns + "."; - fqn = name.starts_with(prefix) ? name : ns + "." + name; + if (name.starts_with(prefix) && name.size() > prefix.size()) { + name = name.substr(prefix.size()); + } } - - // Step 3 — split the FQN on its LAST dot: prefix → ns, final segment → name. - auto pos = fqn.rfind('.'); - if (pos == std::string::npos) return XpkgIdentity{ /*ns=*/{}, /*name=*/fqn }; - return XpkgIdentity{ fqn.substr(0, pos), fqn.substr(pos + 1) }; + return XpkgIdentity{ std::move(ns), std::move(name) }; } XpkgIdentity canonical_xpkg_identity_from_lua(std::string_view luaContent, @@ -753,29 +761,43 @@ xpkg_name_form_violation(std::string_view declaredNs, std::string_view declaredName) { if (declaredName.empty()) return std::nullopt; // cannot verify → lenient - if (declaredNs.empty()) return std::nullopt; // bare upstream package - std::string prefix = std::string(declaredNs) + "."; - if (declaredName.starts_with(prefix) && - declaredName.size() > prefix.size()) { - return std::nullopt; // conforming FQN + // Legacy fully-qualified spelling: `namespace="compat", name="compat.zlib"`. + // Descriptors written before SPEC-001 look like this and remain installable + // (the literal name is what the index is keyed by, either way), so accept + // them — but only when the remainder is a single atomic segment. + std::string_view shortName = declaredName; + if (!declaredNs.empty()) { + std::string prefix = std::string(declaredNs) + "."; + if (declaredName.starts_with(prefix) && + declaredName.size() > prefix.size()) { + shortName = declaredName.substr(prefix.size()); + } } - // The expected spelling: the declared name is the SHORT segment when it - // carries no namespace prefix at all; when it carries a *different* prefix - // we still suggest `.` rather than guessing which half - // the author meant — the namespace field is the authority here. + if (shortName.find('.') == std::string_view::npos) return std::nullopt; + + // What's left carries dots that `namespace` does not account for. Identity + // is (namespace, name) where `namespace` is the dotted path and `name` is + // ONE segment (SPEC-001 §2/§3.2), so the hierarchy has to move into + // `namespace` — mcpp will not guess where the split belongs. + auto lastDot = shortName.rfind('.'); + auto suggestedNsTail = shortName.substr(0, lastDot); + auto suggestedShort = shortName.substr(lastDot + 1); + std::string suggestedNs = declaredNs.empty() + ? std::string(suggestedNsTail) + : std::string(declaredNs) + "." + std::string(suggestedNsTail); + return std::format( - "package.name must be the fully-qualified name when package.namespace " - "is declared\n" + "package.name must be a single atomic segment — the hierarchy belongs " + "in package.namespace\n" + " namespace = \"{}\"\n" + " name = \"{}\" <-- short name \"{}\" still carries a '.'\n" + " Identity is (namespace, name): `namespace` is the dotted path,\n" + " `name` is ONE segment. Move the depth into the namespace:\n" " namespace = \"{}\"\n" - " name = \"{}\" <-- expected \"{}{}\"\n" - " The index is a flat key space keyed by the LITERAL package.name,\n" - " while mcpp addresses the package as .. They\n" - " never meet, so this descriptor parses but can never be installed\n" - " (E_NOT_FOUND). See mcpp#278.\n" - " fix: name = \"{}{}\"", - declaredNs, declaredName, prefix, declaredName, prefix, declaredName); + " name = \"{}\"", + declaredNs, declaredName, shortName, suggestedNs, suggestedShort); } std::optional diff --git a/src/pm/compat.cppm b/src/pm/compat.cppm index b196438d..9f1f7aaf 100644 --- a/src/pm/compat.cppm +++ b/src/pm/compat.cppm @@ -224,8 +224,18 @@ inline std::vector install_dir_candidates(std::string_view ns, auto fqname = ns.empty() ? std::string(shortName) : std::format("{}.{}", ns, shortName); - // Canonical: -x-. (xlings current layout) + // xlings names an xpkg directory `{package.namespace}-x-{package.name}` — + // whatever `name` literally says (SPEC-001 §6, verified against all + // installed packages). mcpp does not know the literal here, so it offers + // the two spellings a descriptor can legally use, BOTH canonical: + // + // -x- SPEC-001 form (name = "zlib") + // -x-. legacy FQN form (name = "compat.zlib") + // + // Short-name first: it is the form new descriptors use, and the one an + // ambiguous hit should resolve toward. if (!ns.empty()) { + candidates.push_back(std::format("{}-x-{}", ns, shortName)); candidates.push_back(std::format("{}-x-{}", ns, fqname)); } @@ -238,11 +248,10 @@ inline std::vector install_dir_candidates(std::string_view ns, // such as bare `gtest = "1.15.2"`. if (ns.empty() || ns == mcpp::pm::kDefaultNamespace) { candidates.push_back(std::format("compat-x-compat.{}", shortName)); + candidates.push_back(std::format("compat-x-{}", shortName)); } - // Bare short name variants if (!ns.empty()) { - candidates.push_back(std::format("{}-x-{}", ns, shortName)); candidates.push_back(std::format("{}-x-{}", indexName, shortName)); } diff --git a/src/pm/package_fetcher.cppm b/src/pm/package_fetcher.cppm index 0e61e3d2..b6e26403 100644 --- a/src/pm/package_fetcher.cppm +++ b/src/pm/package_fetcher.cppm @@ -640,6 +640,46 @@ read_identity_verified_xpkg_lua(const std::filesystem::path& pkgsDir, // Filename matched but the descriptor is a different package — keep // scanning the remaining candidates / index dirs. } + + // ── Identity-first fallback: the filename is only a hint ──────── + // + // SPEC-001 §3.4 — a descriptor's identity is its declared + // `package.{namespace,name}`; the file may be called anything. The + // candidate-filename probes above are a fast path for the RECOMMENDED + // names (`.lua` / `..lua`), not a constraint: a + // package filed under any other name must still resolve. + // + // This closes the "discovery half" of identity-first resolution — until now only + // the VERIFY half was implemented (every hit re-checked against declared + // identity), while discovery silently could not see a descriptor whose + // filename matched no candidate. + // + // Cost: runs ONLY after every candidate missed, so a conforming index pays + // nothing. Every hit goes through the same identity gate, so a scan can + // never accept a package the filename path would have rejected. + auto scan_dir = [&](const std::filesystem::path& dir) + -> std::optional + { + for (auto& entry : std::filesystem::directory_iterator(dir, ec)) { + if (entry.path().extension() != ".lua") continue; + std::ifstream is(entry.path()); + if (!is) continue; + std::stringstream ss; ss << is.rdbuf(); + auto content = ss.str(); + if (mcpp::manifest::xpkg_lua_identity_matches( + content, ns, shortName, /*allowLegacyBareDefault=*/true, + indexDefaultNs)) { + return content; + } + } + return std::nullopt; + }; + + if (auto hit = scan_dir(pkgsDir)) return hit; // descriptors sitting flat + for (auto& letterDir : std::filesystem::directory_iterator(pkgsDir, ec)) { + if (!letterDir.is_directory(ec)) continue; + if (auto hit = scan_dir(letterDir.path())) return hit; + } return std::nullopt; } diff --git a/src/toolchain/fingerprint.cppm b/src/toolchain/fingerprint.cppm index 2ae8f701..409e2f51 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.105"; +inline constexpr std::string_view MCPP_VERSION = "0.0.106"; struct FingerprintInputs { Toolchain toolchain; diff --git a/src/xlings.cppm b/src/xlings.cppm index e06fe7be..91424d26 100644 --- a/src/xlings.cppm +++ b/src/xlings.cppm @@ -36,7 +36,7 @@ namespace pinned { // Keep in lock-step with the XLINGS_VERSION pins in release.yml / // cross-build-test.yml / ci-linux-e2e.yml (the xlings actually bundled // into releases). Printed by `mcpp self env`. - inline constexpr std::string_view kXlingsVersion = "0.4.68"; + inline constexpr std::string_view kXlingsVersion = "0.4.69"; inline constexpr std::string_view kNasmVersion = "3.02"; } diff --git a/tests/e2e/161_xpkg_name_form.sh b/tests/e2e/161_xpkg_name_form.sh index e5c1b5cf..8cd4132f 100755 --- a/tests/e2e/161_xpkg_name_form.sh +++ b/tests/e2e/161_xpkg_name_form.sh @@ -1,73 +1,88 @@ #!/usr/bin/env bash # requires: -# 161_xpkg_name_form.sh — INV-NAME (#278): `package.name` must BE the -# fully-qualified name whenever `package.namespace` is declared. +# 161_xpkg_name_form.sh — SPEC-001 §3.2: `package.name` is a SINGLE ATOMIC +# SEGMENT; all hierarchy belongs in `package.namespace`. # -# Why this matters: the package index is a FLAT key space keyed by the literal -# `package.name`, while mcpp addresses a dependency as `ns + "." + shortName`. -# A split-form descriptor (namespace="a", name="b") satisfies mcpp's identity -# gate and prints fine from `xpkg parse`, but the two never meet — the package -# is uninstallable on every platform, and before this check the user found out -# an hour into a three-platform CI run via an opaque E_NOT_FOUND. +# ✅ namespace = "chriskohlhoff", name = "asio" +# ✅ namespace = "mcpplibs.capi", name = "lua" +# ✅ namespace = "compat", name = "compat.zlib" (legacy FQN, kept working) +# ❌ namespace = "mcpplibs", name = "capi.lua" (short name has a dot) # -# Covered here: the lint gate (`mcpp xpkg parse`), its `--json` shape, the -# `--allow-split-name` escape for xlings-native indices, and the runtime -# fail-fast that catches descriptors which never passed through index CI. +# Why the last one is rejected rather than reinterpreted: identity is the pair +# (namespace, name). A `name` carrying dots the namespace does not account for +# describes a package whose namespace nobody declared. mcpp used to split such a +# name on its LAST dot and silently invent `(mcpplibs.capi, lua)` — a namespace +# absent from the descriptor. It now refuses to guess. +# +# This test was the inverse before mcpp 0.0.106 (it asserted that the short form +# was the violation). See docs/spec/package-identity.md §3.2 and mcpp#278. set -e TMP=$(mktemp -d) trap "rm -rf $TMP" EXIT cd "$TMP" -# ── 1. split form is rejected by the lint, and says how to fix it ──── -cat > split.lua <<'EOF' +mk() { # mk + cat > "$1" < /dev/null + +# ── 2. hierarchical namespace with an atomic short name ───────────── +mk nested.lua "mcpplibs.capi" "lua" +"$MCPP" xpkg parse nested.lua > /dev/null -if "$MCPP" xpkg parse split.lua > /dev/null 2>&1; then - echo "FAIL: split-form descriptor must be rejected"; exit 1 +# ── 3. legacy fully-qualified spelling still accepted ─────────────── +# Descriptors written before SPEC-001 repeat the namespace inside `name`. +# They resolve to the same identity and stay installable, so they must not +# become errors — that would break every currently published package. +mk legacy.lua "compat" "compat.zlib" +"$MCPP" xpkg parse legacy.lua > /dev/null + +# ── 4. non-atomic short name is rejected, with the fix spelled out ── +mk bad.lua "mcpplibs" "capi.lua" +if "$MCPP" xpkg parse bad.lua > /dev/null 2>&1; then + echo "FAIL: a short name containing '.' must be rejected"; exit 1 fi -"$MCPP" xpkg parse split.lua 2>&1 | tee split.err -grep -q "fully-qualified" split.err -# The diagnostic must carry the exact spelling to write — a bare "invalid" -# would leave the author guessing which half of the identity to change. -grep -q 'chriskohlhoff.asio' split.err +"$MCPP" xpkg parse bad.lua 2>&1 | tee bad.err +grep -q "single atomic segment" bad.err +# The diagnostic must name BOTH corrected fields, not just complain. +grep -q 'mcpplibs.capi' bad.err +grep -q '"lua"' bad.err -# ── 2. the FQN form passes ─────────────────────────────────────────── -sed 's/name = "asio"/name = "chriskohlhoff.asio"/' split.lua > fqn.lua -"$MCPP" xpkg parse fqn.lua > /dev/null +# ── 5. dotted name with no namespace at all is rejected ───────────── +# It would resolve into a namespace written down nowhere. +mk nons.lua "" "mcpplibs.capi.lua" +if "$MCPP" xpkg parse nons.lua > /dev/null 2>&1; then + echo "FAIL: dotted name without a namespace must be rejected"; exit 1 +fi -# ── 3. --json carries the violation machine-readably (index CI) ────── -if "$MCPP" xpkg parse --json split.lua > split.json 2>/dev/null; then +# ── 6. --json carries the violation machine-readably (index CI) ───── +if "$MCPP" xpkg parse --json bad.lua > bad.json 2>/dev/null; then echo "FAIL: --json must still exit non-zero on violation"; exit 1 fi python3 - <<'PY' import json -j = json.load(open("split.json")) +j = json.load(open("bad.json")) assert "error" in j, j -assert "chriskohlhoff.asio" in j["error"], j -assert j["namespace"] == "chriskohlhoff" and j["name"] == "asio", j -print("json ok") +assert "atomic" in j["error"], j PY -# ── 4. --allow-split-name opts out (xlings-native indices) ─────────── -# In xim-pkgindex / -scode, `package.namespace` is an install-dir CATEGORY -# ("config", "scode") and the index is keyed by the bare name, so the split -# spelling is correct there. Those trees lint with this flag. -"$MCPP" xpkg parse --allow-split-name split.lua > /dev/null - -# ── 5. runtime fail-fast: a descriptor that skipped index CI ───────── -# A `[indices]` path index is the transport that bypasses lint entirely, so -# the same predicate has to guard the install path. It must fire BEFORE any -# download, from the descriptor mcpp already holds in memory. +# ── 7. runtime fail-fast: a descriptor that skipped index CI ──────── +# A `[indices]` path index bypasses lint entirely, so the same predicate has +# to guard the install path — and must fire BEFORE any download. mkdir -p idx/pkgs/d -cat > idx/pkgs/d/demo.thing.lua <<'EOF' +cat > idx/pkgs/d/thing.lua <<'EOF' package = { - spec = "1", namespace = "demo", name = "thing", + spec = "1", namespace = "demo", name = "sub.thing", xpm = { linux = { ["1.0.0"] = { url = "u", sha256 = "h" } }, macosx = { ["1.0.0"] = { url = "u", sha256 = "h" } }, windows = { ["1.0.0"] = { url = "u", sha256 = "h" } } }, @@ -85,16 +100,14 @@ version = "0.1.0" demo = { path = "../idx" } [dependencies.demo] -thing = "1.0.0" +"sub.thing" = "1.0.0" EOF echo 'int main() { return 0; }' > src/main.cpp if "$MCPP" build > build.out 2>&1; then - echo "FAIL: split-form descriptor must not build"; cat build.out; exit 1 + echo "FAIL: non-atomic short name must not build"; cat build.out; exit 1 fi -grep -q "fully-qualified" build.out || { cat build.out; exit 1; } -grep -q "demo.thing" build.out || { cat build.out; exit 1; } -# It must fail at resolve time, never after fetching the (nonexistent) asset. +grep -q "single atomic segment" build.out || { cat build.out; exit 1; } if grep -q "Downloading" build.out; then echo "FAIL: must fail before any download"; cat build.out; exit 1 fi diff --git a/tests/e2e/163_identity_first_resolution.sh b/tests/e2e/163_identity_first_resolution.sh new file mode 100755 index 00000000..169a0670 --- /dev/null +++ b/tests/e2e/163_identity_first_resolution.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# requires: +# 163_identity_first_resolution.sh — SPEC-001 §3.4 / §6: a package is found by +# its DECLARED identity `(namespace, name)`, and addressed on the wire by the +# LITERAL `package.name`. +# +# Three properties, none of which held before mcpp 0.0.106: +# +# 1. A short-name descriptor (`namespace="acme", name="widget"`) installs. +# Previously mcpp re-derived the wire name as `.` and asked the +# index for `acme.widget`, which it is not keyed by → E_NOT_FOUND (#278). +# 2. The filename is only a hint. A descriptor called anything, in any letter +# directory, is still discovered by its declared identity. Previously +# discovery probed a fixed candidate-filename list, so such a file was +# invisible even though the identity gate would have accepted it. +# 3. Two packages sharing a short name under different namespaces coexist in +# ONE index and are each addressable. This needs xlings >= 0.4.69 +# (openxlings/xlings#381); mcpp's part is sending `:` +# rather than a reconstructed string. +# +# The legacy fully-qualified spelling must keep working throughout — every +# currently published descriptor uses it. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +# A real, downloadable payload so this exercises the whole install path rather +# than stopping at resolution. Reuse whatever asio the registry already has. +SRC="" +for c in "$MCPP_HOME/registry/data/mcpplibs/pkgs/c/chriskohlhoff.asio.lua"; do + [ -f "$c" ] && SRC="$c" && break +done +if [ -z "$SRC" ]; then + echo "SKIP 163_identity_first_resolution (no asio descriptor in registry)" + exit 0 +fi + +mkidx() { # mkidx + mkdir -p "$1/pkgs/$(dirname "$2")" + sed -e "s/namespace = \"chriskohlhoff\"/namespace = \"$3\"/" \ + -e "s/name = \"chriskohlhoff.asio\"/name = \"$4\"/" \ + "$SRC" > "$1/pkgs/$2" +} + +mkapp() { # mkapp + mkdir -p "$1/src" + cat > "$1/mcpp.toml" < "$1/src/main.cpp" +} + +# ── 1. short-name descriptor installs ─────────────────────────────── +mkidx idx1 a/acme.widget.lua acme widget +mkapp app1 acme ../idx1 acme widget +(cd app1 && "$MCPP" build > out.txt 2>&1) || { cat app1/out.txt; exit 1; } +grep -q "Compiling acme.widget" app1/out.txt || { cat app1/out.txt; exit 1; } +# xlings names the dir {namespace}-x-{literal name} — short name → acme-x-widget +test -d "app1/.mcpp/.xlings/data/xpkgs/acme-x-widget" \ + || { echo "FAIL: expected store dir acme-x-widget"; ls -R app1/.mcpp/.xlings/data/xpkgs 2>/dev/null; exit 1; } + +# ── 2. legacy fully-qualified spelling still installs ─────────────── +mkidx idx2 a/acme.widget.lua acme acme.widget +mkapp app2 acme ../idx2 acme widget +(cd app2 && "$MCPP" build > out.txt 2>&1) || { cat app2/out.txt; exit 1; } +# ...and lands under the dir its LITERAL name implies +test -d "app2/.mcpp/.xlings/data/xpkgs/acme-x-acme.widget" \ + || { echo "FAIL: expected store dir acme-x-acme.widget"; exit 1; } + +# ── 3. filename is only a hint ────────────────────────────────────── +# Arbitrary basename, and deliberately the "wrong" letter directory. +mkidx idx3 z/totally-unrelated-name.lua acme widget +mkapp app3 acme ../idx3 acme widget +(cd app3 && "$MCPP" build > out.txt 2>&1) || { + echo "FAIL: descriptor must be discoverable by identity, not filename" + cat app3/out.txt; exit 1; } + +# ── 4. two namespaces, one short name, ONE index ──────────────────── +# Needs xlings >= 0.4.69. Both must be independently addressable. +mkidx idx4 a/alpha.widget.lua alpha widget +mkidx idx4 b/beta.widget.lua beta widget + +mkapp app4a alpha ../idx4 alpha widget +(cd app4a && "$MCPP" build > out.txt 2>&1) || { cat app4a/out.txt; exit 1; } +test -d "app4a/.mcpp/.xlings/data/xpkgs/alpha-x-widget" \ + || { echo "FAIL: alpha:widget should land in alpha-x-widget"; exit 1; } + +mkapp app4b beta ../idx4 beta widget +(cd app4b && "$MCPP" build > out.txt 2>&1) || { + echo "FAIL: beta:widget unreachable — needs xlings >= 0.4.69 (#381)" + cat app4b/out.txt; exit 1; } +test -d "app4b/.mcpp/.xlings/data/xpkgs/beta-x-widget" \ + || { echo "FAIL: beta:widget should land in beta-x-widget"; exit 1; } + +echo "PASS 163_identity_first_resolution" diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index ef79a44a..7092f246 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -1890,16 +1890,16 @@ TEST(XpkgIdentity, DefaultNamespaceRequestMatchesCompatAlias) { // ─── INV-NAME: xpkg_name_form_violation (#278) ────────────────────── // -// `package.name` must BE the fully-qualified name. The index is a flat key -// space keyed by the literal name, while mcpp addresses packages as -// `ns + "." + shortName` — a split-form descriptor (namespace="a", name="b") -// parses, passes the identity gate, and can never be installed. +// `package.name` is a SINGLE ATOMIC SEGMENT; all hierarchy lives in +// `package.namespace` (SPEC-001 §3.2). The legacy fully-qualified spelling +// (`namespace="compat", name="compat.zlib"`) stays accepted for compatibility. -TEST(XpkgNameForm, SplitFormNameIsAViolation) { - auto v = mcpp::manifest::xpkg_name_form_violation("chriskohlhoff", "asio"); - ASSERT_TRUE(v.has_value()); - // The message must carry the exact spelling the author should write. - EXPECT_NE(v->find("chriskohlhoff.asio"), std::string::npos) << *v; +TEST(XpkgNameForm, ShortNameIsCanonical) { + // SPEC-001 form: namespace carries the path, name is one segment. + EXPECT_FALSE(mcpp::manifest::xpkg_name_form_violation("chriskohlhoff", "asio") + .has_value()); + EXPECT_FALSE(mcpp::manifest::xpkg_name_form_violation("mcpplibs.capi", "lua") + .has_value()); } TEST(XpkgNameForm, FqnFormIsClean) { @@ -1941,15 +1941,22 @@ TEST(XpkgNameForm, PrefixWithoutShortSegmentIsAViolation) { .has_value()); } -TEST(XpkgNameForm, FromLuaReadsBothFields) { - constexpr std::string_view splitLua = +TEST(XpkgNameForm, FromLuaAcceptsBothCanonicalAndLegacy) { + constexpr std::string_view shortLua = R"(package = { namespace = "aimol", name = "tensorvia-cpu" })"; - constexpr std::string_view fqnLua = + constexpr std::string_view legacyFqnLua = R"(package = { namespace = "aimol", name = "aimol.tensorvia-cpu" })"; - EXPECT_TRUE( - mcpp::manifest::xpkg_name_form_violation_from_lua(splitLua).has_value()); + // Canonical short-name form and the legacy FQN spelling both resolve to + // the same identity, so both are accepted. EXPECT_FALSE( - mcpp::manifest::xpkg_name_form_violation_from_lua(fqnLua).has_value()); + mcpp::manifest::xpkg_name_form_violation_from_lua(shortLua).has_value()); + EXPECT_FALSE( + mcpp::manifest::xpkg_name_form_violation_from_lua(legacyFqnLua).has_value()); + // But a short name that still carries a dot is rejected. + constexpr std::string_view nonAtomic = + R"(package = { namespace = "mcpplibs", name = "capi.lua" })"; + EXPECT_TRUE( + mcpp::manifest::xpkg_name_form_violation_from_lua(nonAtomic).has_value()); } // ─── canonical_xpkg_identity — the unified (ns, name) model (§4.2) ─── @@ -1991,20 +1998,29 @@ TEST(CanonicalIdentity, DeclaredNamespaceWinsOverIndexDefault) { EXPECT_EQ(id("compat", "compat.zlib", "xim"), want("compat", "zlib")); } -TEST(CanonicalIdentity, DottedNameWithNoNamespaceSplitsOnLastDot) { - // ns=∅, name=a.b → (a, b) - EXPECT_EQ(id("", "a.b"), want("a", "b")); - EXPECT_EQ(id("", "x.y.z"), want("x.y", "z")); +TEST(CanonicalIdentity, DottedNameWithNoNamespaceIsNotReinterpreted) { + // SPEC-001: identity is exactly what the descriptor declares. A dotted + // `name` with no namespace is NOT silently split into one — that inferred + // a namespace nobody wrote. It stays literal here and is rejected by + // xpkg_name_form_violation instead. + EXPECT_EQ(id("", "a.b"), want("", "a.b")); + EXPECT_EQ(id("", "x.y.z"), want("", "x.y.z")); + EXPECT_TRUE(mcpp::manifest::xpkg_name_form_violation("", "a.b").has_value()); } TEST(CanonicalIdentity, HierarchicalNamespaceIsSupported) { // ns=a.b, name=c → (a.b, c) ; name is the single trailing segment. EXPECT_EQ(id("a.b", "c"), want("a.b", "c")); EXPECT_EQ(id("mcpplibs.capi", "lua"), want("mcpplibs.capi", "lua")); - // Nested + prefix-embedded forms both land on the same tuple. - EXPECT_EQ(id("mcpplibs", "mcpplibs.capi.lua"), want("mcpplibs.capi", "lua")); + // Legacy FQN spelling under the SAME namespace strips the prefix. EXPECT_EQ(id("mcpplibs.capi", "mcpplibs.capi.lua"), want("mcpplibs.capi", "lua")); + // But a shorter declared namespace no longer "absorbs" the extra segment: + // the descriptor said mcpplibs, so the identity is mcpplibs — the leftover + // dot makes it a form violation rather than a silent (mcpplibs.capi, lua). + EXPECT_EQ(id("mcpplibs", "mcpplibs.capi.lua"), want("mcpplibs", "capi.lua")); + EXPECT_TRUE(mcpp::manifest::xpkg_name_form_violation( + "mcpplibs", "mcpplibs.capi.lua").has_value()); } TEST(CanonicalIdentity, BareNameNoNamespaceNoIndexStaysRootless) { From 210cc369a42d03cc3dd90f8e0acef8cfed1089d7 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sat, 25 Jul 2026 18:47:38 +0800 Subject: [PATCH 2/2] fix(ci): pick the newest mcpp.exe on Windows; gate e2e 163 step 4 on xlings 0.4.69 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two CI failures from PR #280, both real and both worth fixing rather than working around. 1. Windows e2e ran the PREVIOUS release's binary. `MCPP_SELF=$(find target -name "mcpp.exe" -path "*/bin/*" | head -1)` `target/` is restored from cache and keeps one directory per build fingerprint. A version bump changes the fingerprint, so the freshly built 0.0.106 binary landed next to the cached 0.0.105 one and `head -1` returned whichever the directory walk hit first — hence `Version mismatch: mcpp.toml=0.0.106, --version='mcpp 0.0.105'`. Latent all along; the version bump is just what made two candidates exist. The Linux workflows already sort by mtime and take the newest — this brings the five Windows/release sites in line with them. 2. e2e 163 step 4 asserted a capability the sandbox cannot have yet. That step covers two same-short-name packages in ONE index, which needs xlings >= 0.4.69 (openxlings/xlings#381). The sandbox xlings comes from bootstrapping the PREVIOUS mcpp release, so CI runs 0.4.68 until 0.0.106 ships bundling 0.4.69 — a genuine chicken-and-egg, not a defect. Step 4 now detects the sandbox xlings version and skips with a stated reason when it is older. Properties 1-3 (short-name install, legacy FQN install, arbitrary-filename discovery) are what this change is responsible for and stay asserted unconditionally. Verified both branches locally by swapping the sandbox xlings: 0.4.69 runs step 4, 0.4.68 skips it with the reason printed. --- .github/workflows/ci-windows-e2e.yml | 9 ++++++- .github/workflows/ci-windows.yml | 28 ++++++++++++++++++---- .github/workflows/release.yml | 10 ++++++-- tests/e2e/163_identity_first_resolution.sh | 21 +++++++++++++++- 4 files changed, 60 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci-windows-e2e.yml b/.github/workflows/ci-windows-e2e.yml index 16dcee80..a6ff7086 100644 --- a/.github/workflows/ci-windows-e2e.yml +++ b/.github/workflows/ci-windows-e2e.yml @@ -44,7 +44,14 @@ jobs: run: | export MCPP_VENDORED_XLINGS="$XLINGS_BIN" "$MCPP" build - MCPP_SELF=$(find target -name "mcpp.exe" -path "*/bin/*" | head -1) + # Pick the NEWEST mcpp.exe, not an arbitrary one: `target/` is + # restored from cache and keeps a directory per build fingerprint, + # so after a version bump the freshly built binary sits alongside + # the previous release's. `find | head -1` returned whichever the + # directory walk hit first — which is how a 0.0.106 build ran the + # 0.0.105 binary and failed 01_help_and_version. + MCPP_SELF=$(find target -name "mcpp.exe" -path "*/bin/*" -printf "%T@ %p\n" \ + | sort -rn | head -1 | cut -d" " -f2-) test -n "$MCPP_SELF" || { echo "FAIL: no mcpp.exe"; exit 1; } MCPP_SELF=$(cd "$(dirname "$MCPP_SELF")" && pwd)/$(basename "$MCPP_SELF") "$MCPP_SELF" --version diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 96d33ea0..bb57f98b 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -40,7 +40,14 @@ jobs: run: | export MCPP_VENDORED_XLINGS="$XLINGS_BIN" "$MCPP" build - MCPP_SELF=$(find target -name "mcpp.exe" -path "*/bin/*" | head -1) + # Pick the NEWEST mcpp.exe, not an arbitrary one: `target/` is + # restored from cache and keeps a directory per build fingerprint, + # so after a version bump the freshly built binary sits alongside + # the previous release's. `find | head -1` returned whichever the + # directory walk hit first — which is how a 0.0.106 build ran the + # 0.0.105 binary and failed 01_help_and_version. + MCPP_SELF=$(find target -name "mcpp.exe" -path "*/bin/*" -printf "%T@ %p\n" \ + | sort -rn | head -1 | cut -d" " -f2-) test -n "$MCPP_SELF" || { echo "FAIL: no mcpp.exe"; exit 1; } MCPP_SELF=$(cd "$(dirname "$MCPP_SELF")" && pwd)/$(basename "$MCPP_SELF") "$MCPP_SELF" --version @@ -59,8 +66,14 @@ jobs: VERSION=$(awk -F '"' '/^version[[:space:]]*=/{print $2; exit}' mcpp.toml) WRAPPER="mcpp-${VERSION}-windows-x86_64" ZIPNAME="${WRAPPER}.zip" - - MCPP_BIN=$(find target -name "mcpp.exe" -path "*/bin/*" | head -1) + # Pick the NEWEST mcpp.exe, not an arbitrary one: `target/` is + # restored from cache and keeps a directory per build fingerprint, + # so after a version bump the freshly built binary sits alongside + # the previous release's. `find | head -1` returned whichever the + # directory walk hit first — which is how a 0.0.106 build ran the + # 0.0.105 binary and failed 01_help_and_version. + MCPP_BIN=$(find target -name "mcpp.exe" -path "*/bin/*" -printf "%T@ %p\n" \ + | sort -rn | head -1 | cut -d" " -f2-) test -n "$MCPP_BIN" || { echo "FAIL: no mcpp.exe in target/"; exit 1; } STAGING=$(mktemp -d) @@ -116,7 +129,14 @@ jobs: run: | export MCPP_VENDORED_XLINGS="$XLINGS_BIN" "$MCPP" build - MCPP_SELF=$(find target -name "mcpp.exe" -path "*/bin/*" | head -1) + # Pick the NEWEST mcpp.exe, not an arbitrary one: `target/` is + # restored from cache and keeps a directory per build fingerprint, + # so after a version bump the freshly built binary sits alongside + # the previous release's. `find | head -1` returned whichever the + # directory walk hit first — which is how a 0.0.106 build ran the + # 0.0.105 binary and failed 01_help_and_version. + MCPP_SELF=$(find target -name "mcpp.exe" -path "*/bin/*" -printf "%T@ %p\n" \ + | sort -rn | head -1 | cut -d" " -f2-) test -n "$MCPP_SELF" || { echo "FAIL: no mcpp.exe"; exit 1; } MCPP_SELF=$(cd "$(dirname "$MCPP_SELF")" && pwd)/$(basename "$MCPP_SELF") echo "MCPP_SELF=$MCPP_SELF" >> "$GITHUB_ENV" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e3652403..bda4c578 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -630,8 +630,14 @@ jobs: export MCPP_VENDORED_XLINGS="$XLINGS_BIN" "$MCPP" build - - MCPP_BIN=$(find target -name "mcpp.exe" -path "*/bin/*" | head -1) + # Pick the NEWEST mcpp.exe, not an arbitrary one: `target/` is + # restored from cache and keeps a directory per build fingerprint, + # so after a version bump the freshly built binary sits alongside + # the previous release's. `find | head -1` returned whichever the + # directory walk hit first — which is how a 0.0.106 build ran the + # 0.0.105 binary and failed 01_help_and_version. + MCPP_BIN=$(find target -name "mcpp.exe" -path "*/bin/*" -printf "%T@ %p\n" \ + | sort -rn | head -1 | cut -d" " -f2-) test -n "$MCPP_BIN" || { echo "FAIL: no mcpp.exe in target/"; exit 1; } MCPP_BIN=$(cd "$(dirname "$MCPP_BIN")" && pwd)/$(basename "$MCPP_BIN") echo "Self-hosted binary: $MCPP_BIN" diff --git a/tests/e2e/163_identity_first_resolution.sh b/tests/e2e/163_identity_first_resolution.sh index 169a0670..5acdbfaa 100755 --- a/tests/e2e/163_identity_first_resolution.sh +++ b/tests/e2e/163_identity_first_resolution.sh @@ -86,7 +86,26 @@ mkapp app3 acme ../idx3 acme widget cat app3/out.txt; exit 1; } # ── 4. two namespaces, one short name, ONE index ──────────────────── -# Needs xlings >= 0.4.69. Both must be independently addressable. +# Needs xlings >= 0.4.69 (openxlings/xlings#381): before it, one index repo +# keyed its table by the bare `package.name`, so the second package was +# unreachable even with an explicit namespace. +# +# The sandbox xlings comes from bootstrapping the PREVIOUS mcpp release, so +# until 0.0.106 ships (bundling 0.4.69) CI still runs 0.4.68 here. Skip this +# sub-case rather than assert a capability the sandbox cannot have yet — +# properties 1-3 above are the ones this change is responsible for. +XLBIN="${MCPP_HOME:-$HOME/.mcpp}/registry/bin/xlings" +XLVER="" +[ -x "$XLBIN" ] && XLVER=$("$XLBIN" --version 2>/dev/null | head -1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) +xl_ge_0469() { + [ -n "$XLVER" ] || return 1 + printf '%s\n0.4.69\n' "$XLVER" | sort -V | head -1 | grep -qx "0.4.69" +} +if ! xl_ge_0469; then + echo "PASS 163_identity_first_resolution (step 4 skipped: sandbox xlings ${XLVER:-unknown} < 0.4.69)" + exit 0 +fi + mkidx idx4 a/alpha.widget.lua alpha widget mkidx idx4 b/beta.widget.lua beta widget