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