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