Skip to main content

chainsaw/
cache.rs

1//! Three-tier disk cache for dependency graphs.
2//!
3//! Tier 1 caches the entire graph with file mtimes for a stat-only fast path.
4//! Tier 1.5 incrementally re-parses only changed files when imports are stable.
5//! Tier 2 caches per-file parse and resolve results so single-file edits skip
6//! the resolver entirely.
7
8use rayon::prelude::*;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::fs;
12use std::path::{Path, PathBuf};
13use std::sync::atomic::{AtomicBool, Ordering};
14use std::thread;
15use std::time::SystemTime;
16
17use crate::graph::ModuleGraph;
18use crate::lang::ParseResult;
19
20const CACHE_FILE: &str = ".chainsaw.cache";
21const CACHE_VERSION: u32 = 7;
22// 16-byte header: magic (4) + version (4) + graph_len (8)
23const CACHE_MAGIC: u32 = 0x4348_5357; // "CHSW"
24const HEADER_SIZE: usize = 16;
25
26pub fn cache_path(root: &Path) -> PathBuf {
27    root.join(CACHE_FILE)
28}
29
30fn mtime_of(meta: &fs::Metadata) -> Option<u128> {
31    meta.modified()
32        .ok()
33        .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
34        .map(|d| d.as_nanos())
35}
36
37// --- Per-file parse cache (tier 2) ---
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40struct CachedParse {
41    mtime_nanos: u128,
42    size: u64,
43    result: ParseResult,
44    resolved_paths: Vec<Option<PathBuf>>,
45}
46
47// --- Whole-graph cache (tier 1) ---
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50struct CachedMtime {
51    mtime_nanos: u128,
52    size: u64,
53}
54
55#[derive(Debug, Serialize, Deserialize)]
56struct CachedGraph {
57    entry: PathBuf,
58    graph: ModuleGraph,
59    file_mtimes: HashMap<PathBuf, CachedMtime>,
60    unresolved_specifiers: Vec<String>,
61    unresolvable_dynamic: usize,
62    /// Lockfile mtimes — if unchanged, skip re-resolving unresolved specifiers.
63    dep_sentinels: Vec<(PathBuf, u128)>,
64}
65
66const LOCKFILES: &[&str] = &[
67    "package-lock.json",
68    "pnpm-lock.yaml",
69    "yarn.lock",
70    "bun.lockb",
71    "poetry.lock",
72    "Pipfile.lock",
73    "uv.lock",
74    "requirements.txt",
75];
76
77/// Find lockfile sentinels by walking up from `root` until a directory
78/// containing a lockfile is found. This handles workspace layouts where
79/// the lockfile lives at the workspace root, not the package root.
80fn find_dep_sentinels(root: &Path) -> Vec<(PathBuf, u128)> {
81    let mut dir = root.to_path_buf();
82    loop {
83        let sentinels: Vec<(PathBuf, u128)> = LOCKFILES
84            .iter()
85            .filter_map(|name| {
86                let path = dir.join(name);
87                let meta = fs::metadata(&path).ok()?;
88                let mtime = mtime_of(&meta)?;
89                Some((path, mtime))
90            })
91            .collect();
92        if !sentinels.is_empty() {
93            return sentinels;
94        }
95        if !dir.pop() {
96            return Vec::new();
97        }
98    }
99}
100
101#[derive(Debug)]
102#[non_exhaustive]
103pub enum GraphCacheResult {
104    /// All files unchanged — graph is valid.
105    Hit {
106        graph: ModuleGraph,
107        unresolvable_dynamic: usize,
108        unresolved_specifiers: Vec<String>,
109        /// True if the graph is valid but sentinel mtimes need updating.
110        needs_resave: bool,
111    },
112    /// Some files have different mtimes — incremental update possible.
113    Stale {
114        graph: ModuleGraph,
115        unresolvable_dynamic: usize,
116        changed_files: Vec<PathBuf>,
117    },
118    /// Cache miss — wrong entry, no cache, file deleted, or new imports resolve.
119    Miss,
120}
121
122/// Handle for a background cache write. Joins the write thread on drop
123/// to ensure the cache file is fully written before process exit.
124#[derive(Debug)]
125#[repr(transparent)]
126pub struct CacheWriteHandle(Option<thread::JoinHandle<()>>);
127
128impl CacheWriteHandle {
129    pub const fn none() -> Self {
130        Self(None)
131    }
132
133    /// Block until the background cache write completes.
134    ///
135    /// This is equivalent to dropping the handle, but makes the intent explicit.
136    pub fn join(mut self) {
137        if let Some(handle) = self.0.take() {
138            let _ = handle.join();
139        }
140    }
141}
142
143impl Drop for CacheWriteHandle {
144    fn drop(&mut self) {
145        if let Some(handle) = self.0.take() {
146            let _ = handle.join();
147        }
148    }
149}
150
151#[derive(Debug)]
152pub struct ParseCache {
153    entries: HashMap<PathBuf, CachedParse>,
154    deferred_parse_data: Option<Vec<u8>>,
155    cached_graph: Option<CachedGraph>,
156    /// Preserved from Stale result for incremental save.
157    stale_file_mtimes: Option<HashMap<PathBuf, CachedMtime>>,
158    stale_unresolved: Option<Vec<String>>,
159}
160
161impl Default for ParseCache {
162    fn default() -> Self {
163        Self::new()
164    }
165}
166
167impl ParseCache {
168    pub fn new() -> Self {
169        Self {
170            entries: HashMap::new(),
171            deferred_parse_data: None,
172            cached_graph: None,
173            stale_file_mtimes: None,
174            stale_unresolved: None,
175        }
176    }
177
178    /// Load cache from disk. The graph cache is deserialized immediately;
179    /// parse entries are deferred until first access (saves ~2.5ms on cache hit).
180    #[allow(clippy::cast_possible_truncation)]
181    pub fn load(root: &Path) -> Self {
182        let path = cache_path(root);
183        let Ok(data) = fs::read(&path) else {
184            return Self::new();
185        };
186        if data.len() < HEADER_SIZE {
187            return Self::new();
188        }
189        let magic = u32::from_le_bytes(data[0..4].try_into().unwrap());
190        let version = u32::from_le_bytes(data[4..8].try_into().unwrap());
191        if magic != CACHE_MAGIC || version != CACHE_VERSION {
192            return Self::new();
193        }
194        let graph_len = u64::from_le_bytes(data[8..16].try_into().unwrap()) as usize;
195        let graph_end = HEADER_SIZE + graph_len;
196        if data.len() < graph_end {
197            return Self::new();
198        }
199
200        let cached_graph: Option<CachedGraph> =
201            bitcode::deserialize(&data[HEADER_SIZE..graph_end]).ok();
202
203        let deferred = if data.len() > graph_end {
204            Some(data[graph_end..].to_vec())
205        } else {
206            None
207        };
208
209        Self {
210            entries: HashMap::new(),
211            deferred_parse_data: deferred,
212            cached_graph,
213            stale_file_mtimes: None,
214            stale_unresolved: None,
215        }
216    }
217
218    fn ensure_entries(&mut self) {
219        if let Some(bytes) = self.deferred_parse_data.take() {
220            self.entries = bitcode::deserialize(&bytes).unwrap_or_default();
221        }
222    }
223
224    /// Try to load the cached graph (tier 1).
225    ///
226    /// Returns `Hit` if all files are unchanged, `Stale` if some files changed
227    /// (incremental update possible), or `Miss` for entry mismatch/deleted files/
228    /// newly-resolved imports.
229    pub fn try_load_graph(
230        &mut self,
231        entry: &Path,
232        resolve_fn: &(dyn Fn(&str) -> bool + Sync),
233    ) -> GraphCacheResult {
234        let cached = match self.cached_graph.as_ref() {
235            Some(c) if c.entry == entry => c,
236            _ => return GraphCacheResult::Miss,
237        };
238
239        // Check all file mtimes in parallel — collect changed files.
240        // If any file is missing (deleted), return Miss.
241        let any_missing = AtomicBool::new(false);
242        let changed_files: Vec<PathBuf> = cached
243            .file_mtimes
244            .par_iter()
245            .filter_map(|(path, saved)| if let Ok(meta) = fs::metadata(path) {
246                let mtime = mtime_of(&meta)?;
247                if mtime != saved.mtime_nanos || meta.len() != saved.size {
248                    Some(path.clone())
249                } else {
250                    None
251                }
252            } else {
253                any_missing.store(true, Ordering::Relaxed);
254                None
255            })
256            .collect();
257
258        if any_missing.load(Ordering::Relaxed) {
259            return GraphCacheResult::Miss;
260        }
261
262        // Check if any previously-unresolved specifier now resolves.
263        // Optimization: if we have lockfile sentinels and none changed, skip the
264        // expensive re-resolution check. A new `npm install` / `pip install` would
265        // modify the lockfile, triggering the full check.
266        let sentinels_unchanged = !cached.dep_sentinels.is_empty()
267            && cached.dep_sentinels.iter().all(|(path, saved_mtime)| {
268                fs::metadata(path)
269                    .ok()
270                    .and_then(|m| mtime_of(&m))
271                    .is_some_and(|t| t == *saved_mtime)
272            });
273
274        if !sentinels_unchanged {
275            let any_resolves = cached
276                .unresolved_specifiers
277                .par_iter()
278                .any(|spec| resolve_fn(spec));
279            if any_resolves {
280                return GraphCacheResult::Miss;
281            }
282        }
283
284        if changed_files.is_empty() {
285            let cached = self.cached_graph.take().unwrap();
286            return GraphCacheResult::Hit {
287                graph: cached.graph,
288                unresolvable_dynamic: cached.unresolvable_dynamic,
289                unresolved_specifiers: cached.unresolved_specifiers,
290                needs_resave: !sentinels_unchanged,
291            };
292        }
293
294        // Files changed — extract graph and preserve mtimes for incremental save
295        let cached = self.cached_graph.take().unwrap();
296        self.stale_file_mtimes = Some(cached.file_mtimes);
297        self.stale_unresolved = Some(cached.unresolved_specifiers);
298        GraphCacheResult::Stale {
299            graph: cached.graph,
300            unresolvable_dynamic: cached.unresolvable_dynamic,
301            changed_files,
302        }
303    }
304
305    /// Get the cached parse result for a file WITHOUT verifying its mtime.
306    /// Used by incremental update to compare old imports against new parse results.
307    pub fn lookup_unchecked(&mut self, path: &Path) -> Option<&ParseResult> {
308        self.ensure_entries();
309        self.entries.get(path).map(|e| &e.result)
310    }
311
312    /// Save after incremental update. Uses the preserved `file_mtimes` from the
313    /// Stale result, updating only the changed files' mtimes instead of
314    /// re-statting every file. Serialization and disk write happen on a
315    /// background thread.
316    pub fn save_incremental(
317        &mut self,
318        root: &Path,
319        entry: &Path,
320        graph: &ModuleGraph,
321        changed_files: &[PathBuf],
322        unresolvable_dynamic: usize,
323    ) -> CacheWriteHandle {
324        let Some(mut file_mtimes) = self.stale_file_mtimes.take() else {
325            return CacheWriteHandle::none();
326        };
327        let unresolved_specifiers = self.stale_unresolved.take().unwrap_or_default();
328
329        // Update only changed files' mtimes (cheap, typically 1-2 files)
330        for path in changed_files {
331            if let Ok(meta) = fs::metadata(path)
332                && let Some(mtime) = mtime_of(&meta)
333                && let Some(saved) = file_mtimes.get_mut(path)
334            {
335                saved.mtime_nanos = mtime;
336                saved.size = meta.len();
337            }
338        }
339
340        self.ensure_entries();
341        let entries = std::mem::take(&mut self.entries);
342        let root = root.to_path_buf();
343        let entry = entry.to_path_buf();
344        let graph = graph.clone();
345        let dep_sentinels = find_dep_sentinels(&root);
346
347        CacheWriteHandle(Some(thread::spawn(move || {
348            write_cache_to_disk(root, entry, graph, entries, file_mtimes, unresolved_specifiers, unresolvable_dynamic, dep_sentinels);
349        })))
350    }
351
352    /// Save the full graph + parse cache to disk. File mtime collection,
353    /// serialization, and disk write all happen on a background thread.
354    pub fn save(
355        &mut self,
356        root: &Path,
357        entry: &Path,
358        graph: &ModuleGraph,
359        unresolved_specifiers: Vec<String>,
360        unresolvable_dynamic: usize,
361    ) -> CacheWriteHandle {
362        self.ensure_entries();
363        let entries = std::mem::take(&mut self.entries);
364        let root = root.to_path_buf();
365        let entry = entry.to_path_buf();
366        let graph = graph.clone();
367
368        let dep_sentinels = find_dep_sentinels(&root);
369
370        CacheWriteHandle(Some(thread::spawn(move || {
371            let file_mtimes: HashMap<PathBuf, CachedMtime> = graph
372                .modules
373                .par_iter()
374                .filter_map(|m| {
375                    let meta = fs::metadata(&m.path).ok()?;
376                    let mtime = mtime_of(&meta)?;
377                    Some((
378                        m.path.clone(),
379                        CachedMtime {
380                            mtime_nanos: mtime,
381                            size: meta.len(),
382                        },
383                    ))
384                })
385                .collect();
386
387            write_cache_to_disk(root, entry, graph, entries, file_mtimes, unresolved_specifiers, unresolvable_dynamic, dep_sentinels);
388        })))
389    }
390
391    pub fn lookup(&mut self, path: &Path) -> Option<(ParseResult, Vec<Option<PathBuf>>)> {
392        self.ensure_entries();
393        let entry = self.entries.get(path)?;
394        let meta = fs::metadata(path).ok()?;
395        let current_mtime = mtime_of(&meta)?;
396        if current_mtime == entry.mtime_nanos && meta.len() == entry.size {
397            Some((entry.result.clone(), entry.resolved_paths.clone()))
398        } else {
399            None
400        }
401    }
402
403    pub fn insert(
404        &mut self,
405        path: PathBuf,
406        size: u64,
407        mtime_nanos: u128,
408        result: ParseResult,
409        resolved_paths: Vec<Option<PathBuf>>,
410    ) {
411        self.ensure_entries();
412        self.entries.insert(
413            path,
414            CachedParse {
415                mtime_nanos,
416                size,
417                result,
418                resolved_paths,
419            },
420        );
421    }
422}
423
424/// Serialize and write the cache to disk. Runs on a background thread.
425#[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)]
426fn write_cache_to_disk(
427    root: PathBuf,
428    entry: PathBuf,
429    graph: ModuleGraph,
430    entries: HashMap<PathBuf, CachedParse>,
431    file_mtimes: HashMap<PathBuf, CachedMtime>,
432    unresolved_specifiers: Vec<String>,
433    unresolvable_dynamic: usize,
434    dep_sentinels: Vec<(PathBuf, u128)>,
435) {
436    let graph_cache = CachedGraph {
437        entry,
438        graph,
439        file_mtimes,
440        unresolved_specifiers,
441        unresolvable_dynamic,
442        dep_sentinels,
443    };
444
445    let graph_data = match bitcode::serialize(&graph_cache) {
446        Ok(d) => d,
447        Err(e) => {
448            eprintln!("warning: failed to serialize graph cache: {e}");
449            return;
450        }
451    };
452    let parse_data = match bitcode::serialize(&entries) {
453        Ok(d) => d,
454        Err(e) => {
455            eprintln!("warning: failed to serialize parse cache: {e}");
456            return;
457        }
458    };
459
460    let mut out = Vec::with_capacity(HEADER_SIZE + graph_data.len() + parse_data.len());
461    out.extend_from_slice(&CACHE_MAGIC.to_le_bytes());
462    out.extend_from_slice(&CACHE_VERSION.to_le_bytes());
463    out.extend_from_slice(&(graph_data.len() as u64).to_le_bytes());
464    out.extend_from_slice(&graph_data);
465    out.extend_from_slice(&parse_data);
466
467    if let Err(e) = fs::write(cache_path(&root), &out) {
468        eprintln!("warning: failed to write cache: {e}");
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475    use crate::graph::EdgeKind;
476    use crate::lang::RawImport;
477
478    /// Helper: insert into parse cache by stat-ing the file for mtime/size.
479    fn insert_with_stat(
480        cache: &mut ParseCache,
481        path: PathBuf,
482        result: ParseResult,
483        resolved: Vec<Option<PathBuf>>,
484    ) {
485        let meta = fs::metadata(&path).unwrap();
486        let mtime = mtime_of(&meta).unwrap();
487        cache.insert(path, meta.len(), mtime, result, resolved);
488    }
489
490    #[test]
491    fn parse_cache_hit_when_unchanged() {
492        let tmp = tempfile::tempdir().unwrap();
493        let root = tmp.path().canonicalize().unwrap();
494        let file = root.join("test.py");
495        fs::write(&file, "import os").unwrap();
496
497        let mut cache = ParseCache::new();
498        let result = ParseResult {
499            imports: vec![RawImport {
500                specifier: "os".into(),
501                kind: EdgeKind::Static,
502            }],
503            unresolvable_dynamic: 0,
504        };
505        let resolved = vec![None];
506        insert_with_stat(&mut cache, file.clone(), result, resolved);
507
508        let cached = cache.lookup(&file);
509        assert!(cached.is_some());
510        let (parse_result, resolved_paths) = cached.unwrap();
511        assert_eq!(parse_result.imports.len(), 1);
512        assert_eq!(resolved_paths.len(), 1);
513        assert!(resolved_paths[0].is_none());
514    }
515
516    #[test]
517    fn parse_cache_miss_when_modified() {
518        let tmp = tempfile::tempdir().unwrap();
519        let root = tmp.path().canonicalize().unwrap();
520        let file = root.join("test.py");
521        fs::write(&file, "import os").unwrap();
522
523        let mut cache = ParseCache::new();
524        let result = ParseResult {
525            imports: vec![],
526            unresolvable_dynamic: 0,
527        };
528        insert_with_stat(&mut cache, file.clone(), result, vec![]);
529
530        fs::write(&file, "import os\nimport sys").unwrap();
531
532        assert!(cache.lookup(&file).is_none());
533    }
534
535    #[test]
536    fn parse_cache_save_and_load_roundtrip() {
537        let tmp = tempfile::tempdir().unwrap();
538        let root = tmp.path().canonicalize().unwrap();
539        let file = root.join("test.py");
540        let target = root.join("os_impl.py");
541        fs::write(&file, "import os").unwrap();
542        fs::write(&target, "").unwrap();
543
544        let mut cache = ParseCache::new();
545        let result = ParseResult {
546            imports: vec![RawImport {
547                specifier: "os".into(),
548                kind: EdgeKind::Static,
549            }],
550            unresolvable_dynamic: 1,
551        };
552        let resolved = vec![Some(target.clone())];
553        insert_with_stat(&mut cache, file.clone(), result, resolved);
554
555        let graph = ModuleGraph::new();
556        drop(cache.save(&root, &file, &graph, vec![], 0));
557
558        let mut loaded = ParseCache::load(&root);
559        let cached = loaded.lookup(&file);
560        assert!(cached.is_some());
561        let (parse_result, resolved_paths) = cached.unwrap();
562        assert_eq!(parse_result.imports.len(), 1);
563        assert_eq!(parse_result.imports[0].specifier, "os");
564        assert_eq!(parse_result.unresolvable_dynamic, 1);
565        assert_eq!(resolved_paths.len(), 1);
566        assert_eq!(resolved_paths[0], Some(target));
567    }
568
569    #[test]
570    fn graph_cache_valid_when_unchanged() {
571        let tmp = tempfile::tempdir().unwrap();
572        let root = tmp.path().canonicalize().unwrap();
573        let file = root.join("entry.py");
574        fs::write(&file, "x = 1").unwrap();
575
576        let mut graph = ModuleGraph::new();
577        let size = fs::metadata(&file).unwrap().len();
578        graph.add_module(file.clone(), size, None);
579
580        let mut cache = ParseCache::new();
581        drop(cache.save(&root, &file, &graph, vec!["os".into()], 2));
582
583        let mut loaded = ParseCache::load(&root);
584        let resolve_fn = |_: &str| false;
585        let result = loaded.try_load_graph(&file, &resolve_fn);
586        assert!(matches!(result, GraphCacheResult::Hit { .. }));
587        if let GraphCacheResult::Hit { graph: g, unresolvable_dynamic: unresolvable, .. } = result {
588            assert_eq!(g.module_count(), 1);
589            assert_eq!(unresolvable, 2);
590        }
591    }
592
593    #[test]
594    fn graph_cache_stale_when_file_modified() {
595        let tmp = tempfile::tempdir().unwrap();
596        let root = tmp.path().canonicalize().unwrap();
597        let file = root.join("entry.py");
598        fs::write(&file, "x = 1").unwrap();
599
600        let mut graph = ModuleGraph::new();
601        let size = fs::metadata(&file).unwrap().len();
602        graph.add_module(file.clone(), size, None);
603
604        let mut cache = ParseCache::new();
605        drop(cache.save(&root, &file, &graph, vec![], 0));
606
607        fs::write(&file, "x = 2; y = 3").unwrap();
608
609        let mut loaded = ParseCache::load(&root);
610        let resolve_fn = |_: &str| false;
611        let result = loaded.try_load_graph(&file, &resolve_fn);
612        assert!(matches!(result, GraphCacheResult::Stale { .. }));
613        if let GraphCacheResult::Stale { changed_files, .. } = result {
614            assert_eq!(changed_files.len(), 1);
615            assert_eq!(changed_files[0], file);
616        }
617    }
618
619    #[test]
620    fn graph_cache_invalidates_when_unresolved_import_resolves() {
621        let tmp = tempfile::tempdir().unwrap();
622        let root = tmp.path().canonicalize().unwrap();
623        let file = root.join("entry.py");
624        fs::write(&file, "import foo").unwrap();
625
626        let mut graph = ModuleGraph::new();
627        let size = fs::metadata(&file).unwrap().len();
628        graph.add_module(file.clone(), size, None);
629
630        let mut cache = ParseCache::new();
631        drop(cache.save(&root, &file, &graph, vec!["foo".into()], 0));
632
633        let mut loaded = ParseCache::load(&root);
634        let resolve_fn = |spec: &str| spec == "foo";
635        assert!(matches!(
636            loaded.try_load_graph(&file, &resolve_fn),
637            GraphCacheResult::Miss
638        ));
639    }
640
641    #[test]
642    fn graph_cache_invalidates_for_different_entry() {
643        let tmp = tempfile::tempdir().unwrap();
644        let root = tmp.path().canonicalize().unwrap();
645        let file_a = root.join("a.py");
646        let file_b = root.join("b.py");
647        fs::write(&file_a, "x = 1").unwrap();
648        fs::write(&file_b, "y = 2").unwrap();
649
650        let mut graph = ModuleGraph::new();
651        let size = fs::metadata(&file_a).unwrap().len();
652        graph.add_module(file_a.clone(), size, None);
653
654        let mut cache = ParseCache::new();
655        drop(cache.save(&root, &file_a, &graph, vec![], 0));
656
657        let mut loaded = ParseCache::load(&root);
658        let resolve_fn = |_: &str| false;
659        assert!(matches!(
660            loaded.try_load_graph(&file_b, &resolve_fn),
661            GraphCacheResult::Miss
662        ));
663    }
664
665    #[test]
666    fn incremental_save_updates_changed_mtimes() {
667        let tmp = tempfile::tempdir().unwrap();
668        let root = tmp.path().canonicalize().unwrap();
669        let file = root.join("entry.py");
670        fs::write(&file, "x = 1").unwrap();
671
672        let mut graph = ModuleGraph::new();
673        let size = fs::metadata(&file).unwrap().len();
674        graph.add_module(file.clone(), size, None);
675
676        let mut cache = ParseCache::new();
677        drop(cache.save(&root, &file, &graph, vec![], 0));
678
679        // Modify the file
680        fs::write(&file, "x = 2").unwrap();
681
682        let mut loaded = ParseCache::load(&root);
683        let resolve_fn = |_: &str| false;
684        let result = loaded.try_load_graph(&file, &resolve_fn);
685        assert!(matches!(result, GraphCacheResult::Stale { .. }));
686
687        if let GraphCacheResult::Stale { graph, changed_files, .. } = result {
688            // Incremental save with updated mtimes
689            drop(loaded.save_incremental(&root, &file, &graph, &changed_files, 0));
690
691            // Reload — should now be a Hit
692            let mut reloaded = ParseCache::load(&root);
693            let result = reloaded.try_load_graph(&file, &resolve_fn);
694            assert!(
695                matches!(result, GraphCacheResult::Hit { .. }),
696                "expected Hit after incremental save"
697            );
698        }
699    }
700
701    #[test]
702    fn lockfile_sentinel_walks_up_to_workspace_root() {
703        let tmp = tempfile::tempdir().unwrap();
704        let workspace = tmp.path().canonicalize().unwrap();
705        let pkg = workspace.join("packages").join("app");
706        fs::create_dir_all(&pkg).unwrap();
707
708        // Lockfile at workspace root, not package root
709        fs::write(workspace.join("pnpm-lock.yaml"), "lockfileVersion: 9").unwrap();
710        let file = pkg.join("entry.ts");
711        fs::write(&file, "x = 1").unwrap();
712
713        let mut graph = ModuleGraph::new();
714        let size = fs::metadata(&file).unwrap().len();
715        graph.add_module(file.clone(), size, None);
716
717        // Save with package root — should find pnpm-lock.yaml via walk-up
718        let mut cache = ParseCache::new();
719        drop(cache.save(&pkg, &file, &graph, vec![], 0));
720
721        // First load: sentinels should be present → no resave needed
722        let mut loaded = ParseCache::load(&pkg);
723        let resolve_fn = |_: &str| false;
724        let result = loaded.try_load_graph(&file, &resolve_fn);
725        match result {
726            GraphCacheResult::Hit { needs_resave, .. } => {
727                assert!(!needs_resave, "sentinels should match — no resave needed");
728            }
729            other => panic!("expected Hit, got {other:?}"),
730        }
731    }
732}