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