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