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