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