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#[derive(Debug, Clone, PartialEq, Eq)]
56struct CallgraphProjectionIdentity {
57 project_root: PathBuf,
58 generation: Option<String>,
59 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
140struct 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
303struct 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 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 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 reuse_completions: AtomicU64,
370 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 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 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 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 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 if let Ok(mut states) = self.builder_states.lock() {
1010 states.retain(|_, entry| entry.is_in_flight());
1011 }
1012 }
1013
1014 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 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 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 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 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 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 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 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 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 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 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 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 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 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 ¤t_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 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 self.finish_tier2_flight(&result.key, outcome);
2459 }
2464
2465 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#[derive(Default)]
2539struct Tier2PhaseTimings {
2540 freshness: Duration,
2542 snapshot: Duration,
2544 scan: Duration,
2546 db: Duration,
2548 db_lock: Duration,
2550 db_txn: Duration,
2552 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 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 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 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 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 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 if let Some(snapshot) = cache.cached_callgraph_projection(identity) {
3156 return Some(snapshot);
3157 }
3158 } else if cache_identity.is_none() {
3159 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 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
3390fn 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 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 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 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 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 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 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 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 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 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 #[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 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 #[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 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 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 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}