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