mirror of
https://github.com/pnpm/pnpm.git
synced 2026-06-28 09:55:39 -04:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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, ®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<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`
|
||||
|
||||
@@ -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) = ¤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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user