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