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