From b7184a2510de2371c288c6fd162dbeb18e3d3893 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Fri, 26 Jun 2026 21:32:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(pm):=20identity-first=20candidate=20selecti?= =?UTF-8?q?on=20=E2=80=94=20resolve=20custom-ns=20pkgs=20filed=20under=20n?= =?UTF-8?q?on-canonical=20filenames=20(v0.0.67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dependency with a namespace prefix whose package declares the split form (`name="tensorvia-cpu"`, `namespace="aimol"`) and is filed under a non-canonical filename in a shared index (`pkgs/t/tensorvia-cpu.lua`, not the canonical `pkgs/a/aimol.tensorvia-cpu.lua`) failed to resolve: error: dependency 'aimol.tensorvia-cpu': index entry not found in local clone while the bare form `tensorvia-cpu` resolved and installed correctly. Root cause: `selectDependencyCandidate` disambiguated the dotted-selector candidate ladder [(mcpplibs.aimol, tensorvia-cpu), (aimol, tensorvia-cpu)] by probing whether each candidate's CANONICAL FILENAME `..lua` exists on disk. The descriptor's bytes live under a non-canonical name, so the correct peer-root candidate (aimol, tensorvia-cpu) was invisible, the request stayed pinned to the wrong front candidate (mcpplibs.aimol, …), and the load-path identity gate rejected it. Fix: candidate selection is now identity-first. It locates each candidate via the same identity-verified readers the load path uses (`read_xpkg_lua*`), which key on the descriptor's DECLARED (ns, name) and already cover non-canonical filenames — so selection and loading can never disagree. The canonical-filename-only strict reader (`readStrictLuaFromPkgsDir` / `canonicalXpkgLuaFilename`) is removed; the filename is no longer an identity key. This is Step 0 of the identity-first architecture; the §5 PackageLocator/IdentityIndex choke-point consolidation remains follow-up. Design + research + the complete (ns,name) matching-rules table + test-coverage-gap analysis: .agents/docs/2026-06-26-identity-first-resolution-no-filename.md Tests: - unit PmPackageFetcher.ResolvesCustomNamespaceDescriptorUnderNonCanonicalFilename (locks the read-layer identity-not-filename invariant) - e2e 76_qualified_custom_ns_noncanonical_filename.sh (the full repro: builtin index × non-canonical filename × qualified request; asserts resolve + lock ns="aimol" + clean not-found for a genuinely wrong namespace) --- ...6-identity-first-resolution-no-filename.md | 534 ++++++++++++++++++ CHANGELOG.md | 16 + mcpp.toml | 2 +- src/build/prepare.cppm | 75 +-- src/toolchain/fingerprint.cppm | 2 +- ...alified_custom_ns_noncanonical_filename.sh | 133 +++++ tests/unit/test_pm_package_fetcher.cpp | 42 ++ 7 files changed, 747 insertions(+), 57 deletions(-) create mode 100644 .agents/docs/2026-06-26-identity-first-resolution-no-filename.md create mode 100755 tests/e2e/76_qualified_custom_ns_noncanonical_filename.sh 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 new file mode 100644 index 0000000..0e446fe --- /dev/null +++ b/.agents/docs/2026-06-26-identity-first-resolution-no-filename.md @@ -0,0 +1,534 @@ +# Identity-First Package Resolution — Filename Is Not a Key + +**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 +**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), +[`2026-06-02-dotted-dependency-selectors.md`](2026-06-02-dotted-dependency-selectors.md) +**Trigger:** A user's package `aimol.tensorvia-cpu` (`package.name="tensorvia-cpu"`, +`package.namespace="aimol"`, hosted in the `mcpplibs` index) fails to resolve with +`error: dependency 'aimol.tensorvia-cpu': index entry not found in local clone`, +while the **bare** form `tensorvia-cpu` resolves, installs as `aimol-x-tensorvia-cpu`, +and builds. The package is correct; mcpp is wrong. + +--- + +## 一、摘要(Chinese summary) + +**核心论点:包的身份只有 `(pkg.ns, pkg.name)` 两个字段,文件名/目录名不参与身份判定 —— 一个字符都不参与。** + +调查证明:`aimol.tensorvia-cpu` 解析失败**不是包的问题**,而是 mcpp 的「候选消歧」阶段 +用**规范文件名 `..lua` 是否存在**来判定候选,而该描述符以裸文件名 +`tensorvia-cpu.lua` 落盘 —— 于是正确的 peer-root 候选 `(aimol, tensorvia-cpu)` 对消歧器 +**隐形**,请求被钉死在错误的首选候选 `(mcpplibs.aimol, tensorvia-cpu)` 上,再被身份门合理拒绝。 +裸名能过,仅因为它的回退候选命名空间为空(按短名通配),与文件名是否规范无关。 + +这正是 [`2026-06-20`](2026-06-20-package-resolution-architecture.md) 文档点名的 L1 反模式 +(「用文件名当身份证明」),也是它 §10 明确**推迟**的 §5 工作。本文给出**根治架构**(非补丁): +建立**唯一的身份索引 `IdentityIndex: (ns,name) → record`**,由读取每个描述符声明的 +`package.{namespace,name}` 构建;文件名仅作为可选的、永远要被身份校验的加速提示,甚至可整体丢弃而 +解析结果不变。所有 12 处解析点(描述符读取、候选消歧、载荷定位、SemVer、emit、索引缓存键) +统一收口到一个 `PackageLocator`,全部以 `(ns,name)` 为唯一键。 + +--- + +## 二、Research — the investigation, distilled + +### 2.1 The incident & isolated reproduction + +`helloworld/mcpp.toml`: + +```toml +[dependencies] +mcpplibs.cmdline = "0.0.1" +aimol.tensorvia-cpu = "0.1.1" +``` + +Isolated each dependency against the installed `mcpp 0.0.66`: + +| `[dependencies]` entry | result | failing stage | +|---|---|---| +| `tensorvia-cpu = "0.1.1"` (bare) | ✅ resolves, installs `aimol-x-tensorvia-cpu/0.1.1`, builds | — | +| `mcpplibs.cmdline` / `mcpplibs.tinyhttps` | ✅ | — | +| `aimol.tensorvia-cpu = "0.1.1"` | ❌ `index entry not found in local clone` | **candidate selection** | +| `aimol.tensorvia-cpu = "*"` | ❌ error leaks name **`mcpplibs.aimol.tensorvia-cpu`** | candidate selection (kept wrong front) | +| `mcpplibs.tensorvia-cpu = "0.1.1"` | ❌ `index entry not found` | load identity-gate (`mcpplibs ≠ aimol`; correct rejection) | + +Two facts immediately exonerate the package and the namespace: + +- It is **not** the `aimol` prefix — `mcpplibs.tensorvia-cpu` fails too, and + `mcpplibs.cmdline` (same `mcpplibs` index) works. +- The identity `(aimol, tensorvia-cpu)` is **honored end-to-end on the install + side**: the bare form installs to `xpkgs/aimol-x-tensorvia-cpu/0.1.1/`. The + payload layer reads the descriptor's `namespace="aimol"` correctly. Only the + *qualified-request descriptor read* is broken. + +### 2.2 The descriptors + +```lua +-- mcpplibs/pkgs/c/cmdline.lua (works) +package = { namespace = "mcpplibs", name = "mcpplibs.cmdline", ... } -- FQN in name + +-- mcpplibs/pkgs/t/tensorvia-cpu.lua (fails on qualified request) +package = { namespace = "aimol", name = "tensorvia-cpu", ... } -- split form +``` + +Both forms are **explicitly legal** per the canonical model +([`2026-06-20`](2026-06-20-package-resolution-architecture.md) §4.2): +`canonical_xpkg_identity` normalizes `(ns="aimol", name="tensorvia-cpu")` → +`(aimol, tensorvia-cpu)`, and `(ns="mcpplibs", name="mcpplibs.cmdline")` → +`(mcpplibs, cmdline)`. The model says these two spellings must be **equivalent**. +They are not, in practice — because of where identity is read from. + +The index cache (`.xlings-index-cache.json`) mirrors the asymmetry, keying by the +declared `name` verbatim: + +| descriptor | `name` | `namespace` | cache key | +|---|---|---|---| +| cmdline | `mcpplibs.cmdline` | `mcpplibs` | `mcpplibs.cmdline` | +| tensorvia-cpu | `tensorvia-cpu` | `aimol` | **`tensorvia-cpu`** ← `aimol` dropped | + +### 2.3 The dotted-selector candidate ladder + +`resolve_dependency_selector` (`src/pm/dependency_selector.cppm:59`) emits ordered +candidates (`kDefaultNamespace = "mcpplibs"`, `dep_spec.cppm:55`): + +| selector | candidates (front → back) | +|---|---| +| `tensorvia-cpu` (1 seg) | `(mcpplibs, tensorvia-cpu)`, **`(∅, tensorvia-cpu)`** | +| `aimol.tensorvia-cpu` (2 seg) | `(mcpplibs.aimol, tensorvia-cpu)`, `(aimol, tensorvia-cpu)` | +| `mcpplibs.cmdline` (front == default) | `(mcpplibs, cmdline)` — single | + +This is the omitted-`mcpplibs`-priority rule from +[`2026-06-02`](2026-06-02-dotted-dependency-selectors.md). It is **correct**: the +peer-root candidate `(aimol, tensorvia-cpu)` is in the list. The bug is in how the +list is **disambiguated**. + +### 2.4 Root cause — candidate selection proves identity by filename existence + +`selectDependencyCandidate` (`src/build/prepare.cppm:997`, active when +`candidates.size() > 1`) picks the first candidate whose descriptor "exists", via +`readStrictLuaForCandidate` → `readStrictLuaFromPkgsDir` (`prepare.cppm:910`): + +```cpp +auto fname = canonicalXpkgLuaFilename(ns, shortName); // "..lua" | ".lua" +auto candidate = pkgsDir / first_letter(fname) / fname; // a single exists() probe +if (!exists(candidate)) return std::nullopt; // ← filename IS the identity test +``` + +`canonicalXpkgLuaFilename` (`prepare.cppm:902`): default-ns → `.lua`, +otherwise `..lua`. The descriptor's *declared* identity is never read +during selection — **only the filename is consulted.** Tracing both cases against +the real on-disk file `t/tensorvia-cpu.lua`: + +**`aimol.tensorvia-cpu` (fails):** +- cand① `(mcpplibs.aimol, tensorvia-cpu)` → probe `m/mcpplibs.aimol.tensorvia-cpu.lua` → absent. +- cand② `(aimol, tensorvia-cpu)` → probe `a/aimol.tensorvia-cpu.lua` → **absent** (file is `t/tensorvia-cpu.lua`). +- No candidate "exists" → `selected` stays at `front()` = cand① `(mcpplibs.aimol, …)`. +- `loadVersionDep` reads cand① via `read_xpkg_lua` (which *does* have a bare-name + fallback), finds `t/tensorvia-cpu.lua`, but the identity gate computes its + identity as `(aimol, tensorvia-cpu)` ≠ requested `(mcpplibs.aimol, tensorvia-cpu)` + → rejected → `luaContent` empty → `prepare.cppm:1263` throws + `index entry not found in local clone`. (With `*`, the SemVer path + `resolver.cppm:96` reports the kept front qname → the leaked + `mcpplibs.aimol.tensorvia-cpu`.) + +**`tensorvia-cpu` (works) — and why:** +- cand① `(mcpplibs, tensorvia-cpu)` → canonical filename = bare `tensorvia-cpu.lua` + → `t/tensorvia-cpu.lua` exists → read → identity `(aimol, …)` ≠ `(mcpplibs, …)` → not matched. +- cand② **`(∅, tensorvia-cpu)`** → canonical filename = bare `tensorvia-cpu.lua` → + exists → read → request ns is **empty** → identity gate takes the *discovery + branch* (`manifest.cppm:1679`: empty request ns ⇒ match by short name alone) → + **matched.** `selected = (∅, tensorvia-cpu)`; load + install succeed. + +The bare form survives by accident: its fallback candidate has an **empty +namespace**, which the canonical-filename happens to spell as the bare filename +*and* which the matcher treats as a name-only wildcard. The qualified form has no +such escape hatch — its peer-root candidate carries a real namespace, so its +canonical filename `..lua` must exist on disk, and here it does not. + +> **The defect, in one line:** candidate selection uses the **filename** as proof of +> identity. A descriptor whose bytes live under a non-canonical filename is invisible +> to any candidate that carries a real namespace — even when its declared +> `(pkg.ns, pkg.name)` is an exact match. + +This is exactly the **L1 anti-pattern** named in +[`2026-06-20`](2026-06-20-package-resolution-architecture.md) §2 ("identity is +inferred from filename instead of read from the file"). That PR (#136) closed L1 at +the **load** reader (`read_xpkg_lua`) but left the **selection** reader +(`readStrictLuaForCandidate`) — and emit, and the cache key — still filename-shaped. +§10 of that doc explicitly defers the cure: *"Identity-indexed slow path + cache +(§5) — replace the fuzzy candidate generators with an `(ns,name)→path` map built +from declared identities."* This document specifies that cure. + +--- + +## 三、Design principles — identity is two fields, nothing else + +> **P0. A package's identity is the 2-tuple `(pkg.ns, pkg.name)` — and nothing +> else.** It is read from the descriptor's declared `package.namespace` + +> `package.name`, normalized by `canonical_xpkg_identity` (§4.2). The filename, the +> first-letter bucket, the directory, and the install-dir name are **byte +> locations**, never identity and never keys. + +The remaining principles follow from P0 and restate +[`2026-06-20`](2026-06-20-package-resolution-architecture.md) §4 with the filename +demoted all the way out of the key space: + +- **P1. One key everywhere.** Selector candidates, descriptor reads, payload + locates, SemVer version lists, emit output, and the index cache all key on the + *normalized* `(ns, name)`. The qualified serialization `ns + "." + name` and the + install dir `-x-.` are *renderings* of that tuple, never independent + keys. +- **P2. Filename is droppable.** Resolution must be 100% correct if every + descriptor file were renamed to a random string. The canonical filename may be + used as a *fast-path hint* to avoid a directory scan, but **every hit — including + the fast-path hit — is identity-verified against the file's declared + `(ns, name)`**, and a miss falls through to an identity scan, never to a list of + guessed alternative filenames. +- **P3. Namespace is total.** A descriptor with no `package.namespace` inherits its + **owning index's** default namespace (§4.1: `xim-pkgindex → xim`, + `mcpplibs → mcpplibs`, custom `[indices]` → its key). There is no empty namespace + in a *resolved* identity. (The selector's `(∅, name)` discovery candidate is an + *ingestion-time* wildcard, not a resolved identity — see §四.4.) +- **P4. One choke point.** All filesystem resolution funnels through a single + `PackageLocator`. No caller runs `directory_iterator`, builds a filename, or + probes `exists()` itself. +- **P5. Deterministic precedence.** When an identity could resolve in multiple + indexes, an explicit ordered precedence decides — never filesystem iteration + order. + +--- + +## 四、Target architecture + +### 4.1 The single source of truth: `IdentityIndex` + +Each index root owns one lazily-built, cached map from **declared identity** to a +located record. It is built by reading **only** each descriptor's `package{}` +header — the filename is never parsed. + +```cpp +struct PackageIdentity { std::string ns; std::string name; }; // normalized (§4.2) + +struct DescriptorRecord { + PackageIdentity identity; // declared + normalized — the key + std::filesystem::path path; // where the bytes happen to live (opaque) + std::string indexName; // owning index (for payload reuse + precedence) +}; + +class IdentityIndex { // one per index root, cached on the root's + // refresh marker (rebuilt only when index changes) +public: + // Built by: for each *.lua under root/pkgs/**, read package{namespace,name}, + // normalize via canonical_xpkg_identity(declaredNs, declaredName, rootDefaultNs), + // insert {identity -> record}. Filename/bucket are NOT inputs. + const DescriptorRecord* get(const PackageIdentity& id) const; // exact (ns,name) lookup +}; +``` + +Collisions (two files in one index normalizing to the same identity) surface as an +insert conflict — reported or precedence-resolved, **never** decided by scan order. +A descriptor named `xyz123.lua` that declares `(aimol, tensorvia-cpu)` **is** +`aimol.tensorvia-cpu`; a file named `tensorvia-cpu.lua` that declares +`(aimol, tensorvia-cpu)` is the *same* entry. The on-disk name is irrelevant. + +### 4.2 The choke point: `PackageLocator` + +```cpp +class PackageLocator { + // Ordered index roots, most-specific first (P5): + // 1. custom [indices] match (findIndexForNs) — scoped to one ns + // 2. builtin precedence: mcpplibs, xim-pkgindex, + // (an explicit sorted vector, NOT directory_iterator order) + std::optional locate(const PackageIdentity& want) const; + std::optional + locatePayload(const DescriptorRecord&, std::string_view version) const; +}; + +std::optional +PackageLocator::locate(const PackageIdentity& want) const { + for (auto& root : orderedIndexRoots(want)) { // deterministic (P5) + // OPTIONAL fast path: canonical filename as a hint, still identity-verified. + if (auto f = root.canonicalProbe(want); // ..lua | .lua + f && declaredIdentity(read(*f)) == want) // P2: verify even the hint + return DescriptorRecord{want, *f, root.name}; + // AUTHORITATIVE path: exact (ns,name) lookup in the identity map (P0/P1). + if (auto* hit = root.identityIndex().get(want)) // no filename guessing (P2) + return *hit; + } + return std::nullopt; +} +``` + +Drop the fast path entirely and `locate` is still 100% correct — only slower. That +is the litmus test for P2. + +### 4.3 Every layer becomes a thin delegate + +The 12 blind-first-hit sites inventoried in +[`2026-06-20`](2026-06-20-package-resolution-architecture.md) §3 all reduce to +`PackageLocator` calls: + +| layer | today | target | +|---|---|---| +| candidate selection (`selectDependencyCandidate`, the bug) | `exists(..lua)` probe | for each candidate `c`: `locate(c)` and verify declared identity == `c`; pick first hit | +| descriptor read (`read_xpkg_lua*` ×3) | candidate-filename scan + gate | `locate(want).content` | +| payload (`install_path*`, `resolve_xpkg_path`, `scan_legacy_install_dirs`) | dir-name guessing | `locatePayload(record, ver)`, reusing the resolved `indexName` | +| SemVer (`resolve_semver`) | `read_xpkg_lua` then list versions | `locate(want)` then list versions | +| **emit** (`pipeline.cppm:89`, `xpkg_emit`) | filename/key = `pkg.name` verbatim | filename/key = canonical render of `(ns,name)`; declare both `namespace` + `name` | +| **index cache key** | declared `name` verbatim | canonical `(ns,name)` (folds `namespace` in) | + +The structural win is unchanged from §5: **descriptor and payload share the +resolved `indexName`, so they can never disagree about which package they found** — +and now selection shares it too, so it can never pick a candidate the loader will +reject. + +### 4.4 Candidate selection, corrected + +The dotted-selector ladder (§2.3) stays exactly as designed — it is an *ordered +list of identities to try*, which is correct. Only the **disambiguator** changes, +from "does the canonical filename exist?" to "does an entry with this declared +identity exist?": + +```cpp +for (auto& c : selector.candidates) { // mcpplibs.aimol/…, then aimol/… + auto want = normalize(c); // §4.2 + if (want.ns.empty()) { // discovery candidate (bare ingestion) + if (auto r = locator.locateByName(want.name)) // name-only, across precedence + { select(r->identity); break; } // resolves to a REAL (ns,name) — P3 + } else if (auto r = locator.locate(want)) { // exact identity lookup — P0 + select(r->identity); break; + } +} +``` + +Re-tracing the bug under this design, with the file still at the +non-canonical `t/tensorvia-cpu.lua`: + +- `aimol.tensorvia-cpu`: cand① `locate(mcpplibs.aimol, tensorvia-cpu)` → + `IdentityIndex` has no such declared identity → miss. cand② + `locate(aimol, tensorvia-cpu)` → the map (built from the descriptor's declared + `namespace="aimol"`, `name="tensorvia-cpu"`) **hits**, regardless of filename → + **selected**. ✔ Fixed. +- `tensorvia-cpu`: cand① `(mcpplibs, tensorvia-cpu)` → miss; cand② `(∅, …)` → + `locateByName("tensorvia-cpu")` → resolves to the real `(aimol, tensorvia-cpu)` + (P3) → selected. ✔ Still works, now for a principled reason. +- `mcpplibs.tensorvia-cpu`: single candidate `(mcpplibs, tensorvia-cpu)` → no + declared `(mcpplibs, tensorvia-cpu)` exists → clean "not found". ✔ Correct + rejection (the package genuinely is not in the `mcpplibs` namespace). + +The discovery candidate `(∅, name)` is the **only** place an empty namespace +appears, and it is resolved to a real `(ns, name)` before anything downstream sees +it (P3). No empty namespace ever reaches the locator's exact path, the lockfile, or +the install layer. + +### 4.5 Emit & cache: stop minting filename-shaped keys + +`mcpp emit xpkg` (`src/publish/pipeline.cppm`) currently derives both the output +filename and the de-facto index key from `pkg.name` verbatim (`pipeline.cppm:89`, +`:159`). Under P1 it must render from the canonical identity: + +- always write **both** `package.namespace` and `package.name` (short form), +- choose the file path as the canonical render `pkgs//..lua` + *as a convention only* — never as a key, +- key the index/cache on canonical `(ns, name)`, so the split form + (`name=tensorvia-cpu`, `namespace=aimol`) and the FQN form + (`name=aimol.tensorvia-cpu`) produce **identical** keys and identical resolution. + +This makes the producer (emit), the catalog (cache), and the consumer (locate) +agree on one key. It also means a non-canonically-named legacy descriptor still +resolves (P2) while new emits self-heal toward the canonical layout. + +### 4.6 The complete matching-rules table + +Identity has exactly two fields. Everything a user writes is a *selector* that +expands to an ordered list of candidate identities; every candidate resolves by one +rule. This table is the single normative reference. + +**(a) What a user may write in `mcpp.toml` → ordered candidates** + +`resolve_dependency_selector` (`src/pm/dependency_selector.cppm:59`), +`kDefaultNamespace = "mcpplibs"`. `∅` = empty namespace (discovery wildcard, never a +resolved identity — P3). + +| # | user-writable form | example | ordered candidates `(ns, name)` | notes | +|---|---|---|---|---| +| 1 | bare, 1 segment | `cmdline = "…"` | `(mcpplibs, cmdline)` → `(∅, cmdline)` | mcpplibs first, then peer-root discovery | +| 2 | dotted, front ≠ `mcpplibs` | `aimol.tensorvia-cpu = "…"` | `(mcpplibs.aimol, tensorvia-cpu)` → `(aimol, tensorvia-cpu)` | **the incident**; peer-root is 2nd | +| 3 | dotted, front = `mcpplibs` | `mcpplibs.cmdline = "…"` | `(mcpplibs, cmdline)` | explicit prefix; single candidate | +| 4 | deep dotted | `imgui.backend.glfw = "…"` | `(mcpplibs.imgui.backend, glfw)` → `(imgui.backend, glfw)` | split on **last** dot | +| 5 | explicit subtable | `[dependencies.compat]`
`gtest = "…"` | `(compat, gtest)` | subtable root is authoritative; **no** mcpplibs priority | +| 6 | explicit default subtable | `[dependencies.mcpplibs]`
`cmdline = "…"` | `(mcpplibs, cmdline)` | — | +| 7 | quoted legacy dotted | `"aimol.tensorvia-cpu" = "…"` | same as #2 | compat input, not preferred spelling | +| 8 | path / git inline | `x = { path = "…" }` | — | bypasses the index entirely | + +**(b) What a package may declare in `package{}` → canonical `(ns, name)`** + +`canonical_xpkg_identity(declaredNs, declaredName, owningIndexNs)` (§4.2, +`manifest.cppm:1628`). The filename is **not** an input. + +| declared `namespace` | declared `name` | owning index | canonical `(ns, name)` | +|---|---|---|---| +| `aimol` | `tensorvia-cpu` | mcpplibs | **`(aimol, tensorvia-cpu)`** ← the incident package | +| `mcpplibs` | `cmdline` | mcpplibs | `(mcpplibs, cmdline)` | +| `mcpplibs` | `mcpplibs.cmdline` | mcpplibs | `(mcpplibs, cmdline)` (prefix-embedded == bare) | +| `compat` | `compat.zlib` | mcpplibs | `(compat, zlib)` | +| *(none)* | `zlib` | xim-pkgindex | `(xim, zlib)` (index-owned ns) | +| *(none)* | `tinycfg` | `local-dev` | `(local-dev, tinycfg)` | +| `a.b` | `c` | — | `(a.b, c)` | +| *(none)* | `a.b.c` | — | `(a.b, c)` | + +Equivalence (must all resolve identically): `(a.b, c)` ≡ declared `(a, b.c)` ≡ +declared `(∅, a.b.c)` in the `a.b`-owned index. The user's point, encoded. + +**(c) Resolving one candidate against one declared identity** + +| 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)` | + +**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 +the index** (`IdentityIndex.get`) — *not* "a file with the candidate's canonical name +exists on disk." That one-word difference (declared-identity vs filename) is the +entire bug and the entire fix. + +Worked, for `aimol.tensorvia-cpu` (file at the non-canonical `pkgs/t/tensorvia-cpu.lua`): +cand① `(mcpplibs.aimol, tensorvia-cpu)` → no such declared identity → skip; cand② +`(aimol, tensorvia-cpu)` → declared identity exists (read from the descriptor header, +filename irrelevant) → **selected**. Under today's filename-probe selection, cand②'s +canonical file `a/aimol.tensorvia-cpu.lua` is absent, so cand② is skipped and cand① +is wrongly kept — the failure. + +--- + +## 五、Why this is the architecture, not a patch + +- **A patch** would special-case the failing shape — e.g. add `tensorvia-cpu.lua` + as another guessed filename in the strict reader, or rename the file in the + index. That re-encodes "filename is identity" and breaks again on the next + package whose bytes don't sit at the guessed path. +- **This design deletes the premise.** Identity is `(pkg.ns, pkg.name)`, read from + the file's declared header; the filename is demoted to an optional, always-verified + accelerator that can be removed without changing any result. Selection, load, + payload, SemVer, emit, and cache converge on one `(ns,name)` key through one + `PackageLocator`. The whole class of "same bytes, different result" / "works bare, + fails qualified" / "works in index A, not index B" defects dissolves, because none + of them can be expressed once the filename is not a key. + +It is also **convergence, not invention**: `canonical_xpkg_identity` +(`manifest.cppm:1628`), the identity gate (`xpkg_lua_identity_matches`), sorted +index dirs, and the scoped index-owned-namespace attribution already exist (#136, +`443b7d3`, `c55efbd`). This document finishes wiring them into the **selection**, +**payload**, **emit**, and **cache** layers that #136 left filename-shaped, behind +the §5 `PackageLocator` choke point. + +--- + +## 六、Migration plan (incremental, behavior-preserving) + +- **Step 0 — Hotfix (unblock the user today).** In `selectDependencyCandidate`, + replace the `readStrictLuaForCandidate` canonical-filename probe with an + identity-verified read: for each candidate, read the descriptor by *any* filename + under the relevant index and accept on declared-identity equality (reuse + `read_xpkg_lua` + `xpkg_lua_identity_matches`, which already exist). Minimal, + surgical, closes the `aimol.tensorvia-cpu` case. Add e2e: a descriptor filed under + a **non-canonical** name resolving for a **qualified custom-ns** request. +- **Step 1 — Extract `IdentityIndex`.** Build the `(ns,name)→record` map per index + root from declared headers; cache on the existing refresh marker. Make + `read_xpkg_lua*` thin delegates. Behavior-preserving. +- **Step 2 — `PackageLocator` choke point.** Route all 12 sites (selection, reads, + payload, SemVer) through `locate`/`locatePayload`. Remove every caller-side + `directory_iterator` / filename build / `exists()` probe. +- **Step 3 — Emit & cache on canonical identity.** `mcpp emit xpkg` writes both + `namespace`+`name` and keys on canonical `(ns,name)`; index cache key folds + `namespace` in. One-time deprecation warning when a descriptor's filename is + non-canonical, so data migrates. +- **Step 4 — 1.0.0 cleanup.** Delete `xpkg_lua_candidates` / + `install_dir_candidates` fuzzy generators, `scan_legacy_install_dirs`, and the + canonical-filename fast path if profiling allows. Settles the standing + `// remove in 1.0.0` debt. + +--- + +## 七、Test coverage — why it shipped, and what to add + +### 7.1 Why the existing suite was green while the feature was broken + +The dotted-selector feature (#37cbc83) and the identity gate (#136) *did* ship with +tests. They were green because **every fixture sits at its canonical filename and/or +routes through a `[indices]`-scoped index** — i.e. they exercise exactly the paths +that route *around* the bug. + +| existing test | what it covers | why it misses this bug | +|---|---|---| +| `CanonicalIdentity.*` (27 cases, `test_manifest.cpp`) | the `(ns,name)` *normalization* — incl. `BareNameCombinesWithNamespace`, hierarchical, prefix-embedded | tests the pure function; never touches **selection-against-filesystem** | +| `DependencySelector.*` (`test_pm_compat.cpp:50`) | candidate *generation* (the §(a) ladder) | asserts the candidate list is right; never resolves it against descriptors | +| `PmPackageFetcher.ResolvesCompatZlib…` (`test_pm_package_fetcher.cpp`) | identity gate in `read_xpkg_lua*`; cross-index order | fixture `compat.zlib.lua` is at its **canonical** path; never a non-canonical filename | +| `PmPackageFetcher.LocalPathIndex…` | index-owned namespace | `read_xpkg_lua_from_path` (**scoped**), canonical `tinycfg.lua` | +| e2e `62_dotted_dependency_selector_priority` | dotted selector fallback `imgui.core` | `[indices]` **path index** (scoped, not builtin) **and** canonical filename `i/imgui.core.lua` | +| e2e `63_bare_dependency_peer_root_priority` | bare → peer-root `(∅, imgui)` | canonical filename `i/imgui.lua` | + +**The uncovered intersection — the production scenario — is all three at once:** +(1) a **builtin** index (namespace *not* in `[indices]`, so the multi-candidate +`selectDependencyCandidate` path runs), (2) a descriptor filed under a +**non-canonical** filename, (3) a **qualified** request. No unit or e2e test puts +those together, so the canonical-filename-only candidate reader +(`readStrictLuaFromPkgsDir`) was never observed failing. The bug lives precisely in +the seam none of the axes crossed. + +A second structural gap: `selectDependencyCandidate` (and its strict reader) is an +in-`prepare.cppm` lambda with **zero direct unit coverage** — its behavior is only +ever exercised end-to-end, and only on canonical fixtures. + +### 7.2 Required additions (this is the coverage that should have existed) + +**Unit — lock the read-layer invariant (green today; guards against regressing the +already-correct layer):** +- `PmPackageFetcher.ResolvesCustomNamespaceDescriptorUnderNonCanonicalFilename`: + stage a descriptor declaring `(aimol, tensorvia-cpu)` at the **bare** path + `mcpplibs/pkgs/t/tensorvia-cpu.lua`; assert `read_xpkg_lua_from_project_data(…, + "aimol", "tensorvia-cpu")` resolves it. Proves the read layer keys on declared + identity, not filename — scoping the defect to *selection*. *(Added.)* + +**e2e — the real regression (red until Step 0 lands):** +- `tests/e2e/76_qualified_custom_ns_noncanonical_filename.sh`: a **builtin** mcpplibs + index (no `[indices]` entry), a descriptor declaring `(aimol, tensorvia-cpu)` filed + at the non-canonical `pkgs/t/tensorvia-cpu.lua`, requested as + `aimol.tensorvia-cpu`. Asserts `mcpp build` resolves it and the lock records + `namespace = "aimol"`. Also asserts bare `tensorvia-cpu` and rejects + `mcpplibs.tensorvia-cpu` (clean not-found) per §4.4. *(Added, gated behind + `MCPP_E2E_INCLUDE_PENDING=1` until the Step 0 fix lands, then un-gate.)* + +**Future, once the identity-first selector exists (P2 litmus):** +- **Filename-droppability property:** rename every resolver fixture to a random + string; the full suite must stay green. +- **Identity invariant fuzz:** the resolver never returns a record whose declared + `(ns,name)` ≠ the resolved coordinate, over filename/bucket-colliding fixtures. +- **Selector matrix × filename matrix:** every §4.6(a) row against descriptors filed + at canonical *and* arbitrary paths — the cross-product that was missing. +- **Emit round-trip:** `emit xpkg` for split-form and FQN-form inputs yields + byte-identical canonical `(ns,name)` keys and mutually resolvable output. + +--- + +## 八、One-paragraph summary + +`aimol.tensorvia-cpu` fails because mcpp's candidate-selection layer proves a +package's identity by probing whether its **canonical filename** `..lua` +exists, while the descriptor's bytes live under a non-canonical filename — so the +correct peer-root candidate `(aimol, tensorvia-cpu)` is invisible and the request is +pinned to the wrong front candidate `(mcpplibs.aimol, …)` and rejected; the bare form +survives only because its fallback candidate has an empty namespace that the matcher +treats as a name-only wildcard. The fix is not another guessed filename but the +removal of filename from the key space entirely: identity is the 2-tuple +`(pkg.ns, pkg.name)` read from each descriptor's declared header, indexed into one +`(ns,name)→record` `IdentityIndex` per root, served through a single +`PackageLocator` that selection, descriptor read, payload, SemVer, emit, and the +index cache all delegate to — realizing the deferred §5 of the 2026-06-20 +architecture so that resolution is correct even if every descriptor file were +renamed to a random string. diff --git a/CHANGELOG.md b/CHANGELOG.md index c4c5dfb..75f583c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,22 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 +## [0.0.67] — 2026-06-26 + +### 修复 + +- **带命名空间前缀的依赖解析失败 `index entry not found in local clone`(自定义 ns + 非规范文件名)**: + 当一个包以「裸 `name` + 独立 `namespace` 字段」形态声明(如 `aimol.tensorvia-cpu`: + `name="tensorvia-cpu"`、`namespace="aimol"`),并以**非规范文件名**落盘在共享索引里 + (`pkgs/t/tensorvia-cpu.lua` 而非 `pkgs/a/aimol.tensorvia-cpu.lua`)时,限定请求 + `aimol.tensorvia-cpu` 报「索引条目缺失」,而裸名 `tensorvia-cpu` 却能解析。根因是 + **候选消歧 `selectDependencyCandidate` 用「规范文件名 `..lua` 是否存在」当身份 + 判据**——描述符以非规范文件名落盘时,正确的 peer-root 候选 `(aimol, tensorvia-cpu)` 对消歧 + 器隐形,请求被钉死在错误的首选候选 `(mcpplibs.aimol, …)` 上并被身份门拒绝。修复:候选消歧 + 改为**身份优先**,经由加载路径同款的身份校验读取器(`read_xpkg_lua*`)按描述符**声明的 + `(ns, name)`** 定位候选,文件名不再参与身份判定——选择层与加载层从此不可能对同一候选产生 + 分歧。详见 `.agents/docs/2026-06-26-identity-first-resolution-no-filename.md`。 + ## [0.0.66] — 2026-06-26 ### 修复 diff --git a/mcpp.toml b/mcpp.toml index 4c1f21f..1cd12c5 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,6 +1,6 @@ [package] name = "mcpp" -version = "0.0.66" +version = "0.0.67" 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 6a1bf41..c893e02 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -899,32 +899,20 @@ prepare_build(bool print_fingerprint, return nullptr; }; - auto canonicalXpkgLuaFilename = - [](std::string_view ns, std::string_view shortName) { - if (ns.empty() || ns == mcpp::pm::kDefaultNamespace) { - return std::string(shortName) + ".lua"; - } - return std::format("{}.{}.lua", ns, shortName); - }; - - auto readStrictLuaFromPkgsDir = - [&](const std::filesystem::path& pkgsDir, - std::string_view ns, - std::string_view shortName) -> std::optional - { - auto fname = canonicalXpkgLuaFilename(ns, shortName); - if (fname.empty()) return std::nullopt; - char first = static_cast(std::tolower( - static_cast(fname.front()))); - auto candidate = pkgsDir / std::string(1, first) / fname; - if (!std::filesystem::exists(candidate)) return std::nullopt; - - std::ifstream is(candidate); - std::stringstream ss; - ss << is.rdbuf(); - return ss.str(); - }; - + // 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 and already cover non-canonical filenames), so candidate selection + // and loading can never disagree about what a candidate resolves to. + // + // 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 + // peer-root candidate `(aimol, tensorvia-cpu)`, leaving the request pinned to + // the wrong front candidate `(mcpplibs.aimol, …)`. See + // .agents/docs/2026-06-26-identity-first-resolution-no-filename.md. auto readStrictLuaForCandidate = [&](const mcpp::pm::DependencyCoordinate& coord) -> std::optional @@ -935,38 +923,15 @@ prepare_build(bool print_fingerprint, auto* idxSpec = findIndexForNs(coord.namespace_); if (idxSpec && idxSpec->is_local()) { auto indexPath = mcpp::config::resolve_project_index_path(*root, *idxSpec); - return readStrictLuaFromPkgsDir(indexPath / "pkgs", - coord.namespace_, - coord.shortName); + return mcpp::fetcher::Fetcher::read_xpkg_lua_from_path( + indexPath, coord.namespace_, coord.shortName); } if (idxSpec && !idxSpec->is_builtin()) { - std::error_code ec; - for (auto& data : mcpp::config::project_xlings_data_roots(*root)) { - if (!std::filesystem::exists(data)) continue; - for (auto& entry : std::filesystem::directory_iterator(data, ec)) { - if (!entry.is_directory()) continue; - auto pkgsDir = entry.path() / "pkgs"; - if (auto lua = readStrictLuaFromPkgsDir( - pkgsDir, coord.namespace_, coord.shortName)) { - return lua; - } - } - } - return std::nullopt; - } - - auto data = (*cfg)->xlingsHome() / "data"; - if (!std::filesystem::exists(data)) return std::nullopt; - std::error_code ec; - for (auto& entry : std::filesystem::directory_iterator(data, ec)) { - if (!entry.is_directory()) continue; - auto pkgsDir = entry.path() / "pkgs"; - if (auto lua = readStrictLuaFromPkgsDir( - pkgsDir, coord.namespace_, coord.shortName)) { - return lua; - } + return mcpp::fetcher::Fetcher::read_xpkg_lua_from_project_data( + *root, coord.namespace_, coord.shortName); } - return std::nullopt; + mcpp::fetcher::Fetcher fetcher(**cfg); + return fetcher.read_xpkg_lua(coord.namespace_, coord.shortName); }; auto xpkgLuaMatchesCandidate = diff --git a/src/toolchain/fingerprint.cppm b/src/toolchain/fingerprint.cppm index 8a2134f..a8f7968 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.66"; +inline constexpr std::string_view MCPP_VERSION = "0.0.67"; struct FingerprintInputs { Toolchain toolchain; diff --git a/tests/e2e/76_qualified_custom_ns_noncanonical_filename.sh b/tests/e2e/76_qualified_custom_ns_noncanonical_filename.sh new file mode 100755 index 0000000..8096184 --- /dev/null +++ b/tests/e2e/76_qualified_custom_ns_noncanonical_filename.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# requires: gcc fresh-sandbox +# Regression for the identity-first resolution gap +# (.agents/docs/2026-06-26-identity-first-resolution-no-filename.md). +# +# Production trigger: package `aimol.tensorvia-cpu` declares +# namespace = "aimol", name = "tensorvia-cpu" +# and is hosted in the BUILTIN mcpplibs index (NOT a [indices] entry), filed under +# the NON-canonical bare filename `pkgs/t/tensorvia-cpu.lua` (canonical would be +# `pkgs/a/aimol.tensorvia-cpu.lua`). A qualified request `aimol.tensorvia-cpu` +# must resolve by the descriptor's DECLARED (ns, name) — the filename is not a key. +# +# This is the exact intersection the prior suite never crossed at once: +# builtin index × non-canonical filename × qualified multi-candidate request. +# Fixed by making selectDependencyCandidate identity-first (it now locates each +# candidate by the descriptor's declared (ns, name) via read_xpkg_lua* instead of +# probing the canonical filename `..lua`). +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT + +export MCPP_HOME="$TMP/mcpp-home" +source "$(dirname "$0")/_inherit_toolchain.sh" + +INDEX_DIR="$MCPP_HOME/registry/data/mcpplibs" +# Observed real install layout is -x-: aimol-x-tensorvia-cpu. +PKG_ROOT="$MCPP_HOME/registry/data/xpkgs/aimol-x-tensorvia-cpu/0.1.1" +mkdir -p "$INDEX_DIR/pkgs/t" "$PKG_ROOT/src" +printf 'ok\n' > "$INDEX_DIR/.mcpp-index-updated" + +# Custom namespace "aimol", bare name "tensorvia-cpu", filed under the BARE +# filename in the mcpplibs index — filename does not encode the namespace. +cat > "$INDEX_DIR/pkgs/t/tensorvia-cpu.lua" <<'EOF' +package = { + spec = "1", + namespace = "aimol", + name = "tensorvia-cpu", + description = "Custom-namespace package filed under a non-canonical filename", + licenses = {"MIT"}, + type = "package", + xpm = { + linux = { + ["0.1.1"] = { + url = "https://example.invalid/tensorvia-cpu-0.1.1.tar.gz", + sha256 = "0000000000000000000000000000000000000000000000000000000000000000", + }, + }, + }, + mcpp = { + language = "c++23", + import_std = false, + sources = { "src/tensorvia.cppm" }, + targets = { ["tensorvia-cpu"] = { kind = "lib" } }, + deps = {}, + }, +} +EOF + +cat > "$PKG_ROOT/src/tensorvia.cppm" <<'EOF' +export module tensorvia.cpu; + +export int tensorvia_value() { + return 42; +} +EOF +printf 'ok\n' > "$PKG_ROOT/.mcpp_ok" + +mkdir -p "$TMP/project/app/src" +cd "$TMP/project/app" + +cat > src/main.cpp <<'EOF' +import tensorvia.cpu; + +int main() { + return tensorvia_value() == 42 ? 0 : 1; +} +EOF + +cat > mcpp.toml <<'EOF' +[package] +name = "app" +version = "0.1.0" + +[dependencies] +aimol.tensorvia-cpu = "0.1.1" + +[targets.app] +kind = "bin" +main = "src/main.cpp" +EOF + +# (1) Qualified custom-ns request must resolve despite the non-canonical filename. +"$MCPP" build > build.log 2>&1 || { + echo "FAIL: aimol.tensorvia-cpu did not resolve (identity-first regression)" + cat build.log + exit 1 +} +"$MCPP" run > run.log 2>&1 || { cat run.log; exit 1; } + +# (2) The resolved identity must record the declared namespace, not mcpplibs.aimol. +grep -q 'namespace = "aimol"' mcpp.lock || { + cat mcpp.lock + echo "FAIL: lock must record resolved namespace aimol" + exit 1 +} +if grep -q 'mcpplibs.aimol' mcpp.lock; then + cat mcpp.lock + echo "FAIL: front candidate mcpplibs.aimol leaked into the lock" + exit 1 +fi + +# (3) A genuinely wrong namespace must be a clean not-found, not a silent match. +cat > mcpp.toml <<'EOF' +[package] +name = "app" +version = "0.1.0" + +[dependencies] +mcpplibs.tensorvia-cpu = "0.1.1" + +[targets.app] +kind = "bin" +main = "src/main.cpp" +EOF +rm -f mcpp.lock +if "$MCPP" build > wrong.log 2>&1; then + echo "FAIL: mcpplibs.tensorvia-cpu must NOT resolve (package is aimol-namespaced)" + cat wrong.log + exit 1 +fi + +echo "OK" diff --git a/tests/unit/test_pm_package_fetcher.cpp b/tests/unit/test_pm_package_fetcher.cpp index 6b8992e..e28bd1d 100644 --- a/tests/unit/test_pm_package_fetcher.cpp +++ b/tests/unit/test_pm_package_fetcher.cpp @@ -133,3 +133,45 @@ TEST(PmPackageFetcher, LocalPathIndexAttributesOwnNamespaceToNoNsDescriptor) { std::filesystem::remove_all(index); } + +// Coverage gap closed: a custom-namespace descriptor filed under a NON-canonical +// filename must still resolve for a qualified request, because identity is the +// declared (namespace, name) — never the filename (design doc +// 2026-06-26-identity-first-resolution-no-filename.md, P0/P2). +// +// Real-world trigger: `aimol.tensorvia-cpu` declares namespace="aimol", +// name="tensorvia-cpu" but is filed in the mcpplibs index as the BARE +// `pkgs/t/tensorvia-cpu.lua` (canonical would be `pkgs/a/aimol.tensorvia-cpu.lua`). +// Every prior fetcher fixture sat at its canonical path, so this seam was untested. +// `read_xpkg_lua*` already keys on declared identity, so this asserts the READ +// layer is correct and the production failure is isolated to candidate SELECTION +// (`selectDependencyCandidate`'s canonical-filename-only strict reader). +TEST(PmPackageFetcher, ResolvesCustomNamespaceDescriptorUnderNonCanonicalFilename) { + auto project = make_tempdir("mcpp-noncanonical-filename"); + auto dataRoot = project / ".mcpp" / "data"; + + // Declared identity (aimol, tensorvia-cpu), but filed under the bare short + // name in the mcpplibs index — filename does NOT encode the namespace. + write_file(dataRoot / "mcpplibs" / "pkgs" / "t" / "tensorvia-cpu.lua", + R"(package = { + namespace = "aimol", + name = "tensorvia-cpu", + version = "0.1.1", + mcpp = { sources = { "*.cppm" } }, + })"); + + // Qualified request for the custom namespace must resolve, filename be damned. + auto hit = mcpp::pm::Fetcher::read_xpkg_lua_from_project_data( + project, "aimol", "tensorvia-cpu"); + ASSERT_TRUE(hit.has_value()) + << "declared (aimol, tensorvia-cpu) must resolve regardless of filename"; + EXPECT_NE(hit->find("tensorvia-cpu"), std::string::npos); + + // A foreign namespace for the same short name must NOT match it. + auto wrongNs = mcpp::pm::Fetcher::read_xpkg_lua_from_project_data( + project, "mcpplibs", "tensorvia-cpu"); + EXPECT_FALSE(wrongNs.has_value()) + << "the descriptor is (aimol, …), so a (mcpplibs, …) request must miss"; + + std::filesystem::remove_all(project); +}