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