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