diff --git a/pacquet/crates/cli/tests/custom_resolvers.rs b/pacquet/crates/cli/tests/custom_resolvers.rs index a1377dabf3..db959a5957 100644 --- a/pacquet/crates/cli/tests/custom_resolvers.rs +++ b/pacquet/crates/cli/tests/custom_resolvers.rs @@ -146,3 +146,79 @@ fn failing_should_refresh_resolution_aborts_the_install() { drop((root, mock_instance)); // cleanup } + +/// Port of upstream's `'custom resolver receives currentPkg on +/// subsequent installs'` (`installing/deps-installer/test/install/customResolvers.ts`). +/// +/// The first install records the npm resolver's pick — a `Registry` +/// (`{integrity}`-only) lockfile entry. The second install is forced to +/// re-resolve via `shouldRefreshResolution`; the resolver must receive +/// that entry as `currentPkg` with the tarball URL re-derived from the +/// registry, and echoing it back must keep the pinned version. +#[test] +fn custom_resolver_receives_current_pkg_on_subsequent_installs() { + let CommandTempCwd { pacquet, root, workspace, npmrc_info, .. } = + CommandTempCwd::init().add_mocked_registry(); + let AddMockedRegistry { mock_instance, .. } = npmrc_info; + + write_manifest(&workspace); + // First install: the resolver claims nothing, so the npm resolver + // pins 100.0.0 and the lockfile compacts it to `{integrity}`. + fs::write( + workspace.join(".pnpmfile.cjs"), + "module.exports = { resolvers: [{ shouldRefreshResolution: () => false }] }\n", + ) + .expect("write pnpmfile"); + pacquet.with_arg("install").assert().success(); + assert_eq!(installed_version(&workspace), "100.0.0"); + + fs::write( + workspace.join(".pnpmfile.cjs"), + r"const fs = require('node:fs'); +const path = require('node:path'); +module.exports = { + resolvers: [ + { + canResolve (wanted) { + return wanted.alias === '@pnpm.e2e/dep-of-pkg-with-1-dep'; + }, + resolve (wanted, opts) { + fs.writeFileSync(path.join(opts.lockfileDir, 'resolver-opts.json'), JSON.stringify(opts)); + if (!opts.currentPkg) { + throw new Error('expected currentPkg on the second install'); + } + return { id: opts.currentPkg.id, resolution: opts.currentPkg.resolution }; + }, + shouldRefreshResolution: () => true, + }, + ], +} +", + ) + .expect("rewrite pnpmfile"); + pacquet_at(&workspace).with_arg("install").assert().success(); + + assert_eq!(installed_version(&workspace), "100.0.0", "echoing currentPkg keeps the pin"); + let opts: serde_json::Value = serde_json::from_str( + &fs::read_to_string(workspace.join("resolver-opts.json")).expect("resolver dumped opts"), + ) + .expect("parse dumped opts"); + let current_pkg = &opts["currentPkg"]; + assert_eq!(current_pkg["id"], "@pnpm.e2e/dep-of-pkg-with-1-dep@100.0.0"); + assert_eq!(current_pkg["name"], "@pnpm.e2e/dep-of-pkg-with-1-dep"); + assert_eq!(current_pkg["version"], "100.0.0"); + // The on-disk entry is `{integrity}`-only; the payload must carry + // the tarball URL re-derived from the registry, like pnpm's + // `pkgSnapshotToResolution`. + let tarball = current_pkg["resolution"]["tarball"].as_str().expect("derived tarball URL"); + assert!( + tarball.ends_with("/@pnpm.e2e/dep-of-pkg-with-1-dep/-/dep-of-pkg-with-1-dep-100.0.0.tgz"), + "got: {tarball}", + ); + assert!( + current_pkg["resolution"]["integrity"].is_string(), + "the recorded integrity carries over: {current_pkg}", + ); + + drop((root, mock_instance)); // cleanup +} diff --git a/pacquet/crates/lockfile/src/resolution.rs b/pacquet/crates/lockfile/src/resolution.rs index f345f494be..39c8a89b7c 100644 --- a/pacquet/crates/lockfile/src/resolution.rs +++ b/pacquet/crates/lockfile/src/resolution.rs @@ -2,7 +2,7 @@ use derive_more::{From, TryInto}; use pipe_trait::Pipe; use serde::{Deserialize, Serialize}; use ssri::Integrity; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; /// For tarball hosted remotely or locally. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -335,6 +335,44 @@ pub fn npm_tarball_url(name: &str, version: &str, registry: &str) -> String { format!("{registry}{name}/-/{scopeless}-{version}.tgz") } +/// Default-vs-scope routing for an npm package. Mirrors pnpm's +/// [`pickRegistryForPackage`](https://github.com/pnpm/pnpm/blob/main/config/pick-registry-for-package/src/index.ts). +/// +/// Routing rules: +/// +/// 1. **`npm:` alias.** When `bare_specifier` is an `npm:` alias the +/// *alias target* decides routing, not the local key: +/// - `npm:@scope/name@` → `registries[@scope]`. +/// - `npm:name@` (unscoped target) → `registries["default"]`, +/// never the local alias's scope, because the fetched package is +/// unscoped and doesn't live on a scoped registry. +/// 2. **Plain spec.** Falls back to `pkg_name`'s scope when present; +/// otherwise `registries["default"]`. +#[must_use] +pub fn pick_registry_for_package( + registries: &HashMap, + pkg_name: &str, + bare_specifier: Option<&str>, +) -> String { + let scope = match bare_specifier.and_then(|spec| spec.strip_prefix("npm:")) { + Some(target) => scope_of(target), + None => scope_of(pkg_name), + }; + if let Some(scope) = scope + && let Some(url) = registries.get(scope) + { + return url.clone(); + } + registries.get("default").cloned().unwrap_or_default() +} + +fn scope_of(name: &str) -> Option<&str> { + if !name.starts_with('@') { + return None; + } + name.find('/').map(|sep| &name[..sep]) +} + /// Strip the URL scheme (everything up to and including `://`). Port of pnpm's /// `removeProtocol` (`url.split('://')[1]`). fn remove_protocol(url: &str) -> &str { diff --git a/pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs b/pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs index 51af365d89..14dae309b0 100644 --- a/pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs +++ b/pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs @@ -593,7 +593,7 @@ impl InstallWithFreshLockfile<'_, DependencyGroupList> { let progress_reported = SharedReportedProgressKeys::default(); let npm_resolver: Arc = Arc::new(NpmResolver { - registries, + registries: registries.clone(), named_registries: merged_named_registries.clone(), http_client: Arc::clone(&http_client_arc), auth_headers: Arc::clone(&auth_headers), @@ -1096,6 +1096,7 @@ impl InstallWithFreshLockfile<'_, DependencyGroupList> { .map(Arc::new), update_reuse_scope, auto_install_peers: config.auto_install_peers, + registries, }; let modules_basename = config.modules_dir.file_name().map_or_else( || std::ffi::OsString::from("node_modules"), diff --git a/pacquet/crates/resolving-deps-resolver/src/lockfile_reuse.rs b/pacquet/crates/resolving-deps-resolver/src/lockfile_reuse.rs index 6b2ef442fd..db538f43bf 100644 --- a/pacquet/crates/resolving-deps-resolver/src/lockfile_reuse.rs +++ b/pacquet/crates/resolving-deps-resolver/src/lockfile_reuse.rs @@ -10,13 +10,85 @@ use std::collections::HashMap; use node_semver::Range; use pacquet_lockfile::{ Lockfile, LockfileResolution, PkgName, PkgNameVer, PkgNameVerPeer, ProjectSnapshot, - ResolvedDependencySpec, + ResolvedDependencySpec, SnapshotEntry, TarballResolution, npm_tarball_url, + pick_registry_for_package, }; -use pacquet_resolving_resolver_base::{PkgResolutionId, ResolveResult}; +use pacquet_resolving_resolver_base::{CurrentPkg, PkgResolutionId, ResolveResult}; use serde_json::{Map, Value}; use crate::hoist_peers::satisfies_including_prerelease; +/// The `currentPkg` payload for re-resolving `key`'s edge: the prior +/// lockfile entry in the shape pnpm's `getInfoFromLockfile` + +/// [`pkgSnapshotToResolution`](https://github.com/pnpm/pnpm/blob/1627943d2a/lockfile/utils/src/pkgSnapshotToResolution.ts) +/// hand the resolver — a `Registry` entry regains its derived tarball +/// URL, every other shape passes through as recorded. `None` when the +/// lockfile has no `packages:` entry for the key (matching upstream, +/// where a missing `resolution` leaves `currentPkg.resolution` +/// undefined so the entry can be autofixed by a fresh resolve). +pub(crate) fn current_pkg_from_lockfile( + lockfile: &Lockfile, + key: &PkgNameVerPeer, + registries: &HashMap, +) -> Option { + let metadata_key = key.without_peer(); + let metadata = lockfile.packages.as_ref()?.get(&metadata_key)?; + let name = metadata_key.name.to_string(); + let version = metadata + .version + .clone() + .or_else(|| metadata_key.suffix.version_semver().map(ToString::to_string)); + let resolution = match &metadata.resolution { + LockfileResolution::Registry(registry_resolution) => { + let registry = pick_registry_for_package(registries, &name, None); + if registry.is_empty() { + // No registry map was threaded in (e.g. the + // single-importer entry point) — a `Registry` entry + // can't be materialized into its tarball URL, and a + // URL-less payload would diverge from pnpm's shape. + return None; + } + let tarball_version = metadata_key.suffix.version().to_string(); + LockfileResolution::Tarball(TarballResolution { + tarball: npm_tarball_url(&name, &tarball_version, ®istry), + integrity: Some(registry_resolution.integrity.clone()), + git_hosted: None, + path: None, + }) + } + recorded => recorded.clone(), + }; + Some(CurrentPkg { + id: PkgResolutionId::from(metadata_key.to_string()), + name: Some(name), + version, + resolution, + published_at: None, + }) +} + +/// The prior snapshot key recorded for child edge `alias` under +/// `snapshot`'s dependency maps, when the recorded version still +/// satisfies `bare_specifier`. The satisfies gate mirrors pnpm's +/// `referenceSatisfiesWantedSpec` guard on `resolvedDependencies` +/// references. +pub(crate) fn prior_child_key( + snapshot: &SnapshotEntry, + alias: &str, + bare_specifier: &str, +) -> Option { + let name: PkgName = alias.parse().ok()?; + let dep_ref = snapshot + .dependencies + .as_ref() + .and_then(|deps| deps.get(&name)) + .or_else(|| snapshot.optional_dependencies.as_ref().and_then(|deps| deps.get(&name)))?; + let key = dep_ref.resolve(&name)?; + let range = bare_specifier.parse::().ok()?; + let satisfied = satisfies_including_prerelease(&range, key.suffix.version_semver()?); + satisfied.then_some(key) +} + /// The snapshot key (`snapshots:` / `packages:` map key) the prior /// lockfile resolved `alias` to in importer `importer_id`, when the /// recorded version still satisfies the manifest's `bare_specifier` diff --git a/pacquet/crates/resolving-deps-resolver/src/lockfile_reuse/tests.rs b/pacquet/crates/resolving-deps-resolver/src/lockfile_reuse/tests.rs index a2fb6ac87b..c04ca495e7 100644 --- a/pacquet/crates/resolving-deps-resolver/src/lockfile_reuse/tests.rs +++ b/pacquet/crates/resolving-deps-resolver/src/lockfile_reuse/tests.rs @@ -160,3 +160,97 @@ fn does_not_reuse_a_non_semver_version_slot() { lockfile.packages = Some(HashMap::from([(key.clone(), registry_metadata())])); assert!(synthesize_reused_result(&lockfile, &key, "pkg").is_none()); } + +fn default_registry() -> HashMap { + HashMap::from([("default".to_string(), "https://registry.example.test/".to_string())]) +} + +#[test] +fn current_pkg_materializes_a_registry_resolution_into_its_tarball_url() { + let key: PkgNameVerPeer = "react@18.2.0(foo@1.0.0)".parse().expect("parse key"); + let mut lockfile = empty_lockfile(); + lockfile.packages = + Some(HashMap::from([("react@18.2.0".parse().expect("parse key"), registry_metadata())])); + + let current_pkg = super::current_pkg_from_lockfile(&lockfile, &key, &default_registry()) + .expect("packages entry exists"); + + assert_eq!(current_pkg.id.to_string(), "react@18.2.0"); + assert_eq!(current_pkg.name.as_deref(), Some("react")); + assert_eq!(current_pkg.version.as_deref(), Some("18.2.0")); + let LockfileResolution::Tarball(tarball) = ¤t_pkg.resolution else { + panic!("registry resolution must materialize as a tarball: {:?}", current_pkg.resolution); + }; + assert_eq!(tarball.tarball, "https://registry.example.test/react/-/react-18.2.0.tgz"); + assert!(tarball.integrity.is_some(), "the recorded integrity carries over"); +} + +#[test] +fn current_pkg_routes_a_scoped_package_to_its_scope_registry() { + let key: PkgNameVerPeer = "@scope/pkg@1.0.0".parse().expect("parse key"); + let mut lockfile = empty_lockfile(); + lockfile.packages = Some(HashMap::from([(key.clone(), registry_metadata())])); + let mut registries = default_registry(); + registries.insert("@scope".to_string(), "https://scoped.example.test/".to_string()); + + let current_pkg = super::current_pkg_from_lockfile(&lockfile, &key, ®istries) + .expect("packages entry exists"); + + let LockfileResolution::Tarball(tarball) = ¤t_pkg.resolution else { + panic!("registry resolution must materialize as a tarball"); + }; + assert_eq!(tarball.tarball, "https://scoped.example.test/@scope/pkg/-/pkg-1.0.0.tgz"); +} + +#[test] +fn current_pkg_passes_a_recorded_tarball_resolution_through() { + let key: PkgNameVerPeer = "pkg@1.0.0".parse().expect("parse key"); + let mut metadata = registry_metadata(); + metadata.resolution = LockfileResolution::Tarball(TarballResolution { + tarball: "https://example.test/pkg-1.0.0.tgz".to_string(), + integrity: None, + git_hosted: None, + path: None, + }); + let mut lockfile = empty_lockfile(); + lockfile.packages = Some(HashMap::from([(key.clone(), metadata)])); + + let current_pkg = super::current_pkg_from_lockfile(&lockfile, &key, &default_registry()) + .expect("packages entry exists"); + + let LockfileResolution::Tarball(tarball) = ¤t_pkg.resolution else { + panic!("tarball resolution must pass through"); + }; + assert_eq!(tarball.tarball, "https://example.test/pkg-1.0.0.tgz"); +} + +#[test] +fn current_pkg_is_none_without_a_packages_entry() { + let key: PkgNameVerPeer = "react@18.2.0".parse().expect("parse key"); + let lockfile = empty_lockfile(); + assert!(super::current_pkg_from_lockfile(&lockfile, &key, &default_registry()).is_none()); +} + +#[test] +fn current_pkg_is_withheld_for_a_registry_entry_without_a_registry_map() { + let key: PkgNameVerPeer = "react@18.2.0".parse().expect("parse key"); + let mut lockfile = empty_lockfile(); + lockfile.packages = Some(HashMap::from([(key.clone(), registry_metadata())])); + assert!(super::current_pkg_from_lockfile(&lockfile, &key, &HashMap::new()).is_none()); +} + +#[test] +fn prior_child_key_applies_the_satisfies_gate() { + let snapshot: pacquet_lockfile::SnapshotEntry = + serde_json::from_value(serde_json::json!({ "dependencies": { "bar": "1.2.0" } })) + .expect("parse snapshot entry"); + + let key = super::prior_child_key(&snapshot, "bar", "^1.0.0").expect("recorded ref satisfies"); + assert_eq!(key.to_string(), "bar@1.2.0"); + + assert!( + super::prior_child_key(&snapshot, "bar", "^2.0.0").is_none(), + "an edited range the recorded version no longer satisfies yields no prior key", + ); + assert!(super::prior_child_key(&snapshot, "baz", "^1.0.0").is_none(), "unrecorded alias"); +} diff --git a/pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs b/pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs index 354e1d5e3e..4232e5a417 100644 --- a/pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs +++ b/pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree.rs @@ -33,7 +33,9 @@ fn lock_recoverable(mutex: &Mutex) -> MutexGuard<'_, Inner> { } use crate::{ - lockfile_reuse::{reusable_importer_dep, synthesize_reused_result}, + lockfile_reuse::{ + current_pkg_from_lockfile, prior_child_key, reusable_importer_dep, synthesize_reused_result, + }, node_id::NodeId, resolved_tree::{DependenciesTreeNode, DirectDep, PeerDep, ResolvedPackage, ResolvedTree}, }; @@ -75,10 +77,44 @@ enum ReuseSource { /// ancestor discarded its child-refs, forcing this subtree to /// re-resolve (pnpm's `parentPkg.updated ? undefined : refs`). Transitive { key: Option }, + /// A child of a freshly-resolved parent: subtree reuse stays + /// disabled, but when the parent re-resolved to its previously + /// recorded version the child's prior snapshot ref is still + /// meaningful — it feeds the `currentPkg` payload of the child's + /// own re-resolution. Mirrors pnpm, where a non-`updated` parent + /// keeps `resolvedDependencies` references alive. + PriorOnly { key: Option }, /// Reuse disabled for this node (no prior lockfile). Off, } +impl ReuseSource { + /// The prior lockfile snapshot key recorded for this edge, if any — + /// the basis of both subtree reuse and the `currentPkg` payload. + /// The `Importer` arm applies the semver-satisfies gate + /// ([`reusable_importer_dep`]), mirroring pnpm's + /// `referenceSatisfiesWantedSpec` guard on lockfile references. + fn prior_key(&self, ctx: &TreeCtx, wanted: &WantedDependency) -> Option { + let lockfile = ctx.workspace.wanted_lockfile.as_ref()?; + match self { + ReuseSource::Importer { importer_id } => reusable_importer_dep( + &lockfile.importers, + importer_id, + wanted.alias.as_deref()?, + wanted.bare_specifier.as_deref()?, + ), + ReuseSource::Transitive { key } | ReuseSource::PriorOnly { key } => key.clone(), + ReuseSource::Off => None, + } + } + + /// Whether this edge may reuse the prior lockfile's subtree. + /// `PriorOnly` keeps the key for `currentPkg` but never reuses. + fn allows_reuse(&self) -> bool { + matches!(self, ReuseSource::Importer { .. } | ReuseSource::Transitive { .. }) + } +} + /// Options threaded into [`fn@resolve_dependency_tree`]. /// /// Mirrors upstream's per-importer options; pacquet's slice is single- @@ -423,6 +459,7 @@ type WantedKey = ( bool, Option>, Option, + Option, ); /// Whether a wanted dep's resolution is computed relative to the @@ -510,6 +547,12 @@ pub struct WorkspaceTreeCtx { /// peer edge supplies the package instead. See /// [`omit_peer_shadowed_dependencies`]. auto_install_peers: bool, + /// Resolved registry map (`"default"` + per-scope) used to + /// materialize a prior `Registry` lockfile resolution back into its + /// tarball URL for the `currentPkg` payload. Empty when the entry + /// point doesn't thread registries (then `currentPkg` is withheld + /// for `Registry`-shaped entries rather than sent without a URL). + registries: HashMap, /// `pkg id → importer id` of the importer whose walk first resolved /// each package. Mirrors the ownership implied by upstream's /// per-`pkgId` shared subtree records @@ -544,6 +587,7 @@ impl Default for WorkspaceTreeCtx { pnpmfile_hook: None, read_package_log: None, auto_install_peers: false, + registries: HashMap::new(), first_importer_by_pkg: Mutex::new(HashMap::new()), first_walk_missing_by_pkg: Mutex::new(HashMap::new()), } @@ -649,6 +693,13 @@ impl WorkspaceTreeCtx { self } + /// Attach the resolved registry map. See the `registries` field. + #[must_use] + pub fn with_registries(mut self, registries: HashMap) -> Self { + self.registries = registries; + self + } + /// Take ownership of `self` and emit the final [`ResolvedTree`]. /// Pacquet's single-importer path consumes the context via /// [`TreeCtx::into_resolved_tree`], which routes through here once @@ -994,6 +1045,11 @@ where { let current_is_optional = wanted.optional.unwrap_or(false) || parent_optional; + // The edge's recorded snapshot key in the prior lockfile, if any. + // Feeds both subtree reuse (below) and — when the edge re-resolves + // anyway — the `currentPkg` payload custom resolvers receive. + let prior_key = reuse.prior_key(ctx, &wanted); + // **Lockfile-resolution reuse.** When the prior lockfile already // resolved this edge (and the recorded version still satisfies the // manifest range, for a direct dep), synthesize the resolution from @@ -1003,7 +1059,9 @@ where // `synthesize_reused_result` is conservative: any shape it can't // faithfully reproduce (non-registry resolutions, missing metadata) // yields `None` here and the node falls through to a fresh resolve. - if let Some(reused) = try_reuse_node(ctx, &wanted, reuse) { + if reuse.allows_reuse() + && let Some(reused) = try_reuse_node(ctx, &wanted, prior_key.as_ref()) + { return resolve_reused_node( ctx, resolver, @@ -1032,9 +1090,29 @@ where // a direct (`depth == 0`) or transitive dep, so the cache key and // the resolver call both key off the depth-specific options. let opts = ctx.opts_for_depth(depth); + // The prior lockfile entry rides along as `currentPkg`, mirroring + // pnpm's `currentPkg: extendedWantedDep.infoFromLockfile` hand-off + // to the resolver. Only custom resolvers read it today; the clone + // of the shared per-depth options is paid only when a prior entry + // exists for a freshly resolving edge. + let current_pkg = prior_key.as_ref().and_then(|key| { + let lockfile = ctx.workspace.wanted_lockfile.as_ref()?; + current_pkg_from_lockfile(lockfile, key, &ctx.workspace.registries) + }); + let opts_with_current_pkg; + let opts = match current_pkg { + Some(current_pkg) => { + opts_with_current_pkg = + ResolveOptions { current_pkg: Some(current_pkg), ..opts.clone() }; + &opts_with_current_pkg + } + None => opts, + }; // Project-relative resolutions (`link:`/`file:`/`workspace:`) are // keyed by the consuming importer so one importer's relative path - // is never reused by another. See [`WantedKey`]. + // is never reused by another. See [`WantedKey`]. The prior key + // joins so two edges that share a specifier but recorded different + // versions never share a `currentPkg`-dependent result. let project_scope = is_project_relative_specifier(wanted.bare_specifier.as_deref()) .then(|| ctx.base_opts.project_dir.clone()); let cache_key: WantedKey = ( @@ -1045,6 +1123,7 @@ where opts.pick_lowest_version, opts.published_by, project_scope, + prior_key.clone(), ); let cached = lock_recoverable(&ctx.workspace.resolved_by_wanted).get(&cache_key).map(Arc::clone); @@ -1249,6 +1328,17 @@ where .or_insert_with(|| Arc::clone(&specs)); specs }; + // A freshly-resolved node forces its whole subtree to + // re-resolve — pnpm's `resolvedDependencies = parentPkg.updated + // ? undefined`. But when the parent landed back on its + // previously recorded version, pnpm keeps the prior child refs + // (the non-`updated` arm), so each child's re-resolution still + // receives its `currentPkg`. `PriorOnly` is that arm: the key + // rides along for `currentPkg` while reuse stays disabled. + let prior_children_snapshot = prior_key + .as_ref() + .filter(|key| landed_on_prior_entry(key, &id)) + .and_then(|key| ctx.workspace.wanted_lockfile.as_ref()?.snapshots.as_ref()?.get(key)); let child_results = child_specs .iter() .map(|(child_name, child_range, child_optional)| { @@ -1258,12 +1348,10 @@ where optional: Some(*child_optional), ..WantedDependency::default() }; + let child_prior = prior_children_snapshot + .and_then(|snapshot| prior_child_key(snapshot, child_name, child_range)); let next_ancestors = next_ancestors.clone(); async move { - // A freshly-resolved node forces its whole subtree - // to re-resolve — pnpm's `resolvedDependencies = - // parentPkg.updated ? undefined`. `ReuseSource::Off` - // is the `undefined` arm. resolve_node( ctx, resolver, @@ -1271,7 +1359,7 @@ where &next_ancestors, depth + 1, current_is_optional, - ReuseSource::Off, + ReuseSource::PriorOnly { key: child_prior }, ) .await } @@ -1331,6 +1419,18 @@ where Ok(Some(DirectDep { alias, node_id, id })) } +/// Whether a freshly resolved node landed back on its previously +/// recorded lockfile entry — pnpm's `parentPkg.updated == false` arm, +/// which keeps the prior child refs alive. Compares suffix-stripped +/// forms on both sides: `resolved_pkg_id` is the canonical dep-path id +/// ([`build_pkg_id_with_patch_hash`]'s output, which may carry a +/// `(patch_hash=…)` suffix and `name@`-prefixes `file:`/git/tarball +/// ids), and the recorded key may carry peer and patch-hash suffixes — +/// none of which change *which package version* the parent is. +fn landed_on_prior_entry(prior_key: &PkgNameVerPeer, resolved_pkg_id: &str) -> bool { + prior_key.without_peer().to_string() == pacquet_deps_path::remove_suffix(resolved_pkg_id) +} + /// One reusable node: its prior-lockfile snapshot key plus the /// `ResolveResult` synthesized from the lockfile metadata. struct ReusedNode { @@ -1339,36 +1439,31 @@ struct ReusedNode { } /// Decide whether the current edge can reuse the prior lockfile's -/// resolution. Returns the synthesized node when the edge's whole -/// transitive subtree is reusable; `None` (fresh resolve) otherwise. +/// resolution. `prior_key` is the edge's recorded snapshot key (see +/// [`ReuseSource::prior_key`]). Returns the synthesized node when the +/// edge's whole transitive subtree is reusable; `None` (fresh resolve) +/// otherwise. /// -/// Conservative on every axis: no prior lockfile, an unsatisfied direct -/// range, a `link:` / non-registry shape anywhere in the subtree, or a -/// missing snapshot entry all yield `None`. See -/// [`fn@subtree_fully_reusable`] for the recursive subtree check. +/// Conservative on every axis: no prior lockfile, no recorded key, a +/// `link:` / non-registry shape anywhere in the subtree, or a missing +/// snapshot entry all yield `None`. See [`fn@subtree_fully_reusable`] +/// for the recursive subtree check. fn try_reuse_node( ctx: &TreeCtx, wanted: &WantedDependency, - reuse: ReuseSource, + prior_key: Option<&PkgNameVerPeer>, ) -> Option { let lockfile = ctx.workspace.wanted_lockfile.as_ref()?; if matches!(ctx.workspace.update_reuse_scope, UpdateReuseScope::None) { return None; } let alias = wanted.alias.as_deref()?; - let key = match reuse { - ReuseSource::Importer { importer_id } => { - let bare_specifier = wanted.bare_specifier.as_deref()?; - reusable_importer_dep(&lockfile.importers, &importer_id, alias, bare_specifier)? - } - ReuseSource::Transitive { key } => key?, - ReuseSource::Off => return None, - }; - if !subtree_fully_reusable(ctx, lockfile, &key) { + let key = prior_key?; + if !subtree_fully_reusable(ctx, lockfile, key) { return None; } - let result = synthesize_reused_result(lockfile, &key, alias)?; - Some(ReusedNode { key, result }) + let result = synthesize_reused_result(lockfile, key, alias)?; + Some(ReusedNode { key: key.clone(), result }) } /// `true` when `name` is a `pacquet update` target excluded from reuse. @@ -1491,6 +1586,7 @@ where opts.pick_lowest_version, opts.published_by, project_scope, + Some(key.clone()), ); lock_recoverable(&ctx.workspace.resolved_by_wanted) .entry(cache_key) @@ -2006,3 +2102,6 @@ const NON_EXOTIC_RESOLVED_VIA: &[&str] = &[ fn is_exotic_resolved_via(resolved_via: &str) -> bool { !NON_EXOTIC_RESOLVED_VIA.contains(&resolved_via) } + +#[cfg(test)] +mod tests; diff --git a/pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree/tests.rs b/pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree/tests.rs new file mode 100644 index 0000000000..0dedac3205 --- /dev/null +++ b/pacquet/crates/resolving-deps-resolver/src/resolve_dependency_tree/tests.rs @@ -0,0 +1,37 @@ +use pacquet_lockfile::PkgNameVerPeer; + +use super::landed_on_prior_entry; + +fn key(raw: &str) -> PkgNameVerPeer { + raw.parse().expect("parse snapshot key") +} + +#[test] +fn matches_a_plain_registry_id() { + assert!(landed_on_prior_entry(&key("foo@1.0.0"), "foo@1.0.0")); + assert!(!landed_on_prior_entry(&key("foo@1.0.0"), "foo@1.1.0")); +} + +#[test] +fn strips_the_recorded_key_peer_and_patch_suffixes() { + assert!(landed_on_prior_entry(&key("foo@1.0.0(bar@2.0.0)"), "foo@1.0.0")); + assert!(landed_on_prior_entry(&key("foo@1.0.0(patch_hash=0000)"), "foo@1.0.0")); + assert!(landed_on_prior_entry(&key("foo@1.0.0(patch_hash=0000)(bar@2.0.0)"), "foo@1.0.0")); +} + +#[test] +fn strips_the_resolved_id_patch_suffix() { + assert!(landed_on_prior_entry( + &key("foo@1.0.0(patch_hash=0000)"), + "foo@1.0.0(patch_hash=0000)" + )); + assert!(landed_on_prior_entry(&key("foo@1.0.0"), "foo@1.0.0(patch_hash=0000)")); +} + +#[test] +fn matches_a_name_prefixed_file_id() { + // `build_pkg_id_with_patch_hash` prefixes `file:` / git / tarball + // ids with the manifest name, matching the recorded key shape. + assert!(landed_on_prior_entry(&key("foo@file:packages/foo"), "foo@file:packages/foo")); + assert!(!landed_on_prior_entry(&key("foo@file:packages/foo"), "file:packages/foo")); +} diff --git a/pacquet/crates/resolving-deps-resolver/src/resolve_workspace.rs b/pacquet/crates/resolving-deps-resolver/src/resolve_workspace.rs index c6bea53455..13f92c5fd5 100644 --- a/pacquet/crates/resolving-deps-resolver/src/resolve_workspace.rs +++ b/pacquet/crates/resolving-deps-resolver/src/resolve_workspace.rs @@ -34,7 +34,7 @@ use crate::{ use chrono::{DateTime, Duration, Utc}; use pacquet_package_manifest::{DependencyGroup, PackageManifest}; use pacquet_resolving_resolver_base::{Resolver, WantedDependency, parse_packument_timestamp}; -use std::{path::PathBuf, sync::Arc}; +use std::{collections::HashMap, path::PathBuf, sync::Arc}; /// One importer's input to [`fn@resolve_workspace`]. pub struct WorkspaceImporter<'a> { @@ -113,6 +113,11 @@ pub struct WorkspaceResolveOptions { /// [`crate::ResolveImporterOptions::auto_install_peers`] — the /// setting is workspace-wide, like pnpm's `autoInstallPeers`. pub auto_install_peers: bool, + /// Resolved registry map (`"default"` + per-scope), for + /// materializing a prior `Registry` lockfile resolution back into + /// its tarball URL when building the `currentPkg` payload custom + /// resolvers receive. + pub registries: HashMap, } /// Result of [`fn@resolve_workspace`]. The combined @@ -160,6 +165,7 @@ where wanted_lockfile, update_reuse_scope, auto_install_peers, + registries, } = opts; let workspace = Arc::new( WorkspaceTreeCtx::default() @@ -168,7 +174,8 @@ where .with_update_reuse_scope(update_reuse_scope) .with_pnpmfile_hook(pnpmfile_hook) .with_read_package_log(read_package_log) - .with_auto_install_peers(auto_install_peers), + .with_auto_install_peers(auto_install_peers) + .with_registries(registries), ); // Build every importer's options up front so the `time-based` diff --git a/pacquet/crates/resolving-deps-resolver/src/resolve_workspace/tests.rs b/pacquet/crates/resolving-deps-resolver/src/resolve_workspace/tests.rs index 2e5c7d2f48..0922bb3b12 100644 --- a/pacquet/crates/resolving-deps-resolver/src/resolve_workspace/tests.rs +++ b/pacquet/crates/resolving-deps-resolver/src/resolve_workspace/tests.rs @@ -143,6 +143,7 @@ fn workspace_opts(pick_lowest_direct: bool, time_based: bool) -> WorkspaceResolv wanted_lockfile: None, update_reuse_scope: crate::UpdateReuseScope::All, auto_install_peers: false, + registries: HashMap::new(), } } diff --git a/pacquet/crates/resolving-npm-resolver/src/lib.rs b/pacquet/crates/resolving-npm-resolver/src/lib.rs index 819f6f8d3c..657995156b 100644 --- a/pacquet/crates/resolving-npm-resolver/src/lib.rs +++ b/pacquet/crates/resolving-npm-resolver/src/lib.rs @@ -50,10 +50,11 @@ pub use fetch_full_metadata_cached::{FetchFullMetadataCachedOptions, fetch_full_ pub use mirror::{ABBREVIATED_META_DIR, FULL_META_DIR}; pub use named_registry::{ BUILTIN_NAMED_REGISTRIES, MergeNamedRegistriesError, build_named_registry_prefixes, - merge_named_registries, pick_registry_for_package, pick_registry_for_version, + merge_named_registries, pick_registry_for_version, }; pub use named_registry_resolver::NamedRegistryResolver; pub use npm_resolver::NpmResolver; +pub use pacquet_lockfile::pick_registry_for_package; pub use parse_bare_specifier::{ JsrRegistryPackageSpec, NamedRegistryPackageSpec, ParseNamedRegistrySpecifierError, parse_bare_specifier, parse_jsr_specifier_to_registry_package_spec, diff --git a/pacquet/crates/resolving-npm-resolver/src/named_registry.rs b/pacquet/crates/resolving-npm-resolver/src/named_registry.rs index 70bc61acb3..6046c5ee2c 100644 --- a/pacquet/crates/resolving-npm-resolver/src/named_registry.rs +++ b/pacquet/crates/resolving-npm-resolver/src/named_registry.rs @@ -17,6 +17,7 @@ use std::collections::HashMap; use derive_more::{Display, Error}; use miette::Diagnostic; +pub use pacquet_lockfile::pick_registry_for_package; use reqwest::Url; /// Built-in named-registry aliases the resolver recognizes @@ -152,43 +153,5 @@ pub fn pick_registry_for_version( pick_registry_for_package(registries, name, None) } -/// Default-vs-scope routing for an npm package. Mirrors pnpm's -/// [`pickRegistryForPackage`](https://github.com/pnpm/pnpm/blob/main/config/pick-registry-for-package/src/index.ts). -/// -/// Routing rules: -/// -/// 1. **`npm:` alias.** When `bare_specifier` is an `npm:` alias the -/// *alias target* decides routing, not the local key: -/// - `npm:@scope/name@` → `registries[@scope]`. -/// - `npm:name@` (unscoped target) → `registries["default"]`, -/// never the local alias's scope, because the fetched package is -/// unscoped and doesn't live on a scoped registry. -/// 2. **Plain spec.** Falls back to `pkg_name`'s scope when present; -/// otherwise `registries["default"]`. -#[must_use] -pub fn pick_registry_for_package( - registries: &HashMap, - pkg_name: &str, - bare_specifier: Option<&str>, -) -> String { - let scope = match bare_specifier.and_then(|spec| spec.strip_prefix("npm:")) { - Some(target) => scope_of(target), - None => scope_of(pkg_name), - }; - if let Some(scope) = scope - && let Some(url) = registries.get(scope) - { - return url.clone(); - } - registries.get("default").cloned().unwrap_or_default() -} - -fn scope_of(name: &str) -> Option<&str> { - if !name.starts_with('@') { - return None; - } - name.find('/').map(|sep| &name[..sep]) -} - #[cfg(test)] mod tests;