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