Skip to main content

aft/inspect/
manager.rs

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