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};
14#[cfg(test)]
15use super::job::normalize_path;
16use super::job::{
17    is_test_file, CallgraphSnapshot, FileContribution, InspectCategory, InspectJob, InspectResult,
18    InspectScanSuccess, InspectSnapshot, JobKey, JobOutcome, JobScope,
19};
20use super::oxc_engine::LivenessVerdict;
21use super::oxc_engine::{
22    analyze_file_facts, analyze_files_with_cache, normalize_input_path, AnalyzeOptions,
23    DynamicImportFact, ExportFact, FileFacts, FileId, ImportFact, OxcEngineResult, OxcFactsCache,
24    ReExportFact, FACTS_FORMAT_VERSION, OXC_PROVENANCE,
25};
26use crate::cache_freshness::{self, FileFreshness};
27use crate::callgraph_store::{project_dead_code_snapshot, CallGraphStore, CallGraphStoreError};
28
29const DEFAULT_SOFT_DEADLINE: Duration = Duration::from_secs(1);
30
31type WaiterTx = Sender<JobOutcome>;
32
33#[derive(Clone)]
34struct Waiter {
35    tx: WaiterTx,
36}
37
38struct CachedContributionFreshness {
39    file_path: PathBuf,
40    freshness: FileFreshness,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Hash)]
44struct InspectCacheIdentity {
45    sqlite_path: PathBuf,
46    project_root: PathBuf,
47}
48
49#[derive(Debug, Clone)]
50pub struct Tier2RunSubmissionError {
51    pub category: InspectCategory,
52    pub message: String,
53}
54
55#[derive(Debug, Clone, Default)]
56pub struct Tier2RunSubmission {
57    pub queued_categories: Vec<InspectCategory>,
58    pub newly_queued_categories: Vec<InspectCategory>,
59    pub errors: Vec<Tier2RunSubmissionError>,
60}
61
62impl Tier2RunSubmission {
63    pub fn has_new_work(&self) -> bool {
64        !self.newly_queued_categories.is_empty()
65    }
66}
67
68#[derive(Debug, Clone)]
69pub struct DirectTier2RunOutcome {
70    pub outcome: JobOutcome,
71    pub force_paths_completed: bool,
72}
73
74#[derive(Debug, Clone)]
75struct Tier2ReuseOptions {
76    force_rescan_paths: BTreeSet<PathBuf>,
77    allow_callgraph_cold_build: bool,
78}
79
80impl Tier2ReuseOptions {
81    fn direct(paths: Vec<PathBuf>) -> Self {
82        Self {
83            force_rescan_paths: paths.into_iter().collect(),
84            allow_callgraph_cold_build: false,
85        }
86    }
87
88    fn has_force_paths(&self) -> bool {
89        !self.force_rescan_paths.is_empty()
90    }
91}
92
93impl Default for Tier2ReuseOptions {
94    fn default() -> Self {
95        Self {
96            force_rescan_paths: BTreeSet::new(),
97            allow_callgraph_cold_build: true,
98        }
99    }
100}
101
102pub struct InspectManager {
103    request_tx: Sender<InspectJob>,
104    result_rx: Receiver<InspectResult>,
105    #[allow(dead_code)]
106    pool: Arc<rayon::ThreadPool>,
107    in_flight: Mutex<HashMap<JobKey, Vec<Waiter>>>,
108    caches: Mutex<HashMap<InspectCacheIdentity, Arc<InspectCache>>>,
109    oxc_facts_cache: Mutex<OxcFactsCache>,
110    soft_deadline: Duration,
111    next_job_id: AtomicU64,
112    /// Monotonic count of Tier-2 completions delivered via the reuse path
113    /// (watcher-driven scheduler runs). These bypass `result_rx`/
114    /// `drain_completions`, so the `&AppContext`-side drain polls this counter
115    /// to know when to refresh the agent status bar after a background scan.
116    reuse_completions: AtomicU64,
117}
118
119impl InspectManager {
120    pub fn new() -> Self {
121        Self::with_worker(default_worker(), DEFAULT_SOFT_DEADLINE)
122    }
123
124    #[doc(hidden)]
125    pub fn with_worker(worker: InspectWorker, soft_deadline: Duration) -> Self {
126        let handles = start_dispatch_loop(worker);
127        Self {
128            request_tx: handles.request_tx,
129            result_rx: handles.result_rx,
130            pool: handles.pool,
131            in_flight: Mutex::new(HashMap::new()),
132            caches: Mutex::new(HashMap::new()),
133            oxc_facts_cache: Mutex::new(OxcFactsCache::new()),
134            soft_deadline,
135            next_job_id: AtomicU64::new(1),
136            reuse_completions: AtomicU64::new(0),
137        }
138    }
139
140    pub fn submit_category(
141        &self,
142        snapshot: InspectSnapshot,
143        category: InspectCategory,
144        caller_scope: JobScope,
145    ) -> JobOutcome {
146        self.submit_category_with_callgraph(snapshot, category, caller_scope, None)
147    }
148
149    pub fn submit_category_with_callgraph(
150        &self,
151        snapshot: InspectSnapshot,
152        category: InspectCategory,
153        caller_scope: JobScope,
154        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
155    ) -> JobOutcome {
156        if !category.is_active() {
157            return JobOutcome::Failed {
158                message: format!("inspect category '{category}' is disabled in v0.33"),
159            };
160        }
161
162        let cache = match self.cache_for_snapshot(&snapshot) {
163            Ok(cache) => cache,
164            Err(message) => return JobOutcome::Failed { message },
165        };
166        let key = JobKey::for_category_scope(category, &caller_scope);
167        let (waiter_tx, waiter_rx) = bounded(1);
168
169        let wait_snapshot = snapshot.clone();
170        match self.enqueue_with_waiter(
171            snapshot,
172            category,
173            caller_scope.clone(),
174            key.clone(),
175            waiter_tx,
176            callgraph_snapshot,
177        ) {
178            Ok(()) => self.wait_for_outcome(key, caller_scope, cache, waiter_rx, wait_snapshot),
179            Err(message) => JobOutcome::Failed { message },
180        }
181    }
182
183    pub fn submit_background(
184        &self,
185        snapshot: InspectSnapshot,
186        category: InspectCategory,
187        caller_scope: JobScope,
188    ) -> Result<JobKey, String> {
189        self.submit_background_with_callgraph(snapshot, category, caller_scope, None)
190    }
191
192    pub fn submit_background_with_callgraph(
193        &self,
194        snapshot: InspectSnapshot,
195        category: InspectCategory,
196        caller_scope: JobScope,
197        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
198    ) -> Result<JobKey, String> {
199        if !category.is_active() {
200            return Err(format!(
201                "inspect category '{category}' is disabled in v0.33"
202            ));
203        }
204        let key = JobKey::for_category_scope(category, &caller_scope);
205        self.enqueue_without_waiter(
206            snapshot,
207            category,
208            caller_scope,
209            key.clone(),
210            callgraph_snapshot,
211        )?;
212        Ok(key)
213    }
214
215    pub fn submit_tier2_run_with_reuse_background(
216        self: &Arc<Self>,
217        snapshot: InspectSnapshot,
218        category: InspectCategory,
219    ) -> Result<JobKey, String> {
220        if !category.is_active() {
221            return Err(format!(
222                "inspect category '{category}' is disabled in v0.33"
223            ));
224        }
225        if !category.is_tier2() {
226            return Err(format!(
227                "inspect category '{category}' is not a Tier 2 category"
228            ));
229        }
230
231        let job = self.tier2_reuse_job(snapshot, category, None);
232        let key = job.key.clone();
233        let mut in_flight = self
234            .in_flight
235            .lock()
236            .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
237        if in_flight.contains_key(&key) {
238            return Ok(key);
239        }
240        in_flight.insert(key.clone(), Vec::new());
241        drop(in_flight);
242
243        let manager = Arc::clone(self);
244        let pool = Arc::clone(&self.pool);
245        pool.spawn(move || {
246            let result = manager.tier2_run_with_reuse_job_result(job);
247            manager.route_tier2_reuse_completion(result);
248        });
249
250        Ok(key)
251    }
252
253    pub fn submit_tier2_run_with_reuse_serial_background(
254        self: &Arc<Self>,
255        snapshot: InspectSnapshot,
256        categories: Vec<InspectCategory>,
257    ) -> Tier2RunSubmission {
258        let mut submission = Tier2RunSubmission::default();
259        let mut requested = Vec::new();
260
261        for category in categories {
262            if !category.is_active() {
263                submission.errors.push(Tier2RunSubmissionError {
264                    category,
265                    message: format!("inspect category '{category}' is disabled in v0.33"),
266                });
267                continue;
268            }
269            if !category.is_tier2() {
270                submission.errors.push(Tier2RunSubmissionError {
271                    category,
272                    message: format!("inspect category '{category}' is not a Tier 2 category"),
273                });
274                continue;
275            }
276            requested.push(category);
277        }
278
279        if requested.is_empty() {
280            return submission;
281        }
282
283        let mut in_flight = match self.in_flight.lock() {
284            Ok(in_flight) => in_flight,
285            Err(_) => {
286                for category in requested {
287                    submission.errors.push(Tier2RunSubmissionError {
288                        category,
289                        message: "inspect in-flight map lock poisoned".to_string(),
290                    });
291                }
292                return submission;
293            }
294        };
295
296        for category in requested {
297            let key = JobKey::for_project_category(category);
298            submission.queued_categories.push(category);
299            if in_flight.contains_key(&key) {
300                continue;
301            }
302            in_flight.insert(key, Vec::new());
303            submission.newly_queued_categories.push(category);
304        }
305        drop(in_flight);
306
307        if submission.newly_queued_categories.is_empty() {
308            return submission;
309        }
310
311        let categories_for_worker = submission.newly_queued_categories.clone();
312        let manager = Arc::clone(self);
313        let pool = Arc::clone(&self.pool);
314        pool.spawn(move || {
315            for category in categories_for_worker {
316                let result = manager.tier2_run_with_reuse_result(snapshot.clone(), category, None);
317                manager.route_tier2_reuse_completion(result);
318            }
319        });
320
321        submission
322    }
323
324    pub fn tier2_any_in_flight(&self) -> bool {
325        self.in_flight
326            .lock()
327            .map(|in_flight| in_flight.keys().any(|key| key.category.is_tier2()))
328            .unwrap_or(false)
329    }
330
331    pub fn drain_completions(&self) -> usize {
332        let mut drained = 0usize;
333        while let Ok(result) = self.result_rx.try_recv() {
334            self.route_completion(result);
335            drained += 1;
336        }
337        drained
338    }
339
340    pub fn cache_for_snapshot(
341        &self,
342        snapshot: &InspectSnapshot,
343    ) -> Result<Arc<InspectCache>, String> {
344        self.cache_for_paths(snapshot.inspect_dir.clone(), snapshot.project_root.clone())
345    }
346
347    /// Latest persisted counts for the three Tier-2 categories, in
348    /// `(dead_code, unused_exports, duplicates)` order. Reads the most recent
349    /// aggregate regardless of contribution-hash freshness (last-known), so the
350    /// agent status bar can refresh after a background scan completes without a
351    /// freshness round-trip. A category with no readable aggregate reports
352    /// `None` (never a fabricated `0`), so the status bar can preserve any
353    /// last-known value and stay suppressed until every category is real (#1).
354    pub fn latest_tier2_counts(
355        &self,
356        inspect_dir: PathBuf,
357        project_root: PathBuf,
358    ) -> (Option<usize>, Option<usize>, Option<usize>) {
359        let Ok(cache) = self.cache_for_paths(inspect_dir, project_root) else {
360            return (None, None, None);
361        };
362        let count_of = |category: InspectCategory| -> Option<usize> {
363            cache
364                .latest_aggregate_any_hash(category)
365                .ok()
366                .flatten()
367                .and_then(|payload| {
368                    if category == InspectCategory::DeadCode
369                        && payload
370                            .get("callgraph_available")
371                            .and_then(serde_json::Value::as_bool)
372                            == Some(false)
373                    {
374                        return None;
375                    }
376                    payload
377                        .get("count")
378                        .and_then(serde_json::Value::as_u64)
379                        .map(|count| count as usize)
380                })
381        };
382        (
383            count_of(InspectCategory::DeadCode),
384            count_of(InspectCategory::UnusedExports),
385            count_of(InspectCategory::Duplicates),
386        )
387    }
388
389    pub fn cache_for_paths(
390        &self,
391        inspect_dir: PathBuf,
392        project_root: PathBuf,
393    ) -> Result<Arc<InspectCache>, String> {
394        let project_key = crate::search_index::artifact_cache_key(&project_root);
395        let sqlite_path = inspect_dir.join(format!("{project_key}.sqlite"));
396        let identity = InspectCacheIdentity {
397            sqlite_path,
398            project_root: project_root.clone(),
399        };
400        let mut caches = self
401            .caches
402            .lock()
403            .map_err(|_| "inspect manager cache map lock poisoned".to_string())?;
404        if let Some(cache) = caches.get(&identity) {
405            return Ok(Arc::clone(cache));
406        }
407        let cache = Arc::new(
408            InspectCache::open(inspect_dir, project_root)
409                .map_err(|error| format!("failed to open inspect cache: {error}"))?,
410        );
411        caches.insert(identity, Arc::clone(&cache));
412        Ok(cache)
413    }
414
415    fn oxc_result_for_scan(
416        &self,
417        job: &InspectJob,
418        files: &[PathBuf],
419        force_reparse_files: &[PathBuf],
420    ) -> Result<Option<OxcEngineResult>, String> {
421        if !category_uses_oxc(job.category) {
422            return Ok(None);
423        }
424        if job.category == InspectCategory::DeadCode && job.callgraph_snapshot.is_none() {
425            return Ok(None);
426        }
427
428        let public_api_entries =
429            crate::inspect::entry_points::resolve_entry_points(&job.project_root);
430        let entry_points = if job.category == InspectCategory::DeadCode {
431            job.callgraph_snapshot
432                .as_ref()
433                .map(|snapshot| snapshot.entry_points.iter().cloned().collect::<Vec<_>>())
434                .unwrap_or_default()
435        } else {
436            Vec::new()
437        };
438        let options = AnalyzeOptions {
439            entry_points,
440            public_api_files: public_api_entries.public_api_files(),
441            executable_root_exports: public_api_entries.executable_root_exports(),
442            force_reparse_files: force_reparse_files.to_vec(),
443            entry_reachability: job.category == InspectCategory::DeadCode,
444        };
445
446        let mut cache = self
447            .oxc_facts_cache
448            .lock()
449            .map_err(|_| "inspect oxc facts cache lock poisoned".to_string())?;
450        analyze_files_with_cache(&job.project_root, files, options, &mut cache)
451            .map(Some)
452            .map_err(|message| format!("oxc analyze failed: {message}"))
453    }
454
455    pub fn tier2_run_with_reuse(
456        &self,
457        snapshot: InspectSnapshot,
458        category: InspectCategory,
459        caller_scope: JobScope,
460        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
461    ) -> JobOutcome {
462        if let Err(outcome) = validate_tier2_read_category(category) {
463            return outcome;
464        }
465        let cache = match self.cache_for_snapshot(&snapshot) {
466            Ok(cache) => cache,
467            Err(message) => return JobOutcome::Failed { message },
468        };
469        let job = self.tier2_reuse_job(snapshot.clone(), category, callgraph_snapshot);
470        let key = job.key.clone();
471        let (waiter_tx, waiter_rx) = bounded(1);
472        let claimed = match self.register_tier2_reuse_waiter(&key, waiter_tx) {
473            Ok(claimed) => claimed,
474            Err(message) => return JobOutcome::Failed { message },
475        };
476
477        if claimed {
478            let result = self
479                .tier2_run_with_reuse_job_result_with_options(job, Tier2ReuseOptions::default());
480            self.route_tier2_reuse_completion(result);
481        }
482
483        match waiter_rx.recv() {
484            Ok(outcome) => filter_outcome_for_scope_with_contributions(
485                outcome,
486                &snapshot,
487                category,
488                cache.as_ref(),
489                &caller_scope,
490            ),
491            Err(_) => JobOutcome::Pending { in_flight: true },
492        }
493    }
494
495    pub fn tier2_run_with_reuse_direct(
496        self: &Arc<Self>,
497        snapshot: InspectSnapshot,
498        category: InspectCategory,
499        caller_scope: JobScope,
500        deadline: Instant,
501        force_rescan_paths: Vec<PathBuf>,
502    ) -> DirectTier2RunOutcome {
503        if let Err(outcome) = validate_tier2_read_category(category) {
504            return DirectTier2RunOutcome {
505                outcome,
506                force_paths_completed: false,
507            };
508        }
509        let cache = match self.cache_for_snapshot(&snapshot) {
510            Ok(cache) => cache,
511            Err(message) => {
512                return DirectTier2RunOutcome {
513                    outcome: JobOutcome::Failed { message },
514                    force_paths_completed: false,
515                }
516            }
517        };
518
519        let must_run_forced_followup = !force_rescan_paths.is_empty();
520        loop {
521            let options = if must_run_forced_followup {
522                Tier2ReuseOptions::direct(force_rescan_paths.clone())
523            } else {
524                Tier2ReuseOptions::direct(Vec::new())
525            };
526            let job = self.tier2_reuse_job(snapshot.clone(), category, None);
527            let key = job.key.clone();
528            let (waiter_tx, waiter_rx) = bounded(1);
529            let claimed = match self.register_tier2_reuse_waiter(&key, waiter_tx) {
530                Ok(claimed) => claimed,
531                Err(message) => {
532                    return DirectTier2RunOutcome {
533                        outcome: JobOutcome::Failed { message },
534                        force_paths_completed: false,
535                    }
536                }
537            };
538            if claimed {
539                self.spawn_tier2_reuse_job(job, options);
540            }
541
542            let completed_force_run = claimed && must_run_forced_followup;
543            let outcome = self.wait_for_tier2_reuse_until(
544                &key,
545                &caller_scope,
546                cache.as_ref(),
547                waiter_rx,
548                &snapshot,
549                deadline,
550            );
551
552            delay_direct_force_followup_deadline_check_for_debug(&snapshot.project_root);
553            if must_run_forced_followup
554                && !claimed
555                && !matches!(outcome, JobOutcome::Pending { .. })
556            {
557                // The category was already in flight before this direct inspect
558                // could supply its forced paths. Wait for that scan to finish,
559                // then claim a follow-up reuse pass so the direct answer is based
560                // on the paths invalidated by the edit/watcher stream rather than
561                // on a possibly stat-fresh pre-existing scan. If the original scan
562                // used the whole deadline, the forced paths were not incorporated,
563                // so the honest direct result is still incomplete.
564                if Instant::now() < deadline {
565                    continue;
566                }
567                return DirectTier2RunOutcome {
568                    outcome: JobOutcome::Pending { in_flight: true },
569                    force_paths_completed: false,
570                };
571            }
572
573            return DirectTier2RunOutcome {
574                outcome,
575                force_paths_completed: completed_force_run,
576            };
577        }
578    }
579
580    fn register_tier2_reuse_waiter(
581        &self,
582        key: &JobKey,
583        waiter_tx: WaiterTx,
584    ) -> Result<bool, String> {
585        let mut in_flight = self
586            .in_flight
587            .lock()
588            .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
589        if let Some(waiters) = in_flight.get_mut(key) {
590            waiters.push(Waiter { tx: waiter_tx });
591            return Ok(false);
592        }
593
594        in_flight.insert(key.clone(), vec![Waiter { tx: waiter_tx }]);
595        Ok(true)
596    }
597
598    fn spawn_tier2_reuse_job(self: &Arc<Self>, job: InspectJob, options: Tier2ReuseOptions) {
599        let manager = Arc::clone(self);
600        let pool = Arc::clone(&self.pool);
601        pool.spawn(move || {
602            let result = manager.tier2_run_with_reuse_job_result_catching(job, options);
603            manager.route_tier2_reuse_completion(result);
604        });
605    }
606
607    fn wait_for_tier2_reuse_until(
608        &self,
609        key: &JobKey,
610        caller_scope: &JobScope,
611        cache: &InspectCache,
612        waiter_rx: Receiver<JobOutcome>,
613        snapshot: &InspectSnapshot,
614        deadline: Instant,
615    ) -> JobOutcome {
616        let Some(remaining) = deadline.checked_duration_since(Instant::now()) else {
617            return JobOutcome::Pending { in_flight: true };
618        };
619        if remaining.is_zero() {
620            return JobOutcome::Pending { in_flight: true };
621        }
622
623        match waiter_rx.recv_timeout(remaining) {
624            Ok(outcome) => filter_outcome_for_scope_with_contributions(
625                outcome,
626                snapshot,
627                key.category,
628                cache,
629                caller_scope,
630            ),
631            Err(crossbeam_channel::RecvTimeoutError::Timeout) => {
632                JobOutcome::Pending { in_flight: true }
633            }
634            Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
635                JobOutcome::Pending { in_flight: true }
636            }
637        }
638    }
639
640    /// Read-only Tier 2 aggregate lookup for `aft_inspect`. Does NOT run any
641    /// scanner — returns the latest cached aggregate if present and verifies
642    /// its contribution freshness so warm cache hits are reported as fresh.
643    /// This is the non-blocking variant intended for the synchronous `inspect`
644    /// command path; Tier 2 scans run via the watcher-driven scheduler or the
645    /// compatibility `aft_inspect_tier2_run` command.
646    pub fn tier2_read_cached(
647        &self,
648        snapshot: InspectSnapshot,
649        category: InspectCategory,
650        caller_scope: JobScope,
651    ) -> JobOutcome {
652        if let Err(outcome) = validate_tier2_read_category(category) {
653            return outcome;
654        }
655        let cache = match self.cache_for_snapshot(&snapshot) {
656            Ok(cache) => cache,
657            Err(message) => return JobOutcome::Failed { message },
658        };
659        self.tier2_read_cached_from_cache(&snapshot, category, &caller_scope, cache.as_ref())
660    }
661
662    pub fn tier2_read_cached_readonly(
663        &self,
664        snapshot: InspectSnapshot,
665        category: InspectCategory,
666        caller_scope: JobScope,
667    ) -> JobOutcome {
668        if let Err(outcome) = validate_tier2_read_category(category) {
669            return outcome;
670        }
671        let key = JobKey::for_project_category(category);
672        let in_flight = self
673            .in_flight
674            .lock()
675            .map(|guard| guard.contains_key(&key))
676            .unwrap_or(false);
677        let cache = match InspectCache::open_readonly(
678            snapshot.inspect_dir.clone(),
679            snapshot.project_root.clone(),
680        ) {
681            Ok(Some(cache)) => cache,
682            Ok(None) => return JobOutcome::Pending { in_flight },
683            Err(error) => {
684                return JobOutcome::Failed {
685                    message: error.to_string(),
686                }
687            }
688        };
689        self.tier2_read_cached_from_cache(&snapshot, category, &caller_scope, &cache)
690    }
691
692    fn tier2_read_cached_from_cache(
693        &self,
694        snapshot: &InspectSnapshot,
695        category: InspectCategory,
696        caller_scope: &JobScope,
697        cache: &InspectCache,
698    ) -> JobOutcome {
699        let key = JobKey::for_project_category(category);
700        let in_flight = self
701            .in_flight
702            .lock()
703            .map(|guard| guard.contains_key(&key))
704            .unwrap_or(false);
705        match cache.get_aggregated_for_config(&key, snapshot.config.as_ref()) {
706            Ok(Some(payload)) => {
707                match self.tier2_cached_aggregate_is_fresh(snapshot, category, cache) {
708                    Ok(true) => filter_outcome_for_scope_with_contributions(
709                        JobOutcome::Fresh { payload },
710                        snapshot,
711                        category,
712                        cache,
713                        caller_scope,
714                    ),
715                    Ok(false) => filter_outcome_for_scope_with_contributions(
716                        JobOutcome::Stale {
717                            cached: Some(payload),
718                            in_flight,
719                        },
720                        snapshot,
721                        category,
722                        cache,
723                        caller_scope,
724                    ),
725                    Err(message) => JobOutcome::Failed { message },
726                }
727            }
728            Ok(None) => match cache.latest_aggregate_any_hash(category) {
729                Ok(Some(payload)) => filter_outcome_for_scope_with_contributions(
730                    JobOutcome::Stale {
731                        cached: Some(payload),
732                        in_flight,
733                    },
734                    snapshot,
735                    category,
736                    cache,
737                    caller_scope,
738                ),
739                Ok(None) => JobOutcome::Pending { in_flight },
740                Err(error) => JobOutcome::Failed {
741                    message: error.to_string(),
742                },
743            },
744            Err(error) => JobOutcome::Failed {
745                message: error.to_string(),
746            },
747        }
748    }
749
750    fn tier2_cached_aggregate_is_fresh(
751        &self,
752        snapshot: &InspectSnapshot,
753        category: InspectCategory,
754        cache: &InspectCache,
755    ) -> Result<bool, String> {
756        let cached_records = load_contribution_freshness(cache, category)?;
757        let cached_relative = cached_records
758            .iter()
759            .map(freshness_record_relative_key)
760            .collect::<BTreeSet<_>>();
761
762        for record in &cached_records {
763            let absolute = if record.file_path.is_absolute() {
764                record.file_path.clone()
765            } else {
766                snapshot.project_root.join(&record.file_path)
767            };
768            match verify_contribution_file(&absolute, &record.freshness) {
769                ContributionFreshness::Fresh { .. } => {}
770                ContributionFreshness::Stale | ContributionFreshness::Deleted => return Ok(false),
771            }
772        }
773
774        // Detect files added since the cached aggregate was generated (and files
775        // that still exist but are no longer in the gitignore-aware project
776        // scope). This walk remains on the read path because the current API does
777        // not provide a watcher-maintained project file set, and additions cannot
778        // be detected from cached contribution records alone. Existing cached
779        // files are checked above first so ordinary edits/deletes can return stale
780        // without walking the project.
781        let project_scope = JobScope::for_project(snapshot.project_root.clone());
782        let project_files = scope_files(&snapshot.project_root, &project_scope);
783        let current_by_relative = current_project_files(&snapshot.project_root, &project_files);
784
785        Ok(current_by_relative.len() == cached_relative.len()
786            && current_by_relative
787                .keys()
788                .all(|relative| cached_relative.contains(relative)))
789    }
790
791    #[doc(hidden)]
792    pub fn tier2_run_with_reuse_result(
793        &self,
794        snapshot: InspectSnapshot,
795        category: InspectCategory,
796        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
797    ) -> InspectResult {
798        let job = self.tier2_reuse_job(snapshot, category, callgraph_snapshot);
799        self.tier2_run_with_reuse_job_result(job)
800    }
801
802    fn tier2_run_with_reuse_job_result(&self, job: InspectJob) -> InspectResult {
803        self.tier2_run_with_reuse_job_result_with_options(job, Tier2ReuseOptions::default())
804    }
805
806    fn tier2_run_with_reuse_job_result_catching(
807        &self,
808        job: InspectJob,
809        options: Tier2ReuseOptions,
810    ) -> InspectResult {
811        let started = Instant::now();
812        match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
813            self.tier2_run_with_reuse_job_result_with_options(job.clone(), options)
814        })) {
815            Ok(result) => result,
816            Err(_) => InspectResult::failed(
817                &job,
818                "tier2 reuse worker panicked before completion",
819                started.elapsed(),
820            ),
821        }
822    }
823
824    fn tier2_run_with_reuse_job_result_with_options(
825        &self,
826        mut job: InspectJob,
827        options: Tier2ReuseOptions,
828    ) -> InspectResult {
829        let started = Instant::now();
830        panic_tier2_reuse_for_debug(&job);
831        if !job.category.is_active() {
832            let result = InspectResult::failed(
833                &job,
834                format!("inspect category '{}' is disabled in v0.33", job.category),
835                started.elapsed(),
836            );
837            log_tier2_benchmark_category_end(&result);
838            return result;
839        }
840        if !job.category.is_tier2() {
841            let result = InspectResult::failed(
842                &job,
843                format!(
844                    "inspect category '{}' is not a Tier 2 category",
845                    job.category
846                ),
847                started.elapsed(),
848            );
849            log_tier2_benchmark_category_end(&result);
850            return result;
851        }
852
853        let project_scope = JobScope::for_project(job.project_root.clone());
854        job.scope_files = scope_files(&job.project_root, &project_scope);
855        log_tier2_benchmark_category_start(&job);
856        let cache = match self.cache_for_paths(job.inspect_dir.clone(), job.project_root.clone()) {
857            Ok(cache) => cache,
858            Err(message) => {
859                let result = InspectResult::failed(&job, message, started.elapsed());
860                log_tier2_benchmark_category_end(&result);
861                return result;
862            }
863        };
864        delay_tier2_reuse_for_debug(&job.project_root);
865        if !options.has_force_paths() {
866            if let Ok(Some(success)) = self.tier2_quick_reuse_success(&job, cache.as_ref()) {
867                let result = InspectResult::success(&job, success, started.elapsed());
868                crate::slog_debug!(
869                    "perf tier2 category={} reuse=hit ms={}",
870                    job.category,
871                    started.elapsed().as_millis()
872                );
873                log_tier2_benchmark_category_end(&result);
874                return result;
875            }
876        }
877
878        let result = match self.tier2_run_with_reuse_job(&job, &cache, &options) {
879            Ok(success) => InspectResult::success(&job, success, started.elapsed()),
880            Err(message) => InspectResult::failed(&job, message, started.elapsed()),
881        };
882        // Always-on perf line: a full (reuse=miss) scan is the expensive path —
883        // for dead_code it includes store snapshot projection plus the scanner.
884        // ms here lets us attribute background CPU bursts to a specific category from the log.
885        crate::slog_info!(
886            "perf tier2 category={} reuse=miss ms={}",
887            job.category,
888            started.elapsed().as_millis()
889        );
890        log_tier2_benchmark_category_end(&result);
891        result
892    }
893
894    fn tier2_reuse_job(
895        &self,
896        snapshot: InspectSnapshot,
897        category: InspectCategory,
898        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
899    ) -> InspectJob {
900        InspectJob {
901            job_id: self.next_job_id.fetch_add(1, Ordering::Relaxed),
902            key: JobKey::for_project_category(category),
903            category,
904            scope_files: Vec::new(),
905            project_root: snapshot.project_root,
906            inspect_dir: snapshot.inspect_dir,
907            config: snapshot.config,
908            symbol_cache: snapshot.symbol_cache,
909            callgraph_snapshot,
910        }
911    }
912
913    fn tier2_quick_reuse_success(
914        &self,
915        job: &InspectJob,
916        cache: &InspectCache,
917    ) -> Result<Option<InspectScanSuccess>, String> {
918        let cached_records = load_contribution_freshness(cache, job.category)?;
919        let current_by_relative = current_project_files(&job.project_root, &job.scope_files);
920        if cached_records.len() != current_by_relative.len() {
921            return Ok(None);
922        }
923        for record in &cached_records {
924            let relative = freshness_record_relative_key(record);
925            let Some(current_file) = current_by_relative.get(&relative) else {
926                return Ok(None);
927            };
928            match cache_freshness::metadata_matches(current_file, &record.freshness) {
929                Ok(true) => {}
930                Ok(false) => return Ok(None),
931                Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
932                Err(error) => {
933                    return Err(format!(
934                        "failed to stat {} for tier2 quick reuse: {error}",
935                        current_file.display()
936                    ));
937                }
938            }
939        }
940
941        let contribution_set_hash = cache
942            .contribution_set_hash_for_config(job.category, job.config.as_ref())
943            .map_err(|error| error.to_string())?;
944        let Some(aggregate) = cache
945            .load_aggregate_if_hash_matches(job.category, &contribution_set_hash)
946            .map_err(|error| error.to_string())?
947        else {
948            return Ok(None);
949        };
950
951        cache
952            .touch_tier2_last_full_run(job.category)
953            .map_err(|error| error.to_string())?;
954        Ok(Some(InspectScanSuccess {
955            scanned_files: Vec::new(),
956            contributions: Vec::new(),
957            aggregate,
958        }))
959    }
960
961    #[allow(clippy::too_many_lines)]
962    fn tier2_run_with_reuse_job(
963        &self,
964        job: &InspectJob,
965        cache: &InspectCache,
966        options: &Tier2ReuseOptions,
967    ) -> Result<InspectScanSuccess, String> {
968        let mut phases = Tier2PhaseTimings::default();
969        let phase_started = Instant::now();
970        let cached_records = load_contribution_freshness(cache, job.category)?;
971        let current_by_relative = current_project_files(&job.project_root, &job.scope_files);
972        let cached_relative = cached_records
973            .iter()
974            .map(freshness_record_relative_key)
975            .collect::<BTreeSet<_>>();
976        let force_relative = forced_relative_paths(job, &options.force_rescan_paths);
977        let cold_cache = cached_relative.is_empty();
978        #[cfg(debug_assertions)]
979        let debug_cold_cache = cold_cache;
980
981        let mut updates = Tier2ContributionUpdates::default();
982        let mut scan_by_relative = BTreeMap::<String, PathBuf>::new();
983        let mut callgraph_refresh_paths = options
984            .force_rescan_paths
985            .iter()
986            .filter(|path| callgraph_store_indexes_path(path))
987            .cloned()
988            .collect::<BTreeSet<_>>();
989        let mut aggregate_job = job.clone();
990
991        for record in cached_records {
992            let relative = freshness_record_relative_key(&record);
993            let relative_path = PathBuf::from(&relative);
994            let Some(current_file) = current_by_relative.get(&relative) else {
995                updates.deletes.push(relative_path);
996                insert_callgraph_refresh_path(
997                    &mut callgraph_refresh_paths,
998                    job.project_root.join(&relative),
999                );
1000                continue;
1001            };
1002
1003            if force_relative.contains(&relative) {
1004                updates.deletes.push(relative_path);
1005                scan_by_relative.insert(relative, current_file.clone());
1006                insert_callgraph_refresh_path(&mut callgraph_refresh_paths, current_file.clone());
1007                continue;
1008            }
1009
1010            let absolute = job.project_root.join(&record.file_path);
1011            match verify_contribution_file(&absolute, &record.freshness) {
1012                ContributionFreshness::Fresh {
1013                    metadata_changed,
1014                    freshness,
1015                } => {
1016                    if metadata_changed {
1017                        updates.metadata_updates.push((relative_path, freshness));
1018                    }
1019                }
1020                ContributionFreshness::Stale => {
1021                    updates.deletes.push(relative_path);
1022                    scan_by_relative.insert(relative, current_file.clone());
1023                    insert_callgraph_refresh_path(
1024                        &mut callgraph_refresh_paths,
1025                        current_file.clone(),
1026                    );
1027                }
1028                ContributionFreshness::Deleted => {
1029                    updates.deletes.push(relative_path);
1030                    insert_callgraph_refresh_path(
1031                        &mut callgraph_refresh_paths,
1032                        job.project_root.join(&record.file_path),
1033                    );
1034                }
1035            }
1036        }
1037
1038        for (relative, file) in &current_by_relative {
1039            if !cached_relative.contains(relative) {
1040                scan_by_relative.insert(relative.clone(), file.clone());
1041                if !cold_cache {
1042                    insert_callgraph_refresh_path(&mut callgraph_refresh_paths, file.clone());
1043                }
1044            }
1045        }
1046        phases.freshness = phase_started.elapsed();
1047
1048        let mut scan_files = scan_by_relative.into_values().collect::<Vec<_>>();
1049        let force_reparse_files = scan_files.clone();
1050        let callgraph_refresh_files = callgraph_refresh_paths.into_iter().collect::<Vec<_>>();
1051        let dead_code_callgraph_refresh =
1052            job.category == InspectCategory::DeadCode && !callgraph_refresh_files.is_empty();
1053        if !scan_files.is_empty() {
1054            let mut scan_job = job.clone();
1055            scan_job.job_id = self.next_job_id.fetch_add(1, Ordering::Relaxed);
1056            scan_job.scope_files = scan_files.clone();
1057            if scan_job.category == InspectCategory::DeadCode
1058                && scan_job.callgraph_snapshot.is_none()
1059            {
1060                let snapshot_started = Instant::now();
1061                scan_job.callgraph_snapshot = build_tier2_callgraph_snapshot_with_refresh(
1062                    &scan_job,
1063                    options.allow_callgraph_cold_build,
1064                    &callgraph_refresh_files,
1065                );
1066                phases.snapshot += snapshot_started.elapsed();
1067            }
1068            aggregate_job.callgraph_snapshot = scan_job.callgraph_snapshot.clone();
1069            #[cfg(debug_assertions)]
1070            if debug_cold_cache {
1071                std::thread::sleep(Duration::from_millis(10));
1072            }
1073            let scan_started = Instant::now();
1074            let oxc_result =
1075                self.oxc_result_for_scan(&scan_job, &scan_job.scope_files, &force_reparse_files)?;
1076            let scan_result = run_tier2_scan(&scan_job, oxc_result.as_ref());
1077            phases.scan += scan_started.elapsed();
1078            phases.scanned_files += scan_files.len();
1079            let scan_success = scan_result.outcome.map_err(|message| {
1080                format!("{} incremental scan failed: {message}", job.category)
1081            })?;
1082            updates.upserts.extend(scan_success.contributions);
1083        }
1084
1085        let has_updates = !updates.upserts.is_empty()
1086            || !updates.deletes.is_empty()
1087            || !updates.metadata_updates.is_empty();
1088        if !has_updates && !dead_code_callgraph_refresh {
1089            if let Some(aggregate) = cache
1090                .get_aggregated_for_config(&job.key, job.config.as_ref())
1091                .map_err(|error| error.to_string())?
1092            {
1093                cache
1094                    .touch_tier2_last_full_run(job.category)
1095                    .map_err(|error| error.to_string())?;
1096                phases.log(job.category);
1097                return Ok(InspectScanSuccess {
1098                    scanned_files: scan_files,
1099                    contributions: Vec::new(),
1100                    aggregate,
1101                });
1102            }
1103        }
1104
1105        let db_started = Instant::now();
1106        let mut contribution_set_hash = if has_updates {
1107            cache
1108                .apply_contribution_updates_for_config(job.category, updates, job.config.as_ref())
1109                .map_err(|error| error.to_string())?
1110        } else {
1111            cache
1112                .contribution_set_hash_for_config(job.category, job.config.as_ref())
1113                .map_err(|error| error.to_string())?
1114        };
1115        phases.db = db_started.elapsed();
1116
1117        if !dead_code_callgraph_refresh {
1118            if let Some(aggregate) = cache
1119                .load_aggregate_if_hash_matches(job.category, &contribution_set_hash)
1120                .map_err(|error| error.to_string())?
1121            {
1122                cache
1123                    .touch_tier2_last_full_run(job.category)
1124                    .map_err(|error| error.to_string())?;
1125                let contributions = load_contributions(cache, job)?;
1126                phases.log(job.category);
1127                return Ok(InspectScanSuccess {
1128                    scanned_files: scan_files,
1129                    contributions,
1130                    aggregate,
1131                });
1132            }
1133        }
1134
1135        let refresh_dead_code_facts = if job.category == InspectCategory::DeadCode {
1136            dead_code_contributions_need_fact_refresh(cache, job)?
1137        } else {
1138            false
1139        };
1140        let refresh_unused_exports_facts = if job.category == InspectCategory::UnusedExports {
1141            unused_exports_contributions_need_fact_refresh(cache, job)?
1142        } else {
1143            false
1144        };
1145        let refresh_duplicates_facts = if job.category == InspectCategory::Duplicates {
1146            duplicates_contributions_need_fact_refresh(cache, job)?
1147        } else {
1148            false
1149        };
1150        if refresh_dead_code_facts || refresh_unused_exports_facts || refresh_duplicates_facts {
1151            // Raw-facts contributions can be rolled up after manifest/resolver
1152            // edits without re-reading source. Only legacy verdict-bearing or
1153            // facts-version-mismatched caches need a one-time full refresh before
1154            // verdicts/roots can be recomputed globally.
1155            let full_scan_files = current_by_relative.into_values().collect::<Vec<_>>();
1156            if !full_scan_files.is_empty() {
1157                let mut rescan_job = job.clone();
1158                rescan_job.job_id = self.next_job_id.fetch_add(1, Ordering::Relaxed);
1159                rescan_job.scope_files = full_scan_files.clone();
1160                if rescan_job.category == InspectCategory::DeadCode
1161                    && rescan_job.callgraph_snapshot.is_none()
1162                {
1163                    let snapshot_started = Instant::now();
1164                    rescan_job.callgraph_snapshot = build_tier2_callgraph_snapshot_with_refresh(
1165                        &rescan_job,
1166                        options.allow_callgraph_cold_build,
1167                        &callgraph_refresh_files,
1168                    );
1169                    phases.snapshot += snapshot_started.elapsed();
1170                }
1171                let scan_started = Instant::now();
1172                let oxc_result = self.oxc_result_for_scan(
1173                    &rescan_job,
1174                    &rescan_job.scope_files,
1175                    &force_reparse_files,
1176                )?;
1177                let scan_result = run_tier2_scan(&rescan_job, oxc_result.as_ref());
1178                phases.scan += scan_started.elapsed();
1179                phases.scanned_files += full_scan_files.len();
1180                let scan_success = scan_result.outcome.map_err(|message| {
1181                    format!(
1182                        "{} full rescan after entry-point cache miss failed: {message}",
1183                        job.category
1184                    )
1185                })?;
1186                let rescan_updates = Tier2ContributionUpdates {
1187                    upserts: scan_success.contributions,
1188                    ..Tier2ContributionUpdates::default()
1189                };
1190                let db_started = Instant::now();
1191                contribution_set_hash = cache
1192                    .apply_contribution_updates_for_config(
1193                        job.category,
1194                        rescan_updates,
1195                        job.config.as_ref(),
1196                    )
1197                    .map_err(|error| error.to_string())?;
1198                phases.db += db_started.elapsed();
1199                aggregate_job.callgraph_snapshot = rescan_job.callgraph_snapshot.clone();
1200                scan_files = full_scan_files;
1201
1202                if !dead_code_callgraph_refresh {
1203                    if let Some(aggregate) = cache
1204                        .load_aggregate_if_hash_matches(job.category, &contribution_set_hash)
1205                        .map_err(|error| error.to_string())?
1206                    {
1207                        cache
1208                            .touch_tier2_last_full_run(job.category)
1209                            .map_err(|error| error.to_string())?;
1210                        let contributions = load_contributions(cache, job)?;
1211                        phases.log(job.category);
1212                        return Ok(InspectScanSuccess {
1213                            scanned_files: scan_files,
1214                            contributions,
1215                            aggregate,
1216                        });
1217                    }
1218                }
1219            }
1220        }
1221
1222        if aggregate_job.category == InspectCategory::DeadCode
1223            && aggregate_job.callgraph_snapshot.is_none()
1224        {
1225            let snapshot_started = Instant::now();
1226            aggregate_job.callgraph_snapshot = build_tier2_callgraph_snapshot_with_refresh(
1227                &aggregate_job,
1228                options.allow_callgraph_cold_build,
1229                &callgraph_refresh_files,
1230            );
1231            phases.snapshot += snapshot_started.elapsed();
1232        }
1233        let rollup_started = Instant::now();
1234        let contributions = load_contributions(cache, &aggregate_job)?;
1235        let aggregate = roll_up_tier2_contributions(&aggregate_job, &contributions);
1236        cache
1237            .store_tier2_aggregate(job.key.clone(), &contribution_set_hash, aggregate.clone())
1238            .map_err(|error| error.to_string())?;
1239        phases.rollup = rollup_started.elapsed();
1240        phases.log(job.category);
1241
1242        Ok(InspectScanSuccess {
1243            scanned_files: scan_files,
1244            contributions,
1245            aggregate,
1246        })
1247    }
1248
1249    fn enqueue_with_waiter(
1250        &self,
1251        snapshot: InspectSnapshot,
1252        category: InspectCategory,
1253        caller_scope: JobScope,
1254        key: JobKey,
1255        waiter_tx: WaiterTx,
1256        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
1257    ) -> Result<(), String> {
1258        let mut in_flight = self
1259            .in_flight
1260            .lock()
1261            .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
1262        if let Some(waiters) = in_flight.get_mut(&key) {
1263            waiters.push(Waiter { tx: waiter_tx });
1264            return Ok(());
1265        }
1266
1267        in_flight.insert(key.clone(), vec![Waiter { tx: waiter_tx }]);
1268        drop(in_flight);
1269
1270        if let Err(message) = self.enqueue_new_job(
1271            snapshot,
1272            category,
1273            caller_scope,
1274            key.clone(),
1275            callgraph_snapshot,
1276        ) {
1277            if let Ok(mut in_flight) = self.in_flight.lock() {
1278                in_flight.remove(&key);
1279            }
1280            return Err(message);
1281        }
1282        Ok(())
1283    }
1284
1285    fn enqueue_without_waiter(
1286        &self,
1287        snapshot: InspectSnapshot,
1288        category: InspectCategory,
1289        caller_scope: JobScope,
1290        key: JobKey,
1291        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
1292    ) -> Result<(), String> {
1293        let mut in_flight = self
1294            .in_flight
1295            .lock()
1296            .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
1297        if in_flight.contains_key(&key) {
1298            return Ok(());
1299        }
1300        in_flight.insert(key.clone(), Vec::new());
1301        drop(in_flight);
1302
1303        if let Err(message) = self.enqueue_new_job(
1304            snapshot,
1305            category,
1306            caller_scope,
1307            key.clone(),
1308            callgraph_snapshot,
1309        ) {
1310            if let Ok(mut in_flight) = self.in_flight.lock() {
1311                in_flight.remove(&key);
1312            }
1313            return Err(message);
1314        }
1315        Ok(())
1316    }
1317
1318    fn enqueue_new_job(
1319        &self,
1320        snapshot: InspectSnapshot,
1321        category: InspectCategory,
1322        caller_scope: JobScope,
1323        key: JobKey,
1324        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
1325    ) -> Result<(), String> {
1326        let scan_scope = if category.is_tier2() {
1327            JobScope::for_project(snapshot.project_root.clone())
1328        } else {
1329            caller_scope
1330        };
1331        let scope_files = scope_files(&snapshot.project_root, &scan_scope);
1332        let job = InspectJob {
1333            job_id: self.next_job_id.fetch_add(1, Ordering::Relaxed),
1334            key,
1335            category,
1336            scope_files,
1337            project_root: snapshot.project_root,
1338            inspect_dir: snapshot.inspect_dir,
1339            config: snapshot.config,
1340            symbol_cache: snapshot.symbol_cache,
1341            callgraph_snapshot,
1342        };
1343        self.request_tx
1344            .send(job)
1345            .map_err(|_| "inspect dispatch loop is unavailable".to_string())
1346    }
1347
1348    fn wait_for_outcome(
1349        &self,
1350        key: JobKey,
1351        caller_scope: JobScope,
1352        cache: Arc<InspectCache>,
1353        waiter_rx: Receiver<JobOutcome>,
1354        snapshot: InspectSnapshot,
1355    ) -> JobOutcome {
1356        let timeout = after(self.soft_deadline);
1357        let result_rx = self.result_rx.clone();
1358        loop {
1359            select! {
1360                recv(waiter_rx) -> outcome => {
1361                    return match outcome {
1362                        Ok(outcome) => filter_outcome_for_scope_with_contributions(
1363                            outcome,
1364                            &snapshot,
1365                            key.category,
1366                            cache.as_ref(),
1367                            &caller_scope,
1368                        ),
1369                        Err(_) => self.timeout_outcome(&key, &caller_scope, &cache, &snapshot),
1370                    };
1371                }
1372                recv(result_rx) -> result => {
1373                    match result {
1374                        Ok(result) => self.route_completion(result),
1375                        Err(_) => return self.timeout_outcome(&key, &caller_scope, &cache, &snapshot),
1376                    }
1377                }
1378                recv(timeout) -> _ => {
1379                    return self.timeout_outcome(&key, &caller_scope, &cache, &snapshot);
1380                }
1381            }
1382        }
1383    }
1384
1385    fn timeout_outcome(
1386        &self,
1387        key: &JobKey,
1388        caller_scope: &JobScope,
1389        cache: &InspectCache,
1390        snapshot: &InspectSnapshot,
1391    ) -> JobOutcome {
1392        match cache.get_aggregated_for_config(key, snapshot.config.as_ref()) {
1393            Ok(Some(cached)) => filter_outcome_for_scope_with_contributions(
1394                JobOutcome::Stale {
1395                    cached: Some(cached),
1396                    in_flight: true,
1397                },
1398                snapshot,
1399                key.category,
1400                cache,
1401                caller_scope,
1402            ),
1403            Ok(None) => JobOutcome::Pending { in_flight: true },
1404            Err(error) => JobOutcome::Failed {
1405                message: error.to_string(),
1406            },
1407        }
1408    }
1409
1410    fn route_completion(&self, result: InspectResult) {
1411        let outcome = self.completion_outcome(result.clone());
1412        let waiters = self
1413            .in_flight
1414            .lock()
1415            .ok()
1416            .and_then(|mut in_flight| in_flight.remove(&result.key))
1417            .unwrap_or_default();
1418        for waiter in waiters {
1419            let _ = waiter.tx.send(outcome.clone());
1420        }
1421    }
1422
1423    fn route_tier2_reuse_completion(&self, result: InspectResult) {
1424        let outcome = match result.outcome.clone() {
1425            Ok(success) => JobOutcome::Fresh {
1426                payload: success.aggregate,
1427            },
1428            Err(message) => JobOutcome::Failed { message },
1429        };
1430        let waiters = self
1431            .in_flight
1432            .lock()
1433            .ok()
1434            .and_then(|mut in_flight| in_flight.remove(&result.key))
1435            .unwrap_or_default();
1436        for waiter in waiters {
1437            let _ = waiter.tx.send(outcome.clone());
1438        }
1439        // Signal the main-thread drain that a background (watcher-driven) Tier-2
1440        // scan finished so it can refresh the status bar. This path bypasses
1441        // `result_rx`/`drain_completions`, so without this counter the bar's
1442        // counts and `~` marker would only update on a manual `aft_inspect`.
1443        self.reuse_completions.fetch_add(1, Ordering::SeqCst);
1444    }
1445
1446    /// Snapshot the cumulative count of reuse-path (watcher-driven) Tier-2
1447    /// completions. The main-thread drain compares this against its last-seen
1448    /// value to detect background scans that finished since the previous tick.
1449    pub fn reuse_completion_count(&self) -> u64 {
1450        self.reuse_completions.load(Ordering::SeqCst)
1451    }
1452
1453    fn completion_outcome(&self, result: InspectResult) -> JobOutcome {
1454        let cache =
1455            match self.cache_for_paths(result.inspect_dir.clone(), result.project_root.clone()) {
1456                Ok(cache) => cache,
1457                Err(message) => return JobOutcome::Failed { message },
1458            };
1459
1460        match result.outcome {
1461            Ok(success) => {
1462                let store_result = if result.category.is_tier2() {
1463                    cache.store_tier2_result_for_config(
1464                        result.key.clone(),
1465                        &success.scanned_files,
1466                        &success.contributions,
1467                        success.aggregate.clone(),
1468                        result.config.as_ref(),
1469                    )
1470                } else {
1471                    cache.store_aggregated(result.key, success.aggregate.clone())
1472                };
1473
1474                match store_result {
1475                    Ok(()) => JobOutcome::Fresh {
1476                        payload: success.aggregate,
1477                    },
1478                    Err(error) => JobOutcome::Failed {
1479                        message: error.to_string(),
1480                    },
1481                }
1482            }
1483            Err(message) => JobOutcome::Failed { message },
1484        }
1485    }
1486}
1487
1488impl Default for InspectManager {
1489    fn default() -> Self {
1490        Self::new()
1491    }
1492}
1493
1494fn validate_tier2_read_category(category: InspectCategory) -> Result<(), JobOutcome> {
1495    if !category.is_active() {
1496        return Err(JobOutcome::Failed {
1497            message: format!("inspect category '{category}' is disabled in v0.33"),
1498        });
1499    }
1500    if !category.is_tier2() {
1501        return Err(JobOutcome::Failed {
1502            message: format!("inspect category '{category}' is not a Tier 2 category"),
1503        });
1504    }
1505    Ok(())
1506}
1507
1508/// Phase-level wall-time attribution for one Tier-2 reuse=miss pass.
1509///
1510/// Exists to self-attribute pathological scans (e.g. a normally-100ms
1511/// unused_exports pass once took 677s under heavy machine load) without
1512/// needing a lucky live `sample`. Logged as ONE info line per pass, only when
1513/// real work happened (freshness/scan/snapshot/rollup/db), so quiet reuse passes stay silent.
1514#[derive(Default)]
1515struct Tier2PhaseTimings {
1516    /// Freshness verification of cached contributions (file stat + hash reads).
1517    freshness: Duration,
1518    /// Callgraph store snapshot projection (dead_code only).
1519    snapshot: Duration,
1520    /// Scanner compute over files needing (re)scan.
1521    scan: Duration,
1522    /// SQLite contribution upserts/deletes (busy-wait contention shows here).
1523    db: Duration,
1524    /// Aggregate roll-up + store.
1525    rollup: Duration,
1526    scanned_files: usize,
1527}
1528
1529impl Tier2PhaseTimings {
1530    fn log(&self, category: InspectCategory) {
1531        let worked = self.freshness + self.scan + self.snapshot + self.rollup + self.db;
1532        if worked < Duration::from_millis(50) {
1533            return;
1534        }
1535        crate::slog_info!(
1536            "perf tier2 phases category={} freshness={}ms snapshot={}ms scan={}ms({} files) db={}ms rollup={}ms",
1537            category,
1538            self.freshness.as_millis(),
1539            self.snapshot.as_millis(),
1540            self.scan.as_millis(),
1541            self.scanned_files,
1542            self.db.as_millis(),
1543            self.rollup.as_millis()
1544        );
1545    }
1546}
1547
1548fn scope_files(project_root: &Path, scope: &JobScope) -> Vec<PathBuf> {
1549    let mut files = crate::callgraph::walk_project_files(project_root)
1550        .filter(|path| scope.contains(path))
1551        .collect::<Vec<_>>();
1552    files.sort();
1553    files
1554}
1555
1556fn forced_relative_paths(job: &InspectJob, paths: &BTreeSet<PathBuf>) -> BTreeSet<String> {
1557    let mut keys = BTreeSet::new();
1558    for path in paths {
1559        let absolute = if path.is_absolute() {
1560            path.clone()
1561        } else {
1562            job.project_root.join(path)
1563        };
1564        keys.insert(relative_cache_key(&job.project_root, &absolute));
1565        if let Ok(canonical) = std::fs::canonicalize(&absolute) {
1566            keys.insert(relative_cache_key(&job.project_root, &canonical));
1567        }
1568    }
1569    keys
1570}
1571
1572fn panic_tier2_reuse_for_debug(job: &InspectJob) {
1573    #[cfg(not(debug_assertions))]
1574    let _ = job;
1575    #[cfg(debug_assertions)]
1576    {
1577        if !env_project_root_matches("AFT_TEST_TIER2_REUSE_PANIC_ROOT", &job.project_root) {
1578            return;
1579        }
1580        let should_panic = std::env::var("AFT_TEST_TIER2_REUSE_PANIC_CATEGORY")
1581            .ok()
1582            .is_some_and(|category| category == job.category.as_str());
1583        if should_panic {
1584            panic!("forced tier2 reuse panic for {}", job.category);
1585        }
1586    }
1587}
1588
1589fn delay_direct_force_followup_deadline_check_for_debug(project_root: &Path) {
1590    #[cfg(not(debug_assertions))]
1591    let _ = project_root;
1592    #[cfg(debug_assertions)]
1593    {
1594        if !env_project_root_matches("AFT_TEST_DIRECT_FORCE_FOLLOWUP_DELAY_ROOT", project_root) {
1595            return;
1596        }
1597        if let Some(delay_ms) = std::env::var("AFT_TEST_DIRECT_FORCE_FOLLOWUP_DELAY_MS")
1598            .ok()
1599            .and_then(|raw| raw.parse::<u64>().ok())
1600        {
1601            std::thread::sleep(Duration::from_millis(delay_ms));
1602        }
1603    }
1604}
1605
1606fn delay_tier2_reuse_for_debug(project_root: &Path) {
1607    #[cfg(not(debug_assertions))]
1608    let _ = project_root;
1609    #[cfg(debug_assertions)]
1610    {
1611        if !env_project_root_matches("AFT_TEST_TIER2_REUSE_DELAY_ROOT", project_root) {
1612            return;
1613        }
1614        if let Some(delay_ms) = std::env::var("AFT_TEST_TIER2_REUSE_DELAY_MS")
1615            .ok()
1616            .and_then(|raw| raw.parse::<u64>().ok())
1617        {
1618            std::thread::sleep(Duration::from_millis(delay_ms));
1619        }
1620    }
1621}
1622
1623#[cfg(debug_assertions)]
1624fn env_project_root_matches(var: &str, project_root: &Path) -> bool {
1625    let Some(raw) = std::env::var_os(var) else {
1626        return true;
1627    };
1628    let expected = PathBuf::from(raw);
1629    let expected = std::fs::canonicalize(&expected).unwrap_or(expected);
1630    let actual = std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
1631    expected == actual
1632}
1633
1634fn current_project_files(project_root: &Path, files: &[PathBuf]) -> BTreeMap<String, PathBuf> {
1635    files
1636        .iter()
1637        .map(|file| (relative_cache_key(project_root, file), file.clone()))
1638        .collect()
1639}
1640
1641fn insert_callgraph_refresh_path(paths: &mut BTreeSet<PathBuf>, path: PathBuf) {
1642    if callgraph_store_indexes_path(&path) {
1643        paths.insert(path);
1644    }
1645}
1646
1647fn callgraph_store_indexes_path(path: &Path) -> bool {
1648    crate::parser::detect_language(path).is_some()
1649}
1650
1651fn tier2_benchmark_logging_enabled() -> bool {
1652    std::env::var_os("AFT_SETTLE_BENCH_LOG").is_some()
1653}
1654
1655fn log_tier2_benchmark_category_start(job: &InspectJob) {
1656    if !tier2_benchmark_logging_enabled() {
1657        return;
1658    }
1659    crate::slog_info!(
1660        "settle bench: tier2_category_start category={} job_id={} files={}",
1661        job.category.as_str(),
1662        job.job_id,
1663        job.scope_files.len()
1664    );
1665}
1666
1667fn log_tier2_benchmark_category_end(result: &InspectResult) {
1668    if !tier2_benchmark_logging_enabled() {
1669        return;
1670    }
1671    match &result.outcome {
1672        Ok(success) => {
1673            let count = success
1674                .aggregate
1675                .get("count")
1676                .and_then(serde_json::Value::as_u64)
1677                .unwrap_or(0);
1678            crate::slog_info!(
1679                "settle bench: tier2_category_end category={} job_id={} status=success total_ms={} scanned_files={} contributions={} count={}",
1680                result.category.as_str(),
1681                result.job_id,
1682                result.duration.as_millis(),
1683                success.scanned_files.len(),
1684                success.contributions.len(),
1685                count
1686            );
1687        }
1688        Err(message) => {
1689            crate::slog_info!(
1690                "settle bench: tier2_category_end category={} job_id={} status=failed total_ms={} error={}",
1691                result.category.as_str(),
1692                result.job_id,
1693                result.duration.as_millis(),
1694                message.replace('\n', " ")
1695            );
1696        }
1697    }
1698}
1699
1700fn build_tier2_callgraph_snapshot(
1701    job: &InspectJob,
1702    allow_cold_build: bool,
1703) -> Option<Arc<CallgraphSnapshot>> {
1704    build_tier2_callgraph_snapshot_with_refresh(job, allow_cold_build, &[])
1705}
1706
1707fn build_tier2_callgraph_snapshot_with_refresh(
1708    job: &InspectJob,
1709    allow_cold_build: bool,
1710    refresh_paths: &[PathBuf],
1711) -> Option<Arc<CallgraphSnapshot>> {
1712    let started = Instant::now();
1713    if !job.config.callgraph_store {
1714        crate::slog_info!(
1715            "tier2 dead_code: callgraph store disabled; reporting callgraph_unavailable"
1716        );
1717        return None;
1718    }
1719
1720    let callgraph_dirs = callgraph_store_dirs_from_inspect_dir(&job.inspect_dir);
1721    if callgraph_dirs.is_empty() {
1722        crate::slog_info!(
1723            "tier2 dead_code: inspect_dir has no harness parent ({}); reporting callgraph_unavailable",
1724            job.inspect_dir.display()
1725        );
1726        return None;
1727    };
1728
1729    for (index, callgraph_dir) in callgraph_dirs.iter().enumerate() {
1730        // Background refresh may rebuild call graphs for moved project roots.
1731        // Direct inspect cannot trigger that rebuild, so it opens without repair
1732        // and reports callgraph_unavailable when a rebuild is needed.
1733        let store = match if allow_cold_build {
1734            CallGraphStore::open_ready_repairing(callgraph_dir.clone(), job.project_root.clone())
1735        } else {
1736            CallGraphStore::open_ready_no_rebuild(callgraph_dir.clone(), job.project_root.clone())
1737        } {
1738            Ok(Some(store)) => store,
1739            Ok(None) => {
1740                crate::slog_info!(
1741                    "tier2 dead_code: callgraph store unavailable at {} (cold/building/not ready); trying fallback={}",
1742                    callgraph_dir.display(),
1743                    index + 1 < callgraph_dirs.len()
1744                );
1745                continue;
1746            }
1747            Err(error) => {
1748                crate::slog_warn!(
1749                    "tier2 dead_code: failed to open callgraph store at {}: {}; trying fallback={}",
1750                    callgraph_dir.display(),
1751                    error,
1752                    index + 1 < callgraph_dirs.len()
1753                );
1754                continue;
1755            }
1756        };
1757
1758        if !refresh_paths.is_empty() {
1759            match store.refresh_files(refresh_paths) {
1760                Ok(stats) => {
1761                    crate::slog_info!(
1762                        "tier2 dead_code: refreshed callgraph store at {} for {} watcher path(s): changed={} deleted={} refreshed_own={}",
1763                        callgraph_dir.display(),
1764                        refresh_paths.len(),
1765                        stats.changed_files.len(),
1766                        stats.deleted_files.len(),
1767                        stats.refreshed_own_files
1768                    );
1769                }
1770                Err(error) => {
1771                    crate::slog_warn!(
1772                        "tier2 dead_code: failed to refresh callgraph store at {} before projection: {}",
1773                        callgraph_dir.display(),
1774                        error
1775                    );
1776                    if let Err(mark_error) = store.mark_files_stale(refresh_paths) {
1777                        crate::slog_warn!(
1778                            "tier2 dead_code: failed to mark callgraph store files stale at {} after refresh failure: {}",
1779                            callgraph_dir.display(),
1780                            mark_error
1781                        );
1782                    }
1783                }
1784            }
1785        }
1786
1787        let snapshot = match project_dead_code_snapshot(store.sqlite_path()) {
1788            Ok(snapshot) => snapshot,
1789            Err(CallGraphStoreError::Unavailable(message)) => {
1790                crate::slog_info!(
1791                    "tier2 dead_code: callgraph store projection unavailable at {} ({}); trying fallback={}",
1792                    callgraph_dir.display(),
1793                    message,
1794                    index + 1 < callgraph_dirs.len()
1795                );
1796                continue;
1797            }
1798            Err(error) => {
1799                crate::slog_warn!(
1800                    "tier2 dead_code: callgraph store projection failed at {}: {}; trying fallback={}",
1801                    callgraph_dir.display(),
1802                    error,
1803                    index + 1 < callgraph_dirs.len()
1804                );
1805                continue;
1806            }
1807        };
1808
1809        if index > 0 {
1810            crate::slog_info!(
1811                "tier2 dead_code: using ready callgraph store fallback {} for inspect_dir {}",
1812                callgraph_dir.display(),
1813                job.inspect_dir.display()
1814            );
1815        }
1816
1817        crate::slog_info!(
1818            "perf tier2_callgraph_snapshot: source=callgraph_store files={} exports={} edges={} entry_points={} ms={}",
1819            snapshot.files.len(),
1820            snapshot.exported_symbols.len(),
1821            snapshot.outbound_calls.len(),
1822            snapshot.entry_points.len(),
1823            started.elapsed().as_millis()
1824        );
1825
1826        return Some(Arc::new(snapshot));
1827    }
1828
1829    crate::slog_info!(
1830        "tier2 dead_code: no ready callgraph store found for inspect_dir {}; reporting callgraph_unavailable",
1831        job.inspect_dir.display()
1832    );
1833    None
1834}
1835
1836fn callgraph_store_dir_from_inspect_dir(inspect_dir: &Path) -> Option<PathBuf> {
1837    inspect_dir
1838        .parent()
1839        .map(|harness_dir| harness_dir.join("callgraph"))
1840}
1841
1842fn callgraph_store_dirs_from_inspect_dir(inspect_dir: &Path) -> Vec<PathBuf> {
1843    let Some(primary) = callgraph_store_dir_from_inspect_dir(inspect_dir) else {
1844        return Vec::new();
1845    };
1846    let mut dirs = vec![primary.clone()];
1847
1848    let Some(harness_dir) = inspect_dir.parent() else {
1849        return dirs;
1850    };
1851    let Some(storage_dir) = harness_dir.parent() else {
1852        return dirs;
1853    };
1854    let Ok(entries) = std::fs::read_dir(storage_dir) else {
1855        return dirs;
1856    };
1857
1858    let mut siblings = entries
1859        .filter_map(Result::ok)
1860        .map(|entry| entry.path().join("callgraph"))
1861        .filter(|dir| dir != &primary && dir.is_dir())
1862        .collect::<Vec<_>>();
1863    siblings.sort();
1864    siblings.dedup();
1865    dirs.extend(siblings);
1866    dirs
1867}
1868
1869#[cfg(test)]
1870fn canonicalize_for_snapshot(path: &Path) -> PathBuf {
1871    std::fs::canonicalize(path).unwrap_or_else(|_| normalize_path(path))
1872}
1873
1874fn load_contribution_freshness(
1875    cache: &InspectCache,
1876    category: InspectCategory,
1877) -> Result<Vec<CachedContributionFreshness>, String> {
1878    cache
1879        .contribution_freshness(category)
1880        .map_err(|error| error.to_string())
1881        .map(|records| {
1882            records
1883                .into_iter()
1884                .map(|(file_path, freshness)| CachedContributionFreshness {
1885                    file_path,
1886                    freshness,
1887                })
1888                .collect()
1889        })
1890}
1891
1892fn freshness_record_relative_key(record: &CachedContributionFreshness) -> String {
1893    record.file_path.to_string_lossy().to_string()
1894}
1895
1896fn relative_cache_key(project_root: &Path, path: &Path) -> String {
1897    path.strip_prefix(project_root)
1898        .unwrap_or(path)
1899        .to_string_lossy()
1900        .to_string()
1901}
1902
1903fn load_contributions(
1904    cache: &InspectCache,
1905    job: &InspectJob,
1906) -> Result<Vec<FileContribution>, String> {
1907    cache
1908        .load_tier2_contributions(job.category)
1909        .map_err(|error| error.to_string())
1910        .map(|records| {
1911            records
1912                .into_iter()
1913                .map(|record| contribution_from_record(&job.project_root, record))
1914                .collect()
1915        })
1916}
1917
1918fn dead_code_contributions_need_fact_refresh(
1919    cache: &InspectCache,
1920    job: &InspectJob,
1921) -> Result<bool, String> {
1922    let contributions = load_contributions(cache, job)?;
1923    Ok(contributions
1924        .iter()
1925        .any(dead_code_contribution_needs_fact_refresh))
1926}
1927
1928fn dead_code_contribution_needs_fact_refresh(contribution: &FileContribution) -> bool {
1929    let Ok(parsed) =
1930        serde_json::from_value::<DeadCodeRefreshContribution>(contribution.contribution.clone())
1931    else {
1932        return true;
1933    };
1934
1935    if parsed.facts_format_version
1936        != Some(super::scanners::dead_code::DEAD_CODE_FACTS_FORMAT_VERSION)
1937    {
1938        return true;
1939    }
1940
1941    matches!(
1942        parsed.oxc_facts,
1943        Some(facts) if facts.format_version != FACTS_FORMAT_VERSION
1944    )
1945}
1946
1947fn unused_exports_contributions_need_fact_refresh(
1948    cache: &InspectCache,
1949    job: &InspectJob,
1950) -> Result<bool, String> {
1951    let contributions = load_contributions(cache, job)?;
1952    Ok(contributions
1953        .iter()
1954        .any(unused_exports_contribution_needs_fact_refresh))
1955}
1956
1957/// Duplicates contributions written before v0.44 lack the `line_count` field
1958/// (serde defaults it to 0), so a cached roll-up computes total_analyzed_lines
1959/// as 0 and the summary renders "0.0% of 0 analyzed lines". One full rescan
1960/// repopulates the counts; fresh contributions always carry line_count.
1961fn duplicates_contributions_need_fact_refresh(
1962    cache: &InspectCache,
1963    job: &InspectJob,
1964) -> Result<bool, String> {
1965    let contributions = load_contributions(cache, job)?;
1966    Ok(contributions
1967        .iter()
1968        .any(|contribution| contribution.contribution.get("line_count").is_none()))
1969}
1970
1971fn unused_exports_contribution_needs_fact_refresh(contribution: &FileContribution) -> bool {
1972    let top_level_oxc = contribution
1973        .contribution
1974        .get("provenance")
1975        .and_then(Value::as_str)
1976        == Some(OXC_PROVENANCE);
1977    let Ok(parsed) =
1978        serde_json::from_value::<UnusedExportsContribution>(contribution.contribution.clone())
1979    else {
1980        return false;
1981    };
1982    let uses_oxc =
1983        top_level_oxc || parsed.oxc_facts.is_some() || parsed.exports.iter().any(export_uses_oxc);
1984    if !uses_oxc {
1985        return false;
1986    }
1987
1988    !matches!(
1989        parsed.oxc_facts,
1990        Some(facts) if facts.format_version == FACTS_FORMAT_VERSION
1991    )
1992}
1993
1994fn contribution_from_record(
1995    project_root: &Path,
1996    record: super::cache::ContributionRecord,
1997) -> FileContribution {
1998    FileContribution::new(
1999        record.category,
2000        project_root.join(record.file_path),
2001        record.freshness,
2002        record.contribution,
2003    )
2004    .with_type_ref_names(record.type_ref_names)
2005}
2006
2007fn run_tier2_scan(job: &InspectJob, oxc_result: Option<&OxcEngineResult>) -> InspectResult {
2008    use super::scanners;
2009
2010    match job.category {
2011        InspectCategory::DeadCode => {
2012            scanners::dead_code::run_dead_code_scan_with_oxc(job, oxc_result)
2013        }
2014        InspectCategory::UnusedExports => {
2015            scanners::unused_exports::run_unused_exports_scan_with_oxc(job, oxc_result)
2016        }
2017        InspectCategory::Duplicates => scanners::duplicates::run_duplicates_scan(job),
2018        InspectCategory::Cycles => scanners::cycles::run_cycles_scan_with_oxc(job, oxc_result),
2019        other => InspectResult::failed(
2020            job,
2021            format!("inspect category '{other}' is not an active Tier 2 scanner"),
2022            Duration::from_secs(0),
2023        ),
2024    }
2025}
2026
2027fn roll_up_tier2_contributions(job: &InspectJob, contributions: &[FileContribution]) -> Value {
2028    roll_up_tier2_contributions_with_limit(job, contributions, Some(MAX_DRILL_DOWN_ITEMS))
2029}
2030
2031fn roll_up_tier2_contributions_with_limit(
2032    job: &InspectJob,
2033    contributions: &[FileContribution],
2034    drill_down_limit: Option<usize>,
2035) -> Value {
2036    match job.category {
2037        InspectCategory::DeadCode => {
2038            roll_up_dead_code_contributions(job, contributions, drill_down_limit)
2039        }
2040        InspectCategory::UnusedExports => {
2041            roll_up_unused_exports_contributions(job, contributions, drill_down_limit)
2042        }
2043        InspectCategory::Duplicates => {
2044            roll_up_duplicate_contributions(job, contributions, drill_down_limit)
2045        }
2046        InspectCategory::Cycles => {
2047            roll_up_cycle_contributions(job, contributions, drill_down_limit)
2048        }
2049        _ => json!({
2050            "count": 0,
2051            "items": [],
2052            "scanned_files": contributions.len(),
2053        }),
2054    }
2055}
2056
2057fn scoped_tier2_payload_from_contributions(
2058    snapshot: &InspectSnapshot,
2059    category: InspectCategory,
2060    cache: &InspectCache,
2061    project_payload: Value,
2062    scope: &JobScope,
2063) -> Result<Value, String> {
2064    if scope.is_project_wide() {
2065        return Ok(project_payload);
2066    }
2067
2068    let project_scope = JobScope::for_project(snapshot.project_root.clone());
2069    let rollup_job = scoped_tier2_rollup_job(snapshot, category, &project_scope);
2070    let contributions = load_contributions(cache, &rollup_job)?;
2071    let full_payload = roll_up_tier2_contributions_with_limit(&rollup_job, &contributions, None);
2072    let scoped_payload = filter_payload_for_scope(full_payload, scope);
2073    Ok(cap_payload_drill_down(scoped_payload, MAX_DRILL_DOWN_ITEMS))
2074}
2075
2076fn scoped_tier2_rollup_job(
2077    snapshot: &InspectSnapshot,
2078    category: InspectCategory,
2079    scope: &JobScope,
2080) -> InspectJob {
2081    let mut job = InspectJob {
2082        job_id: 0,
2083        key: JobKey::for_project_category(category),
2084        category,
2085        scope_files: scope_files(&snapshot.project_root, scope),
2086        project_root: snapshot.project_root.clone(),
2087        inspect_dir: snapshot.inspect_dir.clone(),
2088        config: Arc::clone(&snapshot.config),
2089        symbol_cache: Arc::clone(&snapshot.symbol_cache),
2090        callgraph_snapshot: None,
2091    };
2092
2093    if category == InspectCategory::DeadCode {
2094        // Scoped read-path rollups recompute dead-code liveness from cached
2095        // contributions. Use a real ready store snapshot when one exists; if no
2096        // snapshot is available, leave it absent so the rollup reports degraded
2097        // callgraph_unavailable instead of treating an empty graph as truth.
2098        job.callgraph_snapshot = build_tier2_callgraph_snapshot(&job, false);
2099    }
2100
2101    job
2102}
2103
2104fn roll_up_dead_code_contributions(
2105    job: &InspectJob,
2106    contributions: &[FileContribution],
2107    drill_down_limit: Option<usize>,
2108) -> Value {
2109    let Some(snapshot) = job.callgraph_snapshot.as_deref() else {
2110        return super::scanners::dead_code::callgraph_unavailable_aggregate(job.scope_files.len());
2111    };
2112
2113    let public_api_files = super::scanners::dead_code::collect_public_api_files(&job.project_root);
2114    let roles = super::entry_points::resolve_project_roles(&job.project_root);
2115    super::scanners::dead_code::aggregate_dead_code_contributions_with_snapshot(
2116        &job.project_root,
2117        snapshot,
2118        contributions,
2119        &public_api_files,
2120        &roles,
2121        drill_down_limit,
2122    )
2123}
2124
2125fn roll_up_unused_exports_contributions(
2126    job: &InspectJob,
2127    contributions: &[FileContribution],
2128    drill_down_limit: Option<usize>,
2129) -> Value {
2130    let parsed = contributions
2131        .iter()
2132        .filter_map(|contribution| {
2133            serde_json::from_value::<UnusedExportsContribution>(contribution.contribution.clone())
2134                .ok()
2135        })
2136        .collect::<Vec<_>>();
2137
2138    if parsed.iter().any(|scan| scan.oxc_facts.is_some()) {
2139        return roll_up_unused_exports_oxc_contributions(job, &parsed, drill_down_limit);
2140    }
2141
2142    let (public_api_files, package_warnings) = unused_public_api_entries(&job.project_root);
2143    let mut imported_by: BTreeMap<(String, String), BTreeSet<String>> = BTreeMap::new();
2144    let mut uncertain_by: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
2145    for scan in &parsed {
2146        for import in &scan.imports {
2147            let Some(resolved_file) = &import.resolved_file else {
2148                continue;
2149            };
2150            for name in &import.named {
2151                if name == "*" {
2152                    uncertain_by
2153                        .entry(resolved_file.clone())
2154                        .or_default()
2155                        .insert(scan.file.clone());
2156                } else {
2157                    imported_by
2158                        .entry((resolved_file.clone(), name.clone()))
2159                        .or_default()
2160                        .insert(scan.file.clone());
2161                }
2162            }
2163        }
2164    }
2165
2166    let mut count = 0usize;
2167    let mut items = Vec::new();
2168    let mut generated_count = 0usize;
2169    let mut generated_items = Vec::new();
2170    let test_only_count = 0usize;
2171    let test_only_items = Vec::new();
2172    let mut uncertain_count = 0usize;
2173    let mut uncertain_items = Vec::new();
2174    for scan in &parsed {
2175        if public_api_files.contains(&scan.file) {
2176            continue;
2177        }
2178        // Mirror the fresh-scan path: fixtures/corpora/mock data are consumed
2179        // by path, never imported, so their exports always look unused.
2180        if super::job::is_test_support_file(&scan.file) {
2181            continue;
2182        }
2183        let generated_file = super::generated::is_generated_file_with_cached_hint(
2184            &job.project_root,
2185            &scan.file,
2186            scan.generated,
2187        );
2188
2189        for export in &scan.exports {
2190            if export_uses_oxc(export) {
2191                match export.verdict.unwrap_or(LivenessVerdict::Unused) {
2192                    LivenessVerdict::Used => continue,
2193                    LivenessVerdict::Uncertain => {
2194                        uncertain_count += 1;
2195                        if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
2196                            uncertain_items.push(json!({
2197                                "file": scan.file,
2198                                "symbol": export.symbol,
2199                                "kind": export.kind,
2200                                "line": export.line,
2201                                "reason": export.reason.as_deref().unwrap_or("oxc_uncertain"),
2202                                "provenance": export.provenance.as_deref().unwrap_or(OXC_PROVENANCE),
2203                            }));
2204                        }
2205                        continue;
2206                    }
2207                    LivenessVerdict::Unused => {}
2208                }
2209            } else {
2210                let imported = imported_by
2211                    .get(&(scan.file.clone(), export.symbol.clone()))
2212                    .map(|files| !files.is_empty())
2213                    .unwrap_or(false);
2214                let uncertain = uncertain_by
2215                    .get(&scan.file)
2216                    .map(|files| !files.is_empty())
2217                    .unwrap_or(false);
2218
2219                if imported {
2220                    continue;
2221                }
2222                if uncertain {
2223                    uncertain_count += 1;
2224                    if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
2225                        uncertain_items.push(json!({
2226                            "file": scan.file,
2227                            "symbol": export.symbol,
2228                            "kind": export.kind,
2229                            "line": export.line,
2230                            "reason": "wildcard_import",
2231                        }));
2232                    }
2233                    continue;
2234                }
2235            }
2236
2237            let mut item = json!({
2238                "file": scan.file,
2239                "symbol": export.symbol,
2240                "kind": export.kind,
2241                "line": export.line,
2242            });
2243            if let Some(provenance) = &export.provenance {
2244                item["provenance"] = json!(provenance);
2245            }
2246            if generated_file {
2247                item["generated"] = json!(true);
2248                generated_count += 1;
2249                generated_items.push(item);
2250            } else {
2251                count += 1;
2252                items.push(item);
2253            }
2254        }
2255    }
2256
2257    let roles = super::entry_points::resolve_project_roles(&job.project_root);
2258    let items = super::entry_points::rank_and_truncate_items(items, &roles, drill_down_limit);
2259    let generated_items =
2260        super::entry_points::rank_and_truncate_items(generated_items, &roles, drill_down_limit);
2261    let top = super::entry_points::top_preview_symbols(&items);
2262    let generated_top = generated_items
2263        .iter()
2264        .take(super::entry_points::TOP_PREVIEW_ITEMS)
2265        .cloned()
2266        .collect::<Vec<_>>();
2267    let mut all_items = items;
2268    all_items.extend(generated_items.iter().cloned());
2269    if let Some(limit) = drill_down_limit {
2270        all_items.truncate(limit);
2271    }
2272    let test_only_items =
2273        super::entry_points::rank_and_truncate_items(test_only_items, &roles, drill_down_limit);
2274    let test_only_top = test_only_items
2275        .iter()
2276        .take(super::entry_points::TOP_PREVIEW_ITEMS)
2277        .cloned()
2278        .collect::<Vec<_>>();
2279
2280    let (parse_errors, skipped_files) = unused_exports_honesty_fields(&parsed);
2281    let mut aggregate = json!({
2282        "count": count,
2283        "generated_count": generated_count,
2284        "total_count": count + test_only_count + generated_count,
2285        "items": all_items,
2286        "top": top,
2287        "generated_items": generated_items,
2288        "generated_top": generated_top,
2289        "test_only_count": test_only_count,
2290        "test_only_items": test_only_items,
2291        "test_only_top": test_only_top,
2292        "drill_down_capped": drill_down_limit.is_some_and(|limit| count + generated_count > limit),
2293        "generated_drill_down_capped": drill_down_limit.is_some_and(|limit| generated_count > limit),
2294        "test_only_drill_down_capped": drill_down_limit.is_some_and(|limit| test_only_count > limit),
2295        "scanned_files": parsed.len(),
2296        "languages_skipped": skipped_languages(&job.scope_files, LanguageSkipMode::UnusedExports),
2297        "uncertain_count": uncertain_count,
2298        "uncertain_items": uncertain_items,
2299        "complete": parse_errors.is_empty() && skipped_files.is_empty(),
2300    });
2301    if !parse_errors.is_empty() {
2302        aggregate["parse_errors"] = Value::Array(parse_errors);
2303    }
2304    if !skipped_files.is_empty() {
2305        aggregate["skipped_files"] = Value::Array(skipped_files);
2306    }
2307    if !package_warnings.is_empty() {
2308        aggregate["note"] = Value::String(package_warnings.join("; "));
2309    }
2310    aggregate
2311}
2312
2313fn roll_up_unused_exports_oxc_contributions(
2314    job: &InspectJob,
2315    parsed: &[UnusedExportsContribution],
2316    drill_down_limit: Option<usize>,
2317) -> Value {
2318    let (public_api_files, package_warnings) = unused_public_api_entries(&job.project_root);
2319    let facts = parsed
2320        .iter()
2321        .filter_map(|scan| {
2322            let oxc_facts = scan.oxc_facts.as_ref()?;
2323            let path = job.project_root.join(&scan.file);
2324            Some(FileFacts {
2325                file_id: FileId(0),
2326                path: normalize_input_path(&job.project_root, &path),
2327                content_hash: oxc_facts.content_hash.clone(),
2328                exports: oxc_facts.exports.clone(),
2329                imports: oxc_facts.imports.clone(),
2330                re_exports: oxc_facts.re_exports.clone(),
2331                dynamic_imports: oxc_facts.dynamic_imports.clone(),
2332                same_file_value_references: oxc_facts.same_file_value_references.clone(),
2333                used_import_bindings: oxc_facts.used_import_bindings.clone(),
2334                type_referenced_import_bindings: oxc_facts.type_referenced_import_bindings.clone(),
2335                value_referenced_import_bindings: oxc_facts
2336                    .value_referenced_import_bindings
2337                    .clone(),
2338                parse_error: oxc_facts.parse_error.clone(),
2339            })
2340        })
2341        .collect::<Vec<_>>();
2342    let generated_by_file = parsed
2343        .iter()
2344        .map(|scan| {
2345            (
2346                scan.file.clone(),
2347                super::generated::is_generated_file_with_cached_hint(
2348                    &job.project_root,
2349                    &scan.file,
2350                    scan.generated,
2351                ),
2352            )
2353        })
2354        .collect::<BTreeMap<_, _>>();
2355    let entry_point_set = crate::inspect::entry_points::resolve_entry_points(&job.project_root);
2356    let oxc_result = analyze_file_facts(
2357        &job.project_root,
2358        facts,
2359        AnalyzeOptions {
2360            entry_points: Vec::new(),
2361            public_api_files: entry_point_set.public_api_files(),
2362            executable_root_exports: entry_point_set.executable_root_exports(),
2363            force_reparse_files: Vec::new(),
2364            entry_reachability: false,
2365        },
2366        Vec::new(),
2367    );
2368    let roles = super::entry_points::resolve_project_roles(&job.project_root);
2369
2370    let mut count = 0usize;
2371    let mut items = Vec::new();
2372    let mut generated_count = 0usize;
2373    let mut generated_items = Vec::new();
2374    let mut test_only_count = 0usize;
2375    let mut test_only_items = Vec::new();
2376    let mut uncertain_count = 0usize;
2377    let mut uncertain_items = Vec::new();
2378    for file in &oxc_result.files {
2379        if public_api_files.contains(&file.relative_file)
2380            || super::job::is_test_support_file(&file.relative_file)
2381        {
2382            continue;
2383        }
2384        let generated_file = generated_by_file
2385            .get(&file.relative_file)
2386            .copied()
2387            .unwrap_or_else(|| {
2388                super::generated::is_generated_file(
2389                    &job.project_root,
2390                    Path::new(&file.relative_file),
2391                )
2392            });
2393
2394        for export in &file.exports {
2395            match export.verdict {
2396                LivenessVerdict::Used => {
2397                    if !is_test_file(&file.relative_file)
2398                        && !export.test_only_reference_files.is_empty()
2399                    {
2400                        let mut item = json!({
2401                            "file": file.relative_file,
2402                            "symbol": export.symbol,
2403                            "kind": export.kind,
2404                            "line": export.line,
2405                            "provenance": export.provenance,
2406                            "used_by": export.test_only_reference_files,
2407                        });
2408                        add_oxc_reexport_contexts(&mut item, &export.also_reexported);
2409                        if generated_file {
2410                            item["generated"] = json!(true);
2411                            generated_count += 1;
2412                            generated_items.push(item);
2413                        } else {
2414                            test_only_count += 1;
2415                            test_only_items.push(item);
2416                        }
2417                    }
2418                }
2419                LivenessVerdict::Uncertain => {
2420                    uncertain_count += 1;
2421                    if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
2422                        let mut item = json!({
2423                            "file": file.relative_file,
2424                            "symbol": export.symbol,
2425                            "kind": export.kind,
2426                            "line": export.line,
2427                            "reason": export.reason,
2428                            "provenance": export.provenance,
2429                        });
2430                        add_oxc_reexport_contexts(&mut item, &export.also_reexported);
2431                        uncertain_items.push(item);
2432                    }
2433                }
2434                LivenessVerdict::Unused => {
2435                    if !is_test_file(&file.relative_file)
2436                        && !export.test_only_reference_files.is_empty()
2437                    {
2438                        let mut item = json!({
2439                            "file": file.relative_file,
2440                            "symbol": export.symbol,
2441                            "kind": export.kind,
2442                            "line": export.line,
2443                            "provenance": export.provenance,
2444                            "used_by": export.test_only_reference_files,
2445                        });
2446                        add_oxc_reexport_contexts(&mut item, &export.also_reexported);
2447                        if generated_file {
2448                            item["generated"] = json!(true);
2449                            generated_count += 1;
2450                            generated_items.push(item);
2451                        } else {
2452                            test_only_count += 1;
2453                            test_only_items.push(item);
2454                        }
2455                        continue;
2456                    }
2457                    if export.has_references {
2458                        continue;
2459                    }
2460                    let mut item = json!({
2461                        "file": file.relative_file,
2462                        "symbol": export.symbol,
2463                        "kind": export.kind,
2464                        "line": export.line,
2465                        "provenance": export.provenance,
2466                    });
2467                    add_oxc_reexport_contexts(&mut item, &export.also_reexported);
2468                    if generated_file {
2469                        item["generated"] = json!(true);
2470                        generated_count += 1;
2471                        generated_items.push(item);
2472                    } else {
2473                        count += 1;
2474                        items.push(item);
2475                    }
2476                }
2477            }
2478        }
2479    }
2480
2481    let items = super::entry_points::rank_and_truncate_items(items, &roles, drill_down_limit);
2482    let generated_items =
2483        super::entry_points::rank_and_truncate_items(generated_items, &roles, drill_down_limit);
2484    let top = super::entry_points::top_preview_symbols(&items);
2485    let generated_top = generated_items
2486        .iter()
2487        .take(super::entry_points::TOP_PREVIEW_ITEMS)
2488        .cloned()
2489        .collect::<Vec<_>>();
2490    let mut all_items = items;
2491    all_items.extend(generated_items.iter().cloned());
2492    if let Some(limit) = drill_down_limit {
2493        all_items.truncate(limit);
2494    }
2495    let test_only_items =
2496        super::entry_points::rank_and_truncate_items(test_only_items, &roles, drill_down_limit);
2497    let test_only_top = test_only_items
2498        .iter()
2499        .take(super::entry_points::TOP_PREVIEW_ITEMS)
2500        .cloned()
2501        .collect::<Vec<_>>();
2502    let (mut parse_errors, skipped_files) = unused_exports_honesty_fields(parsed);
2503    for scan in parsed {
2504        if let Some(oxc_facts) = &scan.oxc_facts {
2505            if oxc_facts.format_version != FACTS_FORMAT_VERSION {
2506                parse_errors.push(json!({
2507                    "file": scan.file,
2508                    "message": format!(
2509                        "unsupported oxc facts format {}; expected {}",
2510                        oxc_facts.format_version, FACTS_FORMAT_VERSION
2511                    ),
2512                }));
2513            }
2514        }
2515    }
2516
2517    let mut aggregate = json!({
2518        "count": count,
2519        "generated_count": generated_count,
2520        "total_count": count + test_only_count + generated_count,
2521        "items": all_items,
2522        "top": top,
2523        "generated_items": generated_items,
2524        "generated_top": generated_top,
2525        "test_only_count": test_only_count,
2526        "test_only_items": test_only_items,
2527        "test_only_top": test_only_top,
2528        "drill_down_capped": drill_down_limit.is_some_and(|limit| count + generated_count > limit),
2529        "generated_drill_down_capped": drill_down_limit.is_some_and(|limit| generated_count > limit),
2530        "test_only_drill_down_capped": drill_down_limit.is_some_and(|limit| test_only_count > limit),
2531        "scanned_files": parsed.len(),
2532        "languages_skipped": skipped_languages(&job.scope_files, LanguageSkipMode::UnusedExports),
2533        "uncertain_count": uncertain_count,
2534        "uncertain_items": uncertain_items,
2535        "complete": parse_errors.is_empty() && skipped_files.is_empty(),
2536    });
2537    if !parse_errors.is_empty() {
2538        aggregate["parse_errors"] = Value::Array(parse_errors);
2539    }
2540    if !skipped_files.is_empty() {
2541        aggregate["skipped_files"] = Value::Array(skipped_files);
2542    }
2543    if !package_warnings.is_empty() {
2544        aggregate["note"] = Value::String(package_warnings.join("; "));
2545    }
2546    aggregate
2547}
2548
2549fn add_oxc_reexport_contexts(
2550    item: &mut Value,
2551    contexts: &[crate::inspect::oxc_engine::OxcReExportContext],
2552) {
2553    if !contexts.is_empty() {
2554        item["also_reexported"] = json!(contexts);
2555    }
2556}
2557
2558fn unused_exports_honesty_fields(parsed: &[UnusedExportsContribution]) -> (Vec<Value>, Vec<Value>) {
2559    let mut parse_error_keys = BTreeSet::new();
2560    let mut parse_errors = Vec::new();
2561    let mut skipped_file_keys = BTreeSet::new();
2562    let mut skipped_files = Vec::new();
2563    for contribution in parsed {
2564        for value in &contribution.parse_errors {
2565            let key = value.to_string();
2566            if parse_error_keys.insert(key) {
2567                parse_errors.push(value.clone());
2568            }
2569        }
2570        for value in &contribution.skipped_files {
2571            let key = value.to_string();
2572            if skipped_file_keys.insert(key) {
2573                skipped_files.push(value.clone());
2574            }
2575        }
2576    }
2577    (parse_errors, skipped_files)
2578}
2579
2580fn roll_up_duplicate_contributions(
2581    job: &InspectJob,
2582    contributions: &[FileContribution],
2583    drill_down_limit: Option<usize>,
2584) -> Value {
2585    super::scanners::duplicates::aggregate_duplicate_contributions_with_limit(
2586        contributions,
2587        skipped_languages(&job.scope_files, LanguageSkipMode::Duplicates),
2588        drill_down_limit,
2589        &job.config.inspect.duplicates.expected_mirrors,
2590    )
2591}
2592
2593fn roll_up_cycle_contributions(
2594    job: &InspectJob,
2595    contributions: &[FileContribution],
2596    drill_down_limit: Option<usize>,
2597) -> Value {
2598    super::scanners::cycles::aggregate_cycle_contributions_with_limit(
2599        &job.project_root,
2600        contributions,
2601        skipped_languages(&job.scope_files, LanguageSkipMode::Cycles),
2602        drill_down_limit,
2603    )
2604}
2605
2606fn cap_payload_drill_down(mut payload: Value, limit: usize) -> Value {
2607    let mut capped = false;
2608    if let Some(items) = payload.get_mut("items").and_then(Value::as_array_mut) {
2609        capped |= items.len() > limit;
2610        items.truncate(limit);
2611    }
2612    if let Some(groups) = payload.get_mut("groups").and_then(Value::as_array_mut) {
2613        capped |= groups.len() > limit;
2614        groups.truncate(limit);
2615    }
2616    if let Some(object) = payload.as_object_mut() {
2617        object.insert("drill_down_capped".to_string(), json!(capped));
2618    }
2619    payload
2620}
2621
2622const MAX_DRILL_DOWN_ITEMS: usize = 100;
2623
2624#[derive(Debug, Clone, Deserialize)]
2625struct ExportContribution {
2626    symbol: String,
2627    kind: String,
2628    line: u32,
2629    #[serde(default)]
2630    verdict: Option<LivenessVerdict>,
2631    #[serde(default)]
2632    reason: Option<String>,
2633    #[serde(default)]
2634    provenance: Option<String>,
2635}
2636
2637fn export_uses_oxc(export: &ExportContribution) -> bool {
2638    export.verdict.is_some() || export.provenance.as_deref() == Some(OXC_PROVENANCE)
2639}
2640
2641#[derive(Debug, Clone, Deserialize)]
2642struct DeadCodeRefreshContribution {
2643    #[serde(default)]
2644    facts_format_version: Option<u32>,
2645    #[serde(default)]
2646    oxc_facts: Option<OxcFactsContribution>,
2647}
2648
2649#[derive(Debug, Clone, Deserialize)]
2650struct UnusedExportsContribution {
2651    file: String,
2652    #[serde(default)]
2653    generated: bool,
2654    exports: Vec<ExportContribution>,
2655    #[serde(default)]
2656    imports: Vec<ImportContribution>,
2657    #[serde(default)]
2658    oxc_facts: Option<OxcFactsContribution>,
2659    #[serde(default)]
2660    parse_errors: Vec<Value>,
2661    #[serde(default)]
2662    skipped_files: Vec<Value>,
2663}
2664
2665#[derive(Debug, Clone, Deserialize)]
2666struct ImportContribution {
2667    resolved_file: Option<String>,
2668    named: Vec<String>,
2669}
2670
2671#[derive(Debug, Clone, Deserialize)]
2672struct OxcFactsContribution {
2673    format_version: u32,
2674    content_hash: String,
2675    exports: Vec<ExportFact>,
2676    imports: Vec<ImportFact>,
2677    re_exports: Vec<ReExportFact>,
2678    dynamic_imports: Vec<DynamicImportFact>,
2679    same_file_value_references: BTreeSet<String>,
2680    used_import_bindings: BTreeSet<String>,
2681    type_referenced_import_bindings: BTreeSet<String>,
2682    value_referenced_import_bindings: BTreeSet<String>,
2683    #[serde(default)]
2684    parse_error: Option<String>,
2685}
2686
2687#[derive(Debug, Clone, Copy)]
2688enum LanguageSkipMode {
2689    Duplicates,
2690    Cycles,
2691    UnusedExports,
2692}
2693
2694fn category_uses_oxc(category: InspectCategory) -> bool {
2695    matches!(
2696        category,
2697        InspectCategory::DeadCode | InspectCategory::UnusedExports | InspectCategory::Cycles
2698    )
2699}
2700
2701fn skipped_languages(files: &[PathBuf], mode: LanguageSkipMode) -> Vec<String> {
2702    files
2703        .iter()
2704        .filter_map(|file| skipped_language(file, mode))
2705        .collect::<BTreeSet<_>>()
2706        .into_iter()
2707        .collect()
2708}
2709
2710fn skipped_language(file: &Path, mode: LanguageSkipMode) -> Option<String> {
2711    let Some(language) = crate::parser::detect_language(file) else {
2712        return match mode {
2713            LanguageSkipMode::Duplicates => Some("unknown".to_string()),
2714            LanguageSkipMode::Cycles => Some("unknown".to_string()),
2715            LanguageSkipMode::UnusedExports => None,
2716        };
2717    };
2718
2719    let skipped = match mode {
2720        LanguageSkipMode::Duplicates => !duplicates_supports_language(language),
2721        LanguageSkipMode::Cycles => !is_js_ts_language(language),
2722        LanguageSkipMode::UnusedExports => !is_js_ts_language(language),
2723    };
2724    skipped.then(|| language_name(language).to_string())
2725}
2726
2727fn duplicates_supports_language(language: crate::parser::LangId) -> bool {
2728    !matches!(
2729        language,
2730        crate::parser::LangId::Bash
2731            | crate::parser::LangId::Html
2732            | crate::parser::LangId::Json
2733            | crate::parser::LangId::Scala
2734            | crate::parser::LangId::Solidity
2735            | crate::parser::LangId::Scss
2736            | crate::parser::LangId::Vue
2737            | crate::parser::LangId::Markdown
2738            | crate::parser::LangId::Java
2739            | crate::parser::LangId::Ruby
2740            | crate::parser::LangId::Kotlin
2741            | crate::parser::LangId::Swift
2742            | crate::parser::LangId::Php
2743            | crate::parser::LangId::Lua
2744            | crate::parser::LangId::Perl
2745            | crate::parser::LangId::Pascal
2746            | crate::parser::LangId::R
2747            | crate::parser::LangId::ObjC
2748    )
2749}
2750
2751fn is_js_ts_language(language: crate::parser::LangId) -> bool {
2752    matches!(
2753        language,
2754        crate::parser::LangId::TypeScript
2755            | crate::parser::LangId::Tsx
2756            | crate::parser::LangId::JavaScript
2757    )
2758}
2759
2760fn language_name(language: crate::parser::LangId) -> &'static str {
2761    match language {
2762        crate::parser::LangId::TypeScript => "typescript",
2763        crate::parser::LangId::Tsx => "tsx",
2764        crate::parser::LangId::JavaScript => "javascript",
2765        crate::parser::LangId::Python => "python",
2766        crate::parser::LangId::Rust => "rust",
2767        crate::parser::LangId::Go => "go",
2768        crate::parser::LangId::C => "c",
2769        crate::parser::LangId::Cpp => "cpp",
2770        crate::parser::LangId::Zig => "zig",
2771        crate::parser::LangId::CSharp => "csharp",
2772        crate::parser::LangId::Bash => "bash",
2773        crate::parser::LangId::Html => "html",
2774        crate::parser::LangId::Markdown => "markdown",
2775        crate::parser::LangId::Yaml => "yaml",
2776        crate::parser::LangId::Solidity => "solidity",
2777        crate::parser::LangId::Scss => "scss",
2778        crate::parser::LangId::Vue => "vue",
2779        crate::parser::LangId::Json => "json",
2780        crate::parser::LangId::Scala => "scala",
2781        crate::parser::LangId::Java => "java",
2782        crate::parser::LangId::Ruby => "ruby",
2783        crate::parser::LangId::Kotlin => "kotlin",
2784        crate::parser::LangId::Swift => "swift",
2785        crate::parser::LangId::Php => "php",
2786        crate::parser::LangId::Lua => "lua",
2787        crate::parser::LangId::Perl => "perl",
2788        crate::parser::LangId::Pascal => "pascal",
2789        crate::parser::LangId::R => "r",
2790        crate::parser::LangId::ObjC => "objc",
2791    }
2792}
2793
2794fn unused_public_api_entries(project_root: &Path) -> (BTreeSet<String>, Vec<String>) {
2795    let entry_points = crate::inspect::entry_points::resolve_entry_points(project_root);
2796    (
2797        entry_points.public_api_files_relative(project_root),
2798        entry_points.warnings().to_vec(),
2799    )
2800}
2801
2802fn filter_outcome_for_scope_with_contributions(
2803    outcome: JobOutcome,
2804    snapshot: &InspectSnapshot,
2805    category: InspectCategory,
2806    cache: &InspectCache,
2807    scope: &JobScope,
2808) -> JobOutcome {
2809    if !category.is_tier2() || scope.is_project_wide() {
2810        return filter_outcome_for_scope(outcome, scope);
2811    }
2812
2813    match outcome {
2814        JobOutcome::Fresh { payload } => {
2815            match scoped_tier2_payload_from_contributions(snapshot, category, cache, payload, scope)
2816            {
2817                Ok(payload) => JobOutcome::Fresh { payload },
2818                Err(message) => JobOutcome::Failed { message },
2819            }
2820        }
2821        JobOutcome::Stale { cached, in_flight } => match cached {
2822            Some(payload) => {
2823                match scoped_tier2_payload_from_contributions(
2824                    snapshot, category, cache, payload, scope,
2825                ) {
2826                    Ok(payload) => JobOutcome::Stale {
2827                        cached: Some(payload),
2828                        in_flight,
2829                    },
2830                    Err(message) => JobOutcome::Failed { message },
2831                }
2832            }
2833            None => JobOutcome::Stale {
2834                cached: None,
2835                in_flight,
2836            },
2837        },
2838        JobOutcome::Pending { in_flight } => JobOutcome::Pending { in_flight },
2839        JobOutcome::Failed { message } => JobOutcome::Failed { message },
2840    }
2841}
2842
2843fn filter_outcome_for_scope(outcome: JobOutcome, scope: &JobScope) -> JobOutcome {
2844    match outcome {
2845        JobOutcome::Fresh { payload } => JobOutcome::Fresh {
2846            payload: filter_payload_for_scope(payload, scope),
2847        },
2848        JobOutcome::Stale { cached, in_flight } => JobOutcome::Stale {
2849            cached: cached.map(|payload| filter_payload_for_scope(payload, scope)),
2850            in_flight,
2851        },
2852        JobOutcome::Pending { in_flight } => JobOutcome::Pending { in_flight },
2853        JobOutcome::Failed { message } => JobOutcome::Failed { message },
2854    }
2855}
2856
2857fn filter_payload_for_scope(mut payload: serde_json::Value, scope: &JobScope) -> serde_json::Value {
2858    if scope.is_project_wide() {
2859        return payload;
2860    }
2861
2862    // Scoped Tier 2 callers pass an uncapped rollup into this filter and cap
2863    // drill-down only afterwards, so the recomputed count below remains the
2864    // true in-scope total rather than the size of a capped sample.
2865    if let Some(items) = payload
2866        .get_mut("items")
2867        .and_then(|value| value.as_array_mut())
2868    {
2869        let count = filter_values_for_scope(items, scope);
2870        let largest_cycle = items
2871            .iter()
2872            .filter_map(|item| item.get("files").and_then(Value::as_array).map(Vec::len))
2873            .max();
2874        if let Some(object) = payload.as_object_mut() {
2875            object.insert("count".to_string(), serde_json::json!(count));
2876            if object.contains_key("largest") {
2877                object.insert(
2878                    "largest".to_string(),
2879                    serde_json::json!(largest_cycle.unwrap_or(0)),
2880                );
2881            }
2882            if object.contains_key("total_groups") {
2883                object.insert("total_groups".to_string(), serde_json::json!(count));
2884            }
2885            if object.contains_key("groups_count") {
2886                object.insert("groups_count".to_string(), serde_json::json!(count));
2887            }
2888        }
2889    }
2890
2891    if let Some(groups) = payload
2892        .get_mut("groups")
2893        .and_then(|value| value.as_array_mut())
2894    {
2895        let count = filter_values_for_scope(groups, scope);
2896        if let Some(object) = payload.as_object_mut() {
2897            object.insert("count".to_string(), serde_json::json!(count));
2898            object.insert("total_groups".to_string(), serde_json::json!(count));
2899            if object.contains_key("groups_count") {
2900                object.insert("groups_count".to_string(), serde_json::json!(count));
2901            }
2902        }
2903    }
2904
2905    // `by_language` is a project-wide breakdown computed before scope filtering.
2906    // Leaving it in a scoped payload contradicts the recomputed in-scope `count`
2907    // (e.g. count: 3 alongside `(rust 214, ts 143)`). The filtered items don't
2908    // carry per-item language, so we can't faithfully recompute it — drop it so
2909    // the scoped summary doesn't render a misleading project-wide breakdown.
2910    if let Some(object) = payload.as_object_mut() {
2911        if object.contains_key("top") {
2912            if let Some(top) = recompute_scoped_top_preview(object) {
2913                object.insert("top".to_string(), top);
2914            } else if let Some(top) = object.get_mut("top").and_then(Value::as_array_mut) {
2915                filter_values_for_scope(top, scope);
2916            }
2917        }
2918        if object.contains_key("duplicated_lines") {
2919            recompute_duplicate_payload_stats(object);
2920        }
2921        object.remove("by_language");
2922    }
2923
2924    payload
2925}
2926
2927fn recompute_duplicate_payload_stats(object: &mut serde_json::Map<String, Value>) {
2928    let values = object
2929        .get("items")
2930        .or_else(|| object.get("groups"))
2931        .and_then(Value::as_array)
2932        .cloned()
2933        .unwrap_or_default();
2934    let (duplicated_lines, duplicated_file_count) = duplicate_line_stats_from_values(&values);
2935    let total_analyzed_lines = object
2936        .get("total_analyzed_lines")
2937        .and_then(Value::as_u64)
2938        .unwrap_or(0);
2939    let duplicated_percent = if total_analyzed_lines == 0 {
2940        0.0
2941    } else {
2942        (duplicated_lines as f64 * 100.0) / total_analyzed_lines as f64
2943    };
2944    object.insert("duplicated_lines".to_string(), json!(duplicated_lines));
2945    object.insert(
2946        "duplicated_file_count".to_string(),
2947        json!(duplicated_file_count),
2948    );
2949    object.insert("duplicated_percent".to_string(), json!(duplicated_percent));
2950}
2951
2952fn duplicate_line_stats_from_values(values: &[Value]) -> (u64, usize) {
2953    let mut by_file = BTreeMap::<String, Vec<(u64, u64)>>::new();
2954    for value in values {
2955        let Some(files) = value.get("files").and_then(Value::as_array) else {
2956            continue;
2957        };
2958        for occurrence in files.iter().filter_map(Value::as_str) {
2959            let Some((file, start, end)) = parse_duplicate_occurrence(occurrence) else {
2960                continue;
2961            };
2962            by_file
2963                .entry(file.to_string())
2964                .or_default()
2965                .push((start, end));
2966        }
2967    }
2968    let file_count = by_file.len();
2969    let duplicated_lines = by_file
2970        .values_mut()
2971        .map(|intervals| merged_duplicate_interval_lines(intervals))
2972        .sum();
2973    (duplicated_lines, file_count)
2974}
2975
2976fn merged_duplicate_interval_lines(intervals: &mut [(u64, u64)]) -> u64 {
2977    if intervals.is_empty() {
2978        return 0;
2979    }
2980    intervals.sort_by(|left, right| left.0.cmp(&right.0).then(left.1.cmp(&right.1)));
2981    let (mut current_start, mut current_end) = intervals[0];
2982    let mut total = 0;
2983    for &(start, end) in &intervals[1..] {
2984        if start <= current_end.saturating_add(1) {
2985            current_end = current_end.max(end);
2986        } else {
2987            total += current_end.saturating_sub(current_start).saturating_add(1);
2988            current_start = start;
2989            current_end = end;
2990        }
2991    }
2992    total + current_end.saturating_sub(current_start).saturating_add(1)
2993}
2994
2995fn recompute_scoped_top_preview(
2996    object: &serde_json::Map<String, Value>,
2997) -> Option<serde_json::Value> {
2998    let values = object
2999        .get("items")
3000        .or_else(|| object.get("groups"))
3001        .and_then(Value::as_array)?;
3002    Some(Value::Array(
3003        values
3004            .iter()
3005            .take(super::entry_points::TOP_PREVIEW_ITEMS)
3006            .map(top_preview_value)
3007            .collect(),
3008    ))
3009}
3010
3011fn top_preview_value(value: &Value) -> Value {
3012    if let Some(files) = value.get("files").and_then(Value::as_array) {
3013        let mut object = serde_json::Map::new();
3014        object.insert("files".to_string(), Value::Array(files.clone()));
3015        if let Some(cost) = value.get("cost").cloned() {
3016            object.insert("cost".to_string(), cost);
3017        }
3018        return Value::Object(object);
3019    }
3020
3021    json!({
3022        "file": value.get("file").and_then(Value::as_str).unwrap_or(""),
3023        "symbol": value.get("symbol").and_then(Value::as_str).unwrap_or(""),
3024    })
3025}
3026
3027fn filter_values_for_scope(values: &mut Vec<serde_json::Value>, scope: &JobScope) -> usize {
3028    values.retain_mut(|value| prune_value_for_scope(value, scope));
3029    values.len()
3030}
3031
3032fn prune_value_for_scope(value: &mut serde_json::Value, scope: &JobScope) -> bool {
3033    if let Some(file) = value.get("file").and_then(|file| file.as_str()) {
3034        return scope.contains_display_path(file);
3035    }
3036
3037    let first_scoped_occurrence = if let Some(files) = value
3038        .get_mut("files")
3039        .and_then(|files| files.as_array_mut())
3040    {
3041        files.retain(|file| {
3042            file.as_str()
3043                .is_some_and(|file| scope.contains_display_path(display_file_from_occurrence(file)))
3044        });
3045        if files.len() < 2 {
3046            return false;
3047        }
3048        files.first().and_then(Value::as_str).map(str::to_string)
3049    } else {
3050        None
3051    };
3052
3053    if let Some(occurrence) = first_scoped_occurrence {
3054        update_duplicate_group_sample(value, &occurrence);
3055    }
3056
3057    true
3058}
3059
3060fn update_duplicate_group_sample(value: &mut serde_json::Value, occurrence: &str) {
3061    let Some((file, start_line, end_line)) = parse_duplicate_occurrence(occurrence) else {
3062        return;
3063    };
3064    let Some(object) = value.as_object_mut() else {
3065        return;
3066    };
3067
3068    if object.contains_key("sample_file") {
3069        object.insert("sample_file".to_string(), json!(file));
3070    }
3071    if object.contains_key("sample_start_line") {
3072        object.insert("sample_start_line".to_string(), json!(start_line));
3073    }
3074    if object.contains_key("sample_end_line") {
3075        object.insert("sample_end_line".to_string(), json!(end_line));
3076    }
3077}
3078
3079fn parse_duplicate_occurrence(value: &str) -> Option<(&str, u64, u64)> {
3080    let (file, range) = value.rsplit_once(':')?;
3081    let (start, end) = range.split_once('-')?;
3082    if !start.chars().all(|char| char.is_ascii_digit())
3083        || !end.chars().all(|char| char.is_ascii_digit())
3084    {
3085        return None;
3086    }
3087
3088    Some((file, start.parse().ok()?, end.parse().ok()?))
3089}
3090
3091fn display_file_from_occurrence(value: &str) -> &str {
3092    let Some((file, range)) = value.rsplit_once(':') else {
3093        return value;
3094    };
3095    let Some((start, end)) = range.split_once('-') else {
3096        return value;
3097    };
3098    if start.chars().all(|char| char.is_ascii_digit())
3099        && end.chars().all(|char| char.is_ascii_digit())
3100    {
3101        file
3102    } else {
3103        value
3104    }
3105}
3106
3107#[cfg(test)]
3108mod guard_tests {
3109    use super::*;
3110
3111    fn write_ts_project(file_count: usize) -> tempfile::TempDir {
3112        let dir = tempfile::tempdir().expect("tempdir");
3113        let root = dir.path();
3114        for i in 0..file_count {
3115            std::fs::write(
3116                root.join(format!("mod{i}.ts")),
3117                format!("export function f{i}() {{ return {i}; }}\n"),
3118            )
3119            .expect("write fixture");
3120        }
3121        dir
3122    }
3123
3124    #[test]
3125    fn scoped_filter_recomputes_top_preview_from_scoped_items() {
3126        let project_root = PathBuf::from("/project");
3127        let scope = JobScope::from_roots(project_root.clone(), vec![project_root.join("src/in")]);
3128        let payload = json!({
3129            "count": 4,
3130            "items": [
3131                { "file": "src/out/a.ts", "symbol": "outside" },
3132                { "file": "src/in/b.ts", "symbol": "inside_b" },
3133                { "file": "src/in/c.ts", "symbol": "inside_c" }
3134            ],
3135            "top": [
3136                { "file": "src/out/a.ts", "symbol": "outside" },
3137                { "file": "src/out/z.ts", "symbol": "outside_z" }
3138            ],
3139            "by_language": { "typescript": 4 }
3140        });
3141
3142        let filtered = filter_payload_for_scope(payload, &scope);
3143
3144        assert_eq!(filtered["count"], json!(2));
3145        assert_eq!(
3146            filtered["top"],
3147            json!([
3148                { "file": "src/in/b.ts", "symbol": "inside_b" },
3149                { "file": "src/in/c.ts", "symbol": "inside_c" }
3150            ])
3151        );
3152        assert!(filtered["top"]
3153            .as_array()
3154            .unwrap()
3155            .iter()
3156            .all(|item| item["file"]
3157                .as_str()
3158                .is_some_and(|file| file.starts_with("src/in/"))));
3159    }
3160
3161    #[test]
3162    fn cache_for_paths_rebinds_same_project_key_to_current_root() {
3163        let dir = tempfile::tempdir().expect("tempdir");
3164        let source = dir.path().join("source");
3165        std::fs::create_dir_all(&source).expect("create source repo");
3166        std::fs::write(
3167            source.join("package.json"),
3168            r#"{"name":"inspect-cache-fixture","version":"1.0.0"}"#,
3169        )
3170        .expect("write source manifest");
3171        std::fs::write(source.join("index.ts"), "export const source = 1;\n")
3172            .expect("write source file");
3173        assert!(std::process::Command::new("git")
3174            .current_dir(&source)
3175            .arg("init")
3176            .status()
3177            .expect("git init source repo")
3178            .success());
3179        assert!(std::process::Command::new("git")
3180            .current_dir(&source)
3181            .args(["add", "."])
3182            .status()
3183            .expect("git add source repo")
3184            .success());
3185        assert!(std::process::Command::new("git")
3186            .current_dir(&source)
3187            .args([
3188                "-c",
3189                "user.name=AFT Tests",
3190                "-c",
3191                "user.email=aft-tests@example.com",
3192                "commit",
3193                "-m",
3194                "initial",
3195            ])
3196            .status()
3197            .expect("git commit source repo")
3198            .success());
3199
3200        let clone = dir.path().join("clone");
3201        assert!(std::process::Command::new("git")
3202            .args(["clone", "--quiet"])
3203            .arg(&source)
3204            .arg(&clone)
3205            .status()
3206            .expect("git clone source repo")
3207            .success());
3208        std::fs::write(
3209            clone.join("package.json"),
3210            r#"{"name":"inspect-cache-fixture","version":"2.0.0"}"#,
3211        )
3212        .expect("write clone manifest edit");
3213        assert_eq!(
3214            crate::search_index::artifact_cache_key(&source),
3215            crate::search_index::artifact_cache_key(&clone),
3216            "clones with the same root commit should share the sqlite project key"
3217        );
3218
3219        let source = std::fs::canonicalize(source).expect("canonical source root");
3220        let clone = std::fs::canonicalize(clone).expect("canonical clone root");
3221        let manager = InspectManager::new();
3222        let inspect_dir = dir.path().join("inspect");
3223        let key = JobKey::for_project_category(InspectCategory::DeadCode);
3224        let source_cache = manager
3225            .cache_for_paths(inspect_dir.clone(), source.clone())
3226            .expect("open source cache");
3227        let source_hash = source_cache
3228            .contribution_set_hash(InspectCategory::DeadCode)
3229            .expect("source contribution hash");
3230        source_cache
3231            .store_tier2_aggregate(
3232                key.clone(),
3233                &source_hash,
3234                serde_json::json!({ "count": 7, "items": [] }),
3235            )
3236            .expect("store source aggregate");
3237        assert_eq!(
3238            source_cache
3239                .get_aggregated(&key)
3240                .expect("read source aggregate")
3241                .and_then(|payload| payload.get("count").and_then(Value::as_u64)),
3242            Some(7)
3243        );
3244
3245        let clone_cache = manager
3246            .cache_for_paths(inspect_dir, clone.clone())
3247            .expect("open clone cache");
3248        assert_eq!(clone_cache.project_root(), clone.as_path());
3249        assert!(
3250            clone_cache
3251                .get_aggregated(&key)
3252                .expect("read clone aggregate")
3253                .is_none(),
3254            "same-key clone with a different manifest must not reuse the source root's cached count"
3255        );
3256    }
3257
3258    fn snapshot_job(root: &Path, inspect_dir: &Path, callgraph_store: bool) -> InspectJob {
3259        use crate::config::Config;
3260        use crate::parser::SymbolCache;
3261        use std::sync::RwLock;
3262
3263        InspectJob {
3264            job_id: 1,
3265            key: JobKey::for_project_category(InspectCategory::DeadCode),
3266            category: InspectCategory::DeadCode,
3267            scope_files: Vec::new(),
3268            project_root: root.to_path_buf(),
3269            inspect_dir: inspect_dir.to_path_buf(),
3270            config: Arc::new(Config {
3271                project_root: Some(root.to_path_buf()),
3272                callgraph_store,
3273                ..Config::default()
3274            }),
3275            symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
3276            callgraph_snapshot: None,
3277        }
3278    }
3279
3280    fn generated_unused_exports_fixture() -> (tempfile::TempDir, PathBuf, Vec<PathBuf>) {
3281        let dir = tempfile::tempdir().expect("tempdir");
3282        let root = dir.path().to_path_buf();
3283        let files = [
3284            (
3285                "src/hand.ts",
3286                "export function handUnused() {}
3287",
3288            ),
3289            (
3290                "gen/schema_pb.ts",
3291                "export function generatedPathUnused() {}
3292",
3293            ),
3294            (
3295                "src/banner.ts",
3296                "// Code generated by fixture. DO NOT EDIT.
3297export function bannerUnused() {}
3298",
3299            ),
3300        ];
3301        let paths = files
3302            .iter()
3303            .map(|(relative, contents)| {
3304                let path = root.join(relative);
3305                if let Some(parent) = path.parent() {
3306                    std::fs::create_dir_all(parent).expect("create parent");
3307                }
3308                std::fs::write(&path, contents).expect("write fixture file");
3309                std::fs::canonicalize(path).expect("canonical fixture path")
3310            })
3311            .collect::<Vec<_>>();
3312        (
3313            dir,
3314            std::fs::canonicalize(root).expect("canonical root"),
3315            paths,
3316        )
3317    }
3318
3319    fn unused_exports_job(root: &Path, scope_files: Vec<PathBuf>) -> InspectJob {
3320        use crate::config::Config;
3321        use crate::parser::SymbolCache;
3322        use std::sync::RwLock;
3323
3324        InspectJob {
3325            job_id: 1,
3326            key: JobKey::for_project_category(InspectCategory::UnusedExports),
3327            category: InspectCategory::UnusedExports,
3328            scope_files,
3329            project_root: root.to_path_buf(),
3330            inspect_dir: root.join(".aft-cache").join("inspect"),
3331            config: Arc::new(Config {
3332                project_root: Some(root.to_path_buf()),
3333                ..Config::default()
3334            }),
3335            symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
3336            callgraph_snapshot: None,
3337        }
3338    }
3339
3340    #[test]
3341    fn unused_exports_oxc_cached_rollup_preserves_generated_split() {
3342        let (_dir, root, paths) = generated_unused_exports_fixture();
3343        let job = unused_exports_job(&root, paths.clone());
3344        let entry_points = crate::inspect::entry_points::resolve_entry_points(&root);
3345        let oxc_result = crate::inspect::oxc_engine::analyze_files(
3346            &root,
3347            &paths,
3348            AnalyzeOptions {
3349                entry_points: Vec::new(),
3350                public_api_files: entry_points.public_api_files(),
3351                executable_root_exports: entry_points.executable_root_exports(),
3352                force_reparse_files: Vec::new(),
3353                entry_reachability: false,
3354            },
3355        )
3356        .expect("oxc analyze succeeds");
3357        let fresh = crate::inspect::scanners::unused_exports::run_unused_exports_scan_with_oxc(
3358            &job,
3359            Some(&oxc_result),
3360        )
3361        .outcome
3362        .expect("fresh scan succeeds");
3363
3364        let rolled_up = roll_up_unused_exports_contributions(
3365            &job,
3366            &fresh.contributions,
3367            Some(MAX_DRILL_DOWN_ITEMS),
3368        );
3369
3370        assert_eq!(
3371            rolled_up, fresh.aggregate,
3372            "cached rollup must match fresh scan"
3373        );
3374        assert_eq!(rolled_up["count"], 1, "{rolled_up:#}");
3375        assert_eq!(rolled_up["generated_count"], 2, "{rolled_up:#}");
3376        assert_eq!(rolled_up["total_count"], 3, "{rolled_up:#}");
3377    }
3378
3379    #[test]
3380    fn callgraph_snapshot_reports_unavailable_when_store_disabled() {
3381        let dir = write_ts_project(3);
3382        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
3383        let inspect_dir = root.join(".aft-cache").join("inspect");
3384
3385        let snapshot =
3386            build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, false), false);
3387
3388        assert!(
3389            snapshot.is_none(),
3390            "dead_code must not rebuild the legacy graph when the store is disabled"
3391        );
3392    }
3393
3394    #[test]
3395    fn callgraph_snapshot_reports_unavailable_when_store_not_ready() {
3396        let dir = write_ts_project(3);
3397        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
3398        let inspect_dir = root.join(".aft-cache").join("inspect");
3399        let callgraph_dir = callgraph_store_dir_from_inspect_dir(&inspect_dir).expect("store dir");
3400        let _store = CallGraphStore::open(callgraph_dir, root.clone()).expect("open empty store");
3401
3402        let snapshot =
3403            build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true), false);
3404
3405        assert!(
3406            snapshot.is_none(),
3407            "a cold/mid-build store must surface callgraph_unavailable instead of rebuilding inline"
3408        );
3409    }
3410
3411    #[test]
3412    fn direct_callgraph_snapshot_does_not_cold_rebuild_when_store_needs_rebuild() {
3413        let dir = write_ts_project(3);
3414        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
3415        let inspect_dir = root.join(".aft-cache").join("inspect");
3416        let callgraph_dir = callgraph_store_dir_from_inspect_dir(&inspect_dir).expect("store dir");
3417        let store = CallGraphStore::open(callgraph_dir.clone(), root.clone()).expect("open store");
3418        let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
3419        store.cold_build(&files).expect("cold build store");
3420        let sqlite_path = store.sqlite_path().to_path_buf();
3421        drop(store);
3422
3423        let still_existing_previous_root = root.with_file_name("previous-root-still-exists");
3424        std::fs::create_dir_all(&still_existing_previous_root).expect("create previous root");
3425        let conn = rusqlite::Connection::open(sqlite_path).expect("open store sqlite");
3426        conn.execute(
3427            "UPDATE backend_file_state SET workspace_root = ?1",
3428            rusqlite::params![still_existing_previous_root.display().to_string()],
3429        )
3430        .expect("force root repair rebuild state");
3431
3432        let snapshot =
3433            build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true), false);
3434
3435        assert!(
3436            snapshot.is_none(),
3437            "direct inspect must report callgraph_unavailable instead of cold-rebuilding a root-repair store"
3438        );
3439    }
3440
3441    #[test]
3442    fn callgraph_snapshot_reads_ready_callgraph_store() {
3443        let dir = write_ts_project(3);
3444        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
3445        let inspect_dir = root.join(".aft-cache").join("inspect");
3446        let callgraph_dir = callgraph_store_dir_from_inspect_dir(&inspect_dir).expect("store dir");
3447        let store = CallGraphStore::open(callgraph_dir, root.clone()).expect("open store");
3448        let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
3449        store.cold_build(&files).expect("cold build store");
3450
3451        let snapshot =
3452            build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true), false)
3453                .expect("ready store snapshot");
3454
3455        assert_eq!(snapshot.files.len(), 3);
3456        assert_eq!(snapshot.exported_symbols.len(), 3);
3457    }
3458
3459    #[test]
3460    fn callgraph_snapshot_uses_ready_sibling_harness_store() {
3461        let dir = write_ts_project(3);
3462        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
3463        let storage_dir = root.join(".aft-cache");
3464        let inspect_dir = storage_dir.join("runner").join("inspect");
3465        let warm_callgraph_dir = storage_dir.join("opencode").join("callgraph");
3466        let store = CallGraphStore::open(warm_callgraph_dir, root.clone()).expect("open store");
3467        let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
3468        store.cold_build(&files).expect("cold build store");
3469
3470        let snapshot =
3471            build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true), false)
3472                .expect("ready sibling store snapshot");
3473
3474        assert_eq!(snapshot.files.len(), 3);
3475        assert_eq!(snapshot.exported_symbols.len(), 3);
3476    }
3477
3478    #[test]
3479    fn dead_code_forced_deletion_refreshes_callgraph_store_before_rollup() {
3480        let dir = tempfile::tempdir().expect("tempdir");
3481        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
3482        write_fixture_file(
3483            &root,
3484            "package.json",
3485            r#"{"name":"dead-code-delete-refresh","type":"module","main":"src/main.ts"}"#,
3486            3_100_000_000,
3487        );
3488        write_fixture_file(
3489            &root,
3490            "src/main.ts",
3491            "export function main() {}\n",
3492            3_100_000_001,
3493        );
3494        write_fixture_file(
3495            &root,
3496            "src/dead.ts",
3497            "export function plantedDead() {}\n",
3498            3_100_000_002,
3499        );
3500
3501        let inspect_dir = root.join(".aft-cache").join("opencode").join("inspect");
3502        let callgraph_dir = callgraph_store_dir_from_inspect_dir(&inspect_dir).expect("store dir");
3503        let store = CallGraphStore::open(callgraph_dir.clone(), root.clone()).expect("open store");
3504        let project_files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
3505        store.cold_build(&project_files).expect("cold build store");
3506        drop(store);
3507
3508        let config = Arc::new(crate::config::Config {
3509            project_root: Some(root.clone()),
3510            callgraph_store: true,
3511            ..crate::config::Config::default()
3512        });
3513        let symbol_cache = Arc::new(std::sync::RwLock::new(crate::parser::SymbolCache::new()));
3514        let snapshot = InspectSnapshot::new(
3515            root.clone(),
3516            inspect_dir.clone(),
3517            Arc::clone(&config),
3518            Arc::clone(&symbol_cache),
3519        );
3520        let manager = InspectManager::new();
3521        let initial_job =
3522            manager.tier2_reuse_job(snapshot.clone(), InspectCategory::DeadCode, None);
3523        let initial = manager
3524            .tier2_run_with_reuse_job_result_with_options(initial_job, Tier2ReuseOptions::default())
3525            .outcome
3526            .expect("initial dead_code scan succeeds")
3527            .aggregate;
3528        assert!(
3529            aggregate_has_file_symbol(&initial, "src/dead.ts", "plantedDead"),
3530            "initial scan should report the planted dead export: {initial:#}"
3531        );
3532
3533        let deleted = root.join("src/dead.ts");
3534        std::fs::remove_file(&deleted).expect("delete dead fixture");
3535        let delete_job = manager.tier2_reuse_job(snapshot, InspectCategory::DeadCode, None);
3536        let refreshed = manager
3537            .tier2_run_with_reuse_job_result_with_options(
3538                delete_job,
3539                Tier2ReuseOptions::direct(vec![deleted.clone()]),
3540            )
3541            .outcome
3542            .expect("delete refresh dead_code scan succeeds")
3543            .aggregate;
3544
3545        assert_eq!(
3546            refreshed
3547                .get("callgraph_available")
3548                .and_then(Value::as_bool),
3549            Some(true),
3550            "forced watcher paths must keep the callgraph-backed aggregate available: {refreshed:#}"
3551        );
3552        assert!(
3553            !aggregate_has_file_symbol(&refreshed, "src/dead.ts", "plantedDead"),
3554            "delete refresh should remove the planted dead export: {refreshed:#}"
3555        );
3556
3557        let store = CallGraphStore::open_ready_no_rebuild(callgraph_dir, root)
3558            .expect("open refreshed store")
3559            .expect("refreshed store is ready");
3560        let projected = project_dead_code_snapshot(store.sqlite_path()).expect("project snapshot");
3561        assert!(
3562            projected
3563                .files
3564                .iter()
3565                .all(|file| !file.ends_with("src/dead.ts")),
3566            "watcher deletion should be applied to the persisted callgraph store: {:#?}",
3567            projected.files
3568        );
3569    }
3570
3571    fn aggregate_has_file_symbol(aggregate: &Value, file: &str, symbol: &str) -> bool {
3572        aggregate
3573            .get("items")
3574            .and_then(Value::as_array)
3575            .is_some_and(|items| {
3576                items.iter().any(|item| {
3577                    item.get("file").and_then(Value::as_str) == Some(file)
3578                        && item.get("symbol").and_then(Value::as_str) == Some(symbol)
3579                })
3580            })
3581    }
3582
3583    // A scoped payload must not carry the project-wide `by_language` breakdown
3584    // alongside the recomputed in-scope count — that contradiction renders as
3585    // e.g. "Dead code: 1 (rust 214, ts 143)".
3586    #[test]
3587    fn scoped_filter_drops_project_wide_by_language() {
3588        let scope = JobScope::from_roots("/proj", vec![PathBuf::from("/proj/src/a")]);
3589        assert!(
3590            !scope.is_project_wide(),
3591            "scope must be non-project for test"
3592        );
3593        let payload = serde_json::json!({
3594            "count": 99,
3595            "by_language": { "rust": 214, "typescript": 143 },
3596            "items": [
3597                { "file": "/proj/src/a/x.rs", "symbol": "live" },
3598                { "file": "/proj/src/other/y.rs", "symbol": "out" },
3599            ],
3600        });
3601        let filtered = filter_payload_for_scope(payload, &scope);
3602        assert!(
3603            filtered.get("by_language").is_none(),
3604            "scoped payload must drop project-wide by_language: {filtered}"
3605        );
3606        // Count is recomputed to the in-scope items (only x.rs under src/a).
3607        assert_eq!(filtered.get("count").and_then(|v| v.as_u64()), Some(1));
3608    }
3609    #[cfg(debug_assertions)]
3610    #[test]
3611    fn tier2_read_cached_freshness_does_not_hash_unchanged_contributions() {
3612        let (_dir, manager, snapshot, scope, _files) = duplicate_cache_fixture();
3613
3614        crate::cache_freshness::reset_hash_file_if_small_count_for_debug();
3615        crate::cache_freshness::reset_verify_file_strict_count_for_debug();
3616        assert_fresh(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
3617
3618        assert_eq!(
3619            crate::cache_freshness::verify_file_strict_count_for_debug(),
3620            0,
3621            "dispatch-thread inspect freshness must not use strict verification"
3622        );
3623        assert_eq!(
3624            crate::cache_freshness::hash_file_if_small_count_for_debug(),
3625            0,
3626            "unchanged contribution files must stay on the stat-only fast path"
3627        );
3628    }
3629
3630    #[cfg(debug_assertions)]
3631    #[test]
3632    fn tier2_read_cached_freshness_returns_byte_identical_cold_scan_aggregate() {
3633        let (_dir, manager, snapshot, scope, _files) = duplicate_uncached_fixture();
3634        let cold_payload = fresh_payload(manager.tier2_run_with_reuse(
3635            snapshot.clone(),
3636            InspectCategory::Duplicates,
3637            scope.clone(),
3638            None,
3639        ));
3640
3641        crate::cache_freshness::reset_hash_file_if_small_count_for_debug();
3642        crate::cache_freshness::reset_verify_file_strict_count_for_debug();
3643        let warm_payload =
3644            fresh_payload(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
3645
3646        let cold_bytes = serde_json::to_vec(&cold_payload).expect("serialize cold aggregate");
3647        let warm_bytes = serde_json::to_vec(&warm_payload).expect("serialize warm aggregate");
3648        assert_eq!(
3649            warm_bytes, cold_bytes,
3650            "warm unchanged read must return the byte-identical aggregate as the cold scan"
3651        );
3652        assert_eq!(
3653            crate::cache_freshness::verify_file_strict_count_for_debug(),
3654            0,
3655            "dispatch-thread warm read must not use strict verification"
3656        );
3657        assert_eq!(
3658            crate::cache_freshness::hash_file_if_small_count_for_debug(),
3659            0,
3660            "warm unchanged read must not content-hash cached contribution files"
3661        );
3662    }
3663
3664    #[test]
3665    fn tier2_read_cached_freshness_detects_changed_added_and_deleted_files() {
3666        let (_dir, manager, snapshot, scope, _files) = duplicate_cache_fixture();
3667        write_fixture_file(
3668            &snapshot.project_root,
3669            "src/foo.ts",
3670            "export const foo = 101;\nexport const changed = true;\n",
3671            3_000_000_001,
3672        );
3673        assert_stale(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
3674
3675        let (_dir, manager, snapshot, scope, _files) = duplicate_cache_fixture();
3676        write_fixture_file(
3677            &snapshot.project_root,
3678            "src/added.ts",
3679            "export const added = 3;\n",
3680            3_000_000_002,
3681        );
3682        assert_stale(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
3683
3684        let (_dir, manager, snapshot, scope, files) = duplicate_cache_fixture();
3685        std::fs::remove_file(&files[0]).expect("delete cached contribution file");
3686        assert_stale(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
3687    }
3688
3689    fn duplicate_cache_fixture() -> (
3690        tempfile::TempDir,
3691        InspectManager,
3692        InspectSnapshot,
3693        JobScope,
3694        Vec<PathBuf>,
3695    ) {
3696        let (dir, manager, snapshot, scope, files) = duplicate_uncached_fixture();
3697        store_duplicate_cache(&manager, &snapshot, &files);
3698        (dir, manager, snapshot, scope, files)
3699    }
3700
3701    fn duplicate_uncached_fixture() -> (
3702        tempfile::TempDir,
3703        InspectManager,
3704        InspectSnapshot,
3705        JobScope,
3706        Vec<PathBuf>,
3707    ) {
3708        use crate::config::Config;
3709        use crate::parser::SymbolCache;
3710        use std::sync::RwLock;
3711
3712        let dir = tempfile::tempdir().expect("tempdir");
3713        let root = std::fs::canonicalize(dir.path()).expect("canonical fixture root");
3714        let files = vec![
3715            write_fixture_file(
3716                &root,
3717                "src/foo.ts",
3718                "export const fixture = () => 1;
3719export const shared = 1;
3720",
3721                3_000_000_000,
3722            ),
3723            write_fixture_file(
3724                &root,
3725                "src/bar.ts",
3726                "export const fixture = () => 1;
3727export const shared = 1;
3728",
3729                3_000_000_000,
3730            ),
3731        ];
3732        let inspect_dir = root.join(".aft-cache").join("inspect");
3733        let snapshot = InspectSnapshot::new(
3734            root.clone(),
3735            inspect_dir,
3736            Arc::new(Config {
3737                project_root: Some(root.clone()),
3738                ..Config::default()
3739            }),
3740            Arc::new(RwLock::new(SymbolCache::new())),
3741        );
3742        let scope = JobScope::for_project(root);
3743        let manager = InspectManager::new();
3744        (dir, manager, snapshot, scope, files)
3745    }
3746
3747    fn write_fixture_file(root: &Path, relative: &str, content: &str, mtime_secs: i64) -> PathBuf {
3748        let path = root.join(relative);
3749        if let Some(parent) = path.parent() {
3750            std::fs::create_dir_all(parent).expect("create fixture parent");
3751        }
3752        std::fs::write(&path, content).expect("write fixture file");
3753        filetime::set_file_mtime(&path, filetime::FileTime::from_unix_time(mtime_secs, 0))
3754            .expect("set fixture mtime");
3755        path
3756    }
3757
3758    fn store_duplicate_cache(
3759        manager: &InspectManager,
3760        snapshot: &InspectSnapshot,
3761        files: &[PathBuf],
3762    ) {
3763        let cache = manager
3764            .cache_for_snapshot(snapshot)
3765            .expect("open inspect cache");
3766        let contributions = files
3767            .iter()
3768            .map(|file| {
3769                let freshness = crate::cache_freshness::collect(file).expect("collect freshness");
3770                FileContribution::new(
3771                    InspectCategory::Duplicates,
3772                    file.clone(),
3773                    freshness,
3774                    serde_json::json!({
3775                        "file": relative_cache_key(&snapshot.project_root, file),
3776                        "fragments": [],
3777                    }),
3778                )
3779            })
3780            .collect::<Vec<_>>();
3781        cache
3782            .store_tier2_result(
3783                JobKey::for_project_category(InspectCategory::Duplicates),
3784                files,
3785                &contributions,
3786                serde_json::json!({
3787                    "count": 0,
3788                    "groups": [],
3789                    "scanned_files": files.len(),
3790                    "total_groups": 0,
3791                }),
3792            )
3793            .expect("store tier2 cache fixture");
3794    }
3795
3796    fn assert_fresh(outcome: JobOutcome) {
3797        let _ = fresh_payload(outcome);
3798    }
3799
3800    fn fresh_payload(outcome: JobOutcome) -> Value {
3801        match outcome {
3802            JobOutcome::Fresh { payload } => payload,
3803            other => panic!("expected fresh cached Tier-2 outcome, got {other:?}"),
3804        }
3805    }
3806
3807    fn assert_stale(outcome: JobOutcome) {
3808        match outcome {
3809            JobOutcome::Stale { .. } => {}
3810            other => panic!("expected stale cached Tier-2 outcome, got {other:?}"),
3811        }
3812    }
3813}
3814
3815#[cfg(test)]
3816mod dead_code_projection_tests {
3817    use super::*;
3818    use crate::callgraph::walk_project_files;
3819    use crate::callgraph_store::{project_dead_code_snapshot, CallGraphStore};
3820    use crate::config::Config;
3821    use crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR;
3822    use crate::inspect::scanners::DEFAULT_EXPORT_MARKER_KIND;
3823    use crate::parser::SymbolCache;
3824    use filetime::FileTime;
3825    use std::sync::atomic::{AtomicI64, Ordering as AtomicOrdering};
3826    use std::sync::RwLock;
3827
3828    static NEXT_MTIME: AtomicI64 = AtomicI64::new(1_900_000_000);
3829
3830    #[test]
3831    fn scoped_dead_code_rollup_uses_ready_callgraph_and_degrades_without_it() {
3832        let dir = tempfile::tempdir().expect("tempdir");
3833        write_projection_fixture(dir.path());
3834        let root = canonical_root(dir.path());
3835        let inspect_dir = root.join(".aft-cache").join("inspect");
3836        let callgraph_dir = callgraph_store_dir_from_inspect_dir(&inspect_dir).expect("store dir");
3837        let store = CallGraphStore::open(callgraph_dir.clone(), root.clone()).expect("open store");
3838        let files = project_files(&root);
3839        store.cold_build(&files).expect("cold build store");
3840        let projected = project_dead_code_snapshot(store.sqlite_path()).expect("project snapshot");
3841        drop(store);
3842
3843        let config = Arc::new(Config {
3844            project_root: Some(root.clone()),
3845            callgraph_store: true,
3846            ..Config::default()
3847        });
3848        let symbol_cache = Arc::new(RwLock::new(SymbolCache::new()));
3849        let scan_job = InspectJob {
3850            job_id: 87,
3851            key: JobKey::for_project_category(InspectCategory::DeadCode),
3852            category: InspectCategory::DeadCode,
3853            scope_files: files.clone(),
3854            project_root: root.clone(),
3855            inspect_dir: inspect_dir.clone(),
3856            config: Arc::clone(&config),
3857            symbol_cache: Arc::clone(&symbol_cache),
3858            callgraph_snapshot: Some(Arc::new(projected)),
3859        };
3860        let success = crate::inspect::scanners::dead_code::run_dead_code_scan(&scan_job)
3861            .outcome
3862            .expect("dead_code scan succeeds");
3863        let cache = InspectCache::open(inspect_dir.clone(), root.clone()).expect("open cache");
3864        cache
3865            .store_tier2_result(
3866                scan_job.key.clone(),
3867                &success.scanned_files,
3868                &success.contributions,
3869                success.aggregate.clone(),
3870            )
3871            .expect("store tier2 result");
3872
3873        let snapshot = InspectSnapshot::new(root.clone(), inspect_dir, config, symbol_cache);
3874        let scope = JobScope::from_roots(root.clone(), vec![root.join("src/live.ts")]);
3875        assert!(
3876            !scope.is_project_wide(),
3877            "live.ts file scope must be scoped"
3878        );
3879
3880        let ready_payload = scoped_tier2_payload_from_contributions(
3881            &snapshot,
3882            InspectCategory::DeadCode,
3883            &cache,
3884            success.aggregate.clone(),
3885            &scope,
3886        )
3887        .expect("ready scoped payload");
3888        assert_eq!(
3889            ready_payload
3890                .get("callgraph_available")
3891                .and_then(Value::as_bool),
3892            Some(true),
3893            "ready store should produce a callgraph-backed scoped rollup: {ready_payload:#}"
3894        );
3895        assert_live_item(&ready_payload, "src/live.ts", "knownLive");
3896
3897        std::fs::remove_dir_all(&callgraph_dir).expect("remove ready callgraph store");
3898        let unavailable_payload = scoped_tier2_payload_from_contributions(
3899            &snapshot,
3900            InspectCategory::DeadCode,
3901            &cache,
3902            success.aggregate,
3903            &scope,
3904        )
3905        .expect("unavailable scoped payload");
3906        assert_eq!(
3907            unavailable_payload
3908                .get("callgraph_available")
3909                .and_then(Value::as_bool),
3910            Some(false),
3911            "missing store must report callgraph_unavailable instead of fabricating an empty graph: {unavailable_payload:#}"
3912        );
3913        assert_live_item(&unavailable_payload, "src/live.ts", "knownLive");
3914    }
3915    #[derive(Debug, PartialEq, Eq)]
3916    struct ComparableSnapshot {
3917        files: BTreeSet<PathBuf>,
3918        exported_symbols: BTreeSet<(PathBuf, String, String, u32)>,
3919        outbound_calls: BTreeSet<(PathBuf, String, String, u32)>,
3920        entry_points: BTreeSet<PathBuf>,
3921        entry_point_symbols: BTreeMap<PathBuf, BTreeSet<String>>,
3922    }
3923
3924    #[test]
3925    fn dead_code_projection_contains_expected_fixture_surface() {
3926        let dir = tempfile::tempdir().expect("tempdir");
3927        write_projection_fixture(dir.path());
3928        let root = canonical_root(dir.path());
3929        let projected = store_projected_snapshot(&root, ".store-dead-code-surface");
3930
3931        assert_projection_fixture_coverage(&root, &projected);
3932    }
3933
3934    #[test]
3935    fn dead_code_projection_incremental_scenario_matrix_matches_cold_rebuild() {
3936        run_projection_scenario("rename", setup_projection_rename, edit_projection_rename);
3937        run_projection_scenario("delete", setup_projection_delete, edit_projection_delete);
3938        run_projection_scenario(
3939            "barrel delete",
3940            setup_projection_barrel,
3941            edit_projection_barrel_delete,
3942        );
3943        run_projection_scenario(
3944            "dispatch edit",
3945            setup_projection_dispatch,
3946            edit_projection_dispatch,
3947        );
3948        run_projection_scenario(
3949            "body-only edit",
3950            setup_projection_body_only,
3951            edit_projection_body_only,
3952        );
3953    }
3954
3955    #[test]
3956    fn dead_code_projection_dead_code_scan_reports_expected_verdicts() {
3957        let dir = tempfile::tempdir().expect("tempdir");
3958        write_projection_fixture(dir.path());
3959        let root = canonical_root(dir.path());
3960        let files = project_files(&root);
3961        let projected = store_projected_snapshot(&root, ".store-dead-code-e2e");
3962
3963        let projected_aggregate = dead_code_aggregate(&root, files, projected);
3964        assert_dead_item(&projected_aggregate, "src/dead.ts", "knownDead");
3965        assert_live_item(&projected_aggregate, "src/live.ts", "knownLive");
3966        assert_live_item(&projected_aggregate, "src/render.ts", "render");
3967        assert_live_item(&projected_aggregate, "src/other_render.ts", "render");
3968    }
3969
3970    #[test]
3971    fn dead_code_projection_rust_attribute_entry_points_are_live() {
3972        let dir = tempfile::tempdir().expect("tempdir");
3973        write_rust_attribute_entry_fixture(dir.path());
3974        let root = canonical_root(dir.path());
3975        let files = project_files(&root);
3976        let store = CallGraphStore::open(root.join(".store-tauri-commands"), root.clone())
3977            .expect("open store");
3978        store.cold_build(&files).expect("cold build store");
3979        let command = store
3980            .node_for(Path::new("src/commands.rs"), "get_primers")
3981            .expect("command node");
3982        assert!(
3983            command.is_entry_point,
3984            "attribute-rooted commands must be labeled as callgraph entry points"
3985        );
3986        let private_command = store
3987            .node_for(Path::new("src/commands.rs"), "private_command")
3988            .expect("private command node");
3989        assert!(
3990            private_command.is_entry_point,
3991            "private attribute-rooted commands must also be callgraph entry points"
3992        );
3993
3994        let projected = project_dead_code_snapshot(store.sqlite_path()).expect("project snapshot");
3995        let aggregate = dead_code_aggregate(&root, files, projected);
3996        assert_live_item(&aggregate, "src/commands.rs", "get_primers");
3997        assert_live_item(&aggregate, "src/db.rs", "helper");
3998        assert_live_item(&aggregate, "src/db.rs", "private_helper");
3999        assert_live_item(&aggregate, "src/imported.rs", "imported_command");
4000        assert_live_item(&aggregate, "src/db.rs", "imported_helper");
4001        assert_dead_item(&aggregate, "src/commands.rs", "planted_dead");
4002        assert_dead_item(&aggregate, "src/unimported.rs", "false_command");
4003        assert_dead_item(&aggregate, "src/db.rs", "false_helper");
4004    }
4005
4006    #[test]
4007    fn dead_code_projection_rust_attribute_roots_are_cold_deterministic() {
4008        let dir = tempfile::tempdir().expect("tempdir");
4009        write_rust_attribute_entry_fixture(dir.path());
4010        let root = canonical_root(dir.path());
4011        let first = store_projected_snapshot(&root, ".store-tauri-cold-a");
4012        let second = store_projected_snapshot(&root, ".store-tauri-cold-b");
4013
4014        assert_snapshot_parts_eq("rust attribute roots cold", &first, &second);
4015    }
4016
4017    #[test]
4018    fn dead_code_projection_rust_attribute_roots_survive_unrelated_incremental_edit() {
4019        let dir = tempfile::tempdir().expect("tempdir");
4020        write_rust_attribute_entry_fixture(dir.path());
4021        let root = canonical_root(dir.path());
4022        let files_before = project_files(&root);
4023        let incremental_store =
4024            CallGraphStore::open(root.join(".store-tauri-incremental"), root.clone())
4025                .expect("open incremental store");
4026        incremental_store
4027            .cold_build(&files_before)
4028            .expect("initial cold build");
4029
4030        write_file(
4031            &root.join("src/unrelated.rs"),
4032            r#"// unrelated edit should not refresh command attribute facts
4033pub fn unrelated() -> u32 { 2 }
4034"#,
4035        );
4036        let stats = incremental_store
4037            .refresh_files(&[root.join("src/unrelated.rs")])
4038            .expect("refresh unrelated file");
4039        assert_eq!(stats.refreshed_own_files, 1);
4040        assert_eq!(stats.changed_files, vec!["src/unrelated.rs".to_string()]);
4041        assert!(
4042            !stats
4043                .surface_changed
4044                .iter()
4045                .any(|file| file == "src/commands.rs"),
4046            "unrelated edit must not refresh the command module: {stats:#?}"
4047        );
4048        let incremental = project_dead_code_snapshot(incremental_store.sqlite_path())
4049            .expect("project incremental snapshot");
4050
4051        let cold_store = CallGraphStore::open(root.join(".store-tauri-cold"), root.clone())
4052            .expect("open cold store");
4053        cold_store
4054            .cold_build(&project_files(&root))
4055            .expect("cold rebuild");
4056        let cold = project_dead_code_snapshot(cold_store.sqlite_path()).expect("project cold");
4057        assert_snapshot_parts_eq("rust attribute roots unrelated edit", &cold, &incremental);
4058
4059        let aggregate = dead_code_aggregate(&root, project_files(&root), incremental);
4060        assert_live_item(&aggregate, "src/commands.rs", "get_primers");
4061        assert_live_item(&aggregate, "src/db.rs", "helper");
4062        assert_live_item(&aggregate, "src/db.rs", "private_helper");
4063        assert_dead_item(&aggregate, "src/commands.rs", "planted_dead");
4064    }
4065
4066    fn assert_projection_fixture_coverage(root: &Path, snapshot: &CallgraphSnapshot) {
4067        let comparable = comparable_snapshot(snapshot);
4068        assert!(
4069            comparable
4070                .files
4071                .iter()
4072                .any(|file| file.extension().and_then(|ext| ext.to_str()) == Some("ts")),
4073            "fixture must include TypeScript files: {:#?}",
4074            comparable.files
4075        );
4076        assert!(
4077            comparable
4078                .files
4079                .iter()
4080                .any(|file| file.extension().and_then(|ext| ext.to_str()) == Some("js")),
4081            "fixture must include JavaScript files: {:#?}",
4082            comparable.files
4083        );
4084        assert!(
4085            comparable
4086                .files
4087                .iter()
4088                .any(|file| file.extension().and_then(|ext| ext.to_str()) == Some("rs")),
4089            "fixture must include Rust files: {:#?}",
4090            comparable.files
4091        );
4092
4093        let main_file = canonicalize_for_snapshot(&root.join("src/main.ts"));
4094        let private_dispatch_target = format!("{}::dispatch", main_file.display());
4095        assert!(
4096            comparable
4097                .outbound_calls
4098                .iter()
4099                .any(
4100                    |(caller_file, caller_symbol, target, _)| caller_file == &main_file
4101                        && caller_symbol == "main"
4102                        && target == &private_dispatch_target
4103                ),
4104            "fixture must cover same-file private fallback target {private_dispatch_target}: {:#?}",
4105            comparable.outbound_calls
4106        );
4107        assert!(
4108            comparable
4109                .outbound_calls
4110                .iter()
4111                .any(|(_, _, target, _)| target.contains(DISPATCHED_CALLEE_SEPARATOR)),
4112            "fixture must cover method-dispatch suffixes: {:#?}",
4113            comparable.outbound_calls
4114        );
4115        assert!(
4116            comparable
4117                .exported_symbols
4118                .iter()
4119                .any(|(_, symbol, kind, _)| symbol == "runDefault"
4120                    && kind == DEFAULT_EXPORT_MARKER_KIND),
4121            "fixture must cover default-export marker rows: {:#?}",
4122            comparable.exported_symbols
4123        );
4124    }
4125
4126    fn run_projection_scenario(name: &str, setup: fn(&Path), edit: fn(&Path) -> Vec<PathBuf>) {
4127        let dir = tempfile::tempdir().expect("tempdir");
4128        setup(dir.path());
4129        let root = canonical_root(dir.path());
4130        let files_before = project_files(&root);
4131        let incremental_store = CallGraphStore::open(
4132            root.join(format!(".store-dead-code-projection-{name}-incremental")),
4133            root.clone(),
4134        )
4135        .expect("open incremental store");
4136        incremental_store
4137            .cold_build(&files_before)
4138            .expect("initial cold build");
4139
4140        let changed = edit(&root);
4141        incremental_store
4142            .refresh_files(&changed)
4143            .expect("refresh changed files");
4144        let incremental = project_dead_code_snapshot(incremental_store.sqlite_path())
4145            .expect("project incremental snapshot");
4146
4147        let cold_store = CallGraphStore::open(
4148            root.join(format!(".store-dead-code-projection-{name}-cold")),
4149            root.clone(),
4150        )
4151        .expect("open cold store");
4152        cold_store
4153            .cold_build(&project_files(&root))
4154            .expect("cold rebuild");
4155        let cold =
4156            project_dead_code_snapshot(cold_store.sqlite_path()).expect("project cold snapshot");
4157
4158        assert_snapshot_parts_eq(name, &cold, &incremental);
4159    }
4160
4161    /// Store-backed dead_code benchmark. Measures, on a real checkout, the
4162    /// persisted-store cold build, the warm SQLite projection cost, and the
4163    /// remaining `run_dead_code_scan` cost (per-file reexport/type-ref reparse +
4164    /// BFS roll-up). Production Tier-2 reads a warm store; cold_build is included
4165    /// here only to make end-to-end store cost visible.
4166    /// Ignored by default; run with:
4167    ///   AFT_BENCH_REPO=/path/to/large/repo cargo test -p agent-file-tools --lib \
4168    ///     -- --ignored --nocapture --test-threads=1 dead_code_decision_b_benchmark
4169    #[test]
4170    #[ignore = "manual benchmark; needs AFT_BENCH_REPO pointing at a large checkout"]
4171    fn dead_code_decision_b_benchmark() {
4172        let Ok(repo) = std::env::var("AFT_BENCH_REPO") else {
4173            eprintln!("AFT_BENCH_REPO unset; skipping");
4174            return;
4175        };
4176        // Each phase flushes immediately so a file-redirected run shows live progress.
4177        macro_rules! mark {
4178            ($($a:tt)*) => {{ eprintln!($($a)*); let _ = std::io::Write::flush(&mut std::io::stderr()); }};
4179        }
4180        let root = canonical_root(Path::new(&repo));
4181        let files = project_files(&root);
4182        mark!(
4183            "\n=== Store-backed dead_code benchmark ===\nrepo: {}\nsource files (walk_project_files): {}\nstarted store cold_build...",
4184            root.display(),
4185            files.len()
4186        );
4187
4188        // Store cold_build + projection. Production warm runs skip cold_build and
4189        // pay only the projection below.
4190        let store_dir = root.join(".aft-bench-store");
4191        let _ = std::fs::remove_dir_all(&store_dir);
4192        let store = CallGraphStore::open(store_dir.clone(), root.clone()).expect("open store");
4193        let t = Instant::now();
4194        let cold_stats = store.cold_build(&files).expect("store cold build");
4195        let store_build_ms = t.elapsed().as_millis();
4196        let t = Instant::now();
4197        let projected = project_dead_code_snapshot(store.sqlite_path()).expect("projection");
4198        let proj_ms = t.elapsed().as_millis();
4199        mark!(
4200            "store cold_build: {} ms ({:?}) + projection: {} ms = {} ms  (exports={}, outbound={})\nstarted scan...",
4201            store_build_ms, cold_stats, proj_ms, store_build_ms + proj_ms,
4202            projected.exported_symbols.len(), projected.outbound_calls.len()
4203        );
4204
4205        // Remaining scanner cost: run_dead_code_scan given a ready snapshot.
4206        let t = Instant::now();
4207        let _result = dead_code_aggregate(&root, files.clone(), projected.clone());
4208        let scan_ms = t.elapsed().as_millis();
4209        mark!("run_dead_code_scan (cold contributions): {} ms", scan_ms);
4210
4211        mark!(
4212            "\nSUMMARY  files={}  store_cold_plus_projection={}ms  projection={}ms  scan_cold={}ms  total={}ms",
4213            files.len(),
4214            store_build_ms + proj_ms,
4215            proj_ms,
4216            scan_ms,
4217            store_build_ms + proj_ms + scan_ms
4218        );
4219        let _ = std::fs::remove_dir_all(&store_dir);
4220    }
4221
4222    fn store_projected_snapshot(root: &Path, store_name: &str) -> CallgraphSnapshot {
4223        let store =
4224            CallGraphStore::open(root.join(store_name), root.to_path_buf()).expect("open store");
4225        store
4226            .cold_build(&project_files(root))
4227            .expect("store cold build");
4228        project_dead_code_snapshot(store.sqlite_path()).expect("project snapshot")
4229    }
4230
4231    fn dead_code_aggregate(
4232        root: &Path,
4233        scope_files: Vec<PathBuf>,
4234        snapshot: CallgraphSnapshot,
4235    ) -> Value {
4236        let job = InspectJob {
4237            job_id: 86,
4238            key: JobKey::for_project_category(InspectCategory::DeadCode),
4239            category: InspectCategory::DeadCode,
4240            scope_files,
4241            project_root: root.to_path_buf(),
4242            inspect_dir: root.join(".aft-cache").join("inspect"),
4243            config: Arc::new(Config {
4244                project_root: Some(root.to_path_buf()),
4245                ..Config::default()
4246            }),
4247            symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
4248            callgraph_snapshot: Some(Arc::new(snapshot)),
4249        };
4250        crate::inspect::scanners::dead_code::run_dead_code_scan(&job)
4251            .outcome
4252            .expect("dead_code scan succeeds")
4253            .aggregate
4254    }
4255
4256    fn assert_snapshot_parts_eq(
4257        label: &str,
4258        expected: &CallgraphSnapshot,
4259        actual: &CallgraphSnapshot,
4260    ) {
4261        let expected = comparable_snapshot(expected);
4262        let actual = comparable_snapshot(actual);
4263        assert_eq!(
4264            actual, expected,
4265            "{label} store-projected snapshot must match cold store snapshot"
4266        );
4267    }
4268
4269    fn comparable_snapshot(snapshot: &CallgraphSnapshot) -> ComparableSnapshot {
4270        ComparableSnapshot {
4271            files: snapshot.files.iter().cloned().collect(),
4272            exported_symbols: snapshot
4273                .exported_symbols
4274                .iter()
4275                .map(|export| {
4276                    (
4277                        export.file.clone(),
4278                        export.symbol.clone(),
4279                        export.kind.clone(),
4280                        export.line,
4281                    )
4282                })
4283                .collect(),
4284            outbound_calls: snapshot
4285                .outbound_calls
4286                .iter()
4287                .map(|call| {
4288                    (
4289                        call.caller_file.clone(),
4290                        call.caller_symbol.clone(),
4291                        call.target.clone(),
4292                        call.line,
4293                    )
4294                })
4295                .collect(),
4296            entry_points: snapshot.entry_points.clone(),
4297            entry_point_symbols: snapshot.entry_point_symbols.clone(),
4298        }
4299    }
4300
4301    fn assert_dead_item(aggregate: &Value, file: &str, symbol: &str) {
4302        assert!(
4303            aggregate_has_item(aggregate, file, symbol),
4304            "expected {file}::{symbol} to be reported dead: {aggregate:#}"
4305        );
4306    }
4307
4308    fn assert_live_item(aggregate: &Value, file: &str, symbol: &str) {
4309        assert!(
4310            !aggregate_has_item(aggregate, file, symbol),
4311            "expected {file}::{symbol} to be live/not reported dead: {aggregate:#}"
4312        );
4313    }
4314
4315    fn aggregate_has_item(aggregate: &Value, file: &str, symbol: &str) -> bool {
4316        let Some(items) = aggregate.get("items").and_then(Value::as_array) else {
4317            return false;
4318        };
4319        items.iter().any(|item| {
4320            item.get("file").and_then(Value::as_str) == Some(file)
4321                && item.get("symbol").and_then(Value::as_str) == Some(symbol)
4322        })
4323    }
4324
4325    fn project_files(root: &Path) -> Vec<PathBuf> {
4326        walk_project_files(root).collect()
4327    }
4328
4329    fn canonical_root(root: &Path) -> PathBuf {
4330        std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf())
4331    }
4332
4333    fn write_file(path: &Path, content: &str) {
4334        if let Some(parent) = path.parent() {
4335            std::fs::create_dir_all(parent).expect("create parent");
4336        }
4337        std::fs::write(path, content).expect("write fixture");
4338        bump_mtime(path);
4339    }
4340
4341    fn bump_mtime(path: &Path) {
4342        let secs = NEXT_MTIME.fetch_add(1, AtomicOrdering::SeqCst);
4343        filetime::set_file_mtime(path, FileTime::from_unix_time(secs, 0)).expect("bump mtime");
4344    }
4345
4346    fn remove_file(path: &Path) {
4347        std::fs::remove_file(path).expect("remove fixture");
4348    }
4349
4350    fn write_projection_fixture(root: &Path) {
4351        write_file(
4352            &root.join("package.json"),
4353            r#"{"name":"dead-code-projection-fixture","type":"module","main":"src/main.ts"}"#,
4354        );
4355        write_file(
4356            &root.join("Cargo.toml"),
4357            r#"[package]
4358name = "dead_code_projection_fixture"
4359version = "0.1.0"
4360edition = "2021"
4361"#,
4362        );
4363        write_file(
4364            &root.join("src/main.ts"),
4365            r#"import runDefault from "./default";
4366import { knownLive } from "./live";
4367import { jsEntry } from "./app.js";
4368
4369export function main() {
4370  dispatch();
4371  runDefault();
4372  jsEntry();
4373}
4374
4375function dispatch() {
4376  knownLive();
4377  const service = { render() {} };
4378  service.render();
4379}
4380"#,
4381        );
4382        write_file(
4383            &root.join("src/default.ts"),
4384            r#"export default function runDefault() {}
4385"#,
4386        );
4387        write_file(
4388            &root.join("src/live.ts"),
4389            r#"export function knownLive() {}
4390"#,
4391        );
4392        write_file(
4393            &root.join("src/dead.ts"),
4394            r#"export function knownDead() {}
4395"#,
4396        );
4397        write_file(
4398            &root.join("src/render.ts"),
4399            r#"export function render() {}
4400"#,
4401        );
4402        write_file(
4403            &root.join("src/other_render.ts"),
4404            r#"export function render() {}
4405"#,
4406        );
4407        write_file(
4408            &root.join("src/app.js"),
4409            r#"import { jsHelper } from "./js_helper.js";
4410
4411export function jsEntry() {
4412  jsHelper();
4413}
4414"#,
4415        );
4416        write_file(
4417            &root.join("src/js_helper.js"),
4418            r#"export function jsHelper() {}
4419"#,
4420        );
4421        write_file(
4422            &root.join("src/lib.rs"),
4423            r#"mod util;
4424use crate::util::rust_helper;
4425
4426pub fn rust_entry() {
4427    rust_helper();
4428}
4429"#,
4430        );
4431        write_file(
4432            &root.join("src/util.rs"),
4433            r#"pub fn rust_helper() {}
4434"#,
4435        );
4436    }
4437
4438    fn write_rust_attribute_entry_fixture(root: &Path) {
4439        write_file(
4440            &root.join("src/main.rs"),
4441            r#"mod commands;
4442mod db;
4443mod imported;
4444mod unimported;
4445mod unrelated;
4446
4447fn main() {
4448    tauri::generate_handler![commands::get_primers, imported::imported_command];
4449}
4450"#,
4451        );
4452        write_file(
4453            &root.join("src/commands.rs"),
4454            r#"use crate::db;
4455
4456#[tauri::command]
4457pub fn get_primers() -> String {
4458    db::helper()
4459}
4460
4461pub fn planted_dead() -> String {
4462    "dead".to_string()
4463}
4464
4465#[tauri::command]
4466fn private_command() -> String {
4467    db::private_helper()
4468}
4469"#,
4470        );
4471        write_file(
4472            &root.join("src/imported.rs"),
4473            r#"use crate::db;
4474use tauri::command;
4475
4476#[command]
4477pub fn imported_command() -> String {
4478    db::imported_helper()
4479}
4480"#,
4481        );
4482        write_file(
4483            &root.join("src/unimported.rs"),
4484            r#"use crate::db;
4485
4486#[command]
4487pub fn false_command() -> String {
4488    db::false_helper()
4489}
4490"#,
4491        );
4492        write_file(
4493            &root.join("src/db.rs"),
4494            r#"pub fn helper() -> String { "live".to_string() }
4495pub fn imported_helper() -> String { "live".to_string() }
4496pub fn private_helper() -> String { "live".to_string() }
4497pub fn false_helper() -> String { "dead".to_string() }
4498"#,
4499        );
4500        write_file(
4501            &root.join("src/unrelated.rs"),
4502            r#"pub fn unrelated() -> u32 { 1 }
4503"#,
4504        );
4505    }
4506
4507    fn setup_projection_rename(root: &Path) {
4508        write_file(
4509            &root.join("a.ts"),
4510            r#"export function outer() {
4511  inner();
4512}
4513
4514export function inner() {}
4515"#,
4516        );
4517    }
4518
4519    fn edit_projection_rename(root: &Path) -> Vec<PathBuf> {
4520        let path = root.join("a.ts");
4521        write_file(
4522            &path,
4523            r#"export function outer() {
4524  renamed();
4525}
4526
4527export function renamed() {}
4528"#,
4529        );
4530        vec![path]
4531    }
4532
4533    fn setup_projection_delete(root: &Path) {
4534        write_file(
4535            &root.join("main.ts"),
4536            r#"import { foo } from "./foo";
4537export function main() { foo(); }
4538"#,
4539        );
4540        write_file(&root.join("foo.ts"), "export function foo() {}\n");
4541    }
4542
4543    fn edit_projection_delete(root: &Path) -> Vec<PathBuf> {
4544        let path = root.join("foo.ts");
4545        remove_file(&path);
4546        vec![path]
4547    }
4548
4549    fn setup_projection_barrel(root: &Path) {
4550        write_file(
4551            &root.join("main.ts"),
4552            r#"import { foo } from "./barrel";
4553export function main() { foo(); }
4554"#,
4555        );
4556        write_file(&root.join("barrel.ts"), "export { foo } from \"./foo\";\n");
4557        write_file(&root.join("foo.ts"), "export function foo() {}\n");
4558    }
4559
4560    fn edit_projection_barrel_delete(root: &Path) -> Vec<PathBuf> {
4561        let path = root.join("barrel.ts");
4562        remove_file(&path);
4563        vec![path]
4564    }
4565
4566    fn setup_projection_dispatch(root: &Path) {
4567        write_file(
4568            &root.join("main.ts"),
4569            r#"export function main() {
4570  const service = { render() {}, paint() {} };
4571  service.render();
4572}
4573"#,
4574        );
4575        write_file(&root.join("render.ts"), "export function render() {}\n");
4576        write_file(&root.join("paint.ts"), "export function paint() {}\n");
4577    }
4578
4579    fn edit_projection_dispatch(root: &Path) -> Vec<PathBuf> {
4580        let path = root.join("main.ts");
4581        write_file(
4582            &path,
4583            r#"export function main() {
4584  const service = { render() {}, paint() {} };
4585  service.paint();
4586}
4587"#,
4588        );
4589        vec![path]
4590    }
4591
4592    fn setup_projection_body_only(root: &Path) {
4593        write_file(
4594            &root.join("main.ts"),
4595            r#"import { foo } from "./foo";
4596export function main() { foo(); }
4597"#,
4598        );
4599        write_file(
4600            &root.join("foo.ts"),
4601            r#"export function foo() {
4602  return 1;
4603}
4604"#,
4605        );
4606    }
4607
4608    fn edit_projection_body_only(root: &Path) -> Vec<PathBuf> {
4609        let path = root.join("foo.ts");
4610        write_file(
4611            &path,
4612            r#"export function foo() {
4613  return 2;
4614}
4615"#,
4616        );
4617        vec![path]
4618    }
4619}