feat(pacquet): hand custom resolvers the currentPkg of a re-resolving edge (#12361)

* feat(pacquet): hand custom resolvers the currentPkg of a re-resolving edge

Custom resolvers received currentPkg: null on every call because nothing
populated ResolveOptions.current_pkg. Mirror pnpm's hand-off
(currentPkg: extendedWantedDep.infoFromLockfile in resolveDependencies):

- the tree walker now derives each edge's prior lockfile snapshot key
  (importer refs satisfies-gated like pnpm's referenceSatisfiesWantedSpec;
  transitive keys from the parent's snapshot) and, when the edge
  re-resolves anyway, threads the recorded entry into the per-call
  ResolveOptions as currentPkg
- a Registry ({integrity}-only) entry regains its derived tarball URL,
  like pnpm's pkgSnapshotToResolution; pick_registry_for_package moved
  from pacquet-resolving-npm-resolver to pacquet-lockfile (next to
  npm_tarball_url) so the deps-resolver can route scoped packages, with
  a re-export keeping existing callers unchanged
- children of a freshly resolved parent that landed back on its
  recorded version keep their prior refs for currentPkg purposes
  (pnpm's non-updated parentPkg arm) via the new ReuseSource::PriorOnly;
  subtree reuse semantics are unchanged
- the per-wanted memo key includes the prior key so two edges sharing a
  specifier but recording different versions never share a
  currentPkg-dependent result

The e2e test ports upstream's 'custom resolver receives currentPkg on
subsequent installs': a forced re-resolve receives the prior entry with
the re-derived tarball URL, and echoing it back keeps the pinned
version.

* fix(pacquet): gate prior child refs on the canonical dep-path id

The 'parent landed back on its recorded entry' check compared the
recorded snapshot key against the raw resolver id, which is not the
canonical dep-path form: build_pkg_id_with_patch_hash may append a
(patch_hash=...) suffix and name@-prefix file:/git/tarball ids. Extract
the comparison into landed_on_prior_entry, which strips suffixes from
both sides (the recorded key's peers/patch hash via without_peer, the
resolved id via remove_suffix) so the gate keys on which package
version the parent is, like pnpm's parentPkg.updated flag.
This commit is contained in:
Zoltan Kochan
2026-06-12 17:26:44 +02:00
committed by GitHub
parent 52148e6916
commit 2aa2eaa6ff
11 changed files with 460 additions and 71 deletions

View File

@@ -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
}

View File

@@ -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@<spec>` → `registries[@scope]`.
/// - `npm:name@<spec>` (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<String, String>,
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 {

View File

@@ -593,7 +593,7 @@ impl<DependencyGroupList> InstallWithFreshLockfile<'_, DependencyGroupList> {
let progress_reported = SharedReportedProgressKeys::default();
let npm_resolver: Arc<dyn Resolver> = 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<DependencyGroupList> 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"),

View File

@@ -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<String, String>,
) -> Option<CurrentPkg> {
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, &registry),
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<PkgNameVerPeer> {
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::<Range>().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`

View File

@@ -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<String, String> {
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) = &current_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, &registries)
.expect("packages entry exists");
let LockfileResolution::Tarball(tarball) = &current_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) = &current_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");
}

View File

@@ -33,7 +33,9 @@ fn lock_recoverable<Inner>(mutex: &Mutex<Inner>) -> 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<PkgNameVerPeer> },
/// 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<PkgNameVerPeer> },
/// 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<PkgNameVerPeer> {
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<DateTime<Utc>>,
Option<PathBuf>,
Option<PkgNameVerPeer>,
);
/// 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<String, String>,
/// `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<String, String>) -> 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<ReusedNode> {
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;

View File

@@ -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"));
}

View File

@@ -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<String, String>,
}
/// 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`

View File

@@ -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(),
}
}

View File

@@ -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,

View File

@@ -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@<spec>` → `registries[@scope]`.
/// - `npm:name@<spec>` (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<String, String>,
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;