Skip to main content

aft/inspect/
manager.rs

1use std::collections::{BTreeMap, BTreeSet, HashMap};
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::sync::{Arc, 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, Tier2ContributionUpdates};
12use super::dispatch::{default_worker, start_dispatch_loop, InspectWorker};
13use super::freshness::{verify_contribution_file, ContributionFreshness};
14#[cfg(test)]
15use super::job::normalize_path;
16use super::job::{
17    is_test_file, CallgraphSnapshot, FileContribution, InspectCategory, InspectJob, InspectResult,
18    InspectScanSuccess, InspectSnapshot, JobKey, JobOutcome, JobScope,
19};
20use super::oxc_engine::LivenessVerdict;
21use super::oxc_engine::{
22    analyze_file_facts, analyze_files_with_cache, normalize_input_path, AnalyzeOptions,
23    DynamicImportFact, ExportFact, FileFacts, FileId, ImportFact, OxcEngineResult, OxcFactsCache,
24    ReExportFact, FACTS_FORMAT_VERSION, OXC_PROVENANCE,
25};
26use crate::cache_freshness::{self, FileFreshness};
27use crate::callgraph_store::{project_dead_code_snapshot, CallGraphStore, CallGraphStoreError};
28
29const DEFAULT_SOFT_DEADLINE: Duration = Duration::from_secs(1);
30
31type WaiterTx = Sender<JobOutcome>;
32
33#[derive(Clone)]
34struct Waiter {
35    tx: WaiterTx,
36}
37
38struct CachedContributionFreshness {
39    file_path: PathBuf,
40    freshness: FileFreshness,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Hash)]
44struct InspectCacheIdentity {
45    sqlite_path: PathBuf,
46    project_root: PathBuf,
47}
48
49#[derive(Debug, Clone)]
50pub struct Tier2RunSubmissionError {
51    pub category: InspectCategory,
52    pub message: String,
53}
54
55#[derive(Debug, Clone, Default)]
56pub struct Tier2RunSubmission {
57    pub queued_categories: Vec<InspectCategory>,
58    pub newly_queued_categories: Vec<InspectCategory>,
59    pub errors: Vec<Tier2RunSubmissionError>,
60}
61
62impl Tier2RunSubmission {
63    pub fn has_new_work(&self) -> bool {
64        !self.newly_queued_categories.is_empty()
65    }
66}
67
68#[derive(Debug, Clone)]
69pub struct DirectTier2RunOutcome {
70    pub outcome: JobOutcome,
71    pub force_paths_completed: bool,
72}
73
74#[derive(Debug, Clone)]
75struct Tier2ReuseOptions {
76    force_rescan_paths: BTreeSet<PathBuf>,
77    allow_callgraph_cold_build: bool,
78}
79
80impl Tier2ReuseOptions {
81    fn direct(paths: Vec<PathBuf>) -> Self {
82        Self {
83            force_rescan_paths: paths.into_iter().collect(),
84            allow_callgraph_cold_build: false,
85        }
86    }
87
88    fn has_force_paths(&self) -> bool {
89        !self.force_rescan_paths.is_empty()
90    }
91}
92
93impl Default for Tier2ReuseOptions {
94    fn default() -> Self {
95        Self {
96            force_rescan_paths: BTreeSet::new(),
97            allow_callgraph_cold_build: true,
98        }
99    }
100}
101
102pub struct InspectManager {
103    request_tx: Sender<InspectJob>,
104    result_rx: Receiver<InspectResult>,
105    #[allow(dead_code)]
106    pool: Arc<rayon::ThreadPool>,
107    in_flight: Mutex<HashMap<JobKey, Vec<Waiter>>>,
108    caches: Mutex<HashMap<InspectCacheIdentity, Arc<InspectCache>>>,
109    oxc_facts_cache: Mutex<OxcFactsCache>,
110    soft_deadline: Duration,
111    next_job_id: AtomicU64,
112    /// Monotonic count of Tier-2 completions delivered via the reuse path
113    /// (watcher-driven scheduler runs). These bypass `result_rx`/
114    /// `drain_completions`, so the `&AppContext`-side drain polls this counter
115    /// to know when to refresh the agent status bar after a background scan.
116    reuse_completions: AtomicU64,
117}
118
119impl InspectManager {
120    pub fn new() -> Self {
121        Self::with_worker(default_worker(), DEFAULT_SOFT_DEADLINE)
122    }
123
124    #[doc(hidden)]
125    pub fn with_worker(worker: InspectWorker, soft_deadline: Duration) -> Self {
126        let handles = start_dispatch_loop(worker);
127        Self {
128            request_tx: handles.request_tx,
129            result_rx: handles.result_rx,
130            pool: handles.pool,
131            in_flight: Mutex::new(HashMap::new()),
132            caches: Mutex::new(HashMap::new()),
133            oxc_facts_cache: Mutex::new(OxcFactsCache::new()),
134            soft_deadline,
135            next_job_id: AtomicU64::new(1),
136            reuse_completions: AtomicU64::new(0),
137        }
138    }
139
140    pub fn submit_category(
141        &self,
142        snapshot: InspectSnapshot,
143        category: InspectCategory,
144        caller_scope: JobScope,
145    ) -> JobOutcome {
146        self.submit_category_with_callgraph(snapshot, category, caller_scope, None)
147    }
148
149    pub fn submit_category_with_callgraph(
150        &self,
151        snapshot: InspectSnapshot,
152        category: InspectCategory,
153        caller_scope: JobScope,
154        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
155    ) -> JobOutcome {
156        if !category.is_active() {
157            return JobOutcome::Failed {
158                message: format!("inspect category '{category}' is disabled in v0.33"),
159            };
160        }
161
162        let cache = match self.cache_for_snapshot(&snapshot) {
163            Ok(cache) => cache,
164            Err(message) => return JobOutcome::Failed { message },
165        };
166        let key = JobKey::for_category_scope(category, &caller_scope);
167        let (waiter_tx, waiter_rx) = bounded(1);
168
169        let wait_snapshot = snapshot.clone();
170        match self.enqueue_with_waiter(
171            snapshot,
172            category,
173            caller_scope.clone(),
174            key.clone(),
175            waiter_tx,
176            callgraph_snapshot,
177        ) {
178            Ok(()) => self.wait_for_outcome(key, caller_scope, cache, waiter_rx, wait_snapshot),
179            Err(message) => JobOutcome::Failed { message },
180        }
181    }
182
183    pub fn submit_background(
184        &self,
185        snapshot: InspectSnapshot,
186        category: InspectCategory,
187        caller_scope: JobScope,
188    ) -> Result<JobKey, String> {
189        self.submit_background_with_callgraph(snapshot, category, caller_scope, None)
190    }
191
192    pub fn submit_background_with_callgraph(
193        &self,
194        snapshot: InspectSnapshot,
195        category: InspectCategory,
196        caller_scope: JobScope,
197        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
198    ) -> Result<JobKey, String> {
199        if !category.is_active() {
200            return Err(format!(
201                "inspect category '{category}' is disabled in v0.33"
202            ));
203        }
204        let key = JobKey::for_category_scope(category, &caller_scope);
205        self.enqueue_without_waiter(
206            snapshot,
207            category,
208            caller_scope,
209            key.clone(),
210            callgraph_snapshot,
211        )?;
212        Ok(key)
213    }
214
215    pub fn submit_tier2_run_with_reuse_background(
216        self: &Arc<Self>,
217        snapshot: InspectSnapshot,
218        category: InspectCategory,
219    ) -> Result<JobKey, String> {
220        if !category.is_active() {
221            return Err(format!(
222                "inspect category '{category}' is disabled in v0.33"
223            ));
224        }
225        if !category.is_tier2() {
226            return Err(format!(
227                "inspect category '{category}' is not a Tier 2 category"
228            ));
229        }
230
231        let job = self.tier2_reuse_job(snapshot, category, None);
232        let key = job.key.clone();
233        let mut in_flight = self
234            .in_flight
235            .lock()
236            .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
237        if in_flight.contains_key(&key) {
238            return Ok(key);
239        }
240        in_flight.insert(key.clone(), Vec::new());
241        drop(in_flight);
242
243        let manager = Arc::clone(self);
244        let pool = Arc::clone(&self.pool);
245        pool.spawn(move || {
246            let result = manager.tier2_run_with_reuse_job_result(job);
247            manager.route_tier2_reuse_completion(result);
248        });
249
250        Ok(key)
251    }
252
253    pub fn submit_tier2_run_with_reuse_serial_background(
254        self: &Arc<Self>,
255        snapshot: InspectSnapshot,
256        categories: Vec<InspectCategory>,
257    ) -> Tier2RunSubmission {
258        let mut submission = Tier2RunSubmission::default();
259        let mut requested = Vec::new();
260
261        for category in categories {
262            if !category.is_active() {
263                submission.errors.push(Tier2RunSubmissionError {
264                    category,
265                    message: format!("inspect category '{category}' is disabled in v0.33"),
266                });
267                continue;
268            }
269            if !category.is_tier2() {
270                submission.errors.push(Tier2RunSubmissionError {
271                    category,
272                    message: format!("inspect category '{category}' is not a Tier 2 category"),
273                });
274                continue;
275            }
276            requested.push(category);
277        }
278
279        if requested.is_empty() {
280            return submission;
281        }
282
283        let mut in_flight = match self.in_flight.lock() {
284            Ok(in_flight) => in_flight,
285            Err(_) => {
286                for category in requested {
287                    submission.errors.push(Tier2RunSubmissionError {
288                        category,
289                        message: "inspect in-flight map lock poisoned".to_string(),
290                    });
291                }
292                return submission;
293            }
294        };
295
296        for category in requested {
297            let key = JobKey::for_project_category(category);
298            submission.queued_categories.push(category);
299            if in_flight.contains_key(&key) {
300                continue;
301            }
302            in_flight.insert(key, Vec::new());
303            submission.newly_queued_categories.push(category);
304        }
305        drop(in_flight);
306
307        if submission.newly_queued_categories.is_empty() {
308            return submission;
309        }
310
311        let categories_for_worker = submission.newly_queued_categories.clone();
312        let manager = Arc::clone(self);
313        let pool = Arc::clone(&self.pool);
314        pool.spawn(move || {
315            for category in categories_for_worker {
316                let result = manager.tier2_run_with_reuse_result(snapshot.clone(), category, None);
317                manager.route_tier2_reuse_completion(result);
318            }
319        });
320
321        submission
322    }
323
324    pub fn tier2_any_in_flight(&self) -> bool {
325        self.in_flight
326            .lock()
327            .map(|in_flight| in_flight.keys().any(|key| key.category.is_tier2()))
328            .unwrap_or(false)
329    }
330
331    pub fn drain_completions(&self) -> usize {
332        let mut drained = 0usize;
333        while let Ok(result) = self.result_rx.try_recv() {
334            self.route_completion(result);
335            drained += 1;
336        }
337        drained
338    }
339
340    pub fn cache_for_snapshot(
341        &self,
342        snapshot: &InspectSnapshot,
343    ) -> Result<Arc<InspectCache>, String> {
344        self.cache_for_paths(snapshot.inspect_dir.clone(), snapshot.project_root.clone())
345    }
346
347    /// Latest persisted counts for the three Tier-2 categories, in
348    /// `(dead_code, unused_exports, duplicates)` order. Reads the most recent
349    /// aggregate regardless of contribution-hash freshness (last-known), so the
350    /// agent status bar can refresh after a background scan completes without a
351    /// freshness round-trip. A category with no readable aggregate reports
352    /// `None` (never a fabricated `0`), so the status bar can preserve any
353    /// last-known value and stay suppressed until every category is real (#1).
354    pub fn latest_tier2_counts(
355        &self,
356        inspect_dir: PathBuf,
357        project_root: PathBuf,
358    ) -> (Option<usize>, Option<usize>, Option<usize>) {
359        let Ok(cache) = self.cache_for_paths(inspect_dir, project_root) else {
360            return (None, None, None);
361        };
362        let count_of = |category: InspectCategory| -> Option<usize> {
363            cache
364                .latest_aggregate_any_hash(category)
365                .ok()
366                .flatten()
367                .and_then(|payload| {
368                    if category == InspectCategory::DeadCode
369                        && payload
370                            .get("callgraph_available")
371                            .and_then(serde_json::Value::as_bool)
372                            == Some(false)
373                    {
374                        return None;
375                    }
376                    payload
377                        .get("count")
378                        .and_then(serde_json::Value::as_u64)
379                        .map(|count| count as usize)
380                })
381        };
382        (
383            count_of(InspectCategory::DeadCode),
384            count_of(InspectCategory::UnusedExports),
385            count_of(InspectCategory::Duplicates),
386        )
387    }
388
389    pub fn cache_for_paths(
390        &self,
391        inspect_dir: PathBuf,
392        project_root: PathBuf,
393    ) -> Result<Arc<InspectCache>, String> {
394        let project_key = crate::search_index::artifact_cache_key(&project_root);
395        let sqlite_path = inspect_dir.join(format!("{project_key}.sqlite"));
396        let identity = InspectCacheIdentity {
397            sqlite_path,
398            project_root: project_root.clone(),
399        };
400        let mut caches = self
401            .caches
402            .lock()
403            .map_err(|_| "inspect manager cache map lock poisoned".to_string())?;
404        if let Some(cache) = caches.get(&identity) {
405            return Ok(Arc::clone(cache));
406        }
407        let cache = Arc::new(
408            InspectCache::open(inspect_dir, project_root)
409                .map_err(|error| format!("failed to open inspect cache: {error}"))?,
410        );
411        caches.insert(identity, Arc::clone(&cache));
412        Ok(cache)
413    }
414
415    fn oxc_result_for_scan(
416        &self,
417        job: &InspectJob,
418        files: &[PathBuf],
419        force_reparse_files: &[PathBuf],
420    ) -> Result<Option<OxcEngineResult>, String> {
421        if !category_uses_oxc(job.category) {
422            return Ok(None);
423        }
424        if job.category == InspectCategory::DeadCode && job.callgraph_snapshot.is_none() {
425            return Ok(None);
426        }
427
428        let public_api_entries = super::entry_points::resolve_entry_points(&job.project_root);
429        let entry_points = if job.category == InspectCategory::DeadCode {
430            job.callgraph_snapshot
431                .as_ref()
432                .map(|snapshot| snapshot.entry_points.iter().cloned().collect::<Vec<_>>())
433                .unwrap_or_default()
434        } else {
435            Vec::new()
436        };
437        let options = AnalyzeOptions {
438            entry_points,
439            public_api_files: public_api_entries.public_api_files(),
440            executable_root_exports: public_api_entries.executable_root_exports(),
441            force_reparse_files: force_reparse_files.to_vec(),
442            entry_reachability: job.category == InspectCategory::DeadCode,
443        };
444
445        let mut cache = self
446            .oxc_facts_cache
447            .lock()
448            .map_err(|_| "inspect oxc facts cache lock poisoned".to_string())?;
449        analyze_files_with_cache(&job.project_root, files, options, &mut cache)
450            .map(Some)
451            .map_err(|message| format!("oxc analyze failed: {message}"))
452    }
453
454    pub fn tier2_run_with_reuse(
455        &self,
456        snapshot: InspectSnapshot,
457        category: InspectCategory,
458        caller_scope: JobScope,
459        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
460    ) -> JobOutcome {
461        if let Err(outcome) = validate_tier2_read_category(category) {
462            return outcome;
463        }
464        let cache = match self.cache_for_snapshot(&snapshot) {
465            Ok(cache) => cache,
466            Err(message) => return JobOutcome::Failed { message },
467        };
468        let job = self.tier2_reuse_job(snapshot.clone(), category, callgraph_snapshot);
469        let key = job.key.clone();
470        let (waiter_tx, waiter_rx) = bounded(1);
471        let claimed = match self.register_tier2_reuse_waiter(&key, waiter_tx) {
472            Ok(claimed) => claimed,
473            Err(message) => return JobOutcome::Failed { message },
474        };
475
476        if claimed {
477            let result = self
478                .tier2_run_with_reuse_job_result_with_options(job, Tier2ReuseOptions::default());
479            self.route_tier2_reuse_completion(result);
480        }
481
482        match waiter_rx.recv() {
483            Ok(outcome) => filter_outcome_for_scope_with_contributions(
484                outcome,
485                &snapshot,
486                category,
487                cache.as_ref(),
488                &caller_scope,
489            ),
490            Err(_) => JobOutcome::Pending { in_flight: true },
491        }
492    }
493
494    pub fn tier2_run_with_reuse_direct(
495        self: &Arc<Self>,
496        snapshot: InspectSnapshot,
497        category: InspectCategory,
498        caller_scope: JobScope,
499        deadline: Instant,
500        force_rescan_paths: Vec<PathBuf>,
501    ) -> DirectTier2RunOutcome {
502        if let Err(outcome) = validate_tier2_read_category(category) {
503            return DirectTier2RunOutcome {
504                outcome,
505                force_paths_completed: false,
506            };
507        }
508        let cache = match self.cache_for_snapshot(&snapshot) {
509            Ok(cache) => cache,
510            Err(message) => {
511                return DirectTier2RunOutcome {
512                    outcome: JobOutcome::Failed { message },
513                    force_paths_completed: false,
514                }
515            }
516        };
517
518        let must_run_forced_followup = !force_rescan_paths.is_empty();
519        loop {
520            let options = if must_run_forced_followup {
521                Tier2ReuseOptions::direct(force_rescan_paths.clone())
522            } else {
523                Tier2ReuseOptions::direct(Vec::new())
524            };
525            let job = self.tier2_reuse_job(snapshot.clone(), category, None);
526            let key = job.key.clone();
527            let (waiter_tx, waiter_rx) = bounded(1);
528            let claimed = match self.register_tier2_reuse_waiter(&key, waiter_tx) {
529                Ok(claimed) => claimed,
530                Err(message) => {
531                    return DirectTier2RunOutcome {
532                        outcome: JobOutcome::Failed { message },
533                        force_paths_completed: false,
534                    }
535                }
536            };
537            if claimed {
538                self.spawn_tier2_reuse_job(job, options);
539            }
540
541            let completed_force_run = claimed && must_run_forced_followup;
542            let outcome = self.wait_for_tier2_reuse_until(
543                &key,
544                &caller_scope,
545                cache.as_ref(),
546                waiter_rx,
547                &snapshot,
548                deadline,
549            );
550
551            delay_direct_force_followup_deadline_check_for_debug(&snapshot.project_root);
552            if must_run_forced_followup
553                && !claimed
554                && !matches!(outcome, JobOutcome::Pending { .. })
555            {
556                // The category was already in flight before this direct inspect
557                // could supply its forced paths. Wait for that scan to finish,
558                // then claim a follow-up reuse pass so the direct answer is based
559                // on the paths invalidated by the edit/watcher stream rather than
560                // on a possibly stat-fresh pre-existing scan. If the original scan
561                // used the whole deadline, the forced paths were not incorporated,
562                // so the honest direct result is still incomplete.
563                if Instant::now() < deadline {
564                    continue;
565                }
566                return DirectTier2RunOutcome {
567                    outcome: JobOutcome::Pending { in_flight: true },
568                    force_paths_completed: false,
569                };
570            }
571
572            return DirectTier2RunOutcome {
573                outcome,
574                force_paths_completed: completed_force_run,
575            };
576        }
577    }
578
579    fn register_tier2_reuse_waiter(
580        &self,
581        key: &JobKey,
582        waiter_tx: WaiterTx,
583    ) -> Result<bool, String> {
584        let mut in_flight = self
585            .in_flight
586            .lock()
587            .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
588        if let Some(waiters) = in_flight.get_mut(key) {
589            waiters.push(Waiter { tx: waiter_tx });
590            return Ok(false);
591        }
592
593        in_flight.insert(key.clone(), vec![Waiter { tx: waiter_tx }]);
594        Ok(true)
595    }
596
597    fn spawn_tier2_reuse_job(self: &Arc<Self>, job: InspectJob, options: Tier2ReuseOptions) {
598        let manager = Arc::clone(self);
599        let pool = Arc::clone(&self.pool);
600        pool.spawn(move || {
601            let result = manager.tier2_run_with_reuse_job_result_catching(job, options);
602            manager.route_tier2_reuse_completion(result);
603        });
604    }
605
606    fn wait_for_tier2_reuse_until(
607        &self,
608        key: &JobKey,
609        caller_scope: &JobScope,
610        cache: &InspectCache,
611        waiter_rx: Receiver<JobOutcome>,
612        snapshot: &InspectSnapshot,
613        deadline: Instant,
614    ) -> JobOutcome {
615        let Some(remaining) = deadline.checked_duration_since(Instant::now()) else {
616            return JobOutcome::Pending { in_flight: true };
617        };
618        if remaining.is_zero() {
619            return JobOutcome::Pending { in_flight: true };
620        }
621
622        match waiter_rx.recv_timeout(remaining) {
623            Ok(outcome) => filter_outcome_for_scope_with_contributions(
624                outcome,
625                snapshot,
626                key.category,
627                cache,
628                caller_scope,
629            ),
630            Err(crossbeam_channel::RecvTimeoutError::Timeout) => {
631                JobOutcome::Pending { in_flight: true }
632            }
633            Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
634                JobOutcome::Pending { in_flight: true }
635            }
636        }
637    }
638
639    /// Read-only Tier 2 aggregate lookup for `aft_inspect`. Does NOT run any
640    /// scanner — returns the latest cached aggregate if present and verifies
641    /// its contribution freshness so warm cache hits are reported as fresh.
642    /// This is the non-blocking variant intended for the synchronous `inspect`
643    /// command path; Tier 2 scans run via the watcher-driven scheduler or the
644    /// compatibility `aft_inspect_tier2_run` command.
645    pub fn tier2_read_cached(
646        &self,
647        snapshot: InspectSnapshot,
648        category: InspectCategory,
649        caller_scope: JobScope,
650    ) -> JobOutcome {
651        if let Err(outcome) = validate_tier2_read_category(category) {
652            return outcome;
653        }
654        let cache = match self.cache_for_snapshot(&snapshot) {
655            Ok(cache) => cache,
656            Err(message) => return JobOutcome::Failed { message },
657        };
658        self.tier2_read_cached_from_cache(&snapshot, category, &caller_scope, cache.as_ref())
659    }
660
661    pub fn tier2_read_cached_readonly(
662        &self,
663        snapshot: InspectSnapshot,
664        category: InspectCategory,
665        caller_scope: JobScope,
666    ) -> JobOutcome {
667        if let Err(outcome) = validate_tier2_read_category(category) {
668            return outcome;
669        }
670        let key = JobKey::for_project_category(category);
671        let in_flight = self
672            .in_flight
673            .lock()
674            .map(|guard| guard.contains_key(&key))
675            .unwrap_or(false);
676        let cache = match InspectCache::open_readonly(
677            snapshot.inspect_dir.clone(),
678            snapshot.project_root.clone(),
679        ) {
680            Ok(Some(cache)) => cache,
681            Ok(None) => return JobOutcome::Pending { in_flight },
682            Err(error) => {
683                return JobOutcome::Failed {
684                    message: error.to_string(),
685                }
686            }
687        };
688        self.tier2_read_cached_from_cache(&snapshot, category, &caller_scope, &cache)
689    }
690
691    fn tier2_read_cached_from_cache(
692        &self,
693        snapshot: &InspectSnapshot,
694        category: InspectCategory,
695        caller_scope: &JobScope,
696        cache: &InspectCache,
697    ) -> JobOutcome {
698        let key = JobKey::for_project_category(category);
699        let in_flight = self
700            .in_flight
701            .lock()
702            .map(|guard| guard.contains_key(&key))
703            .unwrap_or(false);
704        match cache.get_aggregated_for_config(&key, snapshot.config.as_ref()) {
705            Ok(Some(payload)) => {
706                match self.tier2_cached_aggregate_is_fresh(snapshot, category, cache) {
707                    Ok(true) => filter_outcome_for_scope_with_contributions(
708                        JobOutcome::Fresh { payload },
709                        snapshot,
710                        category,
711                        cache,
712                        caller_scope,
713                    ),
714                    Ok(false) => filter_outcome_for_scope_with_contributions(
715                        JobOutcome::Stale {
716                            cached: Some(payload),
717                            in_flight,
718                        },
719                        snapshot,
720                        category,
721                        cache,
722                        caller_scope,
723                    ),
724                    Err(message) => JobOutcome::Failed { message },
725                }
726            }
727            Ok(None) => match cache.latest_aggregate_any_hash(category) {
728                Ok(Some(payload)) => filter_outcome_for_scope_with_contributions(
729                    JobOutcome::Stale {
730                        cached: Some(payload),
731                        in_flight,
732                    },
733                    snapshot,
734                    category,
735                    cache,
736                    caller_scope,
737                ),
738                Ok(None) => JobOutcome::Pending { in_flight },
739                Err(error) => JobOutcome::Failed {
740                    message: error.to_string(),
741                },
742            },
743            Err(error) => JobOutcome::Failed {
744                message: error.to_string(),
745            },
746        }
747    }
748
749    fn tier2_cached_aggregate_is_fresh(
750        &self,
751        snapshot: &InspectSnapshot,
752        category: InspectCategory,
753        cache: &InspectCache,
754    ) -> Result<bool, String> {
755        let cached_records = load_contribution_freshness(cache, category)?;
756        let cached_relative = cached_records
757            .iter()
758            .map(freshness_record_relative_key)
759            .collect::<BTreeSet<_>>();
760
761        for record in &cached_records {
762            let absolute = if record.file_path.is_absolute() {
763                record.file_path.clone()
764            } else {
765                snapshot.project_root.join(&record.file_path)
766            };
767            match verify_contribution_file(&absolute, &record.freshness) {
768                ContributionFreshness::Fresh { .. } => {}
769                ContributionFreshness::Stale | ContributionFreshness::Deleted => return Ok(false),
770            }
771        }
772
773        // Detect files added since the cached aggregate was generated (and files
774        // that still exist but are no longer in the gitignore-aware project
775        // scope). This walk remains on the read path because the current API does
776        // not provide a watcher-maintained project file set, and additions cannot
777        // be detected from cached contribution records alone. Existing cached
778        // files are checked above first so ordinary edits/deletes can return stale
779        // without walking the project.
780        let project_scope = JobScope::for_project(snapshot.project_root.clone());
781        let project_files = scope_files(&snapshot.project_root, &project_scope);
782        let current_by_relative = current_project_files(&snapshot.project_root, &project_files);
783
784        Ok(current_by_relative.len() == cached_relative.len()
785            && current_by_relative
786                .keys()
787                .all(|relative| cached_relative.contains(relative)))
788    }
789
790    #[doc(hidden)]
791    pub fn tier2_run_with_reuse_result(
792        &self,
793        snapshot: InspectSnapshot,
794        category: InspectCategory,
795        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
796    ) -> InspectResult {
797        let job = self.tier2_reuse_job(snapshot, category, callgraph_snapshot);
798        self.tier2_run_with_reuse_job_result(job)
799    }
800
801    fn tier2_run_with_reuse_job_result(&self, job: InspectJob) -> InspectResult {
802        self.tier2_run_with_reuse_job_result_with_options(job, Tier2ReuseOptions::default())
803    }
804
805    fn tier2_run_with_reuse_job_result_catching(
806        &self,
807        job: InspectJob,
808        options: Tier2ReuseOptions,
809    ) -> InspectResult {
810        let started = Instant::now();
811        match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
812            self.tier2_run_with_reuse_job_result_with_options(job.clone(), options)
813        })) {
814            Ok(result) => result,
815            Err(_) => InspectResult::failed(
816                &job,
817                "tier2 reuse worker panicked before completion",
818                started.elapsed(),
819            ),
820        }
821    }
822
823    fn tier2_run_with_reuse_job_result_with_options(
824        &self,
825        mut job: InspectJob,
826        options: Tier2ReuseOptions,
827    ) -> InspectResult {
828        let started = Instant::now();
829        panic_tier2_reuse_for_debug(&job);
830        if !job.category.is_active() {
831            let result = InspectResult::failed(
832                &job,
833                format!("inspect category '{}' is disabled in v0.33", job.category),
834                started.elapsed(),
835            );
836            log_tier2_benchmark_category_end(&result);
837            return result;
838        }
839        if !job.category.is_tier2() {
840            let result = InspectResult::failed(
841                &job,
842                format!(
843                    "inspect category '{}' is not a Tier 2 category",
844                    job.category
845                ),
846                started.elapsed(),
847            );
848            log_tier2_benchmark_category_end(&result);
849            return result;
850        }
851
852        let project_scope = JobScope::for_project(job.project_root.clone());
853        job.scope_files = scope_files(&job.project_root, &project_scope);
854        log_tier2_benchmark_category_start(&job);
855        let cache = match self.cache_for_paths(job.inspect_dir.clone(), job.project_root.clone()) {
856            Ok(cache) => cache,
857            Err(message) => {
858                let result = InspectResult::failed(&job, message, started.elapsed());
859                log_tier2_benchmark_category_end(&result);
860                return result;
861            }
862        };
863        delay_tier2_reuse_for_debug(&job.project_root);
864        if !options.has_force_paths() {
865            if let Ok(Some(success)) = self.tier2_quick_reuse_success(&job, cache.as_ref()) {
866                let result = InspectResult::success(&job, success, started.elapsed());
867                crate::slog_debug!(
868                    "perf tier2 category={} reuse=hit ms={}",
869                    job.category,
870                    started.elapsed().as_millis()
871                );
872                log_tier2_benchmark_category_end(&result);
873                return result;
874            }
875        }
876
877        let result = match self.tier2_run_with_reuse_job(&job, &cache, &options) {
878            Ok(success) => InspectResult::success(&job, success, started.elapsed()),
879            Err(message) => InspectResult::failed(&job, message, started.elapsed()),
880        };
881        // Always-on perf line: a full (reuse=miss) scan is the expensive path —
882        // for dead_code it includes store snapshot projection plus the scanner.
883        // ms here lets us attribute background CPU bursts to a specific category from the log.
884        crate::slog_info!(
885            "perf tier2 category={} reuse=miss ms={}",
886            job.category,
887            started.elapsed().as_millis()
888        );
889        log_tier2_benchmark_category_end(&result);
890        result
891    }
892
893    fn tier2_reuse_job(
894        &self,
895        snapshot: InspectSnapshot,
896        category: InspectCategory,
897        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
898    ) -> InspectJob {
899        InspectJob {
900            job_id: self.next_job_id.fetch_add(1, Ordering::Relaxed),
901            key: JobKey::for_project_category(category),
902            category,
903            scope_files: Vec::new(),
904            project_root: snapshot.project_root,
905            inspect_dir: snapshot.inspect_dir,
906            config: snapshot.config,
907            symbol_cache: snapshot.symbol_cache,
908            callgraph_snapshot,
909        }
910    }
911
912    fn tier2_quick_reuse_success(
913        &self,
914        job: &InspectJob,
915        cache: &InspectCache,
916    ) -> Result<Option<InspectScanSuccess>, String> {
917        let cached_records = load_contribution_freshness(cache, job.category)?;
918        let current_by_relative = current_project_files(&job.project_root, &job.scope_files);
919        if cached_records.len() != current_by_relative.len() {
920            return Ok(None);
921        }
922        for record in &cached_records {
923            let relative = freshness_record_relative_key(record);
924            let Some(current_file) = current_by_relative.get(&relative) else {
925                return Ok(None);
926            };
927            match cache_freshness::metadata_matches(current_file, &record.freshness) {
928                Ok(true) => {}
929                Ok(false) => return Ok(None),
930                Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
931                Err(error) => {
932                    return Err(format!(
933                        "failed to stat {} for tier2 quick reuse: {error}",
934                        current_file.display()
935                    ));
936                }
937            }
938        }
939
940        let contribution_set_hash = cache
941            .contribution_set_hash_for_config(job.category, job.config.as_ref())
942            .map_err(|error| error.to_string())?;
943        let Some(aggregate) = cache
944            .load_aggregate_if_hash_matches(job.category, &contribution_set_hash)
945            .map_err(|error| error.to_string())?
946        else {
947            return Ok(None);
948        };
949
950        cache
951            .touch_tier2_last_full_run(job.category)
952            .map_err(|error| error.to_string())?;
953        Ok(Some(InspectScanSuccess {
954            scanned_files: Vec::new(),
955            contributions: Vec::new(),
956            aggregate,
957        }))
958    }
959
960    #[allow(clippy::too_many_lines)]
961    fn tier2_run_with_reuse_job(
962        &self,
963        job: &InspectJob,
964        cache: &InspectCache,
965        options: &Tier2ReuseOptions,
966    ) -> Result<InspectScanSuccess, String> {
967        let mut phases = Tier2PhaseTimings::default();
968        let phase_started = Instant::now();
969        let cached_records = load_contribution_freshness(cache, job.category)?;
970        let current_by_relative = current_project_files(&job.project_root, &job.scope_files);
971        let cached_relative = cached_records
972            .iter()
973            .map(freshness_record_relative_key)
974            .collect::<BTreeSet<_>>();
975        let force_relative = forced_relative_paths(job, &options.force_rescan_paths);
976        #[cfg(debug_assertions)]
977        let cold_cache = cached_relative.is_empty();
978
979        let mut updates = Tier2ContributionUpdates::default();
980        let mut scan_by_relative = BTreeMap::<String, PathBuf>::new();
981        let mut aggregate_job = job.clone();
982
983        for record in cached_records {
984            let relative = freshness_record_relative_key(&record);
985            let relative_path = PathBuf::from(&relative);
986            let Some(current_file) = current_by_relative.get(&relative) else {
987                updates.deletes.push(relative_path);
988                continue;
989            };
990
991            if force_relative.contains(&relative) {
992                updates.deletes.push(relative_path);
993                scan_by_relative.insert(relative, current_file.clone());
994                continue;
995            }
996
997            let absolute = job.project_root.join(&record.file_path);
998            match verify_contribution_file(&absolute, &record.freshness) {
999                ContributionFreshness::Fresh {
1000                    metadata_changed,
1001                    freshness,
1002                } => {
1003                    if metadata_changed {
1004                        updates.metadata_updates.push((relative_path, freshness));
1005                    }
1006                }
1007                ContributionFreshness::Stale => {
1008                    updates.deletes.push(relative_path);
1009                    scan_by_relative.insert(relative, current_file.clone());
1010                }
1011                ContributionFreshness::Deleted => {
1012                    updates.deletes.push(relative_path);
1013                }
1014            }
1015        }
1016
1017        for (relative, file) in &current_by_relative {
1018            if !cached_relative.contains(relative) {
1019                scan_by_relative.insert(relative.clone(), file.clone());
1020            }
1021        }
1022        phases.freshness = phase_started.elapsed();
1023
1024        let mut scan_files = scan_by_relative.into_values().collect::<Vec<_>>();
1025        let force_reparse_files = scan_files.clone();
1026        if !scan_files.is_empty() {
1027            let mut scan_job = job.clone();
1028            scan_job.job_id = self.next_job_id.fetch_add(1, Ordering::Relaxed);
1029            scan_job.scope_files = scan_files.clone();
1030            if scan_job.category == InspectCategory::DeadCode
1031                && scan_job.callgraph_snapshot.is_none()
1032            {
1033                let snapshot_started = Instant::now();
1034                scan_job.callgraph_snapshot =
1035                    build_tier2_callgraph_snapshot(&scan_job, options.allow_callgraph_cold_build);
1036                phases.snapshot += snapshot_started.elapsed();
1037            }
1038            aggregate_job.callgraph_snapshot = scan_job.callgraph_snapshot.clone();
1039            #[cfg(debug_assertions)]
1040            if cold_cache {
1041                std::thread::sleep(Duration::from_millis(10));
1042            }
1043            let scan_started = Instant::now();
1044            let oxc_result =
1045                self.oxc_result_for_scan(&scan_job, &scan_job.scope_files, &force_reparse_files)?;
1046            let scan_result = run_tier2_scan(&scan_job, oxc_result.as_ref());
1047            phases.scan += scan_started.elapsed();
1048            phases.scanned_files += scan_files.len();
1049            let scan_success = scan_result.outcome.map_err(|message| {
1050                format!("{} incremental scan failed: {message}", job.category)
1051            })?;
1052            updates.upserts.extend(scan_success.contributions);
1053        }
1054
1055        let has_updates = !updates.upserts.is_empty()
1056            || !updates.deletes.is_empty()
1057            || !updates.metadata_updates.is_empty();
1058        if !has_updates {
1059            if let Some(aggregate) = cache
1060                .get_aggregated_for_config(&job.key, job.config.as_ref())
1061                .map_err(|error| error.to_string())?
1062            {
1063                cache
1064                    .touch_tier2_last_full_run(job.category)
1065                    .map_err(|error| error.to_string())?;
1066                phases.log(job.category);
1067                return Ok(InspectScanSuccess {
1068                    scanned_files: scan_files,
1069                    contributions: Vec::new(),
1070                    aggregate,
1071                });
1072            }
1073        }
1074
1075        let db_started = Instant::now();
1076        let mut contribution_set_hash = if has_updates {
1077            cache
1078                .apply_contribution_updates_for_config(job.category, updates, job.config.as_ref())
1079                .map_err(|error| error.to_string())?
1080        } else {
1081            cache
1082                .contribution_set_hash_for_config(job.category, job.config.as_ref())
1083                .map_err(|error| error.to_string())?
1084        };
1085        phases.db = db_started.elapsed();
1086
1087        if let Some(aggregate) = cache
1088            .load_aggregate_if_hash_matches(job.category, &contribution_set_hash)
1089            .map_err(|error| error.to_string())?
1090        {
1091            cache
1092                .touch_tier2_last_full_run(job.category)
1093                .map_err(|error| error.to_string())?;
1094            let contributions = load_contributions(cache, job)?;
1095            phases.log(job.category);
1096            return Ok(InspectScanSuccess {
1097                scanned_files: scan_files,
1098                contributions,
1099                aggregate,
1100            });
1101        }
1102
1103        let refresh_dead_code_facts = if job.category == InspectCategory::DeadCode {
1104            dead_code_contributions_need_fact_refresh(cache, job)?
1105        } else {
1106            false
1107        };
1108        let refresh_unused_exports_facts = if job.category == InspectCategory::UnusedExports {
1109            unused_exports_contributions_need_fact_refresh(cache, job)?
1110        } else {
1111            false
1112        };
1113        let refresh_duplicates_facts = if job.category == InspectCategory::Duplicates {
1114            duplicates_contributions_need_fact_refresh(cache, job)?
1115        } else {
1116            false
1117        };
1118        if refresh_dead_code_facts || refresh_unused_exports_facts || refresh_duplicates_facts {
1119            // Raw-facts contributions can be rolled up after manifest/resolver
1120            // edits without re-reading source. Only legacy verdict-bearing or
1121            // facts-version-mismatched caches need a one-time full refresh before
1122            // verdicts/roots can be recomputed globally.
1123            let full_scan_files = current_by_relative.into_values().collect::<Vec<_>>();
1124            if !full_scan_files.is_empty() {
1125                let mut rescan_job = job.clone();
1126                rescan_job.job_id = self.next_job_id.fetch_add(1, Ordering::Relaxed);
1127                rescan_job.scope_files = full_scan_files.clone();
1128                if rescan_job.category == InspectCategory::DeadCode
1129                    && rescan_job.callgraph_snapshot.is_none()
1130                {
1131                    let snapshot_started = Instant::now();
1132                    rescan_job.callgraph_snapshot = build_tier2_callgraph_snapshot(
1133                        &rescan_job,
1134                        options.allow_callgraph_cold_build,
1135                    );
1136                    phases.snapshot += snapshot_started.elapsed();
1137                }
1138                let scan_started = Instant::now();
1139                let oxc_result = self.oxc_result_for_scan(
1140                    &rescan_job,
1141                    &rescan_job.scope_files,
1142                    &force_reparse_files,
1143                )?;
1144                let scan_result = run_tier2_scan(&rescan_job, oxc_result.as_ref());
1145                phases.scan += scan_started.elapsed();
1146                phases.scanned_files += full_scan_files.len();
1147                let scan_success = scan_result.outcome.map_err(|message| {
1148                    format!(
1149                        "{} full rescan after entry-point cache miss failed: {message}",
1150                        job.category
1151                    )
1152                })?;
1153                let rescan_updates = Tier2ContributionUpdates {
1154                    upserts: scan_success.contributions,
1155                    ..Tier2ContributionUpdates::default()
1156                };
1157                let db_started = Instant::now();
1158                contribution_set_hash = cache
1159                    .apply_contribution_updates_for_config(
1160                        job.category,
1161                        rescan_updates,
1162                        job.config.as_ref(),
1163                    )
1164                    .map_err(|error| error.to_string())?;
1165                phases.db += db_started.elapsed();
1166                aggregate_job.callgraph_snapshot = rescan_job.callgraph_snapshot.clone();
1167                scan_files = full_scan_files;
1168
1169                if let Some(aggregate) = cache
1170                    .load_aggregate_if_hash_matches(job.category, &contribution_set_hash)
1171                    .map_err(|error| error.to_string())?
1172                {
1173                    cache
1174                        .touch_tier2_last_full_run(job.category)
1175                        .map_err(|error| error.to_string())?;
1176                    let contributions = load_contributions(cache, job)?;
1177                    phases.log(job.category);
1178                    return Ok(InspectScanSuccess {
1179                        scanned_files: scan_files,
1180                        contributions,
1181                        aggregate,
1182                    });
1183                }
1184            }
1185        }
1186
1187        if aggregate_job.category == InspectCategory::DeadCode
1188            && aggregate_job.callgraph_snapshot.is_none()
1189        {
1190            let snapshot_started = Instant::now();
1191            aggregate_job.callgraph_snapshot =
1192                build_tier2_callgraph_snapshot(&aggregate_job, options.allow_callgraph_cold_build);
1193            phases.snapshot += snapshot_started.elapsed();
1194        }
1195        let rollup_started = Instant::now();
1196        let contributions = load_contributions(cache, &aggregate_job)?;
1197        let aggregate = roll_up_tier2_contributions(&aggregate_job, &contributions);
1198        cache
1199            .store_tier2_aggregate(job.key.clone(), &contribution_set_hash, aggregate.clone())
1200            .map_err(|error| error.to_string())?;
1201        phases.rollup = rollup_started.elapsed();
1202        phases.log(job.category);
1203
1204        Ok(InspectScanSuccess {
1205            scanned_files: scan_files,
1206            contributions,
1207            aggregate,
1208        })
1209    }
1210
1211    fn enqueue_with_waiter(
1212        &self,
1213        snapshot: InspectSnapshot,
1214        category: InspectCategory,
1215        caller_scope: JobScope,
1216        key: JobKey,
1217        waiter_tx: WaiterTx,
1218        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
1219    ) -> Result<(), String> {
1220        let mut in_flight = self
1221            .in_flight
1222            .lock()
1223            .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
1224        if let Some(waiters) = in_flight.get_mut(&key) {
1225            waiters.push(Waiter { tx: waiter_tx });
1226            return Ok(());
1227        }
1228
1229        in_flight.insert(key.clone(), vec![Waiter { tx: waiter_tx }]);
1230        drop(in_flight);
1231
1232        if let Err(message) = self.enqueue_new_job(
1233            snapshot,
1234            category,
1235            caller_scope,
1236            key.clone(),
1237            callgraph_snapshot,
1238        ) {
1239            if let Ok(mut in_flight) = self.in_flight.lock() {
1240                in_flight.remove(&key);
1241            }
1242            return Err(message);
1243        }
1244        Ok(())
1245    }
1246
1247    fn enqueue_without_waiter(
1248        &self,
1249        snapshot: InspectSnapshot,
1250        category: InspectCategory,
1251        caller_scope: JobScope,
1252        key: JobKey,
1253        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
1254    ) -> Result<(), String> {
1255        let mut in_flight = self
1256            .in_flight
1257            .lock()
1258            .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
1259        if in_flight.contains_key(&key) {
1260            return Ok(());
1261        }
1262        in_flight.insert(key.clone(), Vec::new());
1263        drop(in_flight);
1264
1265        if let Err(message) = self.enqueue_new_job(
1266            snapshot,
1267            category,
1268            caller_scope,
1269            key.clone(),
1270            callgraph_snapshot,
1271        ) {
1272            if let Ok(mut in_flight) = self.in_flight.lock() {
1273                in_flight.remove(&key);
1274            }
1275            return Err(message);
1276        }
1277        Ok(())
1278    }
1279
1280    fn enqueue_new_job(
1281        &self,
1282        snapshot: InspectSnapshot,
1283        category: InspectCategory,
1284        caller_scope: JobScope,
1285        key: JobKey,
1286        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
1287    ) -> Result<(), String> {
1288        let scan_scope = if category.is_tier2() {
1289            JobScope::for_project(snapshot.project_root.clone())
1290        } else {
1291            caller_scope
1292        };
1293        let scope_files = scope_files(&snapshot.project_root, &scan_scope);
1294        let job = InspectJob {
1295            job_id: self.next_job_id.fetch_add(1, Ordering::Relaxed),
1296            key,
1297            category,
1298            scope_files,
1299            project_root: snapshot.project_root,
1300            inspect_dir: snapshot.inspect_dir,
1301            config: snapshot.config,
1302            symbol_cache: snapshot.symbol_cache,
1303            callgraph_snapshot,
1304        };
1305        self.request_tx
1306            .send(job)
1307            .map_err(|_| "inspect dispatch loop is unavailable".to_string())
1308    }
1309
1310    fn wait_for_outcome(
1311        &self,
1312        key: JobKey,
1313        caller_scope: JobScope,
1314        cache: Arc<InspectCache>,
1315        waiter_rx: Receiver<JobOutcome>,
1316        snapshot: InspectSnapshot,
1317    ) -> JobOutcome {
1318        let timeout = after(self.soft_deadline);
1319        let result_rx = self.result_rx.clone();
1320        loop {
1321            select! {
1322                recv(waiter_rx) -> outcome => {
1323                    return match outcome {
1324                        Ok(outcome) => filter_outcome_for_scope_with_contributions(
1325                            outcome,
1326                            &snapshot,
1327                            key.category,
1328                            cache.as_ref(),
1329                            &caller_scope,
1330                        ),
1331                        Err(_) => self.timeout_outcome(&key, &caller_scope, &cache, &snapshot),
1332                    };
1333                }
1334                recv(result_rx) -> result => {
1335                    match result {
1336                        Ok(result) => self.route_completion(result),
1337                        Err(_) => return self.timeout_outcome(&key, &caller_scope, &cache, &snapshot),
1338                    }
1339                }
1340                recv(timeout) -> _ => {
1341                    return self.timeout_outcome(&key, &caller_scope, &cache, &snapshot);
1342                }
1343            }
1344        }
1345    }
1346
1347    fn timeout_outcome(
1348        &self,
1349        key: &JobKey,
1350        caller_scope: &JobScope,
1351        cache: &InspectCache,
1352        snapshot: &InspectSnapshot,
1353    ) -> JobOutcome {
1354        match cache.get_aggregated_for_config(key, snapshot.config.as_ref()) {
1355            Ok(Some(cached)) => filter_outcome_for_scope_with_contributions(
1356                JobOutcome::Stale {
1357                    cached: Some(cached),
1358                    in_flight: true,
1359                },
1360                snapshot,
1361                key.category,
1362                cache,
1363                caller_scope,
1364            ),
1365            Ok(None) => JobOutcome::Pending { in_flight: true },
1366            Err(error) => JobOutcome::Failed {
1367                message: error.to_string(),
1368            },
1369        }
1370    }
1371
1372    fn route_completion(&self, result: InspectResult) {
1373        let outcome = self.completion_outcome(result.clone());
1374        let waiters = self
1375            .in_flight
1376            .lock()
1377            .ok()
1378            .and_then(|mut in_flight| in_flight.remove(&result.key))
1379            .unwrap_or_default();
1380        for waiter in waiters {
1381            let _ = waiter.tx.send(outcome.clone());
1382        }
1383    }
1384
1385    fn route_tier2_reuse_completion(&self, result: InspectResult) {
1386        let outcome = match result.outcome.clone() {
1387            Ok(success) => JobOutcome::Fresh {
1388                payload: success.aggregate,
1389            },
1390            Err(message) => JobOutcome::Failed { message },
1391        };
1392        let waiters = self
1393            .in_flight
1394            .lock()
1395            .ok()
1396            .and_then(|mut in_flight| in_flight.remove(&result.key))
1397            .unwrap_or_default();
1398        for waiter in waiters {
1399            let _ = waiter.tx.send(outcome.clone());
1400        }
1401        // Signal the main-thread drain that a background (watcher-driven) Tier-2
1402        // scan finished so it can refresh the status bar. This path bypasses
1403        // `result_rx`/`drain_completions`, so without this counter the bar's
1404        // counts and `~` marker would only update on a manual `aft_inspect`.
1405        self.reuse_completions.fetch_add(1, Ordering::SeqCst);
1406    }
1407
1408    /// Snapshot the cumulative count of reuse-path (watcher-driven) Tier-2
1409    /// completions. The main-thread drain compares this against its last-seen
1410    /// value to detect background scans that finished since the previous tick.
1411    pub fn reuse_completion_count(&self) -> u64 {
1412        self.reuse_completions.load(Ordering::SeqCst)
1413    }
1414
1415    fn completion_outcome(&self, result: InspectResult) -> JobOutcome {
1416        let cache =
1417            match self.cache_for_paths(result.inspect_dir.clone(), result.project_root.clone()) {
1418                Ok(cache) => cache,
1419                Err(message) => return JobOutcome::Failed { message },
1420            };
1421
1422        match result.outcome {
1423            Ok(success) => {
1424                let store_result = if result.category.is_tier2() {
1425                    cache.store_tier2_result_for_config(
1426                        result.key.clone(),
1427                        &success.scanned_files,
1428                        &success.contributions,
1429                        success.aggregate.clone(),
1430                        result.config.as_ref(),
1431                    )
1432                } else {
1433                    cache.store_aggregated(result.key, success.aggregate.clone())
1434                };
1435
1436                match store_result {
1437                    Ok(()) => JobOutcome::Fresh {
1438                        payload: success.aggregate,
1439                    },
1440                    Err(error) => JobOutcome::Failed {
1441                        message: error.to_string(),
1442                    },
1443                }
1444            }
1445            Err(message) => JobOutcome::Failed { message },
1446        }
1447    }
1448}
1449
1450impl Default for InspectManager {
1451    fn default() -> Self {
1452        Self::new()
1453    }
1454}
1455
1456fn validate_tier2_read_category(category: InspectCategory) -> Result<(), JobOutcome> {
1457    if !category.is_active() {
1458        return Err(JobOutcome::Failed {
1459            message: format!("inspect category '{category}' is disabled in v0.33"),
1460        });
1461    }
1462    if !category.is_tier2() {
1463        return Err(JobOutcome::Failed {
1464            message: format!("inspect category '{category}' is not a Tier 2 category"),
1465        });
1466    }
1467    Ok(())
1468}
1469
1470/// Phase-level wall-time attribution for one Tier-2 reuse=miss pass.
1471///
1472/// Exists to self-attribute pathological scans (e.g. a normally-100ms
1473/// unused_exports pass once took 677s under heavy machine load) without
1474/// needing a lucky live `sample`. Logged as ONE info line per pass, only when
1475/// real work happened (freshness/scan/snapshot/rollup/db), so quiet reuse passes stay silent.
1476#[derive(Default)]
1477struct Tier2PhaseTimings {
1478    /// Freshness verification of cached contributions (file stat + hash reads).
1479    freshness: Duration,
1480    /// Callgraph store snapshot projection (dead_code only).
1481    snapshot: Duration,
1482    /// Scanner compute over files needing (re)scan.
1483    scan: Duration,
1484    /// SQLite contribution upserts/deletes (busy-wait contention shows here).
1485    db: Duration,
1486    /// Aggregate roll-up + store.
1487    rollup: Duration,
1488    scanned_files: usize,
1489}
1490
1491impl Tier2PhaseTimings {
1492    fn log(&self, category: InspectCategory) {
1493        let worked = self.freshness + self.scan + self.snapshot + self.rollup + self.db;
1494        if worked < Duration::from_millis(50) {
1495            return;
1496        }
1497        crate::slog_info!(
1498            "perf tier2 phases category={} freshness={}ms snapshot={}ms scan={}ms({} files) db={}ms rollup={}ms",
1499            category,
1500            self.freshness.as_millis(),
1501            self.snapshot.as_millis(),
1502            self.scan.as_millis(),
1503            self.scanned_files,
1504            self.db.as_millis(),
1505            self.rollup.as_millis()
1506        );
1507    }
1508}
1509
1510fn scope_files(project_root: &Path, scope: &JobScope) -> Vec<PathBuf> {
1511    let mut files = crate::callgraph::walk_project_files(project_root)
1512        .filter(|path| scope.contains(path))
1513        .collect::<Vec<_>>();
1514    files.sort();
1515    files
1516}
1517
1518fn forced_relative_paths(job: &InspectJob, paths: &BTreeSet<PathBuf>) -> BTreeSet<String> {
1519    let mut keys = BTreeSet::new();
1520    for path in paths {
1521        let absolute = if path.is_absolute() {
1522            path.clone()
1523        } else {
1524            job.project_root.join(path)
1525        };
1526        keys.insert(relative_cache_key(&job.project_root, &absolute));
1527        if let Ok(canonical) = std::fs::canonicalize(&absolute) {
1528            keys.insert(relative_cache_key(&job.project_root, &canonical));
1529        }
1530    }
1531    keys
1532}
1533
1534fn panic_tier2_reuse_for_debug(job: &InspectJob) {
1535    #[cfg(not(debug_assertions))]
1536    let _ = job;
1537    #[cfg(debug_assertions)]
1538    {
1539        if !env_project_root_matches("AFT_TEST_TIER2_REUSE_PANIC_ROOT", &job.project_root) {
1540            return;
1541        }
1542        let should_panic = std::env::var("AFT_TEST_TIER2_REUSE_PANIC_CATEGORY")
1543            .ok()
1544            .is_some_and(|category| category == job.category.as_str());
1545        if should_panic {
1546            panic!("forced tier2 reuse panic for {}", job.category);
1547        }
1548    }
1549}
1550
1551fn delay_direct_force_followup_deadline_check_for_debug(project_root: &Path) {
1552    #[cfg(not(debug_assertions))]
1553    let _ = project_root;
1554    #[cfg(debug_assertions)]
1555    {
1556        if !env_project_root_matches("AFT_TEST_DIRECT_FORCE_FOLLOWUP_DELAY_ROOT", project_root) {
1557            return;
1558        }
1559        if let Some(delay_ms) = std::env::var("AFT_TEST_DIRECT_FORCE_FOLLOWUP_DELAY_MS")
1560            .ok()
1561            .and_then(|raw| raw.parse::<u64>().ok())
1562        {
1563            std::thread::sleep(Duration::from_millis(delay_ms));
1564        }
1565    }
1566}
1567
1568fn delay_tier2_reuse_for_debug(project_root: &Path) {
1569    #[cfg(not(debug_assertions))]
1570    let _ = project_root;
1571    #[cfg(debug_assertions)]
1572    {
1573        if !env_project_root_matches("AFT_TEST_TIER2_REUSE_DELAY_ROOT", project_root) {
1574            return;
1575        }
1576        if let Some(delay_ms) = std::env::var("AFT_TEST_TIER2_REUSE_DELAY_MS")
1577            .ok()
1578            .and_then(|raw| raw.parse::<u64>().ok())
1579        {
1580            std::thread::sleep(Duration::from_millis(delay_ms));
1581        }
1582    }
1583}
1584
1585#[cfg(debug_assertions)]
1586fn env_project_root_matches(var: &str, project_root: &Path) -> bool {
1587    let Some(raw) = std::env::var_os(var) else {
1588        return true;
1589    };
1590    let expected = PathBuf::from(raw);
1591    let expected = std::fs::canonicalize(&expected).unwrap_or(expected);
1592    let actual = std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
1593    expected == actual
1594}
1595
1596fn current_project_files(project_root: &Path, files: &[PathBuf]) -> BTreeMap<String, PathBuf> {
1597    files
1598        .iter()
1599        .map(|file| (relative_cache_key(project_root, file), file.clone()))
1600        .collect()
1601}
1602
1603fn tier2_benchmark_logging_enabled() -> bool {
1604    std::env::var_os("AFT_SETTLE_BENCH_LOG").is_some()
1605}
1606
1607fn log_tier2_benchmark_category_start(job: &InspectJob) {
1608    if !tier2_benchmark_logging_enabled() {
1609        return;
1610    }
1611    crate::slog_info!(
1612        "settle bench: tier2_category_start category={} job_id={} files={}",
1613        job.category.as_str(),
1614        job.job_id,
1615        job.scope_files.len()
1616    );
1617}
1618
1619fn log_tier2_benchmark_category_end(result: &InspectResult) {
1620    if !tier2_benchmark_logging_enabled() {
1621        return;
1622    }
1623    match &result.outcome {
1624        Ok(success) => {
1625            let count = success
1626                .aggregate
1627                .get("count")
1628                .and_then(serde_json::Value::as_u64)
1629                .unwrap_or(0);
1630            crate::slog_info!(
1631                "settle bench: tier2_category_end category={} job_id={} status=success total_ms={} scanned_files={} contributions={} count={}",
1632                result.category.as_str(),
1633                result.job_id,
1634                result.duration.as_millis(),
1635                success.scanned_files.len(),
1636                success.contributions.len(),
1637                count
1638            );
1639        }
1640        Err(message) => {
1641            crate::slog_info!(
1642                "settle bench: tier2_category_end category={} job_id={} status=failed total_ms={} error={}",
1643                result.category.as_str(),
1644                result.job_id,
1645                result.duration.as_millis(),
1646                message.replace('\n', " ")
1647            );
1648        }
1649    }
1650}
1651
1652fn build_tier2_callgraph_snapshot(
1653    job: &InspectJob,
1654    allow_cold_build: bool,
1655) -> Option<Arc<CallgraphSnapshot>> {
1656    let started = Instant::now();
1657    if !job.config.callgraph_store {
1658        crate::slog_info!(
1659            "tier2 dead_code: callgraph store disabled; reporting callgraph_unavailable"
1660        );
1661        return None;
1662    }
1663
1664    let Some(callgraph_dir) = callgraph_store_dir_from_inspect_dir(&job.inspect_dir) else {
1665        crate::slog_info!(
1666            "tier2 dead_code: inspect_dir has no harness parent ({}); reporting callgraph_unavailable",
1667            job.inspect_dir.display()
1668        );
1669        return None;
1670    };
1671
1672    // Background Tier-2 refresh may repair moved-root metadata by publishing a
1673    // one-time cold rebuild. Direct inspect cannot spend its request budget on
1674    // that repair path, so it uses the open-only variant and reports
1675    // callgraph_unavailable when a rebuild would be required.
1676    let store = match if allow_cold_build {
1677        CallGraphStore::open_ready_repairing(callgraph_dir.clone(), job.project_root.clone())
1678    } else {
1679        CallGraphStore::open_ready_no_rebuild(callgraph_dir.clone(), job.project_root.clone())
1680    } {
1681        Ok(Some(store)) => store,
1682        Ok(None) => {
1683            crate::slog_info!(
1684                "tier2 dead_code: callgraph store unavailable at {} (cold/building/not ready); reporting callgraph_unavailable",
1685                callgraph_dir.display()
1686            );
1687            return None;
1688        }
1689        Err(error) => {
1690            crate::slog_warn!(
1691                "tier2 dead_code: failed to open callgraph store at {}: {}; reporting callgraph_unavailable",
1692                callgraph_dir.display(),
1693                error
1694            );
1695            return None;
1696        }
1697    };
1698
1699    let snapshot = match project_dead_code_snapshot(store.sqlite_path()) {
1700        Ok(snapshot) => snapshot,
1701        Err(CallGraphStoreError::Unavailable(message)) => {
1702            crate::slog_info!(
1703                "tier2 dead_code: callgraph store projection unavailable ({}); reporting callgraph_unavailable",
1704                message
1705            );
1706            return None;
1707        }
1708        Err(error) => {
1709            crate::slog_warn!(
1710                "tier2 dead_code: callgraph store projection failed: {}; reporting callgraph_unavailable",
1711                error
1712            );
1713            return None;
1714        }
1715    };
1716
1717    crate::slog_info!(
1718        "perf tier2_callgraph_snapshot: source=callgraph_store files={} exports={} edges={} entry_points={} ms={}",
1719        snapshot.files.len(),
1720        snapshot.exported_symbols.len(),
1721        snapshot.outbound_calls.len(),
1722        snapshot.entry_points.len(),
1723        started.elapsed().as_millis()
1724    );
1725
1726    Some(Arc::new(snapshot))
1727}
1728
1729fn callgraph_store_dir_from_inspect_dir(inspect_dir: &Path) -> Option<PathBuf> {
1730    inspect_dir
1731        .parent()
1732        .map(|harness_dir| harness_dir.join("callgraph"))
1733}
1734
1735#[cfg(test)]
1736fn canonicalize_for_snapshot(path: &Path) -> PathBuf {
1737    std::fs::canonicalize(path).unwrap_or_else(|_| normalize_path(path))
1738}
1739
1740fn load_contribution_freshness(
1741    cache: &InspectCache,
1742    category: InspectCategory,
1743) -> Result<Vec<CachedContributionFreshness>, String> {
1744    cache
1745        .contribution_freshness(category)
1746        .map_err(|error| error.to_string())
1747        .map(|records| {
1748            records
1749                .into_iter()
1750                .map(|(file_path, freshness)| CachedContributionFreshness {
1751                    file_path,
1752                    freshness,
1753                })
1754                .collect()
1755        })
1756}
1757
1758fn freshness_record_relative_key(record: &CachedContributionFreshness) -> String {
1759    record.file_path.to_string_lossy().to_string()
1760}
1761
1762fn relative_cache_key(project_root: &Path, path: &Path) -> String {
1763    path.strip_prefix(project_root)
1764        .unwrap_or(path)
1765        .to_string_lossy()
1766        .to_string()
1767}
1768
1769fn load_contributions(
1770    cache: &InspectCache,
1771    job: &InspectJob,
1772) -> Result<Vec<FileContribution>, String> {
1773    cache
1774        .load_tier2_contributions(job.category)
1775        .map_err(|error| error.to_string())
1776        .map(|records| {
1777            records
1778                .into_iter()
1779                .map(|record| contribution_from_record(&job.project_root, record))
1780                .collect()
1781        })
1782}
1783
1784fn dead_code_contributions_need_fact_refresh(
1785    cache: &InspectCache,
1786    job: &InspectJob,
1787) -> Result<bool, String> {
1788    let contributions = load_contributions(cache, job)?;
1789    Ok(contributions
1790        .iter()
1791        .any(dead_code_contribution_needs_fact_refresh))
1792}
1793
1794fn dead_code_contribution_needs_fact_refresh(contribution: &FileContribution) -> bool {
1795    let Ok(parsed) =
1796        serde_json::from_value::<DeadCodeRefreshContribution>(contribution.contribution.clone())
1797    else {
1798        return true;
1799    };
1800
1801    if parsed.facts_format_version
1802        != Some(super::scanners::dead_code::DEAD_CODE_FACTS_FORMAT_VERSION)
1803    {
1804        return true;
1805    }
1806
1807    matches!(
1808        parsed.oxc_facts,
1809        Some(facts) if facts.format_version != FACTS_FORMAT_VERSION
1810    )
1811}
1812
1813fn unused_exports_contributions_need_fact_refresh(
1814    cache: &InspectCache,
1815    job: &InspectJob,
1816) -> Result<bool, String> {
1817    let contributions = load_contributions(cache, job)?;
1818    Ok(contributions
1819        .iter()
1820        .any(unused_exports_contribution_needs_fact_refresh))
1821}
1822
1823/// Duplicates contributions written before v0.44 lack the `line_count` field
1824/// (serde defaults it to 0), so a cached roll-up computes total_analyzed_lines
1825/// as 0 and the summary renders "0.0% of 0 analyzed lines". One full rescan
1826/// repopulates the counts; fresh contributions always carry line_count.
1827fn duplicates_contributions_need_fact_refresh(
1828    cache: &InspectCache,
1829    job: &InspectJob,
1830) -> Result<bool, String> {
1831    let contributions = load_contributions(cache, job)?;
1832    Ok(contributions
1833        .iter()
1834        .any(|contribution| contribution.contribution.get("line_count").is_none()))
1835}
1836
1837fn unused_exports_contribution_needs_fact_refresh(contribution: &FileContribution) -> bool {
1838    let top_level_oxc = contribution
1839        .contribution
1840        .get("provenance")
1841        .and_then(Value::as_str)
1842        == Some(OXC_PROVENANCE);
1843    let Ok(parsed) =
1844        serde_json::from_value::<UnusedExportsContribution>(contribution.contribution.clone())
1845    else {
1846        return false;
1847    };
1848    let uses_oxc =
1849        top_level_oxc || parsed.oxc_facts.is_some() || parsed.exports.iter().any(export_uses_oxc);
1850    if !uses_oxc {
1851        return false;
1852    }
1853
1854    !matches!(
1855        parsed.oxc_facts,
1856        Some(facts) if facts.format_version == FACTS_FORMAT_VERSION
1857    )
1858}
1859
1860fn contribution_from_record(
1861    project_root: &Path,
1862    record: super::cache::ContributionRecord,
1863) -> FileContribution {
1864    FileContribution::new(
1865        record.category,
1866        project_root.join(record.file_path),
1867        record.freshness,
1868        record.contribution,
1869    )
1870    .with_type_ref_names(record.type_ref_names)
1871}
1872
1873fn run_tier2_scan(job: &InspectJob, oxc_result: Option<&OxcEngineResult>) -> InspectResult {
1874    use super::scanners;
1875
1876    match job.category {
1877        InspectCategory::DeadCode => {
1878            scanners::dead_code::run_dead_code_scan_with_oxc(job, oxc_result)
1879        }
1880        InspectCategory::UnusedExports => {
1881            scanners::unused_exports::run_unused_exports_scan_with_oxc(job, oxc_result)
1882        }
1883        InspectCategory::Duplicates => scanners::duplicates::run_duplicates_scan(job),
1884        InspectCategory::Cycles => scanners::cycles::run_cycles_scan_with_oxc(job, oxc_result),
1885        other => InspectResult::failed(
1886            job,
1887            format!("inspect category '{other}' is not an active Tier 2 scanner"),
1888            Duration::from_secs(0),
1889        ),
1890    }
1891}
1892
1893fn roll_up_tier2_contributions(job: &InspectJob, contributions: &[FileContribution]) -> Value {
1894    roll_up_tier2_contributions_with_limit(job, contributions, Some(MAX_DRILL_DOWN_ITEMS))
1895}
1896
1897fn roll_up_tier2_contributions_with_limit(
1898    job: &InspectJob,
1899    contributions: &[FileContribution],
1900    drill_down_limit: Option<usize>,
1901) -> Value {
1902    match job.category {
1903        InspectCategory::DeadCode => {
1904            roll_up_dead_code_contributions(job, contributions, drill_down_limit)
1905        }
1906        InspectCategory::UnusedExports => {
1907            roll_up_unused_exports_contributions(job, contributions, drill_down_limit)
1908        }
1909        InspectCategory::Duplicates => {
1910            roll_up_duplicate_contributions(job, contributions, drill_down_limit)
1911        }
1912        InspectCategory::Cycles => {
1913            roll_up_cycle_contributions(job, contributions, drill_down_limit)
1914        }
1915        _ => json!({
1916            "count": 0,
1917            "items": [],
1918            "scanned_files": contributions.len(),
1919        }),
1920    }
1921}
1922
1923fn scoped_tier2_payload_from_contributions(
1924    snapshot: &InspectSnapshot,
1925    category: InspectCategory,
1926    cache: &InspectCache,
1927    project_payload: Value,
1928    scope: &JobScope,
1929) -> Result<Value, String> {
1930    if scope.is_project_wide() {
1931        return Ok(project_payload);
1932    }
1933
1934    let project_scope = JobScope::for_project(snapshot.project_root.clone());
1935    let rollup_job = scoped_tier2_rollup_job(snapshot, category, &project_scope);
1936    let contributions = load_contributions(cache, &rollup_job)?;
1937    let full_payload = roll_up_tier2_contributions_with_limit(&rollup_job, &contributions, None);
1938    let scoped_payload = filter_payload_for_scope(full_payload, scope);
1939    Ok(cap_payload_drill_down(scoped_payload, MAX_DRILL_DOWN_ITEMS))
1940}
1941
1942fn scoped_tier2_rollup_job(
1943    snapshot: &InspectSnapshot,
1944    category: InspectCategory,
1945    scope: &JobScope,
1946) -> InspectJob {
1947    let mut job = InspectJob {
1948        job_id: 0,
1949        key: JobKey::for_project_category(category),
1950        category,
1951        scope_files: scope_files(&snapshot.project_root, scope),
1952        project_root: snapshot.project_root.clone(),
1953        inspect_dir: snapshot.inspect_dir.clone(),
1954        config: Arc::clone(&snapshot.config),
1955        symbol_cache: Arc::clone(&snapshot.symbol_cache),
1956        callgraph_snapshot: None,
1957    };
1958
1959    if category == InspectCategory::DeadCode {
1960        // Scoped read-path rollups recompute dead-code liveness from cached
1961        // contributions. Use a real ready store snapshot when one exists; if no
1962        // snapshot is available, leave it absent so the rollup reports degraded
1963        // callgraph_unavailable instead of treating an empty graph as truth.
1964        job.callgraph_snapshot = build_tier2_callgraph_snapshot(&job, false);
1965    }
1966
1967    job
1968}
1969
1970fn roll_up_dead_code_contributions(
1971    job: &InspectJob,
1972    contributions: &[FileContribution],
1973    drill_down_limit: Option<usize>,
1974) -> Value {
1975    let Some(snapshot) = job.callgraph_snapshot.as_deref() else {
1976        return super::scanners::dead_code::callgraph_unavailable_aggregate(job.scope_files.len());
1977    };
1978
1979    let public_api_files = super::scanners::dead_code::collect_public_api_files(&job.project_root);
1980    let roles = super::entry_points::resolve_project_roles(&job.project_root);
1981    super::scanners::dead_code::aggregate_dead_code_contributions_with_snapshot(
1982        &job.project_root,
1983        snapshot,
1984        contributions,
1985        &public_api_files,
1986        &roles,
1987        drill_down_limit,
1988    )
1989}
1990
1991fn roll_up_unused_exports_contributions(
1992    job: &InspectJob,
1993    contributions: &[FileContribution],
1994    drill_down_limit: Option<usize>,
1995) -> Value {
1996    let parsed = contributions
1997        .iter()
1998        .filter_map(|contribution| {
1999            serde_json::from_value::<UnusedExportsContribution>(contribution.contribution.clone())
2000                .ok()
2001        })
2002        .collect::<Vec<_>>();
2003
2004    if parsed.iter().any(|scan| scan.oxc_facts.is_some()) {
2005        return roll_up_unused_exports_oxc_contributions(job, &parsed, drill_down_limit);
2006    }
2007
2008    let (public_api_files, package_warnings) = unused_public_api_entries(&job.project_root);
2009    let mut imported_by: BTreeMap<(String, String), BTreeSet<String>> = BTreeMap::new();
2010    let mut uncertain_by: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
2011    for scan in &parsed {
2012        for import in &scan.imports {
2013            let Some(resolved_file) = &import.resolved_file else {
2014                continue;
2015            };
2016            for name in &import.named {
2017                if name == "*" {
2018                    uncertain_by
2019                        .entry(resolved_file.clone())
2020                        .or_default()
2021                        .insert(scan.file.clone());
2022                } else {
2023                    imported_by
2024                        .entry((resolved_file.clone(), name.clone()))
2025                        .or_default()
2026                        .insert(scan.file.clone());
2027                }
2028            }
2029        }
2030    }
2031
2032    let mut count = 0usize;
2033    let mut items = Vec::new();
2034    let test_only_count = 0usize;
2035    let test_only_items = Vec::new();
2036    let mut uncertain_count = 0usize;
2037    let mut uncertain_items = Vec::new();
2038    for scan in &parsed {
2039        if public_api_files.contains(&scan.file) {
2040            continue;
2041        }
2042        // Mirror the fresh-scan path: fixtures/corpora/mock data are consumed
2043        // by path, never imported, so their exports always look unused.
2044        if super::job::is_test_support_file(&scan.file) {
2045            continue;
2046        }
2047
2048        for export in &scan.exports {
2049            if export_uses_oxc(export) {
2050                match export.verdict.unwrap_or(LivenessVerdict::Unused) {
2051                    LivenessVerdict::Used => continue,
2052                    LivenessVerdict::Uncertain => {
2053                        uncertain_count += 1;
2054                        if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
2055                            uncertain_items.push(json!({
2056                                "file": scan.file,
2057                                "symbol": export.symbol,
2058                                "kind": export.kind,
2059                                "line": export.line,
2060                                "reason": export.reason.as_deref().unwrap_or("oxc_uncertain"),
2061                                "provenance": export.provenance.as_deref().unwrap_or(OXC_PROVENANCE),
2062                            }));
2063                        }
2064                        continue;
2065                    }
2066                    LivenessVerdict::Unused => {}
2067                }
2068            } else {
2069                let imported = imported_by
2070                    .get(&(scan.file.clone(), export.symbol.clone()))
2071                    .map(|files| !files.is_empty())
2072                    .unwrap_or(false);
2073                let uncertain = uncertain_by
2074                    .get(&scan.file)
2075                    .map(|files| !files.is_empty())
2076                    .unwrap_or(false);
2077
2078                if imported {
2079                    continue;
2080                }
2081                if uncertain {
2082                    uncertain_count += 1;
2083                    if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
2084                        uncertain_items.push(json!({
2085                            "file": scan.file,
2086                            "symbol": export.symbol,
2087                            "kind": export.kind,
2088                            "line": export.line,
2089                            "reason": "wildcard_import",
2090                        }));
2091                    }
2092                    continue;
2093                }
2094            }
2095
2096            count += 1;
2097            // Collect uncapped; rank by signal tier and truncate below.
2098            let mut item = json!({
2099                "file": scan.file,
2100                "symbol": export.symbol,
2101                "kind": export.kind,
2102                "line": export.line,
2103            });
2104            if let Some(provenance) = &export.provenance {
2105                item["provenance"] = json!(provenance);
2106            }
2107            items.push(item);
2108        }
2109    }
2110
2111    let roles = super::entry_points::resolve_project_roles(&job.project_root);
2112    let items = super::entry_points::rank_and_truncate_items(items, &roles, drill_down_limit);
2113    let top = super::entry_points::top_preview_symbols(&items);
2114    let test_only_items =
2115        super::entry_points::rank_and_truncate_items(test_only_items, &roles, drill_down_limit);
2116    let test_only_top = test_only_items
2117        .iter()
2118        .take(super::entry_points::TOP_PREVIEW_ITEMS)
2119        .cloned()
2120        .collect::<Vec<_>>();
2121
2122    let (parse_errors, skipped_files) = unused_exports_honesty_fields(&parsed);
2123    let mut aggregate = json!({
2124        "count": count,
2125        "items": items,
2126        "top": top,
2127        "test_only_count": test_only_count,
2128        "test_only_items": test_only_items,
2129        "test_only_top": test_only_top,
2130        "drill_down_capped": drill_down_limit.is_some_and(|limit| count > limit),
2131        "test_only_drill_down_capped": drill_down_limit.is_some_and(|limit| test_only_count > limit),
2132        "scanned_files": parsed.len(),
2133        "languages_skipped": skipped_languages(&job.scope_files, LanguageSkipMode::UnusedExports),
2134        "uncertain_count": uncertain_count,
2135        "uncertain_items": uncertain_items,
2136        "complete": parse_errors.is_empty() && skipped_files.is_empty(),
2137    });
2138    if !parse_errors.is_empty() {
2139        aggregate["parse_errors"] = Value::Array(parse_errors);
2140    }
2141    if !skipped_files.is_empty() {
2142        aggregate["skipped_files"] = Value::Array(skipped_files);
2143    }
2144    if !package_warnings.is_empty() {
2145        aggregate["note"] = Value::String(package_warnings.join("; "));
2146    }
2147    aggregate
2148}
2149
2150fn roll_up_unused_exports_oxc_contributions(
2151    job: &InspectJob,
2152    parsed: &[UnusedExportsContribution],
2153    drill_down_limit: Option<usize>,
2154) -> Value {
2155    let (public_api_files, package_warnings) = unused_public_api_entries(&job.project_root);
2156    let facts = parsed
2157        .iter()
2158        .filter_map(|scan| {
2159            let oxc_facts = scan.oxc_facts.as_ref()?;
2160            let path = job.project_root.join(&scan.file);
2161            Some(FileFacts {
2162                file_id: FileId(0),
2163                path: normalize_input_path(&job.project_root, &path),
2164                content_hash: oxc_facts.content_hash.clone(),
2165                exports: oxc_facts.exports.clone(),
2166                imports: oxc_facts.imports.clone(),
2167                re_exports: oxc_facts.re_exports.clone(),
2168                dynamic_imports: oxc_facts.dynamic_imports.clone(),
2169                same_file_value_references: oxc_facts.same_file_value_references.clone(),
2170                used_import_bindings: oxc_facts.used_import_bindings.clone(),
2171                type_referenced_import_bindings: oxc_facts.type_referenced_import_bindings.clone(),
2172                value_referenced_import_bindings: oxc_facts
2173                    .value_referenced_import_bindings
2174                    .clone(),
2175                parse_error: oxc_facts.parse_error.clone(),
2176            })
2177        })
2178        .collect::<Vec<_>>();
2179    let entry_point_set = super::entry_points::resolve_entry_points(&job.project_root);
2180    let oxc_result = analyze_file_facts(
2181        &job.project_root,
2182        facts,
2183        AnalyzeOptions {
2184            entry_points: Vec::new(),
2185            public_api_files: entry_point_set.public_api_files(),
2186            executable_root_exports: entry_point_set.executable_root_exports(),
2187            force_reparse_files: Vec::new(),
2188            entry_reachability: false,
2189        },
2190        Vec::new(),
2191    );
2192    let roles = super::entry_points::resolve_project_roles(&job.project_root);
2193
2194    let mut count = 0usize;
2195    let mut items = Vec::new();
2196    let mut test_only_count = 0usize;
2197    let mut test_only_items = Vec::new();
2198    let mut uncertain_count = 0usize;
2199    let mut uncertain_items = Vec::new();
2200    for file in &oxc_result.files {
2201        if public_api_files.contains(&file.relative_file)
2202            || super::job::is_test_support_file(&file.relative_file)
2203        {
2204            continue;
2205        }
2206
2207        for export in &file.exports {
2208            match export.verdict {
2209                LivenessVerdict::Used => {
2210                    if !is_test_file(&file.relative_file)
2211                        && !export.test_only_reference_files.is_empty()
2212                    {
2213                        test_only_count += 1;
2214                        let mut item = json!({
2215                            "file": file.relative_file,
2216                            "symbol": export.symbol,
2217                            "kind": export.kind,
2218                            "line": export.line,
2219                            "provenance": export.provenance,
2220                            "used_by": export.test_only_reference_files,
2221                        });
2222                        add_oxc_reexport_contexts(&mut item, &export.also_reexported);
2223                        test_only_items.push(item);
2224                    }
2225                }
2226                LivenessVerdict::Uncertain => {
2227                    uncertain_count += 1;
2228                    if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
2229                        let mut item = json!({
2230                            "file": file.relative_file,
2231                            "symbol": export.symbol,
2232                            "kind": export.kind,
2233                            "line": export.line,
2234                            "reason": export.reason,
2235                            "provenance": export.provenance,
2236                        });
2237                        add_oxc_reexport_contexts(&mut item, &export.also_reexported);
2238                        uncertain_items.push(item);
2239                    }
2240                }
2241                LivenessVerdict::Unused => {
2242                    if !is_test_file(&file.relative_file)
2243                        && !export.test_only_reference_files.is_empty()
2244                    {
2245                        test_only_count += 1;
2246                        let mut item = json!({
2247                            "file": file.relative_file,
2248                            "symbol": export.symbol,
2249                            "kind": export.kind,
2250                            "line": export.line,
2251                            "provenance": export.provenance,
2252                            "used_by": export.test_only_reference_files,
2253                        });
2254                        add_oxc_reexport_contexts(&mut item, &export.also_reexported);
2255                        test_only_items.push(item);
2256                        continue;
2257                    }
2258                    if export.has_references {
2259                        continue;
2260                    }
2261                    count += 1;
2262                    let mut item = json!({
2263                        "file": file.relative_file,
2264                        "symbol": export.symbol,
2265                        "kind": export.kind,
2266                        "line": export.line,
2267                        "provenance": export.provenance,
2268                    });
2269                    add_oxc_reexport_contexts(&mut item, &export.also_reexported);
2270                    items.push(item);
2271                }
2272            }
2273        }
2274    }
2275
2276    let items = super::entry_points::rank_and_truncate_items(items, &roles, drill_down_limit);
2277    let top = super::entry_points::top_preview_symbols(&items);
2278    let test_only_items =
2279        super::entry_points::rank_and_truncate_items(test_only_items, &roles, drill_down_limit);
2280    let test_only_top = test_only_items
2281        .iter()
2282        .take(super::entry_points::TOP_PREVIEW_ITEMS)
2283        .cloned()
2284        .collect::<Vec<_>>();
2285    let (mut parse_errors, skipped_files) = unused_exports_honesty_fields(parsed);
2286    for scan in parsed {
2287        if let Some(oxc_facts) = &scan.oxc_facts {
2288            if oxc_facts.format_version != FACTS_FORMAT_VERSION {
2289                parse_errors.push(json!({
2290                    "file": scan.file,
2291                    "message": format!(
2292                        "unsupported oxc facts format {}; expected {}",
2293                        oxc_facts.format_version, FACTS_FORMAT_VERSION
2294                    ),
2295                }));
2296            }
2297        }
2298    }
2299
2300    let mut aggregate = json!({
2301        "count": count,
2302        "items": items,
2303        "top": top,
2304        "test_only_count": test_only_count,
2305        "test_only_items": test_only_items,
2306        "test_only_top": test_only_top,
2307        "drill_down_capped": drill_down_limit.is_some_and(|limit| count > limit),
2308        "test_only_drill_down_capped": drill_down_limit.is_some_and(|limit| test_only_count > limit),
2309        "scanned_files": parsed.len(),
2310        "languages_skipped": skipped_languages(&job.scope_files, LanguageSkipMode::UnusedExports),
2311        "uncertain_count": uncertain_count,
2312        "uncertain_items": uncertain_items,
2313        "complete": parse_errors.is_empty() && skipped_files.is_empty(),
2314    });
2315    if !parse_errors.is_empty() {
2316        aggregate["parse_errors"] = Value::Array(parse_errors);
2317    }
2318    if !skipped_files.is_empty() {
2319        aggregate["skipped_files"] = Value::Array(skipped_files);
2320    }
2321    if !package_warnings.is_empty() {
2322        aggregate["note"] = Value::String(package_warnings.join("; "));
2323    }
2324    aggregate
2325}
2326
2327fn add_oxc_reexport_contexts(
2328    item: &mut Value,
2329    contexts: &[crate::inspect::oxc_engine::OxcReExportContext],
2330) {
2331    if !contexts.is_empty() {
2332        item["also_reexported"] = json!(contexts);
2333    }
2334}
2335
2336fn unused_exports_honesty_fields(parsed: &[UnusedExportsContribution]) -> (Vec<Value>, Vec<Value>) {
2337    let mut parse_error_keys = BTreeSet::new();
2338    let mut parse_errors = Vec::new();
2339    let mut skipped_file_keys = BTreeSet::new();
2340    let mut skipped_files = Vec::new();
2341    for contribution in parsed {
2342        for value in &contribution.parse_errors {
2343            let key = value.to_string();
2344            if parse_error_keys.insert(key) {
2345                parse_errors.push(value.clone());
2346            }
2347        }
2348        for value in &contribution.skipped_files {
2349            let key = value.to_string();
2350            if skipped_file_keys.insert(key) {
2351                skipped_files.push(value.clone());
2352            }
2353        }
2354    }
2355    (parse_errors, skipped_files)
2356}
2357
2358fn roll_up_duplicate_contributions(
2359    job: &InspectJob,
2360    contributions: &[FileContribution],
2361    drill_down_limit: Option<usize>,
2362) -> Value {
2363    super::scanners::duplicates::aggregate_duplicate_contributions_with_limit(
2364        contributions,
2365        skipped_languages(&job.scope_files, LanguageSkipMode::Duplicates),
2366        drill_down_limit,
2367        &job.config.inspect.duplicates.expected_mirrors,
2368    )
2369}
2370
2371fn roll_up_cycle_contributions(
2372    job: &InspectJob,
2373    contributions: &[FileContribution],
2374    drill_down_limit: Option<usize>,
2375) -> Value {
2376    super::scanners::cycles::aggregate_cycle_contributions_with_limit(
2377        &job.project_root,
2378        contributions,
2379        skipped_languages(&job.scope_files, LanguageSkipMode::Cycles),
2380        drill_down_limit,
2381    )
2382}
2383
2384fn cap_payload_drill_down(mut payload: Value, limit: usize) -> Value {
2385    let mut capped = false;
2386    if let Some(items) = payload.get_mut("items").and_then(Value::as_array_mut) {
2387        capped |= items.len() > limit;
2388        items.truncate(limit);
2389    }
2390    if let Some(groups) = payload.get_mut("groups").and_then(Value::as_array_mut) {
2391        capped |= groups.len() > limit;
2392        groups.truncate(limit);
2393    }
2394    if let Some(object) = payload.as_object_mut() {
2395        object.insert("drill_down_capped".to_string(), json!(capped));
2396    }
2397    payload
2398}
2399
2400const MAX_DRILL_DOWN_ITEMS: usize = 100;
2401
2402#[derive(Debug, Clone, Deserialize)]
2403struct ExportContribution {
2404    symbol: String,
2405    kind: String,
2406    line: u32,
2407    #[serde(default)]
2408    verdict: Option<LivenessVerdict>,
2409    #[serde(default)]
2410    reason: Option<String>,
2411    #[serde(default)]
2412    provenance: Option<String>,
2413}
2414
2415fn export_uses_oxc(export: &ExportContribution) -> bool {
2416    export.verdict.is_some() || export.provenance.as_deref() == Some(OXC_PROVENANCE)
2417}
2418
2419#[derive(Debug, Clone, Deserialize)]
2420struct DeadCodeRefreshContribution {
2421    #[serde(default)]
2422    facts_format_version: Option<u32>,
2423    #[serde(default)]
2424    oxc_facts: Option<OxcFactsContribution>,
2425}
2426
2427#[derive(Debug, Clone, Deserialize)]
2428struct UnusedExportsContribution {
2429    file: String,
2430    exports: Vec<ExportContribution>,
2431    #[serde(default)]
2432    imports: Vec<ImportContribution>,
2433    #[serde(default)]
2434    oxc_facts: Option<OxcFactsContribution>,
2435    #[serde(default)]
2436    parse_errors: Vec<Value>,
2437    #[serde(default)]
2438    skipped_files: Vec<Value>,
2439}
2440
2441#[derive(Debug, Clone, Deserialize)]
2442struct ImportContribution {
2443    resolved_file: Option<String>,
2444    named: Vec<String>,
2445}
2446
2447#[derive(Debug, Clone, Deserialize)]
2448struct OxcFactsContribution {
2449    format_version: u32,
2450    content_hash: String,
2451    exports: Vec<ExportFact>,
2452    imports: Vec<ImportFact>,
2453    re_exports: Vec<ReExportFact>,
2454    dynamic_imports: Vec<DynamicImportFact>,
2455    same_file_value_references: BTreeSet<String>,
2456    used_import_bindings: BTreeSet<String>,
2457    type_referenced_import_bindings: BTreeSet<String>,
2458    value_referenced_import_bindings: BTreeSet<String>,
2459    #[serde(default)]
2460    parse_error: Option<String>,
2461}
2462
2463#[derive(Debug, Clone, Copy)]
2464enum LanguageSkipMode {
2465    Duplicates,
2466    Cycles,
2467    UnusedExports,
2468}
2469
2470fn category_uses_oxc(category: InspectCategory) -> bool {
2471    matches!(
2472        category,
2473        InspectCategory::DeadCode | InspectCategory::UnusedExports | InspectCategory::Cycles
2474    )
2475}
2476
2477fn skipped_languages(files: &[PathBuf], mode: LanguageSkipMode) -> Vec<String> {
2478    files
2479        .iter()
2480        .filter_map(|file| skipped_language(file, mode))
2481        .collect::<BTreeSet<_>>()
2482        .into_iter()
2483        .collect()
2484}
2485
2486fn skipped_language(file: &Path, mode: LanguageSkipMode) -> Option<String> {
2487    let Some(language) = crate::parser::detect_language(file) else {
2488        return match mode {
2489            LanguageSkipMode::Duplicates => Some("unknown".to_string()),
2490            LanguageSkipMode::Cycles => Some("unknown".to_string()),
2491            LanguageSkipMode::UnusedExports => None,
2492        };
2493    };
2494
2495    let skipped = match mode {
2496        LanguageSkipMode::Duplicates => !duplicates_supports_language(language),
2497        LanguageSkipMode::Cycles => !is_js_ts_language(language),
2498        LanguageSkipMode::UnusedExports => !is_js_ts_language(language),
2499    };
2500    skipped.then(|| language_name(language).to_string())
2501}
2502
2503fn duplicates_supports_language(language: crate::parser::LangId) -> bool {
2504    !matches!(
2505        language,
2506        crate::parser::LangId::Bash
2507            | crate::parser::LangId::Html
2508            | crate::parser::LangId::Json
2509            | crate::parser::LangId::Scala
2510            | crate::parser::LangId::Solidity
2511            | crate::parser::LangId::Scss
2512            | crate::parser::LangId::Vue
2513            | crate::parser::LangId::Markdown
2514            | crate::parser::LangId::Java
2515            | crate::parser::LangId::Ruby
2516            | crate::parser::LangId::Kotlin
2517            | crate::parser::LangId::Swift
2518            | crate::parser::LangId::Php
2519            | crate::parser::LangId::Lua
2520            | crate::parser::LangId::Perl
2521            | crate::parser::LangId::Pascal
2522            | crate::parser::LangId::R
2523            | crate::parser::LangId::ObjC
2524    )
2525}
2526
2527fn is_js_ts_language(language: crate::parser::LangId) -> bool {
2528    matches!(
2529        language,
2530        crate::parser::LangId::TypeScript
2531            | crate::parser::LangId::Tsx
2532            | crate::parser::LangId::JavaScript
2533    )
2534}
2535
2536fn language_name(language: crate::parser::LangId) -> &'static str {
2537    match language {
2538        crate::parser::LangId::TypeScript => "typescript",
2539        crate::parser::LangId::Tsx => "tsx",
2540        crate::parser::LangId::JavaScript => "javascript",
2541        crate::parser::LangId::Python => "python",
2542        crate::parser::LangId::Rust => "rust",
2543        crate::parser::LangId::Go => "go",
2544        crate::parser::LangId::C => "c",
2545        crate::parser::LangId::Cpp => "cpp",
2546        crate::parser::LangId::Zig => "zig",
2547        crate::parser::LangId::CSharp => "csharp",
2548        crate::parser::LangId::Bash => "bash",
2549        crate::parser::LangId::Html => "html",
2550        crate::parser::LangId::Markdown => "markdown",
2551        crate::parser::LangId::Yaml => "yaml",
2552        crate::parser::LangId::Solidity => "solidity",
2553        crate::parser::LangId::Scss => "scss",
2554        crate::parser::LangId::Vue => "vue",
2555        crate::parser::LangId::Json => "json",
2556        crate::parser::LangId::Scala => "scala",
2557        crate::parser::LangId::Java => "java",
2558        crate::parser::LangId::Ruby => "ruby",
2559        crate::parser::LangId::Kotlin => "kotlin",
2560        crate::parser::LangId::Swift => "swift",
2561        crate::parser::LangId::Php => "php",
2562        crate::parser::LangId::Lua => "lua",
2563        crate::parser::LangId::Perl => "perl",
2564        crate::parser::LangId::Pascal => "pascal",
2565        crate::parser::LangId::R => "r",
2566        crate::parser::LangId::ObjC => "objc",
2567    }
2568}
2569
2570fn unused_public_api_entries(project_root: &Path) -> (BTreeSet<String>, Vec<String>) {
2571    let entry_points = super::entry_points::resolve_entry_points(project_root);
2572    (
2573        entry_points.public_api_files_relative(project_root),
2574        entry_points.warnings().to_vec(),
2575    )
2576}
2577
2578fn filter_outcome_for_scope_with_contributions(
2579    outcome: JobOutcome,
2580    snapshot: &InspectSnapshot,
2581    category: InspectCategory,
2582    cache: &InspectCache,
2583    scope: &JobScope,
2584) -> JobOutcome {
2585    if !category.is_tier2() || scope.is_project_wide() {
2586        return filter_outcome_for_scope(outcome, scope);
2587    }
2588
2589    match outcome {
2590        JobOutcome::Fresh { payload } => {
2591            match scoped_tier2_payload_from_contributions(snapshot, category, cache, payload, scope)
2592            {
2593                Ok(payload) => JobOutcome::Fresh { payload },
2594                Err(message) => JobOutcome::Failed { message },
2595            }
2596        }
2597        JobOutcome::Stale { cached, in_flight } => match cached {
2598            Some(payload) => {
2599                match scoped_tier2_payload_from_contributions(
2600                    snapshot, category, cache, payload, scope,
2601                ) {
2602                    Ok(payload) => JobOutcome::Stale {
2603                        cached: Some(payload),
2604                        in_flight,
2605                    },
2606                    Err(message) => JobOutcome::Failed { message },
2607                }
2608            }
2609            None => JobOutcome::Stale {
2610                cached: None,
2611                in_flight,
2612            },
2613        },
2614        JobOutcome::Pending { in_flight } => JobOutcome::Pending { in_flight },
2615        JobOutcome::Failed { message } => JobOutcome::Failed { message },
2616    }
2617}
2618
2619fn filter_outcome_for_scope(outcome: JobOutcome, scope: &JobScope) -> JobOutcome {
2620    match outcome {
2621        JobOutcome::Fresh { payload } => JobOutcome::Fresh {
2622            payload: filter_payload_for_scope(payload, scope),
2623        },
2624        JobOutcome::Stale { cached, in_flight } => JobOutcome::Stale {
2625            cached: cached.map(|payload| filter_payload_for_scope(payload, scope)),
2626            in_flight,
2627        },
2628        JobOutcome::Pending { in_flight } => JobOutcome::Pending { in_flight },
2629        JobOutcome::Failed { message } => JobOutcome::Failed { message },
2630    }
2631}
2632
2633fn filter_payload_for_scope(mut payload: serde_json::Value, scope: &JobScope) -> serde_json::Value {
2634    if scope.is_project_wide() {
2635        return payload;
2636    }
2637
2638    // Scoped Tier 2 callers pass an uncapped rollup into this filter and cap
2639    // drill-down only afterwards, so the recomputed count below remains the
2640    // true in-scope total rather than the size of a capped sample.
2641    if let Some(items) = payload
2642        .get_mut("items")
2643        .and_then(|value| value.as_array_mut())
2644    {
2645        let count = filter_values_for_scope(items, scope);
2646        let largest_cycle = items
2647            .iter()
2648            .filter_map(|item| item.get("files").and_then(Value::as_array).map(Vec::len))
2649            .max();
2650        if let Some(object) = payload.as_object_mut() {
2651            object.insert("count".to_string(), serde_json::json!(count));
2652            if object.contains_key("largest") {
2653                object.insert(
2654                    "largest".to_string(),
2655                    serde_json::json!(largest_cycle.unwrap_or(0)),
2656                );
2657            }
2658            if object.contains_key("total_groups") {
2659                object.insert("total_groups".to_string(), serde_json::json!(count));
2660            }
2661            if object.contains_key("groups_count") {
2662                object.insert("groups_count".to_string(), serde_json::json!(count));
2663            }
2664        }
2665    }
2666
2667    if let Some(groups) = payload
2668        .get_mut("groups")
2669        .and_then(|value| value.as_array_mut())
2670    {
2671        let count = filter_values_for_scope(groups, scope);
2672        if let Some(object) = payload.as_object_mut() {
2673            object.insert("count".to_string(), serde_json::json!(count));
2674            object.insert("total_groups".to_string(), serde_json::json!(count));
2675            if object.contains_key("groups_count") {
2676                object.insert("groups_count".to_string(), serde_json::json!(count));
2677            }
2678        }
2679    }
2680
2681    // `by_language` is a project-wide breakdown computed before scope filtering.
2682    // Leaving it in a scoped payload contradicts the recomputed in-scope `count`
2683    // (e.g. count: 3 alongside `(rust 214, ts 143)`). The filtered items don't
2684    // carry per-item language, so we can't faithfully recompute it — drop it so
2685    // the scoped summary doesn't render a misleading project-wide breakdown.
2686    if let Some(object) = payload.as_object_mut() {
2687        if object.contains_key("top") {
2688            if let Some(top) = recompute_scoped_top_preview(object) {
2689                object.insert("top".to_string(), top);
2690            } else if let Some(top) = object.get_mut("top").and_then(Value::as_array_mut) {
2691                filter_values_for_scope(top, scope);
2692            }
2693        }
2694        if object.contains_key("duplicated_lines") {
2695            recompute_duplicate_payload_stats(object);
2696        }
2697        object.remove("by_language");
2698    }
2699
2700    payload
2701}
2702
2703fn recompute_duplicate_payload_stats(object: &mut serde_json::Map<String, Value>) {
2704    let values = object
2705        .get("items")
2706        .or_else(|| object.get("groups"))
2707        .and_then(Value::as_array)
2708        .cloned()
2709        .unwrap_or_default();
2710    let (duplicated_lines, duplicated_file_count) = duplicate_line_stats_from_values(&values);
2711    let total_analyzed_lines = object
2712        .get("total_analyzed_lines")
2713        .and_then(Value::as_u64)
2714        .unwrap_or(0);
2715    let duplicated_percent = if total_analyzed_lines == 0 {
2716        0.0
2717    } else {
2718        (duplicated_lines as f64 * 100.0) / total_analyzed_lines as f64
2719    };
2720    object.insert("duplicated_lines".to_string(), json!(duplicated_lines));
2721    object.insert(
2722        "duplicated_file_count".to_string(),
2723        json!(duplicated_file_count),
2724    );
2725    object.insert("duplicated_percent".to_string(), json!(duplicated_percent));
2726}
2727
2728fn duplicate_line_stats_from_values(values: &[Value]) -> (u64, usize) {
2729    let mut by_file = BTreeMap::<String, Vec<(u64, u64)>>::new();
2730    for value in values {
2731        let Some(files) = value.get("files").and_then(Value::as_array) else {
2732            continue;
2733        };
2734        for occurrence in files.iter().filter_map(Value::as_str) {
2735            let Some((file, start, end)) = parse_duplicate_occurrence(occurrence) else {
2736                continue;
2737            };
2738            by_file
2739                .entry(file.to_string())
2740                .or_default()
2741                .push((start, end));
2742        }
2743    }
2744    let file_count = by_file.len();
2745    let duplicated_lines = by_file
2746        .values_mut()
2747        .map(|intervals| merged_duplicate_interval_lines(intervals))
2748        .sum();
2749    (duplicated_lines, file_count)
2750}
2751
2752fn merged_duplicate_interval_lines(intervals: &mut [(u64, u64)]) -> u64 {
2753    if intervals.is_empty() {
2754        return 0;
2755    }
2756    intervals.sort_by(|left, right| left.0.cmp(&right.0).then(left.1.cmp(&right.1)));
2757    let (mut current_start, mut current_end) = intervals[0];
2758    let mut total = 0;
2759    for &(start, end) in &intervals[1..] {
2760        if start <= current_end.saturating_add(1) {
2761            current_end = current_end.max(end);
2762        } else {
2763            total += current_end.saturating_sub(current_start).saturating_add(1);
2764            current_start = start;
2765            current_end = end;
2766        }
2767    }
2768    total + current_end.saturating_sub(current_start).saturating_add(1)
2769}
2770
2771fn recompute_scoped_top_preview(
2772    object: &serde_json::Map<String, Value>,
2773) -> Option<serde_json::Value> {
2774    let values = object
2775        .get("items")
2776        .or_else(|| object.get("groups"))
2777        .and_then(Value::as_array)?;
2778    Some(Value::Array(
2779        values
2780            .iter()
2781            .take(super::entry_points::TOP_PREVIEW_ITEMS)
2782            .map(top_preview_value)
2783            .collect(),
2784    ))
2785}
2786
2787fn top_preview_value(value: &Value) -> Value {
2788    if let Some(files) = value.get("files").and_then(Value::as_array) {
2789        let mut object = serde_json::Map::new();
2790        object.insert("files".to_string(), Value::Array(files.clone()));
2791        if let Some(cost) = value.get("cost").cloned() {
2792            object.insert("cost".to_string(), cost);
2793        }
2794        return Value::Object(object);
2795    }
2796
2797    json!({
2798        "file": value.get("file").and_then(Value::as_str).unwrap_or(""),
2799        "symbol": value.get("symbol").and_then(Value::as_str).unwrap_or(""),
2800    })
2801}
2802
2803fn filter_values_for_scope(values: &mut Vec<serde_json::Value>, scope: &JobScope) -> usize {
2804    values.retain_mut(|value| prune_value_for_scope(value, scope));
2805    values.len()
2806}
2807
2808fn prune_value_for_scope(value: &mut serde_json::Value, scope: &JobScope) -> bool {
2809    if let Some(file) = value.get("file").and_then(|file| file.as_str()) {
2810        return scope.contains_display_path(file);
2811    }
2812
2813    let first_scoped_occurrence = if let Some(files) = value
2814        .get_mut("files")
2815        .and_then(|files| files.as_array_mut())
2816    {
2817        files.retain(|file| {
2818            file.as_str()
2819                .is_some_and(|file| scope.contains_display_path(display_file_from_occurrence(file)))
2820        });
2821        if files.len() < 2 {
2822            return false;
2823        }
2824        files.first().and_then(Value::as_str).map(str::to_string)
2825    } else {
2826        None
2827    };
2828
2829    if let Some(occurrence) = first_scoped_occurrence {
2830        update_duplicate_group_sample(value, &occurrence);
2831    }
2832
2833    true
2834}
2835
2836fn update_duplicate_group_sample(value: &mut serde_json::Value, occurrence: &str) {
2837    let Some((file, start_line, end_line)) = parse_duplicate_occurrence(occurrence) else {
2838        return;
2839    };
2840    let Some(object) = value.as_object_mut() else {
2841        return;
2842    };
2843
2844    if object.contains_key("sample_file") {
2845        object.insert("sample_file".to_string(), json!(file));
2846    }
2847    if object.contains_key("sample_start_line") {
2848        object.insert("sample_start_line".to_string(), json!(start_line));
2849    }
2850    if object.contains_key("sample_end_line") {
2851        object.insert("sample_end_line".to_string(), json!(end_line));
2852    }
2853}
2854
2855fn parse_duplicate_occurrence(value: &str) -> Option<(&str, u64, u64)> {
2856    let (file, range) = value.rsplit_once(':')?;
2857    let (start, end) = range.split_once('-')?;
2858    if !start.chars().all(|char| char.is_ascii_digit())
2859        || !end.chars().all(|char| char.is_ascii_digit())
2860    {
2861        return None;
2862    }
2863
2864    Some((file, start.parse().ok()?, end.parse().ok()?))
2865}
2866
2867fn display_file_from_occurrence(value: &str) -> &str {
2868    let Some((file, range)) = value.rsplit_once(':') else {
2869        return value;
2870    };
2871    let Some((start, end)) = range.split_once('-') else {
2872        return value;
2873    };
2874    if start.chars().all(|char| char.is_ascii_digit())
2875        && end.chars().all(|char| char.is_ascii_digit())
2876    {
2877        file
2878    } else {
2879        value
2880    }
2881}
2882
2883#[cfg(test)]
2884mod guard_tests {
2885    use super::*;
2886
2887    fn write_ts_project(file_count: usize) -> tempfile::TempDir {
2888        let dir = tempfile::tempdir().expect("tempdir");
2889        let root = dir.path();
2890        for i in 0..file_count {
2891            std::fs::write(
2892                root.join(format!("mod{i}.ts")),
2893                format!("export function f{i}() {{ return {i}; }}\n"),
2894            )
2895            .expect("write fixture");
2896        }
2897        dir
2898    }
2899
2900    #[test]
2901    fn scoped_filter_recomputes_top_preview_from_scoped_items() {
2902        let project_root = PathBuf::from("/project");
2903        let scope = JobScope::from_roots(project_root.clone(), vec![project_root.join("src/in")]);
2904        let payload = json!({
2905            "count": 4,
2906            "items": [
2907                { "file": "src/out/a.ts", "symbol": "outside" },
2908                { "file": "src/in/b.ts", "symbol": "inside_b" },
2909                { "file": "src/in/c.ts", "symbol": "inside_c" }
2910            ],
2911            "top": [
2912                { "file": "src/out/a.ts", "symbol": "outside" },
2913                { "file": "src/out/z.ts", "symbol": "outside_z" }
2914            ],
2915            "by_language": { "typescript": 4 }
2916        });
2917
2918        let filtered = filter_payload_for_scope(payload, &scope);
2919
2920        assert_eq!(filtered["count"], json!(2));
2921        assert_eq!(
2922            filtered["top"],
2923            json!([
2924                { "file": "src/in/b.ts", "symbol": "inside_b" },
2925                { "file": "src/in/c.ts", "symbol": "inside_c" }
2926            ])
2927        );
2928        assert!(filtered["top"]
2929            .as_array()
2930            .unwrap()
2931            .iter()
2932            .all(|item| item["file"]
2933                .as_str()
2934                .is_some_and(|file| file.starts_with("src/in/"))));
2935    }
2936
2937    #[test]
2938    fn cache_for_paths_rebinds_same_project_key_to_current_root() {
2939        let dir = tempfile::tempdir().expect("tempdir");
2940        let source = dir.path().join("source");
2941        std::fs::create_dir_all(&source).expect("create source repo");
2942        std::fs::write(
2943            source.join("package.json"),
2944            r#"{"name":"inspect-cache-fixture","version":"1.0.0"}"#,
2945        )
2946        .expect("write source manifest");
2947        std::fs::write(source.join("index.ts"), "export const source = 1;\n")
2948            .expect("write source file");
2949        assert!(std::process::Command::new("git")
2950            .current_dir(&source)
2951            .arg("init")
2952            .status()
2953            .expect("git init source repo")
2954            .success());
2955        assert!(std::process::Command::new("git")
2956            .current_dir(&source)
2957            .args(["add", "."])
2958            .status()
2959            .expect("git add source repo")
2960            .success());
2961        assert!(std::process::Command::new("git")
2962            .current_dir(&source)
2963            .args([
2964                "-c",
2965                "user.name=AFT Tests",
2966                "-c",
2967                "user.email=aft-tests@example.com",
2968                "commit",
2969                "-m",
2970                "initial",
2971            ])
2972            .status()
2973            .expect("git commit source repo")
2974            .success());
2975
2976        let clone = dir.path().join("clone");
2977        assert!(std::process::Command::new("git")
2978            .args(["clone", "--quiet"])
2979            .arg(&source)
2980            .arg(&clone)
2981            .status()
2982            .expect("git clone source repo")
2983            .success());
2984        std::fs::write(
2985            clone.join("package.json"),
2986            r#"{"name":"inspect-cache-fixture","version":"2.0.0"}"#,
2987        )
2988        .expect("write clone manifest edit");
2989        assert_eq!(
2990            crate::search_index::artifact_cache_key(&source),
2991            crate::search_index::artifact_cache_key(&clone),
2992            "clones with the same root commit should share the sqlite project key"
2993        );
2994
2995        let source = std::fs::canonicalize(source).expect("canonical source root");
2996        let clone = std::fs::canonicalize(clone).expect("canonical clone root");
2997        let manager = InspectManager::new();
2998        let inspect_dir = dir.path().join("inspect");
2999        let key = JobKey::for_project_category(InspectCategory::DeadCode);
3000        let source_cache = manager
3001            .cache_for_paths(inspect_dir.clone(), source.clone())
3002            .expect("open source cache");
3003        let source_hash = source_cache
3004            .contribution_set_hash(InspectCategory::DeadCode)
3005            .expect("source contribution hash");
3006        source_cache
3007            .store_tier2_aggregate(
3008                key.clone(),
3009                &source_hash,
3010                serde_json::json!({ "count": 7, "items": [] }),
3011            )
3012            .expect("store source aggregate");
3013        assert_eq!(
3014            source_cache
3015                .get_aggregated(&key)
3016                .expect("read source aggregate")
3017                .and_then(|payload| payload.get("count").and_then(Value::as_u64)),
3018            Some(7)
3019        );
3020
3021        let clone_cache = manager
3022            .cache_for_paths(inspect_dir, clone.clone())
3023            .expect("open clone cache");
3024        assert_eq!(clone_cache.project_root(), clone.as_path());
3025        assert!(
3026            clone_cache
3027                .get_aggregated(&key)
3028                .expect("read clone aggregate")
3029                .is_none(),
3030            "same-key clone with a different manifest must not reuse the source root's cached count"
3031        );
3032    }
3033
3034    fn snapshot_job(root: &Path, inspect_dir: &Path, callgraph_store: bool) -> InspectJob {
3035        use crate::config::Config;
3036        use crate::parser::SymbolCache;
3037        use std::sync::RwLock;
3038
3039        InspectJob {
3040            job_id: 1,
3041            key: JobKey::for_project_category(InspectCategory::DeadCode),
3042            category: InspectCategory::DeadCode,
3043            scope_files: Vec::new(),
3044            project_root: root.to_path_buf(),
3045            inspect_dir: inspect_dir.to_path_buf(),
3046            config: Arc::new(Config {
3047                project_root: Some(root.to_path_buf()),
3048                callgraph_store,
3049                ..Config::default()
3050            }),
3051            symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
3052            callgraph_snapshot: None,
3053        }
3054    }
3055
3056    #[test]
3057    fn callgraph_snapshot_reports_unavailable_when_store_disabled() {
3058        let dir = write_ts_project(3);
3059        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
3060        let inspect_dir = root.join(".aft-cache").join("inspect");
3061
3062        let snapshot =
3063            build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, false), false);
3064
3065        assert!(
3066            snapshot.is_none(),
3067            "dead_code must not rebuild the legacy graph when the store is disabled"
3068        );
3069    }
3070
3071    #[test]
3072    fn callgraph_snapshot_reports_unavailable_when_store_not_ready() {
3073        let dir = write_ts_project(3);
3074        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
3075        let inspect_dir = root.join(".aft-cache").join("inspect");
3076        let callgraph_dir = callgraph_store_dir_from_inspect_dir(&inspect_dir).expect("store dir");
3077        let _store = CallGraphStore::open(callgraph_dir, root.clone()).expect("open empty store");
3078
3079        let snapshot =
3080            build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true), false);
3081
3082        assert!(
3083            snapshot.is_none(),
3084            "a cold/mid-build store must surface callgraph_unavailable instead of rebuilding inline"
3085        );
3086    }
3087
3088    #[test]
3089    fn direct_callgraph_snapshot_does_not_cold_rebuild_when_store_needs_rebuild() {
3090        let dir = write_ts_project(3);
3091        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
3092        let inspect_dir = root.join(".aft-cache").join("inspect");
3093        let callgraph_dir = callgraph_store_dir_from_inspect_dir(&inspect_dir).expect("store dir");
3094        let store = CallGraphStore::open(callgraph_dir.clone(), root.clone()).expect("open store");
3095        let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
3096        store.cold_build(&files).expect("cold build store");
3097        let sqlite_path = store.sqlite_path().to_path_buf();
3098        drop(store);
3099
3100        let still_existing_previous_root = root.with_file_name("previous-root-still-exists");
3101        std::fs::create_dir_all(&still_existing_previous_root).expect("create previous root");
3102        let conn = rusqlite::Connection::open(sqlite_path).expect("open store sqlite");
3103        conn.execute(
3104            "UPDATE backend_file_state SET workspace_root = ?1",
3105            rusqlite::params![still_existing_previous_root.display().to_string()],
3106        )
3107        .expect("force root repair rebuild state");
3108
3109        let snapshot =
3110            build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true), false);
3111
3112        assert!(
3113            snapshot.is_none(),
3114            "direct inspect must report callgraph_unavailable instead of cold-rebuilding a root-repair store"
3115        );
3116    }
3117
3118    #[test]
3119    fn callgraph_snapshot_reads_ready_callgraph_store() {
3120        let dir = write_ts_project(3);
3121        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
3122        let inspect_dir = root.join(".aft-cache").join("inspect");
3123        let callgraph_dir = callgraph_store_dir_from_inspect_dir(&inspect_dir).expect("store dir");
3124        let store = CallGraphStore::open(callgraph_dir, root.clone()).expect("open store");
3125        let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
3126        store.cold_build(&files).expect("cold build store");
3127
3128        let snapshot =
3129            build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true), false)
3130                .expect("ready store snapshot");
3131
3132        assert_eq!(snapshot.files.len(), 3);
3133        assert_eq!(snapshot.exported_symbols.len(), 3);
3134    }
3135
3136    // A scoped payload must not carry the project-wide `by_language` breakdown
3137    // alongside the recomputed in-scope count — that contradiction renders as
3138    // e.g. "Dead code: 1 (rust 214, ts 143)".
3139    #[test]
3140    fn scoped_filter_drops_project_wide_by_language() {
3141        let scope = JobScope::from_roots("/proj", vec![PathBuf::from("/proj/src/a")]);
3142        assert!(
3143            !scope.is_project_wide(),
3144            "scope must be non-project for test"
3145        );
3146        let payload = serde_json::json!({
3147            "count": 99,
3148            "by_language": { "rust": 214, "typescript": 143 },
3149            "items": [
3150                { "file": "/proj/src/a/x.rs", "symbol": "live" },
3151                { "file": "/proj/src/other/y.rs", "symbol": "out" },
3152            ],
3153        });
3154        let filtered = filter_payload_for_scope(payload, &scope);
3155        assert!(
3156            filtered.get("by_language").is_none(),
3157            "scoped payload must drop project-wide by_language: {filtered}"
3158        );
3159        // Count is recomputed to the in-scope items (only x.rs under src/a).
3160        assert_eq!(filtered.get("count").and_then(|v| v.as_u64()), Some(1));
3161    }
3162    #[cfg(debug_assertions)]
3163    #[test]
3164    fn tier2_read_cached_freshness_does_not_hash_unchanged_contributions() {
3165        let (_dir, manager, snapshot, scope, _files) = duplicate_cache_fixture();
3166
3167        crate::cache_freshness::reset_hash_file_if_small_count_for_debug();
3168        crate::cache_freshness::reset_verify_file_strict_count_for_debug();
3169        assert_fresh(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
3170
3171        assert_eq!(
3172            crate::cache_freshness::verify_file_strict_count_for_debug(),
3173            0,
3174            "dispatch-thread inspect freshness must not use strict verification"
3175        );
3176        assert_eq!(
3177            crate::cache_freshness::hash_file_if_small_count_for_debug(),
3178            0,
3179            "unchanged contribution files must stay on the stat-only fast path"
3180        );
3181    }
3182
3183    #[cfg(debug_assertions)]
3184    #[test]
3185    fn tier2_read_cached_freshness_returns_byte_identical_cold_scan_aggregate() {
3186        let (_dir, manager, snapshot, scope, _files) = duplicate_uncached_fixture();
3187        let cold_payload = fresh_payload(manager.tier2_run_with_reuse(
3188            snapshot.clone(),
3189            InspectCategory::Duplicates,
3190            scope.clone(),
3191            None,
3192        ));
3193
3194        crate::cache_freshness::reset_hash_file_if_small_count_for_debug();
3195        crate::cache_freshness::reset_verify_file_strict_count_for_debug();
3196        let warm_payload =
3197            fresh_payload(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
3198
3199        let cold_bytes = serde_json::to_vec(&cold_payload).expect("serialize cold aggregate");
3200        let warm_bytes = serde_json::to_vec(&warm_payload).expect("serialize warm aggregate");
3201        assert_eq!(
3202            warm_bytes, cold_bytes,
3203            "warm unchanged read must return the byte-identical aggregate as the cold scan"
3204        );
3205        assert_eq!(
3206            crate::cache_freshness::verify_file_strict_count_for_debug(),
3207            0,
3208            "dispatch-thread warm read must not use strict verification"
3209        );
3210        assert_eq!(
3211            crate::cache_freshness::hash_file_if_small_count_for_debug(),
3212            0,
3213            "warm unchanged read must not content-hash cached contribution files"
3214        );
3215    }
3216
3217    #[test]
3218    fn tier2_read_cached_freshness_detects_changed_added_and_deleted_files() {
3219        let (_dir, manager, snapshot, scope, _files) = duplicate_cache_fixture();
3220        write_fixture_file(
3221            &snapshot.project_root,
3222            "src/foo.ts",
3223            "export const foo = 101;\nexport const changed = true;\n",
3224            3_000_000_001,
3225        );
3226        assert_stale(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
3227
3228        let (_dir, manager, snapshot, scope, _files) = duplicate_cache_fixture();
3229        write_fixture_file(
3230            &snapshot.project_root,
3231            "src/added.ts",
3232            "export const added = 3;\n",
3233            3_000_000_002,
3234        );
3235        assert_stale(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
3236
3237        let (_dir, manager, snapshot, scope, files) = duplicate_cache_fixture();
3238        std::fs::remove_file(&files[0]).expect("delete cached contribution file");
3239        assert_stale(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
3240    }
3241
3242    fn duplicate_cache_fixture() -> (
3243        tempfile::TempDir,
3244        InspectManager,
3245        InspectSnapshot,
3246        JobScope,
3247        Vec<PathBuf>,
3248    ) {
3249        let (dir, manager, snapshot, scope, files) = duplicate_uncached_fixture();
3250        store_duplicate_cache(&manager, &snapshot, &files);
3251        (dir, manager, snapshot, scope, files)
3252    }
3253
3254    fn duplicate_uncached_fixture() -> (
3255        tempfile::TempDir,
3256        InspectManager,
3257        InspectSnapshot,
3258        JobScope,
3259        Vec<PathBuf>,
3260    ) {
3261        use crate::config::Config;
3262        use crate::parser::SymbolCache;
3263        use std::sync::RwLock;
3264
3265        let dir = tempfile::tempdir().expect("tempdir");
3266        let root = std::fs::canonicalize(dir.path()).expect("canonical fixture root");
3267        let files = vec![
3268            write_fixture_file(
3269                &root,
3270                "src/foo.ts",
3271                "export const fixture = () => 1;
3272export const shared = 1;
3273",
3274                3_000_000_000,
3275            ),
3276            write_fixture_file(
3277                &root,
3278                "src/bar.ts",
3279                "export const fixture = () => 1;
3280export const shared = 1;
3281",
3282                3_000_000_000,
3283            ),
3284        ];
3285        let inspect_dir = root.join(".aft-cache").join("inspect");
3286        let snapshot = InspectSnapshot::new(
3287            root.clone(),
3288            inspect_dir,
3289            Arc::new(Config {
3290                project_root: Some(root.clone()),
3291                ..Config::default()
3292            }),
3293            Arc::new(RwLock::new(SymbolCache::new())),
3294        );
3295        let scope = JobScope::for_project(root);
3296        let manager = InspectManager::new();
3297        (dir, manager, snapshot, scope, files)
3298    }
3299
3300    fn write_fixture_file(root: &Path, relative: &str, content: &str, mtime_secs: i64) -> PathBuf {
3301        let path = root.join(relative);
3302        if let Some(parent) = path.parent() {
3303            std::fs::create_dir_all(parent).expect("create fixture parent");
3304        }
3305        std::fs::write(&path, content).expect("write fixture file");
3306        filetime::set_file_mtime(&path, filetime::FileTime::from_unix_time(mtime_secs, 0))
3307            .expect("set fixture mtime");
3308        path
3309    }
3310
3311    fn store_duplicate_cache(
3312        manager: &InspectManager,
3313        snapshot: &InspectSnapshot,
3314        files: &[PathBuf],
3315    ) {
3316        let cache = manager
3317            .cache_for_snapshot(snapshot)
3318            .expect("open inspect cache");
3319        let contributions = files
3320            .iter()
3321            .map(|file| {
3322                let freshness = crate::cache_freshness::collect(file).expect("collect freshness");
3323                FileContribution::new(
3324                    InspectCategory::Duplicates,
3325                    file.clone(),
3326                    freshness,
3327                    serde_json::json!({
3328                        "file": relative_cache_key(&snapshot.project_root, file),
3329                        "fragments": [],
3330                    }),
3331                )
3332            })
3333            .collect::<Vec<_>>();
3334        cache
3335            .store_tier2_result(
3336                JobKey::for_project_category(InspectCategory::Duplicates),
3337                files,
3338                &contributions,
3339                serde_json::json!({
3340                    "count": 0,
3341                    "groups": [],
3342                    "scanned_files": files.len(),
3343                    "total_groups": 0,
3344                }),
3345            )
3346            .expect("store tier2 cache fixture");
3347    }
3348
3349    fn assert_fresh(outcome: JobOutcome) {
3350        let _ = fresh_payload(outcome);
3351    }
3352
3353    fn fresh_payload(outcome: JobOutcome) -> Value {
3354        match outcome {
3355            JobOutcome::Fresh { payload } => payload,
3356            other => panic!("expected fresh cached Tier-2 outcome, got {other:?}"),
3357        }
3358    }
3359
3360    fn assert_stale(outcome: JobOutcome) {
3361        match outcome {
3362            JobOutcome::Stale { .. } => {}
3363            other => panic!("expected stale cached Tier-2 outcome, got {other:?}"),
3364        }
3365    }
3366}
3367
3368#[cfg(test)]
3369mod dead_code_projection_tests {
3370    use super::*;
3371    use crate::callgraph::walk_project_files;
3372    use crate::callgraph_store::{project_dead_code_snapshot, CallGraphStore};
3373    use crate::config::Config;
3374    use crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR;
3375    use crate::inspect::scanners::DEFAULT_EXPORT_MARKER_KIND;
3376    use crate::parser::SymbolCache;
3377    use filetime::FileTime;
3378    use std::sync::atomic::{AtomicI64, Ordering as AtomicOrdering};
3379    use std::sync::RwLock;
3380
3381    static NEXT_MTIME: AtomicI64 = AtomicI64::new(1_900_000_000);
3382
3383    #[test]
3384    fn scoped_dead_code_rollup_uses_ready_callgraph_and_degrades_without_it() {
3385        let dir = tempfile::tempdir().expect("tempdir");
3386        write_projection_fixture(dir.path());
3387        let root = canonical_root(dir.path());
3388        let inspect_dir = root.join(".aft-cache").join("inspect");
3389        let callgraph_dir = callgraph_store_dir_from_inspect_dir(&inspect_dir).expect("store dir");
3390        let store = CallGraphStore::open(callgraph_dir.clone(), root.clone()).expect("open store");
3391        let files = project_files(&root);
3392        store.cold_build(&files).expect("cold build store");
3393        let projected = project_dead_code_snapshot(store.sqlite_path()).expect("project snapshot");
3394        drop(store);
3395
3396        let config = Arc::new(Config {
3397            project_root: Some(root.clone()),
3398            callgraph_store: true,
3399            ..Config::default()
3400        });
3401        let symbol_cache = Arc::new(RwLock::new(SymbolCache::new()));
3402        let scan_job = InspectJob {
3403            job_id: 87,
3404            key: JobKey::for_project_category(InspectCategory::DeadCode),
3405            category: InspectCategory::DeadCode,
3406            scope_files: files.clone(),
3407            project_root: root.clone(),
3408            inspect_dir: inspect_dir.clone(),
3409            config: Arc::clone(&config),
3410            symbol_cache: Arc::clone(&symbol_cache),
3411            callgraph_snapshot: Some(Arc::new(projected)),
3412        };
3413        let success = crate::inspect::scanners::dead_code::run_dead_code_scan(&scan_job)
3414            .outcome
3415            .expect("dead_code scan succeeds");
3416        let cache = InspectCache::open(inspect_dir.clone(), root.clone()).expect("open cache");
3417        cache
3418            .store_tier2_result(
3419                scan_job.key.clone(),
3420                &success.scanned_files,
3421                &success.contributions,
3422                success.aggregate.clone(),
3423            )
3424            .expect("store tier2 result");
3425
3426        let snapshot = InspectSnapshot::new(root.clone(), inspect_dir, config, symbol_cache);
3427        let scope = JobScope::from_roots(root.clone(), vec![root.join("src/live.ts")]);
3428        assert!(
3429            !scope.is_project_wide(),
3430            "live.ts file scope must be scoped"
3431        );
3432
3433        let ready_payload = scoped_tier2_payload_from_contributions(
3434            &snapshot,
3435            InspectCategory::DeadCode,
3436            &cache,
3437            success.aggregate.clone(),
3438            &scope,
3439        )
3440        .expect("ready scoped payload");
3441        assert_eq!(
3442            ready_payload
3443                .get("callgraph_available")
3444                .and_then(Value::as_bool),
3445            Some(true),
3446            "ready store should produce a callgraph-backed scoped rollup: {ready_payload:#}"
3447        );
3448        assert_live_item(&ready_payload, "src/live.ts", "knownLive");
3449
3450        std::fs::remove_dir_all(&callgraph_dir).expect("remove ready callgraph store");
3451        let unavailable_payload = scoped_tier2_payload_from_contributions(
3452            &snapshot,
3453            InspectCategory::DeadCode,
3454            &cache,
3455            success.aggregate,
3456            &scope,
3457        )
3458        .expect("unavailable scoped payload");
3459        assert_eq!(
3460            unavailable_payload
3461                .get("callgraph_available")
3462                .and_then(Value::as_bool),
3463            Some(false),
3464            "missing store must report callgraph_unavailable instead of fabricating an empty graph: {unavailable_payload:#}"
3465        );
3466        assert_live_item(&unavailable_payload, "src/live.ts", "knownLive");
3467    }
3468    #[derive(Debug, PartialEq, Eq)]
3469    struct ComparableSnapshot {
3470        files: BTreeSet<PathBuf>,
3471        exported_symbols: BTreeSet<(PathBuf, String, String, u32)>,
3472        outbound_calls: BTreeSet<(PathBuf, String, String, u32)>,
3473        entry_points: BTreeSet<PathBuf>,
3474        entry_point_symbols: BTreeMap<PathBuf, BTreeSet<String>>,
3475    }
3476
3477    #[test]
3478    fn dead_code_projection_contains_expected_fixture_surface() {
3479        let dir = tempfile::tempdir().expect("tempdir");
3480        write_projection_fixture(dir.path());
3481        let root = canonical_root(dir.path());
3482        let projected = store_projected_snapshot(&root, ".store-dead-code-surface");
3483
3484        assert_projection_fixture_coverage(&root, &projected);
3485    }
3486
3487    #[test]
3488    fn dead_code_projection_incremental_scenario_matrix_matches_cold_rebuild() {
3489        run_projection_scenario("rename", setup_projection_rename, edit_projection_rename);
3490        run_projection_scenario("delete", setup_projection_delete, edit_projection_delete);
3491        run_projection_scenario(
3492            "barrel delete",
3493            setup_projection_barrel,
3494            edit_projection_barrel_delete,
3495        );
3496        run_projection_scenario(
3497            "dispatch edit",
3498            setup_projection_dispatch,
3499            edit_projection_dispatch,
3500        );
3501        run_projection_scenario(
3502            "body-only edit",
3503            setup_projection_body_only,
3504            edit_projection_body_only,
3505        );
3506    }
3507
3508    #[test]
3509    fn dead_code_projection_dead_code_scan_reports_expected_verdicts() {
3510        let dir = tempfile::tempdir().expect("tempdir");
3511        write_projection_fixture(dir.path());
3512        let root = canonical_root(dir.path());
3513        let files = project_files(&root);
3514        let projected = store_projected_snapshot(&root, ".store-dead-code-e2e");
3515
3516        let projected_aggregate = dead_code_aggregate(&root, files, projected);
3517        assert_dead_item(&projected_aggregate, "src/dead.ts", "knownDead");
3518        assert_live_item(&projected_aggregate, "src/live.ts", "knownLive");
3519        assert_live_item(&projected_aggregate, "src/render.ts", "render");
3520        assert_live_item(&projected_aggregate, "src/other_render.ts", "render");
3521    }
3522
3523    #[test]
3524    fn dead_code_projection_rust_attribute_entry_points_are_live() {
3525        let dir = tempfile::tempdir().expect("tempdir");
3526        write_rust_attribute_entry_fixture(dir.path());
3527        let root = canonical_root(dir.path());
3528        let files = project_files(&root);
3529        let store = CallGraphStore::open(root.join(".store-tauri-commands"), root.clone())
3530            .expect("open store");
3531        store.cold_build(&files).expect("cold build store");
3532        let command = store
3533            .node_for(Path::new("src/commands.rs"), "get_primers")
3534            .expect("command node");
3535        assert!(
3536            command.is_entry_point,
3537            "attribute-rooted commands must be labeled as callgraph entry points"
3538        );
3539        let private_command = store
3540            .node_for(Path::new("src/commands.rs"), "private_command")
3541            .expect("private command node");
3542        assert!(
3543            private_command.is_entry_point,
3544            "private attribute-rooted commands must also be callgraph entry points"
3545        );
3546
3547        let projected = project_dead_code_snapshot(store.sqlite_path()).expect("project snapshot");
3548        let aggregate = dead_code_aggregate(&root, files, projected);
3549        assert_live_item(&aggregate, "src/commands.rs", "get_primers");
3550        assert_live_item(&aggregate, "src/db.rs", "helper");
3551        assert_live_item(&aggregate, "src/db.rs", "private_helper");
3552        assert_live_item(&aggregate, "src/imported.rs", "imported_command");
3553        assert_live_item(&aggregate, "src/db.rs", "imported_helper");
3554        assert_dead_item(&aggregate, "src/commands.rs", "planted_dead");
3555        assert_dead_item(&aggregate, "src/unimported.rs", "false_command");
3556        assert_dead_item(&aggregate, "src/db.rs", "false_helper");
3557    }
3558
3559    #[test]
3560    fn dead_code_projection_rust_attribute_roots_are_cold_deterministic() {
3561        let dir = tempfile::tempdir().expect("tempdir");
3562        write_rust_attribute_entry_fixture(dir.path());
3563        let root = canonical_root(dir.path());
3564        let first = store_projected_snapshot(&root, ".store-tauri-cold-a");
3565        let second = store_projected_snapshot(&root, ".store-tauri-cold-b");
3566
3567        assert_snapshot_parts_eq("rust attribute roots cold", &first, &second);
3568    }
3569
3570    #[test]
3571    fn dead_code_projection_rust_attribute_roots_survive_unrelated_incremental_edit() {
3572        let dir = tempfile::tempdir().expect("tempdir");
3573        write_rust_attribute_entry_fixture(dir.path());
3574        let root = canonical_root(dir.path());
3575        let files_before = project_files(&root);
3576        let incremental_store =
3577            CallGraphStore::open(root.join(".store-tauri-incremental"), root.clone())
3578                .expect("open incremental store");
3579        incremental_store
3580            .cold_build(&files_before)
3581            .expect("initial cold build");
3582
3583        write_file(
3584            &root.join("src/unrelated.rs"),
3585            r#"// unrelated edit should not refresh command attribute facts
3586pub fn unrelated() -> u32 { 2 }
3587"#,
3588        );
3589        let stats = incremental_store
3590            .refresh_files(&[root.join("src/unrelated.rs")])
3591            .expect("refresh unrelated file");
3592        assert_eq!(stats.refreshed_own_files, 1);
3593        assert_eq!(stats.changed_files, vec!["src/unrelated.rs".to_string()]);
3594        assert!(
3595            !stats
3596                .surface_changed
3597                .iter()
3598                .any(|file| file == "src/commands.rs"),
3599            "unrelated edit must not refresh the command module: {stats:#?}"
3600        );
3601        let incremental = project_dead_code_snapshot(incremental_store.sqlite_path())
3602            .expect("project incremental snapshot");
3603
3604        let cold_store = CallGraphStore::open(root.join(".store-tauri-cold"), root.clone())
3605            .expect("open cold store");
3606        cold_store
3607            .cold_build(&project_files(&root))
3608            .expect("cold rebuild");
3609        let cold = project_dead_code_snapshot(cold_store.sqlite_path()).expect("project cold");
3610        assert_snapshot_parts_eq("rust attribute roots unrelated edit", &cold, &incremental);
3611
3612        let aggregate = dead_code_aggregate(&root, project_files(&root), incremental);
3613        assert_live_item(&aggregate, "src/commands.rs", "get_primers");
3614        assert_live_item(&aggregate, "src/db.rs", "helper");
3615        assert_live_item(&aggregate, "src/db.rs", "private_helper");
3616        assert_dead_item(&aggregate, "src/commands.rs", "planted_dead");
3617    }
3618
3619    fn assert_projection_fixture_coverage(root: &Path, snapshot: &CallgraphSnapshot) {
3620        let comparable = comparable_snapshot(snapshot);
3621        assert!(
3622            comparable
3623                .files
3624                .iter()
3625                .any(|file| file.extension().and_then(|ext| ext.to_str()) == Some("ts")),
3626            "fixture must include TypeScript files: {:#?}",
3627            comparable.files
3628        );
3629        assert!(
3630            comparable
3631                .files
3632                .iter()
3633                .any(|file| file.extension().and_then(|ext| ext.to_str()) == Some("js")),
3634            "fixture must include JavaScript files: {:#?}",
3635            comparable.files
3636        );
3637        assert!(
3638            comparable
3639                .files
3640                .iter()
3641                .any(|file| file.extension().and_then(|ext| ext.to_str()) == Some("rs")),
3642            "fixture must include Rust files: {:#?}",
3643            comparable.files
3644        );
3645
3646        let main_file = canonicalize_for_snapshot(&root.join("src/main.ts"));
3647        let private_dispatch_target = format!("{}::dispatch", main_file.display());
3648        assert!(
3649            comparable
3650                .outbound_calls
3651                .iter()
3652                .any(
3653                    |(caller_file, caller_symbol, target, _)| caller_file == &main_file
3654                        && caller_symbol == "main"
3655                        && target == &private_dispatch_target
3656                ),
3657            "fixture must cover same-file private fallback target {private_dispatch_target}: {:#?}",
3658            comparable.outbound_calls
3659        );
3660        assert!(
3661            comparable
3662                .outbound_calls
3663                .iter()
3664                .any(|(_, _, target, _)| target.contains(DISPATCHED_CALLEE_SEPARATOR)),
3665            "fixture must cover method-dispatch suffixes: {:#?}",
3666            comparable.outbound_calls
3667        );
3668        assert!(
3669            comparable
3670                .exported_symbols
3671                .iter()
3672                .any(|(_, symbol, kind, _)| symbol == "runDefault"
3673                    && kind == DEFAULT_EXPORT_MARKER_KIND),
3674            "fixture must cover default-export marker rows: {:#?}",
3675            comparable.exported_symbols
3676        );
3677    }
3678
3679    fn run_projection_scenario(name: &str, setup: fn(&Path), edit: fn(&Path) -> Vec<PathBuf>) {
3680        let dir = tempfile::tempdir().expect("tempdir");
3681        setup(dir.path());
3682        let root = canonical_root(dir.path());
3683        let files_before = project_files(&root);
3684        let incremental_store = CallGraphStore::open(
3685            root.join(format!(".store-dead-code-projection-{name}-incremental")),
3686            root.clone(),
3687        )
3688        .expect("open incremental store");
3689        incremental_store
3690            .cold_build(&files_before)
3691            .expect("initial cold build");
3692
3693        let changed = edit(&root);
3694        incremental_store
3695            .refresh_files(&changed)
3696            .expect("refresh changed files");
3697        let incremental = project_dead_code_snapshot(incremental_store.sqlite_path())
3698            .expect("project incremental snapshot");
3699
3700        let cold_store = CallGraphStore::open(
3701            root.join(format!(".store-dead-code-projection-{name}-cold")),
3702            root.clone(),
3703        )
3704        .expect("open cold store");
3705        cold_store
3706            .cold_build(&project_files(&root))
3707            .expect("cold rebuild");
3708        let cold =
3709            project_dead_code_snapshot(cold_store.sqlite_path()).expect("project cold snapshot");
3710
3711        assert_snapshot_parts_eq(name, &cold, &incremental);
3712    }
3713
3714    /// Store-backed dead_code benchmark. Measures, on a real checkout, the
3715    /// persisted-store cold build, the warm SQLite projection cost, and the
3716    /// remaining `run_dead_code_scan` cost (per-file reexport/type-ref reparse +
3717    /// BFS roll-up). Production Tier-2 reads a warm store; cold_build is included
3718    /// here only to make end-to-end store cost visible.
3719    /// Ignored by default; run with:
3720    ///   AFT_BENCH_REPO=/path/to/large/repo cargo test -p agent-file-tools --lib \
3721    ///     -- --ignored --nocapture --test-threads=1 dead_code_decision_b_benchmark
3722    #[test]
3723    #[ignore = "manual benchmark; needs AFT_BENCH_REPO pointing at a large checkout"]
3724    fn dead_code_decision_b_benchmark() {
3725        let Ok(repo) = std::env::var("AFT_BENCH_REPO") else {
3726            eprintln!("AFT_BENCH_REPO unset; skipping");
3727            return;
3728        };
3729        // Each phase flushes immediately so a file-redirected run shows live progress.
3730        macro_rules! mark {
3731            ($($a:tt)*) => {{ eprintln!($($a)*); let _ = std::io::Write::flush(&mut std::io::stderr()); }};
3732        }
3733        let root = canonical_root(Path::new(&repo));
3734        let files = project_files(&root);
3735        mark!(
3736            "\n=== Store-backed dead_code benchmark ===\nrepo: {}\nsource files (walk_project_files): {}\nstarted store cold_build...",
3737            root.display(),
3738            files.len()
3739        );
3740
3741        // Store cold_build + projection. Production warm runs skip cold_build and
3742        // pay only the projection below.
3743        let store_dir = root.join(".aft-bench-store");
3744        let _ = std::fs::remove_dir_all(&store_dir);
3745        let store = CallGraphStore::open(store_dir.clone(), root.clone()).expect("open store");
3746        let t = Instant::now();
3747        let cold_stats = store.cold_build(&files).expect("store cold build");
3748        let store_build_ms = t.elapsed().as_millis();
3749        let t = Instant::now();
3750        let projected = project_dead_code_snapshot(store.sqlite_path()).expect("projection");
3751        let proj_ms = t.elapsed().as_millis();
3752        mark!(
3753            "store cold_build: {} ms ({:?}) + projection: {} ms = {} ms  (exports={}, outbound={})\nstarted scan...",
3754            store_build_ms, cold_stats, proj_ms, store_build_ms + proj_ms,
3755            projected.exported_symbols.len(), projected.outbound_calls.len()
3756        );
3757
3758        // Remaining scanner cost: run_dead_code_scan given a ready snapshot.
3759        let t = Instant::now();
3760        let _result = dead_code_aggregate(&root, files.clone(), projected.clone());
3761        let scan_ms = t.elapsed().as_millis();
3762        mark!("run_dead_code_scan (cold contributions): {} ms", scan_ms);
3763
3764        mark!(
3765            "\nSUMMARY  files={}  store_cold_plus_projection={}ms  projection={}ms  scan_cold={}ms  total={}ms",
3766            files.len(),
3767            store_build_ms + proj_ms,
3768            proj_ms,
3769            scan_ms,
3770            store_build_ms + proj_ms + scan_ms
3771        );
3772        let _ = std::fs::remove_dir_all(&store_dir);
3773    }
3774
3775    fn store_projected_snapshot(root: &Path, store_name: &str) -> CallgraphSnapshot {
3776        let store =
3777            CallGraphStore::open(root.join(store_name), root.to_path_buf()).expect("open store");
3778        store
3779            .cold_build(&project_files(root))
3780            .expect("store cold build");
3781        project_dead_code_snapshot(store.sqlite_path()).expect("project snapshot")
3782    }
3783
3784    fn dead_code_aggregate(
3785        root: &Path,
3786        scope_files: Vec<PathBuf>,
3787        snapshot: CallgraphSnapshot,
3788    ) -> Value {
3789        let job = InspectJob {
3790            job_id: 86,
3791            key: JobKey::for_project_category(InspectCategory::DeadCode),
3792            category: InspectCategory::DeadCode,
3793            scope_files,
3794            project_root: root.to_path_buf(),
3795            inspect_dir: root.join(".aft-cache").join("inspect"),
3796            config: Arc::new(Config {
3797                project_root: Some(root.to_path_buf()),
3798                ..Config::default()
3799            }),
3800            symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
3801            callgraph_snapshot: Some(Arc::new(snapshot)),
3802        };
3803        crate::inspect::scanners::dead_code::run_dead_code_scan(&job)
3804            .outcome
3805            .expect("dead_code scan succeeds")
3806            .aggregate
3807    }
3808
3809    fn assert_snapshot_parts_eq(
3810        label: &str,
3811        expected: &CallgraphSnapshot,
3812        actual: &CallgraphSnapshot,
3813    ) {
3814        let expected = comparable_snapshot(expected);
3815        let actual = comparable_snapshot(actual);
3816        assert_eq!(
3817            actual, expected,
3818            "{label} store-projected snapshot must match cold store snapshot"
3819        );
3820    }
3821
3822    fn comparable_snapshot(snapshot: &CallgraphSnapshot) -> ComparableSnapshot {
3823        ComparableSnapshot {
3824            files: snapshot.files.iter().cloned().collect(),
3825            exported_symbols: snapshot
3826                .exported_symbols
3827                .iter()
3828                .map(|export| {
3829                    (
3830                        export.file.clone(),
3831                        export.symbol.clone(),
3832                        export.kind.clone(),
3833                        export.line,
3834                    )
3835                })
3836                .collect(),
3837            outbound_calls: snapshot
3838                .outbound_calls
3839                .iter()
3840                .map(|call| {
3841                    (
3842                        call.caller_file.clone(),
3843                        call.caller_symbol.clone(),
3844                        call.target.clone(),
3845                        call.line,
3846                    )
3847                })
3848                .collect(),
3849            entry_points: snapshot.entry_points.clone(),
3850            entry_point_symbols: snapshot.entry_point_symbols.clone(),
3851        }
3852    }
3853
3854    fn assert_dead_item(aggregate: &Value, file: &str, symbol: &str) {
3855        assert!(
3856            aggregate_has_item(aggregate, file, symbol),
3857            "expected {file}::{symbol} to be reported dead: {aggregate:#}"
3858        );
3859    }
3860
3861    fn assert_live_item(aggregate: &Value, file: &str, symbol: &str) {
3862        assert!(
3863            !aggregate_has_item(aggregate, file, symbol),
3864            "expected {file}::{symbol} to be live/not reported dead: {aggregate:#}"
3865        );
3866    }
3867
3868    fn aggregate_has_item(aggregate: &Value, file: &str, symbol: &str) -> bool {
3869        let Some(items) = aggregate.get("items").and_then(Value::as_array) else {
3870            return false;
3871        };
3872        items.iter().any(|item| {
3873            item.get("file").and_then(Value::as_str) == Some(file)
3874                && item.get("symbol").and_then(Value::as_str) == Some(symbol)
3875        })
3876    }
3877
3878    fn project_files(root: &Path) -> Vec<PathBuf> {
3879        walk_project_files(root).collect()
3880    }
3881
3882    fn canonical_root(root: &Path) -> PathBuf {
3883        std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf())
3884    }
3885
3886    fn write_file(path: &Path, content: &str) {
3887        if let Some(parent) = path.parent() {
3888            std::fs::create_dir_all(parent).expect("create parent");
3889        }
3890        std::fs::write(path, content).expect("write fixture");
3891        bump_mtime(path);
3892    }
3893
3894    fn bump_mtime(path: &Path) {
3895        let secs = NEXT_MTIME.fetch_add(1, AtomicOrdering::SeqCst);
3896        filetime::set_file_mtime(path, FileTime::from_unix_time(secs, 0)).expect("bump mtime");
3897    }
3898
3899    fn remove_file(path: &Path) {
3900        std::fs::remove_file(path).expect("remove fixture");
3901    }
3902
3903    fn write_projection_fixture(root: &Path) {
3904        write_file(
3905            &root.join("package.json"),
3906            r#"{"name":"dead-code-projection-fixture","type":"module","main":"src/main.ts"}"#,
3907        );
3908        write_file(
3909            &root.join("Cargo.toml"),
3910            r#"[package]
3911name = "dead_code_projection_fixture"
3912version = "0.1.0"
3913edition = "2021"
3914"#,
3915        );
3916        write_file(
3917            &root.join("src/main.ts"),
3918            r#"import runDefault from "./default";
3919import { knownLive } from "./live";
3920import { jsEntry } from "./app.js";
3921
3922export function main() {
3923  dispatch();
3924  runDefault();
3925  jsEntry();
3926}
3927
3928function dispatch() {
3929  knownLive();
3930  const service = { render() {} };
3931  service.render();
3932}
3933"#,
3934        );
3935        write_file(
3936            &root.join("src/default.ts"),
3937            r#"export default function runDefault() {}
3938"#,
3939        );
3940        write_file(
3941            &root.join("src/live.ts"),
3942            r#"export function knownLive() {}
3943"#,
3944        );
3945        write_file(
3946            &root.join("src/dead.ts"),
3947            r#"export function knownDead() {}
3948"#,
3949        );
3950        write_file(
3951            &root.join("src/render.ts"),
3952            r#"export function render() {}
3953"#,
3954        );
3955        write_file(
3956            &root.join("src/other_render.ts"),
3957            r#"export function render() {}
3958"#,
3959        );
3960        write_file(
3961            &root.join("src/app.js"),
3962            r#"import { jsHelper } from "./js_helper.js";
3963
3964export function jsEntry() {
3965  jsHelper();
3966}
3967"#,
3968        );
3969        write_file(
3970            &root.join("src/js_helper.js"),
3971            r#"export function jsHelper() {}
3972"#,
3973        );
3974        write_file(
3975            &root.join("src/lib.rs"),
3976            r#"mod util;
3977use crate::util::rust_helper;
3978
3979pub fn rust_entry() {
3980    rust_helper();
3981}
3982"#,
3983        );
3984        write_file(
3985            &root.join("src/util.rs"),
3986            r#"pub fn rust_helper() {}
3987"#,
3988        );
3989    }
3990
3991    fn write_rust_attribute_entry_fixture(root: &Path) {
3992        write_file(
3993            &root.join("src/main.rs"),
3994            r#"mod commands;
3995mod db;
3996mod imported;
3997mod unimported;
3998mod unrelated;
3999
4000fn main() {
4001    tauri::generate_handler![commands::get_primers, imported::imported_command];
4002}
4003"#,
4004        );
4005        write_file(
4006            &root.join("src/commands.rs"),
4007            r#"use crate::db;
4008
4009#[tauri::command]
4010pub fn get_primers() -> String {
4011    db::helper()
4012}
4013
4014pub fn planted_dead() -> String {
4015    "dead".to_string()
4016}
4017
4018#[tauri::command]
4019fn private_command() -> String {
4020    db::private_helper()
4021}
4022"#,
4023        );
4024        write_file(
4025            &root.join("src/imported.rs"),
4026            r#"use crate::db;
4027use tauri::command;
4028
4029#[command]
4030pub fn imported_command() -> String {
4031    db::imported_helper()
4032}
4033"#,
4034        );
4035        write_file(
4036            &root.join("src/unimported.rs"),
4037            r#"use crate::db;
4038
4039#[command]
4040pub fn false_command() -> String {
4041    db::false_helper()
4042}
4043"#,
4044        );
4045        write_file(
4046            &root.join("src/db.rs"),
4047            r#"pub fn helper() -> String { "live".to_string() }
4048pub fn imported_helper() -> String { "live".to_string() }
4049pub fn private_helper() -> String { "live".to_string() }
4050pub fn false_helper() -> String { "dead".to_string() }
4051"#,
4052        );
4053        write_file(
4054            &root.join("src/unrelated.rs"),
4055            r#"pub fn unrelated() -> u32 { 1 }
4056"#,
4057        );
4058    }
4059
4060    fn setup_projection_rename(root: &Path) {
4061        write_file(
4062            &root.join("a.ts"),
4063            r#"export function outer() {
4064  inner();
4065}
4066
4067export function inner() {}
4068"#,
4069        );
4070    }
4071
4072    fn edit_projection_rename(root: &Path) -> Vec<PathBuf> {
4073        let path = root.join("a.ts");
4074        write_file(
4075            &path,
4076            r#"export function outer() {
4077  renamed();
4078}
4079
4080export function renamed() {}
4081"#,
4082        );
4083        vec![path]
4084    }
4085
4086    fn setup_projection_delete(root: &Path) {
4087        write_file(
4088            &root.join("main.ts"),
4089            r#"import { foo } from "./foo";
4090export function main() { foo(); }
4091"#,
4092        );
4093        write_file(&root.join("foo.ts"), "export function foo() {}\n");
4094    }
4095
4096    fn edit_projection_delete(root: &Path) -> Vec<PathBuf> {
4097        let path = root.join("foo.ts");
4098        remove_file(&path);
4099        vec![path]
4100    }
4101
4102    fn setup_projection_barrel(root: &Path) {
4103        write_file(
4104            &root.join("main.ts"),
4105            r#"import { foo } from "./barrel";
4106export function main() { foo(); }
4107"#,
4108        );
4109        write_file(&root.join("barrel.ts"), "export { foo } from \"./foo\";\n");
4110        write_file(&root.join("foo.ts"), "export function foo() {}\n");
4111    }
4112
4113    fn edit_projection_barrel_delete(root: &Path) -> Vec<PathBuf> {
4114        let path = root.join("barrel.ts");
4115        remove_file(&path);
4116        vec![path]
4117    }
4118
4119    fn setup_projection_dispatch(root: &Path) {
4120        write_file(
4121            &root.join("main.ts"),
4122            r#"export function main() {
4123  const service = { render() {}, paint() {} };
4124  service.render();
4125}
4126"#,
4127        );
4128        write_file(&root.join("render.ts"), "export function render() {}\n");
4129        write_file(&root.join("paint.ts"), "export function paint() {}\n");
4130    }
4131
4132    fn edit_projection_dispatch(root: &Path) -> Vec<PathBuf> {
4133        let path = root.join("main.ts");
4134        write_file(
4135            &path,
4136            r#"export function main() {
4137  const service = { render() {}, paint() {} };
4138  service.paint();
4139}
4140"#,
4141        );
4142        vec![path]
4143    }
4144
4145    fn setup_projection_body_only(root: &Path) {
4146        write_file(
4147            &root.join("main.ts"),
4148            r#"import { foo } from "./foo";
4149export function main() { foo(); }
4150"#,
4151        );
4152        write_file(
4153            &root.join("foo.ts"),
4154            r#"export function foo() {
4155  return 1;
4156}
4157"#,
4158        );
4159    }
4160
4161    fn edit_projection_body_only(root: &Path) -> Vec<PathBuf> {
4162        let path = root.join("foo.ts");
4163        write_file(
4164            &path,
4165            r#"export function foo() {
4166  return 2;
4167}
4168"#,
4169        );
4170        vec![path]
4171    }
4172}