diff --git a/.agents/docs/2026-07-25-issue381-namespace-index-identity-design.md b/.agents/docs/2026-07-25-issue381-namespace-index-identity-design.md new file mode 100644 index 00000000..95db83ab --- /dev/null +++ b/.agents/docs/2026-07-25-issue381-namespace-index-identity-design.md @@ -0,0 +1,732 @@ +# Issue #381 — 同仓库同名包的 namespace 索引身份设计 + +**日期**: 2026-07-25 +**状态**: Implemented for xlings 0.4.69(待 PR、CI 与 release 验证) +**Issue**: [openxlings/xlings#381](https://github.com/openxlings/xlings/issues/381) +**影响版本**: xlings 0.4.68 + openxlings/libxpkg 0.0.45 +**涉及仓库**: + +- `openxlings/libxpkg` + - `src/xpkg.cppm` + - `src/xpkg-loader.cppm` + - `src/xpkg-index.cppm` +- `openxlings/xlings` + - `src/core/xim/index.cppm` + - `src/core/xim/catalog.cppm` + - `src/core/xim/resolver.cppm` + - `tests/unit/test_main.cpp` + - `tests/e2e/index_cache_test.sh` + +--- + +## 0. 结论先行 + +Issue #381 是真实且稳定可复现的身份模型缺陷,不只是查询时少做了一次 namespace +过滤。 + +当前 `libxpkg::build_index()` 在扫描 descriptor 时,以裸 `package.name` 写入 +`PackageIndex::entries`。同一个 index repo 内的 `alpha:demo` 和 `beta:demo` 都写到 +`entries["demo"]`,后写入者覆盖先写入者。xlings catalog 随后才加载 descriptor 并检查 +namespace;此时另一个 descriptor 的路径已经从索引和缓存中消失,catalog 无法恢复。 + +推荐进行跨仓库结构性修复: + +1. **libxpkg 将 `(effective namespace, package.name)` 作为完整包身份**; +2. **PackageIndex 同时维护完整身份表和短名候选表**; +3. **xlings catalog 允许一个 repo 返回多个短名候选**; +4. 显式 `namespace:name` 精确解析,裸 `name` 多候选时报现有 ambiguity 错误; +5. xlings index cache 升级为 v2,强制废弃缺少 namespace 信息的 v1 cache; +6. 同一完整身份重复时构建失败并报告两个 descriptor 路径,彻底取消扫描顺序决定胜者。 + +只在 xlings 侧加过滤无法修复本问题,因为输入 xlings catalog 之前信息已经丢失。 + +--- + +## 1. 现状验证 + +### 1.1 隔离复现 + +使用当前仓库构建的 xlings 0.4.68,在独立 `XLINGS_HOME` 中建立单一 index repo: + +```text +pkgs/a/alpha.demo.lua -> namespace = "alpha", name = "demo" +pkgs/b/beta.demo.lua -> namespace = "beta", name = "demo" +``` + +实测结果: + +```text +xlings search demo + alpha:demo + +xlings info alpha:demo + success + +xlings info beta:demo + [error] package 'beta:demo' not found +``` + +该隔离环境生成的 `.xlings-index-cache.json` 也只含一个条目: + +```json +{ + "version": 1, + "entries": { + "demo": { + "name": "demo", + "path": ".../pkgs/a/alpha.demo.lua" + } + } +} +``` + +这直接证明丢失发生在索引构建阶段,而不是 CLI 展示阶段。 + +### 1.2 不受影响的场景 + +两个同名包位于不同 index repo 时,每个 repo 有独立 `PackageIndex`,不会在构建时共享 +key space。catalog 能聚合两个候选并报告 ambiguity;这个行为正确,应保持不变。 + +### 1.3 当前根因链 + +```text +descriptor: + namespace=alpha, name=demo ─┐ + ├─ build_index -> entries["demo"] -> 只剩一个路径 + namespace=beta, name=demo ─┘ + │ + v + xlings cache v1 同样只保存一个条目 + │ + v + catalog 先按 "demo" 查找,再加载 descriptor + │ + v + namespace 仅作为后置过滤,无法找回 beta +``` + +具体代码: + +- `openxlings/libxpkg/src/xpkg-loader.cppm` + - `build_index(repo_dir, namespace_)` + - key 使用函数参数 `namespace_ + pkg.name` + - xlings 调用时不传该参数,因此实际 key 为 `pkg.name` + - `index.entries[key] = ...` 对重复 key 直接覆盖 +- `xlings/src/core/xim/index.cppm` + - `IndexManager::rebuild()` 调用 `xpkg::build_index(repoDir_)` + - cache v1 只保存 entry key/name/path,不保存 descriptor namespace +- `xlings/src/core/xim/catalog.cppm` + - `build_match_()` 先以 `parsed.name` 调 `resolve/find_entry/match_version` + - 找到单个 entry 后才加载 package 并检查 `pkg.namespace_` + - `build_match_()` 的返回类型还是单个 `PackageMatch`,无法表达同仓库多候选 + +--- + +## 2. 需求与不变量 + +### 2.1 功能需求 + +1. 同一 index repo 可以同时保存 `alpha:demo` 与 `beta:demo`。 +2. `xlings info/install alpha:demo` 只能解析到 alpha descriptor。 +3. `xlings info/install beta:demo` 只能解析到 beta descriptor。 +4. 裸 `demo`: + - 只有一个候选时保持现有便捷解析; + - 有多个 namespace 候选时明确报 ambiguity,并列出所有候选。 +5. `xlings search demo` 必须列出两个包。 +6. install、info、search、remove、plan/interface 和递归依赖解析使用同一身份规则。 +7. 跨 repo 的既有候选聚合、project scope 优先级和 sub-index 优先级保持不变。 + +### 2.2 身份不变量 + +定义: + +```text +effectiveNamespace = + package.namespace 非空 ? package.namespace : repo.defaultNamespace + +PackageIdentity = (effectiveNamespace, package.name) + +canonicalName = + effectiveNamespace 非空 ? effectiveNamespace + ":" + package.name + : package.name + +entryKey = + version 非空 ? canonicalName + "@" + version + : canonicalName +``` + +必须满足: + +- 同一个 `PackageIndex` 内,`entryKey` 唯一。 +- `build_index()` 扫描得到的无独立 version descriptor,其 `canonicalName` 唯一;手工构造 + 的 versioned entries 可以共享 canonical identity,但 version/entry key 必须不同。 +- 不同 namespace 的同一短名是两个合法身份。 +- 同一完整身份对应两个 descriptor 是索引错误,不能覆盖、不能选一个继续。 +- `canonicalName` 表示不带版本的包身份;`entryKey` 表示该身份下的具体索引条目。当前 + `build_index()` 生成的 descriptor entry 没有独立 version,因此两者相同。保留 + `entryKey` 是为了兼容 libxpkg 现有 versioned-entry API。 +- 空 descriptor namespace 与显式写出 repo 默认 namespace 的同名包属于同一身份: + +```text +repo default = alpha + +{ namespace = "", name = "demo" } -> alpha:demo +{ namespace = "alpha", name = "demo" } -> alpha:demo // duplicate,构建失败 +``` + +- 文件系统遍历顺序不得影响合法索引内容或错误结果。 + +### 2.3 非功能需求 + +- 构建复杂度保持 O(N)。 +- 显式完整身份查询平均 O(1)。 +- 裸名查询为 O(k),`k` 是该短名的候选数,不扫描整个索引。 +- ambiguity 与 duplicate 诊断按 canonical name/path 排序,跨平台输出稳定。 +- 不增加第二套 Lua descriptor 解析器;身份元数据仍由 libxpkg loader 提取。 + +--- + +## 3. 方案比较 + +### 3.1 方案 A:完整身份表 + 短名候选表(推荐) + +`PackageIndex` 的主表按 canonical name 唯一存储,并维护 +`short name -> canonical names` 的二级索引。 + +优点: + +- 与 xlings 已公开的 `namespace:name` 地址模型一致; +- 显式查询、裸名候选和 search 都有清晰语义; +- 同仓库与跨仓库最终都进入 catalog 的统一候选/ambiguity 流程; +- 查询复杂度稳定; +- 后续 alias、version、mutex 都可以在完整身份内演进。 + +代价: + +- libxpkg 公共数据模型/API 需要升级; +- xlings `IndexManager` 和 catalog 需要从“单条命中”改为“候选集合”; +- cache 必须升级。 + +### 3.2 方案 B:`name -> vector` + +主表直接按短名保存 vector。 + +优点是直观地保留碰撞项;缺点是所有现有单值操作 +`find_entry/resolve/match_version/set_installed/mutex/merge` 都会突然变成多值操作, +完整身份没有成为一等概念,显式 namespace 仍需在 vector 上做后置过滤。 + +该方案修复了覆盖症状,但继续保留“存储按短名、namespace 是过滤条件”的架构方向, +不推荐。 + +### 3.3 方案 C:碰撞时 warn/error + +在 `entries[key]` 写入前检测已有 key,warning 或直接失败。 + +优点是改动小、立即消除静默覆盖;缺点是合法的 `alpha:demo` 与 `beta:demo` 仍不能共存, +不满足 issue 的 Expected。 + +它只适合作为结构修复中的防御性检查,不能作为最终方案。 + +--- + +## 4. 推荐设计 + +### 4.1 libxpkg:让 PackageIdentity 成为一等数据 + +在 `mcpplibs.xpkg` 数据模型中引入身份元数据: + +```cpp +struct PackageIdentity { + std::string namespaceName; + std::string name; + + std::string canonical_name() const; +}; + +struct IndexEntry { + PackageIdentity identity; + std::string canonicalName; + std::string entryKey; + std::string version; + std::filesystem::path path; + PackageType type; + std::string description; + bool installed = false; + std::string ref; +}; + +struct PackageIndex { + // entryKey -> unique entry + std::unordered_map entries; + + // canonicalName -> sorted entryKey candidates + std::unordered_map> identityEntries; + + // short package.name -> sorted, unique canonicalName candidates + std::unordered_map> shortNames; + + std::unordered_map> mutex_groups; +}; +``` + +`entries` 的 map key 与 `entry.entryKey` 必须一致。`IndexEntry::identity.name` 始终是 +descriptor 的短 `package.name`,不再混用“entry key”“完整身份”和“包名”三个概念。 +当前 descriptor build path 中 `entryKey == canonicalName`;现有 libxpkg +versioned-entry 用例则使用 `namespace:name@version`,并由 `identityEntries` 归组。 + +`canonicalName` 使用 xlings 已有用户地址形式 `namespace:name`,而安装目录继续使用现有 +`namespace-x-name`,两者职责不混合: + +- `namespace:name`:包身份与查询; +- `namespace-x-name`:文件系统 store name。 + +### 4.2 libxpkg:build_index 接收 repo 默认 namespace + +保留 `build_index` 的第二参数,但明确其语义为 repo 默认 namespace: + +```cpp +std::expected +build_index(const std::filesystem::path& repoDir, + const std::string& defaultNamespace = ""); +``` + +构建前先收集全部 `.lua` descriptor 路径,规范化并排序,再按稳定顺序加载。这样合法索引 +结果和 duplicate 诊断都不依赖 `std::filesystem::directory_iterator` 的平台顺序。 + +对每个 descriptor: + +```cpp +auto effectiveNamespace = pkg.namespace_.empty() + ? defaultNamespace + : pkg.namespace_; + +PackageIdentity identity { + .namespaceName = effectiveNamespace, + .name = pkg.name, +}; +auto key = identity.canonical_name(); +``` + +写入前执行 duplicate 检查: + +```text +duplicate package identity 'alpha:demo' in one index: + first: .../pkgs/a/alpha.demo.lua + second: .../pkgs/b/other.demo.lua +``` + +duplicate 是 fatal build error。warning 后继续仍会产生不确定的 descriptor 选择,不能接受。 + +成功写入后,将 entry key 加入 `identityEntries[canonicalName]`,将 canonical name 加入 +`shortNames[pkg.name]`。扫描结束后对每个候选 vector 排序并去重,使 search 和 +ambiguity 输出稳定。 + +### 4.3 libxpkg:身份感知的查询 API + +新增或替换为以下语义: + +```cpp +const IndexEntry* +find_entry(const PackageIndex&, std::string_view entryKey); + +std::vector +find_candidates(const PackageIndex&, + std::string_view shortName, + std::optional namespaceName = std::nullopt); + +std::string +resolve_candidate(const PackageIndex&, std::string_view canonicalName); + +std::optional +match_version(const PackageIndex&, + std::string_view canonicalName, + std::string_view versionHint = {}); +``` + +规则: + +- 传 namespace 时,`find_candidates` 只查 canonical key,返回 0 或 1 项。 +- 不传 namespace 时,从 `shortNames` 返回全部 canonical keys。 +- alias 在候选身份确定后解析,不允许先按裸名选中一个 alias。 +- alias 的裸 ref 默认继承 alias 自身 namespace;显式 `other:target` 才能跨 namespace。 +- version 匹配只遍历 `identityEntries[canonicalName]`,不能把 `alpha:demo@1` 匹配到 + `beta:demo@1`。 +- `set_installed` 接收最终 entry key;`mutex_packages` 等身份级 API 接收 canonical name。 + +现有 `merge(base, overlay, namespace)` 在 xlings 中没有生产调用点。建议同步收紧语义: + +- overlay entry 已带 namespace 时保留; +- namespace 参数只作为 entry namespace 为空时的默认值; +- 完整身份碰撞返回 error,不再覆盖。 + +### 4.4 xlings IndexManager:封装 libxpkg 身份 API + +`IndexManager` 增加 repo 默认 namespace: + +```cpp +class IndexManager { + std::filesystem::path repoDir_; + std::string defaultNamespace_; + // ... +}; +``` + +`PackageCatalog::make_state_()` 将 `RepoIndexSpec::defaultNamespace` 传给 manager, +`rebuild()` 改为: + +```cpp +xpkg::build_index(repoDir_, defaultNamespace_); +``` + +对外提供: + +```cpp +std::vector +find_candidates(std::string_view name, + std::optional namespaceName) const; + +const xpkg::IndexEntry* +find_entry(std::string_view entryKey) const; + +std::expected +load_package(std::string_view entryKey) const; +``` + +`all_names/search` 返回 canonical identities; +`installed_names/entry_path/mark_installed/load_package` 使用最终 entry key。 + +### 4.5 xlings catalog:每个 repo 可以产生多个 PackageMatch + +把当前: + +```cpp +static PackageMatch build_match_(RepoState&, ParsedTarget_, ...); +``` + +改成: + +```cpp +static std::vector +build_matches_(RepoState&, const ParsedTarget_&, ...); +``` + +解析流程: + +```text +target + │ + ├─ alpha:demo ─> repo.index.find_candidates("demo", "alpha") ─> 0..1 + │ + └─ demo ─> repo.index.find_candidates("demo", none) ─> 0..N + │ + v + 每个 canonical candidate 独立做 alias/version/package 加载 + │ + v + PackageCatalog 聚合所有 repo 的 PackageMatch +``` + +`collect_matches_()` 保留现有优先级: + +1. project repo; +2. global primary repo; +3. 没有 primary 裸名命中时才使用 sub-index; +4. 显式 namespace 始终检查所有相关 repo; +5. 相同 project/global identity 继续执行 project scope 优先; +6. 最终 0 个候选为 not found,1 个成功,多于 1 个调用现有 + `format_ambiguous_candidates()`。 + +因此同一 repo 的两个 namespace 包自然得到: + +```text +xlings info alpha:demo -> alpha:demo +xlings info beta:demo -> beta:demo +xlings info demo -> ambiguous: + 1. alpha:demo@1.0.0 from global repo 'demoidx' + 2. beta:demo@1.0.0 from global repo 'demoidx' +``` + +### 4.6 search + +libxpkg `search()` 遍历完整身份表,并同时匹配: + +- canonical name; +- short name; +- description。 + +它返回 canonical keys。xlings search 对每个 key 构造 match,不再把 search 返回值重新当作 +裸名查询,因此不会把两个结果再次折叠。 + +排序以 canonical name 为主,保证 `alpha:demo`、`beta:demo` 都显示且顺序稳定。 + +### 4.7 resolver 与依赖 + +顶层 install/info/remove 和递归依赖都必须调用 `PackageCatalog::resolve_target()`,不能保留 +绕过 catalog、直接对 `IndexManager` 做裸名单项查询的生产路径。 + +依赖规则保持当前语义: + +- descriptor 写 `alpha:dep` 时精确解析; +- descriptor 写裸 `dep` 时按 catalog 全局候选规则解析; +- 本次不新增“裸依赖自动继承声明者 namespace”的隐式规则。 + +如果裸依赖因本次修复暴露出真实的多 namespace 候选,应报 ambiguity,由包作者写完整身份; +不能继续依赖扫描顺序。 + +### 4.8 cache v2 + +当前 v1 cache 无法恢复 namespace,必须直接升级: + +```json +{ + "version": 2, + "repo_head_hash": "...", + "default_namespace": "demoidx", + "entries": { + "alpha:demo": { + "name": "demo", + "namespace": "alpha", + "canonical_name": "alpha:demo", + "entry_key": "alpha:demo", + "path": ".../alpha.demo.lua", + "type": 0, + "description": "alpha's demo package", + "version": "", + "ref": "" + } + } +} +``` + +规则: + +- loader 只接受 `version == 2`; +- v1 即使 `repo_head_hash` 相同也视为 cache miss,重新扫描 descriptor; +- cache 中的 `default_namespace` 必须与当前 `RepoIndexSpec::defaultNamespace` 一致,否则 + 即使 repo HEAD 相同也必须重建。同一物理 repo 可能以不同 repo name/default namespace + 挂载,单靠 HEAD 不能证明缓存身份上下文一致; +- `identityEntries` 与 `shortNames` 不必持久化,加载 v2 entries 时线性重建并排序; +- loader 校验 map key、`entry_key`、`canonical_name`、namespace/name/version 的推导关系; + 任一不一致都把整个 cache 视为 invalid,回退到 descriptor rebuild; +- cache 写入仍是 best effort; +- artifact/git repo head hash 与 cache 文件位置不变; +- cache 是可再生内部数据,不提供 v1 兼容读取开关。 + +### 4.9 错误处理 + +| 场景 | 行为 | +|---|---| +| 同 repo、不同 namespace、同 short name | 合法,保存两个候选 | +| 同 repo、相同 effective namespace + name | build_index fatal,列出两个路径 | +| 显式 namespace 无候选 | `package 'ns:name' not found` | +| 裸名多个候选 | ambiguity,列出 canonical name/repo | +| v1 cache | 忽略并重建 | +| v2 cache default namespace 与当前 repo spec 不同 | 忽略并重建 | +| v2 cache 数据自相矛盾 | cache invalid,重建 | +| malformed descriptor | 保持现有 loader/build 策略,本 issue 不扩展其诊断模型 | + +--- + +## 5. 兼容性 + +### 5.1 用户行为 + +保持: + +- 唯一裸名仍可直接使用; +- 显式 `namespace:name` 语法不变; +- 不同 repo 同名包的现有 ambiguity 行为不变; +- project/global/sub-index 优先级不变; +- 安装目录与 xvm namespace version 格式不变。 + +有意改变: + +- 过去被静默覆盖的同仓库同名包现在全部可见; +- 裸名因此可能从“偶然选中一个”变成 ambiguity; +- 相同完整身份重复从“扫描顺序决定胜者”变成明确构建失败。 + +这两项变化都是把未定义/错误行为改为确定行为,不需要兼容开关。 + +### 5.2 libxpkg API + +这是数据模型/API 变更,需要发布新的 libxpkg 版本,再由 xlings 升级依赖。 + +为降低迁移风险,可在一个 libxpkg release 周期内保留旧单项 API 作为 deprecated wrapper, +但 wrapper 遇到多候选必须返回空/错误,不能暗中选第一个。 + +### 5.3 cache + +cache 只属于本地可再生状态。版本升级后的首次命令会重新扫描索引,后续恢复 cache hit; +不需要迁移工具。 + +--- + +## 6. 测试设计 + +### 6.1 libxpkg 单元测试 + +新增 fixture: + +```text +pkgs/a/alpha.demo.lua namespace=alpha name=demo +pkgs/b/beta.demo.lua namespace=beta name=demo +``` + +覆盖: + +1. build_index 保留两个 canonical entries; +2. `find_candidates("demo", none)` 返回两个; +3. `find_candidates("demo", "alpha")` 只返回 alpha; +4. search name/description 均返回两个; +5. 两个相同 canonical identity 构建失败,错误含两个路径; +6. 空 namespace 使用传入的 default namespace; +7. 空 namespace 与显式 default namespace 冲突; +8. alias 裸 ref 不跨 namespace; +9. version matching 不跨 namespace; +10. merge 不覆盖完整身份。 + +### 6.2 xlings 单元测试 + +覆盖 `IndexManager` 和 `PackageCatalog`: + +1. 同 repo 显式解析 alpha/beta 均成功; +2. 同 repo 裸 `demo` 返回 ambiguity,候选顺序稳定; +3. search 返回两个 canonical matches; +4. cross-repo 既有 ambiguity 不回归; +5. project scope 对同 canonical identity 的优先级不回归; +6. primary/sub-index 优先级不回归; +7. `mark_installed/load_package/entry_path` 使用 canonical key 操作正确; +8. 显式 namespace 的递归依赖解析正确; +9. 裸依赖多候选返回 ambiguity。 + +### 6.3 cache 测试 + +扩展 `tests/e2e/index_cache_test.sh` 或新增独立测试: + +1. 第一次构建生成 v2 cache; +2. cache 同时保存 alpha/beta; +3. 第二次查询 cache hit,mtime 不变; +4. 手工放入同 HEAD 的 v1 cache,下一次查询仍重建为 v2; +5. 损坏 namespace/canonical_name 一致性时 fail closed 到 cache miss; +6. 同 HEAD、不同 default namespace 时 cache miss 并重建; +7. 从 v2 cache 加载后 explicit/bare/search 行为与冷构建一致。 + +### 6.4 隔离 E2E + +新增 `tests/e2e/index_same_name_namespace_test.sh`,只使用临时 `XLINGS_HOME` 和本地 fixture: + +```text +search demo -> 同时包含 alpha:demo、beta:demo +info alpha:demo -> alpha description +info beta:demo -> beta description +info demo -> non-zero + ambiguity + 两个候选 +interface plan_install -> 显式 namespace 产生正确 canonical package +``` + +测试不访问真实用户环境,不需要下载 `example.invalid` 资源;使用 `info/search/plan_install` +证明解析链即可。 + +### 6.5 回归验证 + +xlings: + +```bash +xlings install +xlings use gcc@16.1.0 +mcpp build +mcpp test +XLINGS_BIN=$(find target -path '*/bin/xlings' -type f | head -1) \ + bash tests/e2e/index_same_name_namespace_test.sh +XLINGS_BIN=$(find target -path '*/bin/xlings' -type f | head -1) \ + bash tests/e2e/index_cache_test.sh +``` + +libxpkg: + +```bash +mcpp build +mcpp test +``` + +最终以 Linux、macOS、Windows CI 全绿为合入门槛。 + +--- + +## 7. 分阶段落地 + +### Phase 1 — libxpkg 身份模型 + +1. 增加 `PackageIdentity` 和 IndexEntry namespace/canonical metadata; +2. 改造 build_index,传入 default namespace; +3. duplicate identity fail closed; +4. 增加 short-name candidate index; +5. 改造 search/resolve/version/merge/set_installed; +6. 补齐 libxpkg 单测并发布新版本。 + +### Phase 2 — xlings 集成 + +1. 升级 `mcpp.toml` 的 libxpkg 依赖; +2. IndexManager 接收 repo default namespace; +3. cache 升级 v2; +4. catalog 从单 match 改为 match vector; +5. search/resolver/install/remove 统一 canonical identity; +6. 增加 unit + isolated E2E。 + +### Phase 3 — 真实索引验证 + +1. 对现有官方索引和多 namespace index 做全量冷构建; +2. 检查是否存在过去被覆盖的重复完整身份; +3. 验证现有短名唯一包行为不变; +4. 验证多 namespace 同短名出现稳定 ambiguity; +5. 三平台 CI 通过后合入。 + +不建议先发布“warning 后继续覆盖”的过渡版本。Phase 1 自身就应同时做到: + +- 不同完整身份共存; +- 相同完整身份 fail closed。 + +--- + +## 8. 风险与缓解 + +| 风险 | 缓解 | +|---|---| +| 现有调用方假设 `IndexEntry.name == map key` | 明确拆分 short name/canonical name,编译期逐点迁移 | +| 旧 cache 继续隐藏包 | cache format 强制升 v2,不读取 v1 | +| 同 repo HEAD 在不同默认 namespace 下误复用 cache | v2 header 记录并校验 `default_namespace` | +| 修复后裸依赖出现新 ambiguity | 输出完整候选,要求 descriptor 使用显式 namespace | +| alias/version 意外跨 namespace | 候选身份先确定,再在同 canonical base 内解析 | +| 同身份重复过去被扫描顺序掩盖 | 构建 fatal 并输出两个 descriptor 路径 | +| search 返回 canonical key 后二次解析折叠 | search 结果按 canonical key 直接构造 match | +| libxpkg 与 xlings 升级不同步 | 先发布并验证 libxpkg,再在 xlings 单独升级依赖 | + +--- + +## 9. 非目标 + +- 不改变 `namespace:name` 用户语法。 +- 不改变 `namespace-x-name` 安装目录格式。 +- 不引入裸依赖自动继承当前包 namespace。 +- 不改变跨 repo 的 project/global/sub-index 优先级。 +- 不重构 descriptor malformed-error 策略。 +- 不为 v1 cache 提供迁移或兼容开关。 +- 不在 xlings 中复制 Lua descriptor 解析逻辑。 + +--- + +## 10. Review 结论 + +2026-07-25 review 已确认六项设计决策: + +1. **跨仓库修复**:接受完整修复跨越 libxpkg + xlings 两个仓库; +2. **Canonical identity**:使用现有用户形式 `namespace:name`; +3. **重复完整身份**:`build_index` 构建失败,并报告冲突的 descriptor 路径; +4. **裸名多候选**:统一返回 ambiguity,不采用 first-match; +5. **Cache 升级**:直接升级 v2,不兼容读取 v1 cache; +6. **裸依赖 namespace**:维持现状,不隐式继承声明者 namespace;多个候选时返回 + ambiguity,由 descriptor 显式填写 `namespace:name`。 + +以上决策共同建立一个可验证的不变量: + +> xlings 对外展示的 `namespace:name`,在索引存储、缓存、查询和依赖解析中始终代表同一个 +> 完整包身份;任何歧义都显式报告,任何重复身份都拒绝构建。 diff --git a/.agents/plans/2026-07-25-issue381-namespace-index-ecosystem-implementation.md b/.agents/plans/2026-07-25-issue381-namespace-index-ecosystem-implementation.md new file mode 100644 index 00000000..8f2f0c17 --- /dev/null +++ b/.agents/plans/2026-07-25-issue381-namespace-index-ecosystem-implementation.md @@ -0,0 +1,449 @@ +# Issue #381 Namespace Index Ecosystem Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship namespace-aware same-repo package identity through libxpkg, mcpp-index/resource mirrors, xlings 0.4.69, release infrastructure, and a real latest-version ecosystem smoke test. + +**Architecture:** libxpkg owns descriptor identity and indexes every entry by canonical `namespace:name`, while preserving the descriptor's original `package.name` as the short-name lookup token. xlings passes each repo's default namespace into libxpkg, persists identity metadata in cache v2, and lets each repo yield multiple catalog candidates so explicit namespace resolves exactly and unqualified ambiguous names fail visibly. Releases proceed in dependency order and are verified at their public GitHub/GitCode boundaries before downstream consumption. + +**Tech Stack:** C++23 modules (`mcpp`, `import std`), Lua xpkg descriptors, Bash E2E, GitHub Actions/`gh`, GitCode `gtc`, xlings/xpkg resource publishing. + +## Global Constraints + +- Implement `.agents/docs/2026-07-25-issue381-namespace-index-identity-design.md` without reducing its identity, cache, ambiguity, or test scope. +- Preserve the descriptor's original `package.name`; do not strip a `.` prefix used by existing mcpp-index packages. +- `effectiveNamespace = package.namespace` when non-empty, otherwise the index repo default namespace supplied by xlings. +- Different namespaces with the same name coexist; duplicate effective `(namespace, name)` identities in one index make `build_index` fail with both paths. +- Bare dependencies keep current global catalog semantics and do not inherit the declaring package namespace. +- Cache v2 rejects v1 and rejects a cache whose `default_namespace` differs from the current repo spec. +- Use `xlings install` for tool installation and `xlings use gcc@16.1.0` for xlings development. +- Every E2E uses an isolated temporary `XLINGS_HOME`. +- Preserve visible git history; use additive commits and normal pushes only. +- PRs merge with `--squash --admin` after required CI passes; no force push, amend, or rebase. +- Target versions: libxpkg `0.0.46`, xlings `0.4.69`, unless a live remote audit shows either version already exists before its release step. + +--- + +### Task 1: Prepare isolated worktrees and baselines + +**Files:** +- No source changes +- Worktrees: repo-local `.worktrees/fix-issue381-namespace-identity` + +**Interfaces:** +- Consumes: `origin/main` for `openxlings/libxpkg`, `mcpplibs/mcpp-index`, `openxlings/xlings`, and `openxlings/xim-pkgindex` +- Produces: clean feature branches and baseline test evidence + +- [ ] **Step 1: Verify `.worktrees` is ignored in each repository** + +Run: + +```bash +git check-ignore -q .worktrees +``` + +If absent, add only `/.worktrees/` to that repository's `.gitignore` and commit `chore: ignore local worktrees`. + +- [ ] **Step 2: Create feature worktrees from current `origin/main`** + +```bash +git worktree add .worktrees/fix-issue381-namespace-identity \ + -b fix/issue381-namespace-identity origin/main +``` + +Use separate repository roots; never reuse a worktree directory across repositories. + +- [ ] **Step 3: Baseline libxpkg** + +Run from the libxpkg worktree: + +```bash +xlings install +mcpp build +mcpp test +``` + +Expected: build succeeds and all current libxpkg tests pass before changes. + +- [ ] **Step 4: Baseline xlings** + +Run from the xlings worktree: + +```bash +xlings install +xlings use gcc@16.1.0 +mcpp build +mcpp test +``` + +Expected: build succeeds and all current xlings unit tests pass before changes. + +--- + +### Task 2: Add failing libxpkg identity tests + +**Files:** +- Modify: `openxlings/libxpkg/tests/test_loader.cpp` +- Modify: `openxlings/libxpkg/tests/test_index.cpp` +- Create: `openxlings/libxpkg/tests/fixtures/pkgindex-namespaces/pkgs/a/alpha.demo.lua` +- Create: `openxlings/libxpkg/tests/fixtures/pkgindex-namespaces/pkgs/b/beta.demo.lua` +- Create: `openxlings/libxpkg/tests/fixtures/pkgindex-duplicate/pkgs/a/implicit.demo.lua` +- Create: `openxlings/libxpkg/tests/fixtures/pkgindex-duplicate/pkgs/b/explicit.demo.lua` + +**Interfaces:** +- Consumes: existing `build_index`, `search`, `resolve`, `match_version` +- Produces: executable requirements for canonical entries, short-name candidates, duplicate rejection, aliases, and namespace-scoped versions + +- [ ] **Step 1: Add same-name/different-namespace fixtures** + +Use literal package definitions with `alpha/demo` and `beta/demo`, distinct descriptions, and no network hooks. + +- [ ] **Step 2: Add loader tests** + +Tests must assert: + +```cpp +auto index = build_index(fixturePath, "repo-default"); +ASSERT_TRUE(index); +EXPECT_TRUE(index->entries.contains("alpha:demo")); +EXPECT_TRUE(index->entries.contains("beta:demo")); +EXPECT_EQ(index->short_names.at("demo"), + (std::vector { "alpha:demo", "beta:demo" })); +``` + +Add a duplicate test where an empty namespace under default `alpha` conflicts with explicit `alpha`; assert `build_index` returns `unexpected` containing both fixture paths. + +- [ ] **Step 3: Add index API tests** + +Assert: + +- `find_candidates(index, "demo", std::nullopt)` returns both canonical identities; +- `find_candidates(index, "demo", "alpha")` returns only `alpha:demo`; +- search returns both canonical entries in sorted order; +- an unqualified alias inherits its own namespace; +- version matching never crosses from `alpha:demo` to `beta:demo`. + +- [ ] **Step 4: Verify RED** + +Run: + +```bash +mcpp test +``` + +Expected: compilation/test failure because the new identity fields/APIs do not yet exist, or behavior failure because one descriptor is overwritten. + +--- + +### Task 3: Implement libxpkg namespace identity + +**Files:** +- Modify: `openxlings/libxpkg/src/xpkg.cppm` +- Modify: `openxlings/libxpkg/src/xpkg-loader.cppm` +- Modify: `openxlings/libxpkg/src/xpkg-index.cppm` +- Modify: `openxlings/libxpkg/mcpp.toml` + +**Interfaces:** +- Produces: + - `PackageIdentity { namespaceName, name, canonical_name() }` + - `IndexEntry { identity, canonicalName, entryKey, ... }` + - `PackageIndex::{entries, identity_entries, short_names, mutex_groups}` + - `find_candidates(index, name, optional namespace)` + - namespace-scoped `resolve`/`match_version` + +- [ ] **Step 1: Add the model** + +Follow mcpp naming style: PascalCase types, camelCase fields, snake_case functions, private members with `_`. Keep out-of-line destructors for module/GCC stability. + +- [ ] **Step 2: Implement deterministic build** + +Collect descriptor paths, sort them, load each package, derive effective namespace and canonical identity, and reject a duplicate before insertion: + +```cpp +auto [it, inserted] = index.entries.emplace(entry.entryKey, entry); +if (!inserted) { + return std::unexpected(std::format( + "duplicate package identity '{}': '{}' conflicts with '{}'", + entry.canonicalName, it->second.path.string(), entry.path.string())); +} +``` + +Populate and sort `identity_entries` and `short_names`. + +- [ ] **Step 3: Implement identity-aware lookup** + +Explicit namespace lookup is O(1); bare lookup uses `short_names`. Alias resolution occurs after candidate selection and inherits its candidate namespace unless the ref is explicitly namespaced. + +- [ ] **Step 4: Preserve existing empty-namespace APIs** + +Existing tests using `vscode`, `python@3.12.0`, merge, mutex groups, and installed flags must remain valid with empty namespaces. + +- [ ] **Step 5: Bump version** + +Set `mcpp.toml` package version to `0.0.46`. + +- [ ] **Step 6: Verify GREEN** + +```bash +mcpp build +mcpp test +``` + +Expected: all existing and new tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add mcpp.toml src tests +git commit -m "fix(index): preserve namespace package identity (0.0.46)" +``` + +--- + +### Task 4: Publish libxpkg 0.0.46 + +**Files:** +- GitHub PR/release state only + +**Interfaces:** +- Produces: merged libxpkg main commit, tags `v0.0.46` and/or `0.0.46` as required by the live release workflow, public source archive with a verified SHA256 + +- [ ] **Step 1: Push and create PR** + +Create a PR titled `fix(index): preserve namespace package identity (0.0.46)` referencing `openxlings/xlings#381`. + +- [ ] **Step 2: Wait for all libxpkg CI checks** + +Use `gh pr checks --watch`; on failure inspect `gh run view --log-failed`, fix by new additive commit, and re-run local tests. + +- [ ] **Step 3: Squash merge with bypass** + +```bash +gh pr merge --squash --delete-branch --admin +``` + +- [ ] **Step 4: Trigger/perform release** + +Use the repository's checked-in release workflow. Do not invent tag spelling; inspect the workflow and existing `0.0.45` release first. + +- [ ] **Step 5: Verify release** + +Verify tag commit ancestry, GitHub release state, source archive readability, `mcpp.toml` version, and SHA256. + +--- + +### Task 5: Update mcpp-index and libxpkg mirrors + +**Files:** +- Modify: `mcpplibs/mcpp-index/pkgs/x/xpkg.lua` +- Modify only if the live contract requires it: `mcpplibs/mcpp-index/tests/check_package_name.lua` +- Resource state: `github.com/mcpp-res/xpkg` or current configured resource owner, plus GitCode `mcpp-res/xpkg` + +**Interfaces:** +- Consumes: verified libxpkg 0.0.46 source archive and SHA256 +- Produces: mcpp-index recipe for Linux/macOS/Windows and byte-identical GLOBAL/CN resources + +- [ ] **Step 1: Add 0.0.46 to all platform matrices** + +Use the public release URL/tag actually produced in Task 4 and one verified SHA256 for the identical source archive. + +- [ ] **Step 2: Run mcpp-index validation** + +Run repository-prescribed Lua/static/index tests and an isolated `mcpp` dependency resolution smoke for `mcpplibs:xpkg@0.0.46`. + +- [ ] **Step 3: Publish missing CN resource** + +If automation has not published GitCode, use the repository-provided `gtc` path with explicit target `mcpp-res/xpkg`, then verify with a ranged GET and compare SHA256 to GLOBAL. + +- [ ] **Step 4: PR, CI, squash merge** + +Create a versioned PR, wait for all required checks, merge with `--squash --admin`, and verify `origin/main`. + +- [ ] **Step 5: Verify mcpp-index artifact** + +Confirm the post-merge artifact/pointer workflow contains the new commit and both public source URLs remain downloadable. + +--- + +### Task 6: Add failing xlings unit and E2E tests + +**Files:** +- Modify: `openxlings/xlings/tests/unit/test_main.cpp` +- Create: `openxlings/xlings/tests/e2e/index_same_name_namespace_test.sh` +- Modify: `openxlings/xlings/tests/e2e/index_cache_test.sh` +- Create fixtures under: `openxlings/xlings/tests/fixtures/index-same-name/` + +**Interfaces:** +- Consumes: libxpkg 0.0.46 identity API +- Produces: observable requirements for explicit lookup, bare ambiguity, search, dependency resolution, duplicate rejection, cache v1 invalidation, cache-context invalidation + +- [ ] **Step 1: Upgrade only the test dependency edge** + +Set xlings `mcpp.toml` xpkg dependency to `0.0.46`, install from the updated mcpp-index, and keep production source unchanged. + +- [ ] **Step 2: Add unit tests** + +Test `IndexManager` and `PackageCatalog` with one repo containing `alpha:demo` and `beta:demo`. Assert explicit success, bare ambiguity, deterministic candidate order, search completeness, project/global precedence, and bare dependency non-inheritance. + +- [ ] **Step 3: Add cache tests** + +Assert cache version 2, both entries, cache hit without rewrite, v1 rejection, corrupt identity rejection, and same HEAD/different default namespace rejection. + +- [ ] **Step 4: Add isolated E2E** + +Run `search`, `info alpha:demo`, `info beta:demo`, bare `info demo`, and interface `plan_install` without downloading package payloads. + +- [ ] **Step 5: Verify RED** + +```bash +mcpp test +XLINGS_BIN=$(find target -path '*/bin/xlings' -type f | head -1) \ + bash tests/e2e/index_same_name_namespace_test.sh +``` + +Expected: production xlings cannot yet produce the required candidate behavior/cache. + +--- + +### Task 7: Implement xlings identity-aware index, cache, and catalog + +**Files:** +- Modify: `openxlings/xlings/src/core/xim/index.cppm` +- Modify: `openxlings/xlings/src/core/xim/catalog.cppm` +- Modify as required by compile-time consumers: + - `src/core/xim/resolver.cppm` + - `src/core/xim/installer.cppm` + - `src/core/xim/commands.cppm` +- Modify: `openxlings/xlings/src/core/config.cppm` +- Modify: `openxlings/xlings/mcpp.toml` +- Add approved design and this plan under `.agents/docs` / `.agents/plans` + +**Interfaces:** +- Produces: cache v2, default-namespace-aware `IndexManager`, multi-match catalog path, canonical entry-key install state + +- [ ] **Step 1: Pass repo default namespace** + +`PackageCatalog::make_state_` configures each `IndexManager` with `RepoIndexSpec::defaultNamespace`; `rebuild()` calls libxpkg with it. + +- [ ] **Step 2: Implement cache v2** + +Persist `default_namespace`, identity, canonical name, entry key, version and path. Rebuild `identity_entries` and `short_names` on load. Any v1/context/identity mismatch is a cache miss. + +- [ ] **Step 3: Convert single match to candidate vector** + +Replace `build_match_` with `build_matches_`, run alias/version resolution per canonical candidate, and retain current project/global/primary/sub-index precedence. + +- [ ] **Step 4: Update all entry-key consumers** + +Use canonical/entry keys for load, installed state, entry paths, install/uninstall, and recursive dependencies. Do not add same-namespace inheritance for bare dependencies. + +- [ ] **Step 5: Bump xlings** + +Set xpkg dependency to `0.0.46`, `mcpp.toml` package version and `src/core/config.cppm` runtime version to `0.4.69`. + +- [ ] **Step 6: Verify GREEN** + +```bash +mcpp build +mcpp test +XLINGS_BIN=$(find target -path '*/bin/xlings' -type f | head -1) \ + bash tests/e2e/index_same_name_namespace_test.sh +XLINGS_BIN=$(find target -path '*/bin/xlings' -type f | head -1) \ + bash tests/e2e/index_cache_test.sh +``` + +- [ ] **Step 7: Run existing E2E regression set** + +Run every repository-required E2E suite, especially multi-repo, project, subos-xpkg, interface, install/remove, and index artifact tests. + +- [ ] **Step 8: Commit** + +```bash +git add .agents mcpp.toml src tests +git commit -m "fix(xim): preserve namespace index identity (0.4.69)" +``` + +--- + +### Task 8: PR, CI, squash-bypass merge xlings 0.4.69 + +**Files:** +- GitHub PR state only + +- [ ] **Step 1: Push and create versioned PR** + +Title: `fix(xim): preserve namespace index identity (0.4.69)`. Body includes Issue #381, libxpkg 0.0.46, cache v2 migration, test commands, and release intent. + +- [ ] **Step 2: Wait for all required CI** + +Linux, macOS, Windows and relevant E2E checks must pass. Fix failures with additive commits only. + +- [ ] **Step 3: Squash merge with bypass** + +```bash +gh pr merge --squash --delete-branch --admin +``` + +- [ ] **Step 4: Verify merge** + +Confirm the squash commit is on `origin/main`, contains version `0.4.69`, closes #381, and all required checks belong to the merged head. + +--- + +### Task 9: Release xlings 0.4.69 and publish resources + +**Files:** +- GitHub Actions/release state +- `openxlings/xim-pkgindex` xlings descriptor if automation does not update it +- GitHub/GitCode `xlings-res` release assets + +- [ ] **Step 1: Trigger release workflow** + +Use the checked-in `release.yml` on merged main and monitor every platform/package job to terminal success. + +- [ ] **Step 2: Verify GitHub release** + +Check all expected Linux/macOS/Windows assets, sidecars/checksums, version output, archive readability, and commit/tag ancestry. + +- [ ] **Step 3: Verify xlings-res publication** + +Inspect the post-release ecosystem workflow. If GitCode assets are absent or incomplete, follow the checked-in mirror-latest runbook and use local `gtc` only for missing assets. + +- [ ] **Step 4: Update xim-pkgindex if required** + +If automation did not create/merge the `xlings 0.4.69` bump, update the descriptor through its normal PR/CI/squash path. Verify GLOBAL/CN URLs and hashes. + +- [ ] **Step 5: Verify public install metadata** + +Fresh index/artifact pointers must resolve xlings 0.4.69 as latest without relying on a local checkout. + +--- + +### Task 10: Real latest-ecosystem validation and completion audit + +**Files:** +- Create/update: `openxlings/xlings/.agents/docs/2026-07-25-issue381-namespace-index-identity-validation.md` + +- [ ] **Step 1: Bootstrap latest release in isolated home** + +Use a temporary `XLINGS_HOME` and the public quick-install/release path. Confirm `xlings --version` is `0.4.69`. + +- [ ] **Step 2: Validate package ecosystem** + +From public indexes/resources: + +- search/install/use/remove a normal unique package; +- install an mcpp workspace consuming `mcpplibs:xpkg@0.0.46`; +- load one GLOBAL resource and one CN/GitCode resource; +- run the Issue #381 same-repo fixture and verify both explicit identities plus bare ambiguity; +- verify cache v2 cold build and warm reload; +- run `xlings interface plan_install` and confirm canonical identities; +- verify `xlings update` and latest index artifact pointers. + +- [ ] **Step 3: Record evidence** + +Document exact release URLs, PRs, merge commits, workflow run IDs, asset hashes, commands, outputs, and any intentionally skipped platform-local checks. Do not infer three-platform runtime behavior from Linux; use CI artifacts/checks as the evidence for macOS/Windows. + +- [ ] **Step 4: Audit every original requirement** + +Mark each dependency release, index/resource update, xlings implementation, tests, PR, CI, squash bypass merge, release, mirror, and real smoke as proven or incomplete. Do not declare completion with any missing external asset or non-terminal workflow. diff --git a/mcpp.lock b/mcpp.lock index 94aed346..f66350eb 100644 --- a/mcpp.lock +++ b/mcpp.lock @@ -33,7 +33,7 @@ hash = "fnv1a:3465dd0bd5d7aa20" [package."mcpplibs.xpkg"] namespace = "mcpplibs" -version = "0.0.45" -source = "index+mcpplibs@0.0.45" -hash = "fnv1a:33400813b5eea84f" +version = "0.0.46" +source = "index+mcpplibs@0.0.46" +hash = "fnv1a:816ccfc9d544660a" diff --git a/mcpp.toml b/mcpp.toml index 20e22e44..b26de6eb 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "xlings" -version = "0.4.67" +version = "0.4.69" description = "Universal package management infrastructure tool with SubOS isolation" license = "Apache-2.0" repo = "https://github.com/openxlings/xlings" @@ -39,7 +39,7 @@ libarchive = "3.8.7" [dependencies.mcpplibs] cmdline = "0.0.2" -xpkg = "0.0.45" +xpkg = "0.0.46" tinyhttps = "0.2.9" capi.lua = "0.0.3" diff --git a/src/core/config.cppm b/src/core/config.cppm index ae952514..d3e9fc7a 100644 --- a/src/core/config.cppm +++ b/src/core/config.cppm @@ -13,7 +13,7 @@ import xlings.core.xvm.db; namespace xlings { export struct Info { - static constexpr std::string_view VERSION = "0.4.68"; + static constexpr std::string_view VERSION = "0.4.69"; static constexpr std::string_view REPO = "https://github.com/openxlings/xlings"; }; diff --git a/src/core/xim/catalog.cppm b/src/core/xim/catalog.cppm index d46f1abb..1a2e4f9b 100644 --- a/src/core/xim/catalog.cppm +++ b/src/core/xim/catalog.cppm @@ -254,10 +254,20 @@ class PackageCatalog { RepoState state; state.spec = spec; state.index.set_repo_dir(spec.dir); + state.index.set_default_namespace(spec.defaultNamespace); return state; } static std::vector dedupe_matches_(std::vector matches) { + std::ranges::sort(matches, {}, [](const PackageMatch& match) { + return std::tuple { + match.canonicalName, + match.version, + match.scope == PackageScope::Project ? 0 : 1, + match.repoName, + match.rawName, + }; + }); std::vector unique; for (auto& match : matches) { bool seen = false; @@ -272,57 +282,61 @@ class PackageCatalog { return unique; } - static PackageMatch build_match_(RepoState& state, - const detail_::ParsedTarget_& parsed, - const std::string& platform, - bool forSearch = false) { - auto resolved = state.index.resolve(parsed.name); - if (resolved.empty()) { - resolved = parsed.name; - } - - std::optional matched; - if (auto* entry = state.index.find_entry(resolved)) { - matched = entry->name; - } else { - matched = state.index.match_version(resolved); - } - if (!matched) return {}; - - auto pkg = state.index.load_package(*matched); - if (!pkg) return {}; - - auto version = detail_::select_version_(*pkg, platform, parsed.version); - // For search: allow metadata-only packages (no xpm versions) - if (version.empty() && !forSearch) return {}; - - auto ns = pkg->namespace_.empty() ? state.spec.defaultNamespace : pkg->namespace_; - if (parsed.explicitNamespace && parsed.namespaceName != ns) return {}; - - auto* entry = state.index.find_entry(*matched); - if (!entry) return {}; - - PackageMatch match; - match.query = parsed.raw; - match.rawName = *matched; - match.name = pkg->name; - match.version = version; - match.namespaceName = ns; - match.canonicalName = detail_::make_canonical_name_(ns, pkg->name); - match.repoName = state.spec.name; - match.pkgFile = entry->path; - match.scope = state.spec.scope; - match.storeRoot = (state.spec.scope == PackageScope::Project - ? Config::project_data_dir() - : Config::global_data_dir()) / "xpkgs"; - if (!version.empty()) { - auto installDir = match.storeRoot / package_store_name(match.namespaceName, match.name) / match.version; - std::error_code ec; - match.installed = std::filesystem::exists(installDir, ec) - && std::filesystem::is_directory(installDir, ec) - && !std::filesystem::is_empty(installDir, ec); + static std::vector + build_matches_(RepoState& state, + const detail_::ParsedTarget_& parsed, + const std::string& platform, + bool forSearch = false) { + std::optional namespaceName; + if (parsed.explicitNamespace) namespaceName = parsed.namespaceName; + + std::vector matches; + for (auto& candidate : + state.index.find_candidates(parsed.name, namespaceName)) { + auto resolved = state.index.resolve(candidate); + if (resolved.empty()) resolved = candidate; + + std::optional matched; + if (state.index.find_entry(resolved)) { + matched = resolved; + } else { + matched = state.index.match_version(resolved); + } + if (!matched) continue; + + auto* entry = state.index.find_entry(*matched); + if (!entry) continue; + auto pkg = state.index.load_package(*matched); + if (!pkg) continue; + + auto version = detail_::select_version_(*pkg, platform, parsed.version); + if (version.empty() && !forSearch) continue; + + PackageMatch match; + match.query = parsed.raw; + match.rawName = *matched; + match.name = entry->identity.name; + match.version = version; + match.namespaceName = entry->identity.namespaceName; + match.canonicalName = entry->canonicalName; + match.repoName = state.spec.name; + match.pkgFile = entry->path; + match.scope = state.spec.scope; + match.storeRoot = (state.spec.scope == PackageScope::Project + ? Config::project_data_dir() + : Config::global_data_dir()) / "xpkgs"; + if (!version.empty()) { + auto installDir = match.storeRoot + / package_store_name(match.namespaceName, match.name) + / match.version; + std::error_code ec; + match.installed = std::filesystem::exists(installDir, ec) + && std::filesystem::is_directory(installDir, ec) + && !std::filesystem::is_empty(installDir, ec); + } + matches.push_back(std::move(match)); } - return match; + return matches; } std::vector collect_matches_(const std::string& target, @@ -333,12 +347,15 @@ class PackageCatalog { auto collect = [&](std::vector& repos) { for (auto& repo : repos) { - auto match = build_match_(repo, parsed, platform); - if (match.name.empty()) continue; + auto matches = build_matches_(repo, parsed, platform); if (repo.spec.subIndex) { - subMatches.push_back(std::move(match)); + for (auto& match : matches) { + subMatches.push_back(std::move(match)); + } } else { - primaryMatches.push_back(std::move(match)); + for (auto& match : matches) { + primaryMatches.push_back(std::move(match)); + } } } }; @@ -450,11 +467,13 @@ public: auto append = [&](std::vector& repos) { for (auto& repo : repos) { for (auto& raw : repo.index.search(query)) { - auto match = build_match_(repo, detail_::parse_target_(raw), platform, true); - if (match.name.empty()) continue; - auto key = match.canonicalName + "@" + match.version + ":" + match.repoName; - if (seen.insert(key).second) { - results.push_back(std::move(match)); + for (auto& match : build_matches_( + repo, detail_::parse_target_(raw), platform, true)) { + auto key = match.canonicalName + "@" + match.version + + ":" + match.repoName; + if (seen.insert(key).second) { + results.push_back(std::move(match)); + } } } } @@ -462,7 +481,7 @@ public: append(projectRepos_); append(globalRepos_); - return results; + return dedupe_matches_(std::move(results)); } std::expected load_package(const PackageMatch& match) { diff --git a/src/core/xim/index.cppm b/src/core/xim/index.cppm index 740ccc5c..7b100e7f 100644 --- a/src/core/xim/index.cppm +++ b/src/core/xim/index.cppm @@ -14,7 +14,7 @@ namespace xpkg = mcpplibs::xpkg; namespace xlings::xim::cache_detail_ { -constexpr int CACHE_FORMAT_VERSION = 1; +constexpr int CACHE_FORMAT_VERSION = 2; int type_to_int(xpkg::PackageType t) { return static_cast(t); @@ -32,11 +32,18 @@ xpkg::PackageType int_to_type(int v) { bool save_index_cache(const xpkg::PackageIndex& index, const std::filesystem::path& cacheFile, - const std::string& repoHeadHash) { + const std::string& repoHeadHash, + const std::string& defaultNamespace) { try { nlohmann::json entries = nlohmann::json::object(); for (auto& [key, entry] : index.entries) { entries[key] = { + {"identity", { + {"namespace", entry.identity.namespaceName}, + {"name", entry.identity.name} + }}, + {"canonical_name", entry.canonicalName}, + {"entry_key", entry.entryKey}, {"name", entry.name}, {"version", entry.version}, {"path", entry.path.string()}, @@ -54,6 +61,7 @@ bool save_index_cache(const xpkg::PackageIndex& index, nlohmann::json root = { {"version", CACHE_FORMAT_VERSION}, {"repo_head_hash", repoHeadHash}, + {"default_namespace", defaultNamespace}, {"entries", std::move(entries)}, {"mutex_groups", std::move(mutexGroups)} }; @@ -72,7 +80,8 @@ struct CacheResult { }; CacheResult load_index_cache(const std::filesystem::path& cacheFile, - xpkg::PackageIndex& index) { + xpkg::PackageIndex& index, + const std::string& defaultNamespace) { CacheResult result; if (!std::filesystem::exists(cacheFile)) return result; @@ -82,21 +91,68 @@ CacheResult load_index_cache(const std::filesystem::path& cacheFile, if (root.is_discarded() || !root.is_object()) return result; if (root.value("version", 0) != CACHE_FORMAT_VERSION) return result; + if (root.value("default_namespace", std::string{}) != defaultNamespace) { + return result; + } result.repoHeadHash = root.value("repo_head_hash", ""); - if (root.contains("entries") && root["entries"].is_object()) { - for (auto it = root["entries"].begin(); it != root["entries"].end(); ++it) { - auto& val = it.value(); - xpkg::IndexEntry entry; - entry.name = val.value("name", ""); - entry.version = val.value("version", ""); - entry.path = std::filesystem::path(val.value("path", "")); - entry.type = int_to_type(val.value("type", 0)); - entry.description = val.value("description", ""); - entry.ref = val.value("ref", ""); - index.entries[it.key()] = std::move(entry); + if (!root.contains("entries") || !root["entries"].is_object()) { + return result; + } + for (auto it = root["entries"].begin(); it != root["entries"].end(); ++it) { + auto& val = it.value(); + if (!val.is_object() + || !val.contains("identity") + || !val["identity"].is_object()) { + return result; + } + auto& identity = val["identity"]; + if (!identity.contains("namespace") + || !identity["namespace"].is_string() + || !identity.contains("name") + || !identity["name"].is_string() + || !val.contains("canonical_name") + || !val["canonical_name"].is_string() + || !val.contains("entry_key") + || !val["entry_key"].is_string()) { + return result; + } + + xpkg::IndexEntry entry; + entry.identity.namespaceName = identity["namespace"].get(); + entry.identity.name = identity["name"].get(); + entry.canonicalName = val["canonical_name"].get(); + entry.entryKey = val["entry_key"].get(); + entry.name = val.value("name", ""); + entry.version = val.value("version", ""); + entry.path = std::filesystem::path(val.value("path", "")); + entry.type = int_to_type(val.value("type", 0)); + entry.description = val.value("description", ""); + entry.ref = val.value("ref", ""); + + if (entry.identity.name.empty() + || entry.identity.canonical_name() != entry.canonicalName + || entry.entryKey != it.key() + || entry.name != entry.identity.name) { + return result; } + + auto [_, inserted] = index.entries.emplace(entry.entryKey, entry); + if (!inserted) return result; + index.identityEntries[entry.canonicalName].push_back(entry.entryKey); + index.shortNames[entry.identity.name].push_back(entry.canonicalName); + } + + for (auto& [_, candidates] : index.identityEntries) { + std::ranges::sort(candidates); + auto uniqueEnd = std::ranges::unique(candidates).begin(); + candidates.erase(uniqueEnd, candidates.end()); + } + for (auto& [_, candidates] : index.shortNames) { + std::ranges::sort(candidates); + auto uniqueEnd = std::ranges::unique(candidates).begin(); + candidates.erase(uniqueEnd, candidates.end()); } if (root.contains("mutex_groups") && root["mutex_groups"].is_object()) { @@ -123,18 +179,25 @@ export namespace xlings::xim { class IndexManager { xpkg::PackageIndex index_; std::filesystem::path repoDir_; + std::string defaultNamespace_; bool loaded_ { false }; public: IndexManager() = default; - explicit IndexManager(const std::filesystem::path& repoDir) - : repoDir_(repoDir) {} + explicit IndexManager(const std::filesystem::path& repoDir, + std::string defaultNamespace = {}) + : repoDir_(repoDir), + defaultNamespace_(std::move(defaultNamespace)) {} void set_repo_dir(const std::filesystem::path& dir) { repoDir_ = dir; } + void set_default_namespace(std::string defaultNamespace) { + defaultNamespace_ = std::move(defaultNamespace); + } + // Build index by scanning pkgs/ directory via libxpkg std::expected rebuild() { namespace fs = std::filesystem; @@ -150,7 +213,7 @@ public: log::debug("building package index from {}", repoDir_.string()); - auto result = xpkg::build_index(repoDir_); + auto result = xpkg::build_index(repoDir_, defaultNamespace_); if (!result) { return std::unexpected( std::format("build_index failed: {}", result.error())); @@ -176,7 +239,8 @@ public: // Try loading from cache (unless forced or no git hash) if (!forceRebuild && !repoHeadHash.empty()) { xpkg::PackageIndex cached; - auto cacheResult = cache_detail_::load_index_cache(cacheFile, cached); + auto cacheResult = cache_detail_::load_index_cache( + cacheFile, cached, defaultNamespace_); if (cacheResult.valid && cacheResult.repoHeadHash == repoHeadHash) { index_ = std::move(cached); loaded_ = true; @@ -190,7 +254,8 @@ public: if (!result) return result; // Save cache (best effort) - if (!cache_detail_::save_index_cache(index_, cacheFile, repoHeadHash)) { + if (!cache_detail_::save_index_cache( + index_, cacheFile, repoHeadHash, defaultNamespace_)) { log::warn("failed to save index cache for {}", repoDir_.string()); } @@ -205,14 +270,31 @@ public: return xpkg::search(index_, keyword); } + std::vector + find_candidates( + std::string_view name, + std::optional namespaceName = std::nullopt) const { + return xpkg::find_candidates(index_, name, namespaceName); + } + // Match a version query like "gcc@15" to best version "gcc@15.1.0" std::optional match_version(const std::string& name) const { - return xpkg::match_version(index_, name); + if (name.contains(':') || index_.entries.contains(name)) { + return xpkg::match_version(index_, name); + } + auto candidates = find_candidates(name); + if (candidates.size() != 1) return std::nullopt; + return xpkg::match_version(index_, candidates.front()); } // Resolve an alias (e.g., "c" -> "gcc") std::string resolve(const std::string& name) const { - return xpkg::resolve(index_, name); + if (name.contains(':') || index_.entries.contains(name)) { + return xpkg::resolve(index_, name); + } + auto candidates = find_candidates(name); + if (candidates.size() != 1) return name; + return xpkg::resolve(index_, candidates.front()); } // Get mutex group packages for conflict detection diff --git a/tests/e2e/index_cache_test.sh b/tests/e2e/index_cache_test.sh index eb3474a5..b906bf5f 100755 --- a/tests/e2e/index_cache_test.sh +++ b/tests/e2e/index_cache_test.sh @@ -62,7 +62,12 @@ log "PASS: sub-repo cache file exists" # ── 4. Verify cache contains valid JSON with expected fields ── grep -q '"repo_head_hash"' "$MAIN_CACHE" || fail "cache missing repo_head_hash field" grep -q '"entries"' "$MAIN_CACHE" || fail "cache missing entries field" -grep -q '"version"' "$MAIN_CACHE" || fail "cache missing version field" +grep -q '"version":2' "$MAIN_CACHE" || fail "cache is not format v2" +grep -q '"default_namespace":"xim"' "$MAIN_CACHE" \ + || fail "cache missing main repo namespace context" +grep -q '"identity"' "$MAIN_CACHE" || fail "cache missing identity metadata" +grep -q '"canonical_name"' "$MAIN_CACHE" || fail "cache missing canonical names" +grep -q '"entry_key"' "$MAIN_CACHE" || fail "cache missing entry keys" log "PASS: cache file has expected structure" # ── 5. Record cache mtime, run search, verify cache was NOT rewritten ── diff --git a/tests/e2e/index_same_name_namespace_test.sh b/tests/e2e/index_same_name_namespace_test.sh new file mode 100755 index 00000000..a0695196 --- /dev/null +++ b/tests/e2e/index_same_name_namespace_test.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/project_test_lib.sh" + +RUNTIME_DIR="$ROOT_DIR/tests/e2e/runtime/index_same_name_namespace" +HOME_DIR="$RUNTIME_DIR/home" +SOURCE_INDEX_DIR="$ROOT_DIR/tests/fixtures/index-same-name" +INDEX_DIR="$RUNTIME_DIR/index" +DUPLICATE_SOURCE_DIR="$ROOT_DIR/tests/fixtures/index-duplicate" +DUPLICATE_INDEX_DIR="$RUNTIME_DIR/duplicate-index" +DUPLICATE_HOME_DIR="$RUNTIME_DIR/duplicate-home" + +cleanup() { + rm -rf "$RUNTIME_DIR" +} +trap cleanup EXIT +cleanup + +mkdir -p "$INDEX_DIR" +cp -R "$SOURCE_INDEX_DIR/." "$INDEX_DIR/" +(cd "$INDEX_DIR" && git init -q && git add -A && git commit -q -m "init") + +write_home_config "$HOME_DIR" "GLOBAL" "$INDEX_DIR" +mkdir -p "$HOME_DIR/data/xim-index-repos" +printf '{}\n' > "$HOME_DIR/data/xim-index-repos/xim-indexrepos.json" + +log "search exposes both canonical identities" +SEARCH_OUT="$(run_xlings "$HOME_DIR" "$ROOT_DIR" search demo 2>&1)" +assert_contains "$SEARCH_OUT" "alpha:demo" "search missing alpha:demo" +assert_contains "$SEARCH_OUT" "beta:demo" "search missing beta:demo" + +INDEX_CACHE="$INDEX_DIR/.xlings-index-cache.json" +[[ -f "$INDEX_CACHE" ]] || fail "namespace index cache was not created" +grep -q '"version":2' "$INDEX_CACHE" || fail "namespace cache is not v2" +grep -q '"default_namespace":"xim"' "$INDEX_CACHE" \ + || fail "namespace cache missing default namespace context" +grep -q '"alpha:demo"' "$INDEX_CACHE" || fail "cache missing alpha:demo" +grep -q '"beta:demo"' "$INDEX_CACHE" || fail "cache missing beta:demo" +grep -q '"canonical_name":"alpha:demo"' "$INDEX_CACHE" \ + || fail "cache missing alpha canonical identity" + +log "valid v2 cache is reused without rewrite" +CACHE_MTIME_BEFORE="$(stat -c %Y "$INDEX_CACHE" 2>/dev/null || stat -f %m "$INDEX_CACHE")" +sleep 1 +run_xlings "$HOME_DIR" "$ROOT_DIR" search demo >/dev/null 2>&1 +CACHE_MTIME_AFTER="$(stat -c %Y "$INDEX_CACHE" 2>/dev/null || stat -f %m "$INDEX_CACHE")" +[[ "$CACHE_MTIME_BEFORE" == "$CACHE_MTIME_AFTER" ]] \ + || fail "valid v2 cache was unexpectedly rewritten" + +log "v1 cache is rejected and rebuilt as v2" +printf '{"version":1,"repo_head_hash":"stale","entries":{}}\n' > "$INDEX_CACHE" +run_xlings "$HOME_DIR" "$ROOT_DIR" search demo >/dev/null 2>&1 +grep -q '"version":2' "$INDEX_CACHE" || fail "v1 cache was not rebuilt as v2" + +log "corrupt identity cache fails closed to rebuild" +sed '0,/"namespace":"alpha"/s//"namespace":"corrupt"/' \ + "$INDEX_CACHE" > "$INDEX_CACHE.corrupt" +mv "$INDEX_CACHE.corrupt" "$INDEX_CACHE" +CORRUPT_RECOVERY="$(run_xlings "$HOME_DIR" "$ROOT_DIR" info alpha:demo 2>&1)" +assert_contains "$CORRUPT_RECOVERY" "Alpha namespace demo package" \ + "corrupt identity cache did not rebuild" +grep -q '"namespace":"alpha"' "$INDEX_CACHE" \ + || fail "rebuilt cache did not restore alpha identity" +if grep -q '"namespace":"corrupt"' "$INDEX_CACHE"; then + fail "corrupt identity survived cache validation" +fi + +log "explicit identities resolve independently" +ALPHA_INFO="$(run_xlings "$HOME_DIR" "$ROOT_DIR" info alpha:demo 2>&1)" +BETA_INFO="$(run_xlings "$HOME_DIR" "$ROOT_DIR" info beta:demo 2>&1)" +assert_contains "$ALPHA_INFO" "Alpha namespace demo package" \ + "alpha:demo resolved to the wrong descriptor" +assert_contains "$BETA_INFO" "Beta namespace demo package" \ + "beta:demo resolved to the wrong descriptor" + +log "bare name fails with stable ambiguity candidates" +set +e +BARE_INFO="$(run_xlings "$HOME_DIR" "$ROOT_DIR" info demo 2>&1)" +BARE_RC=$? +set -e +[[ "$BARE_RC" -ne 0 ]] || fail "bare demo unexpectedly resolved" +assert_contains "$BARE_INFO" "package 'demo' is ambiguous" \ + "bare demo did not report ambiguity" +assert_contains "$BARE_INFO" "1. alpha:demo@1.0.0" \ + "alpha:demo is not the first stable candidate" +assert_contains "$BARE_INFO" "2. beta:demo@1.0.0" \ + "beta:demo is not the second stable candidate" + +log "explicit dependency preserves canonical identity" +EXPLICIT_PLAN="$(run_xlings "$HOME_DIR" "$ROOT_DIR" interface plan_install \ + --args '{"targets":["alpha:explicit-consumer"]}' 2>&1)" +assert_contains "$EXPLICIT_PLAN" '"alpha:demo@1.0.0"' \ + "explicit dependency did not resolve alpha:demo" +assert_contains "$EXPLICIT_PLAN" '"alpha:explicit-consumer@1.0.0"' \ + "explicit consumer missing from plan" + +log "bare dependency does not inherit the declaring namespace" +set +e +BARE_PLAN="$(run_xlings "$HOME_DIR" "$ROOT_DIR" interface plan_install \ + --args '{"targets":["alpha:bare-consumer"]}' 2>&1)" +BARE_PLAN_RC=$? +set -e +[[ "$BARE_PLAN_RC" -ne 0 ]] \ + || fail "bare dependency unexpectedly inherited namespace alpha" +assert_contains "$BARE_PLAN" "package 'demo' is ambiguous" \ + "bare dependency did not preserve catalog ambiguity" +assert_contains "$BARE_PLAN" "alpha:demo@1.0.0" \ + "bare dependency error missing alpha candidate" +assert_contains "$BARE_PLAN" "beta:demo@1.0.0" \ + "bare dependency error missing beta candidate" + +log "duplicate effective identity fails with both descriptor paths" +mkdir -p "$DUPLICATE_INDEX_DIR" +cp -R "$DUPLICATE_SOURCE_DIR/." "$DUPLICATE_INDEX_DIR/" +(cd "$DUPLICATE_INDEX_DIR" && git init -q && git add -A && git commit -q -m "init") +write_home_config "$DUPLICATE_HOME_DIR" "GLOBAL" "$DUPLICATE_INDEX_DIR" +mkdir -p "$DUPLICATE_HOME_DIR/data/xim-index-repos" +printf '{}\n' > "$DUPLICATE_HOME_DIR/data/xim-index-repos/xim-indexrepos.json" +set +e +DUPLICATE_OUT="$(run_xlings "$DUPLICATE_HOME_DIR" "$ROOT_DIR" search demo 2>&1)" +DUPLICATE_RC=$? +set -e +[[ "$DUPLICATE_RC" -ne 0 ]] || fail "duplicate xim:demo unexpectedly built" +assert_contains "$DUPLICATE_OUT" "duplicate package identity 'xim:demo'" \ + "duplicate identity error missing canonical identity" +assert_contains "$DUPLICATE_OUT" "implicit.demo.lua" \ + "duplicate identity error missing implicit descriptor path" +assert_contains "$DUPLICATE_OUT" "explicit.demo.lua" \ + "duplicate identity error missing explicit descriptor path" + +log "same HEAD with a different default namespace invalidates cache" +ln -s "$INDEX_DIR" "$HOME_DIR/data/other" +write_home_config "$HOME_DIR" "GLOBAL" "$INDEX_DIR" "other" +run_xlings "$HOME_DIR" "$ROOT_DIR" search demo >/dev/null 2>&1 +grep -q '"default_namespace":"other"' "$INDEX_CACHE" \ + || fail "cache context did not follow the changed default namespace" + +log "PASS: same-name namespace identity e2e" diff --git a/tests/e2e/project_test_lib.sh b/tests/e2e/project_test_lib.sh index f938a04a..0269b0f5 100644 --- a/tests/e2e/project_test_lib.sh +++ b/tests/e2e/project_test_lib.sh @@ -64,6 +64,7 @@ write_home_config() { local home_dir="$1" local mirror="${2:-GLOBAL}" local index_dir="${3:-$FIXTURE_INDEX_DIR}" + local index_name="${4:-xim}" mkdir -p "$home_dir" mkdir -p "$home_dir/subos/default/bin" cp "$(find_xlings_bin)" "$home_dir/xlings" @@ -72,7 +73,7 @@ write_home_config() { "mirror": "$mirror", "index_repos": [ { - "name": "xim", + "name": "$index_name", "url": "$index_dir" } ] diff --git a/tests/e2e/run_all.sh b/tests/e2e/run_all.sh index d7613f35..6844f0aa 100755 --- a/tests/e2e/run_all.sh +++ b/tests/e2e/run_all.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# tests/e2e/run_all.sh — run the release-artifact E2E block (E2E-02..E2E-28) +# tests/e2e/run_all.sh — run the release-artifact E2E block (E2E-02..E2E-31) # with per-test timing + a slowest-first summary, mirroring mcpp's runner. # # Usage: bash tests/e2e/run_all.sh @@ -80,6 +80,7 @@ TESTS=( "E2E-28 |shim_owner_anchoring_test.sh||" "E2E-29 |interface_multi_repo_error_visibility_test.sh||" "E2E-30 |custom_index_artifact_test.sh||" + "E2E-31 |index_same_name_namespace_test.sh||" ) PASS=0; FAIL=0; SOFTFAIL=0 diff --git a/tests/fixtures/index-duplicate/pkgs/a/implicit.demo.lua b/tests/fixtures/index-duplicate/pkgs/a/implicit.demo.lua new file mode 100644 index 00000000..97c50fed --- /dev/null +++ b/tests/fixtures/index-duplicate/pkgs/a/implicit.demo.lua @@ -0,0 +1,20 @@ +package = { + spec = "1", + name = "demo", + description = "Implicit default namespace demo package", + type = "package", + xpm = { + linux = { + ["latest"] = { ref = "1.0.0" }, + ["1.0.0"] = {}, + }, + macosx = { + ["latest"] = { ref = "1.0.0" }, + ["1.0.0"] = {}, + }, + windows = { + ["latest"] = { ref = "1.0.0" }, + ["1.0.0"] = {}, + }, + }, +} diff --git a/tests/fixtures/index-duplicate/pkgs/b/explicit.demo.lua b/tests/fixtures/index-duplicate/pkgs/b/explicit.demo.lua new file mode 100644 index 00000000..4da40378 --- /dev/null +++ b/tests/fixtures/index-duplicate/pkgs/b/explicit.demo.lua @@ -0,0 +1,21 @@ +package = { + spec = "1", + namespace = "xim", + name = "demo", + description = "Explicit default namespace demo package", + type = "package", + xpm = { + linux = { + ["latest"] = { ref = "1.0.0" }, + ["1.0.0"] = {}, + }, + macosx = { + ["latest"] = { ref = "1.0.0" }, + ["1.0.0"] = {}, + }, + windows = { + ["latest"] = { ref = "1.0.0" }, + ["1.0.0"] = {}, + }, + }, +} diff --git a/tests/fixtures/index-duplicate/xim-indexrepos.lua b/tests/fixtures/index-duplicate/xim-indexrepos.lua new file mode 100644 index 00000000..7938697b --- /dev/null +++ b/tests/fixtures/index-duplicate/xim-indexrepos.lua @@ -0,0 +1 @@ +xim_indexrepos = {} diff --git a/tests/fixtures/index-same-name/pkgs/a/alpha.demo.lua b/tests/fixtures/index-same-name/pkgs/a/alpha.demo.lua new file mode 100644 index 00000000..99fbe699 --- /dev/null +++ b/tests/fixtures/index-same-name/pkgs/a/alpha.demo.lua @@ -0,0 +1,21 @@ +package = { + spec = "1", + namespace = "alpha", + name = "demo", + description = "Alpha namespace demo package", + type = "package", + xpm = { + linux = { + ["latest"] = { ref = "1.0.0" }, + ["1.0.0"] = {}, + }, + macosx = { + ["latest"] = { ref = "1.0.0" }, + ["1.0.0"] = {}, + }, + windows = { + ["latest"] = { ref = "1.0.0" }, + ["1.0.0"] = {}, + }, + }, +} diff --git a/tests/fixtures/index-same-name/pkgs/b/beta.demo.lua b/tests/fixtures/index-same-name/pkgs/b/beta.demo.lua new file mode 100644 index 00000000..b4fa8161 --- /dev/null +++ b/tests/fixtures/index-same-name/pkgs/b/beta.demo.lua @@ -0,0 +1,21 @@ +package = { + spec = "1", + namespace = "beta", + name = "demo", + description = "Beta namespace demo package", + type = "package", + xpm = { + linux = { + ["latest"] = { ref = "1.0.0" }, + ["1.0.0"] = {}, + }, + macosx = { + ["latest"] = { ref = "1.0.0" }, + ["1.0.0"] = {}, + }, + windows = { + ["latest"] = { ref = "1.0.0" }, + ["1.0.0"] = {}, + }, + }, +} diff --git a/tests/fixtures/index-same-name/pkgs/c/alpha.bare-consumer.lua b/tests/fixtures/index-same-name/pkgs/c/alpha.bare-consumer.lua new file mode 100644 index 00000000..0acbd374 --- /dev/null +++ b/tests/fixtures/index-same-name/pkgs/c/alpha.bare-consumer.lua @@ -0,0 +1,24 @@ +package = { + spec = "1", + namespace = "alpha", + name = "bare-consumer", + description = "Consumer with an intentionally bare dependency", + type = "package", + xpm = { + linux = { + deps = { "demo" }, + ["latest"] = { ref = "1.0.0" }, + ["1.0.0"] = {}, + }, + macosx = { + deps = { "demo" }, + ["latest"] = { ref = "1.0.0" }, + ["1.0.0"] = {}, + }, + windows = { + deps = { "demo" }, + ["latest"] = { ref = "1.0.0" }, + ["1.0.0"] = {}, + }, + }, +} diff --git a/tests/fixtures/index-same-name/pkgs/c/alpha.explicit-consumer.lua b/tests/fixtures/index-same-name/pkgs/c/alpha.explicit-consumer.lua new file mode 100644 index 00000000..1dfd5dc7 --- /dev/null +++ b/tests/fixtures/index-same-name/pkgs/c/alpha.explicit-consumer.lua @@ -0,0 +1,24 @@ +package = { + spec = "1", + namespace = "alpha", + name = "explicit-consumer", + description = "Consumer with an explicit namespace dependency", + type = "package", + xpm = { + linux = { + deps = { "alpha:demo" }, + ["latest"] = { ref = "1.0.0" }, + ["1.0.0"] = {}, + }, + macosx = { + deps = { "alpha:demo" }, + ["latest"] = { ref = "1.0.0" }, + ["1.0.0"] = {}, + }, + windows = { + deps = { "alpha:demo" }, + ["latest"] = { ref = "1.0.0" }, + ["1.0.0"] = {}, + }, + }, +} diff --git a/tests/fixtures/index-same-name/xim-indexrepos.lua b/tests/fixtures/index-same-name/xim-indexrepos.lua new file mode 100644 index 00000000..7938697b --- /dev/null +++ b/tests/fixtures/index-same-name/xim-indexrepos.lua @@ -0,0 +1 @@ +xim_indexrepos = {} diff --git a/tests/unit/test_main.cpp b/tests/unit/test_main.cpp index f6ccd248..d6a377d6 100644 --- a/tests/unit/test_main.cpp +++ b/tests/unit/test_main.cpp @@ -86,6 +86,22 @@ std::optional find_pkgindex_repo() { return std::nullopt; } +std::optional find_fixture_repo(std::string_view name) { + namespace fs = std::filesystem; + + const std::vector candidates = { + fs::current_path() / "tests/fixtures" / name, + fs::current_path() / "../../tests/fixtures" / name, + }; + for (auto& path : candidates) { + std::error_code ec; + if (fs::exists(path / "pkgs", ec)) { + return fs::weakly_canonical(path, ec); + } + } + return std::nullopt; +} + } // namespace // ============================================================ @@ -798,6 +814,54 @@ TEST_F(XimIndexTest, NonexistentRepoDirFails) { EXPECT_FALSE(result.has_value()); } +TEST(XimNamespaceIndexTest, PreservesSameNameCandidatesAndCanonicalOperations) { + auto fixture = find_fixture_repo("index-same-name"); + ASSERT_TRUE(fixture.has_value()); + + xlings::xim::IndexManager mgr(*fixture, "fixture-default"); + auto result = mgr.rebuild(); + ASSERT_TRUE(result.has_value()) << result.error(); + + EXPECT_EQ( + mgr.find_candidates("demo"), + (std::vector { "alpha:demo", "beta:demo" })); + EXPECT_EQ( + mgr.find_candidates("demo", std::string_view { "alpha" }), + (std::vector { "alpha:demo" })); + + auto* alphaEntry = mgr.find_entry("alpha:demo"); + auto* betaEntry = mgr.find_entry("beta:demo"); + ASSERT_NE(alphaEntry, nullptr); + ASSERT_NE(betaEntry, nullptr); + EXPECT_EQ(alphaEntry->identity.namespaceName, "alpha"); + EXPECT_EQ(betaEntry->identity.namespaceName, "beta"); + + auto alphaPackage = mgr.load_package("alpha:demo"); + auto betaPackage = mgr.load_package("beta:demo"); + ASSERT_TRUE(alphaPackage.has_value()) << alphaPackage.error(); + ASSERT_TRUE(betaPackage.has_value()) << betaPackage.error(); + EXPECT_EQ(alphaPackage->description, "Alpha namespace demo package"); + EXPECT_EQ(betaPackage->description, "Beta namespace demo package"); + + mgr.mark_installed("alpha:demo", true); + EXPECT_TRUE(mgr.find_entry("alpha:demo")->installed); + EXPECT_FALSE(mgr.find_entry("beta:demo")->installed); + EXPECT_EQ(mgr.entry_path("alpha:demo"), alphaEntry->path); +} + +TEST(XimNamespaceIndexTest, RejectsDuplicateEffectiveIdentityWithBothPaths) { + auto fixture = find_fixture_repo("index-duplicate"); + ASSERT_TRUE(fixture.has_value()); + + xlings::xim::IndexManager mgr(*fixture, "xim"); + auto result = mgr.rebuild(); + ASSERT_FALSE(result.has_value()); + EXPECT_NE(result.error().find("duplicate package identity 'xim:demo'"), + std::string::npos); + EXPECT_NE(result.error().find("implicit.demo.lua"), std::string::npos); + EXPECT_NE(result.error().find("explicit.demo.lua"), std::string::npos); +} + // ============================================================ // xim resolver tests // ============================================================