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            // pnpm mirrors active optional edges into `dependencies`, but
307            // Yarn Berry records them only in `optional_dependencies`.
308            for (name, tail) in pkg
309                .dependencies
310                .iter()
311                .chain(pkg.optional_dependencies.iter())
312            {
313                // Resolve the edge through every reader convention,
314                // including the git/remote-tarball `name@url+<hash>` form
315                // — otherwise a canonically-keyed git/tarball child (and
316                // its whole subtree) is unreachable here and gets GC'd.
317                if let Some(child) =
318                    aube_lockfile::resolve_dep_edge(name, tail, |k| graph.packages.contains_key(k))
319                {
320                    stack.push(child);
321                }
322            }
323        }
324    }
325    graph.packages.retain(|k, _| reachable.contains(k));
326}
327
328/// Set each package's `optional` flag the way pnpm marks the
329/// `snapshots:` section: a package is `optional: true` when it is
330/// reachable *only* through optional dependency edges (the classic case
331/// is every `@esbuild/*` platform native sitting under `esbuild`'s
332/// `optionalDependencies`). pnpm derives this during resolution; aube
333/// recomputes it as a post-resolve pass so freshly resolved lockfiles
334/// carry the same markers pnpm writes instead of an empty `{}` snapshot.
335///
336/// Algorithm: seed a `required` set from every non-optional direct
337/// dependency of every importer, then walk each required package's
338/// *non-optional* edges. A package's non-optional edges are its
339/// `dependencies` minus its `optional_dependencies`, because the pnpm
340/// parser mirrors active optional edges into `dependencies`. Any package
341/// not reached this way is optional. A single fully-required path keeps a
342/// package required even when other paths to it are optional, matching
343/// pnpm.
344pub fn mark_optional_packages(graph: &mut aube_lockfile::LockfileGraph) {
345    use crate::FxHashSet;
346    use aube_lockfile::DepType;
347
348    let mut required: FxHashSet<String> = FxHashSet::default();
349    let mut stack: Vec<String> = Vec::new();
350    for deps in graph.importers.values() {
351        for dep in deps {
352            if dep.dep_type != DepType::Optional {
353                stack.push(dep.dep_path.clone());
354            }
355        }
356    }
357    while let Some(dep_path) = stack.pop() {
358        if !required.insert(dep_path.clone()) {
359            continue;
360        }
361        let Some(pkg) = graph.packages.get(&dep_path) else {
362            continue;
363        };
364        for (name, tail) in &pkg.dependencies {
365            // Skip optional edges. `dependencies` carries pnpm's mirrored
366            // active optionals, so the `optional_dependencies` membership
367            // check is what separates a required edge from an optional one.
368            if pkg.optional_dependencies.contains_key(name) {
369                continue;
370            }
371            // Match `filter_graph`'s child-key convention (incl. the
372            // git/remote-tarball `name@url+<hash>` form) so a required
373            // git/tarball dep isn't mis-marked optional-only.
374            if let Some(child) =
375                aube_lockfile::resolve_dep_edge(name, tail, |k| graph.packages.contains_key(k))
376            {
377                stack.push(child);
378            }
379        }
380    }
381    for (dep_path, pkg) in graph.packages.iter_mut() {
382        pkg.optional = !required.contains(dep_path);
383    }
384}
385
386/// Populate each package's `transitive_peer_dependencies` the way pnpm
387/// does: a snapshot lists every peer name that some package in its
388/// dependency subtree declares but leaves unresolved (the peers that
389/// "bubble up" to be provided by a consumer). A peer that *was* resolved
390/// is mirrored into the declaring package's `dependencies` (pnpm and aube
391/// both do this — e.g. `@babel/core` lands in
392/// `@babel/helper-module-transforms`'s deps), so `peer_dependencies` minus
393/// `dependencies` is exactly the unresolved set. Those unresolved names are
394/// propagated to every ancestor; a package never lists its own peers.
395///
396/// Runs on the final, peer-contextualized graph (after `apply_peer_contexts`
397/// and the dedupe passes) so dep-path tails carry their peer suffixes.
398pub fn mark_transitive_peer_dependencies(graph: &mut aube_lockfile::LockfileGraph) {
399    use crate::{FxHashMap, FxHashSet};
400    use std::collections::BTreeSet;
401
402    // Reverse edges (child dep_path -> the parents that depend on it) plus
403    // each package's unresolved declared peers.
404    let mut parents: FxHashMap<String, Vec<String>> = FxHashMap::default();
405    let mut unresolved: FxHashMap<String, Vec<String>> = FxHashMap::default();
406
407    for (dep_path, pkg) in &graph.packages {
408        for (name, tail) in pkg
409            .dependencies
410            .iter()
411            .chain(pkg.optional_dependencies.iter())
412        {
413            // Skip resolved-peer edges. A dependency the package also
414            // declares as a peer (e.g. `eslint` inside an eslint plugin) is
415            // an injected peer, not an owned dependency — pnpm satisfies it
416            // from the consumer's context and does not bubble that peer's
417            // own transitive peers through the edge. Mirroring that keeps a
418            // plugin from inheriting `supports-color`/`typescript` purely
419            // because its injected `eslint`/`typescript` peer transitively
420            // depends on them.
421            if pkg.peer_dependencies.contains_key(name)
422                || pkg.peer_dependencies_meta.contains_key(name)
423            {
424                continue;
425            }
426            // Match `filter_graph`'s child-key convention (incl. the
427            // git/remote-tarball `name@url+<hash>` form) so peers bubble
428            // through git/tarball edges too.
429            if let Some(child) =
430                aube_lockfile::resolve_dep_edge(name, tail, |k| graph.packages.contains_key(k))
431            {
432                parents.entry(child).or_default().push(dep_path.clone());
433            } else {
434                // Edge points outside the resolved graph (workspace
435                // `link:`/`file:` deps, or a child pruned by platform
436                // filtering). It has no snapshot to bubble peers through,
437                // so dropping it is correct — log at debug for anyone
438                // chasing a missing `transitivePeerDependencies` entry.
439                tracing::debug!(
440                    parent = %dep_path,
441                    dep = %name,
442                    tail = %tail,
443                    "transitive-peer pass: dependency edge has no graph node, skipping"
444                );
445            }
446        }
447        // Declared peers plus pnpm's meta-only peers (the optional
448        // `peerDependenciesMeta` keys, folded in as `*` by the helper —
449        // e.g. debug's `supports-color`). A resolved peer is mirrored into
450        // `dependencies` (pnpm does the same for active optionals too, so
451        // only `dependencies` needs checking — never `optional_dependencies`),
452        // so subtracting `dependencies` keys leaves exactly the unresolved
453        // set that bubbles up.
454        let own: BTreeSet<String> = pkg
455            .peer_dependencies_with_meta_defaults()
456            .into_keys()
457            .filter(|p| !pkg.dependencies.contains_key(p))
458            .collect();
459        if !own.is_empty() {
460            unresolved.insert(dep_path.clone(), own.into_iter().collect());
461        }
462    }
463
464    // Bubble each package's unresolved peers up to every ancestor. The
465    // originating package is pre-marked visited, so it never collects its
466    // own peers even inside a dependency cycle.
467    let mut acc: FxHashMap<String, BTreeSet<String>> = FxHashMap::default();
468    for (origin, peers) in &unresolved {
469        let mut visited: FxHashSet<String> = FxHashSet::default();
470        visited.insert(origin.clone());
471        let mut stack: Vec<String> = parents.get(origin).cloned().unwrap_or_default();
472        while let Some(node) = stack.pop() {
473            if !visited.insert(node.clone()) {
474                continue;
475            }
476            let entry = acc.entry(node.clone()).or_default();
477            entry.extend(peers.iter().cloned());
478            if let Some(ps) = parents.get(&node) {
479                stack.extend(ps.iter().cloned());
480            }
481        }
482    }
483
484    for (dep_path, pkg) in graph.packages.iter_mut() {
485        pkg.transitive_peer_dependencies = acc
486            .get(dep_path)
487            .map(|s| s.iter().cloned().collect())
488            .unwrap_or_default();
489    }
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495
496    fn s(xs: &[&str]) -> Vec<String> {
497        xs.iter().map(|x| (*x).to_string()).collect()
498    }
499
500    #[test]
501    fn empty_fields_accept_any_host() {
502        let sup = SupportedArchitectures::default();
503        assert!(is_supported(&[], &[], &[], &sup));
504    }
505
506    #[test]
507    fn positive_match_rules() {
508        assert!(field_matches(&s(&["linux", "darwin"]), "linux"));
509        assert!(!field_matches(&s(&["linux", "darwin"]), "win32"));
510    }
511
512    #[test]
513    fn negation_rejects_match() {
514        assert!(!field_matches(&s(&["!win32"]), "win32"));
515        assert!(field_matches(&s(&["!win32"]), "linux"));
516    }
517
518    #[test]
519    fn mixed_negation_and_positive() {
520        // Negation takes precedence: even if a positive also matches,
521        // hitting a negation rejects.
522        assert!(!field_matches(&s(&["linux", "!linux"]), "linux"));
523    }
524
525    #[test]
526    fn supported_architectures_widens_with_current() {
527        // `["current", "linux"]` should accept the host *or* linux.
528        let sup = SupportedArchitectures {
529            os: s(&["current", "linux"]),
530            ..Default::default()
531        };
532        // A linux-only package passes regardless of host.
533        assert!(is_supported(&s(&["linux"]), &[], &[], &sup));
534    }
535
536    #[test]
537    fn accept_all_accepts_every_arch_including_non_host_triples() {
538        // pnpm/bun parity: `accept_all` records every optional-dep
539        // variant a package declares, even triples a host-only filter
540        // would reject (darwin-x64 on an arm64 mac, freebsd, ppc64,
541        // s390x, …). Without it, a regenerated cross-platform lockfile
542        // loses arches pnpm/bun keep, breaking teammates on those
543        // platforms.
544        let sup = SupportedArchitectures {
545            accept_all: true,
546            ..Default::default()
547        };
548        assert!(is_supported(&s(&["darwin"]), &s(&["x64"]), &[], &sup));
549        assert!(is_supported(&s(&["freebsd"]), &s(&["arm64"]), &[], &sup));
550        assert!(is_supported(
551            &s(&["linux"]),
552            &s(&["ppc64"]),
553            &s(&["glibc"]),
554            &sup
555        ));
556        assert!(is_supported(
557            &s(&["openharmony"]),
558            &s(&["arm64"]),
559            &[],
560            &sup
561        ));
562        assert!(is_supported(&s(&["win32"]), &s(&["ia32"]), &[], &sup));
563        // Sanity: a host-only (default) set rejects at least one of
564        // these, so the accept-all branch is doing real work.
565        let host_only = SupportedArchitectures::default();
566        let (host_os, _, _) = host_triple();
567        if host_os != "freebsd" {
568            assert!(!is_supported(
569                &s(&["freebsd"]),
570                &s(&["arm64"]),
571                &[],
572                &host_only
573            ));
574        }
575    }
576
577    #[test]
578    fn filter_graph_prunes_transitive_optional_platform_mismatches() {
579        let supported = SupportedArchitectures {
580            os: s(&["darwin"]),
581            cpu: s(&["arm64"]),
582            ..Default::default()
583        };
584        let mut graph = aube_lockfile::LockfileGraph::default();
585        graph.importers.insert(
586            ".".to_string(),
587            vec![aube_lockfile::DirectDep {
588                name: "host".to_string(),
589                dep_path: "host@1.0.0".to_string(),
590                dep_type: aube_lockfile::DepType::Production,
591                specifier: Some("1.0.0".to_string()),
592            }],
593        );
594        graph.packages.insert(
595            "host@1.0.0".to_string(),
596            aube_lockfile::LockedPackage {
597                name: "host".to_string(),
598                version: "1.0.0".to_string(),
599                dep_path: "host@1.0.0".to_string(),
600                dependencies: [
601                    ("native-darwin".to_string(), "1.0.0".to_string()),
602                    ("native-linux".to_string(), "1.0.0".to_string()),
603                ]
604                .into(),
605                optional_dependencies: [
606                    ("native-darwin".to_string(), "1.0.0".to_string()),
607                    ("native-linux".to_string(), "1.0.0".to_string()),
608                ]
609                .into(),
610                ..Default::default()
611            },
612        );
613        graph.packages.insert(
614            "native-darwin@1.0.0".to_string(),
615            aube_lockfile::LockedPackage {
616                name: "native-darwin".to_string(),
617                version: "1.0.0".to_string(),
618                dep_path: "native-darwin@1.0.0".to_string(),
619                os: s(&["darwin"]).into(),
620                cpu: s(&["arm64"]).into(),
621                ..Default::default()
622            },
623        );
624        graph.packages.insert(
625            "native-linux@1.0.0".to_string(),
626            aube_lockfile::LockedPackage {
627                name: "native-linux".to_string(),
628                version: "1.0.0".to_string(),
629                dep_path: "native-linux@1.0.0".to_string(),
630                os: s(&["linux"]).into(),
631                cpu: s(&["x64"]).into(),
632                ..Default::default()
633            },
634        );
635
636        filter_graph(&mut graph, &supported, &Default::default());
637
638        let host = graph.packages.get("host@1.0.0").unwrap();
639        assert!(host.dependencies.contains_key("native-darwin"));
640        assert!(!host.dependencies.contains_key("native-linux"));
641        assert!(graph.packages.contains_key("native-darwin@1.0.0"));
642        assert!(!graph.packages.contains_key("native-linux@1.0.0"));
643    }
644
645    #[test]
646    fn filter_graph_keeps_supported_yarn_berry_optional_children() {
647        let supported = SupportedArchitectures {
648            os: s(&["darwin"]),
649            cpu: s(&["arm64"]),
650            ..Default::default()
651        };
652        let mut graph = aube_lockfile::LockfileGraph::default();
653        graph.importers.insert(
654            ".".to_string(),
655            vec![aube_lockfile::DirectDep {
656                name: "host".to_string(),
657                dep_path: "host@1.0.0".to_string(),
658                dep_type: aube_lockfile::DepType::Production,
659                specifier: Some("1.0.0".to_string()),
660            }],
661        );
662        graph.packages.insert(
663            "host@1.0.0".to_string(),
664            aube_lockfile::LockedPackage {
665                name: "host".to_string(),
666                version: "1.0.0".to_string(),
667                dep_path: "host@1.0.0".to_string(),
668                // Yarn Berry does not mirror optional edges here.
669                dependencies: Default::default(),
670                optional_dependencies: [("native-darwin".to_string(), "1.0.0".to_string())].into(),
671                ..Default::default()
672            },
673        );
674        graph.packages.insert(
675            "native-darwin@1.0.0".to_string(),
676            aube_lockfile::LockedPackage {
677                name: "native-darwin".to_string(),
678                version: "1.0.0".to_string(),
679                dep_path: "native-darwin@1.0.0".to_string(),
680                os: s(&["darwin"]).into(),
681                cpu: s(&["arm64"]).into(),
682                ..Default::default()
683            },
684        );
685
686        filter_graph(&mut graph, &supported, &Default::default());
687
688        assert!(graph.packages.contains_key("native-darwin@1.0.0"));
689    }
690
691    fn dep(name: &str, dep_type: aube_lockfile::DepType) -> aube_lockfile::DirectDep {
692        aube_lockfile::DirectDep {
693            name: name.to_string(),
694            dep_path: format!("{name}@1.0.0"),
695            dep_type,
696            specifier: Some("1.0.0".to_string()),
697        }
698    }
699
700    fn pkg(name: &str, deps: &[&str], opt_deps: &[&str]) -> (String, aube_lockfile::LockedPackage) {
701        let dep_path = format!("{name}@1.0.0");
702        (
703            dep_path.clone(),
704            aube_lockfile::LockedPackage {
705                name: name.to_string(),
706                version: "1.0.0".to_string(),
707                dep_path,
708                dependencies: deps
709                    .iter()
710                    .map(|d| ((*d).to_string(), "1.0.0".to_string()))
711                    .collect(),
712                optional_dependencies: opt_deps
713                    .iter()
714                    .map(|d| ((*d).to_string(), "1.0.0".to_string()))
715                    .collect(),
716                ..Default::default()
717            },
718        )
719    }
720
721    #[test]
722    fn mark_optional_packages_marks_optional_only_reachable() {
723        use aube_lockfile::DepType;
724        let mut graph = aube_lockfile::LockfileGraph::default();
725        graph.importers.insert(
726            ".".to_string(),
727            vec![
728                dep("host", DepType::Production),
729                dep("also-required", DepType::Production),
730                dep("opt-root", DepType::Optional),
731            ],
732        );
733        // `host` has a required prod dep (`shared`), two optional-only
734        // natives, and `dual` reachable both optionally (here) and via a
735        // required edge from `also-required`. pnpm mirrors active optionals
736        // into `dependencies`, so they appear in both maps.
737        graph.packages.extend([
738            pkg(
739                "host",
740                &["shared", "native-darwin", "native-linux", "dual"],
741                &["native-darwin", "native-linux", "dual"],
742            ),
743            pkg("also-required", &["dual"], &[]),
744            pkg("shared", &[], &[]),
745            pkg("native-darwin", &[], &[]),
746            pkg("native-linux", &[], &[]),
747            pkg("dual", &[], &[]),
748            pkg("opt-root", &[], &[]),
749        ]);
750
751        mark_optional_packages(&mut graph);
752
753        let is_opt = |k: &str| graph.packages[k].optional;
754        // Required by a non-optional path.
755        assert!(!is_opt("host@1.0.0"));
756        assert!(!is_opt("also-required@1.0.0"));
757        assert!(!is_opt("shared@1.0.0"));
758        // Reachable both optionally and via a required edge → stays required.
759        assert!(!is_opt("dual@1.0.0"));
760        // Reachable only through optional edges → optional.
761        assert!(is_opt("native-darwin@1.0.0"));
762        assert!(is_opt("native-linux@1.0.0"));
763        // Direct optional importer dep with no required path → optional.
764        assert!(is_opt("opt-root@1.0.0"));
765    }
766
767    fn pkg_with_peers(
768        name: &str,
769        deps: &[&str],
770        peers: &[&str],
771    ) -> (String, aube_lockfile::LockedPackage) {
772        let (key, mut p) = pkg(name, deps, &[]);
773        p.peer_dependencies = peers
774            .iter()
775            .map(|d| ((*d).to_string(), "*".to_string()))
776            .collect();
777        (key, p)
778    }
779
780    #[test]
781    fn transitive_peer_dependencies_bubble_unresolved_peers() {
782        let mut graph = aube_lockfile::LockfileGraph::default();
783        graph.packages.extend([
784            pkg("app", &["host", "mid"], &[]),
785            // `host` declares `core` as a peer AND resolves it (core is in
786            // deps, mirrored like pnpm), so nothing bubbles from host.
787            pkg_with_peers("host", &["core"], &["core"]),
788            pkg("core", &[], &[]),
789            // `mid` -> `leaf`, and `leaf` peers on an unresolved
790            // `supports-color` (not in its deps): it must bubble to ancestors.
791            pkg("mid", &["leaf"], &[]),
792            pkg_with_peers("leaf", &["ms"], &["supports-color"]),
793            pkg("ms", &[], &[]),
794        ]);
795
796        mark_transitive_peer_dependencies(&mut graph);
797
798        let tp = |k: &str| graph.packages[k].transitive_peer_dependencies.clone();
799        // Unresolved peer bubbles to every ancestor of `leaf`.
800        assert_eq!(tp("app@1.0.0"), vec!["supports-color".to_string()]);
801        assert_eq!(tp("mid@1.0.0"), vec!["supports-color".to_string()]);
802        // `leaf` declares the peer itself → not in its OWN transitive list.
803        assert!(tp("leaf@1.0.0").is_empty());
804        assert!(tp("ms@1.0.0").is_empty());
805        // `host` resolves its `core` peer → nothing unresolved to bubble.
806        assert!(tp("host@1.0.0").is_empty());
807        assert!(tp("core@1.0.0").is_empty());
808    }
809
810    #[test]
811    fn transitive_peer_dependencies_handle_cycles_without_self() {
812        let mut graph = aube_lockfile::LockfileGraph::default();
813        // a <-> b dependency cycle, each with a distinct unresolved peer.
814        graph.packages.extend([
815            pkg_with_peers("a", &["b"], &["pa"]),
816            pkg_with_peers("b", &["a"], &["pb"]),
817        ]);
818
819        mark_transitive_peer_dependencies(&mut graph);
820
821        // Each node collects the other's peer through the cycle but never its
822        // own — `a` doesn't list `pa`, `b` doesn't list `pb`.
823        assert_eq!(
824            graph.packages["a@1.0.0"].transitive_peer_dependencies,
825            vec!["pb".to_string()]
826        );
827        assert_eq!(
828            graph.packages["b@1.0.0"].transitive_peer_dependencies,
829            vec!["pa".to_string()]
830        );
831    }
832
833    #[test]
834    fn filter_graph_prunes_npm_lockfile_transitive_optional_platform_mismatch() {
835        let content = r#"{
836            "name": "platform-optional-root",
837            "version": "1.0.0",
838            "lockfileVersion": 3,
839            "packages": {
840                "": {
841                    "name": "platform-optional-root",
842                    "version": "1.0.0",
843                    "dependencies": { "host": "file:host" }
844                },
845                "node_modules/host": {
846                    "resolved": "host",
847                    "link": true
848                },
849                "host": {
850                    "name": "host",
851                    "version": "1.0.0",
852                    "optionalDependencies": { "native-win": "1.0.0" }
853                },
854                "node_modules/native-win": {
855                    "version": "1.0.0",
856                    "resolved": "https://registry.npmjs.org/native-win/-/native-win-1.0.0.tgz",
857                    "integrity": "sha512-native",
858                    "optional": true,
859                    "os": ["win32"],
860                    "cpu": ["x64"],
861                    "libc": ["glibc"]
862                }
863            }
864        }"#;
865        let tmp = tempfile::NamedTempFile::new().unwrap();
866        std::fs::write(tmp.path(), content).unwrap();
867        let mut graph = aube_lockfile::npm::parse(tmp.path()).unwrap();
868
869        let host_dep_path = graph.importers["."][0].dep_path.clone();
870        assert!(
871            graph.packages.contains_key(&host_dep_path),
872            "fixture must contain the host package before filtering"
873        );
874        assert!(
875            graph.packages.contains_key("native-win@1.0.0"),
876            "fixture must contain native-win before filtering"
877        );
878        let host = &graph.packages[&host_dep_path];
879        assert!(host.dependencies.contains_key("native-win"));
880        assert!(host.optional_dependencies.contains_key("native-win"));
881
882        let supported = SupportedArchitectures {
883            os: s(&["linux"]),
884            cpu: s(&["x64"]),
885            libc: s(&["glibc"]),
886            ..Default::default()
887        };
888        filter_graph(&mut graph, &supported, &Default::default());
889
890        assert!(graph.packages.contains_key(&host_dep_path));
891        assert!(!graph.packages.contains_key("native-win@1.0.0"));
892        let host = &graph.packages[&host_dep_path];
893        assert!(!host.dependencies.contains_key("native-win"));
894        assert!(!host.optional_dependencies.contains_key("native-win"));
895    }
896
897    #[cfg(not(target_os = "linux"))]
898    #[test]
899    fn libc_ignored_off_linux() {
900        // On a non-Linux host, a package that declares libc=musl
901        // should still pass — npm only enforces libc on Linux.
902        let sup = SupportedArchitectures::default();
903        assert!(is_supported(&[], &[], &s(&["musl"]), &sup));
904    }
905
906    #[cfg(target_os = "linux")]
907    #[test]
908    fn linux_glibc_host_rejects_musl_only_package() {
909        // The mirror of `libc_ignored_off_linux`: on a glibc Linux
910        // host, a package that declares libc=musl must not pass.
911        // Skipped on musl Linux builds, since "current" expands to
912        // musl there and the package would (correctly) match.
913        if cfg!(target_env = "musl") {
914            return;
915        }
916        let sup = SupportedArchitectures::default();
917        assert!(!is_supported(&[], &[], &s(&["musl"]), &sup));
918    }
919}