Skip to content

fix(lockfile): emit pacquet lockfile maps in canonical key order#12120

Merged
zkochan merged 1 commit into
mainfrom
fix/12117
Jun 2, 2026
Merged

fix(lockfile): emit pacquet lockfile maps in canonical key order#12120
zkochan merged 1 commit into
mainfrom
fix/12117

Conversation

@zkochan

@zkochan zkochan commented Jun 1, 2026

Copy link
Copy Markdown
Member

What

Makes pacquet's pnpm-lock.yaml serialization byte-stable by emitting every lockfile map in canonical key order, matching pnpm. Fixes #12117.

Why

pacquet serialized the importers, packages, snapshots, and per-importer/per-snapshot dependency maps in std::HashMap iteration order — a per-instance random seed — so two installs of the same resolution could emit byte-different lockfiles, producing spurious git diffs on no-op re-installs. pnpm's lockfile is canonically ordered (sortLockfileKeys).

How

  • Two serialize_with helpers in serialize_yaml (sorted_map / sorted_map_opt) sort each map by its rendered key string at emit time, wired onto every map field across lib.rs, project_snapshot.rs, snapshot_entry.rs, and package_metadata.rs. The in-memory maps stay HashMap (no iteration-order ripple elsewhere).
  • Sorting by the rendered key (not a field-wise Ord) is load-bearing and matches pnpm's lexCompare: the @ separating name@version and the leading @ of a scoped name both order differently as struct fields than as the concatenated string pnpm compares (react-dom@1.0.0 before react@17.0.2; @types/node before node).
  • overrides is the one map pnpm deliberately leaves out of sortLockfileKeys (it keeps declaration order). Rather than sort it, the lockfile's overrides field moves from HashMap to IndexMap so the user's declaration order is preserved — deterministic and faithful to pnpm.

Tests

  • The reuse-vs-fresh equivalence test (surfaced this in perf(pacquet): reuse lockfile resolutions on re-resolution #12113) now asserts byte-for-byte parity, replacing the parsed-struct comparison that worked around the old non-determinism.
  • New reinstalling_an_unchanged_manifest_keeps_the_lockfile_byte_identical guards that a no-op re-install leaves the lockfile bytes unchanged.

Verification

cargo check --workspace --all-targets, clippy (-D warnings), cargo dylint, typos, and cargo fmt/taplo all clean. pacquet-lockfile (183), pacquet-package-manager (381), and the reuse + inject CLI tests pass.

pacquet-only (pnpm already orders canonically), so no TypeScript change and no changeset.


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

Summary by CodeRabbit

  • Bug Fixes

    • Fixed lockfile byte-ordering to be deterministic and not affected by build order, ensuring consistent output across fresh and reused lockfile generation.
  • Tests

    • Enhanced tests to verify byte-for-byte lockfile correctness and added validation that re-installing unchanged manifests produces identical lockfiles.

pacquet serialized the `importers`, `packages`, `snapshots`, and
per-importer/per-snapshot dependency maps in `std::HashMap` iteration
order — a per-instance random seed — so two installs of the same
resolution could emit byte-different `pnpm-lock.yaml` files. This
diverges from pnpm, whose lockfile is canonically ordered, and produces
spurious git diffs on no-op re-installs (#12117).

Sort every lockfile map by its rendered key string at emit time via two
`serialize_with` helpers in `serialize_yaml`, matching pnpm's
`sortLockfileKeys`/`lexCompare`. Sorting by the rendered key (not a
field-wise `Ord`) is load-bearing: the `@` separating `name@version` and
the leading `@` of a scoped name both order differently as struct fields
than as the concatenated string pnpm compares.

`overrides` is the one map pnpm leaves unsorted (declaration order), so
it moves from `HashMap` to `IndexMap` to preserve insertion order rather
than being sorted — deterministic and faithful to pnpm.

The reuse-vs-fresh equivalence test now asserts byte-for-byte parity (was
a parsed-struct comparison working around the old non-determinism), and a
new test guards that a no-op re-install leaves the lockfile bytes
unchanged.
@coderabbitai

coderabbitai Bot commented Jun 1, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 76cd49de-dd6a-4e1e-91d7-3fffab39c6a4

📥 Commits

Reviewing files that changed from the base of the PR and between 5c669d7 and 8256187.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (11)
  • pacquet/crates/cli/tests/inject_workspace_packages.rs
  • pacquet/crates/cli/tests/lockfile_resolution_reuse.rs
  • pacquet/crates/lockfile/Cargo.toml
  • pacquet/crates/lockfile/src/lib.rs
  • pacquet/crates/lockfile/src/package_metadata.rs
  • pacquet/crates/lockfile/src/project_snapshot.rs
  • pacquet/crates/lockfile/src/serialize_yaml.rs
  • pacquet/crates/lockfile/src/snapshot_entry.rs
  • pacquet/crates/package-manager/src/dependencies_graph_to_lockfile.rs
  • pacquet/crates/package-manager/src/dependencies_graph_to_lockfile/tests.rs
  • pacquet/plans/LOCKFILE_RESOLUTION_REUSE.md
📜 Recent review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Agent
🧰 Additional context used
📓 Path-based instructions (3)
pacquet/**/Cargo.toml

📄 CodeRabbit inference engine (pacquet/AGENTS.md)

pacquet/**/Cargo.toml: Check whether the workspace already depends on something suitable in [workspace.dependencies] in the root Cargo.toml before adding a new dependency
Keep dependencies at the right level — add a new dependency to the specific crate that needs it, not to the workspace root or shared crate unless multiple crates depend on it

Files:

  • pacquet/crates/lockfile/Cargo.toml
pacquet/**/*.rs

📄 CodeRabbit inference engine (pacquet/AGENTS.md)

pacquet/**/*.rs: Log emissions are part of matching pnpm — when porting a function that fires pnpm:<channel> events through globalLogger, logger.debug(...), or streamParser.write(...), mirror the call site, payload, and ordering so @pnpm/cli.default-reporter parses pacquet's NDJSON the same way
Declare a newtype wrapper for branded string types instead of collapsing the brand into a plain String or &str in Rust
If upstream TypeScript always validates before construction of a branded string, validate in the Rust wrapper too via TryFrom<String> and/or FromStr and do not provide an infallible public constructor
If upstream TypeScript never validates a branded string, just brand for type-safety in Rust by exposing an infallible From<String> constructor
If upstream TypeScript occasionally constructs a branded string without validation, expose from_str_unchecked in Rust as an escape hatch alongside the validating constructor
Match upstream serde behavior for branded strings crossing JSON, YAML, or INI boundaries by using #[serde(try_from = "String")] for deserialization and #[serde(into = "String")] for serialization
Derive simple conversions for branded strings using #[derive(derive_more::From)] and #[derive(derive_more::Into)] instead of handwriting impl blocks; use manual impl only when conversion needs custom logic
Model TypeScript string literal unions (like 'auto' | 'always' | 'never') as Rust enums instead of newtype wrappers, since the set of valid values is closed
Treat TypeScript string template literal types (like `${string}@${string}`) the same as branded string types in Rust, using a newtype wrapper with validation
Follow the code style guide in CODE_STYLE_GUIDE.md — imports, modules, naming, ownership and borrowing, parameter type selection, trait bounds, pattern matching, pipe-trait, error handling, test layout, and cloning of Arc and Rc
Choose owned vs. borrowed parameters to minimize copies; widen to t...

Files:

  • pacquet/crates/cli/tests/inject_workspace_packages.rs
  • pacquet/crates/lockfile/src/serialize_yaml.rs
  • pacquet/crates/package-manager/src/dependencies_graph_to_lockfile/tests.rs
  • pacquet/crates/lockfile/src/project_snapshot.rs
  • pacquet/crates/cli/tests/lockfile_resolution_reuse.rs
  • pacquet/crates/lockfile/src/package_metadata.rs
  • pacquet/crates/lockfile/src/snapshot_entry.rs
  • pacquet/crates/package-manager/src/dependencies_graph_to_lockfile.rs
  • pacquet/crates/lockfile/src/lib.rs
pacquet/**/tests/**/*.rs

📄 CodeRabbit inference engine (pacquet/AGENTS.md)

pacquet/**/tests/**/*.rs: Prefer real fixtures over dependency-injection seams — use tempfile::TempDir, the mocked registry, or integration tests spawning the actual binary for happy paths and most error paths; use the DI seam only for filesystem error kinds, deterministic time, shared process-global state, or external-service happy paths
Follow test-logging guidance — log before non-assert_eq! assertions, dbg! complex structures, skip logging for simple scalar assert_eq!
Tests must not be tolerant of missing build/runtime environment by silently returning early when a tool isn't found — let the test panic when a required tool is absent
Prefer #[cfg_attr(target_os = "windows", ignore = "...")] (or matching #[cfg(unix)] gates) over runtime probe-and-skip helpers for platform-locked tools
Port relevant pnpm tests to Rust tests whenever they translate when porting behavior from pnpm
Use snapshot tests with insta and carefully review diffs when intentional changes alter snapshots; accept with cargo insta review only after careful review
Tests that need the mocked registry should start pnpr through pacquet-testing-utils; cargo test / cargo nextest run should not require a separate just registry-mock launch step

Files:

  • pacquet/crates/cli/tests/inject_workspace_packages.rs
  • pacquet/crates/cli/tests/lockfile_resolution_reuse.rs
🧠 Learnings (4)
📚 Learning: 2026-05-20T19:40:55.051Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11774
File: pacquet/crates/resolving-deps-resolver/src/resolve_peers.rs:0-0
Timestamp: 2026-05-20T19:40:55.051Z
Learning: In the pacquet Rust code, ensure the semver implementation uses the `node-semver` crate (not `nodejs-semver`). `node-semver`’s public API does not include a `satisfies_with_prerelease`-style method; prerelease-tolerant matching should be implemented inline by first calling `Range::satisfies`, and when it rejects a prerelease version, retry matching against a stripped `MAJOR.MINOR.PATCH` base of the prerelease version.

Applied to files:

  • pacquet/crates/cli/tests/inject_workspace_packages.rs
  • pacquet/crates/lockfile/src/serialize_yaml.rs
  • pacquet/crates/package-manager/src/dependencies_graph_to_lockfile/tests.rs
  • pacquet/crates/lockfile/src/project_snapshot.rs
  • pacquet/crates/cli/tests/lockfile_resolution_reuse.rs
  • pacquet/crates/lockfile/src/package_metadata.rs
  • pacquet/crates/lockfile/src/snapshot_entry.rs
  • pacquet/crates/package-manager/src/dependencies_graph_to_lockfile.rs
  • pacquet/crates/lockfile/src/lib.rs
📚 Learning: 2026-05-22T00:08:44.646Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11837
File: pacquet/crates/resolving-npm-resolver/src/pick_package.rs:33-51
Timestamp: 2026-05-22T00:08:44.646Z
Learning: In the pnpm/pnpm repo’s pacquet Rust crates, do not flag Unicode ellipsis characters (U+2026, `…`) in Rust doc comments (`///` / `/** */`) as a lint violation. The pacquet crate’s `dylint.toml` only enables `perfectionist::derive_ordering`, and the Dylint `unicode-ellipsis` rule is not enabled for this project—so `…` in doc comments is an intentional, repo-consistent style.

Applied to files:

  • pacquet/crates/cli/tests/inject_workspace_packages.rs
  • pacquet/crates/lockfile/src/serialize_yaml.rs
  • pacquet/crates/package-manager/src/dependencies_graph_to_lockfile/tests.rs
  • pacquet/crates/lockfile/src/project_snapshot.rs
  • pacquet/crates/cli/tests/lockfile_resolution_reuse.rs
  • pacquet/crates/lockfile/src/package_metadata.rs
  • pacquet/crates/lockfile/src/snapshot_entry.rs
  • pacquet/crates/package-manager/src/dependencies_graph_to_lockfile.rs
  • pacquet/crates/lockfile/src/lib.rs
📚 Learning: 2026-05-20T23:07:58.444Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11784
File: pacquet/crates/resolving-deps-resolver/src/hoist_peers.rs:120-133
Timestamp: 2026-05-20T23:07:58.444Z
Learning: When reviewing code in this pacquet Rust port, follow the upstream pnpm compatibility rule: only match pnpm’s behavior exactly. Do not propose review changes that intentionally deviate from pnpm’s documented/observed behavior, even if pnpm appears buggy. If you identify a real bug in pnpm behavior, the review should prioritize fixing it upstream in pnpm first, and avoid implementing a pnpm-behavior workaround here unless the same fix has already landed upstream.

Applied to files:

  • pacquet/crates/cli/tests/inject_workspace_packages.rs
  • pacquet/crates/lockfile/src/serialize_yaml.rs
  • pacquet/crates/package-manager/src/dependencies_graph_to_lockfile/tests.rs
  • pacquet/crates/lockfile/src/project_snapshot.rs
  • pacquet/crates/cli/tests/lockfile_resolution_reuse.rs
  • pacquet/crates/lockfile/src/package_metadata.rs
  • pacquet/crates/lockfile/src/snapshot_entry.rs
  • pacquet/crates/package-manager/src/dependencies_graph_to_lockfile.rs
  • pacquet/crates/lockfile/src/lib.rs
📚 Learning: 2026-05-20T23:23:40.606Z
Learnt from: zkochan
Repo: pnpm/pnpm PR: 11788
File: pacquet/crates/lockfile/src/snapshot_dep_ref.rs:52-56
Timestamp: 2026-05-20T23:23:40.606Z
Learning: In the pacquet lockfile crate, the link target is intentionally represented as a raw `Link(String)` (not a `LinkTarget` newtype) for both `SnapshotDepRef::Link` and `ImporterDepVersion::Link`. pnpm upstream preserves `link:` targets verbatim with only a prefix check (e.g., `refToRelative`), so do not suggest introducing a `LinkTarget` newtype in a bug-fix/targeted PR. If type branding is ever desired, it should be done together for both variants in a separate dedicated refactor PR rather than as part of a small fix.

Applied to files:

  • pacquet/crates/lockfile/src/serialize_yaml.rs
  • pacquet/crates/lockfile/src/project_snapshot.rs
  • pacquet/crates/lockfile/src/package_metadata.rs
  • pacquet/crates/lockfile/src/snapshot_entry.rs
  • pacquet/crates/lockfile/src/lib.rs
🔇 Additional comments (13)
pacquet/crates/lockfile/src/serialize_yaml.rs (1)

9-15: LGTM!

Also applies to: 29-77

pacquet/crates/lockfile/Cargo.toml (1)

19-19: LGTM!

pacquet/crates/lockfile/src/lib.rs (1)

40-40: LGTM!

Also applies to: 102-110, 141-158

pacquet/crates/package-manager/src/dependencies_graph_to_lockfile.rs (1)

16-16: LGTM!

Also applies to: 86-90

pacquet/crates/package-manager/src/dependencies_graph_to_lockfile/tests.rs (1)

1-2: LGTM!

Also applies to: 13-16, 22-29

pacquet/crates/lockfile/src/project_snapshot.rs (1)

10-29: LGTM!

pacquet/crates/lockfile/src/package_metadata.rs (1)

16-19: LGTM!

Also applies to: 40-49

pacquet/crates/lockfile/src/snapshot_entry.rs (1)

21-30: LGTM!

pacquet/crates/cli/tests/lockfile_resolution_reuse.rs (3)

112-129: LGTM!


160-162: LGTM!


168-204: LGTM!

pacquet/crates/cli/tests/inject_workspace_packages.rs (1)

141-143: LGTM!

pacquet/plans/LOCKFILE_RESOLUTION_REUSE.md (1)

99-105: LGTM!


📝 Walkthrough

Walkthrough

The PR makes pnpm-lock.yaml byte-stable by implementing sorted-map serialization to canonicalize YAML output. It introduces serde helpers to emit maps by sorted rendered keys, switches overrides fields to IndexMap for insertion-order preservation, applies sorted serialization across the lockfile schema, and verifies determinism through updated tests comparing byte-identical lockfiles across fresh and reused installs.

Changes

Lockfile determinism and byte-stability

Layer / File(s) Summary
Sorted-map serialization infrastructure
pacquet/crates/lockfile/src/serialize_yaml.rs
Module imports updated to include SerializeMap and Serializer. Two new helpers added: sorted_map iterates a HashMap, sorts entries by rendered key string, and emits them in canonical order; sorted_map_opt wraps it for Option<HashMap> by delegating to sorted_map for Some and emitting YAML null for None.
Preserve insertion order for overrides
pacquet/crates/lockfile/Cargo.toml, pacquet/crates/lockfile/src/lib.rs, pacquet/crates/package-manager/src/dependencies_graph_to_lockfile.rs, pacquet/crates/package-manager/src/dependencies_graph_to_lockfile/tests.rs
Workspace dependency indexmap added. Lockfile.overrides and GraphToLockfileOptions.overrides switched from HashMap to IndexMap with updated imports across all affected files and tests, preserving user declaration order in serialization.
Apply canonical sorting to lockfile maps
pacquet/crates/lockfile/src/lib.rs, pacquet/crates/lockfile/src/project_snapshot.rs, pacquet/crates/lockfile/src/package_metadata.rs, pacquet/crates/lockfile/src/snapshot_entry.rs
Serde attributes for all map fields (Lockfile.importers, Lockfile.packages, ProjectSnapshot specifiers/dependencies/optional_dependencies, PackageMetadata engines/peer_dependencies, SnapshotEntry.dependencies) updated with serialize_with = crate::serialize_yaml::sorted_map* to emit keys in canonical order.
Verify byte-determinism with updated tests
pacquet/crates/cli/tests/lockfile_resolution_reuse.rs
Module documentation reworded from "structurally identical" to "byte-for-byte identical". Comments expanded explaining that lockfile maps are sorted by rendered key at emit time so insertion order cannot affect bytes. Existing reuse-vs-fresh test replaced struct comparison with direct lockfile string equality assertion. New test added: constructs manifest with multiple dependencies, runs pacquet install twice, asserts pnpm-lock.yaml bytes remain identical between runs.
Clarify test assertions for model-based validation
pacquet/crates/cli/tests/inject_workspace_packages.rs
Comment updated to describe lockfile assertions as performed by parsing YAML into Pacquet's lockfile model, removing reference to YAML string-slicing due to resolved ordering concerns.
Plan documentation update
pacquet/plans/LOCKFILE_RESOLUTION_REUSE.md
Issue "lockfile byte-ordering is build-order-dependent" marked as fixed. Documentation replaced explanation of map reordering on re-install with canonical sorted emission at serialization time. Tests/guards updated to enforce byte-parity requirement and mention reinstall-stability check for unchanged manifests.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

  • pnpm/pacquet#431: Lockfile importer map serialization changes touch the same code paths affected by this PR's determinism fix.

Possibly related PRs

  • pnpm/pnpm#12113: Lockfile-resolution reuse tests depend on deterministic pnpm-lock.yaml serialization, which this PR directly enables through canonical sorted-map emission.
  • pnpm/pnpm#11904: Frozen-install short-circuit relies on byte-equality checks of wanted vs on-disk lockfiles, which this PR stabilizes through sorted serialization.

Poem

🐰 Lockfiles in order, no more random seeds!
Sorted keys emit, a deterministic deed.
Byte-stable YAML, fresh installs agree—
No spurious diffs in git's history!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely describes the main change: emitting lockfile maps in canonical key order to fix byte-stability issues.
Linked Issues check ✅ Passed All coding requirements from issue #12117 are fully implemented: maps use sorted serialization, byte-parity is verified through tests, and byte-stability tests confirm identical lockfile output across installs.
Out of Scope Changes check ✅ Passed All changes are within scope of issue #12117: fixing map ordering via sorted serialization, updating tests for byte-parity, and adding reinstall stability verification. No unrelated modifications present.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/12117

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

github-actions Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Micro-Benchmark Results

Linux

group                          main                                   pr
-----                          ----                                   --
tarball/download_dependency    1.00      7.8±0.27ms   557.3 KB/sec    1.03      8.0±0.25ms   541.1 KB/sec

@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.42857% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 87.41%. Comparing base (ae6e077) to head (8256187).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
pacquet/crates/lockfile/src/serialize_yaml.rs 96.42% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #12120      +/-   ##
==========================================
+ Coverage   87.34%   87.41%   +0.06%     
==========================================
  Files         255      260       +5     
  Lines       28703    29375     +672     
==========================================
+ Hits        25072    25679     +607     
- Misses       3631     3696      +65     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions

github-actions Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Integrated-Benchmark Report (Linux)

Scenario: Isolated linker: fresh restore, cold cache + cold store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 2.178 ± 0.187 2.051 2.647 1.05 ± 0.09
pacquet@main 2.083 ± 0.039 2.026 2.145 1.00
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 2.17753773488,
      "stddev": 0.186686222498313,
      "median": 2.10834939198,
      "user": 2.6813086800000003,
      "system": 3.4833246599999996,
      "min": 2.0510873584800002,
      "max": 2.6471934964800004,
      "times": [
        2.14776205148,
        2.11288342948,
        2.05391287748,
        2.15829035048,
        2.0704396694800002,
        2.0510873584800002,
        2.0795935444800002,
        2.35039921648,
        2.6471934964800004,
        2.10381535448
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 2.0829256513800005,
      "stddev": 0.03938835033056582,
      "median": 2.0868610494800004,
      "user": 2.6635872800000002,
      "system": 3.43882536,
      "min": 2.02629232948,
      "max": 2.14486361248,
      "times": [
        2.03524125248,
        2.0476763134800002,
        2.14486361248,
        2.06027947948,
        2.1110459714800003,
        2.1154741714800003,
        2.11466128448,
        2.08783176848,
        2.02629232948,
        2.0858903304800003
      ]
    }
  ]
}

Scenario: Isolated linker: fresh restore, hot cache + hot store

Command Mean [ms] Min [ms] Max [ms] Relative
pacquet@HEAD 659.8 ± 9.1 649.8 674.7 1.00
pacquet@main 700.1 ± 38.9 661.4 790.8 1.06 ± 0.06
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 0.6597741216000002,
      "stddev": 0.009058340956507958,
      "median": 0.6591102968,
      "user": 0.36403121999999993,
      "system": 1.33915784,
      "min": 0.6498004098000001,
      "max": 0.6746553648000001,
      "times": [
        0.6746553648000001,
        0.6595735468,
        0.6498004098000001,
        0.6648118938,
        0.6525169448000001,
        0.6612072788000001,
        0.6586470468000001,
        0.6737532008000001,
        0.6512024348000001,
        0.6515730948
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 0.7001027842000002,
      "stddev": 0.03888837578654229,
      "median": 0.6934296468000001,
      "user": 0.37240522,
      "system": 1.34042054,
      "min": 0.6613669348000001,
      "max": 0.7908469478000001,
      "times": [
        0.7132695748000001,
        0.6913656068,
        0.6954936868000001,
        0.6963746418000001,
        0.6884278658,
        0.7326363188000001,
        0.6625809428000001,
        0.6686653218,
        0.6613669348000001,
        0.7908469478000001
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, cold cache + cold store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 2.388 ± 0.034 2.327 2.444 1.00
pacquet@main 2.435 ± 0.064 2.340 2.568 1.02 ± 0.03
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 2.38840678034,
      "stddev": 0.03441428883066735,
      "median": 2.39803868364,
      "user": 3.8208116199999993,
      "system": 3.20560878,
      "min": 2.32747500214,
      "max": 2.44411684614,
      "times": [
        2.35183657314,
        2.40143017514,
        2.40894784614,
        2.3547576011399998,
        2.39464719214,
        2.44411684614,
        2.38684788214,
        2.4119284361399997,
        2.40208024914,
        2.32747500214
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 2.4353036767400003,
      "stddev": 0.06378521847733426,
      "median": 2.42172582414,
      "user": 3.85223052,
      "system": 3.2611651800000003,
      "min": 2.33954486614,
      "max": 2.56807620514,
      "times": [
        2.33954486614,
        2.38687153414,
        2.42957002114,
        2.56807620514,
        2.40028313614,
        2.46952958314,
        2.41388162714,
        2.40122197714,
        2.49105971014,
        2.45299810714
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, hot cache + hot store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 1.560 ± 0.018 1.531 1.584 1.00
pacquet@main 1.586 ± 0.038 1.558 1.678 1.02 ± 0.03
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 1.55998744084,
      "stddev": 0.017785511642964737,
      "median": 1.5614981667399999,
      "user": 1.7879530199999998,
      "system": 1.8861212599999995,
      "min": 1.53138384374,
      "max": 1.5836639477399999,
      "times": [
        1.5655828247399999,
        1.54047933374,
        1.54718438374,
        1.5574135087399998,
        1.56881792774,
        1.53138384374,
        1.5493504427399998,
        1.57321389574,
        1.58278429974,
        1.5836639477399999
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 1.58597640384,
      "stddev": 0.03764286224097454,
      "median": 1.5688805287399998,
      "user": 1.8234330200000002,
      "system": 1.88600426,
      "min": 1.55795996274,
      "max": 1.67830968274,
      "times": [
        1.59009094374,
        1.57502222874,
        1.67830968274,
        1.55795996274,
        1.6097926567399998,
        1.56225156174,
        1.6033128857399999,
        1.56045893974,
        1.55982634774,
        1.56273882874
      ]
    }
  ]
}

@github-actions

github-actions Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

Branchpr/12120
Testbedpacquet
Click to view all benchmark results
BenchmarkLatencyBenchmark Result
milliseconds (ms)
(Result Δ%)
Upper Boundary
milliseconds (ms)
(Limit %)
isolated-linker.fresh-install.cold-cache.cold-store📈 view plot
🚷 view threshold
2,388.41 ms
(+1.75%)Baseline: 2,347.32 ms
2,816.78 ms
(84.79%)
isolated-linker.fresh-install.hot-cache.hot-store📈 view plot
🚷 view threshold
1,559.99 ms
(+2.85%)Baseline: 1,516.76 ms
1,820.11 ms
(85.71%)
isolated-linker.fresh-restore.cold-cache.cold-store📈 view plot
🚷 view threshold
2,177.54 ms
(+4.88%)Baseline: 2,076.26 ms
2,491.51 ms
(87.40%)
isolated-linker.fresh-restore.hot-cache.hot-store📈 view plot
🚷 view threshold
659.77 ms
(-0.47%)Baseline: 662.89 ms
795.46 ms
(82.94%)
🐰 View full continuous benchmarking report in Bencher

@zkochan zkochan marked this pull request as ready for review June 2, 2026 05:37
Copilot AI review requested due to automatic review settings June 2, 2026 05:37
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Review Summary by Qodo

Make pnpm-lock.yaml serialization byte-stable with canonical key ordering

🐞 Bug fix

Grey Divider

Walkthroughs

Description
• Sort all lockfile maps by rendered key at emit time for byte-stable output
• Matches pnpm's canonical ordering via sortLockfileKeys algorithm
• Preserves overrides map insertion order using IndexMap instead of HashMap
• Adds tests verifying byte-identical lockfiles across reuse and re-install scenarios
Diagram
flowchart LR
  A["HashMap maps<br/>in random order"] -->|"sorted_map helpers<br/>at emit time"| B["Sorted by rendered<br/>key string"]
  B -->|"matches pnpm<br/>lexCompare"| C["Byte-stable<br/>pnpm-lock.yaml"]
  D["overrides map<br/>HashMap"] -->|"changed to<br/>IndexMap"| E["Preserves user<br/>declaration order"]
  E -->|"pnpm behavior"| C
  C -->|"no spurious<br/>git diffs"| F["Reuse & fresh<br/>resolve identical"]

Loading

Grey Divider

File Changes

1. pacquet/crates/lockfile/src/serialize_yaml.rs ✨ Enhancement +55/-1

Add sorted_map helpers for canonical key ordering

pacquet/crates/lockfile/src/serialize_yaml.rs


2. pacquet/crates/lockfile/src/lib.rs ✨ Enhancement +22/-4

Wire sorted_map serializers to all lockfile maps

pacquet/crates/lockfile/src/lib.rs


3. pacquet/crates/lockfile/src/project_snapshot.rs ✨ Enhancement +16/-4

Apply sorted_map to project snapshot dependency maps

pacquet/crates/lockfile/src/project_snapshot.rs


View more (8)
4. pacquet/crates/lockfile/src/snapshot_entry.rs ✨ Enhancement +8/-2

Apply sorted_map to snapshot entry dependency maps

pacquet/crates/lockfile/src/snapshot_entry.rs


5. pacquet/crates/lockfile/src/package_metadata.rs ✨ Enhancement +12/-3

Apply sorted_map to package metadata maps

pacquet/crates/lockfile/src/package_metadata.rs


6. pacquet/crates/lockfile/Cargo.toml Dependencies +1/-0

Add indexmap dependency for overrides field

pacquet/crates/lockfile/Cargo.toml


7. pacquet/crates/package-manager/src/dependencies_graph_to_lockfile.rs ✨ Enhancement +4/-1

Change overrides from HashMap to IndexMap

pacquet/crates/package-manager/src/dependencies_graph_to_lockfile.rs


8. pacquet/crates/package-manager/src/dependencies_graph_to_lockfile/tests.rs 🧪 Tests +3/-2

Update test imports for IndexMap usage

pacquet/crates/package-manager/src/dependencies_graph_to_lockfile/tests.rs


9. pacquet/crates/cli/tests/lockfile_resolution_reuse.rs 🧪 Tests +49/-14

Add byte-identical lockfile assertions and new test

pacquet/crates/cli/tests/lockfile_resolution_reuse.rs


10. pacquet/crates/cli/tests/inject_workspace_packages.rs 🧪 Tests +3/-5

Update test comments reflecting deterministic ordering

pacquet/crates/cli/tests/inject_workspace_packages.rs


11. pacquet/plans/LOCKFILE_RESOLUTION_REUSE.md 📝 Documentation +7/-8

Mark lockfile byte-ordering issue as resolved

pacquet/plans/LOCKFILE_RESOLUTION_REUSE.md


Grey Divider

Qodo Logo

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Makes pacquet's pnpm-lock.yaml byte-stable by emitting every lockfile map in canonical key order, matching pnpm's sortLockfileKeys. Previously, maps were serialized in HashMap iteration (random-seed) order, so re-installs of the same resolution could produce differently ordered (but content-identical) lockfiles.

Changes:

  • Added sorted_map / sorted_map_opt helpers in serialize_yaml that sort entries by their Display rendering (matching pnpm's lexCompare), and wire them onto every map field in Lockfile, ProjectSnapshot, SnapshotEntry, and PackageMetadata.
  • Switched overrides (in Lockfile and GraphToLockfileOptions) from HashMap to IndexMap to preserve user declaration order — pnpm intentionally leaves overrides out of sortLockfileKeys.
  • Tightened tests: reuse-vs-fresh equivalence now asserts byte parity, and a new reinstalling_an_unchanged_manifest_keeps_the_lockfile_byte_identical guards no-op re-install stability.

Reviewed changes

Copilot reviewed 11 out of 12 changed files in this pull request and generated no comments.

Show a summary per file
File Description
pacquet/crates/lockfile/src/serialize_yaml.rs Adds sorted_map/sorted_map_opt helpers that sort by rendered key string.
pacquet/crates/lockfile/src/lib.rs Sorts importers/packages/snapshots on emit; switches overrides to IndexMap.
pacquet/crates/lockfile/src/project_snapshot.rs Sorts specifiers/dependencies/optionalDependencies/devDependencies.
pacquet/crates/lockfile/src/snapshot_entry.rs Sorts snapshot dependencies/optionalDependencies.
pacquet/crates/lockfile/src/package_metadata.rs Sorts engines/peerDependencies/peerDependenciesMeta.
pacquet/crates/lockfile/Cargo.toml Adds indexmap dependency.
pacquet/crates/package-manager/src/dependencies_graph_to_lockfile.rs Changes overrides parameter type to IndexMap.
pacquet/crates/package-manager/src/dependencies_graph_to_lockfile/tests.rs Updates test helper signature for the IndexMap change.
pacquet/crates/cli/tests/lockfile_resolution_reuse.rs Switches reuse-vs-fresh comparison to byte equality; adds re-install byte-stability test.
pacquet/crates/cli/tests/inject_workspace_packages.rs Comment cleanup; removes stale ordering caveat.
pacquet/plans/LOCKFILE_RESOLUTION_REUSE.md Marks the byte-ordering follow-up resolved.
Cargo.lock Lockfile update for indexmap dep on pacquet-lockfile.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@zkochan zkochan merged commit eec417b into main Jun 2, 2026
32 checks passed
@zkochan zkochan deleted the fix/12117 branch June 2, 2026 05:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

pacquet: pnpm-lock.yaml map ordering is non-canonical (HashMap iteration order)

3 participants