Skip to main content

aft/inspect/
manager.rs

1use std::collections::{BTreeMap, BTreeSet, HashMap};
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::sync::{Arc, Mutex};
5use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
6
7use crossbeam_channel::{after, bounded, select, Receiver, Sender};
8use serde::Deserialize;
9use serde_json::{json, Value};
10
11use super::cache::{InspectCache, Tier2ContributionUpdates};
12use super::dispatch::{default_worker, start_dispatch_loop, InspectWorker};
13use super::freshness::ContributionFreshness;
14use super::job::{
15    normalize_path, CallgraphExport, CallgraphOutboundCall, CallgraphSnapshot, FileContribution,
16    InspectCategory, InspectJob, InspectResult, InspectScanSuccess, InspectSnapshot, JobKey,
17    JobOutcome, JobScope, DISPATCHED_CALLEE_SEPARATOR,
18};
19use super::scanners::DEFAULT_EXPORT_MARKER_KIND;
20use crate::cache_freshness::{self, FileFreshness, FreshnessVerdict};
21use crate::callgraph::{is_bare_callee, resolve_symbol_query_in_data, CallGraph, EdgeResolution};
22use crate::symbols::SymbolKind;
23
24const DEFAULT_SOFT_DEADLINE: Duration = Duration::from_secs(1);
25
26type WaiterTx = Sender<JobOutcome>;
27
28#[derive(Clone)]
29struct Waiter {
30    tx: WaiterTx,
31}
32
33struct CachedContributionFreshness {
34    file_path: PathBuf,
35    freshness: FileFreshness,
36}
37
38#[derive(PartialEq, Eq)]
39struct ContributionFingerprint {
40    count: usize,
41    set_hash: String,
42    hash_complete: bool,
43}
44
45pub struct InspectManager {
46    request_tx: Sender<InspectJob>,
47    result_rx: Receiver<InspectResult>,
48    #[allow(dead_code)]
49    pool: Arc<rayon::ThreadPool>,
50    in_flight: Mutex<HashMap<JobKey, Vec<Waiter>>>,
51    caches: Mutex<HashMap<PathBuf, Arc<InspectCache>>>,
52    soft_deadline: Duration,
53    next_job_id: AtomicU64,
54}
55
56impl InspectManager {
57    pub fn new() -> Self {
58        Self::with_worker(default_worker(), DEFAULT_SOFT_DEADLINE)
59    }
60
61    #[doc(hidden)]
62    pub fn with_worker(worker: InspectWorker, soft_deadline: Duration) -> Self {
63        let handles = start_dispatch_loop(worker);
64        Self {
65            request_tx: handles.request_tx,
66            result_rx: handles.result_rx,
67            pool: handles.pool,
68            in_flight: Mutex::new(HashMap::new()),
69            caches: Mutex::new(HashMap::new()),
70            soft_deadline,
71            next_job_id: AtomicU64::new(1),
72        }
73    }
74
75    pub fn submit_category(
76        &self,
77        snapshot: InspectSnapshot,
78        category: InspectCategory,
79        caller_scope: JobScope,
80    ) -> JobOutcome {
81        self.submit_category_with_callgraph(snapshot, category, caller_scope, None)
82    }
83
84    pub fn submit_category_with_callgraph(
85        &self,
86        snapshot: InspectSnapshot,
87        category: InspectCategory,
88        caller_scope: JobScope,
89        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
90    ) -> JobOutcome {
91        if !category.is_active() {
92            return JobOutcome::Failed {
93                message: format!("inspect category '{category}' is disabled in v0.33"),
94            };
95        }
96
97        let cache = match self.cache_for_snapshot(&snapshot) {
98            Ok(cache) => cache,
99            Err(message) => return JobOutcome::Failed { message },
100        };
101        let key = JobKey::for_category_scope(category, &caller_scope);
102        let (waiter_tx, waiter_rx) = bounded(1);
103
104        let wait_snapshot = snapshot.clone();
105        match self.enqueue_with_waiter(
106            snapshot,
107            category,
108            caller_scope.clone(),
109            key.clone(),
110            waiter_tx,
111            callgraph_snapshot,
112        ) {
113            Ok(()) => self.wait_for_outcome(key, caller_scope, cache, waiter_rx, wait_snapshot),
114            Err(message) => JobOutcome::Failed { message },
115        }
116    }
117
118    pub fn submit_background(
119        &self,
120        snapshot: InspectSnapshot,
121        category: InspectCategory,
122        caller_scope: JobScope,
123    ) -> Result<JobKey, String> {
124        self.submit_background_with_callgraph(snapshot, category, caller_scope, None)
125    }
126
127    pub fn submit_background_with_callgraph(
128        &self,
129        snapshot: InspectSnapshot,
130        category: InspectCategory,
131        caller_scope: JobScope,
132        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
133    ) -> Result<JobKey, String> {
134        if !category.is_active() {
135            return Err(format!(
136                "inspect category '{category}' is disabled in v0.33"
137            ));
138        }
139        let key = JobKey::for_category_scope(category, &caller_scope);
140        self.enqueue_without_waiter(
141            snapshot,
142            category,
143            caller_scope,
144            key.clone(),
145            callgraph_snapshot,
146        )?;
147        Ok(key)
148    }
149
150    pub fn submit_tier2_run_with_reuse_background(
151        self: &Arc<Self>,
152        snapshot: InspectSnapshot,
153        category: InspectCategory,
154    ) -> Result<JobKey, String> {
155        if !category.is_active() {
156            return Err(format!(
157                "inspect category '{category}' is disabled in v0.33"
158            ));
159        }
160        if !category.is_tier2() {
161            return Err(format!(
162                "inspect category '{category}' is not a Tier 2 category"
163            ));
164        }
165
166        let job = self.tier2_reuse_job(snapshot, category, None);
167        let key = job.key.clone();
168        let mut in_flight = self
169            .in_flight
170            .lock()
171            .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
172        if in_flight.contains_key(&key) {
173            return Ok(key);
174        }
175        in_flight.insert(key.clone(), Vec::new());
176        drop(in_flight);
177
178        let manager = Arc::clone(self);
179        let pool = Arc::clone(&self.pool);
180        pool.spawn(move || {
181            let result = manager.tier2_run_with_reuse_job_result(job);
182            manager.route_tier2_reuse_completion(result);
183        });
184
185        Ok(key)
186    }
187
188    pub fn drain_completions(&self) -> usize {
189        let mut drained = 0usize;
190        while let Ok(result) = self.result_rx.try_recv() {
191            self.route_completion(result);
192            drained += 1;
193        }
194        drained
195    }
196
197    pub fn cache_for_snapshot(
198        &self,
199        snapshot: &InspectSnapshot,
200    ) -> Result<Arc<InspectCache>, String> {
201        self.cache_for_paths(snapshot.inspect_dir.clone(), snapshot.project_root.clone())
202    }
203
204    pub fn cache_for_paths(
205        &self,
206        inspect_dir: PathBuf,
207        project_root: PathBuf,
208    ) -> Result<Arc<InspectCache>, String> {
209        let project_key = crate::search_index::project_cache_key(&project_root);
210        let sqlite_path = inspect_dir.join(format!("{project_key}.sqlite"));
211        let mut caches = self
212            .caches
213            .lock()
214            .map_err(|_| "inspect manager cache map lock poisoned".to_string())?;
215        if let Some(cache) = caches.get(&sqlite_path) {
216            return Ok(Arc::clone(cache));
217        }
218        let cache = Arc::new(
219            InspectCache::open(inspect_dir, project_root)
220                .map_err(|error| format!("failed to open inspect cache: {error}"))?,
221        );
222        caches.insert(sqlite_path, Arc::clone(&cache));
223        Ok(cache)
224    }
225
226    pub fn tier2_run_with_reuse(
227        &self,
228        snapshot: InspectSnapshot,
229        category: InspectCategory,
230        caller_scope: JobScope,
231        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
232    ) -> JobOutcome {
233        let result =
234            self.tier2_run_with_reuse_result(snapshot.clone(), category, callgraph_snapshot);
235        let outcome = match result.outcome {
236            Ok(success) => JobOutcome::Fresh {
237                payload: success.aggregate,
238            },
239            Err(message) => JobOutcome::Failed { message },
240        };
241        match self.cache_for_snapshot(&snapshot) {
242            Ok(cache) => filter_outcome_for_scope_with_contributions(
243                outcome,
244                &snapshot,
245                category,
246                cache.as_ref(),
247                &caller_scope,
248            ),
249            Err(message) => JobOutcome::Failed { message },
250        }
251    }
252
253    /// Read-only Tier 2 aggregate lookup for `aft_inspect`. Does NOT run any
254    /// scanner — returns the latest cached aggregate if present and verifies
255    /// its contribution freshness so warm cache hits are reported as fresh.
256    /// This is the non-blocking variant intended for the synchronous `inspect`
257    /// command path; Tier 2 scans run via `aft_inspect_tier2_run` on
258    /// `session.idle`.
259    pub fn tier2_read_cached(
260        &self,
261        snapshot: InspectSnapshot,
262        category: InspectCategory,
263        caller_scope: JobScope,
264    ) -> JobOutcome {
265        if !category.is_active() {
266            return JobOutcome::Failed {
267                message: format!("inspect category '{category}' is disabled in v0.33"),
268            };
269        }
270        if !category.is_tier2() {
271            return JobOutcome::Failed {
272                message: format!("inspect category '{category}' is not a Tier 2 category"),
273            };
274        }
275
276        let cache = match self.cache_for_snapshot(&snapshot) {
277            Ok(cache) => cache,
278            Err(message) => return JobOutcome::Failed { message },
279        };
280        let key = JobKey::for_project_category(category);
281        let in_flight = self
282            .in_flight
283            .lock()
284            .map(|guard| guard.contains_key(&key))
285            .unwrap_or(false);
286        match cache.get_aggregated(&key) {
287            Ok(Some(payload)) => {
288                match self.tier2_cached_aggregate_is_fresh(&snapshot, category, cache.as_ref()) {
289                    Ok(true) => filter_outcome_for_scope_with_contributions(
290                        JobOutcome::Fresh { payload },
291                        &snapshot,
292                        category,
293                        cache.as_ref(),
294                        &caller_scope,
295                    ),
296                    Ok(false) => filter_outcome_for_scope_with_contributions(
297                        JobOutcome::Stale {
298                            cached: Some(payload),
299                            in_flight,
300                        },
301                        &snapshot,
302                        category,
303                        cache.as_ref(),
304                        &caller_scope,
305                    ),
306                    Err(message) => JobOutcome::Failed { message },
307                }
308            }
309            Ok(None) => match cache.latest_aggregate_any_hash(category) {
310                Ok(Some(payload)) => filter_outcome_for_scope_with_contributions(
311                    JobOutcome::Stale {
312                        cached: Some(payload),
313                        in_flight,
314                    },
315                    &snapshot,
316                    category,
317                    cache.as_ref(),
318                    &caller_scope,
319                ),
320                Ok(None) => JobOutcome::Pending { in_flight },
321                Err(error) => JobOutcome::Failed {
322                    message: error.to_string(),
323                },
324            },
325            Err(error) => JobOutcome::Failed {
326                message: error.to_string(),
327            },
328        }
329    }
330
331    fn tier2_cached_aggregate_is_fresh(
332        &self,
333        snapshot: &InspectSnapshot,
334        category: InspectCategory,
335        cache: &InspectCache,
336    ) -> Result<bool, String> {
337        let cached_records = load_contribution_freshness(cache, category)?;
338        let project_scope = JobScope::for_project(snapshot.project_root.clone());
339        let project_files = scope_files(&snapshot.project_root, &project_scope);
340        let current_by_relative = current_project_files(&snapshot.project_root, &project_files);
341        let cached_relative = cached_records
342            .iter()
343            .map(freshness_record_relative_key)
344            .collect::<BTreeSet<_>>();
345
346        for record in &cached_records {
347            let relative = freshness_record_relative_key(record);
348            if !current_by_relative.contains_key(&relative) {
349                return Ok(false);
350            }
351
352            let absolute = if record.file_path.is_absolute() {
353                record.file_path.clone()
354            } else {
355                snapshot.project_root.join(&record.file_path)
356            };
357            match verify_contribution_file_strict(&absolute, &record.freshness) {
358                ContributionFreshness::Fresh {
359                    metadata_changed,
360                    freshness,
361                } => {
362                    if metadata_changed {
363                        cache
364                            .update_content_fresh_metadata(
365                                category,
366                                &PathBuf::from(&relative),
367                                &freshness,
368                            )
369                            .map_err(|error| error.to_string())?;
370                    }
371                }
372                ContributionFreshness::Stale | ContributionFreshness::Deleted => return Ok(false),
373            }
374        }
375
376        Ok(current_by_relative
377            .keys()
378            .all(|relative| cached_relative.contains(relative)))
379    }
380
381    #[doc(hidden)]
382    pub fn tier2_run_with_reuse_result(
383        &self,
384        snapshot: InspectSnapshot,
385        category: InspectCategory,
386        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
387    ) -> InspectResult {
388        let job = self.tier2_reuse_job(snapshot, category, callgraph_snapshot);
389        self.tier2_run_with_reuse_job_result(job)
390    }
391
392    fn tier2_run_with_reuse_job_result(&self, mut job: InspectJob) -> InspectResult {
393        let started = Instant::now();
394        if !job.category.is_active() {
395            return InspectResult::failed(
396                &job,
397                format!("inspect category '{}' is disabled in v0.33", job.category),
398                started.elapsed(),
399            );
400        }
401        if !job.category.is_tier2() {
402            return InspectResult::failed(
403                &job,
404                format!(
405                    "inspect category '{}' is not a Tier 2 category",
406                    job.category
407                ),
408                started.elapsed(),
409            );
410        }
411
412        let project_scope = JobScope::for_project(job.project_root.clone());
413        job.scope_files = scope_files(&job.project_root, &project_scope);
414        let cache = match self.cache_for_paths(job.inspect_dir.clone(), job.project_root.clone()) {
415            Ok(cache) => cache,
416            Err(message) => return InspectResult::failed(&job, message, started.elapsed()),
417        };
418        if let Ok(Some(success)) = self.tier2_quick_reuse_success(&job, cache.as_ref()) {
419            return InspectResult::success(&job, success, started.elapsed());
420        }
421
422        match self.tier2_run_with_reuse_job(&job, &cache) {
423            Ok(success) => InspectResult::success(&job, success, started.elapsed()),
424            Err(message) => InspectResult::failed(&job, message, started.elapsed()),
425        }
426    }
427
428    fn tier2_reuse_job(
429        &self,
430        snapshot: InspectSnapshot,
431        category: InspectCategory,
432        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
433    ) -> InspectJob {
434        InspectJob {
435            job_id: self.next_job_id.fetch_add(1, Ordering::Relaxed),
436            key: JobKey::for_project_category(category),
437            category,
438            scope_files: Vec::new(),
439            project_root: snapshot.project_root,
440            inspect_dir: snapshot.inspect_dir,
441            config: snapshot.config,
442            symbol_cache: snapshot.symbol_cache,
443            callgraph_snapshot,
444        }
445    }
446
447    fn tier2_quick_reuse_success(
448        &self,
449        job: &InspectJob,
450        cache: &InspectCache,
451    ) -> Result<Option<InspectScanSuccess>, String> {
452        let Some(aggregate) = cache
453            .get_aggregated(&job.key)
454            .map_err(|error| error.to_string())?
455        else {
456            return Ok(None);
457        };
458        let cached = load_contribution_fingerprint(cache, job.category)?;
459        let current = current_file_fingerprint(&job.project_root, &job.scope_files)?;
460        if !cached.hash_complete || !current.hash_complete || cached != current {
461            return Ok(None);
462        }
463
464        cache
465            .touch_tier2_last_full_run(job.category)
466            .map_err(|error| error.to_string())?;
467        Ok(Some(InspectScanSuccess {
468            scanned_files: Vec::new(),
469            contributions: Vec::new(),
470            aggregate,
471        }))
472    }
473
474    fn tier2_run_with_reuse_job(
475        &self,
476        job: &InspectJob,
477        cache: &InspectCache,
478    ) -> Result<InspectScanSuccess, String> {
479        let cached_records = load_contribution_freshness(cache, job.category)?;
480        let current_by_relative = current_project_files(&job.project_root, &job.scope_files);
481        let cached_relative = cached_records
482            .iter()
483            .map(freshness_record_relative_key)
484            .collect::<BTreeSet<_>>();
485        #[cfg(debug_assertions)]
486        let cold_cache = cached_relative.is_empty();
487
488        let mut updates = Tier2ContributionUpdates::default();
489        let mut scan_by_relative = BTreeMap::<String, PathBuf>::new();
490        let mut aggregate_job = job.clone();
491
492        for record in cached_records {
493            let relative = freshness_record_relative_key(&record);
494            let relative_path = PathBuf::from(&relative);
495            let Some(current_file) = current_by_relative.get(&relative) else {
496                updates.deletes.push(relative_path);
497                continue;
498            };
499
500            let absolute = job.project_root.join(&record.file_path);
501            match verify_contribution_file_strict(&absolute, &record.freshness) {
502                ContributionFreshness::Fresh {
503                    metadata_changed,
504                    freshness,
505                } => {
506                    if metadata_changed {
507                        updates.metadata_updates.push((relative_path, freshness));
508                    }
509                }
510                ContributionFreshness::Stale => {
511                    updates.deletes.push(relative_path);
512                    scan_by_relative.insert(relative, current_file.clone());
513                }
514                ContributionFreshness::Deleted => {
515                    updates.deletes.push(relative_path);
516                }
517            }
518        }
519
520        for (relative, file) in &current_by_relative {
521            if !cached_relative.contains(relative) {
522                scan_by_relative.insert(relative.clone(), file.clone());
523            }
524        }
525
526        let mut scan_files = scan_by_relative.into_values().collect::<Vec<_>>();
527        if !scan_files.is_empty() {
528            let mut scan_job = job.clone();
529            scan_job.job_id = self.next_job_id.fetch_add(1, Ordering::Relaxed);
530            scan_job.scope_files = scan_files.clone();
531            if scan_job.category == InspectCategory::DeadCode
532                && scan_job.callgraph_snapshot.is_none()
533            {
534                scan_job.callgraph_snapshot =
535                    Some(build_tier2_callgraph_snapshot(&scan_job.project_root));
536            }
537            aggregate_job.callgraph_snapshot = scan_job.callgraph_snapshot.clone();
538            #[cfg(debug_assertions)]
539            if cold_cache {
540                std::thread::sleep(Duration::from_millis(10));
541            }
542            let scan_result = run_tier2_scan(&scan_job);
543            let scan_success = scan_result.outcome.map_err(|message| {
544                format!("{} incremental scan failed: {message}", job.category)
545            })?;
546            updates.upserts.extend(scan_success.contributions);
547        }
548
549        let has_updates = !updates.upserts.is_empty()
550            || !updates.deletes.is_empty()
551            || !updates.metadata_updates.is_empty();
552        if !has_updates {
553            if let Some(aggregate) = cache
554                .get_aggregated(&job.key)
555                .map_err(|error| error.to_string())?
556            {
557                cache
558                    .touch_tier2_last_full_run(job.category)
559                    .map_err(|error| error.to_string())?;
560                return Ok(InspectScanSuccess {
561                    scanned_files: scan_files,
562                    contributions: Vec::new(),
563                    aggregate,
564                });
565            }
566        }
567
568        let mut contribution_set_hash = if has_updates {
569            cache
570                .apply_contribution_updates(job.category, updates)
571                .map_err(|error| error.to_string())?
572        } else {
573            cache
574                .contribution_set_hash(job.category)
575                .map_err(|error| error.to_string())?
576        };
577
578        if let Some(aggregate) = cache
579            .load_aggregate_if_hash_matches(job.category, &contribution_set_hash)
580            .map_err(|error| error.to_string())?
581        {
582            cache
583                .touch_tier2_last_full_run(job.category)
584                .map_err(|error| error.to_string())?;
585            let contributions = load_contributions(cache, job)?;
586            return Ok(InspectScanSuccess {
587                scanned_files: scan_files,
588                contributions,
589                aggregate,
590            });
591        }
592
593        if category_contributions_depend_on_entry_points(job.category) {
594            // Manifest edits can change entry/public roots without touching any
595            // source file. Dead-code and unused-export file contributions embed
596            // those roots, so an aggregate hash miss for these categories must
597            // refresh every current contribution before rolling up again.
598            let full_scan_files = current_by_relative.into_values().collect::<Vec<_>>();
599            if !full_scan_files.is_empty() {
600                let mut rescan_job = job.clone();
601                rescan_job.job_id = self.next_job_id.fetch_add(1, Ordering::Relaxed);
602                rescan_job.scope_files = full_scan_files.clone();
603                if rescan_job.category == InspectCategory::DeadCode
604                    && rescan_job.callgraph_snapshot.is_none()
605                {
606                    rescan_job.callgraph_snapshot =
607                        Some(build_tier2_callgraph_snapshot(&rescan_job.project_root));
608                }
609                let scan_result = run_tier2_scan(&rescan_job);
610                let scan_success = scan_result.outcome.map_err(|message| {
611                    format!(
612                        "{} full rescan after entry-point cache miss failed: {message}",
613                        job.category
614                    )
615                })?;
616                let rescan_updates = Tier2ContributionUpdates {
617                    upserts: scan_success.contributions,
618                    ..Tier2ContributionUpdates::default()
619                };
620                contribution_set_hash = cache
621                    .apply_contribution_updates(job.category, rescan_updates)
622                    .map_err(|error| error.to_string())?;
623                aggregate_job.callgraph_snapshot = rescan_job.callgraph_snapshot.clone();
624                scan_files = full_scan_files;
625
626                if let Some(aggregate) = cache
627                    .load_aggregate_if_hash_matches(job.category, &contribution_set_hash)
628                    .map_err(|error| error.to_string())?
629                {
630                    cache
631                        .touch_tier2_last_full_run(job.category)
632                        .map_err(|error| error.to_string())?;
633                    let contributions = load_contributions(cache, job)?;
634                    return Ok(InspectScanSuccess {
635                        scanned_files: scan_files,
636                        contributions,
637                        aggregate,
638                    });
639                }
640            }
641        }
642
643        if aggregate_job.category == InspectCategory::DeadCode
644            && aggregate_job.callgraph_snapshot.is_none()
645        {
646            aggregate_job.callgraph_snapshot =
647                Some(build_tier2_callgraph_snapshot(&aggregate_job.project_root));
648        }
649        let contributions = load_contributions(cache, &aggregate_job)?;
650        let aggregate = roll_up_tier2_contributions(&aggregate_job, &contributions);
651        cache
652            .store_tier2_aggregate(job.key.clone(), &contribution_set_hash, aggregate.clone())
653            .map_err(|error| error.to_string())?;
654
655        Ok(InspectScanSuccess {
656            scanned_files: scan_files,
657            contributions,
658            aggregate,
659        })
660    }
661
662    fn enqueue_with_waiter(
663        &self,
664        snapshot: InspectSnapshot,
665        category: InspectCategory,
666        caller_scope: JobScope,
667        key: JobKey,
668        waiter_tx: WaiterTx,
669        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
670    ) -> Result<(), String> {
671        let mut in_flight = self
672            .in_flight
673            .lock()
674            .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
675        if let Some(waiters) = in_flight.get_mut(&key) {
676            waiters.push(Waiter { tx: waiter_tx });
677            return Ok(());
678        }
679
680        in_flight.insert(key.clone(), vec![Waiter { tx: waiter_tx }]);
681        drop(in_flight);
682
683        if let Err(message) = self.enqueue_new_job(
684            snapshot,
685            category,
686            caller_scope,
687            key.clone(),
688            callgraph_snapshot,
689        ) {
690            if let Ok(mut in_flight) = self.in_flight.lock() {
691                in_flight.remove(&key);
692            }
693            return Err(message);
694        }
695        Ok(())
696    }
697
698    fn enqueue_without_waiter(
699        &self,
700        snapshot: InspectSnapshot,
701        category: InspectCategory,
702        caller_scope: JobScope,
703        key: JobKey,
704        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
705    ) -> Result<(), String> {
706        let mut in_flight = self
707            .in_flight
708            .lock()
709            .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
710        if in_flight.contains_key(&key) {
711            return Ok(());
712        }
713        in_flight.insert(key.clone(), Vec::new());
714        drop(in_flight);
715
716        if let Err(message) = self.enqueue_new_job(
717            snapshot,
718            category,
719            caller_scope,
720            key.clone(),
721            callgraph_snapshot,
722        ) {
723            if let Ok(mut in_flight) = self.in_flight.lock() {
724                in_flight.remove(&key);
725            }
726            return Err(message);
727        }
728        Ok(())
729    }
730
731    fn enqueue_new_job(
732        &self,
733        snapshot: InspectSnapshot,
734        category: InspectCategory,
735        caller_scope: JobScope,
736        key: JobKey,
737        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
738    ) -> Result<(), String> {
739        let scan_scope = if category.is_tier2() {
740            JobScope::for_project(snapshot.project_root.clone())
741        } else {
742            caller_scope
743        };
744        let scope_files = scope_files(&snapshot.project_root, &scan_scope);
745        let job = InspectJob {
746            job_id: self.next_job_id.fetch_add(1, Ordering::Relaxed),
747            key,
748            category,
749            scope_files,
750            project_root: snapshot.project_root,
751            inspect_dir: snapshot.inspect_dir,
752            config: snapshot.config,
753            symbol_cache: snapshot.symbol_cache,
754            callgraph_snapshot,
755        };
756        self.request_tx
757            .send(job)
758            .map_err(|_| "inspect dispatch loop is unavailable".to_string())
759    }
760
761    fn wait_for_outcome(
762        &self,
763        key: JobKey,
764        caller_scope: JobScope,
765        cache: Arc<InspectCache>,
766        waiter_rx: Receiver<JobOutcome>,
767        snapshot: InspectSnapshot,
768    ) -> JobOutcome {
769        let timeout = after(self.soft_deadline);
770        let result_rx = self.result_rx.clone();
771        loop {
772            select! {
773                recv(waiter_rx) -> outcome => {
774                    return match outcome {
775                        Ok(outcome) => filter_outcome_for_scope_with_contributions(
776                            outcome,
777                            &snapshot,
778                            key.category,
779                            cache.as_ref(),
780                            &caller_scope,
781                        ),
782                        Err(_) => self.timeout_outcome(&key, &caller_scope, &cache, &snapshot),
783                    };
784                }
785                recv(result_rx) -> result => {
786                    match result {
787                        Ok(result) => self.route_completion(result),
788                        Err(_) => return self.timeout_outcome(&key, &caller_scope, &cache, &snapshot),
789                    }
790                }
791                recv(timeout) -> _ => {
792                    return self.timeout_outcome(&key, &caller_scope, &cache, &snapshot);
793                }
794            }
795        }
796    }
797
798    fn timeout_outcome(
799        &self,
800        key: &JobKey,
801        caller_scope: &JobScope,
802        cache: &InspectCache,
803        snapshot: &InspectSnapshot,
804    ) -> JobOutcome {
805        match cache.get_aggregated(key) {
806            Ok(Some(cached)) => filter_outcome_for_scope_with_contributions(
807                JobOutcome::Stale {
808                    cached: Some(cached),
809                    in_flight: true,
810                },
811                snapshot,
812                key.category,
813                cache,
814                caller_scope,
815            ),
816            Ok(None) => JobOutcome::Pending { in_flight: true },
817            Err(error) => JobOutcome::Failed {
818                message: error.to_string(),
819            },
820        }
821    }
822
823    fn route_completion(&self, result: InspectResult) {
824        let outcome = self.completion_outcome(result.clone());
825        let waiters = self
826            .in_flight
827            .lock()
828            .ok()
829            .and_then(|mut in_flight| in_flight.remove(&result.key))
830            .unwrap_or_default();
831        for waiter in waiters {
832            let _ = waiter.tx.send(outcome.clone());
833        }
834    }
835
836    fn route_tier2_reuse_completion(&self, result: InspectResult) {
837        let outcome = match result.outcome.clone() {
838            Ok(success) => JobOutcome::Fresh {
839                payload: success.aggregate,
840            },
841            Err(message) => JobOutcome::Failed { message },
842        };
843        let waiters = self
844            .in_flight
845            .lock()
846            .ok()
847            .and_then(|mut in_flight| in_flight.remove(&result.key))
848            .unwrap_or_default();
849        for waiter in waiters {
850            let _ = waiter.tx.send(outcome.clone());
851        }
852    }
853
854    fn completion_outcome(&self, result: InspectResult) -> JobOutcome {
855        let cache =
856            match self.cache_for_paths(result.inspect_dir.clone(), result.project_root.clone()) {
857                Ok(cache) => cache,
858                Err(message) => return JobOutcome::Failed { message },
859            };
860
861        match result.outcome {
862            Ok(success) => {
863                let store_result = if result.category.is_tier2() {
864                    cache.store_tier2_result(
865                        result.key.clone(),
866                        &success.scanned_files,
867                        &success.contributions,
868                        success.aggregate.clone(),
869                    )
870                } else {
871                    cache.store_aggregated(result.key, success.aggregate.clone())
872                };
873
874                match store_result {
875                    Ok(()) => JobOutcome::Fresh {
876                        payload: success.aggregate,
877                    },
878                    Err(error) => JobOutcome::Failed {
879                        message: error.to_string(),
880                    },
881                }
882            }
883            Err(message) => JobOutcome::Failed { message },
884        }
885    }
886}
887
888impl Default for InspectManager {
889    fn default() -> Self {
890        Self::new()
891    }
892}
893
894fn scope_files(project_root: &Path, scope: &JobScope) -> Vec<PathBuf> {
895    let mut files = crate::callgraph::walk_project_files(project_root)
896        .filter(|path| scope.contains(path))
897        .collect::<Vec<_>>();
898    files.sort();
899    files
900}
901
902fn current_project_files(project_root: &Path, files: &[PathBuf]) -> BTreeMap<String, PathBuf> {
903    files
904        .iter()
905        .map(|file| (relative_cache_key(project_root, file), file.clone()))
906        .collect()
907}
908
909fn build_tier2_callgraph_snapshot(project_root: &Path) -> Arc<CallgraphSnapshot> {
910    let mut graph = CallGraph::new(project_root.to_path_buf());
911    let graph_files = graph.project_files().to_vec();
912    let files = graph_files
913        .iter()
914        .map(canonicalize_for_snapshot)
915        .collect::<Vec<_>>();
916    let resolved_entry_points = super::entry_points::resolve_entry_points(project_root);
917
918    let mut exported_symbols = Vec::new();
919    let mut outbound_calls = Vec::new();
920    let mut entry_points = BTreeSet::new();
921
922    for file in &graph_files {
923        let snapshot_file = canonicalize_for_snapshot(file);
924        if is_entry_point_file(&resolved_entry_points, &snapshot_file) {
925            entry_points.insert(snapshot_file.clone());
926        }
927
928        let file_data = match graph.build_file(file) {
929            Ok(file_data) => file_data.clone(),
930            Err(_) => continue,
931        };
932
933        for symbol in &file_data.exported_symbols {
934            let metadata = file_data.symbol_metadata_for(symbol);
935            exported_symbols.push(CallgraphExport {
936                file: snapshot_file.clone(),
937                symbol: symbol.clone(),
938                kind: metadata
939                    .map(|metadata| symbol_kind_name(&metadata.kind))
940                    .unwrap_or("unknown")
941                    .to_string(),
942                line: metadata.map(|metadata| metadata.line).unwrap_or(1),
943            });
944        }
945
946        if let Some(default_symbol) = &file_data.default_export_symbol {
947            let metadata = file_data.symbol_metadata_for(default_symbol);
948            exported_symbols.push(CallgraphExport {
949                file: snapshot_file.clone(),
950                symbol: default_symbol.clone(),
951                kind: DEFAULT_EXPORT_MARKER_KIND.to_string(),
952                line: metadata.map(|metadata| metadata.line).unwrap_or(1),
953            });
954        }
955
956        for (caller_symbol, calls) in &file_data.calls_by_symbol {
957            for call in calls {
958                let target = match graph.resolve_cross_file_edge(
959                    &call.full_callee,
960                    &call.callee_name,
961                    file,
962                    &file_data.import_block,
963                ) {
964                    EdgeResolution::Resolved { file, symbol } => {
965                        let file = canonicalize_for_snapshot(&file);
966                        format!("{}::{symbol}", file.display())
967                    }
968                    // Unresolved cross-file edge. Before falling back to a bare
969                    // callee name, try to resolve it to a symbol DEFINED IN THE
970                    // SAME FILE (private functions included) — mirroring
971                    // build_reverse_index. This is what makes a local call like
972                    // `main()` -> `dispatch()` resolve to `main.rs::dispatch`
973                    // (the private command router) instead of leaking a bare
974                    // `dispatch` that dead_code then misresolves to an unrelated
975                    // exported `dispatch` in another file. Without this, liveness
976                    // breaks at every private same-file intermediary.
977                    EdgeResolution::Unresolved { callee_name } => {
978                        if is_bare_callee(&call.full_callee, &callee_name) {
979                            match resolve_symbol_query_in_data(&file_data, file, &callee_name) {
980                                Ok(symbol) => {
981                                    format!("{}::{symbol}", snapshot_file.display())
982                                }
983                                Err(_) => callee_name,
984                            }
985                        } else {
986                            callee_name
987                        }
988                    }
989                };
990                let target = if is_method_dispatch_callee(&call.full_callee, &call.callee_name) {
991                    format!("{target}{DISPATCHED_CALLEE_SEPARATOR}{}", call.full_callee)
992                } else {
993                    target
994                };
995                outbound_calls.push(CallgraphOutboundCall {
996                    caller_file: snapshot_file.clone(),
997                    caller_symbol: caller_symbol.clone(),
998                    target,
999                    line: call.line,
1000                });
1001            }
1002        }
1003    }
1004
1005    Arc::new(CallgraphSnapshot {
1006        generated_at: Some(SystemTime::now()),
1007        files,
1008        exported_symbols,
1009        outbound_calls,
1010        entry_points,
1011    })
1012}
1013
1014fn canonicalize_for_snapshot(path: &PathBuf) -> PathBuf {
1015    std::fs::canonicalize(path).unwrap_or_else(|_| normalize_path(path))
1016}
1017
1018fn is_entry_point_file(entry_points: &super::entry_points::EntryPointSet, file: &Path) -> bool {
1019    entry_points.is_entry_point(file)
1020}
1021
1022fn is_method_dispatch_callee(full_callee: &str, callee_name: &str) -> bool {
1023    let full_callee = full_callee.trim();
1024    if !full_callee.contains('.') || full_callee == callee_name.trim() {
1025        return false;
1026    }
1027
1028    full_callee
1029        .rsplit('.')
1030        .next()
1031        .map(|segment| segment.trim().trim_start_matches('?') == callee_name.trim())
1032        .unwrap_or(false)
1033}
1034
1035fn symbol_kind_name(kind: &SymbolKind) -> &'static str {
1036    match kind {
1037        SymbolKind::Function => "function",
1038        SymbolKind::Method => "method",
1039        SymbolKind::Class => "class",
1040        SymbolKind::Struct => "struct",
1041        SymbolKind::Interface => "interface",
1042        SymbolKind::Enum => "enum",
1043        SymbolKind::TypeAlias => "type_alias",
1044        SymbolKind::Variable => "variable",
1045        SymbolKind::Heading => "heading",
1046        SymbolKind::FileSummary => "file_summary",
1047    }
1048}
1049
1050fn load_contribution_fingerprint(
1051    cache: &InspectCache,
1052    category: InspectCategory,
1053) -> Result<ContributionFingerprint, String> {
1054    let (count, set_hash, hash_complete) = cache
1055        .contribution_fingerprint(category)
1056        .map_err(|error| error.to_string())?;
1057    Ok(ContributionFingerprint {
1058        count,
1059        set_hash,
1060        hash_complete,
1061    })
1062}
1063
1064fn current_file_fingerprint(
1065    project_root: &Path,
1066    files: &[PathBuf],
1067) -> Result<ContributionFingerprint, String> {
1068    let mut entries = Vec::with_capacity(files.len());
1069    let mut hash_complete = true;
1070    for file in files {
1071        let freshness = cache_freshness::collect(file)
1072            .map_err(|error| format!("failed to fingerprint {}: {error}", file.display()))?;
1073        let relative_path = relative_cache_key(project_root, file);
1074        let mtime_ns = system_time_to_ns_i64(freshness.mtime);
1075        if freshness.content_hash == cache_freshness::zero_hash() {
1076            hash_complete = false;
1077        }
1078        entries.push((
1079            relative_path,
1080            mtime_ns,
1081            freshness.size,
1082            freshness.content_hash.to_hex().to_string(),
1083        ));
1084    }
1085    entries.sort_by(|left, right| left.0.cmp(&right.0));
1086
1087    let mut hasher = blake3::Hasher::new();
1088    for (relative_path, mtime_ns, file_size, file_hash) in &entries {
1089        update_contribution_fingerprint_hash(
1090            &mut hasher,
1091            relative_path,
1092            *mtime_ns,
1093            *file_size,
1094            file_hash,
1095        );
1096    }
1097
1098    Ok(ContributionFingerprint {
1099        count: entries.len(),
1100        set_hash: hasher.finalize().to_hex().to_string(),
1101        hash_complete,
1102    })
1103}
1104
1105fn update_contribution_fingerprint_hash(
1106    hasher: &mut blake3::Hasher,
1107    relative_path: &str,
1108    mtime_ns: i64,
1109    file_size: u64,
1110    file_hash: &str,
1111) {
1112    hasher.update(relative_path.as_bytes());
1113    hasher.update(&[0]);
1114    hasher.update(&mtime_ns.to_le_bytes());
1115    hasher.update(&file_size.to_le_bytes());
1116    hasher.update(&[0]);
1117    hasher.update(file_hash.as_bytes());
1118}
1119
1120fn verify_contribution_file_strict(path: &Path, cached: &FileFreshness) -> ContributionFreshness {
1121    match cache_freshness::verify_file_strict(path, cached) {
1122        FreshnessVerdict::HotFresh => ContributionFreshness::Fresh {
1123            metadata_changed: false,
1124            freshness: *cached,
1125        },
1126        FreshnessVerdict::ContentFresh {
1127            new_mtime,
1128            new_size,
1129        } => ContributionFreshness::Fresh {
1130            metadata_changed: true,
1131            freshness: FileFreshness {
1132                mtime: new_mtime,
1133                size: new_size,
1134                content_hash: cached.content_hash,
1135            },
1136        },
1137        FreshnessVerdict::Stale => ContributionFreshness::Stale,
1138        FreshnessVerdict::Deleted => ContributionFreshness::Deleted,
1139    }
1140}
1141
1142fn load_contribution_freshness(
1143    cache: &InspectCache,
1144    category: InspectCategory,
1145) -> Result<Vec<CachedContributionFreshness>, String> {
1146    cache
1147        .contribution_freshness(category)
1148        .map_err(|error| error.to_string())
1149        .map(|records| {
1150            records
1151                .into_iter()
1152                .map(|(file_path, freshness)| CachedContributionFreshness {
1153                    file_path,
1154                    freshness,
1155                })
1156                .collect()
1157        })
1158}
1159
1160fn freshness_record_relative_key(record: &CachedContributionFreshness) -> String {
1161    record.file_path.to_string_lossy().to_string()
1162}
1163
1164fn system_time_to_ns_i64(time: SystemTime) -> i64 {
1165    let nanos = time
1166        .duration_since(UNIX_EPOCH)
1167        .unwrap_or_else(|_| Duration::from_secs(0))
1168        .as_nanos();
1169    nanos.min(i64::MAX as u128) as i64
1170}
1171
1172fn relative_cache_key(project_root: &Path, path: &Path) -> String {
1173    path.strip_prefix(project_root)
1174        .unwrap_or(path)
1175        .to_string_lossy()
1176        .to_string()
1177}
1178
1179fn load_contributions(
1180    cache: &InspectCache,
1181    job: &InspectJob,
1182) -> Result<Vec<FileContribution>, String> {
1183    cache
1184        .load_tier2_contributions(job.category)
1185        .map_err(|error| error.to_string())
1186        .map(|records| {
1187            records
1188                .into_iter()
1189                .map(|record| contribution_from_record(&job.project_root, record))
1190                .collect()
1191        })
1192}
1193
1194fn contribution_from_record(
1195    project_root: &Path,
1196    record: super::cache::ContributionRecord,
1197) -> FileContribution {
1198    FileContribution::new(
1199        record.category,
1200        project_root.join(record.file_path),
1201        record.freshness,
1202        record.contribution,
1203    )
1204    .with_type_ref_names(record.type_ref_names)
1205}
1206
1207fn run_tier2_scan(job: &InspectJob) -> InspectResult {
1208    use super::scanners;
1209
1210    match job.category {
1211        InspectCategory::DeadCode => scanners::dead_code::run_dead_code_scan(job),
1212        InspectCategory::UnusedExports => scanners::unused_exports::run_unused_exports_scan(job),
1213        InspectCategory::Duplicates => scanners::duplicates::run_duplicates_scan(job),
1214        other => InspectResult::failed(
1215            job,
1216            format!("inspect category '{other}' is not an active Tier 2 scanner"),
1217            Duration::from_secs(0),
1218        ),
1219    }
1220}
1221
1222fn roll_up_tier2_contributions(job: &InspectJob, contributions: &[FileContribution]) -> Value {
1223    roll_up_tier2_contributions_with_limit(job, contributions, Some(MAX_DRILL_DOWN_ITEMS))
1224}
1225
1226fn roll_up_tier2_contributions_with_limit(
1227    job: &InspectJob,
1228    contributions: &[FileContribution],
1229    drill_down_limit: Option<usize>,
1230) -> Value {
1231    match job.category {
1232        InspectCategory::DeadCode => {
1233            roll_up_dead_code_contributions(job, contributions, drill_down_limit)
1234        }
1235        InspectCategory::UnusedExports => {
1236            roll_up_unused_exports_contributions(job, contributions, drill_down_limit)
1237        }
1238        InspectCategory::Duplicates => {
1239            roll_up_duplicate_contributions(job, contributions, drill_down_limit)
1240        }
1241        _ => json!({
1242            "count": 0,
1243            "items": [],
1244            "scanned_files": contributions.len(),
1245        }),
1246    }
1247}
1248
1249fn scoped_tier2_payload_from_contributions(
1250    snapshot: &InspectSnapshot,
1251    category: InspectCategory,
1252    cache: &InspectCache,
1253    project_payload: Value,
1254    scope: &JobScope,
1255) -> Result<Value, String> {
1256    if scope.is_project_wide() {
1257        return Ok(project_payload);
1258    }
1259
1260    let project_scope = JobScope::for_project(snapshot.project_root.clone());
1261    let rollup_job = scoped_tier2_rollup_job(snapshot, category, &project_scope);
1262    let contributions = load_contributions(cache, &rollup_job)?;
1263    let full_payload = roll_up_tier2_contributions_with_limit(&rollup_job, &contributions, None);
1264    let scoped_payload = filter_payload_for_scope(full_payload, scope);
1265    Ok(cap_payload_drill_down(scoped_payload, MAX_DRILL_DOWN_ITEMS))
1266}
1267
1268fn scoped_tier2_rollup_job(
1269    snapshot: &InspectSnapshot,
1270    category: InspectCategory,
1271    scope: &JobScope,
1272) -> InspectJob {
1273    InspectJob {
1274        job_id: 0,
1275        key: JobKey::for_project_category(category),
1276        category,
1277        scope_files: scope_files(&snapshot.project_root, scope),
1278        project_root: snapshot.project_root.clone(),
1279        inspect_dir: snapshot.inspect_dir.clone(),
1280        config: Arc::clone(&snapshot.config),
1281        symbol_cache: Arc::clone(&snapshot.symbol_cache),
1282        callgraph_snapshot: (category == InspectCategory::DeadCode)
1283            .then(|| Arc::new(CallgraphSnapshot::default())),
1284    }
1285}
1286
1287fn roll_up_dead_code_contributions(
1288    job: &InspectJob,
1289    contributions: &[FileContribution],
1290    drill_down_limit: Option<usize>,
1291) -> Value {
1292    if job.callgraph_snapshot.is_none() {
1293        return super::scanners::dead_code::callgraph_unavailable_aggregate(job.scope_files.len());
1294    }
1295
1296    let public_api_files = super::scanners::dead_code::collect_public_api_files(&job.project_root);
1297    super::scanners::dead_code::aggregate_dead_code_contributions_with_limit(
1298        contributions,
1299        &public_api_files,
1300        drill_down_limit,
1301    )
1302}
1303
1304fn roll_up_unused_exports_contributions(
1305    job: &InspectJob,
1306    contributions: &[FileContribution],
1307    drill_down_limit: Option<usize>,
1308) -> Value {
1309    let parsed = contributions
1310        .iter()
1311        .filter_map(|contribution| {
1312            serde_json::from_value::<UnusedExportsContribution>(contribution.contribution.clone())
1313                .ok()
1314        })
1315        .collect::<Vec<_>>();
1316
1317    let (public_api_entries, package_warnings) = unused_public_api_entries(&job.project_root);
1318    let mut imported_by: BTreeMap<(String, String), BTreeSet<String>> = BTreeMap::new();
1319    let mut uncertain_by: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
1320    for scan in &parsed {
1321        for import in &scan.imports {
1322            let Some(resolved_file) = &import.resolved_file else {
1323                continue;
1324            };
1325            for name in &import.named {
1326                if name == "*" {
1327                    uncertain_by
1328                        .entry(resolved_file.clone())
1329                        .or_default()
1330                        .insert(scan.file.clone());
1331                } else {
1332                    imported_by
1333                        .entry((resolved_file.clone(), name.clone()))
1334                        .or_default()
1335                        .insert(scan.file.clone());
1336                }
1337            }
1338        }
1339    }
1340
1341    let mut count = 0usize;
1342    let mut items = Vec::new();
1343    let mut uncertain_count = 0usize;
1344    let mut uncertain_items = Vec::new();
1345    for scan in &parsed {
1346        if public_api_entries.contains(&scan.file) {
1347            continue;
1348        }
1349
1350        for export in &scan.exports {
1351            let imported = imported_by
1352                .get(&(scan.file.clone(), export.symbol.clone()))
1353                .map(|files| !files.is_empty())
1354                .unwrap_or(false);
1355            let uncertain = uncertain_by
1356                .get(&scan.file)
1357                .map(|files| !files.is_empty())
1358                .unwrap_or(false);
1359
1360            if imported {
1361                continue;
1362            }
1363            if uncertain {
1364                uncertain_count += 1;
1365                if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
1366                    uncertain_items.push(json!({
1367                        "file": scan.file,
1368                        "symbol": export.symbol,
1369                        "kind": export.kind,
1370                        "line": export.line,
1371                        "reason": "wildcard_import",
1372                    }));
1373                }
1374                continue;
1375            }
1376
1377            count += 1;
1378            if drill_down_limit.is_none_or(|limit| items.len() < limit) {
1379                items.push(json!({
1380                    "file": scan.file,
1381                    "symbol": export.symbol,
1382                    "kind": export.kind,
1383                    "line": export.line,
1384                }));
1385            }
1386        }
1387    }
1388
1389    let mut aggregate = json!({
1390        "count": count,
1391        "items": items,
1392        "drill_down_capped": drill_down_limit.is_some_and(|limit| count > limit),
1393        "scanned_files": parsed.len(),
1394        "languages_skipped": skipped_languages(&job.scope_files, LanguageSkipMode::UnusedExports),
1395        "uncertain_count": uncertain_count,
1396        "uncertain_items": uncertain_items,
1397    });
1398    if !package_warnings.is_empty() {
1399        aggregate["note"] = Value::String(package_warnings.join("; "));
1400    }
1401    aggregate
1402}
1403
1404fn roll_up_duplicate_contributions(
1405    job: &InspectJob,
1406    contributions: &[FileContribution],
1407    drill_down_limit: Option<usize>,
1408) -> Value {
1409    super::scanners::duplicates::aggregate_duplicate_contributions_with_limit(
1410        contributions,
1411        skipped_languages(&job.scope_files, LanguageSkipMode::Duplicates),
1412        drill_down_limit,
1413    )
1414}
1415
1416fn cap_payload_drill_down(mut payload: Value, limit: usize) -> Value {
1417    let mut capped = false;
1418    if let Some(items) = payload.get_mut("items").and_then(Value::as_array_mut) {
1419        capped |= items.len() > limit;
1420        items.truncate(limit);
1421    }
1422    if let Some(groups) = payload.get_mut("groups").and_then(Value::as_array_mut) {
1423        capped |= groups.len() > limit;
1424        groups.truncate(limit);
1425    }
1426    if let Some(object) = payload.as_object_mut() {
1427        object.insert("drill_down_capped".to_string(), json!(capped));
1428    }
1429    payload
1430}
1431
1432const MAX_DRILL_DOWN_ITEMS: usize = 100;
1433
1434#[derive(Debug, Clone, Deserialize)]
1435struct ExportContribution {
1436    symbol: String,
1437    kind: String,
1438    line: u32,
1439}
1440
1441#[derive(Debug, Clone, Deserialize)]
1442struct UnusedExportsContribution {
1443    file: String,
1444    exports: Vec<ExportContribution>,
1445    imports: Vec<ImportContribution>,
1446}
1447
1448#[derive(Debug, Clone, Deserialize)]
1449struct ImportContribution {
1450    resolved_file: Option<String>,
1451    named: Vec<String>,
1452}
1453
1454#[derive(Debug, Clone, Copy)]
1455enum LanguageSkipMode {
1456    Duplicates,
1457    UnusedExports,
1458}
1459
1460fn category_contributions_depend_on_entry_points(category: InspectCategory) -> bool {
1461    matches!(
1462        category,
1463        InspectCategory::DeadCode | InspectCategory::UnusedExports
1464    )
1465}
1466
1467fn skipped_languages(files: &[PathBuf], mode: LanguageSkipMode) -> Vec<String> {
1468    files
1469        .iter()
1470        .filter_map(|file| skipped_language(file, mode))
1471        .collect::<BTreeSet<_>>()
1472        .into_iter()
1473        .collect()
1474}
1475
1476fn skipped_language(file: &Path, mode: LanguageSkipMode) -> Option<String> {
1477    let Some(language) = crate::parser::detect_language(file) else {
1478        return match mode {
1479            LanguageSkipMode::Duplicates => Some("unknown".to_string()),
1480            LanguageSkipMode::UnusedExports => None,
1481        };
1482    };
1483
1484    let skipped = match mode {
1485        LanguageSkipMode::Duplicates => !duplicates_supports_language(language),
1486        LanguageSkipMode::UnusedExports => !is_js_ts_language(language),
1487    };
1488    skipped.then(|| language_name(language).to_string())
1489}
1490
1491fn duplicates_supports_language(language: crate::parser::LangId) -> bool {
1492    !matches!(
1493        language,
1494        crate::parser::LangId::Bash
1495            | crate::parser::LangId::Html
1496            | crate::parser::LangId::Json
1497            | crate::parser::LangId::Scala
1498            | crate::parser::LangId::Solidity
1499            | crate::parser::LangId::Vue
1500            | crate::parser::LangId::Markdown
1501            | crate::parser::LangId::Java
1502            | crate::parser::LangId::Ruby
1503            | crate::parser::LangId::Kotlin
1504            | crate::parser::LangId::Swift
1505            | crate::parser::LangId::Php
1506            | crate::parser::LangId::Lua
1507            | crate::parser::LangId::Perl
1508    )
1509}
1510
1511fn is_js_ts_language(language: crate::parser::LangId) -> bool {
1512    matches!(
1513        language,
1514        crate::parser::LangId::TypeScript
1515            | crate::parser::LangId::Tsx
1516            | crate::parser::LangId::JavaScript
1517    )
1518}
1519
1520fn language_name(language: crate::parser::LangId) -> &'static str {
1521    match language {
1522        crate::parser::LangId::TypeScript => "typescript",
1523        crate::parser::LangId::Tsx => "tsx",
1524        crate::parser::LangId::JavaScript => "javascript",
1525        crate::parser::LangId::Python => "python",
1526        crate::parser::LangId::Rust => "rust",
1527        crate::parser::LangId::Go => "go",
1528        crate::parser::LangId::C => "c",
1529        crate::parser::LangId::Cpp => "cpp",
1530        crate::parser::LangId::Zig => "zig",
1531        crate::parser::LangId::CSharp => "csharp",
1532        crate::parser::LangId::Bash => "bash",
1533        crate::parser::LangId::Html => "html",
1534        crate::parser::LangId::Markdown => "markdown",
1535        crate::parser::LangId::Yaml => "yaml",
1536        crate::parser::LangId::Solidity => "solidity",
1537        crate::parser::LangId::Vue => "vue",
1538        crate::parser::LangId::Json => "json",
1539        crate::parser::LangId::Scala => "scala",
1540        crate::parser::LangId::Java => "java",
1541        crate::parser::LangId::Ruby => "ruby",
1542        crate::parser::LangId::Kotlin => "kotlin",
1543        crate::parser::LangId::Swift => "swift",
1544        crate::parser::LangId::Php => "php",
1545        crate::parser::LangId::Lua => "lua",
1546        crate::parser::LangId::Perl => "perl",
1547    }
1548}
1549
1550fn unused_public_api_entries(project_root: &Path) -> (BTreeSet<String>, Vec<String>) {
1551    let entry_points = super::entry_points::resolve_entry_points(project_root);
1552    (
1553        entry_points.public_api_files_relative(project_root),
1554        entry_points.warnings().to_vec(),
1555    )
1556}
1557
1558fn filter_outcome_for_scope_with_contributions(
1559    outcome: JobOutcome,
1560    snapshot: &InspectSnapshot,
1561    category: InspectCategory,
1562    cache: &InspectCache,
1563    scope: &JobScope,
1564) -> JobOutcome {
1565    if !category.is_tier2() || scope.is_project_wide() {
1566        return filter_outcome_for_scope(outcome, scope);
1567    }
1568
1569    match outcome {
1570        JobOutcome::Fresh { payload } => {
1571            match scoped_tier2_payload_from_contributions(snapshot, category, cache, payload, scope)
1572            {
1573                Ok(payload) => JobOutcome::Fresh { payload },
1574                Err(message) => JobOutcome::Failed { message },
1575            }
1576        }
1577        JobOutcome::Stale { cached, in_flight } => match cached {
1578            Some(payload) => {
1579                match scoped_tier2_payload_from_contributions(
1580                    snapshot, category, cache, payload, scope,
1581                ) {
1582                    Ok(payload) => JobOutcome::Stale {
1583                        cached: Some(payload),
1584                        in_flight,
1585                    },
1586                    Err(message) => JobOutcome::Failed { message },
1587                }
1588            }
1589            None => JobOutcome::Stale {
1590                cached: None,
1591                in_flight,
1592            },
1593        },
1594        JobOutcome::Pending { in_flight } => JobOutcome::Pending { in_flight },
1595        JobOutcome::Failed { message } => JobOutcome::Failed { message },
1596    }
1597}
1598
1599fn filter_outcome_for_scope(outcome: JobOutcome, scope: &JobScope) -> JobOutcome {
1600    match outcome {
1601        JobOutcome::Fresh { payload } => JobOutcome::Fresh {
1602            payload: filter_payload_for_scope(payload, scope),
1603        },
1604        JobOutcome::Stale { cached, in_flight } => JobOutcome::Stale {
1605            cached: cached.map(|payload| filter_payload_for_scope(payload, scope)),
1606            in_flight,
1607        },
1608        JobOutcome::Pending { in_flight } => JobOutcome::Pending { in_flight },
1609        JobOutcome::Failed { message } => JobOutcome::Failed { message },
1610    }
1611}
1612
1613fn filter_payload_for_scope(mut payload: serde_json::Value, scope: &JobScope) -> serde_json::Value {
1614    if scope.is_project_wide() {
1615        return payload;
1616    }
1617
1618    // Scoped Tier 2 callers pass an uncapped rollup into this filter and cap
1619    // drill-down only afterwards, so the recomputed count below remains the
1620    // true in-scope total rather than the size of a capped sample.
1621    if let Some(items) = payload
1622        .get_mut("items")
1623        .and_then(|value| value.as_array_mut())
1624    {
1625        let count = filter_values_for_scope(items, scope);
1626        if let Some(object) = payload.as_object_mut() {
1627            object.insert("count".to_string(), serde_json::json!(count));
1628            if object.contains_key("total_groups") {
1629                object.insert("total_groups".to_string(), serde_json::json!(count));
1630            }
1631            if object.contains_key("groups_count") {
1632                object.insert("groups_count".to_string(), serde_json::json!(count));
1633            }
1634        }
1635    }
1636
1637    if let Some(groups) = payload
1638        .get_mut("groups")
1639        .and_then(|value| value.as_array_mut())
1640    {
1641        let count = filter_values_for_scope(groups, scope);
1642        if let Some(object) = payload.as_object_mut() {
1643            object.insert("count".to_string(), serde_json::json!(count));
1644            object.insert("total_groups".to_string(), serde_json::json!(count));
1645            if object.contains_key("groups_count") {
1646                object.insert("groups_count".to_string(), serde_json::json!(count));
1647            }
1648        }
1649    }
1650
1651    payload
1652}
1653
1654fn filter_values_for_scope(values: &mut Vec<serde_json::Value>, scope: &JobScope) -> usize {
1655    values.retain_mut(|value| prune_value_for_scope(value, scope));
1656    values.len()
1657}
1658
1659fn prune_value_for_scope(value: &mut serde_json::Value, scope: &JobScope) -> bool {
1660    if let Some(file) = value.get("file").and_then(|file| file.as_str()) {
1661        return scope.contains_display_path(file);
1662    }
1663
1664    let first_scoped_occurrence = if let Some(files) = value
1665        .get_mut("files")
1666        .and_then(|files| files.as_array_mut())
1667    {
1668        files.retain(|file| {
1669            file.as_str()
1670                .is_some_and(|file| scope.contains_display_path(display_file_from_occurrence(file)))
1671        });
1672        if files.len() < 2 {
1673            return false;
1674        }
1675        files.first().and_then(Value::as_str).map(str::to_string)
1676    } else {
1677        None
1678    };
1679
1680    if let Some(occurrence) = first_scoped_occurrence {
1681        update_duplicate_group_sample(value, &occurrence);
1682    }
1683
1684    true
1685}
1686
1687fn update_duplicate_group_sample(value: &mut serde_json::Value, occurrence: &str) {
1688    let Some((file, start_line, end_line)) = parse_duplicate_occurrence(occurrence) else {
1689        return;
1690    };
1691    let Some(object) = value.as_object_mut() else {
1692        return;
1693    };
1694
1695    if object.contains_key("sample_file") {
1696        object.insert("sample_file".to_string(), json!(file));
1697    }
1698    if object.contains_key("sample_start_line") {
1699        object.insert("sample_start_line".to_string(), json!(start_line));
1700    }
1701    if object.contains_key("sample_end_line") {
1702        object.insert("sample_end_line".to_string(), json!(end_line));
1703    }
1704}
1705
1706fn parse_duplicate_occurrence(value: &str) -> Option<(&str, u64, u64)> {
1707    let (file, range) = value.rsplit_once(':')?;
1708    let (start, end) = range.split_once('-')?;
1709    if !start.chars().all(|char| char.is_ascii_digit())
1710        || !end.chars().all(|char| char.is_ascii_digit())
1711    {
1712        return None;
1713    }
1714
1715    Some((file, start.parse().ok()?, end.parse().ok()?))
1716}
1717
1718fn display_file_from_occurrence(value: &str) -> &str {
1719    let Some((file, range)) = value.rsplit_once(':') else {
1720        return value;
1721    };
1722    let Some((start, end)) = range.split_once('-') else {
1723        return value;
1724    };
1725    if start.chars().all(|char| char.is_ascii_digit())
1726        && end.chars().all(|char| char.is_ascii_digit())
1727    {
1728        file
1729    } else {
1730        value
1731    }
1732}
1733
1734#[allow(dead_code)]
1735fn normalize_scope_root(path: &Path) -> PathBuf {
1736    std::fs::canonicalize(path).unwrap_or_else(|_| normalize_path(path))
1737}