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