From 58e04dc4b26e737407fd190880de95c45c842dc9 Mon Sep 17 00:00:00 2001 From: sunrisepeak Date: Sun, 10 May 2026 00:06:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(deps):=20SemVer=20merge=20=E2=80=94=20Leve?= =?UTF-8?q?l=202=20of=20multi-version=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the transitive walker encounters the same package twice with different pinned versions, AND-combine the two original constraints and re-query the index for a single satisfying version. Bare exact pins like `0.0.1` are treated as `=0.0.1` so they participate cleanly in the AND. If the merged pin differs from the previously-recorded one, the dep is re-fetched at the merged version: the slot in `dep_manifests`/`packages` is replaced in-place, the old `[build].include_dirs` entries are evicted from the main manifest, the new ones are appended, and the new manifest's children are pushed onto the worklist. Same pin → just record the new consumer; no overlap → hard error with a Level-1 mangling hint. Code shape: - `mcpp.pm.resolver::try_merge_semver` is the new public helper. - `cli.cppm`'s resolve loop tracks `originalConstraint` per WorkItem and `constraint` / `depIndex` / `includeDirsAdded` per ResolvedRecord. - The version-source manifest acquisition (install + xpkg-lua field dispatch) is factored into `loadVersionDep` so the merger can re-use it. Tests: new `32_semver_merge.sh` covers the compatible-merge happy path (`=0.0.1` ⨯ `>=0.0.1, <1` → 0.0.1, with the previously-pinned 0.0.2 slot overwritten) and the irreconcilable case (`=0.0.1` ⨯ `=0.0.2` still errors). Existing 31_transitive_deps stays green. CHANGELOG: opens 0.0.3 entry covering both PR #17 (transitive walker) and this one (SemVer merge); the Level-1 mangling fallback follows in a separate PR before tagging 0.0.3. --- CHANGELOG.md | 22 +- src/cli.cppm | 449 ++++++++++++++++++++++------------- src/pm/resolver.cppm | 39 +++ tests/e2e/32_semver_merge.sh | 146 ++++++++++++ 4 files changed, 487 insertions(+), 169 deletions(-) create mode 100755 tests/e2e/32_semver_merge.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cc1a5f..e878d1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,27 @@ > 本文件追踪 `mcpp-community/mcpp` 公开仓的版本演进。 > 格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/)。 -## [0.0.2] — 2026-05-09 +## [Unreleased] — 0.0.3 + +依赖解析体系的两步演进:0.0.2 release tag 之后合入的 transitive walker +之上,这一版叠加 SemVer 合并;后续 PR 还会补上多版本 mangling 兜底。 + +### 新增 + +- ✅ **依赖图传递性遍历** —— 直接依赖的子依赖(以及更深层)自动跟随入解析图, + 消费者不必再在自己的 `mcpp.toml` 里把 grandchild 也写一遍;子依赖的 + `[build].include_dirs` 也会沿链路传播,让中间层在编译时看得到 grandchild + 的头文件。冲突检测同时区分 path / git / version 三类来源,跨来源不允许 + 混用。 + +- ✅ **SemVer 合并解析(Level 2)** —— 同一个包在传递依赖图里被多个消费者 + 以不同版本约束声明时,resolver 会把两条原始约束 AND 合并(裸版本号视作 + `=X.Y.Z`),向 index 重新查询,选出同时满足两侧的具体版本。若该版本与 + 此前已 pin 的不一致,旧的 manifest 与 `[build].include_dirs` 会被原地 + 替换为新版本的内容,孩子依赖也按新 manifest 重新入队。完全无重叠 + (典型如 `=0.0.1` 对 `=0.0.2`)仍硬报错并提示后续 PR 会用多版本 + mangling 兜底。新增 e2e `32_semver_merge.sh` 覆盖兼容合并 + 不可调和 + 两条主链路。 第二个公开版本。新增 C 语言一等公民支持、xpkg 风格依赖命名空间、包管理子系统骨架重构,以及 lib-root 约定。 diff --git a/src/cli.cppm b/src/cli.cppm index 16247a3..c045e79 100644 --- a/src/cli.cppm +++ b/src/cli.cppm @@ -1041,8 +1041,10 @@ prepare_build(bool print_fingerprint, std::vector packages; packages.push_back({*root, *m}); - // Use a deque + stable storage for dep manifests (vector of unique_ptr - // so PackageRoot's reference stays valid as new ones are appended). + // dep_manifests is kept around purely so the build plan can move it + // out at the end (PackageRoot stores a `Manifest` by value, so the + // unique_ptr is not load-bearing for liveness — it's a leftover from + // an earlier design and harmless). std::vector> dep_manifests; struct ResolvedKey { @@ -1051,16 +1053,20 @@ prepare_build(bool print_fingerprint, auto operator<=>(const ResolvedKey&) const = default; }; struct ResolvedRecord { - std::string version; // empty for path/git deps - std::string requestedBy; // human-readable for error messages - std::string source; // "version" | "path" | "git" — for type-clash check + std::string version; // empty for path/git deps + std::string constraint; // AND-combined original constraints (version src only) + std::string requestedBy; // human-readable for error messages + std::string source; // "version" | "path" | "git" — for type-clash check + std::size_t depIndex = 0; // index into dep_manifests/packages-1 (for in-place re-fetch) + std::vector includeDirsAdded; // entries appended to m->buildConfig.includeDirs by this dep }; std::map resolved; struct WorkItem { - std::string name; // dep map key as written - mcpp::manifest::DependencySpec spec; // copy (we may mutate version) - std::string requestedBy; // who asked for it + std::string name; // dep map key as written + mcpp::manifest::DependencySpec spec; // copy (we may mutate version) + std::string requestedBy; // who asked for it + std::string originalConstraint; // spec.version BEFORE pinning (for SemVer merge) }; std::deque worklist; @@ -1084,15 +1090,137 @@ prepare_build(bool print_fingerprint, return {}; }; + // Acquire a version-source dep at a specific pinned version. Used both + // by the first-time walk and by the SemVer merger when a re-fetch at a + // different version is needed. Returns the dep's effective root (where + // mcpp.toml lives) and a fully loaded manifest. + using LoadedDep = std::pair; + auto loadVersionDep = [&](const std::string& depName, + const std::string& version) + -> std::expected + { + auto cfg = get_cfg(); + if (!cfg) return std::unexpected(cfg.error()); + mcpp::fetcher::Fetcher fetcher(**cfg); + + auto installed = fetcher.install_path(depName, version); + if (!installed) { + mcpp::ui::info("Downloading", std::format("{} v{}", depName, version)); + std::vector targets{ std::format("{}@{}", depName, version) }; + CliInstallProgress progress; + auto r = fetcher.install(targets, &progress); + if (!r) return std::unexpected(std::format( + "fetch '{}@{}': {}", depName, version, r.error().message)); + if (r->exitCode != 0) { + std::string err = std::format( + "fetch '{}@{}' failed (exit {})", depName, version, r->exitCode); + if (r->error) err += ": " + r->error->message; + return std::unexpected(err); + } + installed = fetcher.install_path(depName, version); + if (!installed) return std::unexpected(std::format( + "package '{}@{}' install path missing after fetch", depName, version)); + } + std::filesystem::path verRoot = *installed; + + auto luaContent = fetcher.read_xpkg_lua(depName); + if (!luaContent) return std::unexpected(std::format( + "dependency '{}': index entry not found in local clone", depName)); + auto field = mcpp::manifest::extract_mcpp_field(*luaContent); + + std::optional manifest; + std::filesystem::path effRoot = verRoot; + auto loadFrom = [&](const std::filesystem::path& mcppToml) + -> std::expected + { + auto dm = mcpp::manifest::load(mcppToml); + if (!dm) return std::unexpected(std::format( + "dependency '{}' (at '{}'): {}", + depName, mcppToml.string(), dm.error().format())); + manifest = std::move(*dm); + effRoot = mcppToml.parent_path(); + return {}; + }; + if (field.kind == mcpp::manifest::McppField::StringPath) { + auto matches = mcpp::modgraph::expand_glob(verRoot, field.value); + if (matches.empty()) return std::unexpected(std::format( + "dependency '{}': mcpp pointer '{}' did not match any " + "file under '{}'", depName, field.value, verRoot.string())); + if (matches.size() > 1) return std::unexpected(std::format( + "dependency '{}': mcpp pointer '{}' matched {} files " + "(expected exactly one)", depName, field.value, matches.size())); + if (auto r = loadFrom(matches.front()); !r) return std::unexpected(r.error()); + } else if (field.kind == mcpp::manifest::McppField::TableBody) { + auto dm = mcpp::manifest::synthesize_from_xpkg_lua(*luaContent, depName, version); + if (!dm) return std::unexpected(std::format( + "dependency '{}': {}", depName, dm.error().format())); + manifest = std::move(*dm); + // effRoot stays as verRoot + } else { + std::vector matches; + for (auto pat : { "mcpp.toml", "*/mcpp.toml" }) { + matches = mcpp::modgraph::expand_glob(verRoot, pat); + if (!matches.empty()) break; + } + if (matches.empty()) return std::unexpected(std::format( + "dependency '{}': index entry has no `mcpp = ...` field, " + "and no mcpp.toml was found at /mcpp.toml or " + "/*/mcpp.toml — add an explicit `mcpp = \"\"` " + "or `mcpp = {{ ... }}` block to the .lua descriptor.", + depName)); + if (matches.size() > 1) return std::unexpected(std::format( + "dependency '{}': default mcpp.toml lookup matched {} " + "files; pin one with explicit `mcpp = \"\"`.", + depName, matches.size())); + if (auto r = loadFrom(matches.front()); !r) return std::unexpected(r.error()); + } + return std::pair{effRoot, std::move(*manifest)}; + }; + + // Append a dep's [build].include_dirs onto the main manifest's, glob- + // expanded against the dep's root. Returns the absolute paths actually + // appended so the caller can later evict them on a SemVer-merge re-fetch. + auto propagateIncludeDirs = [&](const std::filesystem::path& depRoot, + const mcpp::manifest::Manifest& depManifest) + -> std::vector + { + std::vector added; + for (auto& inc : depManifest.buildConfig.includeDirs) { + if (inc.is_absolute()) { + m->buildConfig.includeDirs.push_back(inc); + added.push_back(inc); + continue; + } + auto matches = mcpp::modgraph::expand_dir_glob(depRoot, inc.generic_string()); + if (matches.empty()) continue; + for (auto& d : matches) { + m->buildConfig.includeDirs.push_back(d); + added.push_back(d); + } + } + return added; + }; + + // Drop earlier include_dirs that came from a now-superseded dep version. + // Erases by value match — safe because the outer code only ever appends, + // and on re-fetch we re-record the new entries afterwards. + auto removeIncludeDirs = [&](const std::vector& paths) { + auto& dirs = m->buildConfig.includeDirs; + for (auto& p : paths) { + auto pos = std::find(dirs.begin(), dirs.end(), p); + if (pos != dirs.end()) dirs.erase(pos); + } + }; + // Seed the worklist from the main manifest. Dev-deps only when the // caller wants them; they're never propagated transitively. const std::string mainPkgLabel = m->package.name; for (auto& [n, s] : m->dependencies) { - worklist.push_back({n, s, mainPkgLabel}); + worklist.push_back({n, s, mainPkgLabel, s.version}); } if (includeDevDeps) { for (auto& [n, s] : m->devDependencies) { - worklist.push_back({n, s, mainPkgLabel + " (dev-dep)"}); + worklist.push_back({n, s, mainPkgLabel + " (dev-dep)", s.version}); } } @@ -1130,17 +1258,116 @@ prepare_build(bool print_fingerprint, sourceKind, item.requestedBy)); } if (sourceKind == "version" && it->second.version != spec.version) { - return std::unexpected(std::format( - "dependency '{}{}{}' has conflicting versions in the " - "transitive graph:\n" - " '{}' requested by '{}'\n" - " '{}' requested by '{}'\n" - "C++ modules require a single global version of each " - "package. Pick a version compatible with both consumers, " - "or ask one upstream to widen its dep range.", - key.ns, key.ns.empty() ? "" : ".", key.shortName, - it->second.version, it->second.requestedBy, - spec.version, item.requestedBy)); + // SemVer merge attempt: AND-combine the two original + // constraint strings and ask the index for a single version + // satisfying both. Same-major caret/tilde/exact pairs that + // overlap converge here; cross-major or otherwise + // unsatisfiable pairs fall through to a hard error (a future + // PR adds multi-version mangling as a Level-1 fallback). + auto cfg = get_cfg(); + if (!cfg) return std::unexpected(cfg.error()); + mcpp::fetcher::Fetcher fetcher(**cfg); + + auto merged = mcpp::pm::try_merge_semver( + name, + it->second.constraint, + item.originalConstraint, + fetcher); + if (!merged) { + return std::unexpected(std::format( + "dependency '{}{}{}' has irreconcilable versions in " + "the transitive graph:\n" + " '{}' (constraint '{}') requested by '{}'\n" + " '{}' (constraint '{}') requested by '{}'\n" + "SemVer merge: {}\n" + "C++ modules require a single global version of each " + "package; pick a version compatible with both " + "consumers, or ask one upstream to widen its dep " + "range. (cross-major fallback via multi-version " + "mangling is planned in a follow-up PR)", + key.ns, key.ns.empty() ? "" : ".", key.shortName, + it->second.version, it->second.constraint, it->second.requestedBy, + spec.version, item.originalConstraint, item.requestedBy, + merged.error())); + } + + // Combine the constraint strings so future merges AND with + // both. Empty originalConstraint means "any" — use "*". + const std::string& addCstr = + item.originalConstraint.empty() ? std::string("*") + : item.originalConstraint; + if (it->second.constraint.empty()) + it->second.constraint = addCstr; + else + it->second.constraint += "," + addCstr; + + if (*merged == it->second.version) { + // The existing pin already satisfies the new constraint — + // no re-fetch needed; just record this consumer. + continue; + } + + // Merged version differs from the previously-pinned one. + // Re-fetch the dep at the merged version and replace the + // earlier slot in dep_manifests / packages so the build plan + // sees only one version. Old include_dir entries are evicted + // and the new manifest's entries are appended. + mcpp::ui::info("Merged", + std::format("{}{}{} {} ⨯ {} → v{}", + key.ns, key.ns.empty() ? "" : ".", key.shortName, + it->second.version, spec.version, *merged)); + auto reloaded = loadVersionDep(name, *merged); + if (!reloaded) return std::unexpected(reloaded.error()); + auto& [newRoot, newManifest] = *reloaded; + + // Name match against the re-loaded manifest. + { + const std::string& expectedShort = + spec.shortName.empty() ? name : spec.shortName; + std::string expectedComposite; + if (!spec.namespace_.empty() + && spec.namespace_ != mcpp::manifest::kDefaultNamespace) { + expectedComposite = std::format("{}.{}", + spec.namespace_, expectedShort); + } + const bool nameOk = + newManifest.package.name == expectedShort + || (!expectedComposite.empty() + && newManifest.package.name == expectedComposite); + if (!nameOk) { + return std::unexpected(std::format( + "dependency '{}' (merged to v{}) resolved to " + "package '{}' (mismatch with declared name '{}')", + name, *merged, newManifest.package.name, + expectedShort)); + } + } + + removeIncludeDirs(it->second.includeDirsAdded); + auto added = propagateIncludeDirs(newRoot, newManifest); + + // Replace in dep_manifests + packages. depIndex is the slot + // in dep_manifests; packages = [main, dep_0, dep_1, …], so + // packages[depIndex+1] is the same dep. + *dep_manifests[it->second.depIndex] = std::move(newManifest); + packages[it->second.depIndex + 1] = + {newRoot, *dep_manifests[it->second.depIndex]}; + + it->second.version = *merged; + it->second.includeDirsAdded = std::move(added); + + // Walk the *new* manifest's deps so their constraints feed + // future merges. Already-resolved children dedup via the + // resolved map. + const std::string newLabel = std::format("{}{}{}@{}", + key.ns, key.ns.empty() ? "" : ".", + key.shortName, *merged); + for (auto& [child_name, child_spec] : + dep_manifests[it->second.depIndex]->dependencies) { + worklist.push_back({child_name, child_spec, newLabel, + child_spec.version}); + } + continue; } // Same key, same version (or compatible path/git) — already // processed; skip. @@ -1202,45 +1429,15 @@ prepare_build(bool print_fingerprint, } } dep_root = gitRoot; - } else { - // Version-based: ensure installed via xlings, then point at install dir. - auto cfg = get_cfg(); - if (!cfg) return std::unexpected(cfg.error()); - mcpp::fetcher::Fetcher fetcher(**cfg); - - auto installed = fetcher.install_path(name, spec.version); - if (!installed) { - mcpp::ui::info("Downloading", - std::format("{} v{}", name, spec.version)); - std::vector targets { - std::format("{}@{}", name, spec.version) - }; - CliInstallProgress progress; - auto r = fetcher.install(targets, &progress); - if (!r) return std::unexpected(std::format( - "fetch '{}@{}': {}", name, spec.version, r.error().message)); - if (r->exitCode != 0) { - std::string err = std::format( - "fetch '{}@{}' failed (exit {})", name, spec.version, r->exitCode); - if (r->error) err += ": " + r->error->message; - return std::unexpected(err); - } - installed = fetcher.install_path(name, spec.version); - if (!installed) return std::unexpected(std::format( - "package '{}@{}' install path missing after fetch", name, spec.version)); - } - dep_root = *installed; - } - - // M6.x: Manifest acquisition strategy - // - Path/git dep: dep_root is the source tree. mcpp.toml must be - // at its root (otherwise hard error). - // - Version dep: dep_root from install_path = verdir. We consult - // the xpkg.lua's `mcpp` field: - // * string → glob-resolve to mcpp.toml; load that; - // dep_root = mcpp.toml.parent_path() - // * table → synthesize Form B manifest in place; - // dep_root stays as verdir (paths are globs) + } + // (version-source: dep_root + manifest are loaded together via + // loadVersionDep below since the index entry drives both.) + + // Manifest acquisition. + // - Path/git dep: dep_root is the source tree, mcpp.toml at root. + // - Version dep: delegate to loadVersionDep — the index entry's + // `mcpp` field decides where mcpp.toml lives (StringPath / + // TableBody / default lookup). std::optional dep_manifest; if (spec.isPath() || spec.isGit()) { if (!std::filesystem::exists(dep_root / "mcpp.toml")) { @@ -1256,85 +1453,10 @@ prepare_build(bool print_fingerprint, } dep_manifest = std::move(*dm); } else { - // Version dep: drive everything off the index entry's `mcpp` field. - auto cfg = get_cfg(); - if (!cfg) return std::unexpected(cfg.error()); - mcpp::fetcher::Fetcher fetcher(**cfg); - - auto luaContent = fetcher.read_xpkg_lua(name); - if (!luaContent) { - return std::unexpected(std::format( - "dependency '{}': index entry not found in local clone", - name)); - } - auto field = mcpp::manifest::extract_mcpp_field(*luaContent); - - // Lambda: load mcpp.toml at `mcppToml`, set dep_manifest + re-anchor dep_root. - auto loadFromMcppToml = [&](const std::filesystem::path& mcppToml) - -> std::expected - { - auto dm = mcpp::manifest::load(mcppToml); - if (!dm) { - return std::unexpected(std::format( - "dependency '{}' (at '{}'): {}", - name, mcppToml.string(), dm.error().format())); - } - dep_manifest = std::move(*dm); - dep_root = mcppToml.parent_path(); - return {}; - }; - - if (field.kind == mcpp::manifest::McppField::StringPath) { - // Explicit pointer (glob OK). - auto matches = mcpp::modgraph::expand_glob(dep_root, field.value); - if (matches.empty()) { - return std::unexpected(std::format( - "dependency '{}': mcpp pointer '{}' did not match any " - "file under '{}'", name, field.value, dep_root.string())); - } - if (matches.size() > 1) { - return std::unexpected(std::format( - "dependency '{}': mcpp pointer '{}' matched {} files " - "(expected exactly one)", name, field.value, matches.size())); - } - if (auto r = loadFromMcppToml(matches.front()); !r) - return std::unexpected(r.error()); - } else if (field.kind == mcpp::manifest::McppField::TableBody) { - // Form B inline — paths in the table are globs relative to verdir. - auto dm = mcpp::manifest::synthesize_from_xpkg_lua( - *luaContent, name, spec.version); - if (!dm) { - return std::unexpected(std::format( - "dependency '{}': {}", name, dm.error().format())); - } - dep_manifest = std::move(*dm); - // dep_root stays as verdir - } else { - // No `mcpp` field → try default lookup: /mcpp.toml, - // then /*/mcpp.toml. Covers most real-world layouts - // (bare tarball + GitHub -/ wrap). - std::vector matches; - for (auto pat : { "mcpp.toml", "*/mcpp.toml" }) { - matches = mcpp::modgraph::expand_glob(dep_root, pat); - if (!matches.empty()) break; - } - if (matches.empty()) { - return std::unexpected(std::format( - "dependency '{}': index entry has no `mcpp = ...` field, " - "and no mcpp.toml was found at /mcpp.toml or " - "/*/mcpp.toml — add an explicit `mcpp = \"\"` " - "or `mcpp = {{ ... }}` block to the .lua descriptor.", - name)); - } - if (matches.size() > 1) { - return std::unexpected(std::format( - "dependency '{}': default mcpp.toml lookup matched {} " - "files; pin one with explicit `mcpp = \"\"`.", - name, matches.size())); - } - if (auto r = loadFromMcppToml(matches.front()); !r) - return std::unexpected(r.error()); - } + auto loaded = loadVersionDep(name, spec.version); + if (!loaded) return std::unexpected(loaded.error()); + dep_root = std::move(loaded->first); + dep_manifest = std::move(loaded->second); } // Name match: prefer the dep's *short* name (the new xpkg-style @@ -1360,39 +1482,29 @@ prepare_build(bool print_fingerprint, name, dep_manifest->package.name, expectedShort)); } - // M5.0+M6.x: propagate dep's [build].include_dirs to the main - // manifest, glob-expanded against the dep's root. Supports plain - // literal paths AND globs like "*/include" (used by Form B index - // descriptors to handle GitHub-tarball wrap layers). - for (auto& inc : dep_manifest->buildConfig.includeDirs) { - if (inc.is_absolute()) { - m->buildConfig.includeDirs.push_back(inc); - continue; - } - auto matches = mcpp::modgraph::expand_dir_glob(dep_root, inc.generic_string()); - if (matches.empty()) { - // Tolerate missing — produce nothing, no error (some libs may - // legitimately have no headers; consumer will catch broken - // includes at compile time). - continue; - } - for (auto& d : matches) m->buildConfig.includeDirs.push_back(d); - } - - // Record this dep as resolved so future encounters of the same - // (ns, name) hit the fast path (skip / conflict check). - resolved[key] = ResolvedRecord{ - .version = sourceKind == "version" ? spec.version : "", - .requestedBy = item.requestedBy, - .source = sourceKind, - }; + // Propagate dep's [build].include_dirs to the main manifest. The + // returned vector is what was actually appended (after glob + // expansion against dep_root) — stash it so a SemVer merge can + // evict these entries on a re-fetch. + auto includeDirsAdded = propagateIncludeDirs(dep_root, *dep_manifest); - // Stable storage so the PackageRoot reference below stays valid - // when the worklist appends more deps and the vector grows. + // Move the manifest into stable storage so we can later look it up + // by depIndex (the SemVer merger needs to overwrite the slot). dep_manifests.push_back( std::make_unique(std::move(*dep_manifest))); packages.push_back({dep_root, *dep_manifests.back()}); + // Record this dep as resolved so future encounters of the same + // (ns, name) hit the fast path (skip / merge / conflict). + resolved[key] = ResolvedRecord{ + .version = sourceKind == "version" ? spec.version : "", + .constraint = sourceKind == "version" ? item.originalConstraint : "", + .requestedBy = item.requestedBy, + .source = sourceKind, + .depIndex = dep_manifests.size() - 1, + .includeDirsAdded = std::move(includeDirsAdded), + }; + // Recurse: the dep's own [dependencies] become new worklist items. // dev-dependencies are intentionally NOT walked — those are // private to the dep's test runs, not part of its public ABI. @@ -1403,7 +1515,8 @@ prepare_build(bool print_fingerprint, key.shortName, sourceKind == "version" ? spec.version : sourceKind); for (auto& [child_name, child_spec] : dep_manifests.back()->dependencies) { - worklist.push_back({child_name, child_spec, thisDepLabel}); + worklist.push_back({child_name, child_spec, thisDepLabel, + child_spec.version}); } } diff --git a/src/pm/resolver.cppm b/src/pm/resolver.cppm index af3bea3..6f71c65 100644 --- a/src/pm/resolver.cppm +++ b/src/pm/resolver.cppm @@ -52,6 +52,19 @@ resolve_semver(std::string_view name, std::string_view constraint, mcpp::pm::Fetcher& fetcher); +// Try to AND-merge two version constraints (caret / tilde / range / +// bare-exact) and resolve to a single concrete version that satisfies +// both. Bare exacts like "1.2.3" are treated as `=1.2.3`. Returns the +// merged version on success, or an error message describing why the +// two constraints cannot be reconciled (no overlap in the available +// version inventory) — the caller can use that to surface a Level-1 +// "needs multi-version mangling" hint to the user. +std::expected +try_merge_semver(std::string_view name, + std::string_view a, + std::string_view b, + mcpp::pm::Fetcher& fetcher); + } // namespace mcpp::pm namespace mcpp::pm { @@ -117,4 +130,30 @@ resolve_semver(std::string_view name, return parsed[*idx].str(); } +std::expected +try_merge_semver(std::string_view name, + std::string_view a, + std::string_view b, + mcpp::pm::Fetcher& fetcher) +{ + // Promote a bare-exact "1.2.3" to "=1.2.3" so the AND works under + // the comma-joined constraint grammar. Empty / "*" means "any" and + // contributes nothing to the AND. + auto canon = [](std::string_view v) -> std::string { + if (v.empty() || v == "*") return std::string{}; + if (is_version_constraint(v)) return std::string(v); + return "=" + std::string(v); + }; + + std::string ca = canon(a); + std::string cb = canon(b); + std::string merged; + if (!ca.empty() && !cb.empty()) merged = ca + "," + cb; + else if (!ca.empty()) merged = ca; + else if (!cb.empty()) merged = cb; + else merged = "*"; + + return resolve_semver(name, merged, fetcher); +} + } // namespace mcpp::pm diff --git a/tests/e2e/32_semver_merge.sh b/tests/e2e/32_semver_merge.sh new file mode 100755 index 0000000..132ca32 --- /dev/null +++ b/tests/e2e/32_semver_merge.sh @@ -0,0 +1,146 @@ +#!/usr/bin/env bash +# 32_semver_merge.sh — SemVer merge in the transitive walker: +# * Two consumers of the same package with overlapping constraints +# (one exact, one range) merge to a single satisfying version +# instead of erroring out. +# * Non-overlapping pins still hard-error (Level-1 mangling fallback +# is a follow-up). +set -e + +TMP=$(mktemp -d) +trap "rm -rf $TMP" EXIT +export MCPP_HOME="$TMP/mcpp-home" +source "$(dirname "$0")/_inherit_toolchain.sh" + +# Index is needed since we exercise version-source deps. +mkdir -p "$MCPP_HOME/registry/data" +if [[ -d "$HOME/.mcpp/registry/data/mcpp-index" ]]; then + ln -sf "$HOME/.mcpp/registry/data/mcpp-index" \ + "$MCPP_HOME/registry/data/mcpp-index" +fi +# Pre-cached xpkg downloads so the test doesn't re-fetch the world. +if [[ -d "$HOME/.mcpp/registry/data/xpkgs" ]]; then + [[ -e "$MCPP_HOME/registry/data/xpkgs" ]] \ + || ln -sf "$HOME/.mcpp/registry/data/xpkgs" \ + "$MCPP_HOME/registry/data/xpkgs" +fi + +# ── 1. Compatible-merge case ──────────────────────────────────────────── +# mylib (path) pins cmdline to =0.0.1 +# app (root) pins cmdline to >=0.0.1,<1 (resolves to 0.0.2) +# The walker first sees app's broader range (→ 0.0.2), then mylib's +# exact (=0.0.1) — strict equality would error; the merger AND-combines +# the two constraints, picks 0.0.1, re-fetches that version, and +# replaces the previously-pinned slot. + +mkdir -p "$TMP/mylib" && cd "$TMP/mylib" +"$MCPP" new mylib > /dev/null +cd mylib +rm -f src/main.cpp +cat > src/mylib.cppm <<'EOF' +export module mylib; +export int mylib_answer() { return 1; } +EOF +cat > mcpp.toml <<'EOF' +[package] +name = "mylib" +version = "0.1.0" +[targets.mylib] +kind = "lib" + +[dependencies.mcpplibs] +cmdline = "=0.0.1" +EOF + +mkdir -p "$TMP/app" && cd "$TMP/app" +"$MCPP" new app > /dev/null +cd app +cat > src/main.cpp <<'EOF' +import std; +import mylib; +int main() { + std::println("ok={}", mylib_answer()); + return mylib_answer() == 1 ? 0 : 1; +} +EOF +cat > mcpp.toml < build.log 2>&1 || { + cat build.log + echo "compatible merge failed"; exit 1; } + +# The resolver should announce the merge step so future debugging is +# obvious. Either trace style is fine — we just want proof the merger +# (not the strict-equality fallback) handled the conflict. +grep -qE 'Merged.*cmdline.*0\.0\.1|→ v0\.0\.1' build.log || { + cat build.log + echo "no merge trace in build log"; exit 1; } + +out="$("$MCPP" run 2>&1 | tail -1)" +[[ "$out" == "ok=1" ]] || { echo "unexpected output: $out"; exit 1; } + +# ── 2. Irreconcilable case ───────────────────────────────────────────── +# Two non-overlapping exact pins (=0.0.1 vs =0.0.2). The merger fails +# to find a satisfying version and the build hard-errors. The error +# message must mention the package and both constraints so the user +# can pick one. (Cross-major mangling fallback is a separate PR.) + +mkdir -p "$TMP/mylib2" && cd "$TMP/mylib2" +"$MCPP" new mylib2 > /dev/null +cd mylib2 +rm -f src/main.cpp +cat > src/mylib2.cppm <<'EOF' +export module mylib2; +export int mylib2_answer() { return 2; } +EOF +cat > mcpp.toml <<'EOF' +[package] +name = "mylib2" +version = "0.1.0" +[targets.mylib2] +kind = "lib" + +[dependencies.mcpplibs] +cmdline = "=0.0.1" +EOF + +mkdir -p "$TMP/app2" && cd "$TMP/app2" +"$MCPP" new app2 > /dev/null +cd app2 +cat > src/main.cpp <<'EOF' +import std; +import mylib2; +int main() { return mylib2_answer() == 2 ? 0 : 1; } +EOF +cat > mcpp.toml < build-bad.log 2>&1; then + cat build-bad.log + echo "non-overlapping pins should have failed"; exit 1 +fi +grep -q 'irreconcilable versions' build-bad.log \ + && grep -q 'cmdline' build-bad.log \ + || { cat build-bad.log + echo "expected irreconcilable diagnostic missing"; exit 1; } + +echo "OK"