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