Skip to content
This repository was archived by the owner on Jul 21, 2026. It is now read-only.

Commit e3e9f51

Browse files
authored
feat(graph-hasher): engine-agnostic GVS hash gating (#459) (#469)
* feat(graph-hasher): engine-agnostic GVS hash gating (#459) Port pnpm's `transitivelyRequiresBuild` + `computeBuiltDepPaths` into `graph-hasher`, and extend `calc_graph_node_hash` with the `builtDepPaths`-driven engine-gating that mirrors upstream's [`calcGraphNodeHash`](https://github.com/pnpm/pnpm/blob/94240bc046/deps/graph-hasher/src/index.ts#L140-L142). When a snapshot — and its entire transitive subtree — has no entry in `allowBuilds` (and `dangerouslyAllowAllBuilds` is off), its GVS hash payload now sets `engine: null` instead of `engine: ENGINE_NAME`. Pure-JS packages share one slot directory across Node.js versions, OSes, and CPUs, matching upstream and unlocking the cross-host store sharing the GVS layout was designed for. `VirtualStoreLayout::new` grows an `Option<&AllowBuildPolicy>` parameter. `InstallFrozenLockfile::run` passes the install's `AllowBuildPolicy` through; callers that pass `None` retain the pre-#459 always-include-engine shape (matches upstream's `builtDepPaths === undefined` short-circuit). Tests: - six new `transitively_requires_build` unit tests covering self-hit, transitive ancestor, unrelated tree, missing node, cycle termination, and cycle-doesn't-mask-sibling-builder - four new `calc_graph_node_hash` tests covering the engine-agnostic vs engine-specific paths and the `built_dep_paths = None` shortcut - two end-to-end `VirtualStoreLayout::new` tests proving a pure-JS snapshot's slot directory is identical across two `engine` strings, and that a built snapshot's slot differs --- Written by an agent (Claude Code, claude-opus-4-7). * docs(graph-hasher): address Copilot feedback on calc_graph_node_hash docs - Drop the cross-module intra-doc link `[transitively_requires_build]: crate::dep_state::transitively_requires_build` that resolved to a pub(crate) item. The link rendered correctly under pacquet's CI (which runs `cargo doc --document-private-items`), but downstream doc builds without that flag would trip `private_intra_doc_links`. Switched to a plain-backticks reference + an inline "private to this crate" note so the doc remains informative either way. - Reword the `build_required_cache` hint to avoid the ambiguous `&mut HashMap::new()` phrasing — temporaries-in-call-args is a valid pattern but a confusing one to surface at the API doc. Pointed callers at the explicit `let mut cache = HashMap::new();` + `&mut cache` shape instead.
1 parent 4882312 commit e3e9f51

4 files changed

Lines changed: 699 additions & 24 deletions

File tree

crates/graph-hasher/src/dep_state.rs

Lines changed: 268 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,9 +131,78 @@ where
131131
cache.get(dep_path).expect("just inserted").clone()
132132
}
133133

134+
/// Recursive helper used by [`crate::calc_graph_node_hash`] to decide
135+
/// whether a snapshot's engine string should contribute to its global-
136+
/// virtual-store hash. Mirrors upstream's
137+
/// [`transitivelyRequiresBuild`](https://github.com/pnpm/pnpm/blob/94240bc046/deps/graph-hasher/src/index.ts#L221-L249).
138+
///
139+
/// Returns `true` if `dep_path` is either in `built_dep_paths`
140+
/// directly, or transitively depends on a snapshot that is. The
141+
/// returned boolean drives `includeEngine` at upstream's
142+
/// [`calcGraphNodeHash`](https://github.com/pnpm/pnpm/blob/94240bc046/deps/graph-hasher/src/index.ts#L140-L142)
143+
/// — pure-JS leaves (and their pure-JS ancestors) get
144+
/// `engine = null`, so their GVS hashes survive Node.js upgrades and
145+
/// architecture moves. Snapshots that *might* run a postinstall
146+
/// script keep `engine = ENGINE_NAME` so the hash partitions them by
147+
/// host environment.
148+
///
149+
/// The cycle guard uses `dep_path` itself, not `node.full_pkg_id`
150+
/// (unlike [`calc_dep_graph_hash`]). Upstream picked `depPath`
151+
/// because the same pkg id reachable through two different peer
152+
/// contexts is two distinct nodes — once one is mid-walk we still
153+
/// want to recurse into the other.
154+
///
155+
/// On cycle hit (`parents.contains(dep_path)`) the function returns
156+
/// `false` *without* caching. The "false in this particular cycle
157+
/// rotation" answer isn't the canonical one — a sibling visit might
158+
/// still find a builder upstream, and caching `false` here would
159+
/// poison the next visit at the same key.
160+
///
161+
/// `cache` is install-scoped and threaded across every snapshot
162+
/// visited inside one [`crate::calc_graph_node_hash`] walk. `parents`
163+
/// is the per-walk cycle-tracking set — callers always pass a fresh
164+
/// empty `HashSet`, the function inserts/removes `dep_path` around
165+
/// the recursion.
166+
pub(crate) fn transitively_requires_build<K>(
167+
graph: &HashMap<K, DepsGraphNode<K>>,
168+
built_dep_paths: &HashSet<K>,
169+
cache: &mut HashMap<K, bool>,
170+
dep_path: &K,
171+
parents: &mut HashSet<K>,
172+
) -> bool
173+
where
174+
K: Clone + Eq + std::hash::Hash,
175+
{
176+
if let Some(&cached) = cache.get(dep_path) {
177+
return cached;
178+
}
179+
if built_dep_paths.contains(dep_path) {
180+
cache.insert(dep_path.clone(), true);
181+
return true;
182+
}
183+
let Some(node) = graph.get(dep_path) else {
184+
cache.insert(dep_path.clone(), false);
185+
return false;
186+
};
187+
if parents.contains(dep_path) {
188+
return false;
189+
}
190+
parents.insert(dep_path.clone());
191+
let mut result = false;
192+
for child in node.children.values() {
193+
if transitively_requires_build(graph, built_dep_paths, cache, child, parents) {
194+
result = true;
195+
break;
196+
}
197+
}
198+
parents.remove(dep_path);
199+
cache.insert(dep_path.clone(), result);
200+
result
201+
}
202+
134203
#[cfg(test)]
135204
mod tests {
136-
use super::{CalcDepStateOptions, DepsGraphNode, calc_dep_state};
205+
use super::{CalcDepStateOptions, DepsGraphNode, calc_dep_state, transitively_requires_build};
137206
use pretty_assertions::assert_eq;
138207
use std::collections::HashMap;
139208

@@ -338,4 +407,202 @@ mod tests {
338407
let patch_pos = result.find(";patch=").expect("patch section present");
339408
assert!(deps_pos < patch_pos, "deps must come before patch in {result:?}");
340409
}
410+
411+
/// Self-hit: a snapshot listed in `built_dep_paths` returns
412+
/// `true` and caches `true` without looking at its children.
413+
#[test]
414+
fn transitively_requires_build_self_in_built_set() {
415+
let mut graph: HashMap<String, DepsGraphNode<String>> = HashMap::new();
416+
graph.insert(
417+
"[email protected]".to_string(),
418+
DepsGraphNode {
419+
full_pkg_id: "[email protected]:sha512-b".to_string(),
420+
children: HashMap::new(),
421+
},
422+
);
423+
let built: std::collections::HashSet<String> =
424+
["[email protected]".to_string()].into_iter().collect();
425+
let mut cache = HashMap::new();
426+
let mut parents = std::collections::HashSet::new();
427+
assert!(transitively_requires_build(
428+
&graph,
429+
&built,
430+
&mut cache,
431+
&"[email protected]".to_string(),
432+
&mut parents
433+
));
434+
assert_eq!(cache.get("[email protected]"), Some(&true));
435+
}
436+
437+
/// Transitive: a snapshot whose child is a builder returns
438+
/// `true` and caches `true` at every level walked.
439+
#[test]
440+
fn transitively_requires_build_walks_to_descendant_builder() {
441+
let mut graph: HashMap<String, DepsGraphNode<String>> = HashMap::new();
442+
let mut root_children = HashMap::new();
443+
root_children.insert("dep".to_string(), "[email protected]".to_string());
444+
graph.insert(
445+
"[email protected]".to_string(),
446+
DepsGraphNode {
447+
full_pkg_id: "[email protected]:sha512-r".to_string(),
448+
children: root_children,
449+
},
450+
);
451+
graph.insert(
452+
"[email protected]".to_string(),
453+
DepsGraphNode {
454+
full_pkg_id: "[email protected]:sha512-b".to_string(),
455+
children: HashMap::new(),
456+
},
457+
);
458+
let built: std::collections::HashSet<String> =
459+
["[email protected]".to_string()].into_iter().collect();
460+
let mut cache = HashMap::new();
461+
let mut parents = std::collections::HashSet::new();
462+
assert!(transitively_requires_build(
463+
&graph,
464+
&built,
465+
&mut cache,
466+
&"[email protected]".to_string(),
467+
&mut parents
468+
));
469+
assert_eq!(cache.get("[email protected]"), Some(&true));
470+
assert_eq!(cache.get("[email protected]"), Some(&true));
471+
}
472+
473+
/// Unrelated: a snapshot whose subtree contains no builder
474+
/// returns `false` and caches `false` at every visited node.
475+
#[test]
476+
fn transitively_requires_build_returns_false_for_unrelated_tree() {
477+
let mut graph: HashMap<String, DepsGraphNode<String>> = HashMap::new();
478+
let mut root_children = HashMap::new();
479+
root_children.insert("dep".to_string(), "[email protected]".to_string());
480+
graph.insert(
481+
"[email protected]".to_string(),
482+
DepsGraphNode {
483+
full_pkg_id: "[email protected]:sha512-r".to_string(),
484+
children: root_children,
485+
},
486+
);
487+
graph.insert(
488+
"[email protected]".to_string(),
489+
DepsGraphNode {
490+
full_pkg_id: "[email protected]:sha512-l".to_string(),
491+
children: HashMap::new(),
492+
},
493+
);
494+
let built: std::collections::HashSet<String> =
495+
["[email protected]".to_string()].into_iter().collect();
496+
let mut cache = HashMap::new();
497+
let mut parents = std::collections::HashSet::new();
498+
assert!(!transitively_requires_build(
499+
&graph,
500+
&built,
501+
&mut cache,
502+
&"[email protected]".to_string(),
503+
&mut parents
504+
));
505+
assert_eq!(cache.get("[email protected]"), Some(&false));
506+
assert_eq!(cache.get("[email protected]"), Some(&false));
507+
}
508+
509+
/// Missing node: returns `false` and caches `false`. Mirrors
510+
/// upstream's `if (!node) { cache[depPath] = false; return false }`
511+
/// branch — the install-time graph build can drop entries for
512+
/// resolutions the linker rejects later, so the walker must not
513+
/// panic on missing keys.
514+
#[test]
515+
fn transitively_requires_build_caches_false_for_missing_node() {
516+
let graph: HashMap<String, DepsGraphNode<String>> = HashMap::new();
517+
let built: std::collections::HashSet<String> = std::collections::HashSet::new();
518+
let mut cache = HashMap::new();
519+
let mut parents = std::collections::HashSet::new();
520+
assert!(!transitively_requires_build(
521+
&graph,
522+
&built,
523+
&mut cache,
524+
&"[email protected]".to_string(),
525+
&mut parents
526+
));
527+
assert_eq!(cache.get("[email protected]"), Some(&false));
528+
}
529+
530+
/// Cycle: a → b → a with no builder anywhere. The walk must
531+
/// terminate and return `false`. Cache state on `a` is `false`
532+
/// (the post-loop write), `b` is also `false`. The cycle short
533+
/// circuit at upstream's `if (parents.has(depPath)) return false`
534+
/// fires once but doesn't taint the eventual cache write at
535+
/// the frame that owns `a`.
536+
#[test]
537+
fn transitively_requires_build_cycle_terminates() {
538+
let mut graph: HashMap<String, DepsGraphNode<String>> = HashMap::new();
539+
let mut a_children = HashMap::new();
540+
a_children.insert("b".to_string(), "[email protected]".to_string());
541+
graph.insert(
542+
"[email protected]".to_string(),
543+
DepsGraphNode { full_pkg_id: "[email protected]:sha512-a".to_string(), children: a_children },
544+
);
545+
let mut b_children = HashMap::new();
546+
b_children.insert("a".to_string(), "[email protected]".to_string());
547+
graph.insert(
548+
"[email protected]".to_string(),
549+
DepsGraphNode { full_pkg_id: "[email protected]:sha512-b".to_string(), children: b_children },
550+
);
551+
let built: std::collections::HashSet<String> = std::collections::HashSet::new();
552+
let mut cache = HashMap::new();
553+
let mut parents = std::collections::HashSet::new();
554+
assert!(!transitively_requires_build(
555+
&graph,
556+
&built,
557+
&mut cache,
558+
&"[email protected]".to_string(),
559+
&mut parents
560+
));
561+
assert_eq!(cache.get("[email protected]"), Some(&false));
562+
assert_eq!(cache.get("[email protected]"), Some(&false));
563+
assert!(parents.is_empty(), "parents set must be restored to empty");
564+
}
565+
566+
/// Cycle with builder elsewhere in the tree: `a → b → a` plus
567+
/// `a → builder`. The cycle must not short-circuit `a`'s overall
568+
/// answer to `false` — the sibling builder visit still drives
569+
/// `a` to `true`. Verifies that the no-cache-on-cycle behaviour
570+
/// is what makes this work.
571+
#[test]
572+
fn transitively_requires_build_cycle_does_not_mask_sibling_builder() {
573+
let mut graph: HashMap<String, DepsGraphNode<String>> = HashMap::new();
574+
// Use a BTreeMap-style two-key insert so child iteration
575+
// order can take either path; both must yield `true`.
576+
let mut a_children = HashMap::new();
577+
a_children.insert("b".to_string(), "[email protected]".to_string());
578+
a_children.insert("c".to_string(), "[email protected]".to_string());
579+
graph.insert(
580+
"[email protected]".to_string(),
581+
DepsGraphNode { full_pkg_id: "[email protected]:sha512-a".to_string(), children: a_children },
582+
);
583+
let mut b_children = HashMap::new();
584+
b_children.insert("a".to_string(), "[email protected]".to_string());
585+
graph.insert(
586+
"[email protected]".to_string(),
587+
DepsGraphNode { full_pkg_id: "[email protected]:sha512-b".to_string(), children: b_children },
588+
);
589+
graph.insert(
590+
"[email protected]".to_string(),
591+
DepsGraphNode {
592+
full_pkg_id: "[email protected]:sha512-x".to_string(),
593+
children: HashMap::new(),
594+
},
595+
);
596+
let built: std::collections::HashSet<String> =
597+
["[email protected]".to_string()].into_iter().collect();
598+
let mut cache = HashMap::new();
599+
let mut parents = std::collections::HashSet::new();
600+
assert!(transitively_requires_build(
601+
&graph,
602+
&built,
603+
&mut cache,
604+
&"[email protected]".to_string(),
605+
&mut parents
606+
));
607+
}
341608
}

0 commit comments

Comments
 (0)