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