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