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