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