Skip to main content

aft/inspect/
manager.rs

1use std::collections::{BTreeMap, BTreeSet, HashMap};
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
4use std::sync::{Arc, Condvar, 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, InspectCacheRead, InspectDbTimings, Tier2ContributionUpdates};
12use super::dispatch::{default_worker, start_dispatch_loop, InspectWorker};
13use super::freshness::{verify_contribution_file, ContributionFreshness};
14use super::job::{
15    is_test_file, CallgraphSnapshot, FileContribution, InspectCategory, InspectJob, InspectResult,
16    InspectScanSuccess, InspectSnapshot, JobKey, JobOutcome, JobScope,
17};
18use super::oxc_engine::LivenessVerdict;
19use super::oxc_engine::{
20    analyze_file_facts, analyze_files_with_cache, normalize_input_path, AnalyzeOptions,
21    DynamicImportFact, ExportFact, FileFacts, FileId, ImportFact, OxcEngineResult, OxcFactsCache,
22    ReExportFact, FACTS_FORMAT_VERSION, OXC_PROVENANCE,
23};
24use crate::cache_freshness::{self, FileFreshness, FreshnessVerdict};
25#[cfg(test)]
26use crate::callgraph_store::project_dead_code_snapshot;
27use crate::callgraph_store::{
28    project_dead_code_snapshot_with_revision, CallGraphStore, CallGraphStoreError,
29    ReadonlyCallGraphStore,
30};
31use crate::cold_build_limiter;
32
33const DEFAULT_SOFT_DEADLINE: Duration = Duration::from_secs(1);
34
35type WaiterTx = Sender<JobOutcome>;
36
37#[derive(Clone)]
38struct Waiter {
39    tx: WaiterTx,
40}
41
42struct CachedContributionFreshness {
43    file_path: PathBuf,
44    freshness: FileFreshness,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Hash)]
48struct InspectCacheIdentity {
49    sqlite_path: PathBuf,
50    project_root: PathBuf,
51}
52
53/// A published generation names an immutable cold build; the durable revision
54/// distinguishes the cheap in-place refreshes of that generation.
55#[derive(Debug, Clone, PartialEq, Eq)]
56struct CallgraphProjectionIdentity {
57    project_root: PathBuf,
58    generation: Option<String>,
59    /// Only legacy stores lack a generation label, so their concrete database
60    /// path keeps fallback stores distinct without weakening generation checks.
61    legacy_sqlite_path: Option<PathBuf>,
62    write_revision: u64,
63}
64
65#[derive(Debug)]
66struct CachedCallgraphProjection {
67    identity: CallgraphProjectionIdentity,
68    snapshot: Arc<CallgraphSnapshot>,
69    estimated_bytes: u64,
70}
71
72#[derive(Debug, Clone)]
73pub struct Tier2RunSubmissionError {
74    pub category: InspectCategory,
75    pub message: String,
76}
77
78#[derive(Debug, Clone, Default)]
79pub struct Tier2RunSubmission {
80    pub queued_categories: Vec<InspectCategory>,
81    pub newly_queued_categories: Vec<InspectCategory>,
82    pub deferred_categories: Vec<InspectCategory>,
83    pub errors: Vec<Tier2RunSubmissionError>,
84}
85
86impl Tier2RunSubmission {
87    pub fn has_new_work(&self) -> bool {
88        !self.newly_queued_categories.is_empty()
89    }
90}
91
92#[derive(Debug, Clone)]
93struct Tier2ReuseOptions {
94    force_rescan_paths: BTreeSet<PathBuf>,
95    allow_callgraph_cold_build: bool,
96    require_callgraph_snapshot: bool,
97    interactive: bool,
98}
99
100impl Tier2ReuseOptions {
101    fn has_force_paths(&self) -> bool {
102        !self.force_rescan_paths.is_empty()
103    }
104}
105
106impl Default for Tier2ReuseOptions {
107    fn default() -> Self {
108        Self {
109            force_rescan_paths: BTreeSet::new(),
110            allow_callgraph_cold_build: true,
111            require_callgraph_snapshot: false,
112            interactive: false,
113        }
114    }
115}
116
117#[derive(Clone, Copy, Debug, Eq, PartialEq)]
118pub(crate) enum InspectBuilderState {
119    Building,
120    QueuedBehindColdBuilds,
121    GatedBySemanticSeed,
122    Suspended,
123    BuildDenied,
124    Absent,
125}
126
127impl InspectBuilderState {
128    pub(crate) const fn as_str(self) -> &'static str {
129        match self {
130            Self::Building => "building",
131            Self::QueuedBehindColdBuilds => "queued_behind_cold_builds",
132            Self::GatedBySemanticSeed => "gated_by_semantic_seed",
133            Self::Suspended => "suspended",
134            Self::BuildDenied => "build_denied (borrow-only)",
135            Self::Absent => "absent",
136        }
137    }
138}
139
140/// One admission into the inspect builder registry. Health's `tier2` field and
141/// inspect refusals both read this map so a published aggregate cannot report
142/// ready while a rebuild is still registered.
143///
144/// Failed attempts keep their history here after the in-flight state is
145/// cleared. A fast-failing rebuild that restarts on every probe would otherwise
146/// look like a brand-new warm-up (`building` at age 0) even though the same
147/// terminal keeps repeating.
148struct BuilderStateEntry {
149    state: Option<InspectBuilderState>,
150    started_at: Instant,
151    started_unix: u64,
152    first_attempt_unix: u64,
153    attempt_count: u64,
154    last_failure: Option<String>,
155    suspension: Option<crate::build_breaker::BuildSuspension>,
156}
157
158impl BuilderStateEntry {
159    fn new(state: InspectBuilderState) -> Self {
160        let now = unix_now_secs();
161        Self {
162            state: Some(state),
163            started_at: Instant::now(),
164            started_unix: now,
165            first_attempt_unix: now,
166            attempt_count: 0,
167            last_failure: None,
168            suspension: None,
169        }
170    }
171
172    fn is_in_flight(&self) -> bool {
173        self.state.is_some_and(|state| {
174            matches!(
175                state,
176                InspectBuilderState::Building
177                    | InspectBuilderState::QueuedBehindColdBuilds
178                    | InspectBuilderState::GatedBySemanticSeed
179            )
180        })
181    }
182
183    fn begin_attempt(&mut self, state: InspectBuilderState) {
184        self.state = Some(state);
185        self.suspension = None;
186        self.started_at = Instant::now();
187        self.started_unix = unix_now_secs();
188        if self.attempt_count == 0 && self.last_failure.is_none() {
189            self.first_attempt_unix = self.started_unix;
190        }
191    }
192
193    fn record_failure(&mut self, terminal: String) {
194        self.state = None;
195        self.suspension = None;
196        self.attempt_count = self.attempt_count.saturating_add(1);
197        self.last_failure = Some(terminal);
198    }
199
200    fn record_suspension(&mut self, suspension: crate::build_breaker::BuildSuspension) {
201        self.state = Some(InspectBuilderState::Suspended);
202        self.last_failure = None;
203        self.suspension = Some(suspension);
204    }
205
206    fn detail(&self) -> String {
207        if let Some(suspension) = self.suspension.as_ref() {
208            let now_ms = SystemTime::now()
209                .duration_since(UNIX_EPOCH)
210                .unwrap_or_default()
211                .as_millis()
212                .min(u128::from(u64::MAX)) as u64;
213            return format!(
214                "suspended domain={} deaths={} age_s={} reason={}",
215                suspension.domain.as_str(),
216                suspension.death_count,
217                suspension.age_seconds_at(now_ms),
218                suspension.reason,
219            );
220        }
221        if let Some(terminal) = self.last_failure.as_deref() {
222            return format!(
223                "last attempt failed: {terminal} (attempt {}, first at {})",
224                self.attempt_count, self.first_attempt_unix
225            );
226        }
227        match self.state {
228            Some(InspectBuilderState::Building) => format!(
229                "building since {} (age_s={})",
230                self.started_unix,
231                self.started_at.elapsed().as_secs()
232            ),
233            Some(other) => other.as_str().to_string(),
234            None => InspectBuilderState::Absent.as_str().to_string(),
235        }
236    }
237}
238
239fn unix_now_secs() -> u64 {
240    SystemTime::now()
241        .duration_since(UNIX_EPOCH)
242        .map(|duration| duration.as_secs())
243        .unwrap_or(0)
244}
245
246enum BuilderAttemptTerminal {
247    Succeeded,
248    Failed(String),
249    Inconclusive,
250}
251
252fn builder_attempt_terminal(outcome: &JobOutcome) -> BuilderAttemptTerminal {
253    match outcome {
254        JobOutcome::Fresh { payload } if callgraph_unavailable_payload(payload) => {
255            BuilderAttemptTerminal::Failed("callgraph_unavailable".to_string())
256        }
257        JobOutcome::Fresh { .. } => BuilderAttemptTerminal::Succeeded,
258        JobOutcome::Failed { message } => {
259            BuilderAttemptTerminal::Failed(builder_failure_terminal(message))
260        }
261        JobOutcome::Stale { .. } | JobOutcome::Pending { .. } => {
262            BuilderAttemptTerminal::Inconclusive
263        }
264    }
265}
266
267fn callgraph_unavailable_payload(payload: &Value) -> bool {
268    payload.get("callgraph_available").and_then(Value::as_bool) == Some(false)
269        || payload
270            .get("notes")
271            .and_then(Value::as_array)
272            .is_some_and(|notes| {
273                notes
274                    .iter()
275                    .any(|note| note.as_str() == Some("callgraph_unavailable"))
276            })
277}
278
279fn builder_failure_terminal(message: &str) -> String {
280    if message.contains("callgraph_unavailable") {
281        "callgraph_unavailable".to_string()
282    } else {
283        message
284            .lines()
285            .next()
286            .unwrap_or("failed")
287            .chars()
288            .take(64)
289            .collect()
290    }
291}
292
293fn callgraph_store_ready_for_dead_code(callgraph_dir: PathBuf, project_root: PathBuf) -> bool {
294    match CallGraphStore::open_readonly(callgraph_dir, project_root) {
295        Ok(Some(store)) => store
296            .stale_files()
297            .ok()
298            .is_some_and(|files| files.is_empty()),
299        _ => false,
300    }
301}
302
303/// Drops the builder-registry entry (and any leftover waiters) if a reuse
304/// worker returns, panics, or is cancelled without going through the
305/// completion router. Admission and exit must be paired; a leftover
306/// registration reads as `building` with no running job.
307struct Tier2FlightExitGuard<'a> {
308    manager: &'a InspectManager,
309    key: JobKey,
310}
311
312impl Drop for Tier2FlightExitGuard<'_> {
313    fn drop(&mut self) {
314        self.manager.finish_tier2_flight(
315            &self.key,
316            JobOutcome::Failed {
317                message: "tier2 reuse worker exited without publishing a result".to_string(),
318            },
319        );
320    }
321}
322
323fn cached_tier2_aggregate_usable(
324    category: InspectCategory,
325    options: &Tier2ReuseOptions,
326    aggregate: &Value,
327) -> bool {
328    if category == InspectCategory::DeadCode
329        && options.allow_callgraph_cold_build
330        && aggregate
331            .get("callgraph_available")
332            .and_then(Value::as_bool)
333            == Some(false)
334    {
335        return false;
336    }
337    true
338}
339
340pub struct InspectManager {
341    request_tx: Sender<InspectJob>,
342    result_rx: Receiver<InspectResult>,
343    #[allow(dead_code)]
344    pool: Arc<rayon::ThreadPool>,
345    in_flight: Mutex<HashMap<JobKey, Vec<Waiter>>>,
346    in_flight_changed: Condvar,
347    caches: Mutex<HashMap<InspectCacheIdentity, Arc<InspectCache>>>,
348    /// One root-scoped dead-code graph projection. It is cleared with the
349    /// manager's other idle artifacts rather than on a separate timer.
350    callgraph_projection: Mutex<Option<CachedCallgraphProjection>>,
351    oxc_facts_cache: Mutex<OxcFactsCache>,
352    soft_deadline: Duration,
353    next_job_id: AtomicU64,
354    heavy_root_work_allowed: Arc<AtomicBool>,
355    semantic_cold_seed_active: Arc<AtomicBool>,
356    cold_build_limiter: Mutex<Arc<cold_build_limiter::ColdBuildLimiter>>,
357    /// Inspect refusals (`builder_state=...`) and health's `tier2` field both
358    /// read this registry. The waiter map (`in_flight`) fans out completions;
359    /// both surfaces treat a category as busy when it has an entry here, and
360    /// fall back to the waiter map if the registry is empty.
361    builder_states: Mutex<HashMap<JobKey, BuilderStateEntry>>,
362    automatic_tier2_refresh_allowed: AtomicBool,
363    automatic_tier2_skip_logged: AtomicBool,
364    automatic_tier2_schedule_count: AtomicU64,
365    /// Monotonic count of Tier-2 completions delivered via the reuse path
366    /// (watcher-driven scheduler runs). These bypass `result_rx`/
367    /// `drain_completions`, so the `&AppContext`-side drain polls this counter
368    /// to know when to refresh the agent status bar after a background scan.
369    reuse_completions: AtomicU64,
370    /// Test observability for distinguishing queued reuse work from a worker that
371    /// has actually begun executing it.
372    reuse_starts: AtomicU64,
373}
374
375impl InspectManager {
376    pub fn new() -> Self {
377        Self::with_heavy_root_work_gate(Arc::new(AtomicBool::new(true)))
378    }
379
380    pub fn with_heavy_root_work_gate(heavy_root_work_allowed: Arc<AtomicBool>) -> Self {
381        Self::with_root_work_gates(heavy_root_work_allowed, Arc::new(AtomicBool::new(false)))
382    }
383
384    pub fn with_root_work_gates(
385        heavy_root_work_allowed: Arc<AtomicBool>,
386        semantic_cold_seed_active: Arc<AtomicBool>,
387    ) -> Self {
388        Self::with_worker_and_gates(
389            default_worker(),
390            DEFAULT_SOFT_DEADLINE,
391            heavy_root_work_allowed,
392            semantic_cold_seed_active,
393        )
394    }
395
396    #[doc(hidden)]
397    pub fn with_worker(worker: InspectWorker, soft_deadline: Duration) -> Self {
398        Self::with_worker_and_gate(worker, soft_deadline, Arc::new(AtomicBool::new(true)))
399    }
400
401    #[doc(hidden)]
402    pub fn with_worker_and_gate(
403        worker: InspectWorker,
404        soft_deadline: Duration,
405        heavy_root_work_allowed: Arc<AtomicBool>,
406    ) -> Self {
407        Self::with_worker_and_gates(
408            worker,
409            soft_deadline,
410            heavy_root_work_allowed,
411            Arc::new(AtomicBool::new(false)),
412        )
413    }
414
415    fn with_worker_and_gates(
416        worker: InspectWorker,
417        soft_deadline: Duration,
418        heavy_root_work_allowed: Arc<AtomicBool>,
419        semantic_cold_seed_active: Arc<AtomicBool>,
420    ) -> Self {
421        let handles = start_dispatch_loop(worker);
422        Self {
423            request_tx: handles.request_tx,
424            result_rx: handles.result_rx,
425            pool: handles.pool,
426            in_flight: Mutex::new(HashMap::new()),
427            in_flight_changed: Condvar::new(),
428            caches: Mutex::new(HashMap::new()),
429            callgraph_projection: Mutex::new(None),
430            oxc_facts_cache: Mutex::new(OxcFactsCache::new()),
431            soft_deadline,
432            next_job_id: AtomicU64::new(1),
433            heavy_root_work_allowed,
434            semantic_cold_seed_active,
435            cold_build_limiter: Mutex::new(cold_build_limiter::global_limiter()),
436            builder_states: Mutex::new(HashMap::new()),
437            automatic_tier2_refresh_allowed: AtomicBool::new(true),
438            automatic_tier2_skip_logged: AtomicBool::new(false),
439            automatic_tier2_schedule_count: AtomicU64::new(0),
440            reuse_completions: AtomicU64::new(0),
441            reuse_starts: AtomicU64::new(0),
442        }
443    }
444
445    fn heavy_root_work_allowed(&self) -> bool {
446        self.heavy_root_work_allowed.load(Ordering::SeqCst)
447    }
448
449    pub(crate) fn set_cold_build_limiter(
450        &self,
451        limiter: Arc<cold_build_limiter::ColdBuildLimiter>,
452    ) {
453        *self
454            .cold_build_limiter
455            .lock()
456            .unwrap_or_else(std::sync::PoisonError::into_inner) = limiter;
457    }
458
459    fn cold_build_limiter(&self) -> Arc<cold_build_limiter::ColdBuildLimiter> {
460        Arc::clone(
461            &self
462                .cold_build_limiter
463                .lock()
464                .unwrap_or_else(std::sync::PoisonError::into_inner),
465        )
466    }
467
468    fn set_builder_state(&self, key: &JobKey, state: InspectBuilderState) {
469        if let Ok(mut states) = self.builder_states.lock() {
470            if let Some(entry) = states.get_mut(key) {
471                entry.begin_attempt(state);
472            } else {
473                states.insert(key.clone(), BuilderStateEntry::new(state));
474            }
475        }
476    }
477
478    fn clear_builder_state(&self, key: &JobKey) {
479        if let Ok(mut states) = self.builder_states.lock() {
480            states.remove(key);
481        }
482    }
483
484    fn record_flight_start(&self, key: &JobKey) {
485        self.set_builder_state(key, InspectBuilderState::Building);
486    }
487
488    fn record_builder_attempt_outcome(&self, key: &JobKey, outcome: &JobOutcome) {
489        let Ok(mut states) = self.builder_states.lock() else {
490            return;
491        };
492        if states
493            .get(key)
494            .is_some_and(|entry| entry.suspension.is_some())
495        {
496            return;
497        }
498        match builder_attempt_terminal(outcome) {
499            BuilderAttemptTerminal::Succeeded => {
500                states.remove(key);
501            }
502            BuilderAttemptTerminal::Failed(terminal) => {
503                if let Some(entry) = states.get_mut(key) {
504                    entry.record_failure(terminal);
505                } else {
506                    let mut entry = BuilderStateEntry::new(InspectBuilderState::Building);
507                    entry.record_failure(terminal);
508                    states.insert(key.clone(), entry);
509                }
510            }
511            BuilderAttemptTerminal::Inconclusive => {
512                if let Some(entry) = states.get_mut(key) {
513                    entry.state = None;
514                    if entry.last_failure.is_none() && entry.attempt_count == 0 {
515                        states.remove(key);
516                    }
517                }
518            }
519        }
520    }
521
522    fn tier2_flight_exit_guard(&self, key: JobKey) -> Tier2FlightExitGuard<'_> {
523        Tier2FlightExitGuard { manager: self, key }
524    }
525
526    /// Record the attempt outcome and wake leftover waiters. Idempotent: a
527    /// second call after the completion router already ran is a no-op.
528    fn finish_tier2_flight(&self, key: &JobKey, outcome: JobOutcome) {
529        let Some(waiters) = self
530            .in_flight
531            .lock()
532            .ok()
533            .and_then(|mut in_flight| in_flight.remove(key))
534        else {
535            return;
536        };
537        self.record_builder_attempt_outcome(key, &outcome);
538        self.reuse_completions.fetch_add(1, Ordering::SeqCst);
539        for waiter in waiters {
540            let _ = waiter.tx.send(outcome.clone());
541        }
542    }
543
544    pub(crate) fn tier2_builder_state(&self, category: InspectCategory) -> InspectBuilderState {
545        let key = JobKey::for_project_category(category);
546        if let Ok(states) = self.builder_states.lock() {
547            if let Some(entry) = states.get(&key) {
548                if let Some(state) = entry.state {
549                    return state;
550                }
551            }
552        }
553        if self
554            .in_flight
555            .lock()
556            .map(|in_flight| in_flight.contains_key(&key))
557            .unwrap_or(false)
558        {
559            InspectBuilderState::Building
560        } else {
561            InspectBuilderState::Absent
562        }
563    }
564
565    pub(crate) fn tier2_builder_state_detail(&self, category: InspectCategory) -> String {
566        let key = JobKey::for_project_category(category);
567        if let Ok(states) = self.builder_states.lock() {
568            if let Some(entry) = states.get(&key) {
569                return entry.detail();
570            }
571        }
572        self.tier2_builder_state(category).as_str().to_string()
573    }
574
575    fn record_tier2_build_suspension(
576        &self,
577        key: &JobKey,
578        suspension: crate::build_breaker::BuildSuspension,
579    ) {
580        if let Ok(mut states) = self.builder_states.lock() {
581            if let Some(entry) = states.get_mut(key) {
582                entry.record_suspension(suspension);
583            } else {
584                let mut entry = BuilderStateEntry::new(InspectBuilderState::Suspended);
585                entry.record_suspension(suspension);
586                states.insert(key.clone(), entry);
587            }
588        }
589    }
590
591    #[cfg(test)]
592    pub(crate) fn record_tier2_build_suspension_for_test(
593        &self,
594        category: InspectCategory,
595        suspension: crate::build_breaker::BuildSuspension,
596    ) {
597        self.record_tier2_build_suspension(&JobKey::for_project_category(category), suspension);
598    }
599
600    fn builder_state_detail_for_job(&self, job: &InspectJob) -> String {
601        if !job.inspect_writer || !job.callgraph_writer {
602            InspectBuilderState::BuildDenied.as_str().to_string()
603        } else {
604            self.tier2_builder_state_detail(job.category)
605        }
606    }
607
608    /// Whether any Tier-2 category is registered in the builder registry.
609    /// Health uses this instead of published status-bar completeness so the
610    /// two surfaces cannot disagree about a live rebuild.
611    pub(crate) fn try_tier2_builder_busy(&self) -> Option<bool> {
612        let states = self.builder_states.try_lock().ok()?;
613        if states
614            .iter()
615            .any(|(key, entry)| key.category.is_tier2() && entry.is_in_flight())
616        {
617            return Some(true);
618        }
619        drop(states);
620        self.try_tier2_any_in_flight()
621    }
622
623    /// Whether a published callgraph store can back a dead_code snapshot.
624    ///
625    /// This is the same readiness predicate the builder uses before projection:
626    /// a store that opens but still has `backend_file_state='stale'` rows is
627    /// not ready, because `project_dead_code_snapshot` refuses those rows.
628    pub(crate) fn callgraph_ready_for_snapshot(&self, snapshot: &InspectSnapshot) -> bool {
629        if !snapshot.config.callgraph_store {
630            return false;
631        }
632        callgraph_store_dirs_from_inspect_dir(&snapshot.inspect_dir, &snapshot.project_root)
633            .into_iter()
634            .any(|dir| callgraph_store_ready_for_dead_code(dir, snapshot.project_root.clone()))
635    }
636
637    pub fn set_automatic_tier2_refresh_allowed(&self, allowed: bool) {
638        self.automatic_tier2_refresh_allowed
639            .store(allowed, Ordering::SeqCst);
640        self.automatic_tier2_skip_logged
641            .store(false, Ordering::SeqCst);
642    }
643
644    pub fn automatic_tier2_refresh_enabled(&self) -> bool {
645        self.automatic_tier2_refresh_allowed.load(Ordering::SeqCst)
646    }
647
648    pub fn automatic_tier2_refresh_allowed(&self) -> bool {
649        let allowed = self.automatic_tier2_refresh_enabled();
650        if !allowed
651            && !self
652                .automatic_tier2_skip_logged
653                .swap(true, Ordering::SeqCst)
654        {
655            crate::slog_debug!("automatic Tier-2 scan scheduling skipped for linked worktree root");
656        }
657        allowed
658    }
659
660    #[doc(hidden)]
661    pub fn inspect_pool_for_test(&self) -> Arc<rayon::ThreadPool> {
662        Arc::clone(&self.pool)
663    }
664
665    #[doc(hidden)]
666    pub fn automatic_tier2_schedule_count_for_test(&self) -> u64 {
667        self.automatic_tier2_schedule_count.load(Ordering::SeqCst)
668    }
669
670    fn category_needs_heavy_root_work(category: InspectCategory) -> bool {
671        category != InspectCategory::Diagnostics
672    }
673
674    fn heavy_root_work_block_message(category: InspectCategory) -> String {
675        format!(
676            "inspect category '{category}' is unavailable because heavy project-wide work is disabled for this root"
677        )
678    }
679
680    pub fn submit_category(
681        &self,
682        snapshot: InspectSnapshot,
683        category: InspectCategory,
684        caller_scope: JobScope,
685    ) -> JobOutcome {
686        self.submit_category_with_callgraph(snapshot, category, caller_scope, None)
687    }
688
689    pub fn submit_category_with_callgraph(
690        &self,
691        snapshot: InspectSnapshot,
692        category: InspectCategory,
693        caller_scope: JobScope,
694        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
695    ) -> JobOutcome {
696        if !category.is_active() {
697            return JobOutcome::Failed {
698                message: format!("inspect category '{category}' is disabled in v0.33"),
699            };
700        }
701        if Self::category_needs_heavy_root_work(category) && !self.heavy_root_work_allowed() {
702            return JobOutcome::Failed {
703                message: Self::heavy_root_work_block_message(category),
704            };
705        }
706
707        let cache = match self.cache_for_snapshot(&snapshot) {
708            Ok(cache) => cache,
709            Err(message) => return JobOutcome::Failed { message },
710        };
711        let key = JobKey::for_category_scope(category, &caller_scope);
712        let (waiter_tx, waiter_rx) = bounded(1);
713
714        let wait_snapshot = snapshot.clone();
715        match self.enqueue_with_waiter(
716            snapshot,
717            category,
718            caller_scope.clone(),
719            key.clone(),
720            waiter_tx,
721            callgraph_snapshot,
722        ) {
723            Ok(()) => self.wait_for_outcome(key, caller_scope, cache, waiter_rx, wait_snapshot),
724            Err(message) => JobOutcome::Failed { message },
725        }
726    }
727
728    pub fn submit_background(
729        &self,
730        snapshot: InspectSnapshot,
731        category: InspectCategory,
732        caller_scope: JobScope,
733    ) -> Result<JobKey, String> {
734        self.submit_background_with_callgraph(snapshot, category, caller_scope, None)
735    }
736
737    pub fn submit_background_with_callgraph(
738        &self,
739        snapshot: InspectSnapshot,
740        category: InspectCategory,
741        caller_scope: JobScope,
742        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
743    ) -> Result<JobKey, String> {
744        if !category.is_active() {
745            return Err(format!(
746                "inspect category '{category}' is disabled in v0.33"
747            ));
748        }
749        if Self::category_needs_heavy_root_work(category) && !self.heavy_root_work_allowed() {
750            return Err(Self::heavy_root_work_block_message(category));
751        }
752        let key = JobKey::for_category_scope(category, &caller_scope);
753        self.enqueue_without_waiter(
754            snapshot,
755            category,
756            caller_scope,
757            key.clone(),
758            callgraph_snapshot,
759        )?;
760        Ok(key)
761    }
762
763    pub fn submit_tier2_run_with_reuse_background(
764        self: &Arc<Self>,
765        snapshot: InspectSnapshot,
766        category: InspectCategory,
767    ) -> Result<Option<JobKey>, String> {
768        if !category.is_active() {
769            return Err(format!(
770                "inspect category '{category}' is disabled in v0.33"
771            ));
772        }
773        if !category.is_tier2() {
774            return Err(format!(
775                "inspect category '{category}' is not a Tier 2 category"
776            ));
777        }
778        if !self.heavy_root_work_allowed() {
779            return Err(Self::heavy_root_work_block_message(category));
780        }
781        if !self.automatic_tier2_refresh_allowed() {
782            return Ok(None);
783        }
784        self.automatic_tier2_schedule_count
785            .fetch_add(1, Ordering::SeqCst);
786
787        let job = self.tier2_reuse_job(snapshot, category, None);
788        let key = job.key.clone();
789        let mut in_flight = self
790            .in_flight
791            .lock()
792            .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
793        if in_flight.contains_key(&key) {
794            return Ok(Some(key));
795        }
796        let limiter = self.cold_build_limiter();
797        let request = cold_build_limiter::ColdBuildAdmissionRequest::new(
798            format!("tier2-background:{}", category.as_str()),
799            cold_build_limiter::ColdBuildAdmissionClass::Maintenance,
800        );
801        let Some(permit) =
802            cold_build_limiter::try_acquire_classified_with_limiter(&limiter, &request)
803        else {
804            return Err(format!(
805                "cold build concurrency limit ({}) reached; retrying later",
806                limiter.limit()
807            ));
808        };
809        in_flight.insert(key.clone(), Vec::new());
810        drop(in_flight);
811        self.record_flight_start(&key);
812
813        let manager = Arc::clone(self);
814        let pool = Arc::clone(&self.pool);
815        pool.spawn_fifo(move || {
816            let _permit = permit;
817            let _flight = manager.tier2_flight_exit_guard(job.key.clone());
818            let result =
819                manager.tier2_run_with_reuse_job_result_catching(job, Tier2ReuseOptions::default());
820            manager.route_tier2_reuse_completion(result);
821        });
822
823        Ok(Some(key))
824    }
825
826    pub fn submit_tier2_run_with_reuse_serial_background(
827        self: &Arc<Self>,
828        snapshot: InspectSnapshot,
829        categories: Vec<InspectCategory>,
830    ) -> Tier2RunSubmission {
831        let mut submission = Tier2RunSubmission::default();
832        let mut requested = Vec::new();
833
834        for category in categories {
835            if !category.is_active() {
836                submission.errors.push(Tier2RunSubmissionError {
837                    category,
838                    message: format!("inspect category '{category}' is disabled in v0.33"),
839                });
840                continue;
841            }
842            if !category.is_tier2() {
843                submission.errors.push(Tier2RunSubmissionError {
844                    category,
845                    message: format!("inspect category '{category}' is not a Tier 2 category"),
846                });
847                continue;
848            }
849            requested.push(category);
850        }
851
852        if requested.is_empty() {
853            return submission;
854        }
855        if !self.heavy_root_work_allowed() {
856            for category in requested {
857                submission.errors.push(Tier2RunSubmissionError {
858                    category,
859                    message: Self::heavy_root_work_block_message(category),
860                });
861            }
862            return submission;
863        }
864        if !self.automatic_tier2_refresh_allowed() {
865            return submission;
866        }
867        self.automatic_tier2_schedule_count
868            .fetch_add(requested.len() as u64, Ordering::SeqCst);
869
870        let mut in_flight = match self.in_flight.lock() {
871            Ok(in_flight) => in_flight,
872            Err(_) => {
873                for category in requested {
874                    submission.errors.push(Tier2RunSubmissionError {
875                        category,
876                        message: "inspect in-flight map lock poisoned".to_string(),
877                    });
878                }
879                return submission;
880            }
881        };
882
883        let mut started = Vec::new();
884        for category in requested {
885            let key = JobKey::for_project_category(category);
886            submission.queued_categories.push(category);
887            if in_flight.contains_key(&key) {
888                continue;
889            }
890            in_flight.insert(key.clone(), Vec::new());
891            started.push(key);
892            submission.newly_queued_categories.push(category);
893        }
894        drop(in_flight);
895        for key in &started {
896            self.record_flight_start(key);
897        }
898
899        if submission.newly_queued_categories.is_empty() {
900            return submission;
901        }
902
903        let limiter = self.cold_build_limiter();
904        let request = cold_build_limiter::ColdBuildAdmissionRequest::new(
905            "tier2-serial-background",
906            cold_build_limiter::ColdBuildAdmissionClass::Maintenance,
907        );
908        let Some(permit) =
909            cold_build_limiter::try_acquire_classified_with_limiter(&limiter, &request)
910        else {
911            let deferred = submission.newly_queued_categories.clone();
912            if let Ok(mut in_flight) = self.in_flight.lock() {
913                for category in &deferred {
914                    in_flight.remove(&JobKey::for_project_category(*category));
915                }
916            }
917            for category in &deferred {
918                self.clear_builder_state(&JobKey::for_project_category(*category));
919            }
920            submission
921                .queued_categories
922                .retain(|category| !deferred.contains(category));
923            submission.deferred_categories = deferred;
924            submission.newly_queued_categories.clear();
925            return submission;
926        };
927
928        let categories_for_worker = submission.newly_queued_categories.clone();
929        let manager = Arc::clone(self);
930        let pool = Arc::clone(&self.pool);
931        pool.spawn_fifo(move || {
932            let _permit = permit;
933            for category in categories_for_worker {
934                let job = manager.tier2_reuse_job(snapshot.clone(), category, None);
935                let _flight = manager.tier2_flight_exit_guard(job.key.clone());
936                let result = manager
937                    .tier2_run_with_reuse_job_result_catching(job, Tier2ReuseOptions::default());
938                manager.route_tier2_reuse_completion(result);
939            }
940        });
941
942        submission
943    }
944
945    pub fn tier2_any_in_flight(&self) -> bool {
946        self.in_flight
947            .lock()
948            .map(|in_flight| in_flight.keys().any(|key| key.category.is_tier2()))
949            .unwrap_or(false)
950    }
951
952    pub(crate) fn try_tier2_any_in_flight(&self) -> Option<bool> {
953        self.in_flight
954            .try_lock()
955            .ok()
956            .map(|in_flight| in_flight.keys().any(|key| key.category.is_tier2()))
957    }
958
959    #[cfg(test)]
960    pub(crate) fn set_tier2_in_flight_for_test(&self, category: InspectCategory, in_flight: bool) {
961        let key = JobKey::for_project_category(category);
962        let mut jobs = self
963            .in_flight
964            .lock()
965            .unwrap_or_else(std::sync::PoisonError::into_inner);
966        if in_flight {
967            jobs.entry(key.clone()).or_default();
968            drop(jobs);
969            self.record_flight_start(&key);
970        } else {
971            jobs.remove(&key);
972            drop(jobs);
973            self.clear_builder_state(&key);
974        }
975    }
976
977    #[cfg(test)]
978    pub(crate) fn record_tier2_attempt_outcome_for_test(
979        &self,
980        category: InspectCategory,
981        outcome: JobOutcome,
982    ) {
983        let key = JobKey::for_project_category(category);
984        {
985            let mut jobs = self
986                .in_flight
987                .lock()
988                .unwrap_or_else(std::sync::PoisonError::into_inner);
989            jobs.entry(key.clone()).or_default();
990        }
991        self.record_flight_start(&key);
992        self.finish_tier2_flight(&key, outcome);
993    }
994
995    /// Release per-project inspect caches so their SQLite readers and writer
996    /// leases do not remain open after a root has gone idle. Callers must check
997    /// [`Self::tier2_any_in_flight`] first so a running scan never loses its
998    /// cache while it is being used.
999    pub fn evict_idle_caches(&self) {
1000        if let Ok(mut caches) = self.caches.lock() {
1001            caches.clear();
1002        }
1003        self.clear_callgraph_projection();
1004        if let Ok(mut facts) = self.oxc_facts_cache.lock() {
1005            *facts = OxcFactsCache::new();
1006        }
1007        // A new corpus after idle eviction must not inherit the previous
1008        // root's failed-attempt history.
1009        if let Ok(mut states) = self.builder_states.lock() {
1010            states.retain(|_, entry| entry.is_in_flight());
1011        }
1012    }
1013
1014    /// Estimate inspect's resident aggregate maps without waiting on active
1015    /// scans. SQLite allocations are measured process-wide; OXC fact payload
1016    /// bytes remain an explicit gap.
1017    pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
1018        let caches = match self.caches.try_lock() {
1019            Ok(caches) => caches.values().cloned().collect::<Vec<_>>(),
1020            Err(_) => return crate::memory::MemoryEstimate::busy(),
1021        };
1022        let facts_entries = match self.oxc_facts_cache.try_lock() {
1023            Ok(facts) => facts.len(),
1024            Err(_) => return crate::memory::MemoryEstimate::busy(),
1025        };
1026        let mut bytes = 0u64;
1027        let mut memory_aggregates = 0u64;
1028        for cache in &caches {
1029            let estimate = cache.estimated_memory();
1030            let Some(cache_bytes) = estimate.estimated_bytes else {
1031                return crate::memory::MemoryEstimate::busy();
1032            };
1033            bytes = bytes.saturating_add(cache_bytes);
1034            memory_aggregates = memory_aggregates.saturating_add(
1035                estimate
1036                    .counts
1037                    .get("memory_aggregates")
1038                    .copied()
1039                    .unwrap_or(0),
1040            );
1041        }
1042        crate::memory::MemoryEstimate::partial(bytes)
1043            .count("open_generation_handles", caches.len())
1044            .count("oxc_fact_entries", facts_entries)
1045            .count_u64("memory_aggregates", memory_aggregates)
1046            .gap("oxc_fact_bytes")
1047    }
1048
1049    /// Estimate the resident full-graph projection used by dead-code scans.
1050    /// The slot belongs to this root's manager and is dropped on idle eviction.
1051    pub fn callgraph_projection_estimated_memory(&self) -> crate::memory::MemoryEstimate {
1052        let projection = match self.callgraph_projection.try_lock() {
1053            Ok(projection) => projection,
1054            Err(_) => return crate::memory::MemoryEstimate::busy(),
1055        };
1056        let bytes = projection
1057            .as_ref()
1058            .map(|projection| projection.estimated_bytes)
1059            .unwrap_or(0);
1060        crate::memory::MemoryEstimate::estimated(bytes)
1061            .count(
1062                "callgraph_projection_snapshots",
1063                usize::from(projection.is_some()),
1064            )
1065            .count_u64("callgraph_projection_snapshot_bytes", bytes)
1066    }
1067
1068    fn cached_callgraph_projection(
1069        &self,
1070        identity: &CallgraphProjectionIdentity,
1071    ) -> Option<Arc<CallgraphSnapshot>> {
1072        let projection = self.callgraph_projection.lock().ok()?;
1073        projection
1074            .as_ref()
1075            .filter(|cached| cached.identity == *identity)
1076            .map(|cached| Arc::clone(&cached.snapshot))
1077    }
1078
1079    fn cache_callgraph_projection(
1080        &self,
1081        identity: CallgraphProjectionIdentity,
1082        snapshot: Arc<CallgraphSnapshot>,
1083    ) {
1084        let estimated_bytes = estimate_callgraph_snapshot_bytes(snapshot.as_ref());
1085        if let Ok(mut cached) = self.callgraph_projection.lock() {
1086            *cached = Some(CachedCallgraphProjection {
1087                identity,
1088                snapshot,
1089                estimated_bytes,
1090            });
1091        }
1092    }
1093
1094    fn clear_callgraph_projection(&self) {
1095        if let Ok(mut cached) = self.callgraph_projection.lock() {
1096            cached.take();
1097        }
1098    }
1099
1100    fn build_tier2_callgraph_snapshot_with_refresh(
1101        &self,
1102        job: &InspectJob,
1103        allow_cold_build: bool,
1104        build_if_missing: bool,
1105        refresh_paths: &[PathBuf],
1106    ) -> Option<Arc<CallgraphSnapshot>> {
1107        build_tier2_callgraph_snapshot_with_refresh_inner(
1108            job,
1109            allow_cold_build,
1110            build_if_missing,
1111            refresh_paths,
1112            Some(self),
1113        )
1114    }
1115
1116    /// Whether completed scan results are waiting in the channel. Used by the
1117    /// maintenance scheduler to skip enqueueing a completion drain with no work.
1118    pub fn has_pending_completions(&self) -> bool {
1119        !self.result_rx.is_empty()
1120    }
1121
1122    pub fn drain_completions(&self) -> usize {
1123        let mut drained = 0usize;
1124        while let Ok(result) = self.result_rx.try_recv() {
1125            self.route_completion(result);
1126            drained += 1;
1127        }
1128        drained
1129    }
1130
1131    pub fn discard_completions(&self) -> usize {
1132        let mut discarded = 0usize;
1133        while self.result_rx.try_recv().is_ok() {
1134            discarded += 1;
1135        }
1136        discarded
1137    }
1138
1139    pub fn cache_for_snapshot(
1140        &self,
1141        snapshot: &InspectSnapshot,
1142    ) -> Result<Arc<InspectCache>, String> {
1143        self.cache_for_paths(snapshot.inspect_dir.clone(), snapshot.project_root.clone())
1144    }
1145
1146    /// Latest persisted counts for the three Tier-2 categories, in
1147    /// `(dead_code, unused_exports, duplicates)` order. Reads the most recent
1148    /// aggregate regardless of contribution-hash freshness (last-known), so the
1149    /// agent status bar can refresh after a background scan completes without a
1150    /// freshness round-trip. A category with no readable aggregate reports
1151    /// `None` (never a fabricated `0`), so the status bar can preserve any
1152    /// last-known value and stay suppressed until every category is real (#1).
1153    pub fn latest_tier2_counts(
1154        &self,
1155        inspect_dir: PathBuf,
1156        project_root: PathBuf,
1157    ) -> (Option<usize>, Option<usize>, Option<usize>) {
1158        let Ok(cache) = self.cache_for_paths(inspect_dir, project_root) else {
1159            return (None, None, None);
1160        };
1161        let count_of = |category: InspectCategory| -> Option<usize> {
1162            cache
1163                .latest_aggregate_any_hash(category)
1164                .ok()
1165                .flatten()
1166                .and_then(|payload| {
1167                    if category == InspectCategory::DeadCode
1168                        && payload
1169                            .get("callgraph_available")
1170                            .and_then(serde_json::Value::as_bool)
1171                            == Some(false)
1172                    {
1173                        return None;
1174                    }
1175                    payload
1176                        .get("count")
1177                        .and_then(serde_json::Value::as_u64)
1178                        .map(|count| count as usize)
1179                })
1180        };
1181        (
1182            count_of(InspectCategory::DeadCode),
1183            count_of(InspectCategory::UnusedExports),
1184            count_of(InspectCategory::Duplicates),
1185        )
1186    }
1187
1188    /// Whether the latest persisted dead_code aggregate reported
1189    /// `callgraph_available:false` — i.e. dead_code was suppressed because the
1190    /// callgraph store was not ready when it scanned. Health uses this to avoid
1191    /// reporting tier2 as permanently "building" for a root whose only missing
1192    /// category is dead_code blocked on the callgraph store. Mirrors the
1193    /// suppression rule in [`Self::latest_tier2_counts`].
1194    pub fn dead_code_blocked_on_callgraph(
1195        &self,
1196        inspect_dir: PathBuf,
1197        project_root: PathBuf,
1198    ) -> bool {
1199        let Ok(cache) = self.cache_for_paths(inspect_dir, project_root) else {
1200            return false;
1201        };
1202        cache
1203            .latest_aggregate_any_hash(InspectCategory::DeadCode)
1204            .ok()
1205            .flatten()
1206            .and_then(|payload| {
1207                payload
1208                    .get("callgraph_available")
1209                    .and_then(serde_json::Value::as_bool)
1210            })
1211            == Some(false)
1212    }
1213
1214    pub fn cache_for_paths(
1215        &self,
1216        inspect_dir: PathBuf,
1217        project_root: PathBuf,
1218    ) -> Result<Arc<InspectCache>, String> {
1219        let project_key = crate::path_identity::project_scope_key(&project_root);
1220        let inspect_dir = if inspect_dir
1221            .file_name()
1222            .and_then(|name| name.to_str())
1223            .is_some_and(|name| name == project_key)
1224        {
1225            inspect_dir
1226        } else {
1227            inspect_dir.join(&project_key)
1228        };
1229        let identity = InspectCacheIdentity {
1230            sqlite_path: inspect_dir.join(format!("{project_key}.current")),
1231            project_root: project_root.clone(),
1232        };
1233        let mut caches = self
1234            .caches
1235            .lock()
1236            .map_err(|_| "inspect manager cache map lock poisoned".to_string())?;
1237        if let Some(cache) = caches.get(&identity) {
1238            return Ok(Arc::clone(cache));
1239        }
1240        let cache = Arc::new(
1241            InspectCache::open(inspect_dir, project_root)
1242                .map_err(|error| format!("failed to open inspect cache: {error}"))?,
1243        );
1244        caches.insert(identity, Arc::clone(&cache));
1245        Ok(cache)
1246    }
1247
1248    fn oxc_result_for_scan(
1249        &self,
1250        job: &InspectJob,
1251        files: &[PathBuf],
1252        force_reparse_files: &[PathBuf],
1253    ) -> Result<Option<OxcEngineResult>, String> {
1254        if !category_uses_oxc(job.category) {
1255            return Ok(None);
1256        }
1257        if job.category == InspectCategory::DeadCode && job.callgraph_snapshot.is_none() {
1258            return Ok(None);
1259        }
1260
1261        let public_api_entries =
1262            crate::inspect::entry_points::resolve_entry_points(&job.project_root);
1263        let entry_points = if job.category == InspectCategory::DeadCode {
1264            job.callgraph_snapshot
1265                .as_ref()
1266                .map(|snapshot| snapshot.entry_points.iter().cloned().collect::<Vec<_>>())
1267                .unwrap_or_default()
1268        } else {
1269            Vec::new()
1270        };
1271        let options = AnalyzeOptions {
1272            entry_points,
1273            public_api_files: public_api_entries.public_api_files(),
1274            executable_root_exports: public_api_entries.executable_root_exports(),
1275            force_reparse_files: force_reparse_files.to_vec(),
1276            entry_reachability: job.category == InspectCategory::DeadCode,
1277        };
1278
1279        let mut cache = self
1280            .oxc_facts_cache
1281            .lock()
1282            .map_err(|_| "inspect oxc facts cache lock poisoned".to_string())?;
1283        analyze_files_with_cache(&job.project_root, files, options, &mut cache)
1284            .map(Some)
1285            .map_err(|message| format!("oxc analyze failed: {message}"))
1286    }
1287
1288    pub fn tier2_run_with_reuse(
1289        &self,
1290        snapshot: InspectSnapshot,
1291        category: InspectCategory,
1292        caller_scope: JobScope,
1293        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
1294    ) -> JobOutcome {
1295        if let Err(outcome) = validate_tier2_read_category(category) {
1296            return outcome;
1297        }
1298        if !self.heavy_root_work_allowed() {
1299            return JobOutcome::Failed {
1300                message: Self::heavy_root_work_block_message(category),
1301            };
1302        }
1303        let cache = match self.cache_for_snapshot(&snapshot) {
1304            Ok(cache) => cache,
1305            Err(message) => return JobOutcome::Failed { message },
1306        };
1307        let job = self.tier2_reuse_job(snapshot.clone(), category, callgraph_snapshot);
1308        let key = job.key.clone();
1309        let (waiter_tx, waiter_rx) = bounded(1);
1310        let claimed = match self.register_tier2_reuse_waiter(&key, waiter_tx) {
1311            Ok(claimed) => claimed,
1312            Err(message) => return JobOutcome::Failed { message },
1313        };
1314
1315        if claimed {
1316            let _flight = self.tier2_flight_exit_guard(key.clone());
1317            let result =
1318                self.tier2_run_with_reuse_job_result_catching(job, Tier2ReuseOptions::default());
1319            self.route_tier2_reuse_completion(result);
1320        }
1321
1322        match waiter_rx.recv() {
1323            Ok(outcome) => filter_outcome_for_scope_with_contributions(
1324                outcome,
1325                &snapshot,
1326                category,
1327                cache.as_ref(),
1328                &caller_scope,
1329            ),
1330            Err(_) => JobOutcome::Pending { in_flight: true },
1331        }
1332    }
1333
1334    /// Run a Tier-2 category to a terminal outcome for an explicit inspect.
1335    ///
1336    /// The blocking inspect path must not turn an unfinished reuse job into a
1337    /// partial response. A caller either receives the completed aggregate or a
1338    /// failure from the worker; it never receives a timeout-shaped `Pending`.
1339    pub fn tier2_run_with_reuse_blocking(
1340        self: &Arc<Self>,
1341        snapshot: InspectSnapshot,
1342        category: InspectCategory,
1343        caller_scope: JobScope,
1344    ) -> JobOutcome {
1345        self.tier2_run_with_reuse_blocking_once(snapshot, category, caller_scope, false)
1346    }
1347
1348    /// Run a Tier-2 category for a blocking request that requires fresh results.
1349    /// Unlike compatibility callers, this retries a temporarily unavailable
1350    /// callgraph instead of accepting that incomplete scan as the final result.
1351    pub fn tier2_run_with_reuse_blocking_fresh(
1352        self: &Arc<Self>,
1353        snapshot: InspectSnapshot,
1354        category: InspectCategory,
1355        caller_scope: JobScope,
1356    ) -> JobOutcome {
1357        let first = self.tier2_run_with_reuse_blocking_once(
1358            snapshot.clone(),
1359            category,
1360            caller_scope.clone(),
1361            category == InspectCategory::DeadCode,
1362        );
1363        if category == InspectCategory::DeadCode
1364            && first.payload().is_some_and(|payload| {
1365                payload.get("callgraph_available").and_then(Value::as_bool) == Some(false)
1366            })
1367        {
1368            // A blocking caller can attach to a background scan that started
1369            // before the callgraph was ready. Retry once under the blocking
1370            // policy so that transient result cannot become the terminal payload.
1371            return self.tier2_run_with_reuse_blocking_once(snapshot, category, caller_scope, true);
1372        }
1373        first
1374    }
1375
1376    fn tier2_run_with_reuse_blocking_once(
1377        self: &Arc<Self>,
1378        snapshot: InspectSnapshot,
1379        category: InspectCategory,
1380        caller_scope: JobScope,
1381        require_callgraph_snapshot: bool,
1382    ) -> JobOutcome {
1383        if let Err(outcome) = validate_tier2_read_category(category) {
1384            return outcome;
1385        }
1386        if !self.heavy_root_work_allowed() {
1387            return JobOutcome::Failed {
1388                message: Self::heavy_root_work_block_message(category),
1389            };
1390        }
1391        let cache = match self.cache_for_snapshot(&snapshot) {
1392            Ok(cache) => cache,
1393            Err(message) => return JobOutcome::Failed { message },
1394        };
1395
1396        let job = self.tier2_reuse_job(snapshot.clone(), category, None);
1397        let key = job.key.clone();
1398        let (waiter_tx, waiter_rx) = bounded(1);
1399        let claimed = match self.register_tier2_reuse_waiter(&key, waiter_tx) {
1400            Ok(claimed) => claimed,
1401            Err(message) => return JobOutcome::Failed { message },
1402        };
1403        if claimed {
1404            self.spawn_tier2_reuse_job(
1405                job,
1406                Tier2ReuseOptions {
1407                    require_callgraph_snapshot,
1408                    interactive: true,
1409                    ..Tier2ReuseOptions::default()
1410                },
1411            );
1412        }
1413
1414        self.wait_for_tier2_reuse(&key, &caller_scope, cache.as_ref(), waiter_rx, &snapshot)
1415    }
1416
1417    fn register_tier2_reuse_waiter(
1418        &self,
1419        key: &JobKey,
1420        waiter_tx: WaiterTx,
1421    ) -> Result<bool, String> {
1422        let mut in_flight = self
1423            .in_flight
1424            .lock()
1425            .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
1426        if let Some(waiters) = in_flight.get_mut(key) {
1427            waiters.push(Waiter { tx: waiter_tx });
1428            self.in_flight_changed.notify_all();
1429            return Ok(false);
1430        }
1431
1432        in_flight.insert(key.clone(), vec![Waiter { tx: waiter_tx }]);
1433        drop(in_flight);
1434        self.record_flight_start(key);
1435        Ok(true)
1436    }
1437
1438    fn wait_for_tier2_reuse_waiter_for_debug(&self, job: &InspectJob) {
1439        #[cfg(not(debug_assertions))]
1440        let _ = job;
1441        #[cfg(debug_assertions)]
1442        {
1443            const WAIT_ROOT_ENV: &str = "AFT_TEST_TIER2_REUSE_WAIT_FOR_WAITER_ROOT";
1444            if std::env::var_os(WAIT_ROOT_ENV).is_none()
1445                || !env_project_root_matches(WAIT_ROOT_ENV, &job.project_root)
1446            {
1447                return;
1448            }
1449
1450            // This test gate releases on the actual waiter registration, not elapsed
1451            // wall-clock time, so a queued background job cannot finish before the
1452            // direct-reuse request has attached on a contended runner.
1453            let deadline = Instant::now() + Duration::from_secs(30);
1454            let mut in_flight = self
1455                .in_flight
1456                .lock()
1457                .unwrap_or_else(std::sync::PoisonError::into_inner);
1458            loop {
1459                match in_flight.get(&job.key) {
1460                    Some(waiters) if waiters.is_empty() => {}
1461                    _ => return,
1462                }
1463                let now = Instant::now();
1464                if now >= deadline {
1465                    return;
1466                }
1467                let (next, wait_result) = self
1468                    .in_flight_changed
1469                    .wait_timeout(in_flight, deadline.saturating_duration_since(now))
1470                    .unwrap_or_else(std::sync::PoisonError::into_inner);
1471                in_flight = next;
1472                if wait_result.timed_out() {
1473                    return;
1474                }
1475            }
1476        }
1477    }
1478
1479    fn spawn_tier2_reuse_job(self: &Arc<Self>, job: InspectJob, options: Tier2ReuseOptions) {
1480        // Rebinds retain the persisted contribution cache. Let quick reuse prove
1481        // that cache before joining the cold-build queue, so an unchanged root can
1482        // answer immediately even while unrelated background builds own the slots.
1483        self.record_flight_start(&job.key);
1484        let manager = Arc::clone(self);
1485        let pool = Arc::clone(&self.pool);
1486        let cancellation = crate::executor::current_job_cancellation();
1487        pool.spawn_fifo(move || {
1488            let _cancellation = cancellation.map(crate::executor::install_job_cancellation);
1489            let _flight = manager.tier2_flight_exit_guard(job.key.clone());
1490            let result = manager.tier2_run_with_reuse_job_result_catching(job, options);
1491            manager.route_tier2_reuse_completion(result);
1492        });
1493    }
1494
1495    fn wait_for_tier2_reuse(
1496        &self,
1497        key: &JobKey,
1498        caller_scope: &JobScope,
1499        cache: &(impl InspectCacheRead + ?Sized),
1500        waiter_rx: Receiver<JobOutcome>,
1501        snapshot: &InspectSnapshot,
1502    ) -> JobOutcome {
1503        match waiter_rx.recv() {
1504            Ok(outcome) => filter_outcome_for_scope_with_contributions(
1505                outcome,
1506                snapshot,
1507                key.category,
1508                cache,
1509                caller_scope,
1510            ),
1511            Err(_) => JobOutcome::Failed {
1512                message: "inspect Tier-2 worker disconnected before completion".to_string(),
1513            },
1514        }
1515    }
1516
1517    /// Read-only Tier 2 aggregate lookup for `aft_inspect`. Does NOT run any
1518    /// scanner — returns the latest cached aggregate if present and verifies
1519    /// its contribution freshness so warm cache hits are reported as fresh.
1520    /// This is the non-blocking variant intended for the synchronous `inspect`
1521    /// command path; Tier 2 scans run via the watcher-driven scheduler or the
1522    /// compatibility `aft_inspect_tier2_run` command.
1523    pub fn tier2_read_cached(
1524        &self,
1525        snapshot: InspectSnapshot,
1526        category: InspectCategory,
1527        caller_scope: JobScope,
1528    ) -> JobOutcome {
1529        if let Err(outcome) = validate_tier2_read_category(category) {
1530            return outcome;
1531        }
1532        if !self.heavy_root_work_allowed() {
1533            return JobOutcome::Failed {
1534                message: Self::heavy_root_work_block_message(category),
1535            };
1536        }
1537        let cache = match self.cache_for_snapshot(&snapshot) {
1538            Ok(cache) => cache,
1539            Err(message) => return JobOutcome::Failed { message },
1540        };
1541        self.tier2_read_cached_from_cache(&snapshot, category, &caller_scope, cache.as_ref())
1542    }
1543
1544    pub fn tier2_read_cached_readonly(
1545        &self,
1546        snapshot: InspectSnapshot,
1547        category: InspectCategory,
1548        caller_scope: JobScope,
1549    ) -> JobOutcome {
1550        if let Err(outcome) = validate_tier2_read_category(category) {
1551            return outcome;
1552        }
1553        if !self.heavy_root_work_allowed() {
1554            return JobOutcome::Failed {
1555                message: Self::heavy_root_work_block_message(category),
1556            };
1557        }
1558        let key = JobKey::for_project_category(category);
1559        let in_flight = self
1560            .in_flight
1561            .lock()
1562            .map(|guard| guard.contains_key(&key))
1563            .unwrap_or(false);
1564        let cache = match InspectCache::open_readonly(
1565            snapshot.inspect_dir.clone(),
1566            snapshot.project_root.clone(),
1567        ) {
1568            Ok(Some(cache)) => cache,
1569            Ok(None) => return JobOutcome::Pending { in_flight },
1570            Err(error) => {
1571                return JobOutcome::Failed {
1572                    message: error.to_string(),
1573                }
1574            }
1575        };
1576        self.tier2_read_cached_from_cache(&snapshot, category, &caller_scope, &cache)
1577    }
1578
1579    fn tier2_read_cached_from_cache(
1580        &self,
1581        snapshot: &InspectSnapshot,
1582        category: InspectCategory,
1583        caller_scope: &JobScope,
1584        cache: &(impl InspectCacheRead + ?Sized),
1585    ) -> JobOutcome {
1586        let key = JobKey::for_project_category(category);
1587        let in_flight = self
1588            .in_flight
1589            .lock()
1590            .map(|guard| guard.contains_key(&key))
1591            .unwrap_or(false);
1592        match cache.get_aggregated_for_config(&key, snapshot.config.as_ref()) {
1593            Ok(Some(payload)) => {
1594                match self.tier2_cached_aggregate_is_fresh(snapshot, category, cache) {
1595                    Ok(true) => filter_outcome_for_scope_with_contributions(
1596                        JobOutcome::Fresh { payload },
1597                        snapshot,
1598                        category,
1599                        cache,
1600                        caller_scope,
1601                    ),
1602                    Ok(false) => filter_outcome_for_scope_with_contributions(
1603                        JobOutcome::Stale {
1604                            cached: Some(payload),
1605                            in_flight,
1606                        },
1607                        snapshot,
1608                        category,
1609                        cache,
1610                        caller_scope,
1611                    ),
1612                    Err(message) => JobOutcome::Failed { message },
1613                }
1614            }
1615            Ok(None) => match cache.latest_aggregate_any_hash(category) {
1616                Ok(Some(payload)) => filter_outcome_for_scope_with_contributions(
1617                    JobOutcome::Stale {
1618                        cached: Some(payload),
1619                        in_flight,
1620                    },
1621                    snapshot,
1622                    category,
1623                    cache,
1624                    caller_scope,
1625                ),
1626                Ok(None) => JobOutcome::Pending { in_flight },
1627                Err(error) => JobOutcome::Failed {
1628                    message: error.to_string(),
1629                },
1630            },
1631            Err(error) => JobOutcome::Failed {
1632                message: error.to_string(),
1633            },
1634        }
1635    }
1636
1637    fn tier2_cached_aggregate_is_fresh(
1638        &self,
1639        snapshot: &InspectSnapshot,
1640        category: InspectCategory,
1641        cache: &(impl InspectCacheRead + ?Sized),
1642    ) -> Result<bool, String> {
1643        let cached_records = load_contribution_freshness(cache, category)?;
1644        let cached_relative = cached_records
1645            .iter()
1646            .map(freshness_record_relative_key)
1647            .collect::<BTreeSet<_>>();
1648
1649        // The project walk is part of every identity check, including a negative
1650        // verdict. It detects additions and removals that per-record metadata
1651        // cannot observe, and gives all callers the same gitignore-aware file set.
1652        let project_scope = JobScope::for_project(snapshot.project_root.clone());
1653        let project_files = scope_files(&snapshot.project_root, &project_scope);
1654        let current_by_relative = current_project_files(&snapshot.project_root, &project_files);
1655
1656        let mut records_match = true;
1657        for record in &cached_records {
1658            let absolute = if record.file_path.is_absolute() {
1659                record.file_path.clone()
1660            } else {
1661                snapshot.project_root.join(&record.file_path)
1662            };
1663            match verify_contribution_file(&absolute, &record.freshness) {
1664                ContributionFreshness::Fresh { .. } => {}
1665                ContributionFreshness::Stale | ContributionFreshness::Deleted => {
1666                    records_match = false;
1667                }
1668            }
1669        }
1670
1671        Ok(records_match
1672            && current_by_relative.len() == cached_relative.len()
1673            && current_by_relative
1674                .keys()
1675                .all(|relative| cached_relative.contains(relative)))
1676    }
1677
1678    #[doc(hidden)]
1679    pub fn tier2_run_with_reuse_result(
1680        &self,
1681        snapshot: InspectSnapshot,
1682        category: InspectCategory,
1683        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
1684    ) -> InspectResult {
1685        let job = self.tier2_reuse_job(snapshot, category, callgraph_snapshot);
1686        self.tier2_run_with_reuse_job_result(job)
1687    }
1688
1689    fn tier2_run_with_reuse_job_result(&self, job: InspectJob) -> InspectResult {
1690        self.tier2_run_with_reuse_job_result_with_options(job, Tier2ReuseOptions::default())
1691    }
1692
1693    fn tier2_run_with_reuse_job_result_catching(
1694        &self,
1695        job: InspectJob,
1696        options: Tier2ReuseOptions,
1697    ) -> InspectResult {
1698        let started = Instant::now();
1699        match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1700            self.tier2_run_with_reuse_job_result_with_options(job.clone(), options)
1701        })) {
1702            Ok(result) => result,
1703            Err(_) => InspectResult::failed(
1704                &job,
1705                "tier2 reuse worker panicked before completion",
1706                started.elapsed(),
1707            ),
1708        }
1709    }
1710
1711    fn tier2_run_with_reuse_job_result_with_options(
1712        &self,
1713        mut job: InspectJob,
1714        mut options: Tier2ReuseOptions,
1715    ) -> InspectResult {
1716        let started = Instant::now();
1717        self.reuse_starts.fetch_add(1, Ordering::SeqCst);
1718        self.wait_for_tier2_reuse_waiter_for_debug(&job);
1719        panic_tier2_reuse_for_debug(&job);
1720        if !job.category.is_active() {
1721            let result = InspectResult::failed(
1722                &job,
1723                format!("inspect category '{}' is disabled in v0.33", job.category),
1724                started.elapsed(),
1725            );
1726            log_tier2_benchmark_category_end(&result);
1727            return result;
1728        }
1729        if !job.category.is_tier2() {
1730            let result = InspectResult::failed(
1731                &job,
1732                format!(
1733                    "inspect category '{}' is not a Tier 2 category",
1734                    job.category
1735                ),
1736                started.elapsed(),
1737            );
1738            log_tier2_benchmark_category_end(&result);
1739            return result;
1740        }
1741
1742        if !job.inspect_writer {
1743            let result = InspectResult::failed(
1744                &job,
1745                "inspect writer capability is unavailable for this read-only cache path",
1746                started.elapsed(),
1747            );
1748            log_tier2_benchmark_category_end(&result);
1749            return result;
1750        }
1751
1752        let project_scope = JobScope::for_project(job.project_root.clone());
1753        job.scope_files = scope_files(&job.project_root, &project_scope);
1754        log_tier2_benchmark_category_start(&job);
1755        let cache = match self.cache_for_paths(job.inspect_dir.clone(), job.project_root.clone()) {
1756            Ok(cache) => cache,
1757            Err(message) => {
1758                let result = InspectResult::failed(&job, message, started.elapsed());
1759                log_tier2_benchmark_category_end(&result);
1760                return result;
1761            }
1762        };
1763        delay_tier2_reuse_for_debug(&job.project_root);
1764        if options.has_force_paths() {
1765            if let Ok(cached) = load_contribution_freshness(cache.as_ref(), job.category) {
1766                let (remaining, downgraded) = downgrade_unchanged_forced_paths_with_freshness(
1767                    &job.project_root,
1768                    &cached,
1769                    options.force_rescan_paths.iter().cloned().collect(),
1770                );
1771                options.force_rescan_paths = remaining.into_iter().collect();
1772                if downgraded > 0 {
1773                    crate::slog_info!(
1774                        "inspect: {} forced paths downgraded to cached (content unchanged)",
1775                        downgraded
1776                    );
1777                }
1778            }
1779        }
1780        if !options.has_force_paths() {
1781            if let Ok(Some(success)) =
1782                self.tier2_quick_reuse_success(&job, cache.as_ref(), &options)
1783            {
1784                let result = InspectResult::success(&job, success, started.elapsed());
1785                crate::slog_debug!(
1786                    "perf tier2 category={} reuse=hit ms={}",
1787                    job.category,
1788                    started.elapsed().as_millis()
1789                );
1790                log_tier2_benchmark_category_end(&result);
1791                return result;
1792            }
1793        }
1794
1795        // Automatic scans use the background seed gate to serialize their work.
1796        // A blocking inspect that proves it needs real work joins the interactive
1797        // class instead: it never preempts an in-flight build, but it takes a
1798        // released slot before another maintenance build can extend the wait.
1799        let _interactive_permit = if options.interactive {
1800            let queued_state = if self.semantic_cold_seed_active.load(Ordering::SeqCst) {
1801                InspectBuilderState::GatedBySemanticSeed
1802            } else {
1803                InspectBuilderState::QueuedBehindColdBuilds
1804            };
1805            self.set_builder_state(&job.key, queued_state);
1806            let request = cold_build_limiter::ColdBuildAdmissionRequest::new(
1807                format!("inspect:{}:{}", job.project_root.display(), job.job_id),
1808                cold_build_limiter::ColdBuildAdmissionClass::InspectTriggered,
1809            );
1810            let permit = cold_build_limiter::acquire_blocking_while_cancellable_with_limiter(
1811                &self.cold_build_limiter(),
1812                "explicit inspect Tier-2 run",
1813                request,
1814                || self.heavy_root_work_allowed(),
1815                || {
1816                    crate::executor::current_job_cancellation()
1817                        .is_some_and(|token| token.cancel_requested_before_commit())
1818                },
1819            );
1820            let Some(permit) = permit else {
1821                let result = InspectResult::failed(
1822                    &job,
1823                    "explicit inspect Tier-2 cold-build admission was cancelled",
1824                    started.elapsed(),
1825                );
1826                log_tier2_benchmark_category_end(&result);
1827                return result;
1828            };
1829            self.set_builder_state(&job.key, InspectBuilderState::Building);
1830            Some(permit)
1831        } else {
1832            None
1833        };
1834
1835        let result = match self.tier2_run_with_reuse_job(&job, &cache, &options) {
1836            Ok(success) => InspectResult::success(&job, success, started.elapsed()),
1837            Err(message) => InspectResult::failed(&job, message, started.elapsed()),
1838        };
1839        // Always-on perf line: a full (reuse=miss) scan is the expensive path —
1840        // for dead_code it includes store snapshot projection plus the scanner.
1841        // ms here lets us attribute background CPU bursts to a specific category from the log.
1842        crate::slog_info!(
1843            "perf tier2 category={} reuse=miss ms={}",
1844            job.category,
1845            started.elapsed().as_millis()
1846        );
1847        log_tier2_benchmark_category_end(&result);
1848        result
1849    }
1850
1851    fn tier2_reuse_job(
1852        &self,
1853        snapshot: InspectSnapshot,
1854        category: InspectCategory,
1855        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
1856    ) -> InspectJob {
1857        InspectJob {
1858            job_id: self.next_job_id.fetch_add(1, Ordering::Relaxed),
1859            key: JobKey::for_project_category(category),
1860            category,
1861            scope_files: Vec::new(),
1862            project_root: snapshot.project_root,
1863            inspect_dir: snapshot.inspect_dir,
1864            config: snapshot.config,
1865            symbol_cache: snapshot.symbol_cache,
1866            inspect_writer: snapshot.inspect_writer,
1867            callgraph_writer: snapshot.callgraph_writer,
1868            callgraph_snapshot,
1869        }
1870    }
1871
1872    fn tier2_quick_reuse_success(
1873        &self,
1874        job: &InspectJob,
1875        cache: &InspectCache,
1876        options: &Tier2ReuseOptions,
1877    ) -> Result<Option<InspectScanSuccess>, String> {
1878        let cached_records = load_contribution_freshness(cache, job.category)?;
1879        let current_by_relative = current_project_files(&job.project_root, &job.scope_files);
1880        if cached_records.len() != current_by_relative.len() {
1881            return Ok(None);
1882        }
1883        for record in &cached_records {
1884            let relative = freshness_record_relative_key(record);
1885            let Some(current_file) = current_by_relative.get(&relative) else {
1886                return Ok(None);
1887            };
1888            match cache_freshness::metadata_matches(current_file, &record.freshness) {
1889                Ok(true) => {}
1890                Ok(false) => return Ok(None),
1891                Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1892                Err(error) => {
1893                    return Err(format!(
1894                        "failed to stat {} for tier2 quick reuse: {error}",
1895                        current_file.display()
1896                    ));
1897                }
1898            }
1899        }
1900
1901        let contribution_set_hash = cache
1902            .contribution_set_hash_for_config(job.category, job.config.as_ref())
1903            .map_err(|error| error.to_string())?;
1904        let Some(aggregate) = cache
1905            .load_aggregate_if_hash_matches(job.category, &contribution_set_hash)
1906            .map_err(|error| error.to_string())?
1907        else {
1908            return Ok(None);
1909        };
1910        if !cached_tier2_aggregate_usable(job.category, options, &aggregate) {
1911            return Ok(None);
1912        }
1913
1914        cache
1915            .touch_tier2_last_full_run(job.category)
1916            .map_err(|error| error.to_string())?;
1917        Ok(Some(InspectScanSuccess {
1918            scanned_files: Vec::new(),
1919            contributions: Vec::new(),
1920            aggregate,
1921        }))
1922    }
1923
1924    #[allow(clippy::too_many_lines)]
1925    fn tier2_run_with_reuse_job(
1926        &self,
1927        job: &InspectJob,
1928        cache: &InspectCache,
1929        options: &Tier2ReuseOptions,
1930    ) -> Result<InspectScanSuccess, String> {
1931        let mut phases = Tier2PhaseTimings::default();
1932        let phase_started = Instant::now();
1933        let cached_records = load_contribution_freshness(cache, job.category)?;
1934        let current_by_relative = current_project_files(&job.project_root, &job.scope_files);
1935        let cached_relative = cached_records
1936            .iter()
1937            .map(freshness_record_relative_key)
1938            .collect::<BTreeSet<_>>();
1939        let force_relative = forced_relative_paths(job, &options.force_rescan_paths);
1940        let cold_cache = cached_relative.is_empty();
1941        #[cfg(debug_assertions)]
1942        let debug_cold_cache = cold_cache;
1943
1944        let mut updates = Tier2ContributionUpdates::default();
1945        let mut scan_by_relative = BTreeMap::<String, PathBuf>::new();
1946        let require_callgraph_refresh =
1947            if job.category == InspectCategory::DeadCode && options.require_callgraph_snapshot {
1948                !cache
1949                    .get_aggregated_for_config(&job.key, job.config.as_ref())
1950                    .map_err(|error| error.to_string())?
1951                    .is_some_and(|aggregate| {
1952                        aggregate
1953                            .get("callgraph_available")
1954                            .and_then(Value::as_bool)
1955                            == Some(true)
1956                    })
1957            } else {
1958                false
1959            };
1960        let mut callgraph_refresh_paths = options
1961            .force_rescan_paths
1962            .iter()
1963            .filter(|path| callgraph_store_indexes_path(path))
1964            .cloned()
1965            .collect::<BTreeSet<_>>();
1966        if require_callgraph_refresh {
1967            callgraph_refresh_paths.extend(
1968                current_by_relative
1969                    .values()
1970                    .filter(|path| callgraph_store_indexes_path(path))
1971                    .cloned(),
1972            );
1973        }
1974        let mut aggregate_job = job.clone();
1975
1976        for record in cached_records {
1977            let relative = freshness_record_relative_key(&record);
1978            let relative_path = PathBuf::from(&relative);
1979            let Some(current_file) = current_by_relative.get(&relative) else {
1980                updates.deletes.push(relative_path);
1981                insert_callgraph_refresh_path(
1982                    &mut callgraph_refresh_paths,
1983                    job.project_root.join(&relative),
1984                );
1985                continue;
1986            };
1987
1988            if force_relative.contains(&relative) {
1989                updates.deletes.push(relative_path);
1990                scan_by_relative.insert(relative, current_file.clone());
1991                insert_callgraph_refresh_path(&mut callgraph_refresh_paths, current_file.clone());
1992                continue;
1993            }
1994
1995            let absolute = job.project_root.join(&record.file_path);
1996            match verify_contribution_file(&absolute, &record.freshness) {
1997                ContributionFreshness::Fresh {
1998                    metadata_changed,
1999                    freshness,
2000                } => {
2001                    if metadata_changed {
2002                        updates.metadata_updates.push((relative_path, freshness));
2003                    }
2004                }
2005                ContributionFreshness::Stale => {
2006                    updates.deletes.push(relative_path);
2007                    scan_by_relative.insert(relative, current_file.clone());
2008                    insert_callgraph_refresh_path(
2009                        &mut callgraph_refresh_paths,
2010                        current_file.clone(),
2011                    );
2012                }
2013                ContributionFreshness::Deleted => {
2014                    updates.deletes.push(relative_path);
2015                    insert_callgraph_refresh_path(
2016                        &mut callgraph_refresh_paths,
2017                        job.project_root.join(&record.file_path),
2018                    );
2019                }
2020            }
2021        }
2022
2023        for (relative, file) in &current_by_relative {
2024            if !cached_relative.contains(relative) {
2025                scan_by_relative.insert(relative.clone(), file.clone());
2026                if !cold_cache {
2027                    insert_callgraph_refresh_path(&mut callgraph_refresh_paths, file.clone());
2028                }
2029            }
2030        }
2031        phases.freshness = phase_started.elapsed();
2032
2033        let mut scan_files = scan_by_relative.into_values().collect::<Vec<_>>();
2034        let force_reparse_files = scan_files.clone();
2035        let callgraph_refresh_files = callgraph_refresh_paths.into_iter().collect::<Vec<_>>();
2036        let dead_code_callgraph_refresh =
2037            job.category == InspectCategory::DeadCode && !callgraph_refresh_files.is_empty();
2038        if !scan_files.is_empty() {
2039            let mut scan_job = job.clone();
2040            scan_job.job_id = self.next_job_id.fetch_add(1, Ordering::Relaxed);
2041            scan_job.scope_files = scan_files.clone();
2042            if scan_job.category == InspectCategory::DeadCode
2043                && scan_job.callgraph_snapshot.is_none()
2044            {
2045                let snapshot_started = Instant::now();
2046                scan_job.callgraph_snapshot = self.build_tier2_callgraph_snapshot_with_refresh(
2047                    &scan_job,
2048                    options.allow_callgraph_cold_build,
2049                    options.require_callgraph_snapshot,
2050                    &callgraph_refresh_files,
2051                );
2052                phases.snapshot += snapshot_started.elapsed();
2053            }
2054            aggregate_job.callgraph_snapshot = scan_job.callgraph_snapshot.clone();
2055            #[cfg(debug_assertions)]
2056            if debug_cold_cache {
2057                std::thread::sleep(Duration::from_millis(10));
2058            }
2059            let scan_started = Instant::now();
2060            let oxc_result =
2061                self.oxc_result_for_scan(&scan_job, &scan_job.scope_files, &force_reparse_files)?;
2062            let scan_result = run_tier2_scan(&scan_job, oxc_result.as_ref());
2063            phases.scan += scan_started.elapsed();
2064            phases.scanned_files += scan_files.len();
2065            let scan_success = scan_result.outcome.map_err(|message| {
2066                format!("{} incremental scan failed: {message}", job.category)
2067            })?;
2068            updates.upserts.extend(scan_success.contributions);
2069        }
2070
2071        let has_updates = !updates.upserts.is_empty()
2072            || !updates.deletes.is_empty()
2073            || !updates.metadata_updates.is_empty();
2074        if !has_updates && !dead_code_callgraph_refresh {
2075            if let Some(aggregate) = cache
2076                .get_aggregated_for_config(&job.key, job.config.as_ref())
2077                .map_err(|error| error.to_string())?
2078            {
2079                if cached_tier2_aggregate_usable(job.category, options, &aggregate) {
2080                    cache
2081                        .touch_tier2_last_full_run(job.category)
2082                        .map_err(|error| error.to_string())?;
2083                    phases.log(job.category);
2084                    return Ok(InspectScanSuccess {
2085                        scanned_files: scan_files,
2086                        contributions: Vec::new(),
2087                        aggregate,
2088                    });
2089                }
2090            }
2091        }
2092
2093        let db_started = Instant::now();
2094        let mut contribution_set_hash = if has_updates {
2095            let (hash, db_timings) = cache
2096                .apply_contribution_updates_for_config(job.category, updates, job.config.as_ref())
2097                .map_err(|error| error.to_string())?;
2098            phases.add_db_timings(db_timings);
2099            hash
2100        } else {
2101            cache
2102                .contribution_set_hash_for_config(job.category, job.config.as_ref())
2103                .map_err(|error| error.to_string())?
2104        };
2105        phases.db = db_started.elapsed();
2106
2107        if !dead_code_callgraph_refresh {
2108            if let Some(aggregate) = cache
2109                .load_aggregate_if_hash_matches(job.category, &contribution_set_hash)
2110                .map_err(|error| error.to_string())?
2111            {
2112                if cached_tier2_aggregate_usable(job.category, options, &aggregate) {
2113                    cache
2114                        .touch_tier2_last_full_run(job.category)
2115                        .map_err(|error| error.to_string())?;
2116                    let contributions = load_contributions(cache, job)?;
2117                    phases.log(job.category);
2118                    return Ok(InspectScanSuccess {
2119                        scanned_files: scan_files,
2120                        contributions,
2121                        aggregate,
2122                    });
2123                }
2124            }
2125        }
2126
2127        let refresh_dead_code_facts = if job.category == InspectCategory::DeadCode {
2128            dead_code_contributions_need_fact_refresh(cache, job)?
2129        } else {
2130            false
2131        };
2132        let refresh_unused_exports_facts = if job.category == InspectCategory::UnusedExports {
2133            unused_exports_contributions_need_fact_refresh(cache, job)?
2134        } else {
2135            false
2136        };
2137        let refresh_duplicates_facts = if job.category == InspectCategory::Duplicates {
2138            duplicates_contributions_need_fact_refresh(cache, job)?
2139        } else {
2140            false
2141        };
2142        if refresh_dead_code_facts || refresh_unused_exports_facts || refresh_duplicates_facts {
2143            // Raw-facts contributions can be rolled up after manifest/resolver
2144            // edits without re-reading source. Only legacy verdict-bearing or
2145            // facts-version-mismatched caches need a one-time full refresh before
2146            // verdicts/roots can be recomputed globally.
2147            let full_scan_files = current_by_relative.into_values().collect::<Vec<_>>();
2148            if !full_scan_files.is_empty() {
2149                let mut rescan_job = job.clone();
2150                rescan_job.job_id = self.next_job_id.fetch_add(1, Ordering::Relaxed);
2151                rescan_job.scope_files = full_scan_files.clone();
2152                if rescan_job.category == InspectCategory::DeadCode
2153                    && rescan_job.callgraph_snapshot.is_none()
2154                {
2155                    let snapshot_started = Instant::now();
2156                    rescan_job.callgraph_snapshot = self
2157                        .build_tier2_callgraph_snapshot_with_refresh(
2158                            &rescan_job,
2159                            options.allow_callgraph_cold_build,
2160                            options.require_callgraph_snapshot,
2161                            &callgraph_refresh_files,
2162                        );
2163                    phases.snapshot += snapshot_started.elapsed();
2164                }
2165                let scan_started = Instant::now();
2166                let oxc_result = self.oxc_result_for_scan(
2167                    &rescan_job,
2168                    &rescan_job.scope_files,
2169                    &force_reparse_files,
2170                )?;
2171                let scan_result = run_tier2_scan(&rescan_job, oxc_result.as_ref());
2172                phases.scan += scan_started.elapsed();
2173                phases.scanned_files += full_scan_files.len();
2174                let scan_success = scan_result.outcome.map_err(|message| {
2175                    format!(
2176                        "{} full rescan after entry-point cache miss failed: {message}",
2177                        job.category
2178                    )
2179                })?;
2180                let rescan_updates = Tier2ContributionUpdates {
2181                    upserts: scan_success.contributions,
2182                    ..Tier2ContributionUpdates::default()
2183                };
2184                let db_started = Instant::now();
2185                let (hash, db_timings) = cache
2186                    .apply_contribution_updates_for_config(
2187                        job.category,
2188                        rescan_updates,
2189                        job.config.as_ref(),
2190                    )
2191                    .map_err(|error| error.to_string())?;
2192                contribution_set_hash = hash;
2193                phases.add_db_timings(db_timings);
2194                phases.db += db_started.elapsed();
2195                aggregate_job.callgraph_snapshot = rescan_job.callgraph_snapshot.clone();
2196                scan_files = full_scan_files;
2197
2198                if !dead_code_callgraph_refresh {
2199                    if let Some(aggregate) = cache
2200                        .load_aggregate_if_hash_matches(job.category, &contribution_set_hash)
2201                        .map_err(|error| error.to_string())?
2202                    {
2203                        if cached_tier2_aggregate_usable(job.category, options, &aggregate) {
2204                            cache
2205                                .touch_tier2_last_full_run(job.category)
2206                                .map_err(|error| error.to_string())?;
2207                            let contributions = load_contributions(cache, job)?;
2208                            phases.log(job.category);
2209                            return Ok(InspectScanSuccess {
2210                                scanned_files: scan_files,
2211                                contributions,
2212                                aggregate,
2213                            });
2214                        }
2215                    }
2216                }
2217            }
2218        }
2219
2220        if aggregate_job.category == InspectCategory::DeadCode
2221            && aggregate_job.callgraph_snapshot.is_none()
2222        {
2223            let snapshot_started = Instant::now();
2224            aggregate_job.callgraph_snapshot = self.build_tier2_callgraph_snapshot_with_refresh(
2225                &aggregate_job,
2226                options.allow_callgraph_cold_build,
2227                options.require_callgraph_snapshot,
2228                &callgraph_refresh_files,
2229            );
2230            phases.snapshot += snapshot_started.elapsed();
2231        }
2232        if options.require_callgraph_snapshot
2233            && aggregate_job.category == InspectCategory::DeadCode
2234            && aggregate_job.callgraph_snapshot.is_none()
2235        {
2236            return Err(format!(
2237                "tier2 dead_code aggregate did not complete; builder_state={}",
2238                self.builder_state_detail_for_job(job)
2239            ));
2240        }
2241        let rollup_started = Instant::now();
2242        let contributions = load_contributions(cache, &aggregate_job)?;
2243        let aggregate = roll_up_tier2_contributions(&aggregate_job, &contributions);
2244        cache
2245            .store_tier2_aggregate(job.key.clone(), &contribution_set_hash, aggregate.clone())
2246            .map_err(|error| error.to_string())?;
2247        phases.rollup = rollup_started.elapsed();
2248        phases.log(job.category);
2249
2250        Ok(InspectScanSuccess {
2251            scanned_files: scan_files,
2252            contributions,
2253            aggregate,
2254        })
2255    }
2256
2257    fn enqueue_with_waiter(
2258        &self,
2259        snapshot: InspectSnapshot,
2260        category: InspectCategory,
2261        caller_scope: JobScope,
2262        key: JobKey,
2263        waiter_tx: WaiterTx,
2264        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
2265    ) -> Result<(), String> {
2266        let mut in_flight = self
2267            .in_flight
2268            .lock()
2269            .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
2270        if let Some(waiters) = in_flight.get_mut(&key) {
2271            waiters.push(Waiter { tx: waiter_tx });
2272            return Ok(());
2273        }
2274
2275        in_flight.insert(key.clone(), vec![Waiter { tx: waiter_tx }]);
2276        drop(in_flight);
2277        self.record_flight_start(&key);
2278
2279        if let Err(message) = self.enqueue_new_job(
2280            snapshot,
2281            category,
2282            caller_scope,
2283            key.clone(),
2284            callgraph_snapshot,
2285        ) {
2286            if let Ok(mut in_flight) = self.in_flight.lock() {
2287                in_flight.remove(&key);
2288            }
2289            self.clear_builder_state(&key);
2290            return Err(message);
2291        }
2292        Ok(())
2293    }
2294
2295    fn enqueue_without_waiter(
2296        &self,
2297        snapshot: InspectSnapshot,
2298        category: InspectCategory,
2299        caller_scope: JobScope,
2300        key: JobKey,
2301        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
2302    ) -> Result<(), String> {
2303        let mut in_flight = self
2304            .in_flight
2305            .lock()
2306            .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
2307        if in_flight.contains_key(&key) {
2308            return Ok(());
2309        }
2310        in_flight.insert(key.clone(), Vec::new());
2311        drop(in_flight);
2312        self.record_flight_start(&key);
2313
2314        if let Err(message) = self.enqueue_new_job(
2315            snapshot,
2316            category,
2317            caller_scope,
2318            key.clone(),
2319            callgraph_snapshot,
2320        ) {
2321            if let Ok(mut in_flight) = self.in_flight.lock() {
2322                in_flight.remove(&key);
2323            }
2324            self.clear_builder_state(&key);
2325            return Err(message);
2326        }
2327        Ok(())
2328    }
2329
2330    fn enqueue_new_job(
2331        &self,
2332        snapshot: InspectSnapshot,
2333        category: InspectCategory,
2334        caller_scope: JobScope,
2335        key: JobKey,
2336        callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
2337    ) -> Result<(), String> {
2338        let scan_scope = if category.is_tier2() {
2339            JobScope::for_project(snapshot.project_root.clone())
2340        } else {
2341            caller_scope
2342        };
2343        let scope_files = scope_files(&snapshot.project_root, &scan_scope);
2344        let job = InspectJob {
2345            job_id: self.next_job_id.fetch_add(1, Ordering::Relaxed),
2346            key,
2347            category,
2348            scope_files,
2349            project_root: snapshot.project_root,
2350            inspect_dir: snapshot.inspect_dir,
2351            config: snapshot.config,
2352            symbol_cache: snapshot.symbol_cache,
2353            inspect_writer: snapshot.inspect_writer,
2354            callgraph_writer: snapshot.callgraph_writer,
2355            callgraph_snapshot,
2356        };
2357        self.request_tx
2358            .send(job)
2359            .map_err(|_| "inspect dispatch loop is unavailable".to_string())
2360    }
2361
2362    fn wait_for_outcome(
2363        &self,
2364        key: JobKey,
2365        caller_scope: JobScope,
2366        cache: Arc<InspectCache>,
2367        waiter_rx: Receiver<JobOutcome>,
2368        snapshot: InspectSnapshot,
2369    ) -> JobOutcome {
2370        let timeout = after(self.soft_deadline);
2371        let result_rx = self.result_rx.clone();
2372        loop {
2373            select! {
2374                recv(waiter_rx) -> outcome => {
2375                    return match outcome {
2376                        Ok(outcome) => filter_outcome_for_scope_with_contributions(
2377                            outcome,
2378                            &snapshot,
2379                            key.category,
2380                            cache.as_ref(),
2381                            &caller_scope,
2382                        ),
2383                        Err(_) => self.timeout_outcome(&key, &caller_scope, &cache, &snapshot),
2384                    };
2385                }
2386                recv(result_rx) -> result => {
2387                    match result {
2388                        Ok(result) => self.route_completion(result),
2389                        Err(_) => return self.timeout_outcome(&key, &caller_scope, &cache, &snapshot),
2390                    }
2391                }
2392                recv(timeout) -> _ => {
2393                    return self.timeout_outcome(&key, &caller_scope, &cache, &snapshot);
2394                }
2395            }
2396        }
2397    }
2398
2399    fn timeout_outcome(
2400        &self,
2401        key: &JobKey,
2402        caller_scope: &JobScope,
2403        cache: &(impl InspectCacheRead + ?Sized),
2404        snapshot: &InspectSnapshot,
2405    ) -> JobOutcome {
2406        match cache.get_aggregated_for_config(key, snapshot.config.as_ref()) {
2407            Ok(Some(cached)) => filter_outcome_for_scope_with_contributions(
2408                JobOutcome::Stale {
2409                    cached: Some(cached),
2410                    in_flight: true,
2411                },
2412                snapshot,
2413                key.category,
2414                cache,
2415                caller_scope,
2416            ),
2417            Ok(None) => JobOutcome::Pending { in_flight: true },
2418            Err(error) => JobOutcome::Failed {
2419                message: error.to_string(),
2420            },
2421        }
2422    }
2423
2424    fn route_completion(&self, result: InspectResult) {
2425        let outcome = self.completion_outcome(result.clone());
2426        self.record_builder_attempt_outcome(&result.key, &outcome);
2427        let waiters = self
2428            .in_flight
2429            .lock()
2430            .ok()
2431            .and_then(|mut in_flight| in_flight.remove(&result.key))
2432            .unwrap_or_default();
2433        for waiter in waiters {
2434            let _ = waiter.tx.send(outcome.clone());
2435        }
2436    }
2437
2438    fn route_tier2_reuse_completion(&self, result: InspectResult) {
2439        let outcome = match result.outcome.clone() {
2440            Ok(success) => JobOutcome::Fresh {
2441                payload: success.aggregate,
2442            },
2443            Err(message) => JobOutcome::Failed { message },
2444        };
2445        // Publish completion before waking waiters so a direct-reuse caller sees all
2446        // completion side effects when its result channel becomes ready. The same
2447        // finish path runs from the exit guard if this router is skipped.
2448        self.finish_tier2_flight(&result.key, outcome);
2449        // The counter also signals the main-thread drain that a background
2450        // (watcher-driven) Tier-2 scan finished. This path bypasses
2451        // `result_rx`/`drain_completions`, so without this signal the bar's
2452        // counts and `~` marker would only update on a manual `aft_inspect`.
2453    }
2454
2455    /// Snapshot the cumulative count of reuse-path (watcher-driven) Tier-2
2456    /// completions. The main-thread drain compares this against its last-seen
2457    /// value to detect background scans that finished since the previous tick.
2458    pub fn reuse_completion_count(&self) -> u64 {
2459        self.reuse_completions.load(Ordering::SeqCst)
2460    }
2461
2462    #[doc(hidden)]
2463    pub fn reuse_start_count_for_test(&self) -> u64 {
2464        self.reuse_starts.load(Ordering::SeqCst)
2465    }
2466
2467    fn completion_outcome(&self, result: InspectResult) -> JobOutcome {
2468        let cache =
2469            match self.cache_for_paths(result.inspect_dir.clone(), result.project_root.clone()) {
2470                Ok(cache) => cache,
2471                Err(message) => return JobOutcome::Failed { message },
2472            };
2473
2474        match result.outcome {
2475            Ok(success) => {
2476                let store_result = if result.category.is_tier2() {
2477                    cache.store_tier2_result_for_config(
2478                        result.key.clone(),
2479                        &success.scanned_files,
2480                        &success.contributions,
2481                        success.aggregate.clone(),
2482                        result.config.as_ref(),
2483                    )
2484                } else {
2485                    cache.store_aggregated(result.key, success.aggregate.clone())
2486                };
2487
2488                match store_result {
2489                    Ok(()) => JobOutcome::Fresh {
2490                        payload: success.aggregate,
2491                    },
2492                    Err(error) => JobOutcome::Failed {
2493                        message: error.to_string(),
2494                    },
2495                }
2496            }
2497            Err(message) => JobOutcome::Failed { message },
2498        }
2499    }
2500}
2501
2502impl Default for InspectManager {
2503    fn default() -> Self {
2504        Self::new()
2505    }
2506}
2507
2508fn validate_tier2_read_category(category: InspectCategory) -> Result<(), JobOutcome> {
2509    if !category.is_active() {
2510        return Err(JobOutcome::Failed {
2511            message: format!("inspect category '{category}' is disabled in v0.33"),
2512        });
2513    }
2514    if !category.is_tier2() {
2515        return Err(JobOutcome::Failed {
2516            message: format!("inspect category '{category}' is not a Tier 2 category"),
2517        });
2518    }
2519    Ok(())
2520}
2521
2522/// Phase-level wall-time attribution for one Tier-2 reuse=miss pass.
2523///
2524/// Exists to self-attribute pathological scans (e.g. a normally-100ms
2525/// unused_exports pass once took 677s under heavy machine load) without
2526/// needing a lucky live `sample`. Logged as ONE info line per pass, only when
2527/// real work happened (freshness/scan/snapshot/rollup/db), so quiet reuse passes stay silent.
2528#[derive(Default)]
2529struct Tier2PhaseTimings {
2530    /// Freshness verification of cached contributions (file stat + hash reads).
2531    freshness: Duration,
2532    /// Callgraph store snapshot projection (dead_code only).
2533    snapshot: Duration,
2534    /// Scanner compute over files needing (re)scan.
2535    scan: Duration,
2536    /// SQLite contribution upserts/deletes, including connection lock wait.
2537    db: Duration,
2538    /// Time waiting for the shared SQLite connection mutex.
2539    db_lock: Duration,
2540    /// Time spent in contribution update transactions after acquiring the mutex.
2541    db_txn: Duration,
2542    /// Aggregate roll-up + store.
2543    rollup: Duration,
2544    scanned_files: usize,
2545}
2546
2547impl Tier2PhaseTimings {
2548    fn add_db_timings(&mut self, timings: InspectDbTimings) {
2549        self.db_lock += timings.lock_wait;
2550        self.db_txn += timings.transaction;
2551    }
2552
2553    fn log(&self, category: InspectCategory) {
2554        let worked = self.freshness + self.scan + self.snapshot + self.rollup + self.db;
2555        if !worked.is_zero() {
2556            crate::logging::note_tier2_scan(
2557                category.to_string(),
2558                worked.as_millis().min(u128::from(u64::MAX)) as u64,
2559            );
2560        }
2561        if worked < Duration::from_millis(50) {
2562            return;
2563        }
2564        crate::slog_info!(
2565            "perf tier2 phases category={} freshness={}ms snapshot={}ms scan={}ms({} files) db={}ms(lock={},txn={}) rollup={}ms",
2566            category,
2567            self.freshness.as_millis(),
2568            self.snapshot.as_millis(),
2569            self.scan.as_millis(),
2570            self.scanned_files,
2571            self.db.as_millis(),
2572            self.db_lock.as_millis(),
2573            self.db_txn.as_millis(),
2574            self.rollup.as_millis()
2575        );
2576    }
2577}
2578
2579fn scope_files(project_root: &Path, scope: &JobScope) -> Vec<PathBuf> {
2580    let mut files = crate::callgraph::walk_project_files(project_root)
2581        .filter(|path| scope.contains(path))
2582        .collect::<Vec<_>>();
2583    files.sort();
2584    files
2585}
2586
2587fn forced_relative_paths(job: &InspectJob, paths: &BTreeSet<PathBuf>) -> BTreeSet<String> {
2588    let mut keys = BTreeSet::new();
2589    for path in paths {
2590        let absolute = if path.is_absolute() {
2591            path.clone()
2592        } else {
2593            job.project_root.join(path)
2594        };
2595        keys.insert(relative_cache_key(&job.project_root, &absolute));
2596        // Normalized, not bare-canonical: the project root is verbatim-stripped,
2597        // so a verbatim canonical path would fail strip_prefix and produce an
2598        // absolute key no cached contribution matches (the forced rescan then
2599        // silently misses).
2600        keys.insert(relative_cache_key(
2601            &job.project_root,
2602            &crate::inspect::job::canonicalize_normalized(&absolute),
2603        ));
2604    }
2605    keys
2606}
2607
2608fn downgrade_unchanged_forced_paths_with_freshness(
2609    project_root: &Path,
2610    cached: &[CachedContributionFreshness],
2611    paths: Vec<PathBuf>,
2612) -> (Vec<PathBuf>, usize) {
2613    let cached = cached
2614        .iter()
2615        .map(|record| (freshness_record_relative_key(record), record.freshness))
2616        .collect::<BTreeMap<_, _>>();
2617    let mut remaining = Vec::with_capacity(paths.len());
2618    let mut downgraded = 0;
2619
2620    for path in paths {
2621        let absolute = if path.is_absolute() {
2622            path.clone()
2623        } else {
2624            project_root.join(&path)
2625        };
2626        let direct_key = relative_cache_key(project_root, &absolute);
2627        // Same normalized form as forced_relative_paths; see the comment there.
2628        let canonical_key = Some(relative_cache_key(
2629            project_root,
2630            &crate::inspect::job::canonicalize_normalized(&absolute),
2631        ));
2632        let freshness = cached
2633            .get(&direct_key)
2634            .or_else(|| canonical_key.as_ref().and_then(|key| cached.get(key)));
2635        let content_unchanged = freshness.is_some_and(|freshness| {
2636            matches!(
2637                cache_freshness::verify_file_strict(&absolute, freshness),
2638                FreshnessVerdict::HotFresh | FreshnessVerdict::ContentFresh { .. }
2639            )
2640        });
2641        if content_unchanged {
2642            downgraded += 1;
2643        } else {
2644            remaining.push(path);
2645        }
2646    }
2647
2648    (remaining, downgraded)
2649}
2650
2651fn panic_tier2_reuse_for_debug(job: &InspectJob) {
2652    #[cfg(not(debug_assertions))]
2653    let _ = job;
2654    #[cfg(debug_assertions)]
2655    {
2656        if !env_project_root_matches("AFT_TEST_TIER2_REUSE_PANIC_ROOT", &job.project_root) {
2657            return;
2658        }
2659        let should_panic = std::env::var("AFT_TEST_TIER2_REUSE_PANIC_CATEGORY")
2660            .ok()
2661            .is_some_and(|category| category == job.category.as_str());
2662        if should_panic {
2663            panic!("forced tier2 reuse panic for {}", job.category);
2664        }
2665    }
2666}
2667
2668fn delay_tier2_reuse_for_debug(project_root: &Path) {
2669    #[cfg(not(debug_assertions))]
2670    let _ = project_root;
2671    #[cfg(debug_assertions)]
2672    {
2673        if !env_project_root_matches("AFT_TEST_TIER2_REUSE_DELAY_ROOT", project_root) {
2674            return;
2675        }
2676        if let Some(delay_ms) = std::env::var("AFT_TEST_TIER2_REUSE_DELAY_MS")
2677            .ok()
2678            .and_then(|raw| raw.parse::<u64>().ok())
2679        {
2680            std::thread::sleep(Duration::from_millis(delay_ms));
2681        }
2682    }
2683}
2684
2685#[cfg(debug_assertions)]
2686fn env_project_root_matches(var: &str, project_root: &Path) -> bool {
2687    let Some(raw) = std::env::var_os(var) else {
2688        return true;
2689    };
2690    let expected = PathBuf::from(raw);
2691    let expected = std::fs::canonicalize(&expected).unwrap_or(expected);
2692    let actual = std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
2693    expected == actual
2694}
2695
2696fn current_project_files(project_root: &Path, files: &[PathBuf]) -> BTreeMap<String, PathBuf> {
2697    files
2698        .iter()
2699        .map(|file| (relative_cache_key(project_root, file), file.clone()))
2700        .collect()
2701}
2702
2703fn insert_callgraph_refresh_path(paths: &mut BTreeSet<PathBuf>, path: PathBuf) {
2704    if callgraph_store_indexes_path(&path) {
2705        paths.insert(path);
2706    }
2707}
2708
2709fn callgraph_store_indexes_path(path: &Path) -> bool {
2710    crate::parser::detect_language(path).is_some()
2711}
2712
2713fn tier2_benchmark_logging_enabled() -> bool {
2714    std::env::var_os("AFT_SETTLE_BENCH_LOG").is_some()
2715}
2716
2717fn log_tier2_benchmark_category_start(job: &InspectJob) {
2718    if !tier2_benchmark_logging_enabled() {
2719        return;
2720    }
2721    crate::slog_info!(
2722        "settle bench: tier2_category_start category={} job_id={} files={}",
2723        job.category.as_str(),
2724        job.job_id,
2725        job.scope_files.len()
2726    );
2727}
2728
2729fn log_tier2_benchmark_category_end(result: &InspectResult) {
2730    if !tier2_benchmark_logging_enabled() {
2731        return;
2732    }
2733    match &result.outcome {
2734        Ok(success) => {
2735            let count = success
2736                .aggregate
2737                .get("count")
2738                .and_then(serde_json::Value::as_u64)
2739                .unwrap_or(0);
2740            crate::slog_info!(
2741                "settle bench: tier2_category_end category={} job_id={} status=success total_ms={} scanned_files={} contributions={} count={}",
2742                result.category.as_str(),
2743                result.job_id,
2744                result.duration.as_millis(),
2745                success.scanned_files.len(),
2746                success.contributions.len(),
2747                count
2748            );
2749        }
2750        Err(message) => {
2751            crate::slog_info!(
2752                "settle bench: tier2_category_end category={} job_id={} status=failed total_ms={} error={}",
2753                result.category.as_str(),
2754                result.job_id,
2755                result.duration.as_millis(),
2756                message.replace('\n', " ")
2757            );
2758        }
2759    }
2760}
2761
2762fn build_tier2_callgraph_snapshot(
2763    job: &InspectJob,
2764    allow_cold_build: bool,
2765) -> Option<Arc<CallgraphSnapshot>> {
2766    build_tier2_callgraph_snapshot_with_refresh_inner(job, allow_cold_build, false, &[], None)
2767}
2768
2769#[cfg(test)]
2770fn build_tier2_callgraph_snapshot_with_refresh(
2771    job: &InspectJob,
2772    allow_cold_build: bool,
2773    refresh_paths: &[PathBuf],
2774) -> Option<Arc<CallgraphSnapshot>> {
2775    build_tier2_callgraph_snapshot_with_refresh_inner(
2776        job,
2777        allow_cold_build,
2778        false,
2779        refresh_paths,
2780        None,
2781    )
2782}
2783
2784const BLOCKING_CALLGRAPH_STORE_RETRY_TIMEOUT: Duration = Duration::from_secs(30);
2785const BLOCKING_CALLGRAPH_STORE_RETRY_INTERVAL: Duration = Duration::from_millis(20);
2786
2787fn open_ready_for_blocking_inspect(
2788    callgraph_dir: &Path,
2789    project_root: &Path,
2790    wait_for_publication: bool,
2791) -> Result<Option<CallGraphStore>, CallGraphStoreError> {
2792    let deadline = Instant::now() + BLOCKING_CALLGRAPH_STORE_RETRY_TIMEOUT;
2793    loop {
2794        match CallGraphStore::open_ready_repairing(
2795            callgraph_dir.to_path_buf(),
2796            project_root.to_path_buf(),
2797        ) {
2798            Ok(None) if wait_for_publication && Instant::now() < deadline => {}
2799            Err(error) if error.is_transient_lock_contention() && Instant::now() < deadline => {}
2800            result => return result,
2801        }
2802        std::thread::sleep(BLOCKING_CALLGRAPH_STORE_RETRY_INTERVAL);
2803    }
2804}
2805
2806fn open_or_build_blocking_callgraph_store(
2807    callgraph_dir: PathBuf,
2808    project_root: PathBuf,
2809    allow_cold_build: bool,
2810    refresh_paths: &[PathBuf],
2811) -> Result<Option<CallGraphStore>, CallGraphStoreError> {
2812    if let Some(store) = open_ready_for_blocking_inspect(&callgraph_dir, &project_root, false)? {
2813        return Ok(Some(store));
2814    }
2815    if !allow_cold_build || refresh_paths.is_empty() {
2816        return Ok(None);
2817    }
2818
2819    match CallGraphStore::cold_build_with_lease(
2820        callgraph_dir.clone(),
2821        project_root.clone(),
2822        refresh_paths,
2823    ) {
2824        Ok((store, _)) => Ok(Some(store)),
2825        Err(error)
2826            if matches!(error, CallGraphStoreError::Unavailable(_))
2827                || error.is_transient_lock_contention() =>
2828        {
2829            // The background builder and an inspect-triggered cold build can
2830            // briefly meet on the same generation. Keep either lock loser on
2831            // the Building/retry path instead of terminally failing inspect.
2832            match open_ready_for_blocking_inspect(&callgraph_dir, &project_root, true)? {
2833                Some(store) => Ok(Some(store)),
2834                None => Err(error),
2835            }
2836        }
2837        Err(error) => Err(error),
2838    }
2839}
2840
2841fn merge_callgraph_refresh_paths(
2842    project_root: &Path,
2843    refresh_paths: &[PathBuf],
2844    stale: impl IntoIterator<Item = String>,
2845) -> Vec<PathBuf> {
2846    let mut paths = refresh_paths.to_vec();
2847    for rel in stale {
2848        let absolute = project_root.join(rel);
2849        if !paths.iter().any(|path| path == &absolute) {
2850            paths.push(absolute);
2851        }
2852    }
2853    paths
2854}
2855
2856fn refresh_writable_dead_code_store(
2857    store: &CallGraphStore,
2858    callgraph_dir: &Path,
2859    refresh_paths: &[PathBuf],
2860) {
2861    match store.refresh_files(refresh_paths) {
2862        Ok(stats) => {
2863            crate::slog_info!(
2864                "tier2 dead_code: refreshed callgraph store at {} for {} watcher path(s): changed={} deleted={} refreshed_own={}",
2865                callgraph_dir.display(),
2866                refresh_paths.len(),
2867                stats.changed_files.len(),
2868                stats.deleted_files.len(),
2869                stats.refreshed_own_files
2870            );
2871        }
2872        Err(error) => {
2873            crate::slog_warn!(
2874                "tier2 dead_code: failed to refresh callgraph store at {} before projection: {}",
2875                callgraph_dir.display(),
2876                error
2877            );
2878            if let Err(mark_error) = store.mark_files_stale(refresh_paths) {
2879                crate::slog_warn!(
2880                    "tier2 dead_code: failed to mark callgraph store files stale at {} after refresh failure: {}",
2881                    callgraph_dir.display(),
2882                    mark_error
2883                );
2884            }
2885        }
2886    }
2887}
2888
2889fn open_writable_dead_code_store(
2890    callgraph_dir: PathBuf,
2891    project_root: PathBuf,
2892    allow_cold_build: bool,
2893    build_if_missing: bool,
2894    refresh_paths: &[PathBuf],
2895) -> Result<Option<CallGraphStore>, CallGraphStoreError> {
2896    if build_if_missing {
2897        open_or_build_blocking_callgraph_store(
2898            callgraph_dir,
2899            project_root,
2900            allow_cold_build,
2901            refresh_paths,
2902        )
2903    } else if allow_cold_build {
2904        CallGraphStore::open_ready_repairing(callgraph_dir, project_root)
2905    } else {
2906        CallGraphStore::open_ready_no_rebuild(callgraph_dir, project_root)
2907    }
2908}
2909
2910fn build_tier2_callgraph_snapshot_with_refresh_inner(
2911    job: &InspectJob,
2912    allow_cold_build: bool,
2913    build_if_missing: bool,
2914    refresh_paths: &[PathBuf],
2915    projection_cache: Option<&InspectManager>,
2916) -> Option<Arc<CallgraphSnapshot>> {
2917    let started = Instant::now();
2918    if !job.config.callgraph_store {
2919        crate::slog_info!(
2920            "tier2 dead_code: callgraph store disabled; reporting callgraph_unavailable"
2921        );
2922        return None;
2923    }
2924
2925    let callgraph_dirs = callgraph_store_dirs_from_inspect_dir(&job.inspect_dir, &job.project_root);
2926    if callgraph_dirs.is_empty() {
2927        crate::slog_info!(
2928            "tier2 dead_code: inspect_dir has no root-keyed storage parent ({}); reporting callgraph_unavailable",
2929            job.inspect_dir.display()
2930        );
2931        return None;
2932    };
2933    for callgraph_dir in &callgraph_dirs {
2934        match CallGraphStore::cold_build_suspension(callgraph_dir, &job.project_root) {
2935            Ok(Some(suspension)) => {
2936                // This is a durable admission refusal, not a failed scan attempt.
2937                // Preserve it separately so blocking inspect reports the same
2938                // breaker tuple that navigation and health expose.
2939                if let Some(manager) = projection_cache {
2940                    manager.record_tier2_build_suspension(&job.key, suspension.clone());
2941                }
2942                crate::slog_info!(
2943                    "tier2 dead_code: callgraph build suspended for {} after {} deaths",
2944                    suspension.domain.as_str(),
2945                    suspension.death_count
2946                );
2947                return None;
2948            }
2949            Ok(None) => {}
2950            Err(error) => {
2951                crate::slog_warn!(
2952                    "tier2 dead_code: failed to read callgraph breaker at {}: {}",
2953                    callgraph_dir.display(),
2954                    error
2955                );
2956            }
2957        }
2958    }
2959
2960    enum ProjectionStore {
2961        ReadOnly(ReadonlyCallGraphStore),
2962        Writable(CallGraphStore),
2963    }
2964
2965    impl ProjectionStore {
2966        fn sqlite_path(&self) -> &Path {
2967            match self {
2968                Self::ReadOnly(store) => store.sqlite_path(),
2969                Self::Writable(store) => store.sqlite_path(),
2970            }
2971        }
2972
2973        fn projection_identity(
2974            &self,
2975            project_root: &Path,
2976            write_revision: u64,
2977        ) -> CallgraphProjectionIdentity {
2978            let generation = match self {
2979                Self::ReadOnly(store) => store.projection_generation(),
2980                Self::Writable(store) => store.projection_generation(),
2981            }
2982            .map(str::to_owned);
2983            let legacy_sqlite_path = generation
2984                .is_none()
2985                .then(|| self.sqlite_path().to_path_buf());
2986            CallgraphProjectionIdentity {
2987                project_root: project_root.to_path_buf(),
2988                generation,
2989                legacy_sqlite_path,
2990                write_revision,
2991            }
2992        }
2993
2994        fn current_projection_identity(
2995            &self,
2996            project_root: &Path,
2997        ) -> Result<Option<CallgraphProjectionIdentity>, CallGraphStoreError> {
2998            let write_revision = match self {
2999                Self::ReadOnly(store) => store.projection_write_revision()?,
3000                Self::Writable(store) => store.projection_write_revision()?,
3001            };
3002            Ok(write_revision.map(|revision| self.projection_identity(project_root, revision)))
3003        }
3004    }
3005
3006    for (index, callgraph_dir) in callgraph_dirs.iter().enumerate() {
3007        // Paths without an explicit refresh stay read-only unless the published
3008        // store still has stale backend rows. The background refresh worker is
3009        // the usual writer for those rows; when it never runs for this root,
3010        // dead_code refreshes them inline so projection is not stuck forever.
3011        let projection_store = if refresh_paths.is_empty() || !job.callgraph_writer {
3012            let store = match CallGraphStore::open_readonly(
3013                callgraph_dir.clone(),
3014                job.project_root.clone(),
3015            ) {
3016                Ok(Some(store)) => store,
3017                Ok(None) => {
3018                    crate::slog_info!(
3019                        "tier2 dead_code: callgraph store unavailable at {} (cold/building/not ready); trying fallback={}",
3020                        callgraph_dir.display(),
3021                        index + 1 < callgraph_dirs.len()
3022                    );
3023                    continue;
3024                }
3025                Err(error) => {
3026                    crate::slog_warn!(
3027                        "tier2 dead_code: failed to open callgraph store read-only at {}: {}; trying fallback={}",
3028                        callgraph_dir.display(),
3029                        error,
3030                        index + 1 < callgraph_dirs.len()
3031                    );
3032                    continue;
3033                }
3034            };
3035            let stale = job
3036                .callgraph_writer
3037                .then(|| store.stale_files().ok())
3038                .flatten()
3039                .unwrap_or_default();
3040            if stale.is_empty() {
3041                ProjectionStore::ReadOnly(store)
3042            } else {
3043                drop(store);
3044                let refresh =
3045                    merge_callgraph_refresh_paths(&job.project_root, refresh_paths, stale);
3046                let store = match open_writable_dead_code_store(
3047                    callgraph_dir.clone(),
3048                    job.project_root.clone(),
3049                    allow_cold_build,
3050                    build_if_missing,
3051                    &refresh,
3052                ) {
3053                    Ok(Some(store)) => store,
3054                    Ok(None) => {
3055                        crate::slog_info!(
3056                            "tier2 dead_code: callgraph store unavailable at {} (cold/building/not ready); trying fallback={}",
3057                            callgraph_dir.display(),
3058                            index + 1 < callgraph_dirs.len()
3059                        );
3060                        continue;
3061                    }
3062                    Err(error) => {
3063                        crate::slog_warn!(
3064                            "tier2 dead_code: failed to open callgraph writer at {}: {}; trying fallback={}",
3065                            callgraph_dir.display(),
3066                            error,
3067                            index + 1 < callgraph_dirs.len()
3068                        );
3069                        continue;
3070                    }
3071                };
3072                refresh_writable_dead_code_store(&store, callgraph_dir, &refresh);
3073                ProjectionStore::Writable(store)
3074            }
3075        } else {
3076            let store = match open_writable_dead_code_store(
3077                callgraph_dir.clone(),
3078                job.project_root.clone(),
3079                allow_cold_build,
3080                build_if_missing,
3081                refresh_paths,
3082            ) {
3083                Ok(Some(store)) => store,
3084                Ok(None) => {
3085                    crate::slog_info!(
3086                        "tier2 dead_code: callgraph store unavailable at {} (cold/building/not ready); trying fallback={}",
3087                        callgraph_dir.display(),
3088                        index + 1 < callgraph_dirs.len()
3089                    );
3090                    continue;
3091                }
3092                Err(error) => {
3093                    crate::slog_warn!(
3094                        "tier2 dead_code: failed to open callgraph writer at {}: {}; trying fallback={}",
3095                        callgraph_dir.display(),
3096                        error,
3097                        index + 1 < callgraph_dirs.len()
3098                    );
3099                    continue;
3100                }
3101            };
3102            let stale = store.stale_files().unwrap_or_default();
3103            let refresh = merge_callgraph_refresh_paths(&job.project_root, refresh_paths, stale);
3104            refresh_writable_dead_code_store(&store, callgraph_dir, &refresh);
3105            ProjectionStore::Writable(store)
3106        };
3107
3108        let cache_identity = match projection_store.current_projection_identity(&job.project_root) {
3109            Ok(identity) => identity,
3110            Err(error) => {
3111                crate::slog_warn!(
3112                    "tier2 dead_code: failed to read callgraph projection identity at {}: {}; trying fallback={}",
3113                    callgraph_dir.display(),
3114                    error,
3115                    index + 1 < callgraph_dirs.len()
3116                );
3117                continue;
3118            }
3119        };
3120        if let (Some(cache), Some(identity)) = (projection_cache, cache_identity.as_ref()) {
3121            // The pointer names immutable cold-build generations, while the durable
3122            // revision advances in the same SQLite transaction as every in-place
3123            // graph mutation. Equal identities therefore prove identical store
3124            // bytes for dead-code projection: this cache is exact, not heuristic.
3125            if let Some(snapshot) = cache.cached_callgraph_projection(identity) {
3126                return Some(snapshot);
3127            }
3128        } else if cache_identity.is_none() {
3129            // Stores from older binaries lack a durable revision, so keeping an
3130            // earlier snapshot would make an in-place refresh indistinguishable.
3131            if let Some(cache) = projection_cache {
3132                cache.clear_callgraph_projection();
3133            }
3134        }
3135
3136        let (write_revision, snapshot) = match project_dead_code_snapshot_with_revision(
3137            projection_store.sqlite_path(),
3138        ) {
3139            Ok(projected) => projected,
3140            Err(CallGraphStoreError::Unavailable(message)) => {
3141                crate::slog_info!(
3142                        "tier2 dead_code: callgraph store projection unavailable at {} ({}); trying fallback={}",
3143                        callgraph_dir.display(),
3144                        message,
3145                        index + 1 < callgraph_dirs.len()
3146                    );
3147                continue;
3148            }
3149            Err(error) => {
3150                crate::slog_warn!(
3151                        "tier2 dead_code: callgraph store projection failed at {}: {}; trying fallback={}",
3152                        callgraph_dir.display(),
3153                        error,
3154                        index + 1 < callgraph_dirs.len()
3155                    );
3156                continue;
3157            }
3158        };
3159        let snapshot = Arc::new(snapshot);
3160        if let (Some(cache), Some(write_revision)) = (projection_cache, write_revision) {
3161            cache.cache_callgraph_projection(
3162                projection_store.projection_identity(&job.project_root, write_revision),
3163                Arc::clone(&snapshot),
3164            );
3165        }
3166
3167        if index > 0 {
3168            crate::slog_info!(
3169                "tier2 dead_code: using ready callgraph store fallback {} for inspect_dir {}",
3170                callgraph_dir.display(),
3171                job.inspect_dir.display()
3172            );
3173        }
3174
3175        crate::slog_info!(
3176            "perf tier2_callgraph_snapshot: source=callgraph_store files={} exports={} edges={} entry_points={} ms={}",
3177            snapshot.files.len(),
3178            snapshot.exported_symbols.len(),
3179            snapshot.outbound_calls.len(),
3180            snapshot.entry_points.len(),
3181            started.elapsed().as_millis()
3182        );
3183
3184        return Some(snapshot);
3185    }
3186
3187    crate::slog_info!(
3188        "tier2 dead_code: no ready callgraph store found for inspect_dir {}; reporting callgraph_unavailable",
3189        job.inspect_dir.display()
3190    );
3191    None
3192}
3193
3194fn estimate_callgraph_snapshot_bytes(snapshot: &CallgraphSnapshot) -> u64 {
3195    let files = snapshot.files.iter().fold(0u64, |bytes, path| {
3196        bytes
3197            .saturating_add(std::mem::size_of::<PathBuf>() as u64)
3198            .saturating_add(crate::memory::path_bytes(path))
3199    });
3200    let exports = snapshot
3201        .exported_symbols
3202        .iter()
3203        .fold(0u64, |bytes, export| {
3204            bytes
3205                .saturating_add(std::mem::size_of::<super::job::CallgraphExport>() as u64)
3206                .saturating_add(crate::memory::path_bytes(&export.file))
3207                .saturating_add(crate::memory::usize_to_u64(export.symbol.len()))
3208                .saturating_add(crate::memory::usize_to_u64(export.kind.len()))
3209        });
3210    let calls = snapshot.outbound_calls.iter().fold(0u64, |bytes, call| {
3211        bytes
3212            .saturating_add(std::mem::size_of::<super::job::CallgraphOutboundCall>() as u64)
3213            .saturating_add(crate::memory::path_bytes(&call.caller_file))
3214            .saturating_add(crate::memory::usize_to_u64(call.caller_symbol.len()))
3215            .saturating_add(crate::memory::usize_to_u64(call.target.len()))
3216            .saturating_add(crate::memory::usize_to_u64(call.provenance.len()))
3217    });
3218    let entry_points = snapshot.entry_points.iter().fold(0u64, |bytes, path| {
3219        bytes
3220            .saturating_add(std::mem::size_of::<PathBuf>() as u64)
3221            .saturating_add(crate::memory::path_bytes(path))
3222    });
3223    let entry_point_symbols =
3224        snapshot
3225            .entry_point_symbols
3226            .iter()
3227            .fold(0u64, |bytes, (path, symbols)| {
3228                let symbols_bytes = symbols.iter().fold(0u64, |bytes, symbol| {
3229                    bytes
3230                        .saturating_add(std::mem::size_of::<String>() as u64)
3231                        .saturating_add(crate::memory::usize_to_u64(symbol.len()))
3232                });
3233                bytes
3234                    .saturating_add(std::mem::size_of::<(PathBuf, BTreeSet<String>)>() as u64)
3235                    .saturating_add(crate::memory::path_bytes(path))
3236                    .saturating_add(symbols_bytes)
3237            });
3238    (std::mem::size_of::<CallgraphSnapshot>() as u64)
3239        .saturating_add(files)
3240        .saturating_add(exports)
3241        .saturating_add(calls)
3242        .saturating_add(entry_points)
3243        .saturating_add(entry_point_symbols)
3244}
3245
3246fn callgraph_store_dir_from_inspect_dir(
3247    inspect_dir: &Path,
3248    project_root: &Path,
3249) -> Option<PathBuf> {
3250    let scope_key = crate::path_identity::project_scope_key(project_root);
3251    let storage_dir = if inspect_dir
3252        .file_name()
3253        .and_then(|name| name.to_str())
3254        .is_some_and(|name| name == scope_key)
3255    {
3256        inspect_dir.parent()?.parent()?
3257    } else {
3258        inspect_dir.parent()?
3259    };
3260    let project_key = crate::search_index::artifact_cache_key(project_root);
3261    Some(storage_dir.join("callgraph").join(project_key))
3262}
3263
3264fn callgraph_store_dirs_from_inspect_dir(inspect_dir: &Path, project_root: &Path) -> Vec<PathBuf> {
3265    callgraph_store_dir_from_inspect_dir(inspect_dir, project_root)
3266        .into_iter()
3267        .collect()
3268}
3269
3270#[cfg(test)]
3271fn canonicalize_for_snapshot(path: &Path) -> PathBuf {
3272    // Mirrors the projection's normalizer: snapshot paths are
3273    // verbatim-stripped, so test expectations must be too.
3274    crate::inspect::job::canonicalize_normalized(path)
3275}
3276
3277fn load_contribution_freshness(
3278    cache: &(impl InspectCacheRead + ?Sized),
3279    category: InspectCategory,
3280) -> Result<Vec<CachedContributionFreshness>, String> {
3281    cache
3282        .contribution_freshness(category)
3283        .map_err(|error| error.to_string())
3284        .map(|records| {
3285            records
3286                .into_iter()
3287                .map(|(file_path, freshness)| CachedContributionFreshness {
3288                    file_path,
3289                    freshness,
3290                })
3291                .collect()
3292        })
3293}
3294
3295fn freshness_record_relative_key(record: &CachedContributionFreshness) -> String {
3296    record.file_path.to_string_lossy().to_string()
3297}
3298
3299fn relative_cache_key(project_root: &Path, path: &Path) -> String {
3300    path.strip_prefix(project_root)
3301        .unwrap_or(path)
3302        .to_string_lossy()
3303        .to_string()
3304}
3305
3306fn load_contributions(
3307    cache: &(impl InspectCacheRead + ?Sized),
3308    job: &InspectJob,
3309) -> Result<Vec<FileContribution>, String> {
3310    cache
3311        .load_tier2_contributions(job.category)
3312        .map_err(|error| error.to_string())
3313        .map(|records| {
3314            records
3315                .into_iter()
3316                .map(|record| contribution_from_record(&job.project_root, record))
3317                .collect()
3318        })
3319}
3320
3321fn dead_code_contributions_need_fact_refresh(
3322    cache: &(impl InspectCacheRead + ?Sized),
3323    job: &InspectJob,
3324) -> Result<bool, String> {
3325    let contributions = load_contributions(cache, job)?;
3326    Ok(contributions
3327        .iter()
3328        .any(dead_code_contribution_needs_fact_refresh))
3329}
3330
3331fn dead_code_contribution_needs_fact_refresh(contribution: &FileContribution) -> bool {
3332    let Ok(parsed) =
3333        serde_json::from_value::<DeadCodeRefreshContribution>(contribution.contribution.clone())
3334    else {
3335        return true;
3336    };
3337
3338    if parsed.facts_format_version
3339        != Some(super::scanners::dead_code::DEAD_CODE_FACTS_FORMAT_VERSION)
3340    {
3341        return true;
3342    }
3343
3344    matches!(
3345        parsed.oxc_facts,
3346        Some(facts) if facts.format_version != FACTS_FORMAT_VERSION
3347    )
3348}
3349
3350fn unused_exports_contributions_need_fact_refresh(
3351    cache: &(impl InspectCacheRead + ?Sized),
3352    job: &InspectJob,
3353) -> Result<bool, String> {
3354    let contributions = load_contributions(cache, job)?;
3355    Ok(contributions
3356        .iter()
3357        .any(unused_exports_contribution_needs_fact_refresh))
3358}
3359
3360/// Duplicates contributions written before v0.44 lack the `line_count` field
3361/// (serde defaults it to 0), so a cached roll-up computes total_analyzed_lines
3362/// as 0 and the summary renders "0.0% of 0 analyzed lines". One full rescan
3363/// repopulates the counts; fresh contributions always carry line_count.
3364fn duplicates_contributions_need_fact_refresh(
3365    cache: &(impl InspectCacheRead + ?Sized),
3366    job: &InspectJob,
3367) -> Result<bool, String> {
3368    let contributions = load_contributions(cache, job)?;
3369    Ok(contributions
3370        .iter()
3371        .any(|contribution| contribution.contribution.get("line_count").is_none()))
3372}
3373
3374fn unused_exports_contribution_needs_fact_refresh(contribution: &FileContribution) -> bool {
3375    let top_level_oxc = contribution
3376        .contribution
3377        .get("provenance")
3378        .and_then(Value::as_str)
3379        == Some(OXC_PROVENANCE);
3380    let Ok(parsed) =
3381        serde_json::from_value::<UnusedExportsContribution>(contribution.contribution.clone())
3382    else {
3383        return false;
3384    };
3385    let uses_oxc =
3386        top_level_oxc || parsed.oxc_facts.is_some() || parsed.exports.iter().any(export_uses_oxc);
3387    if !uses_oxc {
3388        return false;
3389    }
3390
3391    !matches!(
3392        parsed.oxc_facts,
3393        Some(facts) if facts.format_version == FACTS_FORMAT_VERSION
3394    )
3395}
3396
3397fn contribution_from_record(
3398    project_root: &Path,
3399    record: super::cache::ContributionRecord,
3400) -> FileContribution {
3401    FileContribution::new(
3402        record.category,
3403        project_root.join(record.file_path),
3404        record.freshness,
3405        record.contribution,
3406    )
3407    .with_type_ref_names(record.type_ref_names)
3408}
3409
3410fn run_tier2_scan(job: &InspectJob, oxc_result: Option<&OxcEngineResult>) -> InspectResult {
3411    use super::scanners;
3412
3413    match job.category {
3414        InspectCategory::DeadCode => {
3415            scanners::dead_code::run_dead_code_scan_with_oxc(job, oxc_result)
3416        }
3417        InspectCategory::UnusedExports => {
3418            scanners::unused_exports::run_unused_exports_scan_with_oxc(job, oxc_result)
3419        }
3420        InspectCategory::Duplicates => scanners::duplicates::run_duplicates_scan(job),
3421        InspectCategory::Cycles => scanners::cycles::run_cycles_scan_with_oxc(job, oxc_result),
3422        other => InspectResult::failed(
3423            job,
3424            format!("inspect category '{other}' is not an active Tier 2 scanner"),
3425            Duration::from_secs(0),
3426        ),
3427    }
3428}
3429
3430fn roll_up_tier2_contributions(job: &InspectJob, contributions: &[FileContribution]) -> Value {
3431    roll_up_tier2_contributions_with_limit(job, contributions, Some(MAX_DRILL_DOWN_ITEMS))
3432}
3433
3434fn roll_up_tier2_contributions_with_limit(
3435    job: &InspectJob,
3436    contributions: &[FileContribution],
3437    drill_down_limit: Option<usize>,
3438) -> Value {
3439    match job.category {
3440        InspectCategory::DeadCode => {
3441            roll_up_dead_code_contributions(job, contributions, drill_down_limit)
3442        }
3443        InspectCategory::UnusedExports => {
3444            roll_up_unused_exports_contributions(job, contributions, drill_down_limit)
3445        }
3446        InspectCategory::Duplicates => {
3447            roll_up_duplicate_contributions(job, contributions, drill_down_limit)
3448        }
3449        InspectCategory::Cycles => {
3450            roll_up_cycle_contributions(job, contributions, drill_down_limit)
3451        }
3452        _ => json!({
3453            "count": 0,
3454            "items": [],
3455            "scanned_files": contributions.len(),
3456        }),
3457    }
3458}
3459
3460fn scoped_tier2_payload_from_contributions(
3461    snapshot: &InspectSnapshot,
3462    category: InspectCategory,
3463    cache: &(impl InspectCacheRead + ?Sized),
3464    project_payload: Value,
3465    scope: &JobScope,
3466) -> Result<Value, String> {
3467    if scope.is_project_wide() {
3468        return Ok(project_payload);
3469    }
3470
3471    let project_scope = JobScope::for_project(snapshot.project_root.clone());
3472    let rollup_job = scoped_tier2_rollup_job(snapshot, category, &project_scope);
3473    let contributions = load_contributions(cache, &rollup_job)?;
3474    let full_payload = roll_up_tier2_contributions_with_limit(&rollup_job, &contributions, None);
3475    let scoped_payload = filter_payload_for_scope(full_payload, scope);
3476    Ok(cap_payload_drill_down(scoped_payload, MAX_DRILL_DOWN_ITEMS))
3477}
3478
3479fn scoped_tier2_rollup_job(
3480    snapshot: &InspectSnapshot,
3481    category: InspectCategory,
3482    scope: &JobScope,
3483) -> InspectJob {
3484    let mut job = InspectJob {
3485        job_id: 0,
3486        key: JobKey::for_project_category(category),
3487        category,
3488        scope_files: scope_files(&snapshot.project_root, scope),
3489        project_root: snapshot.project_root.clone(),
3490        inspect_dir: snapshot.inspect_dir.clone(),
3491        config: Arc::clone(&snapshot.config),
3492        symbol_cache: Arc::clone(&snapshot.symbol_cache),
3493        inspect_writer: snapshot.inspect_writer,
3494        callgraph_writer: snapshot.callgraph_writer,
3495        callgraph_snapshot: None,
3496    };
3497
3498    if category == InspectCategory::DeadCode {
3499        // Scoped read-path rollups recompute dead-code liveness from cached
3500        // contributions. Use a real ready store snapshot when one exists; if no
3501        // snapshot is available, leave it absent so the rollup reports degraded
3502        // callgraph_unavailable instead of treating an empty graph as truth.
3503        job.callgraph_snapshot = build_tier2_callgraph_snapshot(&job, false);
3504    }
3505
3506    job
3507}
3508
3509fn roll_up_dead_code_contributions(
3510    job: &InspectJob,
3511    contributions: &[FileContribution],
3512    drill_down_limit: Option<usize>,
3513) -> Value {
3514    let Some(snapshot) = job.callgraph_snapshot.as_deref() else {
3515        return super::scanners::dead_code::callgraph_unavailable_aggregate(job.scope_files.len());
3516    };
3517
3518    let public_api_files = super::scanners::dead_code::collect_public_api_files(&job.project_root);
3519    let roles = super::entry_points::resolve_project_roles(&job.project_root);
3520    super::scanners::dead_code::aggregate_dead_code_contributions_with_snapshot(
3521        &job.project_root,
3522        snapshot,
3523        contributions,
3524        &public_api_files,
3525        &roles,
3526        drill_down_limit,
3527    )
3528}
3529
3530fn roll_up_unused_exports_contributions(
3531    job: &InspectJob,
3532    contributions: &[FileContribution],
3533    drill_down_limit: Option<usize>,
3534) -> Value {
3535    let parsed = contributions
3536        .iter()
3537        .filter_map(|contribution| {
3538            serde_json::from_value::<UnusedExportsContribution>(contribution.contribution.clone())
3539                .ok()
3540        })
3541        .collect::<Vec<_>>();
3542
3543    if parsed.iter().any(|scan| scan.oxc_facts.is_some()) {
3544        return roll_up_unused_exports_oxc_contributions(job, &parsed, drill_down_limit);
3545    }
3546
3547    let (public_api_files, package_warnings) = unused_public_api_entries(&job.project_root);
3548    let mut imported_by: BTreeMap<(String, String), BTreeSet<String>> = BTreeMap::new();
3549    let mut uncertain_by: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
3550    for scan in &parsed {
3551        for import in &scan.imports {
3552            let Some(resolved_file) = &import.resolved_file else {
3553                continue;
3554            };
3555            for name in &import.named {
3556                if name == "*" {
3557                    uncertain_by
3558                        .entry(resolved_file.clone())
3559                        .or_default()
3560                        .insert(scan.file.clone());
3561                } else {
3562                    imported_by
3563                        .entry((resolved_file.clone(), name.clone()))
3564                        .or_default()
3565                        .insert(scan.file.clone());
3566                }
3567            }
3568        }
3569    }
3570
3571    let mut count = 0usize;
3572    let mut items = Vec::new();
3573    let mut generated_count = 0usize;
3574    let mut generated_items = Vec::new();
3575    let test_only_count = 0usize;
3576    let test_only_items = Vec::new();
3577    let mut uncertain_count = 0usize;
3578    let mut uncertain_items = Vec::new();
3579    for scan in &parsed {
3580        if public_api_files.contains(&scan.file) {
3581            continue;
3582        }
3583        // Mirror the fresh-scan path: fixtures/corpora/mock data are consumed
3584        // by path, never imported, so their exports always look unused.
3585        if super::job::is_test_support_file(&scan.file) {
3586            continue;
3587        }
3588        let generated_file = super::generated::is_generated_file_with_cached_hint(
3589            &job.project_root,
3590            &scan.file,
3591            scan.generated,
3592        );
3593
3594        for export in &scan.exports {
3595            if export_uses_oxc(export) {
3596                match export.verdict.unwrap_or(LivenessVerdict::Unused) {
3597                    LivenessVerdict::Used => continue,
3598                    LivenessVerdict::Uncertain => {
3599                        uncertain_count += 1;
3600                        if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
3601                            uncertain_items.push(json!({
3602                                "file": scan.file,
3603                                "symbol": export.symbol,
3604                                "kind": export.kind,
3605                                "line": export.line,
3606                                "reason": export.reason.as_deref().unwrap_or("oxc_uncertain"),
3607                                "provenance": export.provenance.as_deref().unwrap_or(OXC_PROVENANCE),
3608                            }));
3609                        }
3610                        continue;
3611                    }
3612                    LivenessVerdict::Unused => {}
3613                }
3614            } else {
3615                let imported = imported_by
3616                    .get(&(scan.file.clone(), export.symbol.clone()))
3617                    .map(|files| !files.is_empty())
3618                    .unwrap_or(false);
3619                let uncertain = uncertain_by
3620                    .get(&scan.file)
3621                    .map(|files| !files.is_empty())
3622                    .unwrap_or(false);
3623
3624                if imported {
3625                    continue;
3626                }
3627                if uncertain {
3628                    uncertain_count += 1;
3629                    if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
3630                        uncertain_items.push(json!({
3631                            "file": scan.file,
3632                            "symbol": export.symbol,
3633                            "kind": export.kind,
3634                            "line": export.line,
3635                            "reason": "wildcard_import",
3636                        }));
3637                    }
3638                    continue;
3639                }
3640            }
3641
3642            let mut item = json!({
3643                "file": scan.file,
3644                "symbol": export.symbol,
3645                "kind": export.kind,
3646                "line": export.line,
3647            });
3648            if let Some(provenance) = &export.provenance {
3649                item["provenance"] = json!(provenance);
3650            }
3651            if generated_file {
3652                item["generated"] = json!(true);
3653                generated_count += 1;
3654                generated_items.push(item);
3655            } else {
3656                count += 1;
3657                items.push(item);
3658            }
3659        }
3660    }
3661
3662    let roles = super::entry_points::resolve_project_roles(&job.project_root);
3663    let items = super::entry_points::rank_and_truncate_items(items, &roles, drill_down_limit);
3664    let generated_items =
3665        super::entry_points::rank_and_truncate_items(generated_items, &roles, drill_down_limit);
3666    let top = super::entry_points::top_preview_symbols(&items);
3667    let generated_top = generated_items
3668        .iter()
3669        .take(super::entry_points::TOP_PREVIEW_ITEMS)
3670        .cloned()
3671        .collect::<Vec<_>>();
3672    let mut all_items = items;
3673    all_items.extend(generated_items.iter().cloned());
3674    if let Some(limit) = drill_down_limit {
3675        all_items.truncate(limit);
3676    }
3677    let test_only_items =
3678        super::entry_points::rank_and_truncate_items(test_only_items, &roles, drill_down_limit);
3679    let test_only_top = test_only_items
3680        .iter()
3681        .take(super::entry_points::TOP_PREVIEW_ITEMS)
3682        .cloned()
3683        .collect::<Vec<_>>();
3684
3685    let (parse_errors, skipped_files) = unused_exports_honesty_fields(&parsed);
3686    let mut aggregate = json!({
3687        "count": count,
3688        "generated_count": generated_count,
3689        "total_count": count + test_only_count + generated_count,
3690        "items": all_items,
3691        "top": top,
3692        "generated_items": generated_items,
3693        "generated_top": generated_top,
3694        "test_only_count": test_only_count,
3695        "test_only_items": test_only_items,
3696        "test_only_top": test_only_top,
3697        "drill_down_capped": drill_down_limit.is_some_and(|limit| count + generated_count > limit),
3698        "generated_drill_down_capped": drill_down_limit.is_some_and(|limit| generated_count > limit),
3699        "test_only_drill_down_capped": drill_down_limit.is_some_and(|limit| test_only_count > limit),
3700        "scanned_files": parsed.len(),
3701        "languages_skipped": skipped_languages(&job.scope_files, LanguageSkipMode::UnusedExports),
3702        "uncertain_count": uncertain_count,
3703        "uncertain_items": uncertain_items,
3704        "complete": parse_errors.is_empty() && skipped_files.is_empty(),
3705    });
3706    if !parse_errors.is_empty() {
3707        aggregate["parse_errors"] = Value::Array(parse_errors);
3708    }
3709    if !skipped_files.is_empty() {
3710        aggregate["skipped_files"] = Value::Array(skipped_files);
3711    }
3712    if !package_warnings.is_empty() {
3713        aggregate["note"] = Value::String(package_warnings.join("; "));
3714    }
3715    aggregate
3716}
3717
3718fn roll_up_unused_exports_oxc_contributions(
3719    job: &InspectJob,
3720    parsed: &[UnusedExportsContribution],
3721    drill_down_limit: Option<usize>,
3722) -> Value {
3723    let (public_api_files, package_warnings) = unused_public_api_entries(&job.project_root);
3724    let facts = parsed
3725        .iter()
3726        .filter_map(|scan| {
3727            let oxc_facts = scan.oxc_facts.as_ref()?;
3728            let path = job.project_root.join(&scan.file);
3729            Some(FileFacts {
3730                file_id: FileId(0),
3731                path: normalize_input_path(&job.project_root, &path),
3732                content_hash: oxc_facts.content_hash.clone(),
3733                exports: oxc_facts.exports.clone(),
3734                imports: oxc_facts.imports.clone(),
3735                re_exports: oxc_facts.re_exports.clone(),
3736                dynamic_imports: oxc_facts.dynamic_imports.clone(),
3737                same_file_value_references: oxc_facts.same_file_value_references.clone(),
3738                used_import_bindings: oxc_facts.used_import_bindings.clone(),
3739                type_referenced_import_bindings: oxc_facts.type_referenced_import_bindings.clone(),
3740                value_referenced_import_bindings: oxc_facts
3741                    .value_referenced_import_bindings
3742                    .clone(),
3743                parse_error: oxc_facts.parse_error.clone(),
3744            })
3745        })
3746        .collect::<Vec<_>>();
3747    let generated_by_file = parsed
3748        .iter()
3749        .map(|scan| {
3750            (
3751                scan.file.clone(),
3752                super::generated::is_generated_file_with_cached_hint(
3753                    &job.project_root,
3754                    &scan.file,
3755                    scan.generated,
3756                ),
3757            )
3758        })
3759        .collect::<BTreeMap<_, _>>();
3760    let entry_point_set = crate::inspect::entry_points::resolve_entry_points(&job.project_root);
3761    let oxc_result = analyze_file_facts(
3762        &job.project_root,
3763        facts,
3764        AnalyzeOptions {
3765            entry_points: Vec::new(),
3766            public_api_files: entry_point_set.public_api_files(),
3767            executable_root_exports: entry_point_set.executable_root_exports(),
3768            force_reparse_files: Vec::new(),
3769            entry_reachability: false,
3770        },
3771        Vec::new(),
3772    );
3773    let roles = super::entry_points::resolve_project_roles(&job.project_root);
3774
3775    let mut count = 0usize;
3776    let mut items = Vec::new();
3777    let mut generated_count = 0usize;
3778    let mut generated_items = Vec::new();
3779    let mut test_only_count = 0usize;
3780    let mut test_only_items = Vec::new();
3781    let mut uncertain_count = 0usize;
3782    let mut uncertain_items = Vec::new();
3783    for file in &oxc_result.files {
3784        if public_api_files.contains(&file.relative_file)
3785            || super::job::is_test_support_file(&file.relative_file)
3786        {
3787            continue;
3788        }
3789        let generated_file = generated_by_file
3790            .get(&file.relative_file)
3791            .copied()
3792            .unwrap_or_else(|| {
3793                super::generated::is_generated_file(
3794                    &job.project_root,
3795                    Path::new(&file.relative_file),
3796                )
3797            });
3798
3799        for export in &file.exports {
3800            match export.verdict {
3801                LivenessVerdict::Used => {
3802                    if !is_test_file(&file.relative_file)
3803                        && !export.test_only_reference_files.is_empty()
3804                    {
3805                        let mut item = json!({
3806                            "file": file.relative_file,
3807                            "symbol": export.symbol,
3808                            "kind": export.kind,
3809                            "line": export.line,
3810                            "provenance": export.provenance,
3811                            "used_by": export.test_only_reference_files,
3812                        });
3813                        add_oxc_reexport_contexts(&mut item, &export.also_reexported);
3814                        if generated_file {
3815                            item["generated"] = json!(true);
3816                            generated_count += 1;
3817                            generated_items.push(item);
3818                        } else {
3819                            test_only_count += 1;
3820                            test_only_items.push(item);
3821                        }
3822                    }
3823                }
3824                LivenessVerdict::Uncertain => {
3825                    uncertain_count += 1;
3826                    if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
3827                        let mut item = json!({
3828                            "file": file.relative_file,
3829                            "symbol": export.symbol,
3830                            "kind": export.kind,
3831                            "line": export.line,
3832                            "reason": export.reason,
3833                            "provenance": export.provenance,
3834                        });
3835                        add_oxc_reexport_contexts(&mut item, &export.also_reexported);
3836                        uncertain_items.push(item);
3837                    }
3838                }
3839                LivenessVerdict::Unused => {
3840                    if !is_test_file(&file.relative_file)
3841                        && !export.test_only_reference_files.is_empty()
3842                    {
3843                        let mut item = json!({
3844                            "file": file.relative_file,
3845                            "symbol": export.symbol,
3846                            "kind": export.kind,
3847                            "line": export.line,
3848                            "provenance": export.provenance,
3849                            "used_by": export.test_only_reference_files,
3850                        });
3851                        add_oxc_reexport_contexts(&mut item, &export.also_reexported);
3852                        if generated_file {
3853                            item["generated"] = json!(true);
3854                            generated_count += 1;
3855                            generated_items.push(item);
3856                        } else {
3857                            test_only_count += 1;
3858                            test_only_items.push(item);
3859                        }
3860                        continue;
3861                    }
3862                    if export.has_references {
3863                        continue;
3864                    }
3865                    let mut item = json!({
3866                        "file": file.relative_file,
3867                        "symbol": export.symbol,
3868                        "kind": export.kind,
3869                        "line": export.line,
3870                        "provenance": export.provenance,
3871                    });
3872                    add_oxc_reexport_contexts(&mut item, &export.also_reexported);
3873                    if generated_file {
3874                        item["generated"] = json!(true);
3875                        generated_count += 1;
3876                        generated_items.push(item);
3877                    } else {
3878                        count += 1;
3879                        items.push(item);
3880                    }
3881                }
3882            }
3883        }
3884    }
3885
3886    let items = super::entry_points::rank_and_truncate_items(items, &roles, drill_down_limit);
3887    let generated_items =
3888        super::entry_points::rank_and_truncate_items(generated_items, &roles, drill_down_limit);
3889    let top = super::entry_points::top_preview_symbols(&items);
3890    let generated_top = generated_items
3891        .iter()
3892        .take(super::entry_points::TOP_PREVIEW_ITEMS)
3893        .cloned()
3894        .collect::<Vec<_>>();
3895    let mut all_items = items;
3896    all_items.extend(generated_items.iter().cloned());
3897    if let Some(limit) = drill_down_limit {
3898        all_items.truncate(limit);
3899    }
3900    let test_only_items =
3901        super::entry_points::rank_and_truncate_items(test_only_items, &roles, drill_down_limit);
3902    let test_only_top = test_only_items
3903        .iter()
3904        .take(super::entry_points::TOP_PREVIEW_ITEMS)
3905        .cloned()
3906        .collect::<Vec<_>>();
3907    let (mut parse_errors, skipped_files) = unused_exports_honesty_fields(parsed);
3908    for scan in parsed {
3909        if let Some(oxc_facts) = &scan.oxc_facts {
3910            if oxc_facts.format_version != FACTS_FORMAT_VERSION {
3911                parse_errors.push(json!({
3912                    "file": scan.file,
3913                    "message": format!(
3914                        "unsupported oxc facts format {}; expected {}",
3915                        oxc_facts.format_version, FACTS_FORMAT_VERSION
3916                    ),
3917                }));
3918            }
3919        }
3920    }
3921
3922    let mut aggregate = json!({
3923        "count": count,
3924        "generated_count": generated_count,
3925        "total_count": count + test_only_count + generated_count,
3926        "items": all_items,
3927        "top": top,
3928        "generated_items": generated_items,
3929        "generated_top": generated_top,
3930        "test_only_count": test_only_count,
3931        "test_only_items": test_only_items,
3932        "test_only_top": test_only_top,
3933        "drill_down_capped": drill_down_limit.is_some_and(|limit| count + generated_count > limit),
3934        "generated_drill_down_capped": drill_down_limit.is_some_and(|limit| generated_count > limit),
3935        "test_only_drill_down_capped": drill_down_limit.is_some_and(|limit| test_only_count > limit),
3936        "scanned_files": parsed.len(),
3937        "languages_skipped": skipped_languages(&job.scope_files, LanguageSkipMode::UnusedExports),
3938        "uncertain_count": uncertain_count,
3939        "uncertain_items": uncertain_items,
3940        "complete": parse_errors.is_empty() && skipped_files.is_empty(),
3941    });
3942    if !parse_errors.is_empty() {
3943        aggregate["parse_errors"] = Value::Array(parse_errors);
3944    }
3945    if !skipped_files.is_empty() {
3946        aggregate["skipped_files"] = Value::Array(skipped_files);
3947    }
3948    if !package_warnings.is_empty() {
3949        aggregate["note"] = Value::String(package_warnings.join("; "));
3950    }
3951    aggregate
3952}
3953
3954fn add_oxc_reexport_contexts(
3955    item: &mut Value,
3956    contexts: &[crate::inspect::oxc_engine::OxcReExportContext],
3957) {
3958    if !contexts.is_empty() {
3959        item["also_reexported"] = json!(contexts);
3960    }
3961}
3962
3963fn unused_exports_honesty_fields(parsed: &[UnusedExportsContribution]) -> (Vec<Value>, Vec<Value>) {
3964    let mut parse_error_keys = BTreeSet::new();
3965    let mut parse_errors = Vec::new();
3966    let mut skipped_file_keys = BTreeSet::new();
3967    let mut skipped_files = Vec::new();
3968    for contribution in parsed {
3969        for value in &contribution.parse_errors {
3970            let key = value.to_string();
3971            if parse_error_keys.insert(key) {
3972                parse_errors.push(value.clone());
3973            }
3974        }
3975        for value in &contribution.skipped_files {
3976            let key = value.to_string();
3977            if skipped_file_keys.insert(key) {
3978                skipped_files.push(value.clone());
3979            }
3980        }
3981    }
3982    (parse_errors, skipped_files)
3983}
3984
3985fn roll_up_duplicate_contributions(
3986    job: &InspectJob,
3987    contributions: &[FileContribution],
3988    drill_down_limit: Option<usize>,
3989) -> Value {
3990    super::scanners::duplicates::aggregate_duplicate_contributions_with_limit(
3991        contributions,
3992        skipped_languages(&job.scope_files, LanguageSkipMode::Duplicates),
3993        drill_down_limit,
3994        &job.config.inspect.duplicates.expected_mirrors,
3995    )
3996}
3997
3998fn roll_up_cycle_contributions(
3999    job: &InspectJob,
4000    contributions: &[FileContribution],
4001    drill_down_limit: Option<usize>,
4002) -> Value {
4003    super::scanners::cycles::aggregate_cycle_contributions_with_limit(
4004        &job.project_root,
4005        contributions,
4006        skipped_languages(&job.scope_files, LanguageSkipMode::Cycles),
4007        drill_down_limit,
4008    )
4009}
4010
4011fn cap_payload_drill_down(mut payload: Value, limit: usize) -> Value {
4012    let mut capped = false;
4013    if let Some(items) = payload.get_mut("items").and_then(Value::as_array_mut) {
4014        capped |= items.len() > limit;
4015        items.truncate(limit);
4016    }
4017    if let Some(groups) = payload.get_mut("groups").and_then(Value::as_array_mut) {
4018        capped |= groups.len() > limit;
4019        groups.truncate(limit);
4020    }
4021    if let Some(object) = payload.as_object_mut() {
4022        object.insert("drill_down_capped".to_string(), json!(capped));
4023    }
4024    payload
4025}
4026
4027const MAX_DRILL_DOWN_ITEMS: usize = 100;
4028
4029#[derive(Debug, Clone, Deserialize)]
4030struct ExportContribution {
4031    symbol: String,
4032    kind: String,
4033    line: u32,
4034    #[serde(default)]
4035    verdict: Option<LivenessVerdict>,
4036    #[serde(default)]
4037    reason: Option<String>,
4038    #[serde(default)]
4039    provenance: Option<String>,
4040}
4041
4042fn export_uses_oxc(export: &ExportContribution) -> bool {
4043    export.verdict.is_some() || export.provenance.as_deref() == Some(OXC_PROVENANCE)
4044}
4045
4046#[derive(Debug, Clone, Deserialize)]
4047struct DeadCodeRefreshContribution {
4048    #[serde(default)]
4049    facts_format_version: Option<u32>,
4050    #[serde(default)]
4051    oxc_facts: Option<OxcFactsContribution>,
4052}
4053
4054#[derive(Debug, Clone, Deserialize)]
4055struct UnusedExportsContribution {
4056    file: String,
4057    #[serde(default)]
4058    generated: Option<bool>,
4059    exports: Vec<ExportContribution>,
4060    #[serde(default)]
4061    imports: Vec<ImportContribution>,
4062    #[serde(default)]
4063    oxc_facts: Option<OxcFactsContribution>,
4064    #[serde(default)]
4065    parse_errors: Vec<Value>,
4066    #[serde(default)]
4067    skipped_files: Vec<Value>,
4068}
4069
4070#[derive(Debug, Clone, Deserialize)]
4071struct ImportContribution {
4072    resolved_file: Option<String>,
4073    named: Vec<String>,
4074}
4075
4076#[derive(Debug, Clone, Deserialize)]
4077struct OxcFactsContribution {
4078    format_version: u32,
4079    content_hash: String,
4080    exports: Vec<ExportFact>,
4081    imports: Vec<ImportFact>,
4082    re_exports: Vec<ReExportFact>,
4083    dynamic_imports: Vec<DynamicImportFact>,
4084    same_file_value_references: BTreeSet<String>,
4085    used_import_bindings: BTreeSet<String>,
4086    type_referenced_import_bindings: BTreeSet<String>,
4087    value_referenced_import_bindings: BTreeSet<String>,
4088    #[serde(default)]
4089    parse_error: Option<String>,
4090}
4091
4092#[derive(Debug, Clone, Copy)]
4093enum LanguageSkipMode {
4094    Duplicates,
4095    Cycles,
4096    UnusedExports,
4097}
4098
4099fn category_uses_oxc(category: InspectCategory) -> bool {
4100    matches!(
4101        category,
4102        InspectCategory::DeadCode | InspectCategory::UnusedExports | InspectCategory::Cycles
4103    )
4104}
4105
4106fn skipped_languages(files: &[PathBuf], mode: LanguageSkipMode) -> Vec<String> {
4107    files
4108        .iter()
4109        .filter_map(|file| skipped_language(file, mode))
4110        .collect::<BTreeSet<_>>()
4111        .into_iter()
4112        .collect()
4113}
4114
4115fn skipped_language(file: &Path, mode: LanguageSkipMode) -> Option<String> {
4116    let Some(language) = crate::parser::detect_language(file) else {
4117        return match mode {
4118            LanguageSkipMode::Duplicates => Some("unknown".to_string()),
4119            LanguageSkipMode::Cycles => Some("unknown".to_string()),
4120            LanguageSkipMode::UnusedExports => None,
4121        };
4122    };
4123
4124    let skipped = match mode {
4125        LanguageSkipMode::Duplicates => !duplicates_supports_language(language),
4126        LanguageSkipMode::Cycles => !is_js_ts_language(language),
4127        LanguageSkipMode::UnusedExports => !is_js_ts_language(language),
4128    };
4129    skipped.then(|| language_name(language).to_string())
4130}
4131
4132fn duplicates_supports_language(language: crate::parser::LangId) -> bool {
4133    !matches!(
4134        language,
4135        crate::parser::LangId::Bash
4136            | crate::parser::LangId::Html
4137            | crate::parser::LangId::Json
4138            | crate::parser::LangId::Scala
4139            | crate::parser::LangId::Solidity
4140            | crate::parser::LangId::Scss
4141            | crate::parser::LangId::Vue
4142            | crate::parser::LangId::Markdown
4143            | crate::parser::LangId::Java
4144            | crate::parser::LangId::Ruby
4145            | crate::parser::LangId::Kotlin
4146            | crate::parser::LangId::Swift
4147            | crate::parser::LangId::Php
4148            | crate::parser::LangId::Lua
4149            | crate::parser::LangId::Perl
4150            | crate::parser::LangId::Pascal
4151            | crate::parser::LangId::R
4152            | crate::parser::LangId::Groovy
4153            | crate::parser::LangId::ObjC
4154    )
4155}
4156
4157fn is_js_ts_language(language: crate::parser::LangId) -> bool {
4158    matches!(
4159        language,
4160        crate::parser::LangId::TypeScript
4161            | crate::parser::LangId::Tsx
4162            | crate::parser::LangId::JavaScript
4163    )
4164}
4165
4166fn language_name(language: crate::parser::LangId) -> &'static str {
4167    match language {
4168        crate::parser::LangId::TypeScript => "typescript",
4169        crate::parser::LangId::Tsx => "tsx",
4170        crate::parser::LangId::JavaScript => "javascript",
4171        crate::parser::LangId::Python => "python",
4172        crate::parser::LangId::Rust => "rust",
4173        crate::parser::LangId::Go => "go",
4174        crate::parser::LangId::C => "c",
4175        crate::parser::LangId::Cpp => "cpp",
4176        crate::parser::LangId::Zig => "zig",
4177        crate::parser::LangId::CSharp => "csharp",
4178        crate::parser::LangId::Bash => "bash",
4179        crate::parser::LangId::Html => "html",
4180        crate::parser::LangId::Markdown => "markdown",
4181        crate::parser::LangId::Yaml => "yaml",
4182        crate::parser::LangId::Solidity => "solidity",
4183        crate::parser::LangId::Scss => "scss",
4184        crate::parser::LangId::Vue => "vue",
4185        crate::parser::LangId::Json => "json",
4186        crate::parser::LangId::Scala => "scala",
4187        crate::parser::LangId::Java => "java",
4188        crate::parser::LangId::Ruby => "ruby",
4189        crate::parser::LangId::Kotlin => "kotlin",
4190        crate::parser::LangId::Swift => "swift",
4191        crate::parser::LangId::Php => "php",
4192        crate::parser::LangId::Lua => "lua",
4193        crate::parser::LangId::Perl => "perl",
4194        crate::parser::LangId::Pascal => "pascal",
4195        crate::parser::LangId::R => "r",
4196        crate::parser::LangId::Groovy => "groovy",
4197        crate::parser::LangId::ObjC => "objc",
4198    }
4199}
4200
4201fn unused_public_api_entries(project_root: &Path) -> (BTreeSet<String>, Vec<String>) {
4202    let entry_points = crate::inspect::entry_points::resolve_entry_points(project_root);
4203    (
4204        entry_points.public_api_files_relative(project_root),
4205        entry_points.warnings().to_vec(),
4206    )
4207}
4208
4209fn filter_outcome_for_scope_with_contributions(
4210    outcome: JobOutcome,
4211    snapshot: &InspectSnapshot,
4212    category: InspectCategory,
4213    cache: &(impl InspectCacheRead + ?Sized),
4214    scope: &JobScope,
4215) -> JobOutcome {
4216    if !category.is_tier2() || scope.is_project_wide() {
4217        return filter_outcome_for_scope(outcome, scope);
4218    }
4219
4220    match outcome {
4221        JobOutcome::Fresh { payload } => {
4222            match scoped_tier2_payload_from_contributions(snapshot, category, cache, payload, scope)
4223            {
4224                Ok(payload) => JobOutcome::Fresh { payload },
4225                Err(message) => JobOutcome::Failed { message },
4226            }
4227        }
4228        JobOutcome::Stale { cached, in_flight } => match cached {
4229            Some(payload) => {
4230                match scoped_tier2_payload_from_contributions(
4231                    snapshot, category, cache, payload, scope,
4232                ) {
4233                    Ok(payload) => JobOutcome::Stale {
4234                        cached: Some(payload),
4235                        in_flight,
4236                    },
4237                    Err(message) => JobOutcome::Failed { message },
4238                }
4239            }
4240            None => JobOutcome::Stale {
4241                cached: None,
4242                in_flight,
4243            },
4244        },
4245        JobOutcome::Pending { in_flight } => JobOutcome::Pending { in_flight },
4246        JobOutcome::Failed { message } => JobOutcome::Failed { message },
4247    }
4248}
4249
4250fn filter_outcome_for_scope(outcome: JobOutcome, scope: &JobScope) -> JobOutcome {
4251    match outcome {
4252        JobOutcome::Fresh { payload } => JobOutcome::Fresh {
4253            payload: filter_payload_for_scope(payload, scope),
4254        },
4255        JobOutcome::Stale { cached, in_flight } => JobOutcome::Stale {
4256            cached: cached.map(|payload| filter_payload_for_scope(payload, scope)),
4257            in_flight,
4258        },
4259        JobOutcome::Pending { in_flight } => JobOutcome::Pending { in_flight },
4260        JobOutcome::Failed { message } => JobOutcome::Failed { message },
4261    }
4262}
4263
4264fn filter_payload_for_scope(mut payload: serde_json::Value, scope: &JobScope) -> serde_json::Value {
4265    if scope.is_project_wide() {
4266        return payload;
4267    }
4268
4269    // Scoped Tier 2 callers pass an uncapped rollup into this filter and cap
4270    // drill-down only afterwards, so the recomputed count below remains the
4271    // true in-scope total rather than the size of a capped sample.
4272    if let Some(items) = payload
4273        .get_mut("items")
4274        .and_then(|value| value.as_array_mut())
4275    {
4276        let count = filter_values_for_scope(items, scope);
4277        let largest_cycle = items
4278            .iter()
4279            .filter_map(|item| item.get("files").and_then(Value::as_array).map(Vec::len))
4280            .max();
4281        if let Some(object) = payload.as_object_mut() {
4282            object.insert("count".to_string(), serde_json::json!(count));
4283            if object.contains_key("largest") {
4284                object.insert(
4285                    "largest".to_string(),
4286                    serde_json::json!(largest_cycle.unwrap_or(0)),
4287                );
4288            }
4289            if object.contains_key("total_groups") {
4290                object.insert("total_groups".to_string(), serde_json::json!(count));
4291            }
4292            if object.contains_key("groups_count") {
4293                object.insert("groups_count".to_string(), serde_json::json!(count));
4294            }
4295        }
4296    }
4297
4298    if let Some(groups) = payload
4299        .get_mut("groups")
4300        .and_then(|value| value.as_array_mut())
4301    {
4302        let count = filter_values_for_scope(groups, scope);
4303        if let Some(object) = payload.as_object_mut() {
4304            object.insert("count".to_string(), serde_json::json!(count));
4305            object.insert("total_groups".to_string(), serde_json::json!(count));
4306            if object.contains_key("groups_count") {
4307                object.insert("groups_count".to_string(), serde_json::json!(count));
4308            }
4309        }
4310    }
4311
4312    // `by_language` is a project-wide breakdown computed before scope filtering.
4313    // Leaving it in a scoped payload contradicts the recomputed in-scope `count`
4314    // (e.g. count: 3 alongside `(rust 214, ts 143)`). The filtered items don't
4315    // carry per-item language, so we can't faithfully recompute it — drop it so
4316    // the scoped summary doesn't render a misleading project-wide breakdown.
4317    if let Some(object) = payload.as_object_mut() {
4318        if object.contains_key("top") {
4319            if let Some(top) = recompute_scoped_top_preview(object) {
4320                object.insert("top".to_string(), top);
4321            } else if let Some(top) = object.get_mut("top").and_then(Value::as_array_mut) {
4322                filter_values_for_scope(top, scope);
4323            }
4324        }
4325        if object.contains_key("duplicated_lines") {
4326            recompute_duplicate_payload_stats(object);
4327        }
4328        object.remove("by_language");
4329    }
4330
4331    payload
4332}
4333
4334fn recompute_duplicate_payload_stats(object: &mut serde_json::Map<String, Value>) {
4335    let values = object
4336        .get("items")
4337        .or_else(|| object.get("groups"))
4338        .and_then(Value::as_array)
4339        .cloned()
4340        .unwrap_or_default();
4341    let (duplicated_lines, duplicated_file_count) = duplicate_line_stats_from_values(&values);
4342    let total_analyzed_lines = object
4343        .get("total_analyzed_lines")
4344        .and_then(Value::as_u64)
4345        .unwrap_or(0);
4346    let duplicated_percent = if total_analyzed_lines == 0 {
4347        0.0
4348    } else {
4349        (duplicated_lines as f64 * 100.0) / total_analyzed_lines as f64
4350    };
4351    object.insert("duplicated_lines".to_string(), json!(duplicated_lines));
4352    object.insert(
4353        "duplicated_file_count".to_string(),
4354        json!(duplicated_file_count),
4355    );
4356    object.insert("duplicated_percent".to_string(), json!(duplicated_percent));
4357}
4358
4359fn duplicate_line_stats_from_values(values: &[Value]) -> (u64, usize) {
4360    let mut by_file = BTreeMap::<String, Vec<(u64, u64)>>::new();
4361    for value in values {
4362        let Some(files) = value.get("files").and_then(Value::as_array) else {
4363            continue;
4364        };
4365        for occurrence in files.iter().filter_map(Value::as_str) {
4366            let Some((file, start, end)) = parse_duplicate_occurrence(occurrence) else {
4367                continue;
4368            };
4369            by_file
4370                .entry(file.to_string())
4371                .or_default()
4372                .push((start, end));
4373        }
4374    }
4375    let file_count = by_file.len();
4376    let duplicated_lines = by_file
4377        .values_mut()
4378        .map(|intervals| merged_duplicate_interval_lines(intervals))
4379        .sum();
4380    (duplicated_lines, file_count)
4381}
4382
4383fn merged_duplicate_interval_lines(intervals: &mut [(u64, u64)]) -> u64 {
4384    if intervals.is_empty() {
4385        return 0;
4386    }
4387    intervals.sort_by(|left, right| left.0.cmp(&right.0).then(left.1.cmp(&right.1)));
4388    let (mut current_start, mut current_end) = intervals[0];
4389    let mut total = 0;
4390    for &(start, end) in &intervals[1..] {
4391        if start <= current_end.saturating_add(1) {
4392            current_end = current_end.max(end);
4393        } else {
4394            total += current_end.saturating_sub(current_start).saturating_add(1);
4395            current_start = start;
4396            current_end = end;
4397        }
4398    }
4399    total + current_end.saturating_sub(current_start).saturating_add(1)
4400}
4401
4402fn recompute_scoped_top_preview(
4403    object: &serde_json::Map<String, Value>,
4404) -> Option<serde_json::Value> {
4405    let values = object
4406        .get("items")
4407        .or_else(|| object.get("groups"))
4408        .and_then(Value::as_array)?;
4409    Some(Value::Array(
4410        values
4411            .iter()
4412            .take(super::entry_points::TOP_PREVIEW_ITEMS)
4413            .map(top_preview_value)
4414            .collect(),
4415    ))
4416}
4417
4418fn top_preview_value(value: &Value) -> Value {
4419    if let Some(files) = value.get("files").and_then(Value::as_array) {
4420        let mut object = serde_json::Map::new();
4421        object.insert("files".to_string(), Value::Array(files.clone()));
4422        if let Some(cost) = value.get("cost").cloned() {
4423            object.insert("cost".to_string(), cost);
4424        }
4425        return Value::Object(object);
4426    }
4427
4428    json!({
4429        "file": value.get("file").and_then(Value::as_str).unwrap_or(""),
4430        "symbol": value.get("symbol").and_then(Value::as_str).unwrap_or(""),
4431    })
4432}
4433
4434fn filter_values_for_scope(values: &mut Vec<serde_json::Value>, scope: &JobScope) -> usize {
4435    values.retain_mut(|value| prune_value_for_scope(value, scope));
4436    values.len()
4437}
4438
4439fn prune_value_for_scope(value: &mut serde_json::Value, scope: &JobScope) -> bool {
4440    if let Some(file) = value.get("file").and_then(|file| file.as_str()) {
4441        return scope.contains_display_path(file);
4442    }
4443
4444    let first_scoped_occurrence = if let Some(files) = value
4445        .get_mut("files")
4446        .and_then(|files| files.as_array_mut())
4447    {
4448        files.retain(|file| {
4449            file.as_str()
4450                .is_some_and(|file| scope.contains_display_path(display_file_from_occurrence(file)))
4451        });
4452        if files.len() < 2 {
4453            return false;
4454        }
4455        files.first().and_then(Value::as_str).map(str::to_string)
4456    } else {
4457        None
4458    };
4459
4460    if let Some(occurrence) = first_scoped_occurrence {
4461        update_duplicate_group_sample(value, &occurrence);
4462    }
4463
4464    true
4465}
4466
4467fn update_duplicate_group_sample(value: &mut serde_json::Value, occurrence: &str) {
4468    let Some((file, start_line, end_line)) = parse_duplicate_occurrence(occurrence) else {
4469        return;
4470    };
4471    let Some(object) = value.as_object_mut() else {
4472        return;
4473    };
4474
4475    if object.contains_key("sample_file") {
4476        object.insert("sample_file".to_string(), json!(file));
4477    }
4478    if object.contains_key("sample_start_line") {
4479        object.insert("sample_start_line".to_string(), json!(start_line));
4480    }
4481    if object.contains_key("sample_end_line") {
4482        object.insert("sample_end_line".to_string(), json!(end_line));
4483    }
4484}
4485
4486fn parse_duplicate_occurrence(value: &str) -> Option<(&str, u64, u64)> {
4487    let (file, range) = value.rsplit_once(':')?;
4488    let (start, end) = range.split_once('-')?;
4489    if !start.chars().all(|char| char.is_ascii_digit())
4490        || !end.chars().all(|char| char.is_ascii_digit())
4491    {
4492        return None;
4493    }
4494
4495    Some((file, start.parse().ok()?, end.parse().ok()?))
4496}
4497
4498fn display_file_from_occurrence(value: &str) -> &str {
4499    let Some((file, range)) = value.rsplit_once(':') else {
4500        return value;
4501    };
4502    let Some((start, end)) = range.split_once('-') else {
4503        return value;
4504    };
4505    if start.chars().all(|char| char.is_ascii_digit())
4506        && end.chars().all(|char| char.is_ascii_digit())
4507    {
4508        file
4509    } else {
4510        value
4511    }
4512}
4513
4514#[cfg(test)]
4515mod guard_tests {
4516    use super::*;
4517
4518    fn write_ts_project(file_count: usize) -> tempfile::TempDir {
4519        let dir = tempfile::tempdir().expect("tempdir");
4520        let root = dir.path();
4521        for i in 0..file_count {
4522            std::fs::write(
4523                root.join(format!("mod{i}.ts")),
4524                format!("export function f{i}() {{ return {i}; }}\n"),
4525            )
4526            .expect("write fixture");
4527        }
4528        let canonical_root = std::fs::canonicalize(root).expect("canonical fixture root");
4529        let project_key = crate::search_index::artifact_cache_key(&canonical_root);
4530        crate::root_cache::configure_artifact_access(&canonical_root, &project_key, false);
4531        dir
4532    }
4533
4534    struct ProjectionObserverReset;
4535
4536    impl Drop for ProjectionObserverReset {
4537        fn drop(&mut self) {
4538            crate::callgraph_store::set_projection_before_open_observer(None);
4539        }
4540    }
4541
4542    fn count_projections() -> (Arc<std::sync::atomic::AtomicUsize>, ProjectionObserverReset) {
4543        let count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
4544        let observed = Arc::clone(&count);
4545        crate::callgraph_store::set_projection_before_open_observer(Some(Arc::new(move |_| {
4546            observed.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
4547        })));
4548        (count, ProjectionObserverReset)
4549    }
4550
4551    fn write_projection_cache_file(path: &Path, contents: &str) {
4552        std::fs::create_dir_all(path.parent().expect("fixture file parent"))
4553            .expect("create fixture parent");
4554        std::fs::write(path, contents).expect("write fixture file");
4555    }
4556
4557    fn published_projection_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, InspectJob) {
4558        let dir = tempfile::tempdir().expect("tempdir");
4559        let root = std::fs::canonicalize(dir.path()).expect("canonical fixture root");
4560        write_projection_cache_file(
4561            &root.join("src/main.ts"),
4562            "import { firstTarget } from './target';\nexport function main() { firstTarget(); }\n",
4563        );
4564        write_projection_cache_file(
4565            &root.join("src/target.ts"),
4566            "export function firstTarget() {}\n",
4567        );
4568        let inspect_dir = root.join(".aft-cache").join("inspect");
4569        let project_key = crate::search_index::artifact_cache_key(&root);
4570        crate::root_cache::configure_artifact_access(&root, &project_key, false);
4571        let callgraph_dir =
4572            callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("callgraph dir");
4573        let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
4574        let (store, _) = CallGraphStore::cold_build_with_lease(callgraph_dir, root.clone(), &files)
4575            .expect("publish initial generation");
4576        drop(store);
4577        let mut job = snapshot_job(&root, &inspect_dir, true);
4578        job.callgraph_writer = false;
4579        (dir, root, inspect_dir, job)
4580    }
4581
4582    #[test]
4583    fn scoped_filter_recomputes_top_preview_from_scoped_items() {
4584        let project_root = PathBuf::from("/project");
4585        let scope = JobScope::from_roots(project_root.clone(), vec![project_root.join("src/in")]);
4586        let payload = json!({
4587            "count": 4,
4588            "items": [
4589                { "file": "src/out/a.ts", "symbol": "outside" },
4590                { "file": "src/in/b.ts", "symbol": "inside_b" },
4591                { "file": "src/in/c.ts", "symbol": "inside_c" }
4592            ],
4593            "top": [
4594                { "file": "src/out/a.ts", "symbol": "outside" },
4595                { "file": "src/out/z.ts", "symbol": "outside_z" }
4596            ],
4597            "by_language": { "typescript": 4 }
4598        });
4599
4600        let filtered = filter_payload_for_scope(payload, &scope);
4601
4602        assert_eq!(filtered["count"], json!(2));
4603        assert_eq!(
4604            filtered["top"],
4605            json!([
4606                { "file": "src/in/b.ts", "symbol": "inside_b" },
4607                { "file": "src/in/c.ts", "symbol": "inside_c" }
4608            ])
4609        );
4610        assert!(filtered["top"]
4611            .as_array()
4612            .unwrap()
4613            .iter()
4614            .all(|item| item["file"]
4615                .as_str()
4616                .is_some_and(|file| file.starts_with("src/in/"))));
4617    }
4618
4619    fn artifact_cache_key_for_test(project_root: &std::path::Path) -> String {
4620        let _git_env = crate::test_env::hermetic_git_env_guard();
4621        crate::search_index::artifact_cache_key(project_root)
4622    }
4623
4624    #[test]
4625    fn cache_for_paths_rebinds_same_project_key_to_current_root() {
4626        let _git_env = crate::test_env::hermetic_git_env_guard();
4627        let dir = tempfile::tempdir().expect("tempdir");
4628        let source = dir.path().join("source");
4629        std::fs::create_dir_all(&source).expect("create source repo");
4630        std::fs::write(
4631            source.join("package.json"),
4632            r#"{"name":"inspect-cache-fixture","version":"1.0.0"}"#,
4633        )
4634        .expect("write source manifest");
4635        std::fs::write(source.join("index.ts"), "export const source = 1;\n")
4636            .expect("write source file");
4637        let mut init = std::process::Command::new("git");
4638        assert!(
4639            crate::test_env::apply_hermetic_git_env(init.current_dir(&source))
4640                .arg("init")
4641                .status()
4642                .expect("git init source repo")
4643                .success()
4644        );
4645        let mut add = std::process::Command::new("git");
4646        assert!(
4647            crate::test_env::apply_hermetic_git_env(add.current_dir(&source))
4648                .args(["add", "."])
4649                .status()
4650                .expect("git add source repo")
4651                .success()
4652        );
4653        let mut commit = std::process::Command::new("git");
4654        assert!(
4655            crate::test_env::apply_hermetic_git_env(commit.current_dir(&source))
4656                .args([
4657                    "-c",
4658                    "user.name=AFT Tests",
4659                    "-c",
4660                    "user.email=aft-tests@example.com",
4661                    "commit",
4662                    "-m",
4663                    "initial",
4664                ])
4665                .status()
4666                .expect("git commit source repo")
4667                .success()
4668        );
4669
4670        let clone = dir.path().join("clone");
4671        let mut clone_command = std::process::Command::new("git");
4672        assert!(crate::test_env::apply_hermetic_git_env(&mut clone_command)
4673            .args(["clone", "--quiet"])
4674            .arg(&source)
4675            .arg(&clone)
4676            .status()
4677            .expect("git clone source repo")
4678            .success());
4679        std::fs::write(
4680            clone.join("package.json"),
4681            r#"{"name":"inspect-cache-fixture","version":"2.0.0"}"#,
4682        )
4683        .expect("write clone manifest edit");
4684        assert_eq!(
4685            artifact_cache_key_for_test(&source),
4686            artifact_cache_key_for_test(&clone),
4687            "clones with the same root commit should share the sqlite project key"
4688        );
4689
4690        let source = std::fs::canonicalize(source).expect("canonical source root");
4691        let clone = std::fs::canonicalize(clone).expect("canonical clone root");
4692        let manager = InspectManager::new();
4693        let inspect_dir = dir.path().join("inspect");
4694        let key = JobKey::for_project_category(InspectCategory::DeadCode);
4695        let source_cache = manager
4696            .cache_for_paths(inspect_dir.clone(), source.clone())
4697            .expect("open source cache");
4698        let source_hash = source_cache
4699            .contribution_set_hash(InspectCategory::DeadCode)
4700            .expect("source contribution hash");
4701        source_cache
4702            .store_tier2_aggregate(
4703                key.clone(),
4704                &source_hash,
4705                serde_json::json!({ "count": 7, "items": [] }),
4706            )
4707            .expect("store source aggregate");
4708        assert_eq!(
4709            source_cache
4710                .get_aggregated(&key)
4711                .expect("read source aggregate")
4712                .and_then(|payload| payload.get("count").and_then(Value::as_u64)),
4713            Some(7)
4714        );
4715
4716        let clone_cache = manager
4717            .cache_for_paths(inspect_dir, clone.clone())
4718            .expect("open clone cache");
4719        assert_eq!(clone_cache.project_root(), clone.as_path());
4720        assert!(
4721            clone_cache
4722                .get_aggregated(&key)
4723                .expect("read clone aggregate")
4724                .is_none(),
4725            "same-key clone with a different manifest must not reuse the source root's cached count"
4726        );
4727    }
4728
4729    #[test]
4730    fn dead_code_blocked_on_callgraph_reads_latest_aggregate_flag() {
4731        // Health asks the manager whether dead_code is only missing because the
4732        // callgraph store was not ready when it scanned. The answer must track
4733        // the latest persisted dead_code aggregate's `callgraph_available` flag
4734        // (mirroring the suppression rule in `latest_tier2_counts`).
4735        let dir = tempfile::tempdir().unwrap();
4736        let project_root = std::fs::canonicalize(dir.path()).unwrap();
4737        std::fs::write(project_root.join("lib.rs"), "pub fn marker() {}\n").unwrap();
4738        let manager = InspectManager::new();
4739        let inspect_dir = dir.path().join("inspect");
4740
4741        // No aggregate yet → not blocked.
4742        assert!(!manager.dead_code_blocked_on_callgraph(inspect_dir.clone(), project_root.clone()));
4743
4744        let cache = manager
4745            .cache_for_paths(inspect_dir.clone(), project_root.clone())
4746            .expect("open cache");
4747        let key = JobKey::for_project_category(InspectCategory::DeadCode);
4748        let hash = cache
4749            .contribution_set_hash(InspectCategory::DeadCode)
4750            .expect("contribution hash");
4751
4752        // A callgraph-backed dead_code aggregate → not blocked, count surfaced.
4753        cache
4754            .store_tier2_aggregate(
4755                key.clone(),
4756                &hash,
4757                serde_json::json!({ "count": 3, "callgraph_available": true }),
4758            )
4759            .expect("store callgraph-backed aggregate");
4760        assert!(!manager.dead_code_blocked_on_callgraph(inspect_dir.clone(), project_root.clone()));
4761        assert_eq!(
4762            manager
4763                .latest_tier2_counts(inspect_dir.clone(), project_root.clone())
4764                .0,
4765            Some(3)
4766        );
4767
4768        // A callgraph_unavailable aggregate (store not ready) → blocked, and the
4769        // count stays suppressed so the status bar never fabricates a zero.
4770        cache
4771            .store_tier2_aggregate(
4772                key,
4773                &hash,
4774                crate::inspect::scanners::dead_code::callgraph_unavailable_aggregate(1),
4775            )
4776            .expect("store callgraph_unavailable aggregate");
4777        assert!(manager.dead_code_blocked_on_callgraph(inspect_dir.clone(), project_root.clone()));
4778        assert_eq!(
4779            manager.latest_tier2_counts(inspect_dir, project_root).0,
4780            None,
4781            "callgraph_unavailable dead_code must stay suppressed"
4782        );
4783    }
4784
4785    fn snapshot_job(root: &Path, inspect_dir: &Path, callgraph_store: bool) -> InspectJob {
4786        use crate::config::Config;
4787        use crate::parser::SymbolCache;
4788        use std::sync::RwLock;
4789
4790        InspectJob {
4791            job_id: 1,
4792            key: JobKey::for_project_category(InspectCategory::DeadCode),
4793            category: InspectCategory::DeadCode,
4794            scope_files: Vec::new(),
4795            project_root: root.to_path_buf(),
4796            inspect_dir: inspect_dir.to_path_buf(),
4797            config: Arc::new(Config {
4798                project_root: Some(root.to_path_buf()),
4799                callgraph_store,
4800                ..Config::default()
4801            }),
4802            symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
4803            inspect_writer: true,
4804            callgraph_writer: true,
4805            callgraph_snapshot: None,
4806        }
4807    }
4808
4809    #[test]
4810    fn blocking_inspect_overtakes_queued_maintenance_after_active_seed_releases() {
4811        use crate::config::Config;
4812        use crate::parser::SymbolCache;
4813        use std::sync::RwLock;
4814
4815        let dir = tempfile::tempdir().expect("tempdir");
4816        let root = std::fs::canonicalize(dir.path()).expect("canonical fixture root");
4817        std::fs::create_dir_all(root.join("src")).expect("create source directory");
4818        std::fs::write(
4819            root.join("src/main.ts"),
4820            "export function plantedDead() { return 1; }\n",
4821        )
4822        .expect("write source fixture");
4823        let project_key = crate::search_index::artifact_cache_key(&root);
4824        crate::root_cache::configure_artifact_access(&root, &project_key, false);
4825        let inspect_dir = root.join(".aft-cache").join("inspect");
4826        let snapshot = InspectSnapshot::new_with_capabilities(
4827            root.clone(),
4828            inspect_dir,
4829            Arc::new(Config {
4830                project_root: Some(root.clone()),
4831                callgraph_store: true,
4832                ..Config::default()
4833            }),
4834            Arc::new(RwLock::new(SymbolCache::new())),
4835            true,
4836            true,
4837        );
4838
4839        let limiter = cold_build_limiter::test_limiter(1);
4840        let active_request = cold_build_limiter::ColdBuildAdmissionRequest::new(
4841            "active-semantic-seed",
4842            cold_build_limiter::ColdBuildAdmissionClass::Maintenance,
4843        );
4844        let active =
4845            cold_build_limiter::try_acquire_classified_with_limiter(&limiter, &active_request)
4846                .expect("active semantic seed holds the only slot");
4847        let semantic_seed_active = Arc::new(AtomicBool::new(true));
4848        let manager = Arc::new(InspectManager::with_root_work_gates(
4849            Arc::new(AtomicBool::new(true)),
4850            Arc::clone(&semantic_seed_active),
4851        ));
4852        manager.set_cold_build_limiter(Arc::clone(&limiter));
4853
4854        let inspect_manager = Arc::clone(&manager);
4855        let scope = JobScope::for_project(root);
4856        let (outcome_tx, outcome_rx) = std::sync::mpsc::channel();
4857        std::thread::spawn(move || {
4858            let outcome = inspect_manager.tier2_run_with_reuse_blocking_fresh(
4859                snapshot,
4860                InspectCategory::DeadCode,
4861                scope,
4862            );
4863            outcome_tx.send(outcome).expect("send inspect outcome");
4864        });
4865
4866        let deadline = Instant::now() + Duration::from_secs(3);
4867        while manager.tier2_builder_state(InspectCategory::DeadCode)
4868            != InspectBuilderState::GatedBySemanticSeed
4869        {
4870            assert!(
4871                Instant::now() < deadline,
4872                "inspect must queue while the active seed owns the slot"
4873            );
4874            std::thread::yield_now();
4875        }
4876        assert!(
4877            outcome_rx.try_recv().is_err(),
4878            "in-flight work is not preempted"
4879        );
4880
4881        let maintenance_limiter = Arc::clone(&limiter);
4882        let maintenance = std::thread::spawn(move || {
4883            cold_build_limiter::acquire_blocking_while_with_limiter(
4884                &maintenance_limiter,
4885                "queued background refresh",
4886                || true,
4887            )
4888            .expect("background refresh eventually resumes")
4889        });
4890        std::thread::sleep(Duration::from_millis(150));
4891        semantic_seed_active.store(false, Ordering::SeqCst);
4892        drop(active);
4893
4894        let outcome = outcome_rx
4895            .recv_timeout(Duration::from_secs(10))
4896            .expect("blocking inspect completes after the active seed releases");
4897        let payload = outcome.payload().expect("blocking inspect is fresh");
4898        assert_eq!(
4899            payload.get("callgraph_available").and_then(Value::as_bool),
4900            Some(true)
4901        );
4902        drop(maintenance.join().expect("background waiter joins"));
4903
4904        let events = limiter.admission_events();
4905        assert_eq!(
4906            events[0].class,
4907            cold_build_limiter::ColdBuildAdmissionClass::Maintenance
4908        );
4909        assert_eq!(
4910            events[1].class,
4911            cold_build_limiter::ColdBuildAdmissionClass::InspectTriggered,
4912            "explicit inspect takes the first released slot"
4913        );
4914        assert_eq!(
4915            events[2].class,
4916            cold_build_limiter::ColdBuildAdmissionClass::Maintenance
4917        );
4918    }
4919
4920    #[test]
4921    fn post_eviction_rebind_serves_unchanged_tier2_aggregate_without_cold_slot() {
4922        use crate::config::Config;
4923        use crate::parser::SymbolCache;
4924        use std::sync::RwLock;
4925
4926        let dir = tempfile::tempdir().expect("tempdir");
4927        let root = std::fs::canonicalize(dir.path()).expect("canonical fixture root");
4928        std::fs::create_dir_all(root.join("src")).expect("create source directory");
4929        std::fs::write(
4930            root.join("src/main.ts"),
4931            "export function plantedDead() { return 1; }\n",
4932        )
4933        .expect("write source fixture");
4934        let project_key = crate::search_index::artifact_cache_key(&root);
4935        crate::root_cache::configure_artifact_access(&root, &project_key, false);
4936        let inspect_dir = root.join(".aft-cache").join("inspect");
4937        let snapshot = InspectSnapshot::new_with_capabilities(
4938            root.clone(),
4939            inspect_dir,
4940            Arc::new(Config {
4941                project_root: Some(root.clone()),
4942                callgraph_store: true,
4943                ..Config::default()
4944            }),
4945            Arc::new(RwLock::new(SymbolCache::new())),
4946            true,
4947            true,
4948        );
4949        let limiter = cold_build_limiter::test_limiter(1);
4950        let manager = Arc::new(InspectManager::new());
4951        manager.set_cold_build_limiter(Arc::clone(&limiter));
4952        let first = manager.tier2_run_with_reuse_blocking_fresh(
4953            snapshot.clone(),
4954            InspectCategory::DeadCode,
4955            JobScope::for_project(root.clone()),
4956        );
4957        assert!(
4958            first.payload().is_some(),
4959            "initial scan persists a fresh aggregate"
4960        );
4961        assert!(!manager.tier2_any_in_flight());
4962        manager.evict_idle_caches();
4963
4964        let maintenance_request = cold_build_limiter::ColdBuildAdmissionRequest::new(
4965            "post-eviction-search-verify",
4966            cold_build_limiter::ColdBuildAdmissionClass::Maintenance,
4967        );
4968        let maintenance =
4969            cold_build_limiter::try_acquire_classified_with_limiter(&limiter, &maintenance_request)
4970                .expect("background verification owns the only cold slot");
4971        let events_before = limiter.admission_events().len();
4972        let rebound_manager = Arc::clone(&manager);
4973        let (outcome_tx, outcome_rx) = std::sync::mpsc::channel();
4974        std::thread::spawn(move || {
4975            let outcome = rebound_manager.tier2_run_with_reuse_blocking_fresh(
4976                snapshot,
4977                InspectCategory::DeadCode,
4978                JobScope::for_project(root),
4979            );
4980            outcome_tx.send(outcome).expect("send rebound outcome");
4981        });
4982
4983        let rebound = outcome_rx
4984            .recv_timeout(Duration::from_secs(3))
4985            .expect("unchanged persisted aggregate bypasses the occupied cold-build queue");
4986        assert!(rebound.payload().is_some());
4987        assert_eq!(
4988            limiter.admission_events().len(),
4989            events_before,
4990            "quick reuse must not request an interactive cold-build permit"
4991        );
4992        drop(maintenance);
4993        assert_eq!(
4994            manager.tier2_builder_state(InspectCategory::DeadCode),
4995            InspectBuilderState::Absent,
4996            "quick reuse must clear the builder registry on the way out"
4997        );
4998        assert!(!manager.tier2_any_in_flight());
4999    }
5000
5001    #[test]
5002    fn background_tier2_reuse_panic_clears_builder_registration() {
5003        use crate::config::Config;
5004        use crate::parser::SymbolCache;
5005        use std::sync::RwLock;
5006
5007        let dir = tempfile::tempdir().expect("tempdir");
5008        let root = std::fs::canonicalize(dir.path()).expect("canonical fixture root");
5009        std::fs::create_dir_all(root.join("src")).expect("create source directory");
5010        std::fs::write(
5011            root.join("src/dup.ts"),
5012            "export function planted() { return 1; }\n",
5013        )
5014        .expect("write source fixture");
5015        let project_key = crate::search_index::artifact_cache_key(&root);
5016        crate::root_cache::configure_artifact_access(&root, &project_key, false);
5017        let inspect_dir = root.join(".aft-cache").join("inspect");
5018        let snapshot = InspectSnapshot::new_with_capabilities(
5019            root.clone(),
5020            inspect_dir,
5021            Arc::new(Config {
5022                project_root: Some(root.clone()),
5023                ..Config::default()
5024            }),
5025            Arc::new(RwLock::new(SymbolCache::new())),
5026            true,
5027            true,
5028        );
5029
5030        let previous_root = std::env::var_os("AFT_TEST_TIER2_REUSE_PANIC_ROOT");
5031        let previous_category = std::env::var_os("AFT_TEST_TIER2_REUSE_PANIC_CATEGORY");
5032        unsafe {
5033            std::env::set_var("AFT_TEST_TIER2_REUSE_PANIC_ROOT", &root);
5034            std::env::set_var("AFT_TEST_TIER2_REUSE_PANIC_CATEGORY", "duplicates");
5035        }
5036        struct RestorePanicEnv {
5037            root: Option<std::ffi::OsString>,
5038            category: Option<std::ffi::OsString>,
5039        }
5040        impl Drop for RestorePanicEnv {
5041            fn drop(&mut self) {
5042                unsafe {
5043                    match self.root.take() {
5044                        Some(value) => std::env::set_var("AFT_TEST_TIER2_REUSE_PANIC_ROOT", value),
5045                        None => std::env::remove_var("AFT_TEST_TIER2_REUSE_PANIC_ROOT"),
5046                    }
5047                    match self.category.take() {
5048                        Some(value) => {
5049                            std::env::set_var("AFT_TEST_TIER2_REUSE_PANIC_CATEGORY", value)
5050                        }
5051                        None => std::env::remove_var("AFT_TEST_TIER2_REUSE_PANIC_CATEGORY"),
5052                    }
5053                }
5054            }
5055        }
5056        let _restore = RestorePanicEnv {
5057            root: previous_root,
5058            category: previous_category,
5059        };
5060
5061        let manager = Arc::new(InspectManager::new());
5062        manager
5063            .submit_tier2_run_with_reuse_background(snapshot, InspectCategory::Duplicates)
5064            .expect("queue background duplicates scan");
5065
5066        let deadline = Instant::now() + Duration::from_secs(20);
5067        loop {
5068            if !manager.tier2_any_in_flight()
5069                && manager.tier2_builder_state(InspectCategory::Duplicates)
5070                    == InspectBuilderState::Absent
5071            {
5072                break;
5073            }
5074            assert!(
5075                Instant::now() < deadline,
5076                "a reuse worker that panics before the completion router must still clear the builder registry"
5077            );
5078            std::thread::sleep(Duration::from_millis(10));
5079        }
5080    }
5081
5082    fn generated_unused_exports_fixture() -> (tempfile::TempDir, PathBuf, Vec<PathBuf>) {
5083        let dir = tempfile::tempdir().expect("tempdir");
5084        let root = dir.path().to_path_buf();
5085        let files = [
5086            (
5087                "src/hand.ts",
5088                "export function handUnused() {}
5089",
5090            ),
5091            (
5092                "gen/schema_pb.ts",
5093                "export function generatedPathUnused() {}
5094",
5095            ),
5096            (
5097                "src/banner.ts",
5098                "// Code generated by fixture. DO NOT EDIT.
5099export function bannerUnused() {}
5100",
5101            ),
5102        ];
5103        let paths = files
5104            .iter()
5105            .map(|(relative, contents)| {
5106                let path = root.join(relative);
5107                if let Some(parent) = path.parent() {
5108                    std::fs::create_dir_all(parent).expect("create parent");
5109                }
5110                std::fs::write(&path, contents).expect("write fixture file");
5111                std::fs::canonicalize(path).expect("canonical fixture path")
5112            })
5113            .collect::<Vec<_>>();
5114        (
5115            dir,
5116            std::fs::canonicalize(root).expect("canonical root"),
5117            paths,
5118        )
5119    }
5120
5121    fn unused_exports_job(root: &Path, scope_files: Vec<PathBuf>) -> InspectJob {
5122        use crate::config::Config;
5123        use crate::parser::SymbolCache;
5124        use std::sync::RwLock;
5125
5126        InspectJob {
5127            job_id: 1,
5128            key: JobKey::for_project_category(InspectCategory::UnusedExports),
5129            category: InspectCategory::UnusedExports,
5130            scope_files,
5131            project_root: root.to_path_buf(),
5132            inspect_dir: root.join(".aft-cache").join("inspect"),
5133            config: Arc::new(Config {
5134                project_root: Some(root.to_path_buf()),
5135                ..Config::default()
5136            }),
5137            symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
5138            inspect_writer: true,
5139            callgraph_writer: true,
5140            callgraph_snapshot: None,
5141        }
5142    }
5143
5144    #[test]
5145    fn unused_exports_oxc_cached_rollup_preserves_generated_split() {
5146        let (_dir, root, paths) = generated_unused_exports_fixture();
5147        let job = unused_exports_job(&root, paths.clone());
5148        let entry_points = crate::inspect::entry_points::resolve_entry_points(&root);
5149        let oxc_result = crate::inspect::oxc_engine::analyze_files(
5150            &root,
5151            &paths,
5152            AnalyzeOptions {
5153                entry_points: Vec::new(),
5154                public_api_files: entry_points.public_api_files(),
5155                executable_root_exports: entry_points.executable_root_exports(),
5156                force_reparse_files: Vec::new(),
5157                entry_reachability: false,
5158            },
5159        )
5160        .expect("oxc analyze succeeds");
5161        let fresh = crate::inspect::scanners::unused_exports::run_unused_exports_scan_with_oxc(
5162            &job,
5163            Some(&oxc_result),
5164        )
5165        .outcome
5166        .expect("fresh scan succeeds");
5167
5168        let rolled_up = roll_up_unused_exports_contributions(
5169            &job,
5170            &fresh.contributions,
5171            Some(MAX_DRILL_DOWN_ITEMS),
5172        );
5173
5174        assert_eq!(
5175            rolled_up, fresh.aggregate,
5176            "cached rollup must match fresh scan"
5177        );
5178        assert_eq!(rolled_up["count"], 1, "{rolled_up:#}");
5179        assert_eq!(rolled_up["generated_count"], 2, "{rolled_up:#}");
5180        assert_eq!(rolled_up["total_count"], 3, "{rolled_up:#}");
5181    }
5182
5183    #[test]
5184    fn unused_exports_cached_generated_state_avoids_reprobe_with_legacy_fallback() {
5185        let (_dir, root, paths) = generated_unused_exports_fixture();
5186        let job = unused_exports_job(&root, paths.clone());
5187        let entry_points = crate::inspect::entry_points::resolve_entry_points(&root);
5188        let oxc_result = crate::inspect::oxc_engine::analyze_files(
5189            &root,
5190            &paths,
5191            AnalyzeOptions {
5192                entry_points: Vec::new(),
5193                public_api_files: entry_points.public_api_files(),
5194                executable_root_exports: entry_points.executable_root_exports(),
5195                force_reparse_files: Vec::new(),
5196                entry_reachability: false,
5197            },
5198        )
5199        .expect("oxc analyze succeeds");
5200        let fresh = crate::inspect::scanners::unused_exports::run_unused_exports_scan_with_oxc(
5201            &job,
5202            Some(&oxc_result),
5203        )
5204        .outcome
5205        .expect("fresh scan succeeds");
5206        let mut contributions = fresh.contributions;
5207        let handwritten = contributions
5208            .iter_mut()
5209            .find(|contribution| contribution.file_path.ends_with("src/hand.ts"))
5210            .expect("handwritten contribution");
5211        handwritten.contribution["generated"] = json!(false);
5212
5213        crate::inspect::generated::reset_file_probe_count_for_debug(&root);
5214        let explicit_cached =
5215            roll_up_unused_exports_contributions(&job, &contributions, Some(MAX_DRILL_DOWN_ITEMS));
5216        assert_eq!(explicit_cached, fresh.aggregate);
5217        assert_eq!(
5218            crate::inspect::generated::file_probe_count_for_debug(&root),
5219            0,
5220            "an explicit cached generated=false must not probe the file again"
5221        );
5222
5223        let generated_banner = contributions
5224            .iter_mut()
5225            .find(|contribution| contribution.file_path.ends_with("src/banner.ts"))
5226            .expect("generated banner contribution");
5227        generated_banner
5228            .contribution
5229            .as_object_mut()
5230            .expect("contribution object")
5231            .remove("generated");
5232        crate::inspect::generated::reset_file_probe_count_for_debug(&root);
5233        let legacy_cached =
5234            roll_up_unused_exports_contributions(&job, &contributions, Some(MAX_DRILL_DOWN_ITEMS));
5235        assert_eq!(legacy_cached, fresh.aggregate);
5236        assert_eq!(
5237            crate::inspect::generated::file_probe_count_for_debug(&root),
5238            1,
5239            "a legacy contribution without generated must probe and recover its classification"
5240        );
5241    }
5242
5243    #[test]
5244    fn inspect_callgraph_open_waits_out_transient_sqlite_writer_lock() {
5245        let dir = write_ts_project(3);
5246        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
5247        let inspect_dir = root.join(".aft-cache").join("inspect");
5248        let callgraph_dir =
5249            callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
5250        let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
5251        let (store, _) =
5252            CallGraphStore::cold_build_with_lease(callgraph_dir.clone(), root.clone(), &files)
5253                .expect("publish initial generation");
5254        let sqlite_path = store.sqlite_path().to_path_buf();
5255        drop(store);
5256
5257        let blocker = rusqlite::Connection::open(&sqlite_path).expect("open blocking connection");
5258        blocker
5259            .execute_batch(
5260                "PRAGMA journal_mode=DELETE;
5261                 BEGIN EXCLUSIVE;
5262                 UPDATE meta SET v = v WHERE k = 'ready';",
5263            )
5264            .expect("hold exclusive write transaction");
5265        let (started_tx, started_rx) = std::sync::mpsc::channel();
5266        let open = std::thread::spawn(move || {
5267            started_tx.send(()).expect("signal inspect open start");
5268            open_or_build_blocking_callgraph_store(callgraph_dir, root, true, &files)
5269        });
5270        started_rx.recv().expect("inspect open thread started");
5271        std::thread::sleep(Duration::from_millis(100));
5272        blocker.execute_batch("COMMIT").expect("release write lock");
5273
5274        assert!(
5275            open.join()
5276                .expect("inspect open thread joined")
5277                .expect("transient contention must stay on the Building/retry path")
5278                .is_some(),
5279            "inspect must reopen the ready callgraph instead of failing terminally"
5280        );
5281    }
5282
5283    #[test]
5284    fn callgraph_snapshot_reports_unavailable_when_store_disabled() {
5285        let dir = write_ts_project(3);
5286        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
5287        let inspect_dir = root.join(".aft-cache").join("inspect");
5288
5289        let snapshot =
5290            build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, false), false);
5291
5292        assert!(
5293            snapshot.is_none(),
5294            "dead_code must not rebuild the legacy graph when the store is disabled"
5295        );
5296    }
5297
5298    #[test]
5299    fn callgraph_snapshot_reports_unavailable_when_store_not_ready() {
5300        let dir = write_ts_project(3);
5301        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
5302        let inspect_dir = root.join(".aft-cache").join("inspect");
5303        let callgraph_dir =
5304            callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
5305        let _store = CallGraphStore::open(callgraph_dir, root.clone()).expect("open empty store");
5306
5307        let snapshot =
5308            build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true), false);
5309
5310        assert!(
5311            snapshot.is_none(),
5312            "a cold/mid-build store must surface callgraph_unavailable instead of rebuilding inline"
5313        );
5314    }
5315
5316    #[test]
5317    fn suspended_callgraph_build_sets_distinct_builder_state() {
5318        let dir = write_ts_project(3);
5319        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
5320        let inspect_dir = root.join(".aft-cache").join("inspect");
5321        let callgraph_dir =
5322            callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
5323        let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
5324        let key = crate::build_breaker::BreakerKey::new(
5325            root.display().to_string(),
5326            crate::build_breaker::BuildDomain::CallgraphCold,
5327            crate::callgraph_store::callgraph_corpus_fingerprint_for_test(&root, &files)
5328                .expect("corpus fingerprint"),
5329        );
5330        let breaker = crate::build_breaker::BuildDeathBreaker::open(
5331            callgraph_dir.join("build-breaker.sqlite"),
5332        )
5333        .expect("open breaker");
5334        let now = SystemTime::now()
5335            .duration_since(UNIX_EPOCH)
5336            .expect("system time")
5337            .as_millis() as u64;
5338        for _ in 0..3 {
5339            let crate::build_breaker::BreakerAdmission::Admitted(attempt) =
5340                breaker.admit_at(&key, 0, now).expect("admit build")
5341            else {
5342                panic!("early suspension before the threshold");
5343            };
5344            breaker
5345                .record_attributed_death_at(&key, &attempt.attempt_id, 0, 0, now)
5346                .expect("record death");
5347        }
5348
5349        let manager = InspectManager::new();
5350        let job = snapshot_job(&root, &inspect_dir, true);
5351        assert!(manager
5352            .build_tier2_callgraph_snapshot_with_refresh(&job, true, true, &files)
5353            .is_none());
5354        assert_eq!(
5355            manager.tier2_builder_state(InspectCategory::DeadCode),
5356            InspectBuilderState::Suspended
5357        );
5358        let detail = manager.tier2_builder_state_detail(InspectCategory::DeadCode);
5359        assert!(detail.starts_with("suspended domain=callgraph_cold deaths=3 age_s="));
5360        assert!(detail.ends_with("reason=zero_credit_death_limit"));
5361    }
5362
5363    #[test]
5364    fn readonly_tier2_projection_keeps_generation_pinned_through_concurrent_gc() {
5365        let dir = write_ts_project(3);
5366        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
5367        let inspect_dir = root.join(".aft-cache").join("inspect");
5368        let callgraph_dir =
5369            callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
5370        let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
5371        let (store, _) =
5372            CallGraphStore::cold_build_with_lease(callgraph_dir.clone(), root.clone(), &files)
5373                .expect("initial generation");
5374        let initial_generation = store.sqlite_path().to_path_buf();
5375        drop(store);
5376        let project_key = crate::search_index::artifact_cache_key(&root);
5377        crate::root_cache::enable_writer_lease_acquisition_counts_for_test();
5378        // Delta-based counting: the enable flag is process-global and another
5379        // parallel test may have switched it on before this test's own setup
5380        // acquired its cold-build lease, so the absolute count is
5381        // enable-order-dependent. Only acquisitions inside the observed window
5382        // below are this assertion's business.
5383        let lease_count_before = crate::root_cache::writer_lease_acquisition_count_for_test(
5384            crate::root_cache::RootCacheDomain::Callgraph,
5385            &project_key,
5386            &root,
5387        );
5388
5389        let root_for_observer = root.clone();
5390        let dir_for_observer = callgraph_dir.clone();
5391        let files_for_observer = files.clone();
5392        crate::callgraph_store::set_projection_before_open_observer(Some(Arc::new(
5393            move |projected_path| {
5394                for _ in 0..3 {
5395                    let (published, _) = CallGraphStore::cold_build_with_lease(
5396                        dir_for_observer.clone(),
5397                        root_for_observer.clone(),
5398                        &files_for_observer,
5399                    )
5400                    .expect("concurrent generation publication");
5401                    drop(published);
5402                }
5403                assert!(
5404                    projected_path.is_file(),
5405                    "the tier2 reader marker must pin the selected generation through GC"
5406                );
5407            },
5408        )));
5409        let mut job = snapshot_job(&root, &inspect_dir, true);
5410        job.callgraph_writer = false;
5411
5412        let snapshot =
5413            build_tier2_callgraph_snapshot_with_refresh(&job, false, &[root.join("mod0.ts")]);
5414        crate::callgraph_store::set_projection_before_open_observer(None);
5415
5416        assert!(snapshot.is_some());
5417        assert!(initial_generation.is_file());
5418        assert_eq!(
5419            crate::root_cache::writer_lease_acquisition_count_for_test(
5420                crate::root_cache::RootCacheDomain::Callgraph,
5421                &project_key,
5422                &root,
5423            ) - lease_count_before,
5424            3,
5425            "only the three observer publications may acquire a writer lease; tier2 must stay read-only"
5426        );
5427    }
5428
5429    #[test]
5430    fn direct_callgraph_snapshot_does_not_cold_rebuild_when_store_needs_rebuild() {
5431        let dir = write_ts_project(3);
5432        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
5433        let inspect_dir = root.join(".aft-cache").join("inspect");
5434        let callgraph_dir =
5435            callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
5436        let store = CallGraphStore::open(callgraph_dir.clone(), root.clone()).expect("open store");
5437        let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
5438        store.cold_build(&files).expect("cold build store");
5439        let sqlite_path = store.sqlite_path().to_path_buf();
5440        drop(store);
5441
5442        let still_existing_previous_root = root.with_file_name("previous-root-still-exists");
5443        std::fs::create_dir_all(&still_existing_previous_root).expect("create previous root");
5444        let conn = rusqlite::Connection::open(&sqlite_path).expect("open store sqlite");
5445        conn.execute(
5446            "UPDATE backend_file_state SET workspace_root = ?1",
5447            rusqlite::params![still_existing_previous_root.display().to_string()],
5448        )
5449        .expect("force root repair rebuild state");
5450        drop(conn);
5451
5452        let snapshot =
5453            build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true), false)
5454                .expect("readonly snapshot should avoid cold-rebuilding the store");
5455
5456        assert_eq!(snapshot.files.len(), 3);
5457        let conn = rusqlite::Connection::open(&sqlite_path).expect("reopen store sqlite");
5458        let stored_root: String = conn
5459            .query_row(
5460                "SELECT workspace_root FROM backend_file_state LIMIT 1",
5461                [],
5462                |row| row.get(0),
5463            )
5464            .expect("read stored root");
5465        assert_eq!(
5466            stored_root,
5467            still_existing_previous_root.display().to_string(),
5468            "direct inspect must not cold-rebuild or re-root a read-only snapshot"
5469        );
5470    }
5471
5472    #[test]
5473    fn callgraph_snapshot_reads_ready_callgraph_store() {
5474        let dir = write_ts_project(3);
5475        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
5476        let inspect_dir = root.join(".aft-cache").join("inspect");
5477        let callgraph_dir =
5478            callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
5479        let store = CallGraphStore::open(callgraph_dir, root.clone()).expect("open store");
5480        let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
5481        store.cold_build(&files).expect("cold build store");
5482
5483        let snapshot =
5484            build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true), false)
5485                .expect("ready store snapshot");
5486
5487        assert_eq!(snapshot.files.len(), 3);
5488        assert_eq!(snapshot.exported_symbols.len(), 3);
5489    }
5490
5491    #[test]
5492    fn stale_callgraph_store_refreshes_inline_when_refresh_worker_does_not_run() {
5493        let dir = write_ts_project(2);
5494        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
5495        let inspect_dir = root.join(".aft-cache").join("inspect");
5496        let callgraph_dir =
5497            callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
5498        let project_key = crate::search_index::artifact_cache_key(&root);
5499        crate::root_cache::configure_artifact_access(&root, &project_key, false);
5500        let store = CallGraphStore::open(callgraph_dir.clone(), root.clone()).expect("open store");
5501        let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
5502        store.cold_build(&files).expect("cold build store");
5503        store
5504            .mark_files_stale(&files)
5505            .expect("mark published store stale");
5506        drop(store);
5507
5508        let job = snapshot_job(&root, &inspect_dir, true);
5509        let manager = InspectManager::new();
5510        assert!(
5511            !manager.callgraph_ready_for_snapshot(&InspectSnapshot::new(
5512                root.clone(),
5513                inspect_dir.clone(),
5514                Arc::clone(&job.config),
5515                Arc::clone(&job.symbol_cache),
5516            )),
5517            "a store with leftover stale rows must not look callgraph-ready"
5518        );
5519
5520        let snapshot = manager
5521            .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
5522            .expect("dead_code must refresh stale rows inline when the refresh worker never ran");
5523        assert_eq!(snapshot.files.len(), 2);
5524
5525        let ready = InspectSnapshot::new(
5526            root,
5527            inspect_dir,
5528            Arc::clone(&job.config),
5529            Arc::clone(&job.symbol_cache),
5530        );
5531        assert!(
5532            manager.callgraph_ready_for_snapshot(&ready),
5533            "after the inline refresh, callgraph_ready must agree with a successful projection"
5534        );
5535    }
5536
5537    #[test]
5538    fn callgraph_ready_and_builder_projection_agree_on_stale_rows() {
5539        let dir = write_ts_project(1);
5540        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
5541        let inspect_dir = root.join(".aft-cache").join("inspect");
5542        let callgraph_dir =
5543            callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
5544        let project_key = crate::search_index::artifact_cache_key(&root);
5545        crate::root_cache::configure_artifact_access(&root, &project_key, false);
5546        let store = CallGraphStore::open(callgraph_dir, root.clone()).expect("open store");
5547        let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
5548        store.cold_build(&files).expect("cold build store");
5549        let sqlite_path = store.sqlite_path().to_path_buf();
5550        drop(store);
5551
5552        let job = snapshot_job(&root, &inspect_dir, true);
5553        let snapshot = InspectSnapshot::new(
5554            root.clone(),
5555            inspect_dir.clone(),
5556            Arc::clone(&job.config),
5557            Arc::clone(&job.symbol_cache),
5558        );
5559        let manager = InspectManager::new();
5560        assert!(
5561            manager.callgraph_ready_for_snapshot(&snapshot),
5562            "a fresh store must be ready for both the phase check and projection"
5563        );
5564        project_dead_code_snapshot(&sqlite_path).expect("fresh store should project");
5565
5566        let store = CallGraphStore::open_ready_no_rebuild(
5567            callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir"),
5568            root.clone(),
5569        )
5570        .expect("reopen writer")
5571        .expect("ready writer");
5572        store.mark_files_stale(&files).expect("mark stale");
5573        drop(store);
5574
5575        assert!(
5576            !manager.callgraph_ready_for_snapshot(&snapshot),
5577            "callgraph_ready must use the same stale-row predicate as dead_code projection"
5578        );
5579        let error =
5580            project_dead_code_snapshot(&sqlite_path).expect_err("stale rows must block projection");
5581        match error {
5582            CallGraphStoreError::Unavailable(message) => {
5583                assert_eq!(message, "callgraph has stale files pending refresh")
5584            }
5585            other => panic!("expected Unavailable, got {other:?}"),
5586        }
5587    }
5588
5589    #[test]
5590    fn failed_builder_attempt_history_uses_locked_refusal_detail() {
5591        let manager = InspectManager::new();
5592        let unavailable = JobOutcome::Fresh {
5593            payload: crate::inspect::scanners::dead_code::callgraph_unavailable_aggregate(0),
5594        };
5595        manager
5596            .record_tier2_attempt_outcome_for_test(InspectCategory::DeadCode, unavailable.clone());
5597        let first = manager.tier2_builder_state_detail(InspectCategory::DeadCode);
5598        let first_at = first
5599            .rsplit("first at ")
5600            .next()
5601            .and_then(|tail| tail.strip_suffix(')'))
5602            .expect("first failure detail includes first-at unix time");
5603        for _ in 1..7 {
5604            manager.record_tier2_attempt_outcome_for_test(
5605                InspectCategory::DeadCode,
5606                unavailable.clone(),
5607            );
5608        }
5609        assert_eq!(
5610            manager.tier2_builder_state_detail(InspectCategory::DeadCode),
5611            format!("last attempt failed: callgraph_unavailable (attempt 7, first at {first_at})")
5612        );
5613        assert_eq!(
5614            manager.tier2_builder_state(InspectCategory::DeadCode),
5615            InspectBuilderState::Absent,
5616            "a finished failure must not keep the registry in an in-flight state"
5617        );
5618        assert_eq!(
5619            manager.try_tier2_builder_busy(),
5620            Some(false),
5621            "failed-attempt history must not look like a live rebuild"
5622        );
5623
5624        manager.record_tier2_attempt_outcome_for_test(
5625            InspectCategory::DeadCode,
5626            JobOutcome::Fresh {
5627                payload: serde_json::json!({ "callgraph_available": true, "count": 0 }),
5628            },
5629        );
5630        assert_eq!(
5631            manager.tier2_builder_state_detail(InspectCategory::DeadCode),
5632            InspectBuilderState::Absent.as_str()
5633        );
5634    }
5635
5636    #[test]
5637    fn generation_keyed_projection_cache_reuses_unchanged_snapshot() {
5638        let (_dir, _root, _inspect_dir, job) = published_projection_fixture();
5639        let manager = InspectManager::new();
5640        let (projections, _observer_reset) = count_projections();
5641
5642        let first = manager
5643            .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
5644            .expect("first projection");
5645        let second = manager
5646            .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
5647            .expect("cached projection");
5648
5649        assert!(
5650            Arc::ptr_eq(&first, &second),
5651            "an unchanged generation and write revision must reuse the projected Arc"
5652        );
5653        assert_eq!(
5654            projections.load(std::sync::atomic::Ordering::SeqCst),
5655            1,
5656            "two dead-code scans without a callgraph mutation must project once"
5657        );
5658        let memory = manager.callgraph_projection_estimated_memory();
5659        assert_eq!(
5660            memory.counts["callgraph_projection_snapshots"], 1,
5661            "the resident projection must be attributed to the root"
5662        );
5663        assert!(
5664            memory.estimated_bytes.unwrap_or_default() > 0,
5665            "a populated projection must report an estimated residency"
5666        );
5667    }
5668
5669    #[test]
5670    fn projection_cache_invalidates_on_in_place_refresh_for_readonly_scans() {
5671        let (_dir, root, inspect_dir, job) = published_projection_fixture();
5672        let manager = InspectManager::new();
5673        let (projections, _observer_reset) = count_projections();
5674        let target = root.join("src/target.ts");
5675        let callgraph_dir =
5676            callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("callgraph dir");
5677
5678        let first = manager
5679            .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
5680            .expect("initial projection");
5681        let writer = CallGraphStore::open_ready_no_rebuild(callgraph_dir, root.clone())
5682            .expect("open writer")
5683            .expect("ready writer");
5684        let revision_before = writer
5685            .projection_write_revision()
5686            .expect("read initial revision")
5687            .expect("new stores write a projection revision");
5688        write_projection_cache_file(&target, "export function secondTarget() {}\n");
5689        writer
5690            .refresh_files(&[target])
5691            .expect("refresh changed target");
5692        let revision_after = writer
5693            .projection_write_revision()
5694            .expect("read refreshed revision")
5695            .expect("refreshed stores retain a projection revision");
5696        assert!(
5697            revision_after > revision_before,
5698            "the in-place refresh must advance the durable cache identity"
5699        );
5700        drop(writer);
5701
5702        let second = manager
5703            .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
5704            .expect("refreshed readonly projection");
5705        assert!(
5706            !first
5707                .exported_symbols
5708                .iter()
5709                .any(|export| export.symbol == "secondTarget"),
5710            "the initial snapshot must not already contain the refreshed export"
5711        );
5712        assert!(
5713            second
5714                .exported_symbols
5715                .iter()
5716                .any(|export| export.symbol == "secondTarget"),
5717            "the readonly scan must expose graph data from the refreshed store"
5718        );
5719        assert_eq!(
5720            projections.load(std::sync::atomic::Ordering::SeqCst),
5721            2,
5722            "an in-place refresh must force the next scan to re-project"
5723        );
5724    }
5725
5726    #[test]
5727    fn projection_cache_invalidates_when_cold_build_publishes_new_generation() {
5728        let (_dir, root, inspect_dir, job) = published_projection_fixture();
5729        let manager = InspectManager::new();
5730        let (projections, _observer_reset) = count_projections();
5731        let target = root.join("src/target.ts");
5732        let callgraph_dir =
5733            callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("callgraph dir");
5734
5735        let first = manager
5736            .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
5737            .expect("initial projection");
5738        let before = CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
5739            .expect("open initial reader")
5740            .expect("initial reader");
5741        let revision_before = before
5742            .projection_write_revision()
5743            .expect("read initial revision")
5744            .expect("new stores write a projection revision");
5745        drop(before);
5746        write_projection_cache_file(&target, "export function coldBuildTarget() {}\n");
5747        let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
5748        let (published, _) =
5749            CallGraphStore::cold_build_with_lease(callgraph_dir, root.clone(), &files)
5750                .expect("publish replacement generation");
5751        let revision_after = published
5752            .projection_write_revision()
5753            .expect("read replacement revision")
5754            .expect("replacement stores write a projection revision");
5755        assert_eq!(
5756            revision_after, revision_before,
5757            "cold builds begin with the same revision, so this assertion exercises the generation half of the cache identity"
5758        );
5759        drop(published);
5760
5761        let second = manager
5762            .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
5763            .expect("replacement projection");
5764        assert!(
5765            !first
5766                .exported_symbols
5767                .iter()
5768                .any(|export| export.symbol == "coldBuildTarget"),
5769            "the initial snapshot must not already contain the replacement export"
5770        );
5771        assert!(
5772            second
5773                .exported_symbols
5774                .iter()
5775                .any(|export| export.symbol == "coldBuildTarget"),
5776            "the next scan must expose the generation published by the cold build"
5777        );
5778        assert_eq!(
5779            projections.load(std::sync::atomic::Ordering::SeqCst),
5780            2,
5781            "a new pointer generation must force the next scan to re-project"
5782        );
5783    }
5784
5785    #[test]
5786    fn idle_eviction_drops_generation_keyed_projection_cache() {
5787        let (_dir, _root, _inspect_dir, job) = published_projection_fixture();
5788        let manager = InspectManager::new();
5789        let (projections, _observer_reset) = count_projections();
5790
5791        let first = manager
5792            .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
5793            .expect("initial projection");
5794        manager.evict_idle_caches();
5795        assert_eq!(
5796            manager.callgraph_projection_estimated_memory().counts
5797                ["callgraph_projection_snapshots"],
5798            0,
5799            "idle artifact eviction must release the root projection slot"
5800        );
5801        let second = manager
5802            .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
5803            .expect("reloaded projection");
5804
5805        assert!(
5806            !Arc::ptr_eq(&first, &second),
5807            "eviction must drop the previous projection Arc"
5808        );
5809        assert_eq!(
5810            projections.load(std::sync::atomic::Ordering::SeqCst),
5811            2,
5812            "the next scan after idle eviction must reload the projection"
5813        );
5814    }
5815
5816    #[test]
5817    fn callgraph_snapshot_uses_ready_root_keyed_store() {
5818        let _git_env = crate::test_env::hermetic_git_env_guard();
5819        let dir = write_ts_project(3);
5820        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
5821        let storage_dir = root.join(".aft-cache");
5822        let inspect_dir = storage_dir
5823            .join("inspect")
5824            .join(crate::path_identity::project_scope_key(&root));
5825        let warm_callgraph_dir = storage_dir
5826            .join("callgraph")
5827            .join(artifact_cache_key_for_test(&root));
5828        let store = CallGraphStore::open(warm_callgraph_dir, root.clone()).expect("open store");
5829        let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
5830        store.cold_build(&files).expect("cold build store");
5831
5832        let snapshot =
5833            build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true), false)
5834                .expect("ready sibling store snapshot");
5835
5836        assert_eq!(snapshot.files.len(), 3);
5837        assert_eq!(snapshot.exported_symbols.len(), 3);
5838    }
5839
5840    #[test]
5841    fn dead_code_forced_deletion_refreshes_callgraph_store_before_rollup() {
5842        let dir = tempfile::tempdir().expect("tempdir");
5843        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
5844        write_fixture_file(
5845            &root,
5846            "package.json",
5847            r#"{"name":"dead-code-delete-refresh","type":"module","main":"src/main.ts"}"#,
5848            3_100_000_000,
5849        );
5850        write_fixture_file(
5851            &root,
5852            "src/main.ts",
5853            "export function main() {}\n",
5854            3_100_000_001,
5855        );
5856        write_fixture_file(
5857            &root,
5858            "src/dead.ts",
5859            "export function plantedDead() {}\n",
5860            3_100_000_002,
5861        );
5862
5863        let inspect_dir = root.join(".aft-cache").join("opencode").join("inspect");
5864        let callgraph_dir =
5865            callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
5866        let project_key = crate::search_index::artifact_cache_key(&root);
5867        crate::root_cache::configure_artifact_access(&root, &project_key, false);
5868        let store = CallGraphStore::open(callgraph_dir.clone(), root.clone()).expect("open store");
5869        let project_files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
5870        store.cold_build(&project_files).expect("cold build store");
5871        drop(store);
5872
5873        let config = Arc::new(crate::config::Config {
5874            project_root: Some(root.clone()),
5875            callgraph_store: true,
5876            ..crate::config::Config::default()
5877        });
5878        let symbol_cache = Arc::new(std::sync::RwLock::new(crate::parser::SymbolCache::new()));
5879        let snapshot = InspectSnapshot::new(
5880            root.clone(),
5881            inspect_dir.clone(),
5882            Arc::clone(&config),
5883            Arc::clone(&symbol_cache),
5884        );
5885        let manager = InspectManager::new();
5886        let initial_job =
5887            manager.tier2_reuse_job(snapshot.clone(), InspectCategory::DeadCode, None);
5888        let initial = manager
5889            .tier2_run_with_reuse_job_result_with_options(initial_job, Tier2ReuseOptions::default())
5890            .outcome
5891            .expect("initial dead_code scan succeeds")
5892            .aggregate;
5893        assert!(
5894            aggregate_has_file_symbol(&initial, "src/dead.ts", "plantedDead"),
5895            "initial scan should report the planted dead export: {initial:#}"
5896        );
5897
5898        let deleted = root.join("src/dead.ts");
5899        std::fs::remove_file(&deleted).expect("delete dead fixture");
5900        let delete_job = manager.tier2_reuse_job(snapshot, InspectCategory::DeadCode, None);
5901        let refreshed = manager
5902            .tier2_run_with_reuse_job_result_with_options(
5903                delete_job,
5904                Tier2ReuseOptions {
5905                    force_rescan_paths: [deleted.clone()].into_iter().collect(),
5906                    allow_callgraph_cold_build: true,
5907                    require_callgraph_snapshot: false,
5908                    interactive: false,
5909                },
5910            )
5911            .outcome
5912            .expect("delete refresh dead_code scan succeeds")
5913            .aggregate;
5914
5915        assert_eq!(
5916            refreshed
5917                .get("callgraph_available")
5918                .and_then(Value::as_bool),
5919            Some(true),
5920            "forced watcher paths must keep the callgraph-backed aggregate available: {refreshed:#}"
5921        );
5922        assert!(
5923            !aggregate_has_file_symbol(&refreshed, "src/dead.ts", "plantedDead"),
5924            "delete refresh should remove the planted dead export: {refreshed:#}"
5925        );
5926
5927        let store = CallGraphStore::open_ready_no_rebuild(callgraph_dir, root)
5928            .expect("open refreshed store")
5929            .expect("refreshed store is ready");
5930        let projected = project_dead_code_snapshot(store.sqlite_path()).expect("project snapshot");
5931        assert!(
5932            projected
5933                .files
5934                .iter()
5935                .all(|file| !file.ends_with("src/dead.ts")),
5936            "watcher deletion should be applied to the persisted callgraph store: {:#?}",
5937            projected.files
5938        );
5939    }
5940
5941    fn aggregate_has_file_symbol(aggregate: &Value, file: &str, symbol: &str) -> bool {
5942        aggregate
5943            .get("items")
5944            .and_then(Value::as_array)
5945            .is_some_and(|items| {
5946                items.iter().any(|item| {
5947                    item.get("file").and_then(Value::as_str) == Some(file)
5948                        && item.get("symbol").and_then(Value::as_str) == Some(symbol)
5949                })
5950            })
5951    }
5952
5953    // A scoped payload must not carry the project-wide `by_language` breakdown
5954    // alongside the recomputed in-scope count — that contradiction renders as
5955    // e.g. "Dead code: 1 (rust 214, ts 143)".
5956    #[test]
5957    fn scoped_filter_drops_project_wide_by_language() {
5958        let scope = JobScope::from_roots("/proj", vec![PathBuf::from("/proj/src/a")]);
5959        assert!(
5960            !scope.is_project_wide(),
5961            "scope must be non-project for test"
5962        );
5963        let payload = serde_json::json!({
5964            "count": 99,
5965            "by_language": { "rust": 214, "typescript": 143 },
5966            "items": [
5967                { "file": "/proj/src/a/x.rs", "symbol": "live" },
5968                { "file": "/proj/src/other/y.rs", "symbol": "out" },
5969            ],
5970        });
5971        let filtered = filter_payload_for_scope(payload, &scope);
5972        assert!(
5973            filtered.get("by_language").is_none(),
5974            "scoped payload must drop project-wide by_language: {filtered}"
5975        );
5976        // Count is recomputed to the in-scope items (only x.rs under src/a).
5977        assert_eq!(filtered.get("count").and_then(|v| v.as_u64()), Some(1));
5978    }
5979    #[cfg(debug_assertions)]
5980    #[test]
5981    fn tier2_read_cached_freshness_does_not_hash_unchanged_contributions() {
5982        let (_dir, manager, snapshot, scope, _files) = duplicate_cache_fixture();
5983        let fixture_root = snapshot.project_root.clone();
5984
5985        crate::cache_freshness::reset_hash_file_if_small_count_for_debug();
5986        crate::cache_freshness::reset_verify_file_strict_count_for_debug();
5987        assert_fresh(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
5988
5989        assert_eq!(
5990            crate::cache_freshness::verify_file_strict_count_under_for_debug(&fixture_root),
5991            0,
5992            "dispatch-thread inspect freshness must not use strict verification"
5993        );
5994        assert_eq!(
5995            crate::cache_freshness::hash_file_if_small_count_for_debug(),
5996            0,
5997            "unchanged contribution files must stay on the stat-only fast path"
5998        );
5999    }
6000
6001    #[cfg(debug_assertions)]
6002    #[test]
6003    fn tier2_read_cached_freshness_returns_byte_identical_cold_scan_aggregate() {
6004        let (_dir, manager, snapshot, scope, _files) = duplicate_uncached_fixture();
6005        let cold_payload = fresh_payload(manager.tier2_run_with_reuse(
6006            snapshot.clone(),
6007            InspectCategory::Duplicates,
6008            scope.clone(),
6009            None,
6010        ));
6011
6012        crate::cache_freshness::reset_hash_file_if_small_count_for_debug();
6013        crate::cache_freshness::reset_verify_file_strict_count_for_debug();
6014        let fixture_root = snapshot.project_root.clone();
6015        let warm_payload =
6016            fresh_payload(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
6017
6018        let cold_bytes = serde_json::to_vec(&cold_payload).expect("serialize cold aggregate");
6019        let warm_bytes = serde_json::to_vec(&warm_payload).expect("serialize warm aggregate");
6020        assert_eq!(
6021            warm_bytes, cold_bytes,
6022            "warm unchanged read must return the byte-identical aggregate as the cold scan"
6023        );
6024        assert_eq!(
6025            crate::cache_freshness::verify_file_strict_count_under_for_debug(&fixture_root),
6026            0,
6027            "dispatch-thread warm read must not use strict verification"
6028        );
6029        assert_eq!(
6030            crate::cache_freshness::hash_file_if_small_count_for_debug(),
6031            0,
6032            "warm unchanged read must not content-hash cached contribution files"
6033        );
6034    }
6035
6036    #[test]
6037    fn tier2_read_cached_freshness_detects_changed_added_and_deleted_files() {
6038        let (_dir, manager, snapshot, scope, _files) = duplicate_cache_fixture();
6039        write_fixture_file(
6040            &snapshot.project_root,
6041            "src/foo.ts",
6042            "export const foo = 101;\nexport const changed = true;\n",
6043            3_000_000_001,
6044        );
6045        assert_stale(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
6046
6047        let (_dir, manager, snapshot, scope, _files) = duplicate_cache_fixture();
6048        write_fixture_file(
6049            &snapshot.project_root,
6050            "src/added.ts",
6051            "export const added = 3;\n",
6052            3_000_000_002,
6053        );
6054        assert_stale(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
6055
6056        let (_dir, manager, snapshot, scope, files) = duplicate_cache_fixture();
6057        std::fs::remove_file(&files[0]).expect("delete cached contribution file");
6058        assert_stale(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
6059    }
6060
6061    fn duplicate_cache_fixture() -> (
6062        tempfile::TempDir,
6063        InspectManager,
6064        InspectSnapshot,
6065        JobScope,
6066        Vec<PathBuf>,
6067    ) {
6068        let (dir, manager, snapshot, scope, files) = duplicate_uncached_fixture();
6069        store_duplicate_cache(&manager, &snapshot, &files);
6070        (dir, manager, snapshot, scope, files)
6071    }
6072
6073    fn duplicate_uncached_fixture() -> (
6074        tempfile::TempDir,
6075        InspectManager,
6076        InspectSnapshot,
6077        JobScope,
6078        Vec<PathBuf>,
6079    ) {
6080        use crate::config::Config;
6081        use crate::parser::SymbolCache;
6082        use std::sync::RwLock;
6083
6084        let dir = tempfile::tempdir().expect("tempdir");
6085        let root = std::fs::canonicalize(dir.path()).expect("canonical fixture root");
6086        let files = vec![
6087            write_fixture_file(
6088                &root,
6089                "src/foo.ts",
6090                "export const fixture = () => 1;
6091export const shared = 1;
6092",
6093                3_000_000_000,
6094            ),
6095            write_fixture_file(
6096                &root,
6097                "src/bar.ts",
6098                "export const fixture = () => 1;
6099export const shared = 1;
6100",
6101                3_000_000_000,
6102            ),
6103        ];
6104        let inspect_dir = root.join(".aft-cache").join("inspect");
6105        let snapshot = InspectSnapshot::new(
6106            root.clone(),
6107            inspect_dir,
6108            Arc::new(Config {
6109                project_root: Some(root.clone()),
6110                ..Config::default()
6111            }),
6112            Arc::new(RwLock::new(SymbolCache::new())),
6113        );
6114        let scope = JobScope::for_project(root);
6115        let manager = InspectManager::new();
6116        (dir, manager, snapshot, scope, files)
6117    }
6118
6119    fn write_fixture_file(root: &Path, relative: &str, content: &str, mtime_secs: i64) -> PathBuf {
6120        let path = root.join(relative);
6121        if let Some(parent) = path.parent() {
6122            std::fs::create_dir_all(parent).expect("create fixture parent");
6123        }
6124        std::fs::write(&path, content).expect("write fixture file");
6125        filetime::set_file_mtime(&path, filetime::FileTime::from_unix_time(mtime_secs, 0))
6126            .expect("set fixture mtime");
6127        path
6128    }
6129
6130    fn store_duplicate_cache(
6131        manager: &InspectManager,
6132        snapshot: &InspectSnapshot,
6133        files: &[PathBuf],
6134    ) {
6135        let cache = manager
6136            .cache_for_snapshot(snapshot)
6137            .expect("open inspect cache");
6138        let contributions = files
6139            .iter()
6140            .map(|file| {
6141                let freshness = crate::cache_freshness::collect(file).expect("collect freshness");
6142                FileContribution::new(
6143                    InspectCategory::Duplicates,
6144                    file.clone(),
6145                    freshness,
6146                    serde_json::json!({
6147                        "file": relative_cache_key(&snapshot.project_root, file),
6148                        "fragments": [],
6149                    }),
6150                )
6151            })
6152            .collect::<Vec<_>>();
6153        cache
6154            .store_tier2_result(
6155                JobKey::for_project_category(InspectCategory::Duplicates),
6156                files,
6157                &contributions,
6158                serde_json::json!({
6159                    "count": 0,
6160                    "groups": [],
6161                    "scanned_files": files.len(),
6162                    "total_groups": 0,
6163                }),
6164            )
6165            .expect("store tier2 cache fixture");
6166    }
6167
6168    fn assert_fresh(outcome: JobOutcome) {
6169        let _ = fresh_payload(outcome);
6170    }
6171
6172    fn fresh_payload(outcome: JobOutcome) -> Value {
6173        match outcome {
6174            JobOutcome::Fresh { payload } => payload,
6175            other => panic!("expected fresh cached Tier-2 outcome, got {other:?}"),
6176        }
6177    }
6178
6179    fn assert_stale(outcome: JobOutcome) {
6180        match outcome {
6181            JobOutcome::Stale { .. } => {}
6182            other => panic!("expected stale cached Tier-2 outcome, got {other:?}"),
6183        }
6184    }
6185}
6186
6187#[cfg(test)]
6188mod dead_code_projection_tests {
6189    use super::*;
6190    use crate::callgraph::walk_project_files;
6191    use crate::callgraph_store::{project_dead_code_snapshot, CallGraphStore};
6192    use crate::config::Config;
6193    use crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR;
6194    use crate::inspect::scanners::DEFAULT_EXPORT_MARKER_KIND;
6195    use crate::parser::SymbolCache;
6196    use filetime::FileTime;
6197    use std::sync::atomic::{AtomicI64, Ordering as AtomicOrdering};
6198    use std::sync::RwLock;
6199
6200    static NEXT_MTIME: AtomicI64 = AtomicI64::new(1_900_000_000);
6201
6202    #[test]
6203    fn scoped_dead_code_rollup_uses_ready_callgraph_and_degrades_without_it() {
6204        let dir = tempfile::tempdir().expect("tempdir");
6205        write_projection_fixture(dir.path());
6206        let root = canonical_root(dir.path());
6207        let inspect_dir = root.join(".aft-cache").join("inspect");
6208        let callgraph_dir =
6209            callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
6210        let project_key = crate::search_index::artifact_cache_key(&root);
6211        crate::root_cache::configure_artifact_access(&root, &project_key, false);
6212        let store = CallGraphStore::open(callgraph_dir.clone(), root.clone()).expect("open store");
6213        let files = project_files(&root);
6214        store.cold_build(&files).expect("cold build store");
6215        let projected = project_dead_code_snapshot(store.sqlite_path()).expect("project snapshot");
6216        drop(store);
6217
6218        let config = Arc::new(Config {
6219            project_root: Some(root.clone()),
6220            callgraph_store: true,
6221            ..Config::default()
6222        });
6223        let symbol_cache = Arc::new(RwLock::new(SymbolCache::new()));
6224        let scan_job = InspectJob {
6225            job_id: 87,
6226            key: JobKey::for_project_category(InspectCategory::DeadCode),
6227            category: InspectCategory::DeadCode,
6228            scope_files: files.clone(),
6229            project_root: root.clone(),
6230            inspect_dir: inspect_dir.clone(),
6231            config: Arc::clone(&config),
6232            symbol_cache: Arc::clone(&symbol_cache),
6233            inspect_writer: true,
6234            callgraph_writer: true,
6235            callgraph_snapshot: Some(Arc::new(projected)),
6236        };
6237        let success = crate::inspect::scanners::dead_code::run_dead_code_scan(&scan_job)
6238            .outcome
6239            .expect("dead_code scan succeeds");
6240        let cache = InspectCache::open(inspect_dir.clone(), root.clone()).expect("open cache");
6241        cache
6242            .store_tier2_result(
6243                scan_job.key.clone(),
6244                &success.scanned_files,
6245                &success.contributions,
6246                success.aggregate.clone(),
6247            )
6248            .expect("store tier2 result");
6249
6250        let snapshot = InspectSnapshot::new(root.clone(), inspect_dir, config, symbol_cache);
6251        let scope = JobScope::from_roots(root.clone(), vec![root.join("src/live.ts")]);
6252        assert!(
6253            !scope.is_project_wide(),
6254            "live.ts file scope must be scoped"
6255        );
6256
6257        let ready_payload = scoped_tier2_payload_from_contributions(
6258            &snapshot,
6259            InspectCategory::DeadCode,
6260            &cache,
6261            success.aggregate.clone(),
6262            &scope,
6263        )
6264        .expect("ready scoped payload");
6265        assert_eq!(
6266            ready_payload
6267                .get("callgraph_available")
6268                .and_then(Value::as_bool),
6269            Some(true),
6270            "ready store should produce a callgraph-backed scoped rollup: {ready_payload:#}"
6271        );
6272        assert_live_item(&ready_payload, "src/live.ts", "knownLive");
6273
6274        std::fs::remove_dir_all(&callgraph_dir).expect("remove ready callgraph store");
6275        let unavailable_payload = scoped_tier2_payload_from_contributions(
6276            &snapshot,
6277            InspectCategory::DeadCode,
6278            &cache,
6279            success.aggregate,
6280            &scope,
6281        )
6282        .expect("unavailable scoped payload");
6283        assert_eq!(
6284            unavailable_payload
6285                .get("callgraph_available")
6286                .and_then(Value::as_bool),
6287            Some(false),
6288            "missing store must report callgraph_unavailable instead of fabricating an empty graph: {unavailable_payload:#}"
6289        );
6290        assert_live_item(&unavailable_payload, "src/live.ts", "knownLive");
6291    }
6292    #[derive(Debug, PartialEq, Eq)]
6293    struct ComparableSnapshot {
6294        files: BTreeSet<PathBuf>,
6295        exported_symbols: BTreeSet<(PathBuf, String, String, u32)>,
6296        outbound_calls: BTreeSet<(PathBuf, String, String, u32)>,
6297        entry_points: BTreeSet<PathBuf>,
6298        entry_point_symbols: BTreeMap<PathBuf, BTreeSet<String>>,
6299    }
6300
6301    #[test]
6302    fn dead_code_projection_contains_expected_fixture_surface() {
6303        let dir = tempfile::tempdir().expect("tempdir");
6304        write_projection_fixture(dir.path());
6305        let root = canonical_root(dir.path());
6306        let projected = store_projected_snapshot(&root, ".store-dead-code-surface");
6307
6308        assert_projection_fixture_coverage(&root, &projected);
6309    }
6310
6311    #[test]
6312    fn dead_code_projection_incremental_scenario_matrix_matches_cold_rebuild() {
6313        run_projection_scenario("rename", setup_projection_rename, edit_projection_rename);
6314        run_projection_scenario("delete", setup_projection_delete, edit_projection_delete);
6315        run_projection_scenario(
6316            "barrel delete",
6317            setup_projection_barrel,
6318            edit_projection_barrel_delete,
6319        );
6320        run_projection_scenario(
6321            "dispatch edit",
6322            setup_projection_dispatch,
6323            edit_projection_dispatch,
6324        );
6325        run_projection_scenario(
6326            "body-only edit",
6327            setup_projection_body_only,
6328            edit_projection_body_only,
6329        );
6330    }
6331
6332    #[test]
6333    fn dead_code_projection_dead_code_scan_reports_expected_verdicts() {
6334        let dir = tempfile::tempdir().expect("tempdir");
6335        write_projection_fixture(dir.path());
6336        let root = canonical_root(dir.path());
6337        let files = project_files(&root);
6338        let projected = store_projected_snapshot(&root, ".store-dead-code-e2e");
6339
6340        let projected_aggregate = dead_code_aggregate(&root, files, projected);
6341        assert_dead_item(&projected_aggregate, "src/dead.ts", "knownDead");
6342        assert_live_item(&projected_aggregate, "src/live.ts", "knownLive");
6343        assert_live_item(&projected_aggregate, "src/render.ts", "render");
6344        assert_live_item(&projected_aggregate, "src/other_render.ts", "render");
6345    }
6346
6347    #[test]
6348    fn dead_code_projection_rust_attribute_entry_points_are_live() {
6349        let dir = tempfile::tempdir().expect("tempdir");
6350        write_rust_attribute_entry_fixture(dir.path());
6351        let root = canonical_root(dir.path());
6352        let files = project_files(&root);
6353        let store = CallGraphStore::open(root.join(".store-tauri-commands"), root.clone())
6354            .expect("open store");
6355        store.cold_build(&files).expect("cold build store");
6356        let command = store
6357            .node_for(Path::new("src/commands.rs"), "get_primers")
6358            .expect("command node");
6359        assert!(
6360            command.is_entry_point,
6361            "attribute-rooted commands must be labeled as callgraph entry points"
6362        );
6363        let private_command = store
6364            .node_for(Path::new("src/commands.rs"), "private_command")
6365            .expect("private command node");
6366        assert!(
6367            private_command.is_entry_point,
6368            "private attribute-rooted commands must also be callgraph entry points"
6369        );
6370
6371        let projected = project_dead_code_snapshot(store.sqlite_path()).expect("project snapshot");
6372        let aggregate = dead_code_aggregate(&root, files, projected);
6373        assert_live_item(&aggregate, "src/commands.rs", "get_primers");
6374        assert_live_item(&aggregate, "src/db.rs", "helper");
6375        assert_live_item(&aggregate, "src/db.rs", "private_helper");
6376        assert_live_item(&aggregate, "src/imported.rs", "imported_command");
6377        assert_live_item(&aggregate, "src/db.rs", "imported_helper");
6378        assert_dead_item(&aggregate, "src/commands.rs", "planted_dead");
6379        assert_dead_item(&aggregate, "src/unimported.rs", "false_command");
6380        assert_dead_item(&aggregate, "src/db.rs", "false_helper");
6381    }
6382
6383    #[test]
6384    fn dead_code_projection_rust_attribute_roots_are_cold_deterministic() {
6385        let dir = tempfile::tempdir().expect("tempdir");
6386        write_rust_attribute_entry_fixture(dir.path());
6387        let root = canonical_root(dir.path());
6388        let first = store_projected_snapshot(&root, ".store-tauri-cold-a");
6389        let second = store_projected_snapshot(&root, ".store-tauri-cold-b");
6390
6391        assert_snapshot_parts_eq("rust attribute roots cold", &first, &second);
6392    }
6393
6394    #[test]
6395    fn dead_code_projection_rust_attribute_roots_survive_unrelated_incremental_edit() {
6396        let dir = tempfile::tempdir().expect("tempdir");
6397        write_rust_attribute_entry_fixture(dir.path());
6398        let root = canonical_root(dir.path());
6399        let files_before = project_files(&root);
6400        let incremental_store =
6401            CallGraphStore::open(root.join(".store-tauri-incremental"), root.clone())
6402                .expect("open incremental store");
6403        incremental_store
6404            .cold_build(&files_before)
6405            .expect("initial cold build");
6406
6407        write_file(
6408            &root.join("src/unrelated.rs"),
6409            r#"// unrelated edit should not refresh command attribute facts
6410pub fn unrelated() -> u32 { 2 }
6411"#,
6412        );
6413        let stats = incremental_store
6414            .refresh_files(&[root.join("src/unrelated.rs")])
6415            .expect("refresh unrelated file");
6416        assert_eq!(stats.refreshed_own_files, 1);
6417        assert_eq!(stats.changed_files, vec!["src/unrelated.rs".to_string()]);
6418        assert!(
6419            !stats
6420                .surface_changed
6421                .iter()
6422                .any(|file| file == "src/commands.rs"),
6423            "unrelated edit must not refresh the command module: {stats:#?}"
6424        );
6425        let incremental = project_dead_code_snapshot(incremental_store.sqlite_path())
6426            .expect("project incremental snapshot");
6427
6428        let cold_store = CallGraphStore::open(root.join(".store-tauri-cold"), root.clone())
6429            .expect("open cold store");
6430        cold_store
6431            .cold_build(&project_files(&root))
6432            .expect("cold rebuild");
6433        let cold = project_dead_code_snapshot(cold_store.sqlite_path()).expect("project cold");
6434        assert_snapshot_parts_eq("rust attribute roots unrelated edit", &cold, &incremental);
6435
6436        let aggregate = dead_code_aggregate(&root, project_files(&root), incremental);
6437        assert_live_item(&aggregate, "src/commands.rs", "get_primers");
6438        assert_live_item(&aggregate, "src/db.rs", "helper");
6439        assert_live_item(&aggregate, "src/db.rs", "private_helper");
6440        assert_dead_item(&aggregate, "src/commands.rs", "planted_dead");
6441    }
6442
6443    fn assert_projection_fixture_coverage(root: &Path, snapshot: &CallgraphSnapshot) {
6444        let comparable = comparable_snapshot(snapshot);
6445        assert!(
6446            comparable
6447                .files
6448                .iter()
6449                .any(|file| file.extension().and_then(|ext| ext.to_str()) == Some("ts")),
6450            "fixture must include TypeScript files: {:#?}",
6451            comparable.files
6452        );
6453        assert!(
6454            comparable
6455                .files
6456                .iter()
6457                .any(|file| file.extension().and_then(|ext| ext.to_str()) == Some("js")),
6458            "fixture must include JavaScript files: {:#?}",
6459            comparable.files
6460        );
6461        assert!(
6462            comparable
6463                .files
6464                .iter()
6465                .any(|file| file.extension().and_then(|ext| ext.to_str()) == Some("rs")),
6466            "fixture must include Rust files: {:#?}",
6467            comparable.files
6468        );
6469
6470        let main_file = canonicalize_for_snapshot(&root.join("src/main.ts"));
6471        let private_dispatch_target = format!("{}::dispatch", main_file.display());
6472        assert!(
6473            comparable
6474                .outbound_calls
6475                .iter()
6476                .any(
6477                    |(caller_file, caller_symbol, target, _)| caller_file == &main_file
6478                        && caller_symbol == "main"
6479                        && target == &private_dispatch_target
6480                ),
6481            "fixture must cover same-file private fallback target {private_dispatch_target}: {:#?}",
6482            comparable.outbound_calls
6483        );
6484        assert!(
6485            comparable
6486                .outbound_calls
6487                .iter()
6488                .any(|(_, _, target, _)| target.contains(DISPATCHED_CALLEE_SEPARATOR)),
6489            "fixture must cover method-dispatch suffixes: {:#?}",
6490            comparable.outbound_calls
6491        );
6492        assert!(
6493            comparable
6494                .exported_symbols
6495                .iter()
6496                .any(|(_, symbol, kind, _)| symbol == "runDefault"
6497                    && kind == DEFAULT_EXPORT_MARKER_KIND),
6498            "fixture must cover default-export marker rows: {:#?}",
6499            comparable.exported_symbols
6500        );
6501    }
6502
6503    fn run_projection_scenario(name: &str, setup: fn(&Path), edit: fn(&Path) -> Vec<PathBuf>) {
6504        let dir = tempfile::tempdir().expect("tempdir");
6505        setup(dir.path());
6506        let root = canonical_root(dir.path());
6507        let files_before = project_files(&root);
6508        let incremental_store = CallGraphStore::open(
6509            root.join(format!(".store-dead-code-projection-{name}-incremental")),
6510            root.clone(),
6511        )
6512        .expect("open incremental store");
6513        incremental_store
6514            .cold_build(&files_before)
6515            .expect("initial cold build");
6516
6517        let changed = edit(&root);
6518        incremental_store
6519            .refresh_files(&changed)
6520            .expect("refresh changed files");
6521        let incremental = project_dead_code_snapshot(incremental_store.sqlite_path())
6522            .expect("project incremental snapshot");
6523
6524        let cold_store = CallGraphStore::open(
6525            root.join(format!(".store-dead-code-projection-{name}-cold")),
6526            root.clone(),
6527        )
6528        .expect("open cold store");
6529        cold_store
6530            .cold_build(&project_files(&root))
6531            .expect("cold rebuild");
6532        let cold =
6533            project_dead_code_snapshot(cold_store.sqlite_path()).expect("project cold snapshot");
6534
6535        assert_snapshot_parts_eq(name, &cold, &incremental);
6536    }
6537
6538    /// Store-backed dead_code benchmark. Measures, on a real checkout, the
6539    /// persisted-store cold build, the warm SQLite projection cost, and the
6540    /// remaining `run_dead_code_scan` cost (per-file reexport/type-ref reparse +
6541    /// BFS roll-up). Production Tier-2 reads a warm store; cold_build is included
6542    /// here only to make end-to-end store cost visible.
6543    /// Ignored by default; run with:
6544    ///   AFT_BENCH_REPO=/path/to/large/repo cargo test -p agent-file-tools --lib \
6545    ///     -- --ignored --nocapture --test-threads=1 dead_code_decision_b_benchmark
6546    #[test]
6547    #[ignore = "manual benchmark; needs AFT_BENCH_REPO pointing at a large checkout"]
6548    fn dead_code_decision_b_benchmark() {
6549        let Ok(repo) = std::env::var("AFT_BENCH_REPO") else {
6550            eprintln!("AFT_BENCH_REPO unset; skipping");
6551            return;
6552        };
6553        // Each phase flushes immediately so a file-redirected run shows live progress.
6554        macro_rules! mark {
6555            ($($a:tt)*) => {{ eprintln!($($a)*); let _ = std::io::Write::flush(&mut std::io::stderr()); }};
6556        }
6557        let root = canonical_root(Path::new(&repo));
6558        let files = project_files(&root);
6559        mark!(
6560            "\n=== Store-backed dead_code benchmark ===\nrepo: {}\nsource files (walk_project_files): {}\nstarted store cold_build...",
6561            root.display(),
6562            files.len()
6563        );
6564
6565        // Store cold_build + projection. Production warm runs skip cold_build and
6566        // pay only the projection below.
6567        let store_dir = root.join(".aft-bench-store");
6568        let _ = std::fs::remove_dir_all(&store_dir);
6569        let store = CallGraphStore::open(store_dir.clone(), root.clone()).expect("open store");
6570        let t = Instant::now();
6571        let cold_stats = store.cold_build(&files).expect("store cold build");
6572        let store_build_ms = t.elapsed().as_millis();
6573        let t = Instant::now();
6574        let projected = project_dead_code_snapshot(store.sqlite_path()).expect("projection");
6575        let proj_ms = t.elapsed().as_millis();
6576        mark!(
6577            "store cold_build: {} ms ({:?}) + projection: {} ms = {} ms  (exports={}, outbound={})\nstarted scan...",
6578            store_build_ms, cold_stats, proj_ms, store_build_ms + proj_ms,
6579            projected.exported_symbols.len(), projected.outbound_calls.len()
6580        );
6581
6582        // Remaining scanner cost: run_dead_code_scan given a ready snapshot.
6583        let t = Instant::now();
6584        let _result = dead_code_aggregate(&root, files.clone(), projected.clone());
6585        let scan_ms = t.elapsed().as_millis();
6586        mark!("run_dead_code_scan (cold contributions): {} ms", scan_ms);
6587
6588        mark!(
6589            "\nSUMMARY  files={}  store_cold_plus_projection={}ms  projection={}ms  scan_cold={}ms  total={}ms",
6590            files.len(),
6591            store_build_ms + proj_ms,
6592            proj_ms,
6593            scan_ms,
6594            store_build_ms + proj_ms + scan_ms
6595        );
6596        let _ = std::fs::remove_dir_all(&store_dir);
6597    }
6598
6599    fn store_projected_snapshot(root: &Path, store_name: &str) -> CallgraphSnapshot {
6600        let store =
6601            CallGraphStore::open(root.join(store_name), root.to_path_buf()).expect("open store");
6602        store
6603            .cold_build(&project_files(root))
6604            .expect("store cold build");
6605        project_dead_code_snapshot(store.sqlite_path()).expect("project snapshot")
6606    }
6607
6608    fn dead_code_aggregate(
6609        root: &Path,
6610        scope_files: Vec<PathBuf>,
6611        snapshot: CallgraphSnapshot,
6612    ) -> Value {
6613        let job = InspectJob {
6614            job_id: 86,
6615            key: JobKey::for_project_category(InspectCategory::DeadCode),
6616            category: InspectCategory::DeadCode,
6617            scope_files,
6618            project_root: root.to_path_buf(),
6619            inspect_dir: root.join(".aft-cache").join("inspect"),
6620            config: Arc::new(Config {
6621                project_root: Some(root.to_path_buf()),
6622                ..Config::default()
6623            }),
6624            symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
6625            inspect_writer: true,
6626            callgraph_writer: true,
6627            callgraph_snapshot: Some(Arc::new(snapshot)),
6628        };
6629        crate::inspect::scanners::dead_code::run_dead_code_scan(&job)
6630            .outcome
6631            .expect("dead_code scan succeeds")
6632            .aggregate
6633    }
6634
6635    fn assert_snapshot_parts_eq(
6636        label: &str,
6637        expected: &CallgraphSnapshot,
6638        actual: &CallgraphSnapshot,
6639    ) {
6640        let expected = comparable_snapshot(expected);
6641        let actual = comparable_snapshot(actual);
6642        assert_eq!(
6643            actual, expected,
6644            "{label} store-projected snapshot must match cold store snapshot"
6645        );
6646    }
6647
6648    fn comparable_snapshot(snapshot: &CallgraphSnapshot) -> ComparableSnapshot {
6649        ComparableSnapshot {
6650            files: snapshot.files.iter().cloned().collect(),
6651            exported_symbols: snapshot
6652                .exported_symbols
6653                .iter()
6654                .map(|export| {
6655                    (
6656                        export.file.clone(),
6657                        export.symbol.clone(),
6658                        export.kind.clone(),
6659                        export.line,
6660                    )
6661                })
6662                .collect(),
6663            outbound_calls: snapshot
6664                .outbound_calls
6665                .iter()
6666                .map(|call| {
6667                    (
6668                        call.caller_file.clone(),
6669                        call.caller_symbol.clone(),
6670                        call.target.clone(),
6671                        call.line,
6672                    )
6673                })
6674                .collect(),
6675            entry_points: snapshot.entry_points.clone(),
6676            entry_point_symbols: snapshot.entry_point_symbols.clone(),
6677        }
6678    }
6679
6680    fn assert_dead_item(aggregate: &Value, file: &str, symbol: &str) {
6681        assert!(
6682            aggregate_has_item(aggregate, file, symbol),
6683            "expected {file}::{symbol} to be reported dead: {aggregate:#}"
6684        );
6685    }
6686
6687    fn assert_live_item(aggregate: &Value, file: &str, symbol: &str) {
6688        assert!(
6689            !aggregate_has_item(aggregate, file, symbol),
6690            "expected {file}::{symbol} to be live/not reported dead: {aggregate:#}"
6691        );
6692    }
6693
6694    fn aggregate_has_item(aggregate: &Value, file: &str, symbol: &str) -> bool {
6695        let Some(items) = aggregate.get("items").and_then(Value::as_array) else {
6696            return false;
6697        };
6698        items.iter().any(|item| {
6699            item.get("file").and_then(Value::as_str) == Some(file)
6700                && item.get("symbol").and_then(Value::as_str) == Some(symbol)
6701        })
6702    }
6703
6704    fn project_files(root: &Path) -> Vec<PathBuf> {
6705        walk_project_files(root).collect()
6706    }
6707
6708    fn canonical_root(root: &Path) -> PathBuf {
6709        std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf())
6710    }
6711
6712    fn write_file(path: &Path, content: &str) {
6713        if let Some(parent) = path.parent() {
6714            std::fs::create_dir_all(parent).expect("create parent");
6715        }
6716        std::fs::write(path, content).expect("write fixture");
6717        bump_mtime(path);
6718    }
6719
6720    fn bump_mtime(path: &Path) {
6721        let secs = NEXT_MTIME.fetch_add(1, AtomicOrdering::SeqCst);
6722        filetime::set_file_mtime(path, FileTime::from_unix_time(secs, 0)).expect("bump mtime");
6723    }
6724
6725    fn remove_file(path: &Path) {
6726        std::fs::remove_file(path).expect("remove fixture");
6727    }
6728
6729    fn write_projection_fixture(root: &Path) {
6730        write_file(
6731            &root.join("package.json"),
6732            r#"{"name":"dead-code-projection-fixture","type":"module","main":"src/main.ts"}"#,
6733        );
6734        write_file(
6735            &root.join("Cargo.toml"),
6736            r#"[package]
6737name = "dead_code_projection_fixture"
6738version = "0.1.0"
6739edition = "2021"
6740"#,
6741        );
6742        write_file(
6743            &root.join("src/main.ts"),
6744            r#"import runDefault from "./default";
6745import { knownLive } from "./live";
6746import { jsEntry } from "./app.js";
6747
6748export function main() {
6749  dispatch();
6750  runDefault();
6751  jsEntry();
6752}
6753
6754function dispatch() {
6755  knownLive();
6756  const service = { render() {} };
6757  service.render();
6758}
6759"#,
6760        );
6761        write_file(
6762            &root.join("src/default.ts"),
6763            r#"export default function runDefault() {}
6764"#,
6765        );
6766        write_file(
6767            &root.join("src/live.ts"),
6768            r#"export function knownLive() {}
6769"#,
6770        );
6771        write_file(
6772            &root.join("src/dead.ts"),
6773            r#"export function knownDead() {}
6774"#,
6775        );
6776        write_file(
6777            &root.join("src/render.ts"),
6778            r#"export function render() {}
6779"#,
6780        );
6781        write_file(
6782            &root.join("src/other_render.ts"),
6783            r#"export function render() {}
6784"#,
6785        );
6786        write_file(
6787            &root.join("src/app.js"),
6788            r#"import { jsHelper } from "./js_helper.js";
6789
6790export function jsEntry() {
6791  jsHelper();
6792}
6793"#,
6794        );
6795        write_file(
6796            &root.join("src/js_helper.js"),
6797            r#"export function jsHelper() {}
6798"#,
6799        );
6800        write_file(
6801            &root.join("src/lib.rs"),
6802            r#"mod util;
6803use crate::util::rust_helper;
6804
6805pub fn rust_entry() {
6806    rust_helper();
6807}
6808"#,
6809        );
6810        write_file(
6811            &root.join("src/util.rs"),
6812            r#"pub fn rust_helper() {}
6813"#,
6814        );
6815    }
6816
6817    fn write_rust_attribute_entry_fixture(root: &Path) {
6818        write_file(
6819            &root.join("src/main.rs"),
6820            r#"mod commands;
6821mod db;
6822mod imported;
6823mod unimported;
6824mod unrelated;
6825
6826fn main() {
6827    tauri::generate_handler![commands::get_primers, imported::imported_command];
6828}
6829"#,
6830        );
6831        write_file(
6832            &root.join("src/commands.rs"),
6833            r#"use crate::db;
6834
6835#[tauri::command]
6836pub fn get_primers() -> String {
6837    db::helper()
6838}
6839
6840pub fn planted_dead() -> String {
6841    "dead".to_string()
6842}
6843
6844#[tauri::command]
6845fn private_command() -> String {
6846    db::private_helper()
6847}
6848"#,
6849        );
6850        write_file(
6851            &root.join("src/imported.rs"),
6852            r#"use crate::db;
6853use tauri::command;
6854
6855#[command]
6856pub fn imported_command() -> String {
6857    db::imported_helper()
6858}
6859"#,
6860        );
6861        write_file(
6862            &root.join("src/unimported.rs"),
6863            r#"use crate::db;
6864
6865#[command]
6866pub fn false_command() -> String {
6867    db::false_helper()
6868}
6869"#,
6870        );
6871        write_file(
6872            &root.join("src/db.rs"),
6873            r#"pub fn helper() -> String { "live".to_string() }
6874pub fn imported_helper() -> String { "live".to_string() }
6875pub fn private_helper() -> String { "live".to_string() }
6876pub fn false_helper() -> String { "dead".to_string() }
6877"#,
6878        );
6879        write_file(
6880            &root.join("src/unrelated.rs"),
6881            r#"pub fn unrelated() -> u32 { 1 }
6882"#,
6883        );
6884    }
6885
6886    fn setup_projection_rename(root: &Path) {
6887        write_file(
6888            &root.join("a.ts"),
6889            r#"export function outer() {
6890  inner();
6891}
6892
6893export function inner() {}
6894"#,
6895        );
6896    }
6897
6898    fn edit_projection_rename(root: &Path) -> Vec<PathBuf> {
6899        let path = root.join("a.ts");
6900        write_file(
6901            &path,
6902            r#"export function outer() {
6903  renamed();
6904}
6905
6906export function renamed() {}
6907"#,
6908        );
6909        vec![path]
6910    }
6911
6912    fn setup_projection_delete(root: &Path) {
6913        write_file(
6914            &root.join("main.ts"),
6915            r#"import { foo } from "./foo";
6916export function main() { foo(); }
6917"#,
6918        );
6919        write_file(&root.join("foo.ts"), "export function foo() {}\n");
6920    }
6921
6922    fn edit_projection_delete(root: &Path) -> Vec<PathBuf> {
6923        let path = root.join("foo.ts");
6924        remove_file(&path);
6925        vec![path]
6926    }
6927
6928    fn setup_projection_barrel(root: &Path) {
6929        write_file(
6930            &root.join("main.ts"),
6931            r#"import { foo } from "./barrel";
6932export function main() { foo(); }
6933"#,
6934        );
6935        write_file(&root.join("barrel.ts"), "export { foo } from \"./foo\";\n");
6936        write_file(&root.join("foo.ts"), "export function foo() {}\n");
6937    }
6938
6939    fn edit_projection_barrel_delete(root: &Path) -> Vec<PathBuf> {
6940        let path = root.join("barrel.ts");
6941        remove_file(&path);
6942        vec![path]
6943    }
6944
6945    fn setup_projection_dispatch(root: &Path) {
6946        write_file(
6947            &root.join("main.ts"),
6948            r#"export function main() {
6949  const service = { render() {}, paint() {} };
6950  service.render();
6951}
6952"#,
6953        );
6954        write_file(&root.join("render.ts"), "export function render() {}\n");
6955        write_file(&root.join("paint.ts"), "export function paint() {}\n");
6956    }
6957
6958    fn edit_projection_dispatch(root: &Path) -> Vec<PathBuf> {
6959        let path = root.join("main.ts");
6960        write_file(
6961            &path,
6962            r#"export function main() {
6963  const service = { render() {}, paint() {} };
6964  service.paint();
6965}
6966"#,
6967        );
6968        vec![path]
6969    }
6970
6971    fn setup_projection_body_only(root: &Path) {
6972        write_file(
6973            &root.join("main.ts"),
6974            r#"import { foo } from "./foo";
6975export function main() { foo(); }
6976"#,
6977        );
6978        write_file(
6979            &root.join("foo.ts"),
6980            r#"export function foo() {
6981  return 1;
6982}
6983"#,
6984        );
6985    }
6986
6987    fn edit_projection_body_only(root: &Path) -> Vec<PathBuf> {
6988        let path = root.join("foo.ts");
6989        write_file(
6990            &path,
6991            r#"export function foo() {
6992  return 2;
6993}
6994"#,
6995        );
6996        vec![path]
6997    }
6998
6999    #[test]
7000    fn forced_paths_downgrade_only_when_strict_hash_matches_cached_fact() {
7001        let dir = tempfile::tempdir().expect("tempdir");
7002        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
7003        let unchanged = root.join("unchanged.ts");
7004        let changed = root.join("changed.ts");
7005        let oversized = root.join("oversized.ts");
7006        std::fs::write(&unchanged, "export const value = 1;\n").expect("write unchanged");
7007        std::fs::write(&changed, "export const before = 1;\n").expect("write changed baseline");
7008        let unchanged_freshness =
7009            cache_freshness::collect(&unchanged).expect("unchanged freshness");
7010        let changed_freshness = cache_freshness::collect(&changed).expect("changed freshness");
7011        std::fs::write(&changed, "export const after_ = 2;\n").expect("change same-size content");
7012        let oversized_file = std::fs::File::create(&oversized).expect("create oversized");
7013        oversized_file
7014            .set_len(cache_freshness::CONTENT_HASH_SIZE_CAP + 1)
7015            .expect("size oversized");
7016        let oversized_freshness =
7017            cache_freshness::collect(&oversized).expect("oversized freshness");
7018        let cached = vec![
7019            CachedContributionFreshness {
7020                file_path: PathBuf::from("unchanged.ts"),
7021                freshness: unchanged_freshness,
7022            },
7023            CachedContributionFreshness {
7024                file_path: PathBuf::from("changed.ts"),
7025                freshness: changed_freshness,
7026            },
7027            CachedContributionFreshness {
7028                file_path: PathBuf::from("oversized.ts"),
7029                freshness: oversized_freshness,
7030            },
7031        ];
7032
7033        let (remaining, downgraded) = downgrade_unchanged_forced_paths_with_freshness(
7034            &root,
7035            &cached,
7036            vec![
7037                PathBuf::from("unchanged.ts"),
7038                PathBuf::from("changed.ts"),
7039                PathBuf::from("oversized.ts"),
7040            ],
7041        );
7042
7043        assert_eq!(downgraded, 1);
7044        assert_eq!(
7045            remaining,
7046            vec![PathBuf::from("changed.ts"), PathBuf::from("oversized.ts")]
7047        );
7048    }
7049}