Skip to main content

aft/inspect/
manager.rs

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