Skip to main content

aft/inspect/
manager.rs

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