Skip to main content

aft/inspect/
cache.rs

1use std::collections::{BTreeSet, HashMap, VecDeque};
2use std::fmt;
3use std::fs;
4use std::path::{Path, PathBuf};
5use std::sync::{Mutex, RwLock};
6use std::time::{Duration, SystemTime, UNIX_EPOCH};
7
8use rusqlite::{params, Connection, OpenFlags, OptionalExtension};
9
10use crate::cache_freshness::{FileFreshness, FreshnessVerdict};
11use crate::config::Config;
12use crate::jsonc::strip_jsonc;
13
14use super::job::{
15    contribution_with_type_ref_names, type_ref_names_from_contribution, FileContribution,
16    InspectCategory, JobKey,
17};
18
19#[derive(Debug, Default)]
20pub(crate) struct Tier2ContributionUpdates {
21    pub upserts: Vec<FileContribution>,
22    pub deletes: Vec<PathBuf>,
23    pub metadata_updates: Vec<(PathBuf, FileFreshness)>,
24}
25
26#[derive(Debug)]
27pub enum InspectCacheError {
28    Io(std::io::Error),
29    Sql(rusqlite::Error),
30    Json(serde_json::Error),
31    LockPoisoned(&'static str),
32    InvalidHash(String),
33}
34
35impl fmt::Display for InspectCacheError {
36    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
37        match self {
38            InspectCacheError::Io(error) => write!(formatter, "inspect cache io error: {error}"),
39            InspectCacheError::Sql(error) => {
40                write!(formatter, "inspect cache sqlite error: {error}")
41            }
42            InspectCacheError::Json(error) => {
43                write!(formatter, "inspect cache json error: {error}")
44            }
45            InspectCacheError::LockPoisoned(name) => {
46                write!(formatter, "inspect cache lock poisoned: {name}")
47            }
48            InspectCacheError::InvalidHash(hash) => {
49                write!(formatter, "inspect cache invalid blake3 hash: {hash}")
50            }
51        }
52    }
53}
54
55impl std::error::Error for InspectCacheError {}
56
57impl From<std::io::Error> for InspectCacheError {
58    fn from(error: std::io::Error) -> Self {
59        Self::Io(error)
60    }
61}
62
63impl From<rusqlite::Error> for InspectCacheError {
64    fn from(error: rusqlite::Error) -> Self {
65        Self::Sql(error)
66    }
67}
68
69impl From<serde_json::Error> for InspectCacheError {
70    fn from(error: serde_json::Error) -> Self {
71        Self::Json(error)
72    }
73}
74
75/// Persisted Tier-2 contribution/aggregate format version.
76///
77/// Bump this when `FileContribution.contribution` JSON changes in a way that
78/// requires existing per-file contributions to be rebuilt before roll-up, OR
79/// when the roll-up/aggregation LOGIC changes (e.g. dead_code reachability):
80/// cached aggregates are keyed by a `contribution_set_hash` that folds in this
81/// version, so a logic-only change is invisible to existing caches unless the
82/// version moves. v6: dead_code now propagates liveness through dispatch-only
83/// method bodies (free fns reached only via `obj.method()` were false-dead).
84/// v7: duplicates now collapses nested/overlapping fragments (a duplicated
85/// block no longer reports every nested subtree as its own group).
86/// v8: entry-point recognition seeds npm `scripts` source files as liveness
87/// roots (baked into per-file liveness_roots), and dead_code/unused_exports
88/// exclude test-support files (fixtures/corpora/mocks) from reporting.
89/// v9: unused_exports resolves NodeNext `./x.js` import specifiers to their
90/// `.ts` source (alters resolved import edges), fixing false-unused on symbols
91/// re-exported/imported with a `.js` extension in a `.ts` source tree.
92/// v10: public-API entry resolution remaps build-output entries (dist/index.js)
93/// to their src/ source equivalent, so the source barrel is recognized as a
94/// public-API file and its re-exports are suppressed (changes public-API set).
95/// v11: dead_code/unused_exports drill-down is ranked by signal tier (product
96/// findings before benchmark/tooling noise) before the cap, and a ranked `top`
97/// preview is folded into all three Tier-2 aggregates — changes cached payload.
98/// v12: dead_code internal call rows include call-edge provenance, changing
99/// cached per-file contribution payloads and aggregate roll-up inputs.
100/// v13: dead_code callgraph snapshots are projected from the persisted
101/// CallgraphStore; per-row provenance now reflects store resolution tiers.
102/// v14: TS/JS dead_code and unused_exports contributions carry oxc verdicts,
103/// provenance, and oxc honesty metadata.
104/// v15: dead_code reachability counts exact type_match call edges as resolved
105/// liveness (qualified-constructor calls like AppContext::new -> BackupStore::new
106/// no longer collapse to bare `new` and drop), changing the dead verdict for the
107/// same contribution set — existing caches must invalidate.
108/// v16: unused_exports stores raw oxc FileFacts and recomputes verdicts during
109/// roll-up, enabling incremental one-file reparses without stale verdicts.
110/// v17: dead_code stores raw per-file facts and recomputes callgraph/re-export,
111/// entry-root, imported-export, and oxc verdict liveness during roll-up.
112/// v18: dead_code/unused_exports aggregate hashes include the full TS/JS
113/// resolver-config dependency set (tsconfig/jsconfig variants and extends
114/// chains), so alias-only config edits invalidate verdict roll-ups.
115/// v19: dead_code entry reachability executes side-effect-only imported modules,
116/// preserving same-file and transitive static-import liveness without marking all
117/// target exports used.
118/// v20: duplicates aggregate hashes include inspect.duplicates.expected_mirrors,
119/// so changing intentional mirror architecture rules invalidates cached roll-ups.
120/// v21: Rust dead_code contributions carry attribute-root entry facts for
121/// externally-invoked functions such as Tauri commands and ABI exports.
122/// v22: cycles persists TS/JS resolved import-edge facts and rolls them up into
123/// strongly connected module components.
124/// v23: TS/JS re-export verdicts collapse barrel aliases to canonical exports
125/// and carry non-counted re-export context on canonical findings.
126/// v24: TS/JS verdicts distinguish product references from test-only
127/// references so test-only usage moves out of headline dead/unused counts.
128/// v25: framework file-based route files execute as roots while only their
129/// framework-called exports are seeded live; manifest-driven route detection
130/// changes dead/unused verdict roll-ups without changing per-file facts.
131/// v26: TS/JS per-file facts record exported-symbol decorators, and NestJS
132/// decorator roots change dead/unused verdict roll-ups.
133/// v28: Rust dead-code facts include function/type names found inside macro
134/// token trees. The aggregate scan uses those names for reachability only;
135/// call-graph navigation remains based on resolved source-level calls.
136pub(crate) const TIER2_CONTRIBUTION_CACHE_VERSION: u32 = 28;
137
138#[derive(Debug, Clone)]
139pub struct ContributionRecord {
140    pub category: InspectCategory,
141    pub file_path: PathBuf,
142    pub freshness: FileFreshness,
143    pub contribution: serde_json::Value,
144    pub type_ref_names: BTreeSet<String>,
145}
146
147#[derive(Debug, Clone)]
148struct MemoryAggregate {
149    payload: serde_json::Value,
150    generated_at: i64,
151    contribution_set_hash: Option<String>,
152}
153
154const TIER1_FILE_MEMO_MAX_ENTRIES: usize = 4_096;
155
156#[derive(Debug, Clone)]
157struct Tier1MemoEntry<T> {
158    freshness: FileFreshness,
159    value: T,
160    generation: u64,
161}
162
163#[derive(Debug, Clone)]
164struct LruNode {
165    path: PathBuf,
166    generation: u64,
167}
168
169#[derive(Debug)]
170struct Tier1MemoState<T> {
171    entries: HashMap<PathBuf, Tier1MemoEntry<T>>,
172    lru: VecDeque<LruNode>,
173    next_generation: u64,
174}
175
176impl<T> Default for Tier1MemoState<T> {
177    fn default() -> Self {
178        Self {
179            entries: HashMap::new(),
180            lru: VecDeque::new(),
181            next_generation: 0,
182        }
183    }
184}
185
186impl<T> Tier1MemoState<T> {
187    fn insert(&mut self, path: PathBuf, mut entry: Tier1MemoEntry<T>) {
188        let generation = self.allocate_generation();
189        entry.generation = generation;
190        self.entries.insert(path.clone(), entry);
191        self.lru.push_back(LruNode { path, generation });
192        self.compact_lru_if_needed();
193        self.evict_lru();
194    }
195
196    fn remove(&mut self, path: &Path) {
197        self.entries.remove(path);
198        self.compact_lru_if_needed();
199    }
200
201    fn touch(&mut self, path: &Path) {
202        if !self.entries.contains_key(path) {
203            return;
204        }
205
206        let generation = self.allocate_generation();
207        if let Some(entry) = self.entries.get_mut(path) {
208            entry.generation = generation;
209            self.lru.push_back(LruNode {
210                path: path.to_path_buf(),
211                generation,
212            });
213        }
214        self.compact_lru_if_needed();
215    }
216
217    fn allocate_generation(&mut self) -> u64 {
218        if self.next_generation == u64::MAX {
219            self.rebuild_lru();
220        }
221        let generation = self.next_generation;
222        self.next_generation += 1;
223        generation
224    }
225
226    fn compact_lru_if_needed(&mut self) {
227        let max_lru_nodes = TIER1_FILE_MEMO_MAX_ENTRIES
228            .saturating_mul(2)
229            .max(self.entries.len());
230        if self.lru.len() > max_lru_nodes {
231            self.rebuild_lru();
232        }
233    }
234
235    fn rebuild_lru(&mut self) {
236        let mut live_nodes = self
237            .entries
238            .iter()
239            .map(|(path, entry)| (entry.generation, path.clone()))
240            .collect::<Vec<_>>();
241        live_nodes.sort_by_key(|(generation, _)| *generation);
242
243        self.lru.clear();
244        for (generation, (_, path)) in live_nodes.into_iter().enumerate() {
245            let generation = generation as u64;
246            if let Some(entry) = self.entries.get_mut(&path) {
247                entry.generation = generation;
248            }
249            self.lru.push_back(LruNode { path, generation });
250        }
251        self.next_generation = self.lru.len() as u64;
252    }
253
254    fn evict_lru(&mut self) {
255        while self.entries.len() > TIER1_FILE_MEMO_MAX_ENTRIES {
256            let Some(node) = self.lru.pop_front() else {
257                break;
258            };
259            if self
260                .entries
261                .get(&node.path)
262                .is_some_and(|entry| entry.generation == node.generation)
263            {
264                self.entries.remove(&node.path);
265            }
266        }
267        self.compact_lru_if_needed();
268    }
269}
270
271#[derive(Debug)]
272pub(crate) struct Tier1FileMemo<T> {
273    state: Mutex<Tier1MemoState<T>>,
274}
275
276impl<T> Default for Tier1FileMemo<T> {
277    fn default() -> Self {
278        Self {
279            state: Mutex::new(Tier1MemoState::default()),
280        }
281    }
282}
283
284impl<T: Clone> Tier1FileMemo<T> {
285    pub(crate) fn get_or_insert_with<F>(&self, path: &Path, scan: F) -> T
286    where
287        F: FnOnce(&Path) -> (Option<FileFreshness>, T),
288    {
289        if let Some(cached) = self.cached_value(path) {
290            return cached;
291        }
292
293        let (freshness, value) = scan(path);
294        if let Ok(mut state) = self.state.lock() {
295            if let Some(freshness) = freshness {
296                state.insert(
297                    path.to_path_buf(),
298                    Tier1MemoEntry {
299                        freshness,
300                        value: value.clone(),
301                        generation: 0,
302                    },
303                );
304            } else {
305                state.remove(path);
306            }
307        }
308        value
309    }
310
311    fn cached_value(&self, path: &Path) -> Option<T> {
312        let mut cached = self
313            .state
314            .lock()
315            .ok()
316            .and_then(|state| state.entries.get(path).cloned())?;
317
318        match crate::cache_freshness::verify_file(path, &cached.freshness) {
319            FreshnessVerdict::HotFresh => {
320                if let Ok(mut state) = self.state.lock() {
321                    state.touch(path);
322                }
323                Some(cached.value)
324            }
325            FreshnessVerdict::ContentFresh {
326                new_mtime,
327                new_size,
328            } => {
329                cached.freshness.mtime = new_mtime;
330                cached.freshness.size = new_size;
331                let value = cached.value.clone();
332                if let Ok(mut state) = self.state.lock() {
333                    state.insert(path.to_path_buf(), cached);
334                }
335                Some(value)
336            }
337            FreshnessVerdict::Stale => None,
338            FreshnessVerdict::Deleted => {
339                if let Ok(mut state) = self.state.lock() {
340                    state.remove(path);
341                }
342                None
343            }
344        }
345    }
346}
347
348#[derive(Debug)]
349pub struct InspectCache {
350    project_root: PathBuf,
351    project_key: String,
352    sqlite_path: PathBuf,
353    conn: Mutex<Connection>,
354    memory: RwLock<HashMap<JobKey, MemoryAggregate>>,
355}
356
357impl InspectCache {
358    pub fn open(inspect_dir: PathBuf, project_root: PathBuf) -> Result<Self, InspectCacheError> {
359        std::fs::create_dir_all(&inspect_dir)?;
360        let project_key = crate::search_index::artifact_cache_key(&project_root);
361        let sqlite_path = inspect_dir.join(format!("{project_key}.sqlite"));
362        let conn = Connection::open(&sqlite_path)?;
363        configure_connection(&conn)?;
364        initialize_schema(&conn)?;
365        Ok(Self::from_connection(
366            project_root,
367            project_key,
368            sqlite_path,
369            conn,
370        ))
371    }
372
373    pub fn open_readonly(
374        inspect_dir: PathBuf,
375        project_root: PathBuf,
376    ) -> Result<Option<Self>, InspectCacheError> {
377        let project_key = crate::search_index::artifact_cache_key(&project_root);
378        let sqlite_path = inspect_dir.join(format!("{project_key}.sqlite"));
379        if !sqlite_path.is_file() {
380            return Ok(None);
381        }
382        let conn = Connection::open_with_flags(&sqlite_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
383        conn.busy_timeout(Duration::from_millis(5_000))?;
384        Ok(Some(Self::from_connection(
385            project_root,
386            project_key,
387            sqlite_path,
388            conn,
389        )))
390    }
391
392    fn from_connection(
393        project_root: PathBuf,
394        project_key: String,
395        sqlite_path: PathBuf,
396        conn: Connection,
397    ) -> Self {
398        Self {
399            project_root,
400            project_key,
401            sqlite_path,
402            conn: Mutex::new(conn),
403            memory: RwLock::new(HashMap::new()),
404        }
405    }
406
407    pub fn project_root(&self) -> &Path {
408        &self.project_root
409    }
410
411    pub fn project_key(&self) -> &str {
412        &self.project_key
413    }
414
415    pub fn sqlite_path(&self) -> &Path {
416        &self.sqlite_path
417    }
418
419    pub fn store_aggregated(
420        &self,
421        key: JobKey,
422        payload: serde_json::Value,
423    ) -> Result<(), InspectCacheError> {
424        self.store_memory_aggregate(key, payload, None)
425    }
426
427    fn store_memory_aggregate(
428        &self,
429        key: JobKey,
430        payload: serde_json::Value,
431        contribution_set_hash: Option<String>,
432    ) -> Result<(), InspectCacheError> {
433        self.memory
434            .write()
435            .map_err(|_| InspectCacheError::LockPoisoned("memory"))?
436            .insert(
437                key,
438                MemoryAggregate {
439                    payload,
440                    generated_at: unix_seconds_now(),
441                    contribution_set_hash,
442                },
443            );
444        Ok(())
445    }
446
447    pub fn get_aggregated(
448        &self,
449        key: &JobKey,
450    ) -> Result<Option<serde_json::Value>, InspectCacheError> {
451        self.get_aggregated_with_config(key, None)
452    }
453
454    pub fn get_aggregated_for_config(
455        &self,
456        key: &JobKey,
457        config: &Config,
458    ) -> Result<Option<serde_json::Value>, InspectCacheError> {
459        self.get_aggregated_with_config(key, Some(config))
460    }
461
462    fn get_aggregated_with_config(
463        &self,
464        key: &JobKey,
465        config: Option<&Config>,
466    ) -> Result<Option<serde_json::Value>, InspectCacheError> {
467        if !key.category.is_tier2() {
468            return Ok(self
469                .memory
470                .read()
471                .map_err(|_| InspectCacheError::LockPoisoned("memory"))?
472                .get(key)
473                .map(|entry| entry.payload.clone()));
474        }
475
476        let current_hash = {
477            let conn = self
478                .conn
479                .lock()
480                .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
481            contribution_set_hash_with_conn(
482                &conn,
483                key.category,
484                &self.project_key,
485                &self.project_root,
486                config,
487            )?
488        };
489
490        let memory_entry = {
491            self.memory
492                .read()
493                .map_err(|_| InspectCacheError::LockPoisoned("memory"))?
494                .get(key)
495                .cloned()
496        };
497        if let Some(entry) = memory_entry {
498            if entry.contribution_set_hash.as_deref() == Some(current_hash.as_str()) {
499                return Ok(Some(entry.payload));
500            }
501            self.memory
502                .write()
503                .map_err(|_| InspectCacheError::LockPoisoned("memory"))?
504                .remove(key);
505        }
506
507        let payload = {
508            let conn = self
509                .conn
510                .lock()
511                .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
512            conn.query_row(
513                "SELECT aggregate FROM tier2_aggregates \
514                 WHERE category = ?1 AND project_key = ?2 AND contribution_set_hash = ?3",
515                params![key.category.as_str(), self.project_key, current_hash],
516                |row| row.get::<_, Vec<u8>>(0),
517            )
518            .optional()?
519        };
520
521        match payload {
522            Some(bytes) => {
523                let value = serde_json::from_slice::<serde_json::Value>(&bytes)?;
524                self.store_memory_aggregate(key.clone(), value.clone(), Some(current_hash))?;
525                Ok(Some(value))
526            }
527            None => Ok(None),
528        }
529    }
530
531    pub fn store_tier2_result(
532        &self,
533        key: JobKey,
534        scanned_files: &[PathBuf],
535        contributions: &[FileContribution],
536        aggregate: serde_json::Value,
537    ) -> Result<(), InspectCacheError> {
538        self.store_tier2_result_with_config(key, scanned_files, contributions, aggregate, None)
539    }
540
541    pub fn store_tier2_result_for_config(
542        &self,
543        key: JobKey,
544        scanned_files: &[PathBuf],
545        contributions: &[FileContribution],
546        aggregate: serde_json::Value,
547        config: &Config,
548    ) -> Result<(), InspectCacheError> {
549        self.store_tier2_result_with_config(
550            key,
551            scanned_files,
552            contributions,
553            aggregate,
554            Some(config),
555        )
556    }
557
558    fn store_tier2_result_with_config(
559        &self,
560        key: JobKey,
561        scanned_files: &[PathBuf],
562        contributions: &[FileContribution],
563        aggregate: serde_json::Value,
564        config: Option<&Config>,
565    ) -> Result<(), InspectCacheError> {
566        if !key.category.is_tier2() {
567            self.store_aggregated(key, aggregate)?;
568            return Ok(());
569        }
570
571        let now = unix_seconds_now();
572        let mut conn = self
573            .conn
574            .lock()
575            .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
576        let tx = conn.transaction()?;
577
578        let scanned_relative = scanned_files
579            .iter()
580            .map(|path| relative_string(&self.project_root, path))
581            .collect::<BTreeSet<_>>();
582        let existing = existing_contribution_paths(&tx, key.category, &self.project_key)?;
583        for file_path in existing {
584            if !scanned_relative.contains(&file_path) {
585                tx.execute(
586                    "DELETE FROM tier2_contributions WHERE category = ?1 AND project_key = ?2 AND file_path = ?3",
587                    params![key.category.as_str(), self.project_key, file_path],
588                )?;
589            }
590        }
591
592        for contribution in contributions {
593            let file_path = relative_string(&self.project_root, &contribution.file_path);
594            let blob = serde_json::to_vec(&contribution_with_type_ref_names(
595                contribution.contribution.clone(),
596                &contribution.type_ref_names,
597            ))?;
598            tx.execute(
599                "INSERT INTO tier2_contributions \
600                 (category, project_key, file_path, file_mtime_ns, file_size, file_hash, contribution, generated_at) \
601                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) \
602                 ON CONFLICT(category, project_key, file_path) DO UPDATE SET \
603                 file_mtime_ns = excluded.file_mtime_ns, \
604                 file_size = excluded.file_size, \
605                 file_hash = excluded.file_hash, \
606                 contribution = excluded.contribution, \
607                 generated_at = excluded.generated_at",
608                params![
609                    contribution.category.as_str(),
610                    self.project_key,
611                    file_path,
612                    system_time_to_ns(contribution.freshness.mtime),
613                    contribution.freshness.size as i64,
614                    hash_to_hex(contribution.freshness.content_hash),
615                    blob,
616                    now,
617                ],
618            )?;
619        }
620
621        let contribution_set_hash = contribution_set_hash_with_conn(
622            &tx,
623            key.category,
624            &self.project_key,
625            &self.project_root,
626            config,
627        )?;
628        let aggregate_blob = serde_json::to_vec(&aggregate)?;
629        tx.execute(
630            "INSERT INTO tier2_aggregates \
631             (category, project_key, contribution_set_hash, aggregate, generated_at) \
632             VALUES (?1, ?2, ?3, ?4, ?5) \
633             ON CONFLICT(category, project_key) DO UPDATE SET \
634             contribution_set_hash = excluded.contribution_set_hash, \
635             aggregate = excluded.aggregate, \
636             generated_at = excluded.generated_at",
637            params![
638                key.category.as_str(),
639                self.project_key,
640                contribution_set_hash,
641                aggregate_blob,
642                now,
643            ],
644        )?;
645        tx.execute(
646            "INSERT INTO tier2_meta (category, project_key, last_full_run) VALUES (?1, ?2, ?3) \
647             ON CONFLICT(category, project_key) DO UPDATE SET last_full_run = excluded.last_full_run",
648            params![key.category.as_str(), self.project_key, now],
649        )?;
650        tx.commit()?;
651
652        self.store_memory_aggregate(key, aggregate, Some(contribution_set_hash))
653    }
654
655    pub(crate) fn apply_contribution_updates_for_config(
656        &self,
657        category: InspectCategory,
658        updates: Tier2ContributionUpdates,
659        config: &Config,
660    ) -> Result<String, InspectCacheError> {
661        self.apply_contribution_updates_with_config(category, updates, Some(config))
662    }
663
664    fn apply_contribution_updates_with_config(
665        &self,
666        category: InspectCategory,
667        updates: Tier2ContributionUpdates,
668        config: Option<&Config>,
669    ) -> Result<String, InspectCacheError> {
670        let now = unix_seconds_now();
671        let mut conn = self
672            .conn
673            .lock()
674            .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
675        let tx = conn.transaction()?;
676
677        for relative_file in updates.deletes {
678            tx.execute(
679                "DELETE FROM tier2_contributions WHERE category = ?1 AND project_key = ?2 AND file_path = ?3",
680                params![
681                    category.as_str(),
682                    self.project_key,
683                    relative_file.to_string_lossy().to_string()
684                ],
685            )?;
686        }
687
688        for (relative_file, freshness) in updates.metadata_updates {
689            tx.execute(
690                "UPDATE tier2_contributions \
691                 SET file_mtime_ns = ?4, file_size = ?5, file_hash = ?6 \
692                 WHERE category = ?1 AND project_key = ?2 AND file_path = ?3",
693                params![
694                    category.as_str(),
695                    self.project_key,
696                    relative_file.to_string_lossy().to_string(),
697                    system_time_to_ns(freshness.mtime),
698                    freshness.size as i64,
699                    hash_to_hex(freshness.content_hash),
700                ],
701            )?;
702        }
703
704        for contribution in updates.upserts {
705            let file_path = relative_string(&self.project_root, &contribution.file_path);
706            let blob = serde_json::to_vec(&contribution_with_type_ref_names(
707                contribution.contribution.clone(),
708                &contribution.type_ref_names,
709            ))?;
710            tx.execute(
711                "INSERT INTO tier2_contributions \
712                 (category, project_key, file_path, file_mtime_ns, file_size, file_hash, contribution, generated_at) \
713                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) \
714                 ON CONFLICT(category, project_key, file_path) DO UPDATE SET \
715                 file_mtime_ns = excluded.file_mtime_ns, \
716                 file_size = excluded.file_size, \
717                 file_hash = excluded.file_hash, \
718                 contribution = excluded.contribution, \
719                 generated_at = excluded.generated_at",
720                params![
721                    contribution.category.as_str(),
722                    self.project_key,
723                    file_path,
724                    system_time_to_ns(contribution.freshness.mtime),
725                    contribution.freshness.size as i64,
726                    hash_to_hex(contribution.freshness.content_hash),
727                    blob,
728                    now,
729                ],
730            )?;
731        }
732
733        let contribution_set_hash = contribution_set_hash_with_conn(
734            &tx,
735            category,
736            &self.project_key,
737            &self.project_root,
738            config,
739        )?;
740        tx.commit()?;
741
742        self.memory
743            .write()
744            .map_err(|_| InspectCacheError::LockPoisoned("memory"))?
745            .remove(&JobKey::for_project_category(category));
746
747        Ok(contribution_set_hash)
748    }
749
750    pub(crate) fn load_aggregate_if_hash_matches(
751        &self,
752        category: InspectCategory,
753        contribution_set_hash: &str,
754    ) -> Result<Option<serde_json::Value>, InspectCacheError> {
755        let payload = {
756            let conn = self
757                .conn
758                .lock()
759                .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
760            conn.query_row(
761                "SELECT aggregate FROM tier2_aggregates \
762                 WHERE category = ?1 AND project_key = ?2 AND contribution_set_hash = ?3",
763                params![category.as_str(), self.project_key, contribution_set_hash],
764                |row| row.get::<_, Vec<u8>>(0),
765            )
766            .optional()?
767        };
768
769        match payload {
770            Some(bytes) => {
771                let value = serde_json::from_slice::<serde_json::Value>(&bytes)?;
772                self.store_memory_aggregate(
773                    JobKey::for_project_category(category),
774                    value.clone(),
775                    Some(contribution_set_hash.to_string()),
776                )?;
777                Ok(Some(value))
778            }
779            None => Ok(None),
780        }
781    }
782
783    pub(crate) fn latest_aggregate_any_hash(
784        &self,
785        category: InspectCategory,
786    ) -> Result<Option<serde_json::Value>, InspectCacheError> {
787        let payload = {
788            let conn = self
789                .conn
790                .lock()
791                .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
792            conn.query_row(
793                "SELECT aggregate FROM tier2_aggregates \
794                 WHERE category = ?1 AND project_key = ?2 \
795                 ORDER BY generated_at DESC LIMIT 1",
796                params![category.as_str(), self.project_key],
797                |row| row.get::<_, Vec<u8>>(0),
798            )
799            .optional()?
800        };
801
802        match payload {
803            Some(bytes) => serde_json::from_slice::<serde_json::Value>(&bytes)
804                .map(Some)
805                .map_err(InspectCacheError::from),
806            None => Ok(None),
807        }
808    }
809
810    pub(crate) fn touch_tier2_last_full_run(
811        &self,
812        category: InspectCategory,
813    ) -> Result<i64, InspectCacheError> {
814        let mut conn = self
815            .conn
816            .lock()
817            .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
818        let tx = conn.transaction()?;
819        let previous = tx
820            .query_row(
821                "SELECT last_full_run FROM tier2_meta WHERE category = ?1 AND project_key = ?2",
822                params![category.as_str(), self.project_key],
823                |row| row.get::<_, i64>(0),
824            )
825            .optional()?;
826        let now = unix_seconds_now();
827        let last_full_run = previous.map_or(now, |previous| now.max(previous.saturating_add(1)));
828        tx.execute(
829            "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",
830            params![category.as_str(), self.project_key, last_full_run],
831        )?;
832        tx.commit()?;
833        Ok(last_full_run)
834    }
835
836    pub(crate) fn store_tier2_aggregate(
837        &self,
838        key: JobKey,
839        contribution_set_hash: &str,
840        aggregate: serde_json::Value,
841    ) -> Result<(), InspectCacheError> {
842        if !key.category.is_tier2() {
843            self.store_aggregated(key, aggregate)?;
844            return Ok(());
845        }
846
847        let now = unix_seconds_now();
848        let aggregate_blob = serde_json::to_vec(&aggregate)?;
849        let mut conn = self
850            .conn
851            .lock()
852            .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
853        let tx = conn.transaction()?;
854        tx.execute(
855            "INSERT INTO tier2_aggregates \
856             (category, project_key, contribution_set_hash, aggregate, generated_at) \
857             VALUES (?1, ?2, ?3, ?4, ?5) \
858             ON CONFLICT(category, project_key) DO UPDATE SET \
859             contribution_set_hash = excluded.contribution_set_hash, \
860             aggregate = excluded.aggregate, \
861             generated_at = excluded.generated_at",
862            params![
863                key.category.as_str(),
864                self.project_key,
865                contribution_set_hash,
866                aggregate_blob,
867                now,
868            ],
869        )?;
870        tx.execute(
871            "INSERT INTO tier2_meta (category, project_key, last_full_run) VALUES (?1, ?2, ?3) \
872             ON CONFLICT(category, project_key) DO UPDATE SET last_full_run = excluded.last_full_run",
873            params![key.category.as_str(), self.project_key, now],
874        )?;
875        tx.commit()?;
876
877        self.store_memory_aggregate(key, aggregate, Some(contribution_set_hash.to_string()))
878    }
879
880    pub fn load_tier2_contributions(
881        &self,
882        category: InspectCategory,
883    ) -> Result<Vec<ContributionRecord>, InspectCacheError> {
884        let conn = self
885            .conn
886            .lock()
887            .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
888        let mut stmt = conn.prepare(
889            "SELECT file_path, file_mtime_ns, file_size, file_hash, contribution \
890             FROM tier2_contributions \
891             WHERE category = ?1 AND project_key = ?2 \
892             ORDER BY file_path ASC",
893        )?;
894        let rows = stmt.query_map(params![category.as_str(), self.project_key], |row| {
895            let file_path: String = row.get(0)?;
896            let mtime_ns: i64 = row.get(1)?;
897            let file_size: i64 = row.get(2)?;
898            let file_hash: String = row.get(3)?;
899            let contribution: Vec<u8> = row.get(4)?;
900            Ok((file_path, mtime_ns, file_size, file_hash, contribution))
901        })?;
902
903        let mut records = Vec::new();
904        for row in rows {
905            let (file_path, mtime_ns, file_size, file_hash, contribution) = row?;
906            let contribution: serde_json::Value = serde_json::from_slice(&contribution)?;
907            let type_ref_names = type_ref_names_from_contribution(&contribution);
908            records.push(ContributionRecord {
909                category,
910                file_path: PathBuf::from(file_path),
911                freshness: FileFreshness {
912                    mtime: ns_to_system_time(mtime_ns),
913                    size: file_size.max(0) as u64,
914                    content_hash: hash_from_hex(&file_hash)?,
915                },
916                contribution,
917                type_ref_names,
918            });
919        }
920        Ok(records)
921    }
922
923    pub fn delete_tier2_contribution(
924        &self,
925        category: InspectCategory,
926        relative_file: &Path,
927    ) -> Result<(), InspectCacheError> {
928        let conn = self
929            .conn
930            .lock()
931            .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
932        conn.execute(
933            "DELETE FROM tier2_contributions WHERE category = ?1 AND project_key = ?2 AND file_path = ?3",
934            params![
935                category.as_str(),
936                self.project_key,
937                relative_file.to_string_lossy().to_string()
938            ],
939        )?;
940        Ok(())
941    }
942
943    pub fn update_content_fresh_metadata(
944        &self,
945        category: InspectCategory,
946        relative_file: &Path,
947        freshness: &FileFreshness,
948    ) -> Result<(), InspectCacheError> {
949        let conn = self
950            .conn
951            .lock()
952            .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
953        conn.execute(
954            "UPDATE tier2_contributions \
955             SET file_mtime_ns = ?4, file_size = ?5, file_hash = ?6 \
956             WHERE category = ?1 AND project_key = ?2 AND file_path = ?3",
957            params![
958                category.as_str(),
959                self.project_key,
960                relative_file.to_string_lossy().to_string(),
961                system_time_to_ns(freshness.mtime),
962                freshness.size as i64,
963                hash_to_hex(freshness.content_hash),
964            ],
965        )?;
966        Ok(())
967    }
968
969    pub(crate) fn contribution_freshness(
970        &self,
971        category: InspectCategory,
972    ) -> Result<Vec<(PathBuf, FileFreshness)>, InspectCacheError> {
973        let conn = self
974            .conn
975            .lock()
976            .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
977        let mut stmt = conn.prepare(
978            "SELECT file_path, file_mtime_ns, file_size, file_hash \
979             FROM tier2_contributions \
980             WHERE category = ?1 AND project_key = ?2 \
981             ORDER BY file_path ASC",
982        )?;
983        let rows = stmt.query_map(params![category.as_str(), self.project_key], |row| {
984            Ok((
985                row.get::<_, String>(0)?,
986                row.get::<_, i64>(1)?,
987                row.get::<_, i64>(2)?,
988                row.get::<_, String>(3)?,
989            ))
990        })?;
991
992        let mut records = Vec::new();
993        for row in rows {
994            let (file_path, mtime_ns, file_size, file_hash) = row?;
995            records.push((
996                PathBuf::from(file_path),
997                FileFreshness {
998                    mtime: ns_to_system_time(mtime_ns),
999                    size: file_size.max(0) as u64,
1000                    content_hash: hash_from_hex(&file_hash)?,
1001                },
1002            ));
1003        }
1004        Ok(records)
1005    }
1006
1007    pub fn contribution_set_hash(
1008        &self,
1009        category: InspectCategory,
1010    ) -> Result<String, InspectCacheError> {
1011        self.contribution_set_hash_with_config(category, None)
1012    }
1013
1014    pub fn contribution_set_hash_for_config(
1015        &self,
1016        category: InspectCategory,
1017        config: &Config,
1018    ) -> Result<String, InspectCacheError> {
1019        self.contribution_set_hash_with_config(category, Some(config))
1020    }
1021
1022    fn contribution_set_hash_with_config(
1023        &self,
1024        category: InspectCategory,
1025        config: Option<&Config>,
1026    ) -> Result<String, InspectCacheError> {
1027        let conn = self
1028            .conn
1029            .lock()
1030            .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
1031        contribution_set_hash_with_conn(
1032            &conn,
1033            category,
1034            &self.project_key,
1035            &self.project_root,
1036            config,
1037        )
1038    }
1039
1040    pub fn last_full_run(
1041        &self,
1042        category: InspectCategory,
1043    ) -> Result<Option<i64>, InspectCacheError> {
1044        let conn = self
1045            .conn
1046            .lock()
1047            .map_err(|_| InspectCacheError::LockPoisoned("connection"))?;
1048        conn.query_row(
1049            "SELECT last_full_run FROM tier2_meta WHERE category = ?1 AND project_key = ?2",
1050            params![category.as_str(), self.project_key],
1051            |row| row.get::<_, i64>(0),
1052        )
1053        .optional()
1054        .map_err(InspectCacheError::from)
1055    }
1056
1057    pub fn memory_generated_at(&self, key: &JobKey) -> Result<Option<i64>, InspectCacheError> {
1058        Ok(self
1059            .memory
1060            .read()
1061            .map_err(|_| InspectCacheError::LockPoisoned("memory"))?
1062            .get(key)
1063            .map(|entry| entry.generated_at))
1064    }
1065}
1066
1067fn configure_connection(conn: &Connection) -> Result<(), InspectCacheError> {
1068    conn.pragma_update(None, "journal_mode", "WAL")?;
1069    conn.pragma_update(None, "busy_timeout", 5_000)?;
1070    Ok(())
1071}
1072
1073fn initialize_schema(conn: &Connection) -> Result<(), InspectCacheError> {
1074    conn.execute_batch(
1075        "CREATE TABLE IF NOT EXISTS tier2_contributions (
1076            category        TEXT NOT NULL,
1077            project_key     TEXT NOT NULL,
1078            file_path       TEXT NOT NULL,
1079            file_mtime_ns   INTEGER NOT NULL,
1080            file_size       INTEGER NOT NULL,
1081            file_hash       TEXT NOT NULL,
1082            contribution    BLOB NOT NULL,
1083            generated_at    INTEGER NOT NULL,
1084            PRIMARY KEY (category, project_key, file_path)
1085        );
1086
1087        CREATE TABLE IF NOT EXISTS tier2_aggregates (
1088            category        TEXT NOT NULL,
1089            project_key     TEXT NOT NULL,
1090            contribution_set_hash TEXT NOT NULL,
1091            aggregate       BLOB NOT NULL,
1092            generated_at    INTEGER NOT NULL,
1093            PRIMARY KEY (category, project_key)
1094        );
1095
1096        CREATE TABLE IF NOT EXISTS tier2_meta (
1097            category        TEXT NOT NULL,
1098            project_key     TEXT NOT NULL,
1099            last_full_run   INTEGER NOT NULL,
1100            PRIMARY KEY (category, project_key)
1101        );",
1102    )?;
1103    Ok(())
1104}
1105
1106fn existing_contribution_paths(
1107    conn: &Connection,
1108    category: InspectCategory,
1109    project_key: &str,
1110) -> Result<Vec<String>, InspectCacheError> {
1111    let mut stmt = conn.prepare(
1112        "SELECT file_path FROM tier2_contributions WHERE category = ?1 AND project_key = ?2",
1113    )?;
1114    let rows = stmt.query_map(params![category.as_str(), project_key], |row| {
1115        row.get::<_, String>(0)
1116    })?;
1117    rows.collect::<Result<Vec<_>, _>>()
1118        .map_err(InspectCacheError::from)
1119}
1120
1121fn contribution_set_hash_with_conn(
1122    conn: &Connection,
1123    category: InspectCategory,
1124    project_key: &str,
1125    project_root: &Path,
1126    config: Option<&Config>,
1127) -> Result<String, InspectCacheError> {
1128    let mut stmt = conn.prepare(
1129        "SELECT file_path, file_hash FROM tier2_contributions \
1130         WHERE category = ?1 AND project_key = ?2 ORDER BY file_path ASC",
1131    )?;
1132    let rows = stmt.query_map(params![category.as_str(), project_key], |row| {
1133        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1134    })?;
1135
1136    let mut hasher = blake3::Hasher::new();
1137    hasher.update(b"tier2-contributions\0");
1138    hasher.update(&TIER2_CONTRIBUTION_CACHE_VERSION.to_le_bytes());
1139    hasher.update(b"\0");
1140    for row in rows {
1141        let (file_path, file_hash) = row?;
1142        hasher.update(file_path.as_bytes());
1143        hasher.update(b"\0");
1144        hasher.update(file_hash.as_bytes());
1145        hasher.update(b"\0");
1146    }
1147    update_manifest_fingerprint_hash(&mut hasher, project_root)?;
1148    if matches!(
1149        category,
1150        InspectCategory::DeadCode | InspectCategory::UnusedExports | InspectCategory::Cycles
1151    ) {
1152        update_resolver_config_fingerprint_hash(&mut hasher, project_root)?;
1153    }
1154    update_inspect_config_fingerprint_hash(&mut hasher, category, config);
1155    Ok(hasher.finalize().to_hex().to_string())
1156}
1157
1158fn update_inspect_config_fingerprint_hash(
1159    hasher: &mut blake3::Hasher,
1160    category: InspectCategory,
1161    config: Option<&Config>,
1162) {
1163    if category != InspectCategory::Duplicates {
1164        return;
1165    }
1166
1167    hasher.update(b"inspect.duplicates.expected_mirrors\0");
1168    let Some(config) = config else {
1169        return;
1170    };
1171    for pair in &config.inspect.duplicates.expected_mirrors {
1172        hasher.update(pair[0].as_bytes());
1173        hasher.update(b"\0");
1174        hasher.update(pair[1].as_bytes());
1175        hasher.update(b"\0");
1176    }
1177}
1178
1179fn update_resolver_config_fingerprint_hash(
1180    hasher: &mut blake3::Hasher,
1181    project_root: &Path,
1182) -> Result<(), InspectCacheError> {
1183    let manifest_root =
1184        fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
1185    hasher.update(b"ts-js-resolver-configs\0");
1186    for config in collect_resolver_config_dependency_files(project_root) {
1187        let relative_path = config
1188            .strip_prefix(&manifest_root)
1189            .unwrap_or(config.as_path())
1190            .to_string_lossy()
1191            .replace('\\', "/");
1192        let content_hash = blake3::hash(&fs::read(&config)?);
1193        hasher.update(relative_path.as_bytes());
1194        hasher.update(b"\0");
1195        hasher.update(content_hash.as_bytes());
1196        hasher.update(b"\0");
1197    }
1198    Ok(())
1199}
1200
1201struct ResolverConfigDependency {
1202    path: PathBuf,
1203    follow_extends: bool,
1204}
1205
1206impl ResolverConfigDependency {
1207    fn resolver_config(path: PathBuf) -> Self {
1208        Self {
1209            path,
1210            follow_extends: true,
1211        }
1212    }
1213
1214    fn hashed_file(path: PathBuf) -> Self {
1215        Self {
1216            path,
1217            follow_extends: false,
1218        }
1219    }
1220}
1221
1222fn collect_resolver_config_dependency_files(project_root: &Path) -> BTreeSet<PathBuf> {
1223    let mut configs = walk_resolver_config_files(project_root);
1224    let mut pending = configs.iter().cloned().collect::<Vec<_>>();
1225    let mut queued = configs.clone();
1226    while let Some(config) = pending.pop() {
1227        for dependency in resolver_config_extends_targets(&config, project_root) {
1228            let ResolverConfigDependency {
1229                path,
1230                follow_extends,
1231            } = dependency;
1232            configs.insert(path.clone());
1233            if follow_extends && queued.insert(path.clone()) {
1234                pending.push(path);
1235            }
1236        }
1237    }
1238    configs
1239}
1240
1241fn walk_resolver_config_files(project_root: &Path) -> BTreeSet<PathBuf> {
1242    let walker = ignore::WalkBuilder::new(project_root)
1243        .hidden(true)
1244        .git_ignore(true)
1245        .git_global(true)
1246        .git_exclude(true)
1247        .add_custom_ignore_filename(".aftignore")
1248        .filter_entry(|entry| {
1249            let name = entry.file_name().to_string_lossy();
1250            if entry
1251                .file_type()
1252                .is_some_and(|file_type| file_type.is_dir())
1253            {
1254                return !matches!(
1255                    name.as_ref(),
1256                    "node_modules"
1257                        | "target"
1258                        | "venv"
1259                        | ".venv"
1260                        | ".git"
1261                        | "__pycache__"
1262                        | ".tox"
1263                        | "dist"
1264                        | "build"
1265                );
1266            }
1267            true
1268        })
1269        .build();
1270
1271    walker
1272        .filter_map(Result::ok)
1273        .filter(|entry| {
1274            entry
1275                .file_type()
1276                .is_some_and(|file_type| file_type.is_file())
1277        })
1278        .map(|entry| entry.into_path())
1279        .filter(|path| {
1280            path.file_name()
1281                .and_then(|name| name.to_str())
1282                .is_some_and(is_resolver_config_file_name)
1283        })
1284        .filter_map(canonical_file_path)
1285        .collect()
1286}
1287
1288fn is_resolver_config_file_name(name: &str) -> bool {
1289    name == "tsconfig.json"
1290        || name == "jsconfig.json"
1291        || ((name.starts_with("tsconfig.") || name.starts_with("jsconfig."))
1292            && name.ends_with(".json"))
1293}
1294
1295fn resolver_config_extends_targets(
1296    config: &Path,
1297    project_root: &Path,
1298) -> Vec<ResolverConfigDependency> {
1299    let Ok(source) = fs::read_to_string(config) else {
1300        return Vec::new();
1301    };
1302    let Ok(value) = parse_resolver_config_json(&source) else {
1303        return Vec::new();
1304    };
1305
1306    let mut specs = Vec::new();
1307    collect_extends_specs(value.get("extends"), &mut specs);
1308    specs
1309        .into_iter()
1310        .flat_map(|spec| resolve_resolver_config_extends(config, project_root, spec))
1311        .collect()
1312}
1313
1314fn parse_resolver_config_json(source: &str) -> Result<serde_json::Value, serde_json::Error> {
1315    serde_json::from_str(source).or_else(|_| serde_json::from_str(&strip_jsonc(source)))
1316}
1317
1318fn collect_extends_specs<'a>(value: Option<&'a serde_json::Value>, specs: &mut Vec<&'a str>) {
1319    match value {
1320        Some(serde_json::Value::String(spec)) => specs.push(spec),
1321        Some(serde_json::Value::Array(values)) => {
1322            for value in values {
1323                collect_extends_specs(Some(value), specs);
1324            }
1325        }
1326        _ => {}
1327    }
1328}
1329
1330fn resolve_resolver_config_extends(
1331    config: &Path,
1332    project_root: &Path,
1333    spec: &str,
1334) -> Vec<ResolverConfigDependency> {
1335    let config_dir = config.parent().unwrap_or(project_root);
1336    let spec_path = Path::new(spec);
1337    if spec_path.is_absolute() || spec.starts_with('.') {
1338        return resolver_config_extends_target(&config_dir.join(spec_path))
1339            .map(ResolverConfigDependency::resolver_config)
1340            .into_iter()
1341            .collect();
1342    }
1343
1344    node_modules_resolver_config_dependencies(config_dir, project_root, spec)
1345}
1346
1347fn node_modules_resolver_config_dependencies(
1348    config_dir: &Path,
1349    project_root: &Path,
1350    spec: &str,
1351) -> Vec<ResolverConfigDependency> {
1352    let boundary = fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
1353    let config_dir = fs::canonicalize(config_dir).unwrap_or_else(|_| config_dir.to_path_buf());
1354    let enforce_project_boundary = config_dir.starts_with(&boundary);
1355    let is_bare_package = is_bare_package_extends_spec(spec);
1356    let mut dependencies = Vec::new();
1357    for ancestor in config_dir.ancestors() {
1358        let ancestor = fs::canonicalize(ancestor).unwrap_or_else(|_| ancestor.to_path_buf());
1359        if enforce_project_boundary && !ancestor.starts_with(&boundary) {
1360            break;
1361        }
1362        let package_dir = ancestor.join("node_modules").join(spec);
1363        let mut ancestor_dependencies = Vec::new();
1364        if is_bare_package {
1365            if let Some(mut package_dependencies) =
1366                package_json_resolver_config_dependencies(&package_dir)
1367            {
1368                let has_resolver_config = package_dependencies
1369                    .iter()
1370                    .any(|dependency| dependency.follow_extends);
1371                ancestor_dependencies.append(&mut package_dependencies);
1372                if has_resolver_config {
1373                    dependencies.extend(ancestor_dependencies);
1374                    return dependencies;
1375                }
1376            }
1377        }
1378        if let Some(target) = resolver_config_extends_target(&package_dir) {
1379            ancestor_dependencies.push(ResolverConfigDependency::resolver_config(target));
1380            dependencies.extend(ancestor_dependencies);
1381            return dependencies;
1382        }
1383        dependencies.extend(ancestor_dependencies);
1384    }
1385    dependencies
1386}
1387
1388fn package_json_resolver_config_dependencies(
1389    package_dir: &Path,
1390) -> Option<Vec<ResolverConfigDependency>> {
1391    let package_json = canonical_file_path(package_dir.join("package.json"))?;
1392    let package_root = package_json
1393        .parent()
1394        .map(Path::to_path_buf)
1395        .unwrap_or_else(|| package_dir.to_path_buf());
1396    let mut dependencies = vec![ResolverConfigDependency::hashed_file(package_json.clone())];
1397
1398    let Ok(source) = fs::read_to_string(&package_json) else {
1399        return Some(dependencies);
1400    };
1401    let Ok(value) = parse_resolver_config_json(&source) else {
1402        return Some(dependencies);
1403    };
1404    let selected_config = value
1405        .get("tsconfig")
1406        .and_then(serde_json::Value::as_str)
1407        .map(str::trim)
1408        .filter(|value| !value.is_empty())
1409        .unwrap_or("tsconfig.json");
1410    if let Some(target) = resolver_config_extends_target(&package_root.join(selected_config)) {
1411        dependencies.push(ResolverConfigDependency::resolver_config(target));
1412    }
1413
1414    Some(dependencies)
1415}
1416
1417fn is_bare_package_extends_spec(spec: &str) -> bool {
1418    let mut parts = spec.split('/').filter(|part| !part.is_empty());
1419    let Some(first) = parts.next() else {
1420        return false;
1421    };
1422    if first.starts_with('@') {
1423        parts.next().is_some() && parts.next().is_none()
1424    } else {
1425        parts.next().is_none()
1426    }
1427}
1428
1429fn resolver_config_extends_target(base: &Path) -> Option<PathBuf> {
1430    resolver_config_extends_candidates(base)
1431        .into_iter()
1432        .find_map(canonical_file_path)
1433}
1434
1435fn resolver_config_extends_candidates(base: &Path) -> Vec<PathBuf> {
1436    let mut candidates = vec![base.to_path_buf()];
1437    if base.extension().is_none() {
1438        candidates.push(base.with_extension("json"));
1439        candidates.push(base.join("tsconfig.json"));
1440    }
1441    candidates
1442}
1443
1444fn canonical_file_path(path: PathBuf) -> Option<PathBuf> {
1445    if !path.is_file() {
1446        return None;
1447    }
1448    Some(fs::canonicalize(&path).unwrap_or(path))
1449}
1450
1451fn update_manifest_fingerprint_hash(
1452    hasher: &mut blake3::Hasher,
1453    project_root: &Path,
1454) -> Result<(), InspectCacheError> {
1455    let manifest_root =
1456        fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
1457    hasher.update(b"entry-point-manifests\0");
1458    for manifest in super::entry_points::collect_entry_point_manifests(project_root) {
1459        let relative_path = manifest
1460            .strip_prefix(&manifest_root)
1461            .unwrap_or(manifest.as_path())
1462            .to_string_lossy()
1463            .replace('\\', "/");
1464        let content_hash = blake3::hash(&fs::read(&manifest)?);
1465        hasher.update(relative_path.as_bytes());
1466        hasher.update(b"\0");
1467        hasher.update(content_hash.as_bytes());
1468        hasher.update(b"\0");
1469    }
1470    Ok(())
1471}
1472
1473fn relative_string(project_root: &Path, path: &Path) -> String {
1474    if let Ok(relative) = path.strip_prefix(project_root) {
1475        return relative.to_string_lossy().to_string();
1476    }
1477
1478    if let (Ok(canonical_root), Ok(canonical_path)) =
1479        (fs::canonicalize(project_root), fs::canonicalize(path))
1480    {
1481        if let Ok(relative) = canonical_path.strip_prefix(canonical_root) {
1482            return relative.to_string_lossy().to_string();
1483        }
1484    }
1485
1486    path.to_string_lossy().to_string()
1487}
1488
1489fn system_time_to_ns(time: SystemTime) -> i64 {
1490    let nanos = time
1491        .duration_since(UNIX_EPOCH)
1492        .unwrap_or_else(|_| Duration::from_secs(0))
1493        .as_nanos();
1494    nanos.min(i64::MAX as u128) as i64
1495}
1496
1497fn ns_to_system_time(value: i64) -> SystemTime {
1498    UNIX_EPOCH + Duration::from_nanos(value.max(0) as u64)
1499}
1500
1501fn hash_to_hex(hash: blake3::Hash) -> String {
1502    hash.to_hex().to_string()
1503}
1504
1505fn hash_from_hex(value: &str) -> Result<blake3::Hash, InspectCacheError> {
1506    if value.len() != 64 {
1507        return Err(InspectCacheError::InvalidHash(value.to_string()));
1508    }
1509    let mut bytes = [0u8; 32];
1510    for (index, chunk) in value.as_bytes().chunks(2).enumerate() {
1511        let hex = std::str::from_utf8(chunk)
1512            .map_err(|_| InspectCacheError::InvalidHash(value.to_string()))?;
1513        bytes[index] = u8::from_str_radix(hex, 16)
1514            .map_err(|_| InspectCacheError::InvalidHash(value.to_string()))?;
1515    }
1516    Ok(blake3::Hash::from_bytes(bytes))
1517}
1518
1519fn unix_seconds_now() -> i64 {
1520    SystemTime::now()
1521        .duration_since(UNIX_EPOCH)
1522        .unwrap_or_else(|_| Duration::from_secs(0))
1523        .as_secs()
1524        .min(i64::MAX as u64) as i64
1525}
1526
1527#[cfg(test)]
1528mod tests {
1529    use super::*;
1530    use std::cell::Cell;
1531    use std::fs;
1532    use std::path::{Path, PathBuf};
1533
1534    fn collect_freshness(path: &Path) -> FileFreshness {
1535        crate::cache_freshness::collect(path).unwrap()
1536    }
1537
1538    #[test]
1539    fn tier1_file_memo_evicts_lru_and_keeps_recent_hits() {
1540        let temp = tempfile::tempdir().unwrap();
1541        let memo = Tier1FileMemo::<usize>::default();
1542        let mut paths = Vec::with_capacity(TIER1_FILE_MEMO_MAX_ENTRIES);
1543
1544        for index in 0..TIER1_FILE_MEMO_MAX_ENTRIES {
1545            let path = temp.path().join(format!("file-{index}.txt"));
1546            fs::write(&path, index.to_string()).unwrap();
1547            let value =
1548                memo.get_or_insert_with(&path, |path| (Some(collect_freshness(path)), index));
1549            assert_eq!(value, index);
1550            paths.push(path);
1551        }
1552
1553        let recent_path = paths[0].clone();
1554        let recent_value = memo.get_or_insert_with(&recent_path, |_| {
1555            panic!("recently inserted entry should hit before eviction")
1556        });
1557        assert_eq!(recent_value, 0);
1558
1559        let evicting_path = temp.path().join("new-file.txt");
1560        fs::write(&evicting_path, "new").unwrap();
1561        let evicting_value = memo.get_or_insert_with(&evicting_path, |path| {
1562            (Some(collect_freshness(path)), TIER1_FILE_MEMO_MAX_ENTRIES)
1563        });
1564        assert_eq!(evicting_value, TIER1_FILE_MEMO_MAX_ENTRIES);
1565
1566        let state = memo.state.lock().unwrap();
1567        assert_eq!(state.entries.len(), TIER1_FILE_MEMO_MAX_ENTRIES);
1568        assert!(state.entries.contains_key(&recent_path));
1569        assert!(state.entries.contains_key(&evicting_path));
1570        assert!(!state.entries.contains_key(&paths[1]));
1571        drop(state);
1572
1573        let recent_value = memo.get_or_insert_with(&recent_path, |_| {
1574            panic!("recently used entry should survive eviction")
1575        });
1576        assert_eq!(recent_value, 0);
1577    }
1578
1579    #[test]
1580    fn tier1_file_memo_repeated_touches_keep_lazy_lru_bounded() {
1581        let temp = tempfile::tempdir().unwrap();
1582        let memo = Tier1FileMemo::<usize>::default();
1583        let mut paths = Vec::with_capacity(TIER1_FILE_MEMO_MAX_ENTRIES);
1584
1585        for index in 0..TIER1_FILE_MEMO_MAX_ENTRIES {
1586            let path = temp.path().join(format!("file-{index}.txt"));
1587            fs::write(&path, index.to_string()).unwrap();
1588            memo.get_or_insert_with(&path, |path| (Some(collect_freshness(path)), index));
1589            paths.push(path);
1590        }
1591
1592        for _ in 0..(TIER1_FILE_MEMO_MAX_ENTRIES * 3) {
1593            let value = memo.get_or_insert_with(&paths[0], |_| {
1594                panic!("hot entry should stay cached while it is repeatedly touched")
1595            });
1596            assert_eq!(value, 0);
1597        }
1598
1599        let evicting_path = temp.path().join("new-file.txt");
1600        fs::write(&evicting_path, "new").unwrap();
1601        memo.get_or_insert_with(&evicting_path, |path| {
1602            (Some(collect_freshness(path)), TIER1_FILE_MEMO_MAX_ENTRIES)
1603        });
1604
1605        let state = memo.state.lock().unwrap();
1606        assert_eq!(state.entries.len(), TIER1_FILE_MEMO_MAX_ENTRIES);
1607        assert!(state.entries.contains_key(&paths[0]));
1608        assert!(state.entries.contains_key(&evicting_path));
1609        assert!(!state.entries.contains_key(&paths[1]));
1610        assert!(
1611            state.lru.len() <= TIER1_FILE_MEMO_MAX_ENTRIES * 2,
1612            "lazy LRU queue should be compacted instead of growing without bound"
1613        );
1614    }
1615
1616    #[test]
1617    fn tier1_file_memo_reuses_fresh_entries_and_rescans_stale_files() {
1618        let temp = tempfile::tempdir().unwrap();
1619        let path = temp.path().join("memo.txt");
1620        fs::write(&path, "first").unwrap();
1621
1622        let memo = Tier1FileMemo::<String>::default();
1623        let scans = Cell::new(0);
1624
1625        let first = memo.get_or_insert_with(&path, |path| {
1626            scans.set(scans.get() + 1);
1627            (Some(collect_freshness(path)), "first scan".to_string())
1628        });
1629        assert_eq!(first, "first scan");
1630        assert_eq!(scans.get(), 1);
1631
1632        let unchanged =
1633            memo.get_or_insert_with(&path, |_| panic!("unchanged file should reuse Tier-1 memo"));
1634        assert_eq!(unchanged, "first scan");
1635        assert_eq!(scans.get(), 1);
1636
1637        fs::write(&path, "changed file contents").unwrap();
1638        let changed = memo.get_or_insert_with(&path, |path| {
1639            scans.set(scans.get() + 1);
1640            (Some(collect_freshness(path)), "second scan".to_string())
1641        });
1642        assert_eq!(changed, "second scan");
1643        assert_eq!(scans.get(), 2);
1644
1645        let fresh_after_rescan = memo.get_or_insert_with(&path, |_| {
1646            panic!("rescanned file should reuse refreshed Tier-1 memo")
1647        });
1648        assert_eq!(fresh_after_rescan, "second scan");
1649        assert_eq!(scans.get(), 2);
1650    }
1651
1652    #[derive(serde::Deserialize, serde::Serialize)]
1653    struct RoundTripContributionRecord {
1654        category: String,
1655        file_path: PathBuf,
1656        contribution: serde_json::Value,
1657        type_ref_names: BTreeSet<String>,
1658    }
1659
1660    impl From<&ContributionRecord> for RoundTripContributionRecord {
1661        fn from(record: &ContributionRecord) -> Self {
1662            Self {
1663                category: record.category.as_str().to_string(),
1664                file_path: record.file_path.clone(),
1665                contribution: record.contribution.clone(),
1666                type_ref_names: record.type_ref_names.clone(),
1667            }
1668        }
1669    }
1670
1671    #[test]
1672    fn contribution_record_round_trip_preserves_dead_code_liveness_metadata() {
1673        let temp = tempfile::tempdir().unwrap();
1674        let project_root = temp.path().join("project");
1675        let inspect_dir = temp.path().join("inspect");
1676        let source = project_root.join("src/lib.ts");
1677        fs::create_dir_all(source.parent().unwrap()).unwrap();
1678        fs::write(&source, "export interface Widget { id: string }\n").unwrap();
1679
1680        let cache = InspectCache::open(inspect_dir.clone(), project_root.clone()).unwrap();
1681        let contribution = FileContribution::new(
1682            InspectCategory::DeadCode,
1683            source.clone(),
1684            collect_freshness(&source),
1685            serde_json::json!({
1686                "file": "src/lib.ts",
1687                "exports": [{
1688                    "symbol": "Widget",
1689                    "kind": "interface",
1690                    "line": 1,
1691                    "is_type_like": true,
1692                    "is_entry_point": false,
1693                }],
1694                "internal_calls": [],
1695                "liveness_roots": [],
1696                "dispatched_method_names": ["render"],
1697                "macro_token_refs": [{
1698                    "caller_symbol": "render",
1699                    "line": 1,
1700                    "name": "Widget",
1701                    "shape": "struct"
1702                }],
1703                "type_ref_names": ["Widget"],
1704            }),
1705        )
1706        .with_type_ref_names(["Widget".to_string()]);
1707        cache
1708            .store_tier2_result(
1709                JobKey::for_project_category(InspectCategory::DeadCode),
1710                std::slice::from_ref(&source),
1711                &[contribution],
1712                serde_json::json!({ "count": 0, "items": [] }),
1713            )
1714            .unwrap();
1715        drop(cache);
1716
1717        let cache = InspectCache::open(inspect_dir, project_root).unwrap();
1718        let records = cache
1719            .load_tier2_contributions(InspectCategory::DeadCode)
1720            .unwrap();
1721        assert_eq!(records.len(), 1);
1722
1723        let serialized =
1724            serde_json::to_vec(&RoundTripContributionRecord::from(&records[0])).unwrap();
1725        let decoded: RoundTripContributionRecord = serde_json::from_slice(&serialized).unwrap();
1726        assert_eq!(decoded.category, InspectCategory::DeadCode.as_str());
1727        assert_eq!(decoded.contribution["dispatched_method_names"][0], "render");
1728        assert_eq!(decoded.contribution["type_ref_names"][0], "Widget");
1729        assert_eq!(
1730            decoded.contribution["macro_token_refs"][0]["shape"],
1731            "struct"
1732        );
1733        assert!(decoded.type_ref_names.contains("Widget"));
1734        assert_eq!(
1735            decoded.contribution["exports"][0]["is_type_like"].as_bool(),
1736            Some(true)
1737        );
1738        assert_eq!(TIER2_CONTRIBUTION_CACHE_VERSION, 28);
1739    }
1740
1741    #[test]
1742    fn duplicate_expected_mirrors_participate_in_aggregate_cache_hash() {
1743        let temp = tempfile::tempdir().unwrap();
1744        let project_root = temp.path().join("project");
1745        fs::create_dir_all(&project_root).unwrap();
1746        let left = project_root.join("plugin/a.ts");
1747        let right = project_root.join("pi-plugin/a.ts");
1748        fs::create_dir_all(left.parent().unwrap()).unwrap();
1749        fs::create_dir_all(right.parent().unwrap()).unwrap();
1750        fs::write(&left, "export const value = 1;\n").unwrap();
1751        fs::write(&right, "export const value = 1;\n").unwrap();
1752
1753        let cache = InspectCache::open(temp.path().join("inspect"), project_root.clone()).unwrap();
1754        let contributions = vec![
1755            FileContribution::new(
1756                InspectCategory::Duplicates,
1757                left.clone(),
1758                collect_freshness(&left),
1759                serde_json::json!({ "file": "plugin/a.ts", "line_count": 1, "fragments": [] }),
1760            ),
1761            FileContribution::new(
1762                InspectCategory::Duplicates,
1763                right.clone(),
1764                collect_freshness(&right),
1765                serde_json::json!({ "file": "pi-plugin/a.ts", "line_count": 1, "fragments": [] }),
1766            ),
1767        ];
1768        let config = Config::default();
1769        cache
1770            .store_tier2_result_for_config(
1771                JobKey::for_project_category(InspectCategory::Duplicates),
1772                &[left.clone(), right.clone()],
1773                &contributions,
1774                serde_json::json!({ "count": 0, "items": [] }),
1775                &config,
1776            )
1777            .unwrap();
1778
1779        let without_mirrors = cache
1780            .contribution_set_hash_for_config(InspectCategory::Duplicates, &config)
1781            .unwrap();
1782        let mut mirror_config = Config::default();
1783        mirror_config.inspect.duplicates.expected_mirrors =
1784            vec![["plugin/**".to_string(), "pi-plugin/**".to_string()]];
1785        let with_mirrors = cache
1786            .contribution_set_hash_for_config(InspectCategory::Duplicates, &mirror_config)
1787            .unwrap();
1788
1789        assert_ne!(without_mirrors, with_mirrors);
1790    }
1791}