Skip to main content

aft/inspect/
manager.rs

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