Skip to content

pacquet: wire NpmResolver into the install resolution stage #11756

@zkochan

Description

@zkochan

Goal

Wire the npm-registry version-picking surface ported in #11755 into the install command so resolution actually flows through the Resolver chain instead of the legacy Package::fetch_from_registry + Package::pinned_version path.

Part of #11633 Stage 2.

Current state (after #11755)

Library code exists; nothing in package-manager or cli calls it.

Piece Where Wired into install?
Resolver trait + dispatcher contract pacquet-resolving-resolver-base::resolve No (chain is unused)
DefaultResolver chain pacquet-resolving-default-resolver Constructed nowhere; chain is empty
parse_wanted_dependency pacquet-resolving-parse-wanted-dependency No (raw → (alias, bareSpecifier) split; no protocol routing yet)
pick_package / pick_package_from_meta pacquet-resolving-npm-resolver No — what this issue is about
NpmResolutionVerifier pacquet-resolving-npm-resolver Yes (lockfile-verification gate)
Legacy Package::pinned_version path pacquet/crates/package-manager/src/install_package_from_registry.rs:97-108 Yes — currently the only resolution call in install

The legacy path is a plain max-satisfying lookup. It has no latest-tag short-circuit, no preferred-versions bias, no deprecated-fallback, no minimumReleaseAge filter, no in-memory metaCache, no offline / preferOffline support, no conditional GET, and no policy-violation surfacing.

Plan

Phase A — npm resolver bridge

  1. Port parseBareSpecifier (upstream) into pacquet-resolving-npm-resolver. Produces RegistryPackageSpec from (bare_specifier, alias, default_tag, registry). Handles the npm:-alias form, tarball-URL parse, and tag/version/range discrimination via version-selector-type.
  2. Add NpmResolver struct in pacquet-resolving-npm-resolver. Owns:
    • default registry URL + per-scope registries map (pacquet_config::Registries)
    • named_registries
    • Arc<ThrottledClient> + Arc<AuthHeaders>
    • Arc<dyn PackageMetaCache> (shared across resolves so one install warms one cache)
    • cache_dir
    • flags: offline, prefer_offline, ignore_missing_time_field
  3. Implement Resolver::resolve on NpmResolver:
    • run parseBareSpecifier on wanted_dependency.bare_specifier (use wanted_dependency.alias + opts.default_tag)
    • NoneOk(None) (decline, chain continues to next resolver)
    • pick registry via pick_registry_for_package (already exists in named_registry.rs)
    • build PickPackageOptions from ResolveOptions (preferred-versions, pick-lowest, etc.)
    • call pick_package
    • map PickPackageResultResolveResult: id = PkgNameVer, latest = meta.dist_tag("latest"), published_at = meta.published_at(version), manifest = JSON, resolution = LockfileResolution::TarballResolution { integrity, tarball }, resolved_via = "npm-registry", normalized_bare_specifier, alias
  4. Implement Resolver::resolve_latest on NpmResolver (for pnpm outdated / pnpm update --latest).
  5. Surface policy_violation: after the pick, if opts.published_by is set and the picked version's published_at is past the cutoff, populate ResolveResult.policy_violation with the MinimumReleaseAge variant (mirroring upstream's pickRespectingMinReleaseAge fall-back-to-lowest reporting flow).

Phase B — chain wiring

  1. Construct NpmResolver at install entry (config, http_client, auth_headers, meta_cache all available there).
  2. Build DefaultResolver::new(vec![Box::new(NpmResolver { … })]). Other protocols (git, tarball, workspace, etc.) are separate Tier 2 items and stay out of this chain until their crates land.
  3. Hand the DefaultResolver (as a boxed dyn Resolver) into the install path.

Phase C — install refactor (faithful resolveDependencyTree port)

Port pnpm's two-pass approach: build the full resolved tree first (resolve-only, no fetch), then run the fetch+install pass over that tree. Larger refactor than a per-package swap but lines up with eventual lockfile generation, enables sibling dedup, and gives preferred-versions sharing a place to live.

  1. Port resolveDependencyTree (or the minimum slice needed to drive npm-registry resolution end-to-end) into a new pacquet crate, e.g. pacquet-resolving-deps-resolver. Outputs a resolved tree keyed by (name, version) with parent/child edges + per-node ResolveResult.
  2. Refactor install_package_from_registry:
    • take a pre-resolved ResolveResult (name, version, integrity, tarball URL, manifest) instead of (name, version_range).
    • drop the Package::fetch_from_registry + pinned_version calls.
    • use ResolveResult.resolution for the tarball fetch step.
  3. Refactor install_without_lockfile.rs (both call sites at L145 and L299) so the top-level call runs resolveDependencyTree first, then walks the resulting tree calling install_package_from_registry on each node. The recursion in install becomes tree-traversal, not interleaved resolve+install.
  4. Decide where the cross-call PackageMetaCache lives: probably owned by the resolveDependencyTree driver and dropped after the resolve pass completes.

Phase D — preferred-versions construction

  1. Bootstrap preferred_versions from pnpm-lock.yaml snapshots: in --frozen-lockfile mode the lockfile is already loaded for verification, so harvest the existing pins as EXISTING_VERSION_SELECTOR_WEIGHT entries before the resolve pass begins.
  2. Plumb prev_specifier on WantedDependency from the lockfile entry so the resolver can prefer the previously-pinned version when no update was requested.

Phase E — policy-violation surfacing

  1. Add a collector on the install side that aggregates ResolveResult.policy_violation across all resolves, modeled after upstream's handleResolutionPolicyViolations and the existing lockfile-verifier's violation flow.
  2. On strict-mode minimumReleaseAge, abort install with the collected set; on non-strict, surface as warnings (matching the verifier's split).

Phase F — tests

  1. Unit tests on NpmResolver:
    • parseBareSpecifier parity across the upstream test corpus (resolving/npm-resolver/test/parseBareSpecifier.test.ts is already in TEST_PORTING.md)
    • scope-based registry picking
    • ResolveResult shape verification
    • policy_violation population on immature picks
  2. Unit tests on the resolveDependencyTree driver: dedup across siblings, preferred-versions propagation, error aggregation.
  3. Integration test driving install through the chain via the mocked registry — byte-identical node_modules/ to a pnpm install of the same lockfile.
  4. Update pacquet/plans/TEST_PORTING.md checkboxes.

Phase G — docs

  1. Update crates/resolving-npm-resolver/src/lib.rs module-doc to reflect that the resolver is now active in install.
  2. Note the wiring in pacquet/AGENTS.md if any agent-facing guidance needs to change.

Out of scope

  • Other-protocol resolvers (git, tarball, local, jsr, named-registry, workspace). Each is a separate Tier 2 item under Rust Roadmap #11633 and adds an entry to the DefaultResolver chain when it lands.
  • Abbreviated metadata fetcher. Pacquet currently fetches full metadata only — documented in feat(pacquet): resolver scaffold + npm version picking #11755. Adding an abbreviated fetcher is a follow-up: Accept-header swap, second mirror dir at v11/metadata, fullMetadata: bool flag through the orchestrator, and resurrection of the abbreviated→full upgrade-for-releaseAge path that's already structurally in place.
  • p-limit(1) per-mirror serialization. Atomic rename in save_meta covers write safety today.
  • dry_run no-disk-side-effects. Today's fetcher still warms the on-disk mirror under dry_run; restoring upstream parity needs a write-bypass flag on the fetcher.
  • Lockfile generation. Pacquet's install today is --frozen-lockfile only; writable-lockfile installs are a separate roadmap item, though Phase C's two-pass shape is a prerequisite.

Acceptance criteria

  • install_package_from_registry no longer calls Package::fetch_from_registry or Package::pinned_version. Resolution flows entirely through the Resolver chain.
  • DefaultResolver has at least one resolver registered (NpmResolver).
  • A pacquet install --frozen-lockfile of a package whose lockfile entry was resolved by pnpm produces byte-identical node_modules/ (modulo timestamps), verifying parity through the chain.
  • minimumReleaseAge violations on resolution (not just lockfile verification) surface through the install layer with the same exit code / reporter events as the verifier path.
  • All existing pacquet-package-manager tests continue to pass.

References


Written by an agent (Claude Code, claude-opus-4-7).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions