Skip to main content

aft/inspect/
manager.rs

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