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