1use std::collections::{BTreeMap, BTreeSet, HashMap};
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
4use std::sync::{Arc, Condvar, Mutex};
5use std::time::{Duration, Instant};
6
7use crossbeam_channel::{after, bounded, select, Receiver, Sender};
8use serde::Deserialize;
9use serde_json::{json, Value};
10
11use super::cache::{InspectCache, InspectCacheRead, InspectDbTimings, Tier2ContributionUpdates};
12use super::dispatch::{default_worker, start_dispatch_loop, InspectWorker};
13use super::freshness::{verify_contribution_file, ContributionFreshness};
14use super::job::{
15 is_test_file, CallgraphSnapshot, FileContribution, InspectCategory, InspectJob, InspectResult,
16 InspectScanSuccess, InspectSnapshot, JobKey, JobOutcome, JobScope,
17};
18use super::oxc_engine::LivenessVerdict;
19use super::oxc_engine::{
20 analyze_file_facts, analyze_files_with_cache, normalize_input_path, AnalyzeOptions,
21 DynamicImportFact, ExportFact, FileFacts, FileId, ImportFact, OxcEngineResult, OxcFactsCache,
22 ReExportFact, FACTS_FORMAT_VERSION, OXC_PROVENANCE,
23};
24use crate::cache_freshness::{self, FileFreshness, FreshnessVerdict};
25#[cfg(test)]
26use crate::callgraph_store::project_dead_code_snapshot;
27use crate::callgraph_store::{
28 project_dead_code_snapshot_with_revision, CallGraphStore, CallGraphStoreError,
29 ReadonlyCallGraphStore,
30};
31use crate::cold_build_limiter;
32
33const DEFAULT_SOFT_DEADLINE: Duration = Duration::from_secs(1);
34
35type WaiterTx = Sender<JobOutcome>;
36
37#[derive(Clone)]
38struct Waiter {
39 tx: WaiterTx,
40}
41
42struct CachedContributionFreshness {
43 file_path: PathBuf,
44 freshness: FileFreshness,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Hash)]
48struct InspectCacheIdentity {
49 sqlite_path: PathBuf,
50 project_root: PathBuf,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
56struct CallgraphProjectionIdentity {
57 project_root: PathBuf,
58 generation: Option<String>,
59 legacy_sqlite_path: Option<PathBuf>,
62 write_revision: u64,
63}
64
65#[derive(Debug)]
66struct CachedCallgraphProjection {
67 identity: CallgraphProjectionIdentity,
68 snapshot: Arc<CallgraphSnapshot>,
69 estimated_bytes: u64,
70}
71
72#[derive(Debug, Clone)]
73pub struct Tier2RunSubmissionError {
74 pub category: InspectCategory,
75 pub message: String,
76}
77
78#[derive(Debug, Clone, Default)]
79pub struct Tier2RunSubmission {
80 pub queued_categories: Vec<InspectCategory>,
81 pub newly_queued_categories: Vec<InspectCategory>,
82 pub deferred_categories: Vec<InspectCategory>,
83 pub errors: Vec<Tier2RunSubmissionError>,
84}
85
86impl Tier2RunSubmission {
87 pub fn has_new_work(&self) -> bool {
88 !self.newly_queued_categories.is_empty()
89 }
90}
91
92#[derive(Debug, Clone)]
93pub struct DirectTier2RunOutcome {
94 pub outcome: JobOutcome,
95 pub force_paths_completed: bool,
96}
97
98#[derive(Debug, Clone)]
99struct Tier2ReuseOptions {
100 force_rescan_paths: BTreeSet<PathBuf>,
101 allow_callgraph_cold_build: bool,
102}
103
104impl Tier2ReuseOptions {
105 fn direct(paths: Vec<PathBuf>) -> Self {
106 Self {
107 force_rescan_paths: paths.into_iter().collect(),
108 allow_callgraph_cold_build: false,
109 }
110 }
111
112 fn has_force_paths(&self) -> bool {
113 !self.force_rescan_paths.is_empty()
114 }
115}
116
117impl Default for Tier2ReuseOptions {
118 fn default() -> Self {
119 Self {
120 force_rescan_paths: BTreeSet::new(),
121 allow_callgraph_cold_build: true,
122 }
123 }
124}
125
126fn cached_tier2_aggregate_usable(
127 category: InspectCategory,
128 options: &Tier2ReuseOptions,
129 aggregate: &Value,
130) -> bool {
131 if category == InspectCategory::DeadCode
132 && options.allow_callgraph_cold_build
133 && aggregate
134 .get("callgraph_available")
135 .and_then(Value::as_bool)
136 == Some(false)
137 {
138 return false;
139 }
140 true
141}
142
143pub struct InspectManager {
144 request_tx: Sender<InspectJob>,
145 result_rx: Receiver<InspectResult>,
146 #[allow(dead_code)]
147 pool: Arc<rayon::ThreadPool>,
148 in_flight: Mutex<HashMap<JobKey, Vec<Waiter>>>,
149 in_flight_changed: Condvar,
150 caches: Mutex<HashMap<InspectCacheIdentity, Arc<InspectCache>>>,
151 callgraph_projection: Mutex<Option<CachedCallgraphProjection>>,
154 oxc_facts_cache: Mutex<OxcFactsCache>,
155 soft_deadline: Duration,
156 next_job_id: AtomicU64,
157 heavy_root_work_allowed: Arc<AtomicBool>,
158 cold_build_limiter: Mutex<Arc<cold_build_limiter::ColdBuildLimiter>>,
159 automatic_tier2_refresh_allowed: AtomicBool,
160 automatic_tier2_skip_logged: AtomicBool,
161 automatic_tier2_schedule_count: AtomicU64,
162 reuse_completions: AtomicU64,
167 reuse_starts: AtomicU64,
170}
171
172impl InspectManager {
173 pub fn new() -> Self {
174 Self::with_heavy_root_work_gate(Arc::new(AtomicBool::new(true)))
175 }
176
177 pub fn with_heavy_root_work_gate(heavy_root_work_allowed: Arc<AtomicBool>) -> Self {
178 Self::with_worker_and_gate(
179 default_worker(),
180 DEFAULT_SOFT_DEADLINE,
181 heavy_root_work_allowed,
182 )
183 }
184
185 #[doc(hidden)]
186 pub fn with_worker(worker: InspectWorker, soft_deadline: Duration) -> Self {
187 Self::with_worker_and_gate(worker, soft_deadline, Arc::new(AtomicBool::new(true)))
188 }
189
190 #[doc(hidden)]
191 pub fn with_worker_and_gate(
192 worker: InspectWorker,
193 soft_deadline: Duration,
194 heavy_root_work_allowed: Arc<AtomicBool>,
195 ) -> Self {
196 let handles = start_dispatch_loop(worker);
197 Self {
198 request_tx: handles.request_tx,
199 result_rx: handles.result_rx,
200 pool: handles.pool,
201 in_flight: Mutex::new(HashMap::new()),
202 in_flight_changed: Condvar::new(),
203 caches: Mutex::new(HashMap::new()),
204 callgraph_projection: Mutex::new(None),
205 oxc_facts_cache: Mutex::new(OxcFactsCache::new()),
206 soft_deadline,
207 next_job_id: AtomicU64::new(1),
208 heavy_root_work_allowed,
209 cold_build_limiter: Mutex::new(cold_build_limiter::global_limiter()),
210 automatic_tier2_refresh_allowed: AtomicBool::new(true),
211 automatic_tier2_skip_logged: AtomicBool::new(false),
212 automatic_tier2_schedule_count: AtomicU64::new(0),
213 reuse_completions: AtomicU64::new(0),
214 reuse_starts: AtomicU64::new(0),
215 }
216 }
217
218 fn heavy_root_work_allowed(&self) -> bool {
219 self.heavy_root_work_allowed.load(Ordering::SeqCst)
220 }
221
222 pub(crate) fn set_cold_build_limiter(
223 &self,
224 limiter: Arc<cold_build_limiter::ColdBuildLimiter>,
225 ) {
226 *self
227 .cold_build_limiter
228 .lock()
229 .unwrap_or_else(std::sync::PoisonError::into_inner) = limiter;
230 }
231
232 fn cold_build_limiter(&self) -> Arc<cold_build_limiter::ColdBuildLimiter> {
233 Arc::clone(
234 &self
235 .cold_build_limiter
236 .lock()
237 .unwrap_or_else(std::sync::PoisonError::into_inner),
238 )
239 }
240
241 pub fn set_automatic_tier2_refresh_allowed(&self, allowed: bool) {
242 self.automatic_tier2_refresh_allowed
243 .store(allowed, Ordering::SeqCst);
244 self.automatic_tier2_skip_logged
245 .store(false, Ordering::SeqCst);
246 }
247
248 pub fn automatic_tier2_refresh_enabled(&self) -> bool {
249 self.automatic_tier2_refresh_allowed.load(Ordering::SeqCst)
250 }
251
252 pub fn automatic_tier2_refresh_allowed(&self) -> bool {
253 let allowed = self.automatic_tier2_refresh_enabled();
254 if !allowed
255 && !self
256 .automatic_tier2_skip_logged
257 .swap(true, Ordering::SeqCst)
258 {
259 crate::slog_debug!("automatic Tier-2 scan scheduling skipped for linked worktree root");
260 }
261 allowed
262 }
263
264 #[doc(hidden)]
265 pub fn inspect_pool_for_test(&self) -> Arc<rayon::ThreadPool> {
266 Arc::clone(&self.pool)
267 }
268
269 #[doc(hidden)]
270 pub fn automatic_tier2_schedule_count_for_test(&self) -> u64 {
271 self.automatic_tier2_schedule_count.load(Ordering::SeqCst)
272 }
273
274 fn category_needs_heavy_root_work(category: InspectCategory) -> bool {
275 category != InspectCategory::Diagnostics
276 }
277
278 fn heavy_root_work_block_message(category: InspectCategory) -> String {
279 format!(
280 "inspect category '{category}' is unavailable because heavy project-wide work is disabled for this root"
281 )
282 }
283
284 pub fn submit_category(
285 &self,
286 snapshot: InspectSnapshot,
287 category: InspectCategory,
288 caller_scope: JobScope,
289 ) -> JobOutcome {
290 self.submit_category_with_callgraph(snapshot, category, caller_scope, None)
291 }
292
293 pub fn submit_category_with_callgraph(
294 &self,
295 snapshot: InspectSnapshot,
296 category: InspectCategory,
297 caller_scope: JobScope,
298 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
299 ) -> JobOutcome {
300 if !category.is_active() {
301 return JobOutcome::Failed {
302 message: format!("inspect category '{category}' is disabled in v0.33"),
303 };
304 }
305 if Self::category_needs_heavy_root_work(category) && !self.heavy_root_work_allowed() {
306 return JobOutcome::Failed {
307 message: Self::heavy_root_work_block_message(category),
308 };
309 }
310
311 let cache = match self.cache_for_snapshot(&snapshot) {
312 Ok(cache) => cache,
313 Err(message) => return JobOutcome::Failed { message },
314 };
315 let key = JobKey::for_category_scope(category, &caller_scope);
316 let (waiter_tx, waiter_rx) = bounded(1);
317
318 let wait_snapshot = snapshot.clone();
319 match self.enqueue_with_waiter(
320 snapshot,
321 category,
322 caller_scope.clone(),
323 key.clone(),
324 waiter_tx,
325 callgraph_snapshot,
326 ) {
327 Ok(()) => self.wait_for_outcome(key, caller_scope, cache, waiter_rx, wait_snapshot),
328 Err(message) => JobOutcome::Failed { message },
329 }
330 }
331
332 pub fn submit_background(
333 &self,
334 snapshot: InspectSnapshot,
335 category: InspectCategory,
336 caller_scope: JobScope,
337 ) -> Result<JobKey, String> {
338 self.submit_background_with_callgraph(snapshot, category, caller_scope, None)
339 }
340
341 pub fn submit_background_with_callgraph(
342 &self,
343 snapshot: InspectSnapshot,
344 category: InspectCategory,
345 caller_scope: JobScope,
346 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
347 ) -> Result<JobKey, String> {
348 if !category.is_active() {
349 return Err(format!(
350 "inspect category '{category}' is disabled in v0.33"
351 ));
352 }
353 if Self::category_needs_heavy_root_work(category) && !self.heavy_root_work_allowed() {
354 return Err(Self::heavy_root_work_block_message(category));
355 }
356 let key = JobKey::for_category_scope(category, &caller_scope);
357 self.enqueue_without_waiter(
358 snapshot,
359 category,
360 caller_scope,
361 key.clone(),
362 callgraph_snapshot,
363 )?;
364 Ok(key)
365 }
366
367 pub fn submit_tier2_run_with_reuse_background(
368 self: &Arc<Self>,
369 snapshot: InspectSnapshot,
370 category: InspectCategory,
371 ) -> Result<Option<JobKey>, String> {
372 if !category.is_active() {
373 return Err(format!(
374 "inspect category '{category}' is disabled in v0.33"
375 ));
376 }
377 if !category.is_tier2() {
378 return Err(format!(
379 "inspect category '{category}' is not a Tier 2 category"
380 ));
381 }
382 if !self.heavy_root_work_allowed() {
383 return Err(Self::heavy_root_work_block_message(category));
384 }
385 if !self.automatic_tier2_refresh_allowed() {
386 return Ok(None);
387 }
388 self.automatic_tier2_schedule_count
389 .fetch_add(1, Ordering::SeqCst);
390
391 let job = self.tier2_reuse_job(snapshot, category, None);
392 let key = job.key.clone();
393 let mut in_flight = self
394 .in_flight
395 .lock()
396 .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
397 if in_flight.contains_key(&key) {
398 return Ok(Some(key));
399 }
400 let limiter = self.cold_build_limiter();
401 let Some(permit) = limiter.try_acquire() else {
402 return Err(format!(
403 "cold build concurrency limit ({}) reached; retrying later",
404 limiter.limit()
405 ));
406 };
407 in_flight.insert(key.clone(), Vec::new());
408 drop(in_flight);
409
410 let manager = Arc::clone(self);
411 let pool = Arc::clone(&self.pool);
412 pool.spawn_fifo(move || {
413 let _permit = permit;
414 let result = manager.tier2_run_with_reuse_job_result(job);
415 manager.route_tier2_reuse_completion(result);
416 });
417
418 Ok(Some(key))
419 }
420
421 pub fn submit_tier2_run_with_reuse_serial_background(
422 self: &Arc<Self>,
423 snapshot: InspectSnapshot,
424 categories: Vec<InspectCategory>,
425 ) -> Tier2RunSubmission {
426 let mut submission = Tier2RunSubmission::default();
427 let mut requested = Vec::new();
428
429 for category in categories {
430 if !category.is_active() {
431 submission.errors.push(Tier2RunSubmissionError {
432 category,
433 message: format!("inspect category '{category}' is disabled in v0.33"),
434 });
435 continue;
436 }
437 if !category.is_tier2() {
438 submission.errors.push(Tier2RunSubmissionError {
439 category,
440 message: format!("inspect category '{category}' is not a Tier 2 category"),
441 });
442 continue;
443 }
444 requested.push(category);
445 }
446
447 if requested.is_empty() {
448 return submission;
449 }
450 if !self.heavy_root_work_allowed() {
451 for category in requested {
452 submission.errors.push(Tier2RunSubmissionError {
453 category,
454 message: Self::heavy_root_work_block_message(category),
455 });
456 }
457 return submission;
458 }
459 if !self.automatic_tier2_refresh_allowed() {
460 return submission;
461 }
462 self.automatic_tier2_schedule_count
463 .fetch_add(requested.len() as u64, Ordering::SeqCst);
464
465 let mut in_flight = match self.in_flight.lock() {
466 Ok(in_flight) => in_flight,
467 Err(_) => {
468 for category in requested {
469 submission.errors.push(Tier2RunSubmissionError {
470 category,
471 message: "inspect in-flight map lock poisoned".to_string(),
472 });
473 }
474 return submission;
475 }
476 };
477
478 for category in requested {
479 let key = JobKey::for_project_category(category);
480 submission.queued_categories.push(category);
481 if in_flight.contains_key(&key) {
482 continue;
483 }
484 in_flight.insert(key, Vec::new());
485 submission.newly_queued_categories.push(category);
486 }
487 drop(in_flight);
488
489 if submission.newly_queued_categories.is_empty() {
490 return submission;
491 }
492
493 let limiter = self.cold_build_limiter();
494 let Some(permit) = limiter.try_acquire() else {
495 let deferred = submission.newly_queued_categories.clone();
496 if let Ok(mut in_flight) = self.in_flight.lock() {
497 for category in &deferred {
498 in_flight.remove(&JobKey::for_project_category(*category));
499 }
500 }
501 submission
502 .queued_categories
503 .retain(|category| !deferred.contains(category));
504 submission.deferred_categories = deferred;
505 submission.newly_queued_categories.clear();
506 return submission;
507 };
508
509 let categories_for_worker = submission.newly_queued_categories.clone();
510 let manager = Arc::clone(self);
511 let pool = Arc::clone(&self.pool);
512 pool.spawn_fifo(move || {
513 let _permit = permit;
514 for category in categories_for_worker {
515 let result = manager.tier2_run_with_reuse_result(snapshot.clone(), category, None);
516 manager.route_tier2_reuse_completion(result);
517 }
518 });
519
520 submission
521 }
522
523 pub fn tier2_any_in_flight(&self) -> bool {
524 self.in_flight
525 .lock()
526 .map(|in_flight| in_flight.keys().any(|key| key.category.is_tier2()))
527 .unwrap_or(false)
528 }
529
530 pub fn evict_idle_caches(&self) {
535 if let Ok(mut caches) = self.caches.lock() {
536 caches.clear();
537 }
538 self.clear_callgraph_projection();
539 if let Ok(mut facts) = self.oxc_facts_cache.lock() {
540 *facts = OxcFactsCache::new();
541 }
542 }
543
544 pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
548 let caches = match self.caches.try_lock() {
549 Ok(caches) => caches.values().cloned().collect::<Vec<_>>(),
550 Err(_) => return crate::memory::MemoryEstimate::busy(),
551 };
552 let facts_entries = match self.oxc_facts_cache.try_lock() {
553 Ok(facts) => facts.len(),
554 Err(_) => return crate::memory::MemoryEstimate::busy(),
555 };
556 let mut bytes = 0u64;
557 let mut memory_aggregates = 0u64;
558 for cache in &caches {
559 let estimate = cache.estimated_memory();
560 let Some(cache_bytes) = estimate.estimated_bytes else {
561 return crate::memory::MemoryEstimate::busy();
562 };
563 bytes = bytes.saturating_add(cache_bytes);
564 memory_aggregates = memory_aggregates.saturating_add(
565 estimate
566 .counts
567 .get("memory_aggregates")
568 .copied()
569 .unwrap_or(0),
570 );
571 }
572 crate::memory::MemoryEstimate::partial(bytes)
573 .count("open_generation_handles", caches.len())
574 .count("oxc_fact_entries", facts_entries)
575 .count_u64("memory_aggregates", memory_aggregates)
576 .gap("oxc_fact_bytes")
577 }
578
579 pub fn callgraph_projection_estimated_memory(&self) -> crate::memory::MemoryEstimate {
582 let projection = match self.callgraph_projection.try_lock() {
583 Ok(projection) => projection,
584 Err(_) => return crate::memory::MemoryEstimate::busy(),
585 };
586 let bytes = projection
587 .as_ref()
588 .map(|projection| projection.estimated_bytes)
589 .unwrap_or(0);
590 crate::memory::MemoryEstimate::estimated(bytes)
591 .count(
592 "callgraph_projection_snapshots",
593 usize::from(projection.is_some()),
594 )
595 .count_u64("callgraph_projection_snapshot_bytes", bytes)
596 }
597
598 fn cached_callgraph_projection(
599 &self,
600 identity: &CallgraphProjectionIdentity,
601 ) -> Option<Arc<CallgraphSnapshot>> {
602 let projection = self.callgraph_projection.lock().ok()?;
603 projection
604 .as_ref()
605 .filter(|cached| cached.identity == *identity)
606 .map(|cached| Arc::clone(&cached.snapshot))
607 }
608
609 fn cache_callgraph_projection(
610 &self,
611 identity: CallgraphProjectionIdentity,
612 snapshot: Arc<CallgraphSnapshot>,
613 ) {
614 let estimated_bytes = estimate_callgraph_snapshot_bytes(snapshot.as_ref());
615 if let Ok(mut cached) = self.callgraph_projection.lock() {
616 *cached = Some(CachedCallgraphProjection {
617 identity,
618 snapshot,
619 estimated_bytes,
620 });
621 }
622 }
623
624 fn clear_callgraph_projection(&self) {
625 if let Ok(mut cached) = self.callgraph_projection.lock() {
626 cached.take();
627 }
628 }
629
630 fn build_tier2_callgraph_snapshot_with_refresh(
631 &self,
632 job: &InspectJob,
633 allow_cold_build: bool,
634 refresh_paths: &[PathBuf],
635 ) -> Option<Arc<CallgraphSnapshot>> {
636 build_tier2_callgraph_snapshot_with_refresh_inner(
637 job,
638 allow_cold_build,
639 refresh_paths,
640 Some(self),
641 )
642 }
643
644 pub fn has_pending_completions(&self) -> bool {
647 !self.result_rx.is_empty()
648 }
649
650 pub fn drain_completions(&self) -> usize {
651 let mut drained = 0usize;
652 while let Ok(result) = self.result_rx.try_recv() {
653 self.route_completion(result);
654 drained += 1;
655 }
656 drained
657 }
658
659 pub fn discard_completions(&self) -> usize {
660 let mut discarded = 0usize;
661 while self.result_rx.try_recv().is_ok() {
662 discarded += 1;
663 }
664 discarded
665 }
666
667 pub fn cache_for_snapshot(
668 &self,
669 snapshot: &InspectSnapshot,
670 ) -> Result<Arc<InspectCache>, String> {
671 self.cache_for_paths(snapshot.inspect_dir.clone(), snapshot.project_root.clone())
672 }
673
674 pub fn latest_tier2_counts(
682 &self,
683 inspect_dir: PathBuf,
684 project_root: PathBuf,
685 ) -> (Option<usize>, Option<usize>, Option<usize>) {
686 let Ok(cache) = self.cache_for_paths(inspect_dir, project_root) else {
687 return (None, None, None);
688 };
689 let count_of = |category: InspectCategory| -> Option<usize> {
690 cache
691 .latest_aggregate_any_hash(category)
692 .ok()
693 .flatten()
694 .and_then(|payload| {
695 if category == InspectCategory::DeadCode
696 && payload
697 .get("callgraph_available")
698 .and_then(serde_json::Value::as_bool)
699 == Some(false)
700 {
701 return None;
702 }
703 payload
704 .get("count")
705 .and_then(serde_json::Value::as_u64)
706 .map(|count| count as usize)
707 })
708 };
709 (
710 count_of(InspectCategory::DeadCode),
711 count_of(InspectCategory::UnusedExports),
712 count_of(InspectCategory::Duplicates),
713 )
714 }
715
716 pub fn dead_code_blocked_on_callgraph(
723 &self,
724 inspect_dir: PathBuf,
725 project_root: PathBuf,
726 ) -> bool {
727 let Ok(cache) = self.cache_for_paths(inspect_dir, project_root) else {
728 return false;
729 };
730 cache
731 .latest_aggregate_any_hash(InspectCategory::DeadCode)
732 .ok()
733 .flatten()
734 .and_then(|payload| {
735 payload
736 .get("callgraph_available")
737 .and_then(serde_json::Value::as_bool)
738 })
739 == Some(false)
740 }
741
742 pub fn cache_for_paths(
743 &self,
744 inspect_dir: PathBuf,
745 project_root: PathBuf,
746 ) -> Result<Arc<InspectCache>, String> {
747 let project_key = crate::path_identity::project_scope_key(&project_root);
748 let inspect_dir = if inspect_dir
749 .file_name()
750 .and_then(|name| name.to_str())
751 .is_some_and(|name| name == project_key)
752 {
753 inspect_dir
754 } else {
755 inspect_dir.join(&project_key)
756 };
757 let identity = InspectCacheIdentity {
758 sqlite_path: inspect_dir.join(format!("{project_key}.current")),
759 project_root: project_root.clone(),
760 };
761 let mut caches = self
762 .caches
763 .lock()
764 .map_err(|_| "inspect manager cache map lock poisoned".to_string())?;
765 if let Some(cache) = caches.get(&identity) {
766 return Ok(Arc::clone(cache));
767 }
768 let cache = Arc::new(
769 InspectCache::open(inspect_dir, project_root)
770 .map_err(|error| format!("failed to open inspect cache: {error}"))?,
771 );
772 caches.insert(identity, Arc::clone(&cache));
773 Ok(cache)
774 }
775
776 fn oxc_result_for_scan(
777 &self,
778 job: &InspectJob,
779 files: &[PathBuf],
780 force_reparse_files: &[PathBuf],
781 ) -> Result<Option<OxcEngineResult>, String> {
782 if !category_uses_oxc(job.category) {
783 return Ok(None);
784 }
785 if job.category == InspectCategory::DeadCode && job.callgraph_snapshot.is_none() {
786 return Ok(None);
787 }
788
789 let public_api_entries =
790 crate::inspect::entry_points::resolve_entry_points(&job.project_root);
791 let entry_points = if job.category == InspectCategory::DeadCode {
792 job.callgraph_snapshot
793 .as_ref()
794 .map(|snapshot| snapshot.entry_points.iter().cloned().collect::<Vec<_>>())
795 .unwrap_or_default()
796 } else {
797 Vec::new()
798 };
799 let options = AnalyzeOptions {
800 entry_points,
801 public_api_files: public_api_entries.public_api_files(),
802 executable_root_exports: public_api_entries.executable_root_exports(),
803 force_reparse_files: force_reparse_files.to_vec(),
804 entry_reachability: job.category == InspectCategory::DeadCode,
805 };
806
807 let mut cache = self
808 .oxc_facts_cache
809 .lock()
810 .map_err(|_| "inspect oxc facts cache lock poisoned".to_string())?;
811 analyze_files_with_cache(&job.project_root, files, options, &mut cache)
812 .map(Some)
813 .map_err(|message| format!("oxc analyze failed: {message}"))
814 }
815
816 pub fn tier2_run_with_reuse(
817 &self,
818 snapshot: InspectSnapshot,
819 category: InspectCategory,
820 caller_scope: JobScope,
821 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
822 ) -> JobOutcome {
823 if let Err(outcome) = validate_tier2_read_category(category) {
824 return outcome;
825 }
826 if !self.heavy_root_work_allowed() {
827 return JobOutcome::Failed {
828 message: Self::heavy_root_work_block_message(category),
829 };
830 }
831 let cache = match self.cache_for_snapshot(&snapshot) {
832 Ok(cache) => cache,
833 Err(message) => return JobOutcome::Failed { message },
834 };
835 let job = self.tier2_reuse_job(snapshot.clone(), category, callgraph_snapshot);
836 let key = job.key.clone();
837 let (waiter_tx, waiter_rx) = bounded(1);
838 let claimed = match self.register_tier2_reuse_waiter(&key, waiter_tx) {
839 Ok(claimed) => claimed,
840 Err(message) => return JobOutcome::Failed { message },
841 };
842
843 if claimed {
844 let result = self
845 .tier2_run_with_reuse_job_result_with_options(job, Tier2ReuseOptions::default());
846 self.route_tier2_reuse_completion(result);
847 }
848
849 match waiter_rx.recv() {
850 Ok(outcome) => filter_outcome_for_scope_with_contributions(
851 outcome,
852 &snapshot,
853 category,
854 cache.as_ref(),
855 &caller_scope,
856 ),
857 Err(_) => JobOutcome::Pending { in_flight: true },
858 }
859 }
860
861 pub fn tier2_run_with_reuse_direct(
862 self: &Arc<Self>,
863 snapshot: InspectSnapshot,
864 category: InspectCategory,
865 caller_scope: JobScope,
866 deadline: Instant,
867 force_rescan_paths: Vec<PathBuf>,
868 ) -> DirectTier2RunOutcome {
869 if let Err(outcome) = validate_tier2_read_category(category) {
870 return DirectTier2RunOutcome {
871 outcome,
872 force_paths_completed: false,
873 };
874 }
875 if !self.heavy_root_work_allowed() {
876 return DirectTier2RunOutcome {
877 outcome: JobOutcome::Failed {
878 message: Self::heavy_root_work_block_message(category),
879 },
880 force_paths_completed: false,
881 };
882 }
883 let cache = match self.cache_for_snapshot(&snapshot) {
884 Ok(cache) => cache,
885 Err(message) => {
886 return DirectTier2RunOutcome {
887 outcome: JobOutcome::Failed { message },
888 force_paths_completed: false,
889 }
890 }
891 };
892
893 let must_run_forced_followup = !force_rescan_paths.is_empty();
894 loop {
895 let options = if must_run_forced_followup {
896 Tier2ReuseOptions::direct(force_rescan_paths.clone())
897 } else {
898 Tier2ReuseOptions::direct(Vec::new())
899 };
900 let job = self.tier2_reuse_job(snapshot.clone(), category, None);
901 let key = job.key.clone();
902 let (waiter_tx, waiter_rx) = bounded(1);
903 let claimed = match self.register_tier2_reuse_waiter(&key, waiter_tx) {
904 Ok(claimed) => claimed,
905 Err(message) => {
906 return DirectTier2RunOutcome {
907 outcome: JobOutcome::Failed { message },
908 force_paths_completed: false,
909 }
910 }
911 };
912 if claimed {
913 self.spawn_tier2_reuse_job(job, options);
914 }
915
916 let completed_force_run = claimed && must_run_forced_followup;
917 let outcome = self.wait_for_tier2_reuse_until(
918 &key,
919 &caller_scope,
920 cache.as_ref(),
921 waiter_rx,
922 &snapshot,
923 deadline,
924 );
925
926 delay_direct_force_followup_deadline_check_for_debug(&snapshot.project_root);
927 if must_run_forced_followup
928 && !claimed
929 && !matches!(outcome, JobOutcome::Pending { .. })
930 {
931 if Instant::now() < deadline {
939 continue;
940 }
941 return DirectTier2RunOutcome {
942 outcome: JobOutcome::Pending { in_flight: true },
943 force_paths_completed: false,
944 };
945 }
946
947 return DirectTier2RunOutcome {
948 outcome,
949 force_paths_completed: completed_force_run,
950 };
951 }
952 }
953
954 fn register_tier2_reuse_waiter(
955 &self,
956 key: &JobKey,
957 waiter_tx: WaiterTx,
958 ) -> Result<bool, String> {
959 let mut in_flight = self
960 .in_flight
961 .lock()
962 .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
963 if let Some(waiters) = in_flight.get_mut(key) {
964 waiters.push(Waiter { tx: waiter_tx });
965 self.in_flight_changed.notify_all();
966 return Ok(false);
967 }
968
969 in_flight.insert(key.clone(), vec![Waiter { tx: waiter_tx }]);
970 Ok(true)
971 }
972
973 fn wait_for_tier2_reuse_waiter_for_debug(&self, job: &InspectJob) {
974 #[cfg(not(debug_assertions))]
975 let _ = job;
976 #[cfg(debug_assertions)]
977 {
978 const WAIT_ROOT_ENV: &str = "AFT_TEST_TIER2_REUSE_WAIT_FOR_WAITER_ROOT";
979 if std::env::var_os(WAIT_ROOT_ENV).is_none()
980 || !env_project_root_matches(WAIT_ROOT_ENV, &job.project_root)
981 {
982 return;
983 }
984
985 let deadline = Instant::now() + Duration::from_secs(30);
989 let mut in_flight = self
990 .in_flight
991 .lock()
992 .unwrap_or_else(std::sync::PoisonError::into_inner);
993 loop {
994 match in_flight.get(&job.key) {
995 Some(waiters) if waiters.is_empty() => {}
996 _ => return,
997 }
998 let now = Instant::now();
999 if now >= deadline {
1000 return;
1001 }
1002 let (next, wait_result) = self
1003 .in_flight_changed
1004 .wait_timeout(in_flight, deadline.saturating_duration_since(now))
1005 .unwrap_or_else(std::sync::PoisonError::into_inner);
1006 in_flight = next;
1007 if wait_result.timed_out() {
1008 return;
1009 }
1010 }
1011 }
1012 }
1013
1014 fn spawn_tier2_reuse_job(self: &Arc<Self>, job: InspectJob, options: Tier2ReuseOptions) {
1015 let manager = Arc::clone(self);
1016 let pool = Arc::clone(&self.pool);
1017 pool.spawn_fifo(move || {
1018 let result = manager.tier2_run_with_reuse_job_result_catching(job, options);
1019 manager.route_tier2_reuse_completion(result);
1020 });
1021 }
1022
1023 fn wait_for_tier2_reuse_until(
1024 &self,
1025 key: &JobKey,
1026 caller_scope: &JobScope,
1027 cache: &(impl InspectCacheRead + ?Sized),
1028 waiter_rx: Receiver<JobOutcome>,
1029 snapshot: &InspectSnapshot,
1030 deadline: Instant,
1031 ) -> JobOutcome {
1032 let Some(remaining) = deadline.checked_duration_since(Instant::now()) else {
1033 return JobOutcome::Pending { in_flight: true };
1034 };
1035 if remaining.is_zero() {
1036 return JobOutcome::Pending { in_flight: true };
1037 }
1038
1039 match waiter_rx.recv_timeout(remaining) {
1040 Ok(outcome) => filter_outcome_for_scope_with_contributions(
1041 outcome,
1042 snapshot,
1043 key.category,
1044 cache,
1045 caller_scope,
1046 ),
1047 Err(crossbeam_channel::RecvTimeoutError::Timeout) => {
1048 JobOutcome::Pending { in_flight: true }
1049 }
1050 Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
1051 JobOutcome::Pending { in_flight: true }
1052 }
1053 }
1054 }
1055
1056 pub fn tier2_read_cached(
1063 &self,
1064 snapshot: InspectSnapshot,
1065 category: InspectCategory,
1066 caller_scope: JobScope,
1067 ) -> JobOutcome {
1068 if let Err(outcome) = validate_tier2_read_category(category) {
1069 return outcome;
1070 }
1071 if !self.heavy_root_work_allowed() {
1072 return JobOutcome::Failed {
1073 message: Self::heavy_root_work_block_message(category),
1074 };
1075 }
1076 let cache = match self.cache_for_snapshot(&snapshot) {
1077 Ok(cache) => cache,
1078 Err(message) => return JobOutcome::Failed { message },
1079 };
1080 self.tier2_read_cached_from_cache(&snapshot, category, &caller_scope, cache.as_ref())
1081 }
1082
1083 pub fn tier2_read_cached_readonly(
1084 &self,
1085 snapshot: InspectSnapshot,
1086 category: InspectCategory,
1087 caller_scope: JobScope,
1088 ) -> JobOutcome {
1089 if let Err(outcome) = validate_tier2_read_category(category) {
1090 return outcome;
1091 }
1092 if !self.heavy_root_work_allowed() {
1093 return JobOutcome::Failed {
1094 message: Self::heavy_root_work_block_message(category),
1095 };
1096 }
1097 let key = JobKey::for_project_category(category);
1098 let in_flight = self
1099 .in_flight
1100 .lock()
1101 .map(|guard| guard.contains_key(&key))
1102 .unwrap_or(false);
1103 let cache = match InspectCache::open_readonly(
1104 snapshot.inspect_dir.clone(),
1105 snapshot.project_root.clone(),
1106 ) {
1107 Ok(Some(cache)) => cache,
1108 Ok(None) => return JobOutcome::Pending { in_flight },
1109 Err(error) => {
1110 return JobOutcome::Failed {
1111 message: error.to_string(),
1112 }
1113 }
1114 };
1115 self.tier2_read_cached_from_cache(&snapshot, category, &caller_scope, &cache)
1116 }
1117
1118 fn tier2_read_cached_from_cache(
1119 &self,
1120 snapshot: &InspectSnapshot,
1121 category: InspectCategory,
1122 caller_scope: &JobScope,
1123 cache: &(impl InspectCacheRead + ?Sized),
1124 ) -> JobOutcome {
1125 let key = JobKey::for_project_category(category);
1126 let in_flight = self
1127 .in_flight
1128 .lock()
1129 .map(|guard| guard.contains_key(&key))
1130 .unwrap_or(false);
1131 match cache.get_aggregated_for_config(&key, snapshot.config.as_ref()) {
1132 Ok(Some(payload)) => {
1133 match self.tier2_cached_aggregate_is_fresh(snapshot, category, cache) {
1134 Ok(true) => filter_outcome_for_scope_with_contributions(
1135 JobOutcome::Fresh { payload },
1136 snapshot,
1137 category,
1138 cache,
1139 caller_scope,
1140 ),
1141 Ok(false) => filter_outcome_for_scope_with_contributions(
1142 JobOutcome::Stale {
1143 cached: Some(payload),
1144 in_flight,
1145 },
1146 snapshot,
1147 category,
1148 cache,
1149 caller_scope,
1150 ),
1151 Err(message) => JobOutcome::Failed { message },
1152 }
1153 }
1154 Ok(None) => match cache.latest_aggregate_any_hash(category) {
1155 Ok(Some(payload)) => filter_outcome_for_scope_with_contributions(
1156 JobOutcome::Stale {
1157 cached: Some(payload),
1158 in_flight,
1159 },
1160 snapshot,
1161 category,
1162 cache,
1163 caller_scope,
1164 ),
1165 Ok(None) => JobOutcome::Pending { in_flight },
1166 Err(error) => JobOutcome::Failed {
1167 message: error.to_string(),
1168 },
1169 },
1170 Err(error) => JobOutcome::Failed {
1171 message: error.to_string(),
1172 },
1173 }
1174 }
1175
1176 fn tier2_cached_aggregate_is_fresh(
1177 &self,
1178 snapshot: &InspectSnapshot,
1179 category: InspectCategory,
1180 cache: &(impl InspectCacheRead + ?Sized),
1181 ) -> Result<bool, String> {
1182 let cached_records = load_contribution_freshness(cache, category)?;
1183 let cached_relative = cached_records
1184 .iter()
1185 .map(freshness_record_relative_key)
1186 .collect::<BTreeSet<_>>();
1187
1188 for record in &cached_records {
1189 let absolute = if record.file_path.is_absolute() {
1190 record.file_path.clone()
1191 } else {
1192 snapshot.project_root.join(&record.file_path)
1193 };
1194 match verify_contribution_file(&absolute, &record.freshness) {
1195 ContributionFreshness::Fresh { .. } => {}
1196 ContributionFreshness::Stale | ContributionFreshness::Deleted => return Ok(false),
1197 }
1198 }
1199
1200 let project_scope = JobScope::for_project(snapshot.project_root.clone());
1208 let project_files = scope_files(&snapshot.project_root, &project_scope);
1209 let current_by_relative = current_project_files(&snapshot.project_root, &project_files);
1210
1211 Ok(current_by_relative.len() == cached_relative.len()
1212 && current_by_relative
1213 .keys()
1214 .all(|relative| cached_relative.contains(relative)))
1215 }
1216
1217 #[doc(hidden)]
1218 pub fn tier2_run_with_reuse_result(
1219 &self,
1220 snapshot: InspectSnapshot,
1221 category: InspectCategory,
1222 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
1223 ) -> InspectResult {
1224 let job = self.tier2_reuse_job(snapshot, category, callgraph_snapshot);
1225 self.tier2_run_with_reuse_job_result(job)
1226 }
1227
1228 fn tier2_run_with_reuse_job_result(&self, job: InspectJob) -> InspectResult {
1229 self.tier2_run_with_reuse_job_result_with_options(job, Tier2ReuseOptions::default())
1230 }
1231
1232 fn tier2_run_with_reuse_job_result_catching(
1233 &self,
1234 job: InspectJob,
1235 options: Tier2ReuseOptions,
1236 ) -> InspectResult {
1237 let started = Instant::now();
1238 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1239 self.tier2_run_with_reuse_job_result_with_options(job.clone(), options)
1240 })) {
1241 Ok(result) => result,
1242 Err(_) => InspectResult::failed(
1243 &job,
1244 "tier2 reuse worker panicked before completion",
1245 started.elapsed(),
1246 ),
1247 }
1248 }
1249
1250 fn tier2_run_with_reuse_job_result_with_options(
1251 &self,
1252 mut job: InspectJob,
1253 mut options: Tier2ReuseOptions,
1254 ) -> InspectResult {
1255 let started = Instant::now();
1256 self.reuse_starts.fetch_add(1, Ordering::SeqCst);
1257 self.wait_for_tier2_reuse_waiter_for_debug(&job);
1258 panic_tier2_reuse_for_debug(&job);
1259 if !job.category.is_active() {
1260 let result = InspectResult::failed(
1261 &job,
1262 format!("inspect category '{}' is disabled in v0.33", job.category),
1263 started.elapsed(),
1264 );
1265 log_tier2_benchmark_category_end(&result);
1266 return result;
1267 }
1268 if !job.category.is_tier2() {
1269 let result = InspectResult::failed(
1270 &job,
1271 format!(
1272 "inspect category '{}' is not a Tier 2 category",
1273 job.category
1274 ),
1275 started.elapsed(),
1276 );
1277 log_tier2_benchmark_category_end(&result);
1278 return result;
1279 }
1280
1281 if !job.inspect_writer {
1282 let result = InspectResult::failed(
1283 &job,
1284 "inspect writer capability is unavailable for this read-only cache path",
1285 started.elapsed(),
1286 );
1287 log_tier2_benchmark_category_end(&result);
1288 return result;
1289 }
1290
1291 let project_scope = JobScope::for_project(job.project_root.clone());
1292 job.scope_files = scope_files(&job.project_root, &project_scope);
1293 log_tier2_benchmark_category_start(&job);
1294 let cache = match self.cache_for_paths(job.inspect_dir.clone(), job.project_root.clone()) {
1295 Ok(cache) => cache,
1296 Err(message) => {
1297 let result = InspectResult::failed(&job, message, started.elapsed());
1298 log_tier2_benchmark_category_end(&result);
1299 return result;
1300 }
1301 };
1302 delay_tier2_reuse_for_debug(&job.project_root);
1303 if options.has_force_paths() {
1304 if let Ok(cached) = load_contribution_freshness(cache.as_ref(), job.category) {
1305 let (remaining, downgraded) = downgrade_unchanged_forced_paths_with_freshness(
1306 &job.project_root,
1307 &cached,
1308 options.force_rescan_paths.iter().cloned().collect(),
1309 );
1310 options.force_rescan_paths = remaining.into_iter().collect();
1311 if downgraded > 0 {
1312 crate::slog_info!(
1313 "inspect: {} forced paths downgraded to cached (content unchanged)",
1314 downgraded
1315 );
1316 }
1317 }
1318 }
1319 if !options.has_force_paths() {
1320 if let Ok(Some(success)) =
1321 self.tier2_quick_reuse_success(&job, cache.as_ref(), &options)
1322 {
1323 let result = InspectResult::success(&job, success, started.elapsed());
1324 crate::slog_debug!(
1325 "perf tier2 category={} reuse=hit ms={}",
1326 job.category,
1327 started.elapsed().as_millis()
1328 );
1329 log_tier2_benchmark_category_end(&result);
1330 return result;
1331 }
1332 }
1333
1334 let result = match self.tier2_run_with_reuse_job(&job, &cache, &options) {
1335 Ok(success) => InspectResult::success(&job, success, started.elapsed()),
1336 Err(message) => InspectResult::failed(&job, message, started.elapsed()),
1337 };
1338 crate::slog_info!(
1342 "perf tier2 category={} reuse=miss ms={}",
1343 job.category,
1344 started.elapsed().as_millis()
1345 );
1346 log_tier2_benchmark_category_end(&result);
1347 result
1348 }
1349
1350 fn tier2_reuse_job(
1351 &self,
1352 snapshot: InspectSnapshot,
1353 category: InspectCategory,
1354 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
1355 ) -> InspectJob {
1356 InspectJob {
1357 job_id: self.next_job_id.fetch_add(1, Ordering::Relaxed),
1358 key: JobKey::for_project_category(category),
1359 category,
1360 scope_files: Vec::new(),
1361 project_root: snapshot.project_root,
1362 inspect_dir: snapshot.inspect_dir,
1363 config: snapshot.config,
1364 symbol_cache: snapshot.symbol_cache,
1365 inspect_writer: snapshot.inspect_writer,
1366 callgraph_writer: snapshot.callgraph_writer,
1367 callgraph_snapshot,
1368 }
1369 }
1370
1371 fn tier2_quick_reuse_success(
1372 &self,
1373 job: &InspectJob,
1374 cache: &InspectCache,
1375 options: &Tier2ReuseOptions,
1376 ) -> Result<Option<InspectScanSuccess>, String> {
1377 let cached_records = load_contribution_freshness(cache, job.category)?;
1378 let current_by_relative = current_project_files(&job.project_root, &job.scope_files);
1379 if cached_records.len() != current_by_relative.len() {
1380 return Ok(None);
1381 }
1382 for record in &cached_records {
1383 let relative = freshness_record_relative_key(record);
1384 let Some(current_file) = current_by_relative.get(&relative) else {
1385 return Ok(None);
1386 };
1387 match cache_freshness::metadata_matches(current_file, &record.freshness) {
1388 Ok(true) => {}
1389 Ok(false) => return Ok(None),
1390 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1391 Err(error) => {
1392 return Err(format!(
1393 "failed to stat {} for tier2 quick reuse: {error}",
1394 current_file.display()
1395 ));
1396 }
1397 }
1398 }
1399
1400 let contribution_set_hash = cache
1401 .contribution_set_hash_for_config(job.category, job.config.as_ref())
1402 .map_err(|error| error.to_string())?;
1403 let Some(aggregate) = cache
1404 .load_aggregate_if_hash_matches(job.category, &contribution_set_hash)
1405 .map_err(|error| error.to_string())?
1406 else {
1407 return Ok(None);
1408 };
1409 if !cached_tier2_aggregate_usable(job.category, options, &aggregate) {
1410 return Ok(None);
1411 }
1412
1413 cache
1414 .touch_tier2_last_full_run(job.category)
1415 .map_err(|error| error.to_string())?;
1416 Ok(Some(InspectScanSuccess {
1417 scanned_files: Vec::new(),
1418 contributions: Vec::new(),
1419 aggregate,
1420 }))
1421 }
1422
1423 #[allow(clippy::too_many_lines)]
1424 fn tier2_run_with_reuse_job(
1425 &self,
1426 job: &InspectJob,
1427 cache: &InspectCache,
1428 options: &Tier2ReuseOptions,
1429 ) -> Result<InspectScanSuccess, String> {
1430 let mut phases = Tier2PhaseTimings::default();
1431 let phase_started = Instant::now();
1432 let cached_records = load_contribution_freshness(cache, job.category)?;
1433 let current_by_relative = current_project_files(&job.project_root, &job.scope_files);
1434 let cached_relative = cached_records
1435 .iter()
1436 .map(freshness_record_relative_key)
1437 .collect::<BTreeSet<_>>();
1438 let force_relative = forced_relative_paths(job, &options.force_rescan_paths);
1439 let cold_cache = cached_relative.is_empty();
1440 #[cfg(debug_assertions)]
1441 let debug_cold_cache = cold_cache;
1442
1443 let mut updates = Tier2ContributionUpdates::default();
1444 let mut scan_by_relative = BTreeMap::<String, PathBuf>::new();
1445 let mut callgraph_refresh_paths = options
1446 .force_rescan_paths
1447 .iter()
1448 .filter(|path| callgraph_store_indexes_path(path))
1449 .cloned()
1450 .collect::<BTreeSet<_>>();
1451 let mut aggregate_job = job.clone();
1452
1453 for record in cached_records {
1454 let relative = freshness_record_relative_key(&record);
1455 let relative_path = PathBuf::from(&relative);
1456 let Some(current_file) = current_by_relative.get(&relative) else {
1457 updates.deletes.push(relative_path);
1458 insert_callgraph_refresh_path(
1459 &mut callgraph_refresh_paths,
1460 job.project_root.join(&relative),
1461 );
1462 continue;
1463 };
1464
1465 if force_relative.contains(&relative) {
1466 updates.deletes.push(relative_path);
1467 scan_by_relative.insert(relative, current_file.clone());
1468 insert_callgraph_refresh_path(&mut callgraph_refresh_paths, current_file.clone());
1469 continue;
1470 }
1471
1472 let absolute = job.project_root.join(&record.file_path);
1473 match verify_contribution_file(&absolute, &record.freshness) {
1474 ContributionFreshness::Fresh {
1475 metadata_changed,
1476 freshness,
1477 } => {
1478 if metadata_changed {
1479 updates.metadata_updates.push((relative_path, freshness));
1480 }
1481 }
1482 ContributionFreshness::Stale => {
1483 updates.deletes.push(relative_path);
1484 scan_by_relative.insert(relative, current_file.clone());
1485 insert_callgraph_refresh_path(
1486 &mut callgraph_refresh_paths,
1487 current_file.clone(),
1488 );
1489 }
1490 ContributionFreshness::Deleted => {
1491 updates.deletes.push(relative_path);
1492 insert_callgraph_refresh_path(
1493 &mut callgraph_refresh_paths,
1494 job.project_root.join(&record.file_path),
1495 );
1496 }
1497 }
1498 }
1499
1500 for (relative, file) in ¤t_by_relative {
1501 if !cached_relative.contains(relative) {
1502 scan_by_relative.insert(relative.clone(), file.clone());
1503 if !cold_cache {
1504 insert_callgraph_refresh_path(&mut callgraph_refresh_paths, file.clone());
1505 }
1506 }
1507 }
1508 phases.freshness = phase_started.elapsed();
1509
1510 let mut scan_files = scan_by_relative.into_values().collect::<Vec<_>>();
1511 let force_reparse_files = scan_files.clone();
1512 let callgraph_refresh_files = callgraph_refresh_paths.into_iter().collect::<Vec<_>>();
1513 let dead_code_callgraph_refresh =
1514 job.category == InspectCategory::DeadCode && !callgraph_refresh_files.is_empty();
1515 if !scan_files.is_empty() {
1516 let mut scan_job = job.clone();
1517 scan_job.job_id = self.next_job_id.fetch_add(1, Ordering::Relaxed);
1518 scan_job.scope_files = scan_files.clone();
1519 if scan_job.category == InspectCategory::DeadCode
1520 && scan_job.callgraph_snapshot.is_none()
1521 {
1522 let snapshot_started = Instant::now();
1523 scan_job.callgraph_snapshot = self.build_tier2_callgraph_snapshot_with_refresh(
1524 &scan_job,
1525 options.allow_callgraph_cold_build,
1526 &callgraph_refresh_files,
1527 );
1528 phases.snapshot += snapshot_started.elapsed();
1529 }
1530 aggregate_job.callgraph_snapshot = scan_job.callgraph_snapshot.clone();
1531 #[cfg(debug_assertions)]
1532 if debug_cold_cache {
1533 std::thread::sleep(Duration::from_millis(10));
1534 }
1535 let scan_started = Instant::now();
1536 let oxc_result =
1537 self.oxc_result_for_scan(&scan_job, &scan_job.scope_files, &force_reparse_files)?;
1538 let scan_result = run_tier2_scan(&scan_job, oxc_result.as_ref());
1539 phases.scan += scan_started.elapsed();
1540 phases.scanned_files += scan_files.len();
1541 let scan_success = scan_result.outcome.map_err(|message| {
1542 format!("{} incremental scan failed: {message}", job.category)
1543 })?;
1544 updates.upserts.extend(scan_success.contributions);
1545 }
1546
1547 let has_updates = !updates.upserts.is_empty()
1548 || !updates.deletes.is_empty()
1549 || !updates.metadata_updates.is_empty();
1550 if !has_updates && !dead_code_callgraph_refresh {
1551 if let Some(aggregate) = cache
1552 .get_aggregated_for_config(&job.key, job.config.as_ref())
1553 .map_err(|error| error.to_string())?
1554 {
1555 if cached_tier2_aggregate_usable(job.category, options, &aggregate) {
1556 cache
1557 .touch_tier2_last_full_run(job.category)
1558 .map_err(|error| error.to_string())?;
1559 phases.log(job.category);
1560 return Ok(InspectScanSuccess {
1561 scanned_files: scan_files,
1562 contributions: Vec::new(),
1563 aggregate,
1564 });
1565 }
1566 }
1567 }
1568
1569 let db_started = Instant::now();
1570 let mut contribution_set_hash = if has_updates {
1571 let (hash, db_timings) = cache
1572 .apply_contribution_updates_for_config(job.category, updates, job.config.as_ref())
1573 .map_err(|error| error.to_string())?;
1574 phases.add_db_timings(db_timings);
1575 hash
1576 } else {
1577 cache
1578 .contribution_set_hash_for_config(job.category, job.config.as_ref())
1579 .map_err(|error| error.to_string())?
1580 };
1581 phases.db = db_started.elapsed();
1582
1583 if !dead_code_callgraph_refresh {
1584 if let Some(aggregate) = cache
1585 .load_aggregate_if_hash_matches(job.category, &contribution_set_hash)
1586 .map_err(|error| error.to_string())?
1587 {
1588 if cached_tier2_aggregate_usable(job.category, options, &aggregate) {
1589 cache
1590 .touch_tier2_last_full_run(job.category)
1591 .map_err(|error| error.to_string())?;
1592 let contributions = load_contributions(cache, job)?;
1593 phases.log(job.category);
1594 return Ok(InspectScanSuccess {
1595 scanned_files: scan_files,
1596 contributions,
1597 aggregate,
1598 });
1599 }
1600 }
1601 }
1602
1603 let refresh_dead_code_facts = if job.category == InspectCategory::DeadCode {
1604 dead_code_contributions_need_fact_refresh(cache, job)?
1605 } else {
1606 false
1607 };
1608 let refresh_unused_exports_facts = if job.category == InspectCategory::UnusedExports {
1609 unused_exports_contributions_need_fact_refresh(cache, job)?
1610 } else {
1611 false
1612 };
1613 let refresh_duplicates_facts = if job.category == InspectCategory::Duplicates {
1614 duplicates_contributions_need_fact_refresh(cache, job)?
1615 } else {
1616 false
1617 };
1618 if refresh_dead_code_facts || refresh_unused_exports_facts || refresh_duplicates_facts {
1619 let full_scan_files = current_by_relative.into_values().collect::<Vec<_>>();
1624 if !full_scan_files.is_empty() {
1625 let mut rescan_job = job.clone();
1626 rescan_job.job_id = self.next_job_id.fetch_add(1, Ordering::Relaxed);
1627 rescan_job.scope_files = full_scan_files.clone();
1628 if rescan_job.category == InspectCategory::DeadCode
1629 && rescan_job.callgraph_snapshot.is_none()
1630 {
1631 let snapshot_started = Instant::now();
1632 rescan_job.callgraph_snapshot = self
1633 .build_tier2_callgraph_snapshot_with_refresh(
1634 &rescan_job,
1635 options.allow_callgraph_cold_build,
1636 &callgraph_refresh_files,
1637 );
1638 phases.snapshot += snapshot_started.elapsed();
1639 }
1640 let scan_started = Instant::now();
1641 let oxc_result = self.oxc_result_for_scan(
1642 &rescan_job,
1643 &rescan_job.scope_files,
1644 &force_reparse_files,
1645 )?;
1646 let scan_result = run_tier2_scan(&rescan_job, oxc_result.as_ref());
1647 phases.scan += scan_started.elapsed();
1648 phases.scanned_files += full_scan_files.len();
1649 let scan_success = scan_result.outcome.map_err(|message| {
1650 format!(
1651 "{} full rescan after entry-point cache miss failed: {message}",
1652 job.category
1653 )
1654 })?;
1655 let rescan_updates = Tier2ContributionUpdates {
1656 upserts: scan_success.contributions,
1657 ..Tier2ContributionUpdates::default()
1658 };
1659 let db_started = Instant::now();
1660 let (hash, db_timings) = cache
1661 .apply_contribution_updates_for_config(
1662 job.category,
1663 rescan_updates,
1664 job.config.as_ref(),
1665 )
1666 .map_err(|error| error.to_string())?;
1667 contribution_set_hash = hash;
1668 phases.add_db_timings(db_timings);
1669 phases.db += db_started.elapsed();
1670 aggregate_job.callgraph_snapshot = rescan_job.callgraph_snapshot.clone();
1671 scan_files = full_scan_files;
1672
1673 if !dead_code_callgraph_refresh {
1674 if let Some(aggregate) = cache
1675 .load_aggregate_if_hash_matches(job.category, &contribution_set_hash)
1676 .map_err(|error| error.to_string())?
1677 {
1678 if cached_tier2_aggregate_usable(job.category, options, &aggregate) {
1679 cache
1680 .touch_tier2_last_full_run(job.category)
1681 .map_err(|error| error.to_string())?;
1682 let contributions = load_contributions(cache, job)?;
1683 phases.log(job.category);
1684 return Ok(InspectScanSuccess {
1685 scanned_files: scan_files,
1686 contributions,
1687 aggregate,
1688 });
1689 }
1690 }
1691 }
1692 }
1693 }
1694
1695 if aggregate_job.category == InspectCategory::DeadCode
1696 && aggregate_job.callgraph_snapshot.is_none()
1697 {
1698 let snapshot_started = Instant::now();
1699 aggregate_job.callgraph_snapshot = self.build_tier2_callgraph_snapshot_with_refresh(
1700 &aggregate_job,
1701 options.allow_callgraph_cold_build,
1702 &callgraph_refresh_files,
1703 );
1704 phases.snapshot += snapshot_started.elapsed();
1705 }
1706 let rollup_started = Instant::now();
1707 let contributions = load_contributions(cache, &aggregate_job)?;
1708 let aggregate = roll_up_tier2_contributions(&aggregate_job, &contributions);
1709 cache
1710 .store_tier2_aggregate(job.key.clone(), &contribution_set_hash, aggregate.clone())
1711 .map_err(|error| error.to_string())?;
1712 phases.rollup = rollup_started.elapsed();
1713 phases.log(job.category);
1714
1715 Ok(InspectScanSuccess {
1716 scanned_files: scan_files,
1717 contributions,
1718 aggregate,
1719 })
1720 }
1721
1722 fn enqueue_with_waiter(
1723 &self,
1724 snapshot: InspectSnapshot,
1725 category: InspectCategory,
1726 caller_scope: JobScope,
1727 key: JobKey,
1728 waiter_tx: WaiterTx,
1729 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
1730 ) -> Result<(), String> {
1731 let mut in_flight = self
1732 .in_flight
1733 .lock()
1734 .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
1735 if let Some(waiters) = in_flight.get_mut(&key) {
1736 waiters.push(Waiter { tx: waiter_tx });
1737 return Ok(());
1738 }
1739
1740 in_flight.insert(key.clone(), vec![Waiter { tx: waiter_tx }]);
1741 drop(in_flight);
1742
1743 if let Err(message) = self.enqueue_new_job(
1744 snapshot,
1745 category,
1746 caller_scope,
1747 key.clone(),
1748 callgraph_snapshot,
1749 ) {
1750 if let Ok(mut in_flight) = self.in_flight.lock() {
1751 in_flight.remove(&key);
1752 }
1753 return Err(message);
1754 }
1755 Ok(())
1756 }
1757
1758 fn enqueue_without_waiter(
1759 &self,
1760 snapshot: InspectSnapshot,
1761 category: InspectCategory,
1762 caller_scope: JobScope,
1763 key: JobKey,
1764 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
1765 ) -> Result<(), String> {
1766 let mut in_flight = self
1767 .in_flight
1768 .lock()
1769 .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
1770 if in_flight.contains_key(&key) {
1771 return Ok(());
1772 }
1773 in_flight.insert(key.clone(), Vec::new());
1774 drop(in_flight);
1775
1776 if let Err(message) = self.enqueue_new_job(
1777 snapshot,
1778 category,
1779 caller_scope,
1780 key.clone(),
1781 callgraph_snapshot,
1782 ) {
1783 if let Ok(mut in_flight) = self.in_flight.lock() {
1784 in_flight.remove(&key);
1785 }
1786 return Err(message);
1787 }
1788 Ok(())
1789 }
1790
1791 fn enqueue_new_job(
1792 &self,
1793 snapshot: InspectSnapshot,
1794 category: InspectCategory,
1795 caller_scope: JobScope,
1796 key: JobKey,
1797 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
1798 ) -> Result<(), String> {
1799 let scan_scope = if category.is_tier2() {
1800 JobScope::for_project(snapshot.project_root.clone())
1801 } else {
1802 caller_scope
1803 };
1804 let scope_files = scope_files(&snapshot.project_root, &scan_scope);
1805 let job = InspectJob {
1806 job_id: self.next_job_id.fetch_add(1, Ordering::Relaxed),
1807 key,
1808 category,
1809 scope_files,
1810 project_root: snapshot.project_root,
1811 inspect_dir: snapshot.inspect_dir,
1812 config: snapshot.config,
1813 symbol_cache: snapshot.symbol_cache,
1814 inspect_writer: snapshot.inspect_writer,
1815 callgraph_writer: snapshot.callgraph_writer,
1816 callgraph_snapshot,
1817 };
1818 self.request_tx
1819 .send(job)
1820 .map_err(|_| "inspect dispatch loop is unavailable".to_string())
1821 }
1822
1823 fn wait_for_outcome(
1824 &self,
1825 key: JobKey,
1826 caller_scope: JobScope,
1827 cache: Arc<InspectCache>,
1828 waiter_rx: Receiver<JobOutcome>,
1829 snapshot: InspectSnapshot,
1830 ) -> JobOutcome {
1831 let timeout = after(self.soft_deadline);
1832 let result_rx = self.result_rx.clone();
1833 loop {
1834 select! {
1835 recv(waiter_rx) -> outcome => {
1836 return match outcome {
1837 Ok(outcome) => filter_outcome_for_scope_with_contributions(
1838 outcome,
1839 &snapshot,
1840 key.category,
1841 cache.as_ref(),
1842 &caller_scope,
1843 ),
1844 Err(_) => self.timeout_outcome(&key, &caller_scope, &cache, &snapshot),
1845 };
1846 }
1847 recv(result_rx) -> result => {
1848 match result {
1849 Ok(result) => self.route_completion(result),
1850 Err(_) => return self.timeout_outcome(&key, &caller_scope, &cache, &snapshot),
1851 }
1852 }
1853 recv(timeout) -> _ => {
1854 return self.timeout_outcome(&key, &caller_scope, &cache, &snapshot);
1855 }
1856 }
1857 }
1858 }
1859
1860 fn timeout_outcome(
1861 &self,
1862 key: &JobKey,
1863 caller_scope: &JobScope,
1864 cache: &(impl InspectCacheRead + ?Sized),
1865 snapshot: &InspectSnapshot,
1866 ) -> JobOutcome {
1867 match cache.get_aggregated_for_config(key, snapshot.config.as_ref()) {
1868 Ok(Some(cached)) => filter_outcome_for_scope_with_contributions(
1869 JobOutcome::Stale {
1870 cached: Some(cached),
1871 in_flight: true,
1872 },
1873 snapshot,
1874 key.category,
1875 cache,
1876 caller_scope,
1877 ),
1878 Ok(None) => JobOutcome::Pending { in_flight: true },
1879 Err(error) => JobOutcome::Failed {
1880 message: error.to_string(),
1881 },
1882 }
1883 }
1884
1885 fn route_completion(&self, result: InspectResult) {
1886 let outcome = self.completion_outcome(result.clone());
1887 let waiters = self
1888 .in_flight
1889 .lock()
1890 .ok()
1891 .and_then(|mut in_flight| in_flight.remove(&result.key))
1892 .unwrap_or_default();
1893 for waiter in waiters {
1894 let _ = waiter.tx.send(outcome.clone());
1895 }
1896 }
1897
1898 fn route_tier2_reuse_completion(&self, result: InspectResult) {
1899 let outcome = match result.outcome.clone() {
1900 Ok(success) => JobOutcome::Fresh {
1901 payload: success.aggregate,
1902 },
1903 Err(message) => JobOutcome::Failed { message },
1904 };
1905 let waiters = self
1906 .in_flight
1907 .lock()
1908 .ok()
1909 .and_then(|mut in_flight| in_flight.remove(&result.key))
1910 .unwrap_or_default();
1911 self.reuse_completions.fetch_add(1, Ordering::SeqCst);
1914 for waiter in waiters {
1915 let _ = waiter.tx.send(outcome.clone());
1916 }
1917 }
1922
1923 pub fn reuse_completion_count(&self) -> u64 {
1927 self.reuse_completions.load(Ordering::SeqCst)
1928 }
1929
1930 #[doc(hidden)]
1931 pub fn reuse_start_count_for_test(&self) -> u64 {
1932 self.reuse_starts.load(Ordering::SeqCst)
1933 }
1934
1935 fn completion_outcome(&self, result: InspectResult) -> JobOutcome {
1936 let cache =
1937 match self.cache_for_paths(result.inspect_dir.clone(), result.project_root.clone()) {
1938 Ok(cache) => cache,
1939 Err(message) => return JobOutcome::Failed { message },
1940 };
1941
1942 match result.outcome {
1943 Ok(success) => {
1944 let store_result = if result.category.is_tier2() {
1945 cache.store_tier2_result_for_config(
1946 result.key.clone(),
1947 &success.scanned_files,
1948 &success.contributions,
1949 success.aggregate.clone(),
1950 result.config.as_ref(),
1951 )
1952 } else {
1953 cache.store_aggregated(result.key, success.aggregate.clone())
1954 };
1955
1956 match store_result {
1957 Ok(()) => JobOutcome::Fresh {
1958 payload: success.aggregate,
1959 },
1960 Err(error) => JobOutcome::Failed {
1961 message: error.to_string(),
1962 },
1963 }
1964 }
1965 Err(message) => JobOutcome::Failed { message },
1966 }
1967 }
1968}
1969
1970impl Default for InspectManager {
1971 fn default() -> Self {
1972 Self::new()
1973 }
1974}
1975
1976fn validate_tier2_read_category(category: InspectCategory) -> Result<(), JobOutcome> {
1977 if !category.is_active() {
1978 return Err(JobOutcome::Failed {
1979 message: format!("inspect category '{category}' is disabled in v0.33"),
1980 });
1981 }
1982 if !category.is_tier2() {
1983 return Err(JobOutcome::Failed {
1984 message: format!("inspect category '{category}' is not a Tier 2 category"),
1985 });
1986 }
1987 Ok(())
1988}
1989
1990#[derive(Default)]
1997struct Tier2PhaseTimings {
1998 freshness: Duration,
2000 snapshot: Duration,
2002 scan: Duration,
2004 db: Duration,
2006 db_lock: Duration,
2008 db_txn: Duration,
2010 rollup: Duration,
2012 scanned_files: usize,
2013}
2014
2015impl Tier2PhaseTimings {
2016 fn add_db_timings(&mut self, timings: InspectDbTimings) {
2017 self.db_lock += timings.lock_wait;
2018 self.db_txn += timings.transaction;
2019 }
2020
2021 fn log(&self, category: InspectCategory) {
2022 let worked = self.freshness + self.scan + self.snapshot + self.rollup + self.db;
2023 if !worked.is_zero() {
2024 crate::logging::note_tier2_scan(
2025 category.to_string(),
2026 worked.as_millis().min(u128::from(u64::MAX)) as u64,
2027 );
2028 }
2029 if worked < Duration::from_millis(50) {
2030 return;
2031 }
2032 crate::slog_info!(
2033 "perf tier2 phases category={} freshness={}ms snapshot={}ms scan={}ms({} files) db={}ms(lock={},txn={}) rollup={}ms",
2034 category,
2035 self.freshness.as_millis(),
2036 self.snapshot.as_millis(),
2037 self.scan.as_millis(),
2038 self.scanned_files,
2039 self.db.as_millis(),
2040 self.db_lock.as_millis(),
2041 self.db_txn.as_millis(),
2042 self.rollup.as_millis()
2043 );
2044 }
2045}
2046
2047fn scope_files(project_root: &Path, scope: &JobScope) -> Vec<PathBuf> {
2048 let mut files = crate::callgraph::walk_project_files(project_root)
2049 .filter(|path| scope.contains(path))
2050 .collect::<Vec<_>>();
2051 files.sort();
2052 files
2053}
2054
2055fn forced_relative_paths(job: &InspectJob, paths: &BTreeSet<PathBuf>) -> BTreeSet<String> {
2056 let mut keys = BTreeSet::new();
2057 for path in paths {
2058 let absolute = if path.is_absolute() {
2059 path.clone()
2060 } else {
2061 job.project_root.join(path)
2062 };
2063 keys.insert(relative_cache_key(&job.project_root, &absolute));
2064 keys.insert(relative_cache_key(
2069 &job.project_root,
2070 &crate::inspect::job::canonicalize_normalized(&absolute),
2071 ));
2072 }
2073 keys
2074}
2075
2076fn downgrade_unchanged_forced_paths_with_freshness(
2077 project_root: &Path,
2078 cached: &[CachedContributionFreshness],
2079 paths: Vec<PathBuf>,
2080) -> (Vec<PathBuf>, usize) {
2081 let cached = cached
2082 .iter()
2083 .map(|record| (freshness_record_relative_key(record), record.freshness))
2084 .collect::<BTreeMap<_, _>>();
2085 let mut remaining = Vec::with_capacity(paths.len());
2086 let mut downgraded = 0;
2087
2088 for path in paths {
2089 let absolute = if path.is_absolute() {
2090 path.clone()
2091 } else {
2092 project_root.join(&path)
2093 };
2094 let direct_key = relative_cache_key(project_root, &absolute);
2095 let canonical_key = Some(relative_cache_key(
2097 project_root,
2098 &crate::inspect::job::canonicalize_normalized(&absolute),
2099 ));
2100 let freshness = cached
2101 .get(&direct_key)
2102 .or_else(|| canonical_key.as_ref().and_then(|key| cached.get(key)));
2103 let content_unchanged = freshness.is_some_and(|freshness| {
2104 matches!(
2105 cache_freshness::verify_file_strict(&absolute, freshness),
2106 FreshnessVerdict::HotFresh | FreshnessVerdict::ContentFresh { .. }
2107 )
2108 });
2109 if content_unchanged {
2110 downgraded += 1;
2111 } else {
2112 remaining.push(path);
2113 }
2114 }
2115
2116 (remaining, downgraded)
2117}
2118
2119fn panic_tier2_reuse_for_debug(job: &InspectJob) {
2120 #[cfg(not(debug_assertions))]
2121 let _ = job;
2122 #[cfg(debug_assertions)]
2123 {
2124 if !env_project_root_matches("AFT_TEST_TIER2_REUSE_PANIC_ROOT", &job.project_root) {
2125 return;
2126 }
2127 let should_panic = std::env::var("AFT_TEST_TIER2_REUSE_PANIC_CATEGORY")
2128 .ok()
2129 .is_some_and(|category| category == job.category.as_str());
2130 if should_panic {
2131 panic!("forced tier2 reuse panic for {}", job.category);
2132 }
2133 }
2134}
2135
2136fn delay_direct_force_followup_deadline_check_for_debug(project_root: &Path) {
2137 #[cfg(not(debug_assertions))]
2138 let _ = project_root;
2139 #[cfg(debug_assertions)]
2140 {
2141 if !env_project_root_matches("AFT_TEST_DIRECT_FORCE_FOLLOWUP_DELAY_ROOT", project_root) {
2142 return;
2143 }
2144 if let Some(delay_ms) = std::env::var("AFT_TEST_DIRECT_FORCE_FOLLOWUP_DELAY_MS")
2145 .ok()
2146 .and_then(|raw| raw.parse::<u64>().ok())
2147 {
2148 std::thread::sleep(Duration::from_millis(delay_ms));
2149 }
2150 }
2151}
2152
2153fn delay_tier2_reuse_for_debug(project_root: &Path) {
2154 #[cfg(not(debug_assertions))]
2155 let _ = project_root;
2156 #[cfg(debug_assertions)]
2157 {
2158 if !env_project_root_matches("AFT_TEST_TIER2_REUSE_DELAY_ROOT", project_root) {
2159 return;
2160 }
2161 if let Some(delay_ms) = std::env::var("AFT_TEST_TIER2_REUSE_DELAY_MS")
2162 .ok()
2163 .and_then(|raw| raw.parse::<u64>().ok())
2164 {
2165 std::thread::sleep(Duration::from_millis(delay_ms));
2166 }
2167 }
2168}
2169
2170#[cfg(debug_assertions)]
2171fn env_project_root_matches(var: &str, project_root: &Path) -> bool {
2172 let Some(raw) = std::env::var_os(var) else {
2173 return true;
2174 };
2175 let expected = PathBuf::from(raw);
2176 let expected = std::fs::canonicalize(&expected).unwrap_or(expected);
2177 let actual = std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
2178 expected == actual
2179}
2180
2181fn current_project_files(project_root: &Path, files: &[PathBuf]) -> BTreeMap<String, PathBuf> {
2182 files
2183 .iter()
2184 .map(|file| (relative_cache_key(project_root, file), file.clone()))
2185 .collect()
2186}
2187
2188fn insert_callgraph_refresh_path(paths: &mut BTreeSet<PathBuf>, path: PathBuf) {
2189 if callgraph_store_indexes_path(&path) {
2190 paths.insert(path);
2191 }
2192}
2193
2194fn callgraph_store_indexes_path(path: &Path) -> bool {
2195 crate::parser::detect_language(path).is_some()
2196}
2197
2198fn tier2_benchmark_logging_enabled() -> bool {
2199 std::env::var_os("AFT_SETTLE_BENCH_LOG").is_some()
2200}
2201
2202fn log_tier2_benchmark_category_start(job: &InspectJob) {
2203 if !tier2_benchmark_logging_enabled() {
2204 return;
2205 }
2206 crate::slog_info!(
2207 "settle bench: tier2_category_start category={} job_id={} files={}",
2208 job.category.as_str(),
2209 job.job_id,
2210 job.scope_files.len()
2211 );
2212}
2213
2214fn log_tier2_benchmark_category_end(result: &InspectResult) {
2215 if !tier2_benchmark_logging_enabled() {
2216 return;
2217 }
2218 match &result.outcome {
2219 Ok(success) => {
2220 let count = success
2221 .aggregate
2222 .get("count")
2223 .and_then(serde_json::Value::as_u64)
2224 .unwrap_or(0);
2225 crate::slog_info!(
2226 "settle bench: tier2_category_end category={} job_id={} status=success total_ms={} scanned_files={} contributions={} count={}",
2227 result.category.as_str(),
2228 result.job_id,
2229 result.duration.as_millis(),
2230 success.scanned_files.len(),
2231 success.contributions.len(),
2232 count
2233 );
2234 }
2235 Err(message) => {
2236 crate::slog_info!(
2237 "settle bench: tier2_category_end category={} job_id={} status=failed total_ms={} error={}",
2238 result.category.as_str(),
2239 result.job_id,
2240 result.duration.as_millis(),
2241 message.replace('\n', " ")
2242 );
2243 }
2244 }
2245}
2246
2247fn build_tier2_callgraph_snapshot(
2248 job: &InspectJob,
2249 allow_cold_build: bool,
2250) -> Option<Arc<CallgraphSnapshot>> {
2251 build_tier2_callgraph_snapshot_with_refresh_inner(job, allow_cold_build, &[], None)
2252}
2253
2254#[cfg(test)]
2255fn build_tier2_callgraph_snapshot_with_refresh(
2256 job: &InspectJob,
2257 allow_cold_build: bool,
2258 refresh_paths: &[PathBuf],
2259) -> Option<Arc<CallgraphSnapshot>> {
2260 build_tier2_callgraph_snapshot_with_refresh_inner(job, allow_cold_build, refresh_paths, None)
2261}
2262
2263fn build_tier2_callgraph_snapshot_with_refresh_inner(
2264 job: &InspectJob,
2265 allow_cold_build: bool,
2266 refresh_paths: &[PathBuf],
2267 projection_cache: Option<&InspectManager>,
2268) -> Option<Arc<CallgraphSnapshot>> {
2269 let started = Instant::now();
2270 if !job.config.callgraph_store {
2271 crate::slog_info!(
2272 "tier2 dead_code: callgraph store disabled; reporting callgraph_unavailable"
2273 );
2274 return None;
2275 }
2276
2277 let callgraph_dirs = callgraph_store_dirs_from_inspect_dir(&job.inspect_dir, &job.project_root);
2278 if callgraph_dirs.is_empty() {
2279 crate::slog_info!(
2280 "tier2 dead_code: inspect_dir has no root-keyed storage parent ({}); reporting callgraph_unavailable",
2281 job.inspect_dir.display()
2282 );
2283 return None;
2284 };
2285
2286 enum ProjectionStore {
2287 ReadOnly(ReadonlyCallGraphStore),
2288 Writable(CallGraphStore),
2289 }
2290
2291 impl ProjectionStore {
2292 fn sqlite_path(&self) -> &Path {
2293 match self {
2294 Self::ReadOnly(store) => store.sqlite_path(),
2295 Self::Writable(store) => store.sqlite_path(),
2296 }
2297 }
2298
2299 fn projection_identity(
2300 &self,
2301 project_root: &Path,
2302 write_revision: u64,
2303 ) -> CallgraphProjectionIdentity {
2304 let generation = match self {
2305 Self::ReadOnly(store) => store.projection_generation(),
2306 Self::Writable(store) => store.projection_generation(),
2307 }
2308 .map(str::to_owned);
2309 let legacy_sqlite_path = generation
2310 .is_none()
2311 .then(|| self.sqlite_path().to_path_buf());
2312 CallgraphProjectionIdentity {
2313 project_root: project_root.to_path_buf(),
2314 generation,
2315 legacy_sqlite_path,
2316 write_revision,
2317 }
2318 }
2319
2320 fn current_projection_identity(
2321 &self,
2322 project_root: &Path,
2323 ) -> Result<Option<CallgraphProjectionIdentity>, CallGraphStoreError> {
2324 let write_revision = match self {
2325 Self::ReadOnly(store) => store.projection_write_revision()?,
2326 Self::Writable(store) => store.projection_write_revision()?,
2327 };
2328 Ok(write_revision.map(|revision| self.projection_identity(project_root, revision)))
2329 }
2330 }
2331
2332 for (index, callgraph_dir) in callgraph_dirs.iter().enumerate() {
2333 let projection_store = if refresh_paths.is_empty() || !job.callgraph_writer {
2337 let store = match CallGraphStore::open_readonly(
2338 callgraph_dir.clone(),
2339 job.project_root.clone(),
2340 ) {
2341 Ok(Some(store)) => store,
2342 Ok(None) => {
2343 crate::slog_info!(
2344 "tier2 dead_code: callgraph store unavailable at {} (cold/building/not ready); trying fallback={}",
2345 callgraph_dir.display(),
2346 index + 1 < callgraph_dirs.len()
2347 );
2348 continue;
2349 }
2350 Err(error) => {
2351 crate::slog_warn!(
2352 "tier2 dead_code: failed to open callgraph store read-only at {}: {}; trying fallback={}",
2353 callgraph_dir.display(),
2354 error,
2355 index + 1 < callgraph_dirs.len()
2356 );
2357 continue;
2358 }
2359 };
2360 ProjectionStore::ReadOnly(store)
2361 } else {
2362 let store = match if allow_cold_build {
2363 CallGraphStore::open_ready_repairing(
2364 callgraph_dir.clone(),
2365 job.project_root.clone(),
2366 )
2367 } else {
2368 CallGraphStore::open_ready_no_rebuild(
2369 callgraph_dir.clone(),
2370 job.project_root.clone(),
2371 )
2372 } {
2373 Ok(Some(store)) => store,
2374 Ok(None) => {
2375 crate::slog_info!(
2376 "tier2 dead_code: callgraph store unavailable at {} (cold/building/not ready); trying fallback={}",
2377 callgraph_dir.display(),
2378 index + 1 < callgraph_dirs.len()
2379 );
2380 continue;
2381 }
2382 Err(error) => {
2383 crate::slog_warn!(
2384 "tier2 dead_code: failed to open callgraph writer at {}: {}; trying fallback={}",
2385 callgraph_dir.display(),
2386 error,
2387 index + 1 < callgraph_dirs.len()
2388 );
2389 continue;
2390 }
2391 };
2392 match store.refresh_files(refresh_paths) {
2393 Ok(stats) => {
2394 crate::slog_info!(
2395 "tier2 dead_code: refreshed callgraph store at {} for {} watcher path(s): changed={} deleted={} refreshed_own={}",
2396 callgraph_dir.display(),
2397 refresh_paths.len(),
2398 stats.changed_files.len(),
2399 stats.deleted_files.len(),
2400 stats.refreshed_own_files
2401 );
2402 }
2403 Err(error) => {
2404 crate::slog_warn!(
2405 "tier2 dead_code: failed to refresh callgraph store at {} before projection: {}",
2406 callgraph_dir.display(),
2407 error
2408 );
2409 if let Err(mark_error) = store.mark_files_stale(refresh_paths) {
2410 crate::slog_warn!(
2411 "tier2 dead_code: failed to mark callgraph store files stale at {} after refresh failure: {}",
2412 callgraph_dir.display(),
2413 mark_error
2414 );
2415 }
2416 }
2417 }
2418 ProjectionStore::Writable(store)
2419 };
2420
2421 let cache_identity = match projection_store.current_projection_identity(&job.project_root) {
2422 Ok(identity) => identity,
2423 Err(error) => {
2424 crate::slog_warn!(
2425 "tier2 dead_code: failed to read callgraph projection identity at {}: {}; trying fallback={}",
2426 callgraph_dir.display(),
2427 error,
2428 index + 1 < callgraph_dirs.len()
2429 );
2430 continue;
2431 }
2432 };
2433 if let (Some(cache), Some(identity)) = (projection_cache, cache_identity.as_ref()) {
2434 if let Some(snapshot) = cache.cached_callgraph_projection(identity) {
2439 return Some(snapshot);
2440 }
2441 } else if cache_identity.is_none() {
2442 if let Some(cache) = projection_cache {
2445 cache.clear_callgraph_projection();
2446 }
2447 }
2448
2449 let (write_revision, snapshot) = match project_dead_code_snapshot_with_revision(
2450 projection_store.sqlite_path(),
2451 ) {
2452 Ok(projected) => projected,
2453 Err(CallGraphStoreError::Unavailable(message)) => {
2454 crate::slog_info!(
2455 "tier2 dead_code: callgraph store projection unavailable at {} ({}); trying fallback={}",
2456 callgraph_dir.display(),
2457 message,
2458 index + 1 < callgraph_dirs.len()
2459 );
2460 continue;
2461 }
2462 Err(error) => {
2463 crate::slog_warn!(
2464 "tier2 dead_code: callgraph store projection failed at {}: {}; trying fallback={}",
2465 callgraph_dir.display(),
2466 error,
2467 index + 1 < callgraph_dirs.len()
2468 );
2469 continue;
2470 }
2471 };
2472 let snapshot = Arc::new(snapshot);
2473 if let (Some(cache), Some(write_revision)) = (projection_cache, write_revision) {
2474 cache.cache_callgraph_projection(
2475 projection_store.projection_identity(&job.project_root, write_revision),
2476 Arc::clone(&snapshot),
2477 );
2478 }
2479
2480 if index > 0 {
2481 crate::slog_info!(
2482 "tier2 dead_code: using ready callgraph store fallback {} for inspect_dir {}",
2483 callgraph_dir.display(),
2484 job.inspect_dir.display()
2485 );
2486 }
2487
2488 crate::slog_info!(
2489 "perf tier2_callgraph_snapshot: source=callgraph_store files={} exports={} edges={} entry_points={} ms={}",
2490 snapshot.files.len(),
2491 snapshot.exported_symbols.len(),
2492 snapshot.outbound_calls.len(),
2493 snapshot.entry_points.len(),
2494 started.elapsed().as_millis()
2495 );
2496
2497 return Some(snapshot);
2498 }
2499
2500 crate::slog_info!(
2501 "tier2 dead_code: no ready callgraph store found for inspect_dir {}; reporting callgraph_unavailable",
2502 job.inspect_dir.display()
2503 );
2504 None
2505}
2506
2507fn estimate_callgraph_snapshot_bytes(snapshot: &CallgraphSnapshot) -> u64 {
2508 let files = snapshot.files.iter().fold(0u64, |bytes, path| {
2509 bytes
2510 .saturating_add(std::mem::size_of::<PathBuf>() as u64)
2511 .saturating_add(crate::memory::path_bytes(path))
2512 });
2513 let exports = snapshot
2514 .exported_symbols
2515 .iter()
2516 .fold(0u64, |bytes, export| {
2517 bytes
2518 .saturating_add(std::mem::size_of::<super::job::CallgraphExport>() as u64)
2519 .saturating_add(crate::memory::path_bytes(&export.file))
2520 .saturating_add(crate::memory::usize_to_u64(export.symbol.len()))
2521 .saturating_add(crate::memory::usize_to_u64(export.kind.len()))
2522 });
2523 let calls = snapshot.outbound_calls.iter().fold(0u64, |bytes, call| {
2524 bytes
2525 .saturating_add(std::mem::size_of::<super::job::CallgraphOutboundCall>() as u64)
2526 .saturating_add(crate::memory::path_bytes(&call.caller_file))
2527 .saturating_add(crate::memory::usize_to_u64(call.caller_symbol.len()))
2528 .saturating_add(crate::memory::usize_to_u64(call.target.len()))
2529 .saturating_add(crate::memory::usize_to_u64(call.provenance.len()))
2530 });
2531 let entry_points = snapshot.entry_points.iter().fold(0u64, |bytes, path| {
2532 bytes
2533 .saturating_add(std::mem::size_of::<PathBuf>() as u64)
2534 .saturating_add(crate::memory::path_bytes(path))
2535 });
2536 let entry_point_symbols =
2537 snapshot
2538 .entry_point_symbols
2539 .iter()
2540 .fold(0u64, |bytes, (path, symbols)| {
2541 let symbols_bytes = symbols.iter().fold(0u64, |bytes, symbol| {
2542 bytes
2543 .saturating_add(std::mem::size_of::<String>() as u64)
2544 .saturating_add(crate::memory::usize_to_u64(symbol.len()))
2545 });
2546 bytes
2547 .saturating_add(std::mem::size_of::<(PathBuf, BTreeSet<String>)>() as u64)
2548 .saturating_add(crate::memory::path_bytes(path))
2549 .saturating_add(symbols_bytes)
2550 });
2551 (std::mem::size_of::<CallgraphSnapshot>() as u64)
2552 .saturating_add(files)
2553 .saturating_add(exports)
2554 .saturating_add(calls)
2555 .saturating_add(entry_points)
2556 .saturating_add(entry_point_symbols)
2557}
2558
2559fn callgraph_store_dir_from_inspect_dir(
2560 inspect_dir: &Path,
2561 project_root: &Path,
2562) -> Option<PathBuf> {
2563 let scope_key = crate::path_identity::project_scope_key(project_root);
2564 let storage_dir = if inspect_dir
2565 .file_name()
2566 .and_then(|name| name.to_str())
2567 .is_some_and(|name| name == scope_key)
2568 {
2569 inspect_dir.parent()?.parent()?
2570 } else {
2571 inspect_dir.parent()?
2572 };
2573 let project_key = crate::search_index::artifact_cache_key(project_root);
2574 Some(storage_dir.join("callgraph").join(project_key))
2575}
2576
2577fn callgraph_store_dirs_from_inspect_dir(inspect_dir: &Path, project_root: &Path) -> Vec<PathBuf> {
2578 callgraph_store_dir_from_inspect_dir(inspect_dir, project_root)
2579 .into_iter()
2580 .collect()
2581}
2582
2583#[cfg(test)]
2584fn canonicalize_for_snapshot(path: &Path) -> PathBuf {
2585 crate::inspect::job::canonicalize_normalized(path)
2588}
2589
2590fn load_contribution_freshness(
2591 cache: &(impl InspectCacheRead + ?Sized),
2592 category: InspectCategory,
2593) -> Result<Vec<CachedContributionFreshness>, String> {
2594 cache
2595 .contribution_freshness(category)
2596 .map_err(|error| error.to_string())
2597 .map(|records| {
2598 records
2599 .into_iter()
2600 .map(|(file_path, freshness)| CachedContributionFreshness {
2601 file_path,
2602 freshness,
2603 })
2604 .collect()
2605 })
2606}
2607
2608fn freshness_record_relative_key(record: &CachedContributionFreshness) -> String {
2609 record.file_path.to_string_lossy().to_string()
2610}
2611
2612fn relative_cache_key(project_root: &Path, path: &Path) -> String {
2613 path.strip_prefix(project_root)
2614 .unwrap_or(path)
2615 .to_string_lossy()
2616 .to_string()
2617}
2618
2619fn load_contributions(
2620 cache: &(impl InspectCacheRead + ?Sized),
2621 job: &InspectJob,
2622) -> Result<Vec<FileContribution>, String> {
2623 cache
2624 .load_tier2_contributions(job.category)
2625 .map_err(|error| error.to_string())
2626 .map(|records| {
2627 records
2628 .into_iter()
2629 .map(|record| contribution_from_record(&job.project_root, record))
2630 .collect()
2631 })
2632}
2633
2634fn dead_code_contributions_need_fact_refresh(
2635 cache: &(impl InspectCacheRead + ?Sized),
2636 job: &InspectJob,
2637) -> Result<bool, String> {
2638 let contributions = load_contributions(cache, job)?;
2639 Ok(contributions
2640 .iter()
2641 .any(dead_code_contribution_needs_fact_refresh))
2642}
2643
2644fn dead_code_contribution_needs_fact_refresh(contribution: &FileContribution) -> bool {
2645 let Ok(parsed) =
2646 serde_json::from_value::<DeadCodeRefreshContribution>(contribution.contribution.clone())
2647 else {
2648 return true;
2649 };
2650
2651 if parsed.facts_format_version
2652 != Some(super::scanners::dead_code::DEAD_CODE_FACTS_FORMAT_VERSION)
2653 {
2654 return true;
2655 }
2656
2657 matches!(
2658 parsed.oxc_facts,
2659 Some(facts) if facts.format_version != FACTS_FORMAT_VERSION
2660 )
2661}
2662
2663fn unused_exports_contributions_need_fact_refresh(
2664 cache: &(impl InspectCacheRead + ?Sized),
2665 job: &InspectJob,
2666) -> Result<bool, String> {
2667 let contributions = load_contributions(cache, job)?;
2668 Ok(contributions
2669 .iter()
2670 .any(unused_exports_contribution_needs_fact_refresh))
2671}
2672
2673fn duplicates_contributions_need_fact_refresh(
2678 cache: &(impl InspectCacheRead + ?Sized),
2679 job: &InspectJob,
2680) -> Result<bool, String> {
2681 let contributions = load_contributions(cache, job)?;
2682 Ok(contributions
2683 .iter()
2684 .any(|contribution| contribution.contribution.get("line_count").is_none()))
2685}
2686
2687fn unused_exports_contribution_needs_fact_refresh(contribution: &FileContribution) -> bool {
2688 let top_level_oxc = contribution
2689 .contribution
2690 .get("provenance")
2691 .and_then(Value::as_str)
2692 == Some(OXC_PROVENANCE);
2693 let Ok(parsed) =
2694 serde_json::from_value::<UnusedExportsContribution>(contribution.contribution.clone())
2695 else {
2696 return false;
2697 };
2698 let uses_oxc =
2699 top_level_oxc || parsed.oxc_facts.is_some() || parsed.exports.iter().any(export_uses_oxc);
2700 if !uses_oxc {
2701 return false;
2702 }
2703
2704 !matches!(
2705 parsed.oxc_facts,
2706 Some(facts) if facts.format_version == FACTS_FORMAT_VERSION
2707 )
2708}
2709
2710fn contribution_from_record(
2711 project_root: &Path,
2712 record: super::cache::ContributionRecord,
2713) -> FileContribution {
2714 FileContribution::new(
2715 record.category,
2716 project_root.join(record.file_path),
2717 record.freshness,
2718 record.contribution,
2719 )
2720 .with_type_ref_names(record.type_ref_names)
2721}
2722
2723fn run_tier2_scan(job: &InspectJob, oxc_result: Option<&OxcEngineResult>) -> InspectResult {
2724 use super::scanners;
2725
2726 match job.category {
2727 InspectCategory::DeadCode => {
2728 scanners::dead_code::run_dead_code_scan_with_oxc(job, oxc_result)
2729 }
2730 InspectCategory::UnusedExports => {
2731 scanners::unused_exports::run_unused_exports_scan_with_oxc(job, oxc_result)
2732 }
2733 InspectCategory::Duplicates => scanners::duplicates::run_duplicates_scan(job),
2734 InspectCategory::Cycles => scanners::cycles::run_cycles_scan_with_oxc(job, oxc_result),
2735 other => InspectResult::failed(
2736 job,
2737 format!("inspect category '{other}' is not an active Tier 2 scanner"),
2738 Duration::from_secs(0),
2739 ),
2740 }
2741}
2742
2743fn roll_up_tier2_contributions(job: &InspectJob, contributions: &[FileContribution]) -> Value {
2744 roll_up_tier2_contributions_with_limit(job, contributions, Some(MAX_DRILL_DOWN_ITEMS))
2745}
2746
2747fn roll_up_tier2_contributions_with_limit(
2748 job: &InspectJob,
2749 contributions: &[FileContribution],
2750 drill_down_limit: Option<usize>,
2751) -> Value {
2752 match job.category {
2753 InspectCategory::DeadCode => {
2754 roll_up_dead_code_contributions(job, contributions, drill_down_limit)
2755 }
2756 InspectCategory::UnusedExports => {
2757 roll_up_unused_exports_contributions(job, contributions, drill_down_limit)
2758 }
2759 InspectCategory::Duplicates => {
2760 roll_up_duplicate_contributions(job, contributions, drill_down_limit)
2761 }
2762 InspectCategory::Cycles => {
2763 roll_up_cycle_contributions(job, contributions, drill_down_limit)
2764 }
2765 _ => json!({
2766 "count": 0,
2767 "items": [],
2768 "scanned_files": contributions.len(),
2769 }),
2770 }
2771}
2772
2773fn scoped_tier2_payload_from_contributions(
2774 snapshot: &InspectSnapshot,
2775 category: InspectCategory,
2776 cache: &(impl InspectCacheRead + ?Sized),
2777 project_payload: Value,
2778 scope: &JobScope,
2779) -> Result<Value, String> {
2780 if scope.is_project_wide() {
2781 return Ok(project_payload);
2782 }
2783
2784 let project_scope = JobScope::for_project(snapshot.project_root.clone());
2785 let rollup_job = scoped_tier2_rollup_job(snapshot, category, &project_scope);
2786 let contributions = load_contributions(cache, &rollup_job)?;
2787 let full_payload = roll_up_tier2_contributions_with_limit(&rollup_job, &contributions, None);
2788 let scoped_payload = filter_payload_for_scope(full_payload, scope);
2789 Ok(cap_payload_drill_down(scoped_payload, MAX_DRILL_DOWN_ITEMS))
2790}
2791
2792fn scoped_tier2_rollup_job(
2793 snapshot: &InspectSnapshot,
2794 category: InspectCategory,
2795 scope: &JobScope,
2796) -> InspectJob {
2797 let mut job = InspectJob {
2798 job_id: 0,
2799 key: JobKey::for_project_category(category),
2800 category,
2801 scope_files: scope_files(&snapshot.project_root, scope),
2802 project_root: snapshot.project_root.clone(),
2803 inspect_dir: snapshot.inspect_dir.clone(),
2804 config: Arc::clone(&snapshot.config),
2805 symbol_cache: Arc::clone(&snapshot.symbol_cache),
2806 inspect_writer: snapshot.inspect_writer,
2807 callgraph_writer: snapshot.callgraph_writer,
2808 callgraph_snapshot: None,
2809 };
2810
2811 if category == InspectCategory::DeadCode {
2812 job.callgraph_snapshot = build_tier2_callgraph_snapshot(&job, false);
2817 }
2818
2819 job
2820}
2821
2822fn roll_up_dead_code_contributions(
2823 job: &InspectJob,
2824 contributions: &[FileContribution],
2825 drill_down_limit: Option<usize>,
2826) -> Value {
2827 let Some(snapshot) = job.callgraph_snapshot.as_deref() else {
2828 return super::scanners::dead_code::callgraph_unavailable_aggregate(job.scope_files.len());
2829 };
2830
2831 let public_api_files = super::scanners::dead_code::collect_public_api_files(&job.project_root);
2832 let roles = super::entry_points::resolve_project_roles(&job.project_root);
2833 super::scanners::dead_code::aggregate_dead_code_contributions_with_snapshot(
2834 &job.project_root,
2835 snapshot,
2836 contributions,
2837 &public_api_files,
2838 &roles,
2839 drill_down_limit,
2840 )
2841}
2842
2843fn roll_up_unused_exports_contributions(
2844 job: &InspectJob,
2845 contributions: &[FileContribution],
2846 drill_down_limit: Option<usize>,
2847) -> Value {
2848 let parsed = contributions
2849 .iter()
2850 .filter_map(|contribution| {
2851 serde_json::from_value::<UnusedExportsContribution>(contribution.contribution.clone())
2852 .ok()
2853 })
2854 .collect::<Vec<_>>();
2855
2856 if parsed.iter().any(|scan| scan.oxc_facts.is_some()) {
2857 return roll_up_unused_exports_oxc_contributions(job, &parsed, drill_down_limit);
2858 }
2859
2860 let (public_api_files, package_warnings) = unused_public_api_entries(&job.project_root);
2861 let mut imported_by: BTreeMap<(String, String), BTreeSet<String>> = BTreeMap::new();
2862 let mut uncertain_by: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
2863 for scan in &parsed {
2864 for import in &scan.imports {
2865 let Some(resolved_file) = &import.resolved_file else {
2866 continue;
2867 };
2868 for name in &import.named {
2869 if name == "*" {
2870 uncertain_by
2871 .entry(resolved_file.clone())
2872 .or_default()
2873 .insert(scan.file.clone());
2874 } else {
2875 imported_by
2876 .entry((resolved_file.clone(), name.clone()))
2877 .or_default()
2878 .insert(scan.file.clone());
2879 }
2880 }
2881 }
2882 }
2883
2884 let mut count = 0usize;
2885 let mut items = Vec::new();
2886 let mut generated_count = 0usize;
2887 let mut generated_items = Vec::new();
2888 let test_only_count = 0usize;
2889 let test_only_items = Vec::new();
2890 let mut uncertain_count = 0usize;
2891 let mut uncertain_items = Vec::new();
2892 for scan in &parsed {
2893 if public_api_files.contains(&scan.file) {
2894 continue;
2895 }
2896 if super::job::is_test_support_file(&scan.file) {
2899 continue;
2900 }
2901 let generated_file = super::generated::is_generated_file_with_cached_hint(
2902 &job.project_root,
2903 &scan.file,
2904 scan.generated,
2905 );
2906
2907 for export in &scan.exports {
2908 if export_uses_oxc(export) {
2909 match export.verdict.unwrap_or(LivenessVerdict::Unused) {
2910 LivenessVerdict::Used => continue,
2911 LivenessVerdict::Uncertain => {
2912 uncertain_count += 1;
2913 if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
2914 uncertain_items.push(json!({
2915 "file": scan.file,
2916 "symbol": export.symbol,
2917 "kind": export.kind,
2918 "line": export.line,
2919 "reason": export.reason.as_deref().unwrap_or("oxc_uncertain"),
2920 "provenance": export.provenance.as_deref().unwrap_or(OXC_PROVENANCE),
2921 }));
2922 }
2923 continue;
2924 }
2925 LivenessVerdict::Unused => {}
2926 }
2927 } else {
2928 let imported = imported_by
2929 .get(&(scan.file.clone(), export.symbol.clone()))
2930 .map(|files| !files.is_empty())
2931 .unwrap_or(false);
2932 let uncertain = uncertain_by
2933 .get(&scan.file)
2934 .map(|files| !files.is_empty())
2935 .unwrap_or(false);
2936
2937 if imported {
2938 continue;
2939 }
2940 if uncertain {
2941 uncertain_count += 1;
2942 if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
2943 uncertain_items.push(json!({
2944 "file": scan.file,
2945 "symbol": export.symbol,
2946 "kind": export.kind,
2947 "line": export.line,
2948 "reason": "wildcard_import",
2949 }));
2950 }
2951 continue;
2952 }
2953 }
2954
2955 let mut item = json!({
2956 "file": scan.file,
2957 "symbol": export.symbol,
2958 "kind": export.kind,
2959 "line": export.line,
2960 });
2961 if let Some(provenance) = &export.provenance {
2962 item["provenance"] = json!(provenance);
2963 }
2964 if generated_file {
2965 item["generated"] = json!(true);
2966 generated_count += 1;
2967 generated_items.push(item);
2968 } else {
2969 count += 1;
2970 items.push(item);
2971 }
2972 }
2973 }
2974
2975 let roles = super::entry_points::resolve_project_roles(&job.project_root);
2976 let items = super::entry_points::rank_and_truncate_items(items, &roles, drill_down_limit);
2977 let generated_items =
2978 super::entry_points::rank_and_truncate_items(generated_items, &roles, drill_down_limit);
2979 let top = super::entry_points::top_preview_symbols(&items);
2980 let generated_top = generated_items
2981 .iter()
2982 .take(super::entry_points::TOP_PREVIEW_ITEMS)
2983 .cloned()
2984 .collect::<Vec<_>>();
2985 let mut all_items = items;
2986 all_items.extend(generated_items.iter().cloned());
2987 if let Some(limit) = drill_down_limit {
2988 all_items.truncate(limit);
2989 }
2990 let test_only_items =
2991 super::entry_points::rank_and_truncate_items(test_only_items, &roles, drill_down_limit);
2992 let test_only_top = test_only_items
2993 .iter()
2994 .take(super::entry_points::TOP_PREVIEW_ITEMS)
2995 .cloned()
2996 .collect::<Vec<_>>();
2997
2998 let (parse_errors, skipped_files) = unused_exports_honesty_fields(&parsed);
2999 let mut aggregate = json!({
3000 "count": count,
3001 "generated_count": generated_count,
3002 "total_count": count + test_only_count + generated_count,
3003 "items": all_items,
3004 "top": top,
3005 "generated_items": generated_items,
3006 "generated_top": generated_top,
3007 "test_only_count": test_only_count,
3008 "test_only_items": test_only_items,
3009 "test_only_top": test_only_top,
3010 "drill_down_capped": drill_down_limit.is_some_and(|limit| count + generated_count > limit),
3011 "generated_drill_down_capped": drill_down_limit.is_some_and(|limit| generated_count > limit),
3012 "test_only_drill_down_capped": drill_down_limit.is_some_and(|limit| test_only_count > limit),
3013 "scanned_files": parsed.len(),
3014 "languages_skipped": skipped_languages(&job.scope_files, LanguageSkipMode::UnusedExports),
3015 "uncertain_count": uncertain_count,
3016 "uncertain_items": uncertain_items,
3017 "complete": parse_errors.is_empty() && skipped_files.is_empty(),
3018 });
3019 if !parse_errors.is_empty() {
3020 aggregate["parse_errors"] = Value::Array(parse_errors);
3021 }
3022 if !skipped_files.is_empty() {
3023 aggregate["skipped_files"] = Value::Array(skipped_files);
3024 }
3025 if !package_warnings.is_empty() {
3026 aggregate["note"] = Value::String(package_warnings.join("; "));
3027 }
3028 aggregate
3029}
3030
3031fn roll_up_unused_exports_oxc_contributions(
3032 job: &InspectJob,
3033 parsed: &[UnusedExportsContribution],
3034 drill_down_limit: Option<usize>,
3035) -> Value {
3036 let (public_api_files, package_warnings) = unused_public_api_entries(&job.project_root);
3037 let facts = parsed
3038 .iter()
3039 .filter_map(|scan| {
3040 let oxc_facts = scan.oxc_facts.as_ref()?;
3041 let path = job.project_root.join(&scan.file);
3042 Some(FileFacts {
3043 file_id: FileId(0),
3044 path: normalize_input_path(&job.project_root, &path),
3045 content_hash: oxc_facts.content_hash.clone(),
3046 exports: oxc_facts.exports.clone(),
3047 imports: oxc_facts.imports.clone(),
3048 re_exports: oxc_facts.re_exports.clone(),
3049 dynamic_imports: oxc_facts.dynamic_imports.clone(),
3050 same_file_value_references: oxc_facts.same_file_value_references.clone(),
3051 used_import_bindings: oxc_facts.used_import_bindings.clone(),
3052 type_referenced_import_bindings: oxc_facts.type_referenced_import_bindings.clone(),
3053 value_referenced_import_bindings: oxc_facts
3054 .value_referenced_import_bindings
3055 .clone(),
3056 parse_error: oxc_facts.parse_error.clone(),
3057 })
3058 })
3059 .collect::<Vec<_>>();
3060 let generated_by_file = parsed
3061 .iter()
3062 .map(|scan| {
3063 (
3064 scan.file.clone(),
3065 super::generated::is_generated_file_with_cached_hint(
3066 &job.project_root,
3067 &scan.file,
3068 scan.generated,
3069 ),
3070 )
3071 })
3072 .collect::<BTreeMap<_, _>>();
3073 let entry_point_set = crate::inspect::entry_points::resolve_entry_points(&job.project_root);
3074 let oxc_result = analyze_file_facts(
3075 &job.project_root,
3076 facts,
3077 AnalyzeOptions {
3078 entry_points: Vec::new(),
3079 public_api_files: entry_point_set.public_api_files(),
3080 executable_root_exports: entry_point_set.executable_root_exports(),
3081 force_reparse_files: Vec::new(),
3082 entry_reachability: false,
3083 },
3084 Vec::new(),
3085 );
3086 let roles = super::entry_points::resolve_project_roles(&job.project_root);
3087
3088 let mut count = 0usize;
3089 let mut items = Vec::new();
3090 let mut generated_count = 0usize;
3091 let mut generated_items = Vec::new();
3092 let mut test_only_count = 0usize;
3093 let mut test_only_items = Vec::new();
3094 let mut uncertain_count = 0usize;
3095 let mut uncertain_items = Vec::new();
3096 for file in &oxc_result.files {
3097 if public_api_files.contains(&file.relative_file)
3098 || super::job::is_test_support_file(&file.relative_file)
3099 {
3100 continue;
3101 }
3102 let generated_file = generated_by_file
3103 .get(&file.relative_file)
3104 .copied()
3105 .unwrap_or_else(|| {
3106 super::generated::is_generated_file(
3107 &job.project_root,
3108 Path::new(&file.relative_file),
3109 )
3110 });
3111
3112 for export in &file.exports {
3113 match export.verdict {
3114 LivenessVerdict::Used => {
3115 if !is_test_file(&file.relative_file)
3116 && !export.test_only_reference_files.is_empty()
3117 {
3118 let mut item = json!({
3119 "file": file.relative_file,
3120 "symbol": export.symbol,
3121 "kind": export.kind,
3122 "line": export.line,
3123 "provenance": export.provenance,
3124 "used_by": export.test_only_reference_files,
3125 });
3126 add_oxc_reexport_contexts(&mut item, &export.also_reexported);
3127 if generated_file {
3128 item["generated"] = json!(true);
3129 generated_count += 1;
3130 generated_items.push(item);
3131 } else {
3132 test_only_count += 1;
3133 test_only_items.push(item);
3134 }
3135 }
3136 }
3137 LivenessVerdict::Uncertain => {
3138 uncertain_count += 1;
3139 if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
3140 let mut item = json!({
3141 "file": file.relative_file,
3142 "symbol": export.symbol,
3143 "kind": export.kind,
3144 "line": export.line,
3145 "reason": export.reason,
3146 "provenance": export.provenance,
3147 });
3148 add_oxc_reexport_contexts(&mut item, &export.also_reexported);
3149 uncertain_items.push(item);
3150 }
3151 }
3152 LivenessVerdict::Unused => {
3153 if !is_test_file(&file.relative_file)
3154 && !export.test_only_reference_files.is_empty()
3155 {
3156 let mut item = json!({
3157 "file": file.relative_file,
3158 "symbol": export.symbol,
3159 "kind": export.kind,
3160 "line": export.line,
3161 "provenance": export.provenance,
3162 "used_by": export.test_only_reference_files,
3163 });
3164 add_oxc_reexport_contexts(&mut item, &export.also_reexported);
3165 if generated_file {
3166 item["generated"] = json!(true);
3167 generated_count += 1;
3168 generated_items.push(item);
3169 } else {
3170 test_only_count += 1;
3171 test_only_items.push(item);
3172 }
3173 continue;
3174 }
3175 if export.has_references {
3176 continue;
3177 }
3178 let mut item = json!({
3179 "file": file.relative_file,
3180 "symbol": export.symbol,
3181 "kind": export.kind,
3182 "line": export.line,
3183 "provenance": export.provenance,
3184 });
3185 add_oxc_reexport_contexts(&mut item, &export.also_reexported);
3186 if generated_file {
3187 item["generated"] = json!(true);
3188 generated_count += 1;
3189 generated_items.push(item);
3190 } else {
3191 count += 1;
3192 items.push(item);
3193 }
3194 }
3195 }
3196 }
3197 }
3198
3199 let items = super::entry_points::rank_and_truncate_items(items, &roles, drill_down_limit);
3200 let generated_items =
3201 super::entry_points::rank_and_truncate_items(generated_items, &roles, drill_down_limit);
3202 let top = super::entry_points::top_preview_symbols(&items);
3203 let generated_top = generated_items
3204 .iter()
3205 .take(super::entry_points::TOP_PREVIEW_ITEMS)
3206 .cloned()
3207 .collect::<Vec<_>>();
3208 let mut all_items = items;
3209 all_items.extend(generated_items.iter().cloned());
3210 if let Some(limit) = drill_down_limit {
3211 all_items.truncate(limit);
3212 }
3213 let test_only_items =
3214 super::entry_points::rank_and_truncate_items(test_only_items, &roles, drill_down_limit);
3215 let test_only_top = test_only_items
3216 .iter()
3217 .take(super::entry_points::TOP_PREVIEW_ITEMS)
3218 .cloned()
3219 .collect::<Vec<_>>();
3220 let (mut parse_errors, skipped_files) = unused_exports_honesty_fields(parsed);
3221 for scan in parsed {
3222 if let Some(oxc_facts) = &scan.oxc_facts {
3223 if oxc_facts.format_version != FACTS_FORMAT_VERSION {
3224 parse_errors.push(json!({
3225 "file": scan.file,
3226 "message": format!(
3227 "unsupported oxc facts format {}; expected {}",
3228 oxc_facts.format_version, FACTS_FORMAT_VERSION
3229 ),
3230 }));
3231 }
3232 }
3233 }
3234
3235 let mut aggregate = json!({
3236 "count": count,
3237 "generated_count": generated_count,
3238 "total_count": count + test_only_count + generated_count,
3239 "items": all_items,
3240 "top": top,
3241 "generated_items": generated_items,
3242 "generated_top": generated_top,
3243 "test_only_count": test_only_count,
3244 "test_only_items": test_only_items,
3245 "test_only_top": test_only_top,
3246 "drill_down_capped": drill_down_limit.is_some_and(|limit| count + generated_count > limit),
3247 "generated_drill_down_capped": drill_down_limit.is_some_and(|limit| generated_count > limit),
3248 "test_only_drill_down_capped": drill_down_limit.is_some_and(|limit| test_only_count > limit),
3249 "scanned_files": parsed.len(),
3250 "languages_skipped": skipped_languages(&job.scope_files, LanguageSkipMode::UnusedExports),
3251 "uncertain_count": uncertain_count,
3252 "uncertain_items": uncertain_items,
3253 "complete": parse_errors.is_empty() && skipped_files.is_empty(),
3254 });
3255 if !parse_errors.is_empty() {
3256 aggregate["parse_errors"] = Value::Array(parse_errors);
3257 }
3258 if !skipped_files.is_empty() {
3259 aggregate["skipped_files"] = Value::Array(skipped_files);
3260 }
3261 if !package_warnings.is_empty() {
3262 aggregate["note"] = Value::String(package_warnings.join("; "));
3263 }
3264 aggregate
3265}
3266
3267fn add_oxc_reexport_contexts(
3268 item: &mut Value,
3269 contexts: &[crate::inspect::oxc_engine::OxcReExportContext],
3270) {
3271 if !contexts.is_empty() {
3272 item["also_reexported"] = json!(contexts);
3273 }
3274}
3275
3276fn unused_exports_honesty_fields(parsed: &[UnusedExportsContribution]) -> (Vec<Value>, Vec<Value>) {
3277 let mut parse_error_keys = BTreeSet::new();
3278 let mut parse_errors = Vec::new();
3279 let mut skipped_file_keys = BTreeSet::new();
3280 let mut skipped_files = Vec::new();
3281 for contribution in parsed {
3282 for value in &contribution.parse_errors {
3283 let key = value.to_string();
3284 if parse_error_keys.insert(key) {
3285 parse_errors.push(value.clone());
3286 }
3287 }
3288 for value in &contribution.skipped_files {
3289 let key = value.to_string();
3290 if skipped_file_keys.insert(key) {
3291 skipped_files.push(value.clone());
3292 }
3293 }
3294 }
3295 (parse_errors, skipped_files)
3296}
3297
3298fn roll_up_duplicate_contributions(
3299 job: &InspectJob,
3300 contributions: &[FileContribution],
3301 drill_down_limit: Option<usize>,
3302) -> Value {
3303 super::scanners::duplicates::aggregate_duplicate_contributions_with_limit(
3304 contributions,
3305 skipped_languages(&job.scope_files, LanguageSkipMode::Duplicates),
3306 drill_down_limit,
3307 &job.config.inspect.duplicates.expected_mirrors,
3308 )
3309}
3310
3311fn roll_up_cycle_contributions(
3312 job: &InspectJob,
3313 contributions: &[FileContribution],
3314 drill_down_limit: Option<usize>,
3315) -> Value {
3316 super::scanners::cycles::aggregate_cycle_contributions_with_limit(
3317 &job.project_root,
3318 contributions,
3319 skipped_languages(&job.scope_files, LanguageSkipMode::Cycles),
3320 drill_down_limit,
3321 )
3322}
3323
3324fn cap_payload_drill_down(mut payload: Value, limit: usize) -> Value {
3325 let mut capped = false;
3326 if let Some(items) = payload.get_mut("items").and_then(Value::as_array_mut) {
3327 capped |= items.len() > limit;
3328 items.truncate(limit);
3329 }
3330 if let Some(groups) = payload.get_mut("groups").and_then(Value::as_array_mut) {
3331 capped |= groups.len() > limit;
3332 groups.truncate(limit);
3333 }
3334 if let Some(object) = payload.as_object_mut() {
3335 object.insert("drill_down_capped".to_string(), json!(capped));
3336 }
3337 payload
3338}
3339
3340const MAX_DRILL_DOWN_ITEMS: usize = 100;
3341
3342#[derive(Debug, Clone, Deserialize)]
3343struct ExportContribution {
3344 symbol: String,
3345 kind: String,
3346 line: u32,
3347 #[serde(default)]
3348 verdict: Option<LivenessVerdict>,
3349 #[serde(default)]
3350 reason: Option<String>,
3351 #[serde(default)]
3352 provenance: Option<String>,
3353}
3354
3355fn export_uses_oxc(export: &ExportContribution) -> bool {
3356 export.verdict.is_some() || export.provenance.as_deref() == Some(OXC_PROVENANCE)
3357}
3358
3359#[derive(Debug, Clone, Deserialize)]
3360struct DeadCodeRefreshContribution {
3361 #[serde(default)]
3362 facts_format_version: Option<u32>,
3363 #[serde(default)]
3364 oxc_facts: Option<OxcFactsContribution>,
3365}
3366
3367#[derive(Debug, Clone, Deserialize)]
3368struct UnusedExportsContribution {
3369 file: String,
3370 #[serde(default)]
3371 generated: Option<bool>,
3372 exports: Vec<ExportContribution>,
3373 #[serde(default)]
3374 imports: Vec<ImportContribution>,
3375 #[serde(default)]
3376 oxc_facts: Option<OxcFactsContribution>,
3377 #[serde(default)]
3378 parse_errors: Vec<Value>,
3379 #[serde(default)]
3380 skipped_files: Vec<Value>,
3381}
3382
3383#[derive(Debug, Clone, Deserialize)]
3384struct ImportContribution {
3385 resolved_file: Option<String>,
3386 named: Vec<String>,
3387}
3388
3389#[derive(Debug, Clone, Deserialize)]
3390struct OxcFactsContribution {
3391 format_version: u32,
3392 content_hash: String,
3393 exports: Vec<ExportFact>,
3394 imports: Vec<ImportFact>,
3395 re_exports: Vec<ReExportFact>,
3396 dynamic_imports: Vec<DynamicImportFact>,
3397 same_file_value_references: BTreeSet<String>,
3398 used_import_bindings: BTreeSet<String>,
3399 type_referenced_import_bindings: BTreeSet<String>,
3400 value_referenced_import_bindings: BTreeSet<String>,
3401 #[serde(default)]
3402 parse_error: Option<String>,
3403}
3404
3405#[derive(Debug, Clone, Copy)]
3406enum LanguageSkipMode {
3407 Duplicates,
3408 Cycles,
3409 UnusedExports,
3410}
3411
3412fn category_uses_oxc(category: InspectCategory) -> bool {
3413 matches!(
3414 category,
3415 InspectCategory::DeadCode | InspectCategory::UnusedExports | InspectCategory::Cycles
3416 )
3417}
3418
3419fn skipped_languages(files: &[PathBuf], mode: LanguageSkipMode) -> Vec<String> {
3420 files
3421 .iter()
3422 .filter_map(|file| skipped_language(file, mode))
3423 .collect::<BTreeSet<_>>()
3424 .into_iter()
3425 .collect()
3426}
3427
3428fn skipped_language(file: &Path, mode: LanguageSkipMode) -> Option<String> {
3429 let Some(language) = crate::parser::detect_language(file) else {
3430 return match mode {
3431 LanguageSkipMode::Duplicates => Some("unknown".to_string()),
3432 LanguageSkipMode::Cycles => Some("unknown".to_string()),
3433 LanguageSkipMode::UnusedExports => None,
3434 };
3435 };
3436
3437 let skipped = match mode {
3438 LanguageSkipMode::Duplicates => !duplicates_supports_language(language),
3439 LanguageSkipMode::Cycles => !is_js_ts_language(language),
3440 LanguageSkipMode::UnusedExports => !is_js_ts_language(language),
3441 };
3442 skipped.then(|| language_name(language).to_string())
3443}
3444
3445fn duplicates_supports_language(language: crate::parser::LangId) -> bool {
3446 !matches!(
3447 language,
3448 crate::parser::LangId::Bash
3449 | crate::parser::LangId::Html
3450 | crate::parser::LangId::Json
3451 | crate::parser::LangId::Scala
3452 | crate::parser::LangId::Solidity
3453 | crate::parser::LangId::Scss
3454 | crate::parser::LangId::Vue
3455 | crate::parser::LangId::Markdown
3456 | crate::parser::LangId::Java
3457 | crate::parser::LangId::Ruby
3458 | crate::parser::LangId::Kotlin
3459 | crate::parser::LangId::Swift
3460 | crate::parser::LangId::Php
3461 | crate::parser::LangId::Lua
3462 | crate::parser::LangId::Perl
3463 | crate::parser::LangId::Pascal
3464 | crate::parser::LangId::R
3465 | crate::parser::LangId::Groovy
3466 | crate::parser::LangId::ObjC
3467 )
3468}
3469
3470fn is_js_ts_language(language: crate::parser::LangId) -> bool {
3471 matches!(
3472 language,
3473 crate::parser::LangId::TypeScript
3474 | crate::parser::LangId::Tsx
3475 | crate::parser::LangId::JavaScript
3476 )
3477}
3478
3479fn language_name(language: crate::parser::LangId) -> &'static str {
3480 match language {
3481 crate::parser::LangId::TypeScript => "typescript",
3482 crate::parser::LangId::Tsx => "tsx",
3483 crate::parser::LangId::JavaScript => "javascript",
3484 crate::parser::LangId::Python => "python",
3485 crate::parser::LangId::Rust => "rust",
3486 crate::parser::LangId::Go => "go",
3487 crate::parser::LangId::C => "c",
3488 crate::parser::LangId::Cpp => "cpp",
3489 crate::parser::LangId::Zig => "zig",
3490 crate::parser::LangId::CSharp => "csharp",
3491 crate::parser::LangId::Bash => "bash",
3492 crate::parser::LangId::Html => "html",
3493 crate::parser::LangId::Markdown => "markdown",
3494 crate::parser::LangId::Yaml => "yaml",
3495 crate::parser::LangId::Solidity => "solidity",
3496 crate::parser::LangId::Scss => "scss",
3497 crate::parser::LangId::Vue => "vue",
3498 crate::parser::LangId::Json => "json",
3499 crate::parser::LangId::Scala => "scala",
3500 crate::parser::LangId::Java => "java",
3501 crate::parser::LangId::Ruby => "ruby",
3502 crate::parser::LangId::Kotlin => "kotlin",
3503 crate::parser::LangId::Swift => "swift",
3504 crate::parser::LangId::Php => "php",
3505 crate::parser::LangId::Lua => "lua",
3506 crate::parser::LangId::Perl => "perl",
3507 crate::parser::LangId::Pascal => "pascal",
3508 crate::parser::LangId::R => "r",
3509 crate::parser::LangId::Groovy => "groovy",
3510 crate::parser::LangId::ObjC => "objc",
3511 }
3512}
3513
3514fn unused_public_api_entries(project_root: &Path) -> (BTreeSet<String>, Vec<String>) {
3515 let entry_points = crate::inspect::entry_points::resolve_entry_points(project_root);
3516 (
3517 entry_points.public_api_files_relative(project_root),
3518 entry_points.warnings().to_vec(),
3519 )
3520}
3521
3522fn filter_outcome_for_scope_with_contributions(
3523 outcome: JobOutcome,
3524 snapshot: &InspectSnapshot,
3525 category: InspectCategory,
3526 cache: &(impl InspectCacheRead + ?Sized),
3527 scope: &JobScope,
3528) -> JobOutcome {
3529 if !category.is_tier2() || scope.is_project_wide() {
3530 return filter_outcome_for_scope(outcome, scope);
3531 }
3532
3533 match outcome {
3534 JobOutcome::Fresh { payload } => {
3535 match scoped_tier2_payload_from_contributions(snapshot, category, cache, payload, scope)
3536 {
3537 Ok(payload) => JobOutcome::Fresh { payload },
3538 Err(message) => JobOutcome::Failed { message },
3539 }
3540 }
3541 JobOutcome::Stale { cached, in_flight } => match cached {
3542 Some(payload) => {
3543 match scoped_tier2_payload_from_contributions(
3544 snapshot, category, cache, payload, scope,
3545 ) {
3546 Ok(payload) => JobOutcome::Stale {
3547 cached: Some(payload),
3548 in_flight,
3549 },
3550 Err(message) => JobOutcome::Failed { message },
3551 }
3552 }
3553 None => JobOutcome::Stale {
3554 cached: None,
3555 in_flight,
3556 },
3557 },
3558 JobOutcome::Pending { in_flight } => JobOutcome::Pending { in_flight },
3559 JobOutcome::Failed { message } => JobOutcome::Failed { message },
3560 }
3561}
3562
3563fn filter_outcome_for_scope(outcome: JobOutcome, scope: &JobScope) -> JobOutcome {
3564 match outcome {
3565 JobOutcome::Fresh { payload } => JobOutcome::Fresh {
3566 payload: filter_payload_for_scope(payload, scope),
3567 },
3568 JobOutcome::Stale { cached, in_flight } => JobOutcome::Stale {
3569 cached: cached.map(|payload| filter_payload_for_scope(payload, scope)),
3570 in_flight,
3571 },
3572 JobOutcome::Pending { in_flight } => JobOutcome::Pending { in_flight },
3573 JobOutcome::Failed { message } => JobOutcome::Failed { message },
3574 }
3575}
3576
3577fn filter_payload_for_scope(mut payload: serde_json::Value, scope: &JobScope) -> serde_json::Value {
3578 if scope.is_project_wide() {
3579 return payload;
3580 }
3581
3582 if let Some(items) = payload
3586 .get_mut("items")
3587 .and_then(|value| value.as_array_mut())
3588 {
3589 let count = filter_values_for_scope(items, scope);
3590 let largest_cycle = items
3591 .iter()
3592 .filter_map(|item| item.get("files").and_then(Value::as_array).map(Vec::len))
3593 .max();
3594 if let Some(object) = payload.as_object_mut() {
3595 object.insert("count".to_string(), serde_json::json!(count));
3596 if object.contains_key("largest") {
3597 object.insert(
3598 "largest".to_string(),
3599 serde_json::json!(largest_cycle.unwrap_or(0)),
3600 );
3601 }
3602 if object.contains_key("total_groups") {
3603 object.insert("total_groups".to_string(), serde_json::json!(count));
3604 }
3605 if object.contains_key("groups_count") {
3606 object.insert("groups_count".to_string(), serde_json::json!(count));
3607 }
3608 }
3609 }
3610
3611 if let Some(groups) = payload
3612 .get_mut("groups")
3613 .and_then(|value| value.as_array_mut())
3614 {
3615 let count = filter_values_for_scope(groups, scope);
3616 if let Some(object) = payload.as_object_mut() {
3617 object.insert("count".to_string(), serde_json::json!(count));
3618 object.insert("total_groups".to_string(), serde_json::json!(count));
3619 if object.contains_key("groups_count") {
3620 object.insert("groups_count".to_string(), serde_json::json!(count));
3621 }
3622 }
3623 }
3624
3625 if let Some(object) = payload.as_object_mut() {
3631 if object.contains_key("top") {
3632 if let Some(top) = recompute_scoped_top_preview(object) {
3633 object.insert("top".to_string(), top);
3634 } else if let Some(top) = object.get_mut("top").and_then(Value::as_array_mut) {
3635 filter_values_for_scope(top, scope);
3636 }
3637 }
3638 if object.contains_key("duplicated_lines") {
3639 recompute_duplicate_payload_stats(object);
3640 }
3641 object.remove("by_language");
3642 }
3643
3644 payload
3645}
3646
3647fn recompute_duplicate_payload_stats(object: &mut serde_json::Map<String, Value>) {
3648 let values = object
3649 .get("items")
3650 .or_else(|| object.get("groups"))
3651 .and_then(Value::as_array)
3652 .cloned()
3653 .unwrap_or_default();
3654 let (duplicated_lines, duplicated_file_count) = duplicate_line_stats_from_values(&values);
3655 let total_analyzed_lines = object
3656 .get("total_analyzed_lines")
3657 .and_then(Value::as_u64)
3658 .unwrap_or(0);
3659 let duplicated_percent = if total_analyzed_lines == 0 {
3660 0.0
3661 } else {
3662 (duplicated_lines as f64 * 100.0) / total_analyzed_lines as f64
3663 };
3664 object.insert("duplicated_lines".to_string(), json!(duplicated_lines));
3665 object.insert(
3666 "duplicated_file_count".to_string(),
3667 json!(duplicated_file_count),
3668 );
3669 object.insert("duplicated_percent".to_string(), json!(duplicated_percent));
3670}
3671
3672fn duplicate_line_stats_from_values(values: &[Value]) -> (u64, usize) {
3673 let mut by_file = BTreeMap::<String, Vec<(u64, u64)>>::new();
3674 for value in values {
3675 let Some(files) = value.get("files").and_then(Value::as_array) else {
3676 continue;
3677 };
3678 for occurrence in files.iter().filter_map(Value::as_str) {
3679 let Some((file, start, end)) = parse_duplicate_occurrence(occurrence) else {
3680 continue;
3681 };
3682 by_file
3683 .entry(file.to_string())
3684 .or_default()
3685 .push((start, end));
3686 }
3687 }
3688 let file_count = by_file.len();
3689 let duplicated_lines = by_file
3690 .values_mut()
3691 .map(|intervals| merged_duplicate_interval_lines(intervals))
3692 .sum();
3693 (duplicated_lines, file_count)
3694}
3695
3696fn merged_duplicate_interval_lines(intervals: &mut [(u64, u64)]) -> u64 {
3697 if intervals.is_empty() {
3698 return 0;
3699 }
3700 intervals.sort_by(|left, right| left.0.cmp(&right.0).then(left.1.cmp(&right.1)));
3701 let (mut current_start, mut current_end) = intervals[0];
3702 let mut total = 0;
3703 for &(start, end) in &intervals[1..] {
3704 if start <= current_end.saturating_add(1) {
3705 current_end = current_end.max(end);
3706 } else {
3707 total += current_end.saturating_sub(current_start).saturating_add(1);
3708 current_start = start;
3709 current_end = end;
3710 }
3711 }
3712 total + current_end.saturating_sub(current_start).saturating_add(1)
3713}
3714
3715fn recompute_scoped_top_preview(
3716 object: &serde_json::Map<String, Value>,
3717) -> Option<serde_json::Value> {
3718 let values = object
3719 .get("items")
3720 .or_else(|| object.get("groups"))
3721 .and_then(Value::as_array)?;
3722 Some(Value::Array(
3723 values
3724 .iter()
3725 .take(super::entry_points::TOP_PREVIEW_ITEMS)
3726 .map(top_preview_value)
3727 .collect(),
3728 ))
3729}
3730
3731fn top_preview_value(value: &Value) -> Value {
3732 if let Some(files) = value.get("files").and_then(Value::as_array) {
3733 let mut object = serde_json::Map::new();
3734 object.insert("files".to_string(), Value::Array(files.clone()));
3735 if let Some(cost) = value.get("cost").cloned() {
3736 object.insert("cost".to_string(), cost);
3737 }
3738 return Value::Object(object);
3739 }
3740
3741 json!({
3742 "file": value.get("file").and_then(Value::as_str).unwrap_or(""),
3743 "symbol": value.get("symbol").and_then(Value::as_str).unwrap_or(""),
3744 })
3745}
3746
3747fn filter_values_for_scope(values: &mut Vec<serde_json::Value>, scope: &JobScope) -> usize {
3748 values.retain_mut(|value| prune_value_for_scope(value, scope));
3749 values.len()
3750}
3751
3752fn prune_value_for_scope(value: &mut serde_json::Value, scope: &JobScope) -> bool {
3753 if let Some(file) = value.get("file").and_then(|file| file.as_str()) {
3754 return scope.contains_display_path(file);
3755 }
3756
3757 let first_scoped_occurrence = if let Some(files) = value
3758 .get_mut("files")
3759 .and_then(|files| files.as_array_mut())
3760 {
3761 files.retain(|file| {
3762 file.as_str()
3763 .is_some_and(|file| scope.contains_display_path(display_file_from_occurrence(file)))
3764 });
3765 if files.len() < 2 {
3766 return false;
3767 }
3768 files.first().and_then(Value::as_str).map(str::to_string)
3769 } else {
3770 None
3771 };
3772
3773 if let Some(occurrence) = first_scoped_occurrence {
3774 update_duplicate_group_sample(value, &occurrence);
3775 }
3776
3777 true
3778}
3779
3780fn update_duplicate_group_sample(value: &mut serde_json::Value, occurrence: &str) {
3781 let Some((file, start_line, end_line)) = parse_duplicate_occurrence(occurrence) else {
3782 return;
3783 };
3784 let Some(object) = value.as_object_mut() else {
3785 return;
3786 };
3787
3788 if object.contains_key("sample_file") {
3789 object.insert("sample_file".to_string(), json!(file));
3790 }
3791 if object.contains_key("sample_start_line") {
3792 object.insert("sample_start_line".to_string(), json!(start_line));
3793 }
3794 if object.contains_key("sample_end_line") {
3795 object.insert("sample_end_line".to_string(), json!(end_line));
3796 }
3797}
3798
3799fn parse_duplicate_occurrence(value: &str) -> Option<(&str, u64, u64)> {
3800 let (file, range) = value.rsplit_once(':')?;
3801 let (start, end) = range.split_once('-')?;
3802 if !start.chars().all(|char| char.is_ascii_digit())
3803 || !end.chars().all(|char| char.is_ascii_digit())
3804 {
3805 return None;
3806 }
3807
3808 Some((file, start.parse().ok()?, end.parse().ok()?))
3809}
3810
3811fn display_file_from_occurrence(value: &str) -> &str {
3812 let Some((file, range)) = value.rsplit_once(':') else {
3813 return value;
3814 };
3815 let Some((start, end)) = range.split_once('-') else {
3816 return value;
3817 };
3818 if start.chars().all(|char| char.is_ascii_digit())
3819 && end.chars().all(|char| char.is_ascii_digit())
3820 {
3821 file
3822 } else {
3823 value
3824 }
3825}
3826
3827#[cfg(test)]
3828mod guard_tests {
3829 use super::*;
3830
3831 fn write_ts_project(file_count: usize) -> tempfile::TempDir {
3832 let dir = tempfile::tempdir().expect("tempdir");
3833 let root = dir.path();
3834 for i in 0..file_count {
3835 std::fs::write(
3836 root.join(format!("mod{i}.ts")),
3837 format!("export function f{i}() {{ return {i}; }}\n"),
3838 )
3839 .expect("write fixture");
3840 }
3841 let canonical_root = std::fs::canonicalize(root).expect("canonical fixture root");
3842 let project_key = crate::search_index::artifact_cache_key(&canonical_root);
3843 crate::root_cache::configure_artifact_access(&canonical_root, &project_key, false);
3844 dir
3845 }
3846
3847 struct ProjectionObserverReset;
3848
3849 impl Drop for ProjectionObserverReset {
3850 fn drop(&mut self) {
3851 crate::callgraph_store::set_projection_before_open_observer(None);
3852 }
3853 }
3854
3855 fn count_projections() -> (Arc<std::sync::atomic::AtomicUsize>, ProjectionObserverReset) {
3856 let count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
3857 let observed = Arc::clone(&count);
3858 crate::callgraph_store::set_projection_before_open_observer(Some(Arc::new(move |_| {
3859 observed.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3860 })));
3861 (count, ProjectionObserverReset)
3862 }
3863
3864 fn write_projection_cache_file(path: &Path, contents: &str) {
3865 std::fs::create_dir_all(path.parent().expect("fixture file parent"))
3866 .expect("create fixture parent");
3867 std::fs::write(path, contents).expect("write fixture file");
3868 }
3869
3870 fn published_projection_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, InspectJob) {
3871 let dir = tempfile::tempdir().expect("tempdir");
3872 let root = std::fs::canonicalize(dir.path()).expect("canonical fixture root");
3873 write_projection_cache_file(
3874 &root.join("src/main.ts"),
3875 "import { firstTarget } from './target';\nexport function main() { firstTarget(); }\n",
3876 );
3877 write_projection_cache_file(
3878 &root.join("src/target.ts"),
3879 "export function firstTarget() {}\n",
3880 );
3881 let inspect_dir = root.join(".aft-cache").join("inspect");
3882 let project_key = crate::search_index::artifact_cache_key(&root);
3883 crate::root_cache::configure_artifact_access(&root, &project_key, false);
3884 let callgraph_dir =
3885 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("callgraph dir");
3886 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
3887 let (store, _) = CallGraphStore::cold_build_with_lease(callgraph_dir, root.clone(), &files)
3888 .expect("publish initial generation");
3889 drop(store);
3890 let mut job = snapshot_job(&root, &inspect_dir, true);
3891 job.callgraph_writer = false;
3892 (dir, root, inspect_dir, job)
3893 }
3894
3895 #[test]
3896 fn scoped_filter_recomputes_top_preview_from_scoped_items() {
3897 let project_root = PathBuf::from("/project");
3898 let scope = JobScope::from_roots(project_root.clone(), vec![project_root.join("src/in")]);
3899 let payload = json!({
3900 "count": 4,
3901 "items": [
3902 { "file": "src/out/a.ts", "symbol": "outside" },
3903 { "file": "src/in/b.ts", "symbol": "inside_b" },
3904 { "file": "src/in/c.ts", "symbol": "inside_c" }
3905 ],
3906 "top": [
3907 { "file": "src/out/a.ts", "symbol": "outside" },
3908 { "file": "src/out/z.ts", "symbol": "outside_z" }
3909 ],
3910 "by_language": { "typescript": 4 }
3911 });
3912
3913 let filtered = filter_payload_for_scope(payload, &scope);
3914
3915 assert_eq!(filtered["count"], json!(2));
3916 assert_eq!(
3917 filtered["top"],
3918 json!([
3919 { "file": "src/in/b.ts", "symbol": "inside_b" },
3920 { "file": "src/in/c.ts", "symbol": "inside_c" }
3921 ])
3922 );
3923 assert!(filtered["top"]
3924 .as_array()
3925 .unwrap()
3926 .iter()
3927 .all(|item| item["file"]
3928 .as_str()
3929 .is_some_and(|file| file.starts_with("src/in/"))));
3930 }
3931
3932 fn artifact_cache_key_for_test(project_root: &std::path::Path) -> String {
3933 let _git_env = crate::test_env::hermetic_git_env_guard();
3934 crate::search_index::artifact_cache_key(project_root)
3935 }
3936
3937 #[test]
3938 fn cache_for_paths_rebinds_same_project_key_to_current_root() {
3939 let _git_env = crate::test_env::hermetic_git_env_guard();
3940 let dir = tempfile::tempdir().expect("tempdir");
3941 let source = dir.path().join("source");
3942 std::fs::create_dir_all(&source).expect("create source repo");
3943 std::fs::write(
3944 source.join("package.json"),
3945 r#"{"name":"inspect-cache-fixture","version":"1.0.0"}"#,
3946 )
3947 .expect("write source manifest");
3948 std::fs::write(source.join("index.ts"), "export const source = 1;\n")
3949 .expect("write source file");
3950 let mut init = std::process::Command::new("git");
3951 assert!(
3952 crate::test_env::apply_hermetic_git_env(init.current_dir(&source))
3953 .arg("init")
3954 .status()
3955 .expect("git init source repo")
3956 .success()
3957 );
3958 let mut add = std::process::Command::new("git");
3959 assert!(
3960 crate::test_env::apply_hermetic_git_env(add.current_dir(&source))
3961 .args(["add", "."])
3962 .status()
3963 .expect("git add source repo")
3964 .success()
3965 );
3966 let mut commit = std::process::Command::new("git");
3967 assert!(
3968 crate::test_env::apply_hermetic_git_env(commit.current_dir(&source))
3969 .args([
3970 "-c",
3971 "user.name=AFT Tests",
3972 "-c",
3973 "user.email=aft-tests@example.com",
3974 "commit",
3975 "-m",
3976 "initial",
3977 ])
3978 .status()
3979 .expect("git commit source repo")
3980 .success()
3981 );
3982
3983 let clone = dir.path().join("clone");
3984 let mut clone_command = std::process::Command::new("git");
3985 assert!(crate::test_env::apply_hermetic_git_env(&mut clone_command)
3986 .args(["clone", "--quiet"])
3987 .arg(&source)
3988 .arg(&clone)
3989 .status()
3990 .expect("git clone source repo")
3991 .success());
3992 std::fs::write(
3993 clone.join("package.json"),
3994 r#"{"name":"inspect-cache-fixture","version":"2.0.0"}"#,
3995 )
3996 .expect("write clone manifest edit");
3997 assert_eq!(
3998 artifact_cache_key_for_test(&source),
3999 artifact_cache_key_for_test(&clone),
4000 "clones with the same root commit should share the sqlite project key"
4001 );
4002
4003 let source = std::fs::canonicalize(source).expect("canonical source root");
4004 let clone = std::fs::canonicalize(clone).expect("canonical clone root");
4005 let manager = InspectManager::new();
4006 let inspect_dir = dir.path().join("inspect");
4007 let key = JobKey::for_project_category(InspectCategory::DeadCode);
4008 let source_cache = manager
4009 .cache_for_paths(inspect_dir.clone(), source.clone())
4010 .expect("open source cache");
4011 let source_hash = source_cache
4012 .contribution_set_hash(InspectCategory::DeadCode)
4013 .expect("source contribution hash");
4014 source_cache
4015 .store_tier2_aggregate(
4016 key.clone(),
4017 &source_hash,
4018 serde_json::json!({ "count": 7, "items": [] }),
4019 )
4020 .expect("store source aggregate");
4021 assert_eq!(
4022 source_cache
4023 .get_aggregated(&key)
4024 .expect("read source aggregate")
4025 .and_then(|payload| payload.get("count").and_then(Value::as_u64)),
4026 Some(7)
4027 );
4028
4029 let clone_cache = manager
4030 .cache_for_paths(inspect_dir, clone.clone())
4031 .expect("open clone cache");
4032 assert_eq!(clone_cache.project_root(), clone.as_path());
4033 assert!(
4034 clone_cache
4035 .get_aggregated(&key)
4036 .expect("read clone aggregate")
4037 .is_none(),
4038 "same-key clone with a different manifest must not reuse the source root's cached count"
4039 );
4040 }
4041
4042 #[test]
4043 fn dead_code_blocked_on_callgraph_reads_latest_aggregate_flag() {
4044 let dir = tempfile::tempdir().unwrap();
4049 let project_root = std::fs::canonicalize(dir.path()).unwrap();
4050 std::fs::write(project_root.join("lib.rs"), "pub fn marker() {}\n").unwrap();
4051 let manager = InspectManager::new();
4052 let inspect_dir = dir.path().join("inspect");
4053
4054 assert!(!manager.dead_code_blocked_on_callgraph(inspect_dir.clone(), project_root.clone()));
4056
4057 let cache = manager
4058 .cache_for_paths(inspect_dir.clone(), project_root.clone())
4059 .expect("open cache");
4060 let key = JobKey::for_project_category(InspectCategory::DeadCode);
4061 let hash = cache
4062 .contribution_set_hash(InspectCategory::DeadCode)
4063 .expect("contribution hash");
4064
4065 cache
4067 .store_tier2_aggregate(
4068 key.clone(),
4069 &hash,
4070 serde_json::json!({ "count": 3, "callgraph_available": true }),
4071 )
4072 .expect("store callgraph-backed aggregate");
4073 assert!(!manager.dead_code_blocked_on_callgraph(inspect_dir.clone(), project_root.clone()));
4074 assert_eq!(
4075 manager
4076 .latest_tier2_counts(inspect_dir.clone(), project_root.clone())
4077 .0,
4078 Some(3)
4079 );
4080
4081 cache
4084 .store_tier2_aggregate(
4085 key,
4086 &hash,
4087 crate::inspect::scanners::dead_code::callgraph_unavailable_aggregate(1),
4088 )
4089 .expect("store callgraph_unavailable aggregate");
4090 assert!(manager.dead_code_blocked_on_callgraph(inspect_dir.clone(), project_root.clone()));
4091 assert_eq!(
4092 manager.latest_tier2_counts(inspect_dir, project_root).0,
4093 None,
4094 "callgraph_unavailable dead_code must stay suppressed"
4095 );
4096 }
4097
4098 fn snapshot_job(root: &Path, inspect_dir: &Path, callgraph_store: bool) -> InspectJob {
4099 use crate::config::Config;
4100 use crate::parser::SymbolCache;
4101 use std::sync::RwLock;
4102
4103 InspectJob {
4104 job_id: 1,
4105 key: JobKey::for_project_category(InspectCategory::DeadCode),
4106 category: InspectCategory::DeadCode,
4107 scope_files: Vec::new(),
4108 project_root: root.to_path_buf(),
4109 inspect_dir: inspect_dir.to_path_buf(),
4110 config: Arc::new(Config {
4111 project_root: Some(root.to_path_buf()),
4112 callgraph_store,
4113 ..Config::default()
4114 }),
4115 symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
4116 inspect_writer: true,
4117 callgraph_writer: true,
4118 callgraph_snapshot: None,
4119 }
4120 }
4121
4122 fn generated_unused_exports_fixture() -> (tempfile::TempDir, PathBuf, Vec<PathBuf>) {
4123 let dir = tempfile::tempdir().expect("tempdir");
4124 let root = dir.path().to_path_buf();
4125 let files = [
4126 (
4127 "src/hand.ts",
4128 "export function handUnused() {}
4129",
4130 ),
4131 (
4132 "gen/schema_pb.ts",
4133 "export function generatedPathUnused() {}
4134",
4135 ),
4136 (
4137 "src/banner.ts",
4138 "// Code generated by fixture. DO NOT EDIT.
4139export function bannerUnused() {}
4140",
4141 ),
4142 ];
4143 let paths = files
4144 .iter()
4145 .map(|(relative, contents)| {
4146 let path = root.join(relative);
4147 if let Some(parent) = path.parent() {
4148 std::fs::create_dir_all(parent).expect("create parent");
4149 }
4150 std::fs::write(&path, contents).expect("write fixture file");
4151 std::fs::canonicalize(path).expect("canonical fixture path")
4152 })
4153 .collect::<Vec<_>>();
4154 (
4155 dir,
4156 std::fs::canonicalize(root).expect("canonical root"),
4157 paths,
4158 )
4159 }
4160
4161 fn unused_exports_job(root: &Path, scope_files: Vec<PathBuf>) -> InspectJob {
4162 use crate::config::Config;
4163 use crate::parser::SymbolCache;
4164 use std::sync::RwLock;
4165
4166 InspectJob {
4167 job_id: 1,
4168 key: JobKey::for_project_category(InspectCategory::UnusedExports),
4169 category: InspectCategory::UnusedExports,
4170 scope_files,
4171 project_root: root.to_path_buf(),
4172 inspect_dir: root.join(".aft-cache").join("inspect"),
4173 config: Arc::new(Config {
4174 project_root: Some(root.to_path_buf()),
4175 ..Config::default()
4176 }),
4177 symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
4178 inspect_writer: true,
4179 callgraph_writer: true,
4180 callgraph_snapshot: None,
4181 }
4182 }
4183
4184 #[test]
4185 fn unused_exports_oxc_cached_rollup_preserves_generated_split() {
4186 let (_dir, root, paths) = generated_unused_exports_fixture();
4187 let job = unused_exports_job(&root, paths.clone());
4188 let entry_points = crate::inspect::entry_points::resolve_entry_points(&root);
4189 let oxc_result = crate::inspect::oxc_engine::analyze_files(
4190 &root,
4191 &paths,
4192 AnalyzeOptions {
4193 entry_points: Vec::new(),
4194 public_api_files: entry_points.public_api_files(),
4195 executable_root_exports: entry_points.executable_root_exports(),
4196 force_reparse_files: Vec::new(),
4197 entry_reachability: false,
4198 },
4199 )
4200 .expect("oxc analyze succeeds");
4201 let fresh = crate::inspect::scanners::unused_exports::run_unused_exports_scan_with_oxc(
4202 &job,
4203 Some(&oxc_result),
4204 )
4205 .outcome
4206 .expect("fresh scan succeeds");
4207
4208 let rolled_up = roll_up_unused_exports_contributions(
4209 &job,
4210 &fresh.contributions,
4211 Some(MAX_DRILL_DOWN_ITEMS),
4212 );
4213
4214 assert_eq!(
4215 rolled_up, fresh.aggregate,
4216 "cached rollup must match fresh scan"
4217 );
4218 assert_eq!(rolled_up["count"], 1, "{rolled_up:#}");
4219 assert_eq!(rolled_up["generated_count"], 2, "{rolled_up:#}");
4220 assert_eq!(rolled_up["total_count"], 3, "{rolled_up:#}");
4221 }
4222
4223 #[test]
4224 fn unused_exports_cached_generated_state_avoids_reprobe_with_legacy_fallback() {
4225 let (_dir, root, paths) = generated_unused_exports_fixture();
4226 let job = unused_exports_job(&root, paths.clone());
4227 let entry_points = crate::inspect::entry_points::resolve_entry_points(&root);
4228 let oxc_result = crate::inspect::oxc_engine::analyze_files(
4229 &root,
4230 &paths,
4231 AnalyzeOptions {
4232 entry_points: Vec::new(),
4233 public_api_files: entry_points.public_api_files(),
4234 executable_root_exports: entry_points.executable_root_exports(),
4235 force_reparse_files: Vec::new(),
4236 entry_reachability: false,
4237 },
4238 )
4239 .expect("oxc analyze succeeds");
4240 let fresh = crate::inspect::scanners::unused_exports::run_unused_exports_scan_with_oxc(
4241 &job,
4242 Some(&oxc_result),
4243 )
4244 .outcome
4245 .expect("fresh scan succeeds");
4246 let mut contributions = fresh.contributions;
4247 let handwritten = contributions
4248 .iter_mut()
4249 .find(|contribution| contribution.file_path.ends_with("src/hand.ts"))
4250 .expect("handwritten contribution");
4251 handwritten.contribution["generated"] = json!(false);
4252
4253 crate::inspect::generated::reset_file_probe_count_for_debug(&root);
4254 let explicit_cached =
4255 roll_up_unused_exports_contributions(&job, &contributions, Some(MAX_DRILL_DOWN_ITEMS));
4256 assert_eq!(explicit_cached, fresh.aggregate);
4257 assert_eq!(
4258 crate::inspect::generated::file_probe_count_for_debug(&root),
4259 0,
4260 "an explicit cached generated=false must not probe the file again"
4261 );
4262
4263 let generated_banner = contributions
4264 .iter_mut()
4265 .find(|contribution| contribution.file_path.ends_with("src/banner.ts"))
4266 .expect("generated banner contribution");
4267 generated_banner
4268 .contribution
4269 .as_object_mut()
4270 .expect("contribution object")
4271 .remove("generated");
4272 crate::inspect::generated::reset_file_probe_count_for_debug(&root);
4273 let legacy_cached =
4274 roll_up_unused_exports_contributions(&job, &contributions, Some(MAX_DRILL_DOWN_ITEMS));
4275 assert_eq!(legacy_cached, fresh.aggregate);
4276 assert_eq!(
4277 crate::inspect::generated::file_probe_count_for_debug(&root),
4278 1,
4279 "a legacy contribution without generated must probe and recover its classification"
4280 );
4281 }
4282
4283 #[test]
4284 fn callgraph_snapshot_reports_unavailable_when_store_disabled() {
4285 let dir = write_ts_project(3);
4286 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
4287 let inspect_dir = root.join(".aft-cache").join("inspect");
4288
4289 let snapshot =
4290 build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, false), false);
4291
4292 assert!(
4293 snapshot.is_none(),
4294 "dead_code must not rebuild the legacy graph when the store is disabled"
4295 );
4296 }
4297
4298 #[test]
4299 fn callgraph_snapshot_reports_unavailable_when_store_not_ready() {
4300 let dir = write_ts_project(3);
4301 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
4302 let inspect_dir = root.join(".aft-cache").join("inspect");
4303 let callgraph_dir =
4304 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
4305 let _store = CallGraphStore::open(callgraph_dir, root.clone()).expect("open empty store");
4306
4307 let snapshot =
4308 build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true), false);
4309
4310 assert!(
4311 snapshot.is_none(),
4312 "a cold/mid-build store must surface callgraph_unavailable instead of rebuilding inline"
4313 );
4314 }
4315
4316 #[test]
4317 fn readonly_tier2_projection_keeps_generation_pinned_through_concurrent_gc() {
4318 let dir = write_ts_project(3);
4319 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
4320 let inspect_dir = root.join(".aft-cache").join("inspect");
4321 let callgraph_dir =
4322 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
4323 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
4324 let (store, _) =
4325 CallGraphStore::cold_build_with_lease(callgraph_dir.clone(), root.clone(), &files)
4326 .expect("initial generation");
4327 let initial_generation = store.sqlite_path().to_path_buf();
4328 drop(store);
4329 let project_key = crate::search_index::artifact_cache_key(&root);
4330 crate::root_cache::reset_writer_lease_acquisition_counts_for_test();
4331
4332 let root_for_observer = root.clone();
4333 let dir_for_observer = callgraph_dir.clone();
4334 let files_for_observer = files.clone();
4335 crate::callgraph_store::set_projection_before_open_observer(Some(Arc::new(
4336 move |projected_path| {
4337 for _ in 0..3 {
4338 let (published, _) = CallGraphStore::cold_build_with_lease(
4339 dir_for_observer.clone(),
4340 root_for_observer.clone(),
4341 &files_for_observer,
4342 )
4343 .expect("concurrent generation publication");
4344 drop(published);
4345 }
4346 assert!(
4347 projected_path.is_file(),
4348 "the tier2 reader marker must pin the selected generation through GC"
4349 );
4350 },
4351 )));
4352 let mut job = snapshot_job(&root, &inspect_dir, true);
4353 job.callgraph_writer = false;
4354
4355 let snapshot =
4356 build_tier2_callgraph_snapshot_with_refresh(&job, false, &[root.join("mod0.ts")]);
4357 crate::callgraph_store::set_projection_before_open_observer(None);
4358
4359 assert!(snapshot.is_some());
4360 assert!(initial_generation.is_file());
4361 assert_eq!(
4362 crate::root_cache::writer_lease_acquisition_count_for_test(
4363 crate::root_cache::RootCacheDomain::Callgraph,
4364 &project_key,
4365 &root,
4366 ),
4367 3,
4368 "only the three observer publications may acquire a writer lease; tier2 must stay read-only"
4369 );
4370 }
4371
4372 #[test]
4373 fn direct_callgraph_snapshot_does_not_cold_rebuild_when_store_needs_rebuild() {
4374 let dir = write_ts_project(3);
4375 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
4376 let inspect_dir = root.join(".aft-cache").join("inspect");
4377 let callgraph_dir =
4378 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
4379 let store = CallGraphStore::open(callgraph_dir.clone(), root.clone()).expect("open store");
4380 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
4381 store.cold_build(&files).expect("cold build store");
4382 let sqlite_path = store.sqlite_path().to_path_buf();
4383 drop(store);
4384
4385 let still_existing_previous_root = root.with_file_name("previous-root-still-exists");
4386 std::fs::create_dir_all(&still_existing_previous_root).expect("create previous root");
4387 let conn = rusqlite::Connection::open(&sqlite_path).expect("open store sqlite");
4388 conn.execute(
4389 "UPDATE backend_file_state SET workspace_root = ?1",
4390 rusqlite::params![still_existing_previous_root.display().to_string()],
4391 )
4392 .expect("force root repair rebuild state");
4393 drop(conn);
4394
4395 let snapshot =
4396 build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true), false)
4397 .expect("readonly snapshot should avoid cold-rebuilding the store");
4398
4399 assert_eq!(snapshot.files.len(), 3);
4400 let conn = rusqlite::Connection::open(&sqlite_path).expect("reopen store sqlite");
4401 let stored_root: String = conn
4402 .query_row(
4403 "SELECT workspace_root FROM backend_file_state LIMIT 1",
4404 [],
4405 |row| row.get(0),
4406 )
4407 .expect("read stored root");
4408 assert_eq!(
4409 stored_root,
4410 still_existing_previous_root.display().to_string(),
4411 "direct inspect must not cold-rebuild or re-root a read-only snapshot"
4412 );
4413 }
4414
4415 #[test]
4416 fn callgraph_snapshot_reads_ready_callgraph_store() {
4417 let dir = write_ts_project(3);
4418 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
4419 let inspect_dir = root.join(".aft-cache").join("inspect");
4420 let callgraph_dir =
4421 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
4422 let store = CallGraphStore::open(callgraph_dir, root.clone()).expect("open store");
4423 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
4424 store.cold_build(&files).expect("cold build store");
4425
4426 let snapshot =
4427 build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true), false)
4428 .expect("ready store snapshot");
4429
4430 assert_eq!(snapshot.files.len(), 3);
4431 assert_eq!(snapshot.exported_symbols.len(), 3);
4432 }
4433
4434 #[test]
4435 fn generation_keyed_projection_cache_reuses_unchanged_snapshot() {
4436 let (_dir, _root, _inspect_dir, job) = published_projection_fixture();
4437 let manager = InspectManager::new();
4438 let (projections, _observer_reset) = count_projections();
4439
4440 let first = manager
4441 .build_tier2_callgraph_snapshot_with_refresh(&job, false, &[])
4442 .expect("first projection");
4443 let second = manager
4444 .build_tier2_callgraph_snapshot_with_refresh(&job, false, &[])
4445 .expect("cached projection");
4446
4447 assert!(
4448 Arc::ptr_eq(&first, &second),
4449 "an unchanged generation and write revision must reuse the projected Arc"
4450 );
4451 assert_eq!(
4452 projections.load(std::sync::atomic::Ordering::SeqCst),
4453 1,
4454 "two dead-code scans without a callgraph mutation must project once"
4455 );
4456 let memory = manager.callgraph_projection_estimated_memory();
4457 assert_eq!(
4458 memory.counts["callgraph_projection_snapshots"], 1,
4459 "the resident projection must be attributed to the root"
4460 );
4461 assert!(
4462 memory.estimated_bytes.unwrap_or_default() > 0,
4463 "a populated projection must report an estimated residency"
4464 );
4465 }
4466
4467 #[test]
4468 fn projection_cache_invalidates_on_in_place_refresh_for_readonly_scans() {
4469 let (_dir, root, inspect_dir, job) = published_projection_fixture();
4470 let manager = InspectManager::new();
4471 let (projections, _observer_reset) = count_projections();
4472 let target = root.join("src/target.ts");
4473 let callgraph_dir =
4474 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("callgraph dir");
4475
4476 let first = manager
4477 .build_tier2_callgraph_snapshot_with_refresh(&job, false, &[])
4478 .expect("initial projection");
4479 let writer = CallGraphStore::open_ready_no_rebuild(callgraph_dir, root.clone())
4480 .expect("open writer")
4481 .expect("ready writer");
4482 let revision_before = writer
4483 .projection_write_revision()
4484 .expect("read initial revision")
4485 .expect("new stores write a projection revision");
4486 write_projection_cache_file(&target, "export function secondTarget() {}\n");
4487 writer
4488 .refresh_files(&[target])
4489 .expect("refresh changed target");
4490 let revision_after = writer
4491 .projection_write_revision()
4492 .expect("read refreshed revision")
4493 .expect("refreshed stores retain a projection revision");
4494 assert!(
4495 revision_after > revision_before,
4496 "the in-place refresh must advance the durable cache identity"
4497 );
4498 drop(writer);
4499
4500 let second = manager
4501 .build_tier2_callgraph_snapshot_with_refresh(&job, false, &[])
4502 .expect("refreshed readonly projection");
4503 assert!(
4504 !first
4505 .exported_symbols
4506 .iter()
4507 .any(|export| export.symbol == "secondTarget"),
4508 "the initial snapshot must not already contain the refreshed export"
4509 );
4510 assert!(
4511 second
4512 .exported_symbols
4513 .iter()
4514 .any(|export| export.symbol == "secondTarget"),
4515 "the readonly scan must expose graph data from the refreshed store"
4516 );
4517 assert_eq!(
4518 projections.load(std::sync::atomic::Ordering::SeqCst),
4519 2,
4520 "an in-place refresh must force the next scan to re-project"
4521 );
4522 }
4523
4524 #[test]
4525 fn projection_cache_invalidates_when_cold_build_publishes_new_generation() {
4526 let (_dir, root, inspect_dir, job) = published_projection_fixture();
4527 let manager = InspectManager::new();
4528 let (projections, _observer_reset) = count_projections();
4529 let target = root.join("src/target.ts");
4530 let callgraph_dir =
4531 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("callgraph dir");
4532
4533 let first = manager
4534 .build_tier2_callgraph_snapshot_with_refresh(&job, false, &[])
4535 .expect("initial projection");
4536 let before = CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
4537 .expect("open initial reader")
4538 .expect("initial reader");
4539 let revision_before = before
4540 .projection_write_revision()
4541 .expect("read initial revision")
4542 .expect("new stores write a projection revision");
4543 drop(before);
4544 write_projection_cache_file(&target, "export function coldBuildTarget() {}\n");
4545 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
4546 let (published, _) =
4547 CallGraphStore::cold_build_with_lease(callgraph_dir, root.clone(), &files)
4548 .expect("publish replacement generation");
4549 let revision_after = published
4550 .projection_write_revision()
4551 .expect("read replacement revision")
4552 .expect("replacement stores write a projection revision");
4553 assert_eq!(
4554 revision_after, revision_before,
4555 "cold builds begin with the same revision, so this assertion exercises the generation half of the cache identity"
4556 );
4557 drop(published);
4558
4559 let second = manager
4560 .build_tier2_callgraph_snapshot_with_refresh(&job, false, &[])
4561 .expect("replacement projection");
4562 assert!(
4563 !first
4564 .exported_symbols
4565 .iter()
4566 .any(|export| export.symbol == "coldBuildTarget"),
4567 "the initial snapshot must not already contain the replacement export"
4568 );
4569 assert!(
4570 second
4571 .exported_symbols
4572 .iter()
4573 .any(|export| export.symbol == "coldBuildTarget"),
4574 "the next scan must expose the generation published by the cold build"
4575 );
4576 assert_eq!(
4577 projections.load(std::sync::atomic::Ordering::SeqCst),
4578 2,
4579 "a new pointer generation must force the next scan to re-project"
4580 );
4581 }
4582
4583 #[test]
4584 fn idle_eviction_drops_generation_keyed_projection_cache() {
4585 let (_dir, _root, _inspect_dir, job) = published_projection_fixture();
4586 let manager = InspectManager::new();
4587 let (projections, _observer_reset) = count_projections();
4588
4589 let first = manager
4590 .build_tier2_callgraph_snapshot_with_refresh(&job, false, &[])
4591 .expect("initial projection");
4592 manager.evict_idle_caches();
4593 assert_eq!(
4594 manager.callgraph_projection_estimated_memory().counts
4595 ["callgraph_projection_snapshots"],
4596 0,
4597 "idle artifact eviction must release the root projection slot"
4598 );
4599 let second = manager
4600 .build_tier2_callgraph_snapshot_with_refresh(&job, false, &[])
4601 .expect("reloaded projection");
4602
4603 assert!(
4604 !Arc::ptr_eq(&first, &second),
4605 "eviction must drop the previous projection Arc"
4606 );
4607 assert_eq!(
4608 projections.load(std::sync::atomic::Ordering::SeqCst),
4609 2,
4610 "the next scan after idle eviction must reload the projection"
4611 );
4612 }
4613
4614 #[test]
4615 fn callgraph_snapshot_uses_ready_root_keyed_store() {
4616 let _git_env = crate::test_env::hermetic_git_env_guard();
4617 let dir = write_ts_project(3);
4618 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
4619 let storage_dir = root.join(".aft-cache");
4620 let inspect_dir = storage_dir
4621 .join("inspect")
4622 .join(crate::path_identity::project_scope_key(&root));
4623 let warm_callgraph_dir = storage_dir
4624 .join("callgraph")
4625 .join(artifact_cache_key_for_test(&root));
4626 let store = CallGraphStore::open(warm_callgraph_dir, root.clone()).expect("open store");
4627 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
4628 store.cold_build(&files).expect("cold build store");
4629
4630 let snapshot =
4631 build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true), false)
4632 .expect("ready sibling store snapshot");
4633
4634 assert_eq!(snapshot.files.len(), 3);
4635 assert_eq!(snapshot.exported_symbols.len(), 3);
4636 }
4637
4638 #[test]
4639 fn dead_code_forced_deletion_refreshes_callgraph_store_before_rollup() {
4640 let dir = tempfile::tempdir().expect("tempdir");
4641 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
4642 write_fixture_file(
4643 &root,
4644 "package.json",
4645 r#"{"name":"dead-code-delete-refresh","type":"module","main":"src/main.ts"}"#,
4646 3_100_000_000,
4647 );
4648 write_fixture_file(
4649 &root,
4650 "src/main.ts",
4651 "export function main() {}\n",
4652 3_100_000_001,
4653 );
4654 write_fixture_file(
4655 &root,
4656 "src/dead.ts",
4657 "export function plantedDead() {}\n",
4658 3_100_000_002,
4659 );
4660
4661 let inspect_dir = root.join(".aft-cache").join("opencode").join("inspect");
4662 let callgraph_dir =
4663 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
4664 let project_key = crate::search_index::artifact_cache_key(&root);
4665 crate::root_cache::configure_artifact_access(&root, &project_key, false);
4666 let store = CallGraphStore::open(callgraph_dir.clone(), root.clone()).expect("open store");
4667 let project_files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
4668 store.cold_build(&project_files).expect("cold build store");
4669 drop(store);
4670
4671 let config = Arc::new(crate::config::Config {
4672 project_root: Some(root.clone()),
4673 callgraph_store: true,
4674 ..crate::config::Config::default()
4675 });
4676 let symbol_cache = Arc::new(std::sync::RwLock::new(crate::parser::SymbolCache::new()));
4677 let snapshot = InspectSnapshot::new(
4678 root.clone(),
4679 inspect_dir.clone(),
4680 Arc::clone(&config),
4681 Arc::clone(&symbol_cache),
4682 );
4683 let manager = InspectManager::new();
4684 let initial_job =
4685 manager.tier2_reuse_job(snapshot.clone(), InspectCategory::DeadCode, None);
4686 let initial = manager
4687 .tier2_run_with_reuse_job_result_with_options(initial_job, Tier2ReuseOptions::default())
4688 .outcome
4689 .expect("initial dead_code scan succeeds")
4690 .aggregate;
4691 assert!(
4692 aggregate_has_file_symbol(&initial, "src/dead.ts", "plantedDead"),
4693 "initial scan should report the planted dead export: {initial:#}"
4694 );
4695
4696 let deleted = root.join("src/dead.ts");
4697 std::fs::remove_file(&deleted).expect("delete dead fixture");
4698 let delete_job = manager.tier2_reuse_job(snapshot, InspectCategory::DeadCode, None);
4699 let refreshed = manager
4700 .tier2_run_with_reuse_job_result_with_options(
4701 delete_job,
4702 Tier2ReuseOptions::direct(vec![deleted.clone()]),
4703 )
4704 .outcome
4705 .expect("delete refresh dead_code scan succeeds")
4706 .aggregate;
4707
4708 assert_eq!(
4709 refreshed
4710 .get("callgraph_available")
4711 .and_then(Value::as_bool),
4712 Some(true),
4713 "forced watcher paths must keep the callgraph-backed aggregate available: {refreshed:#}"
4714 );
4715 assert!(
4716 !aggregate_has_file_symbol(&refreshed, "src/dead.ts", "plantedDead"),
4717 "delete refresh should remove the planted dead export: {refreshed:#}"
4718 );
4719
4720 let store = CallGraphStore::open_ready_no_rebuild(callgraph_dir, root)
4721 .expect("open refreshed store")
4722 .expect("refreshed store is ready");
4723 let projected = project_dead_code_snapshot(store.sqlite_path()).expect("project snapshot");
4724 assert!(
4725 projected
4726 .files
4727 .iter()
4728 .all(|file| !file.ends_with("src/dead.ts")),
4729 "watcher deletion should be applied to the persisted callgraph store: {:#?}",
4730 projected.files
4731 );
4732 }
4733
4734 fn aggregate_has_file_symbol(aggregate: &Value, file: &str, symbol: &str) -> bool {
4735 aggregate
4736 .get("items")
4737 .and_then(Value::as_array)
4738 .is_some_and(|items| {
4739 items.iter().any(|item| {
4740 item.get("file").and_then(Value::as_str) == Some(file)
4741 && item.get("symbol").and_then(Value::as_str) == Some(symbol)
4742 })
4743 })
4744 }
4745
4746 #[test]
4750 fn scoped_filter_drops_project_wide_by_language() {
4751 let scope = JobScope::from_roots("/proj", vec![PathBuf::from("/proj/src/a")]);
4752 assert!(
4753 !scope.is_project_wide(),
4754 "scope must be non-project for test"
4755 );
4756 let payload = serde_json::json!({
4757 "count": 99,
4758 "by_language": { "rust": 214, "typescript": 143 },
4759 "items": [
4760 { "file": "/proj/src/a/x.rs", "symbol": "live" },
4761 { "file": "/proj/src/other/y.rs", "symbol": "out" },
4762 ],
4763 });
4764 let filtered = filter_payload_for_scope(payload, &scope);
4765 assert!(
4766 filtered.get("by_language").is_none(),
4767 "scoped payload must drop project-wide by_language: {filtered}"
4768 );
4769 assert_eq!(filtered.get("count").and_then(|v| v.as_u64()), Some(1));
4771 }
4772 #[cfg(debug_assertions)]
4773 #[test]
4774 fn tier2_read_cached_freshness_does_not_hash_unchanged_contributions() {
4775 let (_dir, manager, snapshot, scope, _files) = duplicate_cache_fixture();
4776 let fixture_root = snapshot.project_root.clone();
4777
4778 crate::cache_freshness::reset_hash_file_if_small_count_for_debug();
4779 crate::cache_freshness::reset_verify_file_strict_count_for_debug();
4780 assert_fresh(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
4781
4782 assert_eq!(
4783 crate::cache_freshness::verify_file_strict_count_under_for_debug(&fixture_root),
4784 0,
4785 "dispatch-thread inspect freshness must not use strict verification"
4786 );
4787 assert_eq!(
4788 crate::cache_freshness::hash_file_if_small_count_for_debug(),
4789 0,
4790 "unchanged contribution files must stay on the stat-only fast path"
4791 );
4792 }
4793
4794 #[cfg(debug_assertions)]
4795 #[test]
4796 fn tier2_read_cached_freshness_returns_byte_identical_cold_scan_aggregate() {
4797 let (_dir, manager, snapshot, scope, _files) = duplicate_uncached_fixture();
4798 let cold_payload = fresh_payload(manager.tier2_run_with_reuse(
4799 snapshot.clone(),
4800 InspectCategory::Duplicates,
4801 scope.clone(),
4802 None,
4803 ));
4804
4805 crate::cache_freshness::reset_hash_file_if_small_count_for_debug();
4806 crate::cache_freshness::reset_verify_file_strict_count_for_debug();
4807 let fixture_root = snapshot.project_root.clone();
4808 let warm_payload =
4809 fresh_payload(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
4810
4811 let cold_bytes = serde_json::to_vec(&cold_payload).expect("serialize cold aggregate");
4812 let warm_bytes = serde_json::to_vec(&warm_payload).expect("serialize warm aggregate");
4813 assert_eq!(
4814 warm_bytes, cold_bytes,
4815 "warm unchanged read must return the byte-identical aggregate as the cold scan"
4816 );
4817 assert_eq!(
4818 crate::cache_freshness::verify_file_strict_count_under_for_debug(&fixture_root),
4819 0,
4820 "dispatch-thread warm read must not use strict verification"
4821 );
4822 assert_eq!(
4823 crate::cache_freshness::hash_file_if_small_count_for_debug(),
4824 0,
4825 "warm unchanged read must not content-hash cached contribution files"
4826 );
4827 }
4828
4829 #[test]
4830 fn tier2_read_cached_freshness_detects_changed_added_and_deleted_files() {
4831 let (_dir, manager, snapshot, scope, _files) = duplicate_cache_fixture();
4832 write_fixture_file(
4833 &snapshot.project_root,
4834 "src/foo.ts",
4835 "export const foo = 101;\nexport const changed = true;\n",
4836 3_000_000_001,
4837 );
4838 assert_stale(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
4839
4840 let (_dir, manager, snapshot, scope, _files) = duplicate_cache_fixture();
4841 write_fixture_file(
4842 &snapshot.project_root,
4843 "src/added.ts",
4844 "export const added = 3;\n",
4845 3_000_000_002,
4846 );
4847 assert_stale(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
4848
4849 let (_dir, manager, snapshot, scope, files) = duplicate_cache_fixture();
4850 std::fs::remove_file(&files[0]).expect("delete cached contribution file");
4851 assert_stale(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
4852 }
4853
4854 fn duplicate_cache_fixture() -> (
4855 tempfile::TempDir,
4856 InspectManager,
4857 InspectSnapshot,
4858 JobScope,
4859 Vec<PathBuf>,
4860 ) {
4861 let (dir, manager, snapshot, scope, files) = duplicate_uncached_fixture();
4862 store_duplicate_cache(&manager, &snapshot, &files);
4863 (dir, manager, snapshot, scope, files)
4864 }
4865
4866 fn duplicate_uncached_fixture() -> (
4867 tempfile::TempDir,
4868 InspectManager,
4869 InspectSnapshot,
4870 JobScope,
4871 Vec<PathBuf>,
4872 ) {
4873 use crate::config::Config;
4874 use crate::parser::SymbolCache;
4875 use std::sync::RwLock;
4876
4877 let dir = tempfile::tempdir().expect("tempdir");
4878 let root = std::fs::canonicalize(dir.path()).expect("canonical fixture root");
4879 let files = vec![
4880 write_fixture_file(
4881 &root,
4882 "src/foo.ts",
4883 "export const fixture = () => 1;
4884export const shared = 1;
4885",
4886 3_000_000_000,
4887 ),
4888 write_fixture_file(
4889 &root,
4890 "src/bar.ts",
4891 "export const fixture = () => 1;
4892export const shared = 1;
4893",
4894 3_000_000_000,
4895 ),
4896 ];
4897 let inspect_dir = root.join(".aft-cache").join("inspect");
4898 let snapshot = InspectSnapshot::new(
4899 root.clone(),
4900 inspect_dir,
4901 Arc::new(Config {
4902 project_root: Some(root.clone()),
4903 ..Config::default()
4904 }),
4905 Arc::new(RwLock::new(SymbolCache::new())),
4906 );
4907 let scope = JobScope::for_project(root);
4908 let manager = InspectManager::new();
4909 (dir, manager, snapshot, scope, files)
4910 }
4911
4912 fn write_fixture_file(root: &Path, relative: &str, content: &str, mtime_secs: i64) -> PathBuf {
4913 let path = root.join(relative);
4914 if let Some(parent) = path.parent() {
4915 std::fs::create_dir_all(parent).expect("create fixture parent");
4916 }
4917 std::fs::write(&path, content).expect("write fixture file");
4918 filetime::set_file_mtime(&path, filetime::FileTime::from_unix_time(mtime_secs, 0))
4919 .expect("set fixture mtime");
4920 path
4921 }
4922
4923 fn store_duplicate_cache(
4924 manager: &InspectManager,
4925 snapshot: &InspectSnapshot,
4926 files: &[PathBuf],
4927 ) {
4928 let cache = manager
4929 .cache_for_snapshot(snapshot)
4930 .expect("open inspect cache");
4931 let contributions = files
4932 .iter()
4933 .map(|file| {
4934 let freshness = crate::cache_freshness::collect(file).expect("collect freshness");
4935 FileContribution::new(
4936 InspectCategory::Duplicates,
4937 file.clone(),
4938 freshness,
4939 serde_json::json!({
4940 "file": relative_cache_key(&snapshot.project_root, file),
4941 "fragments": [],
4942 }),
4943 )
4944 })
4945 .collect::<Vec<_>>();
4946 cache
4947 .store_tier2_result(
4948 JobKey::for_project_category(InspectCategory::Duplicates),
4949 files,
4950 &contributions,
4951 serde_json::json!({
4952 "count": 0,
4953 "groups": [],
4954 "scanned_files": files.len(),
4955 "total_groups": 0,
4956 }),
4957 )
4958 .expect("store tier2 cache fixture");
4959 }
4960
4961 fn assert_fresh(outcome: JobOutcome) {
4962 let _ = fresh_payload(outcome);
4963 }
4964
4965 fn fresh_payload(outcome: JobOutcome) -> Value {
4966 match outcome {
4967 JobOutcome::Fresh { payload } => payload,
4968 other => panic!("expected fresh cached Tier-2 outcome, got {other:?}"),
4969 }
4970 }
4971
4972 fn assert_stale(outcome: JobOutcome) {
4973 match outcome {
4974 JobOutcome::Stale { .. } => {}
4975 other => panic!("expected stale cached Tier-2 outcome, got {other:?}"),
4976 }
4977 }
4978}
4979
4980#[cfg(test)]
4981mod dead_code_projection_tests {
4982 use super::*;
4983 use crate::callgraph::walk_project_files;
4984 use crate::callgraph_store::{project_dead_code_snapshot, CallGraphStore};
4985 use crate::config::Config;
4986 use crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR;
4987 use crate::inspect::scanners::DEFAULT_EXPORT_MARKER_KIND;
4988 use crate::parser::SymbolCache;
4989 use filetime::FileTime;
4990 use std::sync::atomic::{AtomicI64, Ordering as AtomicOrdering};
4991 use std::sync::RwLock;
4992
4993 static NEXT_MTIME: AtomicI64 = AtomicI64::new(1_900_000_000);
4994
4995 #[test]
4996 fn scoped_dead_code_rollup_uses_ready_callgraph_and_degrades_without_it() {
4997 let dir = tempfile::tempdir().expect("tempdir");
4998 write_projection_fixture(dir.path());
4999 let root = canonical_root(dir.path());
5000 let inspect_dir = root.join(".aft-cache").join("inspect");
5001 let callgraph_dir =
5002 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
5003 let project_key = crate::search_index::artifact_cache_key(&root);
5004 crate::root_cache::configure_artifact_access(&root, &project_key, false);
5005 let store = CallGraphStore::open(callgraph_dir.clone(), root.clone()).expect("open store");
5006 let files = project_files(&root);
5007 store.cold_build(&files).expect("cold build store");
5008 let projected = project_dead_code_snapshot(store.sqlite_path()).expect("project snapshot");
5009 drop(store);
5010
5011 let config = Arc::new(Config {
5012 project_root: Some(root.clone()),
5013 callgraph_store: true,
5014 ..Config::default()
5015 });
5016 let symbol_cache = Arc::new(RwLock::new(SymbolCache::new()));
5017 let scan_job = InspectJob {
5018 job_id: 87,
5019 key: JobKey::for_project_category(InspectCategory::DeadCode),
5020 category: InspectCategory::DeadCode,
5021 scope_files: files.clone(),
5022 project_root: root.clone(),
5023 inspect_dir: inspect_dir.clone(),
5024 config: Arc::clone(&config),
5025 symbol_cache: Arc::clone(&symbol_cache),
5026 inspect_writer: true,
5027 callgraph_writer: true,
5028 callgraph_snapshot: Some(Arc::new(projected)),
5029 };
5030 let success = crate::inspect::scanners::dead_code::run_dead_code_scan(&scan_job)
5031 .outcome
5032 .expect("dead_code scan succeeds");
5033 let cache = InspectCache::open(inspect_dir.clone(), root.clone()).expect("open cache");
5034 cache
5035 .store_tier2_result(
5036 scan_job.key.clone(),
5037 &success.scanned_files,
5038 &success.contributions,
5039 success.aggregate.clone(),
5040 )
5041 .expect("store tier2 result");
5042
5043 let snapshot = InspectSnapshot::new(root.clone(), inspect_dir, config, symbol_cache);
5044 let scope = JobScope::from_roots(root.clone(), vec![root.join("src/live.ts")]);
5045 assert!(
5046 !scope.is_project_wide(),
5047 "live.ts file scope must be scoped"
5048 );
5049
5050 let ready_payload = scoped_tier2_payload_from_contributions(
5051 &snapshot,
5052 InspectCategory::DeadCode,
5053 &cache,
5054 success.aggregate.clone(),
5055 &scope,
5056 )
5057 .expect("ready scoped payload");
5058 assert_eq!(
5059 ready_payload
5060 .get("callgraph_available")
5061 .and_then(Value::as_bool),
5062 Some(true),
5063 "ready store should produce a callgraph-backed scoped rollup: {ready_payload:#}"
5064 );
5065 assert_live_item(&ready_payload, "src/live.ts", "knownLive");
5066
5067 std::fs::remove_dir_all(&callgraph_dir).expect("remove ready callgraph store");
5068 let unavailable_payload = scoped_tier2_payload_from_contributions(
5069 &snapshot,
5070 InspectCategory::DeadCode,
5071 &cache,
5072 success.aggregate,
5073 &scope,
5074 )
5075 .expect("unavailable scoped payload");
5076 assert_eq!(
5077 unavailable_payload
5078 .get("callgraph_available")
5079 .and_then(Value::as_bool),
5080 Some(false),
5081 "missing store must report callgraph_unavailable instead of fabricating an empty graph: {unavailable_payload:#}"
5082 );
5083 assert_live_item(&unavailable_payload, "src/live.ts", "knownLive");
5084 }
5085 #[derive(Debug, PartialEq, Eq)]
5086 struct ComparableSnapshot {
5087 files: BTreeSet<PathBuf>,
5088 exported_symbols: BTreeSet<(PathBuf, String, String, u32)>,
5089 outbound_calls: BTreeSet<(PathBuf, String, String, u32)>,
5090 entry_points: BTreeSet<PathBuf>,
5091 entry_point_symbols: BTreeMap<PathBuf, BTreeSet<String>>,
5092 }
5093
5094 #[test]
5095 fn dead_code_projection_contains_expected_fixture_surface() {
5096 let dir = tempfile::tempdir().expect("tempdir");
5097 write_projection_fixture(dir.path());
5098 let root = canonical_root(dir.path());
5099 let projected = store_projected_snapshot(&root, ".store-dead-code-surface");
5100
5101 assert_projection_fixture_coverage(&root, &projected);
5102 }
5103
5104 #[test]
5105 fn dead_code_projection_incremental_scenario_matrix_matches_cold_rebuild() {
5106 run_projection_scenario("rename", setup_projection_rename, edit_projection_rename);
5107 run_projection_scenario("delete", setup_projection_delete, edit_projection_delete);
5108 run_projection_scenario(
5109 "barrel delete",
5110 setup_projection_barrel,
5111 edit_projection_barrel_delete,
5112 );
5113 run_projection_scenario(
5114 "dispatch edit",
5115 setup_projection_dispatch,
5116 edit_projection_dispatch,
5117 );
5118 run_projection_scenario(
5119 "body-only edit",
5120 setup_projection_body_only,
5121 edit_projection_body_only,
5122 );
5123 }
5124
5125 #[test]
5126 fn dead_code_projection_dead_code_scan_reports_expected_verdicts() {
5127 let dir = tempfile::tempdir().expect("tempdir");
5128 write_projection_fixture(dir.path());
5129 let root = canonical_root(dir.path());
5130 let files = project_files(&root);
5131 let projected = store_projected_snapshot(&root, ".store-dead-code-e2e");
5132
5133 let projected_aggregate = dead_code_aggregate(&root, files, projected);
5134 assert_dead_item(&projected_aggregate, "src/dead.ts", "knownDead");
5135 assert_live_item(&projected_aggregate, "src/live.ts", "knownLive");
5136 assert_live_item(&projected_aggregate, "src/render.ts", "render");
5137 assert_live_item(&projected_aggregate, "src/other_render.ts", "render");
5138 }
5139
5140 #[test]
5141 fn dead_code_projection_rust_attribute_entry_points_are_live() {
5142 let dir = tempfile::tempdir().expect("tempdir");
5143 write_rust_attribute_entry_fixture(dir.path());
5144 let root = canonical_root(dir.path());
5145 let files = project_files(&root);
5146 let store = CallGraphStore::open(root.join(".store-tauri-commands"), root.clone())
5147 .expect("open store");
5148 store.cold_build(&files).expect("cold build store");
5149 let command = store
5150 .node_for(Path::new("src/commands.rs"), "get_primers")
5151 .expect("command node");
5152 assert!(
5153 command.is_entry_point,
5154 "attribute-rooted commands must be labeled as callgraph entry points"
5155 );
5156 let private_command = store
5157 .node_for(Path::new("src/commands.rs"), "private_command")
5158 .expect("private command node");
5159 assert!(
5160 private_command.is_entry_point,
5161 "private attribute-rooted commands must also be callgraph entry points"
5162 );
5163
5164 let projected = project_dead_code_snapshot(store.sqlite_path()).expect("project snapshot");
5165 let aggregate = dead_code_aggregate(&root, files, projected);
5166 assert_live_item(&aggregate, "src/commands.rs", "get_primers");
5167 assert_live_item(&aggregate, "src/db.rs", "helper");
5168 assert_live_item(&aggregate, "src/db.rs", "private_helper");
5169 assert_live_item(&aggregate, "src/imported.rs", "imported_command");
5170 assert_live_item(&aggregate, "src/db.rs", "imported_helper");
5171 assert_dead_item(&aggregate, "src/commands.rs", "planted_dead");
5172 assert_dead_item(&aggregate, "src/unimported.rs", "false_command");
5173 assert_dead_item(&aggregate, "src/db.rs", "false_helper");
5174 }
5175
5176 #[test]
5177 fn dead_code_projection_rust_attribute_roots_are_cold_deterministic() {
5178 let dir = tempfile::tempdir().expect("tempdir");
5179 write_rust_attribute_entry_fixture(dir.path());
5180 let root = canonical_root(dir.path());
5181 let first = store_projected_snapshot(&root, ".store-tauri-cold-a");
5182 let second = store_projected_snapshot(&root, ".store-tauri-cold-b");
5183
5184 assert_snapshot_parts_eq("rust attribute roots cold", &first, &second);
5185 }
5186
5187 #[test]
5188 fn dead_code_projection_rust_attribute_roots_survive_unrelated_incremental_edit() {
5189 let dir = tempfile::tempdir().expect("tempdir");
5190 write_rust_attribute_entry_fixture(dir.path());
5191 let root = canonical_root(dir.path());
5192 let files_before = project_files(&root);
5193 let incremental_store =
5194 CallGraphStore::open(root.join(".store-tauri-incremental"), root.clone())
5195 .expect("open incremental store");
5196 incremental_store
5197 .cold_build(&files_before)
5198 .expect("initial cold build");
5199
5200 write_file(
5201 &root.join("src/unrelated.rs"),
5202 r#"// unrelated edit should not refresh command attribute facts
5203pub fn unrelated() -> u32 { 2 }
5204"#,
5205 );
5206 let stats = incremental_store
5207 .refresh_files(&[root.join("src/unrelated.rs")])
5208 .expect("refresh unrelated file");
5209 assert_eq!(stats.refreshed_own_files, 1);
5210 assert_eq!(stats.changed_files, vec!["src/unrelated.rs".to_string()]);
5211 assert!(
5212 !stats
5213 .surface_changed
5214 .iter()
5215 .any(|file| file == "src/commands.rs"),
5216 "unrelated edit must not refresh the command module: {stats:#?}"
5217 );
5218 let incremental = project_dead_code_snapshot(incremental_store.sqlite_path())
5219 .expect("project incremental snapshot");
5220
5221 let cold_store = CallGraphStore::open(root.join(".store-tauri-cold"), root.clone())
5222 .expect("open cold store");
5223 cold_store
5224 .cold_build(&project_files(&root))
5225 .expect("cold rebuild");
5226 let cold = project_dead_code_snapshot(cold_store.sqlite_path()).expect("project cold");
5227 assert_snapshot_parts_eq("rust attribute roots unrelated edit", &cold, &incremental);
5228
5229 let aggregate = dead_code_aggregate(&root, project_files(&root), incremental);
5230 assert_live_item(&aggregate, "src/commands.rs", "get_primers");
5231 assert_live_item(&aggregate, "src/db.rs", "helper");
5232 assert_live_item(&aggregate, "src/db.rs", "private_helper");
5233 assert_dead_item(&aggregate, "src/commands.rs", "planted_dead");
5234 }
5235
5236 fn assert_projection_fixture_coverage(root: &Path, snapshot: &CallgraphSnapshot) {
5237 let comparable = comparable_snapshot(snapshot);
5238 assert!(
5239 comparable
5240 .files
5241 .iter()
5242 .any(|file| file.extension().and_then(|ext| ext.to_str()) == Some("ts")),
5243 "fixture must include TypeScript files: {:#?}",
5244 comparable.files
5245 );
5246 assert!(
5247 comparable
5248 .files
5249 .iter()
5250 .any(|file| file.extension().and_then(|ext| ext.to_str()) == Some("js")),
5251 "fixture must include JavaScript files: {:#?}",
5252 comparable.files
5253 );
5254 assert!(
5255 comparable
5256 .files
5257 .iter()
5258 .any(|file| file.extension().and_then(|ext| ext.to_str()) == Some("rs")),
5259 "fixture must include Rust files: {:#?}",
5260 comparable.files
5261 );
5262
5263 let main_file = canonicalize_for_snapshot(&root.join("src/main.ts"));
5264 let private_dispatch_target = format!("{}::dispatch", main_file.display());
5265 assert!(
5266 comparable
5267 .outbound_calls
5268 .iter()
5269 .any(
5270 |(caller_file, caller_symbol, target, _)| caller_file == &main_file
5271 && caller_symbol == "main"
5272 && target == &private_dispatch_target
5273 ),
5274 "fixture must cover same-file private fallback target {private_dispatch_target}: {:#?}",
5275 comparable.outbound_calls
5276 );
5277 assert!(
5278 comparable
5279 .outbound_calls
5280 .iter()
5281 .any(|(_, _, target, _)| target.contains(DISPATCHED_CALLEE_SEPARATOR)),
5282 "fixture must cover method-dispatch suffixes: {:#?}",
5283 comparable.outbound_calls
5284 );
5285 assert!(
5286 comparable
5287 .exported_symbols
5288 .iter()
5289 .any(|(_, symbol, kind, _)| symbol == "runDefault"
5290 && kind == DEFAULT_EXPORT_MARKER_KIND),
5291 "fixture must cover default-export marker rows: {:#?}",
5292 comparable.exported_symbols
5293 );
5294 }
5295
5296 fn run_projection_scenario(name: &str, setup: fn(&Path), edit: fn(&Path) -> Vec<PathBuf>) {
5297 let dir = tempfile::tempdir().expect("tempdir");
5298 setup(dir.path());
5299 let root = canonical_root(dir.path());
5300 let files_before = project_files(&root);
5301 let incremental_store = CallGraphStore::open(
5302 root.join(format!(".store-dead-code-projection-{name}-incremental")),
5303 root.clone(),
5304 )
5305 .expect("open incremental store");
5306 incremental_store
5307 .cold_build(&files_before)
5308 .expect("initial cold build");
5309
5310 let changed = edit(&root);
5311 incremental_store
5312 .refresh_files(&changed)
5313 .expect("refresh changed files");
5314 let incremental = project_dead_code_snapshot(incremental_store.sqlite_path())
5315 .expect("project incremental snapshot");
5316
5317 let cold_store = CallGraphStore::open(
5318 root.join(format!(".store-dead-code-projection-{name}-cold")),
5319 root.clone(),
5320 )
5321 .expect("open cold store");
5322 cold_store
5323 .cold_build(&project_files(&root))
5324 .expect("cold rebuild");
5325 let cold =
5326 project_dead_code_snapshot(cold_store.sqlite_path()).expect("project cold snapshot");
5327
5328 assert_snapshot_parts_eq(name, &cold, &incremental);
5329 }
5330
5331 #[test]
5340 #[ignore = "manual benchmark; needs AFT_BENCH_REPO pointing at a large checkout"]
5341 fn dead_code_decision_b_benchmark() {
5342 let Ok(repo) = std::env::var("AFT_BENCH_REPO") else {
5343 eprintln!("AFT_BENCH_REPO unset; skipping");
5344 return;
5345 };
5346 macro_rules! mark {
5348 ($($a:tt)*) => {{ eprintln!($($a)*); let _ = std::io::Write::flush(&mut std::io::stderr()); }};
5349 }
5350 let root = canonical_root(Path::new(&repo));
5351 let files = project_files(&root);
5352 mark!(
5353 "\n=== Store-backed dead_code benchmark ===\nrepo: {}\nsource files (walk_project_files): {}\nstarted store cold_build...",
5354 root.display(),
5355 files.len()
5356 );
5357
5358 let store_dir = root.join(".aft-bench-store");
5361 let _ = std::fs::remove_dir_all(&store_dir);
5362 let store = CallGraphStore::open(store_dir.clone(), root.clone()).expect("open store");
5363 let t = Instant::now();
5364 let cold_stats = store.cold_build(&files).expect("store cold build");
5365 let store_build_ms = t.elapsed().as_millis();
5366 let t = Instant::now();
5367 let projected = project_dead_code_snapshot(store.sqlite_path()).expect("projection");
5368 let proj_ms = t.elapsed().as_millis();
5369 mark!(
5370 "store cold_build: {} ms ({:?}) + projection: {} ms = {} ms (exports={}, outbound={})\nstarted scan...",
5371 store_build_ms, cold_stats, proj_ms, store_build_ms + proj_ms,
5372 projected.exported_symbols.len(), projected.outbound_calls.len()
5373 );
5374
5375 let t = Instant::now();
5377 let _result = dead_code_aggregate(&root, files.clone(), projected.clone());
5378 let scan_ms = t.elapsed().as_millis();
5379 mark!("run_dead_code_scan (cold contributions): {} ms", scan_ms);
5380
5381 mark!(
5382 "\nSUMMARY files={} store_cold_plus_projection={}ms projection={}ms scan_cold={}ms total={}ms",
5383 files.len(),
5384 store_build_ms + proj_ms,
5385 proj_ms,
5386 scan_ms,
5387 store_build_ms + proj_ms + scan_ms
5388 );
5389 let _ = std::fs::remove_dir_all(&store_dir);
5390 }
5391
5392 fn store_projected_snapshot(root: &Path, store_name: &str) -> CallgraphSnapshot {
5393 let store =
5394 CallGraphStore::open(root.join(store_name), root.to_path_buf()).expect("open store");
5395 store
5396 .cold_build(&project_files(root))
5397 .expect("store cold build");
5398 project_dead_code_snapshot(store.sqlite_path()).expect("project snapshot")
5399 }
5400
5401 fn dead_code_aggregate(
5402 root: &Path,
5403 scope_files: Vec<PathBuf>,
5404 snapshot: CallgraphSnapshot,
5405 ) -> Value {
5406 let job = InspectJob {
5407 job_id: 86,
5408 key: JobKey::for_project_category(InspectCategory::DeadCode),
5409 category: InspectCategory::DeadCode,
5410 scope_files,
5411 project_root: root.to_path_buf(),
5412 inspect_dir: root.join(".aft-cache").join("inspect"),
5413 config: Arc::new(Config {
5414 project_root: Some(root.to_path_buf()),
5415 ..Config::default()
5416 }),
5417 symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
5418 inspect_writer: true,
5419 callgraph_writer: true,
5420 callgraph_snapshot: Some(Arc::new(snapshot)),
5421 };
5422 crate::inspect::scanners::dead_code::run_dead_code_scan(&job)
5423 .outcome
5424 .expect("dead_code scan succeeds")
5425 .aggregate
5426 }
5427
5428 fn assert_snapshot_parts_eq(
5429 label: &str,
5430 expected: &CallgraphSnapshot,
5431 actual: &CallgraphSnapshot,
5432 ) {
5433 let expected = comparable_snapshot(expected);
5434 let actual = comparable_snapshot(actual);
5435 assert_eq!(
5436 actual, expected,
5437 "{label} store-projected snapshot must match cold store snapshot"
5438 );
5439 }
5440
5441 fn comparable_snapshot(snapshot: &CallgraphSnapshot) -> ComparableSnapshot {
5442 ComparableSnapshot {
5443 files: snapshot.files.iter().cloned().collect(),
5444 exported_symbols: snapshot
5445 .exported_symbols
5446 .iter()
5447 .map(|export| {
5448 (
5449 export.file.clone(),
5450 export.symbol.clone(),
5451 export.kind.clone(),
5452 export.line,
5453 )
5454 })
5455 .collect(),
5456 outbound_calls: snapshot
5457 .outbound_calls
5458 .iter()
5459 .map(|call| {
5460 (
5461 call.caller_file.clone(),
5462 call.caller_symbol.clone(),
5463 call.target.clone(),
5464 call.line,
5465 )
5466 })
5467 .collect(),
5468 entry_points: snapshot.entry_points.clone(),
5469 entry_point_symbols: snapshot.entry_point_symbols.clone(),
5470 }
5471 }
5472
5473 fn assert_dead_item(aggregate: &Value, file: &str, symbol: &str) {
5474 assert!(
5475 aggregate_has_item(aggregate, file, symbol),
5476 "expected {file}::{symbol} to be reported dead: {aggregate:#}"
5477 );
5478 }
5479
5480 fn assert_live_item(aggregate: &Value, file: &str, symbol: &str) {
5481 assert!(
5482 !aggregate_has_item(aggregate, file, symbol),
5483 "expected {file}::{symbol} to be live/not reported dead: {aggregate:#}"
5484 );
5485 }
5486
5487 fn aggregate_has_item(aggregate: &Value, file: &str, symbol: &str) -> bool {
5488 let Some(items) = aggregate.get("items").and_then(Value::as_array) else {
5489 return false;
5490 };
5491 items.iter().any(|item| {
5492 item.get("file").and_then(Value::as_str) == Some(file)
5493 && item.get("symbol").and_then(Value::as_str) == Some(symbol)
5494 })
5495 }
5496
5497 fn project_files(root: &Path) -> Vec<PathBuf> {
5498 walk_project_files(root).collect()
5499 }
5500
5501 fn canonical_root(root: &Path) -> PathBuf {
5502 std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf())
5503 }
5504
5505 fn write_file(path: &Path, content: &str) {
5506 if let Some(parent) = path.parent() {
5507 std::fs::create_dir_all(parent).expect("create parent");
5508 }
5509 std::fs::write(path, content).expect("write fixture");
5510 bump_mtime(path);
5511 }
5512
5513 fn bump_mtime(path: &Path) {
5514 let secs = NEXT_MTIME.fetch_add(1, AtomicOrdering::SeqCst);
5515 filetime::set_file_mtime(path, FileTime::from_unix_time(secs, 0)).expect("bump mtime");
5516 }
5517
5518 fn remove_file(path: &Path) {
5519 std::fs::remove_file(path).expect("remove fixture");
5520 }
5521
5522 fn write_projection_fixture(root: &Path) {
5523 write_file(
5524 &root.join("package.json"),
5525 r#"{"name":"dead-code-projection-fixture","type":"module","main":"src/main.ts"}"#,
5526 );
5527 write_file(
5528 &root.join("Cargo.toml"),
5529 r#"[package]
5530name = "dead_code_projection_fixture"
5531version = "0.1.0"
5532edition = "2021"
5533"#,
5534 );
5535 write_file(
5536 &root.join("src/main.ts"),
5537 r#"import runDefault from "./default";
5538import { knownLive } from "./live";
5539import { jsEntry } from "./app.js";
5540
5541export function main() {
5542 dispatch();
5543 runDefault();
5544 jsEntry();
5545}
5546
5547function dispatch() {
5548 knownLive();
5549 const service = { render() {} };
5550 service.render();
5551}
5552"#,
5553 );
5554 write_file(
5555 &root.join("src/default.ts"),
5556 r#"export default function runDefault() {}
5557"#,
5558 );
5559 write_file(
5560 &root.join("src/live.ts"),
5561 r#"export function knownLive() {}
5562"#,
5563 );
5564 write_file(
5565 &root.join("src/dead.ts"),
5566 r#"export function knownDead() {}
5567"#,
5568 );
5569 write_file(
5570 &root.join("src/render.ts"),
5571 r#"export function render() {}
5572"#,
5573 );
5574 write_file(
5575 &root.join("src/other_render.ts"),
5576 r#"export function render() {}
5577"#,
5578 );
5579 write_file(
5580 &root.join("src/app.js"),
5581 r#"import { jsHelper } from "./js_helper.js";
5582
5583export function jsEntry() {
5584 jsHelper();
5585}
5586"#,
5587 );
5588 write_file(
5589 &root.join("src/js_helper.js"),
5590 r#"export function jsHelper() {}
5591"#,
5592 );
5593 write_file(
5594 &root.join("src/lib.rs"),
5595 r#"mod util;
5596use crate::util::rust_helper;
5597
5598pub fn rust_entry() {
5599 rust_helper();
5600}
5601"#,
5602 );
5603 write_file(
5604 &root.join("src/util.rs"),
5605 r#"pub fn rust_helper() {}
5606"#,
5607 );
5608 }
5609
5610 fn write_rust_attribute_entry_fixture(root: &Path) {
5611 write_file(
5612 &root.join("src/main.rs"),
5613 r#"mod commands;
5614mod db;
5615mod imported;
5616mod unimported;
5617mod unrelated;
5618
5619fn main() {
5620 tauri::generate_handler![commands::get_primers, imported::imported_command];
5621}
5622"#,
5623 );
5624 write_file(
5625 &root.join("src/commands.rs"),
5626 r#"use crate::db;
5627
5628#[tauri::command]
5629pub fn get_primers() -> String {
5630 db::helper()
5631}
5632
5633pub fn planted_dead() -> String {
5634 "dead".to_string()
5635}
5636
5637#[tauri::command]
5638fn private_command() -> String {
5639 db::private_helper()
5640}
5641"#,
5642 );
5643 write_file(
5644 &root.join("src/imported.rs"),
5645 r#"use crate::db;
5646use tauri::command;
5647
5648#[command]
5649pub fn imported_command() -> String {
5650 db::imported_helper()
5651}
5652"#,
5653 );
5654 write_file(
5655 &root.join("src/unimported.rs"),
5656 r#"use crate::db;
5657
5658#[command]
5659pub fn false_command() -> String {
5660 db::false_helper()
5661}
5662"#,
5663 );
5664 write_file(
5665 &root.join("src/db.rs"),
5666 r#"pub fn helper() -> String { "live".to_string() }
5667pub fn imported_helper() -> String { "live".to_string() }
5668pub fn private_helper() -> String { "live".to_string() }
5669pub fn false_helper() -> String { "dead".to_string() }
5670"#,
5671 );
5672 write_file(
5673 &root.join("src/unrelated.rs"),
5674 r#"pub fn unrelated() -> u32 { 1 }
5675"#,
5676 );
5677 }
5678
5679 fn setup_projection_rename(root: &Path) {
5680 write_file(
5681 &root.join("a.ts"),
5682 r#"export function outer() {
5683 inner();
5684}
5685
5686export function inner() {}
5687"#,
5688 );
5689 }
5690
5691 fn edit_projection_rename(root: &Path) -> Vec<PathBuf> {
5692 let path = root.join("a.ts");
5693 write_file(
5694 &path,
5695 r#"export function outer() {
5696 renamed();
5697}
5698
5699export function renamed() {}
5700"#,
5701 );
5702 vec![path]
5703 }
5704
5705 fn setup_projection_delete(root: &Path) {
5706 write_file(
5707 &root.join("main.ts"),
5708 r#"import { foo } from "./foo";
5709export function main() { foo(); }
5710"#,
5711 );
5712 write_file(&root.join("foo.ts"), "export function foo() {}\n");
5713 }
5714
5715 fn edit_projection_delete(root: &Path) -> Vec<PathBuf> {
5716 let path = root.join("foo.ts");
5717 remove_file(&path);
5718 vec![path]
5719 }
5720
5721 fn setup_projection_barrel(root: &Path) {
5722 write_file(
5723 &root.join("main.ts"),
5724 r#"import { foo } from "./barrel";
5725export function main() { foo(); }
5726"#,
5727 );
5728 write_file(&root.join("barrel.ts"), "export { foo } from \"./foo\";\n");
5729 write_file(&root.join("foo.ts"), "export function foo() {}\n");
5730 }
5731
5732 fn edit_projection_barrel_delete(root: &Path) -> Vec<PathBuf> {
5733 let path = root.join("barrel.ts");
5734 remove_file(&path);
5735 vec![path]
5736 }
5737
5738 fn setup_projection_dispatch(root: &Path) {
5739 write_file(
5740 &root.join("main.ts"),
5741 r#"export function main() {
5742 const service = { render() {}, paint() {} };
5743 service.render();
5744}
5745"#,
5746 );
5747 write_file(&root.join("render.ts"), "export function render() {}\n");
5748 write_file(&root.join("paint.ts"), "export function paint() {}\n");
5749 }
5750
5751 fn edit_projection_dispatch(root: &Path) -> Vec<PathBuf> {
5752 let path = root.join("main.ts");
5753 write_file(
5754 &path,
5755 r#"export function main() {
5756 const service = { render() {}, paint() {} };
5757 service.paint();
5758}
5759"#,
5760 );
5761 vec![path]
5762 }
5763
5764 fn setup_projection_body_only(root: &Path) {
5765 write_file(
5766 &root.join("main.ts"),
5767 r#"import { foo } from "./foo";
5768export function main() { foo(); }
5769"#,
5770 );
5771 write_file(
5772 &root.join("foo.ts"),
5773 r#"export function foo() {
5774 return 1;
5775}
5776"#,
5777 );
5778 }
5779
5780 fn edit_projection_body_only(root: &Path) -> Vec<PathBuf> {
5781 let path = root.join("foo.ts");
5782 write_file(
5783 &path,
5784 r#"export function foo() {
5785 return 2;
5786}
5787"#,
5788 );
5789 vec![path]
5790 }
5791
5792 #[test]
5793 fn forced_paths_downgrade_only_when_strict_hash_matches_cached_fact() {
5794 let dir = tempfile::tempdir().expect("tempdir");
5795 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
5796 let unchanged = root.join("unchanged.ts");
5797 let changed = root.join("changed.ts");
5798 let oversized = root.join("oversized.ts");
5799 std::fs::write(&unchanged, "export const value = 1;\n").expect("write unchanged");
5800 std::fs::write(&changed, "export const before = 1;\n").expect("write changed baseline");
5801 let unchanged_freshness =
5802 cache_freshness::collect(&unchanged).expect("unchanged freshness");
5803 let changed_freshness = cache_freshness::collect(&changed).expect("changed freshness");
5804 std::fs::write(&changed, "export const after_ = 2;\n").expect("change same-size content");
5805 let oversized_file = std::fs::File::create(&oversized).expect("create oversized");
5806 oversized_file
5807 .set_len(cache_freshness::CONTENT_HASH_SIZE_CAP + 1)
5808 .expect("size oversized");
5809 let oversized_freshness =
5810 cache_freshness::collect(&oversized).expect("oversized freshness");
5811 let cached = vec![
5812 CachedContributionFreshness {
5813 file_path: PathBuf::from("unchanged.ts"),
5814 freshness: unchanged_freshness,
5815 },
5816 CachedContributionFreshness {
5817 file_path: PathBuf::from("changed.ts"),
5818 freshness: changed_freshness,
5819 },
5820 CachedContributionFreshness {
5821 file_path: PathBuf::from("oversized.ts"),
5822 freshness: oversized_freshness,
5823 },
5824 ];
5825
5826 let (remaining, downgraded) = downgrade_unchanged_forced_paths_with_freshness(
5827 &root,
5828 &cached,
5829 vec![
5830 PathBuf::from("unchanged.ts"),
5831 PathBuf::from("changed.ts"),
5832 PathBuf::from("oversized.ts"),
5833 ],
5834 );
5835
5836 assert_eq!(downgraded, 1);
5837 assert_eq!(
5838 remaining,
5839 vec![PathBuf::from("changed.ts"), PathBuf::from("oversized.ts")]
5840 );
5841 }
5842}