Skip to main content

aft/inspect/
manager.rs

1use std::collections::{BTreeMap, BTreeSet, HashMap};
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::sync::{Arc, Mutex};
5use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
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::ContributionFreshness;
14use super::job::{
15    normalize_path, CallgraphSnapshot, FileContribution, InspectCategory, InspectJob,
16    InspectResult, InspectScanSuccess, InspectSnapshot, JobKey, JobOutcome, JobScope,
17};
18use super::oxc_engine::LivenessVerdict;
19use super::oxc_engine::{
20    analyze_files_with_cache, AnalyzeOptions, OxcEngineResult, OxcFactsCache, OXC_PROVENANCE,
21};
22use crate::cache_freshness::{self, FileFreshness, FreshnessVerdict};
23use crate::callgraph_store::{project_dead_code_snapshot, CallGraphStore, CallGraphStoreError};
24
25const DEFAULT_SOFT_DEADLINE: Duration = Duration::from_secs(1);
26
27type WaiterTx = Sender<JobOutcome>;
28
29#[derive(Clone)]
30struct Waiter {
31    tx: WaiterTx,
32}
33
34struct CachedContributionFreshness {
35    file_path: PathBuf,
36    freshness: FileFreshness,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Hash)]
40struct InspectCacheIdentity {
41    sqlite_path: PathBuf,
42    project_root: PathBuf,
43}
44
45#[derive(PartialEq, Eq)]
46struct ContributionFingerprint {
47    count: usize,
48    set_hash: String,
49    hash_complete: bool,
50}
51
52#[derive(Debug, Clone)]
53pub struct Tier2RunSubmissionError {
54    pub category: InspectCategory,
55    pub message: String,
56}
57
58#[derive(Debug, Clone, Default)]
59pub struct Tier2RunSubmission {
60    pub queued_categories: Vec<InspectCategory>,
61    pub newly_queued_categories: Vec<InspectCategory>,
62    pub errors: Vec<Tier2RunSubmissionError>,
63}
64
65impl Tier2RunSubmission {
66    pub fn has_new_work(&self) -> bool {
67        !self.newly_queued_categories.is_empty()
68    }
69}
70
71pub struct InspectManager {
72    request_tx: Sender<InspectJob>,
73    result_rx: Receiver<InspectResult>,
74    #[allow(dead_code)]
75    pool: Arc<rayon::ThreadPool>,
76    in_flight: Mutex<HashMap<JobKey, Vec<Waiter>>>,
77    caches: Mutex<HashMap<InspectCacheIdentity, Arc<InspectCache>>>,
78    oxc_facts_cache: Mutex<OxcFactsCache>,
79    soft_deadline: Duration,
80    next_job_id: AtomicU64,
81    /// Monotonic count of Tier-2 completions delivered via the reuse path
82    /// (watcher-driven scheduler runs). These bypass `result_rx`/
83    /// `drain_completions`, so the `&AppContext`-side drain polls this counter
84    /// to know when to refresh the agent status bar after a background scan.
85    reuse_completions: AtomicU64,
86}
87
88impl InspectManager {
89    pub fn new() -> Self {
90        Self::with_worker(default_worker(), DEFAULT_SOFT_DEADLINE)
91    }
92
93    #[doc(hidden)]
94    pub fn with_worker(worker: InspectWorker, soft_deadline: Duration) -> Self {
95        let handles = start_dispatch_loop(worker);
96        Self {
97            request_tx: handles.request_tx,
98            result_rx: handles.result_rx,
99            pool: handles.pool,
100            in_flight: Mutex::new(HashMap::new()),
101            caches: Mutex::new(HashMap::new()),
102            oxc_facts_cache: Mutex::new(OxcFactsCache::new()),
103            soft_deadline,
104            next_job_id: AtomicU64::new(1),
105            reuse_completions: AtomicU64::new(0),
106        }
107    }
108
109    pub fn submit_category(
110        &self,
111        snapshot: InspectSnapshot,
112        category: InspectCategory,
113        caller_scope: JobScope,
114    ) -> JobOutcome {
115        self.submit_category_with_callgraph(snapshot, category, caller_scope, None)
116    }
117
118    pub fn submit_category_with_callgraph(
119        &self,
120        snapshot: InspectSnapshot,
121        category: InspectCategory,
122        caller_scope: JobScope,
123        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
124    ) -> JobOutcome {
125        if !category.is_active() {
126            return JobOutcome::Failed {
127                message: format!("inspect category '{category}' is disabled in v0.33"),
128            };
129        }
130
131        let cache = match self.cache_for_snapshot(&snapshot) {
132            Ok(cache) => cache,
133            Err(message) => return JobOutcome::Failed { message },
134        };
135        let key = JobKey::for_category_scope(category, &caller_scope);
136        let (waiter_tx, waiter_rx) = bounded(1);
137
138        let wait_snapshot = snapshot.clone();
139        match self.enqueue_with_waiter(
140            snapshot,
141            category,
142            caller_scope.clone(),
143            key.clone(),
144            waiter_tx,
145            callgraph_snapshot,
146        ) {
147            Ok(()) => self.wait_for_outcome(key, caller_scope, cache, waiter_rx, wait_snapshot),
148            Err(message) => JobOutcome::Failed { message },
149        }
150    }
151
152    pub fn submit_background(
153        &self,
154        snapshot: InspectSnapshot,
155        category: InspectCategory,
156        caller_scope: JobScope,
157    ) -> Result<JobKey, String> {
158        self.submit_background_with_callgraph(snapshot, category, caller_scope, None)
159    }
160
161    pub fn submit_background_with_callgraph(
162        &self,
163        snapshot: InspectSnapshot,
164        category: InspectCategory,
165        caller_scope: JobScope,
166        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
167    ) -> Result<JobKey, String> {
168        if !category.is_active() {
169            return Err(format!(
170                "inspect category '{category}' is disabled in v0.33"
171            ));
172        }
173        let key = JobKey::for_category_scope(category, &caller_scope);
174        self.enqueue_without_waiter(
175            snapshot,
176            category,
177            caller_scope,
178            key.clone(),
179            callgraph_snapshot,
180        )?;
181        Ok(key)
182    }
183
184    pub fn submit_tier2_run_with_reuse_background(
185        self: &Arc<Self>,
186        snapshot: InspectSnapshot,
187        category: InspectCategory,
188    ) -> Result<JobKey, String> {
189        if !category.is_active() {
190            return Err(format!(
191                "inspect category '{category}' is disabled in v0.33"
192            ));
193        }
194        if !category.is_tier2() {
195            return Err(format!(
196                "inspect category '{category}' is not a Tier 2 category"
197            ));
198        }
199
200        let job = self.tier2_reuse_job(snapshot, category, None);
201        let key = job.key.clone();
202        let mut in_flight = self
203            .in_flight
204            .lock()
205            .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
206        if in_flight.contains_key(&key) {
207            return Ok(key);
208        }
209        in_flight.insert(key.clone(), Vec::new());
210        drop(in_flight);
211
212        let manager = Arc::clone(self);
213        let pool = Arc::clone(&self.pool);
214        pool.spawn(move || {
215            let result = manager.tier2_run_with_reuse_job_result(job);
216            manager.route_tier2_reuse_completion(result);
217        });
218
219        Ok(key)
220    }
221
222    pub fn submit_tier2_run_with_reuse_serial_background(
223        self: &Arc<Self>,
224        snapshot: InspectSnapshot,
225        categories: Vec<InspectCategory>,
226    ) -> Tier2RunSubmission {
227        let mut submission = Tier2RunSubmission::default();
228        let mut requested = Vec::new();
229
230        for category in categories {
231            if !category.is_active() {
232                submission.errors.push(Tier2RunSubmissionError {
233                    category,
234                    message: format!("inspect category '{category}' is disabled in v0.33"),
235                });
236                continue;
237            }
238            if !category.is_tier2() {
239                submission.errors.push(Tier2RunSubmissionError {
240                    category,
241                    message: format!("inspect category '{category}' is not a Tier 2 category"),
242                });
243                continue;
244            }
245            requested.push(category);
246        }
247
248        if requested.is_empty() {
249            return submission;
250        }
251
252        let mut in_flight = match self.in_flight.lock() {
253            Ok(in_flight) => in_flight,
254            Err(_) => {
255                for category in requested {
256                    submission.errors.push(Tier2RunSubmissionError {
257                        category,
258                        message: "inspect in-flight map lock poisoned".to_string(),
259                    });
260                }
261                return submission;
262            }
263        };
264
265        for category in requested {
266            let key = JobKey::for_project_category(category);
267            submission.queued_categories.push(category);
268            if in_flight.contains_key(&key) {
269                continue;
270            }
271            in_flight.insert(key, Vec::new());
272            submission.newly_queued_categories.push(category);
273        }
274        drop(in_flight);
275
276        if submission.newly_queued_categories.is_empty() {
277            return submission;
278        }
279
280        let categories_for_worker = submission.newly_queued_categories.clone();
281        let manager = Arc::clone(self);
282        let pool = Arc::clone(&self.pool);
283        pool.spawn(move || {
284            for category in categories_for_worker {
285                let result = manager.tier2_run_with_reuse_result(snapshot.clone(), category, None);
286                manager.route_tier2_reuse_completion(result);
287            }
288        });
289
290        submission
291    }
292
293    pub fn tier2_any_in_flight(&self) -> bool {
294        self.in_flight
295            .lock()
296            .map(|in_flight| in_flight.keys().any(|key| key.category.is_tier2()))
297            .unwrap_or(false)
298    }
299
300    pub fn drain_completions(&self) -> usize {
301        let mut drained = 0usize;
302        while let Ok(result) = self.result_rx.try_recv() {
303            self.route_completion(result);
304            drained += 1;
305        }
306        drained
307    }
308
309    pub fn cache_for_snapshot(
310        &self,
311        snapshot: &InspectSnapshot,
312    ) -> Result<Arc<InspectCache>, String> {
313        self.cache_for_paths(snapshot.inspect_dir.clone(), snapshot.project_root.clone())
314    }
315
316    /// Latest persisted counts for the three Tier-2 categories, in
317    /// `(dead_code, unused_exports, duplicates)` order. Reads the most recent
318    /// aggregate regardless of contribution-hash freshness (last-known), so the
319    /// agent status bar can refresh after a background scan completes without a
320    /// freshness round-trip. A category with no readable aggregate reports
321    /// `None` (never a fabricated `0`), so the status bar can preserve any
322    /// last-known value and stay suppressed until every category is real (#1).
323    pub fn latest_tier2_counts(
324        &self,
325        inspect_dir: PathBuf,
326        project_root: PathBuf,
327    ) -> (Option<usize>, Option<usize>, Option<usize>) {
328        let Ok(cache) = self.cache_for_paths(inspect_dir, project_root) else {
329            return (None, None, None);
330        };
331        let count_of = |category: InspectCategory| -> Option<usize> {
332            cache
333                .latest_aggregate_any_hash(category)
334                .ok()
335                .flatten()
336                .and_then(|payload| {
337                    if category == InspectCategory::DeadCode
338                        && payload
339                            .get("callgraph_available")
340                            .and_then(serde_json::Value::as_bool)
341                            == Some(false)
342                    {
343                        return None;
344                    }
345                    payload
346                        .get("count")
347                        .and_then(serde_json::Value::as_u64)
348                        .map(|count| count as usize)
349                })
350        };
351        (
352            count_of(InspectCategory::DeadCode),
353            count_of(InspectCategory::UnusedExports),
354            count_of(InspectCategory::Duplicates),
355        )
356    }
357
358    pub fn cache_for_paths(
359        &self,
360        inspect_dir: PathBuf,
361        project_root: PathBuf,
362    ) -> Result<Arc<InspectCache>, String> {
363        let project_key = crate::search_index::project_cache_key(&project_root);
364        let sqlite_path = inspect_dir.join(format!("{project_key}.sqlite"));
365        let identity = InspectCacheIdentity {
366            sqlite_path,
367            project_root: project_root.clone(),
368        };
369        let mut caches = self
370            .caches
371            .lock()
372            .map_err(|_| "inspect manager cache map lock poisoned".to_string())?;
373        if let Some(cache) = caches.get(&identity) {
374            return Ok(Arc::clone(cache));
375        }
376        let cache = Arc::new(
377            InspectCache::open(inspect_dir, project_root)
378                .map_err(|error| format!("failed to open inspect cache: {error}"))?,
379        );
380        caches.insert(identity, Arc::clone(&cache));
381        Ok(cache)
382    }
383
384    fn oxc_result_for_scan(
385        &self,
386        job: &InspectJob,
387        files: &[PathBuf],
388    ) -> Result<Option<OxcEngineResult>, String> {
389        if !category_uses_oxc(job.category) {
390            return Ok(None);
391        }
392        if job.category == InspectCategory::DeadCode && job.callgraph_snapshot.is_none() {
393            return Ok(None);
394        }
395
396        let public_api_entries = super::entry_points::resolve_entry_points(&job.project_root);
397        let entry_points = if job.category == InspectCategory::DeadCode {
398            job.callgraph_snapshot
399                .as_ref()
400                .map(|snapshot| snapshot.entry_points.iter().cloned().collect::<Vec<_>>())
401                .unwrap_or_default()
402        } else {
403            Vec::new()
404        };
405        let options = AnalyzeOptions {
406            entry_points,
407            public_api_files: public_api_entries.public_api_files(),
408            entry_reachability: job.category == InspectCategory::DeadCode,
409        };
410
411        let mut cache = self
412            .oxc_facts_cache
413            .lock()
414            .map_err(|_| "inspect oxc facts cache lock poisoned".to_string())?;
415        analyze_files_with_cache(&job.project_root, files, options, &mut cache)
416            .map(Some)
417            .map_err(|message| format!("oxc analyze failed: {message}"))
418    }
419
420    pub fn tier2_run_with_reuse(
421        &self,
422        snapshot: InspectSnapshot,
423        category: InspectCategory,
424        caller_scope: JobScope,
425        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
426    ) -> JobOutcome {
427        let result =
428            self.tier2_run_with_reuse_result(snapshot.clone(), category, callgraph_snapshot);
429        let outcome = match result.outcome {
430            Ok(success) => JobOutcome::Fresh {
431                payload: success.aggregate,
432            },
433            Err(message) => JobOutcome::Failed { message },
434        };
435        match self.cache_for_snapshot(&snapshot) {
436            Ok(cache) => filter_outcome_for_scope_with_contributions(
437                outcome,
438                &snapshot,
439                category,
440                cache.as_ref(),
441                &caller_scope,
442            ),
443            Err(message) => JobOutcome::Failed { message },
444        }
445    }
446
447    /// Read-only Tier 2 aggregate lookup for `aft_inspect`. Does NOT run any
448    /// scanner — returns the latest cached aggregate if present and verifies
449    /// its contribution freshness so warm cache hits are reported as fresh.
450    /// This is the non-blocking variant intended for the synchronous `inspect`
451    /// command path; Tier 2 scans run via the watcher-driven scheduler or the
452    /// compatibility `aft_inspect_tier2_run` command.
453    pub fn tier2_read_cached(
454        &self,
455        snapshot: InspectSnapshot,
456        category: InspectCategory,
457        caller_scope: JobScope,
458    ) -> JobOutcome {
459        if let Err(outcome) = validate_tier2_read_category(category) {
460            return outcome;
461        }
462        let cache = match self.cache_for_snapshot(&snapshot) {
463            Ok(cache) => cache,
464            Err(message) => return JobOutcome::Failed { message },
465        };
466        self.tier2_read_cached_from_cache(&snapshot, category, &caller_scope, cache.as_ref())
467    }
468
469    pub fn tier2_read_cached_readonly(
470        &self,
471        snapshot: InspectSnapshot,
472        category: InspectCategory,
473        caller_scope: JobScope,
474    ) -> JobOutcome {
475        if let Err(outcome) = validate_tier2_read_category(category) {
476            return outcome;
477        }
478        let key = JobKey::for_project_category(category);
479        let in_flight = self
480            .in_flight
481            .lock()
482            .map(|guard| guard.contains_key(&key))
483            .unwrap_or(false);
484        let cache = match InspectCache::open_readonly(
485            snapshot.inspect_dir.clone(),
486            snapshot.project_root.clone(),
487        ) {
488            Ok(Some(cache)) => cache,
489            Ok(None) => return JobOutcome::Pending { in_flight },
490            Err(error) => {
491                return JobOutcome::Failed {
492                    message: error.to_string(),
493                }
494            }
495        };
496        self.tier2_read_cached_from_cache(&snapshot, category, &caller_scope, &cache)
497    }
498
499    fn tier2_read_cached_from_cache(
500        &self,
501        snapshot: &InspectSnapshot,
502        category: InspectCategory,
503        caller_scope: &JobScope,
504        cache: &InspectCache,
505    ) -> JobOutcome {
506        let key = JobKey::for_project_category(category);
507        let in_flight = self
508            .in_flight
509            .lock()
510            .map(|guard| guard.contains_key(&key))
511            .unwrap_or(false);
512        match cache.get_aggregated(&key) {
513            Ok(Some(payload)) => {
514                match self.tier2_cached_aggregate_is_fresh(snapshot, category, cache) {
515                    Ok(true) => filter_outcome_for_scope_with_contributions(
516                        JobOutcome::Fresh { payload },
517                        snapshot,
518                        category,
519                        cache,
520                        caller_scope,
521                    ),
522                    Ok(false) => filter_outcome_for_scope_with_contributions(
523                        JobOutcome::Stale {
524                            cached: Some(payload),
525                            in_flight,
526                        },
527                        snapshot,
528                        category,
529                        cache,
530                        caller_scope,
531                    ),
532                    Err(message) => JobOutcome::Failed { message },
533                }
534            }
535            Ok(None) => match cache.latest_aggregate_any_hash(category) {
536                Ok(Some(payload)) => filter_outcome_for_scope_with_contributions(
537                    JobOutcome::Stale {
538                        cached: Some(payload),
539                        in_flight,
540                    },
541                    snapshot,
542                    category,
543                    cache,
544                    caller_scope,
545                ),
546                Ok(None) => JobOutcome::Pending { in_flight },
547                Err(error) => JobOutcome::Failed {
548                    message: error.to_string(),
549                },
550            },
551            Err(error) => JobOutcome::Failed {
552                message: error.to_string(),
553            },
554        }
555    }
556
557    fn tier2_cached_aggregate_is_fresh(
558        &self,
559        snapshot: &InspectSnapshot,
560        category: InspectCategory,
561        cache: &InspectCache,
562    ) -> Result<bool, String> {
563        let cached_records = load_contribution_freshness(cache, category)?;
564        let project_scope = JobScope::for_project(snapshot.project_root.clone());
565        let project_files = scope_files(&snapshot.project_root, &project_scope);
566        let current_by_relative = current_project_files(&snapshot.project_root, &project_files);
567        let cached_relative = cached_records
568            .iter()
569            .map(freshness_record_relative_key)
570            .collect::<BTreeSet<_>>();
571
572        for record in &cached_records {
573            let relative = freshness_record_relative_key(record);
574            if !current_by_relative.contains_key(&relative) {
575                return Ok(false);
576            }
577
578            let absolute = if record.file_path.is_absolute() {
579                record.file_path.clone()
580            } else {
581                snapshot.project_root.join(&record.file_path)
582            };
583            match verify_contribution_file_strict(&absolute, &record.freshness) {
584                ContributionFreshness::Fresh { .. } => {}
585                ContributionFreshness::Stale | ContributionFreshness::Deleted => return Ok(false),
586            }
587        }
588
589        Ok(current_by_relative
590            .keys()
591            .all(|relative| cached_relative.contains(relative)))
592    }
593
594    #[doc(hidden)]
595    pub fn tier2_run_with_reuse_result(
596        &self,
597        snapshot: InspectSnapshot,
598        category: InspectCategory,
599        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
600    ) -> InspectResult {
601        let job = self.tier2_reuse_job(snapshot, category, callgraph_snapshot);
602        self.tier2_run_with_reuse_job_result(job)
603    }
604
605    fn tier2_run_with_reuse_job_result(&self, mut job: InspectJob) -> InspectResult {
606        let started = Instant::now();
607        if !job.category.is_active() {
608            let result = InspectResult::failed(
609                &job,
610                format!("inspect category '{}' is disabled in v0.33", job.category),
611                started.elapsed(),
612            );
613            log_tier2_benchmark_category_end(&result);
614            return result;
615        }
616        if !job.category.is_tier2() {
617            let result = InspectResult::failed(
618                &job,
619                format!(
620                    "inspect category '{}' is not a Tier 2 category",
621                    job.category
622                ),
623                started.elapsed(),
624            );
625            log_tier2_benchmark_category_end(&result);
626            return result;
627        }
628
629        let project_scope = JobScope::for_project(job.project_root.clone());
630        job.scope_files = scope_files(&job.project_root, &project_scope);
631        log_tier2_benchmark_category_start(&job);
632        let cache = match self.cache_for_paths(job.inspect_dir.clone(), job.project_root.clone()) {
633            Ok(cache) => cache,
634            Err(message) => {
635                let result = InspectResult::failed(&job, message, started.elapsed());
636                log_tier2_benchmark_category_end(&result);
637                return result;
638            }
639        };
640        if let Ok(Some(success)) = self.tier2_quick_reuse_success(&job, cache.as_ref()) {
641            let result = InspectResult::success(&job, success, started.elapsed());
642            crate::slog_debug!(
643                "perf tier2 category={} reuse=hit ms={}",
644                job.category,
645                started.elapsed().as_millis()
646            );
647            log_tier2_benchmark_category_end(&result);
648            return result;
649        }
650
651        let result = match self.tier2_run_with_reuse_job(&job, &cache) {
652            Ok(success) => InspectResult::success(&job, success, started.elapsed()),
653            Err(message) => InspectResult::failed(&job, message, started.elapsed()),
654        };
655        // Always-on perf line: a full (reuse=miss) scan is the expensive path —
656        // for dead_code it includes store snapshot projection plus the scanner.
657        // ms here lets us attribute background CPU bursts to a specific category from the log.
658        crate::slog_info!(
659            "perf tier2 category={} reuse=miss ms={}",
660            job.category,
661            started.elapsed().as_millis()
662        );
663        log_tier2_benchmark_category_end(&result);
664        result
665    }
666
667    fn tier2_reuse_job(
668        &self,
669        snapshot: InspectSnapshot,
670        category: InspectCategory,
671        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
672    ) -> InspectJob {
673        InspectJob {
674            job_id: self.next_job_id.fetch_add(1, Ordering::Relaxed),
675            key: JobKey::for_project_category(category),
676            category,
677            scope_files: Vec::new(),
678            project_root: snapshot.project_root,
679            inspect_dir: snapshot.inspect_dir,
680            config: snapshot.config,
681            symbol_cache: snapshot.symbol_cache,
682            callgraph_snapshot,
683        }
684    }
685
686    fn tier2_quick_reuse_success(
687        &self,
688        job: &InspectJob,
689        cache: &InspectCache,
690    ) -> Result<Option<InspectScanSuccess>, String> {
691        let Some(aggregate) = cache
692            .get_aggregated(&job.key)
693            .map_err(|error| error.to_string())?
694        else {
695            return Ok(None);
696        };
697        let cached = load_contribution_fingerprint(cache, job.category)?;
698        let current = current_file_fingerprint(&job.project_root, &job.scope_files)?;
699        if !cached.hash_complete || !current.hash_complete || cached != current {
700            return Ok(None);
701        }
702
703        cache
704            .touch_tier2_last_full_run(job.category)
705            .map_err(|error| error.to_string())?;
706        Ok(Some(InspectScanSuccess {
707            scanned_files: Vec::new(),
708            contributions: Vec::new(),
709            aggregate,
710        }))
711    }
712
713    #[allow(clippy::too_many_lines)]
714    fn tier2_run_with_reuse_job(
715        &self,
716        job: &InspectJob,
717        cache: &InspectCache,
718    ) -> Result<InspectScanSuccess, String> {
719        let mut phases = Tier2PhaseTimings::default();
720        let phase_started = Instant::now();
721        let cached_records = load_contribution_freshness(cache, job.category)?;
722        let current_by_relative = current_project_files(&job.project_root, &job.scope_files);
723        let cached_relative = cached_records
724            .iter()
725            .map(freshness_record_relative_key)
726            .collect::<BTreeSet<_>>();
727        #[cfg(debug_assertions)]
728        let cold_cache = cached_relative.is_empty();
729
730        let mut updates = Tier2ContributionUpdates::default();
731        let mut scan_by_relative = BTreeMap::<String, PathBuf>::new();
732        let mut aggregate_job = job.clone();
733
734        for record in cached_records {
735            let relative = freshness_record_relative_key(&record);
736            let relative_path = PathBuf::from(&relative);
737            let Some(current_file) = current_by_relative.get(&relative) else {
738                updates.deletes.push(relative_path);
739                continue;
740            };
741
742            let absolute = job.project_root.join(&record.file_path);
743            match verify_contribution_file_strict(&absolute, &record.freshness) {
744                ContributionFreshness::Fresh {
745                    metadata_changed,
746                    freshness,
747                } => {
748                    if metadata_changed {
749                        updates.metadata_updates.push((relative_path, freshness));
750                    }
751                }
752                ContributionFreshness::Stale => {
753                    updates.deletes.push(relative_path);
754                    scan_by_relative.insert(relative, current_file.clone());
755                }
756                ContributionFreshness::Deleted => {
757                    updates.deletes.push(relative_path);
758                }
759            }
760        }
761
762        for (relative, file) in &current_by_relative {
763            if !cached_relative.contains(relative) {
764                scan_by_relative.insert(relative.clone(), file.clone());
765            }
766        }
767        phases.freshness = phase_started.elapsed();
768
769        let mut scan_files = scan_by_relative.into_values().collect::<Vec<_>>();
770        if !scan_files.is_empty() {
771            if category_uses_oxc(job.category) {
772                scan_files = current_by_relative.values().cloned().collect::<Vec<_>>();
773            }
774            let mut scan_job = job.clone();
775            scan_job.job_id = self.next_job_id.fetch_add(1, Ordering::Relaxed);
776            scan_job.scope_files = scan_files.clone();
777            if scan_job.category == InspectCategory::DeadCode
778                && scan_job.callgraph_snapshot.is_none()
779            {
780                let snapshot_started = Instant::now();
781                scan_job.callgraph_snapshot = build_tier2_callgraph_snapshot(&scan_job);
782                phases.snapshot += snapshot_started.elapsed();
783            }
784            aggregate_job.callgraph_snapshot = scan_job.callgraph_snapshot.clone();
785            #[cfg(debug_assertions)]
786            if cold_cache {
787                std::thread::sleep(Duration::from_millis(10));
788            }
789            let scan_started = Instant::now();
790            let oxc_result = self.oxc_result_for_scan(&scan_job, &scan_job.scope_files)?;
791            let scan_result = run_tier2_scan(&scan_job, oxc_result.as_ref());
792            phases.scan += scan_started.elapsed();
793            phases.scanned_files += scan_files.len();
794            let scan_success = scan_result.outcome.map_err(|message| {
795                format!("{} incremental scan failed: {message}", job.category)
796            })?;
797            updates.upserts.extend(scan_success.contributions);
798        }
799
800        let has_updates = !updates.upserts.is_empty()
801            || !updates.deletes.is_empty()
802            || !updates.metadata_updates.is_empty();
803        if !has_updates {
804            if let Some(aggregate) = cache
805                .get_aggregated(&job.key)
806                .map_err(|error| error.to_string())?
807            {
808                cache
809                    .touch_tier2_last_full_run(job.category)
810                    .map_err(|error| error.to_string())?;
811                phases.log(job.category);
812                return Ok(InspectScanSuccess {
813                    scanned_files: scan_files,
814                    contributions: Vec::new(),
815                    aggregate,
816                });
817            }
818        }
819
820        let db_started = Instant::now();
821        let mut contribution_set_hash = if has_updates {
822            cache
823                .apply_contribution_updates(job.category, updates)
824                .map_err(|error| error.to_string())?
825        } else {
826            cache
827                .contribution_set_hash(job.category)
828                .map_err(|error| error.to_string())?
829        };
830        phases.db = db_started.elapsed();
831
832        if let Some(aggregate) = cache
833            .load_aggregate_if_hash_matches(job.category, &contribution_set_hash)
834            .map_err(|error| error.to_string())?
835        {
836            cache
837                .touch_tier2_last_full_run(job.category)
838                .map_err(|error| error.to_string())?;
839            let contributions = load_contributions(cache, job)?;
840            phases.log(job.category);
841            return Ok(InspectScanSuccess {
842                scanned_files: scan_files,
843                contributions,
844                aggregate,
845            });
846        }
847
848        if category_contributions_depend_on_entry_points(job.category) {
849            // Manifest edits can change entry/public roots without touching any
850            // source file. Dead-code and unused-export file contributions embed
851            // those roots, so an aggregate hash miss for these categories must
852            // refresh every current contribution before rolling up again.
853            let full_scan_files = current_by_relative.into_values().collect::<Vec<_>>();
854            if !full_scan_files.is_empty() {
855                let mut rescan_job = job.clone();
856                rescan_job.job_id = self.next_job_id.fetch_add(1, Ordering::Relaxed);
857                rescan_job.scope_files = full_scan_files.clone();
858                if rescan_job.category == InspectCategory::DeadCode
859                    && rescan_job.callgraph_snapshot.is_none()
860                {
861                    let snapshot_started = Instant::now();
862                    rescan_job.callgraph_snapshot = build_tier2_callgraph_snapshot(&rescan_job);
863                    phases.snapshot += snapshot_started.elapsed();
864                }
865                let scan_started = Instant::now();
866                let oxc_result = self.oxc_result_for_scan(&rescan_job, &rescan_job.scope_files)?;
867                let scan_result = run_tier2_scan(&rescan_job, oxc_result.as_ref());
868                phases.scan += scan_started.elapsed();
869                phases.scanned_files += full_scan_files.len();
870                let scan_success = scan_result.outcome.map_err(|message| {
871                    format!(
872                        "{} full rescan after entry-point cache miss failed: {message}",
873                        job.category
874                    )
875                })?;
876                let rescan_updates = Tier2ContributionUpdates {
877                    upserts: scan_success.contributions,
878                    ..Tier2ContributionUpdates::default()
879                };
880                let db_started = Instant::now();
881                contribution_set_hash = cache
882                    .apply_contribution_updates(job.category, rescan_updates)
883                    .map_err(|error| error.to_string())?;
884                phases.db += db_started.elapsed();
885                aggregate_job.callgraph_snapshot = rescan_job.callgraph_snapshot.clone();
886                scan_files = full_scan_files;
887
888                if let Some(aggregate) = cache
889                    .load_aggregate_if_hash_matches(job.category, &contribution_set_hash)
890                    .map_err(|error| error.to_string())?
891                {
892                    cache
893                        .touch_tier2_last_full_run(job.category)
894                        .map_err(|error| error.to_string())?;
895                    let contributions = load_contributions(cache, job)?;
896                    phases.log(job.category);
897                    return Ok(InspectScanSuccess {
898                        scanned_files: scan_files,
899                        contributions,
900                        aggregate,
901                    });
902                }
903            }
904        }
905
906        if aggregate_job.category == InspectCategory::DeadCode
907            && aggregate_job.callgraph_snapshot.is_none()
908        {
909            let snapshot_started = Instant::now();
910            aggregate_job.callgraph_snapshot = build_tier2_callgraph_snapshot(&aggregate_job);
911            phases.snapshot += snapshot_started.elapsed();
912        }
913        let rollup_started = Instant::now();
914        let contributions = load_contributions(cache, &aggregate_job)?;
915        let aggregate = roll_up_tier2_contributions(&aggregate_job, &contributions);
916        cache
917            .store_tier2_aggregate(job.key.clone(), &contribution_set_hash, aggregate.clone())
918            .map_err(|error| error.to_string())?;
919        phases.rollup = rollup_started.elapsed();
920        phases.log(job.category);
921
922        Ok(InspectScanSuccess {
923            scanned_files: scan_files,
924            contributions,
925            aggregate,
926        })
927    }
928
929    fn enqueue_with_waiter(
930        &self,
931        snapshot: InspectSnapshot,
932        category: InspectCategory,
933        caller_scope: JobScope,
934        key: JobKey,
935        waiter_tx: WaiterTx,
936        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
937    ) -> Result<(), String> {
938        let mut in_flight = self
939            .in_flight
940            .lock()
941            .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
942        if let Some(waiters) = in_flight.get_mut(&key) {
943            waiters.push(Waiter { tx: waiter_tx });
944            return Ok(());
945        }
946
947        in_flight.insert(key.clone(), vec![Waiter { tx: waiter_tx }]);
948        drop(in_flight);
949
950        if let Err(message) = self.enqueue_new_job(
951            snapshot,
952            category,
953            caller_scope,
954            key.clone(),
955            callgraph_snapshot,
956        ) {
957            if let Ok(mut in_flight) = self.in_flight.lock() {
958                in_flight.remove(&key);
959            }
960            return Err(message);
961        }
962        Ok(())
963    }
964
965    fn enqueue_without_waiter(
966        &self,
967        snapshot: InspectSnapshot,
968        category: InspectCategory,
969        caller_scope: JobScope,
970        key: JobKey,
971        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
972    ) -> Result<(), String> {
973        let mut in_flight = self
974            .in_flight
975            .lock()
976            .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
977        if in_flight.contains_key(&key) {
978            return Ok(());
979        }
980        in_flight.insert(key.clone(), Vec::new());
981        drop(in_flight);
982
983        if let Err(message) = self.enqueue_new_job(
984            snapshot,
985            category,
986            caller_scope,
987            key.clone(),
988            callgraph_snapshot,
989        ) {
990            if let Ok(mut in_flight) = self.in_flight.lock() {
991                in_flight.remove(&key);
992            }
993            return Err(message);
994        }
995        Ok(())
996    }
997
998    fn enqueue_new_job(
999        &self,
1000        snapshot: InspectSnapshot,
1001        category: InspectCategory,
1002        caller_scope: JobScope,
1003        key: JobKey,
1004        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
1005    ) -> Result<(), String> {
1006        let scan_scope = if category.is_tier2() {
1007            JobScope::for_project(snapshot.project_root.clone())
1008        } else {
1009            caller_scope
1010        };
1011        let scope_files = scope_files(&snapshot.project_root, &scan_scope);
1012        let job = InspectJob {
1013            job_id: self.next_job_id.fetch_add(1, Ordering::Relaxed),
1014            key,
1015            category,
1016            scope_files,
1017            project_root: snapshot.project_root,
1018            inspect_dir: snapshot.inspect_dir,
1019            config: snapshot.config,
1020            symbol_cache: snapshot.symbol_cache,
1021            callgraph_snapshot,
1022        };
1023        self.request_tx
1024            .send(job)
1025            .map_err(|_| "inspect dispatch loop is unavailable".to_string())
1026    }
1027
1028    fn wait_for_outcome(
1029        &self,
1030        key: JobKey,
1031        caller_scope: JobScope,
1032        cache: Arc<InspectCache>,
1033        waiter_rx: Receiver<JobOutcome>,
1034        snapshot: InspectSnapshot,
1035    ) -> JobOutcome {
1036        let timeout = after(self.soft_deadline);
1037        let result_rx = self.result_rx.clone();
1038        loop {
1039            select! {
1040                recv(waiter_rx) -> outcome => {
1041                    return match outcome {
1042                        Ok(outcome) => filter_outcome_for_scope_with_contributions(
1043                            outcome,
1044                            &snapshot,
1045                            key.category,
1046                            cache.as_ref(),
1047                            &caller_scope,
1048                        ),
1049                        Err(_) => self.timeout_outcome(&key, &caller_scope, &cache, &snapshot),
1050                    };
1051                }
1052                recv(result_rx) -> result => {
1053                    match result {
1054                        Ok(result) => self.route_completion(result),
1055                        Err(_) => return self.timeout_outcome(&key, &caller_scope, &cache, &snapshot),
1056                    }
1057                }
1058                recv(timeout) -> _ => {
1059                    return self.timeout_outcome(&key, &caller_scope, &cache, &snapshot);
1060                }
1061            }
1062        }
1063    }
1064
1065    fn timeout_outcome(
1066        &self,
1067        key: &JobKey,
1068        caller_scope: &JobScope,
1069        cache: &InspectCache,
1070        snapshot: &InspectSnapshot,
1071    ) -> JobOutcome {
1072        match cache.get_aggregated(key) {
1073            Ok(Some(cached)) => filter_outcome_for_scope_with_contributions(
1074                JobOutcome::Stale {
1075                    cached: Some(cached),
1076                    in_flight: true,
1077                },
1078                snapshot,
1079                key.category,
1080                cache,
1081                caller_scope,
1082            ),
1083            Ok(None) => JobOutcome::Pending { in_flight: true },
1084            Err(error) => JobOutcome::Failed {
1085                message: error.to_string(),
1086            },
1087        }
1088    }
1089
1090    fn route_completion(&self, result: InspectResult) {
1091        let outcome = self.completion_outcome(result.clone());
1092        let waiters = self
1093            .in_flight
1094            .lock()
1095            .ok()
1096            .and_then(|mut in_flight| in_flight.remove(&result.key))
1097            .unwrap_or_default();
1098        for waiter in waiters {
1099            let _ = waiter.tx.send(outcome.clone());
1100        }
1101    }
1102
1103    fn route_tier2_reuse_completion(&self, result: InspectResult) {
1104        let outcome = match result.outcome.clone() {
1105            Ok(success) => JobOutcome::Fresh {
1106                payload: success.aggregate,
1107            },
1108            Err(message) => JobOutcome::Failed { message },
1109        };
1110        let waiters = self
1111            .in_flight
1112            .lock()
1113            .ok()
1114            .and_then(|mut in_flight| in_flight.remove(&result.key))
1115            .unwrap_or_default();
1116        for waiter in waiters {
1117            let _ = waiter.tx.send(outcome.clone());
1118        }
1119        // Signal the main-thread drain that a background (watcher-driven) Tier-2
1120        // scan finished so it can refresh the status bar. This path bypasses
1121        // `result_rx`/`drain_completions`, so without this counter the bar's
1122        // counts and `~` marker would only update on a manual `aft_inspect`.
1123        self.reuse_completions.fetch_add(1, Ordering::SeqCst);
1124    }
1125
1126    /// Snapshot the cumulative count of reuse-path (watcher-driven) Tier-2
1127    /// completions. The main-thread drain compares this against its last-seen
1128    /// value to detect background scans that finished since the previous tick.
1129    pub fn reuse_completion_count(&self) -> u64 {
1130        self.reuse_completions.load(Ordering::SeqCst)
1131    }
1132
1133    fn completion_outcome(&self, result: InspectResult) -> JobOutcome {
1134        let cache =
1135            match self.cache_for_paths(result.inspect_dir.clone(), result.project_root.clone()) {
1136                Ok(cache) => cache,
1137                Err(message) => return JobOutcome::Failed { message },
1138            };
1139
1140        match result.outcome {
1141            Ok(success) => {
1142                let store_result = if result.category.is_tier2() {
1143                    cache.store_tier2_result(
1144                        result.key.clone(),
1145                        &success.scanned_files,
1146                        &success.contributions,
1147                        success.aggregate.clone(),
1148                    )
1149                } else {
1150                    cache.store_aggregated(result.key, success.aggregate.clone())
1151                };
1152
1153                match store_result {
1154                    Ok(()) => JobOutcome::Fresh {
1155                        payload: success.aggregate,
1156                    },
1157                    Err(error) => JobOutcome::Failed {
1158                        message: error.to_string(),
1159                    },
1160                }
1161            }
1162            Err(message) => JobOutcome::Failed { message },
1163        }
1164    }
1165}
1166
1167impl Default for InspectManager {
1168    fn default() -> Self {
1169        Self::new()
1170    }
1171}
1172
1173fn validate_tier2_read_category(category: InspectCategory) -> Result<(), JobOutcome> {
1174    if !category.is_active() {
1175        return Err(JobOutcome::Failed {
1176            message: format!("inspect category '{category}' is disabled in v0.33"),
1177        });
1178    }
1179    if !category.is_tier2() {
1180        return Err(JobOutcome::Failed {
1181            message: format!("inspect category '{category}' is not a Tier 2 category"),
1182        });
1183    }
1184    Ok(())
1185}
1186
1187/// Phase-level wall-time attribution for one Tier-2 reuse=miss pass.
1188///
1189/// Exists to self-attribute pathological scans (note #263 class: a 100ms
1190/// unused_exports pass once took 677s under release-gate machine load) without
1191/// needing a lucky live `sample`. Logged as ONE info line per pass, only when
1192/// real work happened (scan/snapshot/rollup), so quiet reuse passes stay silent.
1193#[derive(Default)]
1194struct Tier2PhaseTimings {
1195    /// Freshness verification of cached contributions (file stat + hash reads).
1196    freshness: Duration,
1197    /// Callgraph store snapshot projection (dead_code only).
1198    snapshot: Duration,
1199    /// Scanner compute over files needing (re)scan.
1200    scan: Duration,
1201    /// SQLite contribution upserts/deletes (busy-wait contention shows here).
1202    db: Duration,
1203    /// Aggregate roll-up + store.
1204    rollup: Duration,
1205    scanned_files: usize,
1206}
1207
1208impl Tier2PhaseTimings {
1209    fn log(&self, category: InspectCategory) {
1210        let worked = self.scan + self.snapshot + self.rollup + self.db;
1211        if worked < Duration::from_millis(50) {
1212            return;
1213        }
1214        crate::slog_info!(
1215            "perf tier2 phases category={} freshness={}ms snapshot={}ms scan={}ms({} files) db={}ms rollup={}ms",
1216            category,
1217            self.freshness.as_millis(),
1218            self.snapshot.as_millis(),
1219            self.scan.as_millis(),
1220            self.scanned_files,
1221            self.db.as_millis(),
1222            self.rollup.as_millis()
1223        );
1224    }
1225}
1226
1227fn scope_files(project_root: &Path, scope: &JobScope) -> Vec<PathBuf> {
1228    let mut files = crate::callgraph::walk_project_files(project_root)
1229        .filter(|path| scope.contains(path))
1230        .collect::<Vec<_>>();
1231    files.sort();
1232    files
1233}
1234
1235fn current_project_files(project_root: &Path, files: &[PathBuf]) -> BTreeMap<String, PathBuf> {
1236    files
1237        .iter()
1238        .map(|file| (relative_cache_key(project_root, file), file.clone()))
1239        .collect()
1240}
1241
1242fn tier2_benchmark_logging_enabled() -> bool {
1243    std::env::var_os("AFT_SETTLE_BENCH_LOG").is_some()
1244}
1245
1246fn log_tier2_benchmark_category_start(job: &InspectJob) {
1247    if !tier2_benchmark_logging_enabled() {
1248        return;
1249    }
1250    crate::slog_info!(
1251        "settle bench: tier2_category_start category={} job_id={} files={}",
1252        job.category.as_str(),
1253        job.job_id,
1254        job.scope_files.len()
1255    );
1256}
1257
1258fn log_tier2_benchmark_category_end(result: &InspectResult) {
1259    if !tier2_benchmark_logging_enabled() {
1260        return;
1261    }
1262    match &result.outcome {
1263        Ok(success) => {
1264            let count = success
1265                .aggregate
1266                .get("count")
1267                .and_then(serde_json::Value::as_u64)
1268                .unwrap_or(0);
1269            crate::slog_info!(
1270                "settle bench: tier2_category_end category={} job_id={} status=success total_ms={} scanned_files={} contributions={} count={}",
1271                result.category.as_str(),
1272                result.job_id,
1273                result.duration.as_millis(),
1274                success.scanned_files.len(),
1275                success.contributions.len(),
1276                count
1277            );
1278        }
1279        Err(message) => {
1280            crate::slog_info!(
1281                "settle bench: tier2_category_end category={} job_id={} status=failed total_ms={} error={}",
1282                result.category.as_str(),
1283                result.job_id,
1284                result.duration.as_millis(),
1285                message.replace('\n', " ")
1286            );
1287        }
1288    }
1289}
1290
1291fn build_tier2_callgraph_snapshot(job: &InspectJob) -> Option<Arc<CallgraphSnapshot>> {
1292    let started = Instant::now();
1293    if !job.config.callgraph_store {
1294        crate::slog_info!(
1295            "tier2 dead_code: callgraph store disabled; reporting callgraph_unavailable"
1296        );
1297        return None;
1298    }
1299
1300    let Some(callgraph_dir) = callgraph_store_dir_from_inspect_dir(&job.inspect_dir) else {
1301        crate::slog_info!(
1302            "tier2 dead_code: inspect_dir has no harness parent ({}); reporting callgraph_unavailable",
1303            job.inspect_dir.display()
1304        );
1305        return None;
1306    };
1307
1308    // Tier-2 refresh is skipped before jobs are submitted from worktree
1309    // bridges, so this non-readonly open may repair moved-root metadata (or
1310    // publish a one-time cold rebuild) for the main checkout. Worktree bridges
1311    // keep their read-only posture by using CallGraphStore::open_readonly via
1312    // AppContext and never queueing Tier-2 refresh jobs.
1313    let store = match CallGraphStore::open_ready_repairing(
1314        callgraph_dir.clone(),
1315        job.project_root.clone(),
1316    ) {
1317        Ok(Some(store)) => store,
1318        Ok(None) => {
1319            crate::slog_info!(
1320                "tier2 dead_code: callgraph store unavailable at {} (cold/building/not ready); reporting callgraph_unavailable",
1321                callgraph_dir.display()
1322            );
1323            return None;
1324        }
1325        Err(error) => {
1326            crate::slog_warn!(
1327                "tier2 dead_code: failed to open callgraph store at {}: {}; reporting callgraph_unavailable",
1328                callgraph_dir.display(),
1329                error
1330            );
1331            return None;
1332        }
1333    };
1334
1335    let snapshot = match project_dead_code_snapshot(store.sqlite_path()) {
1336        Ok(snapshot) => snapshot,
1337        Err(CallGraphStoreError::Unavailable(message)) => {
1338            crate::slog_info!(
1339                "tier2 dead_code: callgraph store projection unavailable ({}); reporting callgraph_unavailable",
1340                message
1341            );
1342            return None;
1343        }
1344        Err(error) => {
1345            crate::slog_warn!(
1346                "tier2 dead_code: callgraph store projection failed: {}; reporting callgraph_unavailable",
1347                error
1348            );
1349            return None;
1350        }
1351    };
1352
1353    crate::slog_info!(
1354        "perf tier2_callgraph_snapshot: source=callgraph_store files={} exports={} edges={} entry_points={} ms={}",
1355        snapshot.files.len(),
1356        snapshot.exported_symbols.len(),
1357        snapshot.outbound_calls.len(),
1358        snapshot.entry_points.len(),
1359        started.elapsed().as_millis()
1360    );
1361
1362    Some(Arc::new(snapshot))
1363}
1364
1365fn callgraph_store_dir_from_inspect_dir(inspect_dir: &Path) -> Option<PathBuf> {
1366    inspect_dir
1367        .parent()
1368        .map(|harness_dir| harness_dir.join("callgraph"))
1369}
1370
1371#[cfg(test)]
1372fn canonicalize_for_snapshot(path: &Path) -> PathBuf {
1373    std::fs::canonicalize(path).unwrap_or_else(|_| normalize_path(path))
1374}
1375
1376fn load_contribution_fingerprint(
1377    cache: &InspectCache,
1378    category: InspectCategory,
1379) -> Result<ContributionFingerprint, String> {
1380    let (count, set_hash, hash_complete) = cache
1381        .contribution_fingerprint(category)
1382        .map_err(|error| error.to_string())?;
1383    Ok(ContributionFingerprint {
1384        count,
1385        set_hash,
1386        hash_complete,
1387    })
1388}
1389
1390fn current_file_fingerprint(
1391    project_root: &Path,
1392    files: &[PathBuf],
1393) -> Result<ContributionFingerprint, String> {
1394    let mut entries = Vec::with_capacity(files.len());
1395    let mut hash_complete = true;
1396    for file in files {
1397        let freshness = cache_freshness::collect(file)
1398            .map_err(|error| format!("failed to fingerprint {}: {error}", file.display()))?;
1399        let relative_path = relative_cache_key(project_root, file);
1400        let mtime_ns = system_time_to_ns_i64(freshness.mtime);
1401        if freshness.content_hash == cache_freshness::zero_hash() {
1402            hash_complete = false;
1403        }
1404        entries.push((
1405            relative_path,
1406            mtime_ns,
1407            freshness.size,
1408            freshness.content_hash.to_hex().to_string(),
1409        ));
1410    }
1411    entries.sort_by(|left, right| left.0.cmp(&right.0));
1412
1413    let mut hasher = blake3::Hasher::new();
1414    for (relative_path, mtime_ns, file_size, file_hash) in &entries {
1415        update_contribution_fingerprint_hash(
1416            &mut hasher,
1417            relative_path,
1418            *mtime_ns,
1419            *file_size,
1420            file_hash,
1421        );
1422    }
1423
1424    Ok(ContributionFingerprint {
1425        count: entries.len(),
1426        set_hash: hasher.finalize().to_hex().to_string(),
1427        hash_complete,
1428    })
1429}
1430
1431fn update_contribution_fingerprint_hash(
1432    hasher: &mut blake3::Hasher,
1433    relative_path: &str,
1434    mtime_ns: i64,
1435    file_size: u64,
1436    file_hash: &str,
1437) {
1438    hasher.update(relative_path.as_bytes());
1439    hasher.update(&[0]);
1440    hasher.update(&mtime_ns.to_le_bytes());
1441    hasher.update(&file_size.to_le_bytes());
1442    hasher.update(&[0]);
1443    hasher.update(file_hash.as_bytes());
1444}
1445
1446fn verify_contribution_file_strict(path: &Path, cached: &FileFreshness) -> ContributionFreshness {
1447    match cache_freshness::verify_file_strict(path, cached) {
1448        FreshnessVerdict::HotFresh => ContributionFreshness::Fresh {
1449            metadata_changed: false,
1450            freshness: *cached,
1451        },
1452        FreshnessVerdict::ContentFresh {
1453            new_mtime,
1454            new_size,
1455        } => ContributionFreshness::Fresh {
1456            metadata_changed: true,
1457            freshness: FileFreshness {
1458                mtime: new_mtime,
1459                size: new_size,
1460                content_hash: cached.content_hash,
1461            },
1462        },
1463        FreshnessVerdict::Stale => ContributionFreshness::Stale,
1464        FreshnessVerdict::Deleted => ContributionFreshness::Deleted,
1465    }
1466}
1467
1468fn load_contribution_freshness(
1469    cache: &InspectCache,
1470    category: InspectCategory,
1471) -> Result<Vec<CachedContributionFreshness>, String> {
1472    cache
1473        .contribution_freshness(category)
1474        .map_err(|error| error.to_string())
1475        .map(|records| {
1476            records
1477                .into_iter()
1478                .map(|(file_path, freshness)| CachedContributionFreshness {
1479                    file_path,
1480                    freshness,
1481                })
1482                .collect()
1483        })
1484}
1485
1486fn freshness_record_relative_key(record: &CachedContributionFreshness) -> String {
1487    record.file_path.to_string_lossy().to_string()
1488}
1489
1490fn system_time_to_ns_i64(time: SystemTime) -> i64 {
1491    let nanos = time
1492        .duration_since(UNIX_EPOCH)
1493        .unwrap_or_else(|_| Duration::from_secs(0))
1494        .as_nanos();
1495    nanos.min(i64::MAX as u128) as i64
1496}
1497
1498fn relative_cache_key(project_root: &Path, path: &Path) -> String {
1499    path.strip_prefix(project_root)
1500        .unwrap_or(path)
1501        .to_string_lossy()
1502        .to_string()
1503}
1504
1505fn load_contributions(
1506    cache: &InspectCache,
1507    job: &InspectJob,
1508) -> Result<Vec<FileContribution>, String> {
1509    cache
1510        .load_tier2_contributions(job.category)
1511        .map_err(|error| error.to_string())
1512        .map(|records| {
1513            records
1514                .into_iter()
1515                .map(|record| contribution_from_record(&job.project_root, record))
1516                .collect()
1517        })
1518}
1519
1520fn contribution_from_record(
1521    project_root: &Path,
1522    record: super::cache::ContributionRecord,
1523) -> FileContribution {
1524    FileContribution::new(
1525        record.category,
1526        project_root.join(record.file_path),
1527        record.freshness,
1528        record.contribution,
1529    )
1530    .with_type_ref_names(record.type_ref_names)
1531}
1532
1533fn run_tier2_scan(job: &InspectJob, oxc_result: Option<&OxcEngineResult>) -> InspectResult {
1534    use super::scanners;
1535
1536    match job.category {
1537        InspectCategory::DeadCode => {
1538            scanners::dead_code::run_dead_code_scan_with_oxc(job, oxc_result)
1539        }
1540        InspectCategory::UnusedExports => {
1541            scanners::unused_exports::run_unused_exports_scan_with_oxc(job, oxc_result)
1542        }
1543        InspectCategory::Duplicates => scanners::duplicates::run_duplicates_scan(job),
1544        other => InspectResult::failed(
1545            job,
1546            format!("inspect category '{other}' is not an active Tier 2 scanner"),
1547            Duration::from_secs(0),
1548        ),
1549    }
1550}
1551
1552fn roll_up_tier2_contributions(job: &InspectJob, contributions: &[FileContribution]) -> Value {
1553    roll_up_tier2_contributions_with_limit(job, contributions, Some(MAX_DRILL_DOWN_ITEMS))
1554}
1555
1556fn roll_up_tier2_contributions_with_limit(
1557    job: &InspectJob,
1558    contributions: &[FileContribution],
1559    drill_down_limit: Option<usize>,
1560) -> Value {
1561    match job.category {
1562        InspectCategory::DeadCode => {
1563            roll_up_dead_code_contributions(job, contributions, drill_down_limit)
1564        }
1565        InspectCategory::UnusedExports => {
1566            roll_up_unused_exports_contributions(job, contributions, drill_down_limit)
1567        }
1568        InspectCategory::Duplicates => {
1569            roll_up_duplicate_contributions(job, contributions, drill_down_limit)
1570        }
1571        _ => json!({
1572            "count": 0,
1573            "items": [],
1574            "scanned_files": contributions.len(),
1575        }),
1576    }
1577}
1578
1579fn scoped_tier2_payload_from_contributions(
1580    snapshot: &InspectSnapshot,
1581    category: InspectCategory,
1582    cache: &InspectCache,
1583    project_payload: Value,
1584    scope: &JobScope,
1585) -> Result<Value, String> {
1586    if scope.is_project_wide() {
1587        return Ok(project_payload);
1588    }
1589
1590    let project_scope = JobScope::for_project(snapshot.project_root.clone());
1591    let rollup_job = scoped_tier2_rollup_job(snapshot, category, &project_scope);
1592    let contributions = load_contributions(cache, &rollup_job)?;
1593    let full_payload = roll_up_tier2_contributions_with_limit(&rollup_job, &contributions, None);
1594    let scoped_payload = filter_payload_for_scope(full_payload, scope);
1595    Ok(cap_payload_drill_down(scoped_payload, MAX_DRILL_DOWN_ITEMS))
1596}
1597
1598fn scoped_tier2_rollup_job(
1599    snapshot: &InspectSnapshot,
1600    category: InspectCategory,
1601    scope: &JobScope,
1602) -> InspectJob {
1603    InspectJob {
1604        job_id: 0,
1605        key: JobKey::for_project_category(category),
1606        category,
1607        scope_files: scope_files(&snapshot.project_root, scope),
1608        project_root: snapshot.project_root.clone(),
1609        inspect_dir: snapshot.inspect_dir.clone(),
1610        config: Arc::clone(&snapshot.config),
1611        symbol_cache: Arc::clone(&snapshot.symbol_cache),
1612        callgraph_snapshot: (category == InspectCategory::DeadCode)
1613            .then(|| Arc::new(CallgraphSnapshot::default())),
1614    }
1615}
1616
1617fn roll_up_dead_code_contributions(
1618    job: &InspectJob,
1619    contributions: &[FileContribution],
1620    drill_down_limit: Option<usize>,
1621) -> Value {
1622    if job.callgraph_snapshot.is_none() {
1623        return super::scanners::dead_code::callgraph_unavailable_aggregate(job.scope_files.len());
1624    }
1625
1626    let public_api_files = super::scanners::dead_code::collect_public_api_files(&job.project_root);
1627    let roles = super::entry_points::resolve_project_roles(&job.project_root);
1628    super::scanners::dead_code::aggregate_dead_code_contributions_with_limit(
1629        contributions,
1630        &public_api_files,
1631        &roles,
1632        drill_down_limit,
1633    )
1634}
1635
1636fn roll_up_unused_exports_contributions(
1637    job: &InspectJob,
1638    contributions: &[FileContribution],
1639    drill_down_limit: Option<usize>,
1640) -> Value {
1641    let parsed = contributions
1642        .iter()
1643        .filter_map(|contribution| {
1644            serde_json::from_value::<UnusedExportsContribution>(contribution.contribution.clone())
1645                .ok()
1646        })
1647        .collect::<Vec<_>>();
1648
1649    let (public_api_entries, package_warnings) = unused_public_api_entries(&job.project_root);
1650    let mut imported_by: BTreeMap<(String, String), BTreeSet<String>> = BTreeMap::new();
1651    let mut uncertain_by: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
1652    for scan in &parsed {
1653        for import in &scan.imports {
1654            let Some(resolved_file) = &import.resolved_file else {
1655                continue;
1656            };
1657            for name in &import.named {
1658                if name == "*" {
1659                    uncertain_by
1660                        .entry(resolved_file.clone())
1661                        .or_default()
1662                        .insert(scan.file.clone());
1663                } else {
1664                    imported_by
1665                        .entry((resolved_file.clone(), name.clone()))
1666                        .or_default()
1667                        .insert(scan.file.clone());
1668                }
1669            }
1670        }
1671    }
1672
1673    let mut count = 0usize;
1674    let mut items = Vec::new();
1675    let mut uncertain_count = 0usize;
1676    let mut uncertain_items = Vec::new();
1677    for scan in &parsed {
1678        if public_api_entries.contains(&scan.file) {
1679            continue;
1680        }
1681        // Mirror the fresh-scan path: fixtures/corpora/mock data are consumed
1682        // by path, never imported, so their exports always look unused.
1683        if super::job::is_test_support_file(&scan.file) {
1684            continue;
1685        }
1686
1687        for export in &scan.exports {
1688            if export_uses_oxc(export) {
1689                match export.verdict.unwrap_or(LivenessVerdict::Unused) {
1690                    LivenessVerdict::Used => continue,
1691                    LivenessVerdict::Uncertain => {
1692                        uncertain_count += 1;
1693                        if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
1694                            uncertain_items.push(json!({
1695                                "file": scan.file,
1696                                "symbol": export.symbol,
1697                                "kind": export.kind,
1698                                "line": export.line,
1699                                "reason": export.reason.as_deref().unwrap_or("oxc_uncertain"),
1700                                "provenance": export.provenance.as_deref().unwrap_or(OXC_PROVENANCE),
1701                            }));
1702                        }
1703                        continue;
1704                    }
1705                    LivenessVerdict::Unused => {}
1706                }
1707            } else {
1708                let imported = imported_by
1709                    .get(&(scan.file.clone(), export.symbol.clone()))
1710                    .map(|files| !files.is_empty())
1711                    .unwrap_or(false);
1712                let uncertain = uncertain_by
1713                    .get(&scan.file)
1714                    .map(|files| !files.is_empty())
1715                    .unwrap_or(false);
1716
1717                if imported {
1718                    continue;
1719                }
1720                if uncertain {
1721                    uncertain_count += 1;
1722                    if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
1723                        uncertain_items.push(json!({
1724                            "file": scan.file,
1725                            "symbol": export.symbol,
1726                            "kind": export.kind,
1727                            "line": export.line,
1728                            "reason": "wildcard_import",
1729                        }));
1730                    }
1731                    continue;
1732                }
1733            }
1734
1735            count += 1;
1736            // Collect uncapped; rank by signal tier and truncate below.
1737            let mut item = json!({
1738                "file": scan.file,
1739                "symbol": export.symbol,
1740                "kind": export.kind,
1741                "line": export.line,
1742            });
1743            if let Some(provenance) = &export.provenance {
1744                item["provenance"] = json!(provenance);
1745            }
1746            items.push(item);
1747        }
1748    }
1749
1750    let roles = super::entry_points::resolve_project_roles(&job.project_root);
1751    let items = super::entry_points::rank_and_truncate_items(items, &roles, drill_down_limit);
1752    let top = super::entry_points::top_preview_symbols(&items);
1753
1754    let (parse_errors, skipped_files) = unused_exports_honesty_fields(&parsed);
1755    let mut aggregate = json!({
1756        "count": count,
1757        "items": items,
1758        "top": top,
1759        "drill_down_capped": drill_down_limit.is_some_and(|limit| count > limit),
1760        "scanned_files": parsed.len(),
1761        "languages_skipped": skipped_languages(&job.scope_files, LanguageSkipMode::UnusedExports),
1762        "uncertain_count": uncertain_count,
1763        "uncertain_items": uncertain_items,
1764        "complete": parse_errors.is_empty() && skipped_files.is_empty(),
1765    });
1766    if !parse_errors.is_empty() {
1767        aggregate["parse_errors"] = Value::Array(parse_errors);
1768    }
1769    if !skipped_files.is_empty() {
1770        aggregate["skipped_files"] = Value::Array(skipped_files);
1771    }
1772    if !package_warnings.is_empty() {
1773        aggregate["note"] = Value::String(package_warnings.join("; "));
1774    }
1775    aggregate
1776}
1777
1778fn unused_exports_honesty_fields(parsed: &[UnusedExportsContribution]) -> (Vec<Value>, Vec<Value>) {
1779    let mut parse_error_keys = BTreeSet::new();
1780    let mut parse_errors = Vec::new();
1781    let mut skipped_file_keys = BTreeSet::new();
1782    let mut skipped_files = Vec::new();
1783    for contribution in parsed {
1784        for value in &contribution.parse_errors {
1785            let key = value.to_string();
1786            if parse_error_keys.insert(key) {
1787                parse_errors.push(value.clone());
1788            }
1789        }
1790        for value in &contribution.skipped_files {
1791            let key = value.to_string();
1792            if skipped_file_keys.insert(key) {
1793                skipped_files.push(value.clone());
1794            }
1795        }
1796    }
1797    (parse_errors, skipped_files)
1798}
1799
1800fn roll_up_duplicate_contributions(
1801    job: &InspectJob,
1802    contributions: &[FileContribution],
1803    drill_down_limit: Option<usize>,
1804) -> Value {
1805    super::scanners::duplicates::aggregate_duplicate_contributions_with_limit(
1806        contributions,
1807        skipped_languages(&job.scope_files, LanguageSkipMode::Duplicates),
1808        drill_down_limit,
1809    )
1810}
1811
1812fn cap_payload_drill_down(mut payload: Value, limit: usize) -> Value {
1813    let mut capped = false;
1814    if let Some(items) = payload.get_mut("items").and_then(Value::as_array_mut) {
1815        capped |= items.len() > limit;
1816        items.truncate(limit);
1817    }
1818    if let Some(groups) = payload.get_mut("groups").and_then(Value::as_array_mut) {
1819        capped |= groups.len() > limit;
1820        groups.truncate(limit);
1821    }
1822    if let Some(object) = payload.as_object_mut() {
1823        object.insert("drill_down_capped".to_string(), json!(capped));
1824    }
1825    payload
1826}
1827
1828const MAX_DRILL_DOWN_ITEMS: usize = 100;
1829
1830#[derive(Debug, Clone, Deserialize)]
1831struct ExportContribution {
1832    symbol: String,
1833    kind: String,
1834    line: u32,
1835    #[serde(default)]
1836    verdict: Option<LivenessVerdict>,
1837    #[serde(default)]
1838    reason: Option<String>,
1839    #[serde(default)]
1840    provenance: Option<String>,
1841}
1842
1843fn export_uses_oxc(export: &ExportContribution) -> bool {
1844    export.verdict.is_some() || export.provenance.as_deref() == Some(OXC_PROVENANCE)
1845}
1846
1847#[derive(Debug, Clone, Deserialize)]
1848struct UnusedExportsContribution {
1849    file: String,
1850    exports: Vec<ExportContribution>,
1851    #[serde(default)]
1852    imports: Vec<ImportContribution>,
1853    #[serde(default)]
1854    parse_errors: Vec<Value>,
1855    #[serde(default)]
1856    skipped_files: Vec<Value>,
1857}
1858
1859#[derive(Debug, Clone, Deserialize)]
1860struct ImportContribution {
1861    resolved_file: Option<String>,
1862    named: Vec<String>,
1863}
1864
1865#[derive(Debug, Clone, Copy)]
1866enum LanguageSkipMode {
1867    Duplicates,
1868    UnusedExports,
1869}
1870
1871fn category_uses_oxc(category: InspectCategory) -> bool {
1872    matches!(
1873        category,
1874        InspectCategory::DeadCode | InspectCategory::UnusedExports
1875    )
1876}
1877
1878fn category_contributions_depend_on_entry_points(category: InspectCategory) -> bool {
1879    matches!(
1880        category,
1881        InspectCategory::DeadCode | InspectCategory::UnusedExports
1882    )
1883}
1884
1885fn skipped_languages(files: &[PathBuf], mode: LanguageSkipMode) -> Vec<String> {
1886    files
1887        .iter()
1888        .filter_map(|file| skipped_language(file, mode))
1889        .collect::<BTreeSet<_>>()
1890        .into_iter()
1891        .collect()
1892}
1893
1894fn skipped_language(file: &Path, mode: LanguageSkipMode) -> Option<String> {
1895    let Some(language) = crate::parser::detect_language(file) else {
1896        return match mode {
1897            LanguageSkipMode::Duplicates => Some("unknown".to_string()),
1898            LanguageSkipMode::UnusedExports => None,
1899        };
1900    };
1901
1902    let skipped = match mode {
1903        LanguageSkipMode::Duplicates => !duplicates_supports_language(language),
1904        LanguageSkipMode::UnusedExports => !is_js_ts_language(language),
1905    };
1906    skipped.then(|| language_name(language).to_string())
1907}
1908
1909fn duplicates_supports_language(language: crate::parser::LangId) -> bool {
1910    !matches!(
1911        language,
1912        crate::parser::LangId::Bash
1913            | crate::parser::LangId::Html
1914            | crate::parser::LangId::Json
1915            | crate::parser::LangId::Scala
1916            | crate::parser::LangId::Solidity
1917            | crate::parser::LangId::Scss
1918            | crate::parser::LangId::Vue
1919            | crate::parser::LangId::Markdown
1920            | crate::parser::LangId::Java
1921            | crate::parser::LangId::Ruby
1922            | crate::parser::LangId::Kotlin
1923            | crate::parser::LangId::Swift
1924            | crate::parser::LangId::Php
1925            | crate::parser::LangId::Lua
1926            | crate::parser::LangId::Perl
1927            | crate::parser::LangId::Pascal
1928    )
1929}
1930
1931fn is_js_ts_language(language: crate::parser::LangId) -> bool {
1932    matches!(
1933        language,
1934        crate::parser::LangId::TypeScript
1935            | crate::parser::LangId::Tsx
1936            | crate::parser::LangId::JavaScript
1937    )
1938}
1939
1940fn language_name(language: crate::parser::LangId) -> &'static str {
1941    match language {
1942        crate::parser::LangId::TypeScript => "typescript",
1943        crate::parser::LangId::Tsx => "tsx",
1944        crate::parser::LangId::JavaScript => "javascript",
1945        crate::parser::LangId::Python => "python",
1946        crate::parser::LangId::Rust => "rust",
1947        crate::parser::LangId::Go => "go",
1948        crate::parser::LangId::C => "c",
1949        crate::parser::LangId::Cpp => "cpp",
1950        crate::parser::LangId::Zig => "zig",
1951        crate::parser::LangId::CSharp => "csharp",
1952        crate::parser::LangId::Bash => "bash",
1953        crate::parser::LangId::Html => "html",
1954        crate::parser::LangId::Markdown => "markdown",
1955        crate::parser::LangId::Yaml => "yaml",
1956        crate::parser::LangId::Solidity => "solidity",
1957        crate::parser::LangId::Scss => "scss",
1958        crate::parser::LangId::Vue => "vue",
1959        crate::parser::LangId::Json => "json",
1960        crate::parser::LangId::Scala => "scala",
1961        crate::parser::LangId::Java => "java",
1962        crate::parser::LangId::Ruby => "ruby",
1963        crate::parser::LangId::Kotlin => "kotlin",
1964        crate::parser::LangId::Swift => "swift",
1965        crate::parser::LangId::Php => "php",
1966        crate::parser::LangId::Lua => "lua",
1967        crate::parser::LangId::Perl => "perl",
1968        crate::parser::LangId::Pascal => "pascal",
1969    }
1970}
1971
1972fn unused_public_api_entries(project_root: &Path) -> (BTreeSet<String>, Vec<String>) {
1973    let entry_points = super::entry_points::resolve_entry_points(project_root);
1974    (
1975        entry_points.public_api_files_relative(project_root),
1976        entry_points.warnings().to_vec(),
1977    )
1978}
1979
1980fn filter_outcome_for_scope_with_contributions(
1981    outcome: JobOutcome,
1982    snapshot: &InspectSnapshot,
1983    category: InspectCategory,
1984    cache: &InspectCache,
1985    scope: &JobScope,
1986) -> JobOutcome {
1987    if !category.is_tier2() || scope.is_project_wide() {
1988        return filter_outcome_for_scope(outcome, scope);
1989    }
1990
1991    match outcome {
1992        JobOutcome::Fresh { payload } => {
1993            match scoped_tier2_payload_from_contributions(snapshot, category, cache, payload, scope)
1994            {
1995                Ok(payload) => JobOutcome::Fresh { payload },
1996                Err(message) => JobOutcome::Failed { message },
1997            }
1998        }
1999        JobOutcome::Stale { cached, in_flight } => match cached {
2000            Some(payload) => {
2001                match scoped_tier2_payload_from_contributions(
2002                    snapshot, category, cache, payload, scope,
2003                ) {
2004                    Ok(payload) => JobOutcome::Stale {
2005                        cached: Some(payload),
2006                        in_flight,
2007                    },
2008                    Err(message) => JobOutcome::Failed { message },
2009                }
2010            }
2011            None => JobOutcome::Stale {
2012                cached: None,
2013                in_flight,
2014            },
2015        },
2016        JobOutcome::Pending { in_flight } => JobOutcome::Pending { in_flight },
2017        JobOutcome::Failed { message } => JobOutcome::Failed { message },
2018    }
2019}
2020
2021fn filter_outcome_for_scope(outcome: JobOutcome, scope: &JobScope) -> JobOutcome {
2022    match outcome {
2023        JobOutcome::Fresh { payload } => JobOutcome::Fresh {
2024            payload: filter_payload_for_scope(payload, scope),
2025        },
2026        JobOutcome::Stale { cached, in_flight } => JobOutcome::Stale {
2027            cached: cached.map(|payload| filter_payload_for_scope(payload, scope)),
2028            in_flight,
2029        },
2030        JobOutcome::Pending { in_flight } => JobOutcome::Pending { in_flight },
2031        JobOutcome::Failed { message } => JobOutcome::Failed { message },
2032    }
2033}
2034
2035fn filter_payload_for_scope(mut payload: serde_json::Value, scope: &JobScope) -> serde_json::Value {
2036    if scope.is_project_wide() {
2037        return payload;
2038    }
2039
2040    // Scoped Tier 2 callers pass an uncapped rollup into this filter and cap
2041    // drill-down only afterwards, so the recomputed count below remains the
2042    // true in-scope total rather than the size of a capped sample.
2043    if let Some(items) = payload
2044        .get_mut("items")
2045        .and_then(|value| value.as_array_mut())
2046    {
2047        let count = filter_values_for_scope(items, scope);
2048        if let Some(object) = payload.as_object_mut() {
2049            object.insert("count".to_string(), serde_json::json!(count));
2050            if object.contains_key("total_groups") {
2051                object.insert("total_groups".to_string(), serde_json::json!(count));
2052            }
2053            if object.contains_key("groups_count") {
2054                object.insert("groups_count".to_string(), serde_json::json!(count));
2055            }
2056        }
2057    }
2058
2059    if let Some(groups) = payload
2060        .get_mut("groups")
2061        .and_then(|value| value.as_array_mut())
2062    {
2063        let count = filter_values_for_scope(groups, scope);
2064        if let Some(object) = payload.as_object_mut() {
2065            object.insert("count".to_string(), serde_json::json!(count));
2066            object.insert("total_groups".to_string(), serde_json::json!(count));
2067            if object.contains_key("groups_count") {
2068                object.insert("groups_count".to_string(), serde_json::json!(count));
2069            }
2070        }
2071    }
2072
2073    // `by_language` is a project-wide breakdown computed before scope filtering.
2074    // Leaving it in a scoped payload contradicts the recomputed in-scope `count`
2075    // (e.g. count: 3 alongside `(rust 214, ts 143)`). The filtered items don't
2076    // carry per-item language, so we can't faithfully recompute it — drop it so
2077    // the scoped summary doesn't render a misleading project-wide breakdown.
2078    if let Some(object) = payload.as_object_mut() {
2079        object.remove("by_language");
2080    }
2081
2082    payload
2083}
2084
2085fn filter_values_for_scope(values: &mut Vec<serde_json::Value>, scope: &JobScope) -> usize {
2086    values.retain_mut(|value| prune_value_for_scope(value, scope));
2087    values.len()
2088}
2089
2090fn prune_value_for_scope(value: &mut serde_json::Value, scope: &JobScope) -> bool {
2091    if let Some(file) = value.get("file").and_then(|file| file.as_str()) {
2092        return scope.contains_display_path(file);
2093    }
2094
2095    let first_scoped_occurrence = if let Some(files) = value
2096        .get_mut("files")
2097        .and_then(|files| files.as_array_mut())
2098    {
2099        files.retain(|file| {
2100            file.as_str()
2101                .is_some_and(|file| scope.contains_display_path(display_file_from_occurrence(file)))
2102        });
2103        if files.len() < 2 {
2104            return false;
2105        }
2106        files.first().and_then(Value::as_str).map(str::to_string)
2107    } else {
2108        None
2109    };
2110
2111    if let Some(occurrence) = first_scoped_occurrence {
2112        update_duplicate_group_sample(value, &occurrence);
2113    }
2114
2115    true
2116}
2117
2118fn update_duplicate_group_sample(value: &mut serde_json::Value, occurrence: &str) {
2119    let Some((file, start_line, end_line)) = parse_duplicate_occurrence(occurrence) else {
2120        return;
2121    };
2122    let Some(object) = value.as_object_mut() else {
2123        return;
2124    };
2125
2126    if object.contains_key("sample_file") {
2127        object.insert("sample_file".to_string(), json!(file));
2128    }
2129    if object.contains_key("sample_start_line") {
2130        object.insert("sample_start_line".to_string(), json!(start_line));
2131    }
2132    if object.contains_key("sample_end_line") {
2133        object.insert("sample_end_line".to_string(), json!(end_line));
2134    }
2135}
2136
2137fn parse_duplicate_occurrence(value: &str) -> Option<(&str, u64, u64)> {
2138    let (file, range) = value.rsplit_once(':')?;
2139    let (start, end) = range.split_once('-')?;
2140    if !start.chars().all(|char| char.is_ascii_digit())
2141        || !end.chars().all(|char| char.is_ascii_digit())
2142    {
2143        return None;
2144    }
2145
2146    Some((file, start.parse().ok()?, end.parse().ok()?))
2147}
2148
2149fn display_file_from_occurrence(value: &str) -> &str {
2150    let Some((file, range)) = value.rsplit_once(':') else {
2151        return value;
2152    };
2153    let Some((start, end)) = range.split_once('-') else {
2154        return value;
2155    };
2156    if start.chars().all(|char| char.is_ascii_digit())
2157        && end.chars().all(|char| char.is_ascii_digit())
2158    {
2159        file
2160    } else {
2161        value
2162    }
2163}
2164
2165#[allow(dead_code)]
2166fn normalize_scope_root(path: &Path) -> PathBuf {
2167    std::fs::canonicalize(path).unwrap_or_else(|_| normalize_path(path))
2168}
2169
2170#[cfg(test)]
2171mod guard_tests {
2172    use super::*;
2173
2174    fn write_ts_project(file_count: usize) -> tempfile::TempDir {
2175        let dir = tempfile::tempdir().expect("tempdir");
2176        let root = dir.path();
2177        for i in 0..file_count {
2178            std::fs::write(
2179                root.join(format!("mod{i}.ts")),
2180                format!("export function f{i}() {{ return {i}; }}\n"),
2181            )
2182            .expect("write fixture");
2183        }
2184        dir
2185    }
2186
2187    #[test]
2188    fn cache_for_paths_rebinds_same_project_key_to_current_root() {
2189        let dir = tempfile::tempdir().expect("tempdir");
2190        let source = dir.path().join("source");
2191        std::fs::create_dir_all(&source).expect("create source repo");
2192        std::fs::write(
2193            source.join("package.json"),
2194            r#"{"name":"inspect-cache-fixture","version":"1.0.0"}"#,
2195        )
2196        .expect("write source manifest");
2197        std::fs::write(source.join("index.ts"), "export const source = 1;\n")
2198            .expect("write source file");
2199        assert!(std::process::Command::new("git")
2200            .current_dir(&source)
2201            .arg("init")
2202            .status()
2203            .expect("git init source repo")
2204            .success());
2205        assert!(std::process::Command::new("git")
2206            .current_dir(&source)
2207            .args(["add", "."])
2208            .status()
2209            .expect("git add source repo")
2210            .success());
2211        assert!(std::process::Command::new("git")
2212            .current_dir(&source)
2213            .args([
2214                "-c",
2215                "user.name=AFT Tests",
2216                "-c",
2217                "user.email=aft-tests@example.com",
2218                "commit",
2219                "-m",
2220                "initial",
2221            ])
2222            .status()
2223            .expect("git commit source repo")
2224            .success());
2225
2226        let clone = dir.path().join("clone");
2227        assert!(std::process::Command::new("git")
2228            .args(["clone", "--quiet"])
2229            .arg(&source)
2230            .arg(&clone)
2231            .status()
2232            .expect("git clone source repo")
2233            .success());
2234        std::fs::write(
2235            clone.join("package.json"),
2236            r#"{"name":"inspect-cache-fixture","version":"2.0.0"}"#,
2237        )
2238        .expect("write clone manifest edit");
2239        assert_eq!(
2240            crate::search_index::project_cache_key(&source),
2241            crate::search_index::project_cache_key(&clone),
2242            "clones with the same root commit should share the sqlite project key"
2243        );
2244
2245        let source = std::fs::canonicalize(source).expect("canonical source root");
2246        let clone = std::fs::canonicalize(clone).expect("canonical clone root");
2247        let manager = InspectManager::new();
2248        let inspect_dir = dir.path().join("inspect");
2249        let key = JobKey::for_project_category(InspectCategory::DeadCode);
2250        let source_cache = manager
2251            .cache_for_paths(inspect_dir.clone(), source.clone())
2252            .expect("open source cache");
2253        let source_hash = source_cache
2254            .contribution_set_hash(InspectCategory::DeadCode)
2255            .expect("source contribution hash");
2256        source_cache
2257            .store_tier2_aggregate(
2258                key.clone(),
2259                &source_hash,
2260                serde_json::json!({ "count": 7, "items": [] }),
2261            )
2262            .expect("store source aggregate");
2263        assert_eq!(
2264            source_cache
2265                .get_aggregated(&key)
2266                .expect("read source aggregate")
2267                .and_then(|payload| payload.get("count").and_then(Value::as_u64)),
2268            Some(7)
2269        );
2270
2271        let clone_cache = manager
2272            .cache_for_paths(inspect_dir, clone.clone())
2273            .expect("open clone cache");
2274        assert_eq!(clone_cache.project_root(), clone.as_path());
2275        assert!(
2276            clone_cache
2277                .get_aggregated(&key)
2278                .expect("read clone aggregate")
2279                .is_none(),
2280            "same-key clone with a different manifest must not reuse the source root's cached count"
2281        );
2282    }
2283
2284    fn snapshot_job(root: &Path, inspect_dir: &Path, callgraph_store: bool) -> InspectJob {
2285        use crate::config::Config;
2286        use crate::parser::SymbolCache;
2287        use std::sync::RwLock;
2288
2289        InspectJob {
2290            job_id: 1,
2291            key: JobKey::for_project_category(InspectCategory::DeadCode),
2292            category: InspectCategory::DeadCode,
2293            scope_files: Vec::new(),
2294            project_root: root.to_path_buf(),
2295            inspect_dir: inspect_dir.to_path_buf(),
2296            config: Arc::new(Config {
2297                project_root: Some(root.to_path_buf()),
2298                callgraph_store,
2299                ..Config::default()
2300            }),
2301            symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
2302            callgraph_snapshot: None,
2303        }
2304    }
2305
2306    #[test]
2307    fn callgraph_snapshot_reports_unavailable_when_store_disabled() {
2308        let dir = write_ts_project(3);
2309        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
2310        let inspect_dir = root.join(".aft-cache").join("inspect");
2311
2312        let snapshot = build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, false));
2313
2314        assert!(
2315            snapshot.is_none(),
2316            "dead_code must not rebuild the legacy graph when the store is disabled"
2317        );
2318    }
2319
2320    #[test]
2321    fn callgraph_snapshot_reports_unavailable_when_store_not_ready() {
2322        let dir = write_ts_project(3);
2323        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
2324        let inspect_dir = root.join(".aft-cache").join("inspect");
2325        let callgraph_dir = callgraph_store_dir_from_inspect_dir(&inspect_dir).expect("store dir");
2326        let _store = CallGraphStore::open(callgraph_dir, root.clone()).expect("open empty store");
2327
2328        let snapshot = build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true));
2329
2330        assert!(
2331            snapshot.is_none(),
2332            "a cold/mid-build store must surface callgraph_unavailable instead of rebuilding inline"
2333        );
2334    }
2335
2336    #[test]
2337    fn callgraph_snapshot_reads_ready_callgraph_store() {
2338        let dir = write_ts_project(3);
2339        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
2340        let inspect_dir = root.join(".aft-cache").join("inspect");
2341        let callgraph_dir = callgraph_store_dir_from_inspect_dir(&inspect_dir).expect("store dir");
2342        let store = CallGraphStore::open(callgraph_dir, root.clone()).expect("open store");
2343        let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
2344        store.cold_build(&files).expect("cold build store");
2345
2346        let snapshot = build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true))
2347            .expect("ready store snapshot");
2348
2349        assert_eq!(snapshot.files.len(), 3);
2350        assert_eq!(snapshot.exported_symbols.len(), 3);
2351    }
2352
2353    // A scoped payload must not carry the project-wide `by_language` breakdown
2354    // alongside the recomputed in-scope count — that contradiction renders as
2355    // e.g. "Dead code: 1 (rust 214, ts 143)".
2356    #[test]
2357    fn scoped_filter_drops_project_wide_by_language() {
2358        let scope = JobScope::from_roots("/proj", vec![PathBuf::from("/proj/src/a")]);
2359        assert!(
2360            !scope.is_project_wide(),
2361            "scope must be non-project for test"
2362        );
2363        let payload = serde_json::json!({
2364            "count": 99,
2365            "by_language": { "rust": 214, "typescript": 143 },
2366            "items": [
2367                { "file": "/proj/src/a/x.rs", "symbol": "live" },
2368                { "file": "/proj/src/other/y.rs", "symbol": "out" },
2369            ],
2370        });
2371        let filtered = filter_payload_for_scope(payload, &scope);
2372        assert!(
2373            filtered.get("by_language").is_none(),
2374            "scoped payload must drop project-wide by_language: {filtered}"
2375        );
2376        // Count is recomputed to the in-scope items (only x.rs under src/a).
2377        assert_eq!(filtered.get("count").and_then(|v| v.as_u64()), Some(1));
2378    }
2379}
2380
2381#[cfg(test)]
2382mod dead_code_projection_tests {
2383    use super::*;
2384    use crate::callgraph::walk_project_files;
2385    use crate::callgraph_store::{project_dead_code_snapshot, CallGraphStore};
2386    use crate::config::Config;
2387    use crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR;
2388    use crate::inspect::scanners::DEFAULT_EXPORT_MARKER_KIND;
2389    use crate::parser::SymbolCache;
2390    use filetime::FileTime;
2391    use std::sync::atomic::{AtomicI64, Ordering as AtomicOrdering};
2392    use std::sync::RwLock;
2393
2394    static NEXT_MTIME: AtomicI64 = AtomicI64::new(1_900_000_000);
2395
2396    #[derive(Debug, PartialEq, Eq)]
2397    struct ComparableSnapshot {
2398        files: BTreeSet<PathBuf>,
2399        exported_symbols: BTreeSet<(PathBuf, String, String, u32)>,
2400        outbound_calls: BTreeSet<(PathBuf, String, String, u32)>,
2401        entry_points: BTreeSet<PathBuf>,
2402    }
2403
2404    #[test]
2405    fn dead_code_projection_contains_expected_fixture_surface() {
2406        let dir = tempfile::tempdir().expect("tempdir");
2407        write_projection_fixture(dir.path());
2408        let root = canonical_root(dir.path());
2409        let projected = store_projected_snapshot(&root, ".store-dead-code-surface");
2410
2411        assert_projection_fixture_coverage(&root, &projected);
2412    }
2413
2414    #[test]
2415    fn dead_code_projection_incremental_scenario_matrix_matches_cold_rebuild() {
2416        run_projection_scenario("rename", setup_projection_rename, edit_projection_rename);
2417        run_projection_scenario("delete", setup_projection_delete, edit_projection_delete);
2418        run_projection_scenario(
2419            "barrel delete",
2420            setup_projection_barrel,
2421            edit_projection_barrel_delete,
2422        );
2423        run_projection_scenario(
2424            "dispatch edit",
2425            setup_projection_dispatch,
2426            edit_projection_dispatch,
2427        );
2428        run_projection_scenario(
2429            "body-only edit",
2430            setup_projection_body_only,
2431            edit_projection_body_only,
2432        );
2433    }
2434
2435    #[test]
2436    fn dead_code_projection_dead_code_scan_reports_expected_verdicts() {
2437        let dir = tempfile::tempdir().expect("tempdir");
2438        write_projection_fixture(dir.path());
2439        let root = canonical_root(dir.path());
2440        let files = project_files(&root);
2441        let projected = store_projected_snapshot(&root, ".store-dead-code-e2e");
2442
2443        let projected_aggregate = dead_code_aggregate(&root, files, projected);
2444        assert_dead_item(&projected_aggregate, "src/dead.ts", "knownDead");
2445        assert_live_item(&projected_aggregate, "src/live.ts", "knownLive");
2446        assert_live_item(&projected_aggregate, "src/render.ts", "render");
2447        assert_live_item(&projected_aggregate, "src/other_render.ts", "render");
2448    }
2449
2450    fn assert_projection_fixture_coverage(root: &Path, snapshot: &CallgraphSnapshot) {
2451        let comparable = comparable_snapshot(snapshot);
2452        assert!(
2453            comparable
2454                .files
2455                .iter()
2456                .any(|file| file.extension().and_then(|ext| ext.to_str()) == Some("ts")),
2457            "fixture must include TypeScript files: {:#?}",
2458            comparable.files
2459        );
2460        assert!(
2461            comparable
2462                .files
2463                .iter()
2464                .any(|file| file.extension().and_then(|ext| ext.to_str()) == Some("js")),
2465            "fixture must include JavaScript files: {:#?}",
2466            comparable.files
2467        );
2468        assert!(
2469            comparable
2470                .files
2471                .iter()
2472                .any(|file| file.extension().and_then(|ext| ext.to_str()) == Some("rs")),
2473            "fixture must include Rust files: {:#?}",
2474            comparable.files
2475        );
2476
2477        let main_file = canonicalize_for_snapshot(&root.join("src/main.ts"));
2478        let private_dispatch_target = format!("{}::dispatch", main_file.display());
2479        assert!(
2480            comparable
2481                .outbound_calls
2482                .iter()
2483                .any(
2484                    |(caller_file, caller_symbol, target, _)| caller_file == &main_file
2485                        && caller_symbol == "main"
2486                        && target == &private_dispatch_target
2487                ),
2488            "fixture must cover same-file private fallback target {private_dispatch_target}: {:#?}",
2489            comparable.outbound_calls
2490        );
2491        assert!(
2492            comparable
2493                .outbound_calls
2494                .iter()
2495                .any(|(_, _, target, _)| target.contains(DISPATCHED_CALLEE_SEPARATOR)),
2496            "fixture must cover method-dispatch suffixes: {:#?}",
2497            comparable.outbound_calls
2498        );
2499        assert!(
2500            comparable
2501                .exported_symbols
2502                .iter()
2503                .any(|(_, symbol, kind, _)| symbol == "runDefault"
2504                    && kind == DEFAULT_EXPORT_MARKER_KIND),
2505            "fixture must cover default-export marker rows: {:#?}",
2506            comparable.exported_symbols
2507        );
2508    }
2509
2510    fn run_projection_scenario(name: &str, setup: fn(&Path), edit: fn(&Path) -> Vec<PathBuf>) {
2511        let dir = tempfile::tempdir().expect("tempdir");
2512        setup(dir.path());
2513        let root = canonical_root(dir.path());
2514        let files_before = project_files(&root);
2515        let incremental_store = CallGraphStore::open(
2516            root.join(format!(".store-dead-code-projection-{name}-incremental")),
2517            root.clone(),
2518        )
2519        .expect("open incremental store");
2520        incremental_store
2521            .cold_build(&files_before)
2522            .expect("initial cold build");
2523
2524        let changed = edit(&root);
2525        incremental_store
2526            .refresh_files(&changed)
2527            .expect("refresh changed files");
2528        let incremental = project_dead_code_snapshot(incremental_store.sqlite_path())
2529            .expect("project incremental snapshot");
2530
2531        let cold_store = CallGraphStore::open(
2532            root.join(format!(".store-dead-code-projection-{name}-cold")),
2533            root.clone(),
2534        )
2535        .expect("open cold store");
2536        cold_store
2537            .cold_build(&project_files(&root))
2538            .expect("cold rebuild");
2539        let cold =
2540            project_dead_code_snapshot(cold_store.sqlite_path()).expect("project cold snapshot");
2541
2542        assert_snapshot_parts_eq(name, &cold, &incremental);
2543    }
2544
2545    /// Store-backed dead_code benchmark. Measures, on a real checkout, the
2546    /// persisted-store cold build, the warm SQLite projection cost, and the
2547    /// remaining `run_dead_code_scan` cost (per-file reexport/type-ref reparse +
2548    /// BFS roll-up). Production Tier-2 reads a warm store; cold_build is included
2549    /// here only to make end-to-end store cost visible.
2550    /// Ignored by default; run with:
2551    ///   AFT_BENCH_REPO=/path/to/large/repo cargo test -p agent-file-tools --lib \
2552    ///     -- --ignored --nocapture --test-threads=1 dead_code_decision_b_benchmark
2553    #[test]
2554    #[ignore = "manual benchmark; needs AFT_BENCH_REPO pointing at a large checkout"]
2555    fn dead_code_decision_b_benchmark() {
2556        let Ok(repo) = std::env::var("AFT_BENCH_REPO") else {
2557            eprintln!("AFT_BENCH_REPO unset; skipping");
2558            return;
2559        };
2560        // Each phase flushes immediately so a file-redirected run shows live progress.
2561        macro_rules! mark {
2562            ($($a:tt)*) => {{ eprintln!($($a)*); let _ = std::io::Write::flush(&mut std::io::stderr()); }};
2563        }
2564        let root = canonical_root(Path::new(&repo));
2565        let files = project_files(&root);
2566        mark!(
2567            "\n=== Store-backed dead_code benchmark ===\nrepo: {}\nsource files (walk_project_files): {}\nstarted store cold_build...",
2568            root.display(),
2569            files.len()
2570        );
2571
2572        // Store cold_build + projection. Production warm runs skip cold_build and
2573        // pay only the projection below.
2574        let store_dir = root.join(".aft-bench-store");
2575        let _ = std::fs::remove_dir_all(&store_dir);
2576        let store = CallGraphStore::open(store_dir.clone(), root.clone()).expect("open store");
2577        let t = Instant::now();
2578        let cold_stats = store.cold_build(&files).expect("store cold build");
2579        let store_build_ms = t.elapsed().as_millis();
2580        let t = Instant::now();
2581        let projected = project_dead_code_snapshot(store.sqlite_path()).expect("projection");
2582        let proj_ms = t.elapsed().as_millis();
2583        mark!(
2584            "store cold_build: {} ms ({:?}) + projection: {} ms = {} ms  (exports={}, outbound={})\nstarted scan...",
2585            store_build_ms, cold_stats, proj_ms, store_build_ms + proj_ms,
2586            projected.exported_symbols.len(), projected.outbound_calls.len()
2587        );
2588
2589        // Remaining scanner cost: run_dead_code_scan given a ready snapshot.
2590        let t = Instant::now();
2591        let _result = dead_code_aggregate(&root, files.clone(), projected.clone());
2592        let scan_ms = t.elapsed().as_millis();
2593        mark!("run_dead_code_scan (cold contributions): {} ms", scan_ms);
2594
2595        mark!(
2596            "\nSUMMARY  files={}  store_cold_plus_projection={}ms  projection={}ms  scan_cold={}ms  total={}ms",
2597            files.len(),
2598            store_build_ms + proj_ms,
2599            proj_ms,
2600            scan_ms,
2601            store_build_ms + proj_ms + scan_ms
2602        );
2603        let _ = std::fs::remove_dir_all(&store_dir);
2604    }
2605
2606    fn store_projected_snapshot(root: &Path, store_name: &str) -> CallgraphSnapshot {
2607        let store =
2608            CallGraphStore::open(root.join(store_name), root.to_path_buf()).expect("open store");
2609        store
2610            .cold_build(&project_files(root))
2611            .expect("store cold build");
2612        project_dead_code_snapshot(store.sqlite_path()).expect("project snapshot")
2613    }
2614
2615    fn dead_code_aggregate(
2616        root: &Path,
2617        scope_files: Vec<PathBuf>,
2618        snapshot: CallgraphSnapshot,
2619    ) -> Value {
2620        let job = InspectJob {
2621            job_id: 86,
2622            key: JobKey::for_project_category(InspectCategory::DeadCode),
2623            category: InspectCategory::DeadCode,
2624            scope_files,
2625            project_root: root.to_path_buf(),
2626            inspect_dir: root.join(".aft-cache").join("inspect"),
2627            config: Arc::new(Config {
2628                project_root: Some(root.to_path_buf()),
2629                ..Config::default()
2630            }),
2631            symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
2632            callgraph_snapshot: Some(Arc::new(snapshot)),
2633        };
2634        crate::inspect::scanners::dead_code::run_dead_code_scan(&job)
2635            .outcome
2636            .expect("dead_code scan succeeds")
2637            .aggregate
2638    }
2639
2640    fn assert_snapshot_parts_eq(
2641        label: &str,
2642        expected: &CallgraphSnapshot,
2643        actual: &CallgraphSnapshot,
2644    ) {
2645        let expected = comparable_snapshot(expected);
2646        let actual = comparable_snapshot(actual);
2647        assert_eq!(
2648            actual, expected,
2649            "{label} store-projected snapshot must match cold store snapshot"
2650        );
2651    }
2652
2653    fn comparable_snapshot(snapshot: &CallgraphSnapshot) -> ComparableSnapshot {
2654        ComparableSnapshot {
2655            files: snapshot.files.iter().cloned().collect(),
2656            exported_symbols: snapshot
2657                .exported_symbols
2658                .iter()
2659                .map(|export| {
2660                    (
2661                        export.file.clone(),
2662                        export.symbol.clone(),
2663                        export.kind.clone(),
2664                        export.line,
2665                    )
2666                })
2667                .collect(),
2668            outbound_calls: snapshot
2669                .outbound_calls
2670                .iter()
2671                .map(|call| {
2672                    (
2673                        call.caller_file.clone(),
2674                        call.caller_symbol.clone(),
2675                        call.target.clone(),
2676                        call.line,
2677                    )
2678                })
2679                .collect(),
2680            entry_points: snapshot.entry_points.clone(),
2681        }
2682    }
2683
2684    fn assert_dead_item(aggregate: &Value, file: &str, symbol: &str) {
2685        assert!(
2686            aggregate_has_item(aggregate, file, symbol),
2687            "expected {file}::{symbol} to be reported dead: {aggregate:#}"
2688        );
2689    }
2690
2691    fn assert_live_item(aggregate: &Value, file: &str, symbol: &str) {
2692        assert!(
2693            !aggregate_has_item(aggregate, file, symbol),
2694            "expected {file}::{symbol} to be live/not reported dead: {aggregate:#}"
2695        );
2696    }
2697
2698    fn aggregate_has_item(aggregate: &Value, file: &str, symbol: &str) -> bool {
2699        let Some(items) = aggregate.get("items").and_then(Value::as_array) else {
2700            return false;
2701        };
2702        items.iter().any(|item| {
2703            item.get("file").and_then(Value::as_str) == Some(file)
2704                && item.get("symbol").and_then(Value::as_str) == Some(symbol)
2705        })
2706    }
2707
2708    fn project_files(root: &Path) -> Vec<PathBuf> {
2709        walk_project_files(root).collect()
2710    }
2711
2712    fn canonical_root(root: &Path) -> PathBuf {
2713        std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf())
2714    }
2715
2716    fn write_file(path: &Path, content: &str) {
2717        if let Some(parent) = path.parent() {
2718            std::fs::create_dir_all(parent).expect("create parent");
2719        }
2720        std::fs::write(path, content).expect("write fixture");
2721        bump_mtime(path);
2722    }
2723
2724    fn bump_mtime(path: &Path) {
2725        let secs = NEXT_MTIME.fetch_add(1, AtomicOrdering::SeqCst);
2726        filetime::set_file_mtime(path, FileTime::from_unix_time(secs, 0)).expect("bump mtime");
2727    }
2728
2729    fn remove_file(path: &Path) {
2730        std::fs::remove_file(path).expect("remove fixture");
2731    }
2732
2733    fn write_projection_fixture(root: &Path) {
2734        write_file(
2735            &root.join("package.json"),
2736            r#"{"name":"dead-code-projection-fixture","type":"module","main":"src/main.ts"}"#,
2737        );
2738        write_file(
2739            &root.join("Cargo.toml"),
2740            r#"[package]
2741name = "dead_code_projection_fixture"
2742version = "0.1.0"
2743edition = "2021"
2744"#,
2745        );
2746        write_file(
2747            &root.join("src/main.ts"),
2748            r#"import runDefault from "./default";
2749import { knownLive } from "./live";
2750import { jsEntry } from "./app.js";
2751
2752export function main() {
2753  dispatch();
2754  runDefault();
2755  jsEntry();
2756}
2757
2758function dispatch() {
2759  knownLive();
2760  const service = { render() {} };
2761  service.render();
2762}
2763"#,
2764        );
2765        write_file(
2766            &root.join("src/default.ts"),
2767            r#"export default function runDefault() {}
2768"#,
2769        );
2770        write_file(
2771            &root.join("src/live.ts"),
2772            r#"export function knownLive() {}
2773"#,
2774        );
2775        write_file(
2776            &root.join("src/dead.ts"),
2777            r#"export function knownDead() {}
2778"#,
2779        );
2780        write_file(
2781            &root.join("src/render.ts"),
2782            r#"export function render() {}
2783"#,
2784        );
2785        write_file(
2786            &root.join("src/other_render.ts"),
2787            r#"export function render() {}
2788"#,
2789        );
2790        write_file(
2791            &root.join("src/app.js"),
2792            r#"import { jsHelper } from "./js_helper.js";
2793
2794export function jsEntry() {
2795  jsHelper();
2796}
2797"#,
2798        );
2799        write_file(
2800            &root.join("src/js_helper.js"),
2801            r#"export function jsHelper() {}
2802"#,
2803        );
2804        write_file(
2805            &root.join("src/lib.rs"),
2806            r#"mod util;
2807use crate::util::rust_helper;
2808
2809pub fn rust_entry() {
2810    rust_helper();
2811}
2812"#,
2813        );
2814        write_file(
2815            &root.join("src/util.rs"),
2816            r#"pub fn rust_helper() {}
2817"#,
2818        );
2819    }
2820
2821    fn setup_projection_rename(root: &Path) {
2822        write_file(
2823            &root.join("a.ts"),
2824            r#"export function outer() {
2825  inner();
2826}
2827
2828export function inner() {}
2829"#,
2830        );
2831    }
2832
2833    fn edit_projection_rename(root: &Path) -> Vec<PathBuf> {
2834        let path = root.join("a.ts");
2835        write_file(
2836            &path,
2837            r#"export function outer() {
2838  renamed();
2839}
2840
2841export function renamed() {}
2842"#,
2843        );
2844        vec![path]
2845    }
2846
2847    fn setup_projection_delete(root: &Path) {
2848        write_file(
2849            &root.join("main.ts"),
2850            r#"import { foo } from "./foo";
2851export function main() { foo(); }
2852"#,
2853        );
2854        write_file(&root.join("foo.ts"), "export function foo() {}\n");
2855    }
2856
2857    fn edit_projection_delete(root: &Path) -> Vec<PathBuf> {
2858        let path = root.join("foo.ts");
2859        remove_file(&path);
2860        vec![path]
2861    }
2862
2863    fn setup_projection_barrel(root: &Path) {
2864        write_file(
2865            &root.join("main.ts"),
2866            r#"import { foo } from "./barrel";
2867export function main() { foo(); }
2868"#,
2869        );
2870        write_file(&root.join("barrel.ts"), "export { foo } from \"./foo\";\n");
2871        write_file(&root.join("foo.ts"), "export function foo() {}\n");
2872    }
2873
2874    fn edit_projection_barrel_delete(root: &Path) -> Vec<PathBuf> {
2875        let path = root.join("barrel.ts");
2876        remove_file(&path);
2877        vec![path]
2878    }
2879
2880    fn setup_projection_dispatch(root: &Path) {
2881        write_file(
2882            &root.join("main.ts"),
2883            r#"export function main() {
2884  const service = { render() {}, paint() {} };
2885  service.render();
2886}
2887"#,
2888        );
2889        write_file(&root.join("render.ts"), "export function render() {}\n");
2890        write_file(&root.join("paint.ts"), "export function paint() {}\n");
2891    }
2892
2893    fn edit_projection_dispatch(root: &Path) -> Vec<PathBuf> {
2894        let path = root.join("main.ts");
2895        write_file(
2896            &path,
2897            r#"export function main() {
2898  const service = { render() {}, paint() {} };
2899  service.paint();
2900}
2901"#,
2902        );
2903        vec![path]
2904    }
2905
2906    fn setup_projection_body_only(root: &Path) {
2907        write_file(
2908            &root.join("main.ts"),
2909            r#"import { foo } from "./foo";
2910export function main() { foo(); }
2911"#,
2912        );
2913        write_file(
2914            &root.join("foo.ts"),
2915            r#"export function foo() {
2916  return 1;
2917}
2918"#,
2919        );
2920    }
2921
2922    fn edit_projection_body_only(root: &Path) -> Vec<PathBuf> {
2923        let path = root.join("foo.ts");
2924        write_file(
2925            &path,
2926            r#"export function foo() {
2927  return 2;
2928}
2929"#,
2930        );
2931        vec![path]
2932    }
2933}