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