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