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