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, OnceLock, Weak};
6use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
7
8use crossbeam_channel::{after, bounded, select_biased, 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, PendingWaitCause,
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_incremental_with_costs, project_dead_code_snapshot_with_revision,
30 CallGraphStore, CallGraphStoreError, ProjectionCostEstimates, ProjectionKind,
31 ProjectionVerdict, ReadonlyCallGraphStore, MAX_DELTA_BYTES,
32};
33use crate::cold_build_limiter;
34
35const DEFAULT_SOFT_DEADLINE: Duration = Duration::from_secs(1);
36
37type WaiterTx = Sender<JobOutcome>;
38type Tier2PermitSlot = Arc<Mutex<Option<cold_build_limiter::ColdBuildPermit>>>;
39
40#[derive(Clone)]
41struct Waiter {
42 tx: WaiterTx,
43}
44
45struct CachedContributionFreshness {
46 file_path: PathBuf,
47 freshness: FileFreshness,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Hash)]
51struct InspectCacheIdentity {
52 sqlite_path: PathBuf,
53 project_root: PathBuf,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
59struct CallgraphProjectionIdentity {
60 project_root: PathBuf,
61 generation: Option<String>,
62 legacy_sqlite_path: Option<PathBuf>,
65 write_revision: u64,
66}
67
68#[derive(Debug)]
69struct CachedCallgraphProjection {
70 identity: CallgraphProjectionIdentity,
71 snapshot: Arc<CallgraphSnapshot>,
72 estimated_bytes: u64,
73 costs: ProjectionCostEstimates,
74 rollup: Option<(
75 CallgraphProjectionIdentity,
76 Arc<super::scanners::dead_code::DeadCodeRollupState>,
77 )>,
78}
79
80const DEAD_CODE_SNAPSHOT_FLEET_BUDGET: u64 = 1024 * 1024 * 1024;
83
84type ProjectionSlot = Mutex<Option<CachedCallgraphProjection>>;
85
86struct ProjectionFleetEntry {
87 slot: Weak<ProjectionSlot>,
88 bytes: u64,
89 touched: u64,
90}
91
92#[derive(Default)]
93struct ProjectionFleet {
94 entries: HashMap<PathBuf, ProjectionFleetEntry>,
95 bytes: u64,
96 drops: u64,
97 clock: u64,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub(crate) struct DeadCodeSnapshotCensus {
102 pub roots: usize,
103 pub bytes: u64,
104 pub drops: u64,
105}
106
107impl ProjectionFleet {
108 fn admit(&mut self, root: PathBuf, slot: Weak<ProjectionSlot>, bytes: u64, budget: u64) {
109 self.clock = self.clock.saturating_add(1);
110 if let Some(previous) = self.entries.remove(&root) {
111 self.bytes = self.bytes.saturating_sub(previous.bytes);
112 }
113 self.bytes = self.bytes.saturating_add(bytes);
114 self.entries.insert(
115 root,
116 ProjectionFleetEntry {
117 slot,
118 bytes,
119 touched: self.clock,
120 },
121 );
122
123 while self.bytes > budget {
124 let Some(oldest) = self
125 .entries
126 .iter()
127 .min_by_key(|(_, entry)| entry.touched)
128 .map(|(root, _)| root.clone())
129 else {
130 break;
131 };
132 let Some(entry) = self.entries.remove(&oldest) else {
133 continue;
134 };
135 self.bytes = self.bytes.saturating_sub(entry.bytes);
136 if let Some(slot) = entry.slot.upgrade() {
137 if let Ok(mut cached) = slot.lock() {
138 cached.take();
139 }
140 }
141 self.drops = self.drops.saturating_add(1);
142 }
143 }
144
145 fn touch(&mut self, root: &Path) {
146 if let Some(entry) = self.entries.get_mut(root) {
147 self.clock = self.clock.saturating_add(1);
148 entry.touched = self.clock;
149 }
150 }
151
152 fn forget(&mut self, root: &Path) {
153 if let Some(entry) = self.entries.remove(root) {
154 self.bytes = self.bytes.saturating_sub(entry.bytes);
155 }
156 }
157
158 fn census(&mut self) -> DeadCodeSnapshotCensus {
159 let stale = self
160 .entries
161 .iter()
162 .filter(|(_, entry)| entry.slot.upgrade().is_none())
163 .map(|(root, _)| root.clone())
164 .collect::<Vec<_>>();
165 for root in stale {
166 self.forget(&root);
167 }
168 DeadCodeSnapshotCensus {
169 roots: self.entries.len(),
170 bytes: self.bytes,
171 drops: self.drops,
172 }
173 }
174}
175
176fn projection_fleet() -> &'static Mutex<ProjectionFleet> {
177 static FLEET: OnceLock<Mutex<ProjectionFleet>> = OnceLock::new();
178 FLEET.get_or_init(|| Mutex::new(ProjectionFleet::default()))
179}
180
181pub(crate) fn dead_code_snapshot_census() -> DeadCodeSnapshotCensus {
182 projection_fleet()
183 .lock()
184 .map(|mut fleet| fleet.census())
185 .unwrap_or(DeadCodeSnapshotCensus {
186 roots: 0,
187 bytes: 0,
188 drops: 0,
189 })
190}
191
192#[derive(Debug, Clone)]
193pub struct Tier2RunSubmissionError {
194 pub category: InspectCategory,
195 pub message: String,
196}
197
198#[derive(Debug, Clone, Default)]
199pub struct Tier2RunSubmission {
200 pub queued_categories: Vec<InspectCategory>,
201 pub newly_queued_categories: Vec<InspectCategory>,
202 pub deferred_categories: Vec<InspectCategory>,
203 pub errors: Vec<Tier2RunSubmissionError>,
204}
205
206impl Tier2RunSubmission {
207 pub fn has_new_work(&self) -> bool {
208 !self.newly_queued_categories.is_empty()
209 }
210}
211
212#[derive(Debug, Clone)]
213struct Tier2ReuseOptions {
214 force_rescan_paths: BTreeSet<PathBuf>,
215 allow_callgraph_cold_build: bool,
216 require_callgraph_snapshot: bool,
217 interactive: bool,
218}
219
220impl Tier2ReuseOptions {
221 fn has_force_paths(&self) -> bool {
222 !self.force_rescan_paths.is_empty()
223 }
224}
225
226impl Default for Tier2ReuseOptions {
227 fn default() -> Self {
228 Self {
229 force_rescan_paths: BTreeSet::new(),
230 allow_callgraph_cold_build: true,
231 require_callgraph_snapshot: false,
232 interactive: false,
233 }
234 }
235}
236
237#[derive(Clone, Copy, Debug, Eq, PartialEq)]
238pub(crate) enum InspectBuilderState {
239 Building,
240 QueuedBehindColdBuilds,
241 GatedBySemanticSeed,
242 Suspended,
243 BuildDenied,
244 Absent,
245}
246
247impl InspectBuilderState {
248 pub(crate) const fn as_str(self) -> &'static str {
249 match self {
250 Self::Building => "building",
251 Self::QueuedBehindColdBuilds => "queued_behind_cold_builds",
252 Self::GatedBySemanticSeed => "gated_by_semantic_seed",
253 Self::Suspended => "suspended",
254 Self::BuildDenied => "build_denied (borrow-only)",
255 Self::Absent => "absent",
256 }
257 }
258}
259
260struct BuilderStateEntry {
269 state: Option<InspectBuilderState>,
270 started_at: Instant,
271 started_unix: u64,
272 first_attempt_unix: u64,
273 attempt_count: u64,
274 last_failure: Option<String>,
275 suspension: Option<crate::build_breaker::BuildSuspension>,
276}
277
278impl BuilderStateEntry {
279 fn new(state: InspectBuilderState) -> Self {
280 let now = unix_now_secs();
281 Self {
282 state: Some(state),
283 started_at: Instant::now(),
284 started_unix: now,
285 first_attempt_unix: now,
286 attempt_count: 0,
287 last_failure: None,
288 suspension: None,
289 }
290 }
291
292 fn is_in_flight(&self) -> bool {
293 self.state.is_some_and(|state| {
294 matches!(
295 state,
296 InspectBuilderState::Building
297 | InspectBuilderState::QueuedBehindColdBuilds
298 | InspectBuilderState::GatedBySemanticSeed
299 )
300 })
301 }
302
303 fn begin_attempt(&mut self, state: InspectBuilderState) {
304 self.state = Some(state);
305 self.suspension = None;
306 self.started_at = Instant::now();
307 self.started_unix = unix_now_secs();
308 if self.attempt_count == 0 && self.last_failure.is_none() {
309 self.first_attempt_unix = self.started_unix;
310 }
311 }
312
313 fn record_failure(&mut self, terminal: String) {
314 self.state = None;
315 self.suspension = None;
316 self.attempt_count = self.attempt_count.saturating_add(1);
317 self.last_failure = Some(terminal);
318 }
319
320 fn record_suspension(&mut self, suspension: crate::build_breaker::BuildSuspension) {
321 self.state = Some(InspectBuilderState::Suspended);
322 self.last_failure = None;
323 self.suspension = Some(suspension);
324 }
325
326 fn detail_at(&self, now_ms: u64) -> String {
327 if let Some(suspension) = self.suspension.as_ref() {
328 return format!(
329 "suspended domain={} deaths={} age_s={} reason={}",
330 suspension.domain.as_str(),
331 suspension.death_count,
332 suspension.age_seconds_at(now_ms),
333 suspension.reason,
334 );
335 }
336 if let Some(terminal) = self.last_failure.as_deref() {
337 return format!(
338 "last attempt failed: {terminal} (attempt {}, first at {})",
339 self.attempt_count, self.first_attempt_unix
340 );
341 }
342 match self.state {
343 Some(InspectBuilderState::Building) => format!(
344 "building since {} (age_s={})",
345 self.started_unix,
346 self.started_at.elapsed().as_secs()
347 ),
348 Some(other) => other.as_str().to_string(),
349 None => InspectBuilderState::Absent.as_str().to_string(),
350 }
351 }
352}
353
354fn unix_millis_now() -> u64 {
355 SystemTime::now()
356 .duration_since(UNIX_EPOCH)
357 .unwrap_or_default()
358 .as_millis()
359 .min(u128::from(u64::MAX)) as u64
360}
361
362fn unix_now_secs() -> u64 {
363 unix_millis_now() / 1_000
364}
365
366fn run_tier2_pass_with_deadline<T>(
367 project_root: &Path,
368 category: InspectCategory,
369 timeout: Duration,
370 permit_slot: Option<Tier2PermitSlot>,
371 action: impl FnOnce() -> T,
372) -> (T, bool) {
373 let cancellation = crate::executor::JobCancellation::new();
374 let parent_cancellation = crate::executor::current_job_cancellation();
375 let deadline_cancellation = cancellation.clone();
376 let root = project_root.to_path_buf();
377 let timed_out = Arc::new(AtomicBool::new(false));
378 let timer_timed_out = Arc::clone(&timed_out);
379 let (done_tx, done_rx) = bounded::<()>(1);
380 let timer = std::thread::spawn(move || {
381 let deadline = Instant::now() + timeout;
382 loop {
383 if parent_cancellation
384 .as_ref()
385 .is_some_and(|token| token.cancel_requested_before_commit())
386 {
387 deadline_cancellation.request_cancel();
388 return;
389 }
390 let now = Instant::now();
391 if now >= deadline {
392 timer_timed_out.store(true, Ordering::Release);
393 deadline_cancellation.request_cancel();
394 if let Some(slot) = permit_slot.as_ref() {
395 slot.lock()
396 .unwrap_or_else(std::sync::PoisonError::into_inner)
397 .take();
398 }
399 crate::slog_warn!(
400 "tier2 pass timeout: root={} category={} timeout_ms={} slot released, pass still running",
401 root.display(),
402 category,
403 timeout.as_millis()
404 );
405 return;
406 }
407 let slice = deadline
408 .saturating_duration_since(now)
409 .min(Duration::from_millis(100));
410 match done_rx.recv_timeout(slice) {
411 Ok(()) | Err(crossbeam_channel::RecvTimeoutError::Disconnected) => return,
412 Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
413 }
414 }
415 });
416 let _cancellation = crate::executor::install_job_cancellation(cancellation);
417 let result = action();
418 let _ = done_tx.send(());
419 let _ = timer.join();
420 (result, timed_out.load(Ordering::Acquire))
421}
422
423enum BuilderAttemptTerminal {
424 Succeeded,
425 Failed(String),
426 Inconclusive,
427}
428
429fn builder_attempt_terminal(outcome: &JobOutcome) -> BuilderAttemptTerminal {
430 match outcome {
431 JobOutcome::Fresh { payload } if callgraph_unavailable_payload(payload) => {
432 BuilderAttemptTerminal::Failed("callgraph_unavailable".to_string())
433 }
434 JobOutcome::Fresh { .. } => BuilderAttemptTerminal::Succeeded,
435 JobOutcome::Failed { message } => {
436 BuilderAttemptTerminal::Failed(builder_failure_terminal(message))
437 }
438 JobOutcome::Stale { .. } | JobOutcome::Pending { .. } => {
439 BuilderAttemptTerminal::Inconclusive
440 }
441 }
442}
443
444fn callgraph_unavailable_payload(payload: &Value) -> bool {
445 payload.get("callgraph_available").and_then(Value::as_bool) == Some(false)
446 || payload
447 .get("notes")
448 .and_then(Value::as_array)
449 .is_some_and(|notes| {
450 notes
451 .iter()
452 .any(|note| note.as_str() == Some("callgraph_unavailable"))
453 })
454}
455
456fn builder_failure_terminal(message: &str) -> String {
457 if message.contains("callgraph_unavailable") {
458 "callgraph_unavailable".to_string()
459 } else {
460 message
461 .lines()
462 .next()
463 .unwrap_or("failed")
464 .chars()
465 .take(64)
466 .collect()
467 }
468}
469
470fn callgraph_store_ready_for_dead_code(callgraph_dir: PathBuf, project_root: PathBuf) -> bool {
471 match CallGraphStore::open_readonly(callgraph_dir, project_root) {
472 Ok(Some(store)) => store
473 .stale_files()
474 .ok()
475 .is_some_and(|files| files.is_empty()),
476 _ => false,
477 }
478}
479
480struct Tier2FlightExitGuard<'a> {
485 manager: &'a InspectManager,
486 key: JobKey,
487}
488
489impl Drop for Tier2FlightExitGuard<'_> {
490 fn drop(&mut self) {
491 self.manager.finish_tier2_flight(
492 &self.key,
493 JobOutcome::Failed {
494 message: "tier2 reuse worker exited without publishing a result".to_string(),
495 },
496 );
497 }
498}
499
500fn cached_tier2_aggregate_usable(
501 category: InspectCategory,
502 options: &Tier2ReuseOptions,
503 aggregate: &Value,
504) -> bool {
505 if category == InspectCategory::DeadCode
506 && options.allow_callgraph_cold_build
507 && aggregate
508 .get("callgraph_available")
509 .and_then(Value::as_bool)
510 == Some(false)
511 {
512 return false;
513 }
514 true
515}
516
517pub struct InspectManager {
518 request_tx: Sender<InspectJob>,
519 result_rx: Receiver<InspectResult>,
520 #[allow(dead_code)]
521 pool: Arc<rayon::ThreadPool>,
522 in_flight: Mutex<HashMap<JobKey, Vec<Waiter>>>,
523 in_flight_changed: Condvar,
524 caches: Mutex<HashMap<InspectCacheIdentity, Arc<InspectCache>>>,
525 callgraph_projection: Arc<ProjectionSlot>,
528 oxc_facts_cache: Mutex<OxcFactsCache>,
529 soft_deadline: Duration,
530 next_job_id: AtomicU64,
531 heavy_root_work_allowed: Arc<AtomicBool>,
532 semantic_cold_seed_active: Arc<AtomicBool>,
533 cold_build_limiter: Mutex<Arc<cold_build_limiter::ColdBuildLimiter>>,
534 builder_states: Mutex<HashMap<JobKey, BuilderStateEntry>>,
539 automatic_tier2_refresh_allowed: AtomicBool,
540 automatic_tier2_skip_logged: AtomicBool,
541 automatic_tier2_schedule_count: AtomicU64,
542 reuse_completions: AtomicU64,
547 reuse_starts: AtomicU64,
550}
551
552impl InspectManager {
553 pub fn new() -> Self {
554 Self::with_heavy_root_work_gate(Arc::new(AtomicBool::new(true)))
555 }
556
557 pub fn with_heavy_root_work_gate(heavy_root_work_allowed: Arc<AtomicBool>) -> Self {
558 Self::with_root_work_gates(heavy_root_work_allowed, Arc::new(AtomicBool::new(false)))
559 }
560
561 pub fn with_root_work_gates(
562 heavy_root_work_allowed: Arc<AtomicBool>,
563 semantic_cold_seed_active: Arc<AtomicBool>,
564 ) -> Self {
565 Self::with_worker_and_gates(
566 default_worker(),
567 DEFAULT_SOFT_DEADLINE,
568 heavy_root_work_allowed,
569 semantic_cold_seed_active,
570 )
571 }
572
573 #[doc(hidden)]
574 pub fn with_worker(worker: InspectWorker, soft_deadline: Duration) -> Self {
575 Self::with_worker_and_gate(worker, soft_deadline, Arc::new(AtomicBool::new(true)))
576 }
577
578 #[doc(hidden)]
579 pub fn with_worker_and_gate(
580 worker: InspectWorker,
581 soft_deadline: Duration,
582 heavy_root_work_allowed: Arc<AtomicBool>,
583 ) -> Self {
584 Self::with_worker_and_gates(
585 worker,
586 soft_deadline,
587 heavy_root_work_allowed,
588 Arc::new(AtomicBool::new(false)),
589 )
590 }
591
592 fn with_worker_and_gates(
593 worker: InspectWorker,
594 soft_deadline: Duration,
595 heavy_root_work_allowed: Arc<AtomicBool>,
596 semantic_cold_seed_active: Arc<AtomicBool>,
597 ) -> Self {
598 let handles = start_dispatch_loop(worker);
599 Self {
600 request_tx: handles.request_tx,
601 result_rx: handles.result_rx,
602 pool: handles.pool,
603 in_flight: Mutex::new(HashMap::new()),
604 in_flight_changed: Condvar::new(),
605 caches: Mutex::new(HashMap::new()),
606 callgraph_projection: Arc::new(Mutex::new(None)),
607 oxc_facts_cache: Mutex::new(OxcFactsCache::new()),
608 soft_deadline,
609 next_job_id: AtomicU64::new(1),
610 heavy_root_work_allowed,
611 semantic_cold_seed_active,
612 cold_build_limiter: Mutex::new(cold_build_limiter::global_limiter()),
613 builder_states: Mutex::new(HashMap::new()),
614 automatic_tier2_refresh_allowed: AtomicBool::new(true),
615 automatic_tier2_skip_logged: AtomicBool::new(false),
616 automatic_tier2_schedule_count: AtomicU64::new(0),
617 reuse_completions: AtomicU64::new(0),
618 reuse_starts: AtomicU64::new(0),
619 }
620 }
621
622 fn heavy_root_work_allowed(&self) -> bool {
623 self.heavy_root_work_allowed.load(Ordering::SeqCst)
624 }
625
626 pub(crate) fn set_cold_build_limiter(
627 &self,
628 limiter: Arc<cold_build_limiter::ColdBuildLimiter>,
629 ) {
630 *self
631 .cold_build_limiter
632 .lock()
633 .unwrap_or_else(std::sync::PoisonError::into_inner) = limiter;
634 }
635
636 fn cold_build_limiter(&self) -> Arc<cold_build_limiter::ColdBuildLimiter> {
637 Arc::clone(
638 &self
639 .cold_build_limiter
640 .lock()
641 .unwrap_or_else(std::sync::PoisonError::into_inner),
642 )
643 }
644
645 fn set_builder_state(&self, key: &JobKey, state: InspectBuilderState) {
646 if let Ok(mut states) = self.builder_states.lock() {
647 if let Some(entry) = states.get_mut(key) {
648 entry.begin_attempt(state);
649 } else {
650 states.insert(key.clone(), BuilderStateEntry::new(state));
651 }
652 }
653 }
654
655 fn clear_builder_state(&self, key: &JobKey) {
656 if let Ok(mut states) = self.builder_states.lock() {
657 states.remove(key);
658 }
659 }
660
661 fn record_flight_start(&self, key: &JobKey) {
662 self.set_builder_state(key, InspectBuilderState::Building);
663 }
664
665 fn record_builder_attempt_outcome(&self, key: &JobKey, outcome: &JobOutcome) {
666 let Ok(mut states) = self.builder_states.lock() else {
667 return;
668 };
669 if states
670 .get(key)
671 .is_some_and(|entry| entry.suspension.is_some())
672 {
673 return;
674 }
675 match builder_attempt_terminal(outcome) {
676 BuilderAttemptTerminal::Succeeded => {
677 states.remove(key);
678 }
679 BuilderAttemptTerminal::Failed(terminal) => {
680 if let Some(entry) = states.get_mut(key) {
681 entry.record_failure(terminal);
682 } else {
683 let mut entry = BuilderStateEntry::new(InspectBuilderState::Building);
684 entry.record_failure(terminal);
685 states.insert(key.clone(), entry);
686 }
687 }
688 BuilderAttemptTerminal::Inconclusive => {
689 if let Some(entry) = states.get_mut(key) {
690 entry.state = None;
691 if entry.last_failure.is_none() && entry.attempt_count == 0 {
692 states.remove(key);
693 }
694 }
695 }
696 }
697 }
698
699 fn tier2_flight_exit_guard(&self, key: JobKey) -> Tier2FlightExitGuard<'_> {
700 Tier2FlightExitGuard { manager: self, key }
701 }
702
703 fn finish_tier2_flight(&self, key: &JobKey, outcome: JobOutcome) {
706 let Some(waiters) = self.take_waiters(key) else {
707 return;
708 };
709 self.record_builder_attempt_outcome(key, &outcome);
710 self.reuse_completions.fetch_add(1, Ordering::SeqCst);
711 Self::deliver_waiters(waiters, outcome);
712 }
713
714 fn take_waiters(&self, key: &JobKey) -> Option<Vec<Waiter>> {
715 let waiters = self
716 .in_flight
717 .lock()
718 .unwrap_or_else(std::sync::PoisonError::into_inner)
719 .remove(key);
720 if waiters.is_some() {
721 self.in_flight_changed.notify_all();
722 }
723 waiters
724 }
725
726 fn deliver_waiters(waiters: Vec<Waiter>, outcome: JobOutcome) {
727 for waiter in waiters {
728 let _ = waiter.tx.send(outcome.clone());
729 }
730 }
731
732 pub(crate) fn tier2_builder_state(&self, category: InspectCategory) -> InspectBuilderState {
733 let key = JobKey::for_project_category(category);
734 if let Ok(states) = self.builder_states.lock() {
735 if let Some(entry) = states.get(&key) {
736 if let Some(state) = entry.state {
737 return state;
738 }
739 }
740 }
741 if self
742 .in_flight
743 .lock()
744 .map(|in_flight| in_flight.contains_key(&key))
745 .unwrap_or(false)
746 {
747 InspectBuilderState::Building
748 } else {
749 InspectBuilderState::Absent
750 }
751 }
752
753 pub(crate) fn tier2_builder_state_detail(&self, category: InspectCategory) -> String {
754 self.tier2_builder_state_detail_at(category, unix_millis_now())
755 }
756
757 pub(crate) fn tier2_builder_state_detail_at(
758 &self,
759 category: InspectCategory,
760 now_ms: u64,
761 ) -> String {
762 let key = JobKey::for_project_category(category);
763 if let Ok(states) = self.builder_states.lock() {
764 if let Some(entry) = states.get(&key) {
765 return entry.detail_at(now_ms);
766 }
767 }
768 self.tier2_builder_state(category).as_str().to_string()
769 }
770
771 fn record_tier2_build_suspension(
772 &self,
773 key: &JobKey,
774 suspension: crate::build_breaker::BuildSuspension,
775 ) {
776 if let Ok(mut states) = self.builder_states.lock() {
777 if let Some(entry) = states.get_mut(key) {
778 entry.record_suspension(suspension);
779 } else {
780 let mut entry = BuilderStateEntry::new(InspectBuilderState::Suspended);
781 entry.record_suspension(suspension);
782 states.insert(key.clone(), entry);
783 }
784 }
785 }
786
787 #[cfg(test)]
788 pub(crate) fn record_tier2_build_suspension_for_test(
789 &self,
790 category: InspectCategory,
791 suspension: crate::build_breaker::BuildSuspension,
792 ) {
793 self.record_tier2_build_suspension(&JobKey::for_project_category(category), suspension);
794 }
795
796 fn builder_state_detail_for_job(&self, job: &InspectJob) -> String {
797 if !job.inspect_writer || !job.callgraph_writer {
798 InspectBuilderState::BuildDenied.as_str().to_string()
799 } else {
800 self.tier2_builder_state_detail(job.category)
801 }
802 }
803
804 pub(crate) fn try_tier2_builder_busy(&self) -> Option<bool> {
808 let states = self.builder_states.try_lock().ok()?;
809 if states
810 .iter()
811 .any(|(key, entry)| key.category.is_tier2() && entry.is_in_flight())
812 {
813 return Some(true);
814 }
815 drop(states);
816 self.try_tier2_any_in_flight()
817 }
818
819 pub(crate) fn callgraph_ready_for_snapshot(&self, snapshot: &InspectSnapshot) -> bool {
825 if !snapshot.config.callgraph_store {
826 return false;
827 }
828 if snapshot.config.views.enabled {
829 return current_view_projection_store(
830 &snapshot.project_root,
831 &snapshot.inspect_dir,
832 &snapshot.config,
833 &[],
834 )
835 .is_some();
836 }
837 callgraph_store_dirs_from_inspect_dir(&snapshot.inspect_dir, &snapshot.project_root)
838 .into_iter()
839 .any(|dir| callgraph_store_ready_for_dead_code(dir, snapshot.project_root.clone()))
840 }
841
842 pub fn set_automatic_tier2_refresh_allowed(&self, allowed: bool) {
843 self.automatic_tier2_refresh_allowed
844 .store(allowed, Ordering::SeqCst);
845 self.automatic_tier2_skip_logged
846 .store(false, Ordering::SeqCst);
847 }
848
849 pub fn automatic_tier2_refresh_enabled(&self) -> bool {
850 self.automatic_tier2_refresh_allowed.load(Ordering::SeqCst)
851 }
852
853 pub fn automatic_tier2_refresh_allowed(&self) -> bool {
854 let allowed = self.automatic_tier2_refresh_enabled();
855 if !allowed
856 && !self
857 .automatic_tier2_skip_logged
858 .swap(true, Ordering::SeqCst)
859 {
860 crate::slog_debug!("automatic Tier-2 scan scheduling skipped for linked worktree root");
861 }
862 allowed
863 }
864
865 #[doc(hidden)]
866 pub fn inspect_pool_for_test(&self) -> Arc<rayon::ThreadPool> {
867 Arc::clone(&self.pool)
868 }
869
870 #[doc(hidden)]
871 pub fn automatic_tier2_schedule_count_for_test(&self) -> u64 {
872 self.automatic_tier2_schedule_count.load(Ordering::SeqCst)
873 }
874
875 fn category_needs_heavy_root_work(category: InspectCategory) -> bool {
876 category != InspectCategory::Diagnostics
877 }
878
879 fn heavy_root_work_block_message(category: InspectCategory) -> String {
880 format!(
881 "inspect category '{category}' is unavailable because heavy project-wide work is disabled for this root"
882 )
883 }
884
885 pub fn submit_category(
886 &self,
887 snapshot: InspectSnapshot,
888 category: InspectCategory,
889 caller_scope: JobScope,
890 ) -> JobOutcome {
891 self.submit_category_with_callgraph(snapshot, category, caller_scope, None)
892 }
893
894 #[doc(hidden)]
898 pub fn submit_category_until(
899 &self,
900 snapshot: InspectSnapshot,
901 category: InspectCategory,
902 caller_scope: JobScope,
903 deadline: Instant,
904 ) -> JobOutcome {
905 self.submit_category_with_callgraph_until(snapshot, category, caller_scope, None, deadline)
906 }
907
908 pub fn submit_category_with_callgraph(
909 &self,
910 snapshot: InspectSnapshot,
911 category: InspectCategory,
912 caller_scope: JobScope,
913 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
914 ) -> JobOutcome {
915 self.submit_category_with_callgraph_until(
916 snapshot,
917 category,
918 caller_scope,
919 callgraph_snapshot,
920 Instant::now() + self.soft_deadline,
921 )
922 }
923
924 fn submit_category_with_callgraph_until(
925 &self,
926 snapshot: InspectSnapshot,
927 category: InspectCategory,
928 caller_scope: JobScope,
929 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
930 deadline: Instant,
931 ) -> JobOutcome {
932 let wait_started = Instant::now();
933 let wait_budget = deadline.saturating_duration_since(wait_started);
934 if !category.is_active() {
935 return JobOutcome::Failed {
936 message: format!("inspect category '{category}' is disabled in v0.33"),
937 };
938 }
939 if Self::category_needs_heavy_root_work(category) && !self.heavy_root_work_allowed() {
940 return JobOutcome::Failed {
941 message: Self::heavy_root_work_block_message(category),
942 };
943 }
944
945 let cache = match self.cache_for_snapshot(&snapshot) {
946 Ok(cache) => cache,
947 Err(message) => return JobOutcome::Failed { message },
948 };
949 let key = JobKey::for_category_scope(category, &caller_scope);
950 let (waiter_tx, waiter_rx) = bounded(1);
951
952 let wait_snapshot = snapshot.clone();
953 match self.enqueue_with_waiter(
954 snapshot,
955 category,
956 caller_scope.clone(),
957 key.clone(),
958 waiter_tx,
959 callgraph_snapshot,
960 ) {
961 Ok(()) => self.wait_for_outcome(
962 key,
963 caller_scope,
964 cache,
965 waiter_rx,
966 wait_snapshot,
967 deadline,
968 wait_started,
969 wait_budget,
970 ),
971 Err(message) => JobOutcome::Failed { message },
972 }
973 }
974
975 pub fn submit_background(
976 &self,
977 snapshot: InspectSnapshot,
978 category: InspectCategory,
979 caller_scope: JobScope,
980 ) -> Result<JobKey, String> {
981 self.submit_background_with_callgraph(snapshot, category, caller_scope, None)
982 }
983
984 pub fn submit_background_with_callgraph(
985 &self,
986 snapshot: InspectSnapshot,
987 category: InspectCategory,
988 caller_scope: JobScope,
989 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
990 ) -> Result<JobKey, String> {
991 if !category.is_active() {
992 return Err(format!(
993 "inspect category '{category}' is disabled in v0.33"
994 ));
995 }
996 if Self::category_needs_heavy_root_work(category) && !self.heavy_root_work_allowed() {
997 return Err(Self::heavy_root_work_block_message(category));
998 }
999 let key = JobKey::for_category_scope(category, &caller_scope);
1000 self.enqueue_without_waiter(
1001 snapshot,
1002 category,
1003 caller_scope,
1004 key.clone(),
1005 callgraph_snapshot,
1006 )?;
1007 Ok(key)
1008 }
1009
1010 pub fn submit_tier2_run_with_reuse_background(
1011 self: &Arc<Self>,
1012 snapshot: InspectSnapshot,
1013 category: InspectCategory,
1014 ) -> Result<Option<JobKey>, String> {
1015 if !category.is_active() {
1016 return Err(format!(
1017 "inspect category '{category}' is disabled in v0.33"
1018 ));
1019 }
1020 if !category.is_tier2() {
1021 return Err(format!(
1022 "inspect category '{category}' is not a Tier 2 category"
1023 ));
1024 }
1025 if !self.heavy_root_work_allowed() {
1026 return Err(Self::heavy_root_work_block_message(category));
1027 }
1028 if !self.automatic_tier2_refresh_allowed() {
1029 return Ok(None);
1030 }
1031 self.automatic_tier2_schedule_count
1032 .fetch_add(1, Ordering::SeqCst);
1033
1034 let job = self.tier2_reuse_job(snapshot, category, None);
1035 let key = job.key.clone();
1036 let mut in_flight = self
1037 .in_flight
1038 .lock()
1039 .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
1040 if in_flight.contains_key(&key) {
1041 return Ok(Some(key));
1042 }
1043 let limiter = self.cold_build_limiter();
1044 let request = cold_build_limiter::ColdBuildAdmissionRequest::new(
1045 format!("tier2-background:{}", category.as_str()),
1046 cold_build_limiter::ColdBuildAdmissionClass::Maintenance,
1047 );
1048 let Some(permit) =
1049 cold_build_limiter::try_acquire_classified_with_limiter(&limiter, &request)
1050 else {
1051 return Err(format!(
1052 "cold build concurrency limit ({}) reached; retrying later",
1053 limiter.limit()
1054 ));
1055 };
1056 let permit_slot = Arc::new(Mutex::new(Some(permit)));
1057 let worker_permit_slot = Arc::clone(&permit_slot);
1058 in_flight.insert(key.clone(), Vec::new());
1059 drop(in_flight);
1060 self.record_flight_start(&key);
1061
1062 let manager = Arc::clone(self);
1063 let pool = Arc::clone(&self.pool);
1064 pool.spawn_fifo(move || {
1065 let _flight = manager.tier2_flight_exit_guard(job.key.clone());
1066 let result = manager.tier2_run_with_reuse_job_result_catching(
1067 job,
1068 Tier2ReuseOptions::default(),
1069 Some(worker_permit_slot),
1070 );
1071 manager.route_tier2_reuse_completion(result);
1072 });
1073
1074 Ok(Some(key))
1075 }
1076
1077 pub fn submit_tier2_run_with_reuse_serial_background(
1078 self: &Arc<Self>,
1079 snapshot: InspectSnapshot,
1080 categories: Vec<InspectCategory>,
1081 ) -> Tier2RunSubmission {
1082 let mut submission = Tier2RunSubmission::default();
1083 let mut requested = Vec::new();
1084
1085 for category in categories {
1086 if !category.is_active() {
1087 submission.errors.push(Tier2RunSubmissionError {
1088 category,
1089 message: format!("inspect category '{category}' is disabled in v0.33"),
1090 });
1091 continue;
1092 }
1093 if !category.is_tier2() {
1094 submission.errors.push(Tier2RunSubmissionError {
1095 category,
1096 message: format!("inspect category '{category}' is not a Tier 2 category"),
1097 });
1098 continue;
1099 }
1100 requested.push(category);
1101 }
1102
1103 if requested.is_empty() {
1104 return submission;
1105 }
1106 if !self.heavy_root_work_allowed() {
1107 for category in requested {
1108 submission.errors.push(Tier2RunSubmissionError {
1109 category,
1110 message: Self::heavy_root_work_block_message(category),
1111 });
1112 }
1113 return submission;
1114 }
1115 if !self.automatic_tier2_refresh_allowed() {
1116 return submission;
1117 }
1118 self.automatic_tier2_schedule_count
1119 .fetch_add(requested.len() as u64, Ordering::SeqCst);
1120
1121 let mut in_flight = match self.in_flight.lock() {
1122 Ok(in_flight) => in_flight,
1123 Err(_) => {
1124 for category in requested {
1125 submission.errors.push(Tier2RunSubmissionError {
1126 category,
1127 message: "inspect in-flight map lock poisoned".to_string(),
1128 });
1129 }
1130 return submission;
1131 }
1132 };
1133
1134 let mut started = Vec::new();
1135 for category in requested {
1136 let key = JobKey::for_project_category(category);
1137 submission.queued_categories.push(category);
1138 if in_flight.contains_key(&key) {
1139 continue;
1140 }
1141 in_flight.insert(key.clone(), Vec::new());
1142 started.push(key);
1143 submission.newly_queued_categories.push(category);
1144 }
1145 drop(in_flight);
1146 for key in &started {
1147 self.record_flight_start(key);
1148 }
1149
1150 if submission.newly_queued_categories.is_empty() {
1151 return submission;
1152 }
1153
1154 let limiter = self.cold_build_limiter();
1155 let request = cold_build_limiter::ColdBuildAdmissionRequest::new(
1156 "tier2-serial-background",
1157 cold_build_limiter::ColdBuildAdmissionClass::Maintenance,
1158 );
1159 let Some(permit) =
1160 cold_build_limiter::try_acquire_classified_with_limiter(&limiter, &request)
1161 else {
1162 let deferred = submission.newly_queued_categories.clone();
1163 if let Ok(mut in_flight) = self.in_flight.lock() {
1164 for category in &deferred {
1165 in_flight.remove(&JobKey::for_project_category(*category));
1166 }
1167 }
1168 for category in &deferred {
1169 self.clear_builder_state(&JobKey::for_project_category(*category));
1170 }
1171 submission
1172 .queued_categories
1173 .retain(|category| !deferred.contains(category));
1174 submission.deferred_categories = deferred;
1175 submission.newly_queued_categories.clear();
1176 return submission;
1177 };
1178
1179 let permit_slot = Arc::new(Mutex::new(Some(permit)));
1180 let categories_for_worker = submission.newly_queued_categories.clone();
1181 let manager = Arc::clone(self);
1182 let pool = Arc::clone(&self.pool);
1183 pool.spawn_fifo(move || {
1184 for category in categories_for_worker {
1185 let job = manager.tier2_reuse_job(snapshot.clone(), category, None);
1186 let _flight = manager.tier2_flight_exit_guard(job.key.clone());
1187 let permit_available = permit_slot
1188 .lock()
1189 .unwrap_or_else(std::sync::PoisonError::into_inner)
1190 .is_some();
1191 let result = if permit_available {
1192 manager.tier2_run_with_reuse_job_result_catching(
1193 job,
1194 Tier2ReuseOptions::default(),
1195 Some(Arc::clone(&permit_slot)),
1196 )
1197 } else {
1198 InspectResult::failed(
1199 &job,
1200 "serial Tier-2 run stopped after the limiter slot deadline",
1201 Duration::ZERO,
1202 )
1203 };
1204 manager.route_tier2_reuse_completion(result);
1205 }
1206 });
1207
1208 submission
1209 }
1210
1211 pub fn tier2_any_in_flight(&self) -> bool {
1212 self.in_flight
1213 .lock()
1214 .map(|in_flight| in_flight.keys().any(|key| key.category.is_tier2()))
1215 .unwrap_or(false)
1216 }
1217
1218 pub(crate) fn try_tier2_any_in_flight(&self) -> Option<bool> {
1219 self.in_flight
1220 .try_lock()
1221 .ok()
1222 .map(|in_flight| in_flight.keys().any(|key| key.category.is_tier2()))
1223 }
1224
1225 #[cfg(test)]
1226 pub(crate) fn set_tier2_in_flight_for_test(&self, category: InspectCategory, in_flight: bool) {
1227 let key = JobKey::for_project_category(category);
1228 let mut jobs = self
1229 .in_flight
1230 .lock()
1231 .unwrap_or_else(std::sync::PoisonError::into_inner);
1232 if in_flight {
1233 jobs.entry(key.clone()).or_default();
1234 drop(jobs);
1235 self.record_flight_start(&key);
1236 } else {
1237 jobs.remove(&key);
1238 drop(jobs);
1239 self.clear_builder_state(&key);
1240 }
1241 }
1242
1243 #[cfg(test)]
1244 pub(crate) fn record_tier2_attempt_outcome_for_test(
1245 &self,
1246 category: InspectCategory,
1247 outcome: JobOutcome,
1248 ) {
1249 let key = JobKey::for_project_category(category);
1250 {
1251 let mut jobs = self
1252 .in_flight
1253 .lock()
1254 .unwrap_or_else(std::sync::PoisonError::into_inner);
1255 jobs.entry(key.clone()).or_default();
1256 }
1257 self.record_flight_start(&key);
1258 self.finish_tier2_flight(&key, outcome);
1259 }
1260
1261 pub fn evict_idle_caches(&self) {
1266 if let Ok(mut caches) = self.caches.lock() {
1267 caches.clear();
1268 }
1269 self.clear_callgraph_projection();
1270 if let Ok(mut facts) = self.oxc_facts_cache.lock() {
1271 *facts = OxcFactsCache::new();
1272 }
1273 if let Ok(mut states) = self.builder_states.lock() {
1276 states.retain(|_, entry| entry.is_in_flight());
1277 }
1278 }
1279
1280 pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
1284 let caches = match self.caches.try_lock() {
1285 Ok(caches) => caches.values().cloned().collect::<Vec<_>>(),
1286 Err(_) => return crate::memory::MemoryEstimate::busy(),
1287 };
1288 let facts_entries = match self.oxc_facts_cache.try_lock() {
1289 Ok(facts) => facts.len(),
1290 Err(_) => return crate::memory::MemoryEstimate::busy(),
1291 };
1292 let mut bytes = 0u64;
1293 let mut memory_aggregates = 0u64;
1294 for cache in &caches {
1295 let estimate = cache.estimated_memory();
1296 let Some(cache_bytes) = estimate.estimated_bytes else {
1297 return crate::memory::MemoryEstimate::busy();
1298 };
1299 bytes = bytes.saturating_add(cache_bytes);
1300 memory_aggregates = memory_aggregates.saturating_add(
1301 estimate
1302 .counts
1303 .get("memory_aggregates")
1304 .copied()
1305 .unwrap_or(0),
1306 );
1307 }
1308 crate::memory::MemoryEstimate::partial(bytes)
1309 .count("open_generation_handles", caches.len())
1310 .count("oxc_fact_entries", facts_entries)
1311 .count_u64("memory_aggregates", memory_aggregates)
1312 .gap("oxc_fact_bytes")
1313 }
1314
1315 pub(crate) fn dead_code_snapshot_census() -> DeadCodeSnapshotCensus {
1316 dead_code_snapshot_census()
1317 }
1318
1319 pub fn callgraph_projection_estimated_memory(&self) -> crate::memory::MemoryEstimate {
1322 let projection = match self.callgraph_projection.try_lock() {
1323 Ok(projection) => projection,
1324 Err(_) => return crate::memory::MemoryEstimate::busy(),
1325 };
1326 let bytes = projection
1327 .as_ref()
1328 .map(|projection| projection.estimated_bytes)
1329 .unwrap_or(0);
1330 crate::memory::MemoryEstimate::estimated(bytes)
1331 .count(
1332 "callgraph_projection_snapshots",
1333 usize::from(projection.is_some()),
1334 )
1335 .count_u64("callgraph_projection_snapshot_bytes", bytes)
1336 }
1337
1338 fn cached_callgraph_projection(
1339 &self,
1340 identity: &CallgraphProjectionIdentity,
1341 ) -> Option<Arc<CallgraphSnapshot>> {
1342 let snapshot = {
1343 let projection = self.callgraph_projection.lock().ok()?;
1344 projection
1345 .as_ref()
1346 .filter(|cached| cached.identity == *identity)
1347 .map(|cached| Arc::clone(&cached.snapshot))
1348 }?;
1349 if let Ok(mut fleet) = projection_fleet().lock() {
1350 fleet.touch(&identity.project_root);
1351 }
1352 Some(snapshot)
1353 }
1354
1355 fn previous_callgraph_projection(
1356 &self,
1357 identity: &CallgraphProjectionIdentity,
1358 ) -> Option<(u64, Arc<CallgraphSnapshot>)> {
1359 let previous = {
1360 let projection = self.callgraph_projection.lock().ok()?;
1361 let cached = projection.as_ref()?;
1362 let mut expected = identity.clone();
1363 expected.write_revision = cached.identity.write_revision;
1364 (cached.identity == expected)
1365 .then(|| (cached.identity.write_revision, Arc::clone(&cached.snapshot)))
1366 }?;
1367 if let Ok(mut fleet) = projection_fleet().lock() {
1368 fleet.touch(&identity.project_root);
1369 }
1370 Some(previous)
1371 }
1372
1373 fn callgraph_projection_costs(
1374 &self,
1375 identity: &CallgraphProjectionIdentity,
1376 ) -> ProjectionCostEstimates {
1377 self.callgraph_projection
1378 .lock()
1379 .ok()
1380 .and_then(|projection| {
1381 projection
1382 .as_ref()
1383 .filter(|cached| cached.identity.project_root == identity.project_root)
1384 .map(|cached| cached.costs)
1385 })
1386 .unwrap_or_default()
1387 }
1388
1389 fn observe_callgraph_projection_cost(
1390 &self,
1391 project_root: &Path,
1392 verdict: ProjectionVerdict,
1393 elapsed: Duration,
1394 ) {
1395 if let Ok(mut projection) = self.callgraph_projection.lock() {
1396 if let Some(cached) = projection
1397 .as_mut()
1398 .filter(|cached| cached.identity.project_root == project_root)
1399 {
1400 cached.costs.observe(verdict, elapsed);
1401 }
1402 }
1403 }
1404
1405 fn cache_callgraph_projection(
1406 &self,
1407 identity: CallgraphProjectionIdentity,
1408 snapshot: Arc<CallgraphSnapshot>,
1409 ) {
1410 let estimated_bytes = estimate_callgraph_snapshot_bytes(snapshot.as_ref());
1411 let root = identity.project_root.clone();
1412 if let Ok(mut cached) = self.callgraph_projection.lock() {
1413 let previous = cached.take();
1414 let costs = previous
1415 .as_ref()
1416 .filter(|cached| cached.identity.project_root == root)
1417 .map(|cached| cached.costs)
1418 .unwrap_or_default();
1419 let rollup = previous.and_then(|cached| cached.rollup);
1420 *cached = Some(CachedCallgraphProjection {
1421 identity,
1422 snapshot,
1423 estimated_bytes,
1424 costs,
1425 rollup,
1426 });
1427 } else {
1428 return;
1429 }
1430 if let Ok(mut fleet) = projection_fleet().lock() {
1431 fleet.admit(
1432 root,
1433 Arc::downgrade(&self.callgraph_projection),
1434 estimated_bytes,
1435 DEAD_CODE_SNAPSHOT_FLEET_BUDGET,
1436 );
1437 }
1438 }
1439
1440 fn previous_dead_code_rollup_state(
1441 &self,
1442 project_root: &Path,
1443 ) -> Option<Arc<super::scanners::dead_code::DeadCodeRollupState>> {
1444 let cached = self.callgraph_projection.lock().ok()?;
1445 let cached = cached.as_ref()?;
1446 let (identity, state) = cached.rollup.as_ref()?;
1447 (identity.project_root == project_root
1448 && identity.generation == cached.identity.generation
1449 && identity.legacy_sqlite_path == cached.identity.legacy_sqlite_path)
1450 .then(|| Arc::clone(state))
1451 }
1452
1453 fn cache_dead_code_rollup_state(
1454 &self,
1455 project_root: &Path,
1456 state: super::scanners::dead_code::DeadCodeRollupState,
1457 ) {
1458 if let Ok(mut cached) = self.callgraph_projection.lock() {
1459 if let Some(cached) = cached
1460 .as_mut()
1461 .filter(|cached| cached.identity.project_root == project_root)
1462 {
1463 cached.rollup = Some((cached.identity.clone(), Arc::new(state)));
1464 }
1465 }
1466 }
1467
1468 fn clear_callgraph_projection(&self) {
1469 let root = self
1470 .callgraph_projection
1471 .lock()
1472 .ok()
1473 .and_then(|mut cached| cached.take())
1474 .map(|cached| cached.identity.project_root);
1475 if let Some(root) = root {
1476 if let Ok(mut fleet) = projection_fleet().lock() {
1477 fleet.forget(&root);
1478 }
1479 }
1480 }
1481
1482 #[cfg(test)]
1483 fn build_tier2_callgraph_snapshot_with_refresh(
1484 &self,
1485 job: &InspectJob,
1486 allow_cold_build: bool,
1487 build_if_missing: bool,
1488 refresh_paths: &[PathBuf],
1489 ) -> Option<Arc<CallgraphSnapshot>> {
1490 self.build_tier2_callgraph_snapshot_with_refresh_and_verdict(
1491 job,
1492 allow_cold_build,
1493 build_if_missing,
1494 refresh_paths,
1495 )
1496 .map(|(snapshot, _, _)| snapshot)
1497 }
1498
1499 fn build_tier2_callgraph_snapshot_with_refresh_and_verdict(
1503 &self,
1504 job: &InspectJob,
1505 allow_cold_build: bool,
1506 build_if_missing: bool,
1507 refresh_paths: &[PathBuf],
1508 ) -> Option<(Arc<CallgraphSnapshot>, ProjectionVerdict, Duration)> {
1509 build_tier2_callgraph_snapshot_with_refresh_inner(
1510 job,
1511 allow_cold_build,
1512 build_if_missing,
1513 refresh_paths,
1514 Some(self),
1515 )
1516 }
1517
1518 pub fn has_pending_completions(&self) -> bool {
1521 !self.result_rx.is_empty()
1522 }
1523
1524 pub fn drain_completions(&self) -> usize {
1525 let mut drained = 0usize;
1526 while let Ok(result) = self.result_rx.try_recv() {
1527 self.route_completion(result);
1528 drained += 1;
1529 }
1530 drained
1531 }
1532
1533 pub fn discard_completions(&self) -> usize {
1534 let mut discarded = 0usize;
1535 while let Ok(result) = self.result_rx.try_recv() {
1536 let outcome = JobOutcome::Failed {
1537 message: "inspect job cancelled because its project root was unbound".to_string(),
1538 };
1539 self.record_builder_attempt_outcome(&result.key, &outcome);
1540 if let Some(waiters) = self.take_waiters(&result.key) {
1541 Self::deliver_waiters(waiters, outcome);
1542 }
1543 discarded += 1;
1544 }
1545 discarded
1546 }
1547
1548 pub fn cache_for_snapshot(
1549 &self,
1550 snapshot: &InspectSnapshot,
1551 ) -> Result<Arc<InspectCache>, String> {
1552 self.cache_for_paths(snapshot.inspect_dir.clone(), snapshot.project_root.clone())
1553 }
1554
1555 pub fn latest_tier2_counts(
1563 &self,
1564 inspect_dir: PathBuf,
1565 project_root: PathBuf,
1566 ) -> (Option<usize>, Option<usize>, Option<usize>) {
1567 let Ok(cache) = self.cache_for_paths(inspect_dir, project_root) else {
1568 return (None, None, None);
1569 };
1570 let count_of = |category: InspectCategory| -> Option<usize> {
1571 cache
1572 .latest_aggregate_any_hash(category)
1573 .ok()
1574 .flatten()
1575 .and_then(|payload| {
1576 if category == InspectCategory::DeadCode
1577 && payload
1578 .get("callgraph_available")
1579 .and_then(serde_json::Value::as_bool)
1580 == Some(false)
1581 {
1582 return None;
1583 }
1584 payload
1585 .get("count")
1586 .and_then(serde_json::Value::as_u64)
1587 .map(|count| count as usize)
1588 })
1589 };
1590 (
1591 count_of(InspectCategory::DeadCode),
1592 count_of(InspectCategory::UnusedExports),
1593 count_of(InspectCategory::Duplicates),
1594 )
1595 }
1596
1597 pub fn dead_code_blocked_on_callgraph(
1604 &self,
1605 inspect_dir: PathBuf,
1606 project_root: PathBuf,
1607 ) -> bool {
1608 let Ok(cache) = self.cache_for_paths(inspect_dir, project_root) else {
1609 return false;
1610 };
1611 cache
1612 .latest_aggregate_any_hash(InspectCategory::DeadCode)
1613 .ok()
1614 .flatten()
1615 .and_then(|payload| {
1616 payload
1617 .get("callgraph_available")
1618 .and_then(serde_json::Value::as_bool)
1619 })
1620 == Some(false)
1621 }
1622
1623 pub fn cache_for_paths(
1624 &self,
1625 inspect_dir: PathBuf,
1626 project_root: PathBuf,
1627 ) -> Result<Arc<InspectCache>, String> {
1628 let project_key = crate::path_identity::project_scope_key(&project_root);
1629 let inspect_dir = if inspect_dir
1630 .file_name()
1631 .and_then(|name| name.to_str())
1632 .is_some_and(|name| name == project_key)
1633 {
1634 inspect_dir
1635 } else {
1636 inspect_dir.join(&project_key)
1637 };
1638 let identity = InspectCacheIdentity {
1639 sqlite_path: inspect_dir.join(format!("{project_key}.current")),
1640 project_root: project_root.clone(),
1641 };
1642 let mut caches = self
1643 .caches
1644 .lock()
1645 .map_err(|_| "inspect manager cache map lock poisoned".to_string())?;
1646 if let Some(cache) = caches.get(&identity) {
1647 return Ok(Arc::clone(cache));
1648 }
1649 let cache = Arc::new(
1650 InspectCache::open(inspect_dir, project_root)
1651 .map_err(|error| format!("failed to open inspect cache: {error}"))?,
1652 );
1653 caches.insert(identity, Arc::clone(&cache));
1654 Ok(cache)
1655 }
1656
1657 fn oxc_result_for_scan(
1658 &self,
1659 job: &InspectJob,
1660 files: &[PathBuf],
1661 force_reparse_files: &[PathBuf],
1662 ) -> Result<Option<OxcEngineResult>, String> {
1663 if !category_uses_oxc(job.category) {
1664 return Ok(None);
1665 }
1666 if job.category == InspectCategory::DeadCode && job.callgraph_snapshot.is_none() {
1667 return Ok(None);
1668 }
1669
1670 let public_api_entries =
1671 crate::inspect::entry_points::resolve_entry_points(&job.project_root);
1672 let entry_points = if job.category == InspectCategory::DeadCode {
1673 job.callgraph_snapshot
1674 .as_ref()
1675 .map(|snapshot| snapshot.entry_points.iter().cloned().collect::<Vec<_>>())
1676 .unwrap_or_default()
1677 } else {
1678 Vec::new()
1679 };
1680 let options = AnalyzeOptions {
1681 entry_points,
1682 public_api_files: public_api_entries.public_api_files(),
1683 executable_root_exports: public_api_entries.executable_root_exports(),
1684 force_reparse_files: force_reparse_files.to_vec(),
1685 entry_reachability: job.category == InspectCategory::DeadCode,
1686 };
1687
1688 let mut cache = self
1689 .oxc_facts_cache
1690 .lock()
1691 .map_err(|_| "inspect oxc facts cache lock poisoned".to_string())?;
1692 analyze_files_with_cache(&job.project_root, files, options, &mut cache)
1693 .map(Some)
1694 .map_err(|message| format!("oxc analyze failed: {message}"))
1695 }
1696
1697 pub fn tier2_run_with_reuse(
1698 &self,
1699 snapshot: InspectSnapshot,
1700 category: InspectCategory,
1701 caller_scope: JobScope,
1702 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
1703 ) -> JobOutcome {
1704 if let Err(outcome) = validate_tier2_read_category(category) {
1705 return outcome;
1706 }
1707 if !self.heavy_root_work_allowed() {
1708 return JobOutcome::Failed {
1709 message: Self::heavy_root_work_block_message(category),
1710 };
1711 }
1712 let cache = match self.cache_for_snapshot(&snapshot) {
1713 Ok(cache) => cache,
1714 Err(message) => return JobOutcome::Failed { message },
1715 };
1716 let job = self.tier2_reuse_job(snapshot.clone(), category, callgraph_snapshot);
1717 let key = job.key.clone();
1718 let (waiter_tx, waiter_rx) = bounded(1);
1719 let claimed = match self.register_tier2_reuse_waiter(&key, waiter_tx) {
1720 Ok(claimed) => claimed,
1721 Err(message) => return JobOutcome::Failed { message },
1722 };
1723
1724 if claimed {
1725 let _flight = self.tier2_flight_exit_guard(key.clone());
1726 let result = self.tier2_run_with_reuse_job_result_catching(
1727 job,
1728 Tier2ReuseOptions::default(),
1729 None,
1730 );
1731 self.route_tier2_reuse_completion(result);
1732 }
1733
1734 match waiter_rx.recv() {
1735 Ok(outcome) => filter_outcome_for_scope_with_contributions(
1736 outcome,
1737 &snapshot,
1738 category,
1739 cache.as_ref(),
1740 &caller_scope,
1741 ),
1742 Err(_) => JobOutcome::Failed {
1743 message: "inspect Tier-2 waiter dropped without a terminal outcome".to_string(),
1744 },
1745 }
1746 }
1747
1748 pub fn tier2_run_with_reuse_blocking(
1754 self: &Arc<Self>,
1755 snapshot: InspectSnapshot,
1756 category: InspectCategory,
1757 caller_scope: JobScope,
1758 ) -> JobOutcome {
1759 self.tier2_run_with_reuse_blocking_once(snapshot, category, caller_scope, false)
1760 }
1761
1762 pub fn tier2_run_with_reuse_blocking_fresh(
1766 self: &Arc<Self>,
1767 snapshot: InspectSnapshot,
1768 category: InspectCategory,
1769 caller_scope: JobScope,
1770 ) -> JobOutcome {
1771 let first = self.tier2_run_with_reuse_blocking_once(
1772 snapshot.clone(),
1773 category,
1774 caller_scope.clone(),
1775 category == InspectCategory::DeadCode,
1776 );
1777 if category == InspectCategory::DeadCode
1778 && first.payload().is_some_and(|payload| {
1779 payload.get("callgraph_available").and_then(Value::as_bool) == Some(false)
1780 })
1781 {
1782 return self.tier2_run_with_reuse_blocking_once(snapshot, category, caller_scope, true);
1786 }
1787 first
1788 }
1789
1790 fn tier2_run_with_reuse_blocking_once(
1791 self: &Arc<Self>,
1792 snapshot: InspectSnapshot,
1793 category: InspectCategory,
1794 caller_scope: JobScope,
1795 require_callgraph_snapshot: bool,
1796 ) -> JobOutcome {
1797 if let Err(outcome) = validate_tier2_read_category(category) {
1798 return outcome;
1799 }
1800 if !self.heavy_root_work_allowed() {
1801 return JobOutcome::Failed {
1802 message: Self::heavy_root_work_block_message(category),
1803 };
1804 }
1805 let cache = match self.cache_for_snapshot(&snapshot) {
1806 Ok(cache) => cache,
1807 Err(message) => return JobOutcome::Failed { message },
1808 };
1809
1810 let job = self.tier2_reuse_job(snapshot.clone(), category, None);
1811 let key = job.key.clone();
1812 let (waiter_tx, waiter_rx) = bounded(1);
1813 let claimed = match self.register_tier2_reuse_waiter(&key, waiter_tx) {
1814 Ok(claimed) => claimed,
1815 Err(message) => return JobOutcome::Failed { message },
1816 };
1817 if claimed {
1818 self.spawn_tier2_reuse_job(
1819 job,
1820 Tier2ReuseOptions {
1821 require_callgraph_snapshot,
1822 interactive: true,
1823 ..Tier2ReuseOptions::default()
1824 },
1825 );
1826 }
1827
1828 self.wait_for_tier2_reuse(&key, &caller_scope, cache.as_ref(), waiter_rx, &snapshot)
1829 }
1830
1831 fn register_tier2_reuse_waiter(
1832 &self,
1833 key: &JobKey,
1834 waiter_tx: WaiterTx,
1835 ) -> Result<bool, String> {
1836 let mut in_flight = self
1837 .in_flight
1838 .lock()
1839 .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
1840 if let Some(waiters) = in_flight.get_mut(key) {
1841 waiters.push(Waiter { tx: waiter_tx });
1842 self.in_flight_changed.notify_all();
1843 return Ok(false);
1844 }
1845
1846 in_flight.insert(key.clone(), vec![Waiter { tx: waiter_tx }]);
1847 drop(in_flight);
1848 self.record_flight_start(key);
1849 Ok(true)
1850 }
1851
1852 fn wait_for_tier2_reuse_waiter_for_debug(&self, job: &InspectJob) {
1853 #[cfg(not(debug_assertions))]
1854 let _ = job;
1855 #[cfg(debug_assertions)]
1856 {
1857 const WAIT_ROOT_ENV: &str = "AFT_TEST_TIER2_REUSE_WAIT_FOR_WAITER_ROOT";
1858 if std::env::var_os(WAIT_ROOT_ENV).is_none()
1859 || !env_project_root_matches(WAIT_ROOT_ENV, &job.project_root)
1860 {
1861 return;
1862 }
1863
1864 let deadline = Instant::now() + Duration::from_secs(30);
1868 let mut in_flight = self
1869 .in_flight
1870 .lock()
1871 .unwrap_or_else(std::sync::PoisonError::into_inner);
1872 loop {
1873 match in_flight.get(&job.key) {
1874 Some(waiters) if waiters.is_empty() => {}
1875 _ => return,
1876 }
1877 let now = Instant::now();
1878 if now >= deadline {
1879 return;
1880 }
1881 let (next, wait_result) = self
1882 .in_flight_changed
1883 .wait_timeout(in_flight, deadline.saturating_duration_since(now))
1884 .unwrap_or_else(std::sync::PoisonError::into_inner);
1885 in_flight = next;
1886 if wait_result.timed_out() {
1887 return;
1888 }
1889 }
1890 }
1891 }
1892
1893 fn spawn_tier2_reuse_job(self: &Arc<Self>, job: InspectJob, options: Tier2ReuseOptions) {
1894 self.record_flight_start(&job.key);
1898 let manager = Arc::clone(self);
1899 let pool = Arc::clone(&self.pool);
1900 let cancellation = crate::executor::current_job_cancellation();
1901 pool.spawn_fifo(move || {
1902 let _cancellation = cancellation.map(crate::executor::install_job_cancellation);
1903 let _flight = manager.tier2_flight_exit_guard(job.key.clone());
1904 let result = manager.tier2_run_with_reuse_job_result_catching(job, options, None);
1905 manager.route_tier2_reuse_completion(result);
1906 });
1907 }
1908
1909 fn wait_for_tier2_reuse(
1910 &self,
1911 key: &JobKey,
1912 caller_scope: &JobScope,
1913 cache: &(impl InspectCacheRead + ?Sized),
1914 waiter_rx: Receiver<JobOutcome>,
1915 snapshot: &InspectSnapshot,
1916 ) -> JobOutcome {
1917 match waiter_rx.recv() {
1918 Ok(outcome) => filter_outcome_for_scope_with_contributions(
1919 outcome,
1920 snapshot,
1921 key.category,
1922 cache,
1923 caller_scope,
1924 ),
1925 Err(_) => JobOutcome::Failed {
1926 message: "inspect Tier-2 worker disconnected before completion".to_string(),
1927 },
1928 }
1929 }
1930
1931 pub fn tier2_read_cached(
1938 &self,
1939 snapshot: InspectSnapshot,
1940 category: InspectCategory,
1941 caller_scope: JobScope,
1942 ) -> JobOutcome {
1943 if let Err(outcome) = validate_tier2_read_category(category) {
1944 return outcome;
1945 }
1946 if !self.heavy_root_work_allowed() {
1947 return JobOutcome::Failed {
1948 message: Self::heavy_root_work_block_message(category),
1949 };
1950 }
1951 let cache = match self.cache_for_snapshot(&snapshot) {
1952 Ok(cache) => cache,
1953 Err(message) => return JobOutcome::Failed { message },
1954 };
1955 self.tier2_read_cached_from_cache(&snapshot, category, &caller_scope, cache.as_ref())
1956 }
1957
1958 pub fn tier2_read_cached_readonly(
1959 &self,
1960 snapshot: InspectSnapshot,
1961 category: InspectCategory,
1962 caller_scope: JobScope,
1963 ) -> JobOutcome {
1964 if let Err(outcome) = validate_tier2_read_category(category) {
1965 return outcome;
1966 }
1967 if !self.heavy_root_work_allowed() {
1968 return JobOutcome::Failed {
1969 message: Self::heavy_root_work_block_message(category),
1970 };
1971 }
1972 let key = JobKey::for_project_category(category);
1973 let in_flight = self
1974 .in_flight
1975 .lock()
1976 .map(|guard| guard.contains_key(&key))
1977 .unwrap_or(false);
1978 let cache = match InspectCache::open_readonly(
1979 snapshot.inspect_dir.clone(),
1980 snapshot.project_root.clone(),
1981 ) {
1982 Ok(Some(cache)) => cache,
1983 Ok(None) => return JobOutcome::pending(in_flight),
1984 Err(error) => {
1985 return JobOutcome::Failed {
1986 message: error.to_string(),
1987 }
1988 }
1989 };
1990 self.tier2_read_cached_from_cache(&snapshot, category, &caller_scope, &cache)
1991 }
1992
1993 fn tier2_read_cached_from_cache(
1994 &self,
1995 snapshot: &InspectSnapshot,
1996 category: InspectCategory,
1997 caller_scope: &JobScope,
1998 cache: &(impl InspectCacheRead + ?Sized),
1999 ) -> JobOutcome {
2000 let key = JobKey::for_project_category(category);
2001 let in_flight = self
2002 .in_flight
2003 .lock()
2004 .map(|guard| guard.contains_key(&key))
2005 .unwrap_or(false);
2006 match cache.get_aggregated_for_config(&key, snapshot.config.as_ref()) {
2007 Ok(Some(payload)) => {
2008 match self.tier2_cached_aggregate_is_fresh(snapshot, category, cache) {
2009 Ok(true) => filter_outcome_for_scope_with_contributions(
2010 JobOutcome::Fresh { payload },
2011 snapshot,
2012 category,
2013 cache,
2014 caller_scope,
2015 ),
2016 Ok(false) => filter_outcome_for_scope_with_contributions(
2017 JobOutcome::Stale {
2018 cached: Some(payload),
2019 in_flight,
2020 },
2021 snapshot,
2022 category,
2023 cache,
2024 caller_scope,
2025 ),
2026 Err(message) => JobOutcome::Failed { message },
2027 }
2028 }
2029 Ok(None) => match cache.latest_aggregate_any_hash(category) {
2030 Ok(Some(payload)) => filter_outcome_for_scope_with_contributions(
2031 JobOutcome::Stale {
2032 cached: Some(payload),
2033 in_flight,
2034 },
2035 snapshot,
2036 category,
2037 cache,
2038 caller_scope,
2039 ),
2040 Ok(None) => JobOutcome::pending(in_flight),
2041 Err(error) => JobOutcome::Failed {
2042 message: error.to_string(),
2043 },
2044 },
2045 Err(error) => JobOutcome::Failed {
2046 message: error.to_string(),
2047 },
2048 }
2049 }
2050
2051 fn tier2_cached_aggregate_is_fresh(
2052 &self,
2053 snapshot: &InspectSnapshot,
2054 category: InspectCategory,
2055 cache: &(impl InspectCacheRead + ?Sized),
2056 ) -> Result<bool, String> {
2057 let cached_records = load_contribution_freshness(cache, category)?;
2058 let cached_relative = cached_records
2059 .iter()
2060 .map(freshness_record_relative_key)
2061 .collect::<BTreeSet<_>>();
2062
2063 let project_scope = JobScope::for_project(snapshot.project_root.clone());
2067 let project_files = scope_files(&snapshot.project_root, &project_scope);
2068 let current_by_relative = current_project_files(&snapshot.project_root, &project_files);
2069
2070 let mut records_match = true;
2071 for record in &cached_records {
2072 let absolute = if record.file_path.is_absolute() {
2073 record.file_path.clone()
2074 } else {
2075 snapshot.project_root.join(&record.file_path)
2076 };
2077 match verify_contribution_file(&absolute, &record.freshness) {
2078 ContributionFreshness::Fresh { .. } => {}
2079 ContributionFreshness::Stale | ContributionFreshness::Deleted => {
2080 records_match = false;
2081 }
2082 }
2083 }
2084
2085 Ok(records_match
2086 && current_by_relative.len() == cached_relative.len()
2087 && current_by_relative
2088 .keys()
2089 .all(|relative| cached_relative.contains(relative)))
2090 }
2091
2092 #[doc(hidden)]
2093 pub fn tier2_run_with_reuse_result(
2094 &self,
2095 snapshot: InspectSnapshot,
2096 category: InspectCategory,
2097 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
2098 ) -> InspectResult {
2099 let job = self.tier2_reuse_job(snapshot, category, callgraph_snapshot);
2100 self.tier2_run_with_reuse_job_result(job)
2101 }
2102
2103 fn tier2_run_with_reuse_job_result(&self, job: InspectJob) -> InspectResult {
2104 self.tier2_run_with_reuse_job_result_with_options(job, Tier2ReuseOptions::default(), None)
2105 }
2106
2107 fn tier2_run_with_reuse_job_result_catching(
2108 &self,
2109 job: InspectJob,
2110 options: Tier2ReuseOptions,
2111 permit_slot: Option<Tier2PermitSlot>,
2112 ) -> InspectResult {
2113 let started = Instant::now();
2114 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2115 self.tier2_run_with_reuse_job_result_with_options(job.clone(), options, permit_slot)
2116 })) {
2117 Ok(result) => result,
2118 Err(_) => InspectResult::failed(
2119 &job,
2120 "tier2 reuse worker panicked before completion",
2121 started.elapsed(),
2122 ),
2123 }
2124 }
2125
2126 fn tier2_run_with_reuse_job_result_with_options(
2127 &self,
2128 mut job: InspectJob,
2129 mut options: Tier2ReuseOptions,
2130 permit_slot: Option<Tier2PermitSlot>,
2131 ) -> InspectResult {
2132 let started = Instant::now();
2133 self.reuse_starts.fetch_add(1, Ordering::SeqCst);
2134 self.wait_for_tier2_reuse_waiter_for_debug(&job);
2135 panic_tier2_reuse_for_debug(&job);
2136 if !job.category.is_active() {
2137 let result = InspectResult::failed(
2138 &job,
2139 format!("inspect category '{}' is disabled in v0.33", job.category),
2140 started.elapsed(),
2141 );
2142 log_tier2_benchmark_category_end(&result);
2143 return result;
2144 }
2145 if !job.category.is_tier2() {
2146 let result = InspectResult::failed(
2147 &job,
2148 format!(
2149 "inspect category '{}' is not a Tier 2 category",
2150 job.category
2151 ),
2152 started.elapsed(),
2153 );
2154 log_tier2_benchmark_category_end(&result);
2155 return result;
2156 }
2157
2158 if !job.inspect_writer {
2159 let result = InspectResult::failed(
2160 &job,
2161 "inspect writer capability is unavailable for this read-only cache path",
2162 started.elapsed(),
2163 );
2164 log_tier2_benchmark_category_end(&result);
2165 return result;
2166 }
2167
2168 let project_scope = JobScope::for_project(job.project_root.clone());
2169 job.scope_files = scope_files(&job.project_root, &project_scope);
2170 log_tier2_benchmark_category_start(&job);
2171 let cache = match self.cache_for_paths(job.inspect_dir.clone(), job.project_root.clone()) {
2172 Ok(cache) => cache,
2173 Err(message) => {
2174 let result = InspectResult::failed(&job, message, started.elapsed());
2175 log_tier2_benchmark_category_end(&result);
2176 return result;
2177 }
2178 };
2179 delay_tier2_reuse_for_debug(&job.project_root);
2180 if options.has_force_paths() {
2181 if let Ok(cached) = load_contribution_freshness(cache.as_ref(), job.category) {
2182 let (remaining, downgraded) = downgrade_unchanged_forced_paths_with_freshness(
2183 &job.project_root,
2184 &cached,
2185 options.force_rescan_paths.iter().cloned().collect(),
2186 );
2187 options.force_rescan_paths = remaining.into_iter().collect();
2188 if downgraded > 0 {
2189 crate::slog_info!(
2190 "inspect: {} forced paths downgraded to cached (content unchanged)",
2191 downgraded
2192 );
2193 }
2194 }
2195 }
2196 if !options.has_force_paths() {
2197 if let Ok(Some(success)) =
2198 self.tier2_quick_reuse_success(&job, cache.as_ref(), &options)
2199 {
2200 let result = InspectResult::success(&job, success, started.elapsed());
2201 crate::slog_debug!(
2202 "perf tier2 category={} reuse=hit ms={}",
2203 job.category,
2204 started.elapsed().as_millis()
2205 );
2206 log_tier2_benchmark_category_end(&result);
2207 return result;
2208 }
2209 }
2210
2211 let interactive_permit_slot = if options.interactive {
2216 let queued_state = if self.semantic_cold_seed_active.load(Ordering::SeqCst) {
2217 InspectBuilderState::GatedBySemanticSeed
2218 } else {
2219 InspectBuilderState::QueuedBehindColdBuilds
2220 };
2221 self.set_builder_state(&job.key, queued_state);
2222 let request = cold_build_limiter::ColdBuildAdmissionRequest::new(
2223 format!("inspect:{}:{}", job.project_root.display(), job.job_id),
2224 cold_build_limiter::ColdBuildAdmissionClass::InspectTriggered,
2225 );
2226 let permit = cold_build_limiter::acquire_blocking_while_cancellable_with_limiter(
2227 &self.cold_build_limiter(),
2228 "explicit inspect Tier-2 run",
2229 request,
2230 || self.heavy_root_work_allowed(),
2231 || {
2232 crate::executor::current_job_cancellation()
2233 .is_some_and(|token| token.cancel_requested_before_commit())
2234 },
2235 );
2236 let Some(permit) = permit else {
2237 let result = InspectResult::failed(
2238 &job,
2239 "explicit inspect Tier-2 cold-build admission was cancelled",
2240 started.elapsed(),
2241 );
2242 log_tier2_benchmark_category_end(&result);
2243 return result;
2244 };
2245 self.set_builder_state(&job.key, InspectBuilderState::Building);
2246 Some(Arc::new(Mutex::new(Some(permit))))
2247 } else {
2248 None
2249 };
2250
2251 let permit_slot = permit_slot.or(interactive_permit_slot);
2252 let timeout = Duration::from_millis(job.config.inspect.tier2_pass_timeout_ms);
2253 let (scan_result, timed_out) = run_tier2_pass_with_deadline(
2254 &job.project_root,
2255 job.category,
2256 timeout,
2257 permit_slot,
2258 || self.tier2_run_with_reuse_job(&job, &cache, &options),
2259 );
2260 let result = if timed_out {
2261 InspectResult::failed(
2262 &job,
2263 format!(
2264 "tier2 pass timed out after {}ms and was cancelled",
2265 timeout.as_millis()
2266 ),
2267 started.elapsed(),
2268 )
2269 } else {
2270 match scan_result {
2271 Ok(success) => InspectResult::success(&job, success, started.elapsed()),
2272 Err(message) => InspectResult::failed(&job, message, started.elapsed()),
2273 }
2274 };
2275 crate::slog_info!(
2279 "perf tier2 category={} reuse=miss ms={}",
2280 job.category,
2281 started.elapsed().as_millis()
2282 );
2283 log_tier2_benchmark_category_end(&result);
2284 result
2285 }
2286
2287 fn tier2_reuse_job(
2288 &self,
2289 snapshot: InspectSnapshot,
2290 category: InspectCategory,
2291 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
2292 ) -> InspectJob {
2293 InspectJob {
2294 job_id: self.next_job_id.fetch_add(1, Ordering::Relaxed),
2295 key: JobKey::for_project_category(category),
2296 category,
2297 scope_files: Vec::new(),
2298 project_root: snapshot.project_root,
2299 inspect_dir: snapshot.inspect_dir,
2300 config: snapshot.config,
2301 symbol_cache: snapshot.symbol_cache,
2302 inspect_writer: snapshot.inspect_writer,
2303 callgraph_writer: snapshot.callgraph_writer,
2304 callgraph_snapshot,
2305 }
2306 }
2307
2308 fn tier2_quick_reuse_success(
2309 &self,
2310 job: &InspectJob,
2311 cache: &InspectCache,
2312 options: &Tier2ReuseOptions,
2313 ) -> Result<Option<InspectScanSuccess>, String> {
2314 let cached_records = load_contribution_freshness(cache, job.category)?;
2315 let current_by_relative = current_project_files(&job.project_root, &job.scope_files);
2316 if cached_records.len() != current_by_relative.len() {
2317 return Ok(None);
2318 }
2319 for record in &cached_records {
2320 let relative = freshness_record_relative_key(record);
2321 let Some(current_file) = current_by_relative.get(&relative) else {
2322 return Ok(None);
2323 };
2324 match cache_freshness::metadata_matches(current_file, &record.freshness) {
2325 Ok(true) => {}
2326 Ok(false) => return Ok(None),
2327 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
2328 Err(error) => {
2329 return Err(format!(
2330 "failed to stat {} for tier2 quick reuse: {error}",
2331 current_file.display()
2332 ));
2333 }
2334 }
2335 }
2336
2337 let contribution_set_hash = cache
2338 .contribution_set_hash_for_config(job.category, job.config.as_ref())
2339 .map_err(|error| error.to_string())?;
2340 let Some(aggregate) = cache
2341 .load_aggregate_if_hash_matches(job.category, &contribution_set_hash)
2342 .map_err(|error| error.to_string())?
2343 else {
2344 return Ok(None);
2345 };
2346 if !cached_tier2_aggregate_usable(job.category, options, &aggregate) {
2347 return Ok(None);
2348 }
2349
2350 cache
2351 .touch_tier2_last_full_run(job.category)
2352 .map_err(|error| error.to_string())?;
2353 Ok(Some(InspectScanSuccess {
2354 scanned_files: Vec::new(),
2355 contributions: Vec::new(),
2356 aggregate,
2357 }))
2358 }
2359
2360 #[allow(clippy::too_many_lines)]
2361 fn tier2_run_with_reuse_job(
2362 &self,
2363 job: &InspectJob,
2364 cache: &InspectCache,
2365 options: &Tier2ReuseOptions,
2366 ) -> Result<InspectScanSuccess, String> {
2367 let mut phases = Tier2PhaseTimings::default();
2368 phases.projection_skip_reason = Some(if job.category != InspectCategory::DeadCode {
2369 "not_required"
2370 } else if job.callgraph_snapshot.is_some() {
2371 "provided_snapshot"
2372 } else {
2373 "aggregate_reused"
2374 });
2375 let phase_started = Instant::now();
2376 let cached_records = load_contribution_freshness(cache, job.category)?;
2377 let current_by_relative = current_project_files(&job.project_root, &job.scope_files);
2378 let cached_relative = cached_records
2379 .iter()
2380 .map(freshness_record_relative_key)
2381 .collect::<BTreeSet<_>>();
2382 let force_relative = forced_relative_paths(job, &options.force_rescan_paths);
2383 let cold_cache = cached_relative.is_empty();
2384 #[cfg(debug_assertions)]
2385 let debug_cold_cache = cold_cache;
2386
2387 let mut updates = Tier2ContributionUpdates::default();
2388 let mut scan_by_relative = BTreeMap::<String, PathBuf>::new();
2389 let require_callgraph_refresh =
2390 if job.category == InspectCategory::DeadCode && options.require_callgraph_snapshot {
2391 !cache
2392 .get_aggregated_for_config(&job.key, job.config.as_ref())
2393 .map_err(|error| error.to_string())?
2394 .is_some_and(|aggregate| {
2395 aggregate
2396 .get("callgraph_available")
2397 .and_then(Value::as_bool)
2398 == Some(true)
2399 })
2400 } else {
2401 false
2402 };
2403 let mut callgraph_refresh_paths = options
2404 .force_rescan_paths
2405 .iter()
2406 .filter(|path| callgraph_store_indexes_path(path))
2407 .cloned()
2408 .collect::<BTreeSet<_>>();
2409 if require_callgraph_refresh {
2410 callgraph_refresh_paths.extend(
2411 current_by_relative
2412 .values()
2413 .filter(|path| callgraph_store_indexes_path(path))
2414 .cloned(),
2415 );
2416 }
2417 let mut aggregate_job = job.clone();
2418
2419 for record in cached_records {
2420 if crate::executor::current_job_cancelled() {
2421 return Err("tier2 pass cancelled during freshness scan".to_string());
2422 }
2423 let relative = freshness_record_relative_key(&record);
2424 let relative_path = PathBuf::from(&relative);
2425 let Some(current_file) = current_by_relative.get(&relative) else {
2426 updates.deletes.push(relative_path);
2427 insert_callgraph_refresh_path(
2428 &mut callgraph_refresh_paths,
2429 job.project_root.join(&relative),
2430 );
2431 continue;
2432 };
2433
2434 if force_relative.contains(&relative) {
2435 updates.deletes.push(relative_path);
2436 scan_by_relative.insert(relative, current_file.clone());
2437 insert_callgraph_refresh_path(&mut callgraph_refresh_paths, current_file.clone());
2438 continue;
2439 }
2440
2441 let absolute = job.project_root.join(&record.file_path);
2442 match verify_contribution_file(&absolute, &record.freshness) {
2443 ContributionFreshness::Fresh {
2444 metadata_changed,
2445 freshness,
2446 } => {
2447 if metadata_changed {
2448 updates.metadata_updates.push((relative_path, freshness));
2449 }
2450 }
2451 ContributionFreshness::Stale => {
2452 updates.deletes.push(relative_path);
2453 scan_by_relative.insert(relative, current_file.clone());
2454 insert_callgraph_refresh_path(
2455 &mut callgraph_refresh_paths,
2456 current_file.clone(),
2457 );
2458 }
2459 ContributionFreshness::Deleted => {
2460 updates.deletes.push(relative_path);
2461 insert_callgraph_refresh_path(
2462 &mut callgraph_refresh_paths,
2463 job.project_root.join(&record.file_path),
2464 );
2465 }
2466 }
2467 }
2468
2469 for (relative, file) in ¤t_by_relative {
2470 if !cached_relative.contains(relative) {
2471 scan_by_relative.insert(relative.clone(), file.clone());
2472 if !cold_cache {
2473 insert_callgraph_refresh_path(&mut callgraph_refresh_paths, file.clone());
2474 }
2475 }
2476 }
2477 phases.freshness = phase_started.elapsed();
2478
2479 let mut scan_files = scan_by_relative.into_values().collect::<Vec<_>>();
2480 let force_reparse_files = scan_files.clone();
2481 let callgraph_refresh_files = callgraph_refresh_paths.into_iter().collect::<Vec<_>>();
2482 let dead_code_callgraph_refresh =
2483 job.category == InspectCategory::DeadCode && !callgraph_refresh_files.is_empty();
2484 if crate::executor::current_job_cancelled() {
2485 return Err("tier2 pass cancelled before incremental scan".to_string());
2486 }
2487 if !scan_files.is_empty() {
2488 let mut scan_job = job.clone();
2489 scan_job.job_id = self.next_job_id.fetch_add(1, Ordering::Relaxed);
2490 scan_job.scope_files = scan_files.clone();
2491 if scan_job.category == InspectCategory::DeadCode
2492 && scan_job.callgraph_snapshot.is_none()
2493 {
2494 let snapshot_started = Instant::now();
2495 match self.build_tier2_callgraph_snapshot_with_refresh_and_verdict(
2496 &scan_job,
2497 options.allow_callgraph_cold_build,
2498 options.require_callgraph_snapshot,
2499 &callgraph_refresh_files,
2500 ) {
2501 Some((snapshot, verdict, projection_cost)) => {
2502 scan_job.callgraph_snapshot = Some(snapshot);
2503 phases.projection = Some(verdict);
2504 phases.projection_cost += projection_cost;
2505 }
2506 None => {
2507 phases.projection_skip_reason = Some(if job.config.views.enabled {
2508 "view_pending"
2509 } else {
2510 "no_callgraph"
2511 })
2512 }
2513 }
2514 phases.snapshot += snapshot_started.elapsed();
2515 }
2516 aggregate_job.callgraph_snapshot = scan_job.callgraph_snapshot.clone();
2517 #[cfg(debug_assertions)]
2518 if debug_cold_cache {
2519 std::thread::sleep(Duration::from_millis(10));
2520 }
2521 let scan_started = Instant::now();
2522 let oxc_result =
2523 self.oxc_result_for_scan(&scan_job, &scan_job.scope_files, &force_reparse_files)?;
2524 let scan_result = run_tier2_scan(&scan_job, oxc_result.as_ref());
2525 phases.scan += scan_started.elapsed();
2526 phases.scanned_files += scan_files.len();
2527 let scan_success = scan_result.outcome.map_err(|message| {
2528 format!("{} incremental scan failed: {message}", job.category)
2529 })?;
2530 updates.upserts.extend(scan_success.contributions);
2531 }
2532
2533 let has_updates = !updates.upserts.is_empty()
2534 || !updates.deletes.is_empty()
2535 || !updates.metadata_updates.is_empty();
2536 if !has_updates && !dead_code_callgraph_refresh {
2537 if let Some(aggregate) = cache
2538 .get_aggregated_for_config(&job.key, job.config.as_ref())
2539 .map_err(|error| error.to_string())?
2540 {
2541 if cached_tier2_aggregate_usable(job.category, options, &aggregate) {
2542 cache
2543 .touch_tier2_last_full_run(job.category)
2544 .map_err(|error| error.to_string())?;
2545 phases.log(job.category, &job.project_root);
2546 return Ok(InspectScanSuccess {
2547 scanned_files: scan_files,
2548 contributions: Vec::new(),
2549 aggregate,
2550 });
2551 }
2552 }
2553 }
2554
2555 let db_started = Instant::now();
2556 let mut contribution_set_hash = if has_updates {
2557 let (hash, db_timings) = cache
2558 .apply_contribution_updates_for_config(job.category, updates, job.config.as_ref())
2559 .map_err(|error| error.to_string())?;
2560 phases.add_db_timings(db_timings);
2561 hash
2562 } else {
2563 cache
2564 .contribution_set_hash_for_config(job.category, job.config.as_ref())
2565 .map_err(|error| error.to_string())?
2566 };
2567 phases.db = db_started.elapsed();
2568
2569 if !dead_code_callgraph_refresh {
2570 if let Some(aggregate) = cache
2571 .load_aggregate_if_hash_matches(job.category, &contribution_set_hash)
2572 .map_err(|error| error.to_string())?
2573 {
2574 if cached_tier2_aggregate_usable(job.category, options, &aggregate) {
2575 cache
2576 .touch_tier2_last_full_run(job.category)
2577 .map_err(|error| error.to_string())?;
2578 let contributions = load_contributions(cache, job)?;
2579 phases.log(job.category, &job.project_root);
2580 return Ok(InspectScanSuccess {
2581 scanned_files: scan_files,
2582 contributions,
2583 aggregate,
2584 });
2585 }
2586 }
2587 }
2588
2589 if crate::executor::current_job_cancelled() {
2590 return Err("tier2 pass cancelled before projection refresh".to_string());
2591 }
2592 let refresh_dead_code_facts = if job.category == InspectCategory::DeadCode {
2593 dead_code_contributions_need_fact_refresh(cache, job)?
2594 } else {
2595 false
2596 };
2597 let refresh_unused_exports_facts = if job.category == InspectCategory::UnusedExports {
2598 unused_exports_contributions_need_fact_refresh(cache, job)?
2599 } else {
2600 false
2601 };
2602 let refresh_duplicates_facts = if job.category == InspectCategory::Duplicates {
2603 duplicates_contributions_need_fact_refresh(cache, job)?
2604 } else {
2605 false
2606 };
2607 if refresh_dead_code_facts || refresh_unused_exports_facts || refresh_duplicates_facts {
2608 let full_scan_files = current_by_relative.into_values().collect::<Vec<_>>();
2613 if !full_scan_files.is_empty() {
2614 let mut rescan_job = job.clone();
2615 rescan_job.job_id = self.next_job_id.fetch_add(1, Ordering::Relaxed);
2616 rescan_job.scope_files = full_scan_files.clone();
2617 if rescan_job.category == InspectCategory::DeadCode
2618 && rescan_job.callgraph_snapshot.is_none()
2619 {
2620 let snapshot_started = Instant::now();
2621 match self.build_tier2_callgraph_snapshot_with_refresh_and_verdict(
2622 &rescan_job,
2623 options.allow_callgraph_cold_build,
2624 options.require_callgraph_snapshot,
2625 &callgraph_refresh_files,
2626 ) {
2627 Some((snapshot, verdict, projection_cost)) => {
2628 rescan_job.callgraph_snapshot = Some(snapshot);
2629 phases.projection = Some(verdict);
2630 phases.projection_cost += projection_cost;
2631 }
2632 None => {
2633 phases.projection_skip_reason = Some(if job.config.views.enabled {
2634 "view_pending"
2635 } else {
2636 "no_callgraph"
2637 })
2638 }
2639 }
2640 phases.snapshot += snapshot_started.elapsed();
2641 }
2642 let scan_started = Instant::now();
2643 let oxc_result = self.oxc_result_for_scan(
2644 &rescan_job,
2645 &rescan_job.scope_files,
2646 &force_reparse_files,
2647 )?;
2648 let scan_result = run_tier2_scan(&rescan_job, oxc_result.as_ref());
2649 phases.scan += scan_started.elapsed();
2650 phases.scanned_files += full_scan_files.len();
2651 let scan_success = scan_result.outcome.map_err(|message| {
2652 format!(
2653 "{} full rescan after entry-point cache miss failed: {message}",
2654 job.category
2655 )
2656 })?;
2657 let rescan_updates = Tier2ContributionUpdates {
2658 upserts: scan_success.contributions,
2659 ..Tier2ContributionUpdates::default()
2660 };
2661 let db_started = Instant::now();
2662 let (hash, db_timings) = cache
2663 .apply_contribution_updates_for_config(
2664 job.category,
2665 rescan_updates,
2666 job.config.as_ref(),
2667 )
2668 .map_err(|error| error.to_string())?;
2669 contribution_set_hash = hash;
2670 phases.add_db_timings(db_timings);
2671 phases.db += db_started.elapsed();
2672 aggregate_job.callgraph_snapshot = rescan_job.callgraph_snapshot.clone();
2673 scan_files = full_scan_files;
2674
2675 if !dead_code_callgraph_refresh {
2676 if let Some(aggregate) = cache
2677 .load_aggregate_if_hash_matches(job.category, &contribution_set_hash)
2678 .map_err(|error| error.to_string())?
2679 {
2680 if cached_tier2_aggregate_usable(job.category, options, &aggregate) {
2681 cache
2682 .touch_tier2_last_full_run(job.category)
2683 .map_err(|error| error.to_string())?;
2684 let contributions = load_contributions(cache, job)?;
2685 phases.log(job.category, &job.project_root);
2686 return Ok(InspectScanSuccess {
2687 scanned_files: scan_files,
2688 contributions,
2689 aggregate,
2690 });
2691 }
2692 }
2693 }
2694 }
2695 }
2696
2697 if crate::executor::current_job_cancelled() {
2698 return Err("tier2 pass cancelled before aggregate projection".to_string());
2699 }
2700 if aggregate_job.category == InspectCategory::DeadCode
2701 && aggregate_job.callgraph_snapshot.is_none()
2702 {
2703 let snapshot_started = Instant::now();
2704 match self.build_tier2_callgraph_snapshot_with_refresh_and_verdict(
2705 &aggregate_job,
2706 options.allow_callgraph_cold_build,
2707 options.require_callgraph_snapshot,
2708 &callgraph_refresh_files,
2709 ) {
2710 Some((snapshot, verdict, projection_cost)) => {
2711 aggregate_job.callgraph_snapshot = Some(snapshot);
2712 phases.projection = Some(verdict);
2713 phases.projection_cost += projection_cost;
2714 }
2715 None => {
2716 phases.projection_skip_reason = Some(if job.config.views.enabled {
2717 "view_pending"
2718 } else {
2719 "no_callgraph"
2720 })
2721 }
2722 }
2723 phases.snapshot += snapshot_started.elapsed();
2724 }
2725 if options.require_callgraph_snapshot
2726 && aggregate_job.category == InspectCategory::DeadCode
2727 && aggregate_job.callgraph_snapshot.is_none()
2728 {
2729 if let Some(reason) = callgraph_path_identity_gap(job) {
2730 return Ok(InspectScanSuccess {
2731 scanned_files: scan_files,
2732 contributions: Vec::new(),
2733 aggregate: crate::inspect::scanners::dead_code::callgraph_unavailable_aggregate_with_reason(
2734 job.scope_files.len(),
2735 Some(&reason),
2736 ),
2737 });
2738 }
2739 return Err(format!(
2740 "tier2 dead_code aggregate did not complete; builder_state={}",
2741 self.builder_state_detail_for_job(job)
2742 ));
2743 }
2744 let rollup_started = Instant::now();
2745 if crate::executor::current_job_cancelled() {
2746 return Err("tier2 pass cancelled before rollup".to_string());
2747 }
2748 let contributions = load_contributions(cache, &aggregate_job)?;
2749 let aggregate = if aggregate_job.category == InspectCategory::DeadCode
2750 && aggregate_job.callgraph_snapshot.is_some()
2751 {
2752 let snapshot = aggregate_job
2753 .callgraph_snapshot
2754 .as_deref()
2755 .expect("checked dead-code snapshot");
2756 let public_api_files =
2757 super::scanners::dead_code::collect_public_api_files(&job.project_root);
2758 let roles = super::entry_points::resolve_project_roles(&job.project_root);
2759 let mut changed_files = scan_files
2760 .iter()
2761 .filter_map(|path| path.strip_prefix(&job.project_root).ok())
2762 .map(|path| path.to_string_lossy().replace('\\', "/"))
2763 .collect::<BTreeSet<_>>();
2764 changed_files.extend(
2767 force_relative
2768 .iter()
2769 .map(|relative| relative.replace('\\', "/")),
2770 );
2771 let allow_incremental = phases
2772 .projection
2773 .is_some_and(|verdict| verdict.kind != ProjectionKind::Full);
2774 let previous = allow_incremental
2775 .then(|| self.previous_dead_code_rollup_state(&job.project_root))
2776 .flatten();
2777 let (aggregate, state, mut verdict) =
2778 super::scanners::dead_code::aggregate_dead_code_contributions_incremental(
2779 &job.project_root,
2780 snapshot,
2781 &contributions,
2782 &public_api_files,
2783 &roles,
2784 Some(MAX_DRILL_DOWN_ITEMS),
2785 Some(&contribution_set_hash),
2786 previous.as_deref(),
2787 &changed_files,
2788 );
2789 if verdict.kind == super::scanners::dead_code::RollupKind::Full {
2790 verdict.reason = phases
2791 .projection
2792 .and_then(|projection| projection.reason)
2793 .or(Some("cold"));
2794 }
2795 phases.rollup_verdict = Some(verdict);
2796 self.cache_dead_code_rollup_state(&job.project_root, state);
2797 aggregate
2798 } else {
2799 roll_up_tier2_contributions(&aggregate_job, &contributions)
2800 };
2801 if crate::executor::current_job_cancelled() {
2802 return Err("tier2 pass cancelled after rollup".to_string());
2803 }
2804 cache
2805 .store_tier2_aggregate(job.key.clone(), &contribution_set_hash, aggregate.clone())
2806 .map_err(|error| error.to_string())?;
2807 phases.rollup = rollup_started.elapsed();
2808 if let Some(verdict) = phases.projection {
2809 self.observe_callgraph_projection_cost(
2810 &job.project_root,
2811 verdict,
2812 projection_estimator_duration(&phases),
2813 );
2814 }
2815 phases.log(job.category, &job.project_root);
2816
2817 Ok(InspectScanSuccess {
2818 scanned_files: scan_files,
2819 contributions,
2820 aggregate,
2821 })
2822 }
2823
2824 fn enqueue_with_waiter(
2825 &self,
2826 snapshot: InspectSnapshot,
2827 category: InspectCategory,
2828 caller_scope: JobScope,
2829 key: JobKey,
2830 waiter_tx: WaiterTx,
2831 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
2832 ) -> Result<(), String> {
2833 let mut in_flight = self
2834 .in_flight
2835 .lock()
2836 .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
2837 if let Some(waiters) = in_flight.get_mut(&key) {
2838 waiters.push(Waiter { tx: waiter_tx });
2839 return Ok(());
2840 }
2841
2842 in_flight.insert(key.clone(), vec![Waiter { tx: waiter_tx }]);
2843 drop(in_flight);
2844 self.record_flight_start(&key);
2845
2846 if let Err(message) = self.enqueue_new_job(
2847 snapshot,
2848 category,
2849 caller_scope,
2850 key.clone(),
2851 callgraph_snapshot,
2852 ) {
2853 let outcome = JobOutcome::Failed {
2854 message: message.clone(),
2855 };
2856 if let Some(waiters) = self.take_waiters(&key) {
2857 Self::deliver_waiters(waiters, outcome);
2858 }
2859 self.clear_builder_state(&key);
2860 return Ok(());
2861 }
2862 Ok(())
2863 }
2864
2865 fn enqueue_without_waiter(
2866 &self,
2867 snapshot: InspectSnapshot,
2868 category: InspectCategory,
2869 caller_scope: JobScope,
2870 key: JobKey,
2871 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
2872 ) -> Result<(), String> {
2873 let mut in_flight = self
2874 .in_flight
2875 .lock()
2876 .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
2877 if in_flight.contains_key(&key) {
2878 return Ok(());
2879 }
2880 in_flight.insert(key.clone(), Vec::new());
2881 drop(in_flight);
2882 self.record_flight_start(&key);
2883
2884 if let Err(message) = self.enqueue_new_job(
2885 snapshot,
2886 category,
2887 caller_scope,
2888 key.clone(),
2889 callgraph_snapshot,
2890 ) {
2891 if let Ok(mut in_flight) = self.in_flight.lock() {
2892 in_flight.remove(&key);
2893 }
2894 self.clear_builder_state(&key);
2895 return Err(message);
2896 }
2897 Ok(())
2898 }
2899
2900 fn enqueue_new_job(
2901 &self,
2902 snapshot: InspectSnapshot,
2903 category: InspectCategory,
2904 caller_scope: JobScope,
2905 key: JobKey,
2906 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
2907 ) -> Result<(), String> {
2908 let scan_scope = if category.is_tier2() {
2909 JobScope::for_project(snapshot.project_root.clone())
2910 } else {
2911 caller_scope
2912 };
2913 let scope_files = scope_files(&snapshot.project_root, &scan_scope);
2914 let job = InspectJob {
2915 job_id: self.next_job_id.fetch_add(1, Ordering::Relaxed),
2916 key,
2917 category,
2918 scope_files,
2919 project_root: snapshot.project_root,
2920 inspect_dir: snapshot.inspect_dir,
2921 config: snapshot.config,
2922 symbol_cache: snapshot.symbol_cache,
2923 inspect_writer: snapshot.inspect_writer,
2924 callgraph_writer: snapshot.callgraph_writer,
2925 callgraph_snapshot,
2926 };
2927 self.request_tx
2928 .send(job)
2929 .map_err(|_| "inspect dispatch loop is unavailable".to_string())
2930 }
2931
2932 #[allow(clippy::too_many_arguments)]
2933 fn wait_for_outcome(
2934 &self,
2935 key: JobKey,
2936 caller_scope: JobScope,
2937 cache: Arc<InspectCache>,
2938 waiter_rx: Receiver<JobOutcome>,
2939 snapshot: InspectSnapshot,
2940 deadline: Instant,
2941 wait_started: Instant,
2942 wait_budget: Duration,
2943 ) -> JobOutcome {
2944 let timeout = after(deadline.saturating_duration_since(Instant::now()));
2945 let result_rx = self.result_rx.clone();
2946 loop {
2947 select_biased! {
2951 recv(waiter_rx) -> outcome => {
2952 return match outcome {
2953 Ok(outcome) => filter_outcome_for_scope_with_contributions(
2954 outcome,
2955 &snapshot,
2956 key.category,
2957 cache.as_ref(),
2958 &caller_scope,
2959 ),
2960 Err(_) => self.timeout_outcome(
2961 &key,
2962 &caller_scope,
2963 &cache,
2964 &snapshot,
2965 PendingWaitCause::WaiterDropped,
2966 wait_started,
2967 wait_budget,
2968 ),
2969 };
2970 }
2971 recv(result_rx) -> result => {
2972 match result {
2973 Ok(result) => self.route_completion(result),
2974 Err(_) => return self.timeout_outcome(
2975 &key,
2976 &caller_scope,
2977 &cache,
2978 &snapshot,
2979 PendingWaitCause::ResultChannelDisconnected,
2980 wait_started,
2981 wait_budget,
2982 ),
2983 }
2984 }
2985 recv(timeout) -> _ => {
2986 return self.timeout_outcome(
2987 &key,
2988 &caller_scope,
2989 &cache,
2990 &snapshot,
2991 PendingWaitCause::DeadlineElapsed,
2992 wait_started,
2993 wait_budget,
2994 );
2995 }
2996 }
2997 }
2998 }
2999
3000 fn timeout_outcome(
3001 &self,
3002 key: &JobKey,
3003 caller_scope: &JobScope,
3004 cache: &(impl InspectCacheRead + ?Sized),
3005 snapshot: &InspectSnapshot,
3006 cause: PendingWaitCause,
3007 wait_started: Instant,
3008 wait_budget: Duration,
3009 ) -> JobOutcome {
3010 match cache.get_aggregated_for_config(key, snapshot.config.as_ref()) {
3011 Ok(Some(cached)) => filter_outcome_for_scope_with_contributions(
3012 JobOutcome::Stale {
3013 cached: Some(cached),
3014 in_flight: true,
3015 },
3016 snapshot,
3017 key.category,
3018 cache,
3019 caller_scope,
3020 ),
3021 Ok(None) => JobOutcome::pending_wait(true, cause, wait_started.elapsed(), wait_budget),
3022 Err(error) => JobOutcome::Failed {
3023 message: error.to_string(),
3024 },
3025 }
3026 }
3027
3028 fn route_completion(&self, result: InspectResult) {
3029 let outcome = self.completion_outcome(result.clone());
3030 self.record_builder_attempt_outcome(&result.key, &outcome);
3031 if let Some(waiters) = self.take_waiters(&result.key) {
3032 Self::deliver_waiters(waiters, outcome);
3033 }
3034 }
3035
3036 fn route_tier2_reuse_completion(&self, result: InspectResult) {
3037 let outcome = match result.outcome.clone() {
3038 Ok(success) => JobOutcome::Fresh {
3039 payload: success.aggregate,
3040 },
3041 Err(message) => JobOutcome::Failed { message },
3042 };
3043 self.finish_tier2_flight(&result.key, outcome);
3047 }
3052
3053 pub fn reuse_completion_count(&self) -> u64 {
3057 self.reuse_completions.load(Ordering::SeqCst)
3058 }
3059
3060 #[doc(hidden)]
3061 pub fn reuse_start_count_for_test(&self) -> u64 {
3062 self.reuse_starts.load(Ordering::SeqCst)
3063 }
3064
3065 fn completion_outcome(&self, result: InspectResult) -> JobOutcome {
3066 let cache =
3067 match self.cache_for_paths(result.inspect_dir.clone(), result.project_root.clone()) {
3068 Ok(cache) => cache,
3069 Err(message) => return JobOutcome::Failed { message },
3070 };
3071
3072 match result.outcome {
3073 Ok(success) => {
3074 let store_result = if result.category.is_tier2() {
3075 cache.store_tier2_result_for_config(
3076 result.key.clone(),
3077 &success.scanned_files,
3078 &success.contributions,
3079 success.aggregate.clone(),
3080 result.config.as_ref(),
3081 )
3082 } else {
3083 cache.store_aggregated(result.key, success.aggregate.clone())
3084 };
3085
3086 match store_result {
3087 Ok(()) => JobOutcome::Fresh {
3088 payload: success.aggregate,
3089 },
3090 Err(error) => JobOutcome::Failed {
3091 message: error.to_string(),
3092 },
3093 }
3094 }
3095 Err(message) => JobOutcome::Failed { message },
3096 }
3097 }
3098}
3099
3100impl Default for InspectManager {
3101 fn default() -> Self {
3102 Self::new()
3103 }
3104}
3105
3106fn validate_tier2_read_category(category: InspectCategory) -> Result<(), JobOutcome> {
3107 if !category.is_active() {
3108 return Err(JobOutcome::Failed {
3109 message: format!("inspect category '{category}' is disabled in v0.33"),
3110 });
3111 }
3112 if !category.is_tier2() {
3113 return Err(JobOutcome::Failed {
3114 message: format!("inspect category '{category}' is not a Tier 2 category"),
3115 });
3116 }
3117 Ok(())
3118}
3119
3120#[derive(Default)]
3127struct Tier2PhaseTimings {
3128 freshness: Duration,
3130 snapshot: Duration,
3132 projection_cost: Duration,
3134 scan: Duration,
3136 db: Duration,
3138 db_lock: Duration,
3140 db_txn: Duration,
3142 rollup: Duration,
3144 scanned_files: usize,
3145 projection: Option<ProjectionVerdict>,
3147 projection_skip_reason: Option<&'static str>,
3149 rollup_verdict: Option<super::scanners::dead_code::RollupVerdict>,
3152}
3153
3154const TIER2_WORK_LOG_THRESHOLD: Duration = Duration::from_millis(50);
3155
3156fn projection_estimator_duration(phases: &Tier2PhaseTimings) -> Duration {
3157 phases.projection_cost
3161}
3162
3163impl Tier2PhaseTimings {
3164 fn add_db_timings(&mut self, timings: InspectDbTimings) {
3165 self.db_lock += timings.lock_wait;
3166 self.db_txn += timings.transaction;
3167 }
3168
3169 fn worked(&self) -> Duration {
3170 self.freshness + self.scan + self.snapshot + self.rollup + self.db
3171 }
3172
3173 fn log(&self, category: InspectCategory, project_root: &Path) {
3174 let worked = self.worked();
3175 if !worked.is_zero() {
3176 crate::logging::note_tier2_scan(
3177 category.to_string(),
3178 worked.as_millis().min(u128::from(u64::MAX)) as u64,
3179 );
3180 }
3181 if worked < TIER2_WORK_LOG_THRESHOLD {
3182 return;
3183 }
3184 let key = crate::search_index::artifact_cache_key(project_root);
3185 crate::slog_info!("{}", self.render(category, project_root, &key));
3186 }
3187
3188 fn render(&self, category: InspectCategory, project_root: &Path, key: &str) -> String {
3189 let projection = self
3190 .projection
3191 .map(render_projection_suffix)
3192 .unwrap_or_else(|| {
3193 render_no_projection_suffix(
3194 self.projection_skip_reason
3195 .unwrap_or_else(|| default_projection_skip_reason(category)),
3196 )
3197 });
3198 let rollup = self
3199 .rollup_verdict
3200 .map(render_rollup_suffix)
3201 .unwrap_or_default();
3202 format!(
3203 "perf tier2 phases category={} freshness={}ms snapshot={}ms projection_ms={} scan={}ms({} files) db={}ms(lock={},txn={}) rollup_ms={}{}{} root={} key={}",
3204 category,
3205 self.freshness.as_millis(),
3206 self.snapshot.as_millis(),
3207 self.projection_cost.as_millis(),
3208 self.scan.as_millis(),
3209 self.scanned_files,
3210 self.db.as_millis(),
3211 self.db_lock.as_millis(),
3212 self.db_txn.as_millis(),
3213 self.rollup.as_millis(),
3214 rollup,
3215 projection,
3216 crate::logging::normalize_index_root(project_root),
3217 key
3218 )
3219 }
3220}
3221
3222fn default_projection_skip_reason(category: InspectCategory) -> &'static str {
3223 if category == InspectCategory::DeadCode {
3224 "no_callgraph"
3225 } else {
3226 "not_required"
3227 }
3228}
3229
3230fn render_no_projection_suffix(reason: &'static str) -> String {
3235 format!(" projection=none reason={reason} journal_bytes=0 changed_files=0")
3236}
3237
3238fn render_rollup_suffix(verdict: super::scanners::dead_code::RollupVerdict) -> String {
3242 let kind = match verdict.kind {
3243 super::scanners::dead_code::RollupKind::Incremental => "incremental",
3244 super::scanners::dead_code::RollupKind::Full => "full",
3245 };
3246 let reason = verdict
3247 .reason
3248 .map(|reason| format!(" reason={reason}"))
3249 .unwrap_or_default();
3250 format!(" rollup={kind}{reason}")
3251}
3252
3253fn render_projection_suffix(verdict: ProjectionVerdict) -> String {
3254 let kind = match verdict.kind {
3255 ProjectionKind::Spliced => "spliced",
3256 ProjectionKind::Full => "full",
3257 ProjectionKind::Reused => "reused",
3258 };
3259 let reason = match verdict.reason {
3260 Some("journal_oversize") => format!(" reason=journal_oversize:{MAX_DELTA_BYTES}"),
3261 Some(reason) => format!(" reason={reason}"),
3262 None => String::new(),
3263 };
3264 format!(
3265 " projection={kind}{reason} journal_bytes={} changed_files={}",
3266 verdict.journal_bytes, verdict.changed_files
3267 )
3268}
3269
3270fn scope_files(project_root: &Path, scope: &JobScope) -> Vec<PathBuf> {
3271 let mut files = crate::callgraph::walk_project_files(project_root)
3272 .filter(|path| scope.contains(path))
3273 .collect::<Vec<_>>();
3274 files.sort();
3275 files
3276}
3277
3278fn forced_relative_paths(job: &InspectJob, paths: &BTreeSet<PathBuf>) -> BTreeSet<String> {
3279 let mut keys = BTreeSet::new();
3280 for path in paths {
3281 let absolute = if path.is_absolute() {
3282 path.clone()
3283 } else {
3284 job.project_root.join(path)
3285 };
3286 keys.insert(relative_cache_key(&job.project_root, &absolute));
3287 keys.insert(relative_cache_key(
3292 &job.project_root,
3293 &crate::inspect::job::canonicalize_normalized(&absolute),
3294 ));
3295 }
3296 keys
3297}
3298
3299fn downgrade_unchanged_forced_paths_with_freshness(
3300 project_root: &Path,
3301 cached: &[CachedContributionFreshness],
3302 paths: Vec<PathBuf>,
3303) -> (Vec<PathBuf>, usize) {
3304 let cached = cached
3305 .iter()
3306 .map(|record| (freshness_record_relative_key(record), record.freshness))
3307 .collect::<BTreeMap<_, _>>();
3308 let mut remaining = Vec::with_capacity(paths.len());
3309 let mut downgraded = 0;
3310
3311 for path in paths {
3312 let absolute = if path.is_absolute() {
3313 path.clone()
3314 } else {
3315 project_root.join(&path)
3316 };
3317 let direct_key = relative_cache_key(project_root, &absolute);
3318 let canonical_key = Some(relative_cache_key(
3320 project_root,
3321 &crate::inspect::job::canonicalize_normalized(&absolute),
3322 ));
3323 let freshness = cached
3324 .get(&direct_key)
3325 .or_else(|| canonical_key.as_ref().and_then(|key| cached.get(key)));
3326 let content_unchanged = freshness.is_some_and(|freshness| {
3327 matches!(
3328 cache_freshness::verify_file_strict(&absolute, freshness),
3329 FreshnessVerdict::HotFresh | FreshnessVerdict::ContentFresh { .. }
3330 )
3331 });
3332 if content_unchanged {
3333 downgraded += 1;
3334 } else {
3335 remaining.push(path);
3336 }
3337 }
3338
3339 (remaining, downgraded)
3340}
3341
3342fn panic_tier2_reuse_for_debug(job: &InspectJob) {
3343 #[cfg(not(debug_assertions))]
3344 let _ = job;
3345 #[cfg(debug_assertions)]
3346 {
3347 if !env_project_root_matches("AFT_TEST_TIER2_REUSE_PANIC_ROOT", &job.project_root) {
3348 return;
3349 }
3350 let should_panic = std::env::var("AFT_TEST_TIER2_REUSE_PANIC_CATEGORY")
3351 .ok()
3352 .is_some_and(|category| category == job.category.as_str());
3353 if should_panic {
3354 panic!("forced tier2 reuse panic for {}", job.category);
3355 }
3356 }
3357}
3358
3359fn delay_tier2_reuse_for_debug(project_root: &Path) {
3360 #[cfg(not(debug_assertions))]
3361 let _ = project_root;
3362 #[cfg(debug_assertions)]
3363 {
3364 if std::env::var_os("AFT_TEST_TIER2_REUSE_GATE_ROOT").is_some()
3365 && env_project_root_matches("AFT_TEST_TIER2_REUSE_GATE_ROOT", project_root)
3366 {
3367 let ready = std::env::var_os("AFT_TEST_TIER2_REUSE_GATE_READY").map(PathBuf::from);
3368 let release = std::env::var_os("AFT_TEST_TIER2_REUSE_GATE_RELEASE").map(PathBuf::from);
3369 if let (Some(ready), Some(release)) = (ready, release) {
3370 let _ = std::fs::write(&ready, b"ready");
3371 let hang_deadline = Instant::now() + Duration::from_secs(30);
3374 while !release.exists() {
3375 assert!(
3376 Instant::now() < hang_deadline,
3377 "timed out waiting for Tier-2 reuse gate release"
3378 );
3379 std::thread::sleep(Duration::from_millis(10));
3380 }
3381 return;
3382 }
3383 }
3384
3385 if !env_project_root_matches("AFT_TEST_TIER2_REUSE_DELAY_ROOT", project_root) {
3386 return;
3387 }
3388 if let Some(delay_ms) = std::env::var("AFT_TEST_TIER2_REUSE_DELAY_MS")
3389 .ok()
3390 .and_then(|raw| raw.parse::<u64>().ok())
3391 {
3392 std::thread::sleep(Duration::from_millis(delay_ms));
3393 }
3394 }
3395}
3396
3397#[cfg(debug_assertions)]
3398fn env_project_root_matches(var: &str, project_root: &Path) -> bool {
3399 let Some(raw) = std::env::var_os(var) else {
3400 return true;
3401 };
3402 let expected = PathBuf::from(raw);
3403 let expected = std::fs::canonicalize(&expected).unwrap_or(expected);
3404 let actual = std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
3405 expected == actual
3406}
3407
3408fn current_project_files(project_root: &Path, files: &[PathBuf]) -> BTreeMap<String, PathBuf> {
3409 files
3410 .iter()
3411 .map(|file| (relative_cache_key(project_root, file), file.clone()))
3412 .collect()
3413}
3414
3415fn insert_callgraph_refresh_path(paths: &mut BTreeSet<PathBuf>, path: PathBuf) {
3416 if callgraph_store_indexes_path(&path) {
3417 paths.insert(path);
3418 }
3419}
3420
3421fn callgraph_store_indexes_path(path: &Path) -> bool {
3422 crate::parser::detect_language(path).is_some()
3423}
3424
3425fn tier2_benchmark_logging_enabled() -> bool {
3426 std::env::var_os("AFT_SETTLE_BENCH_LOG").is_some()
3427}
3428
3429thread_local! {
3430 static TIER2_INDEX_SCOPE: RefCell<Option<crate::logging::IndexBuildScope>> =
3431 const { RefCell::new(None) };
3432}
3433
3434fn log_tier2_benchmark_category_start(job: &InspectJob) {
3435 let key = crate::search_index::artifact_cache_key(&job.project_root);
3436 let scope = crate::logging::IndexBuildScope::new(
3437 crate::logging::IndexPlane::Tier2,
3438 &job.project_root,
3439 key,
3440 );
3441 TIER2_INDEX_SCOPE.with(|slot| *slot.borrow_mut() = Some(scope));
3442 if !tier2_benchmark_logging_enabled() {
3443 return;
3444 }
3445 crate::slog_info!(
3446 "settle bench: tier2_category_start category={} job_id={} files={}",
3447 job.category.as_str(),
3448 job.job_id,
3449 job.scope_files.len()
3450 );
3451}
3452
3453fn tier2_pass_did_real_work(result: &InspectResult) -> bool {
3454 match &result.outcome {
3455 Ok(success) => {
3456 !success.scanned_files.is_empty() || result.duration >= TIER2_WORK_LOG_THRESHOLD
3457 }
3458 Err(_) => false,
3459 }
3460}
3461
3462fn log_tier2_benchmark_category_end(result: &InspectResult) {
3463 let scope = TIER2_INDEX_SCOPE.with(|slot| slot.borrow_mut().take());
3464 if let Some(scope) = scope {
3465 match &result.outcome {
3466 Ok(success) if tier2_pass_did_real_work(result) => {
3467 crate::logging::log_index_event(
3468 crate::logging::IndexEvent::from_scope(
3469 crate::logging::IndexEventKind::BuildStarted,
3470 &scope,
3471 )
3472 .field("category", result.category.as_str())
3473 .field("files", success.scanned_files.len()),
3474 );
3475 crate::logging::log_index_event(
3476 crate::logging::IndexEvent::from_scope(
3477 crate::logging::IndexEventKind::BuildReady,
3478 &scope,
3479 )
3480 .field("category", result.category.as_str())
3481 .field("elapsed_ms", result.duration.as_millis())
3482 .field("files", success.scanned_files.len())
3483 .field("contributions", success.contributions.len()),
3484 );
3485 }
3486 Ok(_) => {}
3487 Err(message) => {
3488 crate::logging::log_index_event(
3489 crate::logging::IndexEvent::from_scope(
3490 crate::logging::IndexEventKind::BuildFailed,
3491 &scope,
3492 )
3493 .field("category", result.category.as_str())
3494 .field("elapsed_ms", result.duration.as_millis())
3495 .field("reason", message),
3496 );
3497 }
3498 }
3499 }
3500 if !tier2_benchmark_logging_enabled() {
3501 return;
3502 }
3503 match &result.outcome {
3504 Ok(success) => {
3505 let count = success
3506 .aggregate
3507 .get("count")
3508 .and_then(serde_json::Value::as_u64)
3509 .unwrap_or(0);
3510 crate::slog_info!(
3511 "settle bench: tier2_category_end category={} job_id={} status=success total_ms={} scanned_files={} contributions={} count={}",
3512 result.category.as_str(),
3513 result.job_id,
3514 result.duration.as_millis(),
3515 success.scanned_files.len(),
3516 success.contributions.len(),
3517 count
3518 );
3519 }
3520 Err(message) => {
3521 crate::slog_info!(
3522 "settle bench: tier2_category_end category={} job_id={} status=failed total_ms={} error={}",
3523 result.category.as_str(),
3524 result.job_id,
3525 result.duration.as_millis(),
3526 message.replace('\n', " ")
3527 );
3528 }
3529 }
3530}
3531
3532fn build_tier2_callgraph_snapshot(
3533 job: &InspectJob,
3534 allow_cold_build: bool,
3535) -> Option<Arc<CallgraphSnapshot>> {
3536 build_tier2_callgraph_snapshot_with_refresh_inner(job, allow_cold_build, false, &[], None)
3537 .map(|(snapshot, _, _)| snapshot)
3538}
3539
3540#[cfg(test)]
3541fn build_tier2_callgraph_snapshot_with_refresh(
3542 job: &InspectJob,
3543 allow_cold_build: bool,
3544 refresh_paths: &[PathBuf],
3545) -> Option<Arc<CallgraphSnapshot>> {
3546 build_tier2_callgraph_snapshot_with_refresh_inner(
3547 job,
3548 allow_cold_build,
3549 false,
3550 refresh_paths,
3551 None,
3552 )
3553 .map(|(snapshot, _, _)| snapshot)
3554}
3555
3556const BLOCKING_CALLGRAPH_STORE_RETRY_TIMEOUT: Duration = Duration::from_secs(30);
3557const BLOCKING_CALLGRAPH_STORE_RETRY_INTERVAL: Duration = Duration::from_millis(20);
3558
3559fn open_ready_for_blocking_inspect(
3560 callgraph_dir: &Path,
3561 project_root: &Path,
3562 wait_for_publication: bool,
3563) -> Result<Option<CallGraphStore>, CallGraphStoreError> {
3564 let deadline = Instant::now() + BLOCKING_CALLGRAPH_STORE_RETRY_TIMEOUT;
3565 loop {
3566 match CallGraphStore::open_ready_repairing(
3567 callgraph_dir.to_path_buf(),
3568 project_root.to_path_buf(),
3569 ) {
3570 Ok(None) if wait_for_publication && Instant::now() < deadline => {}
3571 Err(error) if error.is_transient_lock_contention() && Instant::now() < deadline => {}
3572 result => return result,
3573 }
3574 std::thread::sleep(BLOCKING_CALLGRAPH_STORE_RETRY_INTERVAL);
3575 }
3576}
3577
3578fn open_or_build_blocking_callgraph_store(
3579 callgraph_dir: PathBuf,
3580 project_root: PathBuf,
3581 allow_cold_build: bool,
3582 refresh_paths: &[PathBuf],
3583) -> Result<Option<CallGraphStore>, CallGraphStoreError> {
3584 if let Some(store) = open_ready_for_blocking_inspect(&callgraph_dir, &project_root, false)? {
3585 return Ok(Some(store));
3586 }
3587 if !allow_cold_build || refresh_paths.is_empty() {
3588 return Ok(None);
3589 }
3590
3591 match CallGraphStore::cold_build_with_lease(
3592 callgraph_dir.clone(),
3593 project_root.clone(),
3594 refresh_paths,
3595 ) {
3596 Ok((store, _)) => Ok(Some(store)),
3597 Err(error)
3598 if matches!(error, CallGraphStoreError::Unavailable(_))
3599 || error.is_transient_lock_contention() =>
3600 {
3601 match open_ready_for_blocking_inspect(&callgraph_dir, &project_root, true)? {
3605 Some(store) => Ok(Some(store)),
3606 None => Err(error),
3607 }
3608 }
3609 Err(error) => Err(error),
3610 }
3611}
3612
3613fn merge_callgraph_refresh_paths(
3614 project_root: &Path,
3615 refresh_paths: &[PathBuf],
3616 stale: impl IntoIterator<Item = String>,
3617) -> Vec<PathBuf> {
3618 let mut paths = refresh_paths.to_vec();
3619 for rel in stale {
3620 let absolute = project_root.join(rel);
3621 if !paths.iter().any(|path| path == &absolute) {
3622 paths.push(absolute);
3623 }
3624 }
3625 paths
3626}
3627
3628#[cfg(test)]
3629thread_local! {
3630 static LEGACY_VIEW_REFRESHES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
3631}
3632
3633fn refresh_writable_dead_code_store(
3634 store: &CallGraphStore,
3635 callgraph_dir: &Path,
3636 refresh_paths: &[PathBuf],
3637) {
3638 let mut io = crate::views::io::Window::new();
3639 #[cfg(test)]
3640 LEGACY_VIEW_REFRESHES.with(|count| count.set(count.get() + 1));
3641 match store.refresh_files(refresh_paths) {
3642 Ok(stats) => {
3643 crate::slog_info!(
3644 "tier2 dead_code: refreshed callgraph store at {} for {} watcher path(s): changed={} deleted={} refreshed_own={} root={} {}",
3645 callgraph_dir.display(),
3646 refresh_paths.len(),
3647 stats.changed_files.len(),
3648 stats.deleted_files.len(),
3649 stats.refreshed_own_files,
3650 store.project_root().display(), io.finish()
3651 );
3652 }
3653 Err(error) => {
3654 crate::slog_warn!(
3655 "tier2 dead_code: failed to refresh callgraph store at {} before projection: {} root={} {}",
3656 callgraph_dir.display(),
3657 error, store.project_root().display(), io.finish()
3658 );
3659 if let Err(mark_error) = store.mark_files_stale(refresh_paths) {
3660 crate::slog_warn!(
3661 "tier2 dead_code: failed to mark callgraph store files stale at {} after refresh failure: {}",
3662 callgraph_dir.display(),
3663 mark_error
3664 );
3665 }
3666 }
3667 }
3668}
3669
3670fn callgraph_path_identity_gap(job: &InspectJob) -> Option<String> {
3671 if job.config.views.enabled {
3672 return None;
3673 }
3674 for callgraph_dir in callgraph_store_dirs_from_inspect_dir(&job.inspect_dir, &job.project_root)
3675 {
3676 let Ok(Some(store)) =
3677 CallGraphStore::open_readonly(callgraph_dir, job.project_root.clone())
3678 else {
3679 continue;
3680 };
3681 let Err(CallGraphStoreError::Unavailable(reason)) =
3682 project_dead_code_snapshot_with_revision(store.sqlite_path())
3683 else {
3684 continue;
3685 };
3686 if reason.starts_with("callgraph_path_identity_mismatch ") {
3687 return Some(reason);
3688 }
3689 }
3690 None
3691}
3692
3693fn open_writable_dead_code_store(
3694 callgraph_dir: PathBuf,
3695 project_root: PathBuf,
3696 allow_cold_build: bool,
3697 build_if_missing: bool,
3698 refresh_paths: &[PathBuf],
3699) -> Result<Option<CallGraphStore>, CallGraphStoreError> {
3700 if build_if_missing {
3701 open_or_build_blocking_callgraph_store(
3702 callgraph_dir,
3703 project_root,
3704 allow_cold_build,
3705 refresh_paths,
3706 )
3707 } else if allow_cold_build {
3708 CallGraphStore::open_ready_repairing(callgraph_dir, project_root)
3709 } else {
3710 CallGraphStore::open_ready_no_rebuild(callgraph_dir, project_root)
3711 }
3712}
3713
3714fn current_view_projection_store(
3715 project_root: &Path,
3716 inspect_dir: &Path,
3717 config: &crate::config::Config,
3718 refresh_paths: &[PathBuf],
3719) -> Option<(ReadonlyCallGraphStore, String)> {
3720 let result = (|| -> Result<Option<(ReadonlyCallGraphStore, String)>, String> {
3721 let storage = config
3722 .storage_dir
3723 .clone()
3724 .or_else(|| {
3725 callgraph_store_dir_from_inspect_dir(inspect_dir, project_root)
3726 .and_then(|path| path.parent()?.parent().map(Path::to_path_buf))
3727 })
3728 .ok_or_else(|| "inspect storage unavailable".to_string())?;
3729 let scope = crate::path_identity::project_scope_key(project_root);
3730 let view =
3731 crate::views::ViewStore::open(&storage, &scope).map_err(|error| error.to_string())?;
3732 let Some(generation) = view
3733 .current_generation()
3734 .map_err(|error| error.to_string())?
3735 else {
3736 return Ok(None);
3737 };
3738 let pin = crate::pins::QueryPin::acquire(view.view_dir(), &generation)
3739 .map_err(|error| error.to_string())?;
3740 let Some(head_fingerprint) = crate::views::cached_head_fingerprint(project_root) else {
3741 return Ok(None);
3742 };
3743 if !crate::views::generation_matches_head(&generation, &head_fingerprint) {
3744 return Ok(None);
3745 }
3746 if !refresh_paths.is_empty() {
3747 let manifest = view
3748 .load_manifest(&generation)
3749 .map_err(|error| error.to_string())?;
3750 if !crate::views::read::callgraph_paths_match(&manifest, project_root, refresh_paths)
3751 .map_err(|error| error.to_string())?
3752 {
3753 return Ok(None);
3754 }
3755 }
3756 let store = crate::views::read::open_published_callgraph(
3757 project_root.to_path_buf(),
3758 crate::search_index::artifact_cache_key(project_root),
3759 view.view_dir().to_path_buf(),
3760 &generation,
3761 Some(Arc::new(pin)),
3762 )
3763 .map_err(|error| error.to_string())?;
3764 Ok(Some((store, generation)))
3765 })();
3766 match result {
3767 Ok(store) => store,
3768 Err(error) => {
3769 crate::slog_warn!(
3770 "tier2 view reader unavailable root={}: {}",
3771 project_root.display(),
3772 error
3773 );
3774 None
3775 }
3776 }
3777}
3778
3779fn build_view_callgraph_snapshot(
3780 job: &InspectJob,
3781 projection_cache: Option<&InspectManager>,
3782 refresh_paths: &[PathBuf],
3783) -> Option<(Arc<CallgraphSnapshot>, ProjectionVerdict, Duration)> {
3784 let started = Instant::now();
3785 let Some((store, generation)) = current_view_projection_store(
3786 &job.project_root,
3787 &job.inspect_dir,
3788 &job.config,
3789 refresh_paths,
3790 ) else {
3791 crate::slog_info!(
3792 "tier2 dead_code: projection=none reason=view_pending root={}",
3793 job.project_root.display()
3794 );
3795 return None;
3796 };
3797 let identity = CallgraphProjectionIdentity {
3800 project_root: job.project_root.clone(),
3801 generation: Some(generation),
3802 legacy_sqlite_path: None,
3803 write_revision: 0,
3804 };
3805 if let Some(snapshot) =
3806 projection_cache.and_then(|cache| cache.cached_callgraph_projection(&identity))
3807 {
3808 return Some((
3809 snapshot,
3810 ProjectionVerdict {
3811 kind: ProjectionKind::Reused,
3812 reason: None,
3813 journal_bytes: 0,
3814 changed_files: 0,
3815 },
3816 Duration::ZERO,
3817 ));
3818 }
3819 let (_, snapshot, verdict, cost) =
3820 match crate::callgraph_store::project_dead_code_snapshot_from_view(&store) {
3821 Ok(projected) => projected,
3822 Err(error) => {
3823 crate::slog_warn!(
3824 "tier2 view projection unavailable root={}: {}",
3825 job.project_root.display(),
3826 error
3827 );
3828 return None;
3829 }
3830 };
3831 let snapshot = Arc::new(snapshot);
3832 if let Some(cache) = projection_cache {
3833 cache.cache_callgraph_projection(identity, Arc::clone(&snapshot));
3834 }
3835 crate::slog_info!(
3836 "perf tier2_callgraph_snapshot: source=view files={} exports={} edges={} entry_points={} ms={}",
3837 snapshot.files.len(), snapshot.exported_symbols.len(), snapshot.outbound_calls.len(), snapshot.entry_points.len(), started.elapsed().as_millis()
3838 );
3839 Some((snapshot, verdict, cost))
3840}
3841
3842fn build_tier2_callgraph_snapshot_with_refresh_inner(
3843 job: &InspectJob,
3844 allow_cold_build: bool,
3845 build_if_missing: bool,
3846 refresh_paths: &[PathBuf],
3847 projection_cache: Option<&InspectManager>,
3848) -> Option<(Arc<CallgraphSnapshot>, ProjectionVerdict, Duration)> {
3849 let started = Instant::now();
3850 if !job.config.callgraph_store {
3851 crate::slog_info!(
3852 "tier2 dead_code: callgraph store disabled; reporting callgraph_unavailable"
3853 );
3854 return None;
3855 }
3856
3857 if job.config.views.enabled {
3858 return build_view_callgraph_snapshot(job, projection_cache, refresh_paths);
3859 }
3860
3861 let callgraph_dirs = callgraph_store_dirs_from_inspect_dir(&job.inspect_dir, &job.project_root);
3862 if callgraph_dirs.is_empty() {
3863 crate::slog_info!(
3864 "tier2 dead_code: inspect_dir has no root-keyed storage parent ({}); reporting callgraph_unavailable",
3865 job.inspect_dir.display()
3866 );
3867 return None;
3868 };
3869 for callgraph_dir in &callgraph_dirs {
3870 match CallGraphStore::cold_build_suspension(callgraph_dir, &job.project_root) {
3871 Ok(Some(suspension)) => {
3872 if let Some(manager) = projection_cache {
3876 manager.record_tier2_build_suspension(&job.key, suspension.clone());
3877 }
3878 crate::slog_info!(
3879 "tier2 dead_code: callgraph build suspended for {} after {} deaths",
3880 suspension.domain.as_str(),
3881 suspension.death_count
3882 );
3883 return None;
3884 }
3885 Ok(None) => {}
3886 Err(error) => {
3887 crate::slog_warn!(
3888 "tier2 dead_code: failed to read callgraph breaker at {}: {}",
3889 callgraph_dir.display(),
3890 error
3891 );
3892 }
3893 }
3894 }
3895
3896 enum ProjectionStore {
3897 ReadOnly(ReadonlyCallGraphStore),
3898 Writable(CallGraphStore),
3899 }
3900
3901 impl ProjectionStore {
3902 fn sqlite_path(&self) -> &Path {
3903 match self {
3904 Self::ReadOnly(store) => store.sqlite_path(),
3905 Self::Writable(store) => store.sqlite_path(),
3906 }
3907 }
3908
3909 fn projection_identity(
3910 &self,
3911 project_root: &Path,
3912 write_revision: u64,
3913 ) -> CallgraphProjectionIdentity {
3914 let generation = match self {
3915 Self::ReadOnly(store) => store.projection_generation(),
3916 Self::Writable(store) => store.projection_generation(),
3917 }
3918 .map(str::to_owned);
3919 let legacy_sqlite_path = generation
3920 .is_none()
3921 .then(|| self.sqlite_path().to_path_buf());
3922 CallgraphProjectionIdentity {
3923 project_root: project_root.to_path_buf(),
3924 generation,
3925 legacy_sqlite_path,
3926 write_revision,
3927 }
3928 }
3929
3930 fn current_projection_identity(
3931 &self,
3932 project_root: &Path,
3933 ) -> Result<Option<CallgraphProjectionIdentity>, CallGraphStoreError> {
3934 let stale_files = match self {
3935 Self::ReadOnly(store) => store.stale_files()?,
3936 Self::Writable(store) => store.stale_files()?,
3937 };
3938 if !stale_files.is_empty() {
3939 return Ok(None);
3940 }
3941 let write_revision = match self {
3942 Self::ReadOnly(store) => store.projection_write_revision()?,
3943 Self::Writable(store) => store.projection_write_revision()?,
3944 };
3945 Ok(write_revision.map(|revision| self.projection_identity(project_root, revision)))
3946 }
3947 }
3948
3949 for (index, callgraph_dir) in callgraph_dirs.iter().enumerate() {
3950 let projection_store = if refresh_paths.is_empty() || !job.callgraph_writer {
3955 let store = match CallGraphStore::open_readonly(
3956 callgraph_dir.clone(),
3957 job.project_root.clone(),
3958 ) {
3959 Ok(Some(store)) => store,
3960 Ok(None) => {
3961 crate::slog_info!(
3962 "tier2 dead_code: callgraph store unavailable at {} (cold/building/not ready); trying fallback={}",
3963 callgraph_dir.display(),
3964 index + 1 < callgraph_dirs.len()
3965 );
3966 continue;
3967 }
3968 Err(error) => {
3969 crate::slog_warn!(
3970 "tier2 dead_code: failed to open callgraph store read-only at {}: {}; trying fallback={}",
3971 callgraph_dir.display(),
3972 error,
3973 index + 1 < callgraph_dirs.len()
3974 );
3975 continue;
3976 }
3977 };
3978 let stale = job
3979 .callgraph_writer
3980 .then(|| store.stale_files().ok())
3981 .flatten()
3982 .unwrap_or_default();
3983 if stale.is_empty() {
3984 ProjectionStore::ReadOnly(store)
3985 } else {
3986 drop(store);
3987 let refresh =
3988 merge_callgraph_refresh_paths(&job.project_root, refresh_paths, stale);
3989 let store = match open_writable_dead_code_store(
3990 callgraph_dir.clone(),
3991 job.project_root.clone(),
3992 allow_cold_build,
3993 build_if_missing,
3994 &refresh,
3995 ) {
3996 Ok(Some(store)) => store,
3997 Ok(None) => {
3998 crate::slog_info!(
3999 "tier2 dead_code: callgraph store unavailable at {} (cold/building/not ready); trying fallback={}",
4000 callgraph_dir.display(),
4001 index + 1 < callgraph_dirs.len()
4002 );
4003 continue;
4004 }
4005 Err(error) => {
4006 crate::slog_warn!(
4007 "tier2 dead_code: failed to open callgraph writer at {}: {}; trying fallback={}",
4008 callgraph_dir.display(),
4009 error,
4010 index + 1 < callgraph_dirs.len()
4011 );
4012 continue;
4013 }
4014 };
4015 refresh_writable_dead_code_store(&store, callgraph_dir, &refresh);
4016 ProjectionStore::Writable(store)
4017 }
4018 } else {
4019 let store = match open_writable_dead_code_store(
4020 callgraph_dir.clone(),
4021 job.project_root.clone(),
4022 allow_cold_build,
4023 build_if_missing,
4024 refresh_paths,
4025 ) {
4026 Ok(Some(store)) => store,
4027 Ok(None) => {
4028 crate::slog_info!(
4029 "tier2 dead_code: callgraph store unavailable at {} (cold/building/not ready); trying fallback={}",
4030 callgraph_dir.display(),
4031 index + 1 < callgraph_dirs.len()
4032 );
4033 continue;
4034 }
4035 Err(error) => {
4036 crate::slog_warn!(
4037 "tier2 dead_code: failed to open callgraph writer at {}: {}; trying fallback={}",
4038 callgraph_dir.display(),
4039 error,
4040 index + 1 < callgraph_dirs.len()
4041 );
4042 continue;
4043 }
4044 };
4045 let stale = store.stale_files().unwrap_or_default();
4046 let refresh = merge_callgraph_refresh_paths(&job.project_root, refresh_paths, stale);
4047 refresh_writable_dead_code_store(&store, callgraph_dir, &refresh);
4048 ProjectionStore::Writable(store)
4049 };
4050
4051 let cache_identity = match projection_store.current_projection_identity(&job.project_root) {
4052 Ok(identity) => identity,
4053 Err(error) => {
4054 crate::slog_warn!(
4055 "tier2 dead_code: failed to read callgraph projection identity at {}: {}; trying fallback={}",
4056 callgraph_dir.display(),
4057 error,
4058 index + 1 < callgraph_dirs.len()
4059 );
4060 continue;
4061 }
4062 };
4063 if let (Some(cache), Some(identity)) = (projection_cache, cache_identity.as_ref()) {
4064 if let Some(snapshot) = cache.cached_callgraph_projection(identity) {
4069 return Some((
4070 snapshot,
4071 ProjectionVerdict {
4072 kind: ProjectionKind::Reused,
4073 reason: None,
4074 journal_bytes: 0,
4075 changed_files: 0,
4076 },
4077 Duration::ZERO,
4078 ));
4079 }
4080 } else if cache_identity.is_none() {
4081 if let Some(cache) = projection_cache {
4084 cache.clear_callgraph_projection();
4085 }
4086 }
4087
4088 let previous = projection_cache
4089 .zip(cache_identity.as_ref())
4090 .and_then(|(cache, identity)| cache.previous_callgraph_projection(identity));
4091 let costs = projection_cache
4092 .zip(cache_identity.as_ref())
4093 .map(|(cache, identity)| cache.callgraph_projection_costs(identity))
4094 .unwrap_or_default();
4095 let (write_revision, snapshot, verdict, projection_cost) =
4096 match project_dead_code_snapshot_incremental_with_costs(
4097 projection_store.sqlite_path(),
4098 previous
4099 .as_ref()
4100 .map(|(revision, snapshot)| (*revision, snapshot.as_ref())),
4101 costs,
4102 ) {
4103 Ok(projected) => projected,
4104 Err(CallGraphStoreError::Unavailable(message)) => {
4105 crate::slog_info!(
4106 "tier2 dead_code: callgraph store projection unavailable at {} ({}); trying fallback={}",
4107 callgraph_dir.display(),
4108 message,
4109 index + 1 < callgraph_dirs.len()
4110 );
4111 continue;
4112 }
4113 Err(error) => {
4114 crate::slog_warn!(
4115 "tier2 dead_code: callgraph store projection failed at {}: {}; trying fallback={}",
4116 callgraph_dir.display(),
4117 error,
4118 index + 1 < callgraph_dirs.len()
4119 );
4120 continue;
4121 }
4122 };
4123 let snapshot = Arc::new(snapshot);
4124 if let (Some(cache), Some(write_revision)) = (projection_cache, write_revision) {
4125 cache.cache_callgraph_projection(
4126 projection_store.projection_identity(&job.project_root, write_revision),
4127 Arc::clone(&snapshot),
4128 );
4129 }
4130
4131 if index > 0 {
4132 crate::slog_info!(
4133 "tier2 dead_code: using ready callgraph store fallback {} for inspect_dir {}",
4134 callgraph_dir.display(),
4135 job.inspect_dir.display()
4136 );
4137 }
4138
4139 crate::slog_info!(
4140 "perf tier2_callgraph_snapshot: source=callgraph_store files={} exports={} edges={} entry_points={} ms={}",
4141 snapshot.files.len(),
4142 snapshot.exported_symbols.len(),
4143 snapshot.outbound_calls.len(),
4144 snapshot.entry_points.len(),
4145 started.elapsed().as_millis()
4146 );
4147
4148 return Some((snapshot, verdict, projection_cost));
4149 }
4150
4151 crate::slog_info!(
4152 "tier2 dead_code: no ready callgraph store found for inspect_dir {}; reporting callgraph_unavailable",
4153 job.inspect_dir.display()
4154 );
4155 None
4156}
4157
4158fn estimate_callgraph_snapshot_bytes(snapshot: &CallgraphSnapshot) -> u64 {
4159 let files = snapshot.files.iter().fold(0u64, |bytes, path| {
4160 bytes
4161 .saturating_add(std::mem::size_of::<PathBuf>() as u64)
4162 .saturating_add(crate::memory::path_bytes(path))
4163 });
4164 let exports = snapshot
4165 .exported_symbols
4166 .iter()
4167 .fold(0u64, |bytes, export| {
4168 bytes
4169 .saturating_add(std::mem::size_of::<super::job::CallgraphExport>() as u64)
4170 .saturating_add(crate::memory::path_bytes(&export.file))
4171 .saturating_add(crate::memory::usize_to_u64(export.symbol.len()))
4172 .saturating_add(crate::memory::usize_to_u64(export.kind.len()))
4173 });
4174 let calls = snapshot.outbound_calls.iter().fold(0u64, |bytes, call| {
4175 bytes
4176 .saturating_add(std::mem::size_of::<super::job::CallgraphOutboundCall>() as u64)
4177 .saturating_add(crate::memory::path_bytes(&call.caller_file))
4178 .saturating_add(crate::memory::usize_to_u64(call.caller_symbol.len()))
4179 .saturating_add(crate::memory::usize_to_u64(call.target.len()))
4180 .saturating_add(crate::memory::usize_to_u64(call.provenance.len()))
4181 });
4182 let entry_points = snapshot.entry_points.iter().fold(0u64, |bytes, path| {
4183 bytes
4184 .saturating_add(std::mem::size_of::<PathBuf>() as u64)
4185 .saturating_add(crate::memory::path_bytes(path))
4186 });
4187 let entry_point_symbols =
4188 snapshot
4189 .entry_point_symbols
4190 .iter()
4191 .fold(0u64, |bytes, (path, symbols)| {
4192 let symbols_bytes = symbols.iter().fold(0u64, |bytes, symbol| {
4193 bytes
4194 .saturating_add(std::mem::size_of::<String>() as u64)
4195 .saturating_add(crate::memory::usize_to_u64(symbol.len()))
4196 });
4197 bytes
4198 .saturating_add(std::mem::size_of::<(PathBuf, BTreeSet<String>)>() as u64)
4199 .saturating_add(crate::memory::path_bytes(path))
4200 .saturating_add(symbols_bytes)
4201 });
4202 (std::mem::size_of::<CallgraphSnapshot>() as u64)
4203 .saturating_add(files)
4204 .saturating_add(exports)
4205 .saturating_add(calls)
4206 .saturating_add(entry_points)
4207 .saturating_add(entry_point_symbols)
4208}
4209
4210fn callgraph_store_dir_from_inspect_dir(
4211 inspect_dir: &Path,
4212 project_root: &Path,
4213) -> Option<PathBuf> {
4214 let scope_key = crate::path_identity::project_scope_key(project_root);
4215 let storage_dir = if inspect_dir
4216 .file_name()
4217 .and_then(|name| name.to_str())
4218 .is_some_and(|name| name == scope_key)
4219 {
4220 inspect_dir.parent()?.parent()?
4221 } else {
4222 inspect_dir.parent()?
4223 };
4224 let project_key = crate::search_index::artifact_cache_key(project_root);
4225 Some(storage_dir.join("callgraph").join(project_key))
4226}
4227
4228fn callgraph_store_dirs_from_inspect_dir(inspect_dir: &Path, project_root: &Path) -> Vec<PathBuf> {
4229 callgraph_store_dir_from_inspect_dir(inspect_dir, project_root)
4230 .into_iter()
4231 .collect()
4232}
4233
4234#[cfg(test)]
4235fn canonicalize_for_snapshot(path: &Path) -> PathBuf {
4236 crate::inspect::job::canonicalize_normalized(path)
4239}
4240
4241fn load_contribution_freshness(
4242 cache: &(impl InspectCacheRead + ?Sized),
4243 category: InspectCategory,
4244) -> Result<Vec<CachedContributionFreshness>, String> {
4245 cache
4246 .contribution_freshness(category)
4247 .map_err(|error| error.to_string())
4248 .map(|records| {
4249 records
4250 .into_iter()
4251 .map(|(file_path, freshness)| CachedContributionFreshness {
4252 file_path,
4253 freshness,
4254 })
4255 .collect()
4256 })
4257}
4258
4259fn freshness_record_relative_key(record: &CachedContributionFreshness) -> String {
4260 record.file_path.to_string_lossy().to_string()
4261}
4262
4263fn relative_cache_key(project_root: &Path, path: &Path) -> String {
4264 path.strip_prefix(project_root)
4265 .unwrap_or(path)
4266 .to_string_lossy()
4267 .to_string()
4268}
4269
4270fn load_contributions(
4271 cache: &(impl InspectCacheRead + ?Sized),
4272 job: &InspectJob,
4273) -> Result<Vec<FileContribution>, String> {
4274 cache
4275 .load_tier2_contributions(job.category)
4276 .map_err(|error| error.to_string())
4277 .map(|records| {
4278 records
4279 .into_iter()
4280 .map(|record| contribution_from_record(&job.project_root, record))
4281 .collect()
4282 })
4283}
4284
4285fn dead_code_contributions_need_fact_refresh(
4286 cache: &(impl InspectCacheRead + ?Sized),
4287 job: &InspectJob,
4288) -> Result<bool, String> {
4289 let contributions = load_contributions(cache, job)?;
4290 Ok(contributions
4291 .iter()
4292 .any(dead_code_contribution_needs_fact_refresh))
4293}
4294
4295fn dead_code_contribution_needs_fact_refresh(contribution: &FileContribution) -> bool {
4296 let Ok(parsed) =
4297 serde_json::from_value::<DeadCodeRefreshContribution>(contribution.contribution.clone())
4298 else {
4299 return true;
4300 };
4301
4302 if parsed.facts_format_version
4303 != Some(super::scanners::dead_code::DEAD_CODE_FACTS_FORMAT_VERSION)
4304 {
4305 return true;
4306 }
4307
4308 matches!(
4309 parsed.oxc_facts,
4310 Some(facts) if facts.format_version != FACTS_FORMAT_VERSION
4311 )
4312}
4313
4314fn unused_exports_contributions_need_fact_refresh(
4315 cache: &(impl InspectCacheRead + ?Sized),
4316 job: &InspectJob,
4317) -> Result<bool, String> {
4318 let contributions = load_contributions(cache, job)?;
4319 Ok(contributions
4320 .iter()
4321 .any(unused_exports_contribution_needs_fact_refresh))
4322}
4323
4324fn duplicates_contributions_need_fact_refresh(
4329 cache: &(impl InspectCacheRead + ?Sized),
4330 job: &InspectJob,
4331) -> Result<bool, String> {
4332 let contributions = load_contributions(cache, job)?;
4333 Ok(contributions
4334 .iter()
4335 .any(|contribution| contribution.contribution.get("line_count").is_none()))
4336}
4337
4338fn unused_exports_contribution_needs_fact_refresh(contribution: &FileContribution) -> bool {
4339 let top_level_oxc = contribution
4340 .contribution
4341 .get("provenance")
4342 .and_then(Value::as_str)
4343 == Some(OXC_PROVENANCE);
4344 let Ok(parsed) =
4345 serde_json::from_value::<UnusedExportsContribution>(contribution.contribution.clone())
4346 else {
4347 return false;
4348 };
4349 let uses_oxc =
4350 top_level_oxc || parsed.oxc_facts.is_some() || parsed.exports.iter().any(export_uses_oxc);
4351 if !uses_oxc {
4352 return false;
4353 }
4354
4355 !matches!(
4356 parsed.oxc_facts,
4357 Some(facts) if facts.format_version == FACTS_FORMAT_VERSION
4358 )
4359}
4360
4361fn contribution_from_record(
4362 project_root: &Path,
4363 record: super::cache::ContributionRecord,
4364) -> FileContribution {
4365 FileContribution::new(
4366 record.category,
4367 project_root.join(record.file_path),
4368 record.freshness,
4369 record.contribution,
4370 )
4371 .with_type_ref_names(record.type_ref_names)
4372}
4373
4374fn run_tier2_scan(job: &InspectJob, oxc_result: Option<&OxcEngineResult>) -> InspectResult {
4375 use super::scanners;
4376
4377 match job.category {
4378 InspectCategory::DeadCode => {
4379 scanners::dead_code::run_dead_code_scan_with_oxc(job, oxc_result)
4380 }
4381 InspectCategory::UnusedExports => {
4382 scanners::unused_exports::run_unused_exports_scan_with_oxc(job, oxc_result)
4383 }
4384 InspectCategory::Duplicates => scanners::duplicates::run_duplicates_scan(job),
4385 InspectCategory::Cycles => scanners::cycles::run_cycles_scan_with_oxc(job, oxc_result),
4386 InspectCategory::Complexity => scanners::complexity::run_complexity_scan(job),
4387 other => InspectResult::failed(
4388 job,
4389 format!("inspect category '{other}' is not an active Tier 2 scanner"),
4390 Duration::from_secs(0),
4391 ),
4392 }
4393}
4394
4395fn roll_up_tier2_contributions(job: &InspectJob, contributions: &[FileContribution]) -> Value {
4396 roll_up_tier2_contributions_with_limit(job, contributions, Some(MAX_DRILL_DOWN_ITEMS))
4397}
4398
4399fn roll_up_tier2_contributions_with_limit(
4400 job: &InspectJob,
4401 contributions: &[FileContribution],
4402 drill_down_limit: Option<usize>,
4403) -> Value {
4404 match job.category {
4405 InspectCategory::DeadCode => {
4406 roll_up_dead_code_contributions(job, contributions, drill_down_limit)
4407 }
4408 InspectCategory::UnusedExports => {
4409 roll_up_unused_exports_contributions(job, contributions, drill_down_limit)
4410 }
4411 InspectCategory::Duplicates => {
4412 roll_up_duplicate_contributions(job, contributions, drill_down_limit)
4413 }
4414 InspectCategory::Cycles => {
4415 roll_up_cycle_contributions(job, contributions, drill_down_limit)
4416 }
4417 InspectCategory::Complexity => {
4418 roll_up_complexity_contributions(job, contributions, drill_down_limit)
4419 }
4420 _ => json!({
4421 "count": 0,
4422 "items": [],
4423 "scanned_files": contributions.len(),
4424 }),
4425 }
4426}
4427
4428fn scoped_tier2_payload_from_contributions(
4429 snapshot: &InspectSnapshot,
4430 category: InspectCategory,
4431 cache: &(impl InspectCacheRead + ?Sized),
4432 project_payload: Value,
4433 scope: &JobScope,
4434) -> Result<Value, String> {
4435 if scope.is_project_wide() {
4436 return Ok(project_payload);
4437 }
4438
4439 let project_scope = JobScope::for_project(snapshot.project_root.clone());
4440 let rollup_job = scoped_tier2_rollup_job(snapshot, category, &project_scope);
4441 let contributions = load_contributions(cache, &rollup_job)?;
4442 let full_payload = roll_up_tier2_contributions_with_limit(&rollup_job, &contributions, None);
4443 let scoped_payload = filter_payload_for_scope(full_payload, scope);
4444 Ok(cap_payload_drill_down(scoped_payload, MAX_DRILL_DOWN_ITEMS))
4445}
4446
4447fn scoped_tier2_rollup_job(
4448 snapshot: &InspectSnapshot,
4449 category: InspectCategory,
4450 scope: &JobScope,
4451) -> InspectJob {
4452 let mut job = InspectJob {
4453 job_id: 0,
4454 key: JobKey::for_project_category(category),
4455 category,
4456 scope_files: scope_files(&snapshot.project_root, scope),
4457 project_root: snapshot.project_root.clone(),
4458 inspect_dir: snapshot.inspect_dir.clone(),
4459 config: Arc::clone(&snapshot.config),
4460 symbol_cache: Arc::clone(&snapshot.symbol_cache),
4461 inspect_writer: snapshot.inspect_writer,
4462 callgraph_writer: snapshot.callgraph_writer,
4463 callgraph_snapshot: None,
4464 };
4465
4466 if category == InspectCategory::DeadCode {
4467 job.callgraph_snapshot = build_tier2_callgraph_snapshot(&job, false);
4472 }
4473
4474 job
4475}
4476
4477fn roll_up_dead_code_contributions(
4478 job: &InspectJob,
4479 contributions: &[FileContribution],
4480 drill_down_limit: Option<usize>,
4481) -> Value {
4482 let Some(snapshot) = job.callgraph_snapshot.as_deref() else {
4483 return super::scanners::dead_code::callgraph_unavailable_aggregate(job.scope_files.len());
4484 };
4485
4486 let public_api_files = super::scanners::dead_code::collect_public_api_files(&job.project_root);
4487 let roles = super::entry_points::resolve_project_roles(&job.project_root);
4488 super::scanners::dead_code::aggregate_dead_code_contributions_with_snapshot(
4489 &job.project_root,
4490 snapshot,
4491 contributions,
4492 &public_api_files,
4493 &roles,
4494 drill_down_limit,
4495 )
4496}
4497
4498fn roll_up_unused_exports_contributions(
4499 job: &InspectJob,
4500 contributions: &[FileContribution],
4501 drill_down_limit: Option<usize>,
4502) -> Value {
4503 let parsed = contributions
4504 .iter()
4505 .filter_map(|contribution| {
4506 serde_json::from_value::<UnusedExportsContribution>(contribution.contribution.clone())
4507 .ok()
4508 })
4509 .collect::<Vec<_>>();
4510
4511 if parsed.iter().any(|scan| scan.oxc_facts.is_some()) {
4512 return roll_up_unused_exports_oxc_contributions(job, &parsed, drill_down_limit);
4513 }
4514
4515 let (public_api_files, package_warnings) = unused_public_api_entries(&job.project_root);
4516 let mut imported_by: BTreeMap<(String, String), BTreeSet<String>> = BTreeMap::new();
4517 let mut uncertain_by: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
4518 for scan in &parsed {
4519 for import in &scan.imports {
4520 let Some(resolved_file) = &import.resolved_file else {
4521 continue;
4522 };
4523 for name in &import.named {
4524 if name == "*" {
4525 uncertain_by
4526 .entry(resolved_file.clone())
4527 .or_default()
4528 .insert(scan.file.clone());
4529 } else {
4530 imported_by
4531 .entry((resolved_file.clone(), name.clone()))
4532 .or_default()
4533 .insert(scan.file.clone());
4534 }
4535 }
4536 }
4537 }
4538
4539 let mut count = 0usize;
4540 let mut items = Vec::new();
4541 let mut generated_count = 0usize;
4542 let mut generated_items = Vec::new();
4543 let test_only_count = 0usize;
4544 let test_only_items = Vec::new();
4545 let mut uncertain_count = 0usize;
4546 let mut uncertain_items = Vec::new();
4547 for scan in &parsed {
4548 if public_api_files.contains(&scan.file) {
4549 continue;
4550 }
4551 if super::job::is_test_support_file(&scan.file) {
4554 continue;
4555 }
4556 let generated_file = super::generated::is_generated_file_with_cached_hint(
4557 &job.project_root,
4558 &scan.file,
4559 scan.generated,
4560 );
4561
4562 for export in &scan.exports {
4563 if export_uses_oxc(export) {
4564 match export.verdict.unwrap_or(LivenessVerdict::Unused) {
4565 LivenessVerdict::Used => continue,
4566 LivenessVerdict::Uncertain => {
4567 uncertain_count += 1;
4568 if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
4569 uncertain_items.push(json!({
4570 "file": scan.file,
4571 "symbol": export.symbol,
4572 "kind": export.kind,
4573 "line": export.line,
4574 "reason": export.reason.as_deref().unwrap_or("oxc_uncertain"),
4575 "provenance": export.provenance.as_deref().unwrap_or(OXC_PROVENANCE),
4576 }));
4577 }
4578 continue;
4579 }
4580 LivenessVerdict::Unused => {}
4581 }
4582 } else {
4583 let imported = imported_by
4584 .get(&(scan.file.clone(), export.symbol.clone()))
4585 .map(|files| !files.is_empty())
4586 .unwrap_or(false);
4587 let uncertain = uncertain_by
4588 .get(&scan.file)
4589 .map(|files| !files.is_empty())
4590 .unwrap_or(false);
4591
4592 if imported {
4593 continue;
4594 }
4595 if uncertain {
4596 uncertain_count += 1;
4597 if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
4598 uncertain_items.push(json!({
4599 "file": scan.file,
4600 "symbol": export.symbol,
4601 "kind": export.kind,
4602 "line": export.line,
4603 "reason": "wildcard_import",
4604 }));
4605 }
4606 continue;
4607 }
4608 }
4609
4610 let mut item = json!({
4611 "file": scan.file,
4612 "symbol": export.symbol,
4613 "kind": export.kind,
4614 "line": export.line,
4615 });
4616 if let Some(provenance) = &export.provenance {
4617 item["provenance"] = json!(provenance);
4618 }
4619 if generated_file {
4620 item["generated"] = json!(true);
4621 generated_count += 1;
4622 generated_items.push(item);
4623 } else {
4624 count += 1;
4625 items.push(item);
4626 }
4627 }
4628 }
4629
4630 let roles = super::entry_points::resolve_project_roles(&job.project_root);
4631 let items = super::entry_points::rank_and_truncate_items(items, &roles, drill_down_limit);
4632 let generated_items =
4633 super::entry_points::rank_and_truncate_items(generated_items, &roles, drill_down_limit);
4634 let top = super::entry_points::top_preview_symbols(&items);
4635 let generated_top = generated_items
4636 .iter()
4637 .take(super::entry_points::TOP_PREVIEW_ITEMS)
4638 .cloned()
4639 .collect::<Vec<_>>();
4640 let mut all_items = items;
4641 all_items.extend(generated_items.iter().cloned());
4642 if let Some(limit) = drill_down_limit {
4643 all_items.truncate(limit);
4644 }
4645 let test_only_items =
4646 super::entry_points::rank_and_truncate_items(test_only_items, &roles, drill_down_limit);
4647 let test_only_top = test_only_items
4648 .iter()
4649 .take(super::entry_points::TOP_PREVIEW_ITEMS)
4650 .cloned()
4651 .collect::<Vec<_>>();
4652
4653 let (parse_errors, skipped_files) = unused_exports_honesty_fields(&parsed);
4654 let mut aggregate = json!({
4655 "count": count,
4656 "generated_count": generated_count,
4657 "total_count": count + test_only_count + generated_count,
4658 "items": all_items,
4659 "top": top,
4660 "generated_items": generated_items,
4661 "generated_top": generated_top,
4662 "test_only_count": test_only_count,
4663 "test_only_items": test_only_items,
4664 "test_only_top": test_only_top,
4665 "drill_down_capped": drill_down_limit.is_some_and(|limit| count + generated_count > limit),
4666 "generated_drill_down_capped": drill_down_limit.is_some_and(|limit| generated_count > limit),
4667 "test_only_drill_down_capped": drill_down_limit.is_some_and(|limit| test_only_count > limit),
4668 "scanned_files": parsed.len(),
4669 "languages_skipped": skipped_languages(&job.scope_files, LanguageSkipMode::UnusedExports),
4670 "uncertain_count": uncertain_count,
4671 "uncertain_items": uncertain_items,
4672 "complete": parse_errors.is_empty() && skipped_files.is_empty(),
4673 });
4674 if !parse_errors.is_empty() {
4675 aggregate["parse_errors"] = Value::Array(parse_errors);
4676 }
4677 if !skipped_files.is_empty() {
4678 aggregate["skipped_files"] = Value::Array(skipped_files);
4679 }
4680 if !package_warnings.is_empty() {
4681 aggregate["note"] = Value::String(package_warnings.join("; "));
4682 }
4683 aggregate
4684}
4685
4686fn roll_up_unused_exports_oxc_contributions(
4687 job: &InspectJob,
4688 parsed: &[UnusedExportsContribution],
4689 drill_down_limit: Option<usize>,
4690) -> Value {
4691 let (public_api_files, package_warnings) = unused_public_api_entries(&job.project_root);
4692 let facts = parsed
4693 .iter()
4694 .filter_map(|scan| {
4695 let oxc_facts = scan.oxc_facts.as_ref()?;
4696 let path = job.project_root.join(&scan.file);
4697 Some(FileFacts {
4698 file_id: FileId(0),
4699 path: normalize_input_path(&job.project_root, &path),
4700 content_hash: oxc_facts.content_hash.clone(),
4701 exports: oxc_facts.exports.clone(),
4702 imports: oxc_facts.imports.clone(),
4703 re_exports: oxc_facts.re_exports.clone(),
4704 dynamic_imports: oxc_facts.dynamic_imports.clone(),
4705 same_file_value_references: oxc_facts.same_file_value_references.clone(),
4706 used_import_bindings: oxc_facts.used_import_bindings.clone(),
4707 type_referenced_import_bindings: oxc_facts.type_referenced_import_bindings.clone(),
4708 value_referenced_import_bindings: oxc_facts
4709 .value_referenced_import_bindings
4710 .clone(),
4711 parse_error: oxc_facts.parse_error.clone(),
4712 })
4713 })
4714 .collect::<Vec<_>>();
4715 let generated_by_file = parsed
4716 .iter()
4717 .map(|scan| {
4718 (
4719 scan.file.clone(),
4720 super::generated::is_generated_file_with_cached_hint(
4721 &job.project_root,
4722 &scan.file,
4723 scan.generated,
4724 ),
4725 )
4726 })
4727 .collect::<BTreeMap<_, _>>();
4728 let entry_point_set = crate::inspect::entry_points::resolve_entry_points(&job.project_root);
4729 let oxc_result = analyze_file_facts(
4730 &job.project_root,
4731 facts,
4732 AnalyzeOptions {
4733 entry_points: Vec::new(),
4734 public_api_files: entry_point_set.public_api_files(),
4735 executable_root_exports: entry_point_set.executable_root_exports(),
4736 force_reparse_files: Vec::new(),
4737 entry_reachability: false,
4738 },
4739 Vec::new(),
4740 );
4741 let roles = super::entry_points::resolve_project_roles(&job.project_root);
4742
4743 let mut count = 0usize;
4744 let mut items = Vec::new();
4745 let mut generated_count = 0usize;
4746 let mut generated_items = Vec::new();
4747 let mut test_only_count = 0usize;
4748 let mut test_only_items = Vec::new();
4749 let mut uncertain_count = 0usize;
4750 let mut uncertain_items = Vec::new();
4751 for file in &oxc_result.files {
4752 if public_api_files.contains(&file.relative_file)
4753 || super::job::is_test_support_file(&file.relative_file)
4754 {
4755 continue;
4756 }
4757 let generated_file = generated_by_file
4758 .get(&file.relative_file)
4759 .copied()
4760 .unwrap_or_else(|| {
4761 super::generated::is_generated_file(
4762 &job.project_root,
4763 Path::new(&file.relative_file),
4764 )
4765 });
4766
4767 for export in &file.exports {
4768 match export.verdict {
4769 LivenessVerdict::Used => {
4770 if !is_test_file(&file.relative_file)
4771 && !export.test_only_reference_files.is_empty()
4772 {
4773 let mut item = json!({
4774 "file": file.relative_file,
4775 "symbol": export.symbol,
4776 "kind": export.kind,
4777 "line": export.line,
4778 "provenance": export.provenance,
4779 "used_by": export.test_only_reference_files,
4780 });
4781 add_oxc_reexport_contexts(&mut item, &export.also_reexported);
4782 if generated_file {
4783 item["generated"] = json!(true);
4784 generated_count += 1;
4785 generated_items.push(item);
4786 } else {
4787 test_only_count += 1;
4788 test_only_items.push(item);
4789 }
4790 }
4791 }
4792 LivenessVerdict::Uncertain => {
4793 uncertain_count += 1;
4794 if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
4795 let mut item = json!({
4796 "file": file.relative_file,
4797 "symbol": export.symbol,
4798 "kind": export.kind,
4799 "line": export.line,
4800 "reason": export.reason,
4801 "provenance": export.provenance,
4802 });
4803 add_oxc_reexport_contexts(&mut item, &export.also_reexported);
4804 uncertain_items.push(item);
4805 }
4806 }
4807 LivenessVerdict::Unused => {
4808 if !is_test_file(&file.relative_file)
4809 && !export.test_only_reference_files.is_empty()
4810 {
4811 let mut item = json!({
4812 "file": file.relative_file,
4813 "symbol": export.symbol,
4814 "kind": export.kind,
4815 "line": export.line,
4816 "provenance": export.provenance,
4817 "used_by": export.test_only_reference_files,
4818 });
4819 add_oxc_reexport_contexts(&mut item, &export.also_reexported);
4820 if generated_file {
4821 item["generated"] = json!(true);
4822 generated_count += 1;
4823 generated_items.push(item);
4824 } else {
4825 test_only_count += 1;
4826 test_only_items.push(item);
4827 }
4828 continue;
4829 }
4830 if export.has_references {
4831 continue;
4832 }
4833 let mut item = json!({
4834 "file": file.relative_file,
4835 "symbol": export.symbol,
4836 "kind": export.kind,
4837 "line": export.line,
4838 "provenance": export.provenance,
4839 });
4840 add_oxc_reexport_contexts(&mut item, &export.also_reexported);
4841 if generated_file {
4842 item["generated"] = json!(true);
4843 generated_count += 1;
4844 generated_items.push(item);
4845 } else {
4846 count += 1;
4847 items.push(item);
4848 }
4849 }
4850 }
4851 }
4852 }
4853
4854 let items = super::entry_points::rank_and_truncate_items(items, &roles, drill_down_limit);
4855 let generated_items =
4856 super::entry_points::rank_and_truncate_items(generated_items, &roles, drill_down_limit);
4857 let top = super::entry_points::top_preview_symbols(&items);
4858 let generated_top = generated_items
4859 .iter()
4860 .take(super::entry_points::TOP_PREVIEW_ITEMS)
4861 .cloned()
4862 .collect::<Vec<_>>();
4863 let mut all_items = items;
4864 all_items.extend(generated_items.iter().cloned());
4865 if let Some(limit) = drill_down_limit {
4866 all_items.truncate(limit);
4867 }
4868 let test_only_items =
4869 super::entry_points::rank_and_truncate_items(test_only_items, &roles, drill_down_limit);
4870 let test_only_top = test_only_items
4871 .iter()
4872 .take(super::entry_points::TOP_PREVIEW_ITEMS)
4873 .cloned()
4874 .collect::<Vec<_>>();
4875 let (mut parse_errors, skipped_files) = unused_exports_honesty_fields(parsed);
4876 for scan in parsed {
4877 if let Some(oxc_facts) = &scan.oxc_facts {
4878 if oxc_facts.format_version != FACTS_FORMAT_VERSION {
4879 parse_errors.push(json!({
4880 "file": scan.file,
4881 "message": format!(
4882 "unsupported oxc facts format {}; expected {}",
4883 oxc_facts.format_version, FACTS_FORMAT_VERSION
4884 ),
4885 }));
4886 }
4887 }
4888 }
4889
4890 let mut aggregate = json!({
4891 "count": count,
4892 "generated_count": generated_count,
4893 "total_count": count + test_only_count + generated_count,
4894 "items": all_items,
4895 "top": top,
4896 "generated_items": generated_items,
4897 "generated_top": generated_top,
4898 "test_only_count": test_only_count,
4899 "test_only_items": test_only_items,
4900 "test_only_top": test_only_top,
4901 "drill_down_capped": drill_down_limit.is_some_and(|limit| count + generated_count > limit),
4902 "generated_drill_down_capped": drill_down_limit.is_some_and(|limit| generated_count > limit),
4903 "test_only_drill_down_capped": drill_down_limit.is_some_and(|limit| test_only_count > limit),
4904 "scanned_files": parsed.len(),
4905 "languages_skipped": skipped_languages(&job.scope_files, LanguageSkipMode::UnusedExports),
4906 "uncertain_count": uncertain_count,
4907 "uncertain_items": uncertain_items,
4908 "complete": parse_errors.is_empty() && skipped_files.is_empty(),
4909 });
4910 if !parse_errors.is_empty() {
4911 aggregate["parse_errors"] = Value::Array(parse_errors);
4912 }
4913 if !skipped_files.is_empty() {
4914 aggregate["skipped_files"] = Value::Array(skipped_files);
4915 }
4916 if !package_warnings.is_empty() {
4917 aggregate["note"] = Value::String(package_warnings.join("; "));
4918 }
4919 aggregate
4920}
4921
4922fn add_oxc_reexport_contexts(
4923 item: &mut Value,
4924 contexts: &[crate::inspect::oxc_engine::OxcReExportContext],
4925) {
4926 if !contexts.is_empty() {
4927 item["also_reexported"] = json!(contexts);
4928 }
4929}
4930
4931fn unused_exports_honesty_fields(parsed: &[UnusedExportsContribution]) -> (Vec<Value>, Vec<Value>) {
4932 let mut parse_error_keys = BTreeSet::new();
4933 let mut parse_errors = Vec::new();
4934 let mut skipped_file_keys = BTreeSet::new();
4935 let mut skipped_files = Vec::new();
4936 for contribution in parsed {
4937 for value in &contribution.parse_errors {
4938 let key = value.to_string();
4939 if parse_error_keys.insert(key) {
4940 parse_errors.push(value.clone());
4941 }
4942 }
4943 for value in &contribution.skipped_files {
4944 let key = value.to_string();
4945 if skipped_file_keys.insert(key) {
4946 skipped_files.push(value.clone());
4947 }
4948 }
4949 }
4950 (parse_errors, skipped_files)
4951}
4952
4953fn roll_up_duplicate_contributions(
4954 job: &InspectJob,
4955 contributions: &[FileContribution],
4956 drill_down_limit: Option<usize>,
4957) -> Value {
4958 super::scanners::duplicates::aggregate_duplicate_contributions_with_limit(
4959 contributions,
4960 skipped_languages(&job.scope_files, LanguageSkipMode::Duplicates),
4961 drill_down_limit,
4962 &job.config.inspect.duplicates.expected_mirrors,
4963 )
4964}
4965
4966fn roll_up_cycle_contributions(
4967 job: &InspectJob,
4968 contributions: &[FileContribution],
4969 drill_down_limit: Option<usize>,
4970) -> Value {
4971 super::scanners::cycles::aggregate_cycle_contributions_with_limit(
4972 &job.project_root,
4973 contributions,
4974 skipped_languages(&job.scope_files, LanguageSkipMode::Cycles),
4975 drill_down_limit,
4976 )
4977}
4978
4979fn roll_up_complexity_contributions(
4980 job: &InspectJob,
4981 contributions: &[FileContribution],
4982 drill_down_limit: Option<usize>,
4983) -> Value {
4984 super::scanners::complexity::aggregate_complexity_contributions_with_limit(
4985 &job.project_root,
4986 contributions,
4987 drill_down_limit,
4988 )
4989}
4990
4991fn cap_payload_drill_down(mut payload: Value, limit: usize) -> Value {
4992 let mut capped = false;
4993 if let Some(items) = payload.get_mut("items").and_then(Value::as_array_mut) {
4994 capped |= items.len() > limit;
4995 items.truncate(limit);
4996 }
4997 if let Some(groups) = payload.get_mut("groups").and_then(Value::as_array_mut) {
4998 capped |= groups.len() > limit;
4999 groups.truncate(limit);
5000 }
5001 if let Some(object) = payload.as_object_mut() {
5002 object.insert("drill_down_capped".to_string(), json!(capped));
5003 }
5004 payload
5005}
5006
5007const MAX_DRILL_DOWN_ITEMS: usize = 100;
5008
5009#[derive(Debug, Clone, Deserialize)]
5010struct ExportContribution {
5011 symbol: String,
5012 kind: String,
5013 line: u32,
5014 #[serde(default)]
5015 verdict: Option<LivenessVerdict>,
5016 #[serde(default)]
5017 reason: Option<String>,
5018 #[serde(default)]
5019 provenance: Option<String>,
5020}
5021
5022fn export_uses_oxc(export: &ExportContribution) -> bool {
5023 export.verdict.is_some() || export.provenance.as_deref() == Some(OXC_PROVENANCE)
5024}
5025
5026#[derive(Debug, Clone, Deserialize)]
5027struct DeadCodeRefreshContribution {
5028 #[serde(default)]
5029 facts_format_version: Option<u32>,
5030 #[serde(default)]
5031 oxc_facts: Option<OxcFactsContribution>,
5032}
5033
5034#[derive(Debug, Clone, Deserialize)]
5035struct UnusedExportsContribution {
5036 file: String,
5037 #[serde(default)]
5038 generated: Option<bool>,
5039 exports: Vec<ExportContribution>,
5040 #[serde(default)]
5041 imports: Vec<ImportContribution>,
5042 #[serde(default)]
5043 oxc_facts: Option<OxcFactsContribution>,
5044 #[serde(default)]
5045 parse_errors: Vec<Value>,
5046 #[serde(default)]
5047 skipped_files: Vec<Value>,
5048}
5049
5050#[derive(Debug, Clone, Deserialize)]
5051struct ImportContribution {
5052 resolved_file: Option<String>,
5053 named: Vec<String>,
5054}
5055
5056#[derive(Debug, Clone, Deserialize)]
5057struct OxcFactsContribution {
5058 format_version: u32,
5059 content_hash: String,
5060 exports: Vec<ExportFact>,
5061 imports: Vec<ImportFact>,
5062 re_exports: Vec<ReExportFact>,
5063 dynamic_imports: Vec<DynamicImportFact>,
5064 same_file_value_references: BTreeSet<String>,
5065 used_import_bindings: BTreeSet<String>,
5066 type_referenced_import_bindings: BTreeSet<String>,
5067 value_referenced_import_bindings: BTreeSet<String>,
5068 #[serde(default)]
5069 parse_error: Option<String>,
5070}
5071
5072#[derive(Debug, Clone, Copy)]
5073enum LanguageSkipMode {
5074 Duplicates,
5075 Cycles,
5076 UnusedExports,
5077}
5078
5079fn category_uses_oxc(category: InspectCategory) -> bool {
5080 matches!(
5081 category,
5082 InspectCategory::DeadCode | InspectCategory::UnusedExports | InspectCategory::Cycles
5083 )
5084}
5085
5086fn skipped_languages(files: &[PathBuf], mode: LanguageSkipMode) -> Vec<String> {
5087 files
5088 .iter()
5089 .filter_map(|file| skipped_language(file, mode))
5090 .collect::<BTreeSet<_>>()
5091 .into_iter()
5092 .collect()
5093}
5094
5095fn skipped_language(file: &Path, mode: LanguageSkipMode) -> Option<String> {
5096 let Some(language) = crate::parser::detect_language(file) else {
5097 return match mode {
5098 LanguageSkipMode::Duplicates => Some("unknown".to_string()),
5099 LanguageSkipMode::Cycles => Some("unknown".to_string()),
5100 LanguageSkipMode::UnusedExports => None,
5101 };
5102 };
5103
5104 let skipped = match mode {
5105 LanguageSkipMode::Duplicates => !duplicates_supports_language(language),
5106 LanguageSkipMode::Cycles => !is_js_ts_language(language),
5107 LanguageSkipMode::UnusedExports => !is_js_ts_language(language),
5108 };
5109 skipped.then(|| language_name(language).to_string())
5110}
5111
5112fn duplicates_supports_language(language: crate::parser::LangId) -> bool {
5113 !matches!(
5114 language,
5115 crate::parser::LangId::Bash
5116 | crate::parser::LangId::Html
5117 | crate::parser::LangId::Json
5118 | crate::parser::LangId::Scala
5119 | crate::parser::LangId::Solidity
5120 | crate::parser::LangId::Scss
5121 | crate::parser::LangId::Vue
5122 | crate::parser::LangId::Markdown
5123 | crate::parser::LangId::Java
5124 | crate::parser::LangId::Ruby
5125 | crate::parser::LangId::Kotlin
5126 | crate::parser::LangId::Swift
5127 | crate::parser::LangId::Php
5128 | crate::parser::LangId::Lua
5129 | crate::parser::LangId::Perl
5130 | crate::parser::LangId::Pascal
5131 | crate::parser::LangId::R
5132 | crate::parser::LangId::Groovy
5133 | crate::parser::LangId::ObjC
5134 | crate::parser::LangId::Toml
5135 )
5136}
5137
5138fn is_js_ts_language(language: crate::parser::LangId) -> bool {
5139 matches!(
5140 language,
5141 crate::parser::LangId::TypeScript
5142 | crate::parser::LangId::Tsx
5143 | crate::parser::LangId::JavaScript
5144 )
5145}
5146
5147fn language_name(language: crate::parser::LangId) -> &'static str {
5148 match language {
5149 crate::parser::LangId::TypeScript => "typescript",
5150 crate::parser::LangId::Tsx => "tsx",
5151 crate::parser::LangId::JavaScript => "javascript",
5152 crate::parser::LangId::Python => "python",
5153 crate::parser::LangId::Rust => "rust",
5154 crate::parser::LangId::Go => "go",
5155 crate::parser::LangId::C => "c",
5156 crate::parser::LangId::Cpp => "cpp",
5157 crate::parser::LangId::Cuda => "cuda",
5158 crate::parser::LangId::Metal => "metal",
5159 crate::parser::LangId::Zig => "zig",
5160 crate::parser::LangId::CSharp => "csharp",
5161 crate::parser::LangId::Bash => "bash",
5162 crate::parser::LangId::Html => "html",
5163 crate::parser::LangId::Markdown => "markdown",
5164 crate::parser::LangId::Yaml => "yaml",
5165 crate::parser::LangId::Solidity => "solidity",
5166 crate::parser::LangId::Scss => "scss",
5167 crate::parser::LangId::Vue => "vue",
5168 crate::parser::LangId::Json => "json",
5169 crate::parser::LangId::Scala => "scala",
5170 crate::parser::LangId::Java => "java",
5171 crate::parser::LangId::Ruby => "ruby",
5172 crate::parser::LangId::Kotlin => "kotlin",
5173 crate::parser::LangId::Swift => "swift",
5174 crate::parser::LangId::Php => "php",
5175 crate::parser::LangId::Lua => "lua",
5176 crate::parser::LangId::Perl => "perl",
5177 crate::parser::LangId::Pascal => "pascal",
5178 crate::parser::LangId::R => "r",
5179 crate::parser::LangId::Groovy => "groovy",
5180 crate::parser::LangId::ObjC => "objc",
5181 crate::parser::LangId::Toml => "toml",
5182 }
5183}
5184
5185fn unused_public_api_entries(project_root: &Path) -> (BTreeSet<String>, Vec<String>) {
5186 let entry_points = crate::inspect::entry_points::resolve_entry_points(project_root);
5187 (
5188 entry_points.public_api_files_relative(project_root),
5189 entry_points.warnings().to_vec(),
5190 )
5191}
5192
5193fn filter_outcome_for_scope_with_contributions(
5194 outcome: JobOutcome,
5195 snapshot: &InspectSnapshot,
5196 category: InspectCategory,
5197 cache: &(impl InspectCacheRead + ?Sized),
5198 scope: &JobScope,
5199) -> JobOutcome {
5200 if !category.is_tier2() || scope.is_project_wide() {
5201 return filter_outcome_for_scope(outcome, scope);
5202 }
5203
5204 match outcome {
5205 JobOutcome::Fresh { payload } => {
5206 match scoped_tier2_payload_from_contributions(snapshot, category, cache, payload, scope)
5207 {
5208 Ok(payload) => JobOutcome::Fresh { payload },
5209 Err(message) => JobOutcome::Failed { message },
5210 }
5211 }
5212 JobOutcome::Stale { cached, in_flight } => match cached {
5213 Some(payload) => {
5214 match scoped_tier2_payload_from_contributions(
5215 snapshot, category, cache, payload, scope,
5216 ) {
5217 Ok(payload) => JobOutcome::Stale {
5218 cached: Some(payload),
5219 in_flight,
5220 },
5221 Err(message) => JobOutcome::Failed { message },
5222 }
5223 }
5224 None => JobOutcome::Stale {
5225 cached: None,
5226 in_flight,
5227 },
5228 },
5229 JobOutcome::Pending { in_flight, wait } => JobOutcome::Pending { in_flight, wait },
5230 JobOutcome::Failed { message } => JobOutcome::Failed { message },
5231 }
5232}
5233
5234fn filter_outcome_for_scope(outcome: JobOutcome, scope: &JobScope) -> JobOutcome {
5235 match outcome {
5236 JobOutcome::Fresh { payload } => JobOutcome::Fresh {
5237 payload: filter_payload_for_scope(payload, scope),
5238 },
5239 JobOutcome::Stale { cached, in_flight } => JobOutcome::Stale {
5240 cached: cached.map(|payload| filter_payload_for_scope(payload, scope)),
5241 in_flight,
5242 },
5243 JobOutcome::Pending { in_flight, wait } => JobOutcome::Pending { in_flight, wait },
5244 JobOutcome::Failed { message } => JobOutcome::Failed { message },
5245 }
5246}
5247
5248fn filter_payload_for_scope(mut payload: serde_json::Value, scope: &JobScope) -> serde_json::Value {
5249 if scope.is_project_wide() {
5250 return payload;
5251 }
5252
5253 if let Some(items) = payload
5257 .get_mut("items")
5258 .and_then(|value| value.as_array_mut())
5259 {
5260 let count = filter_values_for_scope(items, scope);
5261 let largest_cycle = items
5262 .iter()
5263 .filter_map(|item| item.get("files").and_then(Value::as_array).map(Vec::len))
5264 .max();
5265 if let Some(object) = payload.as_object_mut() {
5266 object.insert("count".to_string(), serde_json::json!(count));
5267 if object.contains_key("largest") {
5268 object.insert(
5269 "largest".to_string(),
5270 serde_json::json!(largest_cycle.unwrap_or(0)),
5271 );
5272 }
5273 if object.contains_key("total_groups") {
5274 object.insert("total_groups".to_string(), serde_json::json!(count));
5275 }
5276 if object.contains_key("groups_count") {
5277 object.insert("groups_count".to_string(), serde_json::json!(count));
5278 }
5279 }
5280 }
5281
5282 if let Some(groups) = payload
5283 .get_mut("groups")
5284 .and_then(|value| value.as_array_mut())
5285 {
5286 let count = filter_values_for_scope(groups, scope);
5287 if let Some(object) = payload.as_object_mut() {
5288 object.insert("count".to_string(), serde_json::json!(count));
5289 object.insert("total_groups".to_string(), serde_json::json!(count));
5290 if object.contains_key("groups_count") {
5291 object.insert("groups_count".to_string(), serde_json::json!(count));
5292 }
5293 }
5294 }
5295
5296 if let Some(object) = payload.as_object_mut() {
5302 if object.contains_key("top") {
5303 if let Some(top) = recompute_scoped_top_preview(object) {
5304 object.insert("top".to_string(), top);
5305 } else if let Some(top) = object.get_mut("top").and_then(Value::as_array_mut) {
5306 filter_values_for_scope(top, scope);
5307 }
5308 }
5309 if object.contains_key("duplicated_lines") {
5310 recompute_duplicate_payload_stats(object);
5311 }
5312 object.remove("by_language");
5313 }
5314
5315 payload
5316}
5317
5318fn recompute_duplicate_payload_stats(object: &mut serde_json::Map<String, Value>) {
5319 let values = object
5320 .get("items")
5321 .or_else(|| object.get("groups"))
5322 .and_then(Value::as_array)
5323 .cloned()
5324 .unwrap_or_default();
5325 let (duplicated_lines, duplicated_file_count) = duplicate_line_stats_from_values(&values);
5326 let total_analyzed_lines = object
5327 .get("total_analyzed_lines")
5328 .and_then(Value::as_u64)
5329 .unwrap_or(0);
5330 let duplicated_percent = if total_analyzed_lines == 0 {
5331 0.0
5332 } else {
5333 (duplicated_lines as f64 * 100.0) / total_analyzed_lines as f64
5334 };
5335 object.insert("duplicated_lines".to_string(), json!(duplicated_lines));
5336 object.insert(
5337 "duplicated_file_count".to_string(),
5338 json!(duplicated_file_count),
5339 );
5340 object.insert("duplicated_percent".to_string(), json!(duplicated_percent));
5341}
5342
5343fn duplicate_line_stats_from_values(values: &[Value]) -> (u64, usize) {
5344 let mut by_file = BTreeMap::<String, Vec<(u64, u64)>>::new();
5345 for value in values {
5346 let Some(files) = value.get("files").and_then(Value::as_array) else {
5347 continue;
5348 };
5349 for occurrence in files.iter().filter_map(Value::as_str) {
5350 let Some((file, start, end)) = parse_duplicate_occurrence(occurrence) else {
5351 continue;
5352 };
5353 by_file
5354 .entry(file.to_string())
5355 .or_default()
5356 .push((start, end));
5357 }
5358 }
5359 let file_count = by_file.len();
5360 let duplicated_lines = by_file
5361 .values_mut()
5362 .map(|intervals| merged_duplicate_interval_lines(intervals))
5363 .sum();
5364 (duplicated_lines, file_count)
5365}
5366
5367fn merged_duplicate_interval_lines(intervals: &mut [(u64, u64)]) -> u64 {
5368 if intervals.is_empty() {
5369 return 0;
5370 }
5371 intervals.sort_by(|left, right| left.0.cmp(&right.0).then(left.1.cmp(&right.1)));
5372 let (mut current_start, mut current_end) = intervals[0];
5373 let mut total = 0;
5374 for &(start, end) in &intervals[1..] {
5375 if start <= current_end.saturating_add(1) {
5376 current_end = current_end.max(end);
5377 } else {
5378 total += current_end.saturating_sub(current_start).saturating_add(1);
5379 current_start = start;
5380 current_end = end;
5381 }
5382 }
5383 total + current_end.saturating_sub(current_start).saturating_add(1)
5384}
5385
5386fn recompute_scoped_top_preview(
5387 object: &serde_json::Map<String, Value>,
5388) -> Option<serde_json::Value> {
5389 let values = object
5390 .get("items")
5391 .or_else(|| object.get("groups"))
5392 .and_then(Value::as_array)?;
5393 Some(Value::Array(
5394 values
5395 .iter()
5396 .take(super::entry_points::TOP_PREVIEW_ITEMS)
5397 .map(top_preview_value)
5398 .collect(),
5399 ))
5400}
5401
5402fn top_preview_value(value: &Value) -> Value {
5403 if let Some(files) = value.get("files").and_then(Value::as_array) {
5404 let mut object = serde_json::Map::new();
5405 object.insert("files".to_string(), Value::Array(files.clone()));
5406 if let Some(cost) = value.get("cost").cloned() {
5407 object.insert("cost".to_string(), cost);
5408 }
5409 return Value::Object(object);
5410 }
5411
5412 json!({
5413 "file": value.get("file").and_then(Value::as_str).unwrap_or(""),
5414 "symbol": value.get("symbol").and_then(Value::as_str).unwrap_or(""),
5415 })
5416}
5417
5418fn filter_values_for_scope(values: &mut Vec<serde_json::Value>, scope: &JobScope) -> usize {
5419 values.retain_mut(|value| prune_value_for_scope(value, scope));
5420 values.len()
5421}
5422
5423fn prune_value_for_scope(value: &mut serde_json::Value, scope: &JobScope) -> bool {
5424 if let Some(file) = value.get("file").and_then(|file| file.as_str()) {
5425 return scope.contains_display_path(file);
5426 }
5427
5428 let first_scoped_occurrence = if let Some(files) = value
5429 .get_mut("files")
5430 .and_then(|files| files.as_array_mut())
5431 {
5432 files.retain(|file| {
5433 file.as_str()
5434 .is_some_and(|file| scope.contains_display_path(display_file_from_occurrence(file)))
5435 });
5436 if files.len() < 2 {
5437 return false;
5438 }
5439 files.first().and_then(Value::as_str).map(str::to_string)
5440 } else {
5441 None
5442 };
5443
5444 if let Some(occurrence) = first_scoped_occurrence {
5445 update_duplicate_group_sample(value, &occurrence);
5446 }
5447
5448 true
5449}
5450
5451fn update_duplicate_group_sample(value: &mut serde_json::Value, occurrence: &str) {
5452 let Some((file, start_line, end_line)) = parse_duplicate_occurrence(occurrence) else {
5453 return;
5454 };
5455 let Some(object) = value.as_object_mut() else {
5456 return;
5457 };
5458
5459 if object.contains_key("sample_file") {
5460 object.insert("sample_file".to_string(), json!(file));
5461 }
5462 if object.contains_key("sample_start_line") {
5463 object.insert("sample_start_line".to_string(), json!(start_line));
5464 }
5465 if object.contains_key("sample_end_line") {
5466 object.insert("sample_end_line".to_string(), json!(end_line));
5467 }
5468}
5469
5470fn parse_duplicate_occurrence(value: &str) -> Option<(&str, u64, u64)> {
5471 let (file, range) = value.rsplit_once(':')?;
5472 let (start, end) = range.split_once('-')?;
5473 if !start.chars().all(|char| char.is_ascii_digit())
5474 || !end.chars().all(|char| char.is_ascii_digit())
5475 {
5476 return None;
5477 }
5478
5479 Some((file, start.parse().ok()?, end.parse().ok()?))
5480}
5481
5482fn display_file_from_occurrence(value: &str) -> &str {
5483 let Some((file, range)) = value.rsplit_once(':') else {
5484 return value;
5485 };
5486 let Some((start, end)) = range.split_once('-') else {
5487 return value;
5488 };
5489 if start.chars().all(|char| char.is_ascii_digit())
5490 && end.chars().all(|char| char.is_ascii_digit())
5491 {
5492 file
5493 } else {
5494 value
5495 }
5496}
5497
5498#[cfg(test)]
5499#[cfg_attr(not(debug_assertions), allow(dead_code))]
5502mod guard_tests {
5503 use super::*;
5504
5505 fn write_ts_project(file_count: usize) -> tempfile::TempDir {
5506 let dir = tempfile::tempdir().expect("tempdir");
5507 let root = dir.path();
5508 for i in 0..file_count {
5509 std::fs::write(
5510 root.join(format!("mod{i}.ts")),
5511 format!("export function f{i}() {{ return {i}; }}\n"),
5512 )
5513 .expect("write fixture");
5514 }
5515 let canonical_root = std::fs::canonicalize(root).expect("canonical fixture root");
5516 let project_key = crate::search_index::artifact_cache_key(&canonical_root);
5517 crate::root_cache::configure_artifact_access(&canonical_root, &project_key, false);
5518 dir
5519 }
5520
5521 fn tier1_snapshot(root: &Path) -> InspectSnapshot {
5522 use crate::config::Config;
5523 use crate::parser::SymbolCache;
5524 use std::sync::RwLock;
5525
5526 InspectSnapshot::new(
5527 root.to_path_buf(),
5528 root.join(".aft-cache/inspect"),
5529 Arc::new(Config {
5530 project_root: Some(root.to_path_buf()),
5531 ..Config::default()
5532 }),
5533 Arc::new(RwLock::new(SymbolCache::new())),
5534 )
5535 }
5536
5537 #[test]
5538 fn tier1_worker_panic_delivers_failed_to_waiter() {
5539 let dir = write_ts_project(2);
5540 let root = std::fs::canonicalize(dir.path()).expect("canonical fixture root");
5541 let snapshot = tier1_snapshot(&root);
5542 let manager = InspectManager::with_worker(
5546 Arc::new(|_| panic!("forced Tier-1 worker panic")),
5547 Duration::from_secs(10),
5548 );
5549
5550 let outcome = manager.submit_category(
5551 snapshot,
5552 InspectCategory::Metrics,
5553 JobScope::for_project(root),
5554 );
5555
5556 match outcome {
5557 JobOutcome::Failed { message } => assert!(
5558 message.contains(
5559 "inspect worker panicked before completion: forced Tier-1 worker panic"
5560 ),
5561 "unexpected panic terminal: {message}"
5562 ),
5563 other => panic!("worker panic must deliver Failed, got {other:?}"),
5564 }
5565 assert!(
5566 manager
5567 .in_flight
5568 .lock()
5569 .unwrap_or_else(std::sync::PoisonError::into_inner)
5570 .is_empty(),
5571 "panic completion must clear its waiter registration"
5572 );
5573 }
5574
5575 #[test]
5576 fn ready_worker_result_wins_over_simultaneously_ready_deadline() {
5577 let dir = write_ts_project(2);
5578 let root = std::fs::canonicalize(dir.path()).expect("canonical fixture root");
5579 let snapshot = tier1_snapshot(&root);
5580 let manager = InspectManager::with_worker(
5581 Arc::new(|job| {
5582 InspectResult::success(
5583 &job,
5584 InspectScanSuccess {
5585 scanned_files: job.scope_files.clone(),
5586 contributions: Vec::new(),
5587 aggregate: json!({"count": 2}),
5588 },
5589 Duration::ZERO,
5590 )
5591 }),
5592 Duration::from_secs(1),
5593 );
5594 let scope = JobScope::for_project(root);
5595 let key = JobKey::for_category_scope(InspectCategory::Metrics, &scope);
5596 let cache = manager
5597 .cache_for_snapshot(&snapshot)
5598 .expect("open inspect cache");
5599 let (waiter_tx, waiter_rx) = bounded(1);
5600 manager
5601 .enqueue_with_waiter(
5602 snapshot.clone(),
5603 InspectCategory::Metrics,
5604 scope.clone(),
5605 key.clone(),
5606 waiter_tx,
5607 None,
5608 )
5609 .expect("enqueue metrics scan");
5610
5611 let result_deadline = Instant::now() + Duration::from_secs(5);
5612 while manager.result_rx.is_empty() {
5613 assert!(
5614 Instant::now() < result_deadline,
5615 "worker result did not become ready"
5616 );
5617 std::thread::sleep(Duration::from_millis(1));
5618 }
5619 let wait_started = Instant::now();
5620 let outcome = manager.wait_for_outcome(
5621 key,
5622 scope,
5623 cache,
5624 waiter_rx,
5625 snapshot,
5626 wait_started,
5627 wait_started,
5628 Duration::ZERO,
5629 );
5630
5631 assert!(
5632 matches!(outcome, JobOutcome::Fresh { .. }),
5633 "an already-ready terminal result must beat the deadline: {outcome:?}"
5634 );
5635 }
5636
5637 struct ProjectionObserverReset;
5638
5639 impl Drop for ProjectionObserverReset {
5640 fn drop(&mut self) {
5641 crate::callgraph_store::set_projection_before_open_observer(None);
5642 }
5643 }
5644
5645 fn count_projections() -> (Arc<std::sync::atomic::AtomicUsize>, ProjectionObserverReset) {
5646 let count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5647 let observed = Arc::clone(&count);
5648 crate::callgraph_store::set_projection_before_open_observer(Some(Arc::new(move |_| {
5649 observed.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
5650 })));
5651 (count, ProjectionObserverReset)
5652 }
5653
5654 fn write_projection_cache_file(path: &Path, contents: &str) {
5655 std::fs::create_dir_all(path.parent().expect("fixture file parent"))
5656 .expect("create fixture parent");
5657 std::fs::write(path, contents).expect("write fixture file");
5658 }
5659
5660 include!("view_projection_tests.rs");
5661
5662 fn published_projection_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, InspectJob) {
5663 let dir = tempfile::tempdir().expect("tempdir");
5664 let root = std::fs::canonicalize(dir.path()).expect("canonical fixture root");
5665 write_projection_cache_file(
5666 &root.join("src/main.ts"),
5667 "import { firstTarget } from './target';\nexport function main() { firstTarget(); }\n",
5668 );
5669 write_projection_cache_file(
5670 &root.join("src/target.ts"),
5671 "export function firstTarget() {}\n",
5672 );
5673 let inspect_dir = root.join(".aft-cache").join("inspect");
5674 let project_key = crate::search_index::artifact_cache_key(&root);
5675 crate::root_cache::configure_artifact_access(&root, &project_key, false);
5676 let callgraph_dir =
5677 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("callgraph dir");
5678 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
5679 let (store, _) = CallGraphStore::cold_build_with_lease(callgraph_dir, root.clone(), &files)
5680 .expect("publish initial generation");
5681 drop(store);
5682 let mut job = snapshot_job(&root, &inspect_dir, true);
5683 job.callgraph_writer = false;
5684 (dir, root, inspect_dir, job)
5685 }
5686
5687 #[test]
5688 fn scoped_filter_recomputes_top_preview_from_scoped_items() {
5689 let project_root = PathBuf::from("/project");
5690 let scope = JobScope::from_roots(project_root.clone(), vec![project_root.join("src/in")]);
5691 let payload = json!({
5692 "count": 4,
5693 "items": [
5694 { "file": "src/out/a.ts", "symbol": "outside" },
5695 { "file": "src/in/b.ts", "symbol": "inside_b" },
5696 { "file": "src/in/c.ts", "symbol": "inside_c" }
5697 ],
5698 "top": [
5699 { "file": "src/out/a.ts", "symbol": "outside" },
5700 { "file": "src/out/z.ts", "symbol": "outside_z" }
5701 ],
5702 "by_language": { "typescript": 4 }
5703 });
5704
5705 let filtered = filter_payload_for_scope(payload, &scope);
5706
5707 assert_eq!(filtered["count"], json!(2));
5708 assert_eq!(
5709 filtered["top"],
5710 json!([
5711 { "file": "src/in/b.ts", "symbol": "inside_b" },
5712 { "file": "src/in/c.ts", "symbol": "inside_c" }
5713 ])
5714 );
5715 assert!(filtered["top"]
5716 .as_array()
5717 .unwrap()
5718 .iter()
5719 .all(|item| item["file"]
5720 .as_str()
5721 .is_some_and(|file| file.starts_with("src/in/"))));
5722 }
5723
5724 fn artifact_cache_key_for_test(project_root: &std::path::Path) -> String {
5725 let _git_env = crate::test_env::hermetic_git_env_guard();
5726 crate::search_index::artifact_cache_key(project_root)
5727 }
5728
5729 #[test]
5730 fn cache_for_paths_rebinds_same_project_key_to_current_root() {
5731 let _git_env = crate::test_env::hermetic_git_env_guard();
5732 let dir = tempfile::tempdir().expect("tempdir");
5733 let source = dir.path().join("source");
5734 std::fs::create_dir_all(&source).expect("create source repo");
5735 std::fs::write(
5736 source.join("package.json"),
5737 r#"{"name":"inspect-cache-fixture","version":"1.0.0"}"#,
5738 )
5739 .expect("write source manifest");
5740 std::fs::write(source.join("index.ts"), "export const source = 1;\n")
5741 .expect("write source file");
5742 let mut init = std::process::Command::new("git");
5743 assert!(
5744 crate::test_env::apply_hermetic_git_env(init.current_dir(&source))
5745 .arg("init")
5746 .status()
5747 .expect("git init source repo")
5748 .success()
5749 );
5750 let mut add = std::process::Command::new("git");
5751 assert!(
5752 crate::test_env::apply_hermetic_git_env(add.current_dir(&source))
5753 .args(["add", "."])
5754 .status()
5755 .expect("git add source repo")
5756 .success()
5757 );
5758 let mut commit = std::process::Command::new("git");
5759 assert!(
5760 crate::test_env::apply_hermetic_git_env(commit.current_dir(&source))
5761 .args([
5762 "-c",
5763 "user.name=AFT Tests",
5764 "-c",
5765 "user.email=aft-tests@example.com",
5766 "commit",
5767 "-m",
5768 "initial",
5769 ])
5770 .status()
5771 .expect("git commit source repo")
5772 .success()
5773 );
5774
5775 let clone = dir.path().join("clone");
5776 let mut clone_command = std::process::Command::new("git");
5777 assert!(crate::test_env::apply_hermetic_git_env(&mut clone_command)
5778 .args(["clone", "--quiet"])
5779 .arg(&source)
5780 .arg(&clone)
5781 .status()
5782 .expect("git clone source repo")
5783 .success());
5784 std::fs::write(
5785 clone.join("package.json"),
5786 r#"{"name":"inspect-cache-fixture","version":"2.0.0"}"#,
5787 )
5788 .expect("write clone manifest edit");
5789 assert_eq!(
5790 artifact_cache_key_for_test(&source),
5791 artifact_cache_key_for_test(&clone),
5792 "clones with the same root commit should share the sqlite project key"
5793 );
5794
5795 let source = std::fs::canonicalize(source).expect("canonical source root");
5796 let clone = std::fs::canonicalize(clone).expect("canonical clone root");
5797 let manager = InspectManager::new();
5798 let inspect_dir = dir.path().join("inspect");
5799 let key = JobKey::for_project_category(InspectCategory::DeadCode);
5800 let source_cache = manager
5801 .cache_for_paths(inspect_dir.clone(), source.clone())
5802 .expect("open source cache");
5803 let source_hash = source_cache
5804 .contribution_set_hash(InspectCategory::DeadCode)
5805 .expect("source contribution hash");
5806 source_cache
5807 .store_tier2_aggregate(
5808 key.clone(),
5809 &source_hash,
5810 serde_json::json!({ "count": 7, "items": [] }),
5811 )
5812 .expect("store source aggregate");
5813 assert_eq!(
5814 source_cache
5815 .get_aggregated(&key)
5816 .expect("read source aggregate")
5817 .and_then(|payload| payload.get("count").and_then(Value::as_u64)),
5818 Some(7)
5819 );
5820
5821 let clone_cache = manager
5822 .cache_for_paths(inspect_dir, clone.clone())
5823 .expect("open clone cache");
5824 assert_eq!(clone_cache.project_root(), clone.as_path());
5825 assert!(
5826 clone_cache
5827 .get_aggregated(&key)
5828 .expect("read clone aggregate")
5829 .is_none(),
5830 "same-key clone with a different manifest must not reuse the source root's cached count"
5831 );
5832 }
5833
5834 #[test]
5835 fn dead_code_blocked_on_callgraph_reads_latest_aggregate_flag() {
5836 let dir = tempfile::tempdir().unwrap();
5841 let project_root = std::fs::canonicalize(dir.path()).unwrap();
5842 std::fs::write(project_root.join("lib.rs"), "pub fn marker() {}\n").unwrap();
5843 let manager = InspectManager::new();
5844 let inspect_dir = dir.path().join("inspect");
5845
5846 assert!(!manager.dead_code_blocked_on_callgraph(inspect_dir.clone(), project_root.clone()));
5848
5849 let cache = manager
5850 .cache_for_paths(inspect_dir.clone(), project_root.clone())
5851 .expect("open cache");
5852 let key = JobKey::for_project_category(InspectCategory::DeadCode);
5853 let hash = cache
5854 .contribution_set_hash(InspectCategory::DeadCode)
5855 .expect("contribution hash");
5856
5857 cache
5859 .store_tier2_aggregate(
5860 key.clone(),
5861 &hash,
5862 serde_json::json!({ "count": 3, "callgraph_available": true }),
5863 )
5864 .expect("store callgraph-backed aggregate");
5865 assert!(!manager.dead_code_blocked_on_callgraph(inspect_dir.clone(), project_root.clone()));
5866 assert_eq!(
5867 manager
5868 .latest_tier2_counts(inspect_dir.clone(), project_root.clone())
5869 .0,
5870 Some(3)
5871 );
5872
5873 cache
5876 .store_tier2_aggregate(
5877 key,
5878 &hash,
5879 crate::inspect::scanners::dead_code::callgraph_unavailable_aggregate(1),
5880 )
5881 .expect("store callgraph_unavailable aggregate");
5882 assert!(manager.dead_code_blocked_on_callgraph(inspect_dir.clone(), project_root.clone()));
5883 assert_eq!(
5884 manager.latest_tier2_counts(inspect_dir, project_root).0,
5885 None,
5886 "callgraph_unavailable dead_code must stay suppressed"
5887 );
5888 }
5889
5890 fn snapshot_job(root: &Path, inspect_dir: &Path, callgraph_store: bool) -> InspectJob {
5891 use crate::config::Config;
5892 use crate::parser::SymbolCache;
5893 use std::sync::RwLock;
5894
5895 InspectJob {
5896 job_id: 1,
5897 key: JobKey::for_project_category(InspectCategory::DeadCode),
5898 category: InspectCategory::DeadCode,
5899 scope_files: Vec::new(),
5900 project_root: root.to_path_buf(),
5901 inspect_dir: inspect_dir.to_path_buf(),
5902 config: Arc::new(Config {
5903 project_root: Some(root.to_path_buf()),
5904 callgraph_store,
5905 ..Config::default()
5906 }),
5907 symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
5908 inspect_writer: true,
5909 callgraph_writer: true,
5910 callgraph_snapshot: None,
5911 }
5912 }
5913
5914 #[test]
5915 fn blocking_inspect_overtakes_queued_maintenance_after_active_seed_releases() {
5916 use crate::config::Config;
5917 use crate::parser::SymbolCache;
5918 use std::sync::RwLock;
5919
5920 let dir = tempfile::tempdir().expect("tempdir");
5921 let root = std::fs::canonicalize(dir.path()).expect("canonical fixture root");
5922 std::fs::create_dir_all(root.join("src")).expect("create source directory");
5923 std::fs::write(
5924 root.join("src/main.ts"),
5925 "export function plantedDead() { return 1; }\n",
5926 )
5927 .expect("write source fixture");
5928 let project_key = crate::search_index::artifact_cache_key(&root);
5929 crate::root_cache::configure_artifact_access(&root, &project_key, false);
5930 let inspect_dir = root.join(".aft-cache").join("inspect");
5931 let snapshot = InspectSnapshot::new_with_capabilities(
5932 root.clone(),
5933 inspect_dir,
5934 Arc::new(Config {
5935 project_root: Some(root.clone()),
5936 callgraph_store: true,
5937 ..Config::default()
5938 }),
5939 Arc::new(RwLock::new(SymbolCache::new())),
5940 true,
5941 true,
5942 );
5943
5944 let limiter = cold_build_limiter::test_limiter(1);
5945 let active_request = cold_build_limiter::ColdBuildAdmissionRequest::new(
5946 "active-semantic-seed",
5947 cold_build_limiter::ColdBuildAdmissionClass::Maintenance,
5948 );
5949 let active =
5950 cold_build_limiter::try_acquire_classified_with_limiter(&limiter, &active_request)
5951 .expect("active semantic seed holds the only slot");
5952 let semantic_seed_active = Arc::new(AtomicBool::new(true));
5953 let manager = Arc::new(InspectManager::with_root_work_gates(
5954 Arc::new(AtomicBool::new(true)),
5955 Arc::clone(&semantic_seed_active),
5956 ));
5957 manager.set_cold_build_limiter(Arc::clone(&limiter));
5958
5959 let inspect_manager = Arc::clone(&manager);
5960 let scope = JobScope::for_project(root);
5961 let (outcome_tx, outcome_rx) = std::sync::mpsc::channel();
5962 std::thread::spawn(move || {
5963 let outcome = inspect_manager.tier2_run_with_reuse_blocking_fresh(
5964 snapshot,
5965 InspectCategory::DeadCode,
5966 scope,
5967 );
5968 outcome_tx.send(outcome).expect("send inspect outcome");
5969 });
5970
5971 let deadline = Instant::now() + Duration::from_secs(3);
5972 while manager.tier2_builder_state(InspectCategory::DeadCode)
5973 != InspectBuilderState::GatedBySemanticSeed
5974 {
5975 assert!(
5976 Instant::now() < deadline,
5977 "inspect must queue while the active seed owns the slot"
5978 );
5979 std::thread::yield_now();
5980 }
5981 assert!(
5982 outcome_rx.try_recv().is_err(),
5983 "in-flight work is not preempted"
5984 );
5985
5986 let maintenance_limiter = Arc::clone(&limiter);
5987 let maintenance = std::thread::spawn(move || {
5988 cold_build_limiter::acquire_blocking_while_with_limiter(
5989 &maintenance_limiter,
5990 "queued background refresh",
5991 || true,
5992 )
5993 .expect("background refresh eventually resumes")
5994 });
5995 std::thread::sleep(Duration::from_millis(150));
5996 semantic_seed_active.store(false, Ordering::SeqCst);
5997 drop(active);
5998
5999 let outcome = outcome_rx
6000 .recv_timeout(Duration::from_secs(10))
6001 .expect("blocking inspect completes after the active seed releases");
6002 let payload = outcome.payload().expect("blocking inspect is fresh");
6003 assert_eq!(
6004 payload.get("callgraph_available").and_then(Value::as_bool),
6005 Some(true)
6006 );
6007 drop(maintenance.join().expect("background waiter joins"));
6008
6009 let events = limiter.admission_events();
6010 assert_eq!(
6011 events[0].class,
6012 cold_build_limiter::ColdBuildAdmissionClass::Maintenance
6013 );
6014 assert_eq!(
6015 events[1].class,
6016 cold_build_limiter::ColdBuildAdmissionClass::InspectTriggered,
6017 "explicit inspect takes the first released slot"
6018 );
6019 assert_eq!(
6020 events[2].class,
6021 cold_build_limiter::ColdBuildAdmissionClass::Maintenance
6022 );
6023 }
6024
6025 #[test]
6026 fn post_eviction_rebind_serves_unchanged_tier2_aggregate_without_cold_slot() {
6027 use crate::config::Config;
6028 use crate::parser::SymbolCache;
6029 use std::sync::RwLock;
6030
6031 let dir = tempfile::tempdir().expect("tempdir");
6032 let root = std::fs::canonicalize(dir.path()).expect("canonical fixture root");
6033 std::fs::create_dir_all(root.join("src")).expect("create source directory");
6034 std::fs::write(
6035 root.join("src/main.ts"),
6036 "export function plantedDead() { return 1; }\n",
6037 )
6038 .expect("write source fixture");
6039 let project_key = crate::search_index::artifact_cache_key(&root);
6040 crate::root_cache::configure_artifact_access(&root, &project_key, false);
6041 let inspect_dir = root.join(".aft-cache").join("inspect");
6042 let snapshot = InspectSnapshot::new_with_capabilities(
6043 root.clone(),
6044 inspect_dir,
6045 Arc::new(Config {
6046 project_root: Some(root.clone()),
6047 callgraph_store: true,
6048 ..Config::default()
6049 }),
6050 Arc::new(RwLock::new(SymbolCache::new())),
6051 true,
6052 true,
6053 );
6054 let limiter = cold_build_limiter::test_limiter(1);
6055 let manager = Arc::new(InspectManager::new());
6056 manager.set_cold_build_limiter(Arc::clone(&limiter));
6057 let first = manager.tier2_run_with_reuse_blocking_fresh(
6058 snapshot.clone(),
6059 InspectCategory::DeadCode,
6060 JobScope::for_project(root.clone()),
6061 );
6062 assert!(
6063 first.payload().is_some(),
6064 "initial scan persists a fresh aggregate"
6065 );
6066 assert!(!manager.tier2_any_in_flight());
6067 manager.evict_idle_caches();
6068
6069 let maintenance_request = cold_build_limiter::ColdBuildAdmissionRequest::new(
6070 "post-eviction-search-verify",
6071 cold_build_limiter::ColdBuildAdmissionClass::Maintenance,
6072 );
6073 let maintenance =
6074 cold_build_limiter::try_acquire_classified_with_limiter(&limiter, &maintenance_request)
6075 .expect("background verification owns the only cold slot");
6076 let events_before = limiter.admission_events().len();
6077 let rebound_manager = Arc::clone(&manager);
6078 let (outcome_tx, outcome_rx) = std::sync::mpsc::channel();
6079 std::thread::spawn(move || {
6080 let outcome = rebound_manager.tier2_run_with_reuse_blocking_fresh(
6081 snapshot,
6082 InspectCategory::DeadCode,
6083 JobScope::for_project(root),
6084 );
6085 outcome_tx.send(outcome).expect("send rebound outcome");
6086 });
6087
6088 let rebound = outcome_rx
6089 .recv_timeout(Duration::from_secs(3))
6090 .expect("unchanged persisted aggregate bypasses the occupied cold-build queue");
6091 assert!(rebound.payload().is_some());
6092 assert_eq!(
6093 limiter.admission_events().len(),
6094 events_before,
6095 "quick reuse must not request an interactive cold-build permit"
6096 );
6097 drop(maintenance);
6098 assert_eq!(
6099 manager.tier2_builder_state(InspectCategory::DeadCode),
6100 InspectBuilderState::Absent,
6101 "quick reuse must clear the builder registry on the way out"
6102 );
6103 assert!(!manager.tier2_any_in_flight());
6104 }
6105
6106 #[test]
6107 fn background_tier2_reuse_panic_clears_builder_registration() {
6108 use crate::config::Config;
6109 use crate::parser::SymbolCache;
6110 use std::sync::RwLock;
6111
6112 let dir = tempfile::tempdir().expect("tempdir");
6113 let root = std::fs::canonicalize(dir.path()).expect("canonical fixture root");
6114 std::fs::create_dir_all(root.join("src")).expect("create source directory");
6115 std::fs::write(
6116 root.join("src/dup.ts"),
6117 "export function planted() { return 1; }\n",
6118 )
6119 .expect("write source fixture");
6120 let project_key = crate::search_index::artifact_cache_key(&root);
6121 crate::root_cache::configure_artifact_access(&root, &project_key, false);
6122 let inspect_dir = root.join(".aft-cache").join("inspect");
6123 let snapshot = InspectSnapshot::new_with_capabilities(
6124 root.clone(),
6125 inspect_dir,
6126 Arc::new(Config {
6127 project_root: Some(root.clone()),
6128 ..Config::default()
6129 }),
6130 Arc::new(RwLock::new(SymbolCache::new())),
6131 true,
6132 true,
6133 );
6134
6135 let previous_root = std::env::var_os("AFT_TEST_TIER2_REUSE_PANIC_ROOT");
6136 let previous_category = std::env::var_os("AFT_TEST_TIER2_REUSE_PANIC_CATEGORY");
6137 unsafe {
6138 std::env::set_var("AFT_TEST_TIER2_REUSE_PANIC_ROOT", &root);
6139 std::env::set_var("AFT_TEST_TIER2_REUSE_PANIC_CATEGORY", "duplicates");
6140 }
6141 struct RestorePanicEnv {
6142 root: Option<std::ffi::OsString>,
6143 category: Option<std::ffi::OsString>,
6144 }
6145 impl Drop for RestorePanicEnv {
6146 fn drop(&mut self) {
6147 unsafe {
6148 match self.root.take() {
6149 Some(value) => std::env::set_var("AFT_TEST_TIER2_REUSE_PANIC_ROOT", value),
6150 None => std::env::remove_var("AFT_TEST_TIER2_REUSE_PANIC_ROOT"),
6151 }
6152 match self.category.take() {
6153 Some(value) => {
6154 std::env::set_var("AFT_TEST_TIER2_REUSE_PANIC_CATEGORY", value)
6155 }
6156 None => std::env::remove_var("AFT_TEST_TIER2_REUSE_PANIC_CATEGORY"),
6157 }
6158 }
6159 }
6160 }
6161 let _restore = RestorePanicEnv {
6162 root: previous_root,
6163 category: previous_category,
6164 };
6165
6166 let manager = Arc::new(InspectManager::new());
6167 manager
6168 .submit_tier2_run_with_reuse_background(snapshot, InspectCategory::Duplicates)
6169 .expect("queue background duplicates scan");
6170
6171 let deadline = Instant::now() + Duration::from_secs(20);
6172 loop {
6173 if !manager.tier2_any_in_flight()
6174 && manager.tier2_builder_state(InspectCategory::Duplicates)
6175 == InspectBuilderState::Absent
6176 {
6177 break;
6178 }
6179 assert!(
6180 Instant::now() < deadline,
6181 "a reuse worker that panics before the completion router must still clear the builder registry"
6182 );
6183 std::thread::sleep(Duration::from_millis(10));
6184 }
6185 }
6186
6187 fn generated_unused_exports_fixture() -> (tempfile::TempDir, PathBuf, Vec<PathBuf>) {
6188 let dir = tempfile::tempdir().expect("tempdir");
6189 let root = dir.path().to_path_buf();
6190 let files = [
6191 (
6192 "src/hand.ts",
6193 "export function handUnused() {}
6194",
6195 ),
6196 (
6197 "gen/schema_pb.ts",
6198 "export function generatedPathUnused() {}
6199",
6200 ),
6201 (
6202 "src/banner.ts",
6203 "// Code generated by fixture. DO NOT EDIT.
6204export function bannerUnused() {}
6205",
6206 ),
6207 ];
6208 let paths = files
6209 .iter()
6210 .map(|(relative, contents)| {
6211 let path = root.join(relative);
6212 if let Some(parent) = path.parent() {
6213 std::fs::create_dir_all(parent).expect("create parent");
6214 }
6215 std::fs::write(&path, contents).expect("write fixture file");
6216 std::fs::canonicalize(path).expect("canonical fixture path")
6217 })
6218 .collect::<Vec<_>>();
6219 (
6220 dir,
6221 std::fs::canonicalize(root).expect("canonical root"),
6222 paths,
6223 )
6224 }
6225
6226 fn unused_exports_job(root: &Path, scope_files: Vec<PathBuf>) -> InspectJob {
6227 use crate::config::Config;
6228 use crate::parser::SymbolCache;
6229 use std::sync::RwLock;
6230
6231 InspectJob {
6232 job_id: 1,
6233 key: JobKey::for_project_category(InspectCategory::UnusedExports),
6234 category: InspectCategory::UnusedExports,
6235 scope_files,
6236 project_root: root.to_path_buf(),
6237 inspect_dir: root.join(".aft-cache").join("inspect"),
6238 config: Arc::new(Config {
6239 project_root: Some(root.to_path_buf()),
6240 ..Config::default()
6241 }),
6242 symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
6243 inspect_writer: true,
6244 callgraph_writer: true,
6245 callgraph_snapshot: None,
6246 }
6247 }
6248
6249 #[test]
6250 fn unused_exports_oxc_cached_rollup_preserves_generated_split() {
6251 let (_dir, root, paths) = generated_unused_exports_fixture();
6252 let job = unused_exports_job(&root, paths.clone());
6253 let entry_points = crate::inspect::entry_points::resolve_entry_points(&root);
6254 let oxc_result = crate::inspect::oxc_engine::analyze_files(
6255 &root,
6256 &paths,
6257 AnalyzeOptions {
6258 entry_points: Vec::new(),
6259 public_api_files: entry_points.public_api_files(),
6260 executable_root_exports: entry_points.executable_root_exports(),
6261 force_reparse_files: Vec::new(),
6262 entry_reachability: false,
6263 },
6264 )
6265 .expect("oxc analyze succeeds");
6266 let fresh = crate::inspect::scanners::unused_exports::run_unused_exports_scan_with_oxc(
6267 &job,
6268 Some(&oxc_result),
6269 )
6270 .outcome
6271 .expect("fresh scan succeeds");
6272
6273 let rolled_up = roll_up_unused_exports_contributions(
6274 &job,
6275 &fresh.contributions,
6276 Some(MAX_DRILL_DOWN_ITEMS),
6277 );
6278
6279 assert_eq!(
6280 rolled_up, fresh.aggregate,
6281 "cached rollup must match fresh scan"
6282 );
6283 assert_eq!(rolled_up["count"], 1, "{rolled_up:#}");
6284 assert_eq!(rolled_up["generated_count"], 2, "{rolled_up:#}");
6285 assert_eq!(rolled_up["total_count"], 3, "{rolled_up:#}");
6286 }
6287
6288 #[test]
6289 fn unused_exports_cached_generated_state_avoids_reprobe_with_legacy_fallback() {
6290 let (_dir, root, paths) = generated_unused_exports_fixture();
6291 let job = unused_exports_job(&root, paths.clone());
6292 let entry_points = crate::inspect::entry_points::resolve_entry_points(&root);
6293 let oxc_result = crate::inspect::oxc_engine::analyze_files(
6294 &root,
6295 &paths,
6296 AnalyzeOptions {
6297 entry_points: Vec::new(),
6298 public_api_files: entry_points.public_api_files(),
6299 executable_root_exports: entry_points.executable_root_exports(),
6300 force_reparse_files: Vec::new(),
6301 entry_reachability: false,
6302 },
6303 )
6304 .expect("oxc analyze succeeds");
6305 let fresh = crate::inspect::scanners::unused_exports::run_unused_exports_scan_with_oxc(
6306 &job,
6307 Some(&oxc_result),
6308 )
6309 .outcome
6310 .expect("fresh scan succeeds");
6311 let mut contributions = fresh.contributions;
6312 let handwritten = contributions
6313 .iter_mut()
6314 .find(|contribution| contribution.file_path.ends_with("src/hand.ts"))
6315 .expect("handwritten contribution");
6316 handwritten.contribution["generated"] = json!(false);
6317
6318 crate::inspect::generated::reset_file_probe_count_for_debug(&root);
6319 let explicit_cached =
6320 roll_up_unused_exports_contributions(&job, &contributions, Some(MAX_DRILL_DOWN_ITEMS));
6321 assert_eq!(explicit_cached, fresh.aggregate);
6322 assert_eq!(
6323 crate::inspect::generated::file_probe_count_for_debug(&root),
6324 0,
6325 "an explicit cached generated=false must not probe the file again"
6326 );
6327
6328 let generated_banner = contributions
6329 .iter_mut()
6330 .find(|contribution| contribution.file_path.ends_with("src/banner.ts"))
6331 .expect("generated banner contribution");
6332 generated_banner
6333 .contribution
6334 .as_object_mut()
6335 .expect("contribution object")
6336 .remove("generated");
6337 crate::inspect::generated::reset_file_probe_count_for_debug(&root);
6338 let legacy_cached =
6339 roll_up_unused_exports_contributions(&job, &contributions, Some(MAX_DRILL_DOWN_ITEMS));
6340 assert_eq!(legacy_cached, fresh.aggregate);
6341 assert_eq!(
6342 crate::inspect::generated::file_probe_count_for_debug(&root),
6343 1,
6344 "a legacy contribution without generated must probe and recover its classification"
6345 );
6346 }
6347
6348 #[test]
6349 fn inspect_callgraph_open_waits_out_transient_sqlite_writer_lock() {
6350 let dir = write_ts_project(3);
6351 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
6352 let inspect_dir = root.join(".aft-cache").join("inspect");
6353 let callgraph_dir =
6354 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
6355 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
6356 let (store, _) =
6357 CallGraphStore::cold_build_with_lease(callgraph_dir.clone(), root.clone(), &files)
6358 .expect("publish initial generation");
6359 let sqlite_path = store.sqlite_path().to_path_buf();
6360 drop(store);
6361
6362 let blocker = rusqlite::Connection::open(&sqlite_path).expect("open blocking connection");
6363 blocker
6364 .execute_batch(
6365 "PRAGMA journal_mode=DELETE;
6366 BEGIN EXCLUSIVE;
6367 UPDATE meta SET v = v WHERE k = 'ready';",
6368 )
6369 .expect("hold exclusive write transaction");
6370 let (started_tx, started_rx) = std::sync::mpsc::channel();
6371 let open = std::thread::spawn(move || {
6372 started_tx.send(()).expect("signal inspect open start");
6373 open_or_build_blocking_callgraph_store(callgraph_dir, root, true, &files)
6374 });
6375 started_rx.recv().expect("inspect open thread started");
6376 std::thread::sleep(Duration::from_millis(100));
6377 blocker.execute_batch("COMMIT").expect("release write lock");
6378
6379 assert!(
6380 open.join()
6381 .expect("inspect open thread joined")
6382 .expect("transient contention must stay on the Building/retry path")
6383 .is_some(),
6384 "inspect must reopen the ready callgraph instead of failing terminally"
6385 );
6386 }
6387
6388 #[test]
6389 fn callgraph_snapshot_reports_unavailable_when_store_disabled() {
6390 let dir = write_ts_project(3);
6391 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
6392 let inspect_dir = root.join(".aft-cache").join("inspect");
6393
6394 let snapshot =
6395 build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, false), false);
6396
6397 assert!(
6398 snapshot.is_none(),
6399 "dead_code must not rebuild the legacy graph when the store is disabled"
6400 );
6401 }
6402
6403 #[test]
6404 fn callgraph_snapshot_reports_unavailable_when_store_not_ready() {
6405 let dir = write_ts_project(3);
6406 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
6407 let inspect_dir = root.join(".aft-cache").join("inspect");
6408 let callgraph_dir =
6409 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
6410 let _store = CallGraphStore::open(callgraph_dir, root.clone()).expect("open empty store");
6411
6412 let snapshot =
6413 build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true), false);
6414
6415 assert!(
6416 snapshot.is_none(),
6417 "a cold/mid-build store must surface callgraph_unavailable instead of rebuilding inline"
6418 );
6419 }
6420
6421 #[test]
6422 fn suspended_callgraph_build_sets_distinct_builder_state() {
6423 let dir = write_ts_project(3);
6424 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
6425 let inspect_dir = root.join(".aft-cache").join("inspect");
6426 let callgraph_dir =
6427 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
6428 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
6429 let key = crate::build_breaker::BreakerKey::new(
6430 root.display().to_string(),
6431 crate::build_breaker::BuildDomain::CallgraphCold,
6432 crate::callgraph_store::callgraph_corpus_fingerprint_for_test(&root, &files)
6433 .expect("corpus fingerprint"),
6434 );
6435 let breaker = crate::build_breaker::BuildDeathBreaker::open(
6436 callgraph_dir.join("build-breaker.sqlite"),
6437 )
6438 .expect("open breaker");
6439 let now = SystemTime::now()
6440 .duration_since(UNIX_EPOCH)
6441 .expect("system time")
6442 .as_millis() as u64;
6443 for _ in 0..3 {
6444 let crate::build_breaker::BreakerAdmission::Admitted(attempt) =
6445 breaker.admit_at(&key, 0, now).expect("admit build")
6446 else {
6447 panic!("early suspension before the threshold");
6448 };
6449 breaker
6450 .record_attributed_death_at(&key, &attempt.attempt_id, 0, 0, now)
6451 .expect("record death");
6452 }
6453
6454 let manager = InspectManager::new();
6455 let job = snapshot_job(&root, &inspect_dir, true);
6456 assert!(manager
6457 .build_tier2_callgraph_snapshot_with_refresh(&job, true, true, &files)
6458 .is_none());
6459 assert_eq!(
6460 manager.tier2_builder_state(InspectCategory::DeadCode),
6461 InspectBuilderState::Suspended
6462 );
6463 let detail = manager.tier2_builder_state_detail(InspectCategory::DeadCode);
6464 assert!(detail.starts_with("suspended domain=callgraph_cold deaths=3 age_s="));
6465 assert!(detail.ends_with("reason=zero_credit_death_limit"));
6466 }
6467
6468 #[test]
6469 fn readonly_tier2_projection_keeps_generation_pinned_through_concurrent_gc() {
6470 let dir = write_ts_project(3);
6471 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
6472 let inspect_dir = root.join(".aft-cache").join("inspect");
6473 let callgraph_dir =
6474 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
6475 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
6476 let (store, _) =
6477 CallGraphStore::cold_build_with_lease(callgraph_dir.clone(), root.clone(), &files)
6478 .expect("initial generation");
6479 let initial_generation = store.sqlite_path().to_path_buf();
6480 drop(store);
6481 let project_key = crate::search_index::artifact_cache_key(&root);
6482 crate::root_cache::enable_writer_lease_acquisition_counts_for_test();
6483 let lease_count_before = crate::root_cache::writer_lease_acquisition_count_for_test(
6489 crate::root_cache::RootCacheDomain::Callgraph,
6490 &project_key,
6491 &root,
6492 );
6493
6494 let root_for_observer = root.clone();
6495 let dir_for_observer = callgraph_dir.clone();
6496 let files_for_observer = files.clone();
6497 crate::callgraph_store::set_projection_before_open_observer(Some(Arc::new(
6498 move |projected_path| {
6499 for _ in 0..3 {
6500 let (published, _) = CallGraphStore::cold_build_with_lease(
6501 dir_for_observer.clone(),
6502 root_for_observer.clone(),
6503 &files_for_observer,
6504 )
6505 .expect("concurrent generation publication");
6506 drop(published);
6507 }
6508 assert!(
6509 projected_path.is_file(),
6510 "the tier2 reader marker must pin the selected generation through GC"
6511 );
6512 },
6513 )));
6514 let mut job = snapshot_job(&root, &inspect_dir, true);
6515 job.callgraph_writer = false;
6516
6517 let snapshot =
6518 build_tier2_callgraph_snapshot_with_refresh(&job, false, &[root.join("mod0.ts")]);
6519 crate::callgraph_store::set_projection_before_open_observer(None);
6520
6521 assert!(snapshot.is_some());
6522 assert!(initial_generation.is_file());
6523 assert_eq!(
6524 crate::root_cache::writer_lease_acquisition_count_for_test(
6525 crate::root_cache::RootCacheDomain::Callgraph,
6526 &project_key,
6527 &root,
6528 ) - lease_count_before,
6529 3,
6530 "only the three observer publications may acquire a writer lease; tier2 must stay read-only"
6531 );
6532 }
6533
6534 #[test]
6535 fn direct_callgraph_snapshot_does_not_cold_rebuild_when_store_needs_rebuild() {
6536 let dir = write_ts_project(3);
6537 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
6538 let inspect_dir = root.join(".aft-cache").join("inspect");
6539 let callgraph_dir =
6540 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
6541 let store = CallGraphStore::open(callgraph_dir.clone(), root.clone()).expect("open store");
6542 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
6543 store.cold_build(&files).expect("cold build store");
6544 let sqlite_path = store.sqlite_path().to_path_buf();
6545 drop(store);
6546
6547 let still_existing_previous_root = root.with_file_name("previous-root-still-exists");
6548 std::fs::create_dir_all(&still_existing_previous_root).expect("create previous root");
6549 let conn = rusqlite::Connection::open(&sqlite_path).expect("open store sqlite");
6550 conn.execute(
6551 "UPDATE backend_file_state SET workspace_root = ?1",
6552 rusqlite::params![still_existing_previous_root.display().to_string()],
6553 )
6554 .expect("force root repair rebuild state");
6555 drop(conn);
6556
6557 let snapshot =
6558 build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true), false)
6559 .expect("readonly snapshot should avoid cold-rebuilding the store");
6560
6561 assert_eq!(snapshot.files.len(), 3);
6562 let conn = rusqlite::Connection::open(&sqlite_path).expect("reopen store sqlite");
6563 let stored_root: String = conn
6564 .query_row(
6565 "SELECT workspace_root FROM backend_file_state LIMIT 1",
6566 [],
6567 |row| row.get(0),
6568 )
6569 .expect("read stored root");
6570 assert_eq!(
6571 stored_root,
6572 still_existing_previous_root.display().to_string(),
6573 "direct inspect must not cold-rebuild or re-root a read-only snapshot"
6574 );
6575 }
6576
6577 #[test]
6578 fn callgraph_snapshot_reads_ready_callgraph_store() {
6579 let dir = write_ts_project(3);
6580 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
6581 let inspect_dir = root.join(".aft-cache").join("inspect");
6582 let callgraph_dir =
6583 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
6584 let store = CallGraphStore::open(callgraph_dir, root.clone()).expect("open store");
6585 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
6586 store.cold_build(&files).expect("cold build store");
6587
6588 let snapshot =
6589 build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true), false)
6590 .expect("ready store snapshot");
6591
6592 assert_eq!(snapshot.files.len(), 3);
6593 assert_eq!(snapshot.exported_symbols.len(), 3);
6594 }
6595
6596 #[test]
6597 fn path_identity_mismatch_is_a_named_dead_code_terminal_gap() {
6598 let dir = write_ts_project(1);
6599 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
6600 let inspect_dir = root.join(".aft-cache").join("inspect");
6601 let callgraph_dir =
6602 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
6603 let source = root.join("mod0.ts");
6604 let foreign_dir = tempfile::tempdir().expect("foreign tempdir");
6605 let foreign = foreign_dir.path().join("foreign.ts");
6606 std::fs::write(&foreign, "export function foreign() {}\n").expect("write foreign source");
6607 let store = CallGraphStore::open(callgraph_dir, root.clone()).expect("open store");
6608 store.cold_build(&[source]).expect("cold build store");
6609 let error = store
6610 .refresh_files(&[foreign.clone()])
6611 .expect_err("foreign watcher path cannot be assigned a store-relative key");
6612 assert!(matches!(
6613 error,
6614 CallGraphStoreError::PathIdentityMismatch { .. }
6615 ));
6616 drop(store);
6617
6618 let job = snapshot_job(&root, &inspect_dir, true);
6619 let reason = callgraph_path_identity_gap(&job).expect("durable path identity gap");
6620 assert!(reason.contains("callgraph_path_identity_mismatch"));
6621 assert!(reason.contains(&foreign.display().to_string()));
6622 let aggregate =
6623 crate::inspect::scanners::dead_code::callgraph_unavailable_aggregate_with_reason(
6624 1,
6625 Some(&reason),
6626 );
6627 assert_eq!(
6628 aggregate["notes"],
6629 serde_json::json!(["callgraph_unavailable", "callgraph_path_identity_mismatch"])
6630 );
6631 assert_eq!(aggregate["callgraph_unavailable_reason"], reason);
6632 }
6633
6634 #[test]
6635 fn stale_callgraph_store_refreshes_inline_when_refresh_worker_does_not_run() {
6636 let dir = write_ts_project(2);
6637 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
6638 let inspect_dir = root.join(".aft-cache").join("inspect");
6639 let callgraph_dir =
6640 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
6641 let project_key = crate::search_index::artifact_cache_key(&root);
6642 crate::root_cache::configure_artifact_access(&root, &project_key, false);
6643 let store = CallGraphStore::open(callgraph_dir.clone(), root.clone()).expect("open store");
6644 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
6645 store.cold_build(&files).expect("cold build store");
6646 store
6647 .mark_files_stale(&files)
6648 .expect("mark published store stale");
6649 drop(store);
6650
6651 let job = snapshot_job(&root, &inspect_dir, true);
6652 let manager = InspectManager::new();
6653 assert!(
6654 !manager.callgraph_ready_for_snapshot(&InspectSnapshot::new(
6655 root.clone(),
6656 inspect_dir.clone(),
6657 Arc::clone(&job.config),
6658 Arc::clone(&job.symbol_cache),
6659 )),
6660 "a store with leftover stale rows must not look callgraph-ready"
6661 );
6662
6663 let snapshot = manager
6664 .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6665 .expect("dead_code must refresh stale rows inline when the refresh worker never ran");
6666 assert_eq!(snapshot.files.len(), 2);
6667
6668 let ready = InspectSnapshot::new(
6669 root,
6670 inspect_dir,
6671 Arc::clone(&job.config),
6672 Arc::clone(&job.symbol_cache),
6673 );
6674 assert!(
6675 manager.callgraph_ready_for_snapshot(&ready),
6676 "after the inline refresh, callgraph_ready must agree with a successful projection"
6677 );
6678 }
6679
6680 #[test]
6681 fn callgraph_ready_and_builder_projection_agree_on_stale_rows() {
6682 let dir = write_ts_project(1);
6683 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
6684 let inspect_dir = root.join(".aft-cache").join("inspect");
6685 let callgraph_dir =
6686 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
6687 let project_key = crate::search_index::artifact_cache_key(&root);
6688 crate::root_cache::configure_artifact_access(&root, &project_key, false);
6689 let store = CallGraphStore::open(callgraph_dir, root.clone()).expect("open store");
6690 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
6691 store.cold_build(&files).expect("cold build store");
6692 let sqlite_path = store.sqlite_path().to_path_buf();
6693 drop(store);
6694
6695 let job = snapshot_job(&root, &inspect_dir, true);
6696 let snapshot = InspectSnapshot::new(
6697 root.clone(),
6698 inspect_dir.clone(),
6699 Arc::clone(&job.config),
6700 Arc::clone(&job.symbol_cache),
6701 );
6702 let manager = InspectManager::new();
6703 assert!(
6704 manager.callgraph_ready_for_snapshot(&snapshot),
6705 "a fresh store must be ready for both the phase check and projection"
6706 );
6707 project_dead_code_snapshot(&sqlite_path).expect("fresh store should project");
6708
6709 let store = CallGraphStore::open_ready_no_rebuild(
6710 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir"),
6711 root.clone(),
6712 )
6713 .expect("reopen writer")
6714 .expect("ready writer");
6715 store.mark_files_stale(&files).expect("mark stale");
6716 drop(store);
6717
6718 assert!(
6719 !manager.callgraph_ready_for_snapshot(&snapshot),
6720 "callgraph_ready must use the same stale-row predicate as dead_code projection"
6721 );
6722 let error =
6723 project_dead_code_snapshot(&sqlite_path).expect_err("stale rows must block projection");
6724 match error {
6725 CallGraphStoreError::Unavailable(message) => {
6726 assert_eq!(message, "callgraph has stale files pending refresh")
6727 }
6728 other => panic!("expected Unavailable, got {other:?}"),
6729 }
6730 }
6731
6732 #[test]
6733 fn failed_builder_attempt_history_uses_locked_refusal_detail() {
6734 let manager = InspectManager::new();
6735 let unavailable = JobOutcome::Fresh {
6736 payload: crate::inspect::scanners::dead_code::callgraph_unavailable_aggregate(0),
6737 };
6738 manager
6739 .record_tier2_attempt_outcome_for_test(InspectCategory::DeadCode, unavailable.clone());
6740 let first = manager.tier2_builder_state_detail(InspectCategory::DeadCode);
6741 let first_at = first
6742 .rsplit("first at ")
6743 .next()
6744 .and_then(|tail| tail.strip_suffix(')'))
6745 .expect("first failure detail includes first-at unix time");
6746 for _ in 1..7 {
6747 manager.record_tier2_attempt_outcome_for_test(
6748 InspectCategory::DeadCode,
6749 unavailable.clone(),
6750 );
6751 }
6752 assert_eq!(
6753 manager.tier2_builder_state_detail(InspectCategory::DeadCode),
6754 format!("last attempt failed: callgraph_unavailable (attempt 7, first at {first_at})")
6755 );
6756 assert_eq!(
6757 manager.tier2_builder_state(InspectCategory::DeadCode),
6758 InspectBuilderState::Absent,
6759 "a finished failure must not keep the registry in an in-flight state"
6760 );
6761 assert_eq!(
6762 manager.try_tier2_builder_busy(),
6763 Some(false),
6764 "failed-attempt history must not look like a live rebuild"
6765 );
6766
6767 manager.record_tier2_attempt_outcome_for_test(
6768 InspectCategory::DeadCode,
6769 JobOutcome::Fresh {
6770 payload: serde_json::json!({ "callgraph_available": true, "count": 0 }),
6771 },
6772 );
6773 assert_eq!(
6774 manager.tier2_builder_state_detail(InspectCategory::DeadCode),
6775 InspectBuilderState::Absent.as_str()
6776 );
6777 }
6778
6779 #[test]
6780 fn generation_keyed_projection_cache_reuses_unchanged_snapshot() {
6781 let (_dir, _root, _inspect_dir, job) = published_projection_fixture();
6782 let manager = InspectManager::new();
6783 let (projections, _observer_reset) = count_projections();
6784
6785 let first = manager
6786 .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6787 .expect("first projection");
6788 let second = manager
6789 .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6790 .expect("cached projection");
6791
6792 assert!(
6793 Arc::ptr_eq(&first, &second),
6794 "an unchanged generation and write revision must reuse the projected Arc"
6795 );
6796 assert_eq!(
6797 projections.load(std::sync::atomic::Ordering::SeqCst),
6798 1,
6799 "two dead-code scans without a callgraph mutation must project once"
6800 );
6801 let memory = manager.callgraph_projection_estimated_memory();
6802 assert_eq!(
6803 memory.counts["callgraph_projection_snapshots"], 1,
6804 "the resident projection must be attributed to the root"
6805 );
6806 assert!(
6807 memory.estimated_bytes.unwrap_or_default() > 0,
6808 "a populated projection must report an estimated residency"
6809 );
6810 }
6811
6812 #[test]
6813 fn stale_store_never_reuses_an_equal_revision_projection() {
6814 let (_dir, root, inspect_dir, job) = published_projection_fixture();
6815 let manager = InspectManager::new();
6816 manager
6817 .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6818 .expect("initial projection");
6819 let writer = CallGraphStore::open_ready_no_rebuild(
6820 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("callgraph dir"),
6821 root.clone(),
6822 )
6823 .expect("open writer")
6824 .expect("ready writer");
6825 writer
6826 .mark_files_stale(&[root.join("src/target.ts")])
6827 .expect("mark target stale");
6828 drop(writer);
6829
6830 assert!(
6831 manager
6832 .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6833 .is_none(),
6834 "a stale marker must block a cache hit even though it does not advance the graph revision"
6835 );
6836 }
6837
6838 fn assert_fresh_refresh_reuses_projection(job: InspectJob, target: PathBuf) {
6839 let manager = InspectManager::new();
6840 let (projections, _observer_reset) = count_projections();
6841 let started = Instant::now();
6842 let first = manager
6843 .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6844 .expect("initial projection");
6845 let cold_ms = started.elapsed().as_secs_f64() * 1_000.0;
6846 let callgraph_dir =
6847 callgraph_store_dir_from_inspect_dir(&job.inspect_dir, &job.project_root)
6848 .expect("copied graph directory");
6849 let writer = CallGraphStore::open_ready_no_rebuild(callgraph_dir, job.project_root.clone())
6850 .expect("open writer")
6851 .expect("ready graph");
6852 let revision_before = writer
6853 .projection_write_revision()
6854 .expect("initial revision");
6855 let started = Instant::now();
6856 let (_, profile) = writer
6857 .refresh_files_profiled(&[target])
6858 .expect("already-fresh refresh");
6859 let revision_after = writer
6860 .projection_write_revision()
6861 .expect("revision after refresh");
6862 drop(writer);
6863 let second = manager
6864 .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6865 .expect("projection after fresh refresh");
6866 eprintln!("fresh_refresh_projection initial_ms={cold_ms:.3} refresh_and_snapshot_ms={:.3} index_loads={} projections={} outbound_rows={}",
6867 started.elapsed().as_secs_f64() * 1_000.0,
6868 profile.index_loads, projections.load(std::sync::atomic::Ordering::SeqCst),
6869 first.outbound_calls.len());
6870 assert_eq!(
6871 profile.index_loads, 0,
6872 "a fresh refresh must not load the corpus resolver index"
6873 );
6874 assert_eq!(
6875 revision_after, revision_before,
6876 "no written rows means no projection invalidation"
6877 );
6878 assert_eq!(
6879 projections.load(std::sync::atomic::Ordering::SeqCst),
6880 1,
6881 "a duplicate refresh must not re-project the corpus"
6882 );
6883 assert!(Arc::ptr_eq(&first, &second));
6884 }
6885
6886 #[test]
6887 fn projection_cache_reuses_snapshot_after_already_fresh_refresh() {
6888 let (_dir, root, _inspect_dir, job) = published_projection_fixture();
6889 assert_fresh_refresh_reuses_projection(job, root.join("src/target.ts"));
6890 }
6891
6892 #[test]
6893 fn projection_cache_invalidates_after_deleting_a_file_without_dependents() {
6894 let (_dir, root, inspect_dir, job) = published_projection_fixture();
6895 let manager = InspectManager::new();
6896 let (projections, _observer_reset) = count_projections();
6897 let first = manager
6898 .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6899 .expect("initial projection");
6900 let target = canonicalize_for_snapshot(&root.join("src/main.ts"));
6904 assert!(first.files.contains(&target));
6905 let graph_dir = callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).unwrap();
6906 let writer = CallGraphStore::open_ready_no_rebuild(graph_dir, root.clone())
6907 .expect("open writer")
6908 .expect("ready graph");
6909 std::fs::remove_file(&target).expect("remove unreferenced entry file");
6910 let (stats, profile) = writer
6911 .refresh_files_profiled(std::slice::from_ref(&target))
6912 .expect("refresh deletion");
6913 assert_eq!(stats.deleted_files, vec!["src/main.ts"]);
6914 assert_eq!(
6915 profile.index_loads, 0,
6916 "deletion without surviving callers needs no resolver"
6917 );
6918 drop(writer);
6919 let second = manager
6920 .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6921 .expect("projection after deletion");
6922 assert!(!second.files.contains(&target));
6923 assert_eq!(
6924 projections.load(std::sync::atomic::Ordering::SeqCst),
6925 2,
6926 "row deletion must invalidate even when no resolver was loaded"
6927 );
6928 }
6929
6930 #[test]
6931 #[ignore = "offline probe requires a normalized production graph copy below target"]
6932 fn profile_fresh_refresh_projection_on_store_copy() {
6933 let project = Path::new(env!("CARGO_MANIFEST_DIR"))
6934 .parent()
6935 .unwrap()
6936 .parent()
6937 .unwrap()
6938 .canonicalize()
6939 .expect("checkout root");
6940 let storage = PathBuf::from(
6941 std::env::var_os("AFT_CPU_HUNT_STORAGE_COPY")
6942 .expect("set AFT_CPU_HUNT_STORAGE_COPY to an offline copied storage root"),
6943 )
6944 .canonicalize()
6945 .expect("copied storage");
6946 assert!(
6947 storage.starts_with(project.join("target")),
6948 "never probe a live artifact"
6949 );
6950 let target = project.join("crates/aft/tests/engine_comparator_test.rs");
6951 let inspect_dir = storage.join("inspect");
6952 let key = crate::search_index::artifact_cache_key(&project);
6953 crate::root_cache::configure_artifact_access(&project, &key, false);
6954 let graph_dir = callgraph_store_dir_from_inspect_dir(&inspect_dir, &project).unwrap();
6955 let writer = CallGraphStore::open_ready_no_rebuild(graph_dir, project.clone())
6956 .expect("open copied graph")
6957 .expect("copied graph ready");
6958 writer
6959 .refresh_files(std::slice::from_ref(&target))
6960 .expect("normalize copied freshness");
6961 drop(writer);
6962 let mut job = snapshot_job(&project, &inspect_dir, true);
6963 job.callgraph_writer = false;
6964 assert_fresh_refresh_reuses_projection(job, target);
6965 }
6966
6967 #[test]
6968 fn projection_cache_invalidates_on_in_place_refresh_for_readonly_scans() {
6969 let (_dir, root, inspect_dir, job) = published_projection_fixture();
6970 let unrelated = root.join("src/unrelated.ts");
6971 write_projection_cache_file(&unrelated, &"unrelated();\n".repeat(100));
6972 let store = CallGraphStore::open_ready_no_rebuild(
6973 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).unwrap(),
6974 root.clone(),
6975 )
6976 .unwrap()
6977 .unwrap();
6978 store.refresh_files(&[unrelated]).unwrap();
6979 drop(store);
6980 let manager = InspectManager::new();
6981 let (projections, _observer_reset) = count_projections();
6982 let target = root.join("src/target.ts");
6983 let callgraph_dir =
6984 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("callgraph dir");
6985
6986 let first = manager
6987 .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6988 .expect("initial projection");
6989 let writer = CallGraphStore::open_ready_no_rebuild(callgraph_dir, root.clone())
6990 .expect("open writer")
6991 .expect("ready writer");
6992 let revision_before = writer
6993 .projection_write_revision()
6994 .expect("read initial revision")
6995 .expect("new stores write a projection revision");
6996 write_projection_cache_file(&target, "export function secondTarget() {}\n");
6997 writer
6998 .refresh_files(&[target])
6999 .expect("refresh changed target");
7000 let revision_after = writer
7001 .projection_write_revision()
7002 .expect("read refreshed revision")
7003 .expect("refreshed stores retain a projection revision");
7004 assert!(
7005 revision_after > revision_before,
7006 "the in-place refresh must advance the durable cache identity"
7007 );
7008 drop(writer);
7009
7010 crate::callgraph_store::take_projection_work();
7011 let second = manager
7012 .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
7013 .expect("refreshed readonly projection");
7014 let (full_projections, outbound_rows_read) = crate::callgraph_store::take_projection_work();
7015 eprintln!("incremental_work full_projections={full_projections} outbound_rows_read={outbound_rows_read}");
7016 assert_eq!(full_projections, 0);
7017 assert_eq!(outbound_rows_read, 1);
7018 assert!(
7019 !first
7020 .exported_symbols
7021 .iter()
7022 .any(|export| export.symbol == "secondTarget"),
7023 "the initial snapshot must not already contain the refreshed export"
7024 );
7025 assert!(
7026 second
7027 .exported_symbols
7028 .iter()
7029 .any(|export| export.symbol == "secondTarget"),
7030 "the readonly scan must expose graph data from the refreshed store"
7031 );
7032 assert_eq!(
7033 projections.load(std::sync::atomic::Ordering::SeqCst),
7034 2,
7035 "an in-place refresh must force the next scan to re-project"
7036 );
7037 }
7038
7039 #[test]
7040 fn projection_cache_invalidates_when_cold_build_publishes_new_generation() {
7041 let (_dir, root, inspect_dir, job) = published_projection_fixture();
7042 let manager = InspectManager::new();
7043 let (projections, _observer_reset) = count_projections();
7044 let target = root.join("src/target.ts");
7045 let callgraph_dir =
7046 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("callgraph dir");
7047
7048 let first = manager
7049 .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
7050 .expect("initial projection");
7051 let before = CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
7052 .expect("open initial reader")
7053 .expect("initial reader");
7054 let revision_before = before
7055 .projection_write_revision()
7056 .expect("read initial revision")
7057 .expect("new stores write a projection revision");
7058 drop(before);
7059 write_projection_cache_file(&target, "export function coldBuildTarget() {}\n");
7060 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
7061 let (published, _) =
7062 CallGraphStore::cold_build_with_lease(callgraph_dir, root.clone(), &files)
7063 .expect("publish replacement generation");
7064 let revision_after = published
7065 .projection_write_revision()
7066 .expect("read replacement revision")
7067 .expect("replacement stores write a projection revision");
7068 assert_eq!(
7069 revision_after, revision_before,
7070 "cold builds begin with the same revision, so this assertion exercises the generation half of the cache identity"
7071 );
7072 drop(published);
7073
7074 let second = manager
7075 .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
7076 .expect("replacement projection");
7077 assert!(
7078 !first
7079 .exported_symbols
7080 .iter()
7081 .any(|export| export.symbol == "coldBuildTarget"),
7082 "the initial snapshot must not already contain the replacement export"
7083 );
7084 assert!(
7085 second
7086 .exported_symbols
7087 .iter()
7088 .any(|export| export.symbol == "coldBuildTarget"),
7089 "the next scan must expose the generation published by the cold build"
7090 );
7091 assert_eq!(
7092 projections.load(std::sync::atomic::Ordering::SeqCst),
7093 2,
7094 "a new pointer generation must force the next scan to re-project"
7095 );
7096 }
7097
7098 #[test]
7099 fn idle_eviction_drops_generation_keyed_projection_cache() {
7100 let (_dir, _root, _inspect_dir, job) = published_projection_fixture();
7101 let manager = InspectManager::new();
7102 let (projections, _observer_reset) = count_projections();
7103
7104 let first = manager
7105 .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
7106 .expect("initial projection");
7107 manager.evict_idle_caches();
7108 assert_eq!(
7109 manager.callgraph_projection_estimated_memory().counts
7110 ["callgraph_projection_snapshots"],
7111 0,
7112 "idle artifact eviction must release the root projection slot"
7113 );
7114 let second = manager
7115 .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
7116 .expect("reloaded projection");
7117
7118 assert!(
7119 !Arc::ptr_eq(&first, &second),
7120 "eviction must drop the previous projection Arc"
7121 );
7122 assert_eq!(
7123 projections.load(std::sync::atomic::Ordering::SeqCst),
7124 2,
7125 "the next scan after idle eviction must reload the projection"
7126 );
7127 }
7128
7129 #[test]
7130 fn callgraph_snapshot_uses_ready_root_keyed_store() {
7131 let _git_env = crate::test_env::hermetic_git_env_guard();
7132 let dir = write_ts_project(3);
7133 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
7134 let storage_dir = root.join(".aft-cache");
7135 let inspect_dir = storage_dir
7136 .join("inspect")
7137 .join(crate::path_identity::project_scope_key(&root));
7138 let warm_callgraph_dir = storage_dir
7139 .join("callgraph")
7140 .join(artifact_cache_key_for_test(&root));
7141 let store = CallGraphStore::open(warm_callgraph_dir, root.clone()).expect("open store");
7142 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
7143 store.cold_build(&files).expect("cold build store");
7144
7145 let snapshot =
7146 build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true), false)
7147 .expect("ready sibling store snapshot");
7148
7149 assert_eq!(snapshot.files.len(), 3);
7150 assert_eq!(snapshot.exported_symbols.len(), 3);
7151 }
7152
7153 #[test]
7154 fn dead_code_forced_deletion_refreshes_callgraph_store_before_rollup() {
7155 let dir = tempfile::tempdir().expect("tempdir");
7156 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
7157 write_fixture_file(
7158 &root,
7159 "package.json",
7160 r#"{"name":"dead-code-delete-refresh","type":"module","main":"src/main.ts"}"#,
7161 3_100_000_000,
7162 );
7163 write_fixture_file(
7164 &root,
7165 "src/main.ts",
7166 "export function main() {}\n",
7167 3_100_000_001,
7168 );
7169 write_fixture_file(
7170 &root,
7171 "src/dead.ts",
7172 "export function plantedDead() {}\n",
7173 3_100_000_002,
7174 );
7175
7176 let inspect_dir = root.join(".aft-cache").join("opencode").join("inspect");
7177 let callgraph_dir =
7178 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
7179 let project_key = crate::search_index::artifact_cache_key(&root);
7180 crate::root_cache::configure_artifact_access(&root, &project_key, false);
7181 let store = CallGraphStore::open(callgraph_dir.clone(), root.clone()).expect("open store");
7182 let project_files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
7183 store.cold_build(&project_files).expect("cold build store");
7184 drop(store);
7185
7186 let config = Arc::new(crate::config::Config {
7187 project_root: Some(root.clone()),
7188 callgraph_store: true,
7189 ..crate::config::Config::default()
7190 });
7191 let symbol_cache = Arc::new(std::sync::RwLock::new(crate::parser::SymbolCache::new()));
7192 let snapshot = InspectSnapshot::new(
7193 root.clone(),
7194 inspect_dir.clone(),
7195 Arc::clone(&config),
7196 Arc::clone(&symbol_cache),
7197 );
7198 let manager = InspectManager::new();
7199 let initial_job =
7200 manager.tier2_reuse_job(snapshot.clone(), InspectCategory::DeadCode, None);
7201 let initial = manager
7202 .tier2_run_with_reuse_job_result_with_options(
7203 initial_job,
7204 Tier2ReuseOptions::default(),
7205 None,
7206 )
7207 .outcome
7208 .expect("initial dead_code scan succeeds")
7209 .aggregate;
7210 assert!(
7211 aggregate_has_file_symbol(&initial, "src/dead.ts", "plantedDead"),
7212 "initial scan should report the planted dead export: {initial:#}"
7213 );
7214
7215 let deleted = root.join("src/dead.ts");
7216 std::fs::remove_file(&deleted).expect("delete dead fixture");
7217 let delete_job = manager.tier2_reuse_job(snapshot, InspectCategory::DeadCode, None);
7218 let refreshed = manager
7219 .tier2_run_with_reuse_job_result_with_options(
7220 delete_job,
7221 Tier2ReuseOptions {
7222 force_rescan_paths: [deleted.clone()].into_iter().collect(),
7223 allow_callgraph_cold_build: true,
7224 require_callgraph_snapshot: false,
7225 interactive: false,
7226 },
7227 None,
7228 )
7229 .outcome
7230 .expect("delete refresh dead_code scan succeeds")
7231 .aggregate;
7232
7233 assert_eq!(
7234 refreshed
7235 .get("callgraph_available")
7236 .and_then(Value::as_bool),
7237 Some(true),
7238 "forced watcher paths must keep the callgraph-backed aggregate available: {refreshed:#}"
7239 );
7240 assert!(
7241 !aggregate_has_file_symbol(&refreshed, "src/dead.ts", "plantedDead"),
7242 "delete refresh should remove the planted dead export: {refreshed:#}"
7243 );
7244
7245 let store = CallGraphStore::open_ready_no_rebuild(callgraph_dir, root)
7246 .expect("open refreshed store")
7247 .expect("refreshed store is ready");
7248 let projected = project_dead_code_snapshot(store.sqlite_path()).expect("project snapshot");
7249 assert!(
7250 projected
7251 .files
7252 .iter()
7253 .all(|file| !file.ends_with("src/dead.ts")),
7254 "watcher deletion should be applied to the persisted callgraph store: {:#?}",
7255 projected.files
7256 );
7257 }
7258
7259 fn aggregate_has_file_symbol(aggregate: &Value, file: &str, symbol: &str) -> bool {
7260 aggregate
7261 .get("items")
7262 .and_then(Value::as_array)
7263 .is_some_and(|items| {
7264 items.iter().any(|item| {
7265 item.get("file").and_then(Value::as_str) == Some(file)
7266 && item.get("symbol").and_then(Value::as_str) == Some(symbol)
7267 })
7268 })
7269 }
7270
7271 #[test]
7275 fn scoped_filter_drops_project_wide_by_language() {
7276 let scope = JobScope::from_roots("/proj", vec![PathBuf::from("/proj/src/a")]);
7277 assert!(
7278 !scope.is_project_wide(),
7279 "scope must be non-project for test"
7280 );
7281 let payload = serde_json::json!({
7282 "count": 99,
7283 "by_language": { "rust": 214, "typescript": 143 },
7284 "items": [
7285 { "file": "/proj/src/a/x.rs", "symbol": "live" },
7286 { "file": "/proj/src/other/y.rs", "symbol": "out" },
7287 ],
7288 });
7289 let filtered = filter_payload_for_scope(payload, &scope);
7290 assert!(
7291 filtered.get("by_language").is_none(),
7292 "scoped payload must drop project-wide by_language: {filtered}"
7293 );
7294 assert_eq!(filtered.get("count").and_then(|v| v.as_u64()), Some(1));
7296 }
7297 #[cfg(debug_assertions)]
7298 #[test]
7299 fn tier2_read_cached_freshness_does_not_hash_unchanged_contributions() {
7300 let (_dir, manager, snapshot, scope, _files) = duplicate_cache_fixture();
7301 let fixture_root = snapshot.project_root.clone();
7302
7303 crate::cache_freshness::reset_hash_file_if_small_count_for_debug();
7304 crate::cache_freshness::reset_verify_file_strict_count_for_debug();
7305 assert_fresh(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
7306
7307 assert_eq!(
7308 crate::cache_freshness::verify_file_strict_count_under_for_debug(&fixture_root),
7309 0,
7310 "dispatch-thread inspect freshness must not use strict verification"
7311 );
7312 assert_eq!(
7313 crate::cache_freshness::hash_file_if_small_count_for_debug(),
7314 0,
7315 "unchanged contribution files must stay on the stat-only fast path"
7316 );
7317 }
7318
7319 #[cfg(debug_assertions)]
7320 #[test]
7321 fn tier2_read_cached_freshness_returns_byte_identical_cold_scan_aggregate() {
7322 let (_dir, manager, snapshot, scope, _files) = duplicate_uncached_fixture();
7323 let cold_payload = fresh_payload(manager.tier2_run_with_reuse(
7324 snapshot.clone(),
7325 InspectCategory::Duplicates,
7326 scope.clone(),
7327 None,
7328 ));
7329
7330 crate::cache_freshness::reset_hash_file_if_small_count_for_debug();
7331 crate::cache_freshness::reset_verify_file_strict_count_for_debug();
7332 let fixture_root = snapshot.project_root.clone();
7333 let warm_payload =
7334 fresh_payload(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
7335
7336 let cold_bytes = serde_json::to_vec(&cold_payload).expect("serialize cold aggregate");
7337 let warm_bytes = serde_json::to_vec(&warm_payload).expect("serialize warm aggregate");
7338 assert_eq!(
7339 warm_bytes, cold_bytes,
7340 "warm unchanged read must return the byte-identical aggregate as the cold scan"
7341 );
7342 assert_eq!(
7343 crate::cache_freshness::verify_file_strict_count_under_for_debug(&fixture_root),
7344 0,
7345 "dispatch-thread warm read must not use strict verification"
7346 );
7347 assert_eq!(
7348 crate::cache_freshness::hash_file_if_small_count_for_debug(),
7349 0,
7350 "warm unchanged read must not content-hash cached contribution files"
7351 );
7352 }
7353
7354 #[test]
7355 fn tier2_read_cached_freshness_detects_changed_added_and_deleted_files() {
7356 let (_dir, manager, snapshot, scope, _files) = duplicate_cache_fixture();
7357 write_fixture_file(
7358 &snapshot.project_root,
7359 "src/foo.ts",
7360 "export const foo = 101;\nexport const changed = true;\n",
7361 3_000_000_001,
7362 );
7363 assert_stale(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
7364
7365 let (_dir, manager, snapshot, scope, _files) = duplicate_cache_fixture();
7366 write_fixture_file(
7367 &snapshot.project_root,
7368 "src/added.ts",
7369 "export const added = 3;\n",
7370 3_000_000_002,
7371 );
7372 assert_stale(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
7373
7374 let (_dir, manager, snapshot, scope, files) = duplicate_cache_fixture();
7375 std::fs::remove_file(&files[0]).expect("delete cached contribution file");
7376 assert_stale(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
7377 }
7378
7379 fn duplicate_cache_fixture() -> (
7380 tempfile::TempDir,
7381 InspectManager,
7382 InspectSnapshot,
7383 JobScope,
7384 Vec<PathBuf>,
7385 ) {
7386 let (dir, manager, snapshot, scope, files) = duplicate_uncached_fixture();
7387 store_duplicate_cache(&manager, &snapshot, &files);
7388 (dir, manager, snapshot, scope, files)
7389 }
7390
7391 fn duplicate_uncached_fixture() -> (
7392 tempfile::TempDir,
7393 InspectManager,
7394 InspectSnapshot,
7395 JobScope,
7396 Vec<PathBuf>,
7397 ) {
7398 use crate::config::Config;
7399 use crate::parser::SymbolCache;
7400 use std::sync::RwLock;
7401
7402 let dir = tempfile::tempdir().expect("tempdir");
7403 let root = std::fs::canonicalize(dir.path()).expect("canonical fixture root");
7404 let files = vec![
7405 write_fixture_file(
7406 &root,
7407 "src/foo.ts",
7408 "export const fixture = () => 1;
7409export const shared = 1;
7410",
7411 3_000_000_000,
7412 ),
7413 write_fixture_file(
7414 &root,
7415 "src/bar.ts",
7416 "export const fixture = () => 1;
7417export const shared = 1;
7418",
7419 3_000_000_000,
7420 ),
7421 ];
7422 let inspect_dir = root.join(".aft-cache").join("inspect");
7423 let snapshot = InspectSnapshot::new(
7424 root.clone(),
7425 inspect_dir,
7426 Arc::new(Config {
7427 project_root: Some(root.clone()),
7428 ..Config::default()
7429 }),
7430 Arc::new(RwLock::new(SymbolCache::new())),
7431 );
7432 let scope = JobScope::for_project(root);
7433 let manager = InspectManager::new();
7434 (dir, manager, snapshot, scope, files)
7435 }
7436
7437 fn write_fixture_file(root: &Path, relative: &str, content: &str, mtime_secs: i64) -> PathBuf {
7438 let path = root.join(relative);
7439 if let Some(parent) = path.parent() {
7440 std::fs::create_dir_all(parent).expect("create fixture parent");
7441 }
7442 std::fs::write(&path, content).expect("write fixture file");
7443 filetime::set_file_mtime(&path, filetime::FileTime::from_unix_time(mtime_secs, 0))
7444 .expect("set fixture mtime");
7445 path
7446 }
7447
7448 fn store_duplicate_cache(
7449 manager: &InspectManager,
7450 snapshot: &InspectSnapshot,
7451 files: &[PathBuf],
7452 ) {
7453 let cache = manager
7454 .cache_for_snapshot(snapshot)
7455 .expect("open inspect cache");
7456 let contributions = files
7457 .iter()
7458 .map(|file| {
7459 let freshness = crate::cache_freshness::collect(file).expect("collect freshness");
7460 FileContribution::new(
7461 InspectCategory::Duplicates,
7462 file.clone(),
7463 freshness,
7464 serde_json::json!({
7465 "file": relative_cache_key(&snapshot.project_root, file),
7466 "fragments": [],
7467 }),
7468 )
7469 })
7470 .collect::<Vec<_>>();
7471 cache
7472 .store_tier2_result(
7473 JobKey::for_project_category(InspectCategory::Duplicates),
7474 files,
7475 &contributions,
7476 serde_json::json!({
7477 "count": 0,
7478 "groups": [],
7479 "scanned_files": files.len(),
7480 "total_groups": 0,
7481 }),
7482 )
7483 .expect("store tier2 cache fixture");
7484 }
7485
7486 fn assert_fresh(outcome: JobOutcome) {
7487 let _ = fresh_payload(outcome);
7488 }
7489
7490 fn fresh_payload(outcome: JobOutcome) -> Value {
7491 match outcome {
7492 JobOutcome::Fresh { payload } => payload,
7493 other => panic!("expected fresh cached Tier-2 outcome, got {other:?}"),
7494 }
7495 }
7496
7497 fn assert_stale(outcome: JobOutcome) {
7498 match outcome {
7499 JobOutcome::Stale { .. } => {}
7500 other => panic!("expected stale cached Tier-2 outcome, got {other:?}"),
7501 }
7502 }
7503}
7504
7505#[cfg(test)]
7506mod dead_code_projection_tests {
7507 use super::*;
7508 use crate::callgraph::walk_project_files;
7509 use crate::callgraph_store::{project_dead_code_snapshot, CallGraphStore};
7510 use crate::config::Config;
7511 use crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR;
7512 use crate::inspect::scanners::DEFAULT_EXPORT_MARKER_KIND;
7513 use crate::parser::SymbolCache;
7514 use filetime::FileTime;
7515 use std::sync::atomic::{AtomicI64, Ordering as AtomicOrdering};
7516 use std::sync::RwLock;
7517
7518 static NEXT_MTIME: AtomicI64 = AtomicI64::new(1_900_000_000);
7519
7520 #[test]
7521 fn scoped_dead_code_rollup_uses_ready_callgraph_and_degrades_without_it() {
7522 let dir = tempfile::tempdir().expect("tempdir");
7523 write_projection_fixture(dir.path());
7524 let root = canonical_root(dir.path());
7525 let inspect_dir = root.join(".aft-cache").join("inspect");
7526 let callgraph_dir =
7527 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
7528 let project_key = crate::search_index::artifact_cache_key(&root);
7529 crate::root_cache::configure_artifact_access(&root, &project_key, false);
7530 let store = CallGraphStore::open(callgraph_dir.clone(), root.clone()).expect("open store");
7531 let files = project_files(&root);
7532 store.cold_build(&files).expect("cold build store");
7533 let projected = project_dead_code_snapshot(store.sqlite_path()).expect("project snapshot");
7534 drop(store);
7535
7536 let config = Arc::new(Config {
7537 project_root: Some(root.clone()),
7538 callgraph_store: true,
7539 ..Config::default()
7540 });
7541 let symbol_cache = Arc::new(RwLock::new(SymbolCache::new()));
7542 let scan_job = InspectJob {
7543 job_id: 87,
7544 key: JobKey::for_project_category(InspectCategory::DeadCode),
7545 category: InspectCategory::DeadCode,
7546 scope_files: files.clone(),
7547 project_root: root.clone(),
7548 inspect_dir: inspect_dir.clone(),
7549 config: Arc::clone(&config),
7550 symbol_cache: Arc::clone(&symbol_cache),
7551 inspect_writer: true,
7552 callgraph_writer: true,
7553 callgraph_snapshot: Some(Arc::new(projected)),
7554 };
7555 let success = crate::inspect::scanners::dead_code::run_dead_code_scan(&scan_job)
7556 .outcome
7557 .expect("dead_code scan succeeds");
7558 let cache = InspectCache::open(inspect_dir.clone(), root.clone()).expect("open cache");
7559 cache
7560 .store_tier2_result(
7561 scan_job.key.clone(),
7562 &success.scanned_files,
7563 &success.contributions,
7564 success.aggregate.clone(),
7565 )
7566 .expect("store tier2 result");
7567
7568 let snapshot = InspectSnapshot::new(root.clone(), inspect_dir, config, symbol_cache);
7569 let scope = JobScope::from_roots(root.clone(), vec![root.join("src/live.ts")]);
7570 assert!(
7571 !scope.is_project_wide(),
7572 "live.ts file scope must be scoped"
7573 );
7574
7575 let ready_payload = scoped_tier2_payload_from_contributions(
7576 &snapshot,
7577 InspectCategory::DeadCode,
7578 &cache,
7579 success.aggregate.clone(),
7580 &scope,
7581 )
7582 .expect("ready scoped payload");
7583 assert_eq!(
7584 ready_payload
7585 .get("callgraph_available")
7586 .and_then(Value::as_bool),
7587 Some(true),
7588 "ready store should produce a callgraph-backed scoped rollup: {ready_payload:#}"
7589 );
7590 assert_live_item(&ready_payload, "src/live.ts", "knownLive");
7591
7592 std::fs::remove_dir_all(&callgraph_dir).expect("remove ready callgraph store");
7593 let unavailable_payload = scoped_tier2_payload_from_contributions(
7594 &snapshot,
7595 InspectCategory::DeadCode,
7596 &cache,
7597 success.aggregate,
7598 &scope,
7599 )
7600 .expect("unavailable scoped payload");
7601 assert_eq!(
7602 unavailable_payload
7603 .get("callgraph_available")
7604 .and_then(Value::as_bool),
7605 Some(false),
7606 "missing store must report callgraph_unavailable instead of fabricating an empty graph: {unavailable_payload:#}"
7607 );
7608 assert_live_item(&unavailable_payload, "src/live.ts", "knownLive");
7609 }
7610 #[derive(Debug, PartialEq, Eq)]
7611 struct ComparableSnapshot {
7612 files: BTreeSet<PathBuf>,
7613 exported_symbols: BTreeSet<(PathBuf, String, String, u32)>,
7614 outbound_calls: BTreeSet<(PathBuf, String, String, u32)>,
7615 entry_points: BTreeSet<PathBuf>,
7616 entry_point_symbols: BTreeMap<PathBuf, BTreeSet<String>>,
7617 }
7618
7619 #[test]
7620 fn dead_code_projection_contains_expected_fixture_surface() {
7621 let dir = tempfile::tempdir().expect("tempdir");
7622 write_projection_fixture(dir.path());
7623 let root = canonical_root(dir.path());
7624 let projected = store_projected_snapshot(&root, ".store-dead-code-surface");
7625
7626 assert_projection_fixture_coverage(&root, &projected);
7627 }
7628
7629 #[test]
7630 fn dead_code_projection_incremental_scenario_matrix_matches_cold_rebuild() {
7631 run_projection_scenario("rename", setup_projection_rename, edit_projection_rename);
7632 run_projection_scenario("delete", setup_projection_delete, edit_projection_delete);
7633 run_projection_scenario("new-file", setup_projection_delete, |root| {
7634 let path = root.join("new.ts");
7635 write_file(
7636 &path,
7637 "import { foo } from './foo'; export function added() { foo(); }\n",
7638 );
7639 vec![path]
7640 });
7641 run_projection_scenario(
7642 "barrel delete",
7643 setup_projection_barrel,
7644 edit_projection_barrel_delete,
7645 );
7646 run_projection_scenario(
7647 "dispatch edit",
7648 setup_projection_dispatch,
7649 edit_projection_dispatch,
7650 );
7651 run_projection_scenario(
7652 "body-only edit",
7653 setup_projection_body_only,
7654 edit_projection_body_only,
7655 );
7656 }
7657
7658 #[test]
7659 fn dead_code_projection_dead_code_scan_reports_expected_verdicts() {
7660 let dir = tempfile::tempdir().expect("tempdir");
7661 write_projection_fixture(dir.path());
7662 let root = canonical_root(dir.path());
7663 let files = project_files(&root);
7664 let projected = store_projected_snapshot(&root, ".store-dead-code-e2e");
7665
7666 let projected_aggregate = dead_code_aggregate(&root, files, projected);
7667 assert_dead_item(&projected_aggregate, "src/dead.ts", "knownDead");
7668 assert_live_item(&projected_aggregate, "src/live.ts", "knownLive");
7669 assert_live_item(&projected_aggregate, "src/render.ts", "render");
7670 assert_live_item(&projected_aggregate, "src/other_render.ts", "render");
7671 }
7672
7673 #[test]
7674 fn dead_code_projection_rust_attribute_entry_points_are_live() {
7675 let dir = tempfile::tempdir().expect("tempdir");
7676 write_rust_attribute_entry_fixture(dir.path());
7677 let root = canonical_root(dir.path());
7678 let files = project_files(&root);
7679 let store = CallGraphStore::open(root.join(".store-tauri-commands"), root.clone())
7680 .expect("open store");
7681 store.cold_build(&files).expect("cold build store");
7682 let command = store
7683 .node_for(Path::new("src/commands.rs"), "get_primers")
7684 .expect("command node");
7685 assert!(
7686 command.is_entry_point,
7687 "attribute-rooted commands must be labeled as callgraph entry points"
7688 );
7689 let private_command = store
7690 .node_for(Path::new("src/commands.rs"), "private_command")
7691 .expect("private command node");
7692 assert!(
7693 private_command.is_entry_point,
7694 "private attribute-rooted commands must also be callgraph entry points"
7695 );
7696
7697 let projected = project_dead_code_snapshot(store.sqlite_path()).expect("project snapshot");
7698 let aggregate = dead_code_aggregate(&root, files, projected);
7699 assert_live_item(&aggregate, "src/commands.rs", "get_primers");
7700 assert_live_item(&aggregate, "src/db.rs", "helper");
7701 assert_live_item(&aggregate, "src/db.rs", "private_helper");
7702 assert_live_item(&aggregate, "src/imported.rs", "imported_command");
7703 assert_live_item(&aggregate, "src/db.rs", "imported_helper");
7704 assert_dead_item(&aggregate, "src/commands.rs", "planted_dead");
7705 assert_dead_item(&aggregate, "src/unimported.rs", "false_command");
7706 assert_dead_item(&aggregate, "src/db.rs", "false_helper");
7707 }
7708
7709 #[test]
7710 fn dead_code_projection_rust_attribute_roots_are_cold_deterministic() {
7711 let dir = tempfile::tempdir().expect("tempdir");
7712 write_rust_attribute_entry_fixture(dir.path());
7713 let root = canonical_root(dir.path());
7714 let first = store_projected_snapshot(&root, ".store-tauri-cold-a");
7715 let second = store_projected_snapshot(&root, ".store-tauri-cold-b");
7716
7717 assert_snapshot_parts_eq("rust attribute roots cold", &first, &second);
7718 }
7719
7720 #[test]
7721 fn dead_code_projection_rust_attribute_roots_survive_unrelated_incremental_edit() {
7722 let dir = tempfile::tempdir().expect("tempdir");
7723 write_rust_attribute_entry_fixture(dir.path());
7724 let root = canonical_root(dir.path());
7725 let files_before = project_files(&root);
7726 let incremental_store =
7727 CallGraphStore::open(root.join(".store-tauri-incremental"), root.clone())
7728 .expect("open incremental store");
7729 incremental_store
7730 .cold_build(&files_before)
7731 .expect("initial cold build");
7732
7733 write_file(
7734 &root.join("src/unrelated.rs"),
7735 r#"// unrelated edit should not refresh command attribute facts
7736pub fn unrelated() -> u32 { 2 }
7737"#,
7738 );
7739 let stats = incremental_store
7740 .refresh_files(&[root.join("src/unrelated.rs")])
7741 .expect("refresh unrelated file");
7742 assert_eq!(stats.refreshed_own_files, 1);
7743 assert_eq!(stats.changed_files, vec!["src/unrelated.rs".to_string()]);
7744 assert!(
7745 !stats
7746 .surface_changed
7747 .iter()
7748 .any(|file| file == "src/commands.rs"),
7749 "unrelated edit must not refresh the command module: {stats:#?}"
7750 );
7751 let incremental = project_dead_code_snapshot(incremental_store.sqlite_path())
7752 .expect("project incremental snapshot");
7753
7754 let cold_store = CallGraphStore::open(root.join(".store-tauri-cold"), root.clone())
7755 .expect("open cold store");
7756 cold_store
7757 .cold_build(&project_files(&root))
7758 .expect("cold rebuild");
7759 let cold = project_dead_code_snapshot(cold_store.sqlite_path()).expect("project cold");
7760 assert_snapshot_parts_eq("rust attribute roots unrelated edit", &cold, &incremental);
7761
7762 let aggregate = dead_code_aggregate(&root, project_files(&root), incremental);
7763 assert_live_item(&aggregate, "src/commands.rs", "get_primers");
7764 assert_live_item(&aggregate, "src/db.rs", "helper");
7765 assert_live_item(&aggregate, "src/db.rs", "private_helper");
7766 assert_dead_item(&aggregate, "src/commands.rs", "planted_dead");
7767 }
7768
7769 fn assert_projection_fixture_coverage(root: &Path, snapshot: &CallgraphSnapshot) {
7770 let comparable = comparable_snapshot(snapshot);
7771 assert!(
7772 comparable
7773 .files
7774 .iter()
7775 .any(|file| file.extension().and_then(|ext| ext.to_str()) == Some("ts")),
7776 "fixture must include TypeScript files: {:#?}",
7777 comparable.files
7778 );
7779 assert!(
7780 comparable
7781 .files
7782 .iter()
7783 .any(|file| file.extension().and_then(|ext| ext.to_str()) == Some("js")),
7784 "fixture must include JavaScript files: {:#?}",
7785 comparable.files
7786 );
7787 assert!(
7788 comparable
7789 .files
7790 .iter()
7791 .any(|file| file.extension().and_then(|ext| ext.to_str()) == Some("rs")),
7792 "fixture must include Rust files: {:#?}",
7793 comparable.files
7794 );
7795
7796 let main_file = canonicalize_for_snapshot(&root.join("src/main.ts"));
7797 let private_dispatch_target = format!("{}::dispatch", main_file.display());
7798 assert!(
7799 comparable
7800 .outbound_calls
7801 .iter()
7802 .any(
7803 |(caller_file, caller_symbol, target, _)| caller_file == &main_file
7804 && caller_symbol == "main"
7805 && target == &private_dispatch_target
7806 ),
7807 "fixture must cover same-file private fallback target {private_dispatch_target}: {:#?}",
7808 comparable.outbound_calls
7809 );
7810 assert!(
7811 comparable
7812 .outbound_calls
7813 .iter()
7814 .any(|(_, _, target, _)| target.contains(DISPATCHED_CALLEE_SEPARATOR)),
7815 "fixture must cover method-dispatch suffixes: {:#?}",
7816 comparable.outbound_calls
7817 );
7818 assert!(
7819 comparable
7820 .exported_symbols
7821 .iter()
7822 .any(|(_, symbol, kind, _)| symbol == "runDefault"
7823 && kind == DEFAULT_EXPORT_MARKER_KIND),
7824 "fixture must cover default-export marker rows: {:#?}",
7825 comparable.exported_symbols
7826 );
7827 }
7828
7829 fn run_projection_scenario(name: &str, setup: fn(&Path), edit: fn(&Path) -> Vec<PathBuf>) {
7830 let dir = tempfile::tempdir().expect("tempdir");
7831 setup(dir.path());
7832 let root = canonical_root(dir.path());
7833 let files_before = project_files(&root);
7834 let incremental_store = CallGraphStore::open(
7835 root.join(format!(".store-dead-code-projection-{name}-incremental")),
7836 root.clone(),
7837 )
7838 .expect("open incremental store");
7839 incremental_store
7840 .cold_build(&files_before)
7841 .expect("initial cold build");
7842
7843 let (revision, previous) =
7844 project_dead_code_snapshot_with_revision(incremental_store.sqlite_path())
7845 .expect("initial projection");
7846 let changed = edit(&root);
7847 incremental_store
7848 .refresh_files(&changed)
7849 .expect("refresh changed files");
7850 let (_, incremental, verdict) =
7851 crate::callgraph_store::project_dead_code_snapshot_incremental(
7852 incremental_store.sqlite_path(),
7853 Some((revision.unwrap(), &previous)),
7854 )
7855 .expect("project incremental snapshot");
7856 assert_eq!(
7857 verdict.reason, None,
7858 "{name}: a spliced projection carries no full-projection reason"
7859 );
7860 assert_eq!(
7861 verdict.kind,
7862 ProjectionKind::Spliced,
7863 "{name}: a clean journal bridge must splice the previous snapshot"
7864 );
7865 let full =
7866 project_dead_code_snapshot(incremental_store.sqlite_path()).expect("full projection");
7867 let files = project_files(&root);
7868 assert_eq!(
7869 serde_json::to_vec(&dead_code_aggregate(
7870 &root,
7871 files.clone(),
7872 incremental.clone()
7873 ))
7874 .unwrap(),
7875 serde_json::to_vec(&dead_code_aggregate(&root, files, full)).unwrap(),
7876 "{name}: incremental and full projection aggregates must be byte-identical",
7877 );
7878
7879 let cold_store = CallGraphStore::open(
7880 root.join(format!(".store-dead-code-projection-{name}-cold")),
7881 root.clone(),
7882 )
7883 .expect("open cold store");
7884 cold_store
7885 .cold_build(&project_files(&root))
7886 .expect("cold rebuild");
7887 let cold =
7888 project_dead_code_snapshot(cold_store.sqlite_path()).expect("project cold snapshot");
7889
7890 assert_snapshot_parts_eq(name, &cold, &incremental);
7891 }
7892
7893 #[test]
7902 #[ignore = "manual benchmark; needs AFT_BENCH_REPO pointing at a large checkout"]
7903 fn dead_code_decision_b_benchmark() {
7904 let Ok(repo) = std::env::var("AFT_BENCH_REPO") else {
7905 eprintln!("AFT_BENCH_REPO unset; skipping");
7906 return;
7907 };
7908 macro_rules! mark {
7910 ($($a:tt)*) => {{ eprintln!($($a)*); let _ = std::io::Write::flush(&mut std::io::stderr()); }};
7911 }
7912 let root = canonical_root(Path::new(&repo));
7913 let files = project_files(&root);
7914 mark!(
7915 "\n=== Store-backed dead_code benchmark ===\nrepo: {}\nsource files (walk_project_files): {}\nstarted store cold_build...",
7916 root.display(),
7917 files.len()
7918 );
7919
7920 let store_dir = root.join(".aft-bench-store");
7923 let _ = std::fs::remove_dir_all(&store_dir);
7924 let store = CallGraphStore::open(store_dir.clone(), root.clone()).expect("open store");
7925 let t = Instant::now();
7926 let cold_stats = store.cold_build(&files).expect("store cold build");
7927 let store_build_ms = t.elapsed().as_millis();
7928 let t = Instant::now();
7929 let projected = project_dead_code_snapshot(store.sqlite_path()).expect("projection");
7930 let proj_ms = t.elapsed().as_millis();
7931 mark!(
7932 "store cold_build: {} ms ({:?}) + projection: {} ms = {} ms (exports={}, outbound={})\nstarted scan...",
7933 store_build_ms, cold_stats, proj_ms, store_build_ms + proj_ms,
7934 projected.exported_symbols.len(), projected.outbound_calls.len()
7935 );
7936
7937 let t = Instant::now();
7939 let _result = dead_code_aggregate(&root, files.clone(), projected.clone());
7940 let scan_ms = t.elapsed().as_millis();
7941 mark!("run_dead_code_scan (cold contributions): {} ms", scan_ms);
7942
7943 mark!(
7944 "\nSUMMARY files={} store_cold_plus_projection={}ms projection={}ms scan_cold={}ms total={}ms",
7945 files.len(),
7946 store_build_ms + proj_ms,
7947 proj_ms,
7948 scan_ms,
7949 store_build_ms + proj_ms + scan_ms
7950 );
7951 let _ = std::fs::remove_dir_all(&store_dir);
7952 }
7953
7954 #[cfg(unix)]
7955 fn projection_bench_cpu_ms() -> f64 {
7956 let mut usage = std::mem::MaybeUninit::<libc::rusage>::uninit();
7957 let result = unsafe { libc::getrusage(libc::RUSAGE_SELF, usage.as_mut_ptr()) };
7959 assert_eq!(result, 0);
7960 let usage = unsafe { usage.assume_init() };
7961 (usage.ru_utime.tv_sec + usage.ru_stime.tv_sec) as f64 * 1000.0
7962 + (usage.ru_utime.tv_usec + usage.ru_stime.tv_usec) as f64 / 1000.0
7963 }
7964
7965 #[cfg(unix)]
7966 #[test]
7967 #[ignore = "offline projection/rollup benchmark copies a production generation"]
7968 fn profile_incremental_projection_on_store_copy() {
7969 use rusqlite::{backup::Backup, Connection, OpenFlags};
7970 let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR"))
7971 .parent()
7972 .unwrap()
7973 .parent()
7974 .unwrap()
7975 .canonicalize()
7976 .unwrap();
7977 let root = std::env::var_os("AFT_PROJECTION_BENCH_ROOT")
7978 .map(PathBuf::from)
7979 .unwrap_or_else(|| workspace_root.clone())
7980 .canonicalize()
7981 .unwrap();
7982 assert!(
7983 root == workspace_root || root.starts_with(workspace_root.join("target")),
7984 "benchmark roots must be this checkout or a disposable copy below target"
7985 );
7986 let source_dir = PathBuf::from(
7987 std::env::var_os("AFT_CALLGRAPH_REFRESH_STORE")
7988 .expect("set AFT_CALLGRAPH_REFRESH_STORE to the source root-key directory"),
7989 );
7990 let pointer = std::fs::read_dir(&source_dir)
7991 .unwrap()
7992 .filter_map(Result::ok)
7993 .map(|entry| entry.path())
7994 .find(|path| path.extension().is_some_and(|ext| ext == "current"))
7995 .expect("source generation pointer");
7996 let source_path = source_dir.join(std::fs::read_to_string(pointer).unwrap().trim());
7997 let temp = tempfile::tempdir_in(workspace_root.join("target")).unwrap();
7998 let store_dir = temp.path().join("store");
7999 std::fs::create_dir_all(&store_dir).unwrap();
8000 let key = crate::search_index::artifact_cache_key(&root);
8001 let db = store_dir.join(format!("{key}.sqlite"));
8002 {
8003 let source =
8004 Connection::open_with_flags(source_path, OpenFlags::SQLITE_OPEN_READ_ONLY).unwrap();
8005 let mut destination = Connection::open(&db).unwrap();
8006 Backup::new(&source, &mut destination)
8007 .unwrap()
8008 .run_to_completion(256, Duration::from_millis(5), None)
8009 .unwrap();
8010 destination
8011 .execute(
8012 "UPDATE backend_file_state SET workspace_root = ?1",
8013 [root.display().to_string()],
8014 )
8015 .unwrap();
8016 }
8017 let store = CallGraphStore::open(store_dir, root.clone()).unwrap();
8018 let changed_count = std::env::var("AFT_PROJECTION_BENCH_CHANGED_FILES")
8021 .ok()
8022 .and_then(|value| value.parse::<usize>().ok())
8023 .unwrap_or(1);
8024 let changed_paths = std::env::var_os("AFT_PROJECTION_BENCH_CHANGED_PATHS");
8025 let changed = if let Some(changed_paths) = changed_paths.as_ref() {
8026 std::fs::read_to_string(changed_paths)
8027 .unwrap()
8028 .lines()
8029 .filter(|line| !line.is_empty())
8030 .take(changed_count)
8031 .map(|relative| root.join(relative))
8032 .collect::<Vec<_>>()
8033 } else {
8034 (0..changed_count)
8035 .map(|index| temp.path().join(format!("probe-{index}.rs")))
8036 .collect::<Vec<_>>()
8037 };
8038 assert_eq!(
8039 changed.len(),
8040 changed_count,
8041 "changed-path corpus is too small"
8042 );
8043 if changed_paths.is_none() {
8044 for path in &changed {
8045 write_file(path, "pub fn projection_probe() {}\n");
8046 }
8047 store.refresh_files(&changed).unwrap();
8048 }
8049 let (revision, previous) =
8050 project_dead_code_snapshot_with_revision(store.sqlite_path()).unwrap();
8051 let mut refresh_ms = 0.0;
8052 if std::env::var_os("AFT_PROJECTION_BENCH_JOURNAL_ONLY").is_some() {
8053 let next = revision.unwrap() + 1;
8054 let callers = changed
8055 .iter()
8056 .map(|path| {
8057 path.strip_prefix(&root)
8058 .unwrap()
8059 .to_string_lossy()
8060 .replace('\\', "/")
8061 })
8062 .collect::<BTreeSet<_>>();
8063 let payload = serde_json::to_string(&(next, callers)).unwrap();
8064 let conn = rusqlite::Connection::open(store.sqlite_path()).unwrap();
8065 conn.execute(
8066 "INSERT OR REPLACE INTO meta(k, v) VALUES('projection_write_revision', ?1)",
8067 [next.to_string()],
8068 )
8069 .unwrap();
8070 conn.execute(
8071 "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
8072 rusqlite::params![format!("projection_delta_{}", next % 64), payload],
8073 )
8074 .unwrap();
8075 } else {
8076 if changed_paths.is_none() {
8077 for path in &changed {
8078 write_file(
8079 path,
8080 "pub fn projection_probe() { projection_probe_target(); }\npub fn projection_probe_target() {}\n",
8081 );
8082 }
8083 }
8084 let refresh_started = Instant::now();
8085 store.refresh_files(&changed).unwrap();
8086 refresh_ms = refresh_started.elapsed().as_secs_f64() * 1000.0;
8087 if let Some(journal_paths) = std::env::var_os("AFT_PROJECTION_BENCH_JOURNAL_PATHS") {
8088 let current = store.projection_write_revision().unwrap().unwrap();
8089 let callers = std::fs::read_to_string(journal_paths)
8090 .unwrap()
8091 .lines()
8092 .filter(|line| !line.is_empty())
8093 .map(str::to_owned)
8094 .collect::<BTreeSet<_>>();
8095 let payload = serde_json::to_string(&(current, callers)).unwrap();
8096 let conn = rusqlite::Connection::open(store.sqlite_path()).unwrap();
8097 conn.execute(
8098 "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
8099 rusqlite::params![format!("projection_delta_{}", current % 64), payload],
8100 )
8101 .unwrap();
8102 }
8103 }
8104 let snapshot_replay_ms = refresh_ms;
8105 let cpu = projection_bench_cpu_ms();
8106 let started = Instant::now();
8107 let full = project_dead_code_snapshot(store.sqlite_path()).unwrap();
8108 let full_elapsed = started.elapsed();
8109 let full_ms = full_elapsed.as_secs_f64() * 1000.0;
8110 let full_cpu = projection_bench_cpu_ms() - cpu;
8111 crate::callgraph_store::take_projection_work();
8112 let cpu = projection_bench_cpu_ms();
8113 let started = Instant::now();
8114 let (_, incremental, verdict) =
8115 crate::callgraph_store::project_dead_code_snapshot_incremental(
8116 store.sqlite_path(),
8117 Some((revision.unwrap(), &previous)),
8118 )
8119 .unwrap();
8120 let delta_elapsed = started.elapsed();
8121 let delta_ms = delta_elapsed.as_secs_f64() * 1000.0;
8122 let delta_cpu = projection_bench_cpu_ms() - cpu;
8123 let work = crate::callgraph_store::take_projection_work();
8124 if std::env::var_os("AFT_PROJECTION_BENCH_PROJECTION_ONLY").is_some() {
8125 let mut costs = ProjectionCostEstimates::default();
8126 costs.observe(
8127 ProjectionVerdict {
8128 kind: ProjectionKind::Full,
8129 reason: Some("cold"),
8130 journal_bytes: 0,
8131 changed_files: 0,
8132 },
8133 full_elapsed,
8134 );
8135 costs.observe(verdict, delta_elapsed);
8136 let (_, _, predicted, _) = project_dead_code_snapshot_incremental_with_costs(
8137 store.sqlite_path(),
8138 Some((revision.unwrap(), &previous)),
8139 costs,
8140 )
8141 .unwrap();
8142 eprintln!(
8143 "projection_crossover changed_files={} refresh_ms={refresh_ms:.3} snapshot_replay_ms={:.3} full_ms={full_ms:.3} full_cpu_ms={full_cpu:.3} splice_ms={delta_ms:.3} splice_cpu_ms={delta_cpu:.3} measured={:?} predicted={:?} predicted_reason={:?} outbound_rows_read={}",
8144 verdict.changed_files, snapshot_replay_ms + delta_ms, verdict.kind, predicted.kind, predicted.reason, work.1
8145 );
8146 return;
8147 }
8148 let mut job = InspectJob {
8149 job_id: 87,
8150 key: JobKey::for_project_category(InspectCategory::DeadCode),
8151 category: InspectCategory::DeadCode,
8152 scope_files: full.files.clone(),
8153 project_root: root.clone(),
8154 inspect_dir: temp.path().join("inspect"),
8155 config: Arc::new(Config {
8156 project_root: Some(root.clone()),
8157 ..Config::default()
8158 }),
8159 symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
8160 inspect_writer: true,
8161 callgraph_writer: false,
8162 callgraph_snapshot: Some(Arc::new(full)),
8163 };
8164 let contribution_load_started = Instant::now();
8165 let copied_inspect = std::env::var_os("AFT_PROJECTION_BENCH_INSPECT_STORE");
8166 let contributions = if let Some(source_path) = copied_inspect.as_ref() {
8167 let project_key = crate::path_identity::project_scope_key(&root);
8168 let project_inspect_dir = job.inspect_dir.join(&project_key);
8169 std::fs::create_dir_all(&project_inspect_dir).unwrap();
8170 let inspect_db = project_inspect_dir.join(format!("{project_key}.sqlite"));
8171 {
8172 let source =
8173 Connection::open_with_flags(source_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
8174 .unwrap();
8175 let mut destination = Connection::open(&inspect_db).unwrap();
8176 Backup::new(&source, &mut destination)
8177 .unwrap()
8178 .run_to_completion(256, Duration::from_millis(5), None)
8179 .unwrap();
8180 for table in ["tier2_contributions", "tier2_aggregates", "tier2_meta"] {
8181 destination
8182 .execute(
8183 &format!("UPDATE {table} SET project_key = ?1"),
8184 [&project_key],
8185 )
8186 .unwrap();
8187 }
8188 }
8189 let cache = InspectCache::open(job.inspect_dir.clone(), root.clone()).unwrap();
8190 load_contributions(&cache, &job).unwrap()
8191 } else {
8192 crate::inspect::scanners::dead_code::run_dead_code_scan(&job)
8193 .outcome
8194 .unwrap()
8195 .contributions
8196 };
8197 let contribution_load_ms = contribution_load_started.elapsed().as_secs_f64() * 1000.0;
8198 let public_api_files = crate::inspect::scanners::dead_code::collect_public_api_files(&root);
8199 let roles = crate::inspect::entry_points::resolve_project_roles(&root);
8200 let cpu = projection_bench_cpu_ms();
8201 let started = Instant::now();
8202 let (full_aggregate, rollup_state, _) =
8203 crate::inspect::scanners::dead_code::aggregate_dead_code_contributions_incremental(
8204 &root,
8205 job.callgraph_snapshot.as_deref().unwrap(),
8206 &contributions,
8207 &public_api_files,
8208 &roles,
8209 None,
8210 Some("store-copy-benchmark"),
8211 None,
8212 &BTreeSet::new(),
8213 );
8214 let full_rollup_ms = started.elapsed().as_secs_f64() * 1000.0;
8215 let full_rollup_cpu = projection_bench_cpu_ms() - cpu;
8216 job.callgraph_snapshot = Some(Arc::new(incremental));
8217 let changed_relative = changed
8218 .iter()
8219 .map(|path| {
8220 path.strip_prefix(&root)
8221 .unwrap()
8222 .to_string_lossy()
8223 .replace('\\', "/")
8224 })
8225 .collect::<BTreeSet<_>>();
8226 let cpu = projection_bench_cpu_ms();
8227 let started = Instant::now();
8228 let (delta_aggregate, _, rollup_verdict) =
8229 crate::inspect::scanners::dead_code::aggregate_dead_code_contributions_incremental(
8230 &root,
8231 job.callgraph_snapshot.as_deref().unwrap(),
8232 &contributions,
8233 &public_api_files,
8234 &roles,
8235 None,
8236 Some("store-copy-benchmark"),
8237 Some(&rollup_state),
8238 &changed_relative,
8239 );
8240 let delta_rollup_ms = started.elapsed().as_secs_f64() * 1000.0;
8241 let delta_rollup_cpu = projection_bench_cpu_ms() - cpu;
8242 assert_eq!(
8243 rollup_verdict.kind,
8244 crate::inspect::scanners::dead_code::RollupKind::Incremental
8245 );
8246 assert_eq!(
8247 serde_json::to_vec(&full_aggregate).unwrap(),
8248 serde_json::to_vec(&delta_aggregate).unwrap()
8249 );
8250 let manager = InspectManager::new();
8251 manager.cache_callgraph_projection(
8252 CallgraphProjectionIdentity {
8253 project_root: root,
8254 generation: None,
8255 legacy_sqlite_path: Some(db),
8256 write_revision: store.projection_write_revision().unwrap().unwrap(),
8257 },
8258 job.callgraph_snapshot.clone().unwrap(),
8259 );
8260 eprintln!("projection_bench changed_files={changed_count} rows={} contributions={} contribution_source={} contribution_load_ms={contribution_load_ms:.3}; refresh_ms={refresh_ms:.3} snapshot_replay_ms={:.3}; before snapshot={full_ms:.3} cpu={full_cpu:.3} rollup={full_rollup_ms:.3} rollup_cpu={full_rollup_cpu:.3}; after snapshot={delta_ms:.3} cpu={delta_cpu:.3} rollup={delta_rollup_ms:.3} rollup_cpu={delta_rollup_cpu:.3}; full_projections={} outbound_rows_read={}", previous.outbound_calls.len(), contributions.len(), if copied_inspect.is_some() { "inspect_copy" } else { "source_scan" }, snapshot_replay_ms + delta_ms, work.0, work.1);
8261 eprintln!(
8262 "projection_bench callgraph_memory={:?}",
8263 manager.callgraph_projection_estimated_memory()
8264 );
8265 }
8266
8267 fn store_projected_snapshot(root: &Path, store_name: &str) -> CallgraphSnapshot {
8268 let store =
8269 CallGraphStore::open(root.join(store_name), root.to_path_buf()).expect("open store");
8270 store
8271 .cold_build(&project_files(root))
8272 .expect("store cold build");
8273 project_dead_code_snapshot(store.sqlite_path()).expect("project snapshot")
8274 }
8275
8276 fn dead_code_aggregate(
8277 root: &Path,
8278 scope_files: Vec<PathBuf>,
8279 snapshot: CallgraphSnapshot,
8280 ) -> Value {
8281 let job = InspectJob {
8282 job_id: 86,
8283 key: JobKey::for_project_category(InspectCategory::DeadCode),
8284 category: InspectCategory::DeadCode,
8285 scope_files,
8286 project_root: root.to_path_buf(),
8287 inspect_dir: root.join(".aft-cache").join("inspect"),
8288 config: Arc::new(Config {
8289 project_root: Some(root.to_path_buf()),
8290 ..Config::default()
8291 }),
8292 symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
8293 inspect_writer: true,
8294 callgraph_writer: true,
8295 callgraph_snapshot: Some(Arc::new(snapshot)),
8296 };
8297 crate::inspect::scanners::dead_code::run_dead_code_scan(&job)
8298 .outcome
8299 .expect("dead_code scan succeeds")
8300 .aggregate
8301 }
8302
8303 fn assert_snapshot_parts_eq(
8304 label: &str,
8305 expected: &CallgraphSnapshot,
8306 actual: &CallgraphSnapshot,
8307 ) {
8308 let expected = comparable_snapshot(expected);
8309 let actual = comparable_snapshot(actual);
8310 assert_eq!(
8311 actual, expected,
8312 "{label} store-projected snapshot must match cold store snapshot"
8313 );
8314 }
8315
8316 fn comparable_snapshot(snapshot: &CallgraphSnapshot) -> ComparableSnapshot {
8317 ComparableSnapshot {
8318 files: snapshot.files.iter().cloned().collect(),
8319 exported_symbols: snapshot
8320 .exported_symbols
8321 .iter()
8322 .map(|export| {
8323 (
8324 export.file.clone(),
8325 export.symbol.clone(),
8326 export.kind.clone(),
8327 export.line,
8328 )
8329 })
8330 .collect(),
8331 outbound_calls: snapshot
8332 .outbound_calls
8333 .iter()
8334 .map(|call| {
8335 (
8336 call.caller_file.clone(),
8337 call.caller_symbol.clone(),
8338 call.target.clone(),
8339 call.line,
8340 )
8341 })
8342 .collect(),
8343 entry_points: snapshot.entry_points.clone(),
8344 entry_point_symbols: snapshot.entry_point_symbols.clone(),
8345 }
8346 }
8347
8348 fn assert_dead_item(aggregate: &Value, file: &str, symbol: &str) {
8349 assert!(
8350 aggregate_has_item(aggregate, file, symbol),
8351 "expected {file}::{symbol} to be reported dead: {aggregate:#}"
8352 );
8353 }
8354
8355 fn assert_live_item(aggregate: &Value, file: &str, symbol: &str) {
8356 assert!(
8357 !aggregate_has_item(aggregate, file, symbol),
8358 "expected {file}::{symbol} to be live/not reported dead: {aggregate:#}"
8359 );
8360 }
8361
8362 fn aggregate_has_item(aggregate: &Value, file: &str, symbol: &str) -> bool {
8363 let Some(items) = aggregate.get("items").and_then(Value::as_array) else {
8364 return false;
8365 };
8366 items.iter().any(|item| {
8367 item.get("file").and_then(Value::as_str) == Some(file)
8368 && item.get("symbol").and_then(Value::as_str) == Some(symbol)
8369 })
8370 }
8371
8372 fn project_files(root: &Path) -> Vec<PathBuf> {
8373 walk_project_files(root).collect()
8374 }
8375
8376 fn canonical_root(root: &Path) -> PathBuf {
8377 std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf())
8378 }
8379
8380 fn write_file(path: &Path, content: &str) {
8381 if let Some(parent) = path.parent() {
8382 std::fs::create_dir_all(parent).expect("create parent");
8383 }
8384 std::fs::write(path, content).expect("write fixture");
8385 bump_mtime(path);
8386 }
8387
8388 fn bump_mtime(path: &Path) {
8389 let secs = NEXT_MTIME.fetch_add(1, AtomicOrdering::SeqCst);
8390 filetime::set_file_mtime(path, FileTime::from_unix_time(secs, 0)).expect("bump mtime");
8391 }
8392
8393 fn remove_file(path: &Path) {
8394 std::fs::remove_file(path).expect("remove fixture");
8395 }
8396
8397 fn write_projection_fixture(root: &Path) {
8398 write_file(
8399 &root.join("package.json"),
8400 r#"{"name":"dead-code-projection-fixture","type":"module","main":"src/main.ts"}"#,
8401 );
8402 write_file(
8403 &root.join("Cargo.toml"),
8404 r#"[package]
8405name = "dead_code_projection_fixture"
8406version = "0.1.0"
8407edition = "2021"
8408"#,
8409 );
8410 write_file(
8411 &root.join("src/main.ts"),
8412 r#"import runDefault from "./default";
8413import { knownLive } from "./live";
8414import { jsEntry } from "./app.js";
8415
8416export function main() {
8417 dispatch();
8418 runDefault();
8419 jsEntry();
8420}
8421
8422function dispatch() {
8423 knownLive();
8424 const service = { render() {} };
8425 service.render();
8426}
8427"#,
8428 );
8429 write_file(
8430 &root.join("src/default.ts"),
8431 r#"export default function runDefault() {}
8432"#,
8433 );
8434 write_file(
8435 &root.join("src/live.ts"),
8436 r#"export function knownLive() {}
8437"#,
8438 );
8439 write_file(
8440 &root.join("src/dead.ts"),
8441 r#"export function knownDead() {}
8442"#,
8443 );
8444 write_file(
8445 &root.join("src/render.ts"),
8446 r#"export function render() {}
8447"#,
8448 );
8449 write_file(
8450 &root.join("src/other_render.ts"),
8451 r#"export function render() {}
8452"#,
8453 );
8454 write_file(
8455 &root.join("src/app.js"),
8456 r#"import { jsHelper } from "./js_helper.js";
8457
8458export function jsEntry() {
8459 jsHelper();
8460}
8461"#,
8462 );
8463 write_file(
8464 &root.join("src/js_helper.js"),
8465 r#"export function jsHelper() {}
8466"#,
8467 );
8468 write_file(
8469 &root.join("src/lib.rs"),
8470 r#"mod util;
8471use crate::util::rust_helper;
8472
8473pub fn rust_entry() {
8474 rust_helper();
8475}
8476"#,
8477 );
8478 write_file(
8479 &root.join("src/util.rs"),
8480 r#"pub fn rust_helper() {}
8481"#,
8482 );
8483 }
8484
8485 fn write_rust_attribute_entry_fixture(root: &Path) {
8486 write_file(
8487 &root.join("src/main.rs"),
8488 r#"mod commands;
8489mod db;
8490mod imported;
8491mod unimported;
8492mod unrelated;
8493
8494fn main() {
8495 tauri::generate_handler![commands::get_primers, imported::imported_command];
8496}
8497"#,
8498 );
8499 write_file(
8500 &root.join("src/commands.rs"),
8501 r#"use crate::db;
8502
8503#[tauri::command]
8504pub fn get_primers() -> String {
8505 db::helper()
8506}
8507
8508pub fn planted_dead() -> String {
8509 "dead".to_string()
8510}
8511
8512#[tauri::command]
8513fn private_command() -> String {
8514 db::private_helper()
8515}
8516"#,
8517 );
8518 write_file(
8519 &root.join("src/imported.rs"),
8520 r#"use crate::db;
8521use tauri::command;
8522
8523#[command]
8524pub fn imported_command() -> String {
8525 db::imported_helper()
8526}
8527"#,
8528 );
8529 write_file(
8530 &root.join("src/unimported.rs"),
8531 r#"use crate::db;
8532
8533#[command]
8534pub fn false_command() -> String {
8535 db::false_helper()
8536}
8537"#,
8538 );
8539 write_file(
8540 &root.join("src/db.rs"),
8541 r#"pub fn helper() -> String { "live".to_string() }
8542pub fn imported_helper() -> String { "live".to_string() }
8543pub fn private_helper() -> String { "live".to_string() }
8544pub fn false_helper() -> String { "dead".to_string() }
8545"#,
8546 );
8547 write_file(
8548 &root.join("src/unrelated.rs"),
8549 r#"pub fn unrelated() -> u32 { 1 }
8550"#,
8551 );
8552 }
8553
8554 fn setup_projection_rename(root: &Path) {
8555 write_file(
8556 &root.join("a.ts"),
8557 r#"export function outer() {
8558 inner();
8559}
8560
8561export function inner() {}
8562"#,
8563 );
8564 }
8565
8566 fn edit_projection_rename(root: &Path) -> Vec<PathBuf> {
8567 let path = root.join("a.ts");
8568 write_file(
8569 &path,
8570 r#"export function outer() {
8571 renamed();
8572}
8573
8574export function renamed() {}
8575"#,
8576 );
8577 vec![path]
8578 }
8579
8580 fn setup_projection_delete(root: &Path) {
8581 write_file(&root.join("other.ts"), "export function foo() {}\n");
8582 write_file(
8583 &root.join("main.ts"),
8584 r#"import { foo } from "./foo";
8585export function main() { foo(); }
8586"#,
8587 );
8588 write_file(&root.join("foo.ts"), "export function foo() {}\n");
8589 }
8590
8591 fn edit_projection_delete(root: &Path) -> Vec<PathBuf> {
8592 let path = root.join("foo.ts");
8593 remove_file(&path);
8594 vec![path]
8595 }
8596
8597 fn setup_projection_barrel(root: &Path) {
8598 write_file(
8599 &root.join("main.ts"),
8600 r#"import { foo } from "./barrel";
8601export function main() { foo(); }
8602"#,
8603 );
8604 write_file(&root.join("barrel.ts"), "export { foo } from \"./foo\";\n");
8605 write_file(&root.join("foo.ts"), "export function foo() {}\n");
8606 }
8607
8608 fn edit_projection_barrel_delete(root: &Path) -> Vec<PathBuf> {
8609 let path = root.join("barrel.ts");
8610 remove_file(&path);
8611 vec![path]
8612 }
8613
8614 fn setup_projection_dispatch(root: &Path) {
8615 write_file(
8616 &root.join("main.ts"),
8617 r#"export function main() {
8618 const service = { render() {}, paint() {} };
8619 service.render();
8620}
8621"#,
8622 );
8623 write_file(&root.join("render.ts"), "export function render() {}\n");
8624 write_file(&root.join("paint.ts"), "export function paint() {}\n");
8625 }
8626
8627 fn edit_projection_dispatch(root: &Path) -> Vec<PathBuf> {
8628 let path = root.join("main.ts");
8629 write_file(
8630 &path,
8631 r#"export function main() {
8632 const service = { render() {}, paint() {} };
8633 service.paint();
8634}
8635"#,
8636 );
8637 vec![path]
8638 }
8639
8640 fn setup_projection_body_only(root: &Path) {
8641 write_file(
8642 &root.join("main.ts"),
8643 r#"import { foo } from "./foo";
8644export function main() { foo(); }
8645"#,
8646 );
8647 write_file(
8648 &root.join("foo.ts"),
8649 r#"export function foo() {
8650 return 1;
8651}
8652"#,
8653 );
8654 }
8655
8656 fn edit_projection_body_only(root: &Path) -> Vec<PathBuf> {
8657 let path = root.join("foo.ts");
8658 write_file(
8659 &path,
8660 r#"export function foo() {
8661 return 2;
8662}
8663"#,
8664 );
8665 vec![path]
8666 }
8667
8668 #[test]
8669 fn forced_paths_downgrade_only_when_strict_hash_matches_cached_fact() {
8670 let dir = tempfile::tempdir().expect("tempdir");
8671 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
8672 let unchanged = root.join("unchanged.ts");
8673 let changed = root.join("changed.ts");
8674 let oversized = root.join("oversized.ts");
8675 std::fs::write(&unchanged, "export const value = 1;\n").expect("write unchanged");
8676 std::fs::write(&changed, "export const before = 1;\n").expect("write changed baseline");
8677 let unchanged_freshness =
8678 cache_freshness::collect(&unchanged).expect("unchanged freshness");
8679 let changed_freshness = cache_freshness::collect(&changed).expect("changed freshness");
8680 std::fs::write(&changed, "export const after_ = 2;\n").expect("change same-size content");
8681 let oversized_file = std::fs::File::create(&oversized).expect("create oversized");
8682 oversized_file
8683 .set_len(cache_freshness::CONTENT_HASH_SIZE_CAP + 1)
8684 .expect("size oversized");
8685 let oversized_freshness =
8686 cache_freshness::collect(&oversized).expect("oversized freshness");
8687 let cached = vec![
8688 CachedContributionFreshness {
8689 file_path: PathBuf::from("unchanged.ts"),
8690 freshness: unchanged_freshness,
8691 },
8692 CachedContributionFreshness {
8693 file_path: PathBuf::from("changed.ts"),
8694 freshness: changed_freshness,
8695 },
8696 CachedContributionFreshness {
8697 file_path: PathBuf::from("oversized.ts"),
8698 freshness: oversized_freshness,
8699 },
8700 ];
8701
8702 let (remaining, downgraded) = downgrade_unchanged_forced_paths_with_freshness(
8703 &root,
8704 &cached,
8705 vec![
8706 PathBuf::from("unchanged.ts"),
8707 PathBuf::from("changed.ts"),
8708 PathBuf::from("oversized.ts"),
8709 ],
8710 );
8711
8712 assert_eq!(downgraded, 1);
8713 assert_eq!(
8714 remaining,
8715 vec![PathBuf::from("changed.ts"), PathBuf::from("oversized.ts")]
8716 );
8717 }
8718
8719 #[test]
8720 fn cached_projection_retains_root_cost_estimates_across_revisions() {
8721 let manager = InspectManager::new();
8722 let root = PathBuf::from("/cost-root");
8723 let identity = CallgraphProjectionIdentity {
8724 project_root: root.clone(),
8725 generation: Some("generation".to_string()),
8726 legacy_sqlite_path: None,
8727 write_revision: 1,
8728 };
8729 let snapshot = Arc::new(CallgraphSnapshot {
8730 generated_at: None,
8731 files: Vec::new(),
8732 exported_symbols: Vec::new(),
8733 outbound_calls: Vec::new(),
8734 entry_points: BTreeSet::new(),
8735 entry_point_symbols: BTreeMap::new(),
8736 });
8737 manager.cache_callgraph_projection(identity.clone(), Arc::clone(&snapshot));
8738 manager.observe_callgraph_projection_cost(
8739 &root,
8740 ProjectionVerdict {
8741 kind: ProjectionKind::Full,
8742 reason: Some("cold"),
8743 journal_bytes: 0,
8744 changed_files: 0,
8745 },
8746 Duration::from_nanos(100),
8747 );
8748 manager.observe_callgraph_projection_cost(
8749 &root,
8750 ProjectionVerdict {
8751 kind: ProjectionKind::Spliced,
8752 reason: None,
8753 journal_bytes: 20,
8754 changed_files: 4,
8755 },
8756 Duration::from_nanos(120),
8757 );
8758 let mut next_identity = identity;
8759 next_identity.write_revision = 2;
8760 manager.cache_callgraph_projection(next_identity.clone(), snapshot);
8761
8762 assert!(
8763 manager
8764 .callgraph_projection_costs(&next_identity)
8765 .splice_is_costlier(4),
8766 "cost estimates must survive replacing a root snapshot at a new revision"
8767 );
8768 }
8769
8770 #[test]
8771 fn fleet_budget_drops_oldest_whole_root_snapshot() {
8772 fn slot(root: &str) -> Arc<ProjectionSlot> {
8773 Arc::new(Mutex::new(Some(CachedCallgraphProjection {
8774 identity: CallgraphProjectionIdentity {
8775 project_root: PathBuf::from(root),
8776 generation: Some("generation".to_string()),
8777 legacy_sqlite_path: None,
8778 write_revision: 1,
8779 },
8780 snapshot: Arc::new(CallgraphSnapshot {
8781 generated_at: None,
8782 files: Vec::new(),
8783 exported_symbols: Vec::new(),
8784 outbound_calls: Vec::new(),
8785 entry_points: BTreeSet::new(),
8786 entry_point_symbols: BTreeMap::new(),
8787 }),
8788 estimated_bytes: 600 * 1024 * 1024,
8789 costs: ProjectionCostEstimates::default(),
8790 rollup: None,
8791 })))
8792 }
8793
8794 let first = slot("/first");
8795 let second = slot("/second");
8796 let third = slot("/third");
8797 let mut fleet = ProjectionFleet::default();
8798 fleet.admit(
8799 PathBuf::from("/first"),
8800 Arc::downgrade(&first),
8801 400 * 1024 * 1024,
8802 DEAD_CODE_SNAPSHOT_FLEET_BUDGET,
8803 );
8804 fleet.admit(
8805 PathBuf::from("/second"),
8806 Arc::downgrade(&second),
8807 400 * 1024 * 1024,
8808 DEAD_CODE_SNAPSHOT_FLEET_BUDGET,
8809 );
8810 fleet.touch(Path::new("/first"));
8811 fleet.admit(
8812 PathBuf::from("/third"),
8813 Arc::downgrade(&third),
8814 400 * 1024 * 1024,
8815 DEAD_CODE_SNAPSHOT_FLEET_BUDGET,
8816 );
8817
8818 assert!(first.lock().unwrap().is_some());
8819 assert!(second.lock().unwrap().is_none());
8820 assert!(third.lock().unwrap().is_some());
8821 assert_eq!(
8822 fleet.census(),
8823 DeadCodeSnapshotCensus {
8824 roots: 2,
8825 bytes: 800 * 1024 * 1024,
8826 drops: 1,
8827 }
8828 );
8829 }
8830
8831 #[test]
8832 fn projection_estimator_and_verdict_report_only_projection_work() {
8833 let phases = Tier2PhaseTimings {
8834 snapshot: Duration::from_millis(2_620),
8835 projection_cost: Duration::from_millis(187),
8836 rollup: Duration::from_millis(3_186),
8837 rollup_verdict: Some(crate::inspect::scanners::dead_code::RollupVerdict {
8838 kind: crate::inspect::scanners::dead_code::RollupKind::Incremental,
8839 reason: None,
8840 }),
8841 projection: Some(ProjectionVerdict {
8842 kind: ProjectionKind::Spliced,
8843 reason: None,
8844 journal_bytes: 2_397,
8845 changed_files: 46,
8846 }),
8847 ..Tier2PhaseTimings::default()
8848 };
8849
8850 assert_eq!(
8851 projection_estimator_duration(&phases),
8852 Duration::from_millis(187)
8853 );
8854 let line = phases.render(InspectCategory::DeadCode, Path::new("/root"), "test-key");
8855 assert!(line.contains("snapshot=2620ms projection_ms=187"));
8856 assert!(line.contains("rollup_ms=3186 rollup="));
8857 assert!(line.contains("projection=spliced journal_bytes=2397 changed_files=46"));
8858 }
8859
8860 #[test]
8861 fn rollup_cost_cannot_poison_projection_crossover() {
8862 let manager = InspectManager::new();
8863 let root = PathBuf::from("/projection-only-cost-root");
8864 let identity = CallgraphProjectionIdentity {
8865 project_root: root.clone(),
8866 generation: Some("generation".to_string()),
8867 legacy_sqlite_path: None,
8868 write_revision: 1,
8869 };
8870 manager.cache_callgraph_projection(
8871 identity.clone(),
8872 Arc::new(CallgraphSnapshot {
8873 generated_at: None,
8874 files: Vec::new(),
8875 exported_symbols: Vec::new(),
8876 outbound_calls: Vec::new(),
8877 entry_points: BTreeSet::new(),
8878 entry_point_symbols: BTreeMap::new(),
8879 }),
8880 );
8881 for (verdict, projection_ms, rollup_ms) in [
8882 (
8883 ProjectionVerdict {
8884 kind: ProjectionKind::Full,
8885 reason: Some("cold"),
8886 journal_bytes: 0,
8887 changed_files: 0,
8888 },
8889 100,
8890 1_000,
8891 ),
8892 (
8893 ProjectionVerdict {
8894 kind: ProjectionKind::Spliced,
8895 reason: None,
8896 journal_bytes: 200,
8897 changed_files: 4,
8898 },
8899 40,
8900 2_000,
8901 ),
8902 ] {
8903 let phases = Tier2PhaseTimings {
8904 projection_cost: Duration::from_millis(projection_ms),
8905 rollup: Duration::from_millis(rollup_ms),
8906 ..Tier2PhaseTimings::default()
8907 };
8908 manager.observe_callgraph_projection_cost(
8909 &root,
8910 verdict,
8911 projection_estimator_duration(&phases),
8912 );
8913 }
8914
8915 assert!(
8916 !manager
8917 .callgraph_projection_costs(&identity)
8918 .splice_is_costlier(5),
8919 "rollup work must not force a full snapshot projection"
8920 );
8921 }
8922
8923 #[test]
8924 fn every_tier2_phases_line_path_renders_projection_key() {
8925 for (category, reason) in [
8926 (InspectCategory::DeadCode, "no_callgraph"),
8927 (InspectCategory::DeadCode, "aggregate_reused"),
8928 (InspectCategory::DeadCode, "provided_snapshot"),
8929 (InspectCategory::UnusedExports, "not_required"),
8930 (InspectCategory::Duplicates, "not_required"),
8931 (InspectCategory::Cycles, "not_required"),
8932 (InspectCategory::Complexity, "not_required"),
8933 ] {
8934 let phases = Tier2PhaseTimings {
8935 projection_skip_reason: Some(reason),
8936 ..Tier2PhaseTimings::default()
8937 };
8938 let line = phases.render(category, Path::new("/root"), "test-key");
8939 assert!(
8940 line.contains(&format!(" projection=none reason={reason} ")),
8941 "{category} phases line omitted its projection verdict: {line}"
8942 );
8943 assert!(line.contains(" journal_bytes=0 changed_files=0 "));
8944 }
8945 }
8946
8947 #[test]
8948 fn perf_tier2_phases_line_renders_each_projection_verdict() {
8949 assert_eq!(
8950 render_rollup_suffix(crate::inspect::scanners::dead_code::RollupVerdict {
8951 kind: crate::inspect::scanners::dead_code::RollupKind::Incremental,
8952 reason: None,
8953 }),
8954 " rollup=incremental"
8955 );
8956 assert_eq!(
8957 render_rollup_suffix(crate::inspect::scanners::dead_code::RollupVerdict {
8958 kind: crate::inspect::scanners::dead_code::RollupKind::Full,
8959 reason: Some("journal_gap"),
8960 }),
8961 " rollup=full reason=journal_gap"
8962 );
8963
8964 let spliced = render_projection_suffix(ProjectionVerdict {
8965 kind: ProjectionKind::Spliced,
8966 reason: None,
8967 journal_bytes: 4096,
8968 changed_files: 3,
8969 });
8970 assert_eq!(
8971 spliced, " projection=spliced journal_bytes=4096 changed_files=3",
8972 "a spliced verdict must omit the reason field"
8973 );
8974
8975 let cold = render_projection_suffix(ProjectionVerdict {
8976 kind: ProjectionKind::Full,
8977 reason: Some("cold"),
8978 journal_bytes: 0,
8979 changed_files: 0,
8980 });
8981 assert_eq!(
8982 cold,
8983 " projection=full reason=cold journal_bytes=0 changed_files=0"
8984 );
8985
8986 let gap = render_projection_suffix(ProjectionVerdict {
8987 kind: ProjectionKind::Full,
8988 reason: Some("journal_gap"),
8989 journal_bytes: 0,
8990 changed_files: 0,
8991 });
8992 assert_eq!(
8993 gap,
8994 " projection=full reason=journal_gap journal_bytes=0 changed_files=0"
8995 );
8996
8997 let costlier = render_projection_suffix(ProjectionVerdict {
8998 kind: ProjectionKind::Full,
8999 reason: Some("splice_costlier"),
9000 journal_bytes: 261_465,
9001 changed_files: 1_486,
9002 });
9003 assert_eq!(
9004 costlier,
9005 " projection=full reason=splice_costlier journal_bytes=261465 changed_files=1486"
9006 );
9007
9008 let reused = render_projection_suffix(ProjectionVerdict {
9009 kind: ProjectionKind::Reused,
9010 reason: None,
9011 journal_bytes: 0,
9012 changed_files: 0,
9013 });
9014 assert_eq!(
9015 reused, " projection=reused journal_bytes=0 changed_files=0",
9016 "a cache hit is not a full projection and must not read as one"
9017 );
9018 }
9019
9020 #[test]
9021 fn spill_backed_171_file_delta_splices_where_old_bound_was_full() {
9022 let dir = tempfile::tempdir().expect("tempdir");
9023 write_projection_fixture(dir.path());
9024 let root = canonical_root(dir.path());
9025 let store =
9026 CallGraphStore::open(root.join(".store-spill"), root.clone()).expect("open store");
9027 store
9028 .cold_build(&project_files(&root))
9029 .expect("cold build fixture");
9030 let (revision, previous) = project_dead_code_snapshot_with_revision(store.sqlite_path())
9031 .expect("initial projection");
9032 let revision = revision.expect("new stores write a projection revision");
9033 let next = revision + 1;
9034 let callers = (0..171)
9035 .map(|index| format!("src/{index:03}-{}.ts", "x".repeat(1_600)))
9036 .collect::<BTreeSet<_>>();
9037 let payload = serde_json::to_string(&(next, &callers)).expect("serialize caller batch");
9038 assert!(
9039 payload.len() > MAX_DELTA_BYTES,
9040 "fixture must exceed the former absolute journal bound"
9041 );
9042
9043 let conn = rusqlite::Connection::open(store.sqlite_path()).expect("open write conn");
9044 conn.execute(
9045 "INSERT INTO meta(k, v) VALUES('projection_write_revision', '1')
9046 ON CONFLICT(k) DO UPDATE SET v = CAST(v AS INTEGER) + 1",
9047 [],
9048 )
9049 .expect("advance write revision");
9050 let journal_key = format!("projection_delta_{}", next % 64);
9051 conn.execute(
9052 "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
9053 rusqlite::params![journal_key, format!("oversize:{next}")],
9054 )
9055 .expect("plant legacy oversize marker");
9056
9057 let (_, _, old_verdict) = crate::callgraph_store::project_dead_code_snapshot_incremental(
9058 store.sqlite_path(),
9059 Some((revision, &previous)),
9060 )
9061 .expect("project legacy oversize marker");
9062 assert_eq!(old_verdict.kind, ProjectionKind::Full);
9063 assert_eq!(old_verdict.reason, Some("journal_gap"));
9064 assert_eq!(
9065 render_projection_suffix(old_verdict),
9066 " projection=full reason=journal_gap journal_bytes=0 changed_files=0"
9067 );
9068
9069 conn.execute_batch(
9070 "CREATE TABLE projection_delta_spill (
9071 revision INTEGER PRIMARY KEY,
9072 payload TEXT NOT NULL
9073 )",
9074 )
9075 .expect("create spill table");
9076 conn.execute(
9077 "INSERT INTO projection_delta_spill(revision, payload) VALUES(?1, ?2)",
9078 rusqlite::params![next, payload],
9079 )
9080 .expect("store spill payload");
9081 conn.execute(
9082 "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
9083 rusqlite::params![journal_key, format!("spill:{next}")],
9084 )
9085 .expect("replace legacy marker with spill marker");
9086 drop(conn);
9087
9088 let (_, _, verdict) = crate::callgraph_store::project_dead_code_snapshot_incremental(
9089 store.sqlite_path(),
9090 Some((revision, &previous)),
9091 )
9092 .expect("project spill-backed delta");
9093 assert_eq!(verdict.kind, ProjectionKind::Spliced);
9094 assert_eq!(verdict.reason, None);
9095 assert_eq!(verdict.changed_files, 171);
9096 assert_eq!(verdict.journal_bytes, payload.len() as u64);
9097 assert_eq!(
9098 render_projection_suffix(verdict),
9099 format!(
9100 " projection=spliced journal_bytes={} changed_files=171",
9101 payload.len()
9102 )
9103 );
9104 }
9105}
9106
9107#[cfg(test)]
9108mod tier2_deadline_tests {
9109 use super::*;
9110
9111 #[test]
9112 fn tier2_pass_deadline_releases_limiter_while_ignoring_stub_still_runs() {
9113 let limiter = cold_build_limiter::isolated_limiter(1);
9114 let request = cold_build_limiter::ColdBuildAdmissionRequest::new(
9115 "tier2-timeout-test",
9116 cold_build_limiter::ColdBuildAdmissionClass::Maintenance,
9117 );
9118 let permit = cold_build_limiter::try_acquire_classified_with_limiter(&limiter, &request)
9119 .expect("test permit");
9120 let permit_slot = Arc::new(Mutex::new(Some(permit)));
9121 let (running_tx, running_rx) = bounded(1);
9122 let handle = std::thread::spawn({
9123 let permit_slot = Arc::clone(&permit_slot);
9124 move || {
9125 run_tier2_pass_with_deadline(
9126 Path::new("/tmp/tier2-timeout"),
9127 InspectCategory::Duplicates,
9128 Duration::from_millis(20),
9129 Some(permit_slot),
9130 || {
9131 running_tx.send(()).expect("announce running stub");
9132 std::thread::sleep(Duration::from_millis(250));
9133 "ignored cancellation"
9134 },
9135 )
9136 }
9137 });
9138 running_rx.recv().expect("stub started");
9139 let deadline = Instant::now() + Duration::from_secs(1);
9140 while !limiter.census().holders.is_empty() {
9141 assert!(
9142 Instant::now() < deadline,
9143 "deadline did not release limiter slot"
9144 );
9145 std::thread::yield_now();
9146 }
9147 assert!(
9148 !handle.is_finished(),
9149 "stub must still be running when slot is released"
9150 );
9151 let (value, timed_out) = handle.join().expect("stub thread");
9152 assert!(timed_out);
9153 assert_eq!(value, "ignored cancellation");
9154 }
9155
9156 #[test]
9157 fn tier2_pass_deadline_cooperative_stub_returns_within_grace() {
9158 let limiter = cold_build_limiter::isolated_limiter(1);
9159 let permit = limiter.try_acquire().expect("test permit");
9160 let started = Instant::now();
9161 let (value, timed_out) = run_tier2_pass_with_deadline(
9162 Path::new("/tmp/tier2-timeout"),
9163 InspectCategory::Duplicates,
9164 Duration::from_millis(20),
9165 Some(Arc::new(Mutex::new(Some(permit)))),
9166 || {
9167 while !crate::executor::current_job_cancelled() {
9168 std::thread::sleep(Duration::from_millis(1));
9169 }
9170 "cancelled"
9171 },
9172 );
9173 assert!(timed_out, "deadline must own the cancellation request");
9174 assert_eq!(value, "cancelled");
9175 assert!(started.elapsed() < Duration::from_millis(250));
9176 assert!(limiter.census().holders.is_empty());
9177 }
9178}