Skip to main content

aube_linker/
lib.rs

1use aube_lockfile::graph_hash::GraphHashes;
2use aube_store::Store;
3use std::path::PathBuf;
4
5#[cfg(test)]
6use aube_store::PackageIndex;
7#[cfg(test)]
8use std::collections::BTreeMap;
9#[cfg(test)]
10use std::path::Path;
11
12mod builder;
13mod error;
14mod hoisted;
15mod link;
16mod materialize;
17mod patches;
18mod pool;
19mod sweep;
20pub mod sys;
21
22#[cfg(test)]
23mod public_hoist_tests;
24#[cfg(test)]
25mod tests;
26
27pub use error::Error;
28pub use hoisted::HoistedPlacements;
29pub use link::build_nested_link_targets;
30pub(crate) use materialize::{
31    invalidate_stale_index_for_package, validate_index_key, validate_package_link_name,
32};
33pub use patches::Patches;
34pub(crate) use patches::apply_multi_file_patch;
35pub use pool::default_linker_parallelism;
36pub use sweep::{is_physical_importer, mkdirp, remove_dir_all_with_retry, sweep_stale_tmp_dirs};
37pub(crate) use sweep::{sweep_stale_top_level_entries, try_remove_entry};
38pub use sys::{
39    BinShimOptions, create_bin_shim, create_dir_link, normalize_path, parse_posix_shim_target,
40    remove_bin_shim, validate_bin_name, validate_bin_target,
41};
42
43/// Strategy for arranging packages under `node_modules/`.
44///
45/// `Isolated` is pnpm's default layout — every package lives under
46/// `.aube/<dep_path>/node_modules/<name>` and the top-level
47/// `node_modules/<name>` entry is a symlink into that virtual store.
48/// `Hoisted` flattens the tree npm-style: packages are materialized
49/// directly into `node_modules/<name>/` with conflicting versions
50/// nested under the requiring parent. `Hoisted` is slower to
51/// materialize and uses more disk, but matches the layout a handful
52/// of legacy toolchains still expect.
53/// `FromStr` is case-insensitive so settings-file and CLI inputs like
54/// `Isolated` or `HOISTED` parse the same as the canonical lowercase
55/// spellings. Callers that accept user input should still `trim()`
56/// before parsing.
57#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, strum::EnumString)]
58#[strum(serialize_all = "lowercase", ascii_case_insensitive)]
59pub enum NodeLinker {
60    #[default]
61    Isolated,
62    Hoisted,
63}
64
65/// Limit how far packages may be promoted in `NodeLinker::Hoisted`.
66#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
67pub enum HoistingLimits {
68    /// Hoist as far as possible.
69    #[default]
70    None,
71    /// Do not hoist dependencies above their workspace package.
72    Workspaces,
73    /// Do not hoist transitives above the direct dependency that
74    /// introduced them.
75    Dependencies,
76}
77
78/// Links packages from the global store into a project's node_modules/.
79///
80/// Uses pnpm-compatible symlink layout backed by a global virtual store:
81/// - Packages are materialized once in `~/.cache/aube/virtual-store/`
82///   (or `$XDG_CACHE_HOME/aube/virtual-store/`)
83/// - Per-project `.aube/` entries are symlinks into the global virtual store
84/// - Top-level `node_modules/<name>` entries are symlinks to
85///   `.aube/<dep_path>/node_modules/<name>` (matching pnpm)
86/// - Transitive deps live as sibling symlinks inside `.aube/<dep_path>/node_modules/`
87///   so Node's directory walk finds them when resolving from inside the package
88pub struct Linker {
89    virtual_store: PathBuf,
90    /// Keep a handle to the global CAS so the linker can lazy-load a
91    /// `PackageIndex` on demand when the install driver skipped
92    /// `load_index` on the fast path but a stale symlink or missing
93    /// virtual-store entry forces a (re)materialization. Without this,
94    /// the optimistic no-op short-circuit in `install.rs` wouldn't be
95    /// safe against graph-hash changes (e.g. patches added,
96    /// `allowBuilds` entries flipped, engine version bumped).
97    pub(crate) store: Store,
98    use_global_virtual_store: bool,
99    /// Exact dep paths that must be materialized into the project's
100    /// `.aube/` tree even while the rest of the graph uses the global
101    /// virtual store. This is reserved for compatibility transforms
102    /// that need to mutate one package without touching shared bytes.
103    project_local_dep_paths: rustc_hash::FxHashSet<String>,
104    strategy: LinkStrategy,
105    /// Per-`name@version` patch contents applied at materialize
106    /// time. Empty when the project has no `pnpm.patchedDependencies`.
107    pub(crate) patches: Patches,
108    /// Optional content-addressed hashes for global-store subdir
109    /// naming. When set, every path inside `self.virtual_store` uses
110    /// `hashes.hashed_dep_path(dep_path)` as the dep's leaf name,
111    /// which folds the recursive dep-graph hash (and the engine
112    /// string, for packages that transitively require building) into
113    /// the filesystem path. Packages with different builds can't
114    /// collide in the shared store because they end up at different
115    /// paths. When `None`, the linker falls back to the raw dep_path
116    /// (backwards-compatible with pre-hash callers and with the
117    /// per-project `.aube/` layout, which always uses dep_path).
118    hashes: Option<GraphHashes>,
119    /// Cap on the length of a single virtual-store directory name.
120    /// Matches pnpm's `virtual-store-dir-max-length` config (default
121    /// 120). Every dep_path the linker writes to disk gets routed
122    /// through `dep_path_to_filename(_, this)`, which truncates and
123    /// hashes names longer than this cap so peer-heavy graphs (e.g.
124    /// anything pulling in the ESLint + TypeScript matrix) don't
125    /// overflow Linux's 255-byte `NAME_MAX`.
126    virtual_store_dir_max_length: usize,
127    /// pnpm's `shamefully-hoist`: after creating the usual top-level
128    /// symlinks for direct deps, walk every package in the graph and
129    /// create a `node_modules/<name>` symlink for any name that
130    /// isn't already claimed. Mirrors pnpm's "flat node_modules"
131    /// compatibility escape hatch. First-write-wins on name clashes.
132    shamefully_hoist: bool,
133    /// pnpm's `public-hoist-pattern`: glob list matched against
134    /// package names. Any non-local package in the graph whose name
135    /// matches at least one positive pattern (and no `!`-prefixed
136    /// negation) gets a top-level `node_modules/<name>` symlink in
137    /// addition to the direct-dep entries. First-write-wins, so
138    /// direct deps and earlier hoist passes keep priority. Empty list
139    /// disables the feature entirely. Frameworks like Next.js,
140    /// Storybook, and Jest rely on this to resolve transitive deps
141    /// from the project root.
142    public_hoist_patterns: Vec<glob::Pattern>,
143    public_hoist_negations: Vec<glob::Pattern>,
144    /// pnpm's `hoist`: master switch for the hidden modules directory
145    /// at `node_modules/.aube/node_modules/`. When true (the default),
146    /// every non-local package whose name matches `hoist_patterns`
147    /// (and no `hoist_negations`) gets a symlink into that hidden
148    /// directory so Node's parent-directory walk can satisfy
149    /// undeclared deps in third-party packages. When false, the
150    /// hidden tree is skipped entirely and any existing
151    /// `.aube/node_modules/` is wiped so stale entries don't linger.
152    hoist: bool,
153    /// pnpm's `hoist-pattern`: glob list matched against package names
154    /// for hidden-hoist promotion. Populated with `*` in `new()` so a
155    /// default-constructed linker matches everything (pnpm parity).
156    /// `with_hoist_pattern` replaces both positive and negative
157    /// patterns in full, so passing `[]` or only-negation means
158    /// "hoist nothing". Only consulted when `hoist == true`.
159    hoist_patterns: Vec<glob::Pattern>,
160    hoist_negations: Vec<glob::Pattern>,
161    /// pnpm's `hoist-workspace-packages`: when false, workspace
162    /// packages are not symlinked into the root `node_modules/`.
163    /// Other workspace packages can still resolve them through the
164    /// lockfile's workspace protocol, but plain `require('<ws-pkg>')`
165    /// from the root stops working. Default true.
166    hoist_workspace_packages: bool,
167    /// pnpm's `hoistingLimits` for `node-linker=hoisted`. Isolated
168    /// linking ignores this setting because hidden/public hoisting is
169    /// controlled by the separate `hoist*` family above.
170    pub(crate) hoisting_limits: HoistingLimits,
171    /// pnpm's `dedupe-direct-deps`: when true, the linker skips
172    /// creating a per-importer `node_modules/<name>` symlink for a
173    /// direct dep whose root importer already declares the same
174    /// package at the same resolved version. The root-level symlink
175    /// still exists, so Node's parent-directory walk from inside the
176    /// workspace package resolves the same copy — callers just avoid
177    /// the duplicate per-importer link. Default false (pnpm parity).
178    dedupe_direct_deps: bool,
179    /// Active layout mode. `NodeLinker::Isolated` (default) routes
180    /// through the existing `.aube/` virtual-store paths;
181    /// `NodeLinker::Hoisted` dispatches to `hoisted::link_hoisted_importer`
182    /// which writes real package directories flat into `node_modules/`.
183    /// Mode is per-install, not per-package — switching between
184    /// modes leaves the opposite layout on disk so subsequent
185    /// installs in the other mode reuse what's already there (and
186    /// pay the materialization cost once).
187    pub(crate) node_linker: NodeLinker,
188    /// pnpm's `modules-dir`: the *project-level* directory that holds
189    /// the top-level `<name>` entries the user sees under the project
190    /// root. Defaults to `"node_modules"`, which is also what Node.js
191    /// itself expects for the walk from `<project>/src/file.js` up to
192    /// the project root. The virtual-store tree under
193    /// `<modules_dir>/.aube/<dep_path>/node_modules/<name>` keeps its
194    /// inner `node_modules/` name literal — Node requires the exact
195    /// string `node_modules` when resolving sibling deps from inside a
196    /// package — so this setting only affects the *outer* directory
197    /// name, matching pnpm's behavior. Users who change it are
198    /// responsible for setting `NODE_PATH` (or using a custom
199    /// resolver) so Node can still find their deps.
200    pub(crate) modules_dir_name: String,
201    /// pnpm's `virtual-store-dir`: absolute path of the per-project
202    /// virtual store (what pnpm calls `node_modules/.pnpm`). `None`
203    /// means "derive from `modules_dir_name` at link time":
204    /// `<project_dir>/<modules_dir_name>/.<name>` (standalone aube →
205    /// `.aube`), matching the default behavior every caller expected
206    /// before this knob existed. When set by the install driver via
207    /// `with_aube_dir_override`, it overrides that derivation — the
208    /// linker writes its `.<name>/<dep_path>/` tree into the supplied
209    /// path instead. The
210    /// path is *absolute*; relative overrides from `.npmrc` /
211    /// `pnpm-workspace.yaml` get resolved against the project dir by
212    /// the caller (see
213    /// `aube_cli::commands::resolve_virtual_store_dir`).
214    pub(crate) aube_dir_override: Option<std::path::PathBuf>,
215    /// Cap for package-level filesystem materialization/linking work.
216    /// This is deliberately separate from Rayon's global thread-count
217    /// environment: aube is tuning metadata/syscall pressure, not CPU
218    /// parallelism. Defaults are platform-aware and can be overridden by
219    /// the install driver via the `linkConcurrency` setting.
220    link_concurrency: Option<usize>,
221    /// pnpm's `virtual-store-only`: when true, the linker still
222    /// populates `.aube/<dep_path>/node_modules/<name>` (and, in
223    /// global-store mode, the shared virtual store under
224    /// `~/.cache/aube/virtual-store/`), but skips the final pass that
225    /// creates the top-level `node_modules/<name>` symlinks. The
226    /// `shamefullyHoist` and `publicHoistPattern` hoist passes are
227    /// also skipped because both target the same top-level directory.
228    /// Useful for CI jobs that pre-populate a shared store without
229    /// exposing the graph to Node's resolver. No-op under
230    /// `NodeLinker::Hoisted` — that layout *is* a flat top-level
231    /// materialization, so "only the virtual store" doesn't apply.
232    virtual_store_only: bool,
233}
234
235/// Strategy for linking files from the store to node_modules.
236#[derive(Debug, Clone, Copy)]
237pub enum LinkStrategy {
238    /// Copy-on-write (APFS clonefile, btrfs reflink). Selected only by
239    /// explicit `packageImportMethod = clone` / `clone-or-copy`, whose
240    /// documented contract is reflink with a plain **copy** fallback.
241    Reflink,
242    /// Copy-on-write chosen by `auto` on a same-filesystem macOS target,
243    /// where APFS clonefile benchmarks faster than hardlink. (On Linux
244    /// and other targets `auto` picks [`Hardlink`].) Distinct from
245    /// [`Reflink`] because `auto` owns a stronger reflink-failure fallback:
246    /// the same-FS probe already proved the target shares a mount, so on a
247    /// non-APFS same-FS volume (HFS+) — where `clonefile` is unsupported but
248    /// hardlinks are not — `auto` degrades to a hardlink before copy,
249    /// keeping the link zero-cost where explicit `clone` / `clone-or-copy`
250    /// would copy. (Small macOS files copy outright before any reflink or
251    /// hardlink attempt; the hardlink step is the reflink-*failure* fallback,
252    /// not an unconditional same-FS guarantee.) Explicit `clone` /
253    /// `clone-or-copy` keep their documented copy fallback and never take
254    /// this hardlink step.
255    ///
256    /// [`Reflink`]: LinkStrategy::Reflink
257    /// [`Hardlink`]: LinkStrategy::Hardlink
258    ReflinkAuto,
259    /// Hard link (ext4, NTFS)
260    Hardlink,
261    /// Full copy (fallback)
262    Copy,
263}
264
265#[derive(Debug, Default)]
266pub struct LinkStats {
267    pub packages_linked: usize,
268    pub packages_cached: usize,
269    pub files_linked: usize,
270    pub top_level_linked: usize,
271    /// Populated only when the linker ran in `NodeLinker::Hoisted`
272    /// mode. Maps lockfile `dep_path` → list of on-disk directories
273    /// where that package was materialized (most entries have one
274    /// path; name conflicts produce multiple nested copies). The
275    /// install driver uses this to locate packages for bin linking
276    /// and lifecycle scripts without recomputing the placement tree.
277    /// `None` means "isolated layout — use the `.aube/<dep_path>`
278    /// convention".
279    pub hoisted_placements: Option<HoistedPlacements>,
280}