1use std::collections::{BTreeMap, BTreeSet, HashMap};
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::sync::{Arc, 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, 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};
27use crate::callgraph_store::{project_dead_code_snapshot, CallGraphStore, CallGraphStoreError};
28
29const DEFAULT_SOFT_DEADLINE: Duration = Duration::from_secs(1);
30
31type WaiterTx = Sender<JobOutcome>;
32
33#[derive(Clone)]
34struct Waiter {
35 tx: WaiterTx,
36}
37
38struct CachedContributionFreshness {
39 file_path: PathBuf,
40 freshness: FileFreshness,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Hash)]
44struct InspectCacheIdentity {
45 sqlite_path: PathBuf,
46 project_root: PathBuf,
47}
48
49#[derive(Debug, Clone)]
50pub struct Tier2RunSubmissionError {
51 pub category: InspectCategory,
52 pub message: String,
53}
54
55#[derive(Debug, Clone, Default)]
56pub struct Tier2RunSubmission {
57 pub queued_categories: Vec<InspectCategory>,
58 pub newly_queued_categories: Vec<InspectCategory>,
59 pub errors: Vec<Tier2RunSubmissionError>,
60}
61
62impl Tier2RunSubmission {
63 pub fn has_new_work(&self) -> bool {
64 !self.newly_queued_categories.is_empty()
65 }
66}
67
68#[derive(Debug, Clone)]
69pub struct DirectTier2RunOutcome {
70 pub outcome: JobOutcome,
71 pub force_paths_completed: bool,
72}
73
74#[derive(Debug, Clone)]
75struct Tier2ReuseOptions {
76 force_rescan_paths: BTreeSet<PathBuf>,
77 allow_callgraph_cold_build: bool,
78}
79
80impl Tier2ReuseOptions {
81 fn direct(paths: Vec<PathBuf>) -> Self {
82 Self {
83 force_rescan_paths: paths.into_iter().collect(),
84 allow_callgraph_cold_build: false,
85 }
86 }
87
88 fn has_force_paths(&self) -> bool {
89 !self.force_rescan_paths.is_empty()
90 }
91}
92
93impl Default for Tier2ReuseOptions {
94 fn default() -> Self {
95 Self {
96 force_rescan_paths: BTreeSet::new(),
97 allow_callgraph_cold_build: true,
98 }
99 }
100}
101
102pub struct InspectManager {
103 request_tx: Sender<InspectJob>,
104 result_rx: Receiver<InspectResult>,
105 #[allow(dead_code)]
106 pool: Arc<rayon::ThreadPool>,
107 in_flight: Mutex<HashMap<JobKey, Vec<Waiter>>>,
108 caches: Mutex<HashMap<InspectCacheIdentity, Arc<InspectCache>>>,
109 oxc_facts_cache: Mutex<OxcFactsCache>,
110 soft_deadline: Duration,
111 next_job_id: AtomicU64,
112 reuse_completions: AtomicU64,
117}
118
119impl InspectManager {
120 pub fn new() -> Self {
121 Self::with_worker(default_worker(), DEFAULT_SOFT_DEADLINE)
122 }
123
124 #[doc(hidden)]
125 pub fn with_worker(worker: InspectWorker, soft_deadline: Duration) -> Self {
126 let handles = start_dispatch_loop(worker);
127 Self {
128 request_tx: handles.request_tx,
129 result_rx: handles.result_rx,
130 pool: handles.pool,
131 in_flight: Mutex::new(HashMap::new()),
132 caches: Mutex::new(HashMap::new()),
133 oxc_facts_cache: Mutex::new(OxcFactsCache::new()),
134 soft_deadline,
135 next_job_id: AtomicU64::new(1),
136 reuse_completions: AtomicU64::new(0),
137 }
138 }
139
140 pub fn submit_category(
141 &self,
142 snapshot: InspectSnapshot,
143 category: InspectCategory,
144 caller_scope: JobScope,
145 ) -> JobOutcome {
146 self.submit_category_with_callgraph(snapshot, category, caller_scope, None)
147 }
148
149 pub fn submit_category_with_callgraph(
150 &self,
151 snapshot: InspectSnapshot,
152 category: InspectCategory,
153 caller_scope: JobScope,
154 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
155 ) -> JobOutcome {
156 if !category.is_active() {
157 return JobOutcome::Failed {
158 message: format!("inspect category '{category}' is disabled in v0.33"),
159 };
160 }
161
162 let cache = match self.cache_for_snapshot(&snapshot) {
163 Ok(cache) => cache,
164 Err(message) => return JobOutcome::Failed { message },
165 };
166 let key = JobKey::for_category_scope(category, &caller_scope);
167 let (waiter_tx, waiter_rx) = bounded(1);
168
169 let wait_snapshot = snapshot.clone();
170 match self.enqueue_with_waiter(
171 snapshot,
172 category,
173 caller_scope.clone(),
174 key.clone(),
175 waiter_tx,
176 callgraph_snapshot,
177 ) {
178 Ok(()) => self.wait_for_outcome(key, caller_scope, cache, waiter_rx, wait_snapshot),
179 Err(message) => JobOutcome::Failed { message },
180 }
181 }
182
183 pub fn submit_background(
184 &self,
185 snapshot: InspectSnapshot,
186 category: InspectCategory,
187 caller_scope: JobScope,
188 ) -> Result<JobKey, String> {
189 self.submit_background_with_callgraph(snapshot, category, caller_scope, None)
190 }
191
192 pub fn submit_background_with_callgraph(
193 &self,
194 snapshot: InspectSnapshot,
195 category: InspectCategory,
196 caller_scope: JobScope,
197 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
198 ) -> Result<JobKey, String> {
199 if !category.is_active() {
200 return Err(format!(
201 "inspect category '{category}' is disabled in v0.33"
202 ));
203 }
204 let key = JobKey::for_category_scope(category, &caller_scope);
205 self.enqueue_without_waiter(
206 snapshot,
207 category,
208 caller_scope,
209 key.clone(),
210 callgraph_snapshot,
211 )?;
212 Ok(key)
213 }
214
215 pub fn submit_tier2_run_with_reuse_background(
216 self: &Arc<Self>,
217 snapshot: InspectSnapshot,
218 category: InspectCategory,
219 ) -> Result<JobKey, String> {
220 if !category.is_active() {
221 return Err(format!(
222 "inspect category '{category}' is disabled in v0.33"
223 ));
224 }
225 if !category.is_tier2() {
226 return Err(format!(
227 "inspect category '{category}' is not a Tier 2 category"
228 ));
229 }
230
231 let job = self.tier2_reuse_job(snapshot, category, None);
232 let key = job.key.clone();
233 let mut in_flight = self
234 .in_flight
235 .lock()
236 .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
237 if in_flight.contains_key(&key) {
238 return Ok(key);
239 }
240 in_flight.insert(key.clone(), Vec::new());
241 drop(in_flight);
242
243 let manager = Arc::clone(self);
244 let pool = Arc::clone(&self.pool);
245 pool.spawn(move || {
246 let result = manager.tier2_run_with_reuse_job_result(job);
247 manager.route_tier2_reuse_completion(result);
248 });
249
250 Ok(key)
251 }
252
253 pub fn submit_tier2_run_with_reuse_serial_background(
254 self: &Arc<Self>,
255 snapshot: InspectSnapshot,
256 categories: Vec<InspectCategory>,
257 ) -> Tier2RunSubmission {
258 let mut submission = Tier2RunSubmission::default();
259 let mut requested = Vec::new();
260
261 for category in categories {
262 if !category.is_active() {
263 submission.errors.push(Tier2RunSubmissionError {
264 category,
265 message: format!("inspect category '{category}' is disabled in v0.33"),
266 });
267 continue;
268 }
269 if !category.is_tier2() {
270 submission.errors.push(Tier2RunSubmissionError {
271 category,
272 message: format!("inspect category '{category}' is not a Tier 2 category"),
273 });
274 continue;
275 }
276 requested.push(category);
277 }
278
279 if requested.is_empty() {
280 return submission;
281 }
282
283 let mut in_flight = match self.in_flight.lock() {
284 Ok(in_flight) => in_flight,
285 Err(_) => {
286 for category in requested {
287 submission.errors.push(Tier2RunSubmissionError {
288 category,
289 message: "inspect in-flight map lock poisoned".to_string(),
290 });
291 }
292 return submission;
293 }
294 };
295
296 for category in requested {
297 let key = JobKey::for_project_category(category);
298 submission.queued_categories.push(category);
299 if in_flight.contains_key(&key) {
300 continue;
301 }
302 in_flight.insert(key, Vec::new());
303 submission.newly_queued_categories.push(category);
304 }
305 drop(in_flight);
306
307 if submission.newly_queued_categories.is_empty() {
308 return submission;
309 }
310
311 let categories_for_worker = submission.newly_queued_categories.clone();
312 let manager = Arc::clone(self);
313 let pool = Arc::clone(&self.pool);
314 pool.spawn(move || {
315 for category in categories_for_worker {
316 let result = manager.tier2_run_with_reuse_result(snapshot.clone(), category, None);
317 manager.route_tier2_reuse_completion(result);
318 }
319 });
320
321 submission
322 }
323
324 pub fn tier2_any_in_flight(&self) -> bool {
325 self.in_flight
326 .lock()
327 .map(|in_flight| in_flight.keys().any(|key| key.category.is_tier2()))
328 .unwrap_or(false)
329 }
330
331 pub fn drain_completions(&self) -> usize {
332 let mut drained = 0usize;
333 while let Ok(result) = self.result_rx.try_recv() {
334 self.route_completion(result);
335 drained += 1;
336 }
337 drained
338 }
339
340 pub fn cache_for_snapshot(
341 &self,
342 snapshot: &InspectSnapshot,
343 ) -> Result<Arc<InspectCache>, String> {
344 self.cache_for_paths(snapshot.inspect_dir.clone(), snapshot.project_root.clone())
345 }
346
347 pub fn latest_tier2_counts(
355 &self,
356 inspect_dir: PathBuf,
357 project_root: PathBuf,
358 ) -> (Option<usize>, Option<usize>, Option<usize>) {
359 let Ok(cache) = self.cache_for_paths(inspect_dir, project_root) else {
360 return (None, None, None);
361 };
362 let count_of = |category: InspectCategory| -> Option<usize> {
363 cache
364 .latest_aggregate_any_hash(category)
365 .ok()
366 .flatten()
367 .and_then(|payload| {
368 if category == InspectCategory::DeadCode
369 && payload
370 .get("callgraph_available")
371 .and_then(serde_json::Value::as_bool)
372 == Some(false)
373 {
374 return None;
375 }
376 payload
377 .get("count")
378 .and_then(serde_json::Value::as_u64)
379 .map(|count| count as usize)
380 })
381 };
382 (
383 count_of(InspectCategory::DeadCode),
384 count_of(InspectCategory::UnusedExports),
385 count_of(InspectCategory::Duplicates),
386 )
387 }
388
389 pub fn cache_for_paths(
390 &self,
391 inspect_dir: PathBuf,
392 project_root: PathBuf,
393 ) -> Result<Arc<InspectCache>, String> {
394 let project_key = crate::search_index::artifact_cache_key(&project_root);
395 let sqlite_path = inspect_dir.join(format!("{project_key}.sqlite"));
396 let identity = InspectCacheIdentity {
397 sqlite_path,
398 project_root: project_root.clone(),
399 };
400 let mut caches = self
401 .caches
402 .lock()
403 .map_err(|_| "inspect manager cache map lock poisoned".to_string())?;
404 if let Some(cache) = caches.get(&identity) {
405 return Ok(Arc::clone(cache));
406 }
407 let cache = Arc::new(
408 InspectCache::open(inspect_dir, project_root)
409 .map_err(|error| format!("failed to open inspect cache: {error}"))?,
410 );
411 caches.insert(identity, Arc::clone(&cache));
412 Ok(cache)
413 }
414
415 fn oxc_result_for_scan(
416 &self,
417 job: &InspectJob,
418 files: &[PathBuf],
419 force_reparse_files: &[PathBuf],
420 ) -> Result<Option<OxcEngineResult>, String> {
421 if !category_uses_oxc(job.category) {
422 return Ok(None);
423 }
424 if job.category == InspectCategory::DeadCode && job.callgraph_snapshot.is_none() {
425 return Ok(None);
426 }
427
428 let public_api_entries = super::entry_points::resolve_entry_points(&job.project_root);
429 let entry_points = if job.category == InspectCategory::DeadCode {
430 job.callgraph_snapshot
431 .as_ref()
432 .map(|snapshot| snapshot.entry_points.iter().cloned().collect::<Vec<_>>())
433 .unwrap_or_default()
434 } else {
435 Vec::new()
436 };
437 let options = AnalyzeOptions {
438 entry_points,
439 public_api_files: public_api_entries.public_api_files(),
440 executable_root_exports: public_api_entries.executable_root_exports(),
441 force_reparse_files: force_reparse_files.to_vec(),
442 entry_reachability: job.category == InspectCategory::DeadCode,
443 };
444
445 let mut cache = self
446 .oxc_facts_cache
447 .lock()
448 .map_err(|_| "inspect oxc facts cache lock poisoned".to_string())?;
449 analyze_files_with_cache(&job.project_root, files, options, &mut cache)
450 .map(Some)
451 .map_err(|message| format!("oxc analyze failed: {message}"))
452 }
453
454 pub fn tier2_run_with_reuse(
455 &self,
456 snapshot: InspectSnapshot,
457 category: InspectCategory,
458 caller_scope: JobScope,
459 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
460 ) -> JobOutcome {
461 if let Err(outcome) = validate_tier2_read_category(category) {
462 return outcome;
463 }
464 let cache = match self.cache_for_snapshot(&snapshot) {
465 Ok(cache) => cache,
466 Err(message) => return JobOutcome::Failed { message },
467 };
468 let job = self.tier2_reuse_job(snapshot.clone(), category, callgraph_snapshot);
469 let key = job.key.clone();
470 let (waiter_tx, waiter_rx) = bounded(1);
471 let claimed = match self.register_tier2_reuse_waiter(&key, waiter_tx) {
472 Ok(claimed) => claimed,
473 Err(message) => return JobOutcome::Failed { message },
474 };
475
476 if claimed {
477 let result = self
478 .tier2_run_with_reuse_job_result_with_options(job, Tier2ReuseOptions::default());
479 self.route_tier2_reuse_completion(result);
480 }
481
482 match waiter_rx.recv() {
483 Ok(outcome) => filter_outcome_for_scope_with_contributions(
484 outcome,
485 &snapshot,
486 category,
487 cache.as_ref(),
488 &caller_scope,
489 ),
490 Err(_) => JobOutcome::Pending { in_flight: true },
491 }
492 }
493
494 pub fn tier2_run_with_reuse_direct(
495 self: &Arc<Self>,
496 snapshot: InspectSnapshot,
497 category: InspectCategory,
498 caller_scope: JobScope,
499 deadline: Instant,
500 force_rescan_paths: Vec<PathBuf>,
501 ) -> DirectTier2RunOutcome {
502 if let Err(outcome) = validate_tier2_read_category(category) {
503 return DirectTier2RunOutcome {
504 outcome,
505 force_paths_completed: false,
506 };
507 }
508 let cache = match self.cache_for_snapshot(&snapshot) {
509 Ok(cache) => cache,
510 Err(message) => {
511 return DirectTier2RunOutcome {
512 outcome: JobOutcome::Failed { message },
513 force_paths_completed: false,
514 }
515 }
516 };
517
518 let must_run_forced_followup = !force_rescan_paths.is_empty();
519 loop {
520 let options = if must_run_forced_followup {
521 Tier2ReuseOptions::direct(force_rescan_paths.clone())
522 } else {
523 Tier2ReuseOptions::direct(Vec::new())
524 };
525 let job = self.tier2_reuse_job(snapshot.clone(), category, None);
526 let key = job.key.clone();
527 let (waiter_tx, waiter_rx) = bounded(1);
528 let claimed = match self.register_tier2_reuse_waiter(&key, waiter_tx) {
529 Ok(claimed) => claimed,
530 Err(message) => {
531 return DirectTier2RunOutcome {
532 outcome: JobOutcome::Failed { message },
533 force_paths_completed: false,
534 }
535 }
536 };
537 if claimed {
538 self.spawn_tier2_reuse_job(job, options);
539 }
540
541 let completed_force_run = claimed && must_run_forced_followup;
542 let outcome = self.wait_for_tier2_reuse_until(
543 &key,
544 &caller_scope,
545 cache.as_ref(),
546 waiter_rx,
547 &snapshot,
548 deadline,
549 );
550
551 delay_direct_force_followup_deadline_check_for_debug(&snapshot.project_root);
552 if must_run_forced_followup
553 && !claimed
554 && !matches!(outcome, JobOutcome::Pending { .. })
555 {
556 if Instant::now() < deadline {
564 continue;
565 }
566 return DirectTier2RunOutcome {
567 outcome: JobOutcome::Pending { in_flight: true },
568 force_paths_completed: false,
569 };
570 }
571
572 return DirectTier2RunOutcome {
573 outcome,
574 force_paths_completed: completed_force_run,
575 };
576 }
577 }
578
579 fn register_tier2_reuse_waiter(
580 &self,
581 key: &JobKey,
582 waiter_tx: WaiterTx,
583 ) -> Result<bool, String> {
584 let mut in_flight = self
585 .in_flight
586 .lock()
587 .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
588 if let Some(waiters) = in_flight.get_mut(key) {
589 waiters.push(Waiter { tx: waiter_tx });
590 return Ok(false);
591 }
592
593 in_flight.insert(key.clone(), vec![Waiter { tx: waiter_tx }]);
594 Ok(true)
595 }
596
597 fn spawn_tier2_reuse_job(self: &Arc<Self>, job: InspectJob, options: Tier2ReuseOptions) {
598 let manager = Arc::clone(self);
599 let pool = Arc::clone(&self.pool);
600 pool.spawn(move || {
601 let result = manager.tier2_run_with_reuse_job_result_catching(job, options);
602 manager.route_tier2_reuse_completion(result);
603 });
604 }
605
606 fn wait_for_tier2_reuse_until(
607 &self,
608 key: &JobKey,
609 caller_scope: &JobScope,
610 cache: &InspectCache,
611 waiter_rx: Receiver<JobOutcome>,
612 snapshot: &InspectSnapshot,
613 deadline: Instant,
614 ) -> JobOutcome {
615 let Some(remaining) = deadline.checked_duration_since(Instant::now()) else {
616 return JobOutcome::Pending { in_flight: true };
617 };
618 if remaining.is_zero() {
619 return JobOutcome::Pending { in_flight: true };
620 }
621
622 match waiter_rx.recv_timeout(remaining) {
623 Ok(outcome) => filter_outcome_for_scope_with_contributions(
624 outcome,
625 snapshot,
626 key.category,
627 cache,
628 caller_scope,
629 ),
630 Err(crossbeam_channel::RecvTimeoutError::Timeout) => {
631 JobOutcome::Pending { in_flight: true }
632 }
633 Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
634 JobOutcome::Pending { in_flight: true }
635 }
636 }
637 }
638
639 pub fn tier2_read_cached(
646 &self,
647 snapshot: InspectSnapshot,
648 category: InspectCategory,
649 caller_scope: JobScope,
650 ) -> JobOutcome {
651 if let Err(outcome) = validate_tier2_read_category(category) {
652 return outcome;
653 }
654 let cache = match self.cache_for_snapshot(&snapshot) {
655 Ok(cache) => cache,
656 Err(message) => return JobOutcome::Failed { message },
657 };
658 self.tier2_read_cached_from_cache(&snapshot, category, &caller_scope, cache.as_ref())
659 }
660
661 pub fn tier2_read_cached_readonly(
662 &self,
663 snapshot: InspectSnapshot,
664 category: InspectCategory,
665 caller_scope: JobScope,
666 ) -> JobOutcome {
667 if let Err(outcome) = validate_tier2_read_category(category) {
668 return outcome;
669 }
670 let key = JobKey::for_project_category(category);
671 let in_flight = self
672 .in_flight
673 .lock()
674 .map(|guard| guard.contains_key(&key))
675 .unwrap_or(false);
676 let cache = match InspectCache::open_readonly(
677 snapshot.inspect_dir.clone(),
678 snapshot.project_root.clone(),
679 ) {
680 Ok(Some(cache)) => cache,
681 Ok(None) => return JobOutcome::Pending { in_flight },
682 Err(error) => {
683 return JobOutcome::Failed {
684 message: error.to_string(),
685 }
686 }
687 };
688 self.tier2_read_cached_from_cache(&snapshot, category, &caller_scope, &cache)
689 }
690
691 fn tier2_read_cached_from_cache(
692 &self,
693 snapshot: &InspectSnapshot,
694 category: InspectCategory,
695 caller_scope: &JobScope,
696 cache: &InspectCache,
697 ) -> JobOutcome {
698 let key = JobKey::for_project_category(category);
699 let in_flight = self
700 .in_flight
701 .lock()
702 .map(|guard| guard.contains_key(&key))
703 .unwrap_or(false);
704 match cache.get_aggregated_for_config(&key, snapshot.config.as_ref()) {
705 Ok(Some(payload)) => {
706 match self.tier2_cached_aggregate_is_fresh(snapshot, category, cache) {
707 Ok(true) => filter_outcome_for_scope_with_contributions(
708 JobOutcome::Fresh { payload },
709 snapshot,
710 category,
711 cache,
712 caller_scope,
713 ),
714 Ok(false) => filter_outcome_for_scope_with_contributions(
715 JobOutcome::Stale {
716 cached: Some(payload),
717 in_flight,
718 },
719 snapshot,
720 category,
721 cache,
722 caller_scope,
723 ),
724 Err(message) => JobOutcome::Failed { message },
725 }
726 }
727 Ok(None) => match cache.latest_aggregate_any_hash(category) {
728 Ok(Some(payload)) => filter_outcome_for_scope_with_contributions(
729 JobOutcome::Stale {
730 cached: Some(payload),
731 in_flight,
732 },
733 snapshot,
734 category,
735 cache,
736 caller_scope,
737 ),
738 Ok(None) => JobOutcome::Pending { in_flight },
739 Err(error) => JobOutcome::Failed {
740 message: error.to_string(),
741 },
742 },
743 Err(error) => JobOutcome::Failed {
744 message: error.to_string(),
745 },
746 }
747 }
748
749 fn tier2_cached_aggregate_is_fresh(
750 &self,
751 snapshot: &InspectSnapshot,
752 category: InspectCategory,
753 cache: &InspectCache,
754 ) -> Result<bool, String> {
755 let cached_records = load_contribution_freshness(cache, category)?;
756 let cached_relative = cached_records
757 .iter()
758 .map(freshness_record_relative_key)
759 .collect::<BTreeSet<_>>();
760
761 for record in &cached_records {
762 let absolute = if record.file_path.is_absolute() {
763 record.file_path.clone()
764 } else {
765 snapshot.project_root.join(&record.file_path)
766 };
767 match verify_contribution_file(&absolute, &record.freshness) {
768 ContributionFreshness::Fresh { .. } => {}
769 ContributionFreshness::Stale | ContributionFreshness::Deleted => return Ok(false),
770 }
771 }
772
773 let project_scope = JobScope::for_project(snapshot.project_root.clone());
781 let project_files = scope_files(&snapshot.project_root, &project_scope);
782 let current_by_relative = current_project_files(&snapshot.project_root, &project_files);
783
784 Ok(current_by_relative.len() == cached_relative.len()
785 && current_by_relative
786 .keys()
787 .all(|relative| cached_relative.contains(relative)))
788 }
789
790 #[doc(hidden)]
791 pub fn tier2_run_with_reuse_result(
792 &self,
793 snapshot: InspectSnapshot,
794 category: InspectCategory,
795 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
796 ) -> InspectResult {
797 let job = self.tier2_reuse_job(snapshot, category, callgraph_snapshot);
798 self.tier2_run_with_reuse_job_result(job)
799 }
800
801 fn tier2_run_with_reuse_job_result(&self, job: InspectJob) -> InspectResult {
802 self.tier2_run_with_reuse_job_result_with_options(job, Tier2ReuseOptions::default())
803 }
804
805 fn tier2_run_with_reuse_job_result_catching(
806 &self,
807 job: InspectJob,
808 options: Tier2ReuseOptions,
809 ) -> InspectResult {
810 let started = Instant::now();
811 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
812 self.tier2_run_with_reuse_job_result_with_options(job.clone(), options)
813 })) {
814 Ok(result) => result,
815 Err(_) => InspectResult::failed(
816 &job,
817 "tier2 reuse worker panicked before completion",
818 started.elapsed(),
819 ),
820 }
821 }
822
823 fn tier2_run_with_reuse_job_result_with_options(
824 &self,
825 mut job: InspectJob,
826 options: Tier2ReuseOptions,
827 ) -> InspectResult {
828 let started = Instant::now();
829 panic_tier2_reuse_for_debug(&job);
830 if !job.category.is_active() {
831 let result = InspectResult::failed(
832 &job,
833 format!("inspect category '{}' is disabled in v0.33", job.category),
834 started.elapsed(),
835 );
836 log_tier2_benchmark_category_end(&result);
837 return result;
838 }
839 if !job.category.is_tier2() {
840 let result = InspectResult::failed(
841 &job,
842 format!(
843 "inspect category '{}' is not a Tier 2 category",
844 job.category
845 ),
846 started.elapsed(),
847 );
848 log_tier2_benchmark_category_end(&result);
849 return result;
850 }
851
852 let project_scope = JobScope::for_project(job.project_root.clone());
853 job.scope_files = scope_files(&job.project_root, &project_scope);
854 log_tier2_benchmark_category_start(&job);
855 let cache = match self.cache_for_paths(job.inspect_dir.clone(), job.project_root.clone()) {
856 Ok(cache) => cache,
857 Err(message) => {
858 let result = InspectResult::failed(&job, message, started.elapsed());
859 log_tier2_benchmark_category_end(&result);
860 return result;
861 }
862 };
863 delay_tier2_reuse_for_debug(&job.project_root);
864 if !options.has_force_paths() {
865 if let Ok(Some(success)) = self.tier2_quick_reuse_success(&job, cache.as_ref()) {
866 let result = InspectResult::success(&job, success, started.elapsed());
867 crate::slog_debug!(
868 "perf tier2 category={} reuse=hit ms={}",
869 job.category,
870 started.elapsed().as_millis()
871 );
872 log_tier2_benchmark_category_end(&result);
873 return result;
874 }
875 }
876
877 let result = match self.tier2_run_with_reuse_job(&job, &cache, &options) {
878 Ok(success) => InspectResult::success(&job, success, started.elapsed()),
879 Err(message) => InspectResult::failed(&job, message, started.elapsed()),
880 };
881 crate::slog_info!(
885 "perf tier2 category={} reuse=miss ms={}",
886 job.category,
887 started.elapsed().as_millis()
888 );
889 log_tier2_benchmark_category_end(&result);
890 result
891 }
892
893 fn tier2_reuse_job(
894 &self,
895 snapshot: InspectSnapshot,
896 category: InspectCategory,
897 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
898 ) -> InspectJob {
899 InspectJob {
900 job_id: self.next_job_id.fetch_add(1, Ordering::Relaxed),
901 key: JobKey::for_project_category(category),
902 category,
903 scope_files: Vec::new(),
904 project_root: snapshot.project_root,
905 inspect_dir: snapshot.inspect_dir,
906 config: snapshot.config,
907 symbol_cache: snapshot.symbol_cache,
908 callgraph_snapshot,
909 }
910 }
911
912 fn tier2_quick_reuse_success(
913 &self,
914 job: &InspectJob,
915 cache: &InspectCache,
916 ) -> Result<Option<InspectScanSuccess>, String> {
917 let cached_records = load_contribution_freshness(cache, job.category)?;
918 let current_by_relative = current_project_files(&job.project_root, &job.scope_files);
919 if cached_records.len() != current_by_relative.len() {
920 return Ok(None);
921 }
922 for record in &cached_records {
923 let relative = freshness_record_relative_key(record);
924 let Some(current_file) = current_by_relative.get(&relative) else {
925 return Ok(None);
926 };
927 match cache_freshness::metadata_matches(current_file, &record.freshness) {
928 Ok(true) => {}
929 Ok(false) => return Ok(None),
930 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
931 Err(error) => {
932 return Err(format!(
933 "failed to stat {} for tier2 quick reuse: {error}",
934 current_file.display()
935 ));
936 }
937 }
938 }
939
940 let contribution_set_hash = cache
941 .contribution_set_hash_for_config(job.category, job.config.as_ref())
942 .map_err(|error| error.to_string())?;
943 let Some(aggregate) = cache
944 .load_aggregate_if_hash_matches(job.category, &contribution_set_hash)
945 .map_err(|error| error.to_string())?
946 else {
947 return Ok(None);
948 };
949
950 cache
951 .touch_tier2_last_full_run(job.category)
952 .map_err(|error| error.to_string())?;
953 Ok(Some(InspectScanSuccess {
954 scanned_files: Vec::new(),
955 contributions: Vec::new(),
956 aggregate,
957 }))
958 }
959
960 #[allow(clippy::too_many_lines)]
961 fn tier2_run_with_reuse_job(
962 &self,
963 job: &InspectJob,
964 cache: &InspectCache,
965 options: &Tier2ReuseOptions,
966 ) -> Result<InspectScanSuccess, String> {
967 let mut phases = Tier2PhaseTimings::default();
968 let phase_started = Instant::now();
969 let cached_records = load_contribution_freshness(cache, job.category)?;
970 let current_by_relative = current_project_files(&job.project_root, &job.scope_files);
971 let cached_relative = cached_records
972 .iter()
973 .map(freshness_record_relative_key)
974 .collect::<BTreeSet<_>>();
975 let force_relative = forced_relative_paths(job, &options.force_rescan_paths);
976 #[cfg(debug_assertions)]
977 let cold_cache = cached_relative.is_empty();
978
979 let mut updates = Tier2ContributionUpdates::default();
980 let mut scan_by_relative = BTreeMap::<String, PathBuf>::new();
981 let mut aggregate_job = job.clone();
982
983 for record in cached_records {
984 let relative = freshness_record_relative_key(&record);
985 let relative_path = PathBuf::from(&relative);
986 let Some(current_file) = current_by_relative.get(&relative) else {
987 updates.deletes.push(relative_path);
988 continue;
989 };
990
991 if force_relative.contains(&relative) {
992 updates.deletes.push(relative_path);
993 scan_by_relative.insert(relative, current_file.clone());
994 continue;
995 }
996
997 let absolute = job.project_root.join(&record.file_path);
998 match verify_contribution_file(&absolute, &record.freshness) {
999 ContributionFreshness::Fresh {
1000 metadata_changed,
1001 freshness,
1002 } => {
1003 if metadata_changed {
1004 updates.metadata_updates.push((relative_path, freshness));
1005 }
1006 }
1007 ContributionFreshness::Stale => {
1008 updates.deletes.push(relative_path);
1009 scan_by_relative.insert(relative, current_file.clone());
1010 }
1011 ContributionFreshness::Deleted => {
1012 updates.deletes.push(relative_path);
1013 }
1014 }
1015 }
1016
1017 for (relative, file) in ¤t_by_relative {
1018 if !cached_relative.contains(relative) {
1019 scan_by_relative.insert(relative.clone(), file.clone());
1020 }
1021 }
1022 phases.freshness = phase_started.elapsed();
1023
1024 let mut scan_files = scan_by_relative.into_values().collect::<Vec<_>>();
1025 let force_reparse_files = scan_files.clone();
1026 if !scan_files.is_empty() {
1027 let mut scan_job = job.clone();
1028 scan_job.job_id = self.next_job_id.fetch_add(1, Ordering::Relaxed);
1029 scan_job.scope_files = scan_files.clone();
1030 if scan_job.category == InspectCategory::DeadCode
1031 && scan_job.callgraph_snapshot.is_none()
1032 {
1033 let snapshot_started = Instant::now();
1034 scan_job.callgraph_snapshot =
1035 build_tier2_callgraph_snapshot(&scan_job, options.allow_callgraph_cold_build);
1036 phases.snapshot += snapshot_started.elapsed();
1037 }
1038 aggregate_job.callgraph_snapshot = scan_job.callgraph_snapshot.clone();
1039 #[cfg(debug_assertions)]
1040 if cold_cache {
1041 std::thread::sleep(Duration::from_millis(10));
1042 }
1043 let scan_started = Instant::now();
1044 let oxc_result =
1045 self.oxc_result_for_scan(&scan_job, &scan_job.scope_files, &force_reparse_files)?;
1046 let scan_result = run_tier2_scan(&scan_job, oxc_result.as_ref());
1047 phases.scan += scan_started.elapsed();
1048 phases.scanned_files += scan_files.len();
1049 let scan_success = scan_result.outcome.map_err(|message| {
1050 format!("{} incremental scan failed: {message}", job.category)
1051 })?;
1052 updates.upserts.extend(scan_success.contributions);
1053 }
1054
1055 let has_updates = !updates.upserts.is_empty()
1056 || !updates.deletes.is_empty()
1057 || !updates.metadata_updates.is_empty();
1058 if !has_updates {
1059 if let Some(aggregate) = cache
1060 .get_aggregated_for_config(&job.key, job.config.as_ref())
1061 .map_err(|error| error.to_string())?
1062 {
1063 cache
1064 .touch_tier2_last_full_run(job.category)
1065 .map_err(|error| error.to_string())?;
1066 phases.log(job.category);
1067 return Ok(InspectScanSuccess {
1068 scanned_files: scan_files,
1069 contributions: Vec::new(),
1070 aggregate,
1071 });
1072 }
1073 }
1074
1075 let db_started = Instant::now();
1076 let mut contribution_set_hash = if has_updates {
1077 cache
1078 .apply_contribution_updates_for_config(job.category, updates, job.config.as_ref())
1079 .map_err(|error| error.to_string())?
1080 } else {
1081 cache
1082 .contribution_set_hash_for_config(job.category, job.config.as_ref())
1083 .map_err(|error| error.to_string())?
1084 };
1085 phases.db = db_started.elapsed();
1086
1087 if let Some(aggregate) = cache
1088 .load_aggregate_if_hash_matches(job.category, &contribution_set_hash)
1089 .map_err(|error| error.to_string())?
1090 {
1091 cache
1092 .touch_tier2_last_full_run(job.category)
1093 .map_err(|error| error.to_string())?;
1094 let contributions = load_contributions(cache, job)?;
1095 phases.log(job.category);
1096 return Ok(InspectScanSuccess {
1097 scanned_files: scan_files,
1098 contributions,
1099 aggregate,
1100 });
1101 }
1102
1103 let refresh_dead_code_facts = if job.category == InspectCategory::DeadCode {
1104 dead_code_contributions_need_fact_refresh(cache, job)?
1105 } else {
1106 false
1107 };
1108 let refresh_unused_exports_facts = if job.category == InspectCategory::UnusedExports {
1109 unused_exports_contributions_need_fact_refresh(cache, job)?
1110 } else {
1111 false
1112 };
1113 let refresh_duplicates_facts = if job.category == InspectCategory::Duplicates {
1114 duplicates_contributions_need_fact_refresh(cache, job)?
1115 } else {
1116 false
1117 };
1118 if refresh_dead_code_facts || refresh_unused_exports_facts || refresh_duplicates_facts {
1119 let full_scan_files = current_by_relative.into_values().collect::<Vec<_>>();
1124 if !full_scan_files.is_empty() {
1125 let mut rescan_job = job.clone();
1126 rescan_job.job_id = self.next_job_id.fetch_add(1, Ordering::Relaxed);
1127 rescan_job.scope_files = full_scan_files.clone();
1128 if rescan_job.category == InspectCategory::DeadCode
1129 && rescan_job.callgraph_snapshot.is_none()
1130 {
1131 let snapshot_started = Instant::now();
1132 rescan_job.callgraph_snapshot = build_tier2_callgraph_snapshot(
1133 &rescan_job,
1134 options.allow_callgraph_cold_build,
1135 );
1136 phases.snapshot += snapshot_started.elapsed();
1137 }
1138 let scan_started = Instant::now();
1139 let oxc_result = self.oxc_result_for_scan(
1140 &rescan_job,
1141 &rescan_job.scope_files,
1142 &force_reparse_files,
1143 )?;
1144 let scan_result = run_tier2_scan(&rescan_job, oxc_result.as_ref());
1145 phases.scan += scan_started.elapsed();
1146 phases.scanned_files += full_scan_files.len();
1147 let scan_success = scan_result.outcome.map_err(|message| {
1148 format!(
1149 "{} full rescan after entry-point cache miss failed: {message}",
1150 job.category
1151 )
1152 })?;
1153 let rescan_updates = Tier2ContributionUpdates {
1154 upserts: scan_success.contributions,
1155 ..Tier2ContributionUpdates::default()
1156 };
1157 let db_started = Instant::now();
1158 contribution_set_hash = cache
1159 .apply_contribution_updates_for_config(
1160 job.category,
1161 rescan_updates,
1162 job.config.as_ref(),
1163 )
1164 .map_err(|error| error.to_string())?;
1165 phases.db += db_started.elapsed();
1166 aggregate_job.callgraph_snapshot = rescan_job.callgraph_snapshot.clone();
1167 scan_files = full_scan_files;
1168
1169 if let Some(aggregate) = cache
1170 .load_aggregate_if_hash_matches(job.category, &contribution_set_hash)
1171 .map_err(|error| error.to_string())?
1172 {
1173 cache
1174 .touch_tier2_last_full_run(job.category)
1175 .map_err(|error| error.to_string())?;
1176 let contributions = load_contributions(cache, job)?;
1177 phases.log(job.category);
1178 return Ok(InspectScanSuccess {
1179 scanned_files: scan_files,
1180 contributions,
1181 aggregate,
1182 });
1183 }
1184 }
1185 }
1186
1187 if aggregate_job.category == InspectCategory::DeadCode
1188 && aggregate_job.callgraph_snapshot.is_none()
1189 {
1190 let snapshot_started = Instant::now();
1191 aggregate_job.callgraph_snapshot =
1192 build_tier2_callgraph_snapshot(&aggregate_job, options.allow_callgraph_cold_build);
1193 phases.snapshot += snapshot_started.elapsed();
1194 }
1195 let rollup_started = Instant::now();
1196 let contributions = load_contributions(cache, &aggregate_job)?;
1197 let aggregate = roll_up_tier2_contributions(&aggregate_job, &contributions);
1198 cache
1199 .store_tier2_aggregate(job.key.clone(), &contribution_set_hash, aggregate.clone())
1200 .map_err(|error| error.to_string())?;
1201 phases.rollup = rollup_started.elapsed();
1202 phases.log(job.category);
1203
1204 Ok(InspectScanSuccess {
1205 scanned_files: scan_files,
1206 contributions,
1207 aggregate,
1208 })
1209 }
1210
1211 fn enqueue_with_waiter(
1212 &self,
1213 snapshot: InspectSnapshot,
1214 category: InspectCategory,
1215 caller_scope: JobScope,
1216 key: JobKey,
1217 waiter_tx: WaiterTx,
1218 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
1219 ) -> Result<(), String> {
1220 let mut in_flight = self
1221 .in_flight
1222 .lock()
1223 .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
1224 if let Some(waiters) = in_flight.get_mut(&key) {
1225 waiters.push(Waiter { tx: waiter_tx });
1226 return Ok(());
1227 }
1228
1229 in_flight.insert(key.clone(), vec![Waiter { tx: waiter_tx }]);
1230 drop(in_flight);
1231
1232 if let Err(message) = self.enqueue_new_job(
1233 snapshot,
1234 category,
1235 caller_scope,
1236 key.clone(),
1237 callgraph_snapshot,
1238 ) {
1239 if let Ok(mut in_flight) = self.in_flight.lock() {
1240 in_flight.remove(&key);
1241 }
1242 return Err(message);
1243 }
1244 Ok(())
1245 }
1246
1247 fn enqueue_without_waiter(
1248 &self,
1249 snapshot: InspectSnapshot,
1250 category: InspectCategory,
1251 caller_scope: JobScope,
1252 key: JobKey,
1253 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
1254 ) -> Result<(), String> {
1255 let mut in_flight = self
1256 .in_flight
1257 .lock()
1258 .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
1259 if in_flight.contains_key(&key) {
1260 return Ok(());
1261 }
1262 in_flight.insert(key.clone(), Vec::new());
1263 drop(in_flight);
1264
1265 if let Err(message) = self.enqueue_new_job(
1266 snapshot,
1267 category,
1268 caller_scope,
1269 key.clone(),
1270 callgraph_snapshot,
1271 ) {
1272 if let Ok(mut in_flight) = self.in_flight.lock() {
1273 in_flight.remove(&key);
1274 }
1275 return Err(message);
1276 }
1277 Ok(())
1278 }
1279
1280 fn enqueue_new_job(
1281 &self,
1282 snapshot: InspectSnapshot,
1283 category: InspectCategory,
1284 caller_scope: JobScope,
1285 key: JobKey,
1286 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
1287 ) -> Result<(), String> {
1288 let scan_scope = if category.is_tier2() {
1289 JobScope::for_project(snapshot.project_root.clone())
1290 } else {
1291 caller_scope
1292 };
1293 let scope_files = scope_files(&snapshot.project_root, &scan_scope);
1294 let job = InspectJob {
1295 job_id: self.next_job_id.fetch_add(1, Ordering::Relaxed),
1296 key,
1297 category,
1298 scope_files,
1299 project_root: snapshot.project_root,
1300 inspect_dir: snapshot.inspect_dir,
1301 config: snapshot.config,
1302 symbol_cache: snapshot.symbol_cache,
1303 callgraph_snapshot,
1304 };
1305 self.request_tx
1306 .send(job)
1307 .map_err(|_| "inspect dispatch loop is unavailable".to_string())
1308 }
1309
1310 fn wait_for_outcome(
1311 &self,
1312 key: JobKey,
1313 caller_scope: JobScope,
1314 cache: Arc<InspectCache>,
1315 waiter_rx: Receiver<JobOutcome>,
1316 snapshot: InspectSnapshot,
1317 ) -> JobOutcome {
1318 let timeout = after(self.soft_deadline);
1319 let result_rx = self.result_rx.clone();
1320 loop {
1321 select! {
1322 recv(waiter_rx) -> outcome => {
1323 return match outcome {
1324 Ok(outcome) => filter_outcome_for_scope_with_contributions(
1325 outcome,
1326 &snapshot,
1327 key.category,
1328 cache.as_ref(),
1329 &caller_scope,
1330 ),
1331 Err(_) => self.timeout_outcome(&key, &caller_scope, &cache, &snapshot),
1332 };
1333 }
1334 recv(result_rx) -> result => {
1335 match result {
1336 Ok(result) => self.route_completion(result),
1337 Err(_) => return self.timeout_outcome(&key, &caller_scope, &cache, &snapshot),
1338 }
1339 }
1340 recv(timeout) -> _ => {
1341 return self.timeout_outcome(&key, &caller_scope, &cache, &snapshot);
1342 }
1343 }
1344 }
1345 }
1346
1347 fn timeout_outcome(
1348 &self,
1349 key: &JobKey,
1350 caller_scope: &JobScope,
1351 cache: &InspectCache,
1352 snapshot: &InspectSnapshot,
1353 ) -> JobOutcome {
1354 match cache.get_aggregated_for_config(key, snapshot.config.as_ref()) {
1355 Ok(Some(cached)) => filter_outcome_for_scope_with_contributions(
1356 JobOutcome::Stale {
1357 cached: Some(cached),
1358 in_flight: true,
1359 },
1360 snapshot,
1361 key.category,
1362 cache,
1363 caller_scope,
1364 ),
1365 Ok(None) => JobOutcome::Pending { in_flight: true },
1366 Err(error) => JobOutcome::Failed {
1367 message: error.to_string(),
1368 },
1369 }
1370 }
1371
1372 fn route_completion(&self, result: InspectResult) {
1373 let outcome = self.completion_outcome(result.clone());
1374 let waiters = self
1375 .in_flight
1376 .lock()
1377 .ok()
1378 .and_then(|mut in_flight| in_flight.remove(&result.key))
1379 .unwrap_or_default();
1380 for waiter in waiters {
1381 let _ = waiter.tx.send(outcome.clone());
1382 }
1383 }
1384
1385 fn route_tier2_reuse_completion(&self, result: InspectResult) {
1386 let outcome = match result.outcome.clone() {
1387 Ok(success) => JobOutcome::Fresh {
1388 payload: success.aggregate,
1389 },
1390 Err(message) => JobOutcome::Failed { message },
1391 };
1392 let waiters = self
1393 .in_flight
1394 .lock()
1395 .ok()
1396 .and_then(|mut in_flight| in_flight.remove(&result.key))
1397 .unwrap_or_default();
1398 for waiter in waiters {
1399 let _ = waiter.tx.send(outcome.clone());
1400 }
1401 self.reuse_completions.fetch_add(1, Ordering::SeqCst);
1406 }
1407
1408 pub fn reuse_completion_count(&self) -> u64 {
1412 self.reuse_completions.load(Ordering::SeqCst)
1413 }
1414
1415 fn completion_outcome(&self, result: InspectResult) -> JobOutcome {
1416 let cache =
1417 match self.cache_for_paths(result.inspect_dir.clone(), result.project_root.clone()) {
1418 Ok(cache) => cache,
1419 Err(message) => return JobOutcome::Failed { message },
1420 };
1421
1422 match result.outcome {
1423 Ok(success) => {
1424 let store_result = if result.category.is_tier2() {
1425 cache.store_tier2_result_for_config(
1426 result.key.clone(),
1427 &success.scanned_files,
1428 &success.contributions,
1429 success.aggregate.clone(),
1430 result.config.as_ref(),
1431 )
1432 } else {
1433 cache.store_aggregated(result.key, success.aggregate.clone())
1434 };
1435
1436 match store_result {
1437 Ok(()) => JobOutcome::Fresh {
1438 payload: success.aggregate,
1439 },
1440 Err(error) => JobOutcome::Failed {
1441 message: error.to_string(),
1442 },
1443 }
1444 }
1445 Err(message) => JobOutcome::Failed { message },
1446 }
1447 }
1448}
1449
1450impl Default for InspectManager {
1451 fn default() -> Self {
1452 Self::new()
1453 }
1454}
1455
1456fn validate_tier2_read_category(category: InspectCategory) -> Result<(), JobOutcome> {
1457 if !category.is_active() {
1458 return Err(JobOutcome::Failed {
1459 message: format!("inspect category '{category}' is disabled in v0.33"),
1460 });
1461 }
1462 if !category.is_tier2() {
1463 return Err(JobOutcome::Failed {
1464 message: format!("inspect category '{category}' is not a Tier 2 category"),
1465 });
1466 }
1467 Ok(())
1468}
1469
1470#[derive(Default)]
1477struct Tier2PhaseTimings {
1478 freshness: Duration,
1480 snapshot: Duration,
1482 scan: Duration,
1484 db: Duration,
1486 rollup: Duration,
1488 scanned_files: usize,
1489}
1490
1491impl Tier2PhaseTimings {
1492 fn log(&self, category: InspectCategory) {
1493 let worked = self.freshness + self.scan + self.snapshot + self.rollup + self.db;
1494 if worked < Duration::from_millis(50) {
1495 return;
1496 }
1497 crate::slog_info!(
1498 "perf tier2 phases category={} freshness={}ms snapshot={}ms scan={}ms({} files) db={}ms rollup={}ms",
1499 category,
1500 self.freshness.as_millis(),
1501 self.snapshot.as_millis(),
1502 self.scan.as_millis(),
1503 self.scanned_files,
1504 self.db.as_millis(),
1505 self.rollup.as_millis()
1506 );
1507 }
1508}
1509
1510fn scope_files(project_root: &Path, scope: &JobScope) -> Vec<PathBuf> {
1511 let mut files = crate::callgraph::walk_project_files(project_root)
1512 .filter(|path| scope.contains(path))
1513 .collect::<Vec<_>>();
1514 files.sort();
1515 files
1516}
1517
1518fn forced_relative_paths(job: &InspectJob, paths: &BTreeSet<PathBuf>) -> BTreeSet<String> {
1519 let mut keys = BTreeSet::new();
1520 for path in paths {
1521 let absolute = if path.is_absolute() {
1522 path.clone()
1523 } else {
1524 job.project_root.join(path)
1525 };
1526 keys.insert(relative_cache_key(&job.project_root, &absolute));
1527 if let Ok(canonical) = std::fs::canonicalize(&absolute) {
1528 keys.insert(relative_cache_key(&job.project_root, &canonical));
1529 }
1530 }
1531 keys
1532}
1533
1534fn panic_tier2_reuse_for_debug(job: &InspectJob) {
1535 #[cfg(not(debug_assertions))]
1536 let _ = job;
1537 #[cfg(debug_assertions)]
1538 {
1539 if !env_project_root_matches("AFT_TEST_TIER2_REUSE_PANIC_ROOT", &job.project_root) {
1540 return;
1541 }
1542 let should_panic = std::env::var("AFT_TEST_TIER2_REUSE_PANIC_CATEGORY")
1543 .ok()
1544 .is_some_and(|category| category == job.category.as_str());
1545 if should_panic {
1546 panic!("forced tier2 reuse panic for {}", job.category);
1547 }
1548 }
1549}
1550
1551fn delay_direct_force_followup_deadline_check_for_debug(project_root: &Path) {
1552 #[cfg(not(debug_assertions))]
1553 let _ = project_root;
1554 #[cfg(debug_assertions)]
1555 {
1556 if !env_project_root_matches("AFT_TEST_DIRECT_FORCE_FOLLOWUP_DELAY_ROOT", project_root) {
1557 return;
1558 }
1559 if let Some(delay_ms) = std::env::var("AFT_TEST_DIRECT_FORCE_FOLLOWUP_DELAY_MS")
1560 .ok()
1561 .and_then(|raw| raw.parse::<u64>().ok())
1562 {
1563 std::thread::sleep(Duration::from_millis(delay_ms));
1564 }
1565 }
1566}
1567
1568fn delay_tier2_reuse_for_debug(project_root: &Path) {
1569 #[cfg(not(debug_assertions))]
1570 let _ = project_root;
1571 #[cfg(debug_assertions)]
1572 {
1573 if !env_project_root_matches("AFT_TEST_TIER2_REUSE_DELAY_ROOT", project_root) {
1574 return;
1575 }
1576 if let Some(delay_ms) = std::env::var("AFT_TEST_TIER2_REUSE_DELAY_MS")
1577 .ok()
1578 .and_then(|raw| raw.parse::<u64>().ok())
1579 {
1580 std::thread::sleep(Duration::from_millis(delay_ms));
1581 }
1582 }
1583}
1584
1585#[cfg(debug_assertions)]
1586fn env_project_root_matches(var: &str, project_root: &Path) -> bool {
1587 let Some(raw) = std::env::var_os(var) else {
1588 return true;
1589 };
1590 let expected = PathBuf::from(raw);
1591 let expected = std::fs::canonicalize(&expected).unwrap_or(expected);
1592 let actual = std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
1593 expected == actual
1594}
1595
1596fn current_project_files(project_root: &Path, files: &[PathBuf]) -> BTreeMap<String, PathBuf> {
1597 files
1598 .iter()
1599 .map(|file| (relative_cache_key(project_root, file), file.clone()))
1600 .collect()
1601}
1602
1603fn tier2_benchmark_logging_enabled() -> bool {
1604 std::env::var_os("AFT_SETTLE_BENCH_LOG").is_some()
1605}
1606
1607fn log_tier2_benchmark_category_start(job: &InspectJob) {
1608 if !tier2_benchmark_logging_enabled() {
1609 return;
1610 }
1611 crate::slog_info!(
1612 "settle bench: tier2_category_start category={} job_id={} files={}",
1613 job.category.as_str(),
1614 job.job_id,
1615 job.scope_files.len()
1616 );
1617}
1618
1619fn log_tier2_benchmark_category_end(result: &InspectResult) {
1620 if !tier2_benchmark_logging_enabled() {
1621 return;
1622 }
1623 match &result.outcome {
1624 Ok(success) => {
1625 let count = success
1626 .aggregate
1627 .get("count")
1628 .and_then(serde_json::Value::as_u64)
1629 .unwrap_or(0);
1630 crate::slog_info!(
1631 "settle bench: tier2_category_end category={} job_id={} status=success total_ms={} scanned_files={} contributions={} count={}",
1632 result.category.as_str(),
1633 result.job_id,
1634 result.duration.as_millis(),
1635 success.scanned_files.len(),
1636 success.contributions.len(),
1637 count
1638 );
1639 }
1640 Err(message) => {
1641 crate::slog_info!(
1642 "settle bench: tier2_category_end category={} job_id={} status=failed total_ms={} error={}",
1643 result.category.as_str(),
1644 result.job_id,
1645 result.duration.as_millis(),
1646 message.replace('\n', " ")
1647 );
1648 }
1649 }
1650}
1651
1652fn build_tier2_callgraph_snapshot(
1653 job: &InspectJob,
1654 allow_cold_build: bool,
1655) -> Option<Arc<CallgraphSnapshot>> {
1656 let started = Instant::now();
1657 if !job.config.callgraph_store {
1658 crate::slog_info!(
1659 "tier2 dead_code: callgraph store disabled; reporting callgraph_unavailable"
1660 );
1661 return None;
1662 }
1663
1664 let Some(callgraph_dir) = callgraph_store_dir_from_inspect_dir(&job.inspect_dir) else {
1665 crate::slog_info!(
1666 "tier2 dead_code: inspect_dir has no harness parent ({}); reporting callgraph_unavailable",
1667 job.inspect_dir.display()
1668 );
1669 return None;
1670 };
1671
1672 let store = match if allow_cold_build {
1677 CallGraphStore::open_ready_repairing(callgraph_dir.clone(), job.project_root.clone())
1678 } else {
1679 CallGraphStore::open_ready_no_rebuild(callgraph_dir.clone(), job.project_root.clone())
1680 } {
1681 Ok(Some(store)) => store,
1682 Ok(None) => {
1683 crate::slog_info!(
1684 "tier2 dead_code: callgraph store unavailable at {} (cold/building/not ready); reporting callgraph_unavailable",
1685 callgraph_dir.display()
1686 );
1687 return None;
1688 }
1689 Err(error) => {
1690 crate::slog_warn!(
1691 "tier2 dead_code: failed to open callgraph store at {}: {}; reporting callgraph_unavailable",
1692 callgraph_dir.display(),
1693 error
1694 );
1695 return None;
1696 }
1697 };
1698
1699 let snapshot = match project_dead_code_snapshot(store.sqlite_path()) {
1700 Ok(snapshot) => snapshot,
1701 Err(CallGraphStoreError::Unavailable(message)) => {
1702 crate::slog_info!(
1703 "tier2 dead_code: callgraph store projection unavailable ({}); reporting callgraph_unavailable",
1704 message
1705 );
1706 return None;
1707 }
1708 Err(error) => {
1709 crate::slog_warn!(
1710 "tier2 dead_code: callgraph store projection failed: {}; reporting callgraph_unavailable",
1711 error
1712 );
1713 return None;
1714 }
1715 };
1716
1717 crate::slog_info!(
1718 "perf tier2_callgraph_snapshot: source=callgraph_store files={} exports={} edges={} entry_points={} ms={}",
1719 snapshot.files.len(),
1720 snapshot.exported_symbols.len(),
1721 snapshot.outbound_calls.len(),
1722 snapshot.entry_points.len(),
1723 started.elapsed().as_millis()
1724 );
1725
1726 Some(Arc::new(snapshot))
1727}
1728
1729fn callgraph_store_dir_from_inspect_dir(inspect_dir: &Path) -> Option<PathBuf> {
1730 inspect_dir
1731 .parent()
1732 .map(|harness_dir| harness_dir.join("callgraph"))
1733}
1734
1735#[cfg(test)]
1736fn canonicalize_for_snapshot(path: &Path) -> PathBuf {
1737 std::fs::canonicalize(path).unwrap_or_else(|_| normalize_path(path))
1738}
1739
1740fn load_contribution_freshness(
1741 cache: &InspectCache,
1742 category: InspectCategory,
1743) -> Result<Vec<CachedContributionFreshness>, String> {
1744 cache
1745 .contribution_freshness(category)
1746 .map_err(|error| error.to_string())
1747 .map(|records| {
1748 records
1749 .into_iter()
1750 .map(|(file_path, freshness)| CachedContributionFreshness {
1751 file_path,
1752 freshness,
1753 })
1754 .collect()
1755 })
1756}
1757
1758fn freshness_record_relative_key(record: &CachedContributionFreshness) -> String {
1759 record.file_path.to_string_lossy().to_string()
1760}
1761
1762fn relative_cache_key(project_root: &Path, path: &Path) -> String {
1763 path.strip_prefix(project_root)
1764 .unwrap_or(path)
1765 .to_string_lossy()
1766 .to_string()
1767}
1768
1769fn load_contributions(
1770 cache: &InspectCache,
1771 job: &InspectJob,
1772) -> Result<Vec<FileContribution>, String> {
1773 cache
1774 .load_tier2_contributions(job.category)
1775 .map_err(|error| error.to_string())
1776 .map(|records| {
1777 records
1778 .into_iter()
1779 .map(|record| contribution_from_record(&job.project_root, record))
1780 .collect()
1781 })
1782}
1783
1784fn dead_code_contributions_need_fact_refresh(
1785 cache: &InspectCache,
1786 job: &InspectJob,
1787) -> Result<bool, String> {
1788 let contributions = load_contributions(cache, job)?;
1789 Ok(contributions
1790 .iter()
1791 .any(dead_code_contribution_needs_fact_refresh))
1792}
1793
1794fn dead_code_contribution_needs_fact_refresh(contribution: &FileContribution) -> bool {
1795 let Ok(parsed) =
1796 serde_json::from_value::<DeadCodeRefreshContribution>(contribution.contribution.clone())
1797 else {
1798 return true;
1799 };
1800
1801 if parsed.facts_format_version
1802 != Some(super::scanners::dead_code::DEAD_CODE_FACTS_FORMAT_VERSION)
1803 {
1804 return true;
1805 }
1806
1807 matches!(
1808 parsed.oxc_facts,
1809 Some(facts) if facts.format_version != FACTS_FORMAT_VERSION
1810 )
1811}
1812
1813fn unused_exports_contributions_need_fact_refresh(
1814 cache: &InspectCache,
1815 job: &InspectJob,
1816) -> Result<bool, String> {
1817 let contributions = load_contributions(cache, job)?;
1818 Ok(contributions
1819 .iter()
1820 .any(unused_exports_contribution_needs_fact_refresh))
1821}
1822
1823fn duplicates_contributions_need_fact_refresh(
1828 cache: &InspectCache,
1829 job: &InspectJob,
1830) -> Result<bool, String> {
1831 let contributions = load_contributions(cache, job)?;
1832 Ok(contributions
1833 .iter()
1834 .any(|contribution| contribution.contribution.get("line_count").is_none()))
1835}
1836
1837fn unused_exports_contribution_needs_fact_refresh(contribution: &FileContribution) -> bool {
1838 let top_level_oxc = contribution
1839 .contribution
1840 .get("provenance")
1841 .and_then(Value::as_str)
1842 == Some(OXC_PROVENANCE);
1843 let Ok(parsed) =
1844 serde_json::from_value::<UnusedExportsContribution>(contribution.contribution.clone())
1845 else {
1846 return false;
1847 };
1848 let uses_oxc =
1849 top_level_oxc || parsed.oxc_facts.is_some() || parsed.exports.iter().any(export_uses_oxc);
1850 if !uses_oxc {
1851 return false;
1852 }
1853
1854 !matches!(
1855 parsed.oxc_facts,
1856 Some(facts) if facts.format_version == FACTS_FORMAT_VERSION
1857 )
1858}
1859
1860fn contribution_from_record(
1861 project_root: &Path,
1862 record: super::cache::ContributionRecord,
1863) -> FileContribution {
1864 FileContribution::new(
1865 record.category,
1866 project_root.join(record.file_path),
1867 record.freshness,
1868 record.contribution,
1869 )
1870 .with_type_ref_names(record.type_ref_names)
1871}
1872
1873fn run_tier2_scan(job: &InspectJob, oxc_result: Option<&OxcEngineResult>) -> InspectResult {
1874 use super::scanners;
1875
1876 match job.category {
1877 InspectCategory::DeadCode => {
1878 scanners::dead_code::run_dead_code_scan_with_oxc(job, oxc_result)
1879 }
1880 InspectCategory::UnusedExports => {
1881 scanners::unused_exports::run_unused_exports_scan_with_oxc(job, oxc_result)
1882 }
1883 InspectCategory::Duplicates => scanners::duplicates::run_duplicates_scan(job),
1884 InspectCategory::Cycles => scanners::cycles::run_cycles_scan_with_oxc(job, oxc_result),
1885 other => InspectResult::failed(
1886 job,
1887 format!("inspect category '{other}' is not an active Tier 2 scanner"),
1888 Duration::from_secs(0),
1889 ),
1890 }
1891}
1892
1893fn roll_up_tier2_contributions(job: &InspectJob, contributions: &[FileContribution]) -> Value {
1894 roll_up_tier2_contributions_with_limit(job, contributions, Some(MAX_DRILL_DOWN_ITEMS))
1895}
1896
1897fn roll_up_tier2_contributions_with_limit(
1898 job: &InspectJob,
1899 contributions: &[FileContribution],
1900 drill_down_limit: Option<usize>,
1901) -> Value {
1902 match job.category {
1903 InspectCategory::DeadCode => {
1904 roll_up_dead_code_contributions(job, contributions, drill_down_limit)
1905 }
1906 InspectCategory::UnusedExports => {
1907 roll_up_unused_exports_contributions(job, contributions, drill_down_limit)
1908 }
1909 InspectCategory::Duplicates => {
1910 roll_up_duplicate_contributions(job, contributions, drill_down_limit)
1911 }
1912 InspectCategory::Cycles => {
1913 roll_up_cycle_contributions(job, contributions, drill_down_limit)
1914 }
1915 _ => json!({
1916 "count": 0,
1917 "items": [],
1918 "scanned_files": contributions.len(),
1919 }),
1920 }
1921}
1922
1923fn scoped_tier2_payload_from_contributions(
1924 snapshot: &InspectSnapshot,
1925 category: InspectCategory,
1926 cache: &InspectCache,
1927 project_payload: Value,
1928 scope: &JobScope,
1929) -> Result<Value, String> {
1930 if scope.is_project_wide() {
1931 return Ok(project_payload);
1932 }
1933
1934 let project_scope = JobScope::for_project(snapshot.project_root.clone());
1935 let rollup_job = scoped_tier2_rollup_job(snapshot, category, &project_scope);
1936 let contributions = load_contributions(cache, &rollup_job)?;
1937 let full_payload = roll_up_tier2_contributions_with_limit(&rollup_job, &contributions, None);
1938 let scoped_payload = filter_payload_for_scope(full_payload, scope);
1939 Ok(cap_payload_drill_down(scoped_payload, MAX_DRILL_DOWN_ITEMS))
1940}
1941
1942fn scoped_tier2_rollup_job(
1943 snapshot: &InspectSnapshot,
1944 category: InspectCategory,
1945 scope: &JobScope,
1946) -> InspectJob {
1947 let mut job = InspectJob {
1948 job_id: 0,
1949 key: JobKey::for_project_category(category),
1950 category,
1951 scope_files: scope_files(&snapshot.project_root, scope),
1952 project_root: snapshot.project_root.clone(),
1953 inspect_dir: snapshot.inspect_dir.clone(),
1954 config: Arc::clone(&snapshot.config),
1955 symbol_cache: Arc::clone(&snapshot.symbol_cache),
1956 callgraph_snapshot: None,
1957 };
1958
1959 if category == InspectCategory::DeadCode {
1960 job.callgraph_snapshot = build_tier2_callgraph_snapshot(&job, false);
1965 }
1966
1967 job
1968}
1969
1970fn roll_up_dead_code_contributions(
1971 job: &InspectJob,
1972 contributions: &[FileContribution],
1973 drill_down_limit: Option<usize>,
1974) -> Value {
1975 let Some(snapshot) = job.callgraph_snapshot.as_deref() else {
1976 return super::scanners::dead_code::callgraph_unavailable_aggregate(job.scope_files.len());
1977 };
1978
1979 let public_api_files = super::scanners::dead_code::collect_public_api_files(&job.project_root);
1980 let roles = super::entry_points::resolve_project_roles(&job.project_root);
1981 super::scanners::dead_code::aggregate_dead_code_contributions_with_snapshot(
1982 &job.project_root,
1983 snapshot,
1984 contributions,
1985 &public_api_files,
1986 &roles,
1987 drill_down_limit,
1988 )
1989}
1990
1991fn roll_up_unused_exports_contributions(
1992 job: &InspectJob,
1993 contributions: &[FileContribution],
1994 drill_down_limit: Option<usize>,
1995) -> Value {
1996 let parsed = contributions
1997 .iter()
1998 .filter_map(|contribution| {
1999 serde_json::from_value::<UnusedExportsContribution>(contribution.contribution.clone())
2000 .ok()
2001 })
2002 .collect::<Vec<_>>();
2003
2004 if parsed.iter().any(|scan| scan.oxc_facts.is_some()) {
2005 return roll_up_unused_exports_oxc_contributions(job, &parsed, drill_down_limit);
2006 }
2007
2008 let (public_api_files, package_warnings) = unused_public_api_entries(&job.project_root);
2009 let mut imported_by: BTreeMap<(String, String), BTreeSet<String>> = BTreeMap::new();
2010 let mut uncertain_by: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
2011 for scan in &parsed {
2012 for import in &scan.imports {
2013 let Some(resolved_file) = &import.resolved_file else {
2014 continue;
2015 };
2016 for name in &import.named {
2017 if name == "*" {
2018 uncertain_by
2019 .entry(resolved_file.clone())
2020 .or_default()
2021 .insert(scan.file.clone());
2022 } else {
2023 imported_by
2024 .entry((resolved_file.clone(), name.clone()))
2025 .or_default()
2026 .insert(scan.file.clone());
2027 }
2028 }
2029 }
2030 }
2031
2032 let mut count = 0usize;
2033 let mut items = Vec::new();
2034 let test_only_count = 0usize;
2035 let test_only_items = Vec::new();
2036 let mut uncertain_count = 0usize;
2037 let mut uncertain_items = Vec::new();
2038 for scan in &parsed {
2039 if public_api_files.contains(&scan.file) {
2040 continue;
2041 }
2042 if super::job::is_test_support_file(&scan.file) {
2045 continue;
2046 }
2047
2048 for export in &scan.exports {
2049 if export_uses_oxc(export) {
2050 match export.verdict.unwrap_or(LivenessVerdict::Unused) {
2051 LivenessVerdict::Used => continue,
2052 LivenessVerdict::Uncertain => {
2053 uncertain_count += 1;
2054 if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
2055 uncertain_items.push(json!({
2056 "file": scan.file,
2057 "symbol": export.symbol,
2058 "kind": export.kind,
2059 "line": export.line,
2060 "reason": export.reason.as_deref().unwrap_or("oxc_uncertain"),
2061 "provenance": export.provenance.as_deref().unwrap_or(OXC_PROVENANCE),
2062 }));
2063 }
2064 continue;
2065 }
2066 LivenessVerdict::Unused => {}
2067 }
2068 } else {
2069 let imported = imported_by
2070 .get(&(scan.file.clone(), export.symbol.clone()))
2071 .map(|files| !files.is_empty())
2072 .unwrap_or(false);
2073 let uncertain = uncertain_by
2074 .get(&scan.file)
2075 .map(|files| !files.is_empty())
2076 .unwrap_or(false);
2077
2078 if imported {
2079 continue;
2080 }
2081 if uncertain {
2082 uncertain_count += 1;
2083 if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
2084 uncertain_items.push(json!({
2085 "file": scan.file,
2086 "symbol": export.symbol,
2087 "kind": export.kind,
2088 "line": export.line,
2089 "reason": "wildcard_import",
2090 }));
2091 }
2092 continue;
2093 }
2094 }
2095
2096 count += 1;
2097 let mut item = json!({
2099 "file": scan.file,
2100 "symbol": export.symbol,
2101 "kind": export.kind,
2102 "line": export.line,
2103 });
2104 if let Some(provenance) = &export.provenance {
2105 item["provenance"] = json!(provenance);
2106 }
2107 items.push(item);
2108 }
2109 }
2110
2111 let roles = super::entry_points::resolve_project_roles(&job.project_root);
2112 let items = super::entry_points::rank_and_truncate_items(items, &roles, drill_down_limit);
2113 let top = super::entry_points::top_preview_symbols(&items);
2114 let test_only_items =
2115 super::entry_points::rank_and_truncate_items(test_only_items, &roles, drill_down_limit);
2116 let test_only_top = test_only_items
2117 .iter()
2118 .take(super::entry_points::TOP_PREVIEW_ITEMS)
2119 .cloned()
2120 .collect::<Vec<_>>();
2121
2122 let (parse_errors, skipped_files) = unused_exports_honesty_fields(&parsed);
2123 let mut aggregate = json!({
2124 "count": count,
2125 "items": items,
2126 "top": top,
2127 "test_only_count": test_only_count,
2128 "test_only_items": test_only_items,
2129 "test_only_top": test_only_top,
2130 "drill_down_capped": drill_down_limit.is_some_and(|limit| count > limit),
2131 "test_only_drill_down_capped": drill_down_limit.is_some_and(|limit| test_only_count > limit),
2132 "scanned_files": parsed.len(),
2133 "languages_skipped": skipped_languages(&job.scope_files, LanguageSkipMode::UnusedExports),
2134 "uncertain_count": uncertain_count,
2135 "uncertain_items": uncertain_items,
2136 "complete": parse_errors.is_empty() && skipped_files.is_empty(),
2137 });
2138 if !parse_errors.is_empty() {
2139 aggregate["parse_errors"] = Value::Array(parse_errors);
2140 }
2141 if !skipped_files.is_empty() {
2142 aggregate["skipped_files"] = Value::Array(skipped_files);
2143 }
2144 if !package_warnings.is_empty() {
2145 aggregate["note"] = Value::String(package_warnings.join("; "));
2146 }
2147 aggregate
2148}
2149
2150fn roll_up_unused_exports_oxc_contributions(
2151 job: &InspectJob,
2152 parsed: &[UnusedExportsContribution],
2153 drill_down_limit: Option<usize>,
2154) -> Value {
2155 let (public_api_files, package_warnings) = unused_public_api_entries(&job.project_root);
2156 let facts = parsed
2157 .iter()
2158 .filter_map(|scan| {
2159 let oxc_facts = scan.oxc_facts.as_ref()?;
2160 let path = job.project_root.join(&scan.file);
2161 Some(FileFacts {
2162 file_id: FileId(0),
2163 path: normalize_input_path(&job.project_root, &path),
2164 content_hash: oxc_facts.content_hash.clone(),
2165 exports: oxc_facts.exports.clone(),
2166 imports: oxc_facts.imports.clone(),
2167 re_exports: oxc_facts.re_exports.clone(),
2168 dynamic_imports: oxc_facts.dynamic_imports.clone(),
2169 same_file_value_references: oxc_facts.same_file_value_references.clone(),
2170 used_import_bindings: oxc_facts.used_import_bindings.clone(),
2171 type_referenced_import_bindings: oxc_facts.type_referenced_import_bindings.clone(),
2172 value_referenced_import_bindings: oxc_facts
2173 .value_referenced_import_bindings
2174 .clone(),
2175 parse_error: oxc_facts.parse_error.clone(),
2176 })
2177 })
2178 .collect::<Vec<_>>();
2179 let entry_point_set = super::entry_points::resolve_entry_points(&job.project_root);
2180 let oxc_result = analyze_file_facts(
2181 &job.project_root,
2182 facts,
2183 AnalyzeOptions {
2184 entry_points: Vec::new(),
2185 public_api_files: entry_point_set.public_api_files(),
2186 executable_root_exports: entry_point_set.executable_root_exports(),
2187 force_reparse_files: Vec::new(),
2188 entry_reachability: false,
2189 },
2190 Vec::new(),
2191 );
2192 let roles = super::entry_points::resolve_project_roles(&job.project_root);
2193
2194 let mut count = 0usize;
2195 let mut items = Vec::new();
2196 let mut test_only_count = 0usize;
2197 let mut test_only_items = Vec::new();
2198 let mut uncertain_count = 0usize;
2199 let mut uncertain_items = Vec::new();
2200 for file in &oxc_result.files {
2201 if public_api_files.contains(&file.relative_file)
2202 || super::job::is_test_support_file(&file.relative_file)
2203 {
2204 continue;
2205 }
2206
2207 for export in &file.exports {
2208 match export.verdict {
2209 LivenessVerdict::Used => {
2210 if !is_test_file(&file.relative_file)
2211 && !export.test_only_reference_files.is_empty()
2212 {
2213 test_only_count += 1;
2214 let mut item = json!({
2215 "file": file.relative_file,
2216 "symbol": export.symbol,
2217 "kind": export.kind,
2218 "line": export.line,
2219 "provenance": export.provenance,
2220 "used_by": export.test_only_reference_files,
2221 });
2222 add_oxc_reexport_contexts(&mut item, &export.also_reexported);
2223 test_only_items.push(item);
2224 }
2225 }
2226 LivenessVerdict::Uncertain => {
2227 uncertain_count += 1;
2228 if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
2229 let mut item = json!({
2230 "file": file.relative_file,
2231 "symbol": export.symbol,
2232 "kind": export.kind,
2233 "line": export.line,
2234 "reason": export.reason,
2235 "provenance": export.provenance,
2236 });
2237 add_oxc_reexport_contexts(&mut item, &export.also_reexported);
2238 uncertain_items.push(item);
2239 }
2240 }
2241 LivenessVerdict::Unused => {
2242 if !is_test_file(&file.relative_file)
2243 && !export.test_only_reference_files.is_empty()
2244 {
2245 test_only_count += 1;
2246 let mut item = json!({
2247 "file": file.relative_file,
2248 "symbol": export.symbol,
2249 "kind": export.kind,
2250 "line": export.line,
2251 "provenance": export.provenance,
2252 "used_by": export.test_only_reference_files,
2253 });
2254 add_oxc_reexport_contexts(&mut item, &export.also_reexported);
2255 test_only_items.push(item);
2256 continue;
2257 }
2258 if export.has_references {
2259 continue;
2260 }
2261 count += 1;
2262 let mut item = json!({
2263 "file": file.relative_file,
2264 "symbol": export.symbol,
2265 "kind": export.kind,
2266 "line": export.line,
2267 "provenance": export.provenance,
2268 });
2269 add_oxc_reexport_contexts(&mut item, &export.also_reexported);
2270 items.push(item);
2271 }
2272 }
2273 }
2274 }
2275
2276 let items = super::entry_points::rank_and_truncate_items(items, &roles, drill_down_limit);
2277 let top = super::entry_points::top_preview_symbols(&items);
2278 let test_only_items =
2279 super::entry_points::rank_and_truncate_items(test_only_items, &roles, drill_down_limit);
2280 let test_only_top = test_only_items
2281 .iter()
2282 .take(super::entry_points::TOP_PREVIEW_ITEMS)
2283 .cloned()
2284 .collect::<Vec<_>>();
2285 let (mut parse_errors, skipped_files) = unused_exports_honesty_fields(parsed);
2286 for scan in parsed {
2287 if let Some(oxc_facts) = &scan.oxc_facts {
2288 if oxc_facts.format_version != FACTS_FORMAT_VERSION {
2289 parse_errors.push(json!({
2290 "file": scan.file,
2291 "message": format!(
2292 "unsupported oxc facts format {}; expected {}",
2293 oxc_facts.format_version, FACTS_FORMAT_VERSION
2294 ),
2295 }));
2296 }
2297 }
2298 }
2299
2300 let mut aggregate = json!({
2301 "count": count,
2302 "items": items,
2303 "top": top,
2304 "test_only_count": test_only_count,
2305 "test_only_items": test_only_items,
2306 "test_only_top": test_only_top,
2307 "drill_down_capped": drill_down_limit.is_some_and(|limit| count > limit),
2308 "test_only_drill_down_capped": drill_down_limit.is_some_and(|limit| test_only_count > limit),
2309 "scanned_files": parsed.len(),
2310 "languages_skipped": skipped_languages(&job.scope_files, LanguageSkipMode::UnusedExports),
2311 "uncertain_count": uncertain_count,
2312 "uncertain_items": uncertain_items,
2313 "complete": parse_errors.is_empty() && skipped_files.is_empty(),
2314 });
2315 if !parse_errors.is_empty() {
2316 aggregate["parse_errors"] = Value::Array(parse_errors);
2317 }
2318 if !skipped_files.is_empty() {
2319 aggregate["skipped_files"] = Value::Array(skipped_files);
2320 }
2321 if !package_warnings.is_empty() {
2322 aggregate["note"] = Value::String(package_warnings.join("; "));
2323 }
2324 aggregate
2325}
2326
2327fn add_oxc_reexport_contexts(
2328 item: &mut Value,
2329 contexts: &[crate::inspect::oxc_engine::OxcReExportContext],
2330) {
2331 if !contexts.is_empty() {
2332 item["also_reexported"] = json!(contexts);
2333 }
2334}
2335
2336fn unused_exports_honesty_fields(parsed: &[UnusedExportsContribution]) -> (Vec<Value>, Vec<Value>) {
2337 let mut parse_error_keys = BTreeSet::new();
2338 let mut parse_errors = Vec::new();
2339 let mut skipped_file_keys = BTreeSet::new();
2340 let mut skipped_files = Vec::new();
2341 for contribution in parsed {
2342 for value in &contribution.parse_errors {
2343 let key = value.to_string();
2344 if parse_error_keys.insert(key) {
2345 parse_errors.push(value.clone());
2346 }
2347 }
2348 for value in &contribution.skipped_files {
2349 let key = value.to_string();
2350 if skipped_file_keys.insert(key) {
2351 skipped_files.push(value.clone());
2352 }
2353 }
2354 }
2355 (parse_errors, skipped_files)
2356}
2357
2358fn roll_up_duplicate_contributions(
2359 job: &InspectJob,
2360 contributions: &[FileContribution],
2361 drill_down_limit: Option<usize>,
2362) -> Value {
2363 super::scanners::duplicates::aggregate_duplicate_contributions_with_limit(
2364 contributions,
2365 skipped_languages(&job.scope_files, LanguageSkipMode::Duplicates),
2366 drill_down_limit,
2367 &job.config.inspect.duplicates.expected_mirrors,
2368 )
2369}
2370
2371fn roll_up_cycle_contributions(
2372 job: &InspectJob,
2373 contributions: &[FileContribution],
2374 drill_down_limit: Option<usize>,
2375) -> Value {
2376 super::scanners::cycles::aggregate_cycle_contributions_with_limit(
2377 &job.project_root,
2378 contributions,
2379 skipped_languages(&job.scope_files, LanguageSkipMode::Cycles),
2380 drill_down_limit,
2381 )
2382}
2383
2384fn cap_payload_drill_down(mut payload: Value, limit: usize) -> Value {
2385 let mut capped = false;
2386 if let Some(items) = payload.get_mut("items").and_then(Value::as_array_mut) {
2387 capped |= items.len() > limit;
2388 items.truncate(limit);
2389 }
2390 if let Some(groups) = payload.get_mut("groups").and_then(Value::as_array_mut) {
2391 capped |= groups.len() > limit;
2392 groups.truncate(limit);
2393 }
2394 if let Some(object) = payload.as_object_mut() {
2395 object.insert("drill_down_capped".to_string(), json!(capped));
2396 }
2397 payload
2398}
2399
2400const MAX_DRILL_DOWN_ITEMS: usize = 100;
2401
2402#[derive(Debug, Clone, Deserialize)]
2403struct ExportContribution {
2404 symbol: String,
2405 kind: String,
2406 line: u32,
2407 #[serde(default)]
2408 verdict: Option<LivenessVerdict>,
2409 #[serde(default)]
2410 reason: Option<String>,
2411 #[serde(default)]
2412 provenance: Option<String>,
2413}
2414
2415fn export_uses_oxc(export: &ExportContribution) -> bool {
2416 export.verdict.is_some() || export.provenance.as_deref() == Some(OXC_PROVENANCE)
2417}
2418
2419#[derive(Debug, Clone, Deserialize)]
2420struct DeadCodeRefreshContribution {
2421 #[serde(default)]
2422 facts_format_version: Option<u32>,
2423 #[serde(default)]
2424 oxc_facts: Option<OxcFactsContribution>,
2425}
2426
2427#[derive(Debug, Clone, Deserialize)]
2428struct UnusedExportsContribution {
2429 file: String,
2430 exports: Vec<ExportContribution>,
2431 #[serde(default)]
2432 imports: Vec<ImportContribution>,
2433 #[serde(default)]
2434 oxc_facts: Option<OxcFactsContribution>,
2435 #[serde(default)]
2436 parse_errors: Vec<Value>,
2437 #[serde(default)]
2438 skipped_files: Vec<Value>,
2439}
2440
2441#[derive(Debug, Clone, Deserialize)]
2442struct ImportContribution {
2443 resolved_file: Option<String>,
2444 named: Vec<String>,
2445}
2446
2447#[derive(Debug, Clone, Deserialize)]
2448struct OxcFactsContribution {
2449 format_version: u32,
2450 content_hash: String,
2451 exports: Vec<ExportFact>,
2452 imports: Vec<ImportFact>,
2453 re_exports: Vec<ReExportFact>,
2454 dynamic_imports: Vec<DynamicImportFact>,
2455 same_file_value_references: BTreeSet<String>,
2456 used_import_bindings: BTreeSet<String>,
2457 type_referenced_import_bindings: BTreeSet<String>,
2458 value_referenced_import_bindings: BTreeSet<String>,
2459 #[serde(default)]
2460 parse_error: Option<String>,
2461}
2462
2463#[derive(Debug, Clone, Copy)]
2464enum LanguageSkipMode {
2465 Duplicates,
2466 Cycles,
2467 UnusedExports,
2468}
2469
2470fn category_uses_oxc(category: InspectCategory) -> bool {
2471 matches!(
2472 category,
2473 InspectCategory::DeadCode | InspectCategory::UnusedExports | InspectCategory::Cycles
2474 )
2475}
2476
2477fn skipped_languages(files: &[PathBuf], mode: LanguageSkipMode) -> Vec<String> {
2478 files
2479 .iter()
2480 .filter_map(|file| skipped_language(file, mode))
2481 .collect::<BTreeSet<_>>()
2482 .into_iter()
2483 .collect()
2484}
2485
2486fn skipped_language(file: &Path, mode: LanguageSkipMode) -> Option<String> {
2487 let Some(language) = crate::parser::detect_language(file) else {
2488 return match mode {
2489 LanguageSkipMode::Duplicates => Some("unknown".to_string()),
2490 LanguageSkipMode::Cycles => Some("unknown".to_string()),
2491 LanguageSkipMode::UnusedExports => None,
2492 };
2493 };
2494
2495 let skipped = match mode {
2496 LanguageSkipMode::Duplicates => !duplicates_supports_language(language),
2497 LanguageSkipMode::Cycles => !is_js_ts_language(language),
2498 LanguageSkipMode::UnusedExports => !is_js_ts_language(language),
2499 };
2500 skipped.then(|| language_name(language).to_string())
2501}
2502
2503fn duplicates_supports_language(language: crate::parser::LangId) -> bool {
2504 !matches!(
2505 language,
2506 crate::parser::LangId::Bash
2507 | crate::parser::LangId::Html
2508 | crate::parser::LangId::Json
2509 | crate::parser::LangId::Scala
2510 | crate::parser::LangId::Solidity
2511 | crate::parser::LangId::Scss
2512 | crate::parser::LangId::Vue
2513 | crate::parser::LangId::Markdown
2514 | crate::parser::LangId::Java
2515 | crate::parser::LangId::Ruby
2516 | crate::parser::LangId::Kotlin
2517 | crate::parser::LangId::Swift
2518 | crate::parser::LangId::Php
2519 | crate::parser::LangId::Lua
2520 | crate::parser::LangId::Perl
2521 | crate::parser::LangId::Pascal
2522 | crate::parser::LangId::R
2523 | crate::parser::LangId::ObjC
2524 )
2525}
2526
2527fn is_js_ts_language(language: crate::parser::LangId) -> bool {
2528 matches!(
2529 language,
2530 crate::parser::LangId::TypeScript
2531 | crate::parser::LangId::Tsx
2532 | crate::parser::LangId::JavaScript
2533 )
2534}
2535
2536fn language_name(language: crate::parser::LangId) -> &'static str {
2537 match language {
2538 crate::parser::LangId::TypeScript => "typescript",
2539 crate::parser::LangId::Tsx => "tsx",
2540 crate::parser::LangId::JavaScript => "javascript",
2541 crate::parser::LangId::Python => "python",
2542 crate::parser::LangId::Rust => "rust",
2543 crate::parser::LangId::Go => "go",
2544 crate::parser::LangId::C => "c",
2545 crate::parser::LangId::Cpp => "cpp",
2546 crate::parser::LangId::Zig => "zig",
2547 crate::parser::LangId::CSharp => "csharp",
2548 crate::parser::LangId::Bash => "bash",
2549 crate::parser::LangId::Html => "html",
2550 crate::parser::LangId::Markdown => "markdown",
2551 crate::parser::LangId::Yaml => "yaml",
2552 crate::parser::LangId::Solidity => "solidity",
2553 crate::parser::LangId::Scss => "scss",
2554 crate::parser::LangId::Vue => "vue",
2555 crate::parser::LangId::Json => "json",
2556 crate::parser::LangId::Scala => "scala",
2557 crate::parser::LangId::Java => "java",
2558 crate::parser::LangId::Ruby => "ruby",
2559 crate::parser::LangId::Kotlin => "kotlin",
2560 crate::parser::LangId::Swift => "swift",
2561 crate::parser::LangId::Php => "php",
2562 crate::parser::LangId::Lua => "lua",
2563 crate::parser::LangId::Perl => "perl",
2564 crate::parser::LangId::Pascal => "pascal",
2565 crate::parser::LangId::R => "r",
2566 crate::parser::LangId::ObjC => "objc",
2567 }
2568}
2569
2570fn unused_public_api_entries(project_root: &Path) -> (BTreeSet<String>, Vec<String>) {
2571 let entry_points = super::entry_points::resolve_entry_points(project_root);
2572 (
2573 entry_points.public_api_files_relative(project_root),
2574 entry_points.warnings().to_vec(),
2575 )
2576}
2577
2578fn filter_outcome_for_scope_with_contributions(
2579 outcome: JobOutcome,
2580 snapshot: &InspectSnapshot,
2581 category: InspectCategory,
2582 cache: &InspectCache,
2583 scope: &JobScope,
2584) -> JobOutcome {
2585 if !category.is_tier2() || scope.is_project_wide() {
2586 return filter_outcome_for_scope(outcome, scope);
2587 }
2588
2589 match outcome {
2590 JobOutcome::Fresh { payload } => {
2591 match scoped_tier2_payload_from_contributions(snapshot, category, cache, payload, scope)
2592 {
2593 Ok(payload) => JobOutcome::Fresh { payload },
2594 Err(message) => JobOutcome::Failed { message },
2595 }
2596 }
2597 JobOutcome::Stale { cached, in_flight } => match cached {
2598 Some(payload) => {
2599 match scoped_tier2_payload_from_contributions(
2600 snapshot, category, cache, payload, scope,
2601 ) {
2602 Ok(payload) => JobOutcome::Stale {
2603 cached: Some(payload),
2604 in_flight,
2605 },
2606 Err(message) => JobOutcome::Failed { message },
2607 }
2608 }
2609 None => JobOutcome::Stale {
2610 cached: None,
2611 in_flight,
2612 },
2613 },
2614 JobOutcome::Pending { in_flight } => JobOutcome::Pending { in_flight },
2615 JobOutcome::Failed { message } => JobOutcome::Failed { message },
2616 }
2617}
2618
2619fn filter_outcome_for_scope(outcome: JobOutcome, scope: &JobScope) -> JobOutcome {
2620 match outcome {
2621 JobOutcome::Fresh { payload } => JobOutcome::Fresh {
2622 payload: filter_payload_for_scope(payload, scope),
2623 },
2624 JobOutcome::Stale { cached, in_flight } => JobOutcome::Stale {
2625 cached: cached.map(|payload| filter_payload_for_scope(payload, scope)),
2626 in_flight,
2627 },
2628 JobOutcome::Pending { in_flight } => JobOutcome::Pending { in_flight },
2629 JobOutcome::Failed { message } => JobOutcome::Failed { message },
2630 }
2631}
2632
2633fn filter_payload_for_scope(mut payload: serde_json::Value, scope: &JobScope) -> serde_json::Value {
2634 if scope.is_project_wide() {
2635 return payload;
2636 }
2637
2638 if let Some(items) = payload
2642 .get_mut("items")
2643 .and_then(|value| value.as_array_mut())
2644 {
2645 let count = filter_values_for_scope(items, scope);
2646 let largest_cycle = items
2647 .iter()
2648 .filter_map(|item| item.get("files").and_then(Value::as_array).map(Vec::len))
2649 .max();
2650 if let Some(object) = payload.as_object_mut() {
2651 object.insert("count".to_string(), serde_json::json!(count));
2652 if object.contains_key("largest") {
2653 object.insert(
2654 "largest".to_string(),
2655 serde_json::json!(largest_cycle.unwrap_or(0)),
2656 );
2657 }
2658 if object.contains_key("total_groups") {
2659 object.insert("total_groups".to_string(), serde_json::json!(count));
2660 }
2661 if object.contains_key("groups_count") {
2662 object.insert("groups_count".to_string(), serde_json::json!(count));
2663 }
2664 }
2665 }
2666
2667 if let Some(groups) = payload
2668 .get_mut("groups")
2669 .and_then(|value| value.as_array_mut())
2670 {
2671 let count = filter_values_for_scope(groups, scope);
2672 if let Some(object) = payload.as_object_mut() {
2673 object.insert("count".to_string(), serde_json::json!(count));
2674 object.insert("total_groups".to_string(), serde_json::json!(count));
2675 if object.contains_key("groups_count") {
2676 object.insert("groups_count".to_string(), serde_json::json!(count));
2677 }
2678 }
2679 }
2680
2681 if let Some(object) = payload.as_object_mut() {
2687 if object.contains_key("top") {
2688 if let Some(top) = recompute_scoped_top_preview(object) {
2689 object.insert("top".to_string(), top);
2690 } else if let Some(top) = object.get_mut("top").and_then(Value::as_array_mut) {
2691 filter_values_for_scope(top, scope);
2692 }
2693 }
2694 if object.contains_key("duplicated_lines") {
2695 recompute_duplicate_payload_stats(object);
2696 }
2697 object.remove("by_language");
2698 }
2699
2700 payload
2701}
2702
2703fn recompute_duplicate_payload_stats(object: &mut serde_json::Map<String, Value>) {
2704 let values = object
2705 .get("items")
2706 .or_else(|| object.get("groups"))
2707 .and_then(Value::as_array)
2708 .cloned()
2709 .unwrap_or_default();
2710 let (duplicated_lines, duplicated_file_count) = duplicate_line_stats_from_values(&values);
2711 let total_analyzed_lines = object
2712 .get("total_analyzed_lines")
2713 .and_then(Value::as_u64)
2714 .unwrap_or(0);
2715 let duplicated_percent = if total_analyzed_lines == 0 {
2716 0.0
2717 } else {
2718 (duplicated_lines as f64 * 100.0) / total_analyzed_lines as f64
2719 };
2720 object.insert("duplicated_lines".to_string(), json!(duplicated_lines));
2721 object.insert(
2722 "duplicated_file_count".to_string(),
2723 json!(duplicated_file_count),
2724 );
2725 object.insert("duplicated_percent".to_string(), json!(duplicated_percent));
2726}
2727
2728fn duplicate_line_stats_from_values(values: &[Value]) -> (u64, usize) {
2729 let mut by_file = BTreeMap::<String, Vec<(u64, u64)>>::new();
2730 for value in values {
2731 let Some(files) = value.get("files").and_then(Value::as_array) else {
2732 continue;
2733 };
2734 for occurrence in files.iter().filter_map(Value::as_str) {
2735 let Some((file, start, end)) = parse_duplicate_occurrence(occurrence) else {
2736 continue;
2737 };
2738 by_file
2739 .entry(file.to_string())
2740 .or_default()
2741 .push((start, end));
2742 }
2743 }
2744 let file_count = by_file.len();
2745 let duplicated_lines = by_file
2746 .values_mut()
2747 .map(|intervals| merged_duplicate_interval_lines(intervals))
2748 .sum();
2749 (duplicated_lines, file_count)
2750}
2751
2752fn merged_duplicate_interval_lines(intervals: &mut [(u64, u64)]) -> u64 {
2753 if intervals.is_empty() {
2754 return 0;
2755 }
2756 intervals.sort_by(|left, right| left.0.cmp(&right.0).then(left.1.cmp(&right.1)));
2757 let (mut current_start, mut current_end) = intervals[0];
2758 let mut total = 0;
2759 for &(start, end) in &intervals[1..] {
2760 if start <= current_end.saturating_add(1) {
2761 current_end = current_end.max(end);
2762 } else {
2763 total += current_end.saturating_sub(current_start).saturating_add(1);
2764 current_start = start;
2765 current_end = end;
2766 }
2767 }
2768 total + current_end.saturating_sub(current_start).saturating_add(1)
2769}
2770
2771fn recompute_scoped_top_preview(
2772 object: &serde_json::Map<String, Value>,
2773) -> Option<serde_json::Value> {
2774 let values = object
2775 .get("items")
2776 .or_else(|| object.get("groups"))
2777 .and_then(Value::as_array)?;
2778 Some(Value::Array(
2779 values
2780 .iter()
2781 .take(super::entry_points::TOP_PREVIEW_ITEMS)
2782 .map(top_preview_value)
2783 .collect(),
2784 ))
2785}
2786
2787fn top_preview_value(value: &Value) -> Value {
2788 if let Some(files) = value.get("files").and_then(Value::as_array) {
2789 let mut object = serde_json::Map::new();
2790 object.insert("files".to_string(), Value::Array(files.clone()));
2791 if let Some(cost) = value.get("cost").cloned() {
2792 object.insert("cost".to_string(), cost);
2793 }
2794 return Value::Object(object);
2795 }
2796
2797 json!({
2798 "file": value.get("file").and_then(Value::as_str).unwrap_or(""),
2799 "symbol": value.get("symbol").and_then(Value::as_str).unwrap_or(""),
2800 })
2801}
2802
2803fn filter_values_for_scope(values: &mut Vec<serde_json::Value>, scope: &JobScope) -> usize {
2804 values.retain_mut(|value| prune_value_for_scope(value, scope));
2805 values.len()
2806}
2807
2808fn prune_value_for_scope(value: &mut serde_json::Value, scope: &JobScope) -> bool {
2809 if let Some(file) = value.get("file").and_then(|file| file.as_str()) {
2810 return scope.contains_display_path(file);
2811 }
2812
2813 let first_scoped_occurrence = if let Some(files) = value
2814 .get_mut("files")
2815 .and_then(|files| files.as_array_mut())
2816 {
2817 files.retain(|file| {
2818 file.as_str()
2819 .is_some_and(|file| scope.contains_display_path(display_file_from_occurrence(file)))
2820 });
2821 if files.len() < 2 {
2822 return false;
2823 }
2824 files.first().and_then(Value::as_str).map(str::to_string)
2825 } else {
2826 None
2827 };
2828
2829 if let Some(occurrence) = first_scoped_occurrence {
2830 update_duplicate_group_sample(value, &occurrence);
2831 }
2832
2833 true
2834}
2835
2836fn update_duplicate_group_sample(value: &mut serde_json::Value, occurrence: &str) {
2837 let Some((file, start_line, end_line)) = parse_duplicate_occurrence(occurrence) else {
2838 return;
2839 };
2840 let Some(object) = value.as_object_mut() else {
2841 return;
2842 };
2843
2844 if object.contains_key("sample_file") {
2845 object.insert("sample_file".to_string(), json!(file));
2846 }
2847 if object.contains_key("sample_start_line") {
2848 object.insert("sample_start_line".to_string(), json!(start_line));
2849 }
2850 if object.contains_key("sample_end_line") {
2851 object.insert("sample_end_line".to_string(), json!(end_line));
2852 }
2853}
2854
2855fn parse_duplicate_occurrence(value: &str) -> Option<(&str, u64, u64)> {
2856 let (file, range) = value.rsplit_once(':')?;
2857 let (start, end) = range.split_once('-')?;
2858 if !start.chars().all(|char| char.is_ascii_digit())
2859 || !end.chars().all(|char| char.is_ascii_digit())
2860 {
2861 return None;
2862 }
2863
2864 Some((file, start.parse().ok()?, end.parse().ok()?))
2865}
2866
2867fn display_file_from_occurrence(value: &str) -> &str {
2868 let Some((file, range)) = value.rsplit_once(':') else {
2869 return value;
2870 };
2871 let Some((start, end)) = range.split_once('-') else {
2872 return value;
2873 };
2874 if start.chars().all(|char| char.is_ascii_digit())
2875 && end.chars().all(|char| char.is_ascii_digit())
2876 {
2877 file
2878 } else {
2879 value
2880 }
2881}
2882
2883#[cfg(test)]
2884mod guard_tests {
2885 use super::*;
2886
2887 fn write_ts_project(file_count: usize) -> tempfile::TempDir {
2888 let dir = tempfile::tempdir().expect("tempdir");
2889 let root = dir.path();
2890 for i in 0..file_count {
2891 std::fs::write(
2892 root.join(format!("mod{i}.ts")),
2893 format!("export function f{i}() {{ return {i}; }}\n"),
2894 )
2895 .expect("write fixture");
2896 }
2897 dir
2898 }
2899
2900 #[test]
2901 fn scoped_filter_recomputes_top_preview_from_scoped_items() {
2902 let project_root = PathBuf::from("/project");
2903 let scope = JobScope::from_roots(project_root.clone(), vec![project_root.join("src/in")]);
2904 let payload = json!({
2905 "count": 4,
2906 "items": [
2907 { "file": "src/out/a.ts", "symbol": "outside" },
2908 { "file": "src/in/b.ts", "symbol": "inside_b" },
2909 { "file": "src/in/c.ts", "symbol": "inside_c" }
2910 ],
2911 "top": [
2912 { "file": "src/out/a.ts", "symbol": "outside" },
2913 { "file": "src/out/z.ts", "symbol": "outside_z" }
2914 ],
2915 "by_language": { "typescript": 4 }
2916 });
2917
2918 let filtered = filter_payload_for_scope(payload, &scope);
2919
2920 assert_eq!(filtered["count"], json!(2));
2921 assert_eq!(
2922 filtered["top"],
2923 json!([
2924 { "file": "src/in/b.ts", "symbol": "inside_b" },
2925 { "file": "src/in/c.ts", "symbol": "inside_c" }
2926 ])
2927 );
2928 assert!(filtered["top"]
2929 .as_array()
2930 .unwrap()
2931 .iter()
2932 .all(|item| item["file"]
2933 .as_str()
2934 .is_some_and(|file| file.starts_with("src/in/"))));
2935 }
2936
2937 #[test]
2938 fn cache_for_paths_rebinds_same_project_key_to_current_root() {
2939 let dir = tempfile::tempdir().expect("tempdir");
2940 let source = dir.path().join("source");
2941 std::fs::create_dir_all(&source).expect("create source repo");
2942 std::fs::write(
2943 source.join("package.json"),
2944 r#"{"name":"inspect-cache-fixture","version":"1.0.0"}"#,
2945 )
2946 .expect("write source manifest");
2947 std::fs::write(source.join("index.ts"), "export const source = 1;\n")
2948 .expect("write source file");
2949 assert!(std::process::Command::new("git")
2950 .current_dir(&source)
2951 .arg("init")
2952 .status()
2953 .expect("git init source repo")
2954 .success());
2955 assert!(std::process::Command::new("git")
2956 .current_dir(&source)
2957 .args(["add", "."])
2958 .status()
2959 .expect("git add source repo")
2960 .success());
2961 assert!(std::process::Command::new("git")
2962 .current_dir(&source)
2963 .args([
2964 "-c",
2965 "user.name=AFT Tests",
2966 "-c",
2967 "user.email=aft-tests@example.com",
2968 "commit",
2969 "-m",
2970 "initial",
2971 ])
2972 .status()
2973 .expect("git commit source repo")
2974 .success());
2975
2976 let clone = dir.path().join("clone");
2977 assert!(std::process::Command::new("git")
2978 .args(["clone", "--quiet"])
2979 .arg(&source)
2980 .arg(&clone)
2981 .status()
2982 .expect("git clone source repo")
2983 .success());
2984 std::fs::write(
2985 clone.join("package.json"),
2986 r#"{"name":"inspect-cache-fixture","version":"2.0.0"}"#,
2987 )
2988 .expect("write clone manifest edit");
2989 assert_eq!(
2990 crate::search_index::artifact_cache_key(&source),
2991 crate::search_index::artifact_cache_key(&clone),
2992 "clones with the same root commit should share the sqlite project key"
2993 );
2994
2995 let source = std::fs::canonicalize(source).expect("canonical source root");
2996 let clone = std::fs::canonicalize(clone).expect("canonical clone root");
2997 let manager = InspectManager::new();
2998 let inspect_dir = dir.path().join("inspect");
2999 let key = JobKey::for_project_category(InspectCategory::DeadCode);
3000 let source_cache = manager
3001 .cache_for_paths(inspect_dir.clone(), source.clone())
3002 .expect("open source cache");
3003 let source_hash = source_cache
3004 .contribution_set_hash(InspectCategory::DeadCode)
3005 .expect("source contribution hash");
3006 source_cache
3007 .store_tier2_aggregate(
3008 key.clone(),
3009 &source_hash,
3010 serde_json::json!({ "count": 7, "items": [] }),
3011 )
3012 .expect("store source aggregate");
3013 assert_eq!(
3014 source_cache
3015 .get_aggregated(&key)
3016 .expect("read source aggregate")
3017 .and_then(|payload| payload.get("count").and_then(Value::as_u64)),
3018 Some(7)
3019 );
3020
3021 let clone_cache = manager
3022 .cache_for_paths(inspect_dir, clone.clone())
3023 .expect("open clone cache");
3024 assert_eq!(clone_cache.project_root(), clone.as_path());
3025 assert!(
3026 clone_cache
3027 .get_aggregated(&key)
3028 .expect("read clone aggregate")
3029 .is_none(),
3030 "same-key clone with a different manifest must not reuse the source root's cached count"
3031 );
3032 }
3033
3034 fn snapshot_job(root: &Path, inspect_dir: &Path, callgraph_store: bool) -> InspectJob {
3035 use crate::config::Config;
3036 use crate::parser::SymbolCache;
3037 use std::sync::RwLock;
3038
3039 InspectJob {
3040 job_id: 1,
3041 key: JobKey::for_project_category(InspectCategory::DeadCode),
3042 category: InspectCategory::DeadCode,
3043 scope_files: Vec::new(),
3044 project_root: root.to_path_buf(),
3045 inspect_dir: inspect_dir.to_path_buf(),
3046 config: Arc::new(Config {
3047 project_root: Some(root.to_path_buf()),
3048 callgraph_store,
3049 ..Config::default()
3050 }),
3051 symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
3052 callgraph_snapshot: None,
3053 }
3054 }
3055
3056 #[test]
3057 fn callgraph_snapshot_reports_unavailable_when_store_disabled() {
3058 let dir = write_ts_project(3);
3059 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
3060 let inspect_dir = root.join(".aft-cache").join("inspect");
3061
3062 let snapshot =
3063 build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, false), false);
3064
3065 assert!(
3066 snapshot.is_none(),
3067 "dead_code must not rebuild the legacy graph when the store is disabled"
3068 );
3069 }
3070
3071 #[test]
3072 fn callgraph_snapshot_reports_unavailable_when_store_not_ready() {
3073 let dir = write_ts_project(3);
3074 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
3075 let inspect_dir = root.join(".aft-cache").join("inspect");
3076 let callgraph_dir = callgraph_store_dir_from_inspect_dir(&inspect_dir).expect("store dir");
3077 let _store = CallGraphStore::open(callgraph_dir, root.clone()).expect("open empty store");
3078
3079 let snapshot =
3080 build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true), false);
3081
3082 assert!(
3083 snapshot.is_none(),
3084 "a cold/mid-build store must surface callgraph_unavailable instead of rebuilding inline"
3085 );
3086 }
3087
3088 #[test]
3089 fn direct_callgraph_snapshot_does_not_cold_rebuild_when_store_needs_rebuild() {
3090 let dir = write_ts_project(3);
3091 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
3092 let inspect_dir = root.join(".aft-cache").join("inspect");
3093 let callgraph_dir = callgraph_store_dir_from_inspect_dir(&inspect_dir).expect("store dir");
3094 let store = CallGraphStore::open(callgraph_dir.clone(), root.clone()).expect("open store");
3095 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
3096 store.cold_build(&files).expect("cold build store");
3097 let sqlite_path = store.sqlite_path().to_path_buf();
3098 drop(store);
3099
3100 let still_existing_previous_root = root.with_file_name("previous-root-still-exists");
3101 std::fs::create_dir_all(&still_existing_previous_root).expect("create previous root");
3102 let conn = rusqlite::Connection::open(sqlite_path).expect("open store sqlite");
3103 conn.execute(
3104 "UPDATE backend_file_state SET workspace_root = ?1",
3105 rusqlite::params![still_existing_previous_root.display().to_string()],
3106 )
3107 .expect("force root repair rebuild state");
3108
3109 let snapshot =
3110 build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true), false);
3111
3112 assert!(
3113 snapshot.is_none(),
3114 "direct inspect must report callgraph_unavailable instead of cold-rebuilding a root-repair store"
3115 );
3116 }
3117
3118 #[test]
3119 fn callgraph_snapshot_reads_ready_callgraph_store() {
3120 let dir = write_ts_project(3);
3121 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
3122 let inspect_dir = root.join(".aft-cache").join("inspect");
3123 let callgraph_dir = callgraph_store_dir_from_inspect_dir(&inspect_dir).expect("store dir");
3124 let store = CallGraphStore::open(callgraph_dir, root.clone()).expect("open store");
3125 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
3126 store.cold_build(&files).expect("cold build store");
3127
3128 let snapshot =
3129 build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true), false)
3130 .expect("ready store snapshot");
3131
3132 assert_eq!(snapshot.files.len(), 3);
3133 assert_eq!(snapshot.exported_symbols.len(), 3);
3134 }
3135
3136 #[test]
3140 fn scoped_filter_drops_project_wide_by_language() {
3141 let scope = JobScope::from_roots("/proj", vec![PathBuf::from("/proj/src/a")]);
3142 assert!(
3143 !scope.is_project_wide(),
3144 "scope must be non-project for test"
3145 );
3146 let payload = serde_json::json!({
3147 "count": 99,
3148 "by_language": { "rust": 214, "typescript": 143 },
3149 "items": [
3150 { "file": "/proj/src/a/x.rs", "symbol": "live" },
3151 { "file": "/proj/src/other/y.rs", "symbol": "out" },
3152 ],
3153 });
3154 let filtered = filter_payload_for_scope(payload, &scope);
3155 assert!(
3156 filtered.get("by_language").is_none(),
3157 "scoped payload must drop project-wide by_language: {filtered}"
3158 );
3159 assert_eq!(filtered.get("count").and_then(|v| v.as_u64()), Some(1));
3161 }
3162 #[cfg(debug_assertions)]
3163 #[test]
3164 fn tier2_read_cached_freshness_does_not_hash_unchanged_contributions() {
3165 let (_dir, manager, snapshot, scope, _files) = duplicate_cache_fixture();
3166
3167 crate::cache_freshness::reset_hash_file_if_small_count_for_debug();
3168 crate::cache_freshness::reset_verify_file_strict_count_for_debug();
3169 assert_fresh(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
3170
3171 assert_eq!(
3172 crate::cache_freshness::verify_file_strict_count_for_debug(),
3173 0,
3174 "dispatch-thread inspect freshness must not use strict verification"
3175 );
3176 assert_eq!(
3177 crate::cache_freshness::hash_file_if_small_count_for_debug(),
3178 0,
3179 "unchanged contribution files must stay on the stat-only fast path"
3180 );
3181 }
3182
3183 #[cfg(debug_assertions)]
3184 #[test]
3185 fn tier2_read_cached_freshness_returns_byte_identical_cold_scan_aggregate() {
3186 let (_dir, manager, snapshot, scope, _files) = duplicate_uncached_fixture();
3187 let cold_payload = fresh_payload(manager.tier2_run_with_reuse(
3188 snapshot.clone(),
3189 InspectCategory::Duplicates,
3190 scope.clone(),
3191 None,
3192 ));
3193
3194 crate::cache_freshness::reset_hash_file_if_small_count_for_debug();
3195 crate::cache_freshness::reset_verify_file_strict_count_for_debug();
3196 let warm_payload =
3197 fresh_payload(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
3198
3199 let cold_bytes = serde_json::to_vec(&cold_payload).expect("serialize cold aggregate");
3200 let warm_bytes = serde_json::to_vec(&warm_payload).expect("serialize warm aggregate");
3201 assert_eq!(
3202 warm_bytes, cold_bytes,
3203 "warm unchanged read must return the byte-identical aggregate as the cold scan"
3204 );
3205 assert_eq!(
3206 crate::cache_freshness::verify_file_strict_count_for_debug(),
3207 0,
3208 "dispatch-thread warm read must not use strict verification"
3209 );
3210 assert_eq!(
3211 crate::cache_freshness::hash_file_if_small_count_for_debug(),
3212 0,
3213 "warm unchanged read must not content-hash cached contribution files"
3214 );
3215 }
3216
3217 #[test]
3218 fn tier2_read_cached_freshness_detects_changed_added_and_deleted_files() {
3219 let (_dir, manager, snapshot, scope, _files) = duplicate_cache_fixture();
3220 write_fixture_file(
3221 &snapshot.project_root,
3222 "src/foo.ts",
3223 "export const foo = 101;\nexport const changed = true;\n",
3224 3_000_000_001,
3225 );
3226 assert_stale(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
3227
3228 let (_dir, manager, snapshot, scope, _files) = duplicate_cache_fixture();
3229 write_fixture_file(
3230 &snapshot.project_root,
3231 "src/added.ts",
3232 "export const added = 3;\n",
3233 3_000_000_002,
3234 );
3235 assert_stale(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
3236
3237 let (_dir, manager, snapshot, scope, files) = duplicate_cache_fixture();
3238 std::fs::remove_file(&files[0]).expect("delete cached contribution file");
3239 assert_stale(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
3240 }
3241
3242 fn duplicate_cache_fixture() -> (
3243 tempfile::TempDir,
3244 InspectManager,
3245 InspectSnapshot,
3246 JobScope,
3247 Vec<PathBuf>,
3248 ) {
3249 let (dir, manager, snapshot, scope, files) = duplicate_uncached_fixture();
3250 store_duplicate_cache(&manager, &snapshot, &files);
3251 (dir, manager, snapshot, scope, files)
3252 }
3253
3254 fn duplicate_uncached_fixture() -> (
3255 tempfile::TempDir,
3256 InspectManager,
3257 InspectSnapshot,
3258 JobScope,
3259 Vec<PathBuf>,
3260 ) {
3261 use crate::config::Config;
3262 use crate::parser::SymbolCache;
3263 use std::sync::RwLock;
3264
3265 let dir = tempfile::tempdir().expect("tempdir");
3266 let root = std::fs::canonicalize(dir.path()).expect("canonical fixture root");
3267 let files = vec![
3268 write_fixture_file(
3269 &root,
3270 "src/foo.ts",
3271 "export const fixture = () => 1;
3272export const shared = 1;
3273",
3274 3_000_000_000,
3275 ),
3276 write_fixture_file(
3277 &root,
3278 "src/bar.ts",
3279 "export const fixture = () => 1;
3280export const shared = 1;
3281",
3282 3_000_000_000,
3283 ),
3284 ];
3285 let inspect_dir = root.join(".aft-cache").join("inspect");
3286 let snapshot = InspectSnapshot::new(
3287 root.clone(),
3288 inspect_dir,
3289 Arc::new(Config {
3290 project_root: Some(root.clone()),
3291 ..Config::default()
3292 }),
3293 Arc::new(RwLock::new(SymbolCache::new())),
3294 );
3295 let scope = JobScope::for_project(root);
3296 let manager = InspectManager::new();
3297 (dir, manager, snapshot, scope, files)
3298 }
3299
3300 fn write_fixture_file(root: &Path, relative: &str, content: &str, mtime_secs: i64) -> PathBuf {
3301 let path = root.join(relative);
3302 if let Some(parent) = path.parent() {
3303 std::fs::create_dir_all(parent).expect("create fixture parent");
3304 }
3305 std::fs::write(&path, content).expect("write fixture file");
3306 filetime::set_file_mtime(&path, filetime::FileTime::from_unix_time(mtime_secs, 0))
3307 .expect("set fixture mtime");
3308 path
3309 }
3310
3311 fn store_duplicate_cache(
3312 manager: &InspectManager,
3313 snapshot: &InspectSnapshot,
3314 files: &[PathBuf],
3315 ) {
3316 let cache = manager
3317 .cache_for_snapshot(snapshot)
3318 .expect("open inspect cache");
3319 let contributions = files
3320 .iter()
3321 .map(|file| {
3322 let freshness = crate::cache_freshness::collect(file).expect("collect freshness");
3323 FileContribution::new(
3324 InspectCategory::Duplicates,
3325 file.clone(),
3326 freshness,
3327 serde_json::json!({
3328 "file": relative_cache_key(&snapshot.project_root, file),
3329 "fragments": [],
3330 }),
3331 )
3332 })
3333 .collect::<Vec<_>>();
3334 cache
3335 .store_tier2_result(
3336 JobKey::for_project_category(InspectCategory::Duplicates),
3337 files,
3338 &contributions,
3339 serde_json::json!({
3340 "count": 0,
3341 "groups": [],
3342 "scanned_files": files.len(),
3343 "total_groups": 0,
3344 }),
3345 )
3346 .expect("store tier2 cache fixture");
3347 }
3348
3349 fn assert_fresh(outcome: JobOutcome) {
3350 let _ = fresh_payload(outcome);
3351 }
3352
3353 fn fresh_payload(outcome: JobOutcome) -> Value {
3354 match outcome {
3355 JobOutcome::Fresh { payload } => payload,
3356 other => panic!("expected fresh cached Tier-2 outcome, got {other:?}"),
3357 }
3358 }
3359
3360 fn assert_stale(outcome: JobOutcome) {
3361 match outcome {
3362 JobOutcome::Stale { .. } => {}
3363 other => panic!("expected stale cached Tier-2 outcome, got {other:?}"),
3364 }
3365 }
3366}
3367
3368#[cfg(test)]
3369mod dead_code_projection_tests {
3370 use super::*;
3371 use crate::callgraph::walk_project_files;
3372 use crate::callgraph_store::{project_dead_code_snapshot, CallGraphStore};
3373 use crate::config::Config;
3374 use crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR;
3375 use crate::inspect::scanners::DEFAULT_EXPORT_MARKER_KIND;
3376 use crate::parser::SymbolCache;
3377 use filetime::FileTime;
3378 use std::sync::atomic::{AtomicI64, Ordering as AtomicOrdering};
3379 use std::sync::RwLock;
3380
3381 static NEXT_MTIME: AtomicI64 = AtomicI64::new(1_900_000_000);
3382
3383 #[test]
3384 fn scoped_dead_code_rollup_uses_ready_callgraph_and_degrades_without_it() {
3385 let dir = tempfile::tempdir().expect("tempdir");
3386 write_projection_fixture(dir.path());
3387 let root = canonical_root(dir.path());
3388 let inspect_dir = root.join(".aft-cache").join("inspect");
3389 let callgraph_dir = callgraph_store_dir_from_inspect_dir(&inspect_dir).expect("store dir");
3390 let store = CallGraphStore::open(callgraph_dir.clone(), root.clone()).expect("open store");
3391 let files = project_files(&root);
3392 store.cold_build(&files).expect("cold build store");
3393 let projected = project_dead_code_snapshot(store.sqlite_path()).expect("project snapshot");
3394 drop(store);
3395
3396 let config = Arc::new(Config {
3397 project_root: Some(root.clone()),
3398 callgraph_store: true,
3399 ..Config::default()
3400 });
3401 let symbol_cache = Arc::new(RwLock::new(SymbolCache::new()));
3402 let scan_job = InspectJob {
3403 job_id: 87,
3404 key: JobKey::for_project_category(InspectCategory::DeadCode),
3405 category: InspectCategory::DeadCode,
3406 scope_files: files.clone(),
3407 project_root: root.clone(),
3408 inspect_dir: inspect_dir.clone(),
3409 config: Arc::clone(&config),
3410 symbol_cache: Arc::clone(&symbol_cache),
3411 callgraph_snapshot: Some(Arc::new(projected)),
3412 };
3413 let success = crate::inspect::scanners::dead_code::run_dead_code_scan(&scan_job)
3414 .outcome
3415 .expect("dead_code scan succeeds");
3416 let cache = InspectCache::open(inspect_dir.clone(), root.clone()).expect("open cache");
3417 cache
3418 .store_tier2_result(
3419 scan_job.key.clone(),
3420 &success.scanned_files,
3421 &success.contributions,
3422 success.aggregate.clone(),
3423 )
3424 .expect("store tier2 result");
3425
3426 let snapshot = InspectSnapshot::new(root.clone(), inspect_dir, config, symbol_cache);
3427 let scope = JobScope::from_roots(root.clone(), vec![root.join("src/live.ts")]);
3428 assert!(
3429 !scope.is_project_wide(),
3430 "live.ts file scope must be scoped"
3431 );
3432
3433 let ready_payload = scoped_tier2_payload_from_contributions(
3434 &snapshot,
3435 InspectCategory::DeadCode,
3436 &cache,
3437 success.aggregate.clone(),
3438 &scope,
3439 )
3440 .expect("ready scoped payload");
3441 assert_eq!(
3442 ready_payload
3443 .get("callgraph_available")
3444 .and_then(Value::as_bool),
3445 Some(true),
3446 "ready store should produce a callgraph-backed scoped rollup: {ready_payload:#}"
3447 );
3448 assert_live_item(&ready_payload, "src/live.ts", "knownLive");
3449
3450 std::fs::remove_dir_all(&callgraph_dir).expect("remove ready callgraph store");
3451 let unavailable_payload = scoped_tier2_payload_from_contributions(
3452 &snapshot,
3453 InspectCategory::DeadCode,
3454 &cache,
3455 success.aggregate,
3456 &scope,
3457 )
3458 .expect("unavailable scoped payload");
3459 assert_eq!(
3460 unavailable_payload
3461 .get("callgraph_available")
3462 .and_then(Value::as_bool),
3463 Some(false),
3464 "missing store must report callgraph_unavailable instead of fabricating an empty graph: {unavailable_payload:#}"
3465 );
3466 assert_live_item(&unavailable_payload, "src/live.ts", "knownLive");
3467 }
3468 #[derive(Debug, PartialEq, Eq)]
3469 struct ComparableSnapshot {
3470 files: BTreeSet<PathBuf>,
3471 exported_symbols: BTreeSet<(PathBuf, String, String, u32)>,
3472 outbound_calls: BTreeSet<(PathBuf, String, String, u32)>,
3473 entry_points: BTreeSet<PathBuf>,
3474 entry_point_symbols: BTreeMap<PathBuf, BTreeSet<String>>,
3475 }
3476
3477 #[test]
3478 fn dead_code_projection_contains_expected_fixture_surface() {
3479 let dir = tempfile::tempdir().expect("tempdir");
3480 write_projection_fixture(dir.path());
3481 let root = canonical_root(dir.path());
3482 let projected = store_projected_snapshot(&root, ".store-dead-code-surface");
3483
3484 assert_projection_fixture_coverage(&root, &projected);
3485 }
3486
3487 #[test]
3488 fn dead_code_projection_incremental_scenario_matrix_matches_cold_rebuild() {
3489 run_projection_scenario("rename", setup_projection_rename, edit_projection_rename);
3490 run_projection_scenario("delete", setup_projection_delete, edit_projection_delete);
3491 run_projection_scenario(
3492 "barrel delete",
3493 setup_projection_barrel,
3494 edit_projection_barrel_delete,
3495 );
3496 run_projection_scenario(
3497 "dispatch edit",
3498 setup_projection_dispatch,
3499 edit_projection_dispatch,
3500 );
3501 run_projection_scenario(
3502 "body-only edit",
3503 setup_projection_body_only,
3504 edit_projection_body_only,
3505 );
3506 }
3507
3508 #[test]
3509 fn dead_code_projection_dead_code_scan_reports_expected_verdicts() {
3510 let dir = tempfile::tempdir().expect("tempdir");
3511 write_projection_fixture(dir.path());
3512 let root = canonical_root(dir.path());
3513 let files = project_files(&root);
3514 let projected = store_projected_snapshot(&root, ".store-dead-code-e2e");
3515
3516 let projected_aggregate = dead_code_aggregate(&root, files, projected);
3517 assert_dead_item(&projected_aggregate, "src/dead.ts", "knownDead");
3518 assert_live_item(&projected_aggregate, "src/live.ts", "knownLive");
3519 assert_live_item(&projected_aggregate, "src/render.ts", "render");
3520 assert_live_item(&projected_aggregate, "src/other_render.ts", "render");
3521 }
3522
3523 #[test]
3524 fn dead_code_projection_rust_attribute_entry_points_are_live() {
3525 let dir = tempfile::tempdir().expect("tempdir");
3526 write_rust_attribute_entry_fixture(dir.path());
3527 let root = canonical_root(dir.path());
3528 let files = project_files(&root);
3529 let store = CallGraphStore::open(root.join(".store-tauri-commands"), root.clone())
3530 .expect("open store");
3531 store.cold_build(&files).expect("cold build store");
3532 let command = store
3533 .node_for(Path::new("src/commands.rs"), "get_primers")
3534 .expect("command node");
3535 assert!(
3536 command.is_entry_point,
3537 "attribute-rooted commands must be labeled as callgraph entry points"
3538 );
3539 let private_command = store
3540 .node_for(Path::new("src/commands.rs"), "private_command")
3541 .expect("private command node");
3542 assert!(
3543 private_command.is_entry_point,
3544 "private attribute-rooted commands must also be callgraph entry points"
3545 );
3546
3547 let projected = project_dead_code_snapshot(store.sqlite_path()).expect("project snapshot");
3548 let aggregate = dead_code_aggregate(&root, files, projected);
3549 assert_live_item(&aggregate, "src/commands.rs", "get_primers");
3550 assert_live_item(&aggregate, "src/db.rs", "helper");
3551 assert_live_item(&aggregate, "src/db.rs", "private_helper");
3552 assert_live_item(&aggregate, "src/imported.rs", "imported_command");
3553 assert_live_item(&aggregate, "src/db.rs", "imported_helper");
3554 assert_dead_item(&aggregate, "src/commands.rs", "planted_dead");
3555 assert_dead_item(&aggregate, "src/unimported.rs", "false_command");
3556 assert_dead_item(&aggregate, "src/db.rs", "false_helper");
3557 }
3558
3559 #[test]
3560 fn dead_code_projection_rust_attribute_roots_are_cold_deterministic() {
3561 let dir = tempfile::tempdir().expect("tempdir");
3562 write_rust_attribute_entry_fixture(dir.path());
3563 let root = canonical_root(dir.path());
3564 let first = store_projected_snapshot(&root, ".store-tauri-cold-a");
3565 let second = store_projected_snapshot(&root, ".store-tauri-cold-b");
3566
3567 assert_snapshot_parts_eq("rust attribute roots cold", &first, &second);
3568 }
3569
3570 #[test]
3571 fn dead_code_projection_rust_attribute_roots_survive_unrelated_incremental_edit() {
3572 let dir = tempfile::tempdir().expect("tempdir");
3573 write_rust_attribute_entry_fixture(dir.path());
3574 let root = canonical_root(dir.path());
3575 let files_before = project_files(&root);
3576 let incremental_store =
3577 CallGraphStore::open(root.join(".store-tauri-incremental"), root.clone())
3578 .expect("open incremental store");
3579 incremental_store
3580 .cold_build(&files_before)
3581 .expect("initial cold build");
3582
3583 write_file(
3584 &root.join("src/unrelated.rs"),
3585 r#"// unrelated edit should not refresh command attribute facts
3586pub fn unrelated() -> u32 { 2 }
3587"#,
3588 );
3589 let stats = incremental_store
3590 .refresh_files(&[root.join("src/unrelated.rs")])
3591 .expect("refresh unrelated file");
3592 assert_eq!(stats.refreshed_own_files, 1);
3593 assert_eq!(stats.changed_files, vec!["src/unrelated.rs".to_string()]);
3594 assert!(
3595 !stats
3596 .surface_changed
3597 .iter()
3598 .any(|file| file == "src/commands.rs"),
3599 "unrelated edit must not refresh the command module: {stats:#?}"
3600 );
3601 let incremental = project_dead_code_snapshot(incremental_store.sqlite_path())
3602 .expect("project incremental snapshot");
3603
3604 let cold_store = CallGraphStore::open(root.join(".store-tauri-cold"), root.clone())
3605 .expect("open cold store");
3606 cold_store
3607 .cold_build(&project_files(&root))
3608 .expect("cold rebuild");
3609 let cold = project_dead_code_snapshot(cold_store.sqlite_path()).expect("project cold");
3610 assert_snapshot_parts_eq("rust attribute roots unrelated edit", &cold, &incremental);
3611
3612 let aggregate = dead_code_aggregate(&root, project_files(&root), incremental);
3613 assert_live_item(&aggregate, "src/commands.rs", "get_primers");
3614 assert_live_item(&aggregate, "src/db.rs", "helper");
3615 assert_live_item(&aggregate, "src/db.rs", "private_helper");
3616 assert_dead_item(&aggregate, "src/commands.rs", "planted_dead");
3617 }
3618
3619 fn assert_projection_fixture_coverage(root: &Path, snapshot: &CallgraphSnapshot) {
3620 let comparable = comparable_snapshot(snapshot);
3621 assert!(
3622 comparable
3623 .files
3624 .iter()
3625 .any(|file| file.extension().and_then(|ext| ext.to_str()) == Some("ts")),
3626 "fixture must include TypeScript files: {:#?}",
3627 comparable.files
3628 );
3629 assert!(
3630 comparable
3631 .files
3632 .iter()
3633 .any(|file| file.extension().and_then(|ext| ext.to_str()) == Some("js")),
3634 "fixture must include JavaScript files: {:#?}",
3635 comparable.files
3636 );
3637 assert!(
3638 comparable
3639 .files
3640 .iter()
3641 .any(|file| file.extension().and_then(|ext| ext.to_str()) == Some("rs")),
3642 "fixture must include Rust files: {:#?}",
3643 comparable.files
3644 );
3645
3646 let main_file = canonicalize_for_snapshot(&root.join("src/main.ts"));
3647 let private_dispatch_target = format!("{}::dispatch", main_file.display());
3648 assert!(
3649 comparable
3650 .outbound_calls
3651 .iter()
3652 .any(
3653 |(caller_file, caller_symbol, target, _)| caller_file == &main_file
3654 && caller_symbol == "main"
3655 && target == &private_dispatch_target
3656 ),
3657 "fixture must cover same-file private fallback target {private_dispatch_target}: {:#?}",
3658 comparable.outbound_calls
3659 );
3660 assert!(
3661 comparable
3662 .outbound_calls
3663 .iter()
3664 .any(|(_, _, target, _)| target.contains(DISPATCHED_CALLEE_SEPARATOR)),
3665 "fixture must cover method-dispatch suffixes: {:#?}",
3666 comparable.outbound_calls
3667 );
3668 assert!(
3669 comparable
3670 .exported_symbols
3671 .iter()
3672 .any(|(_, symbol, kind, _)| symbol == "runDefault"
3673 && kind == DEFAULT_EXPORT_MARKER_KIND),
3674 "fixture must cover default-export marker rows: {:#?}",
3675 comparable.exported_symbols
3676 );
3677 }
3678
3679 fn run_projection_scenario(name: &str, setup: fn(&Path), edit: fn(&Path) -> Vec<PathBuf>) {
3680 let dir = tempfile::tempdir().expect("tempdir");
3681 setup(dir.path());
3682 let root = canonical_root(dir.path());
3683 let files_before = project_files(&root);
3684 let incremental_store = CallGraphStore::open(
3685 root.join(format!(".store-dead-code-projection-{name}-incremental")),
3686 root.clone(),
3687 )
3688 .expect("open incremental store");
3689 incremental_store
3690 .cold_build(&files_before)
3691 .expect("initial cold build");
3692
3693 let changed = edit(&root);
3694 incremental_store
3695 .refresh_files(&changed)
3696 .expect("refresh changed files");
3697 let incremental = project_dead_code_snapshot(incremental_store.sqlite_path())
3698 .expect("project incremental snapshot");
3699
3700 let cold_store = CallGraphStore::open(
3701 root.join(format!(".store-dead-code-projection-{name}-cold")),
3702 root.clone(),
3703 )
3704 .expect("open cold store");
3705 cold_store
3706 .cold_build(&project_files(&root))
3707 .expect("cold rebuild");
3708 let cold =
3709 project_dead_code_snapshot(cold_store.sqlite_path()).expect("project cold snapshot");
3710
3711 assert_snapshot_parts_eq(name, &cold, &incremental);
3712 }
3713
3714 #[test]
3723 #[ignore = "manual benchmark; needs AFT_BENCH_REPO pointing at a large checkout"]
3724 fn dead_code_decision_b_benchmark() {
3725 let Ok(repo) = std::env::var("AFT_BENCH_REPO") else {
3726 eprintln!("AFT_BENCH_REPO unset; skipping");
3727 return;
3728 };
3729 macro_rules! mark {
3731 ($($a:tt)*) => {{ eprintln!($($a)*); let _ = std::io::Write::flush(&mut std::io::stderr()); }};
3732 }
3733 let root = canonical_root(Path::new(&repo));
3734 let files = project_files(&root);
3735 mark!(
3736 "\n=== Store-backed dead_code benchmark ===\nrepo: {}\nsource files (walk_project_files): {}\nstarted store cold_build...",
3737 root.display(),
3738 files.len()
3739 );
3740
3741 let store_dir = root.join(".aft-bench-store");
3744 let _ = std::fs::remove_dir_all(&store_dir);
3745 let store = CallGraphStore::open(store_dir.clone(), root.clone()).expect("open store");
3746 let t = Instant::now();
3747 let cold_stats = store.cold_build(&files).expect("store cold build");
3748 let store_build_ms = t.elapsed().as_millis();
3749 let t = Instant::now();
3750 let projected = project_dead_code_snapshot(store.sqlite_path()).expect("projection");
3751 let proj_ms = t.elapsed().as_millis();
3752 mark!(
3753 "store cold_build: {} ms ({:?}) + projection: {} ms = {} ms (exports={}, outbound={})\nstarted scan...",
3754 store_build_ms, cold_stats, proj_ms, store_build_ms + proj_ms,
3755 projected.exported_symbols.len(), projected.outbound_calls.len()
3756 );
3757
3758 let t = Instant::now();
3760 let _result = dead_code_aggregate(&root, files.clone(), projected.clone());
3761 let scan_ms = t.elapsed().as_millis();
3762 mark!("run_dead_code_scan (cold contributions): {} ms", scan_ms);
3763
3764 mark!(
3765 "\nSUMMARY files={} store_cold_plus_projection={}ms projection={}ms scan_cold={}ms total={}ms",
3766 files.len(),
3767 store_build_ms + proj_ms,
3768 proj_ms,
3769 scan_ms,
3770 store_build_ms + proj_ms + scan_ms
3771 );
3772 let _ = std::fs::remove_dir_all(&store_dir);
3773 }
3774
3775 fn store_projected_snapshot(root: &Path, store_name: &str) -> CallgraphSnapshot {
3776 let store =
3777 CallGraphStore::open(root.join(store_name), root.to_path_buf()).expect("open store");
3778 store
3779 .cold_build(&project_files(root))
3780 .expect("store cold build");
3781 project_dead_code_snapshot(store.sqlite_path()).expect("project snapshot")
3782 }
3783
3784 fn dead_code_aggregate(
3785 root: &Path,
3786 scope_files: Vec<PathBuf>,
3787 snapshot: CallgraphSnapshot,
3788 ) -> Value {
3789 let job = InspectJob {
3790 job_id: 86,
3791 key: JobKey::for_project_category(InspectCategory::DeadCode),
3792 category: InspectCategory::DeadCode,
3793 scope_files,
3794 project_root: root.to_path_buf(),
3795 inspect_dir: root.join(".aft-cache").join("inspect"),
3796 config: Arc::new(Config {
3797 project_root: Some(root.to_path_buf()),
3798 ..Config::default()
3799 }),
3800 symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
3801 callgraph_snapshot: Some(Arc::new(snapshot)),
3802 };
3803 crate::inspect::scanners::dead_code::run_dead_code_scan(&job)
3804 .outcome
3805 .expect("dead_code scan succeeds")
3806 .aggregate
3807 }
3808
3809 fn assert_snapshot_parts_eq(
3810 label: &str,
3811 expected: &CallgraphSnapshot,
3812 actual: &CallgraphSnapshot,
3813 ) {
3814 let expected = comparable_snapshot(expected);
3815 let actual = comparable_snapshot(actual);
3816 assert_eq!(
3817 actual, expected,
3818 "{label} store-projected snapshot must match cold store snapshot"
3819 );
3820 }
3821
3822 fn comparable_snapshot(snapshot: &CallgraphSnapshot) -> ComparableSnapshot {
3823 ComparableSnapshot {
3824 files: snapshot.files.iter().cloned().collect(),
3825 exported_symbols: snapshot
3826 .exported_symbols
3827 .iter()
3828 .map(|export| {
3829 (
3830 export.file.clone(),
3831 export.symbol.clone(),
3832 export.kind.clone(),
3833 export.line,
3834 )
3835 })
3836 .collect(),
3837 outbound_calls: snapshot
3838 .outbound_calls
3839 .iter()
3840 .map(|call| {
3841 (
3842 call.caller_file.clone(),
3843 call.caller_symbol.clone(),
3844 call.target.clone(),
3845 call.line,
3846 )
3847 })
3848 .collect(),
3849 entry_points: snapshot.entry_points.clone(),
3850 entry_point_symbols: snapshot.entry_point_symbols.clone(),
3851 }
3852 }
3853
3854 fn assert_dead_item(aggregate: &Value, file: &str, symbol: &str) {
3855 assert!(
3856 aggregate_has_item(aggregate, file, symbol),
3857 "expected {file}::{symbol} to be reported dead: {aggregate:#}"
3858 );
3859 }
3860
3861 fn assert_live_item(aggregate: &Value, file: &str, symbol: &str) {
3862 assert!(
3863 !aggregate_has_item(aggregate, file, symbol),
3864 "expected {file}::{symbol} to be live/not reported dead: {aggregate:#}"
3865 );
3866 }
3867
3868 fn aggregate_has_item(aggregate: &Value, file: &str, symbol: &str) -> bool {
3869 let Some(items) = aggregate.get("items").and_then(Value::as_array) else {
3870 return false;
3871 };
3872 items.iter().any(|item| {
3873 item.get("file").and_then(Value::as_str) == Some(file)
3874 && item.get("symbol").and_then(Value::as_str) == Some(symbol)
3875 })
3876 }
3877
3878 fn project_files(root: &Path) -> Vec<PathBuf> {
3879 walk_project_files(root).collect()
3880 }
3881
3882 fn canonical_root(root: &Path) -> PathBuf {
3883 std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf())
3884 }
3885
3886 fn write_file(path: &Path, content: &str) {
3887 if let Some(parent) = path.parent() {
3888 std::fs::create_dir_all(parent).expect("create parent");
3889 }
3890 std::fs::write(path, content).expect("write fixture");
3891 bump_mtime(path);
3892 }
3893
3894 fn bump_mtime(path: &Path) {
3895 let secs = NEXT_MTIME.fetch_add(1, AtomicOrdering::SeqCst);
3896 filetime::set_file_mtime(path, FileTime::from_unix_time(secs, 0)).expect("bump mtime");
3897 }
3898
3899 fn remove_file(path: &Path) {
3900 std::fs::remove_file(path).expect("remove fixture");
3901 }
3902
3903 fn write_projection_fixture(root: &Path) {
3904 write_file(
3905 &root.join("package.json"),
3906 r#"{"name":"dead-code-projection-fixture","type":"module","main":"src/main.ts"}"#,
3907 );
3908 write_file(
3909 &root.join("Cargo.toml"),
3910 r#"[package]
3911name = "dead_code_projection_fixture"
3912version = "0.1.0"
3913edition = "2021"
3914"#,
3915 );
3916 write_file(
3917 &root.join("src/main.ts"),
3918 r#"import runDefault from "./default";
3919import { knownLive } from "./live";
3920import { jsEntry } from "./app.js";
3921
3922export function main() {
3923 dispatch();
3924 runDefault();
3925 jsEntry();
3926}
3927
3928function dispatch() {
3929 knownLive();
3930 const service = { render() {} };
3931 service.render();
3932}
3933"#,
3934 );
3935 write_file(
3936 &root.join("src/default.ts"),
3937 r#"export default function runDefault() {}
3938"#,
3939 );
3940 write_file(
3941 &root.join("src/live.ts"),
3942 r#"export function knownLive() {}
3943"#,
3944 );
3945 write_file(
3946 &root.join("src/dead.ts"),
3947 r#"export function knownDead() {}
3948"#,
3949 );
3950 write_file(
3951 &root.join("src/render.ts"),
3952 r#"export function render() {}
3953"#,
3954 );
3955 write_file(
3956 &root.join("src/other_render.ts"),
3957 r#"export function render() {}
3958"#,
3959 );
3960 write_file(
3961 &root.join("src/app.js"),
3962 r#"import { jsHelper } from "./js_helper.js";
3963
3964export function jsEntry() {
3965 jsHelper();
3966}
3967"#,
3968 );
3969 write_file(
3970 &root.join("src/js_helper.js"),
3971 r#"export function jsHelper() {}
3972"#,
3973 );
3974 write_file(
3975 &root.join("src/lib.rs"),
3976 r#"mod util;
3977use crate::util::rust_helper;
3978
3979pub fn rust_entry() {
3980 rust_helper();
3981}
3982"#,
3983 );
3984 write_file(
3985 &root.join("src/util.rs"),
3986 r#"pub fn rust_helper() {}
3987"#,
3988 );
3989 }
3990
3991 fn write_rust_attribute_entry_fixture(root: &Path) {
3992 write_file(
3993 &root.join("src/main.rs"),
3994 r#"mod commands;
3995mod db;
3996mod imported;
3997mod unimported;
3998mod unrelated;
3999
4000fn main() {
4001 tauri::generate_handler![commands::get_primers, imported::imported_command];
4002}
4003"#,
4004 );
4005 write_file(
4006 &root.join("src/commands.rs"),
4007 r#"use crate::db;
4008
4009#[tauri::command]
4010pub fn get_primers() -> String {
4011 db::helper()
4012}
4013
4014pub fn planted_dead() -> String {
4015 "dead".to_string()
4016}
4017
4018#[tauri::command]
4019fn private_command() -> String {
4020 db::private_helper()
4021}
4022"#,
4023 );
4024 write_file(
4025 &root.join("src/imported.rs"),
4026 r#"use crate::db;
4027use tauri::command;
4028
4029#[command]
4030pub fn imported_command() -> String {
4031 db::imported_helper()
4032}
4033"#,
4034 );
4035 write_file(
4036 &root.join("src/unimported.rs"),
4037 r#"use crate::db;
4038
4039#[command]
4040pub fn false_command() -> String {
4041 db::false_helper()
4042}
4043"#,
4044 );
4045 write_file(
4046 &root.join("src/db.rs"),
4047 r#"pub fn helper() -> String { "live".to_string() }
4048pub fn imported_helper() -> String { "live".to_string() }
4049pub fn private_helper() -> String { "live".to_string() }
4050pub fn false_helper() -> String { "dead".to_string() }
4051"#,
4052 );
4053 write_file(
4054 &root.join("src/unrelated.rs"),
4055 r#"pub fn unrelated() -> u32 { 1 }
4056"#,
4057 );
4058 }
4059
4060 fn setup_projection_rename(root: &Path) {
4061 write_file(
4062 &root.join("a.ts"),
4063 r#"export function outer() {
4064 inner();
4065}
4066
4067export function inner() {}
4068"#,
4069 );
4070 }
4071
4072 fn edit_projection_rename(root: &Path) -> Vec<PathBuf> {
4073 let path = root.join("a.ts");
4074 write_file(
4075 &path,
4076 r#"export function outer() {
4077 renamed();
4078}
4079
4080export function renamed() {}
4081"#,
4082 );
4083 vec![path]
4084 }
4085
4086 fn setup_projection_delete(root: &Path) {
4087 write_file(
4088 &root.join("main.ts"),
4089 r#"import { foo } from "./foo";
4090export function main() { foo(); }
4091"#,
4092 );
4093 write_file(&root.join("foo.ts"), "export function foo() {}\n");
4094 }
4095
4096 fn edit_projection_delete(root: &Path) -> Vec<PathBuf> {
4097 let path = root.join("foo.ts");
4098 remove_file(&path);
4099 vec![path]
4100 }
4101
4102 fn setup_projection_barrel(root: &Path) {
4103 write_file(
4104 &root.join("main.ts"),
4105 r#"import { foo } from "./barrel";
4106export function main() { foo(); }
4107"#,
4108 );
4109 write_file(&root.join("barrel.ts"), "export { foo } from \"./foo\";\n");
4110 write_file(&root.join("foo.ts"), "export function foo() {}\n");
4111 }
4112
4113 fn edit_projection_barrel_delete(root: &Path) -> Vec<PathBuf> {
4114 let path = root.join("barrel.ts");
4115 remove_file(&path);
4116 vec![path]
4117 }
4118
4119 fn setup_projection_dispatch(root: &Path) {
4120 write_file(
4121 &root.join("main.ts"),
4122 r#"export function main() {
4123 const service = { render() {}, paint() {} };
4124 service.render();
4125}
4126"#,
4127 );
4128 write_file(&root.join("render.ts"), "export function render() {}\n");
4129 write_file(&root.join("paint.ts"), "export function paint() {}\n");
4130 }
4131
4132 fn edit_projection_dispatch(root: &Path) -> Vec<PathBuf> {
4133 let path = root.join("main.ts");
4134 write_file(
4135 &path,
4136 r#"export function main() {
4137 const service = { render() {}, paint() {} };
4138 service.paint();
4139}
4140"#,
4141 );
4142 vec![path]
4143 }
4144
4145 fn setup_projection_body_only(root: &Path) {
4146 write_file(
4147 &root.join("main.ts"),
4148 r#"import { foo } from "./foo";
4149export function main() { foo(); }
4150"#,
4151 );
4152 write_file(
4153 &root.join("foo.ts"),
4154 r#"export function foo() {
4155 return 1;
4156}
4157"#,
4158 );
4159 }
4160
4161 fn edit_projection_body_only(root: &Path) -> Vec<PathBuf> {
4162 let path = root.join("foo.ts");
4163 write_file(
4164 &path,
4165 r#"export function foo() {
4166 return 2;
4167}
4168"#,
4169 );
4170 vec![path]
4171 }
4172}