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