Skip to main content

lean_ctx/core/context_package/
deps.rs

1//! Depth-1 dependency resolution at install time (GH #727, Phase 3).
2//!
3//! A package may declare [`PackageDependency`] entries (SemVer ranges). On
4//! `pack install` / `addon add`, the direct dependencies of the root package
5//! are resolved against the registry index and installed alongside it — one
6//! consent surface listing everything that will land.
7//!
8//! **Depth-1 is deliberate** (issue non-goal: no transitive graphs): only the
9//! root's own dependencies resolve; a dependency's dependencies do not. That
10//! keeps resolution O(deps), makes cycles impossible beyond self-reference
11//! (which is refused), and keeps the consent prompt honest — nothing installs
12//! that was not listed.
13//!
14//! Determinism: given the same registry index, resolution always picks the
15//! **highest non-yanked version matching the range** — and repeated installs
16//! short-circuit offline via the lockfile + local store (`already_satisfied`).
17
18use super::manifest::PackageDependency;
19use super::remote::{self, VersionInfo};
20
21/// One resolved direct dependency, ready to download.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct ResolvedDep {
24    /// Scoped name as declared (`@ns/name`).
25    pub name: String,
26    /// Registry namespace (without `@`).
27    pub namespace: String,
28    /// Bare package name (slug).
29    pub slug: String,
30    /// The picked version (highest non-yanked match of the range).
31    pub version: String,
32    /// Artifact hash from the registry index (verified again on download).
33    pub artifact_sha256: String,
34}
35
36/// Resolve the direct, non-optional dependencies in `deps` (declared by the
37/// root package whose scoped reference is `root_name`) against the registry at
38/// `base`. Fails on: unscoped names, self-dependency, invalid ranges, and
39/// ranges with no installable match — a partially-resolved install is worse
40/// than a refused one.
41///
42/// `root_name` is the root's **scoped** reference (`Some("@ns/name")`) or
43/// `None` when the install source cannot name one (see [`resolve_one`]).
44///
45/// Takes the dependency slice + root name directly (rather than a
46/// `PackageManifest`) so every install source can resolve the same way — a
47/// local `lean-ctx-addon.toml` carries its `[[dependencies]]` in
48/// [`crate::core::addons::manifest::AddonManifest`], with no hosted
49/// `PackageManifest` to key off (GH #727, Finding A).
50pub fn resolve_dependencies(
51    deps: &[PackageDependency],
52    root_name: Option<&str>,
53    base: &str,
54    token: Option<&str>,
55) -> Result<Vec<ResolvedDep>, String> {
56    let mut resolved = Vec::new();
57    for dep in deps {
58        if dep.optional {
59            continue;
60        }
61        resolved.push(resolve_one(root_name, dep, base, token)?);
62    }
63    Ok(resolved)
64}
65
66/// Resolve a single declared dependency against the registry index.
67///
68/// `root_name` is the root package's **scoped** reference (`@ns/name`) or
69/// `None` when the install source cannot name one. Pass a scoped reference,
70/// never a bare `[addon] name` slug: the self-dependency guard compares against
71/// the scoped dependency name, so a bare slug can never match and would
72/// silently disable the guard (GH #727, Finding A). A local
73/// `lean-ctx-addon.toml` has only a bare slug — a self-reference is unnameable
74/// there, so its caller passes `None` and the guard is vacuous by construction.
75pub fn resolve_one(
76    root_name: Option<&str>,
77    dep: &PackageDependency,
78    base: &str,
79    token: Option<&str>,
80) -> Result<ResolvedDep, String> {
81    let Some(remote_ref) = remote::parse_remote_ref(&dep.name) else {
82        return Err(format!(
83            "dependency `{}` is not a scoped @ns/name reference — unresolvable",
84            dep.name
85        ));
86    };
87    if let Some(root) = root_name
88        && dep.name.trim_start_matches('@') == root.trim_start_matches('@')
89    {
90        return Err(format!(
91            "package depends on itself (`{}`) — refused",
92            dep.name
93        ));
94    }
95    let req = parse_version_req(&dep.version_req)
96        .map_err(|e| format!("dependency `{}`: {e}", dep.name))?;
97
98    let versions = remote::fetch_versions(base, &remote_ref.namespace, &remote_ref.name, token)
99        .map_err(|e| format!("dependency `{}`: {e}", dep.name))?;
100    let best = pick_highest_match(&versions, &req).ok_or_else(|| {
101        format!(
102            "dependency `{}`: no installable version matches `{}` (available: {})",
103            dep.name,
104            dep.version_req,
105            versions
106                .iter()
107                .map(|v| v.version.as_str())
108                .collect::<Vec<_>>()
109                .join(", ")
110        )
111    })?;
112
113    Ok(ResolvedDep {
114        name: dep.name.clone(),
115        namespace: remote_ref.namespace,
116        slug: remote_ref.name,
117        version: best.version.clone(),
118        artifact_sha256: best.artifact_sha256.clone(),
119    })
120}
121
122/// Parse a SemVer range. An empty/`*` requirement means "any version".
123pub fn parse_version_req(req: &str) -> Result<semver::VersionReq, String> {
124    let trimmed = req.trim();
125    if trimmed.is_empty() || trimmed == "*" {
126        return Ok(semver::VersionReq::STAR);
127    }
128    semver::VersionReq::parse(trimmed).map_err(|e| format!("invalid version range `{req}`: {e}"))
129}
130
131/// Highest non-yanked version matching `req`. Non-SemVer versions in the
132/// index are skipped (they can never match a range).
133pub fn pick_highest_match<'a>(
134    versions: &'a [VersionInfo],
135    req: &semver::VersionReq,
136) -> Option<&'a VersionInfo> {
137    versions
138        .iter()
139        .filter(|v| !v.yanked)
140        .filter_map(|v| Some((semver::Version::parse(&v.version).ok()?, v)))
141        .filter(|(parsed, _)| req.matches(parsed))
142        .max_by(|(a, _), (b, _)| a.cmp(b))
143        .map(|(_, v)| v)
144}
145
146/// Version of `name` pinned in the project lockfile, if any.
147pub fn locked_version(name: &str, project_root: &std::path::Path) -> Option<String> {
148    let lock = super::lockfile::load(project_root).ok()?;
149    lock.packages
150        .iter()
151        .find(|p| p.name == name)
152        .map(|p| p.version.clone())
153}
154
155/// The resolved dependency when `name@version-satisfying-req` is already
156/// pinned in the lockfile **and** present in the local store — the
157/// offline-reproducible fast path: a second `pack install` touches no network
158/// for satisfied dependencies.
159///
160/// Returns the full [`ResolvedDep`] (at the **locked** version, not a fresh
161/// highest-match) so the install step can hand the exact same version to
162/// `[mcp.env]` `{pack_dir:}` expansion — the wiring must point at the version
163/// that actually landed on disk (GH #727, Finding B).
164pub fn already_satisfied(
165    project_root: &std::path::Path,
166    registry: &super::registry::LocalRegistry,
167    dep: &PackageDependency,
168) -> Option<ResolvedDep> {
169    let lock = super::lockfile::load(project_root).ok()?;
170    let locked = lock.packages.iter().find(|p| p.name == dep.name)?;
171    let req = parse_version_req(&dep.version_req).ok()?;
172    let version = semver::Version::parse(&locked.version).ok()?;
173    if !req.matches(&version) {
174        return None;
175    }
176    let installed = registry.get(&dep.name, Some(&locked.version)).ok()??;
177    let remote_ref = remote::parse_remote_ref(&dep.name)?;
178    Some(ResolvedDep {
179        name: dep.name.clone(),
180        namespace: remote_ref.namespace,
181        slug: remote_ref.name,
182        version: installed.version,
183        artifact_sha256: locked.artifact_sha256.clone(),
184    })
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    fn v(version: &str, yanked: bool) -> VersionInfo {
192        VersionInfo {
193            version: version.into(),
194            artifact_sha256: "a".repeat(64),
195            yanked,
196        }
197    }
198
199    #[test]
200    fn picks_highest_matching_version() {
201        let versions = [v("1.0.0", false), v("1.2.0", false), v("2.0.0", false)];
202        let req = parse_version_req("^1.0").unwrap();
203        assert_eq!(
204            pick_highest_match(&versions, &req).unwrap().version,
205            "1.2.0"
206        );
207    }
208
209    #[test]
210    fn yanked_versions_never_match() {
211        let versions = [v("1.0.0", false), v("1.3.0", true)];
212        let req = parse_version_req("^1.0").unwrap();
213        assert_eq!(
214            pick_highest_match(&versions, &req).unwrap().version,
215            "1.0.0"
216        );
217    }
218
219    #[test]
220    fn no_match_yields_none() {
221        let versions = [v("1.0.0", false)];
222        let req = parse_version_req("^2.0").unwrap();
223        assert!(pick_highest_match(&versions, &req).is_none());
224    }
225
226    #[test]
227    fn star_and_empty_match_anything() {
228        let versions = [v("0.3.7", false)];
229        for raw in ["", "*", "  "] {
230            let req = parse_version_req(raw).unwrap();
231            assert_eq!(
232                pick_highest_match(&versions, &req).unwrap().version,
233                "0.3.7",
234                "req `{raw}`"
235            );
236        }
237    }
238
239    #[test]
240    fn non_semver_index_entries_are_skipped() {
241        let versions = [v("not-a-version", false), v("1.1.0", false)];
242        let req = parse_version_req("^1").unwrap();
243        assert_eq!(
244            pick_highest_match(&versions, &req).unwrap().version,
245            "1.1.0"
246        );
247    }
248
249    #[test]
250    fn invalid_range_is_an_error() {
251        assert!(parse_version_req(">>nope<<").is_err());
252    }
253
254    #[test]
255    fn self_dependency_is_refused() {
256        let mut manifest = crate::core::context_package::manifest::PackageManifest {
257            dependencies: vec![PackageDependency {
258                name: "@acme/root".into(),
259                version_req: "^1".into(),
260                optional: false,
261            }],
262            ..minimal("@acme/root")
263        };
264        // resolve_one is exercised via resolve_dependencies; the self-check
265        // fires before any network I/O, so an invalid base URL never matters.
266        let err = resolve_dependencies(
267            &manifest.dependencies,
268            Some(&manifest.name),
269            "http://127.0.0.1:1",
270            None,
271        )
272        .unwrap_err();
273        assert!(err.contains("depends on itself"), "got: {err}");
274
275        // Optional dependencies are skipped entirely.
276        manifest.dependencies[0].optional = true;
277        assert_eq!(
278            resolve_dependencies(
279                &manifest.dependencies,
280                Some(&manifest.name),
281                "http://127.0.0.1:1",
282                None
283            )
284            .unwrap(),
285            Vec::new()
286        );
287    }
288
289    /// The **addon** install path (GH #727, Finding A) resolves self-dependency
290    /// against the addon's *scoped* self-reference (`@ns/slug`), which
291    /// [`crate::cli::addon_cmd::addon_self_ref`] derives from the install
292    /// source — never the bare `[addon] name` slug that the pre-fix code
293    /// passed. The existing `self_dependency_is_refused` goes through
294    /// `PackageManifest.name` (already scoped) and so never covered this path.
295    ///
296    /// This is the regression guard: with the scoped root the guard fires
297    /// before any network I/O; with the pre-fix bare slug it never fires (the
298    /// bare slug cannot equal a scoped `@ns/name` dependency), so resolution
299    /// falls through to the network instead of refusing.
300    #[test]
301    fn addon_scoped_self_dependency_is_refused() {
302        let dep = PackageDependency {
303            name: "@dasTholo/demo".into(),
304            version_req: "^0.2".into(),
305            optional: false,
306        };
307
308        // Fixed addon path: scoped self-ref → refused before any network I/O
309        // (the bad base URL is never reached).
310        let err =
311            resolve_one(Some("@dasTholo/demo"), &dep, "http://127.0.0.1:1", None).unwrap_err();
312        assert!(err.contains("depends on itself"), "got: {err}");
313
314        // Regression witness: the pre-fix bare slug (`[addon] name` = "demo")
315        // does NOT trip the guard — proving why a bare name must never be
316        // passed as the root. Resolution proceeds to the (refused) network, so
317        // the error is anything BUT "depends on itself".
318        let err_bare = resolve_one(Some("demo"), &dep, "http://127.0.0.1:1", None).unwrap_err();
319        assert!(
320            !err_bare.contains("depends on itself"),
321            "bare slug must not trip the self-guard; got: {err_bare}"
322        );
323
324        // A source with no namespace (`None`) is vacuous by construction — same
325        // fall-through, never a false self-match.
326        let err_none = resolve_one(None, &dep, "http://127.0.0.1:1", None).unwrap_err();
327        assert!(
328            !err_none.contains("depends on itself"),
329            "None root must not trip the self-guard; got: {err_none}"
330        );
331    }
332
333    #[test]
334    fn unscoped_dependency_is_refused() {
335        let manifest = crate::core::context_package::manifest::PackageManifest {
336            dependencies: vec![PackageDependency {
337                name: "plain-name".into(),
338                version_req: "^1".into(),
339                optional: false,
340            }],
341            ..minimal("@acme/root")
342        };
343        let err = resolve_dependencies(
344            &manifest.dependencies,
345            Some(&manifest.name),
346            "http://127.0.0.1:1",
347            None,
348        )
349        .unwrap_err();
350        assert!(err.contains("not a scoped"), "got: {err}");
351    }
352
353    #[test]
354    fn already_satisfied_returns_the_locked_version_as_a_resolved_dep() {
355        use crate::core::context_package::lockfile::{self, LockedPackage};
356
357        let store = tempfile::tempdir().unwrap();
358        let proj = tempfile::tempdir().unwrap();
359        let registry = super::super::registry::LocalRegistry::open_at(store.path()).unwrap();
360
361        // The pack is installed on disk at the older, in-range 0.2.0…
362        let mut manifest = minimal("@ns/skills");
363        manifest.version = "0.2.0".into();
364        registry
365            .install(&manifest, &super::super::content::PackageContent::default())
366            .unwrap();
367
368        // …and the lockfile pins exactly that version.
369        lockfile::upsert(
370            proj.path(),
371            LockedPackage {
372                name: "@ns/skills".into(),
373                version: "0.2.0".into(),
374                artifact_sha256: "a".repeat(64),
375                registry: "https://example.test".into(),
376            },
377        )
378        .unwrap();
379
380        let dep = PackageDependency {
381            name: "@ns/skills".into(),
382            version_req: "^0.2".into(),
383            optional: false,
384        };
385        let resolved =
386            already_satisfied(proj.path(), &registry, &dep).expect("locked + present on disk");
387
388        // Regression (GH #727, Finding B): the version the env path burns in must
389        // be the LOCKED/on-disk one — never a fresh highest-match resolve that
390        // could point `{pack_dir:}` at a directory that does not exist.
391        assert_eq!(resolved.version, "0.2.0");
392        assert_eq!(resolved.name, "@ns/skills");
393        assert_eq!(resolved.namespace, "ns");
394        assert_eq!(resolved.slug, "skills");
395    }
396
397    fn minimal(name: &str) -> crate::core::context_package::manifest::PackageManifest {
398        use crate::core::context_package::manifest::*;
399        PackageManifest {
400            schema_version: crate::core::contracts::CONTEXT_PACKAGE_V2_SCHEMA_VERSION,
401            conformance_level: None,
402            kind: PackageKind::default(),
403            name: name.into(),
404            version: "1.0.0".into(),
405            description: "d".into(),
406            author: None,
407            scope: None,
408            created_at: chrono::Utc::now(),
409            updated_at: None,
410            layers: vec![],
411            dependencies: vec![],
412            tags: vec![],
413            visibility: None,
414            integrity: PackageIntegrity {
415                sha256: "a".repeat(64),
416                content_hash: "b".repeat(64),
417                byte_size: 1,
418            },
419            provenance: PackageProvenance {
420                tool: "lean-ctx".into(),
421                tool_version: "0".into(),
422                project_hash: None,
423                source_session_id: None,
424            },
425            compatibility: CompatibilitySpec::default(),
426            stats: PackageStats::default(),
427            signature: None,
428            graph_summary: None,
429            marketplace: None,
430        }
431    }
432}