Skip to content

Commit 413b8d4

Browse files
fix: resolve SemVer constraints through the index route, not the registry (#309)
`resolve_semver` / `try_merge_semver` took a `Fetcher&` and called `read_xpkg_lua` on it, which only ever reads the shared registry under `<XLINGS_HOME>/data`. Candidate selection, meanwhile, dispatches across all three descriptor transports (local path index, project clone, registry). The split meant a package served by a project `[indices]` entry resolved fine as an exact version and failed the moment the same dependency carried a constraint: dependency 'acme.util' has SemVer constraint '^2.0' but the index entry isn't cloned locally yet — run `mcpp index update` first — advice that does nothing for a local path index, leaving no way forward. Both functions now take `const IndexRoute&`, so the descriptor is reached the same way everywhere. `mcpp new --template` passes a route with no project indices, which is what it always meant: there is no project yet. The not-found message drops the `mcpp index update` hint when a local path index is what would have answered. `prepare.cppm`'s `index_route` lambda moves above `resolveSemver` so the resolver call can use it; nothing else about it changes. Closes #308 Co-authored-by: sunrisepeak <speakshen@163.com>
1 parent 9959be5 commit 413b8d4

4 files changed

Lines changed: 213 additions & 40 deletions

File tree

src/build/prepare.cppm

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1411,6 +1411,21 @@ prepare_build(bool print_fingerprint,
14111411
};
14121412
std::deque<WorkItem> worklist;
14131413

1414+
// Index routing — WHICH index answers for a namespace and how its
1415+
// descriptors are read — lives in mcpp.pm.index_route, shared with the
1416+
// `mcpp add` existence gate so the two cannot disagree about which
1417+
// packages are real (#305/#307). `cfg` is filled in per call: the route is
1418+
// rebuilt on demand because `root` moves when a workspace member is
1419+
// selected above.
1420+
auto index_route = [&](mcpp::config::GlobalConfig* cfg = nullptr) {
1421+
return mcpp::pm::IndexRoute{ &m->indices, *root, cfg };
1422+
};
1423+
auto findIndexForNs = [&](const std::string& ns)
1424+
-> const mcpp::pm::IndexSpec*
1425+
{
1426+
return index_route().find_for_ns(ns);
1427+
};
1428+
14141429
// SemVer constraint resolver, shared across the worklist so transitive
14151430
// deps with caret/range constraints (`^1.0`) also get pinned to a
14161431
// concrete version before fetch.
@@ -1422,11 +1437,12 @@ prepare_build(bool print_fingerprint,
14221437
if (!mcpp::pm::is_version_constraint(s.version)) return {};
14231438
auto cfg = get_cfg();
14241439
if (!cfg) return std::unexpected(cfg.error());
1425-
mcpp::fetcher::Fetcher fetcher(**cfg);
1426-
// 0.0.10+: use structured namespace from DependencySpec.
1440+
// 0.0.10+: use structured namespace from DependencySpec. The route (not
1441+
// a bare Fetcher) is what reaches a descriptor served by a project
1442+
// `[indices]` entry — see #308.
14271443
auto resolved = mcpp::pm::resolve_semver(
14281444
s.namespace_, s.shortName.empty() ? depName : s.shortName,
1429-
s.version, fetcher, targetPlatform);
1445+
s.version, index_route(*cfg), targetPlatform);
14301446
if (!resolved) return std::unexpected(resolved.error());
14311447
mcpp::ui::info("Resolved",
14321448
std::format("{} {} → v{}", depName, s.version, *resolved));
@@ -1439,21 +1455,6 @@ prepare_build(bool print_fingerprint,
14391455
// different version is needed. Returns the dep's effective root (where
14401456
// mcpp.toml lives) and a fully loaded manifest.
14411457
using LoadedDep = std::pair<std::filesystem::path, mcpp::manifest::Manifest>;
1442-
// Index routing — WHICH index answers for a namespace and how its
1443-
// descriptors are read — lives in mcpp.pm.index_route, shared with the
1444-
// `mcpp add` existence gate so the two cannot disagree about which
1445-
// packages are real (#305/#307). `cfg` is filled in per call: the route is
1446-
// rebuilt on demand because `root` moves when a workspace member is
1447-
// selected above.
1448-
auto index_route = [&](mcpp::config::GlobalConfig* cfg = nullptr) {
1449-
return mcpp::pm::IndexRoute{ &m->indices, *root, cfg };
1450-
};
1451-
auto findIndexForNs = [&](const std::string& ns)
1452-
-> const mcpp::pm::IndexSpec*
1453-
{
1454-
return index_route().find_for_ns(ns);
1455-
};
1456-
14571458
// Identity-first candidate probe. A candidate is DISAMBIGUATED by the
14581459
// DECLARED (namespace, name) of whatever descriptor the index holds — never
14591460
// by whether a canonically-named file `<ns>.<short>.lua` happens to exist on
@@ -2568,13 +2569,12 @@ prepare_build(bool print_fingerprint,
25682569
// PR adds multi-version mangling as a Level-1 fallback).
25692570
auto cfg = get_cfg();
25702571
if (!cfg) return std::unexpected(cfg.error());
2571-
mcpp::fetcher::Fetcher fetcher(**cfg);
25722572

25732573
auto merged = mcpp::pm::try_merge_semver(
25742574
key.ns, key.shortName,
25752575
it->second.constraint,
25762576
item.originalConstraint,
2577-
fetcher, targetPlatform);
2577+
index_route(*cfg), targetPlatform);
25782578
if (!merged) {
25792579
// Level 1 fallback: multi-version mangling. Two
25802580
// versions can't be reconciled by SemVer, but they

src/pm/resolver.cppm

Lines changed: 34 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,15 @@
22
// using the package's xpkg lua descriptor as the version inventory.
33
//
44
// Part of the package-management subsystem refactor (PR-R4 in
5-
// `.agents/docs/2026-05-08-pm-subsystem-architecture.md`). Strictly
6-
// pulled out of `cli.cppm` with no behavior change; the same
7-
// signatures, the same error strings, the same platform key picking.
5+
// `.agents/docs/2026-05-08-pm-subsystem-architecture.md`), originally
6+
// pulled out of `cli.cppm` verbatim.
7+
//
8+
// The descriptor is reached through `mcpp.pm.index_route`, never through a
9+
// bare `Fetcher`. A `Fetcher` only ever reads the shared registry, so while
10+
// these functions took one, a dependency served by a project
11+
// `[indices]` entry resolved fine as an exact version and then failed the
12+
// moment the same dependency carried a SemVer constraint — candidate
13+
// selection could see the package, version resolution could not (#308).
814
//
915
// Implementation note: `resolve_semver` is **not** declared inline on
1016
// purpose. Inlining it across modules makes every importer
@@ -20,7 +26,8 @@ import mcpp.manifest;
2026
import mcpp.platform;
2127
import mcpp.platform.axis;
2228
import mcpp.pm.compat;
23-
import mcpp.pm.package_fetcher;
29+
import mcpp.pm.dep_spec;
30+
import mcpp.pm.index_route;
2431
import mcpp.version_req;
2532

2633
export namespace mcpp::pm {
@@ -45,7 +52,7 @@ bool is_version_constraint(std::string_view v);
4552
std::expected<std::string, std::string>
4653
resolve_semver(std::string_view ns, std::string_view shortName,
4754
std::string_view constraint,
48-
mcpp::pm::Fetcher& fetcher,
55+
const mcpp::pm::IndexRoute& route,
4956
const mcpp::platform::PlatformKey& platform);
5057

5158
// Try to AND-merge two version constraints and resolve to a single
@@ -54,21 +61,21 @@ std::expected<std::string, std::string>
5461
try_merge_semver(std::string_view ns, std::string_view shortName,
5562
std::string_view a,
5663
std::string_view b,
57-
mcpp::pm::Fetcher& fetcher,
64+
const mcpp::pm::IndexRoute& route,
5865
const mcpp::platform::PlatformKey& platform);
5966

6067
// ─── Legacy overloads (COMPAT, remove in 1.0.0) ─────────────────────
6168

6269
std::expected<std::string, std::string>
6370
resolve_semver(std::string_view name,
6471
std::string_view constraint,
65-
mcpp::pm::Fetcher& fetcher);
72+
const mcpp::pm::IndexRoute& route);
6673

6774
std::expected<std::string, std::string>
6875
try_merge_semver(std::string_view name,
6976
std::string_view a,
7077
std::string_view b,
71-
mcpp::pm::Fetcher& fetcher);
78+
const mcpp::pm::IndexRoute& route);
7279

7380
} // namespace mcpp::pm
7481

@@ -88,18 +95,26 @@ bool is_version_constraint(std::string_view v) {
8895
std::expected<std::string, std::string>
8996
resolve_semver(std::string_view ns, std::string_view shortName,
9097
std::string_view constraint,
91-
mcpp::pm::Fetcher& fetcher,
98+
const mcpp::pm::IndexRoute& route,
9299
const mcpp::platform::PlatformKey& platform)
93100
{
94101
namespace vr = mcpp::version_req;
95102
auto qname = mcpp::pm::compat::qualified_name(ns, shortName);
96103

97-
auto luaContent = fetcher.read_xpkg_lua(ns, shortName);
104+
auto luaContent = route.read(mcpp::pm::DependencyCoordinate{
105+
.namespace_ = std::string(ns), .shortName = std::string(shortName) });
98106
if (!luaContent) {
107+
// `mcpp index update` is only advice worth giving when the shared
108+
// registry (or a not-yet-cloned git index) is what would have answered.
109+
// A project `[indices] path = …` is whatever the user has on disk, and
110+
// telling them to refresh it sends them nowhere.
111+
auto* idx = route.find_for_ns(ns);
112+
const bool refreshable = !idx || idx->is_builtin() || !idx->is_local();
99113
return std::unexpected(std::format(
100-
"dependency '{}' has SemVer constraint '{}' but the index entry "
101-
"isn't cloned locally yet — run `mcpp index update` first",
102-
qname, constraint));
114+
"dependency '{}' has SemVer constraint '{}' but no readable index "
115+
"entry for it{}",
116+
qname, constraint,
117+
refreshable ? " — run `mcpp index update` first" : ""));
103118
}
104119

105120
auto req = vr::parse_req(constraint);
@@ -145,7 +160,7 @@ std::expected<std::string, std::string>
145160
try_merge_semver(std::string_view ns, std::string_view shortName,
146161
std::string_view a,
147162
std::string_view b,
148-
mcpp::pm::Fetcher& fetcher,
163+
const mcpp::pm::IndexRoute& route,
149164
const mcpp::platform::PlatformKey& platform)
150165
{
151166
auto canon = [](std::string_view v) -> std::string {
@@ -162,34 +177,34 @@ try_merge_semver(std::string_view ns, std::string_view shortName,
162177
else if (!cb.empty()) merged = cb;
163178
else merged = "*";
164179

165-
return resolve_semver(ns, shortName, merged, fetcher, platform);
180+
return resolve_semver(ns, shortName, merged, route, platform);
166181
}
167182

168183
// ─── Legacy overloads (COMPAT, remove in 1.0.0) ─────────────────────
169184

170185
std::expected<std::string, std::string>
171186
resolve_semver(std::string_view name,
172187
std::string_view constraint,
173-
mcpp::pm::Fetcher& fetcher)
188+
const mcpp::pm::IndexRoute& route)
174189
{
175190
auto resolved = mcpp::pm::compat::resolve_package_name(name, "");
176191
// Legacy overload: no target is threaded through it, so it names the host
177192
// axis explicitly rather than inheriting a silent default (#254).
178193
return resolve_semver(resolved.namespace_, resolved.shortName,
179-
constraint, fetcher,
194+
constraint, route,
180195
mcpp::platform::HostPlatform::current());
181196
}
182197

183198
std::expected<std::string, std::string>
184199
try_merge_semver(std::string_view name,
185200
std::string_view a,
186201
std::string_view b,
187-
mcpp::pm::Fetcher& fetcher)
202+
const mcpp::pm::IndexRoute& route)
188203
{
189204
auto resolved = mcpp::pm::compat::resolve_package_name(name, "");
190205
// Legacy overload — see the resolve_semver note above.
191206
return try_merge_semver(resolved.namespace_, resolved.shortName,
192-
a, b, fetcher,
207+
a, b, route,
193208
mcpp::platform::HostPlatform::current());
194209
}
195210

src/scaffold/create.cppm

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import mcpp.fetcher;
1414
import mcpp.fetcher.progress;
1515
import mcpp.manifest;
1616
import mcpp.pm.compat;
17+
import mcpp.pm.index_route;
1718
import mcpp.platform.axis;
1819
import mcpp.pm.resolver;
1920
import mcpp.scaffold;
@@ -76,7 +77,10 @@ fetch_template_package(const mcpp::scaffold::TemplateSpec& spec) {
7677
if (version.empty()) {
7778
// `mcpp add` has no target concept — the axis it means is the host,
7879
// named explicitly rather than inherited from a default (#254).
79-
auto v = mcpp::pm::resolve_semver(ns, shortName, "*", fetcher,
80+
// A template package is always a registry package — `mcpp new` has no
81+
// project yet, so there are no `[indices]` to route through.
82+
mcpp::pm::IndexRoute registryOnly{ nullptr, {}, &*cfg };
83+
auto v = mcpp::pm::resolve_semver(ns, shortName, "*", registryOnly,
8084
mcpp::platform::HostPlatform::current());
8185
if (!v) return std::unexpected(v.error());
8286
version = *v;
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
#!/usr/bin/env bash
2+
# requires: gcc fresh-sandbox
3+
# #308: a SemVer constraint on a package served by a project `[indices]` entry
4+
# must resolve. Version resolution used to read the shared registry directly
5+
# instead of routing like candidate selection does, so the exact-version form
6+
# (`gadget = "2.0.0"`) worked while the constraint form (`gadget = "^2.0"`) failed
7+
# with "run `mcpp index update` first" — advice that does nothing for a local
8+
# path index. Both forms are asserted here so the two cannot drift apart again.
9+
#
10+
# No network: the index is a local path and the package payload is pre-seeded
11+
# into the project's xlings data dir, so nothing is ever downloaded.
12+
set -e
13+
14+
TMP=$(mktemp -d)
15+
trap "rm -rf $TMP" EXIT
16+
17+
export MCPP_HOME="$TMP/mcpp-home"
18+
source "$(dirname "$0")/_inherit_toolchain.sh"
19+
20+
mkdir -p "$TMP/proj"
21+
cd "$TMP/proj"
22+
23+
# ── A project-local index carrying acme.gadget 2.0.0 and 2.1.0 ────────────
24+
mkdir -p local-index/pkgs/a
25+
cat > local-index/pkgs/a/acme.gadget.lua <<'EOF'
26+
package = {
27+
spec = "1",
28+
namespace = "acme",
29+
name = "acme.gadget",
30+
description = "Package reachable only through a project [indices] entry",
31+
licenses = {"MIT"},
32+
type = "package",
33+
xpm = {
34+
linux = {
35+
["2.0.0"] = {
36+
url = "https://example.invalid/gadget-2.0.0.tar.gz",
37+
sha256 = "0000000000000000000000000000000000000000000000000000000000000000",
38+
},
39+
["2.1.0"] = {
40+
url = "https://example.invalid/gadget-2.1.0.tar.gz",
41+
sha256 = "0000000000000000000000000000000000000000000000000000000000000000",
42+
},
43+
},
44+
macosx = {
45+
["2.0.0"] = {
46+
url = "https://example.invalid/gadget-2.0.0.tar.gz",
47+
sha256 = "0000000000000000000000000000000000000000000000000000000000000000",
48+
},
49+
["2.1.0"] = {
50+
url = "https://example.invalid/gadget-2.1.0.tar.gz",
51+
sha256 = "0000000000000000000000000000000000000000000000000000000000000000",
52+
},
53+
},
54+
windows = {
55+
["2.0.0"] = {
56+
url = "https://example.invalid/gadget-2.0.0.zip",
57+
sha256 = "0000000000000000000000000000000000000000000000000000000000000000",
58+
},
59+
["2.1.0"] = {
60+
url = "https://example.invalid/gadget-2.1.0.zip",
61+
sha256 = "0000000000000000000000000000000000000000000000000000000000000000",
62+
},
63+
},
64+
},
65+
mcpp = {
66+
language = "c++23",
67+
import_std = false,
68+
sources = { "src/gadget.cppm" },
69+
targets = { ["gadget"] = { kind = "lib" } },
70+
deps = {},
71+
},
72+
}
73+
EOF
74+
75+
# ── Pre-seed the payload so resolution is the only thing under test ─────
76+
mkdir -p .mcpp/.xlings/data/xpkgs/acme.gadget/2.1.0/src
77+
cat > .mcpp/.xlings/data/xpkgs/acme.gadget/2.1.0/src/gadget.cppm <<'EOF'
78+
export module gadget;
79+
80+
export int gadget_value() {
81+
return 42;
82+
}
83+
EOF
84+
85+
mkdir -p src
86+
cat > src/main.cpp <<'EOF'
87+
import gadget;
88+
89+
int main() {
90+
return gadget_value() == 42 ? 0 : 1;
91+
}
92+
EOF
93+
94+
cat > mcpp.toml <<'EOF'
95+
[package]
96+
name = "proj"
97+
version = "0.1.0"
98+
99+
[indices]
100+
acme = { path = "local-index" }
101+
102+
[dependencies.acme]
103+
gadget = "^2.0"
104+
105+
[targets.proj]
106+
kind = "bin"
107+
main = "src/main.cpp"
108+
EOF
109+
110+
"$MCPP" build > build.log 2>&1 || {
111+
cat build.log
112+
echo "FAIL: SemVer constraint did not resolve against the project [indices] entry"
113+
exit 1
114+
}
115+
116+
# The constraint must have been pinned to the newest matching version, not
117+
# merely tolerated.
118+
grep -q '→ v2.1.0' build.log || {
119+
cat build.log
120+
echo "FAIL: expected '^2.0' to resolve to v2.1.0"
121+
exit 1
122+
}
123+
grep -q 'index update' build.log && {
124+
cat build.log
125+
echo "FAIL: a local path index must never be answered with 'mcpp index update'"
126+
exit 1
127+
}
128+
grep -q '\[package\."acme.gadget"\]' mcpp.lock || {
129+
cat mcpp.lock 2>/dev/null || true
130+
echo "FAIL: expected acme.gadget lock entry"
131+
exit 1
132+
}
133+
# Only 2.1.0 is seeded above, so a build that got here at all consumed the
134+
# resolved version rather than falling back to the constraint's lower bound.
135+
# (The lockfile records the CONSTRAINT, not the pin — pre-existing behaviour
136+
# shared with registry deps, not something this test is asserting about.)
137+
138+
"$MCPP" run > run.log 2>&1 || { cat run.log; echo "FAIL: run failed"; exit 1; }
139+
140+
# ── The exact-version form keeps working through the same route ─────────
141+
mkdir -p .mcpp/.xlings/data/xpkgs/acme.gadget/2.0.0/src
142+
cp .mcpp/.xlings/data/xpkgs/acme.gadget/2.1.0/src/gadget.cppm \
143+
.mcpp/.xlings/data/xpkgs/acme.gadget/2.0.0/src/gadget.cppm
144+
sed -i.bak 's/^gadget = "\^2\.0"$/gadget = "2.0.0"/' mcpp.toml && rm -f mcpp.toml.bak
145+
grep -qE '^gadget = "2\.0\.0"$' mcpp.toml || { cat mcpp.toml; echo "FAIL: rewrite to exact version did not apply"; exit 1; }
146+
rm -f mcpp.lock
147+
"$MCPP" build > build2.log 2>&1 || {
148+
cat build2.log
149+
echo "FAIL: exact version through a project [indices] entry regressed"
150+
exit 1
151+
}
152+
grep -q '2\.0\.0' mcpp.lock || { cat mcpp.lock; echo "FAIL: expected 2.0.0 pin"; exit 1; }
153+
154+
echo "OK"

0 commit comments

Comments
 (0)