diff --git a/.agents/docs/2026-06-26-identity-first-resolution-no-filename.md b/.agents/docs/2026-06-26-identity-first-resolution-no-filename.md index 0e446fe..e9006e2 100644 --- a/.agents/docs/2026-06-26-identity-first-resolution-no-filename.md +++ b/.agents/docs/2026-06-26-identity-first-resolution-no-filename.md @@ -2,7 +2,14 @@ **Date:** 2026-06-26 **Status:** Step 0 landed in v0.0.67 (candidate selection is now identity-first); -§5 `PackageLocator` / `IdentityIndex` choke-point consolidation remains follow-up +§5 `PackageLocator` / `IdentityIndex` choke-point consolidation remains follow-up. +**Partly superseded by #278 (v0.0.105):** §4.5's emit half landed (`mcpp emit xpkg` +now writes both `namespace` and the FQN `name`); its "catalog keys on canonical +(ns,name)" half is **unachievable as written** — the catalog belongs to xlings, is +keyed by the literal `package.name` with exact `find()`, and mcpp's normalization +has no authority over it, so the split/FQN equivalence is instead resolved by +constraining the wire key's spelling (INV-NAME). §4.6(c)'s discovery rung is +narrowed — see the SUPERSEDED note at that row. **Extends:** [`2026-06-20-package-resolution-architecture.md`](2026-06-20-package-resolution-architecture.md) (realizes its deferred §5 `PackageLocator` / identity-indexed slow path), [`2026-05-11-namespace-field-design.md`](2026-05-11-namespace-field-design.md), @@ -389,7 +396,30 @@ declared `(∅, a.b.c)` in the `a.b`-owned index. The user's point, encoded. | candidate kind | rule | matches `(aimol, tensorvia-cpu)`? | |---|---|---| | qualified, ns non-empty | **exact tuple equality** `cand == declared` | `(aimol, tensorvia-cpu)` ✅ · `(mcpplibs.aimol, …)` ❌ · `(mcpplibs, …)` ❌ | -| discovery, ns = `∅` | match by `name` alone across the precedence path; **resolve to the declared `(ns, name)`** before returning | `(∅, tensorvia-cpu)` ✅ → resolves to `(aimol, tensorvia-cpu)` | +| discovery, ns = `∅` | ~~match by `name` alone across the precedence path~~ **SUPERSEDED — see the note below**; **resolve to the declared `(ns, name)`** before returning | ~~`(∅, tensorvia-cpu)` ✅ → resolves to `(aimol, tensorvia-cpu)`~~ | + +> **SUPERSEDED by #278 (mcpp 0.0.105).** The discovery rung is **not** a +> cross-namespace wildcard. It is the "upstream package that declares no +> `namespace`" rung, and a hit whose descriptor declares a non-empty namespace is +> now REJECTED at the dependency-resolution call site (`selectDependencyCandidate` +> in `prepare.cppm`; the gate itself is unchanged, because `mcpp new --template X` +> still discovers by short name). A bare `[dependencies]` key therefore resolves in +> exactly three places: `mcpplibs`, `compat`, and no-namespace upstream packages. +> +> Why the reversal: "match by name alone across the precedence path" makes +> resolution depend on which indices happen to be present. Two namespaces owning +> the same short name would be settled by an index ordering that has **no total +> order** across user-added `[indices]`, and adding an index could silently +> retarget an existing dependency. Reproducibility beats the convenience. +> +> The **P3 half of this row still stands and is now actually implemented**: a +> discovery hit writes back the namespace the DESCRIPTOR declares, not the +> candidate's empty one. Caveat: an empty declared namespace is a legal identity +> for upstream bare packages, not a hole to fill — the "no empty namespace ever +> reaches the install layer" claim in §4.4 presumes §4.1 index-owned namespace +> attribution, which remains unimplemented. +> +> See `.agents/docs/2026-07-25-issue278-descriptor-name-form-canonicalization-design.md` §3.2. **Selection** = first candidate (in the §(a) order) that finds a declared identity by rule §(c). Crucially, "finds" means **an entry with that declared identity exists in diff --git a/.agents/docs/2026-07-24-embedded-platform-support-design.md b/.agents/docs/2026-07-24-embedded-platform-support-design.md new file mode 100644 index 0000000..b15287b --- /dev/null +++ b/.agents/docs/2026-07-24-embedded-platform-support-design.md @@ -0,0 +1,224 @@ +# 嵌入式平台支持 — 方案设计 (Embedded Platform Support) + +- Date: 2026-07-24 +- Status: 设计定调(维护者决策已锁),待实施 +- 关联: [issue #276](https://github.com/mcpp-community/mcpp/issues/276) — feat/RFC: 支持 Buildroot/OpenWrt/Yocto 等嵌入式 Linux SDK 的工程化集成 +- 结构:**背景与定位 → 决策点(为什么,前置)→ 工作清单 → 详情(现状核实/可行性,后置)** + +--- + +## 0. TL;DR + +mcpp 进入嵌入式 Linux 生态的方案:**mcpp 自带匹配-libc 的交叉编译器(最新 GCC),消费开发者提供的外部 sysroot;不做发行版构建器、不逐个适配厂商 SDK。** + +- 核心只需开一个 **sysroot 缝**(机制已在),其余(pkg-config、qemu test-run)是 xlings provisioning + 薄接线。 +- 首发滩头 = **树莓派 aarch64**。 +- 这个架构把 #276 最难的「厂商旧编译器 vs C++23」问题用**「根本不用旧编译器」**直接溶掉——mcpp 永远用自己的现代编译器,设备只提供库。 + +--- + +## 1. 背景与现状(为什么需要这个方案) + +### 1.1 issue #276 要什么 + +一份 RFC,讨论 mcpp 对 hosted 嵌入式 Linux(Buildroot / OpenWrt / Yocto / uClibc·musl·glibc + 厂商定制工具链)的定位。三个核心问题: + +1. 厂商 SDK 锁死旧编译器(如 GCC 8.3、uClibc 绑定)与 mcpp 要 C++23 的矛盾; +2. 与 Buildroot/Yocto/OpenWrt SDK 的集成方式(外部 `TARGET_CC`、staging sysroot、作为 build backend); +3. 第三方库管理与**动态链接**(如 ALSA:必须用设备那份、带插件/配置/ABI 耦合)。 + +诉求一句话:mcpp 能否从「自成体系、自管依赖、hermetic 静态倾向」转向能「消费外部 SDK 的交叉工具链 + 外部 sysroot + 外部第三方库」。 + +### 1.2 mcpp 今天的模型 vs 嵌入式的需要 + +| | mcpp 今天 | 嵌入式需要 | +|---|---|---| +| 工具链 | 自带、download-managed | 现代交叉编译器(可自带) | +| sysroot | 仅内部(自家 payload) | 消费**外部**设备 rootfs | +| 第三方库 | 索引下载、源码构建 | 消费设备已有(动态、pkg-config) | +| 链接 | hermetic(拒绝宿主库) | 需对着外部 sysroot 链 | + +**gap = 缺入口(通用缝),不是缺能力**:`CLibMode::Sysroot` 已能发 `--sysroot`、hermetic 白名单已含 `tc.sysroot`——只差把外部路径喂进去的配置通道。 + +--- + +## 2. 定位与职责边界 + +mcpp **不**做发行版构建器(≠ Yocto/Buildroot),**不**逐个适配厂商 SDK。它是「构建现代 C++23-modules 应用的最佳工具」。职责四分: + +``` +mcpp 拥有: 编译器 + C++ 构建/包模型 + 通用交叉缝(target / sysroot / pkg-config) +库作者/社区拥有: 把每个库从源码打包给 mcpp(compat.*),一次,通吃所有目标 +平台/厂商拥有: 设备 rootfs/sysroot(含专有 + 运行期耦合库) +mcpp 永不拥有: 逐 SDK 适配、变成发行版构建器 +``` + +--- + +## 3. 决策点 + 理由(核心,前置) + +维护者(sunrisepeak)在设计评审中锁定的定调。**每条都附「为什么」。** + +| # | 决策 | 为什么 | +|---|---|---| +| 1 | mcpp = C++ app 构建工具,不做发行版构建器/不逐 SDK 适配 | 造整个平台是 Yocto/Buildroot 的活(几千 recipe + BSP + 十年生命周期),且平台 99% 是 C 而非 C++23-modules,mcpp 的模块图/import std 优势完全用不上;战略应聚焦本职 | +| 2 | **架构 = config②**:mcpp 带(匹配 libc 的)交叉编译器 + 消费外部 sysroot | ABI 沿缝解耦——`--sysroot` 管设备 libc、静态 libstdc++ 自包含 C++ 运行期、C-ABI 管第三方;让 mcpp 用自己的现代编译器,设备只提供库;把 C++23 门槛搬到 mcpp 掌控的编译器一侧 | +| 3 | 滩头 = **树莓派 aarch64**(后续 Jetson…) | 三轴全占最优(mainline ISA + hosted + 浅锁 + glibc);出货最大、社区最广、mcpp 已有 aarch64 交叉基建;打通几乎白送 Jetson(同为 Ubuntu/aarch64/glibc) | +| 4 | 核心单点特性 = **开 sysroot 缝(L2)** | 90/10 的一刀,解锁标准「对着 rootfs 交叉」工作流(所有嵌入式 Linux C++ 开发的通用姿势);机制已在,最小改动 | +| 5 | **C++ 只源码;二进制只 C-ABI + 封装层** | C++ 二进制 ABI 跨编译器/版本天生不安全(名字修饰、libstdc++ ABI、vtable、异常);源码编 = ABI 一致;C-ABI 是唯一稳定的二进制互操作契约。拒绝预编译 C++ `.so` = 正确性,非限制 | +| 6 | 绑定质量/类型安全 = **封装作者的领域知识**,非 mcpp 机制 | schema-ownership 原则(mcpp 定机制、领域专家定语义);保持核心小,绑定可经索引一次写、人人用 | +| 7 | 运行期耦合/专有库 = **dlopen** 或有 sysroot 时**直接链** | dlopen 让链接期只碰 libc/libdl(完全 hermetic、无需 sysroot),自包含二进制 + 运行期用设备库;有 sysroot 时直接链更类型安全。绑定(源码)随索引、二进制留设备。`[runtime] dlopen_libs` 钩子已存在 | +| 8 | 编译器:**先 GCC** | GCC 的 import std(`bits/std.cc`)比 clang 实验实现成熟,且 mcpp 已以 GCC 为默认。clang 路线(像 rustc,一个编译器 `--target` 切 libc,少维护 payload)存档待议 | +| 9 | 厂商预编译 **C++-only** 库 = 用户用**厂商 ABI 建 C 桥 `.so`**,mcpp 经 C ABI 消费 | 把 ABI 匹配负担搬进桥(用厂商工具链编、纯 C 边界、catch 住 C++ 异常),mcpp 只见 C ABI,政策 #5 不破——所以是**支持**,非 non-goal | +| 10 | glibc 基线 = **造 payload 时 pin 低 glibc** + 一次实证 | GCC 版本 × glibc 版本**解耦**,可「最新 GCC + 低 glibc」;工具链自带的静态 libstdc++ 的 glibc 基线 `--sysroot` 压不下去,但 **payload 是 mcpp 自己造的** → 造时 pin 低即可。payload 用**最新 GCC(当前 GCC 16.1 = mcpp 默认)**,GCC 15 只是 import-std 能力 floor,不是 payload 版本 | +| 11 | 无 `.pc`:**开发者自解决** | 生产 rootfs 常剥掉 `-dev`(头 + `.pc`);mcpp 有 `.pc` 就用 pkg-config、无则退回手写 `ldflags`(永远可用);mcpp 不造 `.pc` | +| 12 | sysroot 获取/管理/pinning = **开发者的事** | 产品专属 sysroot 是某个产品的指纹,只有「造了产品的东西」能产出;mcpp 消费不制造。可复现 pinning 仅量产需要(而量产后端不做,见 #14) | +| 13 | sysroot 布局:**支持 3 主流覆盖大部分** | ① Debian/Ubuntu multiarch `/usr/lib//`(滩头)② 纯 `/usr/lib`+`/lib`(musl/Buildroot/OpenWrt)③ `/usr/lib64`(RPM 系/部分 Yocto);靠工具链自带搜索 + 少量 `-L` 兜底 | +| 14 | **L4 后端模式(被 Yocto/Buildroot 驱动):不做** | 与「自管闭环」价值观张力最大;只在量产为 Yocto/Buildroot recipe 时才需;开发者交叉工作流不需要 | +| 15 | 裸机 / freestanding-modules / ESP32:**出范围 / 推迟** | 需 freestanding modules(无 hosted import std)核心特性;ESP32 三难合一(定制 ISA Xtensa + 裸机 + 专有 IDF),且 512KB RAM MCU 上 C++23-modules 优势无关 | +| 16 | qemu 交叉 test-run + pkg-config = **xlings provisioning 免费 + 薄接线** | xlings 能装 qemu/pkgconf(同装工具链机制),不是新子系统;qemu 覆盖逻辑测试,碰硬件的测试仍需真机(模拟固有边界,非缺陷) | +| ~~17~~ | ~~「编译器不支持 modules/C++23」明确诊断~~ → **取消** | config② 下 target 编译器永远是 mcpp 自带的最新 GCC(16+),该场景**架构上构造不出**;唯一残留(显式 `[toolchain]="system"` 指向旧编译器)已被现有 `hasImportStd` 门 + import-std 诊断(`prepare.cppm:3419`)兜住 | + +**元结论**:config②(mcpp 带编译器)把 #276 **核心问题 1(厂商旧编译器 vs C++23)整个溶掉**——靠「根本不用旧编译器」,而非「诊断旧编译器」。对 #276 那句「目标编译器不支持 C++23 时给诊断」的正确答复 = 「不需要,设备的编译器从不被调用」。用架构消解问题,比诊断问题更干净。 + +--- + +## 4. 净工作清单 + +| 优先级 | 项 | 性质 | +|---|---|---| +| **P0** | **sysroot 缝(L2)**:manifest/CLI 字段 → `Toolchain::sysroot` → `CLibMode::Sysroot`(已能发 `--sysroot`)+ hermetic 白名单(已含 `tc.sysroot`);含 3 种布局解析(#13) | 核心,小 | +| **P0** | 匹配 libc 的**最新 GCC(GCC 16)** payload、**pin 低 glibc**(aarch64-linux-gnu 首要,补 armv7/riscv64;glibc+musl 变体)+ **一次 glibc 地板实证** | 分发/数据,非引擎 | +| **P1** | pkg-config(pkgconf via xlings + `PKG_CONFIG_SYSROOT_DIR`/`LIBDIR` sysroot-aware) | 工效,薄 | +| **P1** | qemu 交叉 test-run(qemu-user via xlings + `QEMU_LD_PREFIX=`) | 工作流,薄 | +| 去掉 | L4 后端模式、sysroot pinning/管理、造 `.pc`、modules-不支持诊断 | — | +| 推迟 | freestanding modules(裸机)、clang 路线 | — | + +**glibc 地板实证(P0 的唯一真实未知)**:自建 or 采纳 GCC 16 × 低 glibc(如 2.28/2.31)的 aarch64 交叉链,编一个 `import std;` 最小静态程序,在 qemu-user / 真机不同 glibc 版本下验证「import-std 静态产物的 glibc 地板能压多低」。注:Bootlin 2025.08 bleeding-edge 仍是 GCC 15.1,GCC 16 大概率要自建(mcpp 已有 musl-cross/mingw-cross 自建先例)或等 Bootlin 2026.x。该实证决定工具链选型(自建低-glibc GCC16 / 采纳 Bootlin 某档 / musl-static 兜底)。 + +--- + +## 5. 关键机制(怎么做) + +### 5.1 config②:带编译器 + 消费 sysroot + +mcpp 交叉 GCC 16(**匹配 libc**)+ 外部设备 sysroot(`--sysroot`)。运行期这样拆: + +| 组件 | 来自 | 方式 | +|---|---|---| +| 机器码 | mcpp 的 GCC 16(ISA 代码生成) | — | +| libstdc++ / `import std` 的 `std.o` | mcpp 的 GCC 16 | **静态嵌入**(`-static-libstdc++` 默认)→ 设备老 libstdc++ 不参与 | +| glibc / CRT / 动态链接器 | **设备 sysroot** | `--sysroot`,匹配设备实际 loader | +| 第三方 C-ABI 库(ALSA 等) | **设备 sysroot** | 动态链接,ABI 与 rootfs 一致 | + +### 5.2 sysroot 缝(L2)= 最小插入点 + +- 已有:`CLibMode::Sysroot`(`linkmodel.cppm`)`link_flags()` 发 `--sysroot=`;hermetic 白名单(`hermetic.cppm:117-130`)已含 `tc.sysroot`。 +- 只需:一个 manifest/CLI 字段把外部路径填进 `Toolchain::sysroot` → `resolve_link_model` 自动走 Sysroot 模式 → 链接对着设备 libc + 库,类型安全、hermetic(不用 `allow_host_libs`)。 + +### 5.3 三条溶解尾巴的政策 + +1. **C++ 只源码;二进制只 C-ABI + 封装。** 拒绝预编译 C++ `.so` = 正确性。 +2. **绑定质量 = 封装作者的活。** mcpp 定机制、作者定语义。 +3. **运行期耦合/专有库 = dlopen 或有 sysroot 直接链。** 绑定随索引、二进制留设备。 + +### 5.4 C++ 封装的两种形态 + +- **有源码/C-ABI 库**:全局模块片段 `#include` C 头 → `export module xxx` re-export(`generated_files`/`scan_overrides` 已支持)。 +- **厂商 C++-only 二进制**:用**厂商 ABI 编译器**建一个纯 C 边界的桥 `.so`(内部调 C++、catch 住异常、只导出 `extern "C"`),mcpp 经 C ABI 消费。 + +### 5.5 dlopen vs 直接链(何时用哪个) + +| | 直接链(需 sysroot) | dlopen | +|---|---|---| +| 编译期类型检查 | ✅(有头) | ✗(手动 dlsym,封装层内隐藏) | +| 链接期依赖 | 需 sysroot 里的头/桩 | **只需 libc/libdl,完全 hermetic** | +| 二进制形态 | 动态链设备库 | 链接期自包含(需动态可执行文件才能 dlopen) | +| 适合 | sysroot 齐全、要类型安全 | 版本韧性、避免构建期依赖设备库 | + +注:**全静态可执行文件不能 dlopen**(musl 是失败桩、glibc 不可靠)→ 走 dlopen 需动态可执行文件 + libc 匹配设备 + 静态 libstdc++。 + +--- + +## 6. 详情:现状核实与可行性(后置) + +### 6.1 硬边界:C++23-modules floor(不可协商,但交叉模式绕开) + +mcpp 产物 = C++23 modules + import std + 链接 `std.o`,要求**编译器** GCC ≥ 15 / Clang ≥ 18–19。`hasImportStd` 是**能力门控非版本门控**(`gcc.cppm:66-136`:只看有没有 `bits/std.cc`)。`std.o` 必须由 **target** 工具链的 `bits/std.cc` 编出(`stdmod.cppm`、`gcc.cppm:155-192`)。 + +→ **交叉模式下 target 编译器是 mcpp 自带的(满足),设备只需兼容运行期、不需现代编译器** → 「厂商 GCC 8.3」被绕开。真正出范围:裸机无 import std(需 freestanding-modules)。 + +### 6.2 现状核实(四条线,均代码验证) + +- **工具链身份**:封闭 `Family{Gcc,Llvm,Msvc}`(`registry.cppm:30`);GCC/LLVM 一律走 `to_xim_package` → xim 下载(`registry.cppm:200-255`);manifest toolchain 值只接受 `family@version` 不接受路径。**唯一 detection-first「系统工具链」是 `msvc@system`**(`is_system_toolchain` 硬编码只认 MSVC,`registry.cppm:298-300`);`explicit_compiler` 路径通道已存在(`probe.cppm:234-242`)。 +- **sysroot/链接模型**:`CLibMode{None,PayloadFirst,Sysroot}`(`linkmodel.cppm`);`Sysroot` 模式**已能发 `--sysroot`**,但来源写死(`probe_sysroot`:`-print-sysroot`/xlings remap/macOS SDK),**无外部输入通道**;hermetic 白名单**已含 `tc.sysroot`**(`hermetic.cppm:117-130`)。 +- **pkg-config**:全仓库(src/docs/README/CHANGELOG)grep 零命中——完全不存在。 +- **环境注入**:只 fallback 读 `CXX`(`probe.cppm:246`),不读 `CC/CFLAGS/LDFLAGS/AR/SYSROOT/PKG_CONFIG_*`。 +- **C++23 门控**:默认 c++23;`import std` 路径有明确诊断(`prepare.cppm:3419`);modules-无-import-std 透传原始编译器错误。 + +### 6.3 限制清单(config② 下) + +1. **libc 家族必须匹配**(glibc vs musl):`--sysroot` 换路径不换 libc 目标(GCC 编译期烙死);需同时发 glibc/musl 两种交叉工具链。clang 可用 `--target` 切(更像 rustc)。 +2. **设备 glibc ≥ 工具链 glibc 基线**:预编译静态 libstdc++/libgcc 的基线 `--sysroot` 压不下去 → 造 payload 时 pin 低(决策 #10)。 +3. **保持静态 libstdc++**:动态链设备旧 libstdc++.so 给不了 C++23 符号 → 崩;用默认(静态)。 +4. **sysroot 里 C++-ABI 第三方**:名字修饰 + 两份 libstdc++ 隐患 → 需 C 桥(决策 #9);C-ABI 第三方无碍。 +5. **运行期库闭包完整性**(libatomic 那课):静态兜底或确保设备有需要的运行期库。 + +### 6.4 版本图景(哪些真实工具链跨过 import-std floor) + +| 工具链 / SDK | GCC | libc | 过 floor | +|---|---|---|---| +| **mcpp 默认** | **16.1** | glibc/musl | ✅ payload 用这个 | +| Bootlin 2025.08 bleeding-edge | 15.1 | glibc 2.41 / uClibc-ng / musl | ✅(但仍 15,GCC16 待自建) | +| ARM GNU 15.2.rel1 | 15.x | newlib/glibc | ✅ | +| Zephyr SDK | 14.3 | picolibc | ✗ | +| Yocto 5.0 Scarthgap(LTS) | 13.2 | glibc 2.39 | ✗ | +| OpenWrt / 厂商老 SDK | 12 / 8.x | musl / uClibc | ✗ | + +**结论**:现代 import-std-capable 交叉工具链已存在(Bootlin GCC 15 覆盖三大 libc、ARM GNU 15),但厂商锁死的旧 SDK 仍在硬边界外——所以 config②「自带编译器」而非「消费厂商工具链」是对的。 + +### 6.5 平台可攻性三轴(为什么树莓派是滩头,ESP32 是远角) + +可攻性 = **ISA 是否 mainline × hosted-or-裸机 × 厂商锁深度**。 + +- **树莓派**:mainline aarch64 + hosted + 浅锁 = 三轴最优 → 滩头。 +- **Jetson**:同形状(Ubuntu/aarch64/glibc)→ 几乎白送。 +- **ESP32**:定制 ISA(Xtensa,被迫用厂商工具链)+ 裸机(需 freestanding modules)+ 深度专有 IDF(拥有构建/烧录/WiFi blob)= 三轴最差 → 队尾,且 C++23 优势无关。 + +### 6.6 开放风险 / 需实证的点 + +- **A(头号,需实证)**:glibc 基线选型 —— 见决策 #10 + P0 实证。musl-static 可完全绕开(可移植性务实默认)。 +- **B**:生产 rootfs 常剥 `-dev`(无头/`.pc`)→ 需 SDK staging sysroot;归开发者(决策 #11/#12)。 +- **C**:aarch64-linux-gnu payload 还没有(只有 musl)→ 工作项非风险(决策 C)。 +- **D**:clang vs gcc 战略分叉 → 先 GCC(决策 #8),clang 存档。 +- **E**:厂商 C++-only 库 → C 桥支持(决策 #9)。 +- **F**:sysroot 可复现/pinning → 归开发者(决策 #12)。 +- **G**:multiarch 布局差异 → 支持 3 主流(决策 #13)。 + +--- + +## 7. 关键文件索引 + +- 工具链身份 / xim 映射 / `is_system_toolchain`:`src/toolchain/registry.cppm`(30, 41-62, 200-255, 298-300) +- `explicit_compiler` 路径通道(路径式编译器的种子):`src/toolchain/probe.cppm:234-250`、`detect.cppm:16-37` +- 链接模型 + Sysroot 模式(**最小切入点 L2**):`src/toolchain/linkmodel.cppm`(26-31, 80-95, 226-279) +- hermetic 断言(白名单已含 `tc.sysroot`):`src/build/hermetic.cppm:103-212`(117-130) +- sysroot 探测(**无外部入口 = 待补**):`src/toolchain/probe.cppm:271-380`、`src/fallback/probe_sysroot.cppm` +- C++23 能力门控 / std.o:`src/toolchain/gcc.cppm:66-192`、`stdmod.cppm`、`build/prepare.cppm:3419-3464` +- manifest schema(无 sysroot/pkg-config 字段 = 待补)、per-target `cfg` 条件:`src/manifest/types.cppm`、`toml.cppm`、`build/prepare.cppm:77-191` +- `[runtime] dlopen_libs` 钩子:`src/manifest/types.cppm`(RuntimeConfig)、`build/plan.cppm` +- detection-first 先例(msvc@system):`src/toolchain/msvc.cppm`、`.agents/docs/2026-07-13-msvc-system-toolchain-detection-design.md` +- hermetic link model 底座:`.agents/docs/2026-07-07-hermetic-toolchain-link-model-design.md` +- 交叉工具链先例:`.agents/docs/2026-07-15-mingw-linux-cross-windows-design.md`、`2026-06-23-aarch64-musl-gcc-canadian-cross-rebuild.md` + +--- + +## 附录 A:Rust/Cargo 如何解决同类问题(对照) + +Rust 靠三个结构性属性,mcpp 有其二、缺其一: + +1. **一个通用 LLVM 编译器 + rustup 管理器**(编译器与目标解耦,`rustup target add` 只下预编译 std)→ mcpp 已选「自管工具链」,但 GCC 非天生多目标(须每 arch 一套;clang 路线更接近)。 +2. **`no_std` 分层 std**(裸机目标侧运行期需求为零)→ mcpp 的等价物 = freestanding C++23 modules,**尚未实现**(决策 #15 推迟)。 +3. **静态优先 + `-sys` crate + pkg-config**(动态库尾巴走 `PKG_CONFIG_SYSROOT_DIR`)→ mcpp 静态优先已有,pkg-config 待补(P1)。 + +关键洞见:**Rust 没有消灭 sysroot/外部库问题——hosted Linux + 动态系统库场景 Rust 一样有,解法也一样(委托 C 交叉工具链 + pkg-config + 目标 sysroot,或走静态)。Rust 消灭的是「旧厂商编译器」问题(单一通用上游 + `no_std`)。** mcpp 的 config② + 自管工具链正是对标这一点。 diff --git a/.agents/docs/2026-07-25-issue278-descriptor-name-form-canonicalization-design.md b/.agents/docs/2026-07-25-issue278-descriptor-name-form-canonicalization-design.md new file mode 100644 index 0000000..f31a3fe --- /dev/null +++ b/.agents/docs/2026-07-25-issue278-descriptor-name-form-canonicalization-design.md @@ -0,0 +1,418 @@ +# #278 包身份口径收敛 — 索引侧 + 依赖侧完整方案 + +> Issue: [mcpp-community/mcpp#278](https://github.com/mcpp-community/mcpp/issues/278) +> 相关:mcpplibs/mcpp-index#116(索引侧临时 lint 守卫)、事故现场 mcpp-index run 30121144277 +> 前置设计:`2026-06-20-package-resolution-architecture.md` §4、`2026-06-26-identity-first-resolution-no-filename.md` +> 用户文档落点:`docs/05-mcpp-toml.md` §2.5、`docs/zh/05-mcpp-toml.md` §2.5 + +--- + +## 1. 结论 + +包身份在 mcpp 里有**两侧**,两侧今天都不自洽: + +- **索引侧(xpkg 描述符)**:身份归一化容忍 `(ns="a", name="b")` 与 `(ns="a", name="a.b")` 等价,而安装目标构造硬假设后者。结果:split 形式的包能过解析、能过身份闸门,却没有任何消费写法能装上。 +- **依赖侧(mcpp.toml)**:裸名依赖的候选阶梯 `(mcpplibs, name)` → `(∅, name)` 中,第二档在规范里是"跨命名空间按名发现",在实现里是"再试两个文件名",且落空后**静默回退**到第一档,把 mcpp 自己编造的命名空间当作结论继续跑。 + +本方案对两侧同时**做收敛**,而不是继续扩大模糊匹配: + +- **索引侧** — `package.name` 必须是完全限定名(FQN)。这不是风格约定,是被 xlings 扁平 key 空间**结构性强制**的(§3.1)。 +- **依赖侧** — 命名空间缺省时**只**解析 `mcpplibs` / `compat` / 无命名空间的上游包三类;任何第三方命名空间的包必须写完整(`a.b.c` 或 `[dependencies.]`)。**放弃全域模糊发现**,以依赖解析的稳定性与可复现性换取便捷性。 +- **全索引扫描降级为纯诊断** — 只在已经确定失败的路径上跑一次,产出 did-you-mean,**永不参与解析**。既保住"用户知道该怎么写",又完全避开供应链隐患(§5.3)。 + +--- + +## 2. 事实核验 + +issue 的三条根因分析全部属实,已对 HEAD 逐条核对: + +| 主张 | 核实位置 | 结论 | +|---|---|---| +| 归一化把 split 与 FQN 视为等价 | `src/manifest/xpkg.cppm:657-664` | ✅ | +| 安装目标假设 `name == ns + "." + short` | `src/build/prepare.cppm:1716-1717`、`:1756` | ✅ 契约写成散文,无断言 | +| `luaContent` 已在作用域内却未使用 | `prepare.cppm:1584` → 目标构造 `:1716`,同一 lambda | ✅ 零额外 I/O 即可校验 | +| `mcpp xpkg parse` 只校验 `name` 缺失 | `src/cli/cmd_xpkg.cppm:80-85` | ✅ | +| `mcpp emit xpkg` 生成 split 雏形 | `src/pm/publisher.cppm:133`,全程不输出 `namespace` | ✅ | + +### 2.1 split 形式在真实生态里是 2/62 的异类 + +对本机完整索引快照(`~/.mcpp/registry/data/*/pkgs/**/*.lua`)全量抽取 `(namespace, name)`。**所有声明了 `namespace` 的描述符中,只有两个不是 FQN 形式:** + +``` +chriskohlhoff.asio.lua ns='chriskohlhoff' name='asio' ← 事故当事人 +tensorvia-cpu.lua ns='aimol' name='tensorvia-cpu' ← 同源,静默破损 +``` + +其余全部 FQN(`compat.*` 全族、`mcpplibs.*` 全族、`fmtlib.fmt`、`nlohmann.json`、`marzer.tomlplusplus`,含嵌套命名空间的 `ns='mcpplibs.capi' name='mcpplibs.capi.lua'`)。无 `namespace` 的 xim 上游包(`opencv` / `musl-gcc` / `linux-headers` …)属另一类,不受本规则约束。 + +### 2.1b ⚠️ 实施期修正:`package.namespace` 在 xlings 生态里有第二种含义 + +上面的 62 例只覆盖了 **mcpplibs 索引**。实现 T2 后对**完整 registry**(200+ 描述符,含 `xim-pkgindex`、`xim-pkgindex-scode`、`xim-pkgindex-awesome`)跑谓词,得到 **30 个 split 形式**,而非 2 个。逐个查看后结论明确: + +``` +ns=config name=claude-llm / mcpp-vscode-clangd / rustup-mirror … type=config|bugfix +ns=scode name=zlib / openssl / ncurses / readline … type=package|lib +ns=awesome name=d2x / dragonos / xim … type=pkgindex +ns=fromsource name=util-linux type=package +``` + +**在 xlings-native 索引里,`package.namespace` 是安装目录分类(install-dir category),不是包命名空间。** 索引由 `build_index(repoDir_)` 建、key 是裸 `pkg.name`,消费端 `xlings install zlib` 直接命中 —— **split 形式在那个世界里是正确的**。30 例全部 `mcpp` 段缺失、全部是 xlings 原生类型,mcpp 的依赖解析路径从不经过它们。 + +**这意味着 INV-NAME 只在 mcpp 语义下成立**,谓词不能无差别地对所有描述符开火。实施结论: + +- **默认开启**(mcpp-index 的 CI 正是 `mcpp xpkg parse pkgs/*/*.lua`,免费获得防护); +- 新增 **`--allow-split-name`** 供 xlings-native 树使用; +- 现网**没有任何 xim 仓运行 `mcpp xpkg parse`**(已核 `.github/`),故误报是理论风险而非现实风险。 + +`index.toml` 曾被考虑作为自动判别器(**只有** mcpp 索引带它,xim 三个索引全无),但对"作者单独 lint 一个文件"的常见流程会静默跳过检查,故改用显式 flag。此判别器留作后续可选增强。 + +### 2.2 查找侧已兼容 split,只有目标串不兼容 —— 这是 A 方案的陷阱 + +- `src/pm/compat.cppm:245` `install_dir_candidates` 已产出 `{ns}-x-{shortName}`(= split 描述符的真实 store 目录); +- `src/pm/compat.cppm:190` `xpkg_lua_candidates` 已产出裸 `.lua`。 + +即 split 唯一的硬失败点就是那一条 target 串。但这些候选项全部标注 `COMPAT, remove in 1.0.0` —— **"让 target 改用字面 name"等于把一批已排期删除的兜底提升为永久承重路径**,并让同一个包在 search / store 目录 / 错误文案里永久二义。 + +### 2.3 同类缺陷的潜伏支路 + +单测 `XpkgIdentity.DefaultNamespaceBareNameGatedByFlag`(`tests/unit/test_manifest.cpp:1854`)刻意接纳 `package = { name = "cmdline" }`(无 namespace),请求 `(mcpplibs, cmdline)` 在 `allowLegacyBareDefault=true` 下通过。对这种描述符 xlings 的 key 是 `cmdline`,而 mcpp 推导出 `mcpplibs.cmdline` —— **同一 bug 类**。今天没炸只因真实 mcpplibs 描述符全是 FQN 形式。 + +结论:身份闸门容忍 **三** 种形式(FQN / split / legacy-bare),目标构造器只支持 **一** 种。 + +### 2.4 规范考古:归一化没失效,是它配套的另一半从未实现 + +`2026-06-20 §4.6(b)` 的规范表第一行就是 `aimol | tensorvia-cpu | mcpplibs → (aimol, tensorvia-cpu)`,标注 `← the incident package`,并附 `Equivalence (must all resolve identically) … The user's point, encoded.`。**split 不是越界写法,它是规范当初要照顾的那个案例。** 归一化按规范正确工作了 —— 它是 many-to-one 投影,`(a,b)` 与 `(a,a.b)` 都塌到 `(a,b)`,而 `prepare.cppm:1716` 需要逆向还原字面值,原像有两个,它无条件挑了 `a.b`。 + +`2026-06-26 §4.5` 写了配套解法: + +> key the index/cache on canonical `(ns, name)`, so the split form and the FQN form produce **identical keys and identical resolution**. This makes the producer (emit), the catalog (cache), and the consumer (locate) agree on one key. + +而该文档 Status 行:Step 0 landed in v0.0.67(candidate selection is now identity-first);**§5 `PackageLocator` / `IdentityIndex` remains follow-up**。§4.5 的 producer(emit 双字段)与 catalog 收口从未落地 —— 本方案 T4 一字不差写在 §4.5 里。 + +**并且 §4.5 的前提不成立:catalog 不归 mcpp 管。** 见 §3.1。 + +> 模糊匹配是 mcpp 的内部方言,而 `package.name` 同时是外部系统的主键。在一个别人拿来做精确主键的字段上做归一化,归一化越成功,越掩盖分歧。 + +### 2.5 依赖侧候选阶梯的两个实现缺口 + +`resolve_dependency_selector`(`src/pm/dependency_selector.cppm:88-98`)对裸名产出的阶梯与规范一致: + +``` +cand① (mcpplibs, name) ← kDefaultNamespace 优先 +cand② (∅, name) ← 规范语义:跨 ns 按名发现 +``` + +消费它的 `selectDependencyCandidate`(`prepare.cppm:1516-1541`)有两处未兑现规范。 + +**缺口 A — `(∅, name)` 没有扫描能力,只是"再试两个文件名"。** + +`2026-06-26 §4.4` 定义该档为 `locateByName(name)` = "match by `name` alone **across the precedence path**",前提是存在由读遍每个描述符声明字段构建的 `IdentityIndex`。**IdentityIndex 从未实现。** 三个 `read_xpkg_lua*` 入口全是"候选文件名探测 + 命中后校验";`package_fetcher.cppm` 的 `directory_iterator` 只遍历**索引目录**(`sorted_index_dirs`),从未遍历 `pkgs/*/*.lua`。 + +裸 `asio` 的精确 trace(按 `compat.cppm:159-205`): + +| 候选 | 实际探测的文件 | 磁盘 | +|---|---|---| +| ① `(mcpplibs, asio)` | `a/asio.lua`、`m/mcpplibs.asio.lua`、`c/compat.asio.lua` | 均不存在 | +| ② `(∅, asio)` | `a/asio.lua`、`c/compat.asio.lua` | 均不存在 | + +真实文件 `c/chriskohlhoff.asio.lua` **不在任何一档候选名里**。identity-first 只落地了**验证**半边(拒绝假命中),**发现**半边从未落地;文档标题喊 "Filename Is Not a Key",而文件名今天仍是发现的唯一键。`prepare.cppm:1456-1459` 的注释描述的是**消歧判据**(已兑现),读起来却像承诺了发现不依赖文件名(未兑现)。 + +**缺口 B — 全落空静默回退 `front()`;discovery 档命中时写回候选的 ns。** + +```cpp +auto selected = candidates.front(); // 预设 (mcpplibs, name) +if (spec.isVersion() && candidates.size() > 1) { + for (auto& candidate : candidates) { + auto lua = readStrictLuaForCandidate(candidate); + if (lua && xpkgLuaMatchesCandidate(candidate, *lua, false)) { selected = candidate; break; } + } +} +spec.namespace_ = std::move(selected.namespace_); // ← 候选的 ns,非描述符声明的 ns +``` + +- **B1** 全部落空**不报错**,把预设 `(mcpplibs, name)` 当结论继续跑,错误推迟到下载/安装阶段,且文本里带着 mcpp 自己编造的 ns。 +- **B2** discovery 档命中时写回候选的空 ns,而 `extract_xpkg_namespace(*lua)` 一行即得声明值,被丢弃。规范 §4.4:"resolved to a real `(ns, name)` before anything downstream sees it (P3)." +- 附注:`xpkg_lua_identity_matches` 的 discovery 分支注释写着 "the caller derives the namespace from the descriptor" —— **闸门在散文里把 P3 责任交给调用方,调用方没做**。这是本问题域内第三处"契约只写在注释里、无人执行"(另两处:`prepare.cppm:1714` fqname 契约、`:1456` identity-first 注释)。 + +**重解 issue 的四行消费端表**(第 4 行归因与 issue 不同): + +| 消费端写法 | 真实失败阶段 | 收敛后定性 | +|---|---|---| +| `[dependencies.chriskohlhoff]` + `asio` | 描述符找到,死在 target 串 | ✅ **正确写法** | +| `[dependencies.chriskohlhoff]` + `"chriskohlhoff.asio"` | ns 拼两遍,选择器层失败 | ❌ 重复限定 | +| `[dependencies]` + `"chriskohlhoff.asio"` | cand② `(chriskohlhoff, asio)` 命中,死在 target 串 | ✅ **正确写法** | +| `[dependencies]` + 裸 `asio` | **缺口 A** 两档全 miss → **B1** 静默回退 → 误导性错误。issue 归因为"身份闸门失败",实际更早,在 read 探测阶段 | ❌ **错误写法**,须明确拒绝 | + +**三个缺口彼此独立**:即便 A/B 修好、discovery 正确解出 `(chriskohlhoff, asio)`,target 仍是 `chriskohlhoff.asio` 而 xlings key 仍是 `chriskohlhoff-x-asio`;反之只修 target 串,裸名写法仍找不到描述符。 + +--- + +## 3. 两条不变式 + +### 3.1 INV-NAME(索引侧)—— `package.name` 必须是 FQN + +> 描述符的 `package.name` 字面值**就是**它的完全限定名。当 `package.namespace` 非空时,`name` 必须以 `namespace + "."` 开头,且余下短名非空。 + +**这是被结构性强制的,不是风格偏好。** 已对 libxpkg 与 xlings 源码核实: + +```cpp +// openxlings/libxpkg/src/xpkg-loader.cppm:587 — 建索引 +std::string key = (namespace_.empty() ? "" : namespace_ + "-x-") + pkg.name; +index.entries[key] = std::move(ie); +// openxlings/libxpkg/src/xpkg-index.cppm:37,111 — 查找 +auto it = index.entries.find(name); // 精确字符串相等,零归一化 +if (index.entries.count(name)) ... +// openxlings/xlings/src/core/xim/index.cppm:153 — 唯一调用点 +auto result = xpkg::build_index(repoDir_); // namespace_ 缺省 = 空 +``` + +**关键修正**:`build_index` 的唯一调用点**不传 namespace_**,所以 key 就是 `pkg.name` **字面值本身**,连索引前缀都没有。每个索引一份扁平表,查找是精确 `find()`。 + +于是同一个索引内 **`pkg.name` 是全局唯一键**。mcpplibs 索引里同时住着 `compat.*` 与 `mcpplibs.*` 两族 —— 若 `name` 都写短名,`compat` 的 `zlib` 与 `mcpplibs` 的 `zlib` 会撞成同一个 key。**命名空间的区分度必须由 `pkg.name` 自身携带 —— 这就是 FQN 形式的来源。** mcpp 的 `canonical_xpkg_identity` 对这个 key 空间没有任何管辖权,§4.5 期望的"三方同 key"只能靠约束 wire key 的书写形式达成。 + +推论:mcpp 从 `(ns, shortName)` 推导目标串在 INV-NAME 下**构造性正确**,`prepare.cppm:1716` 的推导**无需改动**;读取字面 `name` 的价值**只在于校验**。 + +### 3.2 INV-RESOLVE(依赖侧)—— 缺省命名空间的解析域是封闭的三档 + +> `[dependencies]` 中不带命名空间的裸名,**只**解析到以下三类,按序: +> ① `mcpplibs`(默认命名空间) ② `compat`(包装命名空间) ③ 无 `namespace` 声明的上游包(xim) +> +> **任何声明了第三方 `namespace` 的包,裸名请求一律不解析** —— 必须写 `a.b.c` 点式选择器或 `[dependencies.]` 子表。 + +① 与 ② 今天**已经**由 `xpkg_lua_identity_matches:705-711` 的 `ns == kDefaultNamespace` 分支实现(`id.ns == kDefaultNamespace || id.ns == kCompatNamespace`)。本方案要做的是把 ③ 从"任意 ns 都能命中"收敛为"仅无 ns 声明者",并把落空定性为错误。 + +**设计理由**:全域按名发现带来的便捷性,代价是三条稳定性损失 —— +1. 两个命名空间同名时的裁决依赖"索引优先级",而该优先级只对 builtin 索引有定义,用户 `[indices]` 添加的索引之间**无全序**; +2. **新增一个索引可以悄悄改变已有裸名依赖的解析目标** —— 供应链意义上的隐患; +3. 解析结果依赖本机索引快照的内容,同一份 `mcpp.toml` 在不同机器上可能解析到不同包。 + +依赖解析的可复现性优先于书写便捷性,故收敛。 + +--- + +## 4. 决策 + +### 4.1 索引侧:采纳"规范收紧",否决"target 改用字面 name" + +| | 让 target 用字面 `name` | **规范收紧 + lint 拦截** | +|---|---|---| +| 修 #278 | ✅ | ✅ | +| 身份多义 | ❌ 永久二义 | ✅ 单一口径 | +| COMPAT 债 | ❌ split 兜底从"1.0.0 删除"变成承重 | ✅ 可按期删除 | +| 生态冲击 | 无 | 需改 2 个描述符 | +| 失败可见性 | 仍无 lint | ✅ 秒级拦截 | +| 与 §3.1 扁平 key 空间 | ❌ 无解(短名必然撞 key) | ✅ 顺应 | + +**同时吸收"字面 name 就在手里"这一洞察**,但用途是 **fail-fast 诊断**,不是解析。 + +**表述澄清**:这**不是**推翻 §4.2 的匹配规范。`canonical_xpkg_identity` / `xpkg_lua_identity_matches` **一个字不改**,§4.6(b) 的等价关系对**已在索引里的**描述符继续成立;改的是**发布侧的规范化约束** —— 进入索引的描述符必须已是规范形式。即 *canonicalize at the boundary, match liberally inside*。 + +**诚实记录代价**:§4.6(b) 那条等价标注着 "The user's point, encoded",本方案让其中 split 一支在**新发布**时不再被接受。这是对一条已背书规范的定向收窄,不是纯 bug fix。 + +**否决的第三条路**:在 libxpkg 侧给索引加 alias 表(同时按 `name` 与 FQN 建 key)。能救两种拼写,但把二义性下沉到更难改的上游,需级联发版。 + +### 4.2 依赖侧:采纳收敛,否决 IdentityIndex 全域发现 + +否决理由见 §3.2 三条。**但全索引扫描并非全盘不要 —— 它降级为纯诊断能力**(§5.3):只在已确定失败的路径上跑,产出 did-you-mean,不参与解析。这样彻底避开三条稳定性损失,同时保住便捷性中最有价值的部分。 + +> 扫描只用于 did-you-mean,永不用于解析。 + +--- + +## 5. 实施 + +### 5.1 索引侧 — 一个谓词,三个落点 + +**核心谓词**(`src/manifest/xpkg.cppm`,与 `canonical_xpkg_identity` 并列导出): + +```cpp +// INV-NAME(#278):描述符的 package.name 字面值就是它的 FQN。 +// 违反时返回可直接展示的诊断;符合(或无从判定)时返回 nullopt。 +// +// 判定只在「声明了非空 namespace」时生效 —— 刻意收窄: +// • 无 name → nullopt(沿用身份闸门宽松语义) +// • 无 namespace → nullopt(xim 上游裸包合法) +// • name 以 ns+"." 开头 → nullopt +// • 否则 → 违规 +std::optional +xpkg_name_form_violation(std::string_view declaredNs, std::string_view declaredName); + +std::optional +xpkg_name_form_violation_from_lua(std::string_view luaContent); +``` + +**为什么必须收窄,而不是"字面 name != 推导 fqname"的通用比较**:后者会误伤今天正常工作的 compat 别名路径 —— 裸写 `gtest = "1.15.2"` 时读到 `compat.gtest.lua`(`ns='compat' name='compat.gtest'`),推导 fqname 是 `gtest`,通用比较会判违规并硬失败,而该路径靠 `prepare.cppm:1760` 的 `compat.@version` 重试是**正常可用**的。收窄后对三类现存路径全部中立:compat 别名(合规)、legacy-bare(跳过)、无名描述符(跳过)。 + +**一个谓词、两处调用**是架构要点 —— lint 与运行期共用同一份判定,不再各自推导。 + +- **T1 lint 层**(`src/cli/cmd_xpkg.cppm`,主防线):`mcpp xpkg parse` 在现有 `name` 缺失检查后调用谓词,违规 **exit 1**: + + ``` + error: pkgs/c/chriskohlhoff.asio.lua: package.name must be the fully-qualified + name when package.namespace is declared + namespace = "chriskohlhoff" + name = "asio" <-- expected "chriskohlhoff.asio" + The index is keyed by the literal package.name (flat key space: + "-x-"), so this descriptor parses but can never + be installed (E_NOT_FOUND). See mcpp#278. + fix: name = "chriskohlhoff.asio" + ``` + + `--json` 同步输出 `"error"` 字段供索引 CI 机读。索引侧 lint 只需跑 `mcpp xpkg parse`,mcpp-index#116 的手写守卫退为冗余保险。 + +- **T2 运行期 fail-fast**(`src/build/prepare.cppm`):lint 只保护过索引 CI 的描述符;第三方索引、历史快照、本地 path index 绕不过运行期。在 `loadVersionDep` 构造 target **之前**(`:1716`)插入谓词检查,零额外 I/O(`luaContent` 已在作用域内),把三平台 20~58 分钟后的 `E_NOT_FOUND` 变成秒级自解释失败。 + **放置位置取舍**:放在 install 分支内(而非 `readLuaContent()` 之后),使"本机已装旧快照"的工程不因升级 mcpp 突然 hard fail;但这保留了 issue 点名的**遮蔽陷阱**(本机绿、干净 CI 红),故配套 **T2b**:已解析到安装物、跳过安装的路径上,同一谓词降级为 `ui::warn` 一行。 + +- **T4 关掉生成源**(`src/pm/publisher.cppm`):`emit_xpkg` 目前只写 `name = manifest.package.name` 且不输出 `namespace`,维护者归档时手补 `namespace` 的那一刻描述符即破损(`tensorvia-cpu` 正是这么来的)。`Manifest::Package::namespace_` **已存在**(`types.cppm:39`)、`[package] namespace` **已解析**(`toml.cppm:180`),故修复是纯输出层: + - `namespace_` 非空 → 输出 `namespace = ""`,`name` 写 `.`(若已带该前缀则不重复拼); + - `namespace_` 为空 → 保持现有输出,并在生成注释与 stderr 提示:归档进 namespaced 索引前需声明 `[package] namespace`,否则手工补 `namespace` 会产出无法安装的描述符(引 #278); + - 附带 `mcpp emit xpkg --namespace ` 覆盖开关。 + +### 5.2 依赖侧 — 收敛 discovery 档 + 落空即报错 + +- **T9(缺口 B1)** `selectDependencyCandidate` 全候选落空时**不再静默回退** `front()`,改为明确失败。这是本批次对用户体验改善最大的一项。 + +- **T10(缺口 B2 / P3)** discovery 档命中后,用 `extract_xpkg_namespace(*lua)` 把 `spec.namespace_` 回填为描述符**声明**的 ns。 + **注意语义细节**:对无 `namespace` 声明的 xim 上游包(`opencv` / `musl-gcc`),`extract_xpkg_namespace` 返回空,**空 ns 在此是合法身份**(下游 `fqname == shortName`,与 xlings 无索引前缀的 key 一致),不得强行填充。规范 §4.4 的 "No empty namespace ever reaches …" 建立在 §4.1 index-owned namespace(`xim-pkgindex → xim`)之上,而 §4.1 同样未实现 —— 在它落地前,空 ns 必须保留。 + +- **T11(收敛 INV-RESOLVE ③)** discovery 档 `(∅, name)` 命中后追加检查:**若描述符声明了非空 `namespace` → 拒绝该候选**,并把它的真实 FQN 记入 did-you-mean 素材。 + **实现放在 `selectDependencyCandidate` 内,不动 `xpkg_lua_identity_matches`** —— 该闸门的 discovery 分支还有第二个消费者(`mcpp new --template `,`src/scaffold/create.cppm:70-71`),模板发现场景保留全域按名语义是合理的。收紧必须 scoped 到依赖解析这一个调用点。 + +- **⚠️ 破坏性变更**:今天裸写 `tensorvia-cpu = "0.1.1"` **能装上**(2026-06-26 文档原文:"the **bare** form `tensorvia-cpu` resolves, installs as `aimol-x-tensorvia-cpu`, and builds")。T11 之后它将被拒绝,必须改写为 `aimol.tensorvia-cpu` 或 `[dependencies.aimol] tensorvia-cpu`。此变更须进 CHANGELOG 的 breaking 段,并由 §5.3 的 did-you-mean 给出**逐字可抄的迁移提示**。 + +### 5.3 诊断专用扫描 — did-you-mean(T12) + +`selectDependencyCandidate` 全候选落空时(且**仅**在此时),对已配置的索引跑一次 `pkgs/*/*.lua` 扫描,读每个描述符的声明 `(namespace, name)`,收集短名等于请求短名的条目,产出: + +``` +error: dependency 'asio': no package found in the default namespace search path + tried: + (mcpplibs, asio) -> pkgs/a/asio.lua, pkgs/m/mcpplibs.asio.lua, pkgs/c/compat.asio.lua + (∅, asio) -> pkgs/a/asio.lua, pkgs/c/compat.asio.lua + a package with this name exists under another namespace: + chriskohlhoff.asio (pkgs/c/chriskohlhoff.asio.lua) + bare names only resolve to the `mcpplibs` / `compat` namespaces. write it out: + [dependencies] + "chriskohlhoff.asio" = "" + or: + [dependencies.chriskohlhoff] + asio = "" +``` + +**约束(必须写进实现注释,否则会重新长成 IdentityIndex)**: +1. 扫描**只在失败路径**触发 —— 正常解析零开销; +2. 扫描结果**只进错误文案**,绝不回灌 `spec.namespace_` / lockfile / 安装层; +3. 扫描结果为空时降级为通用提示,不得因扫描失败而改变退出码。 + +这条约束是 §4.2 决策能够成立的前提:全域信息只用于**告诉用户怎么写**,不用于**替用户决定装什么**。 + +### 5.4 用户文档(T13) + +`docs/05-mcpp-toml.md` §2.5 与 `docs/zh/05-mcpp-toml.md` §2.5 新增"命名空间解析规则"小节,内容见 §8。 + +--- + +## 6. 验证 + +**单测**(`tests/unit/test_manifest.cpp`,续 `XpkgIdentity` 组): + +| 用例 | 断言 | +|---|---| +| `SplitFormNameIsAViolation` | `("chriskohlhoff", "asio")` → 有违规,消息含期望名 | +| `FqnFormIsClean` | `("chriskohlhoff", "chriskohlhoff.asio")` → nullopt | +| `NoNamespaceIsClean` | `("", "opencv")` → nullopt(xim 上游裸包) | +| `NoNameIsClean` | `("compat", "")` → nullopt | +| **`CompatAliasIsClean`** | `("compat", "compat.gtest")` → nullopt(**回归锁**:防止 §5.1 收窄退化成通用比较,打断裸 `gtest`) | +| `NestedNamespaceIsClean` | `("mcpplibs.capi", "mcpplibs.capi.lua")` → nullopt | + +**依赖侧单测**(新增 `test_dependency_resolution.cpp` 或并入既有): + +- 裸名解析到 `mcpplibs` 包 → 成功;裸名解析到 `compat` 包(`gtest`)→ 成功;裸名解析到无 ns 上游包(`opencv`)→ 成功且 `spec.namespace_` 保持空。 +- 裸名请求一个声明了第三方 ns 的包 → **失败**,错误含该包 FQN 与两种正确写法。 +- 点式 `chriskohlhoff.asio` / 子表 `[dependencies.chriskohlhoff] asio` → 解析到 `(chriskohlhoff, asio)`。 +- **P3 锁**:discovery 档命中一个声明了 ns 的描述符时,`spec.namespace_` 绝不为空(该场景在 T11 后应已被拒绝,此测锁住"即便将来放宽也不得漏 ns")。 + +**e2e**(2 例): +1. 构造 split 形式描述符的本地 path index + 消费工程 → `mcpp build` **秒级**失败,stderr 含期望名与 `#278`;改为 FQN 后同一命令通过。(锁 T1 + T2) +2. 索引含 `thirdparty.foo`,消费端裸写 `foo` → 失败且提示 `"thirdparty.foo"` 与子表两种写法;改写后通过。(锁 T9 + T11 + T12) + +**手工验收**:`mcpp xpkg parse` 对现网描述符全量跑一遍。这是"不误伤"的最强证据,须在 PR 描述中贴出。 + +--- + +### 6.1 实测结果(0.0.105 实现后) + +| 项 | 结果 | +|---|---| +| 单测 `XpkgNameForm` | **8/8 过**(设计写 6 例,实现补了 `PrefixWithoutShortSegmentIsAViolation` 与 `FromLuaReadsBothFields`) | +| 单测总体 | **37/37 测试二进制全绿**,含 `test_manifest` 119 例 | +| e2e 161 `xpkg_name_form` | 过 —— lint 拒绝 + 修法字面量 + `--json` + `--allow-split-name` + 运行期 fail-fast(**断言"未发生 Downloading"**) | +| e2e 162 `bare_name_namespace_scope` | 过 —— 裸名硬失败 + did-you-mean + 两种正确写法均解析 + compat 别名回归锁 | +| **mcpplibs 索引全量** | **49/49 parse OK,0 违规**(上游已先修好两例,故非设计预期的 2) | +| 本地 e2e 全套 | **165 过 / 1 败**,唯一失败是 `22_doctor_cache_publish.sh`,根因本机 `~/.xlings/subos/current/bin/g++` shim 自引用损坏(环境性,属既有基线 13/22/26/54/62) | +| 活体验证 | 裸 `gtest` 正常构建;`"chriskohlhoff.asio"` 真实下载 + 编译通过 | + +**实现期新增的两项修正**(设计未预见): + +1. **`--allow-split-name`**(§2.1b):全 registry 回归发现 30 个 split,28 个是 xlings 原生索引的合法用法。 +2. **lazy git 索引不参与硬失败**:自定义 **git** 索引由 xlings 在安装期惰性 clone,选择期其描述符可能尚未落盘,"找不到"不是结论性的。故 `selectDependencyCandidate` 在候选命名空间路由到非 builtin、非 local 索引时保留历史 fall-through;builtin 与 local path 索引在选择期均可读,继续走严格规则。e2e 42/43/44 覆盖。 + +--- + +## 7. 风险与后续 + +| 项 | 判断 | +|---|---| +| 误伤现有可用路径(索引侧) | 由 §5.1 收窄 + `CompatAliasIsClean` 回归锁覆盖;62 例全量扫描给出期望结果 | +| **裸名第三方包不再解析** | **已知破坏性变更**(§5.2)。现网受影响者仅 `tensorvia-cpu`(T8 一并改索引)。CHANGELOG breaking 段 + did-you-mean 逐字迁移提示 | +| 第三方索引被新 lint 挡住 | 只在描述符声明了 namespace 时生效,且这类描述符今天本就装不上 —— 从"静默不可用"变为"明示不可用",非回归 | +| 已安装旧快照被遮蔽 | 已知并接受(§5.1 T2),由 T2b 的 warn 使其可见 | +| did-you-mean 扫描长成解析路径 | 由 §5.3 三条约束 + 实现注释锁住;e2e 2 不断言任何"扫描后自动解析成功"的行为 | +| legacy-bare 默认命名空间支路(§2.3) | **不在本批次**。现网零实例。后续:索引默认命名空间非空时发 warn,1.0.0 随 COMPAT 兜底清理 | +| §4.1 index-owned namespace 未实现 | 与 T10 的空 ns 语义耦合(§5.2)。仍是 §5 `PackageLocator` 的正式续作 | +| `mcpp search` 展示口径 | INV-NAME 下 FQN 唯一,无需改动 | + +--- + +## 8. 用户文档增补(§2.5 落地内容) + +在 `docs/05-mcpp-toml.md` §2.5 与中文版对应位置,于既有示例之后新增 **Namespace resolution rules / 命名空间解析规则**: + +1. 每个包的身份是 `(namespace, name)` 二元组;索引描述符的 `package.name` 写**完全限定名**。 +2. **裸名依赖只解析三类**:`mcpplibs`(默认)、`compat`(包装)、无命名空间的上游包。 +3. **第三方命名空间必须写全** —— 点式 `"org.pkg" = "…"` 或子表 `[dependencies.org] pkg = "…"`。 +4. 给出"为什么不做模糊全域匹配"的一句话理由(可复现性 / 新增索引不得改变既有解析结果)。 +5. 给出错误示例与其修法(裸 `asio` → `"chriskohlhoff.asio"`)。 +6. 给 xpkg 作者一句指引:`package.name` 必须以 `namespace.` 开头,由 `mcpp xpkg parse` 校验。 + +--- + +## 9. 任务清单 + +**索引侧(INV-NAME):** + +- [ ] **T1** `xpkg_name_form_violation{,_from_lua}` 谓词 + 6 条单测(`src/manifest/xpkg.cppm`, `tests/unit/test_manifest.cpp`) +- [ ] **T2** `mcpp xpkg parse` 接入谓词,违规 exit 1,`--json` 同步 `error` 字段(`src/cli/cmd_xpkg.cppm`) +- [ ] **T3** `loadVersionDep` install 分支 fail-fast + 已装路径 warn(`src/build/prepare.cppm`) +- [ ] **T4** `emit_xpkg` 输出 `namespace` + FQN `name`,无 namespace 时提示;`--namespace` 开关(`src/pm/publisher.cppm`) + +**依赖侧(INV-RESOLVE):** + +- [ ] **T9** `selectDependencyCandidate` 全候选落空不再静默回退 `front()`,改为明确失败(`prepare.cppm:1516-1541`) +- [ ] **T10** discovery 档命中后用 `extract_xpkg_namespace(*lua)` 回填真实 ns;无 ns 上游包保持空 ns +- [ ] **T11** discovery 档拒绝声明了非空 `namespace` 的描述符;**scoped 到依赖解析调用点,不动 `xpkg_lua_identity_matches`**(保 `mcpp new --template`) +- [ ] **T12** did-you-mean:仅失败路径触发的全索引扫描,三条约束写进实现注释 +- [ ] **T11b** 修订 `2026-06-26 §4.4/§4.6(a)` 对 discovery 档的表述;更新 `prepare.cppm:1456-1459` 注释,消除 "never by filename" 的误读 + +**验证与文档:** + +- [ ] **T5** e2e 1:split 描述符 → 秒级自解释失败;改 FQN → 通过 +- [ ] **T5b** e2e 2:裸名请求第三方 ns 包 → 失败 + did-you-mean;改写后通过 +- [ ] **T6** 全索引 62 例 `mcpp xpkg parse` 回归,PR 描述贴结果(期望 2 报错) +- [ ] **T13** 用户文档 §2.5 增补(`docs/05-mcpp-toml.md` + `docs/zh/05-mcpp-toml.md`),内容见 §8 +- [ ] **T14** CHANGELOG breaking 段:裸名不再解析第三方命名空间包 + +**合入与生态:** + +- [ ] **T7** 单 PR(`Fixes #278`)→ CI 全绿 → squash 合入 +- [ ] **T8** mcpp-index:`chriskohlhoff.asio` + `tensorvia-cpu` 改 FQN;lint job 改调 `mcpp xpkg parse` diff --git a/.agents/docs/2026-07-25-issue278-implementation-plan.md b/.agents/docs/2026-07-25-issue278-implementation-plan.md new file mode 100644 index 0000000..53256ff --- /dev/null +++ b/.agents/docs/2026-07-25-issue278-implementation-plan.md @@ -0,0 +1,59 @@ +# #278 包身份双侧收敛 — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 实现 `.agents/docs/2026-07-25-issue278-descriptor-name-form-canonicalization-design.md` 的两条不变式 → 单 PR(`Fixes #278`)→ CI 全绿 → bypass squash 合入 → 0.0.105 release → xlings 全生态验证。 + +**Architecture:** 一个纯谓词 `xpkg_name_form_violation` 两处调用(lint + 运行期),消灭"同一决策两处推导";依赖侧候选阶梯收敛为封闭三档,全索引扫描降级为**仅失败路径**的 did-you-mean 诊断,绝不回灌解析。 + +## Global Constraints + +- `canonical_xpkg_identity` / `xpkg_lua_identity_matches` **一个字不改** —— 收紧只发生在发布侧(lint)与依赖解析调用点。 +- 谓词必须收窄为"声明了非空 namespace 且 name 不以 `ns.` 开头",**不得**写成"字面 name != 推导 fqname"通用比较(会打断裸 `gtest` → `compat.gtest`)。 +- did-you-mean 扫描三约束:仅失败路径触发、只进错误文案、扫描空不改退出码。 +- 无 `namespace` 声明的上游包(`opencv`/`musl-gcc`)空 ns 是**合法身份**,T10 不得强行填充。 +- T11 收紧 scoped 到 `selectDependencyCandidate`,不动闸门(保 `mcpp new --template`)。 +- 既有单测与 e2e(160 例)全绿。 + +## Tasks + +### C1 索引侧谓词与两个调用点 + +- [x] **T1** `xpkg_name_form_violation(declaredNs, declaredName)` + `_from_lua` 导出(`src/manifest/xpkg.cppm`) +- [x] **T2** `mcpp xpkg parse` 接入谓词,违规 exit 1;`--json` 输出 `error` 字段(`src/cli/cmd_xpkg.cppm`) +- [x] **T3** `loadVersionDep` install 分支 fail-fast;已解析到安装物的路径 `ui::warn`(`src/build/prepare.cppm`) + +### C2 生成源 + +- [x] **T4** `emit_xpkg` 输出 `namespace` + FQN `name`;无 namespace 时 stderr 提示;`--namespace` 覆盖开关(`src/pm/publisher.cppm` + emit CLI) + +### C3 依赖侧收敛 + +- [x] **T9** `selectDependencyCandidate` 全候选落空 → 明确失败(`prepare.cppm:1516-1541`) +- [x] **T10** discovery 档命中后 `extract_xpkg_namespace(*lua)` 回填真实 ns(声明为空则保持空) +- [x] **T11** discovery 档拒绝声明了非空 `namespace` 的描述符 +- [x] **T12** did-you-mean:仅失败路径的全索引扫描,三约束写进实现注释 + +### C4 测试 + +- [x] **T5a** 单测:谓词 6 例(含 `CompatAliasIsClean` 回归锁) +- [x] **T5b** 单测:依赖侧解析(三档成功 / 第三方 ns 裸名失败 / P3 空 ns 锁) +- [x] **T5c** e2e:split 描述符 → 秒级自解释失败;改 FQN → 通过 +- [x] **T5d** e2e:裸名请求第三方 ns 包 → 失败 + did-you-mean;改写后通过 + +### C5 文档与版本 + +- [x] **T13** 用户文档 §2.5(`docs/05-mcpp-toml.md` + `docs/zh/05-mcpp-toml.md`) +- [x] **T14** CHANGELOG 0.0.105 段(含 breaking:裸名不再解析第三方命名空间包) +- [x] **T15** `mcpp.toml` version → 0.0.105 +- [x] **T11b** 修订 `2026-06-26 §4.4/§4.6(a)` 表述;更新 `prepare.cppm:1456-1459` 注释 + +### C6 验收与合入 + +- [x] **T6** 全索引 `mcpp xpkg parse` 回归,结果贴 PR。**实测 49/49 mcpplibs 描述符全过、0 违规**(非设计预期的 2 —— 上游 mcpp-index 已先行修好两例);完整 registry(200+)另见 §2.1b 的 xlings-native 修正 +- [ ] **T7** 单 PR(`Fixes #278`)→ CI 全绿 → `gh pr merge --squash --admin` + +### C7 生态 + +- [ ] **T16** release 0.0.105 四平台 + 镜像 xlings-res 双端 + xim-pkgindex PR + `xlings install mcpp` 真装验证 +- [x] **T8** mcpp-index:**上游已完成** —— `chriskohlhoff.asio` / `tensorvia-cpu` 均已是 FQN,#116 的 `tests/check_package_name.lua` 已合入。该 Lua 守卫现与 `mcpp xpkg parse` 冗余但无害,可留作保险(其 CI 本就跑 `mcpp xpkg parse pkgs/*/*.lua`,升级 MCPP_VERSION 到 0.0.105 后自动获得引擎侧防护) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99928b6..458add8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,30 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [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`。 + +### ⚠️ 破坏性变更 + +- **裸依赖名不再解析到第三方命名空间的包。** 命名空间缺省时,`[dependencies]` 里的裸名**只**解析三类:`mcpplibs`(默认)、`compat`(包装)、无 `namespace` 声明的上游包。此前裸名会跨命名空间命中(例如裸 `tensorvia-cpu` 能装上 `aimol` 下的包),现在必须写全:`"aimol.tensorvia-cpu" = "…"` 或 `[dependencies.aimol] tensorvia-cpu = "…"`。 + 取舍理由:全域按名发现的便捷性换来三条稳定性损失——同名包的裁决依赖索引优先级(而用户 `[indices]` 添加的索引之间**无全序**)、**新增一个索引可能悄悄改变既有依赖解析到的包**(供应链隐患)、同一份 `mcpp.toml` 在不同机器上可能解析到不同包。依赖解析的可复现性优先于书写便捷性。 + 迁移无需查文档:失败时 mcpp 会扫描索引并直接给出应当改写成的那两行(见下)。 + +### 新增 + +- **INV-NAME 校验(`mcpp xpkg parse`)**:描述符声明了非空 `package.namespace` 时,`package.name` 必须是完全限定名(以 `.` 开头),否则报错退出。索引是以 `package.name` **字面值**为键的扁平空间,而 mcpp 按 `.` 寻址,两者不相交即永久不可安装。诊断直接给出应写的字面量(`fix: name = "chriskohlhoff.asio"`),`--json` 同步 `error` 字段供索引 CI 机读。mcpp-index 的 CI 本就跑 `mcpp xpkg parse pkgs/*/*.lua`,免费获得秒级防护。 +- **`mcpp xpkg parse --allow-split-name`**:跳过 INV-NAME 检查。xlings 原生索引(xim-pkgindex、-scode)里 `package.namespace` 是**安装目录分类**(`config`/`scode`/`awesome`)而非包命名空间,索引按裸 `package.name` 建键,split 形式在那个世界里是正确的——这些树用此开关 lint。 +- **运行期 fail-fast**:`mcpp build` 在构造安装目标**之前**用同一个谓词校验手上已有的描述符(零额外 I/O),把"三平台一小时后的 E_NOT_FOUND"变成秒级自解释失败。lint 与运行期共用一份判定,不会再各自推导。已装旧快照的路径降级为 warning,让"本机绿、干净 CI 红"的遮蔽陷阱可见。 +- **依赖解析失败的 did-you-mean**:候选全部落空时(且**仅**在此时)扫描索引,若该短名存在于其他命名空间,直接列出 FQN 与两种可直接抄写的正确写法。该扫描是**纯诊断**——结果只进错误文案,绝不回灌解析、lockfile 或安装层(否则就退化成被否决的全域模糊匹配)。 +- **`mcpp emit xpkg --namespace `**:为归档场景提供命名空间,无需改 `mcpp.toml`。 + +### 修复 + +- **`mcpp emit xpkg` 不再生成破损雏形**:此前只写裸 `name`、完全不输出 `namespace`,维护者归档进命名空间索引时手补一行 `namespace = ""`,那一刻描述符就变成无法安装的 split 形式(`aimol.tensorvia-cpu` 正是这么来的,文件头还留着"AUTO-GENERATED, do not edit by hand")。现在 `[package] namespace` 非空时同时输出 `namespace` 与 FQN `name`;为空时给出明确警告,说明不要事后手补。此修复在 2026-06-26 设计 §4.5 里就已写明,一直未落地。 +- **依赖候选全部落空时不再静默回退**:此前会退到第一个候选并把 mcpp **自己编造的**命名空间当作结论继续跑,失败被推迟到下载/安装阶段,错误文本里还带着用户从未写过的命名空间。现在当场失败并列出试过的每一个身份。 +- **discovery 档不再泄漏空命名空间(P3)**:`(∅, name)` 命中后写回的是**描述符声明的**命名空间,而非候选的空值——空命名空间此前会流进 lockfile 与安装层。无 `namespace` 声明的上游包(`opencv`/`musl-gcc`)保持空命名空间,那是它们的合法身份。 + ## [0.0.104] — 2026-07-24 > `mcpp test` 能力批次(两轮):逐测试编译隔离、子目录路径命名、过滤器、JSON 输出——四项均为通用能力(cargo/ctest 同形),首个下游消费者是 d2mcpp「练习即测试」重设计(见 d2mcpp 仓 `.agents/docs/2026-07-23-exercises-as-tests-design.md` §4,验收标准即出自该文档)。实施计划见 `.agents/docs/2026-07-23-test-isolation-json-plan.md`。 diff --git a/docs/05-mcpp-toml.md b/docs/05-mcpp-toml.md index fa1ab92..5dcd347 100644 --- a/docs/05-mcpp-toml.md +++ b/docs/05-mcpp-toml.md @@ -321,6 +321,61 @@ baz = "=1.2.3" # Exact match qux = ">=1.0, <2.0" # Range combination ``` +#### Namespace resolution rules + +Every package has a two-part identity: a **namespace** and a **name**. How you write +a dependency key decides which namespaces mcpp will look in. + +**A bare name resolves in exactly three places**, in order: + +| # | Namespace | Example | +|---|---|---| +| 1 | `mcpplibs` — the default namespace | `cmdline = "0.0.2"` | +| 2 | `compat` — the wrapper namespace for third-party C/C++ libraries | `gtest = "1.15.2"` → `compat.gtest` | +| 3 | upstream packages that declare no namespace at all | `opencv = "4.10.0"` | + +**Any other namespace must be written out in full.** There is no fuzzy, index-wide +search by short name: + +```toml +# ✅ Correct — dotted selector +[dependencies] +"chriskohlhoff.asio" = "1.38.1" + +# ✅ Correct — namespace sub-table (preferred when you have several from one org) +[dependencies.chriskohlhoff] +asio = "1.38.1" + +# ❌ Wrong — a bare name never reaches the `chriskohlhoff` namespace +[dependencies] +asio = "1.38.1" +``` + +The third form fails with an error that lists the namespaces that were searched and, +when a package with that short name exists elsewhere, the exact line to write instead. + +**Why not resolve bare names across every namespace?** Because dependency resolution +has to be reproducible. A global short-name search would mean that (a) two namespaces +owning the same short name are settled by index ordering, and (b) **adding an index +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: + +```lua +package = { + namespace = "chriskohlhoff", + name = "chriskohlhoff.asio", -- NOT "asio" + ... +} +``` + +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. + ### 2.6 `[dev-dependencies]` — Test Dependencies ```toml diff --git a/docs/zh/05-mcpp-toml.md b/docs/zh/05-mcpp-toml.md index 0f39723..578bcc5 100644 --- a/docs/zh/05-mcpp-toml.md +++ b/docs/zh/05-mcpp-toml.md @@ -293,6 +293,50 @@ baz = "=1.2.3" # 精确匹配 qux = ">=1.0, <2.0" # 范围组合 ``` +#### 命名空间解析规则 + +每个包的身份是**命名空间 + 名字**二元组。依赖 key 的写法决定 mcpp 到哪些命名空间里找。 + +**裸名只在三个地方解析**,按序: + +| # | 命名空间 | 示例 | +|---|---|---| +| 1 | `mcpplibs` — 默认命名空间 | `cmdline = "0.0.2"` | +| 2 | `compat` — 第三方 C/C++ 库的包装命名空间 | `gtest = "1.15.2"` → `compat.gtest` | +| 3 | 完全没有声明命名空间的上游包 | `opencv = "4.10.0"` | + +**其他命名空间一律必须写全。** 不存在按短名的全索引模糊搜索: + +```toml +# ✅ 正确 —— 点式选择器 +[dependencies] +"chriskohlhoff.asio" = "1.38.1" + +# ✅ 正确 —— 命名空间子表(同一组织有多个包时更推荐) +[dependencies.chriskohlhoff] +asio = "1.38.1" + +# ❌ 错误 —— 裸名永远到不了 chriskohlhoff 命名空间 +[dependencies] +asio = "1.38.1" +``` + +第三种写法会明确报错,并列出搜索过的命名空间;若该短名的包存在于别处,错误信息会直接给出应当改写成的那一行。 + +**为什么不让裸名跨所有命名空间去找?** 因为依赖解析必须可复现。全域短名搜索意味着:(a) 两个命名空间拥有同名包时,胜负由索引顺序决定;(b) **新增一个索引可能悄悄改变某个既有依赖解析到的包**。要求写出命名空间,才能让同一份 `mcpp.toml` 在每台机器上解析到相同的包。 + +**给 xpkg 作者:** 索引描述符里的 `package.name` 必须是**完全限定名** —— 以 `package.namespace` 加一个点开头: + +```lua +package = { + namespace = "chriskohlhoff", + name = "chriskohlhoff.asio", -- 不是 "asio" + ... +} +``` + +包索引是以 `package.name` 字面值为键的**扁平键空间**,命名空间的区分度必须由名字本身携带 —— 否则 `mcpplibs` 的 `zlib` 与 `compat` 的 `zlib` 会撞键。写错的描述符能被正常解析,却永远装不上。`mcpp xpkg parse` 会校验这一点,请在索引 CI 里跑它。 + ### 2.6 `[dev-dependencies]` — 测试依赖 ```toml diff --git a/mcpp.toml b/mcpp.toml index f1b71f1..5694c8f 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "0.0.104" +version = "0.0.105" 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 13571ae..accc065 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -1453,14 +1453,25 @@ prepare_build(bool print_fingerprint, return nullptr; }; - // Identity-first candidate probe. A candidate is located by the DECLARED - // (namespace, name) of whatever descriptor the index holds — never by whether - // a canonically-named file `..lua` happens to exist on disk. It - // routes through the same identity-verified readers the load path uses - // (`read_xpkg_lua*`, which gate every hit on the descriptor's declared + // Identity-first candidate probe. A candidate is DISAMBIGUATED by the + // DECLARED (namespace, name) of whatever descriptor the index holds — never + // by whether a canonically-named file `..lua` happens to exist on + // disk. It routes through the same identity-verified readers the load path + // uses (`read_xpkg_lua*`, which gate every hit on the descriptor's declared // identity and already cover non-canonical filenames), so candidate selection // and loading can never disagree about what a candidate resolves to. // + // SCOPE (#278, do not over-read the paragraph above): identity governs which + // hits are ACCEPTED, not which files are REACHED. Discovery is still bounded + // by the candidate-filename list from `compat::xpkg_lua_candidates` — there + // is no index-wide scan of `pkgs/*/*.lua` anywhere in mcpp, so a descriptor + // whose filename matches none of the candidates is simply not found. The + // `IdentityIndex` that would lift that bound was deferred with §5 of the + // 2026-06-26 design and is deliberately NOT being added: see + // .agents/docs/2026-07-25-issue278-descriptor-name-form-canonicalization-design.md + // §3.2/§4.2 for why bare-name discovery across arbitrary namespaces is a + // reproducibility hazard rather than a convenience. + // // Before this, selection probed the canonical filename only, so a descriptor // filed under a non-canonical name (e.g. `aimol.tensorvia-cpu` declared in the // mcpplibs index as bare `pkgs/t/tensorvia-cpu.lua`) was invisible to its own @@ -1524,15 +1535,124 @@ prepare_build(bool print_fingerprint, } auto selected = candidates.front(); + bool matched = false; if (spec.isVersion() && candidates.size() > 1) { for (auto& candidate : candidates) { auto lua = readStrictLuaForCandidate(candidate); - if (lua && xpkgLuaMatchesCandidate( + if (!lua || !xpkgLuaMatchesCandidate( candidate, *lua, /*allowLegacyBareDefault=*/false)) { - selected = candidate; + continue; + } + + // INV-RESOLVE (#278) — the discovery rung `(∅, name)` is the + // "upstream package that declares no namespace" rung, NOT a + // cross-namespace wildcard. The identity gate is intentionally + // permissive here (`ns.empty() → name match is enough`, because + // `mcpp new --template X` legitimately discovers by short name), + // so the narrowing lives at THIS call site rather than in the + // gate — tightening the gate would break template discovery. + // + // Rejecting the hit keeps a third-party-namespaced package from + // being reachable by a bare name: resolution must not depend on + // which indices happen to be present, or adding an index could + // silently retarget an existing dependency (design §3.2). + auto declaredNs = + mcpp::manifest::extract_xpkg_namespace(*lua); + if (candidate.namespace_.empty() && !declaredNs.empty()) { + continue; + } + + // P3 (#278) — resolve the discovery rung to a REAL identity + // before anything downstream sees it. `selected.namespace_` + // used to be the CANDIDATE's namespace, so a discovery hit + // wrote an empty namespace into the spec and on into the + // lockfile and install layer. Read the DECLARED one instead. + // + // An empty `declaredNs` is a legal identity here, not a hole to + // fill: an upstream package with no `namespace` (xim `opencv`, + // `musl-gcc`) is keyed by its bare name, and the derived + // fqname == shortName is exactly right for it. Attributing such + // a descriptor to its owning index (`xim-pkgindex → xim`) is + // §4.1 of the 2026-06-26 design and is still unimplemented. + selected = candidate; + if (selected.namespace_.empty()) selected.namespace_ = declaredNs; + matched = true; + break; + } + + // A custom GIT index is cloned lazily by xlings during install, so + // at selection time its descriptors may legitimately not be on disk + // yet. "Not found" is therefore not conclusive for those namespaces + // — keep the historical fall-through rather than hard-failing on a + // package that would have materialized a moment later. Local path + // indices and the builtin index are both readable here, so they stay + // under the strict rule below. + bool anyLazyGitIndex = false; + for (auto& c : candidates) { + auto* idx = findIndexForNs(c.namespace_); + if (idx && !idx->is_builtin() && !idx->is_local()) { + anyLazyGitIndex = true; break; } } + + // T9 (#278) — no candidate resolved. This used to fall through to + // `candidates.front()` SILENTLY, so mcpp carried on with a namespace + // it had invented, and the user met the failure much later (during + // download/install) wrapped around that invented name. Fail here, + // and say exactly which identities were tried. + if (!matched && !anyLazyGitIndex) { + std::string tried; + for (auto& c : candidates) { + if (!tried.empty()) tried += ", "; + tried += c.namespace_.empty() + ? std::format("(no namespace, {})", c.shortName) + : std::format("({}, {})", c.namespace_, c.shortName); + } + + // T12 — did-you-mean. DIAGNOSTIC ONLY: the scan runs solely on + // this already-failed path and its result never leaves the + // error string (see Fetcher::scan_fqns_with_short_name). + std::string hint; + if (auto cfg = get_cfg()) { + mcpp::fetcher::Fetcher fetcher(**cfg); + auto roots = fetcher.builtin_index_roots(); + for (auto& [idxName, idxSpec] : m->indices) { + if (idxSpec.is_local()) { + roots.push_back( + mcpp::config::resolve_project_index_path( + *root, idxSpec)); + } + } + auto fqns = mcpp::fetcher::Fetcher::scan_fqns_with_short_name( + roots, candidates.front().shortName); + if (!fqns.empty()) { + hint += "\n a package with this name exists under " + "another namespace:"; + for (auto& fqn : fqns) hint += "\n " + fqn; + hint += std::format( + "\n bare names only resolve to the `{}` / `{}` " + "namespaces. write it out:" + "\n [dependencies]" + "\n \"{}\" = \"{}\"" + "\n or:" + "\n [dependencies.{}]" + "\n {} = \"{}\"", + mcpp::pm::kDefaultNamespace, + mcpp::pm::kCompatNamespace, + fqns.front(), spec.version.empty() ? "" + : spec.version, + fqns.front().substr(0, fqns.front().rfind('.')), + fqns.front().substr(fqns.front().rfind('.') + 1), + spec.version.empty() ? "" : spec.version); + } + } + + return std::unexpected(std::format( + "dependency '{}': no package found under the namespaces " + "mcpp searched\n tried: {}{}", + depName, tried, hint)); + } } spec.namespace_ = std::move(selected.namespace_); @@ -1651,6 +1771,22 @@ prepare_build(bool print_fingerprint, // as stale/incomplete on this active resolve path and reinstalled. std::optional installed = findCompleteInstalled(); + // #278 masking guard. The hard INV-NAME check lives on the install path + // below, so a machine that already has the package from an older index + // snapshot keeps building. That asymmetry is exactly the trap the issue + // names — local green, clean CI red — so make it visible here instead of + // letting it stay silent. + if (installed && luaContent) { + if (auto violation = mcpp::manifest:: + xpkg_name_form_violation_from_lua(*luaContent)) { + mcpp::ui::warning(std::format( + "dependency '{}': {}\n" + " resolving from the already-installed copy; a clean " + "environment (CI) will fail here", + depName, *violation)); + } + } + if (!installed) { if (luaContent) { auto field = mcpp::manifest::extract_mcpp_field(*luaContent); @@ -1713,8 +1849,27 @@ 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). + // + // #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); + 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)); + } + } mcpp::ui::info("Downloading", std::format("{} v{}", fqname, version)); // #238: retain whatever error/warn text the child DID emit so we diff --git a/src/cli.cppm b/src/cli.cppm index d00be73..11d5603 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -329,7 +329,11 @@ int run(int argc, char** argv) { .option(cl::Option("version").short_name('V').takes_value().value_name("VER") .help("Override package version")) .option(cl::Option("output").short_name('o').takes_value().value_name("FILE") - .help("Write to file instead of stdout"))) + .help("Write to file instead of stdout")) + .option(cl::Option("namespace").takes_value().value_name("NS") + .help("Package namespace for the emitted descriptor " + "(overrides [package] namespace). Emits both " + "`namespace` and the fully-qualified `name`"))) .action(wrap_rc([&dispatch_sub](const cl::ParsedArgs& p) { return dispatch_sub("emit", p, {{"xpkg", cmd_emit_xpkg}}); }))) @@ -344,7 +348,12 @@ int run(int argc, char** argv) { .help("Downgrade unknown mcpp-segment keys from error to warning")) .option(cl::Option("all-os") .help("Validate every per-OS section (linux/macosx/windows), " - "not just the running host's"))) + "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"))) .action(wrap_rc([&dispatch_sub](const cl::ParsedArgs& p) { return dispatch_sub("xpkg", p, {{"parse", cmd_xpkg_parse}}); }))) diff --git a/src/cli/cmd_publish.cppm b/src/cli/cmd_publish.cppm index 8d79997..4400370 100644 --- a/src/cli/cmd_publish.cppm +++ b/src/cli/cmd_publish.cppm @@ -21,7 +21,8 @@ namespace mcpp::cli { export int cmd_emit_xpkg(const mcpplibs::cmdline::ParsedArgs& parsed) { return mcpp::publish::emit_xpkg_to( parsed.option_or_empty("version").value(), - std::filesystem::path{parsed.option_or_empty("output").value()}); + std::filesystem::path{parsed.option_or_empty("output").value()}, + parsed.option_or_empty("namespace").value()); } export int cmd_publish(const mcpplibs::cmdline::ParsedArgs& parsed) { diff --git a/src/cli/cmd_xpkg.cppm b/src/cli/cmd_xpkg.cppm index 3387087..f9ff13f 100644 --- a/src/cli/cmd_xpkg.cppm +++ b/src/cli/cmd_xpkg.cppm @@ -69,9 +69,10 @@ export int cmd_xpkg_parse(const mcpplibs::cmdline::ParsedArgs& parsed) { } std::string lua{std::istreambuf_iterator(is), {}}; - const bool asJson = parsed.is_flag_set("json"); - const bool allowUnknown = parsed.is_flag_set("allow-unknown"); - const bool allOs = parsed.is_flag_set("all-os"); + const bool asJson = parsed.is_flag_set("json"); + const bool allowUnknown = parsed.is_flag_set("allow-unknown"); + const bool allOs = parsed.is_flag_set("all-os"); + const bool allowSplitName = parsed.is_flag_set("allow-split-name"); if (allOs && asJson) { mcpp::ui::error("--all-os and --json are mutually exclusive"); return 2; @@ -86,6 +87,34 @@ export int cmd_xpkg_parse(const mcpplibs::cmdline::ParsedArgs& parsed) { } std::string fqn = id.ns.empty() ? id.name : id.ns + "." + id.name; + // INV-NAME (#278) — the descriptor's `package.name` literal must BE the FQN. + // This is the primary defense: index CI runs `mcpp xpkg parse`, so a + // split-form descriptor is rejected in seconds instead of burning a + // three-platform workspace job for an hour on an opaque E_NOT_FOUND. + // The very same predicate guards the install path (prepare.cppm), so lint + // and runtime can never drift apart. + // + // `--allow-split-name` exists because `package.namespace` carries a SECOND, + // unrelated meaning in xlings-native indices (xim-pkgindex, -scode): there + // it is an install-directory category (`config`, `scode`, `awesome`) and the + // index is keyed by the bare `package.name`, so the split spelling is + // correct in that world. mcpp's own indices (mcpplibs, anything carrying an + // `index.toml` contract) use it as a package namespace, where INV-NAME + // holds. Lints over an xlings-native tree should pass this flag. + if (auto violation = allowSplitName + ? std::nullopt + : mcpp::manifest::xpkg_name_form_violation_from_lua(lua)) { + if (asJson) { + std::println("{{\"namespace\":\"{}\",\"name\":\"{}\"," + "\"error\":\"{}\"}}", + json_escape(id.ns), json_escape(id.name), + json_escape(*violation)); + } else { + mcpp::ui::error(std::format("{}: {}", file, *violation)); + } + return 1; + } + // Versions per platform (xpm table). static constexpr std::string_view kPlatforms[] = {"linux", "macosx", "windows"}; std::map> versions; diff --git a/src/manifest/xpkg.cppm b/src/manifest/xpkg.cppm index 47492d3..023502c 100644 --- a/src/manifest/xpkg.cppm +++ b/src/manifest/xpkg.cppm @@ -80,6 +80,39 @@ 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. +// +// 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. +// +// 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 +// +// Returns a ready-to-display diagnostic body on violation, nullopt otherwise. +std::optional +xpkg_name_form_violation(std::string_view declaredNs, + std::string_view declaredName); +// Convenience: read `package.{namespace,name}` from a descriptor and apply +// `xpkg_name_form_violation`. +std::optional +xpkg_name_form_violation_from_lua(std::string_view luaContent); // Resolve the lib-root path for a manifest: // 1. `[lib].path` if explicitly set (cargo-style override), // 2. otherwise the convention `src/.cppm`, where @@ -715,6 +748,43 @@ bool xpkg_lua_identity_matches(std::string_view luaContent, return id.ns == ns; } +std::optional +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 + } + + // 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. + return std::format( + "package.name must be the fully-qualified name when package.namespace " + "is declared\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); +} + +std::optional +xpkg_name_form_violation_from_lua(std::string_view luaContent) +{ + return xpkg_name_form_violation(extract_xpkg_namespace(luaContent), + extract_xpkg_name(luaContent)); +} + std::vector list_xpkg_versions(std::string_view luaContent, const mcpp::platform::PlatformKey& platformAxis) { diff --git a/src/pm/package_fetcher.cppm b/src/pm/package_fetcher.cppm index 64a47e4..0e61e3d 100644 --- a/src/pm/package_fetcher.cppm +++ b/src/pm/package_fetcher.cppm @@ -134,6 +134,31 @@ public: std::string_view shortName, std::string_view version); + // ─── did-you-mean scan (#278) — DIAGNOSTIC ONLY ──────────────── + // + // Enumerate every descriptor under the given index roots and return the + // fully-qualified names of those whose canonical SHORT name equals + // `shortName`. This is the only index-wide scan in mcpp, and it exists so a + // failed bare-name lookup can tell the user the exact line to write instead + // of leaving them to guess a namespace. + // + // THREE CONSTRAINTS — this must never grow into an `IdentityIndex`: + // 1. Call it ONLY on an already-failed path. Successful resolution must + // not pay for a directory walk. + // 2. Its result feeds ERROR TEXT ONLY. It must never be written back into + // a DependencySpec, the lockfile, or the install layer — bare names + // resolving across arbitrary namespaces is precisely the + // reproducibility hazard this design rejects (design §3.2/§4.2). + // 3. An empty result is not an error. It downgrades the message, never + // the exit code. + static std::vector + scan_fqns_with_short_name(const std::vector& indexRoots, + std::string_view shortName); + + // The index roots a plain `mcpp build` would consult (global registry data + // dirs). Used to feed `scan_fqns_with_short_name` on the failure path. + std::vector builtin_index_roots() const; + // ─── Legacy overloads (COMPAT, remove in 1.0.0) ───────────── // // Accept a raw package name string and infer namespace from it. @@ -741,6 +766,58 @@ Fetcher::install_path_from_project_data(const std::filesystem::path& projectDir, return std::nullopt; } +// ─── did-you-mean scan (#278) — DIAGNOSTIC ONLY ───────────────────── +// +// See the declaration for the three constraints that keep this from becoming a +// resolution path. Deliberately tolerant: unreadable files are skipped, not +// reported — a diagnostic helper must never turn into a second failure mode. + +std::vector +Fetcher::scan_fqns_with_short_name( + const std::vector& indexRoots, + std::string_view shortName) +{ + std::vector hits; + if (shortName.empty()) return hits; + + std::error_code ec; + for (auto& root : indexRoots) { + auto pkgsDir = root / "pkgs"; + if (!std::filesystem::exists(pkgsDir, ec)) continue; + for (auto& letterDir : std::filesystem::directory_iterator(pkgsDir, ec)) { + if (!letterDir.is_directory(ec)) continue; + for (auto& entry : + std::filesystem::directory_iterator(letterDir.path(), 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(); + + auto id = mcpp::manifest::canonical_xpkg_identity_from_lua(content); + if (id.name != shortName) continue; + if (id.ns.empty()) continue; // bare upstream: already reachable + hits.push_back(id.ns + "." + id.name); + } + } + } + + std::sort(hits.begin(), hits.end()); + hits.erase(std::unique(hits.begin(), hits.end()), hits.end()); + return hits; +} + +std::vector +Fetcher::builtin_index_roots() const +{ + std::vector roots; + auto data = cfg_.xlingsHome() / "data"; + std::error_code ec; + if (!std::filesystem::exists(data, ec)) return roots; + for (auto& dir : sorted_index_dirs(data)) roots.push_back(dir); + return roots; +} + // ─── Legacy read_xpkg_lua (COMPAT, remove in 1.0.0) ───────────────── // // Infers namespace from the raw package_name string and delegates to the diff --git a/src/pm/publisher.cppm b/src/pm/publisher.cppm index 139a18f..4efda26 100644 --- a/src/pm/publisher.cppm +++ b/src/pm/publisher.cppm @@ -130,7 +130,27 @@ std::string emit_xpkg(const mcpp::manifest::Manifest& manifest, out += std::format("-- Source: mcpp.toml @ v{}\n", release.version); out += "package = {\n"; out += " spec = \"1\",\n"; - out += std::format(" name = {},\n", lua_escape(manifest.package.name)); + // #278 — emit BOTH `namespace` and the fully-qualified `name` (INV-NAME). + // + // This used to write only the bare project name and no `namespace` at all. + // A namespaced index requires the namespace, so maintainers hand-added a + // `namespace = ""` line when filing the package — and that edit turned + // the descriptor into the split form, which parses fine but can never be + // installed. `aimol.tensorvia-cpu` was born exactly this way (the file still + // carries the "AUTO-GENERATED … do not edit by hand" banner). Emitting the + // FQN here closes the generator half of that loop; `mcpp xpkg parse` closes + // the lint half. Design: 2026-06-26 §4.5 prescribed this and it never landed. + if (!manifest.package.namespace_.empty()) { + auto prefix = manifest.package.namespace_ + "."; + auto fqn = manifest.package.name.starts_with(prefix) + ? manifest.package.name + : prefix + manifest.package.name; + out += std::format(" namespace = {},\n", + lua_escape(manifest.package.namespace_)); + out += std::format(" name = {},\n", lua_escape(fqn)); + } else { + out += std::format(" name = {},\n", lua_escape(manifest.package.name)); + } if (!manifest.package.description.empty()) out += std::format(" description = {},\n", lua_escape(manifest.package.description)); if (!manifest.package.license.empty()) diff --git a/src/publish/pipeline.cppm b/src/publish/pipeline.cppm index aa639a6..e08cd70 100644 --- a/src/publish/pipeline.cppm +++ b/src/publish/pipeline.cppm @@ -18,8 +18,9 @@ import mcpp.ui; namespace mcpp::publish { -// `mcpp emit xpkg [-V version] [-o output]`. -export int emit_xpkg_to(std::string version, const std::filesystem::path& output) { +// `mcpp emit xpkg [-V version] [-o output] [--namespace NS]`. +export int emit_xpkg_to(std::string version, const std::filesystem::path& output, + std::string namespaceOverride = {}) { auto root = mcpp::project::find_manifest_root(std::filesystem::current_path()); if (!root) { std::println(stderr, "error: no mcpp.toml found"); @@ -34,6 +35,22 @@ export int emit_xpkg_to(std::string version, const std::filesystem::path& output } if (version.empty()) version = m->package.version; + + // #278: `--namespace` lets a one-off archival run supply the namespace + // without editing mcpp.toml. Without a namespace from either source the + // emitted descriptor is namespace-less, and hand-adding one afterwards is + // precisely what produces an uninstallable split-form descriptor — so say + // so, loudly, at the moment of generation. + if (!namespaceOverride.empty()) m->package.namespace_ = namespaceOverride; + if (m->package.namespace_.empty()) { + mcpp::ui::warning(std::format( + "emitted descriptor declares no `namespace`. If you file this into a " + "namespaced index, do NOT hand-add `namespace = \"\"` — that " + "makes `name` a split form the index can never resolve (mcpp#278). " + "Declare `[package] namespace` in mcpp.toml or pass " + "`--namespace `, so both fields are emitted together.")); + } + auto release = mcpp::publish::placeholder_release(version); auto lua = mcpp::publish::emit_xpkg(*m, scan.graph, release); diff --git a/src/toolchain/fingerprint.cppm b/src/toolchain/fingerprint.cppm index 95598f6..2ae8f70 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.104"; +inline constexpr std::string_view MCPP_VERSION = "0.0.105"; struct FingerprintInputs { Toolchain toolchain; diff --git a/tests/e2e/161_xpkg_name_form.sh b/tests/e2e/161_xpkg_name_form.sh new file mode 100755 index 0000000..e5c1b5c --- /dev/null +++ b/tests/e2e/161_xpkg_name_form.sh @@ -0,0 +1,102 @@ +#!/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. +# +# 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. +# +# 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. +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' +package = { + spec = "1", namespace = "chriskohlhoff", name = "asio", + xpm = { linux = { ["1.38.1"] = { url = "u", sha256 = "h" } } }, + mcpp = { schema = "0.1", sources = { "*.cpp" } }, +} +EOF + +if "$MCPP" xpkg parse split.lua > /dev/null 2>&1; then + echo "FAIL: split-form descriptor 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 + +# ── 2. the FQN form passes ─────────────────────────────────────────── +sed 's/name = "asio"/name = "chriskohlhoff.asio"/' split.lua > fqn.lua +"$MCPP" xpkg parse fqn.lua > /dev/null + +# ── 3. --json carries the violation machine-readably (index CI) ────── +if "$MCPP" xpkg parse --json split.lua > split.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")) +assert "error" in j, j +assert "chriskohlhoff.asio" in j["error"], j +assert j["namespace"] == "chriskohlhoff" and j["name"] == "asio", j +print("json ok") +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. +mkdir -p idx/pkgs/d +cat > idx/pkgs/d/demo.thing.lua <<'EOF' +package = { + spec = "1", namespace = "demo", name = "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" } } }, + mcpp = { schema = "0.1", sources = { "*.cpp" } }, +} +EOF + +mkdir -p app/src && cd app +cat > mcpp.toml <<'EOF' +[package] +name = "app" +version = "0.1.0" + +[indices] +demo = { path = "../idx" } + +[dependencies.demo] +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 +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. +if grep -q "Downloading" build.out; then + echo "FAIL: must fail before any download"; cat build.out; exit 1 +fi + +echo "PASS 161_xpkg_name_form" diff --git a/tests/e2e/162_bare_name_namespace_scope.sh b/tests/e2e/162_bare_name_namespace_scope.sh new file mode 100755 index 0000000..742ec37 --- /dev/null +++ b/tests/e2e/162_bare_name_namespace_scope.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# requires: +# 162_bare_name_namespace_scope.sh — INV-RESOLVE (#278): a bare dependency name +# resolves in exactly three places (mcpplibs, compat, no-namespace upstream); +# every other namespace must be written out. +# +# Before this, a bare name that matched nothing fell through to the FIRST +# candidate SILENTLY, so mcpp carried on with a namespace it had invented and +# the user met the failure much later, wrapped around that invented name. And +# the discovery rung `(no namespace, X)` accepted a descriptor from ANY +# namespace, which made resolution depend on which indices happened to be +# present — adding an index could silently retarget an existing dependency. +# +# Covered: the hard failure, the did-you-mean hint (a DIAGNOSTIC-only index +# scan that must never feed resolution), and that both documented spellings +# resolve the very same package the hint names. +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +cd "$TMP" + +# A path index owning namespace `acme`. Note the descriptor is FQN-correct — +# this test is about the CONSUMER's spelling, not the descriptor's. +mkdir -p idx/pkgs/a +cat > idx/pkgs/a/acme.widget.lua <<'EOF' +package = { + spec = "1", namespace = "acme", name = "acme.widget", + 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" } } }, + mcpp = { schema = "0.1", sources = { "*.cpp" } }, +} +EOF + +mkdir -p app/src && cd app +echo 'int main() { return 0; }' > src/main.cpp + +write_manifest() { + cat > mcpp.toml < bare.out 2>&1; then + echo "FAIL: a bare name must not reach the acme namespace"; cat bare.out; exit 1 +fi +grep -q "no package found under the namespaces mcpp searched" bare.out || { + echo "FAIL: expected the explicit not-found error"; cat bare.out; exit 1; } +# The identities actually attempted must be listed — the old silent fallback +# reported a namespace the user never wrote. +grep -q "tried:" bare.out || { cat bare.out; exit 1; } + +# ── 2. did-you-mean names the real package and both spellings ─────── +grep -q "acme.widget" bare.out || { + echo "FAIL: expected did-you-mean to name acme.widget"; cat bare.out; exit 1; } +grep -q "\[dependencies.acme\]" bare.out || { + echo "FAIL: expected the sub-table spelling in the hint"; cat bare.out; exit 1; } + +# ── 3. the dotted spelling the hint suggests resolves ─────────────── +write_manifest '[dependencies] +"acme.widget" = "1.0.0"' +"$MCPP" build > dotted.out 2>&1 || true +# The asset URL is a sentinel, so the fetch fails — but resolution must have +# gotten far enough to ADDRESS the package, which is what this asserts. +if grep -q "no package found under the namespaces" dotted.out; then + echo "FAIL: dotted selector must resolve to (acme, widget)"; cat dotted.out; exit 1 +fi + +# ── 4. the sub-table spelling resolves identically ────────────────── +write_manifest '[dependencies.acme] +widget = "1.0.0"' +"$MCPP" build > subtable.out 2>&1 || true +if grep -q "no package found under the namespaces" subtable.out; then + echo "FAIL: sub-table form must resolve to (acme, widget)"; cat subtable.out; exit 1 +fi + +# ── 5. the default-namespace search path still works ──────────────── +# `gtest` is a bare request served by the `compat.gtest` descriptor. This is +# the regression lock for the compat alias: narrowing the discovery rung must +# not touch the mcpplibs/compat search path. +# +# Asserted on RESOLUTION, not on a successful build: whether the asset actually +# downloads depends on network/cache state, but "did the bare name reach the +# compat descriptor" does not. Keeping the assertion at the resolution layer is +# both the property under test and what makes this test hermetic. +write_manifest '[dependencies] +gtest = "1.15.2"' +"$MCPP" build > compat.out 2>&1 || true +if grep -q "no package found under the namespaces" compat.out; then + echo "FAIL: bare gtest must still resolve via the compat search path" + cat compat.out; exit 1 +fi + +echo "PASS 162_bare_name_namespace_scope" diff --git a/tests/unit/test_manifest.cpp b/tests/unit/test_manifest.cpp index ed9b65f..ef79a44 100644 --- a/tests/unit/test_manifest.cpp +++ b/tests/unit/test_manifest.cpp @@ -1888,6 +1888,70 @@ TEST(XpkgIdentity, DefaultNamespaceRequestMatchesCompatAlias) { compatGtest, "mcpplibs", "zlib")); } +// ─── 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. + +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, FqnFormIsClean) { + EXPECT_FALSE(mcpp::manifest::xpkg_name_form_violation( + "chriskohlhoff", "chriskohlhoff.asio").has_value()); +} + +TEST(XpkgNameForm, NoNamespaceIsClean) { + // xim upstream packages declare no namespace and are keyed by the bare + // name — legal, and outside INV-NAME's scope. + EXPECT_FALSE(mcpp::manifest::xpkg_name_form_violation("", "opencv") + .has_value()); +} + +TEST(XpkgNameForm, NoNameIsClean) { + // Cannot verify → lenient, matching the identity gate's stance. + EXPECT_FALSE(mcpp::manifest::xpkg_name_form_violation("compat", "") + .has_value()); +} + +TEST(XpkgNameForm, CompatAliasIsClean) { + // REGRESSION LOCK. The predicate must stay narrow ("declared ns non-empty + // AND name lacks the `.` prefix"). Written as the general comparison + // "literal name != derived fqname" it would flag this descriptor — a bare + // `gtest` request derives fqname `gtest` while the literal is `compat.gtest` + // — and break the working compat-alias path for every bare dependency. + EXPECT_FALSE(mcpp::manifest::xpkg_name_form_violation("compat", "compat.gtest") + .has_value()); +} + +TEST(XpkgNameForm, NestedNamespaceIsClean) { + EXPECT_FALSE(mcpp::manifest::xpkg_name_form_violation( + "mcpplibs.capi", "mcpplibs.capi.lua").has_value()); +} + +TEST(XpkgNameForm, PrefixWithoutShortSegmentIsAViolation) { + // `name` equal to the namespace itself carries no short segment. + EXPECT_TRUE(mcpp::manifest::xpkg_name_form_violation("compat", "compat.") + .has_value()); +} + +TEST(XpkgNameForm, FromLuaReadsBothFields) { + constexpr std::string_view splitLua = + R"(package = { namespace = "aimol", name = "tensorvia-cpu" })"; + constexpr std::string_view fqnLua = + R"(package = { namespace = "aimol", name = "aimol.tensorvia-cpu" })"; + EXPECT_TRUE( + mcpp::manifest::xpkg_name_form_violation_from_lua(splitLua).has_value()); + EXPECT_FALSE( + mcpp::manifest::xpkg_name_form_violation_from_lua(fqnLua).has_value()); +} + // ─── canonical_xpkg_identity — the unified (ns, name) model (§4.2) ─── // // Identity is a 2-tuple: ns is a hierarchical namespace path, name is a single