Skip to main content

aft/inspect/
manager.rs

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