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