Skip to main content

aft/inspect/
manager.rs

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