Skip to main content

aft/inspect/
cache.rs

1use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
2use std::fmt;
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::sync::{Arc, Mutex, OnceLock, RwLock};
6use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
7
8const INSPECT_SQLITE_SIDECAR_SUFFIXES: &[&str] = &["-wal", "-shm", "-journal"];
9// Field data (2026-08-06): ~/.local/share/cortexkit/aft/inspect held 1,945
10// scope-key directories for about 30 repositories; 1,849 directories (21.5 GB)
11// had no file newer than seven days. Scope keys hash canonical checkout paths,
12// so reclaimed worktrees cannot ever reach their old directories again. This is
13// a structural fix for that permanent accumulation, not a user-facing knob.
14const INSPECT_SCOPE_MIN_AGE: Duration = Duration::from_secs(14 * 24 * 60 * 60);
15/// Bound one process-wide pass so a slow filesystem cannot stall publication;
16/// the first-level cursor resumes the remaining scope directories next time.
17const INSPECT_SCOPE_SWEEP_BUDGET: Duration = Duration::from_secs(5);
18
19#[derive(Default)]
20struct InspectScopeSweepCursor {
21    last_name: Option<String>,
22}
23
24static INSPECT_SCOPE_SWEEP_CURSORS: OnceLock<Mutex<HashMap<PathBuf, InspectScopeSweepCursor>>> =
25    OnceLock::new();
26
27use rusqlite::{params, Connection, OpenFlags, OptionalExtension};
28
29use crate::cache_freshness::{FileFreshness, FreshnessVerdict};
30use crate::config::Config;
31use crate::jsonc::strip_jsonc;
32
33use super::job::{
34    contribution_with_type_ref_names, type_ref_names_from_contribution, FileContribution,
35    InspectCategory, JobKey,
36};
37
38#[derive(Debug, Default)]
39pub(crate) struct Tier2ContributionUpdates {
40    pub upserts: Vec<FileContribution>,
41    pub deletes: Vec<PathBuf>,
42    pub metadata_updates: Vec<(PathBuf, FileFreshness)>,
43}
44
45#[derive(Debug, Default, Clone, Copy)]
46pub(crate) struct InspectDbTimings {
47    pub lock_wait: Duration,
48    pub transaction: Duration,
49}
50
51#[derive(Debug)]
52pub enum InspectCacheError {
53    Io(std::io::Error),
54    Sql(rusqlite::Error),
55    Json(serde_json::Error),
56    LockPoisoned(&'static str),
57    InvalidHash(String),
58}
59
60impl fmt::Display for InspectCacheError {
61    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
62        match self {
63            InspectCacheError::Io(error) => write!(formatter, "inspect cache io error: {error}"),
64            InspectCacheError::Sql(error) => {
65                write!(formatter, "inspect cache sqlite error: {error}")
66            }
67            InspectCacheError::Json(error) => {
68                write!(formatter, "inspect cache json error: {error}")
69            }
70            InspectCacheError::LockPoisoned(name) => {
71                write!(formatter, "inspect cache lock poisoned: {name}")
72            }
73            InspectCacheError::InvalidHash(hash) => {
74                write!(formatter, "inspect cache invalid blake3 hash: {hash}")
75            }
76        }
77    }
78}
79
80impl std::error::Error for InspectCacheError {}
81
82impl From<std::io::Error> for InspectCacheError {
83    fn from(error: std::io::Error) -> Self {
84        Self::Io(error)
85    }
86}
87
88impl From<rusqlite::Error> for InspectCacheError {
89    fn from(error: rusqlite::Error) -> Self {
90        Self::Sql(error)
91    }
92}
93
94impl From<serde_json::Error> for InspectCacheError {
95    fn from(error: serde_json::Error) -> Self {
96        Self::Json(error)
97    }
98}
99
100/// Persisted Tier-2 contribution/aggregate format version.
101///
102/// Bump this when `FileContribution.contribution` JSON changes in a way that
103/// requires existing per-file contributions to be rebuilt before roll-up, OR
104/// when the roll-up/aggregation LOGIC changes (e.g. dead_code reachability):
105/// cached aggregates are keyed by a `contribution_set_hash` that folds in this
106/// version, so a logic-only change is invisible to existing caches unless the
107/// version moves. v6: dead_code now propagates liveness through dispatch-only
108/// method bodies (free fns reached only via `obj.method()` were false-dead).
109/// v7: duplicates now collapses nested/overlapping fragments (a duplicated
110/// block no longer reports every nested subtree as its own group).
111/// v8: entry-point recognition seeds npm `scripts` source files as liveness
112/// roots (baked into per-file liveness_roots), and dead_code/unused_exports
113/// exclude test-support files (fixtures/corpora/mocks) from reporting.
114/// v9: unused_exports resolves NodeNext `./x.js` import specifiers to their
115/// `.ts` source (alters resolved import edges), fixing false-unused on symbols
116/// re-exported/imported with a `.js` extension in a `.ts` source tree.
117/// v10: public-API entry resolution remaps build-output entries (dist/index.js)
118/// to their src/ source equivalent, so the source barrel is recognized as a
119/// public-API file and its re-exports are suppressed (changes public-API set).
120/// v11: dead_code/unused_exports drill-down is ranked by signal tier (product
121/// findings before benchmark/tooling noise) before the cap, and a ranked `top`
122/// preview is folded into all three Tier-2 aggregates — changes cached payload.
123/// v12: dead_code internal call rows include call-edge provenance, changing
124/// cached per-file contribution payloads and aggregate roll-up inputs.
125/// v13: dead_code callgraph snapshots are projected from the persisted
126/// CallgraphStore; per-row provenance now reflects store resolution tiers.
127/// v14: TS/JS dead_code and unused_exports contributions carry oxc verdicts,
128/// provenance, and oxc honesty metadata.
129/// v15: dead_code reachability counts exact type_match call edges as resolved
130/// liveness (qualified-constructor calls like AppContext::new -> BackupStore::new
131/// no longer collapse to bare `new` and drop), changing the dead verdict for the
132/// same contribution set — existing caches must invalidate.
133/// v16: unused_exports stores raw oxc FileFacts and recomputes verdicts during
134/// roll-up, enabling incremental one-file reparses without stale verdicts.
135/// v17: dead_code stores raw per-file facts and recomputes callgraph/re-export,
136/// entry-root, imported-export, and oxc verdict liveness during roll-up.
137/// v18: dead_code/unused_exports aggregate hashes include the full TS/JS
138/// resolver-config dependency set (tsconfig/jsconfig variants and extends
139/// chains), so alias-only config edits invalidate verdict roll-ups.
140/// v19: dead_code entry reachability executes side-effect-only imported modules,
141/// preserving same-file and transitive static-import liveness without marking all
142/// target exports used.
143/// v20: duplicates aggregate hashes include inspect.duplicates.expected_mirrors,
144/// so changing intentional mirror architecture rules invalidates cached roll-ups.
145/// v21: Rust dead_code contributions carry attribute-root entry facts for
146/// externally-invoked functions such as Tauri commands and ABI exports.
147/// v22: cycles persists TS/JS resolved import-edge facts and rolls them up into
148/// strongly connected module components.
149/// v23: TS/JS re-export verdicts collapse barrel aliases to canonical exports
150/// and carry non-counted re-export context on canonical findings.
151/// v24: TS/JS verdicts distinguish product references from test-only
152/// references so test-only usage moves out of headline dead/unused counts.
153/// v25: framework file-based route files execute as roots while only their
154/// framework-called exports are seeded live; manifest-driven route detection
155/// changes dead/unused verdict roll-ups without changing per-file facts.
156/// v26: TS/JS per-file facts record exported-symbol decorators, and NestJS
157/// decorator roots change dead/unused verdict roll-ups.
158/// v28: Rust dead-code facts include function/type names found inside macro
159/// token trees. The aggregate scan uses those names for reachability only;
160/// call-graph navigation remains based on resolved source-level calls.
161/// v29: duplicates verdicts require every reported occurrence to span at least
162/// 10 source lines, so unchanged per-file fragment facts can now aggregate to a
163/// different surfaced group set.
164/// v30: cycles verdicts retain singleton strongly connected components when a
165/// resolved non-type import explicitly points back to the same file.
166/// v31: TODO extraction now reads parser comment nodes and ignores marker-like
167/// text in strings, changing aggregate verdicts for unchanged source files.
168pub(crate) const TIER2_CONTRIBUTION_CACHE_VERSION: u32 = 31;
169
170#[derive(Debug, Clone)]
171pub struct ContributionRecord {
172    pub category: InspectCategory,
173    pub file_path: PathBuf,
174    pub freshness: FileFreshness,
175    pub contribution: serde_json::Value,
176    pub type_ref_names: BTreeSet<String>,
177}
178
179#[derive(Debug, Clone)]
180struct MemoryAggregate {
181    payload: serde_json::Value,
182    generated_at: i64,
183    contribution_set_hash: Option<String>,
184}
185
186const TIER1_FILE_MEMO_MAX_ENTRIES: usize = 4_096;
187
188#[derive(Debug, Clone)]
189struct Tier1MemoEntry<T> {
190    freshness: FileFreshness,
191    value: T,
192    generation: u64,
193}
194
195#[derive(Debug, Clone)]
196struct LruNode {
197    path: PathBuf,
198    generation: u64,
199}
200
201#[derive(Debug)]
202struct Tier1MemoState<T> {
203    entries: HashMap<PathBuf, Tier1MemoEntry<T>>,
204    lru: VecDeque<LruNode>,
205    next_generation: u64,
206    capacity: usize,
207}
208
209impl<T> Default for Tier1MemoState<T> {
210    fn default() -> Self {
211        Self {
212            entries: HashMap::new(),
213            lru: VecDeque::new(),
214            next_generation: 0,
215            capacity: TIER1_FILE_MEMO_MAX_ENTRIES,
216        }
217    }
218}
219
220impl<T> Tier1MemoState<T> {
221    fn insert(&mut self, path: PathBuf, mut entry: Tier1MemoEntry<T>) {
222        let generation = self.allocate_generation();
223        entry.generation = generation;
224        self.entries.insert(path.clone(), entry);
225        self.lru.push_back(LruNode { path, generation });
226        self.compact_lru_if_needed();
227        self.evict_lru();
228    }
229
230    fn remove(&mut self, path: &Path) {
231        self.entries.remove(path);
232        self.compact_lru_if_needed();
233    }
234
235    fn touch(&mut self, path: &Path) {
236        if !self.entries.contains_key(path) {
237            return;
238        }
239
240        let generation = self.allocate_generation();
241        if let Some(entry) = self.entries.get_mut(path) {
242            entry.generation = generation;
243            self.lru.push_back(LruNode {
244                path: path.to_path_buf(),
245                generation,
246            });
247        }
248        self.compact_lru_if_needed();
249    }
250
251    fn allocate_generation(&mut self) -> u64 {
252        if self.next_generation == u64::MAX {
253            self.rebuild_lru();
254        }
255        let generation = self.next_generation;
256        self.next_generation += 1;
257        generation
258    }
259
260    fn compact_lru_if_needed(&mut self) {
261        let max_lru_nodes = self.capacity.saturating_mul(2).max(self.entries.len());
262        if self.lru.len() > max_lru_nodes {
263            self.rebuild_lru();
264        }
265    }
266
267    fn rebuild_lru(&mut self) {
268        let mut live_nodes = self
269            .entries
270            .iter()
271            .map(|(path, entry)| (entry.generation, path.clone()))
272            .collect::<Vec<_>>();
273        live_nodes.sort_by_key(|(generation, _)| *generation);
274
275        self.lru.clear();
276        for (generation, (_, path)) in live_nodes.into_iter().enumerate() {
277            let generation = generation as u64;
278            if let Some(entry) = self.entries.get_mut(&path) {
279                entry.generation = generation;
280            }
281            self.lru.push_back(LruNode { path, generation });
282        }
283        self.next_generation = self.lru.len() as u64;
284    }
285
286    fn retain_live_lru_nodes(&mut self) {
287        let entries = &self.entries;
288        self.lru.retain(|node| {
289            entries
290                .get(&node.path)
291                .is_some_and(|entry| entry.generation == node.generation)
292        });
293    }
294
295    fn evict_lru(&mut self) {
296        while self.entries.len() > self.capacity {
297            let Some(node) = self.lru.pop_front() else {
298                break;
299            };
300            if self
301                .entries
302                .get(&node.path)
303                .is_some_and(|entry| entry.generation == node.generation)
304            {
305                self.entries.remove(&node.path);
306            }
307        }
308        self.compact_lru_if_needed();
309    }
310}
311
312#[derive(Debug)]
313pub(crate) struct Tier1FileMemo<T> {
314    state: Mutex<Tier1MemoState<T>>,
315}
316
317impl<T> Default for Tier1FileMemo<T> {
318    fn default() -> Self {
319        Self {
320            state: Mutex::new(Tier1MemoState::default()),
321        }
322    }
323}
324
325impl<T> Tier1FileMemo<T> {
326    /// Prevent a full-scope scan from evicting entries that the same scan will
327    /// need again on its next run. Capacity only grows here; a completed full
328    /// scan shrinks it back to that scan's live file set in [`Self::prune_to_scope`].
329    pub(crate) fn reserve_for_scan(&self, file_count: usize) {
330        let required_capacity = file_count.max(TIER1_FILE_MEMO_MAX_ENTRIES);
331        if let Ok(mut state) = self.state.lock() {
332            state.capacity = state.capacity.max(required_capacity);
333        }
334    }
335
336    /// Drop entries outside a completed full-project scan and right-size the
337    /// memo to the live project. Scoped scans must not call this because their
338    /// path list intentionally omits valid entries elsewhere in the project.
339    pub(crate) fn prune_to_scope(&self, project_root: &Path, live_paths: &[PathBuf]) {
340        let live_paths = live_paths
341            .iter()
342            .map(PathBuf::as_path)
343            .collect::<HashSet<_>>();
344        if let Ok(mut state) = self.state.lock() {
345            state.entries.retain(|path, _| {
346                !path.starts_with(project_root) || live_paths.contains(path.as_path())
347            });
348            state.capacity = live_paths.len().max(TIER1_FILE_MEMO_MAX_ENTRIES);
349            state.retain_live_lru_nodes();
350            state.evict_lru();
351        }
352    }
353}
354
355impl<T: Clone> Tier1FileMemo<T> {
356    pub(crate) fn get_or_insert_with<F>(&self, path: &Path, scan: F) -> T
357    where
358        F: FnOnce(&Path) -> (Option<FileFreshness>, T),
359    {
360        if let Some(cached) = self.cached_value(path) {
361            return cached;
362        }
363
364        let (freshness, value) = scan(path);
365        if let Ok(mut state) = self.state.lock() {
366            if let Some(freshness) = freshness {
367                state.insert(
368                    path.to_path_buf(),
369                    Tier1MemoEntry {
370                        freshness,
371                        value: value.clone(),
372                        generation: 0,
373                    },
374                );
375            } else {
376                state.remove(path);
377            }
378        }
379        value
380    }
381
382    fn cached_value(&self, path: &Path) -> Option<T> {
383        let mut cached = self
384            .state
385            .lock()
386            .ok()
387            .and_then(|state| state.entries.get(path).cloned())?;
388
389        match crate::cache_freshness::verify_file(path, &cached.freshness) {
390            FreshnessVerdict::HotFresh => {
391                if let Ok(mut state) = self.state.lock() {
392                    state.touch(path);
393                }
394                Some(cached.value)
395            }
396            FreshnessVerdict::ContentFresh {
397                new_mtime,
398                new_size,
399            } => {
400                cached.freshness.mtime = new_mtime;
401                cached.freshness.size = new_size;
402                let value = cached.value.clone();
403                if let Ok(mut state) = self.state.lock() {
404                    state.insert(path.to_path_buf(), cached);
405                }
406                Some(value)
407            }
408            FreshnessVerdict::Stale => None,
409            FreshnessVerdict::Deleted => {
410                if let Ok(mut state) = self.state.lock() {
411                    state.remove(path);
412                }
413                None
414            }
415        }
416    }
417}
418
419#[derive(Debug)]
420pub struct InspectCache {
421    project_root: PathBuf,
422    project_key: String,
423    sqlite_path: PathBuf,
424    writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
425    read_marker: Option<crate::root_cache::ReadMarker>,
426    conn: Mutex<Connection>,
427    memory: RwLock<HashMap<JobKey, MemoryAggregate>>,
428}
429
430#[derive(Debug)]
431pub struct ReadonlyInspectCache {
432    inner: InspectCache,
433}
434
435impl InspectCache {
436    /// Estimate only the in-process aggregate map. SQLite-owned allocations are
437    /// measured once by the process-wide SQLite allocator counters.
438    pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
439        let memory = match self.memory.try_read() {
440            Ok(memory) => memory,
441            Err(_) => return crate::memory::MemoryEstimate::busy(),
442        };
443        if memory.is_empty() {
444            return crate::memory::MemoryEstimate::partial(0)
445                .count("memory_aggregates", 0)
446                .count("open_generation_handles", 1);
447        }
448        let aggregate_bytes = memory.iter().fold(0u64, |bytes, (key, aggregate)| {
449            bytes
450                .saturating_add(std::mem::size_of::<JobKey>() as u64)
451                .saturating_add(
452                    key.scope_hash
453                        .as_ref()
454                        .map(|hash| crate::memory::usize_to_u64(hash.len()))
455                        .unwrap_or(0),
456                )
457                .saturating_add(std::mem::size_of::<MemoryAggregate>() as u64)
458                .saturating_add(crate::memory::estimated_json_bytes(&aggregate.payload))
459                .saturating_add(
460                    aggregate
461                        .contribution_set_hash
462                        .as_ref()
463                        .map(|hash| crate::memory::usize_to_u64(hash.len()))
464                        .unwrap_or(0),
465                )
466        });
467        let metadata_bytes = crate::memory::path_bytes(&self.project_root)
468            .saturating_add(crate::memory::usize_to_u64(self.project_key.len()))
469            .saturating_add(crate::memory::path_bytes(&self.sqlite_path));
470        crate::memory::MemoryEstimate::partial(aggregate_bytes.saturating_add(metadata_bytes))
471            .count("memory_aggregates", memory.len())
472            .count("open_generation_handles", 1)
473    }
474}
475
476pub trait InspectCacheRead {
477    fn get_aggregated_for_config(
478        &self,
479        key: &JobKey,
480        config: &Config,
481    ) -> Result<Option<serde_json::Value>, InspectCacheError>;
482    fn latest_aggregate_any_hash(
483        &self,
484        category: InspectCategory,
485    ) -> Result<Option<serde_json::Value>, InspectCacheError>;
486    fn contribution_freshness(
487        &self,
488        category: InspectCategory,
489    ) -> Result<Vec<(PathBuf, FileFreshness)>, InspectCacheError>;
490    fn load_tier2_contributions(
491        &self,
492        category: InspectCategory,
493    ) -> Result<Vec<ContributionRecord>, InspectCacheError>;
494    fn contribution_set_hash_for_config(
495        &self,
496        category: InspectCategory,
497        config: &Config,
498    ) -> Result<String, InspectCacheError>;
499    fn last_full_run(&self, category: InspectCategory) -> Result<Option<i64>, InspectCacheError>;
500}
501
502impl InspectCache {
503    pub fn open(inspect_dir: PathBuf, project_root: PathBuf) -> Result<Self, InspectCacheError> {
504        let project_key = crate::path_identity::project_scope_key(&project_root);
505        let project_inspect_dir = project_inspect_dir(inspect_dir.clone(), &project_key);
506        let Some(writer_lease) =
507            acquire_writer_lease(&project_inspect_dir, &project_key, &project_root)?
508        else {
509            return match Self::open_readonly(inspect_dir, project_root.clone())? {
510                Some(cache) => Ok(cache.into_inner()),
511                None => Self::borrow_only_empty(project_inspect_dir, project_root, project_key),
512            };
513        };
514        let inspect_dir = project_inspect_dir;
515        if !writer_lease.verify().map_err(InspectCacheError::from)? {
516            return Err(InspectCacheError::Io(std::io::Error::other(
517                "inspect writer lease epoch changed before opening cache",
518            )));
519        }
520        std::fs::create_dir_all(&inspect_dir)?;
521        let (sqlite_path, generation, needs_publish) =
522            resolve_or_create_inspect_target(&inspect_dir, &project_key);
523        let conn = Connection::open(&sqlite_path)?;
524        configure_connection(&conn)?;
525        if !writer_lease.verify().map_err(InspectCacheError::from)? {
526            return Err(InspectCacheError::Io(std::io::Error::other(
527                "inspect writer lease epoch changed before schema initialization",
528            )));
529        }
530        initialize_schema(&conn)?;
531        if needs_publish {
532            if !writer_lease.verify().map_err(InspectCacheError::from)? {
533                return Err(InspectCacheError::Io(std::io::Error::other(
534                    "inspect writer lease epoch changed before pointer publish",
535                )));
536            }
537            publish_inspect_pointer(
538                &inspect_dir,
539                &project_key,
540                generation.as_deref().unwrap_or_default(),
541            )?;
542        }
543        Ok(Self::from_connection(
544            project_root,
545            project_key,
546            sqlite_path,
547            Some(writer_lease),
548            None,
549            conn,
550        ))
551    }
552
553    pub fn open_readonly(
554        inspect_dir: PathBuf,
555        project_root: PathBuf,
556    ) -> Result<Option<ReadonlyInspectCache>, InspectCacheError> {
557        let project_key = crate::path_identity::project_scope_key(&project_root);
558        let inspect_dir = project_inspect_dir(inspect_dir, &project_key);
559        let Some((sqlite_path, generation)) = resolve_inspect_target(&inspect_dir, &project_key)
560        else {
561            return Ok(None);
562        };
563        let conn = open_readonly_connection(&sqlite_path)?;
564        let marker_label = generation.as_deref().unwrap_or("legacy");
565        let read_marker = crate::root_cache::ReadMarker::create(&inspect_dir, marker_label)?;
566        Ok(Some(ReadonlyInspectCache::from_inner(
567            Self::from_connection(
568                project_root,
569                project_key,
570                sqlite_path,
571                None,
572                Some(read_marker),
573                conn,
574            ),
575        )))
576    }
577
578    fn borrow_only_empty(
579        inspect_dir: PathBuf,
580        project_root: PathBuf,
581        project_key: String,
582    ) -> Result<Self, InspectCacheError> {
583        let conn = Connection::open_in_memory()?;
584        initialize_schema(&conn)?;
585        conn.pragma_update(None, "query_only", true)?;
586        Ok(Self::from_connection(
587            project_root,
588            project_key.clone(),
589            inspect_dir.join(format!("{project_key}.borrow-only")),
590            None,
591            None,
592            conn,
593        ))
594    }
595
596    fn from_connection(
597        project_root: PathBuf,
598        project_key: String,
599        sqlite_path: PathBuf,
600        writer_lease: Option<Arc<crate::root_cache::WriterLease>>,
601        read_marker: Option<crate::root_cache::ReadMarker>,
602        conn: Connection,
603    ) -> Self {
604        Self {
605            project_root,
606            project_key,
607            sqlite_path,
608            writer_lease,
609            read_marker,
610            conn: Mutex::new(conn),
611            memory: RwLock::new(HashMap::new()),
612        }
613    }
614
615    pub fn project_root(&self) -> &Path {
616        &self.project_root
617    }
618
619    pub fn project_key(&self) -> &str {
620        &self.project_key
621    }
622
623    pub fn sqlite_path(&self) -> &Path {
624        &self.sqlite_path
625    }
626
627    pub fn writer_epoch_for_test(&self) -> Option<&str> {
628        self.writer_lease.as_ref().map(|lease| lease.epoch())
629    }
630
631    fn verify_writer_lease(&self) -> Result<(), InspectCacheError> {
632        let Some(lease) = self.writer_lease.as_ref() else {
633            return Err(InspectCacheError::Io(std::io::Error::other(
634                "inspect cache opened read-only; write API is unavailable",
635            )));
636        };
637        if lease.verify()? {
638            Ok(())
639        } else {
640            Err(InspectCacheError::Io(std::io::Error::other(format!(
641                "inspect writer lease for key {} lost epoch {}; aborting write",
642                lease.key(),
643                lease.epoch()
644            ))))
645        }
646    }
647
648    fn refresh_read_marker(&self) -> Result<(), InspectCacheError> {
649        if let Some(marker) = self.read_marker.as_ref() {
650            marker.touch_if_due()?;
651        }
652        Ok(())
653    }
654
655    pub fn store_aggregated(
656        &self,
657        key: JobKey,
658        payload: serde_json::Value,
659    ) -> Result<(), InspectCacheError> {
660        self.verify_writer_lease()?;
661        self.store_memory_aggregate(key, payload, None)
662    }
663
664    fn store_memory_aggregate(
665        &self,
666        key: JobKey,
667        payload: serde_json::Value,
668        contribution_set_hash: Option<String>,
669    ) -> Result<(), InspectCacheError> {
670        self.memory
671            .write()
672            .map_err(|_| InspectCacheError::LockPoisoned("memory"))?
673            .insert(
674                key,
675                MemoryAggregate {
676                    payload,
677                    generated_at: unix_seconds_now(),
678                    contribution_set_hash,
679                },
680            );
681        Ok(())
682    }
683
684    pub fn get_aggregated(
685        &self,
686        key: &JobKey,
687    ) -> Result<Option<serde_json::Value>, InspectCacheError> {
688        self.get_aggregated_with_config(key, None)
689    }
690
691    pub fn get_aggregated_for_config(
692        &self,
693        key: &JobKey,
694        config: &Config,
695    ) -> Result<Option<serde_json::Value>, InspectCacheError> {
696        self.get_aggregated_with_config(key, Some(config))
697    }
698
699    fn get_aggregated_with_config(
700        &self,
701        key: &JobKey,
702        config: Option<&Config>,
703    ) -> Result<Option<serde_json::Value>, InspectCacheError> {
704        self.refresh_read_marker()?;
705        if !key.category.is_tier2() {
706            return Ok(self
707                .memory
708                .read()
709                .map_err(|_| InspectCacheError::LockPoisoned("memory"))?
710                .get(key)
711                .map(|entry| entry.payload.clone()));
712        }
713
714        let current_hash = {
715            let conn = self
716                .conn
717                .lock()
718                .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
719            contribution_set_hash_with_conn(
720                &conn,
721                key.category,
722                &self.project_key,
723                &self.project_root,
724                config,
725            )?
726        };
727
728        let memory_entry = {
729            self.memory
730                .read()
731                .map_err(|_| InspectCacheError::LockPoisoned("memory"))?
732                .get(key)
733                .cloned()
734        };
735        if let Some(entry) = memory_entry {
736            if entry.contribution_set_hash.as_deref() == Some(current_hash.as_str()) {
737                return Ok(Some(entry.payload));
738            }
739            self.memory
740                .write()
741                .map_err(|_| InspectCacheError::LockPoisoned("memory"))?
742                .remove(key);
743        }
744
745        let payload = {
746            let conn = self
747                .conn
748                .lock()
749                .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
750            conn.query_row(
751                "SELECT aggregate FROM tier2_aggregates \
752                 WHERE category = ?1 AND project_key = ?2 AND contribution_set_hash = ?3",
753                params![key.category.as_str(), self.project_key, current_hash],
754                |row| row.get::<_, Vec<u8>>(0),
755            )
756            .optional()?
757        };
758
759        match payload {
760            Some(bytes) => {
761                let value = serde_json::from_slice::<serde_json::Value>(&bytes)?;
762                self.store_memory_aggregate(key.clone(), value.clone(), Some(current_hash))?;
763                Ok(Some(value))
764            }
765            None => Ok(None),
766        }
767    }
768
769    pub fn store_tier2_result(
770        &self,
771        key: JobKey,
772        scanned_files: &[PathBuf],
773        contributions: &[FileContribution],
774        aggregate: serde_json::Value,
775    ) -> Result<(), InspectCacheError> {
776        self.store_tier2_result_with_config(key, scanned_files, contributions, aggregate, None)
777    }
778
779    pub fn store_tier2_result_for_config(
780        &self,
781        key: JobKey,
782        scanned_files: &[PathBuf],
783        contributions: &[FileContribution],
784        aggregate: serde_json::Value,
785        config: &Config,
786    ) -> Result<(), InspectCacheError> {
787        self.store_tier2_result_with_config(
788            key,
789            scanned_files,
790            contributions,
791            aggregate,
792            Some(config),
793        )
794    }
795
796    fn store_tier2_result_with_config(
797        &self,
798        key: JobKey,
799        scanned_files: &[PathBuf],
800        contributions: &[FileContribution],
801        aggregate: serde_json::Value,
802        config: Option<&Config>,
803    ) -> Result<(), InspectCacheError> {
804        if !key.category.is_tier2() {
805            self.store_aggregated(key, aggregate)?;
806            return Ok(());
807        }
808
809        self.verify_writer_lease()?;
810        let now = unix_seconds_now();
811        let mut conn = self
812            .conn
813            .lock()
814            .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
815        let tx = conn.transaction()?;
816
817        let scanned_relative = scanned_files
818            .iter()
819            .map(|path| relative_string(&self.project_root, path))
820            .collect::<BTreeSet<_>>();
821        let existing = existing_contribution_paths(&tx, key.category, &self.project_key)?;
822        for file_path in existing {
823            if !scanned_relative.contains(&file_path) {
824                tx.execute(
825                    "DELETE FROM tier2_contributions WHERE category = ?1 AND project_key = ?2 AND file_path = ?3",
826                    params![key.category.as_str(), self.project_key, file_path],
827                )?;
828            }
829        }
830
831        for contribution in contributions {
832            let file_path = relative_string(&self.project_root, &contribution.file_path);
833            let blob = serde_json::to_vec(&contribution_with_type_ref_names(
834                contribution.contribution.clone(),
835                &contribution.type_ref_names,
836            ))?;
837            tx.execute(
838                "INSERT INTO tier2_contributions \
839                 (category, project_key, file_path, file_mtime_ns, file_size, file_hash, contribution, generated_at) \
840                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) \
841                 ON CONFLICT(category, project_key, file_path) DO UPDATE SET \
842                 file_mtime_ns = excluded.file_mtime_ns, \
843                 file_size = excluded.file_size, \
844                 file_hash = excluded.file_hash, \
845                 contribution = excluded.contribution, \
846                 generated_at = excluded.generated_at",
847                params![
848                    contribution.category.as_str(),
849                    self.project_key,
850                    file_path,
851                    system_time_to_ns(contribution.freshness.mtime),
852                    contribution.freshness.size as i64,
853                    hash_to_hex(contribution.freshness.content_hash),
854                    blob,
855                    now,
856                ],
857            )?;
858        }
859
860        let contribution_set_hash = contribution_set_hash_with_conn(
861            &tx,
862            key.category,
863            &self.project_key,
864            &self.project_root,
865            config,
866        )?;
867        let aggregate_blob = serde_json::to_vec(&aggregate)?;
868        tx.execute(
869            "INSERT INTO tier2_aggregates \
870             (category, project_key, contribution_set_hash, aggregate, generated_at) \
871             VALUES (?1, ?2, ?3, ?4, ?5) \
872             ON CONFLICT(category, project_key) DO UPDATE SET \
873             contribution_set_hash = excluded.contribution_set_hash, \
874             aggregate = excluded.aggregate, \
875             generated_at = excluded.generated_at",
876            params![
877                key.category.as_str(),
878                self.project_key,
879                contribution_set_hash,
880                aggregate_blob,
881                now,
882            ],
883        )?;
884        tx.execute(
885            "INSERT INTO tier2_meta (category, project_key, last_full_run) VALUES (?1, ?2, ?3) \
886             ON CONFLICT(category, project_key) DO UPDATE SET last_full_run = excluded.last_full_run",
887            params![key.category.as_str(), self.project_key, now],
888        )?;
889        tx.commit()?;
890
891        self.store_memory_aggregate(key, aggregate, Some(contribution_set_hash))
892    }
893
894    pub(crate) fn apply_contribution_updates_for_config(
895        &self,
896        category: InspectCategory,
897        updates: Tier2ContributionUpdates,
898        config: &Config,
899    ) -> Result<(String, InspectDbTimings), InspectCacheError> {
900        self.apply_contribution_updates_with_config(category, updates, Some(config))
901    }
902
903    fn apply_contribution_updates_with_config(
904        &self,
905        category: InspectCategory,
906        updates: Tier2ContributionUpdates,
907        config: Option<&Config>,
908    ) -> Result<(String, InspectDbTimings), InspectCacheError> {
909        self.verify_writer_lease()?;
910        let now = unix_seconds_now();
911        let lock_started = Instant::now();
912        let mut conn = self
913            .conn
914            .lock()
915            .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
916        let mut timings = InspectDbTimings {
917            lock_wait: lock_started.elapsed(),
918            ..InspectDbTimings::default()
919        };
920        let transaction_started = Instant::now();
921        let tx = conn.transaction()?;
922
923        for relative_file in updates.deletes {
924            tx.execute(
925                "DELETE FROM tier2_contributions WHERE category = ?1 AND project_key = ?2 AND file_path = ?3",
926                params![
927                    category.as_str(),
928                    self.project_key,
929                    relative_file.to_string_lossy().to_string()
930                ],
931            )?;
932        }
933
934        for (relative_file, freshness) in updates.metadata_updates {
935            tx.execute(
936                "UPDATE tier2_contributions \
937                 SET file_mtime_ns = ?4, file_size = ?5, file_hash = ?6 \
938                 WHERE category = ?1 AND project_key = ?2 AND file_path = ?3",
939                params![
940                    category.as_str(),
941                    self.project_key,
942                    relative_file.to_string_lossy().to_string(),
943                    system_time_to_ns(freshness.mtime),
944                    freshness.size as i64,
945                    hash_to_hex(freshness.content_hash),
946                ],
947            )?;
948        }
949
950        for contribution in updates.upserts {
951            let file_path = relative_string(&self.project_root, &contribution.file_path);
952            let blob = serde_json::to_vec(&contribution_with_type_ref_names(
953                contribution.contribution.clone(),
954                &contribution.type_ref_names,
955            ))?;
956            tx.execute(
957                "INSERT INTO tier2_contributions \
958                 (category, project_key, file_path, file_mtime_ns, file_size, file_hash, contribution, generated_at) \
959                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) \
960                 ON CONFLICT(category, project_key, file_path) DO UPDATE SET \
961                 file_mtime_ns = excluded.file_mtime_ns, \
962                 file_size = excluded.file_size, \
963                 file_hash = excluded.file_hash, \
964                 contribution = excluded.contribution, \
965                 generated_at = excluded.generated_at",
966                params![
967                    contribution.category.as_str(),
968                    self.project_key,
969                    file_path,
970                    system_time_to_ns(contribution.freshness.mtime),
971                    contribution.freshness.size as i64,
972                    hash_to_hex(contribution.freshness.content_hash),
973                    blob,
974                    now,
975                ],
976            )?;
977        }
978
979        let contribution_set_hash = contribution_set_hash_with_conn(
980            &tx,
981            category,
982            &self.project_key,
983            &self.project_root,
984            config,
985        )?;
986        tx.commit()?;
987        timings.transaction = transaction_started.elapsed();
988
989        self.memory
990            .write()
991            .map_err(|_| InspectCacheError::LockPoisoned("memory"))?
992            .remove(&JobKey::for_project_category(category));
993
994        Ok((contribution_set_hash, timings))
995    }
996
997    pub(crate) fn load_aggregate_if_hash_matches(
998        &self,
999        category: InspectCategory,
1000        contribution_set_hash: &str,
1001    ) -> Result<Option<serde_json::Value>, InspectCacheError> {
1002        self.refresh_read_marker()?;
1003        let payload = {
1004            let conn = self
1005                .conn
1006                .lock()
1007                .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
1008            conn.query_row(
1009                "SELECT aggregate FROM tier2_aggregates \
1010                 WHERE category = ?1 AND project_key = ?2 AND contribution_set_hash = ?3",
1011                params![category.as_str(), self.project_key, contribution_set_hash],
1012                |row| row.get::<_, Vec<u8>>(0),
1013            )
1014            .optional()?
1015        };
1016
1017        match payload {
1018            Some(bytes) => {
1019                let value = serde_json::from_slice::<serde_json::Value>(&bytes)?;
1020                self.store_memory_aggregate(
1021                    JobKey::for_project_category(category),
1022                    value.clone(),
1023                    Some(contribution_set_hash.to_string()),
1024                )?;
1025                Ok(Some(value))
1026            }
1027            None => Ok(None),
1028        }
1029    }
1030
1031    pub(crate) fn latest_aggregate_any_hash(
1032        &self,
1033        category: InspectCategory,
1034    ) -> Result<Option<serde_json::Value>, InspectCacheError> {
1035        self.refresh_read_marker()?;
1036        let payload = {
1037            let conn = self
1038                .conn
1039                .lock()
1040                .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
1041            conn.query_row(
1042                "SELECT aggregate FROM tier2_aggregates \
1043                 WHERE category = ?1 AND project_key = ?2 \
1044                 ORDER BY generated_at DESC LIMIT 1",
1045                params![category.as_str(), self.project_key],
1046                |row| row.get::<_, Vec<u8>>(0),
1047            )
1048            .optional()?
1049        };
1050
1051        match payload {
1052            Some(bytes) => serde_json::from_slice::<serde_json::Value>(&bytes)
1053                .map(Some)
1054                .map_err(InspectCacheError::from),
1055            None => Ok(None),
1056        }
1057    }
1058
1059    pub(crate) fn touch_tier2_last_full_run(
1060        &self,
1061        category: InspectCategory,
1062    ) -> Result<i64, InspectCacheError> {
1063        self.verify_writer_lease()?;
1064        let mut conn = self
1065            .conn
1066            .lock()
1067            .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
1068        let tx = conn.transaction()?;
1069        let previous = tx
1070            .query_row(
1071                "SELECT last_full_run FROM tier2_meta WHERE category = ?1 AND project_key = ?2",
1072                params![category.as_str(), self.project_key],
1073                |row| row.get::<_, i64>(0),
1074            )
1075            .optional()?;
1076        let now = unix_seconds_now();
1077        let last_full_run = previous.map_or(now, |previous| now.max(previous.saturating_add(1)));
1078        tx.execute(
1079            "INSERT INTO tier2_meta (category, project_key, last_full_run) VALUES (?1, ?2, ?3)              ON CONFLICT(category, project_key) DO UPDATE SET last_full_run = excluded.last_full_run",
1080            params![category.as_str(), self.project_key, last_full_run],
1081        )?;
1082        tx.commit()?;
1083        Ok(last_full_run)
1084    }
1085
1086    pub(crate) fn store_tier2_aggregate(
1087        &self,
1088        key: JobKey,
1089        contribution_set_hash: &str,
1090        aggregate: serde_json::Value,
1091    ) -> Result<(), InspectCacheError> {
1092        if !key.category.is_tier2() {
1093            self.store_aggregated(key, aggregate)?;
1094            return Ok(());
1095        }
1096
1097        self.verify_writer_lease()?;
1098        let now = unix_seconds_now();
1099        let aggregate_blob = serde_json::to_vec(&aggregate)?;
1100        let mut conn = self
1101            .conn
1102            .lock()
1103            .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
1104        let tx = conn.transaction()?;
1105        tx.execute(
1106            "INSERT INTO tier2_aggregates \
1107             (category, project_key, contribution_set_hash, aggregate, generated_at) \
1108             VALUES (?1, ?2, ?3, ?4, ?5) \
1109             ON CONFLICT(category, project_key) DO UPDATE SET \
1110             contribution_set_hash = excluded.contribution_set_hash, \
1111             aggregate = excluded.aggregate, \
1112             generated_at = excluded.generated_at",
1113            params![
1114                key.category.as_str(),
1115                self.project_key,
1116                contribution_set_hash,
1117                aggregate_blob,
1118                now,
1119            ],
1120        )?;
1121        tx.execute(
1122            "INSERT INTO tier2_meta (category, project_key, last_full_run) VALUES (?1, ?2, ?3) \
1123             ON CONFLICT(category, project_key) DO UPDATE SET last_full_run = excluded.last_full_run",
1124            params![key.category.as_str(), self.project_key, now],
1125        )?;
1126        tx.commit()?;
1127
1128        self.store_memory_aggregate(key, aggregate, Some(contribution_set_hash.to_string()))
1129    }
1130
1131    pub fn load_tier2_contributions(
1132        &self,
1133        category: InspectCategory,
1134    ) -> Result<Vec<ContributionRecord>, InspectCacheError> {
1135        self.refresh_read_marker()?;
1136        let conn = self
1137            .conn
1138            .lock()
1139            .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
1140        let mut stmt = conn.prepare(
1141            "SELECT file_path, file_mtime_ns, file_size, file_hash, contribution \
1142             FROM tier2_contributions \
1143             WHERE category = ?1 AND project_key = ?2 \
1144             ORDER BY file_path ASC",
1145        )?;
1146        let rows = stmt.query_map(params![category.as_str(), self.project_key], |row| {
1147            let file_path: String = row.get(0)?;
1148            let mtime_ns: i64 = row.get(1)?;
1149            let file_size: i64 = row.get(2)?;
1150            let file_hash: String = row.get(3)?;
1151            let contribution: Vec<u8> = row.get(4)?;
1152            Ok((file_path, mtime_ns, file_size, file_hash, contribution))
1153        })?;
1154
1155        let mut records = Vec::new();
1156        for row in rows {
1157            let (file_path, mtime_ns, file_size, file_hash, contribution) = row?;
1158            let contribution: serde_json::Value = serde_json::from_slice(&contribution)?;
1159            let type_ref_names = type_ref_names_from_contribution(&contribution);
1160            records.push(ContributionRecord {
1161                category,
1162                file_path: PathBuf::from(file_path),
1163                freshness: FileFreshness {
1164                    mtime: ns_to_system_time(mtime_ns),
1165                    size: file_size.max(0) as u64,
1166                    content_hash: hash_from_hex(&file_hash)?,
1167                },
1168                contribution,
1169                type_ref_names,
1170            });
1171        }
1172        Ok(records)
1173    }
1174
1175    pub fn delete_tier2_contribution(
1176        &self,
1177        category: InspectCategory,
1178        relative_file: &Path,
1179    ) -> Result<(), InspectCacheError> {
1180        self.verify_writer_lease()?;
1181        let conn = self
1182            .conn
1183            .lock()
1184            .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
1185        conn.execute(
1186            "DELETE FROM tier2_contributions WHERE category = ?1 AND project_key = ?2 AND file_path = ?3",
1187            params![
1188                category.as_str(),
1189                self.project_key,
1190                relative_file.to_string_lossy().to_string()
1191            ],
1192        )?;
1193        Ok(())
1194    }
1195
1196    pub fn update_content_fresh_metadata(
1197        &self,
1198        category: InspectCategory,
1199        relative_file: &Path,
1200        freshness: &FileFreshness,
1201    ) -> Result<(), InspectCacheError> {
1202        self.verify_writer_lease()?;
1203        let conn = self
1204            .conn
1205            .lock()
1206            .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
1207        conn.execute(
1208            "UPDATE tier2_contributions \
1209             SET file_mtime_ns = ?4, file_size = ?5, file_hash = ?6 \
1210             WHERE category = ?1 AND project_key = ?2 AND file_path = ?3",
1211            params![
1212                category.as_str(),
1213                self.project_key,
1214                relative_file.to_string_lossy().to_string(),
1215                system_time_to_ns(freshness.mtime),
1216                freshness.size as i64,
1217                hash_to_hex(freshness.content_hash),
1218            ],
1219        )?;
1220        Ok(())
1221    }
1222
1223    pub(crate) fn contribution_freshness(
1224        &self,
1225        category: InspectCategory,
1226    ) -> Result<Vec<(PathBuf, FileFreshness)>, InspectCacheError> {
1227        self.refresh_read_marker()?;
1228        let conn = self
1229            .conn
1230            .lock()
1231            .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
1232        let mut stmt = conn.prepare(
1233            "SELECT file_path, file_mtime_ns, file_size, file_hash \
1234             FROM tier2_contributions \
1235             WHERE category = ?1 AND project_key = ?2 \
1236             ORDER BY file_path ASC",
1237        )?;
1238        let rows = stmt.query_map(params![category.as_str(), self.project_key], |row| {
1239            Ok((
1240                row.get::<_, String>(0)?,
1241                row.get::<_, i64>(1)?,
1242                row.get::<_, i64>(2)?,
1243                row.get::<_, String>(3)?,
1244            ))
1245        })?;
1246
1247        let mut records = Vec::new();
1248        for row in rows {
1249            let (file_path, mtime_ns, file_size, file_hash) = row?;
1250            records.push((
1251                PathBuf::from(file_path),
1252                FileFreshness {
1253                    mtime: ns_to_system_time(mtime_ns),
1254                    size: file_size.max(0) as u64,
1255                    content_hash: hash_from_hex(&file_hash)?,
1256                },
1257            ));
1258        }
1259        Ok(records)
1260    }
1261
1262    pub fn contribution_set_hash(
1263        &self,
1264        category: InspectCategory,
1265    ) -> Result<String, InspectCacheError> {
1266        self.contribution_set_hash_with_config(category, None)
1267    }
1268
1269    pub fn contribution_set_hash_for_config(
1270        &self,
1271        category: InspectCategory,
1272        config: &Config,
1273    ) -> Result<String, InspectCacheError> {
1274        self.contribution_set_hash_with_config(category, Some(config))
1275    }
1276
1277    fn contribution_set_hash_with_config(
1278        &self,
1279        category: InspectCategory,
1280        config: Option<&Config>,
1281    ) -> Result<String, InspectCacheError> {
1282        self.refresh_read_marker()?;
1283        let conn = self
1284            .conn
1285            .lock()
1286            .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
1287        contribution_set_hash_with_conn(
1288            &conn,
1289            category,
1290            &self.project_key,
1291            &self.project_root,
1292            config,
1293        )
1294    }
1295
1296    pub fn last_full_run(
1297        &self,
1298        category: InspectCategory,
1299    ) -> Result<Option<i64>, InspectCacheError> {
1300        self.refresh_read_marker()?;
1301        let conn = self
1302            .conn
1303            .lock()
1304            .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
1305        conn.query_row(
1306            "SELECT last_full_run FROM tier2_meta WHERE category = ?1 AND project_key = ?2",
1307            params![category.as_str(), self.project_key],
1308            |row| row.get::<_, i64>(0),
1309        )
1310        .optional()
1311        .map_err(InspectCacheError::from)
1312    }
1313
1314    pub fn memory_generated_at(&self, key: &JobKey) -> Result<Option<i64>, InspectCacheError> {
1315        self.refresh_read_marker()?;
1316        Ok(self
1317            .memory
1318            .read()
1319            .map_err(|_| InspectCacheError::LockPoisoned("memory"))?
1320            .get(key)
1321            .map(|entry| entry.generated_at))
1322    }
1323}
1324
1325impl ReadonlyInspectCache {
1326    fn from_inner(inner: InspectCache) -> Self {
1327        Self { inner }
1328    }
1329
1330    fn into_inner(self) -> InspectCache {
1331        self.inner
1332    }
1333
1334    pub fn project_root(&self) -> &Path {
1335        self.inner.project_root()
1336    }
1337
1338    pub fn project_key(&self) -> &str {
1339        self.inner.project_key()
1340    }
1341
1342    pub fn sqlite_path(&self) -> &Path {
1343        self.inner.sqlite_path()
1344    }
1345
1346    pub fn get_aggregated_for_config(
1347        &self,
1348        key: &JobKey,
1349        config: &Config,
1350    ) -> Result<Option<serde_json::Value>, InspectCacheError> {
1351        self.inner.get_aggregated_for_config(key, config)
1352    }
1353
1354    pub fn latest_aggregate_any_hash(
1355        &self,
1356        category: InspectCategory,
1357    ) -> Result<Option<serde_json::Value>, InspectCacheError> {
1358        self.inner.latest_aggregate_any_hash(category)
1359    }
1360
1361    pub fn contribution_freshness(
1362        &self,
1363        category: InspectCategory,
1364    ) -> Result<Vec<(PathBuf, FileFreshness)>, InspectCacheError> {
1365        self.inner.contribution_freshness(category)
1366    }
1367
1368    pub fn load_tier2_contributions(
1369        &self,
1370        category: InspectCategory,
1371    ) -> Result<Vec<ContributionRecord>, InspectCacheError> {
1372        self.inner.load_tier2_contributions(category)
1373    }
1374
1375    pub fn contribution_set_hash_for_config(
1376        &self,
1377        category: InspectCategory,
1378        config: &Config,
1379    ) -> Result<String, InspectCacheError> {
1380        self.inner
1381            .contribution_set_hash_for_config(category, config)
1382    }
1383
1384    pub fn last_full_run(
1385        &self,
1386        category: InspectCategory,
1387    ) -> Result<Option<i64>, InspectCacheError> {
1388        self.inner.last_full_run(category)
1389    }
1390}
1391
1392impl InspectCacheRead for InspectCache {
1393    fn get_aggregated_for_config(
1394        &self,
1395        key: &JobKey,
1396        config: &Config,
1397    ) -> Result<Option<serde_json::Value>, InspectCacheError> {
1398        InspectCache::get_aggregated_for_config(self, key, config)
1399    }
1400    fn latest_aggregate_any_hash(
1401        &self,
1402        category: InspectCategory,
1403    ) -> Result<Option<serde_json::Value>, InspectCacheError> {
1404        InspectCache::latest_aggregate_any_hash(self, category)
1405    }
1406    fn contribution_freshness(
1407        &self,
1408        category: InspectCategory,
1409    ) -> Result<Vec<(PathBuf, FileFreshness)>, InspectCacheError> {
1410        InspectCache::contribution_freshness(self, category)
1411    }
1412    fn load_tier2_contributions(
1413        &self,
1414        category: InspectCategory,
1415    ) -> Result<Vec<ContributionRecord>, InspectCacheError> {
1416        InspectCache::load_tier2_contributions(self, category)
1417    }
1418    fn contribution_set_hash_for_config(
1419        &self,
1420        category: InspectCategory,
1421        config: &Config,
1422    ) -> Result<String, InspectCacheError> {
1423        InspectCache::contribution_set_hash_for_config(self, category, config)
1424    }
1425    fn last_full_run(&self, category: InspectCategory) -> Result<Option<i64>, InspectCacheError> {
1426        InspectCache::last_full_run(self, category)
1427    }
1428}
1429
1430impl<T: InspectCacheRead + ?Sized> InspectCacheRead for Arc<T> {
1431    fn get_aggregated_for_config(
1432        &self,
1433        key: &JobKey,
1434        config: &Config,
1435    ) -> Result<Option<serde_json::Value>, InspectCacheError> {
1436        (**self).get_aggregated_for_config(key, config)
1437    }
1438    fn latest_aggregate_any_hash(
1439        &self,
1440        category: InspectCategory,
1441    ) -> Result<Option<serde_json::Value>, InspectCacheError> {
1442        (**self).latest_aggregate_any_hash(category)
1443    }
1444    fn contribution_freshness(
1445        &self,
1446        category: InspectCategory,
1447    ) -> Result<Vec<(PathBuf, FileFreshness)>, InspectCacheError> {
1448        (**self).contribution_freshness(category)
1449    }
1450    fn load_tier2_contributions(
1451        &self,
1452        category: InspectCategory,
1453    ) -> Result<Vec<ContributionRecord>, InspectCacheError> {
1454        (**self).load_tier2_contributions(category)
1455    }
1456    fn contribution_set_hash_for_config(
1457        &self,
1458        category: InspectCategory,
1459        config: &Config,
1460    ) -> Result<String, InspectCacheError> {
1461        (**self).contribution_set_hash_for_config(category, config)
1462    }
1463    fn last_full_run(&self, category: InspectCategory) -> Result<Option<i64>, InspectCacheError> {
1464        (**self).last_full_run(category)
1465    }
1466}
1467
1468impl InspectCacheRead for ReadonlyInspectCache {
1469    fn get_aggregated_for_config(
1470        &self,
1471        key: &JobKey,
1472        config: &Config,
1473    ) -> Result<Option<serde_json::Value>, InspectCacheError> {
1474        self.get_aggregated_for_config(key, config)
1475    }
1476    fn latest_aggregate_any_hash(
1477        &self,
1478        category: InspectCategory,
1479    ) -> Result<Option<serde_json::Value>, InspectCacheError> {
1480        self.latest_aggregate_any_hash(category)
1481    }
1482    fn contribution_freshness(
1483        &self,
1484        category: InspectCategory,
1485    ) -> Result<Vec<(PathBuf, FileFreshness)>, InspectCacheError> {
1486        self.contribution_freshness(category)
1487    }
1488    fn load_tier2_contributions(
1489        &self,
1490        category: InspectCategory,
1491    ) -> Result<Vec<ContributionRecord>, InspectCacheError> {
1492        self.load_tier2_contributions(category)
1493    }
1494    fn contribution_set_hash_for_config(
1495        &self,
1496        category: InspectCategory,
1497        config: &Config,
1498    ) -> Result<String, InspectCacheError> {
1499        self.contribution_set_hash_for_config(category, config)
1500    }
1501    fn last_full_run(&self, category: InspectCategory) -> Result<Option<i64>, InspectCacheError> {
1502        self.last_full_run(category)
1503    }
1504}
1505
1506fn project_inspect_dir(inspect_dir: PathBuf, project_key: &str) -> PathBuf {
1507    if inspect_dir
1508        .file_name()
1509        .and_then(|name| name.to_str())
1510        .is_some_and(|name| name == project_key)
1511    {
1512        inspect_dir
1513    } else {
1514        inspect_dir.join(project_key)
1515    }
1516}
1517
1518fn resolve_or_create_inspect_target(
1519    inspect_dir: &Path,
1520    project_key: &str,
1521) -> (PathBuf, Option<String>, bool) {
1522    if let Some((path, generation)) = resolve_inspect_target(inspect_dir, project_key) {
1523        return (path, generation, false);
1524    }
1525    let generation = inspect_generation_file_name(project_key);
1526    (inspect_dir.join(&generation), Some(generation), true)
1527}
1528
1529fn resolve_inspect_target(
1530    inspect_dir: &Path,
1531    project_key: &str,
1532) -> Option<(PathBuf, Option<String>)> {
1533    for _ in 0..5 {
1534        if let Some(generation) = read_inspect_pointer(inspect_dir, project_key) {
1535            let path = inspect_dir.join(&generation);
1536            if path.is_file() {
1537                return Some((path, Some(generation)));
1538            }
1539            std::thread::sleep(Duration::from_millis(5));
1540            continue;
1541        }
1542        let legacy = inspect_legacy_sqlite_path(inspect_dir, project_key);
1543        return legacy.is_file().then_some((legacy, None));
1544    }
1545    None
1546}
1547
1548fn inspect_generation_file_name(project_key: &str) -> String {
1549    format!(
1550        "{project_key}.g{}.{}.sqlite",
1551        now_nanos(),
1552        std::process::id()
1553    )
1554}
1555
1556fn inspect_pointer_path(inspect_dir: &Path, project_key: &str) -> PathBuf {
1557    inspect_dir.join(format!("{project_key}.current"))
1558}
1559
1560fn inspect_legacy_sqlite_path(inspect_dir: &Path, project_key: &str) -> PathBuf {
1561    inspect_dir.join(format!("{project_key}.sqlite"))
1562}
1563
1564fn read_inspect_pointer(inspect_dir: &Path, project_key: &str) -> Option<String> {
1565    let text = std::fs::read_to_string(inspect_pointer_path(inspect_dir, project_key)).ok()?;
1566    let name = text.trim();
1567    (!name.is_empty()).then(|| name.to_string())
1568}
1569
1570fn publish_inspect_pointer(
1571    inspect_dir: &Path,
1572    project_key: &str,
1573    generation: &str,
1574) -> Result<(), InspectCacheError> {
1575    let pointer = inspect_pointer_path(inspect_dir, project_key);
1576    let tmp = inspect_dir.join(format!(
1577        "{project_key}.current.tmp.{}.{}",
1578        std::process::id(),
1579        now_nanos()
1580    ));
1581    {
1582        use std::io::Write as _;
1583        let mut file = std::fs::File::create(&tmp)?;
1584        file.write_all(generation.as_bytes())?;
1585        file.write_all(b"\n")?;
1586        file.sync_all()?;
1587    }
1588    if let Err(error) = crate::fs_lock::rename_over(&tmp, &pointer) {
1589        let _ = std::fs::remove_file(&tmp);
1590        return Err(error.into());
1591    }
1592    crate::fs_lock::sync_parent(&pointer);
1593    gc_old_inspect_generations(inspect_dir, project_key, generation);
1594    Ok(())
1595}
1596
1597fn gc_old_inspect_generations(inspect_dir: &Path, project_key: &str, current: &str) {
1598    let Ok(entries) = std::fs::read_dir(inspect_dir) else {
1599        return;
1600    };
1601    let prefix = format!("{project_key}.g");
1602    for entry in entries.flatten() {
1603        let name = entry.file_name().to_string_lossy().to_string();
1604        if name == current || !name.starts_with(&prefix) || !name.ends_with(".sqlite") {
1605            continue;
1606        }
1607        let path = entry.path();
1608        let _ = std::fs::remove_file(&path);
1609        for suffix in INSPECT_SQLITE_SIDECAR_SUFFIXES {
1610            let _ = std::fs::remove_file(PathBuf::from(format!("{}{suffix}", path.display())));
1611        }
1612    }
1613}
1614
1615#[derive(Clone, Copy, Debug, Default)]
1616pub(crate) struct InspectScopeSweepSummary {
1617    removed: usize,
1618    bytes: u64,
1619    skipped_live: usize,
1620    skipped_marker: usize,
1621    scanned: usize,
1622    budget_exhausted: bool,
1623}
1624
1625#[derive(Clone, Copy, Debug, Default)]
1626struct InspectScopeFileStats {
1627    newest_file: Option<SystemTime>,
1628    bytes: u64,
1629}
1630
1631enum InspectScopeWalk {
1632    Complete(InspectScopeFileStats),
1633    BudgetExceeded,
1634    Failed,
1635}
1636
1637#[derive(Clone, Copy, Debug, Default)]
1638enum InspectScopeCandidateResult {
1639    #[default]
1640    Processed,
1641    Removed {
1642        bytes: u64,
1643    },
1644    SkippedLive,
1645    SkippedMarker,
1646    BudgetExceeded,
1647}
1648
1649/// Process-wide inspect-scope GC. The caller supplies the scope keys currently
1650/// bound in this process; the cursor lets the same publication cadence resume
1651/// after a large or slow first-level directory exceeds its wall-clock budget.
1652pub(crate) fn sweep_inspect_scope_dirs(
1653    inspect_root: &Path,
1654    live_scope_keys: &HashSet<String>,
1655) -> InspectScopeSweepSummary {
1656    sweep_inspect_scope_dirs_with_limits(
1657        inspect_root,
1658        live_scope_keys,
1659        INSPECT_SCOPE_SWEEP_BUDGET,
1660        usize::MAX,
1661    )
1662}
1663
1664fn sweep_inspect_scope_dirs_with_limits(
1665    inspect_root: &Path,
1666    live_scope_keys: &HashSet<String>,
1667    wall_clock_budget: Duration,
1668    entry_limit: usize,
1669) -> InspectScopeSweepSummary {
1670    let started = Instant::now();
1671    let deadline = started + wall_clock_budget;
1672    let mut summary = InspectScopeSweepSummary::default();
1673    let mut entries = match fs::read_dir(inspect_root) {
1674        Ok(entries) => entries
1675            .filter_map(Result::ok)
1676            .filter_map(|entry| {
1677                let file_type = entry.file_type().ok()?;
1678                file_type.is_dir().then(|| {
1679                    (
1680                        entry.file_name().to_string_lossy().to_string(),
1681                        entry.path(),
1682                    )
1683                })
1684            })
1685            .collect::<Vec<_>>(),
1686        Err(_) => Vec::new(),
1687    };
1688    entries.sort_by(|left, right| left.0.cmp(&right.0));
1689
1690    let cursor_store = INSPECT_SCOPE_SWEEP_CURSORS.get_or_init(|| Mutex::new(HashMap::new()));
1691    let last_name = cursor_store.lock().ok().and_then(|cursors| {
1692        cursors
1693            .get(inspect_root)
1694            .and_then(|cursor| cursor.last_name.clone())
1695    });
1696    let start_index = last_name
1697        .as_deref()
1698        .and_then(|last| entries.iter().position(|(name, _)| name.as_str() > last))
1699        .unwrap_or(0);
1700    if start_index > 0 {
1701        entries.rotate_left(start_index);
1702    }
1703
1704    let mut cursor_name = last_name;
1705    for (processed, (name, path)) in entries.into_iter().enumerate() {
1706        if processed >= entry_limit || started.elapsed() >= wall_clock_budget {
1707            summary.budget_exhausted = true;
1708            break;
1709        }
1710        match inspect_scope_candidate(&path, &name, live_scope_keys, deadline) {
1711            InspectScopeCandidateResult::BudgetExceeded => {
1712                summary.budget_exhausted = true;
1713                break;
1714            }
1715            InspectScopeCandidateResult::Processed => {}
1716            InspectScopeCandidateResult::Removed { bytes } => {
1717                summary.removed += 1;
1718                summary.bytes = summary.bytes.saturating_add(bytes);
1719            }
1720            InspectScopeCandidateResult::SkippedLive => summary.skipped_live += 1,
1721            InspectScopeCandidateResult::SkippedMarker => summary.skipped_marker += 1,
1722        }
1723        summary.scanned += 1;
1724        cursor_name = Some(name);
1725    }
1726
1727    if let Ok(mut cursors) = cursor_store.lock() {
1728        let cursor = cursors.entry(inspect_root.to_path_buf()).or_default();
1729        cursor.last_name = summary.budget_exhausted.then_some(cursor_name).flatten();
1730        if !summary.budget_exhausted {
1731            cursor.last_name = None;
1732        }
1733    }
1734
1735    if summary.removed > 0 {
1736        crate::fs_lock::sync_parent(inspect_root);
1737    }
1738    crate::slog_info!(
1739        "inspect scope cache sweep root={} removed={} bytes={} skipped_live={} skipped_marker={} scanned={} budget_exhausted={}",
1740        inspect_root.display(),
1741        summary.removed,
1742        summary.bytes,
1743        summary.skipped_live,
1744        summary.skipped_marker,
1745        summary.scanned,
1746        summary.budget_exhausted
1747    );
1748    summary
1749}
1750
1751fn inspect_scope_candidate(
1752    scope_dir: &Path,
1753    scope_name: &str,
1754    live_scope_keys: &HashSet<String>,
1755    deadline: Instant,
1756) -> InspectScopeCandidateResult {
1757    if live_scope_keys.contains(scope_name) {
1758        return InspectScopeCandidateResult::SkippedLive;
1759    }
1760
1761    let stats = match inspect_scope_file_stats(scope_dir, deadline) {
1762        InspectScopeWalk::Complete(stats) => stats,
1763        InspectScopeWalk::BudgetExceeded => return InspectScopeCandidateResult::BudgetExceeded,
1764        InspectScopeWalk::Failed => return InspectScopeCandidateResult::Processed,
1765    };
1766    let Some(newest_file) = stats.newest_file else {
1767        return InspectScopeCandidateResult::Processed;
1768    };
1769    let now = SystemTime::now();
1770    if now.duration_since(newest_file).unwrap_or(Duration::ZERO) < INSPECT_SCOPE_MIN_AGE {
1771        return InspectScopeCandidateResult::Processed;
1772    }
1773
1774    if crate::root_cache::sweep_all_read_markers(scope_dir).protected {
1775        return InspectScopeCandidateResult::SkippedMarker;
1776    }
1777
1778    match fs::remove_dir_all(scope_dir) {
1779        Ok(()) => InspectScopeCandidateResult::Removed { bytes: stats.bytes },
1780        Err(error) if error.kind() == std::io::ErrorKind::NotFound && !scope_dir.exists() => {
1781            InspectScopeCandidateResult::Removed { bytes: stats.bytes }
1782        }
1783        // A pinned directory (notably ERROR_SHARING_VIOLATION on Windows) is
1784        // left for the next cursor pass; all deletion errors are best effort.
1785        Err(_) => InspectScopeCandidateResult::Processed,
1786    }
1787}
1788
1789fn inspect_scope_file_stats(scope_dir: &Path, deadline: Instant) -> InspectScopeWalk {
1790    if Instant::now() >= deadline {
1791        return InspectScopeWalk::BudgetExceeded;
1792    }
1793    let entries = match fs::read_dir(scope_dir) {
1794        Ok(entries) => entries,
1795        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1796            return InspectScopeWalk::Complete(InspectScopeFileStats::default())
1797        }
1798        Err(_) => return InspectScopeWalk::Failed,
1799    };
1800    let mut stats = InspectScopeFileStats::default();
1801    for entry in entries {
1802        if Instant::now() >= deadline {
1803            return InspectScopeWalk::BudgetExceeded;
1804        }
1805        let entry = match entry {
1806            Ok(entry) => entry,
1807            Err(_) => return InspectScopeWalk::Failed,
1808        };
1809        let name = entry.file_name();
1810        if name == "readers" {
1811            // Marker heartbeats describe readers, not cache freshness. Marker
1812            // liveness is checked separately after the age predicate.
1813            continue;
1814        }
1815        let file_type = match entry.file_type() {
1816            Ok(file_type) => file_type,
1817            Err(_) => return InspectScopeWalk::Failed,
1818        };
1819        if file_type.is_dir() {
1820            match inspect_scope_file_stats(&entry.path(), deadline) {
1821                InspectScopeWalk::Complete(child) => merge_scope_file_stats(&mut stats, child),
1822                other => return other,
1823            }
1824            continue;
1825        }
1826        if !file_type.is_file() {
1827            continue;
1828        }
1829        let metadata = match entry.metadata() {
1830            Ok(metadata) => metadata,
1831            Err(_) => return InspectScopeWalk::Failed,
1832        };
1833        stats.bytes = stats.bytes.saturating_add(metadata.len());
1834        let Some(modified) = metadata.modified().ok() else {
1835            return InspectScopeWalk::Failed;
1836        };
1837        if stats.newest_file.is_none_or(|newest| modified > newest) {
1838            stats.newest_file = Some(modified);
1839        }
1840    }
1841    InspectScopeWalk::Complete(stats)
1842}
1843
1844fn merge_scope_file_stats(stats: &mut InspectScopeFileStats, child: InspectScopeFileStats) {
1845    stats.bytes = stats.bytes.saturating_add(child.bytes);
1846    if child.newest_file > stats.newest_file {
1847        stats.newest_file = child.newest_file;
1848    }
1849}
1850
1851#[cfg(test)]
1852fn reset_inspect_scope_sweep_cursor_for_test() {
1853    if let Some(cursors) = INSPECT_SCOPE_SWEEP_CURSORS.get() {
1854        cursors.lock().unwrap().clear();
1855    }
1856}
1857
1858fn acquire_writer_lease(
1859    inspect_dir: &Path,
1860    project_key: &str,
1861    project_root: &Path,
1862) -> Result<Option<Arc<crate::root_cache::WriterLease>>, InspectCacheError> {
1863    crate::root_cache::WriterLease::acquire_shared(
1864        crate::root_cache::RootCacheDomain::Inspect,
1865        inspect_dir,
1866        project_key,
1867        project_root,
1868    )
1869    .map_err(|error| InspectCacheError::Io(std::io::Error::other(error.to_string())))
1870}
1871
1872fn open_readonly_connection(path: &Path) -> Result<Connection, InspectCacheError> {
1873    let uri = sqlite_readonly_uri(path);
1874    let conn = Connection::open_with_flags(
1875        &uri,
1876        OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
1877    )?;
1878    conn.pragma_update(None, "synchronous", "NORMAL")?;
1879    conn.busy_timeout(reader_busy_timeout())?;
1880    conn.execute_batch("PRAGMA query_only=ON;")?;
1881    Ok(conn)
1882}
1883
1884fn reader_busy_timeout() -> Duration {
1885    let jitter = (now_nanos() % 500) as u64;
1886    Duration::from_millis(250 + jitter)
1887}
1888
1889fn sqlite_readonly_uri(path: &Path) -> String {
1890    let raw = path.to_string_lossy().replace('\\', "/");
1891    let encoded = percent_encode_sqlite_uri_path(&raw);
1892    if raw.starts_with('/') {
1893        format!("file://{encoded}?mode=ro")
1894    } else if raw.as_bytes().get(1) == Some(&b':') {
1895        format!("file:///{encoded}?mode=ro")
1896    } else {
1897        format!("file:{encoded}?mode=ro")
1898    }
1899}
1900
1901fn percent_encode_sqlite_uri_path(path: &str) -> String {
1902    let mut encoded = String::with_capacity(path.len());
1903    for byte in path.bytes() {
1904        match byte {
1905            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' | b':' => {
1906                encoded.push(byte as char)
1907            }
1908            _ => encoded.push_str(&format!("%{byte:02X}")),
1909        }
1910    }
1911    encoded
1912}
1913
1914fn configure_connection(conn: &Connection) -> Result<(), InspectCacheError> {
1915    conn.pragma_update(None, "journal_mode", "WAL")?;
1916    // Inspect facts are fully recomputable, so NORMAL avoids a full fsync per
1917    // commit while WAL transactions and writer-lease publication stay atomic.
1918    conn.pragma_update(None, "synchronous", "NORMAL")?;
1919    conn.pragma_update(None, "busy_timeout", 5_000)?;
1920    Ok(())
1921}
1922
1923fn initialize_schema(conn: &Connection) -> Result<(), InspectCacheError> {
1924    conn.execute_batch(
1925        "CREATE TABLE IF NOT EXISTS tier2_contributions (
1926            category        TEXT NOT NULL,
1927            project_key     TEXT NOT NULL,
1928            file_path       TEXT NOT NULL,
1929            file_mtime_ns   INTEGER NOT NULL,
1930            file_size       INTEGER NOT NULL,
1931            file_hash       TEXT NOT NULL,
1932            contribution    BLOB NOT NULL,
1933            generated_at    INTEGER NOT NULL,
1934            PRIMARY KEY (category, project_key, file_path)
1935        );
1936
1937        CREATE TABLE IF NOT EXISTS tier2_aggregates (
1938            category        TEXT NOT NULL,
1939            project_key     TEXT NOT NULL,
1940            contribution_set_hash TEXT NOT NULL,
1941            aggregate       BLOB NOT NULL,
1942            generated_at    INTEGER NOT NULL,
1943            PRIMARY KEY (category, project_key)
1944        );
1945
1946        CREATE TABLE IF NOT EXISTS tier2_meta (
1947            category        TEXT NOT NULL,
1948            project_key     TEXT NOT NULL,
1949            last_full_run   INTEGER NOT NULL,
1950            PRIMARY KEY (category, project_key)
1951        );",
1952    )?;
1953    Ok(())
1954}
1955
1956fn existing_contribution_paths(
1957    conn: &Connection,
1958    category: InspectCategory,
1959    project_key: &str,
1960) -> Result<Vec<String>, InspectCacheError> {
1961    let mut stmt = conn.prepare(
1962        "SELECT file_path FROM tier2_contributions WHERE category = ?1 AND project_key = ?2",
1963    )?;
1964    let rows = stmt.query_map(params![category.as_str(), project_key], |row| {
1965        row.get::<_, String>(0)
1966    })?;
1967    rows.collect::<Result<Vec<_>, _>>()
1968        .map_err(InspectCacheError::from)
1969}
1970
1971fn contribution_set_hash_with_conn(
1972    conn: &Connection,
1973    category: InspectCategory,
1974    project_key: &str,
1975    project_root: &Path,
1976    config: Option<&Config>,
1977) -> Result<String, InspectCacheError> {
1978    let mut stmt = conn.prepare(
1979        "SELECT file_path, file_hash FROM tier2_contributions \
1980         WHERE category = ?1 AND project_key = ?2 ORDER BY file_path ASC",
1981    )?;
1982    let rows = stmt.query_map(params![category.as_str(), project_key], |row| {
1983        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1984    })?;
1985
1986    let mut hasher = blake3::Hasher::new();
1987    hasher.update(b"tier2-contributions\0");
1988    hasher.update(&TIER2_CONTRIBUTION_CACHE_VERSION.to_le_bytes());
1989    hasher.update(b"\0");
1990    for row in rows {
1991        let (file_path, file_hash) = row?;
1992        hasher.update(file_path.as_bytes());
1993        hasher.update(b"\0");
1994        hasher.update(file_hash.as_bytes());
1995        hasher.update(b"\0");
1996    }
1997    update_manifest_fingerprint_hash(&mut hasher, project_root)?;
1998    if matches!(
1999        category,
2000        InspectCategory::DeadCode | InspectCategory::UnusedExports | InspectCategory::Cycles
2001    ) {
2002        update_resolver_config_fingerprint_hash(&mut hasher, project_root)?;
2003    }
2004    update_inspect_config_fingerprint_hash(&mut hasher, category, config);
2005    Ok(hasher.finalize().to_hex().to_string())
2006}
2007
2008fn update_inspect_config_fingerprint_hash(
2009    hasher: &mut blake3::Hasher,
2010    category: InspectCategory,
2011    config: Option<&Config>,
2012) {
2013    if category != InspectCategory::Duplicates {
2014        return;
2015    }
2016
2017    hasher.update(b"inspect.duplicates.expected_mirrors\0");
2018    let Some(config) = config else {
2019        return;
2020    };
2021    for pair in &config.inspect.duplicates.expected_mirrors {
2022        hasher.update(pair[0].as_bytes());
2023        hasher.update(b"\0");
2024        hasher.update(pair[1].as_bytes());
2025        hasher.update(b"\0");
2026    }
2027}
2028
2029fn update_resolver_config_fingerprint_hash(
2030    hasher: &mut blake3::Hasher,
2031    project_root: &Path,
2032) -> Result<(), InspectCacheError> {
2033    let manifest_root =
2034        fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
2035    hasher.update(b"ts-js-resolver-configs\0");
2036    for config in collect_resolver_config_dependency_files(project_root) {
2037        let relative_path = config
2038            .strip_prefix(&manifest_root)
2039            .unwrap_or(config.as_path())
2040            .to_string_lossy()
2041            .replace('\\', "/");
2042        let content_hash = blake3::hash(&fs::read(&config)?);
2043        hasher.update(relative_path.as_bytes());
2044        hasher.update(b"\0");
2045        hasher.update(content_hash.as_bytes());
2046        hasher.update(b"\0");
2047    }
2048    Ok(())
2049}
2050
2051struct ResolverConfigDependency {
2052    path: PathBuf,
2053    follow_extends: bool,
2054}
2055
2056impl ResolverConfigDependency {
2057    fn resolver_config(path: PathBuf) -> Self {
2058        Self {
2059            path,
2060            follow_extends: true,
2061        }
2062    }
2063
2064    fn hashed_file(path: PathBuf) -> Self {
2065        Self {
2066            path,
2067            follow_extends: false,
2068        }
2069    }
2070}
2071
2072fn collect_resolver_config_dependency_files(project_root: &Path) -> BTreeSet<PathBuf> {
2073    let mut configs = walk_resolver_config_files(project_root);
2074    let mut pending = configs.iter().cloned().collect::<Vec<_>>();
2075    let mut queued = configs.clone();
2076    while let Some(config) = pending.pop() {
2077        for dependency in resolver_config_extends_targets(&config, project_root) {
2078            let ResolverConfigDependency {
2079                path,
2080                follow_extends,
2081            } = dependency;
2082            configs.insert(path.clone());
2083            if follow_extends && queued.insert(path.clone()) {
2084                pending.push(path);
2085            }
2086        }
2087    }
2088    configs
2089}
2090
2091fn walk_resolver_config_files(project_root: &Path) -> BTreeSet<PathBuf> {
2092    let walker = ignore::WalkBuilder::new(project_root)
2093        .hidden(true)
2094        .git_ignore(true)
2095        .git_global(true)
2096        .git_exclude(true)
2097        .add_custom_ignore_filename(".aftignore")
2098        .filter_entry(|entry| {
2099            let name = entry.file_name().to_string_lossy();
2100            if entry
2101                .file_type()
2102                .is_some_and(|file_type| file_type.is_dir())
2103            {
2104                return !matches!(
2105                    name.as_ref(),
2106                    "node_modules"
2107                        | "target"
2108                        | "venv"
2109                        | ".venv"
2110                        | ".git"
2111                        | "__pycache__"
2112                        | ".tox"
2113                        | "dist"
2114                        | "build"
2115                );
2116            }
2117            true
2118        })
2119        .build();
2120
2121    walker
2122        .filter_map(Result::ok)
2123        .filter(|entry| {
2124            entry
2125                .file_type()
2126                .is_some_and(|file_type| file_type.is_file())
2127        })
2128        .map(|entry| entry.into_path())
2129        .filter(|path| {
2130            path.file_name()
2131                .and_then(|name| name.to_str())
2132                .is_some_and(is_resolver_config_file_name)
2133        })
2134        .filter_map(canonical_file_path)
2135        .collect()
2136}
2137
2138fn is_resolver_config_file_name(name: &str) -> bool {
2139    name == "tsconfig.json"
2140        || name == "jsconfig.json"
2141        || ((name.starts_with("tsconfig.") || name.starts_with("jsconfig."))
2142            && name.ends_with(".json"))
2143}
2144
2145fn resolver_config_extends_targets(
2146    config: &Path,
2147    project_root: &Path,
2148) -> Vec<ResolverConfigDependency> {
2149    let Ok(source) = fs::read_to_string(config) else {
2150        return Vec::new();
2151    };
2152    let Ok(value) = parse_resolver_config_json(&source) else {
2153        return Vec::new();
2154    };
2155
2156    let mut specs = Vec::new();
2157    collect_extends_specs(value.get("extends"), &mut specs);
2158    specs
2159        .into_iter()
2160        .flat_map(|spec| resolve_resolver_config_extends(config, project_root, spec))
2161        .collect()
2162}
2163
2164fn parse_resolver_config_json(source: &str) -> Result<serde_json::Value, serde_json::Error> {
2165    serde_json::from_str(source).or_else(|_| serde_json::from_str(&strip_jsonc(source)))
2166}
2167
2168fn collect_extends_specs<'a>(value: Option<&'a serde_json::Value>, specs: &mut Vec<&'a str>) {
2169    match value {
2170        Some(serde_json::Value::String(spec)) => specs.push(spec),
2171        Some(serde_json::Value::Array(values)) => {
2172            for value in values {
2173                collect_extends_specs(Some(value), specs);
2174            }
2175        }
2176        _ => {}
2177    }
2178}
2179
2180fn resolve_resolver_config_extends(
2181    config: &Path,
2182    project_root: &Path,
2183    spec: &str,
2184) -> Vec<ResolverConfigDependency> {
2185    let config_dir = config.parent().unwrap_or(project_root);
2186    let spec_path = Path::new(spec);
2187    if spec_path.is_absolute() || spec.starts_with('.') {
2188        return resolver_config_extends_target(&config_dir.join(spec_path))
2189            .map(ResolverConfigDependency::resolver_config)
2190            .into_iter()
2191            .collect();
2192    }
2193
2194    node_modules_resolver_config_dependencies(config_dir, project_root, spec)
2195}
2196
2197fn node_modules_resolver_config_dependencies(
2198    config_dir: &Path,
2199    project_root: &Path,
2200    spec: &str,
2201) -> Vec<ResolverConfigDependency> {
2202    let boundary = fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
2203    let config_dir = fs::canonicalize(config_dir).unwrap_or_else(|_| config_dir.to_path_buf());
2204    let enforce_project_boundary = config_dir.starts_with(&boundary);
2205    let is_bare_package = is_bare_package_extends_spec(spec);
2206    let mut dependencies = Vec::new();
2207    for ancestor in config_dir.ancestors() {
2208        let ancestor = fs::canonicalize(ancestor).unwrap_or_else(|_| ancestor.to_path_buf());
2209        if enforce_project_boundary && !ancestor.starts_with(&boundary) {
2210            break;
2211        }
2212        let package_dir = ancestor.join("node_modules").join(spec);
2213        let mut ancestor_dependencies = Vec::new();
2214        if is_bare_package {
2215            if let Some(mut package_dependencies) =
2216                package_json_resolver_config_dependencies(&package_dir)
2217            {
2218                let has_resolver_config = package_dependencies
2219                    .iter()
2220                    .any(|dependency| dependency.follow_extends);
2221                ancestor_dependencies.append(&mut package_dependencies);
2222                if has_resolver_config {
2223                    dependencies.extend(ancestor_dependencies);
2224                    return dependencies;
2225                }
2226            }
2227        }
2228        if let Some(target) = resolver_config_extends_target(&package_dir) {
2229            ancestor_dependencies.push(ResolverConfigDependency::resolver_config(target));
2230            dependencies.extend(ancestor_dependencies);
2231            return dependencies;
2232        }
2233        dependencies.extend(ancestor_dependencies);
2234    }
2235    dependencies
2236}
2237
2238fn package_json_resolver_config_dependencies(
2239    package_dir: &Path,
2240) -> Option<Vec<ResolverConfigDependency>> {
2241    let package_json = canonical_file_path(package_dir.join("package.json"))?;
2242    let package_root = package_json
2243        .parent()
2244        .map(Path::to_path_buf)
2245        .unwrap_or_else(|| package_dir.to_path_buf());
2246    let mut dependencies = vec![ResolverConfigDependency::hashed_file(package_json.clone())];
2247
2248    let Ok(source) = fs::read_to_string(&package_json) else {
2249        return Some(dependencies);
2250    };
2251    let Ok(value) = parse_resolver_config_json(&source) else {
2252        return Some(dependencies);
2253    };
2254    let selected_config = value
2255        .get("tsconfig")
2256        .and_then(serde_json::Value::as_str)
2257        .map(str::trim)
2258        .filter(|value| !value.is_empty())
2259        .unwrap_or("tsconfig.json");
2260    if let Some(target) = resolver_config_extends_target(&package_root.join(selected_config)) {
2261        dependencies.push(ResolverConfigDependency::resolver_config(target));
2262    }
2263
2264    Some(dependencies)
2265}
2266
2267fn is_bare_package_extends_spec(spec: &str) -> bool {
2268    let mut parts = spec.split('/').filter(|part| !part.is_empty());
2269    let Some(first) = parts.next() else {
2270        return false;
2271    };
2272    if first.starts_with('@') {
2273        parts.next().is_some() && parts.next().is_none()
2274    } else {
2275        parts.next().is_none()
2276    }
2277}
2278
2279fn resolver_config_extends_target(base: &Path) -> Option<PathBuf> {
2280    resolver_config_extends_candidates(base)
2281        .into_iter()
2282        .find_map(canonical_file_path)
2283}
2284
2285fn resolver_config_extends_candidates(base: &Path) -> Vec<PathBuf> {
2286    let mut candidates = vec![base.to_path_buf()];
2287    if base.extension().is_none() {
2288        candidates.push(base.with_extension("json"));
2289        candidates.push(base.join("tsconfig.json"));
2290    }
2291    candidates
2292}
2293
2294fn canonical_file_path(path: PathBuf) -> Option<PathBuf> {
2295    if !path.is_file() {
2296        return None;
2297    }
2298    Some(fs::canonicalize(&path).unwrap_or(path))
2299}
2300
2301fn update_manifest_fingerprint_hash(
2302    hasher: &mut blake3::Hasher,
2303    project_root: &Path,
2304) -> Result<(), InspectCacheError> {
2305    // Entry-point manifests are normalized snapshots. A bare canonical root is
2306    // verbatim (`\\?\`) on Windows, so mixing it with those non-verbatim
2307    // manifests makes `strip_prefix` hash an absolute path instead of its
2308    // project-relative name.
2309    let manifest_root = crate::inspect::job::canonicalize_normalized(project_root);
2310    hasher.update(b"entry-point-manifests\0");
2311    for manifest in super::entry_points::collect_entry_point_manifests(project_root) {
2312        let relative_path = manifest
2313            .strip_prefix(&manifest_root)
2314            .unwrap_or(manifest.as_path())
2315            .to_string_lossy()
2316            .replace('\\', "/");
2317        let content_hash = blake3::hash(&fs::read(&manifest)?);
2318        hasher.update(relative_path.as_bytes());
2319        hasher.update(b"\0");
2320        hasher.update(content_hash.as_bytes());
2321        hasher.update(b"\0");
2322    }
2323    Ok(())
2324}
2325
2326fn relative_string(project_root: &Path, path: &Path) -> String {
2327    if let Ok(relative) = path.strip_prefix(project_root) {
2328        return relative.to_string_lossy().to_string();
2329    }
2330
2331    if let (Ok(canonical_root), Ok(canonical_path)) =
2332        (fs::canonicalize(project_root), fs::canonicalize(path))
2333    {
2334        if let Ok(relative) = canonical_path.strip_prefix(canonical_root) {
2335            return relative.to_string_lossy().to_string();
2336        }
2337    }
2338
2339    path.to_string_lossy().to_string()
2340}
2341
2342fn system_time_to_ns(time: SystemTime) -> i64 {
2343    let nanos = time
2344        .duration_since(UNIX_EPOCH)
2345        .unwrap_or_else(|_| Duration::from_secs(0))
2346        .as_nanos();
2347    nanos.min(i64::MAX as u128) as i64
2348}
2349
2350fn ns_to_system_time(value: i64) -> SystemTime {
2351    UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
2352}
2353
2354fn hash_to_hex(hash: blake3::Hash) -> String {
2355    hash.to_hex().to_string()
2356}
2357
2358fn hash_from_hex(value: &str) -> Result<blake3::Hash, InspectCacheError> {
2359    if value.len() != 64 {
2360        return Err(InspectCacheError::InvalidHash(value.to_string()));
2361    }
2362    let mut bytes = [0u8; 32];
2363    for (index, chunk) in value.as_bytes().chunks(2).enumerate() {
2364        let hex = std::str::from_utf8(chunk)
2365            .map_err(|_| InspectCacheError::InvalidHash(value.to_string()))?;
2366        bytes[index] = u8::from_str_radix(hex, 16)
2367            .map_err(|_| InspectCacheError::InvalidHash(value.to_string()))?;
2368    }
2369    Ok(blake3::Hash::from_bytes(bytes))
2370}
2371
2372fn unix_seconds_now() -> i64 {
2373    SystemTime::now()
2374        .duration_since(UNIX_EPOCH)
2375        .unwrap_or_else(|_| Duration::from_secs(0))
2376        .as_secs()
2377        .min(i64::MAX as u64) as i64
2378}
2379
2380fn now_nanos() -> u128 {
2381    SystemTime::now()
2382        .duration_since(UNIX_EPOCH)
2383        .unwrap_or(Duration::ZERO)
2384        .as_nanos()
2385}
2386
2387#[cfg(test)]
2388mod tests {
2389    use super::*;
2390    use std::cell::Cell;
2391    use std::collections::HashSet;
2392    use std::fs;
2393    use std::path::{Path, PathBuf};
2394
2395    fn collect_freshness(path: &Path) -> FileFreshness {
2396        crate::cache_freshness::collect(path).unwrap()
2397    }
2398
2399    #[test]
2400    fn sqlite_readonly_uri_percent_encodes_windows_paths() {
2401        assert_eq!(
2402            sqlite_readonly_uri(Path::new(r"C:\Users\name with spaces\db#1.sqlite")),
2403            "file:///C:/Users/name%20with%20spaces/db%231.sqlite?mode=ro"
2404        );
2405    }
2406
2407    #[cfg(windows)]
2408    fn verbatim_path(path: &Path) -> PathBuf {
2409        PathBuf::from(format!(r"\\?\{}", path.display()))
2410    }
2411
2412    #[cfg(windows)]
2413    #[test]
2414    fn manifest_fingerprint_uses_relative_paths_for_verbatim_roots() {
2415        let temp = tempfile::tempdir().expect("create temporary project");
2416        let project = temp.path().join("project");
2417        fs::create_dir_all(&project).expect("create project directory");
2418        let manifest = project.join("package.json");
2419        let content = br#"{"name":"fixture"}"#;
2420        fs::write(&manifest, content).expect("write package manifest");
2421
2422        let normalized_project = crate::inspect::job::canonicalize_normalized(&project);
2423        let mut actual = blake3::Hasher::new();
2424        update_manifest_fingerprint_hash(&mut actual, &verbatim_path(&normalized_project))
2425            .expect("hash entry-point manifest");
2426
2427        let mut expected = blake3::Hasher::new();
2428        expected.update(b"entry-point-manifests\0");
2429        expected.update(b"package.json\0");
2430        expected.update(blake3::hash(content).as_bytes());
2431        expected.update(b"\0");
2432
2433        assert_eq!(actual.finalize(), expected.finalize());
2434    }
2435
2436    #[test]
2437    fn inspect_cache_writer_uses_normal_synchronous_mode() {
2438        let temp = tempfile::tempdir().unwrap();
2439        let project_root = temp.path().join("checkout");
2440        fs::create_dir_all(&project_root).unwrap();
2441        let cache = InspectCache::open(temp.path().join("inspect"), project_root).unwrap();
2442        let conn = cache.conn.lock().unwrap();
2443        let synchronous: i64 = conn
2444            .query_row("PRAGMA synchronous", [], |row| row.get(0))
2445            .unwrap();
2446        assert_eq!(synchronous, 1, "SQLite NORMAL mode is numeric value 1");
2447    }
2448
2449    #[test]
2450    fn inspect_cache_publishes_pointer_generation_and_reopens_after_crash_redo() {
2451        let temp = tempfile::tempdir().unwrap();
2452        let project_root = temp.path().join("checkout");
2453        fs::create_dir_all(&project_root).unwrap();
2454        let inspect_dir = temp.path().join("inspect");
2455        let project_key = crate::path_identity::project_scope_key(&project_root);
2456
2457        fs::create_dir_all(inspect_dir.join("leftover-nonempty-dir")).unwrap();
2458        let cache = InspectCache::open(inspect_dir.clone(), project_root.clone()).unwrap();
2459        assert!(cache.sqlite_path().is_file());
2460        assert_ne!(
2461            cache.sqlite_path(),
2462            inspect_dir
2463                .join(&project_key)
2464                .join(format!("{project_key}.sqlite"))
2465        );
2466        let pointer = inspect_dir
2467            .join(&project_key)
2468            .join(format!("{project_key}.current"));
2469        let generation = fs::read_to_string(&pointer).unwrap();
2470        assert_eq!(
2471            inspect_dir.join(&project_key).join(generation.trim()),
2472            cache.sqlite_path()
2473        );
2474        drop(cache);
2475
2476        let reopened = InspectCache::open(inspect_dir.clone(), project_root.clone()).unwrap();
2477        assert_eq!(
2478            inspect_dir.join(&project_key).join(generation.trim()),
2479            reopened.sqlite_path()
2480        );
2481        let readonly = InspectCache::open_readonly(inspect_dir, project_root)
2482            .unwrap()
2483            .expect("pointer-published inspect cache should reopen read-only");
2484        assert_eq!(readonly.inner.sqlite_path(), reopened.sqlite_path());
2485    }
2486
2487    fn write_aged_scope_file(scope_dir: &Path, name: &str) {
2488        fs::create_dir_all(scope_dir.join("nested")).unwrap();
2489        let path = scope_dir.join("nested").join(name);
2490        fs::write(&path, b"old inspect payload").unwrap();
2491        let old = SystemTime::now()
2492            .checked_sub(INSPECT_SCOPE_MIN_AGE + Duration::from_secs(60))
2493            .unwrap();
2494        filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(old)).unwrap();
2495    }
2496
2497    #[test]
2498    fn inspect_scope_sweep_reaps_aged_and_keeps_fresh_scope() {
2499        reset_inspect_scope_sweep_cursor_for_test();
2500        let temp = tempfile::tempdir().unwrap();
2501        let inspect_root = temp.path().join("inspect");
2502        let aged = inspect_root.join("aged-scope");
2503        let fresh = inspect_root.join("fresh-scope");
2504        write_aged_scope_file(&aged, "facts.sqlite");
2505        fs::create_dir_all(&fresh).unwrap();
2506        fs::write(fresh.join("facts.sqlite"), b"fresh inspect payload").unwrap();
2507
2508        let summary = sweep_inspect_scope_dirs(&inspect_root, &HashSet::new());
2509
2510        assert_eq!(summary.removed, 1);
2511        assert!(summary.bytes > 0, "reaped cache bytes must be reported");
2512        assert!(!aged.exists(), "an aged scope directory must be reaped");
2513        assert!(fresh.is_dir(), "a fresh scope directory must survive");
2514        // This assertion is intentionally a negative check: if the age
2515        // predicate is accidentally inverted, the fresh directory must still
2516        // be kept and this assertion will fail.
2517    }
2518
2519    #[test]
2520    fn inspect_scope_sweep_keeps_aged_live_scope_key() {
2521        reset_inspect_scope_sweep_cursor_for_test();
2522        let temp = tempfile::tempdir().unwrap();
2523        let inspect_root = temp.path().join("inspect");
2524        let project_root = temp.path().join("checkout");
2525        fs::create_dir_all(&project_root).unwrap();
2526        let scope_key = crate::path_identity::project_scope_key(&project_root);
2527        let live = inspect_root.join(&scope_key);
2528        write_aged_scope_file(&live, "facts.sqlite");
2529        crate::root_cache::register_live_scope(temp.path(), &project_root);
2530        let live_keys = crate::root_cache::live_scope_keys_for_storage(temp.path());
2531
2532        let summary = sweep_inspect_scope_dirs(&inspect_root, &live_keys);
2533
2534        crate::root_cache::unregister_live_scope(temp.path(), &project_root);
2535        assert_eq!(summary.skipped_live, 1);
2536        assert!(live.is_dir(), "a bound root's scope directory must survive");
2537        // This assertion is intentionally a negative check: if the live-key
2538        // exemption is removed, the bound root could be deleted and this
2539        // assertion will fail.
2540    }
2541
2542    #[test]
2543    fn inspect_scope_sweep_keeps_live_marker_then_reaps_stale_marker_scope() {
2544        reset_inspect_scope_sweep_cursor_for_test();
2545        let temp = tempfile::tempdir().unwrap();
2546        let inspect_root = temp.path().join("inspect");
2547        let scope = inspect_root.join("marked-scope");
2548        write_aged_scope_file(&scope, "facts.sqlite");
2549        let marker = crate::root_cache::ReadMarker::create(&scope, "generation").unwrap();
2550        let mut stale_metadata = marker.metadata().clone();
2551        stale_metadata.pid = 0;
2552        fs::write(
2553            marker.path(),
2554            serde_json::to_vec(&stale_metadata).expect("marker metadata serializes"),
2555        )
2556        .unwrap();
2557
2558        // Recreate a live marker after planting the stale fixture so the first
2559        // pass exercises the existing marker liveness rules, not scope age.
2560        let live_marker = crate::root_cache::ReadMarker::create(&scope, "live-generation").unwrap();
2561        let first = sweep_inspect_scope_dirs(&inspect_root, &HashSet::new());
2562        assert_eq!(first.skipped_marker, 1);
2563        assert!(scope.is_dir(), "a live read marker protects an aged scope");
2564        drop(live_marker);
2565
2566        let second = sweep_inspect_scope_dirs(&inspect_root, &HashSet::new());
2567        assert_eq!(second.removed, 1);
2568        assert!(
2569            !scope.exists(),
2570            "the scope is reapable after markers become stale"
2571        );
2572    }
2573
2574    #[test]
2575    fn inspect_scope_sweep_cursor_resumes_after_tiny_budget() {
2576        reset_inspect_scope_sweep_cursor_for_test();
2577        let temp = tempfile::tempdir().unwrap();
2578        let inspect_root = temp.path().join("inspect");
2579        for name in ["scope-a", "scope-b", "scope-c"] {
2580            write_aged_scope_file(&inspect_root.join(name), "facts.sqlite");
2581        }
2582
2583        let first = sweep_inspect_scope_dirs_with_limits(
2584            &inspect_root,
2585            &HashSet::new(),
2586            INSPECT_SCOPE_SWEEP_BUDGET,
2587            1,
2588        );
2589        assert!(first.budget_exhausted);
2590        assert!(!inspect_root.join("scope-a").exists());
2591        assert!(inspect_root.join("scope-b").exists());
2592        assert!(inspect_root.join("scope-c").exists());
2593
2594        let second = sweep_inspect_scope_dirs_with_limits(
2595            &inspect_root,
2596            &HashSet::new(),
2597            INSPECT_SCOPE_SWEEP_BUDGET,
2598            1,
2599        );
2600        assert!(second.budget_exhausted);
2601        assert!(!inspect_root.join("scope-b").exists());
2602        assert!(inspect_root.join("scope-c").exists());
2603
2604        let third = sweep_inspect_scope_dirs_with_limits(
2605            &inspect_root,
2606            &HashSet::new(),
2607            INSPECT_SCOPE_SWEEP_BUDGET,
2608            1,
2609        );
2610        assert!(!third.budget_exhausted);
2611        assert!(!inspect_root.join("scope-c").exists());
2612        reset_inspect_scope_sweep_cursor_for_test();
2613    }
2614
2615    #[test]
2616    fn tier1_file_memo_evicts_lru_and_keeps_recent_hits() {
2617        let temp = tempfile::tempdir().unwrap();
2618        let memo = Tier1FileMemo::<usize>::default();
2619        let mut paths = Vec::with_capacity(TIER1_FILE_MEMO_MAX_ENTRIES);
2620
2621        for index in 0..TIER1_FILE_MEMO_MAX_ENTRIES {
2622            let path = temp.path().join(format!("file-{index}.txt"));
2623            fs::write(&path, index.to_string()).unwrap();
2624            let value =
2625                memo.get_or_insert_with(&path, |path| (Some(collect_freshness(path)), index));
2626            assert_eq!(value, index);
2627            paths.push(path);
2628        }
2629
2630        let recent_path = paths[0].clone();
2631        let recent_value = memo.get_or_insert_with(&recent_path, |_| {
2632            panic!("recently inserted entry should hit before eviction")
2633        });
2634        assert_eq!(recent_value, 0);
2635
2636        let evicting_path = temp.path().join("new-file.txt");
2637        fs::write(&evicting_path, "new").unwrap();
2638        let evicting_value = memo.get_or_insert_with(&evicting_path, |path| {
2639            (Some(collect_freshness(path)), TIER1_FILE_MEMO_MAX_ENTRIES)
2640        });
2641        assert_eq!(evicting_value, TIER1_FILE_MEMO_MAX_ENTRIES);
2642
2643        let state = memo.state.lock().unwrap();
2644        assert_eq!(state.entries.len(), TIER1_FILE_MEMO_MAX_ENTRIES);
2645        assert!(state.entries.contains_key(&recent_path));
2646        assert!(state.entries.contains_key(&evicting_path));
2647        assert!(!state.entries.contains_key(&paths[1]));
2648        drop(state);
2649
2650        let recent_value = memo.get_or_insert_with(&recent_path, |_| {
2651            panic!("recently used entry should survive eviction")
2652        });
2653        assert_eq!(recent_value, 0);
2654    }
2655
2656    #[test]
2657    fn tier1_file_memo_full_scope_prunes_paths_no_longer_present() {
2658        let temp = tempfile::tempdir().unwrap();
2659        let retained_path = temp.path().join("retained.txt");
2660        let removed_path = temp.path().join("removed.txt");
2661        fs::write(&retained_path, "retained").unwrap();
2662        fs::write(&removed_path, "removed").unwrap();
2663        let memo = Tier1FileMemo::<usize>::default();
2664
2665        memo.reserve_for_scan(2);
2666        memo.get_or_insert_with(&retained_path, |path| (Some(collect_freshness(path)), 1));
2667        memo.get_or_insert_with(&removed_path, |path| (Some(collect_freshness(path)), 2));
2668        memo.prune_to_scope(temp.path(), std::slice::from_ref(&retained_path));
2669
2670        let state = memo.state.lock().unwrap();
2671        assert!(state.entries.contains_key(&retained_path));
2672        assert!(!state.entries.contains_key(&removed_path));
2673        assert_eq!(state.capacity, TIER1_FILE_MEMO_MAX_ENTRIES);
2674        drop(state);
2675
2676        let rescanned = Cell::new(false);
2677        let value = memo.get_or_insert_with(&removed_path, |path| {
2678            rescanned.set(true);
2679            (Some(collect_freshness(path)), 3)
2680        });
2681        assert!(
2682            rescanned.get(),
2683            "a path outside the latest full scope must be evicted"
2684        );
2685        assert_eq!(value, 3);
2686    }
2687
2688    #[test]
2689    fn tier1_file_memo_repeated_touches_keep_lazy_lru_bounded() {
2690        let temp = tempfile::tempdir().unwrap();
2691        let memo = Tier1FileMemo::<usize>::default();
2692        let mut paths = Vec::with_capacity(TIER1_FILE_MEMO_MAX_ENTRIES);
2693
2694        for index in 0..TIER1_FILE_MEMO_MAX_ENTRIES {
2695            let path = temp.path().join(format!("file-{index}.txt"));
2696            fs::write(&path, index.to_string()).unwrap();
2697            memo.get_or_insert_with(&path, |path| (Some(collect_freshness(path)), index));
2698            paths.push(path);
2699        }
2700
2701        for _ in 0..(TIER1_FILE_MEMO_MAX_ENTRIES * 3) {
2702            let value = memo.get_or_insert_with(&paths[0], |_| {
2703                panic!("hot entry should stay cached while it is repeatedly touched")
2704            });
2705            assert_eq!(value, 0);
2706        }
2707
2708        let evicting_path = temp.path().join("new-file.txt");
2709        fs::write(&evicting_path, "new").unwrap();
2710        memo.get_or_insert_with(&evicting_path, |path| {
2711            (Some(collect_freshness(path)), TIER1_FILE_MEMO_MAX_ENTRIES)
2712        });
2713
2714        let state = memo.state.lock().unwrap();
2715        assert_eq!(state.entries.len(), TIER1_FILE_MEMO_MAX_ENTRIES);
2716        assert!(state.entries.contains_key(&paths[0]));
2717        assert!(state.entries.contains_key(&evicting_path));
2718        assert!(!state.entries.contains_key(&paths[1]));
2719        assert!(
2720            state.lru.len() <= TIER1_FILE_MEMO_MAX_ENTRIES * 2,
2721            "lazy LRU queue should be compacted instead of growing without bound"
2722        );
2723    }
2724
2725    #[test]
2726    fn tier1_file_memo_reuses_fresh_entries_and_rescans_stale_files() {
2727        let temp = tempfile::tempdir().unwrap();
2728        let path = temp.path().join("memo.txt");
2729        fs::write(&path, "first").unwrap();
2730
2731        let memo = Tier1FileMemo::<String>::default();
2732        let scans = Cell::new(0);
2733
2734        let first = memo.get_or_insert_with(&path, |path| {
2735            scans.set(scans.get() + 1);
2736            (Some(collect_freshness(path)), "first scan".to_string())
2737        });
2738        assert_eq!(first, "first scan");
2739        assert_eq!(scans.get(), 1);
2740
2741        let unchanged =
2742            memo.get_or_insert_with(&path, |_| panic!("unchanged file should reuse Tier-1 memo"));
2743        assert_eq!(unchanged, "first scan");
2744        assert_eq!(scans.get(), 1);
2745
2746        fs::write(&path, "changed file contents").unwrap();
2747        let changed = memo.get_or_insert_with(&path, |path| {
2748            scans.set(scans.get() + 1);
2749            (Some(collect_freshness(path)), "second scan".to_string())
2750        });
2751        assert_eq!(changed, "second scan");
2752        assert_eq!(scans.get(), 2);
2753
2754        let fresh_after_rescan = memo.get_or_insert_with(&path, |_| {
2755            panic!("rescanned file should reuse refreshed Tier-1 memo")
2756        });
2757        assert_eq!(fresh_after_rescan, "second scan");
2758        assert_eq!(scans.get(), 2);
2759    }
2760
2761    #[derive(serde::Deserialize, serde::Serialize)]
2762    struct RoundTripContributionRecord {
2763        category: String,
2764        file_path: PathBuf,
2765        contribution: serde_json::Value,
2766        type_ref_names: BTreeSet<String>,
2767    }
2768
2769    impl From<&ContributionRecord> for RoundTripContributionRecord {
2770        fn from(record: &ContributionRecord) -> Self {
2771            Self {
2772                category: record.category.as_str().to_string(),
2773                file_path: record.file_path.clone(),
2774                contribution: record.contribution.clone(),
2775                type_ref_names: record.type_ref_names.clone(),
2776            }
2777        }
2778    }
2779
2780    #[test]
2781    fn contribution_record_round_trip_preserves_dead_code_liveness_metadata() {
2782        let temp = tempfile::tempdir().unwrap();
2783        let project_root = temp.path().join("project");
2784        let inspect_dir = temp.path().join("inspect");
2785        let source = project_root.join("src/lib.ts");
2786        fs::create_dir_all(source.parent().unwrap()).unwrap();
2787        fs::write(&source, "export interface Widget { id: string }\n").unwrap();
2788
2789        let cache = InspectCache::open(inspect_dir.clone(), project_root.clone()).unwrap();
2790        let contribution = FileContribution::new(
2791            InspectCategory::DeadCode,
2792            source.clone(),
2793            collect_freshness(&source),
2794            serde_json::json!({
2795                "file": "src/lib.ts",
2796                "exports": [{
2797                    "symbol": "Widget",
2798                    "kind": "interface",
2799                    "line": 1,
2800                    "is_type_like": true,
2801                    "is_entry_point": false,
2802                }],
2803                "internal_calls": [],
2804                "liveness_roots": [],
2805                "dispatched_method_names": ["render"],
2806                "macro_token_refs": [{
2807                    "caller_symbol": "render",
2808                    "line": 1,
2809                    "name": "Widget",
2810                    "shape": "struct"
2811                }],
2812                "type_ref_names": ["Widget"],
2813            }),
2814        )
2815        .with_type_ref_names(["Widget".to_string()]);
2816        cache
2817            .store_tier2_result(
2818                JobKey::for_project_category(InspectCategory::DeadCode),
2819                std::slice::from_ref(&source),
2820                &[contribution],
2821                serde_json::json!({ "count": 0, "items": [] }),
2822            )
2823            .unwrap();
2824        drop(cache);
2825
2826        let cache = InspectCache::open(inspect_dir, project_root).unwrap();
2827        let records = cache
2828            .load_tier2_contributions(InspectCategory::DeadCode)
2829            .unwrap();
2830        assert_eq!(records.len(), 1);
2831
2832        let serialized =
2833            serde_json::to_vec(&RoundTripContributionRecord::from(&records[0])).unwrap();
2834        let decoded: RoundTripContributionRecord = serde_json::from_slice(&serialized).unwrap();
2835        assert_eq!(decoded.category, InspectCategory::DeadCode.as_str());
2836        assert_eq!(decoded.contribution["dispatched_method_names"][0], "render");
2837        assert_eq!(decoded.contribution["type_ref_names"][0], "Widget");
2838        assert_eq!(
2839            decoded.contribution["macro_token_refs"][0]["shape"],
2840            "struct"
2841        );
2842        assert!(decoded.type_ref_names.contains("Widget"));
2843        assert_eq!(
2844            decoded.contribution["exports"][0]["is_type_like"].as_bool(),
2845            Some(true)
2846        );
2847        assert_eq!(TIER2_CONTRIBUTION_CACHE_VERSION, 31);
2848    }
2849
2850    #[test]
2851    fn duplicate_expected_mirrors_participate_in_aggregate_cache_hash() {
2852        let temp = tempfile::tempdir().unwrap();
2853        let project_root = temp.path().join("project");
2854        fs::create_dir_all(&project_root).unwrap();
2855        let left = project_root.join("plugin/a.ts");
2856        let right = project_root.join("pi-plugin/a.ts");
2857        fs::create_dir_all(left.parent().unwrap()).unwrap();
2858        fs::create_dir_all(right.parent().unwrap()).unwrap();
2859        fs::write(&left, "export const value = 1;\n").unwrap();
2860        fs::write(&right, "export const value = 1;\n").unwrap();
2861
2862        let cache = InspectCache::open(temp.path().join("inspect"), project_root.clone()).unwrap();
2863        let contributions = vec![
2864            FileContribution::new(
2865                InspectCategory::Duplicates,
2866                left.clone(),
2867                collect_freshness(&left),
2868                serde_json::json!({ "file": "plugin/a.ts", "line_count": 1, "fragments": [] }),
2869            ),
2870            FileContribution::new(
2871                InspectCategory::Duplicates,
2872                right.clone(),
2873                collect_freshness(&right),
2874                serde_json::json!({ "file": "pi-plugin/a.ts", "line_count": 1, "fragments": [] }),
2875            ),
2876        ];
2877        let config = Config::default();
2878        cache
2879            .store_tier2_result_for_config(
2880                JobKey::for_project_category(InspectCategory::Duplicates),
2881                &[left.clone(), right.clone()],
2882                &contributions,
2883                serde_json::json!({ "count": 0, "items": [] }),
2884                &config,
2885            )
2886            .unwrap();
2887
2888        let without_mirrors = cache
2889            .contribution_set_hash_for_config(InspectCategory::Duplicates, &config)
2890            .unwrap();
2891        let mut mirror_config = Config::default();
2892        mirror_config.inspect.duplicates.expected_mirrors =
2893            vec![["plugin/**".to_string(), "pi-plugin/**".to_string()]];
2894        let with_mirrors = cache
2895            .contribution_set_hash_for_config(InspectCategory::Duplicates, &mirror_config)
2896            .unwrap();
2897
2898        assert_ne!(without_mirrors, with_mirrors);
2899    }
2900}
2901
2902#[cfg(test)]
2903mod memory_estimate_tests {
2904    use super::*;
2905
2906    #[test]
2907    fn inspect_memory_estimate_is_zero_when_empty_and_nonzero_when_populated() {
2908        let root = tempfile::tempdir().expect("project root");
2909        let project_root = std::fs::canonicalize(root.path()).expect("canonical project root");
2910        let storage = tempfile::tempdir().expect("inspect storage");
2911        let cache = InspectCache::open(storage.path().to_path_buf(), project_root)
2912            .expect("open inspect cache");
2913        assert_eq!(cache.estimated_memory().estimated_bytes, Some(0));
2914
2915        cache
2916            .store_aggregated(
2917                JobKey::for_project_category(InspectCategory::Todos),
2918                serde_json::json!({"count": 1, "items": [{"text": "resident todo"}]}),
2919            )
2920            .expect("store memory aggregate");
2921        let estimate = cache.estimated_memory();
2922        assert!(estimate.estimated_bytes.unwrap() > 0);
2923        assert_eq!(estimate.counts["memory_aggregates"], 1);
2924        assert_eq!(estimate.counts["open_generation_handles"], 1);
2925    }
2926}