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}