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