aube_resolver/platform.rs
1//! Platform filtering for `os` / `cpu` / `libc` package metadata.
2//!
3//! npm-style packages can declare the platforms they support via the
4//! `os`, `cpu`, and `libc` arrays in `package.json`. Each entry is
5//! either a positive match (`"linux"`, `"x64"`, `"glibc"`) or a
6//! negation prefixed with `!` (`"!win32"`). pnpm's rule:
7//!
8//! - empty array → unconstrained (installable everywhere)
9//! - any negation hit → reject
10//! - at least one pos → accept only if one positive matches
11//! - negations only → accept if no negation matched
12//!
13//! pnpm lets the user widen the match set beyond the host via
14//! `pnpm.supportedArchitectures` — an object with `os`/`cpu`/`libc`
15//! arrays, each entry either a concrete value or the literal `"current"`
16//! which expands to the host triple. The package passes if ANY of the
17//! (os, cpu, libc) combinations in the supported set is installable.
18//!
19//! This module stays intentionally small: no reading of config, no
20//! serde, just the matcher and host detection. Configuration lives on
21//! the `Resolver`, which calls [`is_supported`] during filtering.
22
23/// User-declared override for the host triple used when filtering
24/// optional dependencies. Missing arrays fall back to the host; the
25/// literal `"current"` inside any array expands to the same host value
26/// so users can write `["current", "linux"]` to keep their native
27/// platform *and* also resolve optionals for Linux.
28#[derive(Debug, Clone, Default)]
29pub struct SupportedArchitectures {
30 pub os: Vec<String>,
31 pub cpu: Vec<String>,
32 pub libc: Vec<String>,
33 /// When true, [`is_supported`] accepts every package regardless of
34 /// its `os`/`cpu`/`libc`. Set at *resolve* time for the committed,
35 /// cross-platform lockfiles (pnpm-lock.yaml, aube-lock.yaml,
36 /// bun.lock) so every optional-dep variant a package declares lands
37 /// in the lockfile — exactly what pnpm and bun both record,
38 /// regardless of the host running the resolve. Link-time filtering
39 /// (`filter_graph`) and the streaming-fetch gate run against the
40 /// host triple instead, so `node_modules` and the tarball downloads
41 /// stay trimmed to the host.
42 pub accept_all: bool,
43}
44
45impl SupportedArchitectures {
46 /// Expand any `"current"` entries to the host triple and default
47 /// empty arrays to `[host]`. The result is a non-empty list of
48 /// (os, cpu, libc) combinations the caller can test against.
49 fn combinations(&self) -> Vec<(String, String, String)> {
50 let host = host_triple();
51 let expand = |field: &[String], host_val: &str| -> Vec<String> {
52 if field.is_empty() {
53 return vec![host_val.to_string()];
54 }
55 field
56 .iter()
57 .map(|v| {
58 if v == "current" {
59 host_val.to_string()
60 } else {
61 v.clone()
62 }
63 })
64 .collect()
65 };
66 let os = expand(&self.os, host.0);
67 let cpu = expand(&self.cpu, host.1);
68 let libc = expand(&self.libc, host.2);
69 let mut out = Vec::with_capacity(os.len() * cpu.len() * libc.len());
70 for o in &os {
71 for c in &cpu {
72 for l in &libc {
73 out.push((o.clone(), c.clone(), l.clone()));
74 }
75 }
76 }
77 out
78 }
79}
80
81/// Return the host's (os, cpu, libc) triple using npm's vocabulary.
82/// `libc` is `"glibc"` / `"musl"` on Linux and `""` elsewhere — npm
83/// only sets `libc` on Linux packages, so non-Linux hosts treat libc
84/// constraints as a no-op.
85pub fn host_triple() -> (&'static str, &'static str, &'static str) {
86 let os = match std::env::consts::OS {
87 "macos" => "darwin",
88 "windows" => "win32",
89 other => other,
90 };
91 let cpu = match std::env::consts::ARCH {
92 "x86_64" => "x64",
93 "x86" => "ia32",
94 "aarch64" => "arm64",
95 "powerpc64" => "ppc64",
96 other => other,
97 };
98 // Detect libc at runtime, not compile time. Old code used
99 // `cfg!(target_env = "musl")` which is the toolchain that built
100 // the aube binary, not the host's libc. Real bug: an aube static
101 // binary built against musl and shipped to glibc users reported
102 // libc=musl everywhere, and the glibc-built distro reported
103 // glibc everywhere. Wrong prebuilts got installed, runtime
104 // ld.so errors. Probe /lib/ld-musl-* vs /lib*/ld-linux-*.
105 let libc = if std::env::consts::OS == "linux" {
106 detect_linux_libc()
107 } else {
108 ""
109 };
110 (os, cpu, libc)
111}
112
113/// Probe the active dynamic linker to tell musl from glibc at runtime.
114/// Authoritative signal is `/proc/self/maps`: the dynamic linker that
115/// loaded the running aube binary is always mmap'd into the process,
116/// so whichever of `ld-musl-*` or `ld-linux-*` shows up there is the
117/// libc the host actually runs. Cached once via OnceLock.
118///
119/// The previous /lib-scan heuristic broke on Ubuntu glibc hosts that
120/// `apt install musl` for cross-compile tooling: the musl package
121/// drops `/lib/ld-musl-<arch>.so.1` alongside the system glibc loader,
122/// and a first-match scan returned "musl", causing aube to install
123/// `*-linux-x64-musl` native bindings that node (linked against
124/// glibc) cannot load. /proc/self/maps cuts straight to which loader
125/// actually runs and ignores the partial-install noise. The /lib
126/// fallback is kept for non-Linux containers / stripped rootfs that
127/// expose no procfs, but checks glibc *first* so a dual-loader system
128/// still resolves correctly there.
129fn detect_linux_libc() -> &'static str {
130 use std::sync::OnceLock;
131 static CACHE: OnceLock<&'static str> = OnceLock::new();
132 CACHE.get_or_init(|| {
133 if let Ok(maps) = std::fs::read_to_string("/proc/self/maps") {
134 if maps.contains("/ld-musl-") {
135 return "musl";
136 }
137 if maps.contains("/ld-linux") {
138 return "glibc";
139 }
140 }
141 let glibc_dirs = [
142 "/lib",
143 "/lib64",
144 "/lib/x86_64-linux-gnu",
145 "/lib/aarch64-linux-gnu",
146 ];
147 for dir in glibc_dirs {
148 if let Ok(entries) = std::fs::read_dir(dir) {
149 for entry in entries.flatten() {
150 let name = entry.file_name();
151 if name.to_string_lossy().starts_with("ld-linux") {
152 return "glibc";
153 }
154 }
155 }
156 }
157 if let Ok(entries) = std::fs::read_dir("/lib") {
158 for entry in entries.flatten() {
159 let name = entry.file_name();
160 if name.to_string_lossy().starts_with("ld-musl-") {
161 return "musl";
162 }
163 }
164 }
165 "glibc"
166 })
167}
168
169/// Apply npm's `os`/`cpu`/`libc` rules to a single (pkg_field, host)
170/// pair. An empty pkg array is unconstrained; negations reject; at
171/// least one positive entry means one must match.
172fn field_matches(pkg_field: &[String], host: &str) -> bool {
173 if pkg_field.is_empty() {
174 return true;
175 }
176 let mut has_positive = false;
177 let mut positive_matched = false;
178 for entry in pkg_field {
179 if let Some(neg) = entry.strip_prefix('!') {
180 if neg == host {
181 return false;
182 }
183 } else {
184 has_positive = true;
185 if entry == host {
186 positive_matched = true;
187 }
188 }
189 }
190 !has_positive || positive_matched
191}
192
193/// Decide whether a package is installable on any of the (os, cpu,
194/// libc) combinations expanded from `supported`. The `pkg_libc` check
195/// is skipped when the host libc is empty (non-Linux) — npm doesn't
196/// enforce libc off Linux.
197pub fn is_supported(
198 pkg_os: &[String],
199 pkg_cpu: &[String],
200 pkg_libc: &[String],
201 supported: &SupportedArchitectures,
202) -> bool {
203 // pnpm-lock parity: record every declared variant in the lockfile
204 // regardless of host. Host-only trimming happens later via
205 // `filter_graph` / the streaming-fetch gate, which use the real host
206 // triple rather than this accept-all set.
207 if supported.accept_all {
208 return true;
209 }
210 for (os, cpu, libc) in supported.combinations() {
211 if !field_matches(pkg_os, &os) {
212 continue;
213 }
214 if !field_matches(pkg_cpu, &cpu) {
215 continue;
216 }
217 if !libc.is_empty() && !field_matches(pkg_libc, &libc) {
218 continue;
219 }
220 return true;
221 }
222 false
223}
224
225/// Remove optional dependencies that fail the platform check or appear in the
226/// ignore list from a parsed `LockfileGraph`, then garbage-collect any packages
227/// that become unreachable from the surviving importers.
228///
229/// Used by the install-from-lockfile path, where the resolver's inline
230/// filter never runs: the lockfile carries os/cpu/libc per package so
231/// aube can re-check on every platform without reparsing packuments.
232///
233/// Root and transitive optional edges are inspected directly. Any package that
234/// becomes unreachable after optional-edge pruning is removed by the GC pass.
235pub fn filter_graph(
236 graph: &mut aube_lockfile::LockfileGraph,
237 supported: &SupportedArchitectures,
238 ignored: &std::collections::BTreeSet<String>,
239) {
240 use crate::FxHashSet;
241 use aube_lockfile::DepType;
242
243 let is_mismatched =
244 |pkg: &aube_lockfile::LockedPackage| !is_supported(&pkg.os, &pkg.cpu, &pkg.libc, supported);
245
246 // 1. Drop root optional deps by name or by platform.
247 for deps in graph.importers.values_mut() {
248 deps.retain(|dep| {
249 if dep.dep_type != DepType::Optional {
250 return true;
251 }
252 if ignored.contains(&dep.name) {
253 return false;
254 }
255 !matches!(graph.packages.get(&dep.dep_path), Some(pkg) if is_mismatched(pkg))
256 });
257 }
258
259 // 2. Drop transitive optional deps by name or platform. The pnpm parser
260 // mirrors active optional edges into `dependencies`, so remove that edge
261 // whenever the optional edge is filtered.
262 let package_keys: FxHashSet<String> = graph.packages.keys().cloned().collect();
263 let mismatched_packages: FxHashSet<String> = graph
264 .packages
265 .iter()
266 .filter(|(_, pkg)| is_mismatched(pkg))
267 .map(|(dep_path, _)| dep_path.clone())
268 .collect();
269 for pkg in graph.packages.values_mut() {
270 let mut removed = Vec::new();
271 pkg.optional_dependencies.retain(|name, tail| {
272 // Resolve through every reader convention (incl. the
273 // git/remote-tarball `name@url+<hash>` form) so a
274 // platform-mismatched optional git/tarball child is actually
275 // pruned here rather than surviving until the GC pass below.
276 let child_is_mismatched =
277 match aube_lockfile::resolve_dep_edge(name, tail, |k| package_keys.contains(k)) {
278 Some(child_key) => mismatched_packages.contains(&child_key),
279 None => false,
280 };
281 let keep = !ignored.contains(name) && !child_is_mismatched;
282 if !keep {
283 removed.push(name.clone());
284 }
285 keep
286 });
287 for name in removed {
288 pkg.dependencies.remove(&name);
289 }
290 }
291
292 // 3. Garbage-collect unreachable packages by walking from the
293 // surviving roots.
294 let mut reachable: FxHashSet<String> = FxHashSet::default();
295 let mut stack: Vec<String> = Vec::new();
296 for deps in graph.importers.values() {
297 for dep in deps {
298 stack.push(dep.dep_path.clone());
299 }
300 }
301 while let Some(dep_path) = stack.pop() {
302 if !reachable.insert(dep_path.clone()) {
303 continue;
304 }
305 if let Some(pkg) = graph.packages.get(&dep_path) {
306 for (name, tail) in &pkg.dependencies {
307 // Resolve the edge through every reader convention,
308 // including the git/remote-tarball `name@url+<hash>` form
309 // — otherwise a canonically-keyed git/tarball child (and
310 // its whole subtree) is unreachable here and gets GC'd.
311 if let Some(child) =
312 aube_lockfile::resolve_dep_edge(name, tail, |k| graph.packages.contains_key(k))
313 {
314 stack.push(child);
315 }
316 }
317 }
318 }
319 graph.packages.retain(|k, _| reachable.contains(k));
320}
321
322/// Set each package's `optional` flag the way pnpm marks the
323/// `snapshots:` section: a package is `optional: true` when it is
324/// reachable *only* through optional dependency edges (the classic case
325/// is every `@esbuild/*` platform native sitting under `esbuild`'s
326/// `optionalDependencies`). pnpm derives this during resolution; aube
327/// recomputes it as a post-resolve pass so freshly resolved lockfiles
328/// carry the same markers pnpm writes instead of an empty `{}` snapshot.
329///
330/// Algorithm: seed a `required` set from every non-optional direct
331/// dependency of every importer, then walk each required package's
332/// *non-optional* edges. A package's non-optional edges are its
333/// `dependencies` minus its `optional_dependencies`, because the pnpm
334/// parser mirrors active optional edges into `dependencies`. Any package
335/// not reached this way is optional. A single fully-required path keeps a
336/// package required even when other paths to it are optional, matching
337/// pnpm.
338pub fn mark_optional_packages(graph: &mut aube_lockfile::LockfileGraph) {
339 use crate::FxHashSet;
340 use aube_lockfile::DepType;
341
342 let mut required: FxHashSet<String> = FxHashSet::default();
343 let mut stack: Vec<String> = Vec::new();
344 for deps in graph.importers.values() {
345 for dep in deps {
346 if dep.dep_type != DepType::Optional {
347 stack.push(dep.dep_path.clone());
348 }
349 }
350 }
351 while let Some(dep_path) = stack.pop() {
352 if !required.insert(dep_path.clone()) {
353 continue;
354 }
355 let Some(pkg) = graph.packages.get(&dep_path) else {
356 continue;
357 };
358 for (name, tail) in &pkg.dependencies {
359 // Skip optional edges. `dependencies` carries pnpm's mirrored
360 // active optionals, so the `optional_dependencies` membership
361 // check is what separates a required edge from an optional one.
362 if pkg.optional_dependencies.contains_key(name) {
363 continue;
364 }
365 // Match `filter_graph`'s child-key convention (incl. the
366 // git/remote-tarball `name@url+<hash>` form) so a required
367 // git/tarball dep isn't mis-marked optional-only.
368 if let Some(child) =
369 aube_lockfile::resolve_dep_edge(name, tail, |k| graph.packages.contains_key(k))
370 {
371 stack.push(child);
372 }
373 }
374 }
375 for (dep_path, pkg) in graph.packages.iter_mut() {
376 pkg.optional = !required.contains(dep_path);
377 }
378}
379
380/// Populate each package's `transitive_peer_dependencies` the way pnpm
381/// does: a snapshot lists every peer name that some package in its
382/// dependency subtree declares but leaves unresolved (the peers that
383/// "bubble up" to be provided by a consumer). A peer that *was* resolved
384/// is mirrored into the declaring package's `dependencies` (pnpm and aube
385/// both do this — e.g. `@babel/core` lands in
386/// `@babel/helper-module-transforms`'s deps), so `peer_dependencies` minus
387/// `dependencies` is exactly the unresolved set. Those unresolved names are
388/// propagated to every ancestor; a package never lists its own peers.
389///
390/// Runs on the final, peer-contextualized graph (after `apply_peer_contexts`
391/// and the dedupe passes) so dep-path tails carry their peer suffixes.
392pub fn mark_transitive_peer_dependencies(graph: &mut aube_lockfile::LockfileGraph) {
393 use crate::{FxHashMap, FxHashSet};
394 use std::collections::BTreeSet;
395
396 // Reverse edges (child dep_path -> the parents that depend on it) plus
397 // each package's unresolved declared peers.
398 let mut parents: FxHashMap<String, Vec<String>> = FxHashMap::default();
399 let mut unresolved: FxHashMap<String, Vec<String>> = FxHashMap::default();
400
401 for (dep_path, pkg) in &graph.packages {
402 for (name, tail) in pkg
403 .dependencies
404 .iter()
405 .chain(pkg.optional_dependencies.iter())
406 {
407 // Skip resolved-peer edges. A dependency the package also
408 // declares as a peer (e.g. `eslint` inside an eslint plugin) is
409 // an injected peer, not an owned dependency — pnpm satisfies it
410 // from the consumer's context and does not bubble that peer's
411 // own transitive peers through the edge. Mirroring that keeps a
412 // plugin from inheriting `supports-color`/`typescript` purely
413 // because its injected `eslint`/`typescript` peer transitively
414 // depends on them.
415 if pkg.peer_dependencies.contains_key(name)
416 || pkg.peer_dependencies_meta.contains_key(name)
417 {
418 continue;
419 }
420 // Match `filter_graph`'s child-key convention (incl. the
421 // git/remote-tarball `name@url+<hash>` form) so peers bubble
422 // through git/tarball edges too.
423 if let Some(child) =
424 aube_lockfile::resolve_dep_edge(name, tail, |k| graph.packages.contains_key(k))
425 {
426 parents.entry(child).or_default().push(dep_path.clone());
427 } else {
428 // Edge points outside the resolved graph (workspace
429 // `link:`/`file:` deps, or a child pruned by platform
430 // filtering). It has no snapshot to bubble peers through,
431 // so dropping it is correct — log at debug for anyone
432 // chasing a missing `transitivePeerDependencies` entry.
433 tracing::debug!(
434 parent = %dep_path,
435 dep = %name,
436 tail = %tail,
437 "transitive-peer pass: dependency edge has no graph node, skipping"
438 );
439 }
440 }
441 // Declared peers plus pnpm's meta-only peers (the optional
442 // `peerDependenciesMeta` keys, folded in as `*` by the helper —
443 // e.g. debug's `supports-color`). A resolved peer is mirrored into
444 // `dependencies` (pnpm does the same for active optionals too, so
445 // only `dependencies` needs checking — never `optional_dependencies`),
446 // so subtracting `dependencies` keys leaves exactly the unresolved
447 // set that bubbles up.
448 let own: BTreeSet<String> = pkg
449 .peer_dependencies_with_meta_defaults()
450 .into_keys()
451 .filter(|p| !pkg.dependencies.contains_key(p))
452 .collect();
453 if !own.is_empty() {
454 unresolved.insert(dep_path.clone(), own.into_iter().collect());
455 }
456 }
457
458 // Bubble each package's unresolved peers up to every ancestor. The
459 // originating package is pre-marked visited, so it never collects its
460 // own peers even inside a dependency cycle.
461 let mut acc: FxHashMap<String, BTreeSet<String>> = FxHashMap::default();
462 for (origin, peers) in &unresolved {
463 let mut visited: FxHashSet<String> = FxHashSet::default();
464 visited.insert(origin.clone());
465 let mut stack: Vec<String> = parents.get(origin).cloned().unwrap_or_default();
466 while let Some(node) = stack.pop() {
467 if !visited.insert(node.clone()) {
468 continue;
469 }
470 let entry = acc.entry(node.clone()).or_default();
471 entry.extend(peers.iter().cloned());
472 if let Some(ps) = parents.get(&node) {
473 stack.extend(ps.iter().cloned());
474 }
475 }
476 }
477
478 for (dep_path, pkg) in graph.packages.iter_mut() {
479 pkg.transitive_peer_dependencies = acc
480 .get(dep_path)
481 .map(|s| s.iter().cloned().collect())
482 .unwrap_or_default();
483 }
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489
490 fn s(xs: &[&str]) -> Vec<String> {
491 xs.iter().map(|x| (*x).to_string()).collect()
492 }
493
494 #[test]
495 fn empty_fields_accept_any_host() {
496 let sup = SupportedArchitectures::default();
497 assert!(is_supported(&[], &[], &[], &sup));
498 }
499
500 #[test]
501 fn positive_match_rules() {
502 assert!(field_matches(&s(&["linux", "darwin"]), "linux"));
503 assert!(!field_matches(&s(&["linux", "darwin"]), "win32"));
504 }
505
506 #[test]
507 fn negation_rejects_match() {
508 assert!(!field_matches(&s(&["!win32"]), "win32"));
509 assert!(field_matches(&s(&["!win32"]), "linux"));
510 }
511
512 #[test]
513 fn mixed_negation_and_positive() {
514 // Negation takes precedence: even if a positive also matches,
515 // hitting a negation rejects.
516 assert!(!field_matches(&s(&["linux", "!linux"]), "linux"));
517 }
518
519 #[test]
520 fn supported_architectures_widens_with_current() {
521 // `["current", "linux"]` should accept the host *or* linux.
522 let sup = SupportedArchitectures {
523 os: s(&["current", "linux"]),
524 ..Default::default()
525 };
526 // A linux-only package passes regardless of host.
527 assert!(is_supported(&s(&["linux"]), &[], &[], &sup));
528 }
529
530 #[test]
531 fn accept_all_accepts_every_arch_including_non_host_triples() {
532 // pnpm/bun parity: `accept_all` records every optional-dep
533 // variant a package declares, even triples a host-only filter
534 // would reject (darwin-x64 on an arm64 mac, freebsd, ppc64,
535 // s390x, …). Without it, a regenerated cross-platform lockfile
536 // loses arches pnpm/bun keep, breaking teammates on those
537 // platforms.
538 let sup = SupportedArchitectures {
539 accept_all: true,
540 ..Default::default()
541 };
542 assert!(is_supported(&s(&["darwin"]), &s(&["x64"]), &[], &sup));
543 assert!(is_supported(&s(&["freebsd"]), &s(&["arm64"]), &[], &sup));
544 assert!(is_supported(
545 &s(&["linux"]),
546 &s(&["ppc64"]),
547 &s(&["glibc"]),
548 &sup
549 ));
550 assert!(is_supported(
551 &s(&["openharmony"]),
552 &s(&["arm64"]),
553 &[],
554 &sup
555 ));
556 assert!(is_supported(&s(&["win32"]), &s(&["ia32"]), &[], &sup));
557 // Sanity: a host-only (default) set rejects at least one of
558 // these, so the accept-all branch is doing real work.
559 let host_only = SupportedArchitectures::default();
560 let (host_os, _, _) = host_triple();
561 if host_os != "freebsd" {
562 assert!(!is_supported(
563 &s(&["freebsd"]),
564 &s(&["arm64"]),
565 &[],
566 &host_only
567 ));
568 }
569 }
570
571 #[test]
572 fn filter_graph_prunes_transitive_optional_platform_mismatches() {
573 let supported = SupportedArchitectures {
574 os: s(&["darwin"]),
575 cpu: s(&["arm64"]),
576 ..Default::default()
577 };
578 let mut graph = aube_lockfile::LockfileGraph::default();
579 graph.importers.insert(
580 ".".to_string(),
581 vec![aube_lockfile::DirectDep {
582 name: "host".to_string(),
583 dep_path: "host@1.0.0".to_string(),
584 dep_type: aube_lockfile::DepType::Production,
585 specifier: Some("1.0.0".to_string()),
586 }],
587 );
588 graph.packages.insert(
589 "host@1.0.0".to_string(),
590 aube_lockfile::LockedPackage {
591 name: "host".to_string(),
592 version: "1.0.0".to_string(),
593 dep_path: "host@1.0.0".to_string(),
594 dependencies: [
595 ("native-darwin".to_string(), "1.0.0".to_string()),
596 ("native-linux".to_string(), "1.0.0".to_string()),
597 ]
598 .into(),
599 optional_dependencies: [
600 ("native-darwin".to_string(), "1.0.0".to_string()),
601 ("native-linux".to_string(), "1.0.0".to_string()),
602 ]
603 .into(),
604 ..Default::default()
605 },
606 );
607 graph.packages.insert(
608 "native-darwin@1.0.0".to_string(),
609 aube_lockfile::LockedPackage {
610 name: "native-darwin".to_string(),
611 version: "1.0.0".to_string(),
612 dep_path: "native-darwin@1.0.0".to_string(),
613 os: s(&["darwin"]).into(),
614 cpu: s(&["arm64"]).into(),
615 ..Default::default()
616 },
617 );
618 graph.packages.insert(
619 "native-linux@1.0.0".to_string(),
620 aube_lockfile::LockedPackage {
621 name: "native-linux".to_string(),
622 version: "1.0.0".to_string(),
623 dep_path: "native-linux@1.0.0".to_string(),
624 os: s(&["linux"]).into(),
625 cpu: s(&["x64"]).into(),
626 ..Default::default()
627 },
628 );
629
630 filter_graph(&mut graph, &supported, &Default::default());
631
632 let host = graph.packages.get("host@1.0.0").unwrap();
633 assert!(host.dependencies.contains_key("native-darwin"));
634 assert!(!host.dependencies.contains_key("native-linux"));
635 assert!(graph.packages.contains_key("native-darwin@1.0.0"));
636 assert!(!graph.packages.contains_key("native-linux@1.0.0"));
637 }
638
639 fn dep(name: &str, dep_type: aube_lockfile::DepType) -> aube_lockfile::DirectDep {
640 aube_lockfile::DirectDep {
641 name: name.to_string(),
642 dep_path: format!("{name}@1.0.0"),
643 dep_type,
644 specifier: Some("1.0.0".to_string()),
645 }
646 }
647
648 fn pkg(name: &str, deps: &[&str], opt_deps: &[&str]) -> (String, aube_lockfile::LockedPackage) {
649 let dep_path = format!("{name}@1.0.0");
650 (
651 dep_path.clone(),
652 aube_lockfile::LockedPackage {
653 name: name.to_string(),
654 version: "1.0.0".to_string(),
655 dep_path,
656 dependencies: deps
657 .iter()
658 .map(|d| ((*d).to_string(), "1.0.0".to_string()))
659 .collect(),
660 optional_dependencies: opt_deps
661 .iter()
662 .map(|d| ((*d).to_string(), "1.0.0".to_string()))
663 .collect(),
664 ..Default::default()
665 },
666 )
667 }
668
669 #[test]
670 fn mark_optional_packages_marks_optional_only_reachable() {
671 use aube_lockfile::DepType;
672 let mut graph = aube_lockfile::LockfileGraph::default();
673 graph.importers.insert(
674 ".".to_string(),
675 vec![
676 dep("host", DepType::Production),
677 dep("also-required", DepType::Production),
678 dep("opt-root", DepType::Optional),
679 ],
680 );
681 // `host` has a required prod dep (`shared`), two optional-only
682 // natives, and `dual` reachable both optionally (here) and via a
683 // required edge from `also-required`. pnpm mirrors active optionals
684 // into `dependencies`, so they appear in both maps.
685 graph.packages.extend([
686 pkg(
687 "host",
688 &["shared", "native-darwin", "native-linux", "dual"],
689 &["native-darwin", "native-linux", "dual"],
690 ),
691 pkg("also-required", &["dual"], &[]),
692 pkg("shared", &[], &[]),
693 pkg("native-darwin", &[], &[]),
694 pkg("native-linux", &[], &[]),
695 pkg("dual", &[], &[]),
696 pkg("opt-root", &[], &[]),
697 ]);
698
699 mark_optional_packages(&mut graph);
700
701 let is_opt = |k: &str| graph.packages[k].optional;
702 // Required by a non-optional path.
703 assert!(!is_opt("host@1.0.0"));
704 assert!(!is_opt("also-required@1.0.0"));
705 assert!(!is_opt("shared@1.0.0"));
706 // Reachable both optionally and via a required edge → stays required.
707 assert!(!is_opt("dual@1.0.0"));
708 // Reachable only through optional edges → optional.
709 assert!(is_opt("native-darwin@1.0.0"));
710 assert!(is_opt("native-linux@1.0.0"));
711 // Direct optional importer dep with no required path → optional.
712 assert!(is_opt("opt-root@1.0.0"));
713 }
714
715 fn pkg_with_peers(
716 name: &str,
717 deps: &[&str],
718 peers: &[&str],
719 ) -> (String, aube_lockfile::LockedPackage) {
720 let (key, mut p) = pkg(name, deps, &[]);
721 p.peer_dependencies = peers
722 .iter()
723 .map(|d| ((*d).to_string(), "*".to_string()))
724 .collect();
725 (key, p)
726 }
727
728 #[test]
729 fn transitive_peer_dependencies_bubble_unresolved_peers() {
730 let mut graph = aube_lockfile::LockfileGraph::default();
731 graph.packages.extend([
732 pkg("app", &["host", "mid"], &[]),
733 // `host` declares `core` as a peer AND resolves it (core is in
734 // deps, mirrored like pnpm), so nothing bubbles from host.
735 pkg_with_peers("host", &["core"], &["core"]),
736 pkg("core", &[], &[]),
737 // `mid` -> `leaf`, and `leaf` peers on an unresolved
738 // `supports-color` (not in its deps): it must bubble to ancestors.
739 pkg("mid", &["leaf"], &[]),
740 pkg_with_peers("leaf", &["ms"], &["supports-color"]),
741 pkg("ms", &[], &[]),
742 ]);
743
744 mark_transitive_peer_dependencies(&mut graph);
745
746 let tp = |k: &str| graph.packages[k].transitive_peer_dependencies.clone();
747 // Unresolved peer bubbles to every ancestor of `leaf`.
748 assert_eq!(tp("app@1.0.0"), vec!["supports-color".to_string()]);
749 assert_eq!(tp("mid@1.0.0"), vec!["supports-color".to_string()]);
750 // `leaf` declares the peer itself → not in its OWN transitive list.
751 assert!(tp("leaf@1.0.0").is_empty());
752 assert!(tp("ms@1.0.0").is_empty());
753 // `host` resolves its `core` peer → nothing unresolved to bubble.
754 assert!(tp("host@1.0.0").is_empty());
755 assert!(tp("core@1.0.0").is_empty());
756 }
757
758 #[test]
759 fn transitive_peer_dependencies_handle_cycles_without_self() {
760 let mut graph = aube_lockfile::LockfileGraph::default();
761 // a <-> b dependency cycle, each with a distinct unresolved peer.
762 graph.packages.extend([
763 pkg_with_peers("a", &["b"], &["pa"]),
764 pkg_with_peers("b", &["a"], &["pb"]),
765 ]);
766
767 mark_transitive_peer_dependencies(&mut graph);
768
769 // Each node collects the other's peer through the cycle but never its
770 // own — `a` doesn't list `pa`, `b` doesn't list `pb`.
771 assert_eq!(
772 graph.packages["a@1.0.0"].transitive_peer_dependencies,
773 vec!["pb".to_string()]
774 );
775 assert_eq!(
776 graph.packages["b@1.0.0"].transitive_peer_dependencies,
777 vec!["pa".to_string()]
778 );
779 }
780
781 #[test]
782 fn filter_graph_prunes_npm_lockfile_transitive_optional_platform_mismatch() {
783 let content = r#"{
784 "name": "platform-optional-root",
785 "version": "1.0.0",
786 "lockfileVersion": 3,
787 "packages": {
788 "": {
789 "name": "platform-optional-root",
790 "version": "1.0.0",
791 "dependencies": { "host": "file:host" }
792 },
793 "node_modules/host": {
794 "resolved": "host",
795 "link": true
796 },
797 "host": {
798 "name": "host",
799 "version": "1.0.0",
800 "optionalDependencies": { "native-win": "1.0.0" }
801 },
802 "node_modules/native-win": {
803 "version": "1.0.0",
804 "resolved": "https://registry.npmjs.org/native-win/-/native-win-1.0.0.tgz",
805 "integrity": "sha512-native",
806 "optional": true,
807 "os": ["win32"],
808 "cpu": ["x64"],
809 "libc": ["glibc"]
810 }
811 }
812 }"#;
813 let tmp = tempfile::NamedTempFile::new().unwrap();
814 std::fs::write(tmp.path(), content).unwrap();
815 let mut graph = aube_lockfile::npm::parse(tmp.path()).unwrap();
816
817 let host_dep_path = graph.importers["."][0].dep_path.clone();
818 assert!(
819 graph.packages.contains_key(&host_dep_path),
820 "fixture must contain the host package before filtering"
821 );
822 assert!(
823 graph.packages.contains_key("native-win@1.0.0"),
824 "fixture must contain native-win before filtering"
825 );
826 let host = &graph.packages[&host_dep_path];
827 assert!(host.dependencies.contains_key("native-win"));
828 assert!(host.optional_dependencies.contains_key("native-win"));
829
830 let supported = SupportedArchitectures {
831 os: s(&["linux"]),
832 cpu: s(&["x64"]),
833 libc: s(&["glibc"]),
834 ..Default::default()
835 };
836 filter_graph(&mut graph, &supported, &Default::default());
837
838 assert!(graph.packages.contains_key(&host_dep_path));
839 assert!(!graph.packages.contains_key("native-win@1.0.0"));
840 let host = &graph.packages[&host_dep_path];
841 assert!(!host.dependencies.contains_key("native-win"));
842 assert!(!host.optional_dependencies.contains_key("native-win"));
843 }
844
845 #[cfg(not(target_os = "linux"))]
846 #[test]
847 fn libc_ignored_off_linux() {
848 // On a non-Linux host, a package that declares libc=musl
849 // should still pass — npm only enforces libc on Linux.
850 let sup = SupportedArchitectures::default();
851 assert!(is_supported(&[], &[], &s(&["musl"]), &sup));
852 }
853
854 #[cfg(target_os = "linux")]
855 #[test]
856 fn linux_glibc_host_rejects_musl_only_package() {
857 // The mirror of `libc_ignored_off_linux`: on a glibc Linux
858 // host, a package that declares libc=musl must not pass.
859 // Skipped on musl Linux builds, since "current" expands to
860 // musl there and the package would (correctly) match.
861 if cfg!(target_env = "musl") {
862 return;
863 }
864 let sup = SupportedArchitectures::default();
865 assert!(!is_supported(&[], &[], &s(&["musl"]), &sup));
866 }
867}