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    conn.pragma_update(None, "journal_mode", "WAL")?;
1931    // Inspect facts are fully recomputable, so NORMAL avoids a full fsync per
1932    // commit while WAL transactions and writer-lease publication stay atomic.
1933    conn.pragma_update(None, "synchronous", "NORMAL")?;
1934    conn.pragma_update(None, "busy_timeout", 5_000)?;
1935    Ok(())
1936}
1937
1938fn initialize_schema(conn: &Connection) -> Result<(), InspectCacheError> {
1939    conn.execute_batch(
1940        "CREATE TABLE IF NOT EXISTS tier2_contributions (
1941            category        TEXT NOT NULL,
1942            project_key     TEXT NOT NULL,
1943            file_path       TEXT NOT NULL,
1944            file_mtime_ns   INTEGER NOT NULL,
1945            file_size       INTEGER NOT NULL,
1946            file_hash       TEXT NOT NULL,
1947            contribution    BLOB NOT NULL,
1948            generated_at    INTEGER NOT NULL,
1949            PRIMARY KEY (category, project_key, file_path)
1950        );
1951
1952        CREATE TABLE IF NOT EXISTS tier2_aggregates (
1953            category        TEXT NOT NULL,
1954            project_key     TEXT NOT NULL,
1955            contribution_set_hash TEXT NOT NULL,
1956            aggregate       BLOB NOT NULL,
1957            generated_at    INTEGER NOT NULL,
1958            PRIMARY KEY (category, project_key)
1959        );
1960
1961        CREATE TABLE IF NOT EXISTS tier2_meta (
1962            category        TEXT NOT NULL,
1963            project_key     TEXT NOT NULL,
1964            last_full_run   INTEGER NOT NULL,
1965            PRIMARY KEY (category, project_key)
1966        );",
1967    )?;
1968    Ok(())
1969}
1970
1971fn existing_contribution_paths(
1972    conn: &Connection,
1973    category: InspectCategory,
1974    project_key: &str,
1975) -> Result<Vec<String>, InspectCacheError> {
1976    let mut stmt = conn.prepare(
1977        "SELECT file_path FROM tier2_contributions WHERE category = ?1 AND project_key = ?2",
1978    )?;
1979    let rows = stmt.query_map(params![category.as_str(), project_key], |row| {
1980        row.get::<_, String>(0)
1981    })?;
1982    rows.collect::<Result<Vec<_>, _>>()
1983        .map_err(InspectCacheError::from)
1984}
1985
1986fn contribution_set_hash_with_conn(
1987    conn: &Connection,
1988    category: InspectCategory,
1989    project_key: &str,
1990    project_root: &Path,
1991    config: Option<&Config>,
1992) -> Result<String, InspectCacheError> {
1993    let mut stmt = conn.prepare(
1994        "SELECT file_path, file_hash FROM tier2_contributions \
1995         WHERE category = ?1 AND project_key = ?2 ORDER BY file_path ASC",
1996    )?;
1997    let rows = stmt.query_map(params![category.as_str(), project_key], |row| {
1998        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1999    })?;
2000
2001    let mut hasher = blake3::Hasher::new();
2002    hasher.update(b"tier2-contributions\0");
2003    hasher.update(&TIER2_CONTRIBUTION_CACHE_VERSION.to_le_bytes());
2004    hasher.update(b"\0");
2005    for row in rows {
2006        let (file_path, file_hash) = row?;
2007        hasher.update(file_path.as_bytes());
2008        hasher.update(b"\0");
2009        hasher.update(file_hash.as_bytes());
2010        hasher.update(b"\0");
2011    }
2012    update_manifest_fingerprint_hash(&mut hasher, project_root)?;
2013    if matches!(
2014        category,
2015        InspectCategory::DeadCode | InspectCategory::UnusedExports | InspectCategory::Cycles
2016    ) {
2017        update_resolver_config_fingerprint_hash(&mut hasher, project_root)?;
2018    }
2019    update_inspect_config_fingerprint_hash(&mut hasher, category, config);
2020    Ok(hasher.finalize().to_hex().to_string())
2021}
2022
2023fn update_inspect_config_fingerprint_hash(
2024    hasher: &mut blake3::Hasher,
2025    category: InspectCategory,
2026    config: Option<&Config>,
2027) {
2028    if category != InspectCategory::Duplicates {
2029        return;
2030    }
2031
2032    hasher.update(b"inspect.duplicates.expected_mirrors\0");
2033    let Some(config) = config else {
2034        return;
2035    };
2036    for pair in &config.inspect.duplicates.expected_mirrors {
2037        hasher.update(pair[0].as_bytes());
2038        hasher.update(b"\0");
2039        hasher.update(pair[1].as_bytes());
2040        hasher.update(b"\0");
2041    }
2042}
2043
2044fn update_resolver_config_fingerprint_hash(
2045    hasher: &mut blake3::Hasher,
2046    project_root: &Path,
2047) -> Result<(), InspectCacheError> {
2048    let manifest_root =
2049        fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
2050    hasher.update(b"ts-js-resolver-configs\0");
2051    for config in collect_resolver_config_dependency_files(project_root) {
2052        let relative_path = config
2053            .strip_prefix(&manifest_root)
2054            .unwrap_or(config.as_path())
2055            .to_string_lossy()
2056            .replace('\\', "/");
2057        let content_hash = blake3::hash(&fs::read(&config)?);
2058        hasher.update(relative_path.as_bytes());
2059        hasher.update(b"\0");
2060        hasher.update(content_hash.as_bytes());
2061        hasher.update(b"\0");
2062    }
2063    Ok(())
2064}
2065
2066struct ResolverConfigDependency {
2067    path: PathBuf,
2068    follow_extends: bool,
2069}
2070
2071impl ResolverConfigDependency {
2072    fn resolver_config(path: PathBuf) -> Self {
2073        Self {
2074            path,
2075            follow_extends: true,
2076        }
2077    }
2078
2079    fn hashed_file(path: PathBuf) -> Self {
2080        Self {
2081            path,
2082            follow_extends: false,
2083        }
2084    }
2085}
2086
2087fn collect_resolver_config_dependency_files(project_root: &Path) -> BTreeSet<PathBuf> {
2088    let mut configs = walk_resolver_config_files(project_root);
2089    let mut pending = configs.iter().cloned().collect::<Vec<_>>();
2090    let mut queued = configs.clone();
2091    while let Some(config) = pending.pop() {
2092        for dependency in resolver_config_extends_targets(&config, project_root) {
2093            let ResolverConfigDependency {
2094                path,
2095                follow_extends,
2096            } = dependency;
2097            configs.insert(path.clone());
2098            if follow_extends && queued.insert(path.clone()) {
2099                pending.push(path);
2100            }
2101        }
2102    }
2103    configs
2104}
2105
2106fn walk_resolver_config_files(project_root: &Path) -> BTreeSet<PathBuf> {
2107    let walker = ignore::WalkBuilder::new(project_root)
2108        .hidden(true)
2109        .git_ignore(true)
2110        .git_global(true)
2111        .git_exclude(true)
2112        .add_custom_ignore_filename(".aftignore")
2113        .filter_entry(|entry| {
2114            let name = entry.file_name().to_string_lossy();
2115            if entry
2116                .file_type()
2117                .is_some_and(|file_type| file_type.is_dir())
2118            {
2119                return !matches!(
2120                    name.as_ref(),
2121                    "node_modules"
2122                        | "target"
2123                        | "venv"
2124                        | ".venv"
2125                        | ".git"
2126                        | "__pycache__"
2127                        | ".tox"
2128                        | "dist"
2129                        | "build"
2130                );
2131            }
2132            true
2133        })
2134        .build();
2135
2136    walker
2137        .filter_map(Result::ok)
2138        .filter(|entry| {
2139            entry
2140                .file_type()
2141                .is_some_and(|file_type| file_type.is_file())
2142        })
2143        .map(|entry| entry.into_path())
2144        .filter(|path| {
2145            path.file_name()
2146                .and_then(|name| name.to_str())
2147                .is_some_and(is_resolver_config_file_name)
2148        })
2149        .filter_map(canonical_file_path)
2150        .collect()
2151}
2152
2153fn is_resolver_config_file_name(name: &str) -> bool {
2154    name == "tsconfig.json"
2155        || name == "jsconfig.json"
2156        || ((name.starts_with("tsconfig.") || name.starts_with("jsconfig."))
2157            && name.ends_with(".json"))
2158}
2159
2160fn resolver_config_extends_targets(
2161    config: &Path,
2162    project_root: &Path,
2163) -> Vec<ResolverConfigDependency> {
2164    let Ok(source) = fs::read_to_string(config) else {
2165        return Vec::new();
2166    };
2167    let Ok(value) = parse_resolver_config_json(&source) else {
2168        return Vec::new();
2169    };
2170
2171    let mut specs = Vec::new();
2172    collect_extends_specs(value.get("extends"), &mut specs);
2173    specs
2174        .into_iter()
2175        .flat_map(|spec| resolve_resolver_config_extends(config, project_root, spec))
2176        .collect()
2177}
2178
2179fn parse_resolver_config_json(source: &str) -> Result<serde_json::Value, serde_json::Error> {
2180    serde_json::from_str(source).or_else(|_| serde_json::from_str(&strip_jsonc(source)))
2181}
2182
2183fn collect_extends_specs<'a>(value: Option<&'a serde_json::Value>, specs: &mut Vec<&'a str>) {
2184    match value {
2185        Some(serde_json::Value::String(spec)) => specs.push(spec),
2186        Some(serde_json::Value::Array(values)) => {
2187            for value in values {
2188                collect_extends_specs(Some(value), specs);
2189            }
2190        }
2191        _ => {}
2192    }
2193}
2194
2195fn resolve_resolver_config_extends(
2196    config: &Path,
2197    project_root: &Path,
2198    spec: &str,
2199) -> Vec<ResolverConfigDependency> {
2200    let config_dir = config.parent().unwrap_or(project_root);
2201    let spec_path = Path::new(spec);
2202    if spec_path.is_absolute() || spec.starts_with('.') {
2203        return resolver_config_extends_target(&config_dir.join(spec_path))
2204            .map(ResolverConfigDependency::resolver_config)
2205            .into_iter()
2206            .collect();
2207    }
2208
2209    node_modules_resolver_config_dependencies(config_dir, project_root, spec)
2210}
2211
2212fn node_modules_resolver_config_dependencies(
2213    config_dir: &Path,
2214    project_root: &Path,
2215    spec: &str,
2216) -> Vec<ResolverConfigDependency> {
2217    let boundary = fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
2218    let config_dir = fs::canonicalize(config_dir).unwrap_or_else(|_| config_dir.to_path_buf());
2219    let enforce_project_boundary = config_dir.starts_with(&boundary);
2220    let is_bare_package = is_bare_package_extends_spec(spec);
2221    let mut dependencies = Vec::new();
2222    for ancestor in config_dir.ancestors() {
2223        let ancestor = fs::canonicalize(ancestor).unwrap_or_else(|_| ancestor.to_path_buf());
2224        if enforce_project_boundary && !ancestor.starts_with(&boundary) {
2225            break;
2226        }
2227        let package_dir = ancestor.join("node_modules").join(spec);
2228        let mut ancestor_dependencies = Vec::new();
2229        if is_bare_package {
2230            if let Some(mut package_dependencies) =
2231                package_json_resolver_config_dependencies(&package_dir)
2232            {
2233                let has_resolver_config = package_dependencies
2234                    .iter()
2235                    .any(|dependency| dependency.follow_extends);
2236                ancestor_dependencies.append(&mut package_dependencies);
2237                if has_resolver_config {
2238                    dependencies.extend(ancestor_dependencies);
2239                    return dependencies;
2240                }
2241            }
2242        }
2243        if let Some(target) = resolver_config_extends_target(&package_dir) {
2244            ancestor_dependencies.push(ResolverConfigDependency::resolver_config(target));
2245            dependencies.extend(ancestor_dependencies);
2246            return dependencies;
2247        }
2248        dependencies.extend(ancestor_dependencies);
2249    }
2250    dependencies
2251}
2252
2253fn package_json_resolver_config_dependencies(
2254    package_dir: &Path,
2255) -> Option<Vec<ResolverConfigDependency>> {
2256    let package_json = canonical_file_path(package_dir.join("package.json"))?;
2257    let package_root = package_json
2258        .parent()
2259        .map(Path::to_path_buf)
2260        .unwrap_or_else(|| package_dir.to_path_buf());
2261    let mut dependencies = vec![ResolverConfigDependency::hashed_file(package_json.clone())];
2262
2263    let Ok(source) = fs::read_to_string(&package_json) else {
2264        return Some(dependencies);
2265    };
2266    let Ok(value) = parse_resolver_config_json(&source) else {
2267        return Some(dependencies);
2268    };
2269    let selected_config = value
2270        .get("tsconfig")
2271        .and_then(serde_json::Value::as_str)
2272        .map(str::trim)
2273        .filter(|value| !value.is_empty())
2274        .unwrap_or("tsconfig.json");
2275    if let Some(target) = resolver_config_extends_target(&package_root.join(selected_config)) {
2276        dependencies.push(ResolverConfigDependency::resolver_config(target));
2277    }
2278
2279    Some(dependencies)
2280}
2281
2282fn is_bare_package_extends_spec(spec: &str) -> bool {
2283    let mut parts = spec.split('/').filter(|part| !part.is_empty());
2284    let Some(first) = parts.next() else {
2285        return false;
2286    };
2287    if first.starts_with('@') {
2288        parts.next().is_some() && parts.next().is_none()
2289    } else {
2290        parts.next().is_none()
2291    }
2292}
2293
2294fn resolver_config_extends_target(base: &Path) -> Option<PathBuf> {
2295    resolver_config_extends_candidates(base)
2296        .into_iter()
2297        .find_map(canonical_file_path)
2298}
2299
2300fn resolver_config_extends_candidates(base: &Path) -> Vec<PathBuf> {
2301    let mut candidates = vec![base.to_path_buf()];
2302    if base.extension().is_none() {
2303        candidates.push(base.with_extension("json"));
2304        candidates.push(base.join("tsconfig.json"));
2305    }
2306    candidates
2307}
2308
2309fn canonical_file_path(path: PathBuf) -> Option<PathBuf> {
2310    if !path.is_file() {
2311        return None;
2312    }
2313    Some(fs::canonicalize(&path).unwrap_or(path))
2314}
2315
2316fn update_manifest_fingerprint_hash(
2317    hasher: &mut blake3::Hasher,
2318    project_root: &Path,
2319) -> Result<(), InspectCacheError> {
2320    // Entry-point manifests are normalized snapshots. A bare canonical root is
2321    // verbatim (`\\?\`) on Windows, so mixing it with those non-verbatim
2322    // manifests makes `strip_prefix` hash an absolute path instead of its
2323    // project-relative name.
2324    let manifest_root = crate::inspect::job::canonicalize_normalized(project_root);
2325    hasher.update(b"entry-point-manifests\0");
2326    for manifest in super::entry_points::collect_entry_point_manifests(project_root) {
2327        let relative_path = manifest
2328            .strip_prefix(&manifest_root)
2329            .unwrap_or(manifest.as_path())
2330            .to_string_lossy()
2331            .replace('\\', "/");
2332        let content_hash = blake3::hash(&fs::read(&manifest)?);
2333        hasher.update(relative_path.as_bytes());
2334        hasher.update(b"\0");
2335        hasher.update(content_hash.as_bytes());
2336        hasher.update(b"\0");
2337    }
2338    Ok(())
2339}
2340
2341fn relative_string(project_root: &Path, path: &Path) -> String {
2342    if let Ok(relative) = path.strip_prefix(project_root) {
2343        return relative.to_string_lossy().to_string();
2344    }
2345
2346    if let (Ok(canonical_root), Ok(canonical_path)) =
2347        (fs::canonicalize(project_root), fs::canonicalize(path))
2348    {
2349        if let Ok(relative) = canonical_path.strip_prefix(canonical_root) {
2350            return relative.to_string_lossy().to_string();
2351        }
2352    }
2353
2354    path.to_string_lossy().to_string()
2355}
2356
2357fn system_time_to_ns(time: SystemTime) -> i64 {
2358    let nanos = time
2359        .duration_since(UNIX_EPOCH)
2360        .unwrap_or_else(|_| Duration::from_secs(0))
2361        .as_nanos();
2362    nanos.min(i64::MAX as u128) as i64
2363}
2364
2365fn ns_to_system_time(value: i64) -> SystemTime {
2366    UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
2367}
2368
2369fn hash_to_hex(hash: blake3::Hash) -> String {
2370    hash.to_hex().to_string()
2371}
2372
2373fn hash_from_hex(value: &str) -> Result<blake3::Hash, InspectCacheError> {
2374    if value.len() != 64 {
2375        return Err(InspectCacheError::InvalidHash(value.to_string()));
2376    }
2377    let mut bytes = [0u8; 32];
2378    for (index, chunk) in value.as_bytes().chunks(2).enumerate() {
2379        let hex = std::str::from_utf8(chunk)
2380            .map_err(|_| InspectCacheError::InvalidHash(value.to_string()))?;
2381        bytes[index] = u8::from_str_radix(hex, 16)
2382            .map_err(|_| InspectCacheError::InvalidHash(value.to_string()))?;
2383    }
2384    Ok(blake3::Hash::from_bytes(bytes))
2385}
2386
2387fn unix_seconds_now() -> i64 {
2388    SystemTime::now()
2389        .duration_since(UNIX_EPOCH)
2390        .unwrap_or_else(|_| Duration::from_secs(0))
2391        .as_secs()
2392        .min(i64::MAX as u64) as i64
2393}
2394
2395fn now_nanos() -> u128 {
2396    SystemTime::now()
2397        .duration_since(UNIX_EPOCH)
2398        .unwrap_or(Duration::ZERO)
2399        .as_nanos()
2400}
2401
2402#[cfg(test)]
2403mod tests {
2404    use super::*;
2405    use std::cell::Cell;
2406    use std::collections::HashSet;
2407    use std::fs;
2408    use std::path::{Path, PathBuf};
2409
2410    fn collect_freshness(path: &Path) -> FileFreshness {
2411        crate::cache_freshness::collect(path).unwrap()
2412    }
2413
2414    #[test]
2415    fn sqlite_readonly_uri_percent_encodes_windows_paths() {
2416        assert_eq!(
2417            sqlite_readonly_uri(Path::new(r"C:\Users\name with spaces\db#1.sqlite")),
2418            "file:///C:/Users/name%20with%20spaces/db%231.sqlite?mode=ro"
2419        );
2420    }
2421
2422    #[test]
2423    fn held_writer_lease_fails_within_the_inspect_deadline() {
2424        let temp = tempfile::tempdir().expect("create temporary cache root");
2425        let project_root = temp.path().join("project");
2426        let inspect_dir = temp.path().join("inspect");
2427        fs::create_dir_all(&project_root).expect("create project root");
2428        let project_key = crate::path_identity::project_scope_key(&project_root);
2429        crate::root_cache::configure_artifact_access(&project_root, "shared", false);
2430        let project_dir = project_inspect_dir(inspect_dir.clone(), &project_key);
2431        fs::create_dir_all(&project_dir).expect("create project inspect directory");
2432
2433        // Hold the actual lease file through an independent handle so the test
2434        // exercises the contended wait rather than an uncontended fast path.
2435        let held = crate::fs_lock::try_acquire(
2436            &crate::root_cache::writer_lease_path(&project_dir),
2437            Duration::ZERO,
2438        )
2439        .expect("hold inspect writer lease");
2440        let started = Instant::now();
2441        let error = match InspectCache::open(inspect_dir.clone(), project_root.clone()) {
2442            Err(error) => error,
2443            Ok(_) => panic!("contended inspect cache must fail honestly"),
2444        };
2445        assert!(matches!(error, InspectCacheError::WriterLeaseTimeout));
2446        assert!(
2447            started.elapsed() <= INSPECT_WRITER_LEASE_TIMEOUT + Duration::from_secs(1),
2448            "writer lease acquisition exceeded its deadline"
2449        );
2450
2451        drop(held);
2452        InspectCache::open(inspect_dir, project_root)
2453            .expect("cache opens after the competing writer releases");
2454    }
2455
2456    #[cfg(windows)]
2457    fn verbatim_path(path: &Path) -> PathBuf {
2458        PathBuf::from(format!(r"\\?\{}", path.display()))
2459    }
2460
2461    #[cfg(windows)]
2462    #[test]
2463    fn manifest_fingerprint_uses_relative_paths_for_verbatim_roots() {
2464        let temp = tempfile::tempdir().expect("create temporary project");
2465        let project = temp.path().join("project");
2466        fs::create_dir_all(&project).expect("create project directory");
2467        let manifest = project.join("package.json");
2468        let content = br#"{"name":"fixture"}"#;
2469        fs::write(&manifest, content).expect("write package manifest");
2470
2471        let normalized_project = crate::inspect::job::canonicalize_normalized(&project);
2472        let mut actual = blake3::Hasher::new();
2473        update_manifest_fingerprint_hash(&mut actual, &verbatim_path(&normalized_project))
2474            .expect("hash entry-point manifest");
2475
2476        let mut expected = blake3::Hasher::new();
2477        expected.update(b"entry-point-manifests\0");
2478        expected.update(b"package.json\0");
2479        expected.update(blake3::hash(content).as_bytes());
2480        expected.update(b"\0");
2481
2482        assert_eq!(actual.finalize(), expected.finalize());
2483    }
2484
2485    #[test]
2486    fn inspect_cache_writer_uses_normal_synchronous_mode() {
2487        let temp = tempfile::tempdir().unwrap();
2488        let project_root = temp.path().join("checkout");
2489        fs::create_dir_all(&project_root).unwrap();
2490        let cache = InspectCache::open(temp.path().join("inspect"), project_root).unwrap();
2491        let conn = cache.conn.lock().unwrap();
2492        let synchronous: i64 = conn
2493            .query_row("PRAGMA synchronous", [], |row| row.get(0))
2494            .unwrap();
2495        assert_eq!(synchronous, 1, "SQLite NORMAL mode is numeric value 1");
2496    }
2497
2498    #[test]
2499    fn inspect_cache_publishes_pointer_generation_and_reopens_after_crash_redo() {
2500        let temp = tempfile::tempdir().unwrap();
2501        let project_root = temp.path().join("checkout");
2502        fs::create_dir_all(&project_root).unwrap();
2503        let inspect_dir = temp.path().join("inspect");
2504        let project_key = crate::path_identity::project_scope_key(&project_root);
2505
2506        fs::create_dir_all(inspect_dir.join("leftover-nonempty-dir")).unwrap();
2507        let cache = InspectCache::open(inspect_dir.clone(), project_root.clone()).unwrap();
2508        assert!(cache.sqlite_path().is_file());
2509        assert_ne!(
2510            cache.sqlite_path(),
2511            inspect_dir
2512                .join(&project_key)
2513                .join(format!("{project_key}.sqlite"))
2514        );
2515        let pointer = inspect_dir
2516            .join(&project_key)
2517            .join(format!("{project_key}.current"));
2518        let generation = fs::read_to_string(&pointer).unwrap();
2519        assert_eq!(
2520            inspect_dir.join(&project_key).join(generation.trim()),
2521            cache.sqlite_path()
2522        );
2523        drop(cache);
2524
2525        let reopened = InspectCache::open(inspect_dir.clone(), project_root.clone()).unwrap();
2526        assert_eq!(
2527            inspect_dir.join(&project_key).join(generation.trim()),
2528            reopened.sqlite_path()
2529        );
2530        let readonly = InspectCache::open_readonly(inspect_dir, project_root)
2531            .unwrap()
2532            .expect("pointer-published inspect cache should reopen read-only");
2533        assert_eq!(readonly.inner.sqlite_path(), reopened.sqlite_path());
2534    }
2535
2536    fn write_aged_scope_file(scope_dir: &Path, name: &str) {
2537        fs::create_dir_all(scope_dir.join("nested")).unwrap();
2538        let path = scope_dir.join("nested").join(name);
2539        fs::write(&path, b"old inspect payload").unwrap();
2540        let old = SystemTime::now()
2541            .checked_sub(INSPECT_SCOPE_MIN_AGE + Duration::from_secs(60))
2542            .unwrap();
2543        filetime::set_file_mtime(&path, filetime::FileTime::from_system_time(old)).unwrap();
2544    }
2545
2546    #[test]
2547    fn inspect_scope_sweep_reaps_aged_and_keeps_fresh_scope() {
2548        reset_inspect_scope_sweep_cursor_for_test();
2549        let temp = tempfile::tempdir().unwrap();
2550        let inspect_root = temp.path().join("inspect");
2551        let aged = inspect_root.join("aged-scope");
2552        let fresh = inspect_root.join("fresh-scope");
2553        write_aged_scope_file(&aged, "facts.sqlite");
2554        fs::create_dir_all(&fresh).unwrap();
2555        fs::write(fresh.join("facts.sqlite"), b"fresh inspect payload").unwrap();
2556
2557        let summary = sweep_inspect_scope_dirs(&inspect_root, &HashSet::new());
2558
2559        assert_eq!(summary.removed, 1);
2560        assert!(summary.bytes > 0, "reaped cache bytes must be reported");
2561        assert!(!aged.exists(), "an aged scope directory must be reaped");
2562        assert!(fresh.is_dir(), "a fresh scope directory must survive");
2563        // This assertion is intentionally a negative check: if the age
2564        // predicate is accidentally inverted, the fresh directory must still
2565        // be kept and this assertion will fail.
2566    }
2567
2568    #[test]
2569    fn inspect_scope_sweep_keeps_aged_live_scope_key() {
2570        reset_inspect_scope_sweep_cursor_for_test();
2571        let temp = tempfile::tempdir().unwrap();
2572        let inspect_root = temp.path().join("inspect");
2573        let project_root = temp.path().join("checkout");
2574        fs::create_dir_all(&project_root).unwrap();
2575        let scope_key = crate::path_identity::project_scope_key(&project_root);
2576        let live = inspect_root.join(&scope_key);
2577        write_aged_scope_file(&live, "facts.sqlite");
2578        crate::root_cache::register_live_scope(temp.path(), &project_root);
2579        let live_keys = crate::root_cache::live_scope_keys_for_storage(temp.path());
2580
2581        let summary = sweep_inspect_scope_dirs(&inspect_root, &live_keys);
2582
2583        crate::root_cache::unregister_live_scope(temp.path(), &project_root);
2584        assert_eq!(summary.skipped_live, 1);
2585        assert!(live.is_dir(), "a bound root's scope directory must survive");
2586        // This assertion is intentionally a negative check: if the live-key
2587        // exemption is removed, the bound root could be deleted and this
2588        // assertion will fail.
2589    }
2590
2591    #[test]
2592    fn inspect_scope_sweep_keeps_live_marker_then_reaps_stale_marker_scope() {
2593        reset_inspect_scope_sweep_cursor_for_test();
2594        let temp = tempfile::tempdir().unwrap();
2595        let inspect_root = temp.path().join("inspect");
2596        let scope = inspect_root.join("marked-scope");
2597        write_aged_scope_file(&scope, "facts.sqlite");
2598        let marker = crate::root_cache::ReadMarker::create(&scope, "generation").unwrap();
2599        let mut stale_metadata = marker.metadata().clone();
2600        stale_metadata.pid = 0;
2601        fs::write(
2602            marker.path(),
2603            serde_json::to_vec(&stale_metadata).expect("marker metadata serializes"),
2604        )
2605        .unwrap();
2606
2607        // Recreate a live marker after planting the stale fixture so the first
2608        // pass exercises the existing marker liveness rules, not scope age.
2609        let live_marker = crate::root_cache::ReadMarker::create(&scope, "live-generation").unwrap();
2610        let first = sweep_inspect_scope_dirs(&inspect_root, &HashSet::new());
2611        assert_eq!(first.skipped_marker, 1);
2612        assert!(scope.is_dir(), "a live read marker protects an aged scope");
2613        drop(live_marker);
2614
2615        let second = sweep_inspect_scope_dirs(&inspect_root, &HashSet::new());
2616        assert_eq!(second.removed, 1);
2617        assert!(
2618            !scope.exists(),
2619            "the scope is reapable after markers become stale"
2620        );
2621    }
2622
2623    #[test]
2624    fn inspect_scope_sweep_cursor_resumes_after_tiny_budget() {
2625        reset_inspect_scope_sweep_cursor_for_test();
2626        let temp = tempfile::tempdir().unwrap();
2627        let inspect_root = temp.path().join("inspect");
2628        for name in ["scope-a", "scope-b", "scope-c"] {
2629            write_aged_scope_file(&inspect_root.join(name), "facts.sqlite");
2630        }
2631
2632        let first = sweep_inspect_scope_dirs_with_limits(
2633            &inspect_root,
2634            &HashSet::new(),
2635            INSPECT_SCOPE_SWEEP_BUDGET,
2636            1,
2637        );
2638        assert!(first.budget_exhausted);
2639        assert!(!inspect_root.join("scope-a").exists());
2640        assert!(inspect_root.join("scope-b").exists());
2641        assert!(inspect_root.join("scope-c").exists());
2642
2643        let second = sweep_inspect_scope_dirs_with_limits(
2644            &inspect_root,
2645            &HashSet::new(),
2646            INSPECT_SCOPE_SWEEP_BUDGET,
2647            1,
2648        );
2649        assert!(second.budget_exhausted);
2650        assert!(!inspect_root.join("scope-b").exists());
2651        assert!(inspect_root.join("scope-c").exists());
2652
2653        let third = sweep_inspect_scope_dirs_with_limits(
2654            &inspect_root,
2655            &HashSet::new(),
2656            INSPECT_SCOPE_SWEEP_BUDGET,
2657            1,
2658        );
2659        assert!(!third.budget_exhausted);
2660        assert!(!inspect_root.join("scope-c").exists());
2661        reset_inspect_scope_sweep_cursor_for_test();
2662    }
2663
2664    #[test]
2665    fn tier1_file_memo_evicts_lru_and_keeps_recent_hits() {
2666        let temp = tempfile::tempdir().unwrap();
2667        let memo = Tier1FileMemo::<usize>::default();
2668        let mut paths = Vec::with_capacity(TIER1_FILE_MEMO_MAX_ENTRIES);
2669
2670        for index in 0..TIER1_FILE_MEMO_MAX_ENTRIES {
2671            let path = temp.path().join(format!("file-{index}.txt"));
2672            fs::write(&path, index.to_string()).unwrap();
2673            let value =
2674                memo.get_or_insert_with(&path, |path| (Some(collect_freshness(path)), index));
2675            assert_eq!(value, index);
2676            paths.push(path);
2677        }
2678
2679        let recent_path = paths[0].clone();
2680        let recent_value = memo.get_or_insert_with(&recent_path, |_| {
2681            panic!("recently inserted entry should hit before eviction")
2682        });
2683        assert_eq!(recent_value, 0);
2684
2685        let evicting_path = temp.path().join("new-file.txt");
2686        fs::write(&evicting_path, "new").unwrap();
2687        let evicting_value = memo.get_or_insert_with(&evicting_path, |path| {
2688            (Some(collect_freshness(path)), TIER1_FILE_MEMO_MAX_ENTRIES)
2689        });
2690        assert_eq!(evicting_value, TIER1_FILE_MEMO_MAX_ENTRIES);
2691
2692        let state = memo.state.lock().unwrap();
2693        assert_eq!(state.entries.len(), TIER1_FILE_MEMO_MAX_ENTRIES);
2694        assert!(state.entries.contains_key(&recent_path));
2695        assert!(state.entries.contains_key(&evicting_path));
2696        assert!(!state.entries.contains_key(&paths[1]));
2697        drop(state);
2698
2699        let recent_value = memo.get_or_insert_with(&recent_path, |_| {
2700            panic!("recently used entry should survive eviction")
2701        });
2702        assert_eq!(recent_value, 0);
2703    }
2704
2705    #[test]
2706    fn tier1_file_memo_full_scope_prunes_paths_no_longer_present() {
2707        let temp = tempfile::tempdir().unwrap();
2708        let retained_path = temp.path().join("retained.txt");
2709        let removed_path = temp.path().join("removed.txt");
2710        fs::write(&retained_path, "retained").unwrap();
2711        fs::write(&removed_path, "removed").unwrap();
2712        let memo = Tier1FileMemo::<usize>::default();
2713
2714        memo.reserve_for_scan(2);
2715        memo.get_or_insert_with(&retained_path, |path| (Some(collect_freshness(path)), 1));
2716        memo.get_or_insert_with(&removed_path, |path| (Some(collect_freshness(path)), 2));
2717        memo.prune_to_scope(temp.path(), std::slice::from_ref(&retained_path));
2718
2719        let state = memo.state.lock().unwrap();
2720        assert!(state.entries.contains_key(&retained_path));
2721        assert!(!state.entries.contains_key(&removed_path));
2722        assert_eq!(state.capacity, TIER1_FILE_MEMO_MAX_ENTRIES);
2723        drop(state);
2724
2725        let rescanned = Cell::new(false);
2726        let value = memo.get_or_insert_with(&removed_path, |path| {
2727            rescanned.set(true);
2728            (Some(collect_freshness(path)), 3)
2729        });
2730        assert!(
2731            rescanned.get(),
2732            "a path outside the latest full scope must be evicted"
2733        );
2734        assert_eq!(value, 3);
2735    }
2736
2737    #[test]
2738    fn tier1_file_memo_repeated_touches_keep_lazy_lru_bounded() {
2739        let temp = tempfile::tempdir().unwrap();
2740        let memo = Tier1FileMemo::<usize>::default();
2741        let mut paths = Vec::with_capacity(TIER1_FILE_MEMO_MAX_ENTRIES);
2742
2743        for index in 0..TIER1_FILE_MEMO_MAX_ENTRIES {
2744            let path = temp.path().join(format!("file-{index}.txt"));
2745            fs::write(&path, index.to_string()).unwrap();
2746            memo.get_or_insert_with(&path, |path| (Some(collect_freshness(path)), index));
2747            paths.push(path);
2748        }
2749
2750        for _ in 0..(TIER1_FILE_MEMO_MAX_ENTRIES * 3) {
2751            let value = memo.get_or_insert_with(&paths[0], |_| {
2752                panic!("hot entry should stay cached while it is repeatedly touched")
2753            });
2754            assert_eq!(value, 0);
2755        }
2756
2757        let evicting_path = temp.path().join("new-file.txt");
2758        fs::write(&evicting_path, "new").unwrap();
2759        memo.get_or_insert_with(&evicting_path, |path| {
2760            (Some(collect_freshness(path)), TIER1_FILE_MEMO_MAX_ENTRIES)
2761        });
2762
2763        let state = memo.state.lock().unwrap();
2764        assert_eq!(state.entries.len(), TIER1_FILE_MEMO_MAX_ENTRIES);
2765        assert!(state.entries.contains_key(&paths[0]));
2766        assert!(state.entries.contains_key(&evicting_path));
2767        assert!(!state.entries.contains_key(&paths[1]));
2768        assert!(
2769            state.lru.len() <= TIER1_FILE_MEMO_MAX_ENTRIES * 2,
2770            "lazy LRU queue should be compacted instead of growing without bound"
2771        );
2772    }
2773
2774    #[test]
2775    fn tier1_file_memo_reuses_fresh_entries_and_rescans_stale_files() {
2776        let temp = tempfile::tempdir().unwrap();
2777        let path = temp.path().join("memo.txt");
2778        fs::write(&path, "first").unwrap();
2779
2780        let memo = Tier1FileMemo::<String>::default();
2781        let scans = Cell::new(0);
2782
2783        let first = memo.get_or_insert_with(&path, |path| {
2784            scans.set(scans.get() + 1);
2785            (Some(collect_freshness(path)), "first scan".to_string())
2786        });
2787        assert_eq!(first, "first scan");
2788        assert_eq!(scans.get(), 1);
2789
2790        let unchanged =
2791            memo.get_or_insert_with(&path, |_| panic!("unchanged file should reuse Tier-1 memo"));
2792        assert_eq!(unchanged, "first scan");
2793        assert_eq!(scans.get(), 1);
2794
2795        fs::write(&path, "changed file contents").unwrap();
2796        let changed = memo.get_or_insert_with(&path, |path| {
2797            scans.set(scans.get() + 1);
2798            (Some(collect_freshness(path)), "second scan".to_string())
2799        });
2800        assert_eq!(changed, "second scan");
2801        assert_eq!(scans.get(), 2);
2802
2803        let fresh_after_rescan = memo.get_or_insert_with(&path, |_| {
2804            panic!("rescanned file should reuse refreshed Tier-1 memo")
2805        });
2806        assert_eq!(fresh_after_rescan, "second scan");
2807        assert_eq!(scans.get(), 2);
2808    }
2809
2810    #[derive(serde::Deserialize, serde::Serialize)]
2811    struct RoundTripContributionRecord {
2812        category: String,
2813        file_path: PathBuf,
2814        contribution: serde_json::Value,
2815        type_ref_names: BTreeSet<String>,
2816    }
2817
2818    impl From<&ContributionRecord> for RoundTripContributionRecord {
2819        fn from(record: &ContributionRecord) -> Self {
2820            Self {
2821                category: record.category.as_str().to_string(),
2822                file_path: record.file_path.clone(),
2823                contribution: record.contribution.clone(),
2824                type_ref_names: record.type_ref_names.clone(),
2825            }
2826        }
2827    }
2828
2829    #[test]
2830    fn contribution_record_round_trip_preserves_dead_code_liveness_metadata() {
2831        let temp = tempfile::tempdir().unwrap();
2832        let project_root = temp.path().join("project");
2833        let inspect_dir = temp.path().join("inspect");
2834        let source = project_root.join("src/lib.ts");
2835        fs::create_dir_all(source.parent().unwrap()).unwrap();
2836        fs::write(&source, "export interface Widget { id: string }\n").unwrap();
2837
2838        let cache = InspectCache::open(inspect_dir.clone(), project_root.clone()).unwrap();
2839        let contribution = FileContribution::new(
2840            InspectCategory::DeadCode,
2841            source.clone(),
2842            collect_freshness(&source),
2843            serde_json::json!({
2844                "file": "src/lib.ts",
2845                "exports": [{
2846                    "symbol": "Widget",
2847                    "kind": "interface",
2848                    "line": 1,
2849                    "is_type_like": true,
2850                    "is_entry_point": false,
2851                }],
2852                "internal_calls": [],
2853                "liveness_roots": [],
2854                "dispatched_method_names": ["render"],
2855                "macro_token_refs": [{
2856                    "caller_symbol": "render",
2857                    "line": 1,
2858                    "name": "Widget",
2859                    "shape": "struct"
2860                }],
2861                "cfg_test_ranges": [{"start_line": 10, "end_line": 20}],
2862                "type_ref_names": ["Widget"],
2863            }),
2864        )
2865        .with_type_ref_names(["Widget".to_string()]);
2866        cache
2867            .store_tier2_result(
2868                JobKey::for_project_category(InspectCategory::DeadCode),
2869                std::slice::from_ref(&source),
2870                &[contribution],
2871                serde_json::json!({ "count": 0, "items": [] }),
2872            )
2873            .unwrap();
2874        drop(cache);
2875
2876        let cache = InspectCache::open(inspect_dir, project_root).unwrap();
2877        let records = cache
2878            .load_tier2_contributions(InspectCategory::DeadCode)
2879            .unwrap();
2880        assert_eq!(records.len(), 1);
2881
2882        let serialized =
2883            serde_json::to_vec(&RoundTripContributionRecord::from(&records[0])).unwrap();
2884        let decoded: RoundTripContributionRecord = serde_json::from_slice(&serialized).unwrap();
2885        assert_eq!(decoded.category, InspectCategory::DeadCode.as_str());
2886        assert_eq!(decoded.contribution["dispatched_method_names"][0], "render");
2887        assert_eq!(decoded.contribution["type_ref_names"][0], "Widget");
2888        assert_eq!(
2889            decoded.contribution["macro_token_refs"][0]["shape"],
2890            "struct"
2891        );
2892        assert_eq!(
2893            decoded.contribution["cfg_test_ranges"][0],
2894            serde_json::json!({"start_line": 10, "end_line": 20})
2895        );
2896        assert!(decoded.type_ref_names.contains("Widget"));
2897        assert_eq!(
2898            decoded.contribution["exports"][0]["is_type_like"].as_bool(),
2899            Some(true)
2900        );
2901        assert_eq!(TIER2_CONTRIBUTION_CACHE_VERSION, 32);
2902    }
2903
2904    #[test]
2905    fn duplicate_expected_mirrors_participate_in_aggregate_cache_hash() {
2906        let temp = tempfile::tempdir().unwrap();
2907        let project_root = temp.path().join("project");
2908        fs::create_dir_all(&project_root).unwrap();
2909        let left = project_root.join("plugin/a.ts");
2910        let right = project_root.join("pi-plugin/a.ts");
2911        fs::create_dir_all(left.parent().unwrap()).unwrap();
2912        fs::create_dir_all(right.parent().unwrap()).unwrap();
2913        fs::write(&left, "export const value = 1;\n").unwrap();
2914        fs::write(&right, "export const value = 1;\n").unwrap();
2915
2916        let cache = InspectCache::open(temp.path().join("inspect"), project_root.clone()).unwrap();
2917        let contributions = vec![
2918            FileContribution::new(
2919                InspectCategory::Duplicates,
2920                left.clone(),
2921                collect_freshness(&left),
2922                serde_json::json!({ "file": "plugin/a.ts", "line_count": 1, "fragments": [] }),
2923            ),
2924            FileContribution::new(
2925                InspectCategory::Duplicates,
2926                right.clone(),
2927                collect_freshness(&right),
2928                serde_json::json!({ "file": "pi-plugin/a.ts", "line_count": 1, "fragments": [] }),
2929            ),
2930        ];
2931        let config = Config::default();
2932        cache
2933            .store_tier2_result_for_config(
2934                JobKey::for_project_category(InspectCategory::Duplicates),
2935                &[left.clone(), right.clone()],
2936                &contributions,
2937                serde_json::json!({ "count": 0, "items": [] }),
2938                &config,
2939            )
2940            .unwrap();
2941
2942        let without_mirrors = cache
2943            .contribution_set_hash_for_config(InspectCategory::Duplicates, &config)
2944            .unwrap();
2945        let mut mirror_config = Config::default();
2946        mirror_config.inspect.duplicates.expected_mirrors =
2947            vec![["plugin/**".to_string(), "pi-plugin/**".to_string()]];
2948        let with_mirrors = cache
2949            .contribution_set_hash_for_config(InspectCategory::Duplicates, &mirror_config)
2950            .unwrap();
2951
2952        assert_ne!(without_mirrors, with_mirrors);
2953    }
2954}
2955
2956#[cfg(test)]
2957mod memory_estimate_tests {
2958    use super::*;
2959
2960    #[test]
2961    fn inspect_memory_estimate_is_zero_when_empty_and_nonzero_when_populated() {
2962        let root = tempfile::tempdir().expect("project root");
2963        let project_root = std::fs::canonicalize(root.path()).expect("canonical project root");
2964        let storage = tempfile::tempdir().expect("inspect storage");
2965        let cache = InspectCache::open(storage.path().to_path_buf(), project_root)
2966            .expect("open inspect cache");
2967        assert_eq!(cache.estimated_memory().estimated_bytes, Some(0));
2968
2969        cache
2970            .store_aggregated(
2971                JobKey::for_project_category(InspectCategory::Todos),
2972                serde_json::json!({"count": 1, "items": [{"text": "resident todo"}]}),
2973            )
2974            .expect("store memory aggregate");
2975        let estimate = cache.estimated_memory();
2976        assert!(estimate.estimated_bytes.unwrap() > 0);
2977        assert_eq!(estimate.counts["memory_aggregates"], 1);
2978        assert_eq!(estimate.counts["open_generation_handles"], 1);
2979    }
2980}