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