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