From 5032fe6ed87eb069aa8209d1e3d402cfa5e8f6e5 Mon Sep 17 00:00:00 2001 From: Zhe Feng Date: Wed, 29 Jul 2026 14:19:05 +0800 Subject: [PATCH 1/2] feat: reject mcpp add for packages not present in the index Validate that the requested package exists in a configured index before mutating mcpp.toml. If the package is not found, print an error and exit without writing the dependency. Also propagate config::load_or_init failures. Update tests/e2e/12_add_command.sh to use real packages and add a regression case for the missing-package scenario. Update 23_remove_update.sh to seed fake dependencies directly in mcpp.toml so remove/update remain tested independently of the add index check. Closes #305 --- src/pm/commands.cppm | 21 ++++++++++++++++ tests/e2e/12_add_command.sh | 47 ++++++++++++++++------------------- tests/e2e/23_remove_update.sh | 14 ++++++----- 3 files changed, 50 insertions(+), 32 deletions(-) diff --git a/src/pm/commands.cppm b/src/pm/commands.cppm index b3e5af9f..6aaed7b6 100644 --- a/src/pm/commands.cppm +++ b/src/pm/commands.cppm @@ -14,6 +14,9 @@ export module mcpp.pm.commands; import std; +import mcpp.config; +import mcpp.fetcher; +import mcpp.fetcher.progress; import mcpp.manifest; // kDefaultNamespace alias import mcpp.lockfile; // load / write (still via shim) import mcpp.project; // shared find_manifest_root @@ -68,6 +71,24 @@ inline int cmd_add(const mcpplibs::cmdline::ParsedArgs& parsed) { return 2; } + // Validate package existence against the configured index before mutating + // mcpp.toml. A missing package is a hard error: we don't want to write an + // invalid dependency that only fails later during build. + { + auto cfg = mcpp::config::load_or_init( + /*quiet=*/false, mcpp::fetcher::make_bootstrap_progress_callback()); + if (!cfg) { + mcpp::ui::error(cfg.error().message); + return 4; + } + mcpp::fetcher::Fetcher f(*cfg); + if (!f.read_xpkg_lua(ns, shortName)) { + mcpp::ui::error(std::format( + "package '{}' not found in any configured index", shortName)); + return 2; + } + } + std::ifstream in(manifestPath); std::stringstream ss; ss << in.rdbuf(); std::string text = ss.str(); diff --git a/tests/e2e/12_add_command.sh b/tests/e2e/12_add_command.sh index 199b4afd..951504e4 100755 --- a/tests/e2e/12_add_command.sh +++ b/tests/e2e/12_add_command.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash # requires: -# `mcpp add` modifies mcpp.toml [dependencies]. Dotted package selectors are -# preserved in the single table; `ns:name` remains the explicit namespace form. +# `mcpp add` modifies mcpp.toml [dependencies]. Default-namespace packages land +# as bare keys under [dependencies]; `ns:name` (non-default ns) uses a subtable. +# Non-existent packages are rejected before mcpp.toml is mutated. set -e TMP=$(mktemp -d) @@ -13,38 +14,26 @@ cd "$TMP" "$MCPP" new myapp > /dev/null cd myapp -# (1) Default-namespace dep: bare name → unquoted key under [dependencies]. -"$MCPP" add somedep@0.1.0 > /dev/null +# (1) Default-namespace dep via `:@` where ns is the default +# (mcpplibs). Since 0.0.10+ default namespace is "mcpplibs", this lands as a +# bare key under [dependencies], NOT in [dependencies.mcpplibs]. +"$MCPP" add mcpplibs:cmdline@0.0.1 > /dev/null grep -qE '^\[dependencies\]' mcpp.toml || { cat mcpp.toml; echo "no [dependencies] section"; exit 1; } -grep -qE '^somedep = "0\.1\.0"$' mcpp.toml || { cat mcpp.toml; echo "somedep entry missing or quoted"; exit 1; } -grep -qE '^"somedep"' mcpp.toml && { cat mcpp.toml; echo "default-ns key should not be quoted"; exit 1; } +grep -qE '^cmdline = "0\.0\.1"$' mcpp.toml || { cat mcpp.toml; echo "cmdline entry missing or wrong version"; exit 1; } +grep -qE '^"cmdline"' mcpp.toml && { cat mcpp.toml; echo "default-ns key should not be quoted"; exit 1; } # (2) Second default-ns dep — append, do not duplicate the section header. -"$MCPP" add another@0.2.0 > /dev/null +"$MCPP" add mcpplibs:templates@0.0.1 > /dev/null header_count=$(grep -cE '^\[dependencies\]$' mcpp.toml) [[ "$header_count" == "1" ]] || { cat mcpp.toml; echo "[dependencies] header duplicated"; exit 1; } -grep -qE '^another = "0\.2\.0"$' mcpp.toml || { cat mcpp.toml; echo "another not set"; exit 1; } - -# (3) Default-ns dep via `:@` where ns is the default (mcpplibs). -# Since 0.0.10+ default namespace is "mcpplibs", this lands as a bare key -# under [dependencies], NOT in [dependencies.mcpplibs]. -"$MCPP" add mcpplibs:cmdline@0.0.2 > /dev/null -grep -qE '^cmdline = "0\.0\.2"$' mcpp.toml || { cat mcpp.toml; echo "cmdline entry missing"; exit 1; } - -# (4) A second default-ns package — also goes under [dependencies]. -"$MCPP" add mcpplibs:templates@0.0.1 > /dev/null -grep -qE '^templates = "0\.0\.1"$' mcpp.toml || { cat mcpp.toml; echo "templates entry missing"; exit 1; } - -# (5) Dotted selector input is preserved under the single [dependencies] table. -"$MCPP" add acme.util@2.0.0 > /dev/null -grep -qE '^acme\.util = "2\.0\.0"$' mcpp.toml || { cat mcpp.toml; echo "acme.util selector entry missing"; exit 1; } +grep -qE '^templates = "0\.0\.1"$' mcpp.toml || { cat mcpp.toml; echo "templates not set"; exit 1; } -# (6) Colon form remains explicit namespace syntax and uses a subtable. +# (3) Colon form remains explicit namespace syntax and uses a subtable. "$MCPP" add compat:gtest@1.15.2 > /dev/null grep -qE '^\[dependencies\.compat\]$' mcpp.toml || { cat mcpp.toml; echo "missing [dependencies.compat] section"; exit 1; } grep -qE '^gtest = "1\.15\.2"$' mcpp.toml || { cat mcpp.toml; echo "gtest entry missing"; exit 1; } -# (7) Dotted remove can still clean the old subtable shape for compatibility. +# (4) Dotted remove can still clean the old subtable shape for compatibility. cat >> mcpp.toml <<'EOF' [dependencies.legacy] @@ -53,11 +42,17 @@ EOF "$MCPP" remove legacy.old > /dev/null ! grep -qE '^old = "0\.1\.0"$' mcpp.toml || { cat mcpp.toml; echo "legacy.old was not removed"; exit 1; } -# (8) Reject missing version. +# (5) Reject missing version. err=$("$MCPP" add bareword 2>&1) && { echo "expected error for missing version"; exit 1; } [[ "$err" == *"version required"* ]] || { echo "wrong error: $err"; exit 1; } -# (9) Reject empty package name (e.g. `mcpp add :foo@1.0`). +# (6) Reject empty package name (e.g. `mcpp add :foo@1.0`). err=$("$MCPP" add ":@1.0" 2>&1) && { echo "expected error for empty package name"; exit 1; } +# (7) Reject non-existent package and do not mutate mcpp.toml. +cp mcpp.toml mcpp.toml.before +err=$("$MCPP" add definitely-not-a-real-package@9.9.9 2>&1) && { echo "expected error for missing package"; exit 1; } +[[ "$err" == *"not found"* ]] || { echo "wrong error: $err"; exit 1; } +diff -q mcpp.toml.before mcpp.toml || { cat mcpp.toml; echo "mcpp.toml mutated for missing package"; exit 1; } + echo "OK" diff --git a/tests/e2e/23_remove_update.sh b/tests/e2e/23_remove_update.sh index 8fd0bca9..4810dbae 100755 --- a/tests/e2e/23_remove_update.sh +++ b/tests/e2e/23_remove_update.sh @@ -11,12 +11,14 @@ cd "$TMP" "$MCPP" new myapp >/dev/null cd myapp -"$MCPP" add foo@1.0.0 >/dev/null -"$MCPP" add bar@2.0.0 >/dev/null -# Default-namespace deps land as unquoted bare keys after the namespace -# refactor; accept either form so this stays robust across versions. -grep -qE '^("foo"|foo) = "1\.0\.0"' mcpp.toml || { cat mcpp.toml; echo "foo not added"; exit 1; } -grep -qE '^("bar"|bar) = "2\.0\.0"' mcpp.toml || { cat mcpp.toml; echo "bar not added"; exit 1; } +# Seed dependencies directly in mcpp.toml (not via `mcpp add`) so this test +# exercises remove/update in isolation regardless of package-index state. +cat >> mcpp.toml <<'EOF' + +[dependencies] +foo = "1.0.0" +bar = "2.0.0" +EOF # remove foo "$MCPP" remove foo > /tmp/_r.log 2>&1 From fabffe3508bca81aa3c828cb180d38998cf6932e Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Wed, 29 Jul 2026 16:37:40 +0800 Subject: [PATCH 2/2] fix: route `mcpp add`'s existence check through the real index resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The check probed `read_xpkg_lua(ns, shortName)` directly — the narrowest lookup in the codebase — so it refused two classes of packages that resolve perfectly well at build time: * dotted selectors. `capi.lua` is a namespace path meaning `(mcpplibs.capi, lua)`, then `(capi, lua)`; nothing in any index is NAMED "capi.lua", because `package.name` is a single atomic segment (SPEC-001 §3.2). The literal probe could never match one, and mcpp's own mcpp.toml is written in that form. * anything served by a project `[indices]` entry. The probe only ever read the global registry, while dependency resolution dispatches across local-path, project-clone and global transports. That routing rule now lives in `mcpp.pm.index_route` and both callers go through it, so `mcpp add` and `mcpp build` cannot drift apart again about which packages are real. `[indices]` workspace inheritance likewise moves to `mcpp.project` so the two readers share one copy. The gate also only refuses when absence was PROVEN. A lazily-cloned git index, or a namespace no readable index covers (xim descriptors declare no `namespace` at all, so `(xim, nasm)` can never satisfy the identity gate), now reports "unverified" instead of rejecting — the same fall-through prepare already applies to lazy git indices. Around that: * refresh a stale registry once before refusing, and only when the registry is the index that would have answered, so a package already on disk costs zero network round-trips; * report the identities tried plus a cross-namespace did-you-mean, matching prepare's resolution error; * warn — never fail — when the requested version isn't published for this platform, listing what is. Version tables are per-OS, so "absent here" is not "absent". e2e 12 serves every package from a local `[indices] path`, so it covers the dotted and project-index routes offline instead of depending on the state of the shared registry, and drops its isolated MCPP_HOME, which would otherwise force a full sandbox bootstrap (xlings, index fetch, patchelf, ninja) on a test that is otherwise local file manipulation. Co-authored-by: speak-agent <248744407+speak-agent@users.noreply.github.com> --- src/build/prepare.cppm | 96 ++++--------- src/pm/commands.cppm | 139 +++++++++++++++++-- src/pm/index_route.cppm | 211 +++++++++++++++++++++++++++++ src/project.cppm | 21 +++ tests/e2e/12_add_command.sh | 158 +++++++++++++++++---- tests/unit/test_pm_index_route.cpp | 120 ++++++++++++++++ 6 files changed, 636 insertions(+), 109 deletions(-) create mode 100644 src/pm/index_route.cppm create mode 100644 tests/unit/test_pm_index_route.cpp diff --git a/src/build/prepare.cppm b/src/build/prepare.cppm index 6f2612c9..cdeedce1 100644 --- a/src/build/prepare.cppm +++ b/src/build/prepare.cppm @@ -39,6 +39,7 @@ import mcpp.fetcher; import mcpp.fetcher.progress; import mcpp.pm.resolver; import mcpp.pm.index_spec; +import mcpp.pm.index_route; import mcpp.pm.mangle; import mcpp.pm.compat; import mcpp.pm.dep_spec; @@ -694,20 +695,10 @@ prepare_build(bool print_fingerprint, m->targetOverrides[triple] = entry; } } - // Inherit workspace indices if member doesn't define any. A - // relative `[indices].path` was declared at the WORKSPACE root, - // so it must resolve against `*root` (still the workspace root - // here), not the member directory — otherwise every member - // needs its own `../`-prefixed copy of the same declaration - // (#224). - if (m->indices.empty() && !wsManifest->indices.empty()) { - m->indices = wsManifest->indices; - for (auto& [_, idx] : m->indices) { - if (idx.is_local() && idx.path.is_relative()) { - idx.path = std::filesystem::weakly_canonical(*root / idx.path); - } - } - } + // Inherit workspace indices if member doesn't define any. `*root` + // is still the workspace root here, which is what a relative + // `[indices].path` was written against (#224). + mcpp::project::inherit_workspace_indices(*m, *wsManifest, *root); mcpp::ui::status("Workspace", std::format("building member '{}'", targetMember)); root = memberDir; @@ -730,14 +721,7 @@ prepare_build(bool print_fingerprint, } } // Inherit workspace indices if member doesn't define any - if (m->indices.empty() && !wsm->indices.empty()) { - m->indices = wsm->indices; - for (auto& [_, idx] : m->indices) { - if (idx.is_local() && idx.path.is_relative()) { - idx.path = std::filesystem::weakly_canonical(wsRoot / idx.path); - } - } - } + mcpp::project::inherit_workspace_indices(*m, *wsm, wsRoot); } } } @@ -1455,28 +1439,19 @@ prepare_build(bool print_fingerprint, // different version is needed. Returns the dep's effective root (where // mcpp.toml lives) and a fully loaded manifest. using LoadedDep = std::pair; - // Helper: find the IndexSpec for a namespace from the manifest's [indices]. - // Returns nullptr if the namespace maps to the default/builtin index. + // Index routing — WHICH index answers for a namespace and how its + // descriptors are read — lives in mcpp.pm.index_route, shared with the + // `mcpp add` existence gate so the two cannot disagree about which + // packages are real (#305/#307). `cfg` is filled in per call: the route is + // rebuilt on demand because `root` moves when a workspace member is + // selected above. + auto index_route = [&](mcpp::config::GlobalConfig* cfg = nullptr) { + return mcpp::pm::IndexRoute{ &m->indices, *root, cfg }; + }; auto findIndexForNs = [&](const std::string& ns) -> const mcpp::pm::IndexSpec* { - if (ns.empty() || ns == std::string(mcpp::pm::kDefaultNamespace)) { - // R6: `[indices] default = {...}` (normalized to - // kDefaultNamespace by toml.cppm) redirects the default - // namespace — return it when present instead of unconditionally - // falling back to the builtin index. - auto it = m->indices.find(std::string(mcpp::pm::kDefaultNamespace)); - return it == m->indices.end() ? nullptr : &it->second; - } - if (auto it = m->indices.find(ns); it != m->indices.end()) { - return &it->second; - } - auto root = ns.substr(0, ns.find('.')); - for (auto& [idxName, spec] : m->indices) { - if (idxName == ns) return &spec; - if (idxName == root) return &spec; - } - return nullptr; + return index_route().find_for_ns(ns); }; // Identity-first candidate probe. A candidate is DISAMBIGUATED by the @@ -1510,19 +1485,7 @@ prepare_build(bool print_fingerprint, { auto cfg = get_cfg(); if (!cfg) return std::nullopt; - - auto* idxSpec = findIndexForNs(coord.namespace_); - if (idxSpec && idxSpec->is_local()) { - auto indexPath = mcpp::config::resolve_project_index_path(*root, *idxSpec); - return mcpp::fetcher::Fetcher::read_xpkg_lua_from_path( - indexPath, coord.namespace_, coord.shortName); - } - if (idxSpec && !idxSpec->is_builtin()) { - return mcpp::fetcher::Fetcher::read_xpkg_lua_from_project_data( - *root, coord.namespace_, coord.shortName); - } - mcpp::fetcher::Fetcher fetcher(**cfg); - return fetcher.read_xpkg_lua(coord.namespace_, coord.shortName); + return index_route(*cfg).read(coord); }; auto xpkgLuaMatchesCandidate = @@ -1613,14 +1576,10 @@ prepare_build(bool print_fingerprint, // package that would have materialized a moment later. Local path // indices and the builtin index are both readable here, so they stay // under the strict rule below. - bool anyLazyGitIndex = false; - for (auto& c : candidates) { - auto* idx = findIndexForNs(c.namespace_); - if (idx && !idx->is_builtin() && !idx->is_local()) { - anyLazyGitIndex = true; - break; - } - } + bool anyLazyGitIndex = std::ranges::any_of(candidates, + [&](const mcpp::pm::DependencyCoordinate& c) { + return index_route().lazy_git(c.namespace_); + }); // T9 (#278) — no candidate resolved. This used to fall through to // `candidates.front()` SILENTLY, so mcpp carried on with a namespace @@ -1641,17 +1600,8 @@ prepare_build(bool print_fingerprint, // error string (see Fetcher::scan_fqns_with_short_name). std::string hint; if (auto cfg = get_cfg()) { - mcpp::fetcher::Fetcher fetcher(**cfg); - auto roots = fetcher.builtin_index_roots(); - for (auto& [idxName, idxSpec] : m->indices) { - if (idxSpec.is_local()) { - roots.push_back( - mcpp::config::resolve_project_index_path( - *root, idxSpec)); - } - } - auto fqns = mcpp::fetcher::Fetcher::scan_fqns_with_short_name( - roots, candidates.front().shortName); + auto fqns = mcpp::pm::cross_namespace_matches( + index_route(*cfg), candidates.front().shortName); if (!fqns.empty()) { hint += "\n a package with this name exists under " "another namespace:"; diff --git a/src/pm/commands.cppm b/src/pm/commands.cppm index 6aaed7b6..1f709daa 100644 --- a/src/pm/commands.cppm +++ b/src/pm/commands.cppm @@ -7,22 +7,68 @@ // into the pm subsystem so `cli.cppm` is responsible only for the // global CLI framework + non-pm commands. // -// Strict zero-behavior-change move: every line below is identical to -// what previously lived in `cli.cppm`, only the surrounding namespace -// has changed. +// The bodies started life as a strict zero-behavior-change move out of +// `cli.cppm`; `cmd_add` has since grown the index existence gate (#305). export module mcpp.pm.commands; import std; import mcpp.config; -import mcpp.fetcher; -import mcpp.fetcher.progress; -import mcpp.manifest; // kDefaultNamespace alias +import mcpp.fetcher.progress; // bootstrap progress for load_or_init +import mcpp.manifest; // kDefaultNamespace alias import mcpp.lockfile; // load / write (still via shim) +import mcpp.platform.axis; // HostPlatform for the published-version check +import mcpp.pm.dep_spec; // DependencyCoordinate +import mcpp.pm.dependency_selector; // same candidates the manifest parser derives +import mcpp.pm.index_route; // shared index routing (with mcpp.build.prepare) +import mcpp.pm.resolver; // is_version_constraint import mcpp.project; // shared find_manifest_root import mcpp.ui; +import mcpp.xlings; // index freshness import mcpplibs.cmdline; +namespace mcpp::pm::commands::detail { + +// Render candidate coordinates the way prepare's resolution error does, so a +// rejected `mcpp add` and a failed `mcpp build` name the same identities. +inline std::string format_tried( + const std::vector& candidates) { + std::string tried; + for (auto& c : candidates) { + if (!tried.empty()) tried += ", "; + tried += c.namespace_.empty() + ? std::format("(no namespace, {})", c.shortName) + : std::format("({}, {})", c.namespace_, c.shortName); + } + return tried; +} + +// A version the descriptor does not publish is worth flagging but not worth +// refusing over: version tables are per-OS, so "absent for this host" is not +// "absent". Say what IS published and let the user decide — the alternative is +// a hard failure on a dependency that resolves fine on the platform it was +// added for. +inline void warn_unpublished_version(std::string_view lua, + std::string_view display, + const std::string& version) { + if (mcpp::pm::is_version_constraint(version)) return; + auto versions = mcpp::manifest::list_xpkg_versions( + lua, mcpp::platform::HostPlatform::current()); + if (versions.empty()) return; + if (std::ranges::find(versions, version) != versions.end()) return; + + std::string avail; + for (auto& v : versions) { + if (!avail.empty()) avail += ", "; + avail += v; + } + mcpp::ui::warning(std::format( + "'{}' has no version {} published for this platform — available: {}", + display, version, avail)); +} + +} // namespace mcpp::pm::commands::detail + export namespace mcpp::pm::commands { inline int cmd_add(const mcpplibs::cmdline::ParsedArgs& parsed) { @@ -71,22 +117,91 @@ inline int cmd_add(const mcpplibs::cmdline::ParsedArgs& parsed) { return 2; } - // Validate package existence against the configured index before mutating - // mcpp.toml. A missing package is a hard error: we don't want to write an - // invalid dependency that only fails later during build. + // ── Existence gate (#305) ────────────────────────────────────────── + // Refuse to write a dependency no index can serve, so a typo fails here + // instead of halfway into the next `mcpp build`. Two rules keep the gate + // from refusing packages that are perfectly real: + // + // • It probes the SAME candidates the manifest parser will derive from + // the key about to be written. A dotted selector is a namespace path, + // not a name: `capi.lua` means `(mcpplibs.capi, lua)` then + // `(capi, lua)`, and a literal `(mcpplibs, "capi.lua")` probe can + // never match one — `package.name` is a single atomic segment + // (SPEC-001 §3.2), so nothing in any index is named "capi.lua". + // • It reads through mcpp.pm.index_route, the same routing + // `mcpp.build.prepare` resolves dependencies with. A package served by + // a project `[indices]` entry therefore counts as present, and a + // namespace no readable index can speak for is reported as unverified + // rather than rejected: refusing an add is only correct when absence + // was actually proven. { + auto selector = explicitNamespace + ? mcpp::pm::make_direct_dependency_selector(ns, shortName, nameSpec) + : mcpp::pm::resolve_dependency_selector( + nameSpec, + mcpp::pm::DependencySelectorMode::OmittedMcpplibsPriority); + auto cfg = mcpp::config::load_or_init( /*quiet=*/false, mcpp::fetcher::make_bootstrap_progress_callback()); if (!cfg) { mcpp::ui::error(cfg.error().message); return 4; } - mcpp::fetcher::Fetcher f(*cfg); - if (!f.read_xpkg_lua(ns, shortName)) { + + auto indices = mcpp::pm::effective_indices(*root); + mcpp::pm::IndexRoute route{ &indices, *root, &*cfg }; + auto found = mcpp::pm::lookup_descriptor(route, selector.candidates); + + // Does the shared registry answer for any identity we tried? It is the + // only index a refresh can do anything about — a project + // `[indices] path = …` is whatever the user has on disk. + const bool registryInvolved = std::ranges::any_of(selector.candidates, + [&](const mcpp::pm::DependencyCoordinate& c) { + auto* idx = route.find_for_ns(c.namespace_); + return !idx || idx->is_builtin(); + }); + + // Only pay for a refresh when the answer was "no" and the registry + // that would have answered is stale — a package that is already on + // disk costs zero network round-trips. + if (!found.hit && found.conclusive && registryInvolved) { + auto xlEnv = mcpp::config::make_xlings_env(*cfg); + if (!mcpp::xlings::is_index_fresh(xlEnv, cfg->searchTtlSeconds)) { + mcpp::ui::status("Updating", "package index (auto-refresh)"); + mcpp::xlings::ensure_index_fresh( + xlEnv, cfg->searchTtlSeconds, /*quiet=*/true); + found = mcpp::pm::lookup_descriptor(route, selector.candidates); + } + } + + if (!found.hit && found.conclusive) { + std::string hint; + if (!selector.candidates.empty()) { + for (auto& fqn : mcpp::pm::cross_namespace_matches( + route, selector.candidates.front().shortName)) { + hint += "\n " + fqn; + } + } + if (!hint.empty()) { + hint = "\n a package with this name exists under another " + "namespace:" + hint; + } + if (registryInvolved) { + hint += "\n hint: `mcpp index update` if it was published " + "recently"; + } mcpp::ui::error(std::format( - "package '{}' not found in any configured index", shortName)); + "package '{}' not found in any configured index\n tried: {}{}", + nameSpec, detail::format_tried(selector.candidates), hint)); return 2; } + if (!found.hit) { + mcpp::ui::warning(std::format( + "'{}' could not be verified — no readable index covers that " + "namespace yet; adding it unchecked", nameSpec)); + } else { + detail::warn_unpublished_version(found.hit->lua, nameSpec, version); + } } std::ifstream in(manifestPath); diff --git a/src/pm/index_route.cppm b/src/pm/index_route.cppm new file mode 100644 index 00000000..8594c43f --- /dev/null +++ b/src/pm/index_route.cppm @@ -0,0 +1,211 @@ +// mcpp.pm.index_route — which index answers for a namespace, how its +// descriptors are read, and when a miss is allowed to mean anything. +// +// Three transports serve xpkg descriptors, and picking the wrong one is +// indistinguishable from "this package does not exist": +// +// [indices] = { path = "…" } → read straight off the filesystem +// [indices] = { url = "…" } → a project-level clone under /.mcpp +// builtin / default namespace → the global registry in /data +// +// `mcpp.build.prepare` has always dispatched on that rule while resolving +// dependencies. `mcpp add` grew a second, narrower existence check that only +// ever consulted the global registry (#305/#307), so a package served by a +// project index read as missing and a perfectly valid dependency was refused. +// Both callers now route through this module: what `mcpp add` accepts and what +// `mcpp build` can resolve cannot drift apart again. +// +// The other half of the contract is `authoritative_for`. Refusing to write a +// dependency is only correct when the index that would serve it is readable +// and did not serve it. A custom git index is cloned lazily during install, so +// while it is still absent it can neither serve nor refute anything; and a +// foreign namespace is outside the identity rules mcpp enforces at all (xim's +// descriptors declare no `namespace` field, so `(xim, nasm)` can never match +// the identity gate no matter how present the index is). In both cases a miss +// is inconclusive and callers must fall through rather than hard-fail — +// exactly the "keep the historical fall-through" rule prepare applies to lazy +// git indices today. + +export module mcpp.pm.index_route; + +import std; +import mcpp.config; +import mcpp.fetcher; +import mcpp.manifest; +import mcpp.pm.dep_spec; +import mcpp.pm.index_spec; +import mcpp.project; + +export namespace mcpp::pm { + +using IndexMap = std::map; + +// A candidate coordinate together with the descriptor that proved it. +struct DescriptorHit { + DependencyCoordinate coord; // the candidate that matched + std::string declaredNs; // descriptor's declared namespace ("" = none) + std::string lua; // descriptor source +}; + +struct IndexRoute { + const IndexMap* indices = nullptr; // manifest [indices] + std::filesystem::path projectRoot; + const mcpp::config::GlobalConfig* cfg = nullptr; // null → global registry unreadable + + // The [indices] entry serving `ns`, or nullptr for the builtin registry. + const IndexSpec* find_for_ns(std::string_view ns) const; + // A custom git index, not yet cloned: readable only after install runs. + bool lazy_git(std::string_view ns) const; + // Can a miss in this namespace be taken as proof of absence? + bool authoritative_for(std::string_view ns) const; + // Read the descriptor for one coordinate through the right transport. + std::optional read(const DependencyCoordinate& coord) const; +}; + +struct Lookup { + std::optional hit; + // false → at least one candidate lives behind an index that cannot refute + // it, so "no hit" says nothing and the caller must not hard-fail. + bool conclusive = true; +}; + +// Walk ordered candidates and return the first descriptor that DECLARES the +// requested identity, applying the same two rules prepare's dependency +// disambiguation applies. +Lookup lookup_descriptor(const IndexRoute& route, + const std::vector& candidates); + +// Diagnostic only: fully-qualified names carrying `shortName` under some other +// namespace, for did-you-mean text on an already-failed lookup. +std::vector cross_namespace_matches(const IndexRoute& route, + std::string_view shortName); + +// The `[indices]` a project at `root` effectively sees, including the +// workspace-root inheritance a member gets for free (#224). +IndexMap effective_indices(const std::filesystem::path& root); + +} // namespace mcpp::pm + +namespace mcpp::pm { + +const IndexSpec* IndexRoute::find_for_ns(std::string_view ns) const { + if (!indices) return nullptr; + if (ns.empty() || ns == kDefaultNamespace) { + // R6: `[indices] default = {...}` (normalized to kDefaultNamespace by + // toml.cppm) redirects the default namespace — return it when present + // instead of unconditionally falling back to the builtin index. + auto it = indices->find(std::string(kDefaultNamespace)); + return it == indices->end() ? nullptr : &it->second; + } + if (auto it = indices->find(std::string(ns)); it != indices->end()) { + return &it->second; + } + auto root = ns.substr(0, ns.find('.')); + for (auto& [idxName, spec] : *indices) { + if (idxName == ns) return &spec; + if (idxName == root) return &spec; + } + return nullptr; +} + +bool IndexRoute::lazy_git(std::string_view ns) const { + auto* idx = find_for_ns(ns); + return idx && !idx->is_builtin() && !idx->is_local(); +} + +bool IndexRoute::authoritative_for(std::string_view ns) const { + if (auto* idx = find_for_ns(ns)) { + // Declared index: readable now (local path / builtin registry) or + // cloned later (git), in which case it proves nothing yet. + return idx->is_local() || idx->is_builtin(); + } + if (!cfg) return false; // global registry not even loaded + // No [indices] entry, so the builtin registry answers. It is authoritative + // for the namespaces whose identity mcpp itself defines — the default + // namespace, its nested children, `compat`, and the namespace-less + // discovery rung. A third-party namespace that merely happens to be filed + // in some index is not something a miss here can refute. + if (ns.empty()) return true; + if (ns == kDefaultNamespace || ns == kCompatNamespace) return true; + return ns.starts_with(std::string(kDefaultNamespace) + "."); +} + +std::optional +IndexRoute::read(const DependencyCoordinate& coord) const { + auto* idx = find_for_ns(coord.namespace_); + if (idx && idx->is_local()) { + return mcpp::fetcher::Fetcher::read_xpkg_lua_from_path( + mcpp::config::resolve_project_index_path(projectRoot, *idx), + coord.namespace_, coord.shortName); + } + if (idx && !idx->is_builtin()) { + return mcpp::fetcher::Fetcher::read_xpkg_lua_from_project_data( + projectRoot, coord.namespace_, coord.shortName); + } + if (!cfg) return std::nullopt; + return mcpp::fetcher::Fetcher(*cfg).read_xpkg_lua( + coord.namespace_, coord.shortName); +} + +Lookup lookup_descriptor(const IndexRoute& route, + const std::vector& candidates) { + Lookup out; + for (auto& candidate : candidates) { + if (!route.authoritative_for(candidate.namespace_)) out.conclusive = false; + + auto lua = route.read(candidate); + if (!lua) continue; + // The descriptor must DECLARE the requested identity. `allowLegacy- + // BareDefault=false` matches prepare's disambiguation: a descriptor + // with no namespace is reached through the explicit `(∅, name)` rung + // below, not by being waved through on the default-namespace rung. + if (!mcpp::manifest::xpkg_lua_identity_matches( + *lua, candidate.namespace_, candidate.shortName, + /*allowLegacyBareDefault=*/false)) { + continue; + } + auto declaredNs = mcpp::manifest::extract_xpkg_namespace(*lua); + // INV-RESOLVE (#278) — `(∅, name)` is the "upstream package that + // declares no namespace" rung, NOT a cross-namespace wildcard. The + // identity gate is deliberately permissive there (`mcpp new --template + // X` discovers by short name alone), so the narrowing lives here. + if (candidate.namespace_.empty() && !declaredNs.empty()) continue; + + out.hit = DescriptorHit{ candidate, std::move(declaredNs), std::move(*lua) }; + break; + } + return out; +} + +std::vector cross_namespace_matches(const IndexRoute& route, + std::string_view shortName) { + if (!route.cfg) return {}; + mcpp::fetcher::Fetcher fetcher(*route.cfg); + auto roots = fetcher.builtin_index_roots(); + if (route.indices) { + for (auto& [_, spec] : *route.indices) { + if (spec.is_local()) { + roots.push_back(mcpp::config::resolve_project_index_path( + route.projectRoot, spec)); + } + } + } + return mcpp::fetcher::Fetcher::scan_fqns_with_short_name(roots, shortName); +} + +IndexMap effective_indices(const std::filesystem::path& root) { + auto m = mcpp::manifest::load(root / "mcpp.toml"); + if (!m) return {}; + if (!m->indices.empty()) return m->indices; + + // A member with no [indices] of its own inherits the workspace root's, + // whose relative `path` is anchored at the ROOT, not the member (#224). + auto wsRoot = mcpp::project::find_workspace_root(root); + if (wsRoot.empty()) return m->indices; + auto ws = mcpp::manifest::load(wsRoot / "mcpp.toml"); + if (!ws || !ws->workspace.present) return m->indices; + mcpp::project::inherit_workspace_indices(*m, *ws, wsRoot); + return m->indices; +} + +} // namespace mcpp::pm diff --git a/src/project.cppm b/src/project.cppm index 48ca2e70..0145f550 100644 --- a/src/project.cppm +++ b/src/project.cppm @@ -101,6 +101,27 @@ export void merge_workspace_deps(mcpp::manifest::Manifest& member, merge_map(member.buildDependencies); } +// Inherit the workspace root's `[indices]` when the member declares none. +// A relative `[indices].path` was written at the WORKSPACE ROOT, so it must +// resolve against `wsRoot` and not the member directory — otherwise every +// member needs its own `../`-prefixed copy of the same declaration (#224). +// +// Shared so every reader of `[indices]` sees the same effective map: the build +// path resolves dependencies through it, and `mcpp add` decides whether a +// package exists through it. Two copies of this rule is how the two ended up +// disagreeing about which packages are real. +export void inherit_workspace_indices(mcpp::manifest::Manifest& member, + const mcpp::manifest::Manifest& workspace, + const std::filesystem::path& wsRoot) { + if (!member.indices.empty() || workspace.indices.empty()) return; + member.indices = workspace.indices; + for (auto& [_, idx] : member.indices) { + if (idx.is_local() && idx.path.is_relative()) { + idx.path = std::filesystem::weakly_canonical(wsRoot / idx.path); + } + } +} + // Resolve which member directory a workspace command acts on, for the // single-member case. Shares the match rule (basename OR member path) with // prepare_build's member switch, so `build -p X` and `test -p X` agree. diff --git a/tests/e2e/12_add_command.sh b/tests/e2e/12_add_command.sh index 951504e4..34ddf32b 100755 --- a/tests/e2e/12_add_command.sh +++ b/tests/e2e/12_add_command.sh @@ -1,39 +1,111 @@ #!/usr/bin/env bash # requires: # `mcpp add` modifies mcpp.toml [dependencies]. Default-namespace packages land -# as bare keys under [dependencies]; `ns:name` (non-default ns) uses a subtable. -# Non-existent packages are rejected before mcpp.toml is mutated. +# as bare keys under [dependencies]; `ns:name` (non-default ns) uses a subtable; +# dotted selectors keep their spelling. A package no readable index can serve is +# refused before mcpp.toml is touched (#305) — but only where absence is +# provable, so the gate resolves the same candidates the manifest parser derives +# and reads through the same index routing `mcpp build` resolves dependencies +# with. +# +# Every package here is served by a project-local `[indices] path = …`, so the +# assertions depend on neither the network nor the state of the shared registry. +# MCPP_HOME is deliberately NOT isolated: an empty home would force a full +# sandbox bootstrap (xlings, index fetch, patchelf, ninja) on a test that is +# otherwise pure local file manipulation. set -e TMP=$(mktemp -d) trap "rm -rf $TMP" EXIT -export MCPP_HOME="$TMP/mcpp-home" - cd "$TMP" "$MCPP" new myapp > /dev/null cd myapp -# (1) Default-namespace dep via `:@` where ns is the default -# (mcpplibs). Since 0.0.10+ default namespace is "mcpplibs", this lands as a -# bare key under [dependencies], NOT in [dependencies.mcpplibs]. -"$MCPP" add mcpplibs:cmdline@0.0.1 > /dev/null -grep -qE '^\[dependencies\]' mcpp.toml || { cat mcpp.toml; echo "no [dependencies] section"; exit 1; } -grep -qE '^cmdline = "0\.0\.1"$' mcpp.toml || { cat mcpp.toml; echo "cmdline entry missing or wrong version"; exit 1; } -grep -qE '^"cmdline"' mcpp.toml && { cat mcpp.toml; echo "default-ns key should not be quoted"; exit 1; } +# A local index carrying three packages: two in the DEFAULT namespace (so bare +# and `mcpplibs:`-qualified adds resolve against it) and one under `acme`. +# Descriptors are filed under the first letter of their FILENAME, and publish +# every OS so the version assertions below hold on all three CI platforms. +mkdir -p index/pkgs/w index/pkgs/g index/pkgs/a +cat > index/pkgs/w/widget.lua <<'EOF' +package = { + spec = "1", + namespace = "mcpplibs", + name = "widget", + description = "default-namespace package served by a local index", + licenses = {"MIT"}, + type = "package", + xpm = { + linux = { ["0.1.0"] = { url = "https://example.invalid/w.tar.gz" } }, + macosx = { ["0.1.0"] = { url = "https://example.invalid/w.tar.gz" } }, + windows = { ["0.1.0"] = { url = "https://example.invalid/w.zip" } }, + }, +} +EOF +cat > index/pkgs/g/gadget.lua <<'EOF' +package = { + spec = "1", + namespace = "mcpplibs", + name = "gadget", + description = "second default-namespace package", + licenses = {"MIT"}, + type = "package", + xpm = { + linux = { ["0.2.0"] = { url = "https://example.invalid/g.tar.gz" } }, + macosx = { ["0.2.0"] = { url = "https://example.invalid/g.tar.gz" } }, + windows = { ["0.2.0"] = { url = "https://example.invalid/g.zip" } }, + }, +} +EOF +cat > index/pkgs/a/acme.util.lua <<'EOF' +package = { + spec = "1", + namespace = "acme", + name = "util", + description = "nested-namespace package served by a local index", + licenses = {"MIT"}, + type = "package", + xpm = { + linux = { ["2.0.0"] = { url = "https://example.invalid/u.tar.gz" } }, + macosx = { ["2.0.0"] = { url = "https://example.invalid/u.tar.gz" } }, + windows = { ["2.0.0"] = { url = "https://example.invalid/u.zip" } }, + }, +} +EOF +cat >> mcpp.toml <<'EOF' + +[indices] +default = { path = "index" } +acme = { path = "index" } +EOF + +# (1) Default-namespace dep: bare name → unquoted key under [dependencies]. +"$MCPP" add widget@0.1.0 > /dev/null +grep -qE '^\[dependencies\]' mcpp.toml || { cat mcpp.toml; echo "no [dependencies] section"; exit 1; } +grep -qE '^widget = "0\.1\.0"$' mcpp.toml || { cat mcpp.toml; echo "widget entry missing or quoted"; exit 1; } +grep -qE '^"widget"' mcpp.toml && { cat mcpp.toml; echo "default-ns key should not be quoted"; exit 1; } -# (2) Second default-ns dep — append, do not duplicate the section header. -"$MCPP" add mcpplibs:templates@0.0.1 > /dev/null +# (2) `:` where ns IS the default (mcpplibs) — still a bare key under +# [dependencies], NOT [dependencies.mcpplibs]. Appends without duplicating the +# section header. +"$MCPP" add mcpplibs:gadget@0.2.0 > /dev/null header_count=$(grep -cE '^\[dependencies\]$' mcpp.toml) [[ "$header_count" == "1" ]] || { cat mcpp.toml; echo "[dependencies] header duplicated"; exit 1; } -grep -qE '^templates = "0\.0\.1"$' mcpp.toml || { cat mcpp.toml; echo "templates not set"; exit 1; } +grep -qE '^gadget = "0\.2\.0"$' mcpp.toml || { cat mcpp.toml; echo "gadget not set"; exit 1; } -# (3) Colon form remains explicit namespace syntax and uses a subtable. -"$MCPP" add compat:gtest@1.15.2 > /dev/null -grep -qE '^\[dependencies\.compat\]$' mcpp.toml || { cat mcpp.toml; echo "missing [dependencies.compat] section"; exit 1; } -grep -qE '^gtest = "1\.15\.2"$' mcpp.toml || { cat mcpp.toml; echo "gtest entry missing"; exit 1; } +# (3) Dotted selector input is preserved under the single [dependencies] table. +# `acme.util` is a NAMESPACE PATH, not a name: it resolves as (acme, util). A +# gate probing the literal short name "acme.util" would refuse this — no +# package.name ever contains a dot (SPEC-001 §3.2). +"$MCPP" add acme.util@2.0.0 > /dev/null +grep -qE '^acme\.util = "2\.0\.0"$' mcpp.toml || { cat mcpp.toml; echo "acme.util selector entry missing"; exit 1; } -# (4) Dotted remove can still clean the old subtable shape for compatibility. +# (4) Colon form remains explicit namespace syntax and uses a subtable. +"$MCPP" add acme:util@2.0.0 > /dev/null +grep -qE '^\[dependencies\.acme\]$' mcpp.toml || { cat mcpp.toml; echo "missing [dependencies.acme] section"; exit 1; } +grep -qE '^util = "2\.0\.0"$' mcpp.toml || { cat mcpp.toml; echo "util entry missing"; exit 1; } + +# (5) Dotted remove can still clean the old subtable shape for compatibility. cat >> mcpp.toml <<'EOF' [dependencies.legacy] @@ -42,17 +114,55 @@ EOF "$MCPP" remove legacy.old > /dev/null ! grep -qE '^old = "0\.1\.0"$' mcpp.toml || { cat mcpp.toml; echo "legacy.old was not removed"; exit 1; } -# (5) Reject missing version. +# (6) Reject missing version. err=$("$MCPP" add bareword 2>&1) && { echo "expected error for missing version"; exit 1; } [[ "$err" == *"version required"* ]] || { echo "wrong error: $err"; exit 1; } -# (6) Reject empty package name (e.g. `mcpp add :foo@1.0`). +# (7) Reject empty package name (e.g. `mcpp add :foo@1.0`). err=$("$MCPP" add ":@1.0" 2>&1) && { echo "expected error for empty package name"; exit 1; } -# (7) Reject non-existent package and do not mutate mcpp.toml. -cp mcpp.toml mcpp.toml.before +# (8) A package the index does not carry is refused, and mcpp.toml is left +# exactly as it was. The error names the identities that were tried. +cp mcpp.toml "$TMP/before" err=$("$MCPP" add definitely-not-a-real-package@9.9.9 2>&1) && { echo "expected error for missing package"; exit 1; } [[ "$err" == *"not found"* ]] || { echo "wrong error: $err"; exit 1; } -diff -q mcpp.toml.before mcpp.toml || { cat mcpp.toml; echo "mcpp.toml mutated for missing package"; exit 1; } +[[ "$err" == *"tried:"* ]] || { echo "error should list the identities tried: $err"; exit 1; } +diff -q "$TMP/before" mcpp.toml || { cat mcpp.toml; echo "mcpp.toml mutated for missing package"; exit 1; } + +# (9) Same for an explicitly-namespaced miss in a readable index. +err=$("$MCPP" add acme:nope@1.0.0 2>&1) && { echo "expected error for missing acme package"; exit 1; } +[[ "$err" == *"(acme, nope)"* ]] || { echo "wrong error: $err"; exit 1; } +diff -q "$TMP/before" mcpp.toml || { cat mcpp.toml; echo "mcpp.toml mutated for missing package"; exit 1; } + +# (10) A namespace no readable index covers cannot be refuted, so the add goes +# through unverified rather than failing. Refusing here would be worse than the +# bug being fixed — `mcpp build` resolves such dependencies fine. +"$MCPP" add unknownidx:thing@1.0.0 > /dev/null +grep -qE '^\[dependencies\.unknownidx\]$' mcpp.toml || { cat mcpp.toml; echo "unverifiable namespace should still be added"; exit 1; } + +# (11) A real package asked for at a version it does not publish is a warning, +# not a refusal — version tables are per-OS, so "absent here" is not "absent". +out=$("$MCPP" add widget@9.9.9 2>&1) +[[ "$out" == *"available: 0.1.0"* ]] || { echo "expected available-versions warning: $out"; exit 1; } +grep -qE '^widget = "9\.9\.9"$' mcpp.toml || { cat mcpp.toml; echo "widget@9.9.9 should still be written"; exit 1; } + +# (12) A workspace MEMBER inherits the root's [indices], so adding inside it +# sees the same packages `mcpp build` would resolve from there (#224). +mkdir -p "$TMP/ws" +cd "$TMP/ws" +cp -r "$TMP/myapp/index" index +cat > mcpp.toml <<'EOF' +[workspace] +members = ["m1"] + +[indices] +acme = { path = "index" } +EOF +"$MCPP" new m1 > /dev/null +cd m1 +"$MCPP" add acme:util@2.0.0 > /dev/null +grep -qE '^util = "2\.0\.0"$' mcpp.toml || { cat mcpp.toml; echo "member should inherit workspace [indices]"; exit 1; } +err=$("$MCPP" add acme:nope@1.0.0 2>&1) && { echo "expected error inside workspace member"; exit 1; } +[[ "$err" == *"(acme, nope)"* ]] || { echo "wrong error: $err"; exit 1; } echo "OK" diff --git a/tests/unit/test_pm_index_route.cpp b/tests/unit/test_pm_index_route.cpp new file mode 100644 index 00000000..fb5b773b --- /dev/null +++ b/tests/unit/test_pm_index_route.cpp @@ -0,0 +1,120 @@ +#include + +import std; +import mcpp.pm.dep_spec; +import mcpp.pm.dependency_selector; +import mcpp.pm.index_route; +import mcpp.pm.index_spec; + +namespace { + +struct LocalIndex { + std::filesystem::path root; + + explicit LocalIndex(std::string_view name) { + root = std::filesystem::temp_directory_path() + / std::format("mcpp-index-route-{}", name); + std::filesystem::remove_all(root); + std::filesystem::create_directories(root / "pkgs" / "a"); + std::ofstream(root / "pkgs" / "a" / "acme.util.lua") << R"( +package = { + spec = "1", + namespace = "acme", + name = "util", + type = "package", +} +)"; + } + ~LocalIndex() { std::error_code ec; std::filesystem::remove_all(root, ec); } +}; + +mcpp::pm::IndexMap local_map(const LocalIndex& idx) { + mcpp::pm::IndexMap indices; + indices["acme"] = mcpp::pm::IndexSpec{ .name = "acme", .path = idx.root }; + return indices; +} + +} // namespace + +TEST(PmIndexRoute, FindForNsResolvesNestedNamespaceToItsRootIndex) { + mcpp::pm::IndexMap indices; + indices["acme"] = mcpp::pm::IndexSpec{ .name = "acme", .url = "https://x/y" }; + mcpp::pm::IndexRoute route{ &indices, "/nowhere", nullptr }; + + EXPECT_NE(route.find_for_ns("acme"), nullptr); + EXPECT_NE(route.find_for_ns("acme.nested"), nullptr); + EXPECT_EQ(route.find_for_ns("other"), nullptr); +} + +TEST(PmIndexRoute, LazyGitIndexCannotRefuteAMiss) { + mcpp::pm::IndexMap indices; + indices["acme"] = mcpp::pm::IndexSpec{ .name = "acme", .url = "https://x/y" }; + mcpp::pm::IndexRoute route{ &indices, "/nowhere", nullptr }; + + EXPECT_TRUE(route.lazy_git("acme")); + EXPECT_FALSE(route.authoritative_for("acme")); +} + +TEST(PmIndexRoute, LocalPathIndexIsAuthoritative) { + LocalIndex idx("authoritative"); + auto indices = local_map(idx); + mcpp::pm::IndexRoute route{ &indices, "/nowhere", nullptr }; + + EXPECT_FALSE(route.lazy_git("acme")); + EXPECT_TRUE(route.authoritative_for("acme")); +} + +// The regression behind #305/#307: a dotted selector is a NAMESPACE PATH, so it +// only resolves through the candidates the manifest parser derives. Probing the +// literal short name can never match — `package.name` is a single atomic +// segment, so nothing in any index is named "acme.util". +TEST(PmIndexRoute, DottedSelectorResolvesThroughItsCandidates) { + LocalIndex idx("dotted"); + auto indices = local_map(idx); + mcpp::pm::IndexRoute route{ &indices, "/nowhere", nullptr }; + + auto selector = mcpp::pm::resolve_dependency_selector( + "acme.util", mcpp::pm::DependencySelectorMode::OmittedMcpplibsPriority); + auto found = mcpp::pm::lookup_descriptor(route, selector.candidates); + + ASSERT_TRUE(found.hit.has_value()); + EXPECT_EQ(found.hit->coord.namespace_, "acme"); + EXPECT_EQ(found.hit->coord.shortName, "util"); +} + +TEST(PmIndexRoute, LiteralDottedShortNameNeverMatches) { + LocalIndex idx("literal"); + auto indices = local_map(idx); + mcpp::pm::IndexRoute route{ &indices, "/nowhere", nullptr }; + + std::vector literal{ + { .namespace_ = "mcpplibs", .shortName = "acme.util" }, + }; + EXPECT_FALSE(mcpp::pm::lookup_descriptor(route, literal).hit.has_value()); +} + +TEST(PmIndexRoute, MissInAReadableIndexIsConclusive) { + LocalIndex idx("conclusive"); + auto indices = local_map(idx); + mcpp::pm::IndexRoute route{ &indices, "/nowhere", nullptr }; + + std::vector missing{ + { .namespace_ = "acme", .shortName = "nope" }, + }; + auto found = mcpp::pm::lookup_descriptor(route, missing); + EXPECT_FALSE(found.hit.has_value()); + EXPECT_TRUE(found.conclusive); +} + +TEST(PmIndexRoute, MissBehindAnUnreadableIndexIsNotConclusive) { + mcpp::pm::IndexMap indices; + indices["acme"] = mcpp::pm::IndexSpec{ .name = "acme", .url = "https://x/y" }; + mcpp::pm::IndexRoute route{ &indices, "/nowhere", nullptr }; + + std::vector missing{ + { .namespace_ = "acme", .shortName = "nope" }, + }; + auto found = mcpp::pm::lookup_descriptor(route, missing); + EXPECT_FALSE(found.hit.has_value()); + EXPECT_FALSE(found.conclusive); +}