Skip to main content

aft/inspect/
manager.rs

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