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                projection_estimator_duration(&phases),
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
3009fn projection_estimator_duration(phases: &Tier2PhaseTimings) -> Duration {
3010    // Projection journal files are the denominator used by the cost model.
3011    // Refresh and rollup have different work sets, so folding either into this
3012    // sample makes a cheap splice look expensive for unrelated work.
3013    phases.projection_cost
3014}
3015
3016impl Tier2PhaseTimings {
3017    fn add_db_timings(&mut self, timings: InspectDbTimings) {
3018        self.db_lock += timings.lock_wait;
3019        self.db_txn += timings.transaction;
3020    }
3021
3022    fn worked(&self) -> Duration {
3023        self.freshness + self.scan + self.snapshot + self.rollup + self.db
3024    }
3025
3026    fn log(&self, category: InspectCategory, project_root: &Path) {
3027        let worked = self.worked();
3028        if !worked.is_zero() {
3029            crate::logging::note_tier2_scan(
3030                category.to_string(),
3031                worked.as_millis().min(u128::from(u64::MAX)) as u64,
3032            );
3033        }
3034        if worked < TIER2_WORK_LOG_THRESHOLD {
3035            return;
3036        }
3037        let key = crate::search_index::artifact_cache_key(project_root);
3038        crate::slog_info!("{}", self.render(category, project_root, &key));
3039    }
3040
3041    fn render(&self, category: InspectCategory, project_root: &Path, key: &str) -> String {
3042        let projection = self
3043            .projection
3044            .map(render_projection_suffix)
3045            .unwrap_or_else(|| {
3046                render_no_projection_suffix(
3047                    self.projection_skip_reason
3048                        .unwrap_or_else(|| default_projection_skip_reason(category)),
3049                )
3050            });
3051        let rollup = self
3052            .rollup_verdict
3053            .map(render_rollup_suffix)
3054            .unwrap_or_default();
3055        format!(
3056            "perf tier2 phases category={} freshness={}ms snapshot={}ms projection_ms={} scan={}ms({} files) db={}ms(lock={},txn={}) rollup_ms={}{}{} root={} key={}",
3057            category,
3058            self.freshness.as_millis(),
3059            self.snapshot.as_millis(),
3060            self.projection_cost.as_millis(),
3061            self.scan.as_millis(),
3062            self.scanned_files,
3063            self.db.as_millis(),
3064            self.db_lock.as_millis(),
3065            self.db_txn.as_millis(),
3066            self.rollup.as_millis(),
3067            rollup,
3068            projection,
3069            crate::logging::normalize_index_root(project_root),
3070            key
3071        )
3072    }
3073}
3074
3075fn default_projection_skip_reason(category: InspectCategory) -> &'static str {
3076    if category == InspectCategory::DeadCode {
3077        "no_callgraph"
3078    } else {
3079        "not_required"
3080    }
3081}
3082
3083/// Projection fields in a `perf tier2 phases` line use this grammar:
3084/// `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>`.
3085/// Successful splice/reuse verdicts omit `reason`; every `none` verdict names why
3086/// no callgraph projection ran.
3087fn render_no_projection_suffix(reason: &'static str) -> String {
3088    format!(" projection=none reason={reason} journal_bytes=0 changed_files=0")
3089}
3090
3091/// Render the `projection=...` suffix of the `perf tier2 phases` line for one
3092/// dead-code snapshot verdict. `reason` is omitted for spliced and reused
3093/// projections.
3094fn render_rollup_suffix(verdict: super::scanners::dead_code::RollupVerdict) -> String {
3095    let kind = match verdict.kind {
3096        super::scanners::dead_code::RollupKind::Incremental => "incremental",
3097        super::scanners::dead_code::RollupKind::Full => "full",
3098    };
3099    let reason = verdict
3100        .reason
3101        .map(|reason| format!(" reason={reason}"))
3102        .unwrap_or_default();
3103    format!(" rollup={kind}{reason}")
3104}
3105
3106fn render_projection_suffix(verdict: ProjectionVerdict) -> String {
3107    let kind = match verdict.kind {
3108        ProjectionKind::Spliced => "spliced",
3109        ProjectionKind::Full => "full",
3110        ProjectionKind::Reused => "reused",
3111    };
3112    let reason = match verdict.reason {
3113        Some("journal_oversize") => format!(" reason=journal_oversize:{MAX_DELTA_BYTES}"),
3114        Some(reason) => format!(" reason={reason}"),
3115        None => String::new(),
3116    };
3117    format!(
3118        " projection={kind}{reason} journal_bytes={} changed_files={}",
3119        verdict.journal_bytes, verdict.changed_files
3120    )
3121}
3122
3123fn scope_files(project_root: &Path, scope: &JobScope) -> Vec<PathBuf> {
3124    let mut files = crate::callgraph::walk_project_files(project_root)
3125        .filter(|path| scope.contains(path))
3126        .collect::<Vec<_>>();
3127    files.sort();
3128    files
3129}
3130
3131fn forced_relative_paths(job: &InspectJob, paths: &BTreeSet<PathBuf>) -> BTreeSet<String> {
3132    let mut keys = BTreeSet::new();
3133    for path in paths {
3134        let absolute = if path.is_absolute() {
3135            path.clone()
3136        } else {
3137            job.project_root.join(path)
3138        };
3139        keys.insert(relative_cache_key(&job.project_root, &absolute));
3140        // Normalized, not bare-canonical: the project root is verbatim-stripped,
3141        // so a verbatim canonical path would fail strip_prefix and produce an
3142        // absolute key no cached contribution matches (the forced rescan then
3143        // silently misses).
3144        keys.insert(relative_cache_key(
3145            &job.project_root,
3146            &crate::inspect::job::canonicalize_normalized(&absolute),
3147        ));
3148    }
3149    keys
3150}
3151
3152fn downgrade_unchanged_forced_paths_with_freshness(
3153    project_root: &Path,
3154    cached: &[CachedContributionFreshness],
3155    paths: Vec<PathBuf>,
3156) -> (Vec<PathBuf>, usize) {
3157    let cached = cached
3158        .iter()
3159        .map(|record| (freshness_record_relative_key(record), record.freshness))
3160        .collect::<BTreeMap<_, _>>();
3161    let mut remaining = Vec::with_capacity(paths.len());
3162    let mut downgraded = 0;
3163
3164    for path in paths {
3165        let absolute = if path.is_absolute() {
3166            path.clone()
3167        } else {
3168            project_root.join(&path)
3169        };
3170        let direct_key = relative_cache_key(project_root, &absolute);
3171        // Same normalized form as forced_relative_paths; see the comment there.
3172        let canonical_key = Some(relative_cache_key(
3173            project_root,
3174            &crate::inspect::job::canonicalize_normalized(&absolute),
3175        ));
3176        let freshness = cached
3177            .get(&direct_key)
3178            .or_else(|| canonical_key.as_ref().and_then(|key| cached.get(key)));
3179        let content_unchanged = freshness.is_some_and(|freshness| {
3180            matches!(
3181                cache_freshness::verify_file_strict(&absolute, freshness),
3182                FreshnessVerdict::HotFresh | FreshnessVerdict::ContentFresh { .. }
3183            )
3184        });
3185        if content_unchanged {
3186            downgraded += 1;
3187        } else {
3188            remaining.push(path);
3189        }
3190    }
3191
3192    (remaining, downgraded)
3193}
3194
3195fn panic_tier2_reuse_for_debug(job: &InspectJob) {
3196    #[cfg(not(debug_assertions))]
3197    let _ = job;
3198    #[cfg(debug_assertions)]
3199    {
3200        if !env_project_root_matches("AFT_TEST_TIER2_REUSE_PANIC_ROOT", &job.project_root) {
3201            return;
3202        }
3203        let should_panic = std::env::var("AFT_TEST_TIER2_REUSE_PANIC_CATEGORY")
3204            .ok()
3205            .is_some_and(|category| category == job.category.as_str());
3206        if should_panic {
3207            panic!("forced tier2 reuse panic for {}", job.category);
3208        }
3209    }
3210}
3211
3212fn delay_tier2_reuse_for_debug(project_root: &Path) {
3213    #[cfg(not(debug_assertions))]
3214    let _ = project_root;
3215    #[cfg(debug_assertions)]
3216    {
3217        if std::env::var_os("AFT_TEST_TIER2_REUSE_GATE_ROOT").is_some()
3218            && env_project_root_matches("AFT_TEST_TIER2_REUSE_GATE_ROOT", project_root)
3219        {
3220            let ready = std::env::var_os("AFT_TEST_TIER2_REUSE_GATE_READY").map(PathBuf::from);
3221            let release = std::env::var_os("AFT_TEST_TIER2_REUSE_GATE_RELEASE").map(PathBuf::from);
3222            if let (Some(ready), Some(release)) = (ready, release) {
3223                let _ = std::fs::write(&ready, b"ready");
3224                // The release file controls correctness ordering. This deadline
3225                // only prevents a broken fixture from wedging the test process.
3226                let hang_deadline = Instant::now() + Duration::from_secs(30);
3227                while !release.exists() {
3228                    assert!(
3229                        Instant::now() < hang_deadline,
3230                        "timed out waiting for Tier-2 reuse gate release"
3231                    );
3232                    std::thread::sleep(Duration::from_millis(10));
3233                }
3234                return;
3235            }
3236        }
3237
3238        if !env_project_root_matches("AFT_TEST_TIER2_REUSE_DELAY_ROOT", project_root) {
3239            return;
3240        }
3241        if let Some(delay_ms) = std::env::var("AFT_TEST_TIER2_REUSE_DELAY_MS")
3242            .ok()
3243            .and_then(|raw| raw.parse::<u64>().ok())
3244        {
3245            std::thread::sleep(Duration::from_millis(delay_ms));
3246        }
3247    }
3248}
3249
3250#[cfg(debug_assertions)]
3251fn env_project_root_matches(var: &str, project_root: &Path) -> bool {
3252    let Some(raw) = std::env::var_os(var) else {
3253        return true;
3254    };
3255    let expected = PathBuf::from(raw);
3256    let expected = std::fs::canonicalize(&expected).unwrap_or(expected);
3257    let actual = std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
3258    expected == actual
3259}
3260
3261fn current_project_files(project_root: &Path, files: &[PathBuf]) -> BTreeMap<String, PathBuf> {
3262    files
3263        .iter()
3264        .map(|file| (relative_cache_key(project_root, file), file.clone()))
3265        .collect()
3266}
3267
3268fn insert_callgraph_refresh_path(paths: &mut BTreeSet<PathBuf>, path: PathBuf) {
3269    if callgraph_store_indexes_path(&path) {
3270        paths.insert(path);
3271    }
3272}
3273
3274fn callgraph_store_indexes_path(path: &Path) -> bool {
3275    crate::parser::detect_language(path).is_some()
3276}
3277
3278fn tier2_benchmark_logging_enabled() -> bool {
3279    std::env::var_os("AFT_SETTLE_BENCH_LOG").is_some()
3280}
3281
3282thread_local! {
3283    static TIER2_INDEX_SCOPE: RefCell<Option<crate::logging::IndexBuildScope>> =
3284        const { RefCell::new(None) };
3285}
3286
3287fn log_tier2_benchmark_category_start(job: &InspectJob) {
3288    let key = crate::search_index::artifact_cache_key(&job.project_root);
3289    let scope = crate::logging::IndexBuildScope::new(
3290        crate::logging::IndexPlane::Tier2,
3291        &job.project_root,
3292        key,
3293    );
3294    TIER2_INDEX_SCOPE.with(|slot| *slot.borrow_mut() = Some(scope));
3295    if !tier2_benchmark_logging_enabled() {
3296        return;
3297    }
3298    crate::slog_info!(
3299        "settle bench: tier2_category_start category={} job_id={} files={}",
3300        job.category.as_str(),
3301        job.job_id,
3302        job.scope_files.len()
3303    );
3304}
3305
3306fn tier2_pass_did_real_work(result: &InspectResult) -> bool {
3307    match &result.outcome {
3308        Ok(success) => {
3309            !success.scanned_files.is_empty() || result.duration >= TIER2_WORK_LOG_THRESHOLD
3310        }
3311        Err(_) => false,
3312    }
3313}
3314
3315fn log_tier2_benchmark_category_end(result: &InspectResult) {
3316    let scope = TIER2_INDEX_SCOPE.with(|slot| slot.borrow_mut().take());
3317    if let Some(scope) = scope {
3318        match &result.outcome {
3319            Ok(success) if tier2_pass_did_real_work(result) => {
3320                crate::logging::log_index_event(
3321                    crate::logging::IndexEvent::from_scope(
3322                        crate::logging::IndexEventKind::BuildStarted,
3323                        &scope,
3324                    )
3325                    .field("category", result.category.as_str())
3326                    .field("files", success.scanned_files.len()),
3327                );
3328                crate::logging::log_index_event(
3329                    crate::logging::IndexEvent::from_scope(
3330                        crate::logging::IndexEventKind::BuildReady,
3331                        &scope,
3332                    )
3333                    .field("category", result.category.as_str())
3334                    .field("elapsed_ms", result.duration.as_millis())
3335                    .field("files", success.scanned_files.len())
3336                    .field("contributions", success.contributions.len()),
3337                );
3338            }
3339            Ok(_) => {}
3340            Err(message) => {
3341                crate::logging::log_index_event(
3342                    crate::logging::IndexEvent::from_scope(
3343                        crate::logging::IndexEventKind::BuildFailed,
3344                        &scope,
3345                    )
3346                    .field("category", result.category.as_str())
3347                    .field("elapsed_ms", result.duration.as_millis())
3348                    .field("reason", message),
3349                );
3350            }
3351        }
3352    }
3353    if !tier2_benchmark_logging_enabled() {
3354        return;
3355    }
3356    match &result.outcome {
3357        Ok(success) => {
3358            let count = success
3359                .aggregate
3360                .get("count")
3361                .and_then(serde_json::Value::as_u64)
3362                .unwrap_or(0);
3363            crate::slog_info!(
3364                "settle bench: tier2_category_end category={} job_id={} status=success total_ms={} scanned_files={} contributions={} count={}",
3365                result.category.as_str(),
3366                result.job_id,
3367                result.duration.as_millis(),
3368                success.scanned_files.len(),
3369                success.contributions.len(),
3370                count
3371            );
3372        }
3373        Err(message) => {
3374            crate::slog_info!(
3375                "settle bench: tier2_category_end category={} job_id={} status=failed total_ms={} error={}",
3376                result.category.as_str(),
3377                result.job_id,
3378                result.duration.as_millis(),
3379                message.replace('\n', " ")
3380            );
3381        }
3382    }
3383}
3384
3385fn build_tier2_callgraph_snapshot(
3386    job: &InspectJob,
3387    allow_cold_build: bool,
3388) -> Option<Arc<CallgraphSnapshot>> {
3389    build_tier2_callgraph_snapshot_with_refresh_inner(job, allow_cold_build, false, &[], None)
3390        .map(|(snapshot, _, _)| snapshot)
3391}
3392
3393#[cfg(test)]
3394fn build_tier2_callgraph_snapshot_with_refresh(
3395    job: &InspectJob,
3396    allow_cold_build: bool,
3397    refresh_paths: &[PathBuf],
3398) -> Option<Arc<CallgraphSnapshot>> {
3399    build_tier2_callgraph_snapshot_with_refresh_inner(
3400        job,
3401        allow_cold_build,
3402        false,
3403        refresh_paths,
3404        None,
3405    )
3406    .map(|(snapshot, _, _)| snapshot)
3407}
3408
3409const BLOCKING_CALLGRAPH_STORE_RETRY_TIMEOUT: Duration = Duration::from_secs(30);
3410const BLOCKING_CALLGRAPH_STORE_RETRY_INTERVAL: Duration = Duration::from_millis(20);
3411
3412fn open_ready_for_blocking_inspect(
3413    callgraph_dir: &Path,
3414    project_root: &Path,
3415    wait_for_publication: bool,
3416) -> Result<Option<CallGraphStore>, CallGraphStoreError> {
3417    let deadline = Instant::now() + BLOCKING_CALLGRAPH_STORE_RETRY_TIMEOUT;
3418    loop {
3419        match CallGraphStore::open_ready_repairing(
3420            callgraph_dir.to_path_buf(),
3421            project_root.to_path_buf(),
3422        ) {
3423            Ok(None) if wait_for_publication && Instant::now() < deadline => {}
3424            Err(error) if error.is_transient_lock_contention() && Instant::now() < deadline => {}
3425            result => return result,
3426        }
3427        std::thread::sleep(BLOCKING_CALLGRAPH_STORE_RETRY_INTERVAL);
3428    }
3429}
3430
3431fn open_or_build_blocking_callgraph_store(
3432    callgraph_dir: PathBuf,
3433    project_root: PathBuf,
3434    allow_cold_build: bool,
3435    refresh_paths: &[PathBuf],
3436) -> Result<Option<CallGraphStore>, CallGraphStoreError> {
3437    if let Some(store) = open_ready_for_blocking_inspect(&callgraph_dir, &project_root, false)? {
3438        return Ok(Some(store));
3439    }
3440    if !allow_cold_build || refresh_paths.is_empty() {
3441        return Ok(None);
3442    }
3443
3444    match CallGraphStore::cold_build_with_lease(
3445        callgraph_dir.clone(),
3446        project_root.clone(),
3447        refresh_paths,
3448    ) {
3449        Ok((store, _)) => Ok(Some(store)),
3450        Err(error)
3451            if matches!(error, CallGraphStoreError::Unavailable(_))
3452                || error.is_transient_lock_contention() =>
3453        {
3454            // The background builder and an inspect-triggered cold build can
3455            // briefly meet on the same generation. Keep either lock loser on
3456            // the Building/retry path instead of terminally failing inspect.
3457            match open_ready_for_blocking_inspect(&callgraph_dir, &project_root, true)? {
3458                Some(store) => Ok(Some(store)),
3459                None => Err(error),
3460            }
3461        }
3462        Err(error) => Err(error),
3463    }
3464}
3465
3466fn merge_callgraph_refresh_paths(
3467    project_root: &Path,
3468    refresh_paths: &[PathBuf],
3469    stale: impl IntoIterator<Item = String>,
3470) -> Vec<PathBuf> {
3471    let mut paths = refresh_paths.to_vec();
3472    for rel in stale {
3473        let absolute = project_root.join(rel);
3474        if !paths.iter().any(|path| path == &absolute) {
3475            paths.push(absolute);
3476        }
3477    }
3478    paths
3479}
3480
3481fn refresh_writable_dead_code_store(
3482    store: &CallGraphStore,
3483    callgraph_dir: &Path,
3484    refresh_paths: &[PathBuf],
3485) {
3486    match store.refresh_files(refresh_paths) {
3487        Ok(stats) => {
3488            crate::slog_info!(
3489                "tier2 dead_code: refreshed callgraph store at {} for {} watcher path(s): changed={} deleted={} refreshed_own={}",
3490                callgraph_dir.display(),
3491                refresh_paths.len(),
3492                stats.changed_files.len(),
3493                stats.deleted_files.len(),
3494                stats.refreshed_own_files
3495            );
3496        }
3497        Err(error) => {
3498            crate::slog_warn!(
3499                "tier2 dead_code: failed to refresh callgraph store at {} before projection: {}",
3500                callgraph_dir.display(),
3501                error
3502            );
3503            if let Err(mark_error) = store.mark_files_stale(refresh_paths) {
3504                crate::slog_warn!(
3505                    "tier2 dead_code: failed to mark callgraph store files stale at {} after refresh failure: {}",
3506                    callgraph_dir.display(),
3507                    mark_error
3508                );
3509            }
3510        }
3511    }
3512}
3513
3514fn callgraph_path_identity_gap(job: &InspectJob) -> Option<String> {
3515    for callgraph_dir in callgraph_store_dirs_from_inspect_dir(&job.inspect_dir, &job.project_root)
3516    {
3517        let Ok(Some(store)) =
3518            CallGraphStore::open_readonly(callgraph_dir, job.project_root.clone())
3519        else {
3520            continue;
3521        };
3522        let Err(CallGraphStoreError::Unavailable(reason)) =
3523            project_dead_code_snapshot_with_revision(store.sqlite_path())
3524        else {
3525            continue;
3526        };
3527        if reason.starts_with("callgraph_path_identity_mismatch ") {
3528            return Some(reason);
3529        }
3530    }
3531    None
3532}
3533
3534fn open_writable_dead_code_store(
3535    callgraph_dir: PathBuf,
3536    project_root: PathBuf,
3537    allow_cold_build: bool,
3538    build_if_missing: bool,
3539    refresh_paths: &[PathBuf],
3540) -> Result<Option<CallGraphStore>, CallGraphStoreError> {
3541    if build_if_missing {
3542        open_or_build_blocking_callgraph_store(
3543            callgraph_dir,
3544            project_root,
3545            allow_cold_build,
3546            refresh_paths,
3547        )
3548    } else if allow_cold_build {
3549        CallGraphStore::open_ready_repairing(callgraph_dir, project_root)
3550    } else {
3551        CallGraphStore::open_ready_no_rebuild(callgraph_dir, project_root)
3552    }
3553}
3554
3555fn build_tier2_callgraph_snapshot_with_refresh_inner(
3556    job: &InspectJob,
3557    allow_cold_build: bool,
3558    build_if_missing: bool,
3559    refresh_paths: &[PathBuf],
3560    projection_cache: Option<&InspectManager>,
3561) -> Option<(Arc<CallgraphSnapshot>, ProjectionVerdict, Duration)> {
3562    let started = Instant::now();
3563    if !job.config.callgraph_store {
3564        crate::slog_info!(
3565            "tier2 dead_code: callgraph store disabled; reporting callgraph_unavailable"
3566        );
3567        return None;
3568    }
3569
3570    let callgraph_dirs = callgraph_store_dirs_from_inspect_dir(&job.inspect_dir, &job.project_root);
3571    if callgraph_dirs.is_empty() {
3572        crate::slog_info!(
3573            "tier2 dead_code: inspect_dir has no root-keyed storage parent ({}); reporting callgraph_unavailable",
3574            job.inspect_dir.display()
3575        );
3576        return None;
3577    };
3578    for callgraph_dir in &callgraph_dirs {
3579        match CallGraphStore::cold_build_suspension(callgraph_dir, &job.project_root) {
3580            Ok(Some(suspension)) => {
3581                // This is a durable admission refusal, not a failed scan attempt.
3582                // Preserve it separately so blocking inspect reports the same
3583                // breaker tuple that navigation and health expose.
3584                if let Some(manager) = projection_cache {
3585                    manager.record_tier2_build_suspension(&job.key, suspension.clone());
3586                }
3587                crate::slog_info!(
3588                    "tier2 dead_code: callgraph build suspended for {} after {} deaths",
3589                    suspension.domain.as_str(),
3590                    suspension.death_count
3591                );
3592                return None;
3593            }
3594            Ok(None) => {}
3595            Err(error) => {
3596                crate::slog_warn!(
3597                    "tier2 dead_code: failed to read callgraph breaker at {}: {}",
3598                    callgraph_dir.display(),
3599                    error
3600                );
3601            }
3602        }
3603    }
3604
3605    enum ProjectionStore {
3606        ReadOnly(ReadonlyCallGraphStore),
3607        Writable(CallGraphStore),
3608    }
3609
3610    impl ProjectionStore {
3611        fn sqlite_path(&self) -> &Path {
3612            match self {
3613                Self::ReadOnly(store) => store.sqlite_path(),
3614                Self::Writable(store) => store.sqlite_path(),
3615            }
3616        }
3617
3618        fn projection_identity(
3619            &self,
3620            project_root: &Path,
3621            write_revision: u64,
3622        ) -> CallgraphProjectionIdentity {
3623            let generation = match self {
3624                Self::ReadOnly(store) => store.projection_generation(),
3625                Self::Writable(store) => store.projection_generation(),
3626            }
3627            .map(str::to_owned);
3628            let legacy_sqlite_path = generation
3629                .is_none()
3630                .then(|| self.sqlite_path().to_path_buf());
3631            CallgraphProjectionIdentity {
3632                project_root: project_root.to_path_buf(),
3633                generation,
3634                legacy_sqlite_path,
3635                write_revision,
3636            }
3637        }
3638
3639        fn current_projection_identity(
3640            &self,
3641            project_root: &Path,
3642        ) -> Result<Option<CallgraphProjectionIdentity>, CallGraphStoreError> {
3643            let stale_files = match self {
3644                Self::ReadOnly(store) => store.stale_files()?,
3645                Self::Writable(store) => store.stale_files()?,
3646            };
3647            if !stale_files.is_empty() {
3648                return Ok(None);
3649            }
3650            let write_revision = match self {
3651                Self::ReadOnly(store) => store.projection_write_revision()?,
3652                Self::Writable(store) => store.projection_write_revision()?,
3653            };
3654            Ok(write_revision.map(|revision| self.projection_identity(project_root, revision)))
3655        }
3656    }
3657
3658    for (index, callgraph_dir) in callgraph_dirs.iter().enumerate() {
3659        // Paths without an explicit refresh stay read-only unless the published
3660        // store still has stale backend rows. The background refresh worker is
3661        // the usual writer for those rows; when it never runs for this root,
3662        // dead_code refreshes them inline so projection is not stuck forever.
3663        let projection_store = if refresh_paths.is_empty() || !job.callgraph_writer {
3664            let store = match CallGraphStore::open_readonly(
3665                callgraph_dir.clone(),
3666                job.project_root.clone(),
3667            ) {
3668                Ok(Some(store)) => store,
3669                Ok(None) => {
3670                    crate::slog_info!(
3671                        "tier2 dead_code: callgraph store unavailable at {} (cold/building/not ready); trying fallback={}",
3672                        callgraph_dir.display(),
3673                        index + 1 < callgraph_dirs.len()
3674                    );
3675                    continue;
3676                }
3677                Err(error) => {
3678                    crate::slog_warn!(
3679                        "tier2 dead_code: failed to open callgraph store read-only at {}: {}; trying fallback={}",
3680                        callgraph_dir.display(),
3681                        error,
3682                        index + 1 < callgraph_dirs.len()
3683                    );
3684                    continue;
3685                }
3686            };
3687            let stale = job
3688                .callgraph_writer
3689                .then(|| store.stale_files().ok())
3690                .flatten()
3691                .unwrap_or_default();
3692            if stale.is_empty() {
3693                ProjectionStore::ReadOnly(store)
3694            } else {
3695                drop(store);
3696                let refresh =
3697                    merge_callgraph_refresh_paths(&job.project_root, refresh_paths, stale);
3698                let store = match open_writable_dead_code_store(
3699                    callgraph_dir.clone(),
3700                    job.project_root.clone(),
3701                    allow_cold_build,
3702                    build_if_missing,
3703                    &refresh,
3704                ) {
3705                    Ok(Some(store)) => store,
3706                    Ok(None) => {
3707                        crate::slog_info!(
3708                            "tier2 dead_code: callgraph store unavailable at {} (cold/building/not ready); trying fallback={}",
3709                            callgraph_dir.display(),
3710                            index + 1 < callgraph_dirs.len()
3711                        );
3712                        continue;
3713                    }
3714                    Err(error) => {
3715                        crate::slog_warn!(
3716                            "tier2 dead_code: failed to open callgraph writer at {}: {}; trying fallback={}",
3717                            callgraph_dir.display(),
3718                            error,
3719                            index + 1 < callgraph_dirs.len()
3720                        );
3721                        continue;
3722                    }
3723                };
3724                refresh_writable_dead_code_store(&store, callgraph_dir, &refresh);
3725                ProjectionStore::Writable(store)
3726            }
3727        } else {
3728            let store = match open_writable_dead_code_store(
3729                callgraph_dir.clone(),
3730                job.project_root.clone(),
3731                allow_cold_build,
3732                build_if_missing,
3733                refresh_paths,
3734            ) {
3735                Ok(Some(store)) => store,
3736                Ok(None) => {
3737                    crate::slog_info!(
3738                        "tier2 dead_code: callgraph store unavailable at {} (cold/building/not ready); trying fallback={}",
3739                        callgraph_dir.display(),
3740                        index + 1 < callgraph_dirs.len()
3741                    );
3742                    continue;
3743                }
3744                Err(error) => {
3745                    crate::slog_warn!(
3746                        "tier2 dead_code: failed to open callgraph writer at {}: {}; trying fallback={}",
3747                        callgraph_dir.display(),
3748                        error,
3749                        index + 1 < callgraph_dirs.len()
3750                    );
3751                    continue;
3752                }
3753            };
3754            let stale = store.stale_files().unwrap_or_default();
3755            let refresh = merge_callgraph_refresh_paths(&job.project_root, refresh_paths, stale);
3756            refresh_writable_dead_code_store(&store, callgraph_dir, &refresh);
3757            ProjectionStore::Writable(store)
3758        };
3759
3760        let cache_identity = match projection_store.current_projection_identity(&job.project_root) {
3761            Ok(identity) => identity,
3762            Err(error) => {
3763                crate::slog_warn!(
3764                    "tier2 dead_code: failed to read callgraph projection identity at {}: {}; trying fallback={}",
3765                    callgraph_dir.display(),
3766                    error,
3767                    index + 1 < callgraph_dirs.len()
3768                );
3769                continue;
3770            }
3771        };
3772        if let (Some(cache), Some(identity)) = (projection_cache, cache_identity.as_ref()) {
3773            // The pointer names immutable cold-build generations, while the durable
3774            // revision advances in the same SQLite transaction as every in-place
3775            // graph mutation. Equal identities therefore prove identical store
3776            // bytes for dead-code projection: this cache is exact, not heuristic.
3777            if let Some(snapshot) = cache.cached_callgraph_projection(identity) {
3778                return Some((
3779                    snapshot,
3780                    ProjectionVerdict {
3781                        kind: ProjectionKind::Reused,
3782                        reason: None,
3783                        journal_bytes: 0,
3784                        changed_files: 0,
3785                    },
3786                    Duration::ZERO,
3787                ));
3788            }
3789        } else if cache_identity.is_none() {
3790            // Stores from older binaries lack a durable revision, so keeping an
3791            // earlier snapshot would make an in-place refresh indistinguishable.
3792            if let Some(cache) = projection_cache {
3793                cache.clear_callgraph_projection();
3794            }
3795        }
3796
3797        let previous = projection_cache
3798            .zip(cache_identity.as_ref())
3799            .and_then(|(cache, identity)| cache.previous_callgraph_projection(identity));
3800        let costs = projection_cache
3801            .zip(cache_identity.as_ref())
3802            .map(|(cache, identity)| cache.callgraph_projection_costs(identity))
3803            .unwrap_or_default();
3804        let (write_revision, snapshot, verdict, projection_cost) =
3805            match project_dead_code_snapshot_incremental_with_costs(
3806                projection_store.sqlite_path(),
3807                previous
3808                    .as_ref()
3809                    .map(|(revision, snapshot)| (*revision, snapshot.as_ref())),
3810                costs,
3811            ) {
3812                Ok(projected) => projected,
3813                Err(CallGraphStoreError::Unavailable(message)) => {
3814                    crate::slog_info!(
3815                        "tier2 dead_code: callgraph store projection unavailable at {} ({}); trying fallback={}",
3816                        callgraph_dir.display(),
3817                        message,
3818                        index + 1 < callgraph_dirs.len()
3819                    );
3820                    continue;
3821                }
3822                Err(error) => {
3823                    crate::slog_warn!(
3824                        "tier2 dead_code: callgraph store projection failed at {}: {}; trying fallback={}",
3825                        callgraph_dir.display(),
3826                        error,
3827                        index + 1 < callgraph_dirs.len()
3828                    );
3829                    continue;
3830                }
3831            };
3832        let snapshot = Arc::new(snapshot);
3833        if let (Some(cache), Some(write_revision)) = (projection_cache, write_revision) {
3834            cache.cache_callgraph_projection(
3835                projection_store.projection_identity(&job.project_root, write_revision),
3836                Arc::clone(&snapshot),
3837            );
3838        }
3839
3840        if index > 0 {
3841            crate::slog_info!(
3842                "tier2 dead_code: using ready callgraph store fallback {} for inspect_dir {}",
3843                callgraph_dir.display(),
3844                job.inspect_dir.display()
3845            );
3846        }
3847
3848        crate::slog_info!(
3849            "perf tier2_callgraph_snapshot: source=callgraph_store files={} exports={} edges={} entry_points={} ms={}",
3850            snapshot.files.len(),
3851            snapshot.exported_symbols.len(),
3852            snapshot.outbound_calls.len(),
3853            snapshot.entry_points.len(),
3854            started.elapsed().as_millis()
3855        );
3856
3857        return Some((snapshot, verdict, projection_cost));
3858    }
3859
3860    crate::slog_info!(
3861        "tier2 dead_code: no ready callgraph store found for inspect_dir {}; reporting callgraph_unavailable",
3862        job.inspect_dir.display()
3863    );
3864    None
3865}
3866
3867fn estimate_callgraph_snapshot_bytes(snapshot: &CallgraphSnapshot) -> u64 {
3868    let files = snapshot.files.iter().fold(0u64, |bytes, path| {
3869        bytes
3870            .saturating_add(std::mem::size_of::<PathBuf>() as u64)
3871            .saturating_add(crate::memory::path_bytes(path))
3872    });
3873    let exports = snapshot
3874        .exported_symbols
3875        .iter()
3876        .fold(0u64, |bytes, export| {
3877            bytes
3878                .saturating_add(std::mem::size_of::<super::job::CallgraphExport>() as u64)
3879                .saturating_add(crate::memory::path_bytes(&export.file))
3880                .saturating_add(crate::memory::usize_to_u64(export.symbol.len()))
3881                .saturating_add(crate::memory::usize_to_u64(export.kind.len()))
3882        });
3883    let calls = snapshot.outbound_calls.iter().fold(0u64, |bytes, call| {
3884        bytes
3885            .saturating_add(std::mem::size_of::<super::job::CallgraphOutboundCall>() as u64)
3886            .saturating_add(crate::memory::path_bytes(&call.caller_file))
3887            .saturating_add(crate::memory::usize_to_u64(call.caller_symbol.len()))
3888            .saturating_add(crate::memory::usize_to_u64(call.target.len()))
3889            .saturating_add(crate::memory::usize_to_u64(call.provenance.len()))
3890    });
3891    let entry_points = snapshot.entry_points.iter().fold(0u64, |bytes, path| {
3892        bytes
3893            .saturating_add(std::mem::size_of::<PathBuf>() as u64)
3894            .saturating_add(crate::memory::path_bytes(path))
3895    });
3896    let entry_point_symbols =
3897        snapshot
3898            .entry_point_symbols
3899            .iter()
3900            .fold(0u64, |bytes, (path, symbols)| {
3901                let symbols_bytes = symbols.iter().fold(0u64, |bytes, symbol| {
3902                    bytes
3903                        .saturating_add(std::mem::size_of::<String>() as u64)
3904                        .saturating_add(crate::memory::usize_to_u64(symbol.len()))
3905                });
3906                bytes
3907                    .saturating_add(std::mem::size_of::<(PathBuf, BTreeSet<String>)>() as u64)
3908                    .saturating_add(crate::memory::path_bytes(path))
3909                    .saturating_add(symbols_bytes)
3910            });
3911    (std::mem::size_of::<CallgraphSnapshot>() as u64)
3912        .saturating_add(files)
3913        .saturating_add(exports)
3914        .saturating_add(calls)
3915        .saturating_add(entry_points)
3916        .saturating_add(entry_point_symbols)
3917}
3918
3919fn callgraph_store_dir_from_inspect_dir(
3920    inspect_dir: &Path,
3921    project_root: &Path,
3922) -> Option<PathBuf> {
3923    let scope_key = crate::path_identity::project_scope_key(project_root);
3924    let storage_dir = if inspect_dir
3925        .file_name()
3926        .and_then(|name| name.to_str())
3927        .is_some_and(|name| name == scope_key)
3928    {
3929        inspect_dir.parent()?.parent()?
3930    } else {
3931        inspect_dir.parent()?
3932    };
3933    let project_key = crate::search_index::artifact_cache_key(project_root);
3934    Some(storage_dir.join("callgraph").join(project_key))
3935}
3936
3937fn callgraph_store_dirs_from_inspect_dir(inspect_dir: &Path, project_root: &Path) -> Vec<PathBuf> {
3938    callgraph_store_dir_from_inspect_dir(inspect_dir, project_root)
3939        .into_iter()
3940        .collect()
3941}
3942
3943#[cfg(test)]
3944fn canonicalize_for_snapshot(path: &Path) -> PathBuf {
3945    // Mirrors the projection's normalizer: snapshot paths are
3946    // verbatim-stripped, so test expectations must be too.
3947    crate::inspect::job::canonicalize_normalized(path)
3948}
3949
3950fn load_contribution_freshness(
3951    cache: &(impl InspectCacheRead + ?Sized),
3952    category: InspectCategory,
3953) -> Result<Vec<CachedContributionFreshness>, String> {
3954    cache
3955        .contribution_freshness(category)
3956        .map_err(|error| error.to_string())
3957        .map(|records| {
3958            records
3959                .into_iter()
3960                .map(|(file_path, freshness)| CachedContributionFreshness {
3961                    file_path,
3962                    freshness,
3963                })
3964                .collect()
3965        })
3966}
3967
3968fn freshness_record_relative_key(record: &CachedContributionFreshness) -> String {
3969    record.file_path.to_string_lossy().to_string()
3970}
3971
3972fn relative_cache_key(project_root: &Path, path: &Path) -> String {
3973    path.strip_prefix(project_root)
3974        .unwrap_or(path)
3975        .to_string_lossy()
3976        .to_string()
3977}
3978
3979fn load_contributions(
3980    cache: &(impl InspectCacheRead + ?Sized),
3981    job: &InspectJob,
3982) -> Result<Vec<FileContribution>, String> {
3983    cache
3984        .load_tier2_contributions(job.category)
3985        .map_err(|error| error.to_string())
3986        .map(|records| {
3987            records
3988                .into_iter()
3989                .map(|record| contribution_from_record(&job.project_root, record))
3990                .collect()
3991        })
3992}
3993
3994fn dead_code_contributions_need_fact_refresh(
3995    cache: &(impl InspectCacheRead + ?Sized),
3996    job: &InspectJob,
3997) -> Result<bool, String> {
3998    let contributions = load_contributions(cache, job)?;
3999    Ok(contributions
4000        .iter()
4001        .any(dead_code_contribution_needs_fact_refresh))
4002}
4003
4004fn dead_code_contribution_needs_fact_refresh(contribution: &FileContribution) -> bool {
4005    let Ok(parsed) =
4006        serde_json::from_value::<DeadCodeRefreshContribution>(contribution.contribution.clone())
4007    else {
4008        return true;
4009    };
4010
4011    if parsed.facts_format_version
4012        != Some(super::scanners::dead_code::DEAD_CODE_FACTS_FORMAT_VERSION)
4013    {
4014        return true;
4015    }
4016
4017    matches!(
4018        parsed.oxc_facts,
4019        Some(facts) if facts.format_version != FACTS_FORMAT_VERSION
4020    )
4021}
4022
4023fn unused_exports_contributions_need_fact_refresh(
4024    cache: &(impl InspectCacheRead + ?Sized),
4025    job: &InspectJob,
4026) -> Result<bool, String> {
4027    let contributions = load_contributions(cache, job)?;
4028    Ok(contributions
4029        .iter()
4030        .any(unused_exports_contribution_needs_fact_refresh))
4031}
4032
4033/// Duplicates contributions written before v0.44 lack the `line_count` field
4034/// (serde defaults it to 0), so a cached roll-up computes total_analyzed_lines
4035/// as 0 and the summary renders "0.0% of 0 analyzed lines". One full rescan
4036/// repopulates the counts; fresh contributions always carry line_count.
4037fn duplicates_contributions_need_fact_refresh(
4038    cache: &(impl InspectCacheRead + ?Sized),
4039    job: &InspectJob,
4040) -> Result<bool, String> {
4041    let contributions = load_contributions(cache, job)?;
4042    Ok(contributions
4043        .iter()
4044        .any(|contribution| contribution.contribution.get("line_count").is_none()))
4045}
4046
4047fn unused_exports_contribution_needs_fact_refresh(contribution: &FileContribution) -> bool {
4048    let top_level_oxc = contribution
4049        .contribution
4050        .get("provenance")
4051        .and_then(Value::as_str)
4052        == Some(OXC_PROVENANCE);
4053    let Ok(parsed) =
4054        serde_json::from_value::<UnusedExportsContribution>(contribution.contribution.clone())
4055    else {
4056        return false;
4057    };
4058    let uses_oxc =
4059        top_level_oxc || parsed.oxc_facts.is_some() || parsed.exports.iter().any(export_uses_oxc);
4060    if !uses_oxc {
4061        return false;
4062    }
4063
4064    !matches!(
4065        parsed.oxc_facts,
4066        Some(facts) if facts.format_version == FACTS_FORMAT_VERSION
4067    )
4068}
4069
4070fn contribution_from_record(
4071    project_root: &Path,
4072    record: super::cache::ContributionRecord,
4073) -> FileContribution {
4074    FileContribution::new(
4075        record.category,
4076        project_root.join(record.file_path),
4077        record.freshness,
4078        record.contribution,
4079    )
4080    .with_type_ref_names(record.type_ref_names)
4081}
4082
4083fn run_tier2_scan(job: &InspectJob, oxc_result: Option<&OxcEngineResult>) -> InspectResult {
4084    use super::scanners;
4085
4086    match job.category {
4087        InspectCategory::DeadCode => {
4088            scanners::dead_code::run_dead_code_scan_with_oxc(job, oxc_result)
4089        }
4090        InspectCategory::UnusedExports => {
4091            scanners::unused_exports::run_unused_exports_scan_with_oxc(job, oxc_result)
4092        }
4093        InspectCategory::Duplicates => scanners::duplicates::run_duplicates_scan(job),
4094        InspectCategory::Cycles => scanners::cycles::run_cycles_scan_with_oxc(job, oxc_result),
4095        InspectCategory::Complexity => scanners::complexity::run_complexity_scan(job),
4096        other => InspectResult::failed(
4097            job,
4098            format!("inspect category '{other}' is not an active Tier 2 scanner"),
4099            Duration::from_secs(0),
4100        ),
4101    }
4102}
4103
4104fn roll_up_tier2_contributions(job: &InspectJob, contributions: &[FileContribution]) -> Value {
4105    roll_up_tier2_contributions_with_limit(job, contributions, Some(MAX_DRILL_DOWN_ITEMS))
4106}
4107
4108fn roll_up_tier2_contributions_with_limit(
4109    job: &InspectJob,
4110    contributions: &[FileContribution],
4111    drill_down_limit: Option<usize>,
4112) -> Value {
4113    match job.category {
4114        InspectCategory::DeadCode => {
4115            roll_up_dead_code_contributions(job, contributions, drill_down_limit)
4116        }
4117        InspectCategory::UnusedExports => {
4118            roll_up_unused_exports_contributions(job, contributions, drill_down_limit)
4119        }
4120        InspectCategory::Duplicates => {
4121            roll_up_duplicate_contributions(job, contributions, drill_down_limit)
4122        }
4123        InspectCategory::Cycles => {
4124            roll_up_cycle_contributions(job, contributions, drill_down_limit)
4125        }
4126        InspectCategory::Complexity => {
4127            roll_up_complexity_contributions(job, contributions, drill_down_limit)
4128        }
4129        _ => json!({
4130            "count": 0,
4131            "items": [],
4132            "scanned_files": contributions.len(),
4133        }),
4134    }
4135}
4136
4137fn scoped_tier2_payload_from_contributions(
4138    snapshot: &InspectSnapshot,
4139    category: InspectCategory,
4140    cache: &(impl InspectCacheRead + ?Sized),
4141    project_payload: Value,
4142    scope: &JobScope,
4143) -> Result<Value, String> {
4144    if scope.is_project_wide() {
4145        return Ok(project_payload);
4146    }
4147
4148    let project_scope = JobScope::for_project(snapshot.project_root.clone());
4149    let rollup_job = scoped_tier2_rollup_job(snapshot, category, &project_scope);
4150    let contributions = load_contributions(cache, &rollup_job)?;
4151    let full_payload = roll_up_tier2_contributions_with_limit(&rollup_job, &contributions, None);
4152    let scoped_payload = filter_payload_for_scope(full_payload, scope);
4153    Ok(cap_payload_drill_down(scoped_payload, MAX_DRILL_DOWN_ITEMS))
4154}
4155
4156fn scoped_tier2_rollup_job(
4157    snapshot: &InspectSnapshot,
4158    category: InspectCategory,
4159    scope: &JobScope,
4160) -> InspectJob {
4161    let mut job = InspectJob {
4162        job_id: 0,
4163        key: JobKey::for_project_category(category),
4164        category,
4165        scope_files: scope_files(&snapshot.project_root, scope),
4166        project_root: snapshot.project_root.clone(),
4167        inspect_dir: snapshot.inspect_dir.clone(),
4168        config: Arc::clone(&snapshot.config),
4169        symbol_cache: Arc::clone(&snapshot.symbol_cache),
4170        inspect_writer: snapshot.inspect_writer,
4171        callgraph_writer: snapshot.callgraph_writer,
4172        callgraph_snapshot: None,
4173    };
4174
4175    if category == InspectCategory::DeadCode {
4176        // Scoped read-path rollups recompute dead-code liveness from cached
4177        // contributions. Use a real ready store snapshot when one exists; if no
4178        // snapshot is available, leave it absent so the rollup reports degraded
4179        // callgraph_unavailable instead of treating an empty graph as truth.
4180        job.callgraph_snapshot = build_tier2_callgraph_snapshot(&job, false);
4181    }
4182
4183    job
4184}
4185
4186fn roll_up_dead_code_contributions(
4187    job: &InspectJob,
4188    contributions: &[FileContribution],
4189    drill_down_limit: Option<usize>,
4190) -> Value {
4191    let Some(snapshot) = job.callgraph_snapshot.as_deref() else {
4192        return super::scanners::dead_code::callgraph_unavailable_aggregate(job.scope_files.len());
4193    };
4194
4195    let public_api_files = super::scanners::dead_code::collect_public_api_files(&job.project_root);
4196    let roles = super::entry_points::resolve_project_roles(&job.project_root);
4197    super::scanners::dead_code::aggregate_dead_code_contributions_with_snapshot(
4198        &job.project_root,
4199        snapshot,
4200        contributions,
4201        &public_api_files,
4202        &roles,
4203        drill_down_limit,
4204    )
4205}
4206
4207fn roll_up_unused_exports_contributions(
4208    job: &InspectJob,
4209    contributions: &[FileContribution],
4210    drill_down_limit: Option<usize>,
4211) -> Value {
4212    let parsed = contributions
4213        .iter()
4214        .filter_map(|contribution| {
4215            serde_json::from_value::<UnusedExportsContribution>(contribution.contribution.clone())
4216                .ok()
4217        })
4218        .collect::<Vec<_>>();
4219
4220    if parsed.iter().any(|scan| scan.oxc_facts.is_some()) {
4221        return roll_up_unused_exports_oxc_contributions(job, &parsed, drill_down_limit);
4222    }
4223
4224    let (public_api_files, package_warnings) = unused_public_api_entries(&job.project_root);
4225    let mut imported_by: BTreeMap<(String, String), BTreeSet<String>> = BTreeMap::new();
4226    let mut uncertain_by: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
4227    for scan in &parsed {
4228        for import in &scan.imports {
4229            let Some(resolved_file) = &import.resolved_file else {
4230                continue;
4231            };
4232            for name in &import.named {
4233                if name == "*" {
4234                    uncertain_by
4235                        .entry(resolved_file.clone())
4236                        .or_default()
4237                        .insert(scan.file.clone());
4238                } else {
4239                    imported_by
4240                        .entry((resolved_file.clone(), name.clone()))
4241                        .or_default()
4242                        .insert(scan.file.clone());
4243                }
4244            }
4245        }
4246    }
4247
4248    let mut count = 0usize;
4249    let mut items = Vec::new();
4250    let mut generated_count = 0usize;
4251    let mut generated_items = Vec::new();
4252    let test_only_count = 0usize;
4253    let test_only_items = Vec::new();
4254    let mut uncertain_count = 0usize;
4255    let mut uncertain_items = Vec::new();
4256    for scan in &parsed {
4257        if public_api_files.contains(&scan.file) {
4258            continue;
4259        }
4260        // Mirror the fresh-scan path: fixtures/corpora/mock data are consumed
4261        // by path, never imported, so their exports always look unused.
4262        if super::job::is_test_support_file(&scan.file) {
4263            continue;
4264        }
4265        let generated_file = super::generated::is_generated_file_with_cached_hint(
4266            &job.project_root,
4267            &scan.file,
4268            scan.generated,
4269        );
4270
4271        for export in &scan.exports {
4272            if export_uses_oxc(export) {
4273                match export.verdict.unwrap_or(LivenessVerdict::Unused) {
4274                    LivenessVerdict::Used => continue,
4275                    LivenessVerdict::Uncertain => {
4276                        uncertain_count += 1;
4277                        if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
4278                            uncertain_items.push(json!({
4279                                "file": scan.file,
4280                                "symbol": export.symbol,
4281                                "kind": export.kind,
4282                                "line": export.line,
4283                                "reason": export.reason.as_deref().unwrap_or("oxc_uncertain"),
4284                                "provenance": export.provenance.as_deref().unwrap_or(OXC_PROVENANCE),
4285                            }));
4286                        }
4287                        continue;
4288                    }
4289                    LivenessVerdict::Unused => {}
4290                }
4291            } else {
4292                let imported = imported_by
4293                    .get(&(scan.file.clone(), export.symbol.clone()))
4294                    .map(|files| !files.is_empty())
4295                    .unwrap_or(false);
4296                let uncertain = uncertain_by
4297                    .get(&scan.file)
4298                    .map(|files| !files.is_empty())
4299                    .unwrap_or(false);
4300
4301                if imported {
4302                    continue;
4303                }
4304                if uncertain {
4305                    uncertain_count += 1;
4306                    if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
4307                        uncertain_items.push(json!({
4308                            "file": scan.file,
4309                            "symbol": export.symbol,
4310                            "kind": export.kind,
4311                            "line": export.line,
4312                            "reason": "wildcard_import",
4313                        }));
4314                    }
4315                    continue;
4316                }
4317            }
4318
4319            let mut item = json!({
4320                "file": scan.file,
4321                "symbol": export.symbol,
4322                "kind": export.kind,
4323                "line": export.line,
4324            });
4325            if let Some(provenance) = &export.provenance {
4326                item["provenance"] = json!(provenance);
4327            }
4328            if generated_file {
4329                item["generated"] = json!(true);
4330                generated_count += 1;
4331                generated_items.push(item);
4332            } else {
4333                count += 1;
4334                items.push(item);
4335            }
4336        }
4337    }
4338
4339    let roles = super::entry_points::resolve_project_roles(&job.project_root);
4340    let items = super::entry_points::rank_and_truncate_items(items, &roles, drill_down_limit);
4341    let generated_items =
4342        super::entry_points::rank_and_truncate_items(generated_items, &roles, drill_down_limit);
4343    let top = super::entry_points::top_preview_symbols(&items);
4344    let generated_top = generated_items
4345        .iter()
4346        .take(super::entry_points::TOP_PREVIEW_ITEMS)
4347        .cloned()
4348        .collect::<Vec<_>>();
4349    let mut all_items = items;
4350    all_items.extend(generated_items.iter().cloned());
4351    if let Some(limit) = drill_down_limit {
4352        all_items.truncate(limit);
4353    }
4354    let test_only_items =
4355        super::entry_points::rank_and_truncate_items(test_only_items, &roles, drill_down_limit);
4356    let test_only_top = test_only_items
4357        .iter()
4358        .take(super::entry_points::TOP_PREVIEW_ITEMS)
4359        .cloned()
4360        .collect::<Vec<_>>();
4361
4362    let (parse_errors, skipped_files) = unused_exports_honesty_fields(&parsed);
4363    let mut aggregate = json!({
4364        "count": count,
4365        "generated_count": generated_count,
4366        "total_count": count + test_only_count + generated_count,
4367        "items": all_items,
4368        "top": top,
4369        "generated_items": generated_items,
4370        "generated_top": generated_top,
4371        "test_only_count": test_only_count,
4372        "test_only_items": test_only_items,
4373        "test_only_top": test_only_top,
4374        "drill_down_capped": drill_down_limit.is_some_and(|limit| count + generated_count > limit),
4375        "generated_drill_down_capped": drill_down_limit.is_some_and(|limit| generated_count > limit),
4376        "test_only_drill_down_capped": drill_down_limit.is_some_and(|limit| test_only_count > limit),
4377        "scanned_files": parsed.len(),
4378        "languages_skipped": skipped_languages(&job.scope_files, LanguageSkipMode::UnusedExports),
4379        "uncertain_count": uncertain_count,
4380        "uncertain_items": uncertain_items,
4381        "complete": parse_errors.is_empty() && skipped_files.is_empty(),
4382    });
4383    if !parse_errors.is_empty() {
4384        aggregate["parse_errors"] = Value::Array(parse_errors);
4385    }
4386    if !skipped_files.is_empty() {
4387        aggregate["skipped_files"] = Value::Array(skipped_files);
4388    }
4389    if !package_warnings.is_empty() {
4390        aggregate["note"] = Value::String(package_warnings.join("; "));
4391    }
4392    aggregate
4393}
4394
4395fn roll_up_unused_exports_oxc_contributions(
4396    job: &InspectJob,
4397    parsed: &[UnusedExportsContribution],
4398    drill_down_limit: Option<usize>,
4399) -> Value {
4400    let (public_api_files, package_warnings) = unused_public_api_entries(&job.project_root);
4401    let facts = parsed
4402        .iter()
4403        .filter_map(|scan| {
4404            let oxc_facts = scan.oxc_facts.as_ref()?;
4405            let path = job.project_root.join(&scan.file);
4406            Some(FileFacts {
4407                file_id: FileId(0),
4408                path: normalize_input_path(&job.project_root, &path),
4409                content_hash: oxc_facts.content_hash.clone(),
4410                exports: oxc_facts.exports.clone(),
4411                imports: oxc_facts.imports.clone(),
4412                re_exports: oxc_facts.re_exports.clone(),
4413                dynamic_imports: oxc_facts.dynamic_imports.clone(),
4414                same_file_value_references: oxc_facts.same_file_value_references.clone(),
4415                used_import_bindings: oxc_facts.used_import_bindings.clone(),
4416                type_referenced_import_bindings: oxc_facts.type_referenced_import_bindings.clone(),
4417                value_referenced_import_bindings: oxc_facts
4418                    .value_referenced_import_bindings
4419                    .clone(),
4420                parse_error: oxc_facts.parse_error.clone(),
4421            })
4422        })
4423        .collect::<Vec<_>>();
4424    let generated_by_file = parsed
4425        .iter()
4426        .map(|scan| {
4427            (
4428                scan.file.clone(),
4429                super::generated::is_generated_file_with_cached_hint(
4430                    &job.project_root,
4431                    &scan.file,
4432                    scan.generated,
4433                ),
4434            )
4435        })
4436        .collect::<BTreeMap<_, _>>();
4437    let entry_point_set = crate::inspect::entry_points::resolve_entry_points(&job.project_root);
4438    let oxc_result = analyze_file_facts(
4439        &job.project_root,
4440        facts,
4441        AnalyzeOptions {
4442            entry_points: Vec::new(),
4443            public_api_files: entry_point_set.public_api_files(),
4444            executable_root_exports: entry_point_set.executable_root_exports(),
4445            force_reparse_files: Vec::new(),
4446            entry_reachability: false,
4447        },
4448        Vec::new(),
4449    );
4450    let roles = super::entry_points::resolve_project_roles(&job.project_root);
4451
4452    let mut count = 0usize;
4453    let mut items = Vec::new();
4454    let mut generated_count = 0usize;
4455    let mut generated_items = Vec::new();
4456    let mut test_only_count = 0usize;
4457    let mut test_only_items = Vec::new();
4458    let mut uncertain_count = 0usize;
4459    let mut uncertain_items = Vec::new();
4460    for file in &oxc_result.files {
4461        if public_api_files.contains(&file.relative_file)
4462            || super::job::is_test_support_file(&file.relative_file)
4463        {
4464            continue;
4465        }
4466        let generated_file = generated_by_file
4467            .get(&file.relative_file)
4468            .copied()
4469            .unwrap_or_else(|| {
4470                super::generated::is_generated_file(
4471                    &job.project_root,
4472                    Path::new(&file.relative_file),
4473                )
4474            });
4475
4476        for export in &file.exports {
4477            match export.verdict {
4478                LivenessVerdict::Used => {
4479                    if !is_test_file(&file.relative_file)
4480                        && !export.test_only_reference_files.is_empty()
4481                    {
4482                        let mut item = json!({
4483                            "file": file.relative_file,
4484                            "symbol": export.symbol,
4485                            "kind": export.kind,
4486                            "line": export.line,
4487                            "provenance": export.provenance,
4488                            "used_by": export.test_only_reference_files,
4489                        });
4490                        add_oxc_reexport_contexts(&mut item, &export.also_reexported);
4491                        if generated_file {
4492                            item["generated"] = json!(true);
4493                            generated_count += 1;
4494                            generated_items.push(item);
4495                        } else {
4496                            test_only_count += 1;
4497                            test_only_items.push(item);
4498                        }
4499                    }
4500                }
4501                LivenessVerdict::Uncertain => {
4502                    uncertain_count += 1;
4503                    if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
4504                        let mut item = json!({
4505                            "file": file.relative_file,
4506                            "symbol": export.symbol,
4507                            "kind": export.kind,
4508                            "line": export.line,
4509                            "reason": export.reason,
4510                            "provenance": export.provenance,
4511                        });
4512                        add_oxc_reexport_contexts(&mut item, &export.also_reexported);
4513                        uncertain_items.push(item);
4514                    }
4515                }
4516                LivenessVerdict::Unused => {
4517                    if !is_test_file(&file.relative_file)
4518                        && !export.test_only_reference_files.is_empty()
4519                    {
4520                        let mut item = json!({
4521                            "file": file.relative_file,
4522                            "symbol": export.symbol,
4523                            "kind": export.kind,
4524                            "line": export.line,
4525                            "provenance": export.provenance,
4526                            "used_by": export.test_only_reference_files,
4527                        });
4528                        add_oxc_reexport_contexts(&mut item, &export.also_reexported);
4529                        if generated_file {
4530                            item["generated"] = json!(true);
4531                            generated_count += 1;
4532                            generated_items.push(item);
4533                        } else {
4534                            test_only_count += 1;
4535                            test_only_items.push(item);
4536                        }
4537                        continue;
4538                    }
4539                    if export.has_references {
4540                        continue;
4541                    }
4542                    let mut item = json!({
4543                        "file": file.relative_file,
4544                        "symbol": export.symbol,
4545                        "kind": export.kind,
4546                        "line": export.line,
4547                        "provenance": export.provenance,
4548                    });
4549                    add_oxc_reexport_contexts(&mut item, &export.also_reexported);
4550                    if generated_file {
4551                        item["generated"] = json!(true);
4552                        generated_count += 1;
4553                        generated_items.push(item);
4554                    } else {
4555                        count += 1;
4556                        items.push(item);
4557                    }
4558                }
4559            }
4560        }
4561    }
4562
4563    let items = super::entry_points::rank_and_truncate_items(items, &roles, drill_down_limit);
4564    let generated_items =
4565        super::entry_points::rank_and_truncate_items(generated_items, &roles, drill_down_limit);
4566    let top = super::entry_points::top_preview_symbols(&items);
4567    let generated_top = generated_items
4568        .iter()
4569        .take(super::entry_points::TOP_PREVIEW_ITEMS)
4570        .cloned()
4571        .collect::<Vec<_>>();
4572    let mut all_items = items;
4573    all_items.extend(generated_items.iter().cloned());
4574    if let Some(limit) = drill_down_limit {
4575        all_items.truncate(limit);
4576    }
4577    let test_only_items =
4578        super::entry_points::rank_and_truncate_items(test_only_items, &roles, drill_down_limit);
4579    let test_only_top = test_only_items
4580        .iter()
4581        .take(super::entry_points::TOP_PREVIEW_ITEMS)
4582        .cloned()
4583        .collect::<Vec<_>>();
4584    let (mut parse_errors, skipped_files) = unused_exports_honesty_fields(parsed);
4585    for scan in parsed {
4586        if let Some(oxc_facts) = &scan.oxc_facts {
4587            if oxc_facts.format_version != FACTS_FORMAT_VERSION {
4588                parse_errors.push(json!({
4589                    "file": scan.file,
4590                    "message": format!(
4591                        "unsupported oxc facts format {}; expected {}",
4592                        oxc_facts.format_version, FACTS_FORMAT_VERSION
4593                    ),
4594                }));
4595            }
4596        }
4597    }
4598
4599    let mut aggregate = json!({
4600        "count": count,
4601        "generated_count": generated_count,
4602        "total_count": count + test_only_count + generated_count,
4603        "items": all_items,
4604        "top": top,
4605        "generated_items": generated_items,
4606        "generated_top": generated_top,
4607        "test_only_count": test_only_count,
4608        "test_only_items": test_only_items,
4609        "test_only_top": test_only_top,
4610        "drill_down_capped": drill_down_limit.is_some_and(|limit| count + generated_count > limit),
4611        "generated_drill_down_capped": drill_down_limit.is_some_and(|limit| generated_count > limit),
4612        "test_only_drill_down_capped": drill_down_limit.is_some_and(|limit| test_only_count > limit),
4613        "scanned_files": parsed.len(),
4614        "languages_skipped": skipped_languages(&job.scope_files, LanguageSkipMode::UnusedExports),
4615        "uncertain_count": uncertain_count,
4616        "uncertain_items": uncertain_items,
4617        "complete": parse_errors.is_empty() && skipped_files.is_empty(),
4618    });
4619    if !parse_errors.is_empty() {
4620        aggregate["parse_errors"] = Value::Array(parse_errors);
4621    }
4622    if !skipped_files.is_empty() {
4623        aggregate["skipped_files"] = Value::Array(skipped_files);
4624    }
4625    if !package_warnings.is_empty() {
4626        aggregate["note"] = Value::String(package_warnings.join("; "));
4627    }
4628    aggregate
4629}
4630
4631fn add_oxc_reexport_contexts(
4632    item: &mut Value,
4633    contexts: &[crate::inspect::oxc_engine::OxcReExportContext],
4634) {
4635    if !contexts.is_empty() {
4636        item["also_reexported"] = json!(contexts);
4637    }
4638}
4639
4640fn unused_exports_honesty_fields(parsed: &[UnusedExportsContribution]) -> (Vec<Value>, Vec<Value>) {
4641    let mut parse_error_keys = BTreeSet::new();
4642    let mut parse_errors = Vec::new();
4643    let mut skipped_file_keys = BTreeSet::new();
4644    let mut skipped_files = Vec::new();
4645    for contribution in parsed {
4646        for value in &contribution.parse_errors {
4647            let key = value.to_string();
4648            if parse_error_keys.insert(key) {
4649                parse_errors.push(value.clone());
4650            }
4651        }
4652        for value in &contribution.skipped_files {
4653            let key = value.to_string();
4654            if skipped_file_keys.insert(key) {
4655                skipped_files.push(value.clone());
4656            }
4657        }
4658    }
4659    (parse_errors, skipped_files)
4660}
4661
4662fn roll_up_duplicate_contributions(
4663    job: &InspectJob,
4664    contributions: &[FileContribution],
4665    drill_down_limit: Option<usize>,
4666) -> Value {
4667    super::scanners::duplicates::aggregate_duplicate_contributions_with_limit(
4668        contributions,
4669        skipped_languages(&job.scope_files, LanguageSkipMode::Duplicates),
4670        drill_down_limit,
4671        &job.config.inspect.duplicates.expected_mirrors,
4672    )
4673}
4674
4675fn roll_up_cycle_contributions(
4676    job: &InspectJob,
4677    contributions: &[FileContribution],
4678    drill_down_limit: Option<usize>,
4679) -> Value {
4680    super::scanners::cycles::aggregate_cycle_contributions_with_limit(
4681        &job.project_root,
4682        contributions,
4683        skipped_languages(&job.scope_files, LanguageSkipMode::Cycles),
4684        drill_down_limit,
4685    )
4686}
4687
4688fn roll_up_complexity_contributions(
4689    job: &InspectJob,
4690    contributions: &[FileContribution],
4691    drill_down_limit: Option<usize>,
4692) -> Value {
4693    super::scanners::complexity::aggregate_complexity_contributions_with_limit(
4694        &job.project_root,
4695        contributions,
4696        drill_down_limit,
4697    )
4698}
4699
4700fn cap_payload_drill_down(mut payload: Value, limit: usize) -> Value {
4701    let mut capped = false;
4702    if let Some(items) = payload.get_mut("items").and_then(Value::as_array_mut) {
4703        capped |= items.len() > limit;
4704        items.truncate(limit);
4705    }
4706    if let Some(groups) = payload.get_mut("groups").and_then(Value::as_array_mut) {
4707        capped |= groups.len() > limit;
4708        groups.truncate(limit);
4709    }
4710    if let Some(object) = payload.as_object_mut() {
4711        object.insert("drill_down_capped".to_string(), json!(capped));
4712    }
4713    payload
4714}
4715
4716const MAX_DRILL_DOWN_ITEMS: usize = 100;
4717
4718#[derive(Debug, Clone, Deserialize)]
4719struct ExportContribution {
4720    symbol: String,
4721    kind: String,
4722    line: u32,
4723    #[serde(default)]
4724    verdict: Option<LivenessVerdict>,
4725    #[serde(default)]
4726    reason: Option<String>,
4727    #[serde(default)]
4728    provenance: Option<String>,
4729}
4730
4731fn export_uses_oxc(export: &ExportContribution) -> bool {
4732    export.verdict.is_some() || export.provenance.as_deref() == Some(OXC_PROVENANCE)
4733}
4734
4735#[derive(Debug, Clone, Deserialize)]
4736struct DeadCodeRefreshContribution {
4737    #[serde(default)]
4738    facts_format_version: Option<u32>,
4739    #[serde(default)]
4740    oxc_facts: Option<OxcFactsContribution>,
4741}
4742
4743#[derive(Debug, Clone, Deserialize)]
4744struct UnusedExportsContribution {
4745    file: String,
4746    #[serde(default)]
4747    generated: Option<bool>,
4748    exports: Vec<ExportContribution>,
4749    #[serde(default)]
4750    imports: Vec<ImportContribution>,
4751    #[serde(default)]
4752    oxc_facts: Option<OxcFactsContribution>,
4753    #[serde(default)]
4754    parse_errors: Vec<Value>,
4755    #[serde(default)]
4756    skipped_files: Vec<Value>,
4757}
4758
4759#[derive(Debug, Clone, Deserialize)]
4760struct ImportContribution {
4761    resolved_file: Option<String>,
4762    named: Vec<String>,
4763}
4764
4765#[derive(Debug, Clone, Deserialize)]
4766struct OxcFactsContribution {
4767    format_version: u32,
4768    content_hash: String,
4769    exports: Vec<ExportFact>,
4770    imports: Vec<ImportFact>,
4771    re_exports: Vec<ReExportFact>,
4772    dynamic_imports: Vec<DynamicImportFact>,
4773    same_file_value_references: BTreeSet<String>,
4774    used_import_bindings: BTreeSet<String>,
4775    type_referenced_import_bindings: BTreeSet<String>,
4776    value_referenced_import_bindings: BTreeSet<String>,
4777    #[serde(default)]
4778    parse_error: Option<String>,
4779}
4780
4781#[derive(Debug, Clone, Copy)]
4782enum LanguageSkipMode {
4783    Duplicates,
4784    Cycles,
4785    UnusedExports,
4786}
4787
4788fn category_uses_oxc(category: InspectCategory) -> bool {
4789    matches!(
4790        category,
4791        InspectCategory::DeadCode | InspectCategory::UnusedExports | InspectCategory::Cycles
4792    )
4793}
4794
4795fn skipped_languages(files: &[PathBuf], mode: LanguageSkipMode) -> Vec<String> {
4796    files
4797        .iter()
4798        .filter_map(|file| skipped_language(file, mode))
4799        .collect::<BTreeSet<_>>()
4800        .into_iter()
4801        .collect()
4802}
4803
4804fn skipped_language(file: &Path, mode: LanguageSkipMode) -> Option<String> {
4805    let Some(language) = crate::parser::detect_language(file) else {
4806        return match mode {
4807            LanguageSkipMode::Duplicates => Some("unknown".to_string()),
4808            LanguageSkipMode::Cycles => Some("unknown".to_string()),
4809            LanguageSkipMode::UnusedExports => None,
4810        };
4811    };
4812
4813    let skipped = match mode {
4814        LanguageSkipMode::Duplicates => !duplicates_supports_language(language),
4815        LanguageSkipMode::Cycles => !is_js_ts_language(language),
4816        LanguageSkipMode::UnusedExports => !is_js_ts_language(language),
4817    };
4818    skipped.then(|| language_name(language).to_string())
4819}
4820
4821fn duplicates_supports_language(language: crate::parser::LangId) -> bool {
4822    !matches!(
4823        language,
4824        crate::parser::LangId::Bash
4825            | crate::parser::LangId::Html
4826            | crate::parser::LangId::Json
4827            | crate::parser::LangId::Scala
4828            | crate::parser::LangId::Solidity
4829            | crate::parser::LangId::Scss
4830            | crate::parser::LangId::Vue
4831            | crate::parser::LangId::Markdown
4832            | crate::parser::LangId::Java
4833            | crate::parser::LangId::Ruby
4834            | crate::parser::LangId::Kotlin
4835            | crate::parser::LangId::Swift
4836            | crate::parser::LangId::Php
4837            | crate::parser::LangId::Lua
4838            | crate::parser::LangId::Perl
4839            | crate::parser::LangId::Pascal
4840            | crate::parser::LangId::R
4841            | crate::parser::LangId::Groovy
4842            | crate::parser::LangId::ObjC
4843            | crate::parser::LangId::Toml
4844    )
4845}
4846
4847fn is_js_ts_language(language: crate::parser::LangId) -> bool {
4848    matches!(
4849        language,
4850        crate::parser::LangId::TypeScript
4851            | crate::parser::LangId::Tsx
4852            | crate::parser::LangId::JavaScript
4853    )
4854}
4855
4856fn language_name(language: crate::parser::LangId) -> &'static str {
4857    match language {
4858        crate::parser::LangId::TypeScript => "typescript",
4859        crate::parser::LangId::Tsx => "tsx",
4860        crate::parser::LangId::JavaScript => "javascript",
4861        crate::parser::LangId::Python => "python",
4862        crate::parser::LangId::Rust => "rust",
4863        crate::parser::LangId::Go => "go",
4864        crate::parser::LangId::C => "c",
4865        crate::parser::LangId::Cpp => "cpp",
4866        crate::parser::LangId::Cuda => "cuda",
4867        crate::parser::LangId::Metal => "metal",
4868        crate::parser::LangId::Zig => "zig",
4869        crate::parser::LangId::CSharp => "csharp",
4870        crate::parser::LangId::Bash => "bash",
4871        crate::parser::LangId::Html => "html",
4872        crate::parser::LangId::Markdown => "markdown",
4873        crate::parser::LangId::Yaml => "yaml",
4874        crate::parser::LangId::Solidity => "solidity",
4875        crate::parser::LangId::Scss => "scss",
4876        crate::parser::LangId::Vue => "vue",
4877        crate::parser::LangId::Json => "json",
4878        crate::parser::LangId::Scala => "scala",
4879        crate::parser::LangId::Java => "java",
4880        crate::parser::LangId::Ruby => "ruby",
4881        crate::parser::LangId::Kotlin => "kotlin",
4882        crate::parser::LangId::Swift => "swift",
4883        crate::parser::LangId::Php => "php",
4884        crate::parser::LangId::Lua => "lua",
4885        crate::parser::LangId::Perl => "perl",
4886        crate::parser::LangId::Pascal => "pascal",
4887        crate::parser::LangId::R => "r",
4888        crate::parser::LangId::Groovy => "groovy",
4889        crate::parser::LangId::ObjC => "objc",
4890        crate::parser::LangId::Toml => "toml",
4891    }
4892}
4893
4894fn unused_public_api_entries(project_root: &Path) -> (BTreeSet<String>, Vec<String>) {
4895    let entry_points = crate::inspect::entry_points::resolve_entry_points(project_root);
4896    (
4897        entry_points.public_api_files_relative(project_root),
4898        entry_points.warnings().to_vec(),
4899    )
4900}
4901
4902fn filter_outcome_for_scope_with_contributions(
4903    outcome: JobOutcome,
4904    snapshot: &InspectSnapshot,
4905    category: InspectCategory,
4906    cache: &(impl InspectCacheRead + ?Sized),
4907    scope: &JobScope,
4908) -> JobOutcome {
4909    if !category.is_tier2() || scope.is_project_wide() {
4910        return filter_outcome_for_scope(outcome, scope);
4911    }
4912
4913    match outcome {
4914        JobOutcome::Fresh { payload } => {
4915            match scoped_tier2_payload_from_contributions(snapshot, category, cache, payload, scope)
4916            {
4917                Ok(payload) => JobOutcome::Fresh { payload },
4918                Err(message) => JobOutcome::Failed { message },
4919            }
4920        }
4921        JobOutcome::Stale { cached, in_flight } => match cached {
4922            Some(payload) => {
4923                match scoped_tier2_payload_from_contributions(
4924                    snapshot, category, cache, payload, scope,
4925                ) {
4926                    Ok(payload) => JobOutcome::Stale {
4927                        cached: Some(payload),
4928                        in_flight,
4929                    },
4930                    Err(message) => JobOutcome::Failed { message },
4931                }
4932            }
4933            None => JobOutcome::Stale {
4934                cached: None,
4935                in_flight,
4936            },
4937        },
4938        JobOutcome::Pending { in_flight, wait } => JobOutcome::Pending { in_flight, wait },
4939        JobOutcome::Failed { message } => JobOutcome::Failed { message },
4940    }
4941}
4942
4943fn filter_outcome_for_scope(outcome: JobOutcome, scope: &JobScope) -> JobOutcome {
4944    match outcome {
4945        JobOutcome::Fresh { payload } => JobOutcome::Fresh {
4946            payload: filter_payload_for_scope(payload, scope),
4947        },
4948        JobOutcome::Stale { cached, in_flight } => JobOutcome::Stale {
4949            cached: cached.map(|payload| filter_payload_for_scope(payload, scope)),
4950            in_flight,
4951        },
4952        JobOutcome::Pending { in_flight, wait } => JobOutcome::Pending { in_flight, wait },
4953        JobOutcome::Failed { message } => JobOutcome::Failed { message },
4954    }
4955}
4956
4957fn filter_payload_for_scope(mut payload: serde_json::Value, scope: &JobScope) -> serde_json::Value {
4958    if scope.is_project_wide() {
4959        return payload;
4960    }
4961
4962    // Scoped Tier 2 callers pass an uncapped rollup into this filter and cap
4963    // drill-down only afterwards, so the recomputed count below remains the
4964    // true in-scope total rather than the size of a capped sample.
4965    if let Some(items) = payload
4966        .get_mut("items")
4967        .and_then(|value| value.as_array_mut())
4968    {
4969        let count = filter_values_for_scope(items, scope);
4970        let largest_cycle = items
4971            .iter()
4972            .filter_map(|item| item.get("files").and_then(Value::as_array).map(Vec::len))
4973            .max();
4974        if let Some(object) = payload.as_object_mut() {
4975            object.insert("count".to_string(), serde_json::json!(count));
4976            if object.contains_key("largest") {
4977                object.insert(
4978                    "largest".to_string(),
4979                    serde_json::json!(largest_cycle.unwrap_or(0)),
4980                );
4981            }
4982            if object.contains_key("total_groups") {
4983                object.insert("total_groups".to_string(), serde_json::json!(count));
4984            }
4985            if object.contains_key("groups_count") {
4986                object.insert("groups_count".to_string(), serde_json::json!(count));
4987            }
4988        }
4989    }
4990
4991    if let Some(groups) = payload
4992        .get_mut("groups")
4993        .and_then(|value| value.as_array_mut())
4994    {
4995        let count = filter_values_for_scope(groups, scope);
4996        if let Some(object) = payload.as_object_mut() {
4997            object.insert("count".to_string(), serde_json::json!(count));
4998            object.insert("total_groups".to_string(), serde_json::json!(count));
4999            if object.contains_key("groups_count") {
5000                object.insert("groups_count".to_string(), serde_json::json!(count));
5001            }
5002        }
5003    }
5004
5005    // `by_language` is a project-wide breakdown computed before scope filtering.
5006    // Leaving it in a scoped payload contradicts the recomputed in-scope `count`
5007    // (e.g. count: 3 alongside `(rust 214, ts 143)`). The filtered items don't
5008    // carry per-item language, so we can't faithfully recompute it — drop it so
5009    // the scoped summary doesn't render a misleading project-wide breakdown.
5010    if let Some(object) = payload.as_object_mut() {
5011        if object.contains_key("top") {
5012            if let Some(top) = recompute_scoped_top_preview(object) {
5013                object.insert("top".to_string(), top);
5014            } else if let Some(top) = object.get_mut("top").and_then(Value::as_array_mut) {
5015                filter_values_for_scope(top, scope);
5016            }
5017        }
5018        if object.contains_key("duplicated_lines") {
5019            recompute_duplicate_payload_stats(object);
5020        }
5021        object.remove("by_language");
5022    }
5023
5024    payload
5025}
5026
5027fn recompute_duplicate_payload_stats(object: &mut serde_json::Map<String, Value>) {
5028    let values = object
5029        .get("items")
5030        .or_else(|| object.get("groups"))
5031        .and_then(Value::as_array)
5032        .cloned()
5033        .unwrap_or_default();
5034    let (duplicated_lines, duplicated_file_count) = duplicate_line_stats_from_values(&values);
5035    let total_analyzed_lines = object
5036        .get("total_analyzed_lines")
5037        .and_then(Value::as_u64)
5038        .unwrap_or(0);
5039    let duplicated_percent = if total_analyzed_lines == 0 {
5040        0.0
5041    } else {
5042        (duplicated_lines as f64 * 100.0) / total_analyzed_lines as f64
5043    };
5044    object.insert("duplicated_lines".to_string(), json!(duplicated_lines));
5045    object.insert(
5046        "duplicated_file_count".to_string(),
5047        json!(duplicated_file_count),
5048    );
5049    object.insert("duplicated_percent".to_string(), json!(duplicated_percent));
5050}
5051
5052fn duplicate_line_stats_from_values(values: &[Value]) -> (u64, usize) {
5053    let mut by_file = BTreeMap::<String, Vec<(u64, u64)>>::new();
5054    for value in values {
5055        let Some(files) = value.get("files").and_then(Value::as_array) else {
5056            continue;
5057        };
5058        for occurrence in files.iter().filter_map(Value::as_str) {
5059            let Some((file, start, end)) = parse_duplicate_occurrence(occurrence) else {
5060                continue;
5061            };
5062            by_file
5063                .entry(file.to_string())
5064                .or_default()
5065                .push((start, end));
5066        }
5067    }
5068    let file_count = by_file.len();
5069    let duplicated_lines = by_file
5070        .values_mut()
5071        .map(|intervals| merged_duplicate_interval_lines(intervals))
5072        .sum();
5073    (duplicated_lines, file_count)
5074}
5075
5076fn merged_duplicate_interval_lines(intervals: &mut [(u64, u64)]) -> u64 {
5077    if intervals.is_empty() {
5078        return 0;
5079    }
5080    intervals.sort_by(|left, right| left.0.cmp(&right.0).then(left.1.cmp(&right.1)));
5081    let (mut current_start, mut current_end) = intervals[0];
5082    let mut total = 0;
5083    for &(start, end) in &intervals[1..] {
5084        if start <= current_end.saturating_add(1) {
5085            current_end = current_end.max(end);
5086        } else {
5087            total += current_end.saturating_sub(current_start).saturating_add(1);
5088            current_start = start;
5089            current_end = end;
5090        }
5091    }
5092    total + current_end.saturating_sub(current_start).saturating_add(1)
5093}
5094
5095fn recompute_scoped_top_preview(
5096    object: &serde_json::Map<String, Value>,
5097) -> Option<serde_json::Value> {
5098    let values = object
5099        .get("items")
5100        .or_else(|| object.get("groups"))
5101        .and_then(Value::as_array)?;
5102    Some(Value::Array(
5103        values
5104            .iter()
5105            .take(super::entry_points::TOP_PREVIEW_ITEMS)
5106            .map(top_preview_value)
5107            .collect(),
5108    ))
5109}
5110
5111fn top_preview_value(value: &Value) -> Value {
5112    if let Some(files) = value.get("files").and_then(Value::as_array) {
5113        let mut object = serde_json::Map::new();
5114        object.insert("files".to_string(), Value::Array(files.clone()));
5115        if let Some(cost) = value.get("cost").cloned() {
5116            object.insert("cost".to_string(), cost);
5117        }
5118        return Value::Object(object);
5119    }
5120
5121    json!({
5122        "file": value.get("file").and_then(Value::as_str).unwrap_or(""),
5123        "symbol": value.get("symbol").and_then(Value::as_str).unwrap_or(""),
5124    })
5125}
5126
5127fn filter_values_for_scope(values: &mut Vec<serde_json::Value>, scope: &JobScope) -> usize {
5128    values.retain_mut(|value| prune_value_for_scope(value, scope));
5129    values.len()
5130}
5131
5132fn prune_value_for_scope(value: &mut serde_json::Value, scope: &JobScope) -> bool {
5133    if let Some(file) = value.get("file").and_then(|file| file.as_str()) {
5134        return scope.contains_display_path(file);
5135    }
5136
5137    let first_scoped_occurrence = if let Some(files) = value
5138        .get_mut("files")
5139        .and_then(|files| files.as_array_mut())
5140    {
5141        files.retain(|file| {
5142            file.as_str()
5143                .is_some_and(|file| scope.contains_display_path(display_file_from_occurrence(file)))
5144        });
5145        if files.len() < 2 {
5146            return false;
5147        }
5148        files.first().and_then(Value::as_str).map(str::to_string)
5149    } else {
5150        None
5151    };
5152
5153    if let Some(occurrence) = first_scoped_occurrence {
5154        update_duplicate_group_sample(value, &occurrence);
5155    }
5156
5157    true
5158}
5159
5160fn update_duplicate_group_sample(value: &mut serde_json::Value, occurrence: &str) {
5161    let Some((file, start_line, end_line)) = parse_duplicate_occurrence(occurrence) else {
5162        return;
5163    };
5164    let Some(object) = value.as_object_mut() else {
5165        return;
5166    };
5167
5168    if object.contains_key("sample_file") {
5169        object.insert("sample_file".to_string(), json!(file));
5170    }
5171    if object.contains_key("sample_start_line") {
5172        object.insert("sample_start_line".to_string(), json!(start_line));
5173    }
5174    if object.contains_key("sample_end_line") {
5175        object.insert("sample_end_line".to_string(), json!(end_line));
5176    }
5177}
5178
5179fn parse_duplicate_occurrence(value: &str) -> Option<(&str, u64, u64)> {
5180    let (file, range) = value.rsplit_once(':')?;
5181    let (start, end) = range.split_once('-')?;
5182    if !start.chars().all(|char| char.is_ascii_digit())
5183        || !end.chars().all(|char| char.is_ascii_digit())
5184    {
5185        return None;
5186    }
5187
5188    Some((file, start.parse().ok()?, end.parse().ok()?))
5189}
5190
5191fn display_file_from_occurrence(value: &str) -> &str {
5192    let Some((file, range)) = value.rsplit_once(':') else {
5193        return value;
5194    };
5195    let Some((start, end)) = range.split_once('-') else {
5196        return value;
5197    };
5198    if start.chars().all(|char| char.is_ascii_digit())
5199        && end.chars().all(|char| char.is_ascii_digit())
5200    {
5201        file
5202    } else {
5203        value
5204    }
5205}
5206
5207#[cfg(test)]
5208// Some tests here are `cfg(debug_assertions)` (they lean on debug-only seams);
5209// helpers only they use read as dead in release-profile test builds.
5210#[cfg_attr(not(debug_assertions), allow(dead_code))]
5211mod guard_tests {
5212    use super::*;
5213
5214    fn write_ts_project(file_count: usize) -> tempfile::TempDir {
5215        let dir = tempfile::tempdir().expect("tempdir");
5216        let root = dir.path();
5217        for i in 0..file_count {
5218            std::fs::write(
5219                root.join(format!("mod{i}.ts")),
5220                format!("export function f{i}() {{ return {i}; }}\n"),
5221            )
5222            .expect("write fixture");
5223        }
5224        let canonical_root = std::fs::canonicalize(root).expect("canonical fixture root");
5225        let project_key = crate::search_index::artifact_cache_key(&canonical_root);
5226        crate::root_cache::configure_artifact_access(&canonical_root, &project_key, false);
5227        dir
5228    }
5229
5230    fn tier1_snapshot(root: &Path) -> InspectSnapshot {
5231        use crate::config::Config;
5232        use crate::parser::SymbolCache;
5233        use std::sync::RwLock;
5234
5235        InspectSnapshot::new(
5236            root.to_path_buf(),
5237            root.join(".aft-cache/inspect"),
5238            Arc::new(Config {
5239                project_root: Some(root.to_path_buf()),
5240                ..Config::default()
5241            }),
5242            Arc::new(RwLock::new(SymbolCache::new())),
5243        )
5244    }
5245
5246    #[test]
5247    fn tier1_worker_panic_delivers_failed_to_waiter() {
5248        let dir = write_ts_project(2);
5249        let root = std::fs::canonicalize(dir.path()).expect("canonical fixture root");
5250        let snapshot = tier1_snapshot(&root);
5251        // The property under test is that a worker panic reaches the waiter as
5252        // `Failed`, not how fast the pool unwinds it; a loaded runner took 359ms
5253        // to deliver the panic result, so the soft deadline is generous.
5254        let manager = InspectManager::with_worker(
5255            Arc::new(|_| panic!("forced Tier-1 worker panic")),
5256            Duration::from_secs(10),
5257        );
5258
5259        let outcome = manager.submit_category(
5260            snapshot,
5261            InspectCategory::Metrics,
5262            JobScope::for_project(root),
5263        );
5264
5265        match outcome {
5266            JobOutcome::Failed { message } => assert!(
5267                message.contains(
5268                    "inspect worker panicked before completion: forced Tier-1 worker panic"
5269                ),
5270                "unexpected panic terminal: {message}"
5271            ),
5272            other => panic!("worker panic must deliver Failed, got {other:?}"),
5273        }
5274        assert!(
5275            manager
5276                .in_flight
5277                .lock()
5278                .unwrap_or_else(std::sync::PoisonError::into_inner)
5279                .is_empty(),
5280            "panic completion must clear its waiter registration"
5281        );
5282    }
5283
5284    #[test]
5285    fn ready_worker_result_wins_over_simultaneously_ready_deadline() {
5286        let dir = write_ts_project(2);
5287        let root = std::fs::canonicalize(dir.path()).expect("canonical fixture root");
5288        let snapshot = tier1_snapshot(&root);
5289        let manager = InspectManager::with_worker(
5290            Arc::new(|job| {
5291                InspectResult::success(
5292                    &job,
5293                    InspectScanSuccess {
5294                        scanned_files: job.scope_files.clone(),
5295                        contributions: Vec::new(),
5296                        aggregate: json!({"count": 2}),
5297                    },
5298                    Duration::ZERO,
5299                )
5300            }),
5301            Duration::from_secs(1),
5302        );
5303        let scope = JobScope::for_project(root);
5304        let key = JobKey::for_category_scope(InspectCategory::Metrics, &scope);
5305        let cache = manager
5306            .cache_for_snapshot(&snapshot)
5307            .expect("open inspect cache");
5308        let (waiter_tx, waiter_rx) = bounded(1);
5309        manager
5310            .enqueue_with_waiter(
5311                snapshot.clone(),
5312                InspectCategory::Metrics,
5313                scope.clone(),
5314                key.clone(),
5315                waiter_tx,
5316                None,
5317            )
5318            .expect("enqueue metrics scan");
5319
5320        let result_deadline = Instant::now() + Duration::from_secs(5);
5321        while manager.result_rx.is_empty() {
5322            assert!(
5323                Instant::now() < result_deadline,
5324                "worker result did not become ready"
5325            );
5326            std::thread::sleep(Duration::from_millis(1));
5327        }
5328        let wait_started = Instant::now();
5329        let outcome = manager.wait_for_outcome(
5330            key,
5331            scope,
5332            cache,
5333            waiter_rx,
5334            snapshot,
5335            wait_started,
5336            wait_started,
5337            Duration::ZERO,
5338        );
5339
5340        assert!(
5341            matches!(outcome, JobOutcome::Fresh { .. }),
5342            "an already-ready terminal result must beat the deadline: {outcome:?}"
5343        );
5344    }
5345
5346    struct ProjectionObserverReset;
5347
5348    impl Drop for ProjectionObserverReset {
5349        fn drop(&mut self) {
5350            crate::callgraph_store::set_projection_before_open_observer(None);
5351        }
5352    }
5353
5354    fn count_projections() -> (Arc<std::sync::atomic::AtomicUsize>, ProjectionObserverReset) {
5355        let count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5356        let observed = Arc::clone(&count);
5357        crate::callgraph_store::set_projection_before_open_observer(Some(Arc::new(move |_| {
5358            observed.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
5359        })));
5360        (count, ProjectionObserverReset)
5361    }
5362
5363    fn write_projection_cache_file(path: &Path, contents: &str) {
5364        std::fs::create_dir_all(path.parent().expect("fixture file parent"))
5365            .expect("create fixture parent");
5366        std::fs::write(path, contents).expect("write fixture file");
5367    }
5368
5369    fn published_projection_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, InspectJob) {
5370        let dir = tempfile::tempdir().expect("tempdir");
5371        let root = std::fs::canonicalize(dir.path()).expect("canonical fixture root");
5372        write_projection_cache_file(
5373            &root.join("src/main.ts"),
5374            "import { firstTarget } from './target';\nexport function main() { firstTarget(); }\n",
5375        );
5376        write_projection_cache_file(
5377            &root.join("src/target.ts"),
5378            "export function firstTarget() {}\n",
5379        );
5380        let inspect_dir = root.join(".aft-cache").join("inspect");
5381        let project_key = crate::search_index::artifact_cache_key(&root);
5382        crate::root_cache::configure_artifact_access(&root, &project_key, false);
5383        let callgraph_dir =
5384            callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("callgraph dir");
5385        let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
5386        let (store, _) = CallGraphStore::cold_build_with_lease(callgraph_dir, root.clone(), &files)
5387            .expect("publish initial generation");
5388        drop(store);
5389        let mut job = snapshot_job(&root, &inspect_dir, true);
5390        job.callgraph_writer = false;
5391        (dir, root, inspect_dir, job)
5392    }
5393
5394    #[test]
5395    fn scoped_filter_recomputes_top_preview_from_scoped_items() {
5396        let project_root = PathBuf::from("/project");
5397        let scope = JobScope::from_roots(project_root.clone(), vec![project_root.join("src/in")]);
5398        let payload = json!({
5399            "count": 4,
5400            "items": [
5401                { "file": "src/out/a.ts", "symbol": "outside" },
5402                { "file": "src/in/b.ts", "symbol": "inside_b" },
5403                { "file": "src/in/c.ts", "symbol": "inside_c" }
5404            ],
5405            "top": [
5406                { "file": "src/out/a.ts", "symbol": "outside" },
5407                { "file": "src/out/z.ts", "symbol": "outside_z" }
5408            ],
5409            "by_language": { "typescript": 4 }
5410        });
5411
5412        let filtered = filter_payload_for_scope(payload, &scope);
5413
5414        assert_eq!(filtered["count"], json!(2));
5415        assert_eq!(
5416            filtered["top"],
5417            json!([
5418                { "file": "src/in/b.ts", "symbol": "inside_b" },
5419                { "file": "src/in/c.ts", "symbol": "inside_c" }
5420            ])
5421        );
5422        assert!(filtered["top"]
5423            .as_array()
5424            .unwrap()
5425            .iter()
5426            .all(|item| item["file"]
5427                .as_str()
5428                .is_some_and(|file| file.starts_with("src/in/"))));
5429    }
5430
5431    fn artifact_cache_key_for_test(project_root: &std::path::Path) -> String {
5432        let _git_env = crate::test_env::hermetic_git_env_guard();
5433        crate::search_index::artifact_cache_key(project_root)
5434    }
5435
5436    #[test]
5437    fn cache_for_paths_rebinds_same_project_key_to_current_root() {
5438        let _git_env = crate::test_env::hermetic_git_env_guard();
5439        let dir = tempfile::tempdir().expect("tempdir");
5440        let source = dir.path().join("source");
5441        std::fs::create_dir_all(&source).expect("create source repo");
5442        std::fs::write(
5443            source.join("package.json"),
5444            r#"{"name":"inspect-cache-fixture","version":"1.0.0"}"#,
5445        )
5446        .expect("write source manifest");
5447        std::fs::write(source.join("index.ts"), "export const source = 1;\n")
5448            .expect("write source file");
5449        let mut init = std::process::Command::new("git");
5450        assert!(
5451            crate::test_env::apply_hermetic_git_env(init.current_dir(&source))
5452                .arg("init")
5453                .status()
5454                .expect("git init source repo")
5455                .success()
5456        );
5457        let mut add = std::process::Command::new("git");
5458        assert!(
5459            crate::test_env::apply_hermetic_git_env(add.current_dir(&source))
5460                .args(["add", "."])
5461                .status()
5462                .expect("git add source repo")
5463                .success()
5464        );
5465        let mut commit = std::process::Command::new("git");
5466        assert!(
5467            crate::test_env::apply_hermetic_git_env(commit.current_dir(&source))
5468                .args([
5469                    "-c",
5470                    "user.name=AFT Tests",
5471                    "-c",
5472                    "user.email=aft-tests@example.com",
5473                    "commit",
5474                    "-m",
5475                    "initial",
5476                ])
5477                .status()
5478                .expect("git commit source repo")
5479                .success()
5480        );
5481
5482        let clone = dir.path().join("clone");
5483        let mut clone_command = std::process::Command::new("git");
5484        assert!(crate::test_env::apply_hermetic_git_env(&mut clone_command)
5485            .args(["clone", "--quiet"])
5486            .arg(&source)
5487            .arg(&clone)
5488            .status()
5489            .expect("git clone source repo")
5490            .success());
5491        std::fs::write(
5492            clone.join("package.json"),
5493            r#"{"name":"inspect-cache-fixture","version":"2.0.0"}"#,
5494        )
5495        .expect("write clone manifest edit");
5496        assert_eq!(
5497            artifact_cache_key_for_test(&source),
5498            artifact_cache_key_for_test(&clone),
5499            "clones with the same root commit should share the sqlite project key"
5500        );
5501
5502        let source = std::fs::canonicalize(source).expect("canonical source root");
5503        let clone = std::fs::canonicalize(clone).expect("canonical clone root");
5504        let manager = InspectManager::new();
5505        let inspect_dir = dir.path().join("inspect");
5506        let key = JobKey::for_project_category(InspectCategory::DeadCode);
5507        let source_cache = manager
5508            .cache_for_paths(inspect_dir.clone(), source.clone())
5509            .expect("open source cache");
5510        let source_hash = source_cache
5511            .contribution_set_hash(InspectCategory::DeadCode)
5512            .expect("source contribution hash");
5513        source_cache
5514            .store_tier2_aggregate(
5515                key.clone(),
5516                &source_hash,
5517                serde_json::json!({ "count": 7, "items": [] }),
5518            )
5519            .expect("store source aggregate");
5520        assert_eq!(
5521            source_cache
5522                .get_aggregated(&key)
5523                .expect("read source aggregate")
5524                .and_then(|payload| payload.get("count").and_then(Value::as_u64)),
5525            Some(7)
5526        );
5527
5528        let clone_cache = manager
5529            .cache_for_paths(inspect_dir, clone.clone())
5530            .expect("open clone cache");
5531        assert_eq!(clone_cache.project_root(), clone.as_path());
5532        assert!(
5533            clone_cache
5534                .get_aggregated(&key)
5535                .expect("read clone aggregate")
5536                .is_none(),
5537            "same-key clone with a different manifest must not reuse the source root's cached count"
5538        );
5539    }
5540
5541    #[test]
5542    fn dead_code_blocked_on_callgraph_reads_latest_aggregate_flag() {
5543        // Health asks the manager whether dead_code is only missing because the
5544        // callgraph store was not ready when it scanned. The answer must track
5545        // the latest persisted dead_code aggregate's `callgraph_available` flag
5546        // (mirroring the suppression rule in `latest_tier2_counts`).
5547        let dir = tempfile::tempdir().unwrap();
5548        let project_root = std::fs::canonicalize(dir.path()).unwrap();
5549        std::fs::write(project_root.join("lib.rs"), "pub fn marker() {}\n").unwrap();
5550        let manager = InspectManager::new();
5551        let inspect_dir = dir.path().join("inspect");
5552
5553        // No aggregate yet → not blocked.
5554        assert!(!manager.dead_code_blocked_on_callgraph(inspect_dir.clone(), project_root.clone()));
5555
5556        let cache = manager
5557            .cache_for_paths(inspect_dir.clone(), project_root.clone())
5558            .expect("open cache");
5559        let key = JobKey::for_project_category(InspectCategory::DeadCode);
5560        let hash = cache
5561            .contribution_set_hash(InspectCategory::DeadCode)
5562            .expect("contribution hash");
5563
5564        // A callgraph-backed dead_code aggregate → not blocked, count surfaced.
5565        cache
5566            .store_tier2_aggregate(
5567                key.clone(),
5568                &hash,
5569                serde_json::json!({ "count": 3, "callgraph_available": true }),
5570            )
5571            .expect("store callgraph-backed aggregate");
5572        assert!(!manager.dead_code_blocked_on_callgraph(inspect_dir.clone(), project_root.clone()));
5573        assert_eq!(
5574            manager
5575                .latest_tier2_counts(inspect_dir.clone(), project_root.clone())
5576                .0,
5577            Some(3)
5578        );
5579
5580        // A callgraph_unavailable aggregate (store not ready) → blocked, and the
5581        // count stays suppressed so the status bar never fabricates a zero.
5582        cache
5583            .store_tier2_aggregate(
5584                key,
5585                &hash,
5586                crate::inspect::scanners::dead_code::callgraph_unavailable_aggregate(1),
5587            )
5588            .expect("store callgraph_unavailable aggregate");
5589        assert!(manager.dead_code_blocked_on_callgraph(inspect_dir.clone(), project_root.clone()));
5590        assert_eq!(
5591            manager.latest_tier2_counts(inspect_dir, project_root).0,
5592            None,
5593            "callgraph_unavailable dead_code must stay suppressed"
5594        );
5595    }
5596
5597    fn snapshot_job(root: &Path, inspect_dir: &Path, callgraph_store: bool) -> InspectJob {
5598        use crate::config::Config;
5599        use crate::parser::SymbolCache;
5600        use std::sync::RwLock;
5601
5602        InspectJob {
5603            job_id: 1,
5604            key: JobKey::for_project_category(InspectCategory::DeadCode),
5605            category: InspectCategory::DeadCode,
5606            scope_files: Vec::new(),
5607            project_root: root.to_path_buf(),
5608            inspect_dir: inspect_dir.to_path_buf(),
5609            config: Arc::new(Config {
5610                project_root: Some(root.to_path_buf()),
5611                callgraph_store,
5612                ..Config::default()
5613            }),
5614            symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
5615            inspect_writer: true,
5616            callgraph_writer: true,
5617            callgraph_snapshot: None,
5618        }
5619    }
5620
5621    #[test]
5622    fn blocking_inspect_overtakes_queued_maintenance_after_active_seed_releases() {
5623        use crate::config::Config;
5624        use crate::parser::SymbolCache;
5625        use std::sync::RwLock;
5626
5627        let dir = tempfile::tempdir().expect("tempdir");
5628        let root = std::fs::canonicalize(dir.path()).expect("canonical fixture root");
5629        std::fs::create_dir_all(root.join("src")).expect("create source directory");
5630        std::fs::write(
5631            root.join("src/main.ts"),
5632            "export function plantedDead() { return 1; }\n",
5633        )
5634        .expect("write source fixture");
5635        let project_key = crate::search_index::artifact_cache_key(&root);
5636        crate::root_cache::configure_artifact_access(&root, &project_key, false);
5637        let inspect_dir = root.join(".aft-cache").join("inspect");
5638        let snapshot = InspectSnapshot::new_with_capabilities(
5639            root.clone(),
5640            inspect_dir,
5641            Arc::new(Config {
5642                project_root: Some(root.clone()),
5643                callgraph_store: true,
5644                ..Config::default()
5645            }),
5646            Arc::new(RwLock::new(SymbolCache::new())),
5647            true,
5648            true,
5649        );
5650
5651        let limiter = cold_build_limiter::test_limiter(1);
5652        let active_request = cold_build_limiter::ColdBuildAdmissionRequest::new(
5653            "active-semantic-seed",
5654            cold_build_limiter::ColdBuildAdmissionClass::Maintenance,
5655        );
5656        let active =
5657            cold_build_limiter::try_acquire_classified_with_limiter(&limiter, &active_request)
5658                .expect("active semantic seed holds the only slot");
5659        let semantic_seed_active = Arc::new(AtomicBool::new(true));
5660        let manager = Arc::new(InspectManager::with_root_work_gates(
5661            Arc::new(AtomicBool::new(true)),
5662            Arc::clone(&semantic_seed_active),
5663        ));
5664        manager.set_cold_build_limiter(Arc::clone(&limiter));
5665
5666        let inspect_manager = Arc::clone(&manager);
5667        let scope = JobScope::for_project(root);
5668        let (outcome_tx, outcome_rx) = std::sync::mpsc::channel();
5669        std::thread::spawn(move || {
5670            let outcome = inspect_manager.tier2_run_with_reuse_blocking_fresh(
5671                snapshot,
5672                InspectCategory::DeadCode,
5673                scope,
5674            );
5675            outcome_tx.send(outcome).expect("send inspect outcome");
5676        });
5677
5678        let deadline = Instant::now() + Duration::from_secs(3);
5679        while manager.tier2_builder_state(InspectCategory::DeadCode)
5680            != InspectBuilderState::GatedBySemanticSeed
5681        {
5682            assert!(
5683                Instant::now() < deadline,
5684                "inspect must queue while the active seed owns the slot"
5685            );
5686            std::thread::yield_now();
5687        }
5688        assert!(
5689            outcome_rx.try_recv().is_err(),
5690            "in-flight work is not preempted"
5691        );
5692
5693        let maintenance_limiter = Arc::clone(&limiter);
5694        let maintenance = std::thread::spawn(move || {
5695            cold_build_limiter::acquire_blocking_while_with_limiter(
5696                &maintenance_limiter,
5697                "queued background refresh",
5698                || true,
5699            )
5700            .expect("background refresh eventually resumes")
5701        });
5702        std::thread::sleep(Duration::from_millis(150));
5703        semantic_seed_active.store(false, Ordering::SeqCst);
5704        drop(active);
5705
5706        let outcome = outcome_rx
5707            .recv_timeout(Duration::from_secs(10))
5708            .expect("blocking inspect completes after the active seed releases");
5709        let payload = outcome.payload().expect("blocking inspect is fresh");
5710        assert_eq!(
5711            payload.get("callgraph_available").and_then(Value::as_bool),
5712            Some(true)
5713        );
5714        drop(maintenance.join().expect("background waiter joins"));
5715
5716        let events = limiter.admission_events();
5717        assert_eq!(
5718            events[0].class,
5719            cold_build_limiter::ColdBuildAdmissionClass::Maintenance
5720        );
5721        assert_eq!(
5722            events[1].class,
5723            cold_build_limiter::ColdBuildAdmissionClass::InspectTriggered,
5724            "explicit inspect takes the first released slot"
5725        );
5726        assert_eq!(
5727            events[2].class,
5728            cold_build_limiter::ColdBuildAdmissionClass::Maintenance
5729        );
5730    }
5731
5732    #[test]
5733    fn post_eviction_rebind_serves_unchanged_tier2_aggregate_without_cold_slot() {
5734        use crate::config::Config;
5735        use crate::parser::SymbolCache;
5736        use std::sync::RwLock;
5737
5738        let dir = tempfile::tempdir().expect("tempdir");
5739        let root = std::fs::canonicalize(dir.path()).expect("canonical fixture root");
5740        std::fs::create_dir_all(root.join("src")).expect("create source directory");
5741        std::fs::write(
5742            root.join("src/main.ts"),
5743            "export function plantedDead() { return 1; }\n",
5744        )
5745        .expect("write source fixture");
5746        let project_key = crate::search_index::artifact_cache_key(&root);
5747        crate::root_cache::configure_artifact_access(&root, &project_key, false);
5748        let inspect_dir = root.join(".aft-cache").join("inspect");
5749        let snapshot = InspectSnapshot::new_with_capabilities(
5750            root.clone(),
5751            inspect_dir,
5752            Arc::new(Config {
5753                project_root: Some(root.clone()),
5754                callgraph_store: true,
5755                ..Config::default()
5756            }),
5757            Arc::new(RwLock::new(SymbolCache::new())),
5758            true,
5759            true,
5760        );
5761        let limiter = cold_build_limiter::test_limiter(1);
5762        let manager = Arc::new(InspectManager::new());
5763        manager.set_cold_build_limiter(Arc::clone(&limiter));
5764        let first = manager.tier2_run_with_reuse_blocking_fresh(
5765            snapshot.clone(),
5766            InspectCategory::DeadCode,
5767            JobScope::for_project(root.clone()),
5768        );
5769        assert!(
5770            first.payload().is_some(),
5771            "initial scan persists a fresh aggregate"
5772        );
5773        assert!(!manager.tier2_any_in_flight());
5774        manager.evict_idle_caches();
5775
5776        let maintenance_request = cold_build_limiter::ColdBuildAdmissionRequest::new(
5777            "post-eviction-search-verify",
5778            cold_build_limiter::ColdBuildAdmissionClass::Maintenance,
5779        );
5780        let maintenance =
5781            cold_build_limiter::try_acquire_classified_with_limiter(&limiter, &maintenance_request)
5782                .expect("background verification owns the only cold slot");
5783        let events_before = limiter.admission_events().len();
5784        let rebound_manager = Arc::clone(&manager);
5785        let (outcome_tx, outcome_rx) = std::sync::mpsc::channel();
5786        std::thread::spawn(move || {
5787            let outcome = rebound_manager.tier2_run_with_reuse_blocking_fresh(
5788                snapshot,
5789                InspectCategory::DeadCode,
5790                JobScope::for_project(root),
5791            );
5792            outcome_tx.send(outcome).expect("send rebound outcome");
5793        });
5794
5795        let rebound = outcome_rx
5796            .recv_timeout(Duration::from_secs(3))
5797            .expect("unchanged persisted aggregate bypasses the occupied cold-build queue");
5798        assert!(rebound.payload().is_some());
5799        assert_eq!(
5800            limiter.admission_events().len(),
5801            events_before,
5802            "quick reuse must not request an interactive cold-build permit"
5803        );
5804        drop(maintenance);
5805        assert_eq!(
5806            manager.tier2_builder_state(InspectCategory::DeadCode),
5807            InspectBuilderState::Absent,
5808            "quick reuse must clear the builder registry on the way out"
5809        );
5810        assert!(!manager.tier2_any_in_flight());
5811    }
5812
5813    #[test]
5814    fn background_tier2_reuse_panic_clears_builder_registration() {
5815        use crate::config::Config;
5816        use crate::parser::SymbolCache;
5817        use std::sync::RwLock;
5818
5819        let dir = tempfile::tempdir().expect("tempdir");
5820        let root = std::fs::canonicalize(dir.path()).expect("canonical fixture root");
5821        std::fs::create_dir_all(root.join("src")).expect("create source directory");
5822        std::fs::write(
5823            root.join("src/dup.ts"),
5824            "export function planted() { return 1; }\n",
5825        )
5826        .expect("write source fixture");
5827        let project_key = crate::search_index::artifact_cache_key(&root);
5828        crate::root_cache::configure_artifact_access(&root, &project_key, false);
5829        let inspect_dir = root.join(".aft-cache").join("inspect");
5830        let snapshot = InspectSnapshot::new_with_capabilities(
5831            root.clone(),
5832            inspect_dir,
5833            Arc::new(Config {
5834                project_root: Some(root.clone()),
5835                ..Config::default()
5836            }),
5837            Arc::new(RwLock::new(SymbolCache::new())),
5838            true,
5839            true,
5840        );
5841
5842        let previous_root = std::env::var_os("AFT_TEST_TIER2_REUSE_PANIC_ROOT");
5843        let previous_category = std::env::var_os("AFT_TEST_TIER2_REUSE_PANIC_CATEGORY");
5844        unsafe {
5845            std::env::set_var("AFT_TEST_TIER2_REUSE_PANIC_ROOT", &root);
5846            std::env::set_var("AFT_TEST_TIER2_REUSE_PANIC_CATEGORY", "duplicates");
5847        }
5848        struct RestorePanicEnv {
5849            root: Option<std::ffi::OsString>,
5850            category: Option<std::ffi::OsString>,
5851        }
5852        impl Drop for RestorePanicEnv {
5853            fn drop(&mut self) {
5854                unsafe {
5855                    match self.root.take() {
5856                        Some(value) => std::env::set_var("AFT_TEST_TIER2_REUSE_PANIC_ROOT", value),
5857                        None => std::env::remove_var("AFT_TEST_TIER2_REUSE_PANIC_ROOT"),
5858                    }
5859                    match self.category.take() {
5860                        Some(value) => {
5861                            std::env::set_var("AFT_TEST_TIER2_REUSE_PANIC_CATEGORY", value)
5862                        }
5863                        None => std::env::remove_var("AFT_TEST_TIER2_REUSE_PANIC_CATEGORY"),
5864                    }
5865                }
5866            }
5867        }
5868        let _restore = RestorePanicEnv {
5869            root: previous_root,
5870            category: previous_category,
5871        };
5872
5873        let manager = Arc::new(InspectManager::new());
5874        manager
5875            .submit_tier2_run_with_reuse_background(snapshot, InspectCategory::Duplicates)
5876            .expect("queue background duplicates scan");
5877
5878        let deadline = Instant::now() + Duration::from_secs(20);
5879        loop {
5880            if !manager.tier2_any_in_flight()
5881                && manager.tier2_builder_state(InspectCategory::Duplicates)
5882                    == InspectBuilderState::Absent
5883            {
5884                break;
5885            }
5886            assert!(
5887                Instant::now() < deadline,
5888                "a reuse worker that panics before the completion router must still clear the builder registry"
5889            );
5890            std::thread::sleep(Duration::from_millis(10));
5891        }
5892    }
5893
5894    fn generated_unused_exports_fixture() -> (tempfile::TempDir, PathBuf, Vec<PathBuf>) {
5895        let dir = tempfile::tempdir().expect("tempdir");
5896        let root = dir.path().to_path_buf();
5897        let files = [
5898            (
5899                "src/hand.ts",
5900                "export function handUnused() {}
5901",
5902            ),
5903            (
5904                "gen/schema_pb.ts",
5905                "export function generatedPathUnused() {}
5906",
5907            ),
5908            (
5909                "src/banner.ts",
5910                "// Code generated by fixture. DO NOT EDIT.
5911export function bannerUnused() {}
5912",
5913            ),
5914        ];
5915        let paths = files
5916            .iter()
5917            .map(|(relative, contents)| {
5918                let path = root.join(relative);
5919                if let Some(parent) = path.parent() {
5920                    std::fs::create_dir_all(parent).expect("create parent");
5921                }
5922                std::fs::write(&path, contents).expect("write fixture file");
5923                std::fs::canonicalize(path).expect("canonical fixture path")
5924            })
5925            .collect::<Vec<_>>();
5926        (
5927            dir,
5928            std::fs::canonicalize(root).expect("canonical root"),
5929            paths,
5930        )
5931    }
5932
5933    fn unused_exports_job(root: &Path, scope_files: Vec<PathBuf>) -> InspectJob {
5934        use crate::config::Config;
5935        use crate::parser::SymbolCache;
5936        use std::sync::RwLock;
5937
5938        InspectJob {
5939            job_id: 1,
5940            key: JobKey::for_project_category(InspectCategory::UnusedExports),
5941            category: InspectCategory::UnusedExports,
5942            scope_files,
5943            project_root: root.to_path_buf(),
5944            inspect_dir: root.join(".aft-cache").join("inspect"),
5945            config: Arc::new(Config {
5946                project_root: Some(root.to_path_buf()),
5947                ..Config::default()
5948            }),
5949            symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
5950            inspect_writer: true,
5951            callgraph_writer: true,
5952            callgraph_snapshot: None,
5953        }
5954    }
5955
5956    #[test]
5957    fn unused_exports_oxc_cached_rollup_preserves_generated_split() {
5958        let (_dir, root, paths) = generated_unused_exports_fixture();
5959        let job = unused_exports_job(&root, paths.clone());
5960        let entry_points = crate::inspect::entry_points::resolve_entry_points(&root);
5961        let oxc_result = crate::inspect::oxc_engine::analyze_files(
5962            &root,
5963            &paths,
5964            AnalyzeOptions {
5965                entry_points: Vec::new(),
5966                public_api_files: entry_points.public_api_files(),
5967                executable_root_exports: entry_points.executable_root_exports(),
5968                force_reparse_files: Vec::new(),
5969                entry_reachability: false,
5970            },
5971        )
5972        .expect("oxc analyze succeeds");
5973        let fresh = crate::inspect::scanners::unused_exports::run_unused_exports_scan_with_oxc(
5974            &job,
5975            Some(&oxc_result),
5976        )
5977        .outcome
5978        .expect("fresh scan succeeds");
5979
5980        let rolled_up = roll_up_unused_exports_contributions(
5981            &job,
5982            &fresh.contributions,
5983            Some(MAX_DRILL_DOWN_ITEMS),
5984        );
5985
5986        assert_eq!(
5987            rolled_up, fresh.aggregate,
5988            "cached rollup must match fresh scan"
5989        );
5990        assert_eq!(rolled_up["count"], 1, "{rolled_up:#}");
5991        assert_eq!(rolled_up["generated_count"], 2, "{rolled_up:#}");
5992        assert_eq!(rolled_up["total_count"], 3, "{rolled_up:#}");
5993    }
5994
5995    #[test]
5996    fn unused_exports_cached_generated_state_avoids_reprobe_with_legacy_fallback() {
5997        let (_dir, root, paths) = generated_unused_exports_fixture();
5998        let job = unused_exports_job(&root, paths.clone());
5999        let entry_points = crate::inspect::entry_points::resolve_entry_points(&root);
6000        let oxc_result = crate::inspect::oxc_engine::analyze_files(
6001            &root,
6002            &paths,
6003            AnalyzeOptions {
6004                entry_points: Vec::new(),
6005                public_api_files: entry_points.public_api_files(),
6006                executable_root_exports: entry_points.executable_root_exports(),
6007                force_reparse_files: Vec::new(),
6008                entry_reachability: false,
6009            },
6010        )
6011        .expect("oxc analyze succeeds");
6012        let fresh = crate::inspect::scanners::unused_exports::run_unused_exports_scan_with_oxc(
6013            &job,
6014            Some(&oxc_result),
6015        )
6016        .outcome
6017        .expect("fresh scan succeeds");
6018        let mut contributions = fresh.contributions;
6019        let handwritten = contributions
6020            .iter_mut()
6021            .find(|contribution| contribution.file_path.ends_with("src/hand.ts"))
6022            .expect("handwritten contribution");
6023        handwritten.contribution["generated"] = json!(false);
6024
6025        crate::inspect::generated::reset_file_probe_count_for_debug(&root);
6026        let explicit_cached =
6027            roll_up_unused_exports_contributions(&job, &contributions, Some(MAX_DRILL_DOWN_ITEMS));
6028        assert_eq!(explicit_cached, fresh.aggregate);
6029        assert_eq!(
6030            crate::inspect::generated::file_probe_count_for_debug(&root),
6031            0,
6032            "an explicit cached generated=false must not probe the file again"
6033        );
6034
6035        let generated_banner = contributions
6036            .iter_mut()
6037            .find(|contribution| contribution.file_path.ends_with("src/banner.ts"))
6038            .expect("generated banner contribution");
6039        generated_banner
6040            .contribution
6041            .as_object_mut()
6042            .expect("contribution object")
6043            .remove("generated");
6044        crate::inspect::generated::reset_file_probe_count_for_debug(&root);
6045        let legacy_cached =
6046            roll_up_unused_exports_contributions(&job, &contributions, Some(MAX_DRILL_DOWN_ITEMS));
6047        assert_eq!(legacy_cached, fresh.aggregate);
6048        assert_eq!(
6049            crate::inspect::generated::file_probe_count_for_debug(&root),
6050            1,
6051            "a legacy contribution without generated must probe and recover its classification"
6052        );
6053    }
6054
6055    #[test]
6056    fn inspect_callgraph_open_waits_out_transient_sqlite_writer_lock() {
6057        let dir = write_ts_project(3);
6058        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
6059        let inspect_dir = root.join(".aft-cache").join("inspect");
6060        let callgraph_dir =
6061            callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
6062        let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
6063        let (store, _) =
6064            CallGraphStore::cold_build_with_lease(callgraph_dir.clone(), root.clone(), &files)
6065                .expect("publish initial generation");
6066        let sqlite_path = store.sqlite_path().to_path_buf();
6067        drop(store);
6068
6069        let blocker = rusqlite::Connection::open(&sqlite_path).expect("open blocking connection");
6070        blocker
6071            .execute_batch(
6072                "PRAGMA journal_mode=DELETE;
6073                 BEGIN EXCLUSIVE;
6074                 UPDATE meta SET v = v WHERE k = 'ready';",
6075            )
6076            .expect("hold exclusive write transaction");
6077        let (started_tx, started_rx) = std::sync::mpsc::channel();
6078        let open = std::thread::spawn(move || {
6079            started_tx.send(()).expect("signal inspect open start");
6080            open_or_build_blocking_callgraph_store(callgraph_dir, root, true, &files)
6081        });
6082        started_rx.recv().expect("inspect open thread started");
6083        std::thread::sleep(Duration::from_millis(100));
6084        blocker.execute_batch("COMMIT").expect("release write lock");
6085
6086        assert!(
6087            open.join()
6088                .expect("inspect open thread joined")
6089                .expect("transient contention must stay on the Building/retry path")
6090                .is_some(),
6091            "inspect must reopen the ready callgraph instead of failing terminally"
6092        );
6093    }
6094
6095    #[test]
6096    fn callgraph_snapshot_reports_unavailable_when_store_disabled() {
6097        let dir = write_ts_project(3);
6098        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
6099        let inspect_dir = root.join(".aft-cache").join("inspect");
6100
6101        let snapshot =
6102            build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, false), false);
6103
6104        assert!(
6105            snapshot.is_none(),
6106            "dead_code must not rebuild the legacy graph when the store is disabled"
6107        );
6108    }
6109
6110    #[test]
6111    fn callgraph_snapshot_reports_unavailable_when_store_not_ready() {
6112        let dir = write_ts_project(3);
6113        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
6114        let inspect_dir = root.join(".aft-cache").join("inspect");
6115        let callgraph_dir =
6116            callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
6117        let _store = CallGraphStore::open(callgraph_dir, root.clone()).expect("open empty store");
6118
6119        let snapshot =
6120            build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true), false);
6121
6122        assert!(
6123            snapshot.is_none(),
6124            "a cold/mid-build store must surface callgraph_unavailable instead of rebuilding inline"
6125        );
6126    }
6127
6128    #[test]
6129    fn suspended_callgraph_build_sets_distinct_builder_state() {
6130        let dir = write_ts_project(3);
6131        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
6132        let inspect_dir = root.join(".aft-cache").join("inspect");
6133        let callgraph_dir =
6134            callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
6135        let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
6136        let key = crate::build_breaker::BreakerKey::new(
6137            root.display().to_string(),
6138            crate::build_breaker::BuildDomain::CallgraphCold,
6139            crate::callgraph_store::callgraph_corpus_fingerprint_for_test(&root, &files)
6140                .expect("corpus fingerprint"),
6141        );
6142        let breaker = crate::build_breaker::BuildDeathBreaker::open(
6143            callgraph_dir.join("build-breaker.sqlite"),
6144        )
6145        .expect("open breaker");
6146        let now = SystemTime::now()
6147            .duration_since(UNIX_EPOCH)
6148            .expect("system time")
6149            .as_millis() as u64;
6150        for _ in 0..3 {
6151            let crate::build_breaker::BreakerAdmission::Admitted(attempt) =
6152                breaker.admit_at(&key, 0, now).expect("admit build")
6153            else {
6154                panic!("early suspension before the threshold");
6155            };
6156            breaker
6157                .record_attributed_death_at(&key, &attempt.attempt_id, 0, 0, now)
6158                .expect("record death");
6159        }
6160
6161        let manager = InspectManager::new();
6162        let job = snapshot_job(&root, &inspect_dir, true);
6163        assert!(manager
6164            .build_tier2_callgraph_snapshot_with_refresh(&job, true, true, &files)
6165            .is_none());
6166        assert_eq!(
6167            manager.tier2_builder_state(InspectCategory::DeadCode),
6168            InspectBuilderState::Suspended
6169        );
6170        let detail = manager.tier2_builder_state_detail(InspectCategory::DeadCode);
6171        assert!(detail.starts_with("suspended domain=callgraph_cold deaths=3 age_s="));
6172        assert!(detail.ends_with("reason=zero_credit_death_limit"));
6173    }
6174
6175    #[test]
6176    fn readonly_tier2_projection_keeps_generation_pinned_through_concurrent_gc() {
6177        let dir = write_ts_project(3);
6178        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
6179        let inspect_dir = root.join(".aft-cache").join("inspect");
6180        let callgraph_dir =
6181            callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
6182        let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
6183        let (store, _) =
6184            CallGraphStore::cold_build_with_lease(callgraph_dir.clone(), root.clone(), &files)
6185                .expect("initial generation");
6186        let initial_generation = store.sqlite_path().to_path_buf();
6187        drop(store);
6188        let project_key = crate::search_index::artifact_cache_key(&root);
6189        crate::root_cache::enable_writer_lease_acquisition_counts_for_test();
6190        // Delta-based counting: the enable flag is process-global and another
6191        // parallel test may have switched it on before this test's own setup
6192        // acquired its cold-build lease, so the absolute count is
6193        // enable-order-dependent. Only acquisitions inside the observed window
6194        // below are this assertion's business.
6195        let lease_count_before = crate::root_cache::writer_lease_acquisition_count_for_test(
6196            crate::root_cache::RootCacheDomain::Callgraph,
6197            &project_key,
6198            &root,
6199        );
6200
6201        let root_for_observer = root.clone();
6202        let dir_for_observer = callgraph_dir.clone();
6203        let files_for_observer = files.clone();
6204        crate::callgraph_store::set_projection_before_open_observer(Some(Arc::new(
6205            move |projected_path| {
6206                for _ in 0..3 {
6207                    let (published, _) = CallGraphStore::cold_build_with_lease(
6208                        dir_for_observer.clone(),
6209                        root_for_observer.clone(),
6210                        &files_for_observer,
6211                    )
6212                    .expect("concurrent generation publication");
6213                    drop(published);
6214                }
6215                assert!(
6216                    projected_path.is_file(),
6217                    "the tier2 reader marker must pin the selected generation through GC"
6218                );
6219            },
6220        )));
6221        let mut job = snapshot_job(&root, &inspect_dir, true);
6222        job.callgraph_writer = false;
6223
6224        let snapshot =
6225            build_tier2_callgraph_snapshot_with_refresh(&job, false, &[root.join("mod0.ts")]);
6226        crate::callgraph_store::set_projection_before_open_observer(None);
6227
6228        assert!(snapshot.is_some());
6229        assert!(initial_generation.is_file());
6230        assert_eq!(
6231            crate::root_cache::writer_lease_acquisition_count_for_test(
6232                crate::root_cache::RootCacheDomain::Callgraph,
6233                &project_key,
6234                &root,
6235            ) - lease_count_before,
6236            3,
6237            "only the three observer publications may acquire a writer lease; tier2 must stay read-only"
6238        );
6239    }
6240
6241    #[test]
6242    fn direct_callgraph_snapshot_does_not_cold_rebuild_when_store_needs_rebuild() {
6243        let dir = write_ts_project(3);
6244        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
6245        let inspect_dir = root.join(".aft-cache").join("inspect");
6246        let callgraph_dir =
6247            callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
6248        let store = CallGraphStore::open(callgraph_dir.clone(), root.clone()).expect("open store");
6249        let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
6250        store.cold_build(&files).expect("cold build store");
6251        let sqlite_path = store.sqlite_path().to_path_buf();
6252        drop(store);
6253
6254        let still_existing_previous_root = root.with_file_name("previous-root-still-exists");
6255        std::fs::create_dir_all(&still_existing_previous_root).expect("create previous root");
6256        let conn = rusqlite::Connection::open(&sqlite_path).expect("open store sqlite");
6257        conn.execute(
6258            "UPDATE backend_file_state SET workspace_root = ?1",
6259            rusqlite::params![still_existing_previous_root.display().to_string()],
6260        )
6261        .expect("force root repair rebuild state");
6262        drop(conn);
6263
6264        let snapshot =
6265            build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true), false)
6266                .expect("readonly snapshot should avoid cold-rebuilding the store");
6267
6268        assert_eq!(snapshot.files.len(), 3);
6269        let conn = rusqlite::Connection::open(&sqlite_path).expect("reopen store sqlite");
6270        let stored_root: String = conn
6271            .query_row(
6272                "SELECT workspace_root FROM backend_file_state LIMIT 1",
6273                [],
6274                |row| row.get(0),
6275            )
6276            .expect("read stored root");
6277        assert_eq!(
6278            stored_root,
6279            still_existing_previous_root.display().to_string(),
6280            "direct inspect must not cold-rebuild or re-root a read-only snapshot"
6281        );
6282    }
6283
6284    #[test]
6285    fn callgraph_snapshot_reads_ready_callgraph_store() {
6286        let dir = write_ts_project(3);
6287        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
6288        let inspect_dir = root.join(".aft-cache").join("inspect");
6289        let callgraph_dir =
6290            callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
6291        let store = CallGraphStore::open(callgraph_dir, root.clone()).expect("open store");
6292        let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
6293        store.cold_build(&files).expect("cold build store");
6294
6295        let snapshot =
6296            build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true), false)
6297                .expect("ready store snapshot");
6298
6299        assert_eq!(snapshot.files.len(), 3);
6300        assert_eq!(snapshot.exported_symbols.len(), 3);
6301    }
6302
6303    #[test]
6304    fn path_identity_mismatch_is_a_named_dead_code_terminal_gap() {
6305        let dir = write_ts_project(1);
6306        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
6307        let inspect_dir = root.join(".aft-cache").join("inspect");
6308        let callgraph_dir =
6309            callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
6310        let source = root.join("mod0.ts");
6311        let foreign_dir = tempfile::tempdir().expect("foreign tempdir");
6312        let foreign = foreign_dir.path().join("foreign.ts");
6313        std::fs::write(&foreign, "export function foreign() {}\n").expect("write foreign source");
6314        let store = CallGraphStore::open(callgraph_dir, root.clone()).expect("open store");
6315        store.cold_build(&[source]).expect("cold build store");
6316        let error = store
6317            .refresh_files(&[foreign.clone()])
6318            .expect_err("foreign watcher path cannot be assigned a store-relative key");
6319        assert!(matches!(
6320            error,
6321            CallGraphStoreError::PathIdentityMismatch { .. }
6322        ));
6323        drop(store);
6324
6325        let job = snapshot_job(&root, &inspect_dir, true);
6326        let reason = callgraph_path_identity_gap(&job).expect("durable path identity gap");
6327        assert!(reason.contains("callgraph_path_identity_mismatch"));
6328        assert!(reason.contains(&foreign.display().to_string()));
6329        let aggregate =
6330            crate::inspect::scanners::dead_code::callgraph_unavailable_aggregate_with_reason(
6331                1,
6332                Some(&reason),
6333            );
6334        assert_eq!(
6335            aggregate["notes"],
6336            serde_json::json!(["callgraph_unavailable", "callgraph_path_identity_mismatch"])
6337        );
6338        assert_eq!(aggregate["callgraph_unavailable_reason"], reason);
6339    }
6340
6341    #[test]
6342    fn stale_callgraph_store_refreshes_inline_when_refresh_worker_does_not_run() {
6343        let dir = write_ts_project(2);
6344        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
6345        let inspect_dir = root.join(".aft-cache").join("inspect");
6346        let callgraph_dir =
6347            callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
6348        let project_key = crate::search_index::artifact_cache_key(&root);
6349        crate::root_cache::configure_artifact_access(&root, &project_key, false);
6350        let store = CallGraphStore::open(callgraph_dir.clone(), root.clone()).expect("open store");
6351        let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
6352        store.cold_build(&files).expect("cold build store");
6353        store
6354            .mark_files_stale(&files)
6355            .expect("mark published store stale");
6356        drop(store);
6357
6358        let job = snapshot_job(&root, &inspect_dir, true);
6359        let manager = InspectManager::new();
6360        assert!(
6361            !manager.callgraph_ready_for_snapshot(&InspectSnapshot::new(
6362                root.clone(),
6363                inspect_dir.clone(),
6364                Arc::clone(&job.config),
6365                Arc::clone(&job.symbol_cache),
6366            )),
6367            "a store with leftover stale rows must not look callgraph-ready"
6368        );
6369
6370        let snapshot = manager
6371            .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6372            .expect("dead_code must refresh stale rows inline when the refresh worker never ran");
6373        assert_eq!(snapshot.files.len(), 2);
6374
6375        let ready = InspectSnapshot::new(
6376            root,
6377            inspect_dir,
6378            Arc::clone(&job.config),
6379            Arc::clone(&job.symbol_cache),
6380        );
6381        assert!(
6382            manager.callgraph_ready_for_snapshot(&ready),
6383            "after the inline refresh, callgraph_ready must agree with a successful projection"
6384        );
6385    }
6386
6387    #[test]
6388    fn callgraph_ready_and_builder_projection_agree_on_stale_rows() {
6389        let dir = write_ts_project(1);
6390        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
6391        let inspect_dir = root.join(".aft-cache").join("inspect");
6392        let callgraph_dir =
6393            callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
6394        let project_key = crate::search_index::artifact_cache_key(&root);
6395        crate::root_cache::configure_artifact_access(&root, &project_key, false);
6396        let store = CallGraphStore::open(callgraph_dir, root.clone()).expect("open store");
6397        let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
6398        store.cold_build(&files).expect("cold build store");
6399        let sqlite_path = store.sqlite_path().to_path_buf();
6400        drop(store);
6401
6402        let job = snapshot_job(&root, &inspect_dir, true);
6403        let snapshot = InspectSnapshot::new(
6404            root.clone(),
6405            inspect_dir.clone(),
6406            Arc::clone(&job.config),
6407            Arc::clone(&job.symbol_cache),
6408        );
6409        let manager = InspectManager::new();
6410        assert!(
6411            manager.callgraph_ready_for_snapshot(&snapshot),
6412            "a fresh store must be ready for both the phase check and projection"
6413        );
6414        project_dead_code_snapshot(&sqlite_path).expect("fresh store should project");
6415
6416        let store = CallGraphStore::open_ready_no_rebuild(
6417            callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir"),
6418            root.clone(),
6419        )
6420        .expect("reopen writer")
6421        .expect("ready writer");
6422        store.mark_files_stale(&files).expect("mark stale");
6423        drop(store);
6424
6425        assert!(
6426            !manager.callgraph_ready_for_snapshot(&snapshot),
6427            "callgraph_ready must use the same stale-row predicate as dead_code projection"
6428        );
6429        let error =
6430            project_dead_code_snapshot(&sqlite_path).expect_err("stale rows must block projection");
6431        match error {
6432            CallGraphStoreError::Unavailable(message) => {
6433                assert_eq!(message, "callgraph has stale files pending refresh")
6434            }
6435            other => panic!("expected Unavailable, got {other:?}"),
6436        }
6437    }
6438
6439    #[test]
6440    fn failed_builder_attempt_history_uses_locked_refusal_detail() {
6441        let manager = InspectManager::new();
6442        let unavailable = JobOutcome::Fresh {
6443            payload: crate::inspect::scanners::dead_code::callgraph_unavailable_aggregate(0),
6444        };
6445        manager
6446            .record_tier2_attempt_outcome_for_test(InspectCategory::DeadCode, unavailable.clone());
6447        let first = manager.tier2_builder_state_detail(InspectCategory::DeadCode);
6448        let first_at = first
6449            .rsplit("first at ")
6450            .next()
6451            .and_then(|tail| tail.strip_suffix(')'))
6452            .expect("first failure detail includes first-at unix time");
6453        for _ in 1..7 {
6454            manager.record_tier2_attempt_outcome_for_test(
6455                InspectCategory::DeadCode,
6456                unavailable.clone(),
6457            );
6458        }
6459        assert_eq!(
6460            manager.tier2_builder_state_detail(InspectCategory::DeadCode),
6461            format!("last attempt failed: callgraph_unavailable (attempt 7, first at {first_at})")
6462        );
6463        assert_eq!(
6464            manager.tier2_builder_state(InspectCategory::DeadCode),
6465            InspectBuilderState::Absent,
6466            "a finished failure must not keep the registry in an in-flight state"
6467        );
6468        assert_eq!(
6469            manager.try_tier2_builder_busy(),
6470            Some(false),
6471            "failed-attempt history must not look like a live rebuild"
6472        );
6473
6474        manager.record_tier2_attempt_outcome_for_test(
6475            InspectCategory::DeadCode,
6476            JobOutcome::Fresh {
6477                payload: serde_json::json!({ "callgraph_available": true, "count": 0 }),
6478            },
6479        );
6480        assert_eq!(
6481            manager.tier2_builder_state_detail(InspectCategory::DeadCode),
6482            InspectBuilderState::Absent.as_str()
6483        );
6484    }
6485
6486    #[test]
6487    fn generation_keyed_projection_cache_reuses_unchanged_snapshot() {
6488        let (_dir, _root, _inspect_dir, job) = published_projection_fixture();
6489        let manager = InspectManager::new();
6490        let (projections, _observer_reset) = count_projections();
6491
6492        let first = manager
6493            .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6494            .expect("first projection");
6495        let second = manager
6496            .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6497            .expect("cached projection");
6498
6499        assert!(
6500            Arc::ptr_eq(&first, &second),
6501            "an unchanged generation and write revision must reuse the projected Arc"
6502        );
6503        assert_eq!(
6504            projections.load(std::sync::atomic::Ordering::SeqCst),
6505            1,
6506            "two dead-code scans without a callgraph mutation must project once"
6507        );
6508        let memory = manager.callgraph_projection_estimated_memory();
6509        assert_eq!(
6510            memory.counts["callgraph_projection_snapshots"], 1,
6511            "the resident projection must be attributed to the root"
6512        );
6513        assert!(
6514            memory.estimated_bytes.unwrap_or_default() > 0,
6515            "a populated projection must report an estimated residency"
6516        );
6517    }
6518
6519    #[test]
6520    fn stale_store_never_reuses_an_equal_revision_projection() {
6521        let (_dir, root, inspect_dir, job) = published_projection_fixture();
6522        let manager = InspectManager::new();
6523        manager
6524            .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6525            .expect("initial projection");
6526        let writer = CallGraphStore::open_ready_no_rebuild(
6527            callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("callgraph dir"),
6528            root.clone(),
6529        )
6530        .expect("open writer")
6531        .expect("ready writer");
6532        writer
6533            .mark_files_stale(&[root.join("src/target.ts")])
6534            .expect("mark target stale");
6535        drop(writer);
6536
6537        assert!(
6538            manager
6539                .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6540                .is_none(),
6541            "a stale marker must block a cache hit even though it does not advance the graph revision"
6542        );
6543    }
6544
6545    fn assert_fresh_refresh_reuses_projection(job: InspectJob, target: PathBuf) {
6546        let manager = InspectManager::new();
6547        let (projections, _observer_reset) = count_projections();
6548        let started = Instant::now();
6549        let first = manager
6550            .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6551            .expect("initial projection");
6552        let cold_ms = started.elapsed().as_secs_f64() * 1_000.0;
6553        let callgraph_dir =
6554            callgraph_store_dir_from_inspect_dir(&job.inspect_dir, &job.project_root)
6555                .expect("copied graph directory");
6556        let writer = CallGraphStore::open_ready_no_rebuild(callgraph_dir, job.project_root.clone())
6557            .expect("open writer")
6558            .expect("ready graph");
6559        let revision_before = writer
6560            .projection_write_revision()
6561            .expect("initial revision");
6562        let started = Instant::now();
6563        let (_, profile) = writer
6564            .refresh_files_profiled(&[target])
6565            .expect("already-fresh refresh");
6566        let revision_after = writer
6567            .projection_write_revision()
6568            .expect("revision after refresh");
6569        drop(writer);
6570        let second = manager
6571            .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6572            .expect("projection after fresh refresh");
6573        eprintln!("fresh_refresh_projection initial_ms={cold_ms:.3} refresh_and_snapshot_ms={:.3} index_loads={} projections={} outbound_rows={}",
6574            started.elapsed().as_secs_f64() * 1_000.0,
6575            profile.index_loads, projections.load(std::sync::atomic::Ordering::SeqCst),
6576            first.outbound_calls.len());
6577        assert_eq!(
6578            profile.index_loads, 0,
6579            "a fresh refresh must not load the corpus resolver index"
6580        );
6581        assert_eq!(
6582            revision_after, revision_before,
6583            "no written rows means no projection invalidation"
6584        );
6585        assert_eq!(
6586            projections.load(std::sync::atomic::Ordering::SeqCst),
6587            1,
6588            "a duplicate refresh must not re-project the corpus"
6589        );
6590        assert!(Arc::ptr_eq(&first, &second));
6591    }
6592
6593    #[test]
6594    fn projection_cache_reuses_snapshot_after_already_fresh_refresh() {
6595        let (_dir, root, _inspect_dir, job) = published_projection_fixture();
6596        assert_fresh_refresh_reuses_projection(job, root.join("src/target.ts"));
6597    }
6598
6599    #[test]
6600    fn projection_cache_invalidates_after_deleting_a_file_without_dependents() {
6601        let (_dir, root, inspect_dir, job) = published_projection_fixture();
6602        let manager = InspectManager::new();
6603        let (projections, _observer_reset) = count_projections();
6604        let first = manager
6605            .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6606            .expect("initial projection");
6607        // Snapshot paths are verbatim-stripped by the projection's normalizer;
6608        // mirror it (as the other projection tests do) so the expectation
6609        // matches on Windows, where the canonical root is verbatim.
6610        let target = canonicalize_for_snapshot(&root.join("src/main.ts"));
6611        assert!(first.files.contains(&target));
6612        let graph_dir = callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).unwrap();
6613        let writer = CallGraphStore::open_ready_no_rebuild(graph_dir, root.clone())
6614            .expect("open writer")
6615            .expect("ready graph");
6616        std::fs::remove_file(&target).expect("remove unreferenced entry file");
6617        let (stats, profile) = writer
6618            .refresh_files_profiled(std::slice::from_ref(&target))
6619            .expect("refresh deletion");
6620        assert_eq!(stats.deleted_files, vec!["src/main.ts"]);
6621        assert_eq!(
6622            profile.index_loads, 0,
6623            "deletion without surviving callers needs no resolver"
6624        );
6625        drop(writer);
6626        let second = manager
6627            .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6628            .expect("projection after deletion");
6629        assert!(!second.files.contains(&target));
6630        assert_eq!(
6631            projections.load(std::sync::atomic::Ordering::SeqCst),
6632            2,
6633            "row deletion must invalidate even when no resolver was loaded"
6634        );
6635    }
6636
6637    #[test]
6638    #[ignore = "offline probe requires a normalized production graph copy below target"]
6639    fn profile_fresh_refresh_projection_on_store_copy() {
6640        let project = Path::new(env!("CARGO_MANIFEST_DIR"))
6641            .parent()
6642            .unwrap()
6643            .parent()
6644            .unwrap()
6645            .canonicalize()
6646            .expect("checkout root");
6647        let storage = PathBuf::from(
6648            std::env::var_os("AFT_CPU_HUNT_STORAGE_COPY")
6649                .expect("set AFT_CPU_HUNT_STORAGE_COPY to an offline copied storage root"),
6650        )
6651        .canonicalize()
6652        .expect("copied storage");
6653        assert!(
6654            storage.starts_with(project.join("target")),
6655            "never probe a live artifact"
6656        );
6657        let target = project.join("crates/aft/tests/engine_comparator_test.rs");
6658        let inspect_dir = storage.join("inspect");
6659        let key = crate::search_index::artifact_cache_key(&project);
6660        crate::root_cache::configure_artifact_access(&project, &key, false);
6661        let graph_dir = callgraph_store_dir_from_inspect_dir(&inspect_dir, &project).unwrap();
6662        let writer = CallGraphStore::open_ready_no_rebuild(graph_dir, project.clone())
6663            .expect("open copied graph")
6664            .expect("copied graph ready");
6665        writer
6666            .refresh_files(std::slice::from_ref(&target))
6667            .expect("normalize copied freshness");
6668        drop(writer);
6669        let mut job = snapshot_job(&project, &inspect_dir, true);
6670        job.callgraph_writer = false;
6671        assert_fresh_refresh_reuses_projection(job, target);
6672    }
6673
6674    #[test]
6675    fn projection_cache_invalidates_on_in_place_refresh_for_readonly_scans() {
6676        let (_dir, root, inspect_dir, job) = published_projection_fixture();
6677        let unrelated = root.join("src/unrelated.ts");
6678        write_projection_cache_file(&unrelated, &"unrelated();\n".repeat(100));
6679        let store = CallGraphStore::open_ready_no_rebuild(
6680            callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).unwrap(),
6681            root.clone(),
6682        )
6683        .unwrap()
6684        .unwrap();
6685        store.refresh_files(&[unrelated]).unwrap();
6686        drop(store);
6687        let manager = InspectManager::new();
6688        let (projections, _observer_reset) = count_projections();
6689        let target = root.join("src/target.ts");
6690        let callgraph_dir =
6691            callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("callgraph dir");
6692
6693        let first = manager
6694            .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6695            .expect("initial projection");
6696        let writer = CallGraphStore::open_ready_no_rebuild(callgraph_dir, root.clone())
6697            .expect("open writer")
6698            .expect("ready writer");
6699        let revision_before = writer
6700            .projection_write_revision()
6701            .expect("read initial revision")
6702            .expect("new stores write a projection revision");
6703        write_projection_cache_file(&target, "export function secondTarget() {}\n");
6704        writer
6705            .refresh_files(&[target])
6706            .expect("refresh changed target");
6707        let revision_after = writer
6708            .projection_write_revision()
6709            .expect("read refreshed revision")
6710            .expect("refreshed stores retain a projection revision");
6711        assert!(
6712            revision_after > revision_before,
6713            "the in-place refresh must advance the durable cache identity"
6714        );
6715        drop(writer);
6716
6717        crate::callgraph_store::take_projection_work();
6718        let second = manager
6719            .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6720            .expect("refreshed readonly projection");
6721        let (full_projections, outbound_rows_read) = crate::callgraph_store::take_projection_work();
6722        eprintln!("incremental_work full_projections={full_projections} outbound_rows_read={outbound_rows_read}");
6723        assert_eq!(full_projections, 0);
6724        assert_eq!(outbound_rows_read, 1);
6725        assert!(
6726            !first
6727                .exported_symbols
6728                .iter()
6729                .any(|export| export.symbol == "secondTarget"),
6730            "the initial snapshot must not already contain the refreshed export"
6731        );
6732        assert!(
6733            second
6734                .exported_symbols
6735                .iter()
6736                .any(|export| export.symbol == "secondTarget"),
6737            "the readonly scan must expose graph data from the refreshed store"
6738        );
6739        assert_eq!(
6740            projections.load(std::sync::atomic::Ordering::SeqCst),
6741            2,
6742            "an in-place refresh must force the next scan to re-project"
6743        );
6744    }
6745
6746    #[test]
6747    fn projection_cache_invalidates_when_cold_build_publishes_new_generation() {
6748        let (_dir, root, inspect_dir, job) = published_projection_fixture();
6749        let manager = InspectManager::new();
6750        let (projections, _observer_reset) = count_projections();
6751        let target = root.join("src/target.ts");
6752        let callgraph_dir =
6753            callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("callgraph dir");
6754
6755        let first = manager
6756            .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6757            .expect("initial projection");
6758        let before = CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
6759            .expect("open initial reader")
6760            .expect("initial reader");
6761        let revision_before = before
6762            .projection_write_revision()
6763            .expect("read initial revision")
6764            .expect("new stores write a projection revision");
6765        drop(before);
6766        write_projection_cache_file(&target, "export function coldBuildTarget() {}\n");
6767        let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
6768        let (published, _) =
6769            CallGraphStore::cold_build_with_lease(callgraph_dir, root.clone(), &files)
6770                .expect("publish replacement generation");
6771        let revision_after = published
6772            .projection_write_revision()
6773            .expect("read replacement revision")
6774            .expect("replacement stores write a projection revision");
6775        assert_eq!(
6776            revision_after, revision_before,
6777            "cold builds begin with the same revision, so this assertion exercises the generation half of the cache identity"
6778        );
6779        drop(published);
6780
6781        let second = manager
6782            .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6783            .expect("replacement projection");
6784        assert!(
6785            !first
6786                .exported_symbols
6787                .iter()
6788                .any(|export| export.symbol == "coldBuildTarget"),
6789            "the initial snapshot must not already contain the replacement export"
6790        );
6791        assert!(
6792            second
6793                .exported_symbols
6794                .iter()
6795                .any(|export| export.symbol == "coldBuildTarget"),
6796            "the next scan must expose the generation published by the cold build"
6797        );
6798        assert_eq!(
6799            projections.load(std::sync::atomic::Ordering::SeqCst),
6800            2,
6801            "a new pointer generation must force the next scan to re-project"
6802        );
6803    }
6804
6805    #[test]
6806    fn idle_eviction_drops_generation_keyed_projection_cache() {
6807        let (_dir, _root, _inspect_dir, job) = published_projection_fixture();
6808        let manager = InspectManager::new();
6809        let (projections, _observer_reset) = count_projections();
6810
6811        let first = manager
6812            .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6813            .expect("initial projection");
6814        manager.evict_idle_caches();
6815        assert_eq!(
6816            manager.callgraph_projection_estimated_memory().counts
6817                ["callgraph_projection_snapshots"],
6818            0,
6819            "idle artifact eviction must release the root projection slot"
6820        );
6821        let second = manager
6822            .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6823            .expect("reloaded projection");
6824
6825        assert!(
6826            !Arc::ptr_eq(&first, &second),
6827            "eviction must drop the previous projection Arc"
6828        );
6829        assert_eq!(
6830            projections.load(std::sync::atomic::Ordering::SeqCst),
6831            2,
6832            "the next scan after idle eviction must reload the projection"
6833        );
6834    }
6835
6836    #[test]
6837    fn callgraph_snapshot_uses_ready_root_keyed_store() {
6838        let _git_env = crate::test_env::hermetic_git_env_guard();
6839        let dir = write_ts_project(3);
6840        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
6841        let storage_dir = root.join(".aft-cache");
6842        let inspect_dir = storage_dir
6843            .join("inspect")
6844            .join(crate::path_identity::project_scope_key(&root));
6845        let warm_callgraph_dir = storage_dir
6846            .join("callgraph")
6847            .join(artifact_cache_key_for_test(&root));
6848        let store = CallGraphStore::open(warm_callgraph_dir, root.clone()).expect("open store");
6849        let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
6850        store.cold_build(&files).expect("cold build store");
6851
6852        let snapshot =
6853            build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true), false)
6854                .expect("ready sibling store snapshot");
6855
6856        assert_eq!(snapshot.files.len(), 3);
6857        assert_eq!(snapshot.exported_symbols.len(), 3);
6858    }
6859
6860    #[test]
6861    fn dead_code_forced_deletion_refreshes_callgraph_store_before_rollup() {
6862        let dir = tempfile::tempdir().expect("tempdir");
6863        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
6864        write_fixture_file(
6865            &root,
6866            "package.json",
6867            r#"{"name":"dead-code-delete-refresh","type":"module","main":"src/main.ts"}"#,
6868            3_100_000_000,
6869        );
6870        write_fixture_file(
6871            &root,
6872            "src/main.ts",
6873            "export function main() {}\n",
6874            3_100_000_001,
6875        );
6876        write_fixture_file(
6877            &root,
6878            "src/dead.ts",
6879            "export function plantedDead() {}\n",
6880            3_100_000_002,
6881        );
6882
6883        let inspect_dir = root.join(".aft-cache").join("opencode").join("inspect");
6884        let callgraph_dir =
6885            callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
6886        let project_key = crate::search_index::artifact_cache_key(&root);
6887        crate::root_cache::configure_artifact_access(&root, &project_key, false);
6888        let store = CallGraphStore::open(callgraph_dir.clone(), root.clone()).expect("open store");
6889        let project_files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
6890        store.cold_build(&project_files).expect("cold build store");
6891        drop(store);
6892
6893        let config = Arc::new(crate::config::Config {
6894            project_root: Some(root.clone()),
6895            callgraph_store: true,
6896            ..crate::config::Config::default()
6897        });
6898        let symbol_cache = Arc::new(std::sync::RwLock::new(crate::parser::SymbolCache::new()));
6899        let snapshot = InspectSnapshot::new(
6900            root.clone(),
6901            inspect_dir.clone(),
6902            Arc::clone(&config),
6903            Arc::clone(&symbol_cache),
6904        );
6905        let manager = InspectManager::new();
6906        let initial_job =
6907            manager.tier2_reuse_job(snapshot.clone(), InspectCategory::DeadCode, None);
6908        let initial = manager
6909            .tier2_run_with_reuse_job_result_with_options(initial_job, Tier2ReuseOptions::default())
6910            .outcome
6911            .expect("initial dead_code scan succeeds")
6912            .aggregate;
6913        assert!(
6914            aggregate_has_file_symbol(&initial, "src/dead.ts", "plantedDead"),
6915            "initial scan should report the planted dead export: {initial:#}"
6916        );
6917
6918        let deleted = root.join("src/dead.ts");
6919        std::fs::remove_file(&deleted).expect("delete dead fixture");
6920        let delete_job = manager.tier2_reuse_job(snapshot, InspectCategory::DeadCode, None);
6921        let refreshed = manager
6922            .tier2_run_with_reuse_job_result_with_options(
6923                delete_job,
6924                Tier2ReuseOptions {
6925                    force_rescan_paths: [deleted.clone()].into_iter().collect(),
6926                    allow_callgraph_cold_build: true,
6927                    require_callgraph_snapshot: false,
6928                    interactive: false,
6929                },
6930            )
6931            .outcome
6932            .expect("delete refresh dead_code scan succeeds")
6933            .aggregate;
6934
6935        assert_eq!(
6936            refreshed
6937                .get("callgraph_available")
6938                .and_then(Value::as_bool),
6939            Some(true),
6940            "forced watcher paths must keep the callgraph-backed aggregate available: {refreshed:#}"
6941        );
6942        assert!(
6943            !aggregate_has_file_symbol(&refreshed, "src/dead.ts", "plantedDead"),
6944            "delete refresh should remove the planted dead export: {refreshed:#}"
6945        );
6946
6947        let store = CallGraphStore::open_ready_no_rebuild(callgraph_dir, root)
6948            .expect("open refreshed store")
6949            .expect("refreshed store is ready");
6950        let projected = project_dead_code_snapshot(store.sqlite_path()).expect("project snapshot");
6951        assert!(
6952            projected
6953                .files
6954                .iter()
6955                .all(|file| !file.ends_with("src/dead.ts")),
6956            "watcher deletion should be applied to the persisted callgraph store: {:#?}",
6957            projected.files
6958        );
6959    }
6960
6961    fn aggregate_has_file_symbol(aggregate: &Value, file: &str, symbol: &str) -> bool {
6962        aggregate
6963            .get("items")
6964            .and_then(Value::as_array)
6965            .is_some_and(|items| {
6966                items.iter().any(|item| {
6967                    item.get("file").and_then(Value::as_str) == Some(file)
6968                        && item.get("symbol").and_then(Value::as_str) == Some(symbol)
6969                })
6970            })
6971    }
6972
6973    // A scoped payload must not carry the project-wide `by_language` breakdown
6974    // alongside the recomputed in-scope count — that contradiction renders as
6975    // e.g. "Dead code: 1 (rust 214, ts 143)".
6976    #[test]
6977    fn scoped_filter_drops_project_wide_by_language() {
6978        let scope = JobScope::from_roots("/proj", vec![PathBuf::from("/proj/src/a")]);
6979        assert!(
6980            !scope.is_project_wide(),
6981            "scope must be non-project for test"
6982        );
6983        let payload = serde_json::json!({
6984            "count": 99,
6985            "by_language": { "rust": 214, "typescript": 143 },
6986            "items": [
6987                { "file": "/proj/src/a/x.rs", "symbol": "live" },
6988                { "file": "/proj/src/other/y.rs", "symbol": "out" },
6989            ],
6990        });
6991        let filtered = filter_payload_for_scope(payload, &scope);
6992        assert!(
6993            filtered.get("by_language").is_none(),
6994            "scoped payload must drop project-wide by_language: {filtered}"
6995        );
6996        // Count is recomputed to the in-scope items (only x.rs under src/a).
6997        assert_eq!(filtered.get("count").and_then(|v| v.as_u64()), Some(1));
6998    }
6999    #[cfg(debug_assertions)]
7000    #[test]
7001    fn tier2_read_cached_freshness_does_not_hash_unchanged_contributions() {
7002        let (_dir, manager, snapshot, scope, _files) = duplicate_cache_fixture();
7003        let fixture_root = snapshot.project_root.clone();
7004
7005        crate::cache_freshness::reset_hash_file_if_small_count_for_debug();
7006        crate::cache_freshness::reset_verify_file_strict_count_for_debug();
7007        assert_fresh(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
7008
7009        assert_eq!(
7010            crate::cache_freshness::verify_file_strict_count_under_for_debug(&fixture_root),
7011            0,
7012            "dispatch-thread inspect freshness must not use strict verification"
7013        );
7014        assert_eq!(
7015            crate::cache_freshness::hash_file_if_small_count_for_debug(),
7016            0,
7017            "unchanged contribution files must stay on the stat-only fast path"
7018        );
7019    }
7020
7021    #[cfg(debug_assertions)]
7022    #[test]
7023    fn tier2_read_cached_freshness_returns_byte_identical_cold_scan_aggregate() {
7024        let (_dir, manager, snapshot, scope, _files) = duplicate_uncached_fixture();
7025        let cold_payload = fresh_payload(manager.tier2_run_with_reuse(
7026            snapshot.clone(),
7027            InspectCategory::Duplicates,
7028            scope.clone(),
7029            None,
7030        ));
7031
7032        crate::cache_freshness::reset_hash_file_if_small_count_for_debug();
7033        crate::cache_freshness::reset_verify_file_strict_count_for_debug();
7034        let fixture_root = snapshot.project_root.clone();
7035        let warm_payload =
7036            fresh_payload(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
7037
7038        let cold_bytes = serde_json::to_vec(&cold_payload).expect("serialize cold aggregate");
7039        let warm_bytes = serde_json::to_vec(&warm_payload).expect("serialize warm aggregate");
7040        assert_eq!(
7041            warm_bytes, cold_bytes,
7042            "warm unchanged read must return the byte-identical aggregate as the cold scan"
7043        );
7044        assert_eq!(
7045            crate::cache_freshness::verify_file_strict_count_under_for_debug(&fixture_root),
7046            0,
7047            "dispatch-thread warm read must not use strict verification"
7048        );
7049        assert_eq!(
7050            crate::cache_freshness::hash_file_if_small_count_for_debug(),
7051            0,
7052            "warm unchanged read must not content-hash cached contribution files"
7053        );
7054    }
7055
7056    #[test]
7057    fn tier2_read_cached_freshness_detects_changed_added_and_deleted_files() {
7058        let (_dir, manager, snapshot, scope, _files) = duplicate_cache_fixture();
7059        write_fixture_file(
7060            &snapshot.project_root,
7061            "src/foo.ts",
7062            "export const foo = 101;\nexport const changed = true;\n",
7063            3_000_000_001,
7064        );
7065        assert_stale(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
7066
7067        let (_dir, manager, snapshot, scope, _files) = duplicate_cache_fixture();
7068        write_fixture_file(
7069            &snapshot.project_root,
7070            "src/added.ts",
7071            "export const added = 3;\n",
7072            3_000_000_002,
7073        );
7074        assert_stale(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
7075
7076        let (_dir, manager, snapshot, scope, files) = duplicate_cache_fixture();
7077        std::fs::remove_file(&files[0]).expect("delete cached contribution file");
7078        assert_stale(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
7079    }
7080
7081    fn duplicate_cache_fixture() -> (
7082        tempfile::TempDir,
7083        InspectManager,
7084        InspectSnapshot,
7085        JobScope,
7086        Vec<PathBuf>,
7087    ) {
7088        let (dir, manager, snapshot, scope, files) = duplicate_uncached_fixture();
7089        store_duplicate_cache(&manager, &snapshot, &files);
7090        (dir, manager, snapshot, scope, files)
7091    }
7092
7093    fn duplicate_uncached_fixture() -> (
7094        tempfile::TempDir,
7095        InspectManager,
7096        InspectSnapshot,
7097        JobScope,
7098        Vec<PathBuf>,
7099    ) {
7100        use crate::config::Config;
7101        use crate::parser::SymbolCache;
7102        use std::sync::RwLock;
7103
7104        let dir = tempfile::tempdir().expect("tempdir");
7105        let root = std::fs::canonicalize(dir.path()).expect("canonical fixture root");
7106        let files = vec![
7107            write_fixture_file(
7108                &root,
7109                "src/foo.ts",
7110                "export const fixture = () => 1;
7111export const shared = 1;
7112",
7113                3_000_000_000,
7114            ),
7115            write_fixture_file(
7116                &root,
7117                "src/bar.ts",
7118                "export const fixture = () => 1;
7119export const shared = 1;
7120",
7121                3_000_000_000,
7122            ),
7123        ];
7124        let inspect_dir = root.join(".aft-cache").join("inspect");
7125        let snapshot = InspectSnapshot::new(
7126            root.clone(),
7127            inspect_dir,
7128            Arc::new(Config {
7129                project_root: Some(root.clone()),
7130                ..Config::default()
7131            }),
7132            Arc::new(RwLock::new(SymbolCache::new())),
7133        );
7134        let scope = JobScope::for_project(root);
7135        let manager = InspectManager::new();
7136        (dir, manager, snapshot, scope, files)
7137    }
7138
7139    fn write_fixture_file(root: &Path, relative: &str, content: &str, mtime_secs: i64) -> PathBuf {
7140        let path = root.join(relative);
7141        if let Some(parent) = path.parent() {
7142            std::fs::create_dir_all(parent).expect("create fixture parent");
7143        }
7144        std::fs::write(&path, content).expect("write fixture file");
7145        filetime::set_file_mtime(&path, filetime::FileTime::from_unix_time(mtime_secs, 0))
7146            .expect("set fixture mtime");
7147        path
7148    }
7149
7150    fn store_duplicate_cache(
7151        manager: &InspectManager,
7152        snapshot: &InspectSnapshot,
7153        files: &[PathBuf],
7154    ) {
7155        let cache = manager
7156            .cache_for_snapshot(snapshot)
7157            .expect("open inspect cache");
7158        let contributions = files
7159            .iter()
7160            .map(|file| {
7161                let freshness = crate::cache_freshness::collect(file).expect("collect freshness");
7162                FileContribution::new(
7163                    InspectCategory::Duplicates,
7164                    file.clone(),
7165                    freshness,
7166                    serde_json::json!({
7167                        "file": relative_cache_key(&snapshot.project_root, file),
7168                        "fragments": [],
7169                    }),
7170                )
7171            })
7172            .collect::<Vec<_>>();
7173        cache
7174            .store_tier2_result(
7175                JobKey::for_project_category(InspectCategory::Duplicates),
7176                files,
7177                &contributions,
7178                serde_json::json!({
7179                    "count": 0,
7180                    "groups": [],
7181                    "scanned_files": files.len(),
7182                    "total_groups": 0,
7183                }),
7184            )
7185            .expect("store tier2 cache fixture");
7186    }
7187
7188    fn assert_fresh(outcome: JobOutcome) {
7189        let _ = fresh_payload(outcome);
7190    }
7191
7192    fn fresh_payload(outcome: JobOutcome) -> Value {
7193        match outcome {
7194            JobOutcome::Fresh { payload } => payload,
7195            other => panic!("expected fresh cached Tier-2 outcome, got {other:?}"),
7196        }
7197    }
7198
7199    fn assert_stale(outcome: JobOutcome) {
7200        match outcome {
7201            JobOutcome::Stale { .. } => {}
7202            other => panic!("expected stale cached Tier-2 outcome, got {other:?}"),
7203        }
7204    }
7205}
7206
7207#[cfg(test)]
7208mod dead_code_projection_tests {
7209    use super::*;
7210    use crate::callgraph::walk_project_files;
7211    use crate::callgraph_store::{project_dead_code_snapshot, CallGraphStore};
7212    use crate::config::Config;
7213    use crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR;
7214    use crate::inspect::scanners::DEFAULT_EXPORT_MARKER_KIND;
7215    use crate::parser::SymbolCache;
7216    use filetime::FileTime;
7217    use std::sync::atomic::{AtomicI64, Ordering as AtomicOrdering};
7218    use std::sync::RwLock;
7219
7220    static NEXT_MTIME: AtomicI64 = AtomicI64::new(1_900_000_000);
7221
7222    #[test]
7223    fn scoped_dead_code_rollup_uses_ready_callgraph_and_degrades_without_it() {
7224        let dir = tempfile::tempdir().expect("tempdir");
7225        write_projection_fixture(dir.path());
7226        let root = canonical_root(dir.path());
7227        let inspect_dir = root.join(".aft-cache").join("inspect");
7228        let callgraph_dir =
7229            callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
7230        let project_key = crate::search_index::artifact_cache_key(&root);
7231        crate::root_cache::configure_artifact_access(&root, &project_key, false);
7232        let store = CallGraphStore::open(callgraph_dir.clone(), root.clone()).expect("open store");
7233        let files = project_files(&root);
7234        store.cold_build(&files).expect("cold build store");
7235        let projected = project_dead_code_snapshot(store.sqlite_path()).expect("project snapshot");
7236        drop(store);
7237
7238        let config = Arc::new(Config {
7239            project_root: Some(root.clone()),
7240            callgraph_store: true,
7241            ..Config::default()
7242        });
7243        let symbol_cache = Arc::new(RwLock::new(SymbolCache::new()));
7244        let scan_job = InspectJob {
7245            job_id: 87,
7246            key: JobKey::for_project_category(InspectCategory::DeadCode),
7247            category: InspectCategory::DeadCode,
7248            scope_files: files.clone(),
7249            project_root: root.clone(),
7250            inspect_dir: inspect_dir.clone(),
7251            config: Arc::clone(&config),
7252            symbol_cache: Arc::clone(&symbol_cache),
7253            inspect_writer: true,
7254            callgraph_writer: true,
7255            callgraph_snapshot: Some(Arc::new(projected)),
7256        };
7257        let success = crate::inspect::scanners::dead_code::run_dead_code_scan(&scan_job)
7258            .outcome
7259            .expect("dead_code scan succeeds");
7260        let cache = InspectCache::open(inspect_dir.clone(), root.clone()).expect("open cache");
7261        cache
7262            .store_tier2_result(
7263                scan_job.key.clone(),
7264                &success.scanned_files,
7265                &success.contributions,
7266                success.aggregate.clone(),
7267            )
7268            .expect("store tier2 result");
7269
7270        let snapshot = InspectSnapshot::new(root.clone(), inspect_dir, config, symbol_cache);
7271        let scope = JobScope::from_roots(root.clone(), vec![root.join("src/live.ts")]);
7272        assert!(
7273            !scope.is_project_wide(),
7274            "live.ts file scope must be scoped"
7275        );
7276
7277        let ready_payload = scoped_tier2_payload_from_contributions(
7278            &snapshot,
7279            InspectCategory::DeadCode,
7280            &cache,
7281            success.aggregate.clone(),
7282            &scope,
7283        )
7284        .expect("ready scoped payload");
7285        assert_eq!(
7286            ready_payload
7287                .get("callgraph_available")
7288                .and_then(Value::as_bool),
7289            Some(true),
7290            "ready store should produce a callgraph-backed scoped rollup: {ready_payload:#}"
7291        );
7292        assert_live_item(&ready_payload, "src/live.ts", "knownLive");
7293
7294        std::fs::remove_dir_all(&callgraph_dir).expect("remove ready callgraph store");
7295        let unavailable_payload = scoped_tier2_payload_from_contributions(
7296            &snapshot,
7297            InspectCategory::DeadCode,
7298            &cache,
7299            success.aggregate,
7300            &scope,
7301        )
7302        .expect("unavailable scoped payload");
7303        assert_eq!(
7304            unavailable_payload
7305                .get("callgraph_available")
7306                .and_then(Value::as_bool),
7307            Some(false),
7308            "missing store must report callgraph_unavailable instead of fabricating an empty graph: {unavailable_payload:#}"
7309        );
7310        assert_live_item(&unavailable_payload, "src/live.ts", "knownLive");
7311    }
7312    #[derive(Debug, PartialEq, Eq)]
7313    struct ComparableSnapshot {
7314        files: BTreeSet<PathBuf>,
7315        exported_symbols: BTreeSet<(PathBuf, String, String, u32)>,
7316        outbound_calls: BTreeSet<(PathBuf, String, String, u32)>,
7317        entry_points: BTreeSet<PathBuf>,
7318        entry_point_symbols: BTreeMap<PathBuf, BTreeSet<String>>,
7319    }
7320
7321    #[test]
7322    fn dead_code_projection_contains_expected_fixture_surface() {
7323        let dir = tempfile::tempdir().expect("tempdir");
7324        write_projection_fixture(dir.path());
7325        let root = canonical_root(dir.path());
7326        let projected = store_projected_snapshot(&root, ".store-dead-code-surface");
7327
7328        assert_projection_fixture_coverage(&root, &projected);
7329    }
7330
7331    #[test]
7332    fn dead_code_projection_incremental_scenario_matrix_matches_cold_rebuild() {
7333        run_projection_scenario("rename", setup_projection_rename, edit_projection_rename);
7334        run_projection_scenario("delete", setup_projection_delete, edit_projection_delete);
7335        run_projection_scenario("new-file", setup_projection_delete, |root| {
7336            let path = root.join("new.ts");
7337            write_file(
7338                &path,
7339                "import { foo } from './foo'; export function added() { foo(); }\n",
7340            );
7341            vec![path]
7342        });
7343        run_projection_scenario(
7344            "barrel delete",
7345            setup_projection_barrel,
7346            edit_projection_barrel_delete,
7347        );
7348        run_projection_scenario(
7349            "dispatch edit",
7350            setup_projection_dispatch,
7351            edit_projection_dispatch,
7352        );
7353        run_projection_scenario(
7354            "body-only edit",
7355            setup_projection_body_only,
7356            edit_projection_body_only,
7357        );
7358    }
7359
7360    #[test]
7361    fn dead_code_projection_dead_code_scan_reports_expected_verdicts() {
7362        let dir = tempfile::tempdir().expect("tempdir");
7363        write_projection_fixture(dir.path());
7364        let root = canonical_root(dir.path());
7365        let files = project_files(&root);
7366        let projected = store_projected_snapshot(&root, ".store-dead-code-e2e");
7367
7368        let projected_aggregate = dead_code_aggregate(&root, files, projected);
7369        assert_dead_item(&projected_aggregate, "src/dead.ts", "knownDead");
7370        assert_live_item(&projected_aggregate, "src/live.ts", "knownLive");
7371        assert_live_item(&projected_aggregate, "src/render.ts", "render");
7372        assert_live_item(&projected_aggregate, "src/other_render.ts", "render");
7373    }
7374
7375    #[test]
7376    fn dead_code_projection_rust_attribute_entry_points_are_live() {
7377        let dir = tempfile::tempdir().expect("tempdir");
7378        write_rust_attribute_entry_fixture(dir.path());
7379        let root = canonical_root(dir.path());
7380        let files = project_files(&root);
7381        let store = CallGraphStore::open(root.join(".store-tauri-commands"), root.clone())
7382            .expect("open store");
7383        store.cold_build(&files).expect("cold build store");
7384        let command = store
7385            .node_for(Path::new("src/commands.rs"), "get_primers")
7386            .expect("command node");
7387        assert!(
7388            command.is_entry_point,
7389            "attribute-rooted commands must be labeled as callgraph entry points"
7390        );
7391        let private_command = store
7392            .node_for(Path::new("src/commands.rs"), "private_command")
7393            .expect("private command node");
7394        assert!(
7395            private_command.is_entry_point,
7396            "private attribute-rooted commands must also be callgraph entry points"
7397        );
7398
7399        let projected = project_dead_code_snapshot(store.sqlite_path()).expect("project snapshot");
7400        let aggregate = dead_code_aggregate(&root, files, projected);
7401        assert_live_item(&aggregate, "src/commands.rs", "get_primers");
7402        assert_live_item(&aggregate, "src/db.rs", "helper");
7403        assert_live_item(&aggregate, "src/db.rs", "private_helper");
7404        assert_live_item(&aggregate, "src/imported.rs", "imported_command");
7405        assert_live_item(&aggregate, "src/db.rs", "imported_helper");
7406        assert_dead_item(&aggregate, "src/commands.rs", "planted_dead");
7407        assert_dead_item(&aggregate, "src/unimported.rs", "false_command");
7408        assert_dead_item(&aggregate, "src/db.rs", "false_helper");
7409    }
7410
7411    #[test]
7412    fn dead_code_projection_rust_attribute_roots_are_cold_deterministic() {
7413        let dir = tempfile::tempdir().expect("tempdir");
7414        write_rust_attribute_entry_fixture(dir.path());
7415        let root = canonical_root(dir.path());
7416        let first = store_projected_snapshot(&root, ".store-tauri-cold-a");
7417        let second = store_projected_snapshot(&root, ".store-tauri-cold-b");
7418
7419        assert_snapshot_parts_eq("rust attribute roots cold", &first, &second);
7420    }
7421
7422    #[test]
7423    fn dead_code_projection_rust_attribute_roots_survive_unrelated_incremental_edit() {
7424        let dir = tempfile::tempdir().expect("tempdir");
7425        write_rust_attribute_entry_fixture(dir.path());
7426        let root = canonical_root(dir.path());
7427        let files_before = project_files(&root);
7428        let incremental_store =
7429            CallGraphStore::open(root.join(".store-tauri-incremental"), root.clone())
7430                .expect("open incremental store");
7431        incremental_store
7432            .cold_build(&files_before)
7433            .expect("initial cold build");
7434
7435        write_file(
7436            &root.join("src/unrelated.rs"),
7437            r#"// unrelated edit should not refresh command attribute facts
7438pub fn unrelated() -> u32 { 2 }
7439"#,
7440        );
7441        let stats = incremental_store
7442            .refresh_files(&[root.join("src/unrelated.rs")])
7443            .expect("refresh unrelated file");
7444        assert_eq!(stats.refreshed_own_files, 1);
7445        assert_eq!(stats.changed_files, vec!["src/unrelated.rs".to_string()]);
7446        assert!(
7447            !stats
7448                .surface_changed
7449                .iter()
7450                .any(|file| file == "src/commands.rs"),
7451            "unrelated edit must not refresh the command module: {stats:#?}"
7452        );
7453        let incremental = project_dead_code_snapshot(incremental_store.sqlite_path())
7454            .expect("project incremental snapshot");
7455
7456        let cold_store = CallGraphStore::open(root.join(".store-tauri-cold"), root.clone())
7457            .expect("open cold store");
7458        cold_store
7459            .cold_build(&project_files(&root))
7460            .expect("cold rebuild");
7461        let cold = project_dead_code_snapshot(cold_store.sqlite_path()).expect("project cold");
7462        assert_snapshot_parts_eq("rust attribute roots unrelated edit", &cold, &incremental);
7463
7464        let aggregate = dead_code_aggregate(&root, project_files(&root), incremental);
7465        assert_live_item(&aggregate, "src/commands.rs", "get_primers");
7466        assert_live_item(&aggregate, "src/db.rs", "helper");
7467        assert_live_item(&aggregate, "src/db.rs", "private_helper");
7468        assert_dead_item(&aggregate, "src/commands.rs", "planted_dead");
7469    }
7470
7471    fn assert_projection_fixture_coverage(root: &Path, snapshot: &CallgraphSnapshot) {
7472        let comparable = comparable_snapshot(snapshot);
7473        assert!(
7474            comparable
7475                .files
7476                .iter()
7477                .any(|file| file.extension().and_then(|ext| ext.to_str()) == Some("ts")),
7478            "fixture must include TypeScript 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("js")),
7486            "fixture must include JavaScript files: {:#?}",
7487            comparable.files
7488        );
7489        assert!(
7490            comparable
7491                .files
7492                .iter()
7493                .any(|file| file.extension().and_then(|ext| ext.to_str()) == Some("rs")),
7494            "fixture must include Rust files: {:#?}",
7495            comparable.files
7496        );
7497
7498        let main_file = canonicalize_for_snapshot(&root.join("src/main.ts"));
7499        let private_dispatch_target = format!("{}::dispatch", main_file.display());
7500        assert!(
7501            comparable
7502                .outbound_calls
7503                .iter()
7504                .any(
7505                    |(caller_file, caller_symbol, target, _)| caller_file == &main_file
7506                        && caller_symbol == "main"
7507                        && target == &private_dispatch_target
7508                ),
7509            "fixture must cover same-file private fallback target {private_dispatch_target}: {:#?}",
7510            comparable.outbound_calls
7511        );
7512        assert!(
7513            comparable
7514                .outbound_calls
7515                .iter()
7516                .any(|(_, _, target, _)| target.contains(DISPATCHED_CALLEE_SEPARATOR)),
7517            "fixture must cover method-dispatch suffixes: {:#?}",
7518            comparable.outbound_calls
7519        );
7520        assert!(
7521            comparable
7522                .exported_symbols
7523                .iter()
7524                .any(|(_, symbol, kind, _)| symbol == "runDefault"
7525                    && kind == DEFAULT_EXPORT_MARKER_KIND),
7526            "fixture must cover default-export marker rows: {:#?}",
7527            comparable.exported_symbols
7528        );
7529    }
7530
7531    fn run_projection_scenario(name: &str, setup: fn(&Path), edit: fn(&Path) -> Vec<PathBuf>) {
7532        let dir = tempfile::tempdir().expect("tempdir");
7533        setup(dir.path());
7534        let root = canonical_root(dir.path());
7535        let files_before = project_files(&root);
7536        let incremental_store = CallGraphStore::open(
7537            root.join(format!(".store-dead-code-projection-{name}-incremental")),
7538            root.clone(),
7539        )
7540        .expect("open incremental store");
7541        incremental_store
7542            .cold_build(&files_before)
7543            .expect("initial cold build");
7544
7545        let (revision, previous) =
7546            project_dead_code_snapshot_with_revision(incremental_store.sqlite_path())
7547                .expect("initial projection");
7548        let changed = edit(&root);
7549        incremental_store
7550            .refresh_files(&changed)
7551            .expect("refresh changed files");
7552        let (_, incremental, verdict) =
7553            crate::callgraph_store::project_dead_code_snapshot_incremental(
7554                incremental_store.sqlite_path(),
7555                Some((revision.unwrap(), &previous)),
7556            )
7557            .expect("project incremental snapshot");
7558        assert_eq!(
7559            verdict.reason, None,
7560            "{name}: a spliced projection carries no full-projection reason"
7561        );
7562        assert_eq!(
7563            verdict.kind,
7564            ProjectionKind::Spliced,
7565            "{name}: a clean journal bridge must splice the previous snapshot"
7566        );
7567        let full =
7568            project_dead_code_snapshot(incremental_store.sqlite_path()).expect("full projection");
7569        let files = project_files(&root);
7570        assert_eq!(
7571            serde_json::to_vec(&dead_code_aggregate(
7572                &root,
7573                files.clone(),
7574                incremental.clone()
7575            ))
7576            .unwrap(),
7577            serde_json::to_vec(&dead_code_aggregate(&root, files, full)).unwrap(),
7578            "{name}: incremental and full projection aggregates must be byte-identical",
7579        );
7580
7581        let cold_store = CallGraphStore::open(
7582            root.join(format!(".store-dead-code-projection-{name}-cold")),
7583            root.clone(),
7584        )
7585        .expect("open cold store");
7586        cold_store
7587            .cold_build(&project_files(&root))
7588            .expect("cold rebuild");
7589        let cold =
7590            project_dead_code_snapshot(cold_store.sqlite_path()).expect("project cold snapshot");
7591
7592        assert_snapshot_parts_eq(name, &cold, &incremental);
7593    }
7594
7595    /// Store-backed dead_code benchmark. Measures, on a real checkout, the
7596    /// persisted-store cold build, the warm SQLite projection cost, and the
7597    /// remaining `run_dead_code_scan` cost (per-file reexport/type-ref reparse +
7598    /// BFS roll-up). Production Tier-2 reads a warm store; cold_build is included
7599    /// here only to make end-to-end store cost visible.
7600    /// Ignored by default; run with:
7601    ///   AFT_BENCH_REPO=/path/to/large/repo cargo test -p agent-file-tools --lib \
7602    ///     -- --ignored --nocapture --test-threads=1 dead_code_decision_b_benchmark
7603    #[test]
7604    #[ignore = "manual benchmark; needs AFT_BENCH_REPO pointing at a large checkout"]
7605    fn dead_code_decision_b_benchmark() {
7606        let Ok(repo) = std::env::var("AFT_BENCH_REPO") else {
7607            eprintln!("AFT_BENCH_REPO unset; skipping");
7608            return;
7609        };
7610        // Each phase flushes immediately so a file-redirected run shows live progress.
7611        macro_rules! mark {
7612            ($($a:tt)*) => {{ eprintln!($($a)*); let _ = std::io::Write::flush(&mut std::io::stderr()); }};
7613        }
7614        let root = canonical_root(Path::new(&repo));
7615        let files = project_files(&root);
7616        mark!(
7617            "\n=== Store-backed dead_code benchmark ===\nrepo: {}\nsource files (walk_project_files): {}\nstarted store cold_build...",
7618            root.display(),
7619            files.len()
7620        );
7621
7622        // Store cold_build + projection. Production warm runs skip cold_build and
7623        // pay only the projection below.
7624        let store_dir = root.join(".aft-bench-store");
7625        let _ = std::fs::remove_dir_all(&store_dir);
7626        let store = CallGraphStore::open(store_dir.clone(), root.clone()).expect("open store");
7627        let t = Instant::now();
7628        let cold_stats = store.cold_build(&files).expect("store cold build");
7629        let store_build_ms = t.elapsed().as_millis();
7630        let t = Instant::now();
7631        let projected = project_dead_code_snapshot(store.sqlite_path()).expect("projection");
7632        let proj_ms = t.elapsed().as_millis();
7633        mark!(
7634            "store cold_build: {} ms ({:?}) + projection: {} ms = {} ms  (exports={}, outbound={})\nstarted scan...",
7635            store_build_ms, cold_stats, proj_ms, store_build_ms + proj_ms,
7636            projected.exported_symbols.len(), projected.outbound_calls.len()
7637        );
7638
7639        // Remaining scanner cost: run_dead_code_scan given a ready snapshot.
7640        let t = Instant::now();
7641        let _result = dead_code_aggregate(&root, files.clone(), projected.clone());
7642        let scan_ms = t.elapsed().as_millis();
7643        mark!("run_dead_code_scan (cold contributions): {} ms", scan_ms);
7644
7645        mark!(
7646            "\nSUMMARY  files={}  store_cold_plus_projection={}ms  projection={}ms  scan_cold={}ms  total={}ms",
7647            files.len(),
7648            store_build_ms + proj_ms,
7649            proj_ms,
7650            scan_ms,
7651            store_build_ms + proj_ms + scan_ms
7652        );
7653        let _ = std::fs::remove_dir_all(&store_dir);
7654    }
7655
7656    #[cfg(unix)]
7657    fn projection_bench_cpu_ms() -> f64 {
7658        let mut usage = std::mem::MaybeUninit::<libc::rusage>::uninit();
7659        // getrusage initializes the output only on success.
7660        let result = unsafe { libc::getrusage(libc::RUSAGE_SELF, usage.as_mut_ptr()) };
7661        assert_eq!(result, 0);
7662        let usage = unsafe { usage.assume_init() };
7663        (usage.ru_utime.tv_sec + usage.ru_stime.tv_sec) as f64 * 1000.0
7664            + (usage.ru_utime.tv_usec + usage.ru_stime.tv_usec) as f64 / 1000.0
7665    }
7666
7667    #[cfg(unix)]
7668    #[test]
7669    #[ignore = "offline projection/rollup benchmark copies a production generation"]
7670    fn profile_incremental_projection_on_store_copy() {
7671        use rusqlite::{backup::Backup, Connection, OpenFlags};
7672        let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR"))
7673            .parent()
7674            .unwrap()
7675            .parent()
7676            .unwrap()
7677            .canonicalize()
7678            .unwrap();
7679        let root = std::env::var_os("AFT_PROJECTION_BENCH_ROOT")
7680            .map(PathBuf::from)
7681            .unwrap_or_else(|| workspace_root.clone())
7682            .canonicalize()
7683            .unwrap();
7684        assert!(
7685            root == workspace_root || root.starts_with(workspace_root.join("target")),
7686            "benchmark roots must be this checkout or a disposable copy below target"
7687        );
7688        let source_dir = PathBuf::from(
7689            std::env::var_os("AFT_CALLGRAPH_REFRESH_STORE")
7690                .expect("set AFT_CALLGRAPH_REFRESH_STORE to the source root-key directory"),
7691        );
7692        let pointer = std::fs::read_dir(&source_dir)
7693            .unwrap()
7694            .filter_map(Result::ok)
7695            .map(|entry| entry.path())
7696            .find(|path| path.extension().is_some_and(|ext| ext == "current"))
7697            .expect("source generation pointer");
7698        let source_path = source_dir.join(std::fs::read_to_string(pointer).unwrap().trim());
7699        let temp = tempfile::tempdir_in(workspace_root.join("target")).unwrap();
7700        let store_dir = temp.path().join("store");
7701        std::fs::create_dir_all(&store_dir).unwrap();
7702        let key = crate::search_index::artifact_cache_key(&root);
7703        let db = store_dir.join(format!("{key}.sqlite"));
7704        {
7705            let source =
7706                Connection::open_with_flags(source_path, OpenFlags::SQLITE_OPEN_READ_ONLY).unwrap();
7707            let mut destination = Connection::open(&db).unwrap();
7708            Backup::new(&source, &mut destination)
7709                .unwrap()
7710                .run_to_completion(256, Duration::from_millis(5), None)
7711                .unwrap();
7712            destination
7713                .execute(
7714                    "UPDATE backend_file_state SET workspace_root = ?1",
7715                    [root.display().to_string()],
7716                )
7717                .unwrap();
7718        }
7719        let store = CallGraphStore::open(store_dir, root.clone()).unwrap();
7720        // Only disposable checkout sources and the SQLite backup may be mutated.
7721        // The source generation is always opened read-only.
7722        let changed_count = std::env::var("AFT_PROJECTION_BENCH_CHANGED_FILES")
7723            .ok()
7724            .and_then(|value| value.parse::<usize>().ok())
7725            .unwrap_or(1);
7726        let changed_paths = std::env::var_os("AFT_PROJECTION_BENCH_CHANGED_PATHS");
7727        let changed = if let Some(changed_paths) = changed_paths.as_ref() {
7728            std::fs::read_to_string(changed_paths)
7729                .unwrap()
7730                .lines()
7731                .filter(|line| !line.is_empty())
7732                .take(changed_count)
7733                .map(|relative| root.join(relative))
7734                .collect::<Vec<_>>()
7735        } else {
7736            (0..changed_count)
7737                .map(|index| temp.path().join(format!("probe-{index}.rs")))
7738                .collect::<Vec<_>>()
7739        };
7740        assert_eq!(
7741            changed.len(),
7742            changed_count,
7743            "changed-path corpus is too small"
7744        );
7745        if changed_paths.is_none() {
7746            for path in &changed {
7747                write_file(path, "pub fn projection_probe() {}\n");
7748            }
7749            store.refresh_files(&changed).unwrap();
7750        }
7751        let (revision, previous) =
7752            project_dead_code_snapshot_with_revision(store.sqlite_path()).unwrap();
7753        let mut refresh_ms = 0.0;
7754        if std::env::var_os("AFT_PROJECTION_BENCH_JOURNAL_ONLY").is_some() {
7755            let next = revision.unwrap() + 1;
7756            let callers = changed
7757                .iter()
7758                .map(|path| {
7759                    path.strip_prefix(&root)
7760                        .unwrap()
7761                        .to_string_lossy()
7762                        .replace('\\', "/")
7763                })
7764                .collect::<BTreeSet<_>>();
7765            let payload = serde_json::to_string(&(next, callers)).unwrap();
7766            let conn = rusqlite::Connection::open(store.sqlite_path()).unwrap();
7767            conn.execute(
7768                "INSERT OR REPLACE INTO meta(k, v) VALUES('projection_write_revision', ?1)",
7769                [next.to_string()],
7770            )
7771            .unwrap();
7772            conn.execute(
7773                "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
7774                rusqlite::params![format!("projection_delta_{}", next % 64), payload],
7775            )
7776            .unwrap();
7777        } else {
7778            if changed_paths.is_none() {
7779                for path in &changed {
7780                    write_file(
7781                        path,
7782                        "pub fn projection_probe() { projection_probe_target(); }\npub fn projection_probe_target() {}\n",
7783                    );
7784                }
7785            }
7786            let refresh_started = Instant::now();
7787            store.refresh_files(&changed).unwrap();
7788            refresh_ms = refresh_started.elapsed().as_secs_f64() * 1000.0;
7789            if let Some(journal_paths) = std::env::var_os("AFT_PROJECTION_BENCH_JOURNAL_PATHS") {
7790                let current = store.projection_write_revision().unwrap().unwrap();
7791                let callers = std::fs::read_to_string(journal_paths)
7792                    .unwrap()
7793                    .lines()
7794                    .filter(|line| !line.is_empty())
7795                    .map(str::to_owned)
7796                    .collect::<BTreeSet<_>>();
7797                let payload = serde_json::to_string(&(current, callers)).unwrap();
7798                let conn = rusqlite::Connection::open(store.sqlite_path()).unwrap();
7799                conn.execute(
7800                    "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
7801                    rusqlite::params![format!("projection_delta_{}", current % 64), payload],
7802                )
7803                .unwrap();
7804            }
7805        }
7806        let snapshot_replay_ms = refresh_ms;
7807        let cpu = projection_bench_cpu_ms();
7808        let started = Instant::now();
7809        let full = project_dead_code_snapshot(store.sqlite_path()).unwrap();
7810        let full_elapsed = started.elapsed();
7811        let full_ms = full_elapsed.as_secs_f64() * 1000.0;
7812        let full_cpu = projection_bench_cpu_ms() - cpu;
7813        crate::callgraph_store::take_projection_work();
7814        let cpu = projection_bench_cpu_ms();
7815        let started = Instant::now();
7816        let (_, incremental, verdict) =
7817            crate::callgraph_store::project_dead_code_snapshot_incremental(
7818                store.sqlite_path(),
7819                Some((revision.unwrap(), &previous)),
7820            )
7821            .unwrap();
7822        let delta_elapsed = started.elapsed();
7823        let delta_ms = delta_elapsed.as_secs_f64() * 1000.0;
7824        let delta_cpu = projection_bench_cpu_ms() - cpu;
7825        let work = crate::callgraph_store::take_projection_work();
7826        if std::env::var_os("AFT_PROJECTION_BENCH_PROJECTION_ONLY").is_some() {
7827            let mut costs = ProjectionCostEstimates::default();
7828            costs.observe(
7829                ProjectionVerdict {
7830                    kind: ProjectionKind::Full,
7831                    reason: Some("cold"),
7832                    journal_bytes: 0,
7833                    changed_files: 0,
7834                },
7835                full_elapsed,
7836            );
7837            costs.observe(verdict, delta_elapsed);
7838            let (_, _, predicted, _) = project_dead_code_snapshot_incremental_with_costs(
7839                store.sqlite_path(),
7840                Some((revision.unwrap(), &previous)),
7841                costs,
7842            )
7843            .unwrap();
7844            eprintln!(
7845                "projection_crossover changed_files={} refresh_ms={refresh_ms:.3} snapshot_replay_ms={:.3} 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={}",
7846                verdict.changed_files, snapshot_replay_ms + delta_ms, verdict.kind, predicted.kind, predicted.reason, work.1
7847            );
7848            return;
7849        }
7850        let mut job = InspectJob {
7851            job_id: 87,
7852            key: JobKey::for_project_category(InspectCategory::DeadCode),
7853            category: InspectCategory::DeadCode,
7854            scope_files: full.files.clone(),
7855            project_root: root.clone(),
7856            inspect_dir: temp.path().join("inspect"),
7857            config: Arc::new(Config {
7858                project_root: Some(root.clone()),
7859                ..Config::default()
7860            }),
7861            symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
7862            inspect_writer: true,
7863            callgraph_writer: false,
7864            callgraph_snapshot: Some(Arc::new(full)),
7865        };
7866        let contribution_load_started = Instant::now();
7867        let copied_inspect = std::env::var_os("AFT_PROJECTION_BENCH_INSPECT_STORE");
7868        let contributions = if let Some(source_path) = copied_inspect.as_ref() {
7869            let project_key = crate::path_identity::project_scope_key(&root);
7870            let project_inspect_dir = job.inspect_dir.join(&project_key);
7871            std::fs::create_dir_all(&project_inspect_dir).unwrap();
7872            let inspect_db = project_inspect_dir.join(format!("{project_key}.sqlite"));
7873            {
7874                let source =
7875                    Connection::open_with_flags(source_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
7876                        .unwrap();
7877                let mut destination = Connection::open(&inspect_db).unwrap();
7878                Backup::new(&source, &mut destination)
7879                    .unwrap()
7880                    .run_to_completion(256, Duration::from_millis(5), None)
7881                    .unwrap();
7882                for table in ["tier2_contributions", "tier2_aggregates", "tier2_meta"] {
7883                    destination
7884                        .execute(
7885                            &format!("UPDATE {table} SET project_key = ?1"),
7886                            [&project_key],
7887                        )
7888                        .unwrap();
7889                }
7890            }
7891            let cache = InspectCache::open(job.inspect_dir.clone(), root.clone()).unwrap();
7892            load_contributions(&cache, &job).unwrap()
7893        } else {
7894            crate::inspect::scanners::dead_code::run_dead_code_scan(&job)
7895                .outcome
7896                .unwrap()
7897                .contributions
7898        };
7899        let contribution_load_ms = contribution_load_started.elapsed().as_secs_f64() * 1000.0;
7900        let public_api_files = crate::inspect::scanners::dead_code::collect_public_api_files(&root);
7901        let roles = crate::inspect::entry_points::resolve_project_roles(&root);
7902        let cpu = projection_bench_cpu_ms();
7903        let started = Instant::now();
7904        let (full_aggregate, rollup_state, _) =
7905            crate::inspect::scanners::dead_code::aggregate_dead_code_contributions_incremental(
7906                &root,
7907                job.callgraph_snapshot.as_deref().unwrap(),
7908                &contributions,
7909                &public_api_files,
7910                &roles,
7911                None,
7912                Some("store-copy-benchmark"),
7913                None,
7914                &BTreeSet::new(),
7915            );
7916        let full_rollup_ms = started.elapsed().as_secs_f64() * 1000.0;
7917        let full_rollup_cpu = projection_bench_cpu_ms() - cpu;
7918        job.callgraph_snapshot = Some(Arc::new(incremental));
7919        let changed_relative = changed
7920            .iter()
7921            .map(|path| {
7922                path.strip_prefix(&root)
7923                    .unwrap()
7924                    .to_string_lossy()
7925                    .replace('\\', "/")
7926            })
7927            .collect::<BTreeSet<_>>();
7928        let cpu = projection_bench_cpu_ms();
7929        let started = Instant::now();
7930        let (delta_aggregate, _, rollup_verdict) =
7931            crate::inspect::scanners::dead_code::aggregate_dead_code_contributions_incremental(
7932                &root,
7933                job.callgraph_snapshot.as_deref().unwrap(),
7934                &contributions,
7935                &public_api_files,
7936                &roles,
7937                None,
7938                Some("store-copy-benchmark"),
7939                Some(&rollup_state),
7940                &changed_relative,
7941            );
7942        let delta_rollup_ms = started.elapsed().as_secs_f64() * 1000.0;
7943        let delta_rollup_cpu = projection_bench_cpu_ms() - cpu;
7944        assert_eq!(
7945            rollup_verdict.kind,
7946            crate::inspect::scanners::dead_code::RollupKind::Incremental
7947        );
7948        assert_eq!(
7949            serde_json::to_vec(&full_aggregate).unwrap(),
7950            serde_json::to_vec(&delta_aggregate).unwrap()
7951        );
7952        let manager = InspectManager::new();
7953        manager.cache_callgraph_projection(
7954            CallgraphProjectionIdentity {
7955                project_root: root,
7956                generation: None,
7957                legacy_sqlite_path: Some(db),
7958                write_revision: store.projection_write_revision().unwrap().unwrap(),
7959            },
7960            job.callgraph_snapshot.clone().unwrap(),
7961        );
7962        eprintln!("projection_bench changed_files={changed_count} rows={} contributions={} contribution_source={} contribution_load_ms={contribution_load_ms:.3}; refresh_ms={refresh_ms:.3} snapshot_replay_ms={:.3}; 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(), contributions.len(), if copied_inspect.is_some() { "inspect_copy" } else { "source_scan" }, snapshot_replay_ms + delta_ms, work.0, work.1);
7963        eprintln!(
7964            "projection_bench callgraph_memory={:?}",
7965            manager.callgraph_projection_estimated_memory()
7966        );
7967    }
7968
7969    fn store_projected_snapshot(root: &Path, store_name: &str) -> CallgraphSnapshot {
7970        let store =
7971            CallGraphStore::open(root.join(store_name), root.to_path_buf()).expect("open store");
7972        store
7973            .cold_build(&project_files(root))
7974            .expect("store cold build");
7975        project_dead_code_snapshot(store.sqlite_path()).expect("project snapshot")
7976    }
7977
7978    fn dead_code_aggregate(
7979        root: &Path,
7980        scope_files: Vec<PathBuf>,
7981        snapshot: CallgraphSnapshot,
7982    ) -> Value {
7983        let job = InspectJob {
7984            job_id: 86,
7985            key: JobKey::for_project_category(InspectCategory::DeadCode),
7986            category: InspectCategory::DeadCode,
7987            scope_files,
7988            project_root: root.to_path_buf(),
7989            inspect_dir: root.join(".aft-cache").join("inspect"),
7990            config: Arc::new(Config {
7991                project_root: Some(root.to_path_buf()),
7992                ..Config::default()
7993            }),
7994            symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
7995            inspect_writer: true,
7996            callgraph_writer: true,
7997            callgraph_snapshot: Some(Arc::new(snapshot)),
7998        };
7999        crate::inspect::scanners::dead_code::run_dead_code_scan(&job)
8000            .outcome
8001            .expect("dead_code scan succeeds")
8002            .aggregate
8003    }
8004
8005    fn assert_snapshot_parts_eq(
8006        label: &str,
8007        expected: &CallgraphSnapshot,
8008        actual: &CallgraphSnapshot,
8009    ) {
8010        let expected = comparable_snapshot(expected);
8011        let actual = comparable_snapshot(actual);
8012        assert_eq!(
8013            actual, expected,
8014            "{label} store-projected snapshot must match cold store snapshot"
8015        );
8016    }
8017
8018    fn comparable_snapshot(snapshot: &CallgraphSnapshot) -> ComparableSnapshot {
8019        ComparableSnapshot {
8020            files: snapshot.files.iter().cloned().collect(),
8021            exported_symbols: snapshot
8022                .exported_symbols
8023                .iter()
8024                .map(|export| {
8025                    (
8026                        export.file.clone(),
8027                        export.symbol.clone(),
8028                        export.kind.clone(),
8029                        export.line,
8030                    )
8031                })
8032                .collect(),
8033            outbound_calls: snapshot
8034                .outbound_calls
8035                .iter()
8036                .map(|call| {
8037                    (
8038                        call.caller_file.clone(),
8039                        call.caller_symbol.clone(),
8040                        call.target.clone(),
8041                        call.line,
8042                    )
8043                })
8044                .collect(),
8045            entry_points: snapshot.entry_points.clone(),
8046            entry_point_symbols: snapshot.entry_point_symbols.clone(),
8047        }
8048    }
8049
8050    fn assert_dead_item(aggregate: &Value, file: &str, symbol: &str) {
8051        assert!(
8052            aggregate_has_item(aggregate, file, symbol),
8053            "expected {file}::{symbol} to be reported dead: {aggregate:#}"
8054        );
8055    }
8056
8057    fn assert_live_item(aggregate: &Value, file: &str, symbol: &str) {
8058        assert!(
8059            !aggregate_has_item(aggregate, file, symbol),
8060            "expected {file}::{symbol} to be live/not reported dead: {aggregate:#}"
8061        );
8062    }
8063
8064    fn aggregate_has_item(aggregate: &Value, file: &str, symbol: &str) -> bool {
8065        let Some(items) = aggregate.get("items").and_then(Value::as_array) else {
8066            return false;
8067        };
8068        items.iter().any(|item| {
8069            item.get("file").and_then(Value::as_str) == Some(file)
8070                && item.get("symbol").and_then(Value::as_str) == Some(symbol)
8071        })
8072    }
8073
8074    fn project_files(root: &Path) -> Vec<PathBuf> {
8075        walk_project_files(root).collect()
8076    }
8077
8078    fn canonical_root(root: &Path) -> PathBuf {
8079        std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf())
8080    }
8081
8082    fn write_file(path: &Path, content: &str) {
8083        if let Some(parent) = path.parent() {
8084            std::fs::create_dir_all(parent).expect("create parent");
8085        }
8086        std::fs::write(path, content).expect("write fixture");
8087        bump_mtime(path);
8088    }
8089
8090    fn bump_mtime(path: &Path) {
8091        let secs = NEXT_MTIME.fetch_add(1, AtomicOrdering::SeqCst);
8092        filetime::set_file_mtime(path, FileTime::from_unix_time(secs, 0)).expect("bump mtime");
8093    }
8094
8095    fn remove_file(path: &Path) {
8096        std::fs::remove_file(path).expect("remove fixture");
8097    }
8098
8099    fn write_projection_fixture(root: &Path) {
8100        write_file(
8101            &root.join("package.json"),
8102            r#"{"name":"dead-code-projection-fixture","type":"module","main":"src/main.ts"}"#,
8103        );
8104        write_file(
8105            &root.join("Cargo.toml"),
8106            r#"[package]
8107name = "dead_code_projection_fixture"
8108version = "0.1.0"
8109edition = "2021"
8110"#,
8111        );
8112        write_file(
8113            &root.join("src/main.ts"),
8114            r#"import runDefault from "./default";
8115import { knownLive } from "./live";
8116import { jsEntry } from "./app.js";
8117
8118export function main() {
8119  dispatch();
8120  runDefault();
8121  jsEntry();
8122}
8123
8124function dispatch() {
8125  knownLive();
8126  const service = { render() {} };
8127  service.render();
8128}
8129"#,
8130        );
8131        write_file(
8132            &root.join("src/default.ts"),
8133            r#"export default function runDefault() {}
8134"#,
8135        );
8136        write_file(
8137            &root.join("src/live.ts"),
8138            r#"export function knownLive() {}
8139"#,
8140        );
8141        write_file(
8142            &root.join("src/dead.ts"),
8143            r#"export function knownDead() {}
8144"#,
8145        );
8146        write_file(
8147            &root.join("src/render.ts"),
8148            r#"export function render() {}
8149"#,
8150        );
8151        write_file(
8152            &root.join("src/other_render.ts"),
8153            r#"export function render() {}
8154"#,
8155        );
8156        write_file(
8157            &root.join("src/app.js"),
8158            r#"import { jsHelper } from "./js_helper.js";
8159
8160export function jsEntry() {
8161  jsHelper();
8162}
8163"#,
8164        );
8165        write_file(
8166            &root.join("src/js_helper.js"),
8167            r#"export function jsHelper() {}
8168"#,
8169        );
8170        write_file(
8171            &root.join("src/lib.rs"),
8172            r#"mod util;
8173use crate::util::rust_helper;
8174
8175pub fn rust_entry() {
8176    rust_helper();
8177}
8178"#,
8179        );
8180        write_file(
8181            &root.join("src/util.rs"),
8182            r#"pub fn rust_helper() {}
8183"#,
8184        );
8185    }
8186
8187    fn write_rust_attribute_entry_fixture(root: &Path) {
8188        write_file(
8189            &root.join("src/main.rs"),
8190            r#"mod commands;
8191mod db;
8192mod imported;
8193mod unimported;
8194mod unrelated;
8195
8196fn main() {
8197    tauri::generate_handler![commands::get_primers, imported::imported_command];
8198}
8199"#,
8200        );
8201        write_file(
8202            &root.join("src/commands.rs"),
8203            r#"use crate::db;
8204
8205#[tauri::command]
8206pub fn get_primers() -> String {
8207    db::helper()
8208}
8209
8210pub fn planted_dead() -> String {
8211    "dead".to_string()
8212}
8213
8214#[tauri::command]
8215fn private_command() -> String {
8216    db::private_helper()
8217}
8218"#,
8219        );
8220        write_file(
8221            &root.join("src/imported.rs"),
8222            r#"use crate::db;
8223use tauri::command;
8224
8225#[command]
8226pub fn imported_command() -> String {
8227    db::imported_helper()
8228}
8229"#,
8230        );
8231        write_file(
8232            &root.join("src/unimported.rs"),
8233            r#"use crate::db;
8234
8235#[command]
8236pub fn false_command() -> String {
8237    db::false_helper()
8238}
8239"#,
8240        );
8241        write_file(
8242            &root.join("src/db.rs"),
8243            r#"pub fn helper() -> String { "live".to_string() }
8244pub fn imported_helper() -> String { "live".to_string() }
8245pub fn private_helper() -> String { "live".to_string() }
8246pub fn false_helper() -> String { "dead".to_string() }
8247"#,
8248        );
8249        write_file(
8250            &root.join("src/unrelated.rs"),
8251            r#"pub fn unrelated() -> u32 { 1 }
8252"#,
8253        );
8254    }
8255
8256    fn setup_projection_rename(root: &Path) {
8257        write_file(
8258            &root.join("a.ts"),
8259            r#"export function outer() {
8260  inner();
8261}
8262
8263export function inner() {}
8264"#,
8265        );
8266    }
8267
8268    fn edit_projection_rename(root: &Path) -> Vec<PathBuf> {
8269        let path = root.join("a.ts");
8270        write_file(
8271            &path,
8272            r#"export function outer() {
8273  renamed();
8274}
8275
8276export function renamed() {}
8277"#,
8278        );
8279        vec![path]
8280    }
8281
8282    fn setup_projection_delete(root: &Path) {
8283        write_file(&root.join("other.ts"), "export function foo() {}\n");
8284        write_file(
8285            &root.join("main.ts"),
8286            r#"import { foo } from "./foo";
8287export function main() { foo(); }
8288"#,
8289        );
8290        write_file(&root.join("foo.ts"), "export function foo() {}\n");
8291    }
8292
8293    fn edit_projection_delete(root: &Path) -> Vec<PathBuf> {
8294        let path = root.join("foo.ts");
8295        remove_file(&path);
8296        vec![path]
8297    }
8298
8299    fn setup_projection_barrel(root: &Path) {
8300        write_file(
8301            &root.join("main.ts"),
8302            r#"import { foo } from "./barrel";
8303export function main() { foo(); }
8304"#,
8305        );
8306        write_file(&root.join("barrel.ts"), "export { foo } from \"./foo\";\n");
8307        write_file(&root.join("foo.ts"), "export function foo() {}\n");
8308    }
8309
8310    fn edit_projection_barrel_delete(root: &Path) -> Vec<PathBuf> {
8311        let path = root.join("barrel.ts");
8312        remove_file(&path);
8313        vec![path]
8314    }
8315
8316    fn setup_projection_dispatch(root: &Path) {
8317        write_file(
8318            &root.join("main.ts"),
8319            r#"export function main() {
8320  const service = { render() {}, paint() {} };
8321  service.render();
8322}
8323"#,
8324        );
8325        write_file(&root.join("render.ts"), "export function render() {}\n");
8326        write_file(&root.join("paint.ts"), "export function paint() {}\n");
8327    }
8328
8329    fn edit_projection_dispatch(root: &Path) -> Vec<PathBuf> {
8330        let path = root.join("main.ts");
8331        write_file(
8332            &path,
8333            r#"export function main() {
8334  const service = { render() {}, paint() {} };
8335  service.paint();
8336}
8337"#,
8338        );
8339        vec![path]
8340    }
8341
8342    fn setup_projection_body_only(root: &Path) {
8343        write_file(
8344            &root.join("main.ts"),
8345            r#"import { foo } from "./foo";
8346export function main() { foo(); }
8347"#,
8348        );
8349        write_file(
8350            &root.join("foo.ts"),
8351            r#"export function foo() {
8352  return 1;
8353}
8354"#,
8355        );
8356    }
8357
8358    fn edit_projection_body_only(root: &Path) -> Vec<PathBuf> {
8359        let path = root.join("foo.ts");
8360        write_file(
8361            &path,
8362            r#"export function foo() {
8363  return 2;
8364}
8365"#,
8366        );
8367        vec![path]
8368    }
8369
8370    #[test]
8371    fn forced_paths_downgrade_only_when_strict_hash_matches_cached_fact() {
8372        let dir = tempfile::tempdir().expect("tempdir");
8373        let root = std::fs::canonicalize(dir.path()).expect("canonical root");
8374        let unchanged = root.join("unchanged.ts");
8375        let changed = root.join("changed.ts");
8376        let oversized = root.join("oversized.ts");
8377        std::fs::write(&unchanged, "export const value = 1;\n").expect("write unchanged");
8378        std::fs::write(&changed, "export const before = 1;\n").expect("write changed baseline");
8379        let unchanged_freshness =
8380            cache_freshness::collect(&unchanged).expect("unchanged freshness");
8381        let changed_freshness = cache_freshness::collect(&changed).expect("changed freshness");
8382        std::fs::write(&changed, "export const after_ = 2;\n").expect("change same-size content");
8383        let oversized_file = std::fs::File::create(&oversized).expect("create oversized");
8384        oversized_file
8385            .set_len(cache_freshness::CONTENT_HASH_SIZE_CAP + 1)
8386            .expect("size oversized");
8387        let oversized_freshness =
8388            cache_freshness::collect(&oversized).expect("oversized freshness");
8389        let cached = vec![
8390            CachedContributionFreshness {
8391                file_path: PathBuf::from("unchanged.ts"),
8392                freshness: unchanged_freshness,
8393            },
8394            CachedContributionFreshness {
8395                file_path: PathBuf::from("changed.ts"),
8396                freshness: changed_freshness,
8397            },
8398            CachedContributionFreshness {
8399                file_path: PathBuf::from("oversized.ts"),
8400                freshness: oversized_freshness,
8401            },
8402        ];
8403
8404        let (remaining, downgraded) = downgrade_unchanged_forced_paths_with_freshness(
8405            &root,
8406            &cached,
8407            vec![
8408                PathBuf::from("unchanged.ts"),
8409                PathBuf::from("changed.ts"),
8410                PathBuf::from("oversized.ts"),
8411            ],
8412        );
8413
8414        assert_eq!(downgraded, 1);
8415        assert_eq!(
8416            remaining,
8417            vec![PathBuf::from("changed.ts"), PathBuf::from("oversized.ts")]
8418        );
8419    }
8420
8421    #[test]
8422    fn cached_projection_retains_root_cost_estimates_across_revisions() {
8423        let manager = InspectManager::new();
8424        let root = PathBuf::from("/cost-root");
8425        let identity = CallgraphProjectionIdentity {
8426            project_root: root.clone(),
8427            generation: Some("generation".to_string()),
8428            legacy_sqlite_path: None,
8429            write_revision: 1,
8430        };
8431        let snapshot = Arc::new(CallgraphSnapshot {
8432            generated_at: None,
8433            files: Vec::new(),
8434            exported_symbols: Vec::new(),
8435            outbound_calls: Vec::new(),
8436            entry_points: BTreeSet::new(),
8437            entry_point_symbols: BTreeMap::new(),
8438        });
8439        manager.cache_callgraph_projection(identity.clone(), Arc::clone(&snapshot));
8440        manager.observe_callgraph_projection_cost(
8441            &root,
8442            ProjectionVerdict {
8443                kind: ProjectionKind::Full,
8444                reason: Some("cold"),
8445                journal_bytes: 0,
8446                changed_files: 0,
8447            },
8448            Duration::from_nanos(100),
8449        );
8450        manager.observe_callgraph_projection_cost(
8451            &root,
8452            ProjectionVerdict {
8453                kind: ProjectionKind::Spliced,
8454                reason: None,
8455                journal_bytes: 20,
8456                changed_files: 4,
8457            },
8458            Duration::from_nanos(120),
8459        );
8460        let mut next_identity = identity;
8461        next_identity.write_revision = 2;
8462        manager.cache_callgraph_projection(next_identity.clone(), snapshot);
8463
8464        assert!(
8465            manager
8466                .callgraph_projection_costs(&next_identity)
8467                .splice_is_costlier(4),
8468            "cost estimates must survive replacing a root snapshot at a new revision"
8469        );
8470    }
8471
8472    #[test]
8473    fn fleet_budget_drops_oldest_whole_root_snapshot() {
8474        fn slot(root: &str) -> Arc<ProjectionSlot> {
8475            Arc::new(Mutex::new(Some(CachedCallgraphProjection {
8476                identity: CallgraphProjectionIdentity {
8477                    project_root: PathBuf::from(root),
8478                    generation: Some("generation".to_string()),
8479                    legacy_sqlite_path: None,
8480                    write_revision: 1,
8481                },
8482                snapshot: Arc::new(CallgraphSnapshot {
8483                    generated_at: None,
8484                    files: Vec::new(),
8485                    exported_symbols: Vec::new(),
8486                    outbound_calls: Vec::new(),
8487                    entry_points: BTreeSet::new(),
8488                    entry_point_symbols: BTreeMap::new(),
8489                }),
8490                estimated_bytes: 600 * 1024 * 1024,
8491                costs: ProjectionCostEstimates::default(),
8492                rollup: None,
8493            })))
8494        }
8495
8496        let first = slot("/first");
8497        let second = slot("/second");
8498        let third = slot("/third");
8499        let mut fleet = ProjectionFleet::default();
8500        fleet.admit(
8501            PathBuf::from("/first"),
8502            Arc::downgrade(&first),
8503            400 * 1024 * 1024,
8504            DEAD_CODE_SNAPSHOT_FLEET_BUDGET,
8505        );
8506        fleet.admit(
8507            PathBuf::from("/second"),
8508            Arc::downgrade(&second),
8509            400 * 1024 * 1024,
8510            DEAD_CODE_SNAPSHOT_FLEET_BUDGET,
8511        );
8512        fleet.touch(Path::new("/first"));
8513        fleet.admit(
8514            PathBuf::from("/third"),
8515            Arc::downgrade(&third),
8516            400 * 1024 * 1024,
8517            DEAD_CODE_SNAPSHOT_FLEET_BUDGET,
8518        );
8519
8520        assert!(first.lock().unwrap().is_some());
8521        assert!(second.lock().unwrap().is_none());
8522        assert!(third.lock().unwrap().is_some());
8523        assert_eq!(
8524            fleet.census(),
8525            DeadCodeSnapshotCensus {
8526                roots: 2,
8527                bytes: 800 * 1024 * 1024,
8528                drops: 1,
8529            }
8530        );
8531    }
8532
8533    #[test]
8534    fn projection_estimator_and_verdict_report_only_projection_work() {
8535        let phases = Tier2PhaseTimings {
8536            snapshot: Duration::from_millis(2_620),
8537            projection_cost: Duration::from_millis(187),
8538            rollup: Duration::from_millis(3_186),
8539            rollup_verdict: Some(crate::inspect::scanners::dead_code::RollupVerdict {
8540                kind: crate::inspect::scanners::dead_code::RollupKind::Incremental,
8541                reason: None,
8542            }),
8543            projection: Some(ProjectionVerdict {
8544                kind: ProjectionKind::Spliced,
8545                reason: None,
8546                journal_bytes: 2_397,
8547                changed_files: 46,
8548            }),
8549            ..Tier2PhaseTimings::default()
8550        };
8551
8552        assert_eq!(
8553            projection_estimator_duration(&phases),
8554            Duration::from_millis(187)
8555        );
8556        let line = phases.render(InspectCategory::DeadCode, Path::new("/root"), "test-key");
8557        assert!(line.contains("snapshot=2620ms projection_ms=187"));
8558        assert!(line.contains("rollup_ms=3186 rollup="));
8559        assert!(line.contains("projection=spliced journal_bytes=2397 changed_files=46"));
8560    }
8561
8562    #[test]
8563    fn rollup_cost_cannot_poison_projection_crossover() {
8564        let manager = InspectManager::new();
8565        let root = PathBuf::from("/projection-only-cost-root");
8566        let identity = CallgraphProjectionIdentity {
8567            project_root: root.clone(),
8568            generation: Some("generation".to_string()),
8569            legacy_sqlite_path: None,
8570            write_revision: 1,
8571        };
8572        manager.cache_callgraph_projection(
8573            identity.clone(),
8574            Arc::new(CallgraphSnapshot {
8575                generated_at: None,
8576                files: Vec::new(),
8577                exported_symbols: Vec::new(),
8578                outbound_calls: Vec::new(),
8579                entry_points: BTreeSet::new(),
8580                entry_point_symbols: BTreeMap::new(),
8581            }),
8582        );
8583        for (verdict, projection_ms, rollup_ms) in [
8584            (
8585                ProjectionVerdict {
8586                    kind: ProjectionKind::Full,
8587                    reason: Some("cold"),
8588                    journal_bytes: 0,
8589                    changed_files: 0,
8590                },
8591                100,
8592                1_000,
8593            ),
8594            (
8595                ProjectionVerdict {
8596                    kind: ProjectionKind::Spliced,
8597                    reason: None,
8598                    journal_bytes: 200,
8599                    changed_files: 4,
8600                },
8601                40,
8602                2_000,
8603            ),
8604        ] {
8605            let phases = Tier2PhaseTimings {
8606                projection_cost: Duration::from_millis(projection_ms),
8607                rollup: Duration::from_millis(rollup_ms),
8608                ..Tier2PhaseTimings::default()
8609            };
8610            manager.observe_callgraph_projection_cost(
8611                &root,
8612                verdict,
8613                projection_estimator_duration(&phases),
8614            );
8615        }
8616
8617        assert!(
8618            !manager
8619                .callgraph_projection_costs(&identity)
8620                .splice_is_costlier(5),
8621            "rollup work must not force a full snapshot projection"
8622        );
8623    }
8624
8625    #[test]
8626    fn every_tier2_phases_line_path_renders_projection_key() {
8627        for (category, reason) in [
8628            (InspectCategory::DeadCode, "no_callgraph"),
8629            (InspectCategory::DeadCode, "aggregate_reused"),
8630            (InspectCategory::DeadCode, "provided_snapshot"),
8631            (InspectCategory::UnusedExports, "not_required"),
8632            (InspectCategory::Duplicates, "not_required"),
8633            (InspectCategory::Cycles, "not_required"),
8634            (InspectCategory::Complexity, "not_required"),
8635        ] {
8636            let phases = Tier2PhaseTimings {
8637                projection_skip_reason: Some(reason),
8638                ..Tier2PhaseTimings::default()
8639            };
8640            let line = phases.render(category, Path::new("/root"), "test-key");
8641            assert!(
8642                line.contains(&format!(" projection=none reason={reason} ")),
8643                "{category} phases line omitted its projection verdict: {line}"
8644            );
8645            assert!(line.contains(" journal_bytes=0 changed_files=0 "));
8646        }
8647    }
8648
8649    #[test]
8650    fn perf_tier2_phases_line_renders_each_projection_verdict() {
8651        assert_eq!(
8652            render_rollup_suffix(crate::inspect::scanners::dead_code::RollupVerdict {
8653                kind: crate::inspect::scanners::dead_code::RollupKind::Incremental,
8654                reason: None,
8655            }),
8656            " rollup=incremental"
8657        );
8658        assert_eq!(
8659            render_rollup_suffix(crate::inspect::scanners::dead_code::RollupVerdict {
8660                kind: crate::inspect::scanners::dead_code::RollupKind::Full,
8661                reason: Some("journal_gap"),
8662            }),
8663            " rollup=full reason=journal_gap"
8664        );
8665
8666        let spliced = render_projection_suffix(ProjectionVerdict {
8667            kind: ProjectionKind::Spliced,
8668            reason: None,
8669            journal_bytes: 4096,
8670            changed_files: 3,
8671        });
8672        assert_eq!(
8673            spliced, " projection=spliced journal_bytes=4096 changed_files=3",
8674            "a spliced verdict must omit the reason field"
8675        );
8676
8677        let cold = render_projection_suffix(ProjectionVerdict {
8678            kind: ProjectionKind::Full,
8679            reason: Some("cold"),
8680            journal_bytes: 0,
8681            changed_files: 0,
8682        });
8683        assert_eq!(
8684            cold,
8685            " projection=full reason=cold journal_bytes=0 changed_files=0"
8686        );
8687
8688        let gap = render_projection_suffix(ProjectionVerdict {
8689            kind: ProjectionKind::Full,
8690            reason: Some("journal_gap"),
8691            journal_bytes: 0,
8692            changed_files: 0,
8693        });
8694        assert_eq!(
8695            gap,
8696            " projection=full reason=journal_gap journal_bytes=0 changed_files=0"
8697        );
8698
8699        let costlier = render_projection_suffix(ProjectionVerdict {
8700            kind: ProjectionKind::Full,
8701            reason: Some("splice_costlier"),
8702            journal_bytes: 261_465,
8703            changed_files: 1_486,
8704        });
8705        assert_eq!(
8706            costlier,
8707            " projection=full reason=splice_costlier journal_bytes=261465 changed_files=1486"
8708        );
8709
8710        let reused = render_projection_suffix(ProjectionVerdict {
8711            kind: ProjectionKind::Reused,
8712            reason: None,
8713            journal_bytes: 0,
8714            changed_files: 0,
8715        });
8716        assert_eq!(
8717            reused, " projection=reused journal_bytes=0 changed_files=0",
8718            "a cache hit is not a full projection and must not read as one"
8719        );
8720    }
8721
8722    #[test]
8723    fn spill_backed_171_file_delta_splices_where_old_bound_was_full() {
8724        let dir = tempfile::tempdir().expect("tempdir");
8725        write_projection_fixture(dir.path());
8726        let root = canonical_root(dir.path());
8727        let store =
8728            CallGraphStore::open(root.join(".store-spill"), root.clone()).expect("open store");
8729        store
8730            .cold_build(&project_files(&root))
8731            .expect("cold build fixture");
8732        let (revision, previous) = project_dead_code_snapshot_with_revision(store.sqlite_path())
8733            .expect("initial projection");
8734        let revision = revision.expect("new stores write a projection revision");
8735        let next = revision + 1;
8736        let callers = (0..171)
8737            .map(|index| format!("src/{index:03}-{}.ts", "x".repeat(1_600)))
8738            .collect::<BTreeSet<_>>();
8739        let payload = serde_json::to_string(&(next, &callers)).expect("serialize caller batch");
8740        assert!(
8741            payload.len() > MAX_DELTA_BYTES,
8742            "fixture must exceed the former absolute journal bound"
8743        );
8744
8745        let conn = rusqlite::Connection::open(store.sqlite_path()).expect("open write conn");
8746        conn.execute(
8747            "INSERT INTO meta(k, v) VALUES('projection_write_revision', '1')
8748             ON CONFLICT(k) DO UPDATE SET v = CAST(v AS INTEGER) + 1",
8749            [],
8750        )
8751        .expect("advance write revision");
8752        let journal_key = format!("projection_delta_{}", next % 64);
8753        conn.execute(
8754            "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
8755            rusqlite::params![journal_key, format!("oversize:{next}")],
8756        )
8757        .expect("plant legacy oversize marker");
8758
8759        let (_, _, old_verdict) = crate::callgraph_store::project_dead_code_snapshot_incremental(
8760            store.sqlite_path(),
8761            Some((revision, &previous)),
8762        )
8763        .expect("project legacy oversize marker");
8764        assert_eq!(old_verdict.kind, ProjectionKind::Full);
8765        assert_eq!(old_verdict.reason, Some("journal_gap"));
8766        assert_eq!(
8767            render_projection_suffix(old_verdict),
8768            " projection=full reason=journal_gap journal_bytes=0 changed_files=0"
8769        );
8770
8771        conn.execute_batch(
8772            "CREATE TABLE projection_delta_spill (
8773                 revision INTEGER PRIMARY KEY,
8774                 payload TEXT NOT NULL
8775             )",
8776        )
8777        .expect("create spill table");
8778        conn.execute(
8779            "INSERT INTO projection_delta_spill(revision, payload) VALUES(?1, ?2)",
8780            rusqlite::params![next, payload],
8781        )
8782        .expect("store spill payload");
8783        conn.execute(
8784            "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
8785            rusqlite::params![journal_key, format!("spill:{next}")],
8786        )
8787        .expect("replace legacy marker with spill marker");
8788        drop(conn);
8789
8790        let (_, _, verdict) = crate::callgraph_store::project_dead_code_snapshot_incremental(
8791            store.sqlite_path(),
8792            Some((revision, &previous)),
8793        )
8794        .expect("project spill-backed delta");
8795        assert_eq!(verdict.kind, ProjectionKind::Spliced);
8796        assert_eq!(verdict.reason, None);
8797        assert_eq!(verdict.changed_files, 171);
8798        assert_eq!(verdict.journal_bytes, payload.len() as u64);
8799        assert_eq!(
8800            render_projection_suffix(verdict),
8801            format!(
8802                " projection=spliced journal_bytes={} changed_files=171",
8803                payload.len()
8804            )
8805        );
8806    }
8807}