1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
use super::dep_selection::DepSelection;
use super::lifecycle::{
JailBuildPolicy, run_dep_lifecycle_scripts, run_root_lifecycle, unreviewed_dep_builds,
};
use super::side_effects_cache::{SideEffectsCacheConfig, side_effects_cache_root};
use super::summary::{print_direct_dependency_summary, should_print_human_install_summary};
use super::sweep::sweep_orphaned_aube_entries;
use super::workspace::importer_project_dir;
use super::{InstallPhaseTimings, delta, unreviewed_builds};
use crate::state;
use miette::{Context, IntoDiagnostic, miette};
use std::collections::BTreeMap;
pub(super) struct FinalizePhaseInput<'a> {
pub(super) cwd: &'a std::path::Path,
pub(super) settings_ctx: &'a aube_settings::ResolveCtx<'a>,
pub(super) store: &'a aube_store::Store,
pub(super) graph: &'a aube_lockfile::LockfileGraph,
pub(super) graph_for_link: &'a aube_lockfile::LockfileGraph,
pub(super) manifests: &'a [(String, aube_manifest::PackageJson)],
pub(super) lifecycle_manifests: &'a [(String, aube_manifest::PackageJson)],
pub(super) direct_dep_info: &'a std::collections::HashMap<String, aube_resolver::DirectDepInfo>,
pub(super) deprecations:
&'a std::sync::Arc<std::sync::Mutex<Vec<crate::deprecations::DeprecationRecord>>>,
pub(super) build_policy: &'a aube_scripts::BuildPolicy,
pub(super) jail_policy: &'a JailBuildPolicy,
pub(super) stats: &'a aube_linker::LinkStats,
pub(super) node_linker: aube_linker::NodeLinker,
pub(super) virtual_store_only: bool,
pub(super) current_leaf_hashes: Option<BTreeMap<String, String>>,
pub(super) current_subtree_hashes: Option<BTreeMap<String, String>>,
pub(super) patch_hashes: BTreeMap<String, String>,
pub(super) modules_dir_name: &'a str,
pub(super) aube_dir: &'a std::path::Path,
pub(super) virtual_store_dir_max_length: usize,
pub(super) child_concurrency: usize,
pub(super) side_effects_cache_setting: bool,
pub(super) side_effects_cache_readonly_setting: bool,
pub(super) strict_dep_builds_setting: bool,
pub(super) ignore_scripts: bool,
pub(super) skip_root_lifecycle: bool,
pub(super) workspace_filter_empty: bool,
pub(super) dep_selection: DepSelection,
pub(super) cli_flags: &'a [(String, String)],
pub(super) cached_count: usize,
pub(super) fetch_count: usize,
pub(super) start: std::time::Instant,
pub(super) prog_ref: Option<&'a crate::progress::InstallProgress>,
pub(super) phase_timings: &'a mut InstallPhaseTimings,
}
pub(super) async fn run_finalize_phase(input: FinalizePhaseInput<'_>) -> miette::Result<()> {
let FinalizePhaseInput {
cwd,
settings_ctx,
store,
graph,
graph_for_link,
manifests,
lifecycle_manifests,
direct_dep_info,
deprecations,
build_policy,
jail_policy,
stats,
node_linker,
virtual_store_only,
current_leaf_hashes,
current_subtree_hashes,
patch_hashes,
modules_dir_name,
aube_dir,
virtual_store_dir_max_length,
child_concurrency,
side_effects_cache_setting,
side_effects_cache_readonly_setting,
strict_dep_builds_setting,
ignore_scripts,
skip_root_lifecycle,
workspace_filter_empty,
dep_selection,
cli_flags,
cached_count,
fetch_count,
start,
prog_ref,
phase_timings,
} = input;
let placements_ref = stats.hoisted_placements.as_ref();
// Tear down the progress display before running post-link lifecycle
// scripts or printing the final summary — scripts write directly to
// stdout/stderr and would collide with an active progress bar.
//
// Skip the CI-mode framed summary on a no-op install: `print_install_summary`
// below will print the "Already up to date" branded line, and we don't
// want CI users to see both the framed `[ ✓ … resolved N · reused N ]`
// block and the branded line as redundant twins.
let install_is_noop = stats.packages_linked == 0 && stats.top_level_linked == 0;
if let Some(p) = prog_ref {
p.finish(!install_is_noop);
}
if !ignore_scripts && strict_dep_builds_setting && !virtual_store_only {
let unreviewed = unreviewed_dep_builds(
aube_dir,
graph_for_link,
build_policy,
virtual_store_dir_max_length,
placements_ref,
)?;
if !unreviewed.is_empty() {
return Err(miette!(
"dependencies with build scripts must be reviewed before install:\n{}\nhelp: add the package(s) to `allowBuilds` with `true`/`false`, or set `strictDepBuilds=false`",
unreviewed
.into_iter()
.map(|b| format!(" - {}", b.spec_key))
.collect::<Vec<_>>()
.join("\n")
));
}
}
// 7a. Dependency lifecycle scripts (allowBuilds).
// Every dep that the `BuildPolicy` explicitly allows runs its
// `preinstall` / `install` / `postinstall` scripts from inside
// its linked directory under `node_modules/.aube`. Reuses the
// already-constructed `build_policy` from above. Skipped
// entirely under `--ignore-scripts` (pnpm parity) and when the
// policy has no allow rules at all (fast path: no config, no
// cost). A failing dep script fails the whole install —
// matching pnpm's fail-fast default. No cross-project
// collision warning here: step 6a content-addresses the
// global store so two projects resolving the same
// `(dep-graph, engine)` share a safe directory and divergent
// resolutions land at distinct paths.
if !ignore_scripts && build_policy.has_any_allow_rule() && !virtual_store_only {
let phase_start = std::time::Instant::now();
let side_effects_cache_root =
side_effects_cache_setting.then(|| side_effects_cache_root(store));
let side_effects_cache = side_effects_cache_root
.as_deref()
.map(|root| {
if side_effects_cache_readonly_setting {
SideEffectsCacheConfig::RestoreOnly(root)
} else {
SideEffectsCacheConfig::RestoreAndSave(root)
}
})
.unwrap_or(SideEffectsCacheConfig::Disabled);
let ran = run_dep_lifecycle_scripts(
cwd,
modules_dir_name,
aube_dir,
graph_for_link,
build_policy,
virtual_store_dir_max_length,
child_concurrency,
placements_ref,
side_effects_cache,
jail_policy,
None,
)
.await?;
if ran > 0 {
tracing::debug!("allowBuilds: ran {ran} dep lifecycle script(s)");
}
phase_timings.record("dep_lifecycle", phase_start.elapsed());
}
// 7b. Post-link root lifecycle hooks: install → postinstall → prepare.
// npm and pnpm run these in this order after deps are linked so the
// scripts can use anything they depend on. Skipped with --ignore-scripts
// and under `virtualStoreOnly` — scripts typically resolve
// binaries via `node_modules/.bin`, which doesn't exist in
// that mode.
// A hook that's not defined in package.json is a silent no-op.
// A hook that exits non-zero fails the install (fail-fast, matching pnpm).
if !ignore_scripts && !virtual_store_only && !skip_root_lifecycle {
let phase_start = std::time::Instant::now();
for (importer_path, importer_manifest) in lifecycle_manifests {
let project_dir = importer_project_dir(cwd, importer_path);
for hook in [
aube_scripts::LifecycleHook::Install,
aube_scripts::LifecycleHook::PostInstall,
aube_scripts::LifecycleHook::Prepare,
] {
run_root_lifecycle(&project_dir, modules_dir_name, importer_manifest, hook).await?;
}
}
phase_timings.record("root_lifecycle", phase_start.elapsed());
}
// 8. Write state file for auto-install tracking.
// Record whether this was a --prod install so ensure_installed knows
// to re-install the full graph before running dev tooling.
// Skipped under `virtualStoreOnly` — the state sidecar is
// keyed off a materialized node_modules tree that doesn't
// exist, and writing it would lie on the next auto-install
// freshness check. Same skip when a workspace filter scoped the
// run to a subset of importers. State hash is derived from full
// manifest + lockfile inputs, so writing it after a partial
// materialize would let the next unfiltered `aube install` hit
// the warm path while unfiltered importers are still empty.
// Observed via `aube add <pkg> --filter <ws>` leaving the new
// dep unmaterialized.
let filtered_install = !workspace_filter_empty || dep_selection.is_filtered();
// Walk the linked graph once for the unreviewed-builds set; reused
// by both the state writer (so warm-path repeats keep nudging) and
// the post-install warning emission below. The walk does a stat per
// package, so collapsing two callers into one cuts the linker-tail
// cost on large graphs roughly in half.
let unreviewed_builds = if !ignore_scripts && !strict_dep_builds_setting && !virtual_store_only
{
unreviewed_dep_builds(
aube_dir,
graph_for_link,
build_policy,
virtual_store_dir_max_length,
placements_ref,
)?
} else {
Vec::new()
};
if !virtual_store_only && !filtered_install {
let phase_start = std::time::Instant::now();
// Fingerprint every package in the final graph so the next
// install can diff and skip unchanged entries. Missing or
// stale fingerprints fall back to a full install on the
// read side. Safe for older readers that ignore the field.
// When the early pass already produced the leaf map for
// `graph_for_link`, reuse it here as long as it covers every
// dep_path in the writeback `graph`. Filtered installs short-
// circuit before this branch so the two graphs are normally
// identical, but verifying every key keeps the reuse safe
// against any future code path that diverges them. Any miss
// falls back to a fresh compute over `graph`.
let package_content_hashes = current_leaf_hashes
.filter(|leaf| {
leaf.len() == graph.packages.len()
&& graph.packages.keys().all(|k| leaf.contains_key(k))
})
.unwrap_or_else(|| delta::compute_package_hashes(graph, &patch_hashes, cwd));
let package_subtree_hashes = current_subtree_hashes.unwrap_or_else(|| {
delta::compute_subtree_hashes_from_leaf(graph, &package_content_hashes)
});
let graph_lthash = hex::encode(delta::lthash_of(&package_content_hashes).digest());
let package_json_hashes = state::collect_package_json_hashes_from_manifests(cwd, manifests);
// Diff against the previous install. Logs delta counts at
// debug so `-v` installs surface what actually moved. A
// later pass feeds the plan into fetch and link as a
// pre-filter.
if let Some(prior) = state::read_state_package_content_hashes(cwd) {
let plan = delta::diff(&prior, &package_content_hashes);
if !plan.is_empty() {
// Touched set built once. Doubles as a membership
// probe so future wiring exercises the same shape
// of predicate shipped in production.
let touched = plan.touched_set();
tracing::debug!(
"delta: +{} ~{} -{} ({} touched vs {} total, should_touch(first-added)={})",
plan.added.len(),
plan.changed.len(),
plan.removed.len(),
touched.len(),
package_content_hashes.len(),
plan.added.first().is_some_and(|dp| plan.should_touch(dp)),
);
}
// Incremental LtHash self-check. Start from the prior
// accumulator, apply the observed delta, confirm the
// result matches a from-scratch hash of the new graph.
// Cheap sanity on the homomorphic add/remove ops. The
// future causal scheduler needs these two to stay in
// lockstep with the full recompute.
if let Some(prior_lthash_hex) = state::read_state_graph_lthash(cwd)
&& let Ok(prior_bytes) = hex::decode(&prior_lthash_hex)
&& prior_bytes.len() == 32
{
let mut incr = delta::lthash_of(&prior);
for dp in &plan.removed {
if let Some(fp) = prior.get(dp) {
incr.remove(fp);
}
}
for dp in &plan.added {
if let Some(fp) = package_content_hashes.get(dp) {
incr.add(fp);
}
}
for dp in &plan.changed {
if let Some(old_fp) = prior.get(dp) {
incr.remove(old_fp);
}
if let Some(new_fp) = package_content_hashes.get(dp) {
incr.add(new_fp);
}
}
if hex::encode(incr.digest()) != graph_lthash {
// Real bug signal, not routine noise. `debug`
// hides it behind `-v` so CI would silently
// ship broken homomorphic bookkeeping.
tracing::warn!(
code = aube_codes::warnings::WARN_AUBE_LTHASH_MISMATCH,
"lthash: incremental/full mismatch, homomorphic invariant broken"
);
}
}
}
// LtHash diagnostic. One 32-byte compare proves graph
// equivalence with the last install. Beats the map diff
// when both sides are known good.
if let Some(prior_lthash) = state::read_state_graph_lthash(cwd)
&& prior_lthash != graph_lthash
{
tracing::debug!(
"lthash: graph content digest changed ({}..{} -> {}..{})",
&prior_lthash[..8.min(prior_lthash.len())],
&prior_lthash[prior_lthash.len().saturating_sub(8)..],
&graph_lthash[..8],
&graph_lthash[graph_lthash.len() - 8..],
);
}
// Merkle subtree diagnostic. How many subtree roots moved
// vs how many leaves moved. Fewer roots means tighter
// re-link scope once the delta linker lands.
if let Some(prior_subtrees) = state::read_state_subtree_hashes(cwd) {
let changed_subtrees = package_subtree_hashes
.iter()
.filter(|(k, v)| prior_subtrees.get(*k).is_none_or(|old| old != *v))
.count();
if changed_subtrees > 0 {
tracing::debug!(
"merkle: {} subtree hashes changed of {}",
changed_subtrees,
package_subtree_hashes.len()
);
}
}
// Persist the unreviewed-builds set so the warm-path
// short-circuit can re-emit the warning on repeat installs.
// Computed once above and reused here. The state file
// carries only spec_keys; per-package suspicion data is
// re-derived from the live tree on each install rather
// than persisted, since the regex set evolves with aube.
let unreviewed_builds_for_state: Vec<String> = unreviewed_builds
.iter()
.map(|b| b.spec_key.clone())
.collect();
state::write_state(
cwd,
state::WriteStateInput {
section_filtered: dep_selection.prod_or_dev_axis(),
package_json_hashes,
cli_flags,
package_content_hashes,
graph_lthash,
package_subtree_hashes,
layout: state::WriteStateLayout {
graph: graph_for_link,
node_linker,
modules_dir_name,
aube_dir,
virtual_store_dir_max_length,
placements: placements_ref,
},
unreviewed_builds: unreviewed_builds_for_state,
},
)
.into_diagnostic()
.wrap_err("failed to write install state")?;
phase_timings.record("state", phase_start.elapsed());
}
// 8a. Sweep orphaned `.aube/<dep_path>` entries older than
// `modulesCacheMaxAge`. The "in use" set is built from the
// **unfiltered** `graph`, not `graph_for_link`, so that a
// `--prod` / `--dev` / `--no-optional` / `--filter` install
// doesn't treat the deps it skipped this run as orphans —
// a subsequent full install would otherwise have to re-fetch
// them. Runs best-effort: I/O errors are logged and swallowed
// so a partial sweep never fails an install that otherwise
// succeeded.
let modules_cache_max_age_minutes =
aube_settings::resolved::modules_cache_max_age(settings_ctx);
if modules_cache_max_age_minutes > 0 && !virtual_store_only {
let phase_start = std::time::Instant::now();
let removed = sweep_orphaned_aube_entries(
aube_dir,
graph,
virtual_store_dir_max_length,
std::time::Duration::from_secs(modules_cache_max_age_minutes.saturating_mul(60)),
);
if removed > 0 {
tracing::debug!("modulesCacheMaxAge: swept {removed} orphaned .aube entry/entries");
}
phase_timings.record("sweep", phase_start.elapsed());
}
let elapsed = start.elapsed();
tracing::debug!(
"Done in {:.0?}: {} packages ({} cached), {} files linked, {} top-level",
elapsed,
stats.packages_linked + stats.packages_cached,
stats.packages_cached,
stats.files_linked,
stats.top_level_linked
);
phase_timings.write(
cwd,
elapsed,
graph_for_link.packages.len(),
cached_count,
fetch_count,
);
if stats.packages_linked == 0
&& stats.packages_cached == 0
&& graph_for_link
.packages
.values()
.any(|p| p.local_source.is_none())
{
return Err(miette!("no packages were linked — something went wrong"));
}
// Deprecation warnings, gated by the `deprecationWarnings` setting.
// Prune to packages still in the finalized graph so we don't warn
// on platform-mismatched optionals that `filter_graph` trimmed,
// then dedupe across peer-context dep_path variants.
{
let mut records = std::mem::take(&mut *deprecations.lock().unwrap());
crate::deprecations::retain_in_graph(&mut records, graph_for_link);
let records = crate::deprecations::dedupe(records);
if !records.is_empty() {
let mode = aube_settings::resolved::deprecation_warnings(settings_ctx);
crate::deprecations::render_install_warnings(&records, graph_for_link, mode);
}
}
// Final user-facing output. When linking did real work, print the
// direct deps that now exist at the top level before the green
// `✓ installed N packages in Xs` line. Text modes such as `-v` and
// `--reporter=append-only` skip the progress object but still get the
// dependency summary; silent and ndjson stay machine-clean.
if !install_is_noop && should_print_human_install_summary() {
print_direct_dependency_summary(graph_for_link, manifests, direct_dep_info);
}
if let Some(p) = prog_ref {
p.print_install_summary(
stats.packages_linked,
stats.top_level_linked,
graph_for_link.packages.len(),
elapsed,
);
}
// Surface packages whose build scripts were skipped because they're
// not on the `allowBuilds` / `onlyBuiltDependencies` allowlist. Without
// this, a fresh install of a project that depends on native bindings
// (`better-sqlite3`, `esbuild`, napi-rs packages, etc.) looks like it
// succeeded but leaves those packages unbuilt — the failure only
// surfaces later when something tries to `require` the binding.
// Skipped under `--ignore-scripts`, `virtualStoreOnly`, and
// `strictDepBuilds=true` (the strict path already errored above).
if !unreviewed_builds.is_empty() {
unreviewed_builds::emit_warning(&unreviewed_builds);
}
Ok(())
}