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