Skip to main content

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}