Skip to content

Commit 14332f0

Browse files
fix: don't silently drop locked optional deps on resolution failure (#12855)
When re-resolution of an optional dependency fails (for example, a registry mirror whose packument has not synced the pinned version yet), the resolver silently skipped the dependency. The parent's snapshot was then rewritten without the edge and the pruner erased the locked entries, so identical inputs produced different lockfiles depending on which machine ran the install, and a frozen install on another host had no entry to link for the affected package. Rethrow the resolution error instead of skipping when the wanted lockfile already holds an entry that satisfies the wanted range. An optional dependency that never resolved keeps the skip-on-failure behavior. pacquet previously failed loudly on every optional-dependency resolution failure. It now skips never-locked optional deps like pnpm, emitting the same skipped-optional-dependency log (with the parents chain and the same top-level reporter output), and keeps the loud failure — with the same hint — when the lockfile holds a satisfying entry, so the two stacks agree on both cases. Closes #12853 --------- Co-authored-by: Zoltan Kochan <zoltankochan@gmail.com>
1 parent 3067e4f commit 14332f0

15 files changed

Lines changed: 706 additions & 33 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@pnpm/installing.deps-resolver": patch
3+
"pnpm": patch
4+
---
5+
6+
Fail instead of silently removing an optional dependency's locked entries from `pnpm-lock.yaml` when the registry cannot resolve it. Previously, when registry metadata lacked a version that the lockfile already pinned (for example, a mirror that had not synced a recent release yet), `pnpm install` and `pnpm dedupe` silently dropped the optional dependency's entries — emptying maps such as the platform binaries of `@napi-rs/canvas` — so the lockfile differed between machines and frozen installs on other hosts had nothing to link [#12853](https://github.com/pnpm/pnpm/issues/12853).

‎pacquet/crates/default-reporter/src/state.rs‎

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ use pacquet_reporter::{
1414
AddedRoot, ContextLog, DependencyType, ExecutionTimeLog, FetchingProgressMessage, HookLog,
1515
IgnoredScriptsLog, InstallingConfigDepsLog, InstallingConfigDepsStatus, LifecycleMessage,
1616
LifecycleStdio, LockfileVerificationMessage, LogEvent, LogLevel, PackageImportMethod,
17-
PackageManifestMessage, ProgressMessage, RemovedRoot, RequestRetryLog, Stage, StatsMessage,
17+
PackageManifestMessage, ProgressMessage, RemovedRoot, RequestRetryLog,
18+
SkippedOptionalDependencyLog, SkippedOptionalPackage, Stage, StatsMessage,
1819
};
1920
use serde_json::Value;
2021

@@ -323,11 +324,7 @@ impl ReporterState {
323324
LogEvent::Summary(_) => self.on_summary(),
324325
LogEvent::Lifecycle(log) => self.on_lifecycle(&log.message),
325326
LogEvent::IgnoredScripts(log) => self.on_ignored_scripts(log),
326-
// Upstream's `reportSkippedOptionalDependencies` only prints top-level
327-
// resolution failures (its filter needs a `parents: []`); the emits
328-
// pacquet produces carry no `parents`, so upstream drops them from the
329-
// console. Consume without rendering to match.
330-
LogEvent::SkippedOptionalDependency(_) => {}
327+
LogEvent::SkippedOptionalDependency(log) => self.on_skipped_optional(log),
331328
LogEvent::InstallingConfigDeps(log) => self.on_config_deps(log),
332329
LogEvent::LockfileVerification(log) => self.on_lockfile_verification(&log.message),
333330
LogEvent::RequestRetry(log) => self.on_request_retry(log),
@@ -1029,6 +1026,30 @@ impl ReporterState {
10291026
self.exec_slot = slot;
10301027
}
10311028

1029+
/// Mirrors pnpm's `reportSkippedOptionalDependencies`: only a skip
1030+
/// whose `parents` chain is present and empty (a direct optional
1031+
/// dependency of the current project) renders; transitive and
1032+
/// parent-less skips stay debug-only.
1033+
fn on_skipped_optional(&mut self, log: &SkippedOptionalDependencyLog) {
1034+
if log.prefix != self.cwd || !log.parents.as_ref().is_some_and(Vec::is_empty) {
1035+
return;
1036+
}
1037+
let pkg = match &log.package {
1038+
SkippedOptionalPackage::Installed { id, .. } => id.clone(),
1039+
SkippedOptionalPackage::ResolutionFailure {
1040+
name: Some(name),
1041+
version: Some(version),
1042+
..
1043+
} => format!("{name}@{version}"),
1044+
SkippedOptionalPackage::ResolutionFailure { bare_specifier, .. } => {
1045+
bare_specifier.clone()
1046+
}
1047+
};
1048+
self.push_block(format!(
1049+
"info: {pkg} is an optional dependency and failed compatibility check. Excluding it from installation.",
1050+
));
1051+
}
1052+
10321053
/// Renders a `pnpm:hook` event as `hook: message`, matching pnpm's
10331054
/// `reportHooks.ts` format. When the hook's `prefix` differs from
10341055
/// `self.cwd` the message is zoomed out with the prefix.

‎pacquet/crates/default-reporter/tests/render.rs‎

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ use pacquet_reporter::{
1313
LifecycleMessage, LifecycleStdio, LogEvent, LogLevel, PackageImportMethod,
1414
PackageImportMethodLog, PackageManifestLog, PackageManifestMessage, PnpmLog, ProgressLog,
1515
ProgressMessage, RootLog, RootMessage, SkippedOptionalDependencyLog, SkippedOptionalPackage,
16-
SkippedOptionalReason, Stage, StageLog, StatsLog, StatsMessage, SummaryLog,
16+
SkippedOptionalParent, SkippedOptionalReason, Stage, StageLog, StatsLog, StatsMessage,
17+
SummaryLog,
1718
};
1819

1920
const CWD: &str = "/repo";
@@ -421,8 +422,8 @@ fn warnings_collapse_after_five() {
421422
assert_eq!(lines[5], "[WARN] 1 other warnings");
422423
}
423424

424-
/// Upstream keeps the console silent for the skipped-optional emits pacquet
425-
/// produces (they carry no `parents`), so this channel must render nothing.
425+
/// Upstream keeps the console silent for skipped-optional emits without a
426+
/// `parents` chain (build/platform skips), so those must render nothing.
426427
#[test]
427428
fn skipped_optional_dependency_renders_nothing() {
428429
let mut reporter = state(false);
@@ -435,6 +436,7 @@ fn skipped_optional_dependency_renders_nothing() {
435436
name: name.to_string(),
436437
version: version.to_string(),
437438
},
439+
parents: None,
438440
prefix: CWD.to_string(),
439441
reason,
440442
})
@@ -454,6 +456,48 @@ fn skipped_optional_dependency_renders_nothing() {
454456
assert!(frame.is_empty(), "skipped-optional events must not render, got: {frame:?}");
455457
}
456458

459+
/// A resolution-failure skip on a direct optional dependency
460+
/// (`parents: []`, prefix == cwd) renders the same info line as
461+
/// upstream's `reportSkippedOptionalDependencies`; a transitive skip
462+
/// (non-empty `parents`) stays silent.
463+
#[test]
464+
fn skipped_optional_resolution_failure_renders_only_top_level() {
465+
let skipped = |parents: Vec<SkippedOptionalParent>, prefix: &str| {
466+
LogEvent::SkippedOptionalDependency(SkippedOptionalDependencyLog {
467+
level: LogLevel::Debug,
468+
details: Some("No matching version found for broken@^1.0.0".to_string()),
469+
package: SkippedOptionalPackage::ResolutionFailure {
470+
name: Some("broken".to_string()),
471+
version: Some("^1.0.0".to_string()),
472+
bare_specifier: "^1.0.0".to_string(),
473+
},
474+
parents: Some(parents),
475+
prefix: prefix.to_string(),
476+
reason: SkippedOptionalReason::ResolutionFailure,
477+
})
478+
};
479+
480+
let mut reporter = state(false);
481+
let frame = render(&mut reporter, vec![skipped(Vec::new(), CWD)]);
482+
assert_eq!(
483+
frame,
484+
"info: broken@^1.0.0 is an optional dependency and failed compatibility check. Excluding it from installation.",
485+
);
486+
487+
let mut reporter = state(false);
488+
let parent = SkippedOptionalParent {
489+
id: "parent@1.0.0".to_string(),
490+
name: "parent".to_string(),
491+
version: "1.0.0".to_string(),
492+
};
493+
let frame = render(&mut reporter, vec![skipped(vec![parent], CWD)]);
494+
assert!(frame.is_empty(), "transitive skips must not render, got: {frame:?}");
495+
496+
let mut reporter = state(false);
497+
let frame = render(&mut reporter, vec![skipped(Vec::new(), "/somewhere/else")]);
498+
assert!(frame.is_empty(), "other prefixes must not render, got: {frame:?}");
499+
}
500+
457501
#[test]
458502
fn append_only_emits_lines_not_frames() {
459503
let mut reporter = ReporterState::new(CWD.to_string(), 80, Colors { enabled: false }, true);

‎pacquet/crates/env-installer/src/install_config_deps.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,7 @@ fn is_compatible<Reporter: self::Reporter>(
319319
name: subdep.name.clone(),
320320
version: subdep.version.clone(),
321321
},
322+
parents: None,
322323
prefix: opts.root_dir.to_string_lossy().into_owned(),
323324
reason: match error.skip_reason() {
324325
pacquet_package_is_installable::SkipReason::UnsupportedEngine => {

‎pacquet/crates/package-manager/src/build_modules.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -945,6 +945,7 @@ fn build_one_snapshot<Reporter: self::Reporter>(
945945
name,
946946
version,
947947
},
948+
parents: None,
948949
prefix: lockfile_dir.to_string_lossy().into_owned(),
949950
reason: SkippedOptionalReason::BuildFailure,
950951
},
@@ -995,6 +996,7 @@ fn build_one_snapshot<Reporter: self::Reporter>(
995996
name,
996997
version,
997998
},
999+
parents: None,
9981000
prefix: lockfile_dir.to_string_lossy().into_owned(),
9991001
reason: SkippedOptionalReason::BuildFailure,
10001002
}));
@@ -1072,6 +1074,7 @@ fn build_one_snapshot<Reporter: self::Reporter>(
10721074
name,
10731075
version,
10741076
},
1077+
parents: None,
10751078
prefix: lockfile_dir.to_string_lossy().into_owned(),
10761079
reason: SkippedOptionalReason::BuildFailure,
10771080
},

‎pacquet/crates/package-manager/src/install_with_fresh_lockfile.rs‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@ use pacquet_hooks::finder;
2121
use pacquet_lockfile::{Lockfile, LockfileResolution, SaveLockfileError};
2222
use pacquet_network::{AuthHeaders, ThrottledClient};
2323
use pacquet_package_manifest::{DependencyGroup, PackageManifest};
24-
use pacquet_reporter::{HookLog, LogEvent, LogLevel, Reporter, Stage, StageLog};
24+
use pacquet_reporter::{
25+
HookLog, LogEvent, LogLevel, Reporter, SkippedOptionalDependencyLog, SkippedOptionalPackage,
26+
SkippedOptionalParent, SkippedOptionalReason, Stage, StageLog,
27+
};
2528
use pacquet_resolving_default_resolver::DefaultResolver;
2629
use pacquet_resolving_deps_resolver::{
2730
ManifestHook, ResolveDependencyTreeError, ResolveImporterError, ResolveImporterOptions,
@@ -1098,6 +1101,7 @@ impl<DependencyGroupList> InstallWithFreshLockfile<'_, DependencyGroupList> {
10981101
manifest_hook: manifest_hook.clone(),
10991102
pnpmfile_hook,
11001103
read_package_log,
1104+
skipped_optional_log: Some(skipped_optional_log_fn::<Reporter>()),
11011105
pick_lowest_direct,
11021106
time_based,
11031107
// Hand the resolver the prior lockfile so it can reuse
@@ -2074,6 +2078,38 @@ fn hook_log_fn<Reporter: self::Reporter>(
20742078
})
20752079
}
20762080

2081+
/// Build the resolver's skipped-optional-dependency sink: each
2082+
/// notification emits a `pnpm:skipped-optional-dependency` debug event
2083+
/// with `reason=resolution_failure` through the install's reporter,
2084+
/// matching pnpm's `skippedOptionalDependencyLogger.debug` payload.
2085+
fn skipped_optional_log_fn<Reporter: self::Reporter>()
2086+
-> pacquet_resolving_deps_resolver::SkippedOptionalLogFn {
2087+
Arc::new(|skipped: pacquet_resolving_deps_resolver::SkippedOptionalDependency| {
2088+
Reporter::emit(&LogEvent::SkippedOptionalDependency(SkippedOptionalDependencyLog {
2089+
level: LogLevel::Debug,
2090+
details: Some(skipped.details),
2091+
package: SkippedOptionalPackage::ResolutionFailure {
2092+
name: skipped.name,
2093+
version: skipped.version,
2094+
bare_specifier: skipped.bare_specifier,
2095+
},
2096+
parents: Some(
2097+
skipped
2098+
.parents
2099+
.into_iter()
2100+
.map(|parent| SkippedOptionalParent {
2101+
id: parent.id,
2102+
name: parent.name,
2103+
version: parent.version,
2104+
})
2105+
.collect(),
2106+
),
2107+
prefix: skipped.prefix,
2108+
reason: SkippedOptionalReason::ResolutionFailure,
2109+
}));
2110+
})
2111+
}
2112+
20772113
/// Build one side of the `preResolution` hook's `logger`: each
20782114
/// `logger.info(...)` / `logger.warn(...)` call emits a `pnpm:hook` event at
20792115
/// the given level. `from` is the literal `"pnpmfile"` — pnpm's

‎pacquet/crates/package-manager/src/installability.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -650,6 +650,7 @@ fn emit_skipped<Reporter: self::Reporter>(
650650
level: LogLevel::Debug,
651651
details: Some(details),
652652
package: SkippedOptionalPackage::Installed { id: pkg_id.to_string(), name, version },
653+
parents: None,
653654
prefix: prefix.to_string(),
654655
reason: wire_reason,
655656
}));

‎pacquet/crates/reporter/src/lib.rs‎

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -570,17 +570,34 @@ pub struct IgnoredScriptsLog {
570570
/// real safety, so it's left to convention until a site actually
571571
/// pairs the wrong shapes.
572572
///
573-
/// `parents` is a TODO and is omitted here.
573+
/// `parents` co-varies with `reason` the same way `package` does:
574+
/// only the resolver-side `resolution_failure` emit carries it
575+
/// (empty for a direct optional dependency of the importer); every
576+
/// other emit site omits it, matching pnpm's payloads.
574577
#[derive(Debug, Clone, Serialize)]
575578
pub struct SkippedOptionalDependencyLog {
576579
pub level: LogLevel,
577580
#[serde(skip_serializing_if = "Option::is_none")]
578581
pub details: Option<String>,
579582
pub package: SkippedOptionalPackage,
583+
#[serde(skip_serializing_if = "Option::is_none")]
584+
pub parents: Option<Vec<SkippedOptionalParent>>,
580585
pub prefix: String,
581586
pub reason: SkippedOptionalReason,
582587
}
583588

589+
/// One ancestor on a `resolution_failure` skip's `parents` chain: a
590+
/// resolved package between the importer and the failing optional
591+
/// edge. The default reporter renders only skips whose chain is empty
592+
/// (a direct optional dependency), matching pnpm's
593+
/// `reportSkippedOptionalDependencies`.
594+
#[derive(Debug, Clone, Serialize)]
595+
pub struct SkippedOptionalParent {
596+
pub id: String,
597+
pub name: String,
598+
pub version: String,
599+
}
600+
584601
/// Package identifier carried on a [`SkippedOptionalDependencyLog`].
585602
/// Two shapes, depending on `reason`:
586603
///
@@ -591,9 +608,8 @@ pub struct SkippedOptionalDependencyLog {
591608
/// `build_modules.rs`.
592609
/// - [`SkippedOptionalPackage::ResolutionFailure`] —
593610
/// `{ name?, version?, bareSpecifier }` for `resolution_failure`.
594-
/// Defined for the resolver-side emit. Pacquet has no resolver yet
595-
/// so this variant is wire-shape-only in slice 4 — wired so a
596-
/// future resolver port can land without re-touching this type.
611+
/// Emitted by the deps resolver's skipped-optional sink when an
612+
/// optional dependency's resolution failure drops the edge.
597613
///
598614
/// `#[serde(untagged)]` so each variant serializes as its own object
599615
/// shape — a union of two `package: { ... }` types.
@@ -604,8 +620,10 @@ pub enum SkippedOptionalPackage {
604620
/// emit (installability + build-failure).
605621
Installed { id: String, name: String, version: String },
606622
/// `{ name?, version?, bareSpecifier }` shape used by the
607-
/// resolver-side `resolution_failure` emit. `name` and `version`
608-
/// are optional and stay `None` when the resolver fails before it
623+
/// resolver-side `resolution_failure` emit (the deps resolver's
624+
/// skipped-optional sink wired in
625+
/// `install_with_fresh_lockfile.rs`). `name` and `version` are
626+
/// optional and stay `None` when the resolver fails before it
609627
/// could resolve those fields.
610628
ResolutionFailure {
611629
#[serde(skip_serializing_if = "Option::is_none")]
@@ -617,10 +635,9 @@ pub enum SkippedOptionalPackage {
617635
},
618636
}
619637

620-
/// Discriminator on a [`SkippedOptionalDependencyLog`]. Only
621-
/// `BuildFailure` lands at pacquet's current emit sites; the others
622-
/// are kept in the enum for forward compatibility so callers don't
623-
/// have to widen the type when more reasons are wired up.
638+
/// Discriminator on a [`SkippedOptionalDependencyLog`]. See
639+
/// [`SkippedOptionalPackage`] for which emit site pairs with which
640+
/// reason.
624641
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
625642
#[serde(rename_all = "snake_case")]
626643
pub enum SkippedOptionalReason {

‎pacquet/crates/reporter/src/tests.rs‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ use crate::{
1111
LockfileVerificationMessage, LogEvent, LogLevel, PackageImportMethod, PackageImportMethodLog,
1212
PackageManifestLog, PackageManifestMessage, PnpmLog, ProgressLog, ProgressMessage, RemovedRoot,
1313
Reporter, RequestRetryError, RequestRetryLog, RootLog, RootMessage, SilentReporter,
14-
SkippedOptionalDependencyLog, SkippedOptionalPackage, SkippedOptionalReason, Stage, StageLog,
15-
StatsLog, StatsMessage, SummaryLog,
14+
SkippedOptionalDependencyLog, SkippedOptionalPackage, SkippedOptionalParent,
15+
SkippedOptionalReason, Stage, StageLog, StatsLog, StatsMessage, SummaryLog,
1616
};
1717

1818
/// Context log serializes with the camelCase field names
@@ -675,6 +675,7 @@ fn skipped_optional_dependency_event_matches_pnpm_wire_shape() {
675675
name: "foo".to_string(),
676676
version: "1.0.0".to_string(),
677677
},
678+
parents: None,
678679
prefix: "/projects/x".to_string(),
679680
reason: SkippedOptionalReason::BuildFailure,
680681
});
@@ -693,6 +694,7 @@ fn skipped_optional_dependency_event_matches_pnpm_wire_shape() {
693694
assert_eq!(json["package"]["id"], "/foo/1.0.0");
694695
assert_eq!(json["package"]["name"], "foo");
695696
assert_eq!(json["package"]["version"], "1.0.0");
697+
assert!(json.get("parents").is_none(), "non-resolver emits carry no parents, got {json:?}");
696698
}
697699

698700
/// `details` is optional upstream and must be omitted from the wire
@@ -707,6 +709,7 @@ fn skipped_optional_omits_absent_details() {
707709
name: "bar".to_string(),
708710
version: "2.0.0".to_string(),
709711
},
712+
parents: None,
710713
prefix: "/projects/y".to_string(),
711714
reason: SkippedOptionalReason::BuildFailure,
712715
});
@@ -755,6 +758,11 @@ fn skipped_optional_resolution_failure_event_matches_pnpm_wire_shape() {
755758
version: Some("1.2.3".to_string()),
756759
bare_specifier: "^1.2.0".to_string(),
757760
},
761+
parents: Some(vec![SkippedOptionalParent {
762+
id: "parent@2.0.0".to_string(),
763+
name: "parent".to_string(),
764+
version: "2.0.0".to_string(),
765+
}]),
758766
prefix: "/projects/x".to_string(),
759767
reason: SkippedOptionalReason::ResolutionFailure,
760768
});
@@ -771,6 +779,10 @@ fn skipped_optional_resolution_failure_event_matches_pnpm_wire_shape() {
771779
assert_eq!(json["package"]["name"], "foo");
772780
assert_eq!(json["package"]["version"], "1.2.3");
773781
assert_eq!(json["package"]["bareSpecifier"], "^1.2.0");
782+
assert_eq!(
783+
json["parents"],
784+
serde_json::json!([{ "id": "parent@2.0.0", "name": "parent", "version": "2.0.0" }]),
785+
);
774786
}
775787

776788
/// `name` and `version` are upstream-optional on the
@@ -786,6 +798,7 @@ fn skipped_optional_resolution_failure_omits_absent_name_and_version() {
786798
version: None,
787799
bare_specifier: "git+ssh://broken-url".to_string(),
788800
},
801+
parents: Some(Vec::new()),
789802
prefix: "/projects/y".to_string(),
790803
reason: SkippedOptionalReason::ResolutionFailure,
791804
});
@@ -799,6 +812,7 @@ fn skipped_optional_resolution_failure_omits_absent_name_and_version() {
799812
assert!(json["package"].get("name").is_none(), "name omitted when absent, got {json:?}");
800813
assert!(json["package"].get("version").is_none(), "version omitted when absent, got {json:?}");
801814
assert_eq!(json["package"]["bareSpecifier"], "git+ssh://broken-url");
815+
assert_eq!(json["parents"], serde_json::json!([]), "an empty chain serializes as []");
802816
}
803817

804818
/// All four reason variants serialize as the `snake_case` strings

‎pacquet/crates/resolving-deps-resolver/src/lib.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@ pub use hoist_peers::{
8484
pub use node_id::NodeId;
8585
pub use pacquet_deps_path::DepPath;
8686
pub use resolve_dependency_tree::{
87-
ManifestHook, ResolveDependencyTreeError, ResolveDependencyTreeOptions, TreeCtx,
87+
ManifestHook, ResolveDependencyTreeError, ResolveDependencyTreeOptions,
88+
SkippedOptionalDependency, SkippedOptionalDependencyParent, SkippedOptionalLogFn, TreeCtx,
8889
UpdateReuseScope, WorkspaceTreeCtx, extend_tree, resolve_dependency_tree,
8990
};
9091
pub use resolve_importer::{

0 commit comments

Comments
 (0)