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 projection_estimator_duration(&phases),
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
3009fn projection_estimator_duration(phases: &Tier2PhaseTimings) -> Duration {
3010 phases.projection_cost
3014}
3015
3016impl Tier2PhaseTimings {
3017 fn add_db_timings(&mut self, timings: InspectDbTimings) {
3018 self.db_lock += timings.lock_wait;
3019 self.db_txn += timings.transaction;
3020 }
3021
3022 fn worked(&self) -> Duration {
3023 self.freshness + self.scan + self.snapshot + self.rollup + self.db
3024 }
3025
3026 fn log(&self, category: InspectCategory, project_root: &Path) {
3027 let worked = self.worked();
3028 if !worked.is_zero() {
3029 crate::logging::note_tier2_scan(
3030 category.to_string(),
3031 worked.as_millis().min(u128::from(u64::MAX)) as u64,
3032 );
3033 }
3034 if worked < TIER2_WORK_LOG_THRESHOLD {
3035 return;
3036 }
3037 let key = crate::search_index::artifact_cache_key(project_root);
3038 crate::slog_info!("{}", self.render(category, project_root, &key));
3039 }
3040
3041 fn render(&self, category: InspectCategory, project_root: &Path, key: &str) -> String {
3042 let projection = self
3043 .projection
3044 .map(render_projection_suffix)
3045 .unwrap_or_else(|| {
3046 render_no_projection_suffix(
3047 self.projection_skip_reason
3048 .unwrap_or_else(|| default_projection_skip_reason(category)),
3049 )
3050 });
3051 let rollup = self
3052 .rollup_verdict
3053 .map(render_rollup_suffix)
3054 .unwrap_or_default();
3055 format!(
3056 "perf tier2 phases category={} freshness={}ms snapshot={}ms projection_ms={} scan={}ms({} files) db={}ms(lock={},txn={}) rollup_ms={}{}{} root={} key={}",
3057 category,
3058 self.freshness.as_millis(),
3059 self.snapshot.as_millis(),
3060 self.projection_cost.as_millis(),
3061 self.scan.as_millis(),
3062 self.scanned_files,
3063 self.db.as_millis(),
3064 self.db_lock.as_millis(),
3065 self.db_txn.as_millis(),
3066 self.rollup.as_millis(),
3067 rollup,
3068 projection,
3069 crate::logging::normalize_index_root(project_root),
3070 key
3071 )
3072 }
3073}
3074
3075fn default_projection_skip_reason(category: InspectCategory) -> &'static str {
3076 if category == InspectCategory::DeadCode {
3077 "no_callgraph"
3078 } else {
3079 "not_required"
3080 }
3081}
3082
3083fn render_no_projection_suffix(reason: &'static str) -> String {
3088 format!(" projection=none reason={reason} journal_bytes=0 changed_files=0")
3089}
3090
3091fn render_rollup_suffix(verdict: super::scanners::dead_code::RollupVerdict) -> String {
3095 let kind = match verdict.kind {
3096 super::scanners::dead_code::RollupKind::Incremental => "incremental",
3097 super::scanners::dead_code::RollupKind::Full => "full",
3098 };
3099 let reason = verdict
3100 .reason
3101 .map(|reason| format!(" reason={reason}"))
3102 .unwrap_or_default();
3103 format!(" rollup={kind}{reason}")
3104}
3105
3106fn render_projection_suffix(verdict: ProjectionVerdict) -> String {
3107 let kind = match verdict.kind {
3108 ProjectionKind::Spliced => "spliced",
3109 ProjectionKind::Full => "full",
3110 ProjectionKind::Reused => "reused",
3111 };
3112 let reason = match verdict.reason {
3113 Some("journal_oversize") => format!(" reason=journal_oversize:{MAX_DELTA_BYTES}"),
3114 Some(reason) => format!(" reason={reason}"),
3115 None => String::new(),
3116 };
3117 format!(
3118 " projection={kind}{reason} journal_bytes={} changed_files={}",
3119 verdict.journal_bytes, verdict.changed_files
3120 )
3121}
3122
3123fn scope_files(project_root: &Path, scope: &JobScope) -> Vec<PathBuf> {
3124 let mut files = crate::callgraph::walk_project_files(project_root)
3125 .filter(|path| scope.contains(path))
3126 .collect::<Vec<_>>();
3127 files.sort();
3128 files
3129}
3130
3131fn forced_relative_paths(job: &InspectJob, paths: &BTreeSet<PathBuf>) -> BTreeSet<String> {
3132 let mut keys = BTreeSet::new();
3133 for path in paths {
3134 let absolute = if path.is_absolute() {
3135 path.clone()
3136 } else {
3137 job.project_root.join(path)
3138 };
3139 keys.insert(relative_cache_key(&job.project_root, &absolute));
3140 keys.insert(relative_cache_key(
3145 &job.project_root,
3146 &crate::inspect::job::canonicalize_normalized(&absolute),
3147 ));
3148 }
3149 keys
3150}
3151
3152fn downgrade_unchanged_forced_paths_with_freshness(
3153 project_root: &Path,
3154 cached: &[CachedContributionFreshness],
3155 paths: Vec<PathBuf>,
3156) -> (Vec<PathBuf>, usize) {
3157 let cached = cached
3158 .iter()
3159 .map(|record| (freshness_record_relative_key(record), record.freshness))
3160 .collect::<BTreeMap<_, _>>();
3161 let mut remaining = Vec::with_capacity(paths.len());
3162 let mut downgraded = 0;
3163
3164 for path in paths {
3165 let absolute = if path.is_absolute() {
3166 path.clone()
3167 } else {
3168 project_root.join(&path)
3169 };
3170 let direct_key = relative_cache_key(project_root, &absolute);
3171 let canonical_key = Some(relative_cache_key(
3173 project_root,
3174 &crate::inspect::job::canonicalize_normalized(&absolute),
3175 ));
3176 let freshness = cached
3177 .get(&direct_key)
3178 .or_else(|| canonical_key.as_ref().and_then(|key| cached.get(key)));
3179 let content_unchanged = freshness.is_some_and(|freshness| {
3180 matches!(
3181 cache_freshness::verify_file_strict(&absolute, freshness),
3182 FreshnessVerdict::HotFresh | FreshnessVerdict::ContentFresh { .. }
3183 )
3184 });
3185 if content_unchanged {
3186 downgraded += 1;
3187 } else {
3188 remaining.push(path);
3189 }
3190 }
3191
3192 (remaining, downgraded)
3193}
3194
3195fn panic_tier2_reuse_for_debug(job: &InspectJob) {
3196 #[cfg(not(debug_assertions))]
3197 let _ = job;
3198 #[cfg(debug_assertions)]
3199 {
3200 if !env_project_root_matches("AFT_TEST_TIER2_REUSE_PANIC_ROOT", &job.project_root) {
3201 return;
3202 }
3203 let should_panic = std::env::var("AFT_TEST_TIER2_REUSE_PANIC_CATEGORY")
3204 .ok()
3205 .is_some_and(|category| category == job.category.as_str());
3206 if should_panic {
3207 panic!("forced tier2 reuse panic for {}", job.category);
3208 }
3209 }
3210}
3211
3212fn delay_tier2_reuse_for_debug(project_root: &Path) {
3213 #[cfg(not(debug_assertions))]
3214 let _ = project_root;
3215 #[cfg(debug_assertions)]
3216 {
3217 if std::env::var_os("AFT_TEST_TIER2_REUSE_GATE_ROOT").is_some()
3218 && env_project_root_matches("AFT_TEST_TIER2_REUSE_GATE_ROOT", project_root)
3219 {
3220 let ready = std::env::var_os("AFT_TEST_TIER2_REUSE_GATE_READY").map(PathBuf::from);
3221 let release = std::env::var_os("AFT_TEST_TIER2_REUSE_GATE_RELEASE").map(PathBuf::from);
3222 if let (Some(ready), Some(release)) = (ready, release) {
3223 let _ = std::fs::write(&ready, b"ready");
3224 let hang_deadline = Instant::now() + Duration::from_secs(30);
3227 while !release.exists() {
3228 assert!(
3229 Instant::now() < hang_deadline,
3230 "timed out waiting for Tier-2 reuse gate release"
3231 );
3232 std::thread::sleep(Duration::from_millis(10));
3233 }
3234 return;
3235 }
3236 }
3237
3238 if !env_project_root_matches("AFT_TEST_TIER2_REUSE_DELAY_ROOT", project_root) {
3239 return;
3240 }
3241 if let Some(delay_ms) = std::env::var("AFT_TEST_TIER2_REUSE_DELAY_MS")
3242 .ok()
3243 .and_then(|raw| raw.parse::<u64>().ok())
3244 {
3245 std::thread::sleep(Duration::from_millis(delay_ms));
3246 }
3247 }
3248}
3249
3250#[cfg(debug_assertions)]
3251fn env_project_root_matches(var: &str, project_root: &Path) -> bool {
3252 let Some(raw) = std::env::var_os(var) else {
3253 return true;
3254 };
3255 let expected = PathBuf::from(raw);
3256 let expected = std::fs::canonicalize(&expected).unwrap_or(expected);
3257 let actual = std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
3258 expected == actual
3259}
3260
3261fn current_project_files(project_root: &Path, files: &[PathBuf]) -> BTreeMap<String, PathBuf> {
3262 files
3263 .iter()
3264 .map(|file| (relative_cache_key(project_root, file), file.clone()))
3265 .collect()
3266}
3267
3268fn insert_callgraph_refresh_path(paths: &mut BTreeSet<PathBuf>, path: PathBuf) {
3269 if callgraph_store_indexes_path(&path) {
3270 paths.insert(path);
3271 }
3272}
3273
3274fn callgraph_store_indexes_path(path: &Path) -> bool {
3275 crate::parser::detect_language(path).is_some()
3276}
3277
3278fn tier2_benchmark_logging_enabled() -> bool {
3279 std::env::var_os("AFT_SETTLE_BENCH_LOG").is_some()
3280}
3281
3282thread_local! {
3283 static TIER2_INDEX_SCOPE: RefCell<Option<crate::logging::IndexBuildScope>> =
3284 const { RefCell::new(None) };
3285}
3286
3287fn log_tier2_benchmark_category_start(job: &InspectJob) {
3288 let key = crate::search_index::artifact_cache_key(&job.project_root);
3289 let scope = crate::logging::IndexBuildScope::new(
3290 crate::logging::IndexPlane::Tier2,
3291 &job.project_root,
3292 key,
3293 );
3294 TIER2_INDEX_SCOPE.with(|slot| *slot.borrow_mut() = Some(scope));
3295 if !tier2_benchmark_logging_enabled() {
3296 return;
3297 }
3298 crate::slog_info!(
3299 "settle bench: tier2_category_start category={} job_id={} files={}",
3300 job.category.as_str(),
3301 job.job_id,
3302 job.scope_files.len()
3303 );
3304}
3305
3306fn tier2_pass_did_real_work(result: &InspectResult) -> bool {
3307 match &result.outcome {
3308 Ok(success) => {
3309 !success.scanned_files.is_empty() || result.duration >= TIER2_WORK_LOG_THRESHOLD
3310 }
3311 Err(_) => false,
3312 }
3313}
3314
3315fn log_tier2_benchmark_category_end(result: &InspectResult) {
3316 let scope = TIER2_INDEX_SCOPE.with(|slot| slot.borrow_mut().take());
3317 if let Some(scope) = scope {
3318 match &result.outcome {
3319 Ok(success) if tier2_pass_did_real_work(result) => {
3320 crate::logging::log_index_event(
3321 crate::logging::IndexEvent::from_scope(
3322 crate::logging::IndexEventKind::BuildStarted,
3323 &scope,
3324 )
3325 .field("category", result.category.as_str())
3326 .field("files", success.scanned_files.len()),
3327 );
3328 crate::logging::log_index_event(
3329 crate::logging::IndexEvent::from_scope(
3330 crate::logging::IndexEventKind::BuildReady,
3331 &scope,
3332 )
3333 .field("category", result.category.as_str())
3334 .field("elapsed_ms", result.duration.as_millis())
3335 .field("files", success.scanned_files.len())
3336 .field("contributions", success.contributions.len()),
3337 );
3338 }
3339 Ok(_) => {}
3340 Err(message) => {
3341 crate::logging::log_index_event(
3342 crate::logging::IndexEvent::from_scope(
3343 crate::logging::IndexEventKind::BuildFailed,
3344 &scope,
3345 )
3346 .field("category", result.category.as_str())
3347 .field("elapsed_ms", result.duration.as_millis())
3348 .field("reason", message),
3349 );
3350 }
3351 }
3352 }
3353 if !tier2_benchmark_logging_enabled() {
3354 return;
3355 }
3356 match &result.outcome {
3357 Ok(success) => {
3358 let count = success
3359 .aggregate
3360 .get("count")
3361 .and_then(serde_json::Value::as_u64)
3362 .unwrap_or(0);
3363 crate::slog_info!(
3364 "settle bench: tier2_category_end category={} job_id={} status=success total_ms={} scanned_files={} contributions={} count={}",
3365 result.category.as_str(),
3366 result.job_id,
3367 result.duration.as_millis(),
3368 success.scanned_files.len(),
3369 success.contributions.len(),
3370 count
3371 );
3372 }
3373 Err(message) => {
3374 crate::slog_info!(
3375 "settle bench: tier2_category_end category={} job_id={} status=failed total_ms={} error={}",
3376 result.category.as_str(),
3377 result.job_id,
3378 result.duration.as_millis(),
3379 message.replace('\n', " ")
3380 );
3381 }
3382 }
3383}
3384
3385fn build_tier2_callgraph_snapshot(
3386 job: &InspectJob,
3387 allow_cold_build: bool,
3388) -> Option<Arc<CallgraphSnapshot>> {
3389 build_tier2_callgraph_snapshot_with_refresh_inner(job, allow_cold_build, false, &[], None)
3390 .map(|(snapshot, _, _)| snapshot)
3391}
3392
3393#[cfg(test)]
3394fn build_tier2_callgraph_snapshot_with_refresh(
3395 job: &InspectJob,
3396 allow_cold_build: bool,
3397 refresh_paths: &[PathBuf],
3398) -> Option<Arc<CallgraphSnapshot>> {
3399 build_tier2_callgraph_snapshot_with_refresh_inner(
3400 job,
3401 allow_cold_build,
3402 false,
3403 refresh_paths,
3404 None,
3405 )
3406 .map(|(snapshot, _, _)| snapshot)
3407}
3408
3409const BLOCKING_CALLGRAPH_STORE_RETRY_TIMEOUT: Duration = Duration::from_secs(30);
3410const BLOCKING_CALLGRAPH_STORE_RETRY_INTERVAL: Duration = Duration::from_millis(20);
3411
3412fn open_ready_for_blocking_inspect(
3413 callgraph_dir: &Path,
3414 project_root: &Path,
3415 wait_for_publication: bool,
3416) -> Result<Option<CallGraphStore>, CallGraphStoreError> {
3417 let deadline = Instant::now() + BLOCKING_CALLGRAPH_STORE_RETRY_TIMEOUT;
3418 loop {
3419 match CallGraphStore::open_ready_repairing(
3420 callgraph_dir.to_path_buf(),
3421 project_root.to_path_buf(),
3422 ) {
3423 Ok(None) if wait_for_publication && Instant::now() < deadline => {}
3424 Err(error) if error.is_transient_lock_contention() && Instant::now() < deadline => {}
3425 result => return result,
3426 }
3427 std::thread::sleep(BLOCKING_CALLGRAPH_STORE_RETRY_INTERVAL);
3428 }
3429}
3430
3431fn open_or_build_blocking_callgraph_store(
3432 callgraph_dir: PathBuf,
3433 project_root: PathBuf,
3434 allow_cold_build: bool,
3435 refresh_paths: &[PathBuf],
3436) -> Result<Option<CallGraphStore>, CallGraphStoreError> {
3437 if let Some(store) = open_ready_for_blocking_inspect(&callgraph_dir, &project_root, false)? {
3438 return Ok(Some(store));
3439 }
3440 if !allow_cold_build || refresh_paths.is_empty() {
3441 return Ok(None);
3442 }
3443
3444 match CallGraphStore::cold_build_with_lease(
3445 callgraph_dir.clone(),
3446 project_root.clone(),
3447 refresh_paths,
3448 ) {
3449 Ok((store, _)) => Ok(Some(store)),
3450 Err(error)
3451 if matches!(error, CallGraphStoreError::Unavailable(_))
3452 || error.is_transient_lock_contention() =>
3453 {
3454 match open_ready_for_blocking_inspect(&callgraph_dir, &project_root, true)? {
3458 Some(store) => Ok(Some(store)),
3459 None => Err(error),
3460 }
3461 }
3462 Err(error) => Err(error),
3463 }
3464}
3465
3466fn merge_callgraph_refresh_paths(
3467 project_root: &Path,
3468 refresh_paths: &[PathBuf],
3469 stale: impl IntoIterator<Item = String>,
3470) -> Vec<PathBuf> {
3471 let mut paths = refresh_paths.to_vec();
3472 for rel in stale {
3473 let absolute = project_root.join(rel);
3474 if !paths.iter().any(|path| path == &absolute) {
3475 paths.push(absolute);
3476 }
3477 }
3478 paths
3479}
3480
3481fn refresh_writable_dead_code_store(
3482 store: &CallGraphStore,
3483 callgraph_dir: &Path,
3484 refresh_paths: &[PathBuf],
3485) {
3486 match store.refresh_files(refresh_paths) {
3487 Ok(stats) => {
3488 crate::slog_info!(
3489 "tier2 dead_code: refreshed callgraph store at {} for {} watcher path(s): changed={} deleted={} refreshed_own={}",
3490 callgraph_dir.display(),
3491 refresh_paths.len(),
3492 stats.changed_files.len(),
3493 stats.deleted_files.len(),
3494 stats.refreshed_own_files
3495 );
3496 }
3497 Err(error) => {
3498 crate::slog_warn!(
3499 "tier2 dead_code: failed to refresh callgraph store at {} before projection: {}",
3500 callgraph_dir.display(),
3501 error
3502 );
3503 if let Err(mark_error) = store.mark_files_stale(refresh_paths) {
3504 crate::slog_warn!(
3505 "tier2 dead_code: failed to mark callgraph store files stale at {} after refresh failure: {}",
3506 callgraph_dir.display(),
3507 mark_error
3508 );
3509 }
3510 }
3511 }
3512}
3513
3514fn callgraph_path_identity_gap(job: &InspectJob) -> Option<String> {
3515 for callgraph_dir in callgraph_store_dirs_from_inspect_dir(&job.inspect_dir, &job.project_root)
3516 {
3517 let Ok(Some(store)) =
3518 CallGraphStore::open_readonly(callgraph_dir, job.project_root.clone())
3519 else {
3520 continue;
3521 };
3522 let Err(CallGraphStoreError::Unavailable(reason)) =
3523 project_dead_code_snapshot_with_revision(store.sqlite_path())
3524 else {
3525 continue;
3526 };
3527 if reason.starts_with("callgraph_path_identity_mismatch ") {
3528 return Some(reason);
3529 }
3530 }
3531 None
3532}
3533
3534fn open_writable_dead_code_store(
3535 callgraph_dir: PathBuf,
3536 project_root: PathBuf,
3537 allow_cold_build: bool,
3538 build_if_missing: bool,
3539 refresh_paths: &[PathBuf],
3540) -> Result<Option<CallGraphStore>, CallGraphStoreError> {
3541 if build_if_missing {
3542 open_or_build_blocking_callgraph_store(
3543 callgraph_dir,
3544 project_root,
3545 allow_cold_build,
3546 refresh_paths,
3547 )
3548 } else if allow_cold_build {
3549 CallGraphStore::open_ready_repairing(callgraph_dir, project_root)
3550 } else {
3551 CallGraphStore::open_ready_no_rebuild(callgraph_dir, project_root)
3552 }
3553}
3554
3555fn build_tier2_callgraph_snapshot_with_refresh_inner(
3556 job: &InspectJob,
3557 allow_cold_build: bool,
3558 build_if_missing: bool,
3559 refresh_paths: &[PathBuf],
3560 projection_cache: Option<&InspectManager>,
3561) -> Option<(Arc<CallgraphSnapshot>, ProjectionVerdict, Duration)> {
3562 let started = Instant::now();
3563 if !job.config.callgraph_store {
3564 crate::slog_info!(
3565 "tier2 dead_code: callgraph store disabled; reporting callgraph_unavailable"
3566 );
3567 return None;
3568 }
3569
3570 let callgraph_dirs = callgraph_store_dirs_from_inspect_dir(&job.inspect_dir, &job.project_root);
3571 if callgraph_dirs.is_empty() {
3572 crate::slog_info!(
3573 "tier2 dead_code: inspect_dir has no root-keyed storage parent ({}); reporting callgraph_unavailable",
3574 job.inspect_dir.display()
3575 );
3576 return None;
3577 };
3578 for callgraph_dir in &callgraph_dirs {
3579 match CallGraphStore::cold_build_suspension(callgraph_dir, &job.project_root) {
3580 Ok(Some(suspension)) => {
3581 if let Some(manager) = projection_cache {
3585 manager.record_tier2_build_suspension(&job.key, suspension.clone());
3586 }
3587 crate::slog_info!(
3588 "tier2 dead_code: callgraph build suspended for {} after {} deaths",
3589 suspension.domain.as_str(),
3590 suspension.death_count
3591 );
3592 return None;
3593 }
3594 Ok(None) => {}
3595 Err(error) => {
3596 crate::slog_warn!(
3597 "tier2 dead_code: failed to read callgraph breaker at {}: {}",
3598 callgraph_dir.display(),
3599 error
3600 );
3601 }
3602 }
3603 }
3604
3605 enum ProjectionStore {
3606 ReadOnly(ReadonlyCallGraphStore),
3607 Writable(CallGraphStore),
3608 }
3609
3610 impl ProjectionStore {
3611 fn sqlite_path(&self) -> &Path {
3612 match self {
3613 Self::ReadOnly(store) => store.sqlite_path(),
3614 Self::Writable(store) => store.sqlite_path(),
3615 }
3616 }
3617
3618 fn projection_identity(
3619 &self,
3620 project_root: &Path,
3621 write_revision: u64,
3622 ) -> CallgraphProjectionIdentity {
3623 let generation = match self {
3624 Self::ReadOnly(store) => store.projection_generation(),
3625 Self::Writable(store) => store.projection_generation(),
3626 }
3627 .map(str::to_owned);
3628 let legacy_sqlite_path = generation
3629 .is_none()
3630 .then(|| self.sqlite_path().to_path_buf());
3631 CallgraphProjectionIdentity {
3632 project_root: project_root.to_path_buf(),
3633 generation,
3634 legacy_sqlite_path,
3635 write_revision,
3636 }
3637 }
3638
3639 fn current_projection_identity(
3640 &self,
3641 project_root: &Path,
3642 ) -> Result<Option<CallgraphProjectionIdentity>, CallGraphStoreError> {
3643 let stale_files = match self {
3644 Self::ReadOnly(store) => store.stale_files()?,
3645 Self::Writable(store) => store.stale_files()?,
3646 };
3647 if !stale_files.is_empty() {
3648 return Ok(None);
3649 }
3650 let write_revision = match self {
3651 Self::ReadOnly(store) => store.projection_write_revision()?,
3652 Self::Writable(store) => store.projection_write_revision()?,
3653 };
3654 Ok(write_revision.map(|revision| self.projection_identity(project_root, revision)))
3655 }
3656 }
3657
3658 for (index, callgraph_dir) in callgraph_dirs.iter().enumerate() {
3659 let projection_store = if refresh_paths.is_empty() || !job.callgraph_writer {
3664 let store = match CallGraphStore::open_readonly(
3665 callgraph_dir.clone(),
3666 job.project_root.clone(),
3667 ) {
3668 Ok(Some(store)) => store,
3669 Ok(None) => {
3670 crate::slog_info!(
3671 "tier2 dead_code: callgraph store unavailable at {} (cold/building/not ready); trying fallback={}",
3672 callgraph_dir.display(),
3673 index + 1 < callgraph_dirs.len()
3674 );
3675 continue;
3676 }
3677 Err(error) => {
3678 crate::slog_warn!(
3679 "tier2 dead_code: failed to open callgraph store read-only at {}: {}; trying fallback={}",
3680 callgraph_dir.display(),
3681 error,
3682 index + 1 < callgraph_dirs.len()
3683 );
3684 continue;
3685 }
3686 };
3687 let stale = job
3688 .callgraph_writer
3689 .then(|| store.stale_files().ok())
3690 .flatten()
3691 .unwrap_or_default();
3692 if stale.is_empty() {
3693 ProjectionStore::ReadOnly(store)
3694 } else {
3695 drop(store);
3696 let refresh =
3697 merge_callgraph_refresh_paths(&job.project_root, refresh_paths, stale);
3698 let store = match open_writable_dead_code_store(
3699 callgraph_dir.clone(),
3700 job.project_root.clone(),
3701 allow_cold_build,
3702 build_if_missing,
3703 &refresh,
3704 ) {
3705 Ok(Some(store)) => store,
3706 Ok(None) => {
3707 crate::slog_info!(
3708 "tier2 dead_code: callgraph store unavailable at {} (cold/building/not ready); trying fallback={}",
3709 callgraph_dir.display(),
3710 index + 1 < callgraph_dirs.len()
3711 );
3712 continue;
3713 }
3714 Err(error) => {
3715 crate::slog_warn!(
3716 "tier2 dead_code: failed to open callgraph writer at {}: {}; trying fallback={}",
3717 callgraph_dir.display(),
3718 error,
3719 index + 1 < callgraph_dirs.len()
3720 );
3721 continue;
3722 }
3723 };
3724 refresh_writable_dead_code_store(&store, callgraph_dir, &refresh);
3725 ProjectionStore::Writable(store)
3726 }
3727 } else {
3728 let store = match open_writable_dead_code_store(
3729 callgraph_dir.clone(),
3730 job.project_root.clone(),
3731 allow_cold_build,
3732 build_if_missing,
3733 refresh_paths,
3734 ) {
3735 Ok(Some(store)) => store,
3736 Ok(None) => {
3737 crate::slog_info!(
3738 "tier2 dead_code: callgraph store unavailable at {} (cold/building/not ready); trying fallback={}",
3739 callgraph_dir.display(),
3740 index + 1 < callgraph_dirs.len()
3741 );
3742 continue;
3743 }
3744 Err(error) => {
3745 crate::slog_warn!(
3746 "tier2 dead_code: failed to open callgraph writer at {}: {}; trying fallback={}",
3747 callgraph_dir.display(),
3748 error,
3749 index + 1 < callgraph_dirs.len()
3750 );
3751 continue;
3752 }
3753 };
3754 let stale = store.stale_files().unwrap_or_default();
3755 let refresh = merge_callgraph_refresh_paths(&job.project_root, refresh_paths, stale);
3756 refresh_writable_dead_code_store(&store, callgraph_dir, &refresh);
3757 ProjectionStore::Writable(store)
3758 };
3759
3760 let cache_identity = match projection_store.current_projection_identity(&job.project_root) {
3761 Ok(identity) => identity,
3762 Err(error) => {
3763 crate::slog_warn!(
3764 "tier2 dead_code: failed to read callgraph projection identity at {}: {}; trying fallback={}",
3765 callgraph_dir.display(),
3766 error,
3767 index + 1 < callgraph_dirs.len()
3768 );
3769 continue;
3770 }
3771 };
3772 if let (Some(cache), Some(identity)) = (projection_cache, cache_identity.as_ref()) {
3773 if let Some(snapshot) = cache.cached_callgraph_projection(identity) {
3778 return Some((
3779 snapshot,
3780 ProjectionVerdict {
3781 kind: ProjectionKind::Reused,
3782 reason: None,
3783 journal_bytes: 0,
3784 changed_files: 0,
3785 },
3786 Duration::ZERO,
3787 ));
3788 }
3789 } else if cache_identity.is_none() {
3790 if let Some(cache) = projection_cache {
3793 cache.clear_callgraph_projection();
3794 }
3795 }
3796
3797 let previous = projection_cache
3798 .zip(cache_identity.as_ref())
3799 .and_then(|(cache, identity)| cache.previous_callgraph_projection(identity));
3800 let costs = projection_cache
3801 .zip(cache_identity.as_ref())
3802 .map(|(cache, identity)| cache.callgraph_projection_costs(identity))
3803 .unwrap_or_default();
3804 let (write_revision, snapshot, verdict, projection_cost) =
3805 match project_dead_code_snapshot_incremental_with_costs(
3806 projection_store.sqlite_path(),
3807 previous
3808 .as_ref()
3809 .map(|(revision, snapshot)| (*revision, snapshot.as_ref())),
3810 costs,
3811 ) {
3812 Ok(projected) => projected,
3813 Err(CallGraphStoreError::Unavailable(message)) => {
3814 crate::slog_info!(
3815 "tier2 dead_code: callgraph store projection unavailable at {} ({}); trying fallback={}",
3816 callgraph_dir.display(),
3817 message,
3818 index + 1 < callgraph_dirs.len()
3819 );
3820 continue;
3821 }
3822 Err(error) => {
3823 crate::slog_warn!(
3824 "tier2 dead_code: callgraph store projection failed at {}: {}; trying fallback={}",
3825 callgraph_dir.display(),
3826 error,
3827 index + 1 < callgraph_dirs.len()
3828 );
3829 continue;
3830 }
3831 };
3832 let snapshot = Arc::new(snapshot);
3833 if let (Some(cache), Some(write_revision)) = (projection_cache, write_revision) {
3834 cache.cache_callgraph_projection(
3835 projection_store.projection_identity(&job.project_root, write_revision),
3836 Arc::clone(&snapshot),
3837 );
3838 }
3839
3840 if index > 0 {
3841 crate::slog_info!(
3842 "tier2 dead_code: using ready callgraph store fallback {} for inspect_dir {}",
3843 callgraph_dir.display(),
3844 job.inspect_dir.display()
3845 );
3846 }
3847
3848 crate::slog_info!(
3849 "perf tier2_callgraph_snapshot: source=callgraph_store files={} exports={} edges={} entry_points={} ms={}",
3850 snapshot.files.len(),
3851 snapshot.exported_symbols.len(),
3852 snapshot.outbound_calls.len(),
3853 snapshot.entry_points.len(),
3854 started.elapsed().as_millis()
3855 );
3856
3857 return Some((snapshot, verdict, projection_cost));
3858 }
3859
3860 crate::slog_info!(
3861 "tier2 dead_code: no ready callgraph store found for inspect_dir {}; reporting callgraph_unavailable",
3862 job.inspect_dir.display()
3863 );
3864 None
3865}
3866
3867fn estimate_callgraph_snapshot_bytes(snapshot: &CallgraphSnapshot) -> u64 {
3868 let files = snapshot.files.iter().fold(0u64, |bytes, path| {
3869 bytes
3870 .saturating_add(std::mem::size_of::<PathBuf>() as u64)
3871 .saturating_add(crate::memory::path_bytes(path))
3872 });
3873 let exports = snapshot
3874 .exported_symbols
3875 .iter()
3876 .fold(0u64, |bytes, export| {
3877 bytes
3878 .saturating_add(std::mem::size_of::<super::job::CallgraphExport>() as u64)
3879 .saturating_add(crate::memory::path_bytes(&export.file))
3880 .saturating_add(crate::memory::usize_to_u64(export.symbol.len()))
3881 .saturating_add(crate::memory::usize_to_u64(export.kind.len()))
3882 });
3883 let calls = snapshot.outbound_calls.iter().fold(0u64, |bytes, call| {
3884 bytes
3885 .saturating_add(std::mem::size_of::<super::job::CallgraphOutboundCall>() as u64)
3886 .saturating_add(crate::memory::path_bytes(&call.caller_file))
3887 .saturating_add(crate::memory::usize_to_u64(call.caller_symbol.len()))
3888 .saturating_add(crate::memory::usize_to_u64(call.target.len()))
3889 .saturating_add(crate::memory::usize_to_u64(call.provenance.len()))
3890 });
3891 let entry_points = snapshot.entry_points.iter().fold(0u64, |bytes, path| {
3892 bytes
3893 .saturating_add(std::mem::size_of::<PathBuf>() as u64)
3894 .saturating_add(crate::memory::path_bytes(path))
3895 });
3896 let entry_point_symbols =
3897 snapshot
3898 .entry_point_symbols
3899 .iter()
3900 .fold(0u64, |bytes, (path, symbols)| {
3901 let symbols_bytes = symbols.iter().fold(0u64, |bytes, symbol| {
3902 bytes
3903 .saturating_add(std::mem::size_of::<String>() as u64)
3904 .saturating_add(crate::memory::usize_to_u64(symbol.len()))
3905 });
3906 bytes
3907 .saturating_add(std::mem::size_of::<(PathBuf, BTreeSet<String>)>() as u64)
3908 .saturating_add(crate::memory::path_bytes(path))
3909 .saturating_add(symbols_bytes)
3910 });
3911 (std::mem::size_of::<CallgraphSnapshot>() as u64)
3912 .saturating_add(files)
3913 .saturating_add(exports)
3914 .saturating_add(calls)
3915 .saturating_add(entry_points)
3916 .saturating_add(entry_point_symbols)
3917}
3918
3919fn callgraph_store_dir_from_inspect_dir(
3920 inspect_dir: &Path,
3921 project_root: &Path,
3922) -> Option<PathBuf> {
3923 let scope_key = crate::path_identity::project_scope_key(project_root);
3924 let storage_dir = if inspect_dir
3925 .file_name()
3926 .and_then(|name| name.to_str())
3927 .is_some_and(|name| name == scope_key)
3928 {
3929 inspect_dir.parent()?.parent()?
3930 } else {
3931 inspect_dir.parent()?
3932 };
3933 let project_key = crate::search_index::artifact_cache_key(project_root);
3934 Some(storage_dir.join("callgraph").join(project_key))
3935}
3936
3937fn callgraph_store_dirs_from_inspect_dir(inspect_dir: &Path, project_root: &Path) -> Vec<PathBuf> {
3938 callgraph_store_dir_from_inspect_dir(inspect_dir, project_root)
3939 .into_iter()
3940 .collect()
3941}
3942
3943#[cfg(test)]
3944fn canonicalize_for_snapshot(path: &Path) -> PathBuf {
3945 crate::inspect::job::canonicalize_normalized(path)
3948}
3949
3950fn load_contribution_freshness(
3951 cache: &(impl InspectCacheRead + ?Sized),
3952 category: InspectCategory,
3953) -> Result<Vec<CachedContributionFreshness>, String> {
3954 cache
3955 .contribution_freshness(category)
3956 .map_err(|error| error.to_string())
3957 .map(|records| {
3958 records
3959 .into_iter()
3960 .map(|(file_path, freshness)| CachedContributionFreshness {
3961 file_path,
3962 freshness,
3963 })
3964 .collect()
3965 })
3966}
3967
3968fn freshness_record_relative_key(record: &CachedContributionFreshness) -> String {
3969 record.file_path.to_string_lossy().to_string()
3970}
3971
3972fn relative_cache_key(project_root: &Path, path: &Path) -> String {
3973 path.strip_prefix(project_root)
3974 .unwrap_or(path)
3975 .to_string_lossy()
3976 .to_string()
3977}
3978
3979fn load_contributions(
3980 cache: &(impl InspectCacheRead + ?Sized),
3981 job: &InspectJob,
3982) -> Result<Vec<FileContribution>, String> {
3983 cache
3984 .load_tier2_contributions(job.category)
3985 .map_err(|error| error.to_string())
3986 .map(|records| {
3987 records
3988 .into_iter()
3989 .map(|record| contribution_from_record(&job.project_root, record))
3990 .collect()
3991 })
3992}
3993
3994fn dead_code_contributions_need_fact_refresh(
3995 cache: &(impl InspectCacheRead + ?Sized),
3996 job: &InspectJob,
3997) -> Result<bool, String> {
3998 let contributions = load_contributions(cache, job)?;
3999 Ok(contributions
4000 .iter()
4001 .any(dead_code_contribution_needs_fact_refresh))
4002}
4003
4004fn dead_code_contribution_needs_fact_refresh(contribution: &FileContribution) -> bool {
4005 let Ok(parsed) =
4006 serde_json::from_value::<DeadCodeRefreshContribution>(contribution.contribution.clone())
4007 else {
4008 return true;
4009 };
4010
4011 if parsed.facts_format_version
4012 != Some(super::scanners::dead_code::DEAD_CODE_FACTS_FORMAT_VERSION)
4013 {
4014 return true;
4015 }
4016
4017 matches!(
4018 parsed.oxc_facts,
4019 Some(facts) if facts.format_version != FACTS_FORMAT_VERSION
4020 )
4021}
4022
4023fn unused_exports_contributions_need_fact_refresh(
4024 cache: &(impl InspectCacheRead + ?Sized),
4025 job: &InspectJob,
4026) -> Result<bool, String> {
4027 let contributions = load_contributions(cache, job)?;
4028 Ok(contributions
4029 .iter()
4030 .any(unused_exports_contribution_needs_fact_refresh))
4031}
4032
4033fn duplicates_contributions_need_fact_refresh(
4038 cache: &(impl InspectCacheRead + ?Sized),
4039 job: &InspectJob,
4040) -> Result<bool, String> {
4041 let contributions = load_contributions(cache, job)?;
4042 Ok(contributions
4043 .iter()
4044 .any(|contribution| contribution.contribution.get("line_count").is_none()))
4045}
4046
4047fn unused_exports_contribution_needs_fact_refresh(contribution: &FileContribution) -> bool {
4048 let top_level_oxc = contribution
4049 .contribution
4050 .get("provenance")
4051 .and_then(Value::as_str)
4052 == Some(OXC_PROVENANCE);
4053 let Ok(parsed) =
4054 serde_json::from_value::<UnusedExportsContribution>(contribution.contribution.clone())
4055 else {
4056 return false;
4057 };
4058 let uses_oxc =
4059 top_level_oxc || parsed.oxc_facts.is_some() || parsed.exports.iter().any(export_uses_oxc);
4060 if !uses_oxc {
4061 return false;
4062 }
4063
4064 !matches!(
4065 parsed.oxc_facts,
4066 Some(facts) if facts.format_version == FACTS_FORMAT_VERSION
4067 )
4068}
4069
4070fn contribution_from_record(
4071 project_root: &Path,
4072 record: super::cache::ContributionRecord,
4073) -> FileContribution {
4074 FileContribution::new(
4075 record.category,
4076 project_root.join(record.file_path),
4077 record.freshness,
4078 record.contribution,
4079 )
4080 .with_type_ref_names(record.type_ref_names)
4081}
4082
4083fn run_tier2_scan(job: &InspectJob, oxc_result: Option<&OxcEngineResult>) -> InspectResult {
4084 use super::scanners;
4085
4086 match job.category {
4087 InspectCategory::DeadCode => {
4088 scanners::dead_code::run_dead_code_scan_with_oxc(job, oxc_result)
4089 }
4090 InspectCategory::UnusedExports => {
4091 scanners::unused_exports::run_unused_exports_scan_with_oxc(job, oxc_result)
4092 }
4093 InspectCategory::Duplicates => scanners::duplicates::run_duplicates_scan(job),
4094 InspectCategory::Cycles => scanners::cycles::run_cycles_scan_with_oxc(job, oxc_result),
4095 InspectCategory::Complexity => scanners::complexity::run_complexity_scan(job),
4096 other => InspectResult::failed(
4097 job,
4098 format!("inspect category '{other}' is not an active Tier 2 scanner"),
4099 Duration::from_secs(0),
4100 ),
4101 }
4102}
4103
4104fn roll_up_tier2_contributions(job: &InspectJob, contributions: &[FileContribution]) -> Value {
4105 roll_up_tier2_contributions_with_limit(job, contributions, Some(MAX_DRILL_DOWN_ITEMS))
4106}
4107
4108fn roll_up_tier2_contributions_with_limit(
4109 job: &InspectJob,
4110 contributions: &[FileContribution],
4111 drill_down_limit: Option<usize>,
4112) -> Value {
4113 match job.category {
4114 InspectCategory::DeadCode => {
4115 roll_up_dead_code_contributions(job, contributions, drill_down_limit)
4116 }
4117 InspectCategory::UnusedExports => {
4118 roll_up_unused_exports_contributions(job, contributions, drill_down_limit)
4119 }
4120 InspectCategory::Duplicates => {
4121 roll_up_duplicate_contributions(job, contributions, drill_down_limit)
4122 }
4123 InspectCategory::Cycles => {
4124 roll_up_cycle_contributions(job, contributions, drill_down_limit)
4125 }
4126 InspectCategory::Complexity => {
4127 roll_up_complexity_contributions(job, contributions, drill_down_limit)
4128 }
4129 _ => json!({
4130 "count": 0,
4131 "items": [],
4132 "scanned_files": contributions.len(),
4133 }),
4134 }
4135}
4136
4137fn scoped_tier2_payload_from_contributions(
4138 snapshot: &InspectSnapshot,
4139 category: InspectCategory,
4140 cache: &(impl InspectCacheRead + ?Sized),
4141 project_payload: Value,
4142 scope: &JobScope,
4143) -> Result<Value, String> {
4144 if scope.is_project_wide() {
4145 return Ok(project_payload);
4146 }
4147
4148 let project_scope = JobScope::for_project(snapshot.project_root.clone());
4149 let rollup_job = scoped_tier2_rollup_job(snapshot, category, &project_scope);
4150 let contributions = load_contributions(cache, &rollup_job)?;
4151 let full_payload = roll_up_tier2_contributions_with_limit(&rollup_job, &contributions, None);
4152 let scoped_payload = filter_payload_for_scope(full_payload, scope);
4153 Ok(cap_payload_drill_down(scoped_payload, MAX_DRILL_DOWN_ITEMS))
4154}
4155
4156fn scoped_tier2_rollup_job(
4157 snapshot: &InspectSnapshot,
4158 category: InspectCategory,
4159 scope: &JobScope,
4160) -> InspectJob {
4161 let mut job = InspectJob {
4162 job_id: 0,
4163 key: JobKey::for_project_category(category),
4164 category,
4165 scope_files: scope_files(&snapshot.project_root, scope),
4166 project_root: snapshot.project_root.clone(),
4167 inspect_dir: snapshot.inspect_dir.clone(),
4168 config: Arc::clone(&snapshot.config),
4169 symbol_cache: Arc::clone(&snapshot.symbol_cache),
4170 inspect_writer: snapshot.inspect_writer,
4171 callgraph_writer: snapshot.callgraph_writer,
4172 callgraph_snapshot: None,
4173 };
4174
4175 if category == InspectCategory::DeadCode {
4176 job.callgraph_snapshot = build_tier2_callgraph_snapshot(&job, false);
4181 }
4182
4183 job
4184}
4185
4186fn roll_up_dead_code_contributions(
4187 job: &InspectJob,
4188 contributions: &[FileContribution],
4189 drill_down_limit: Option<usize>,
4190) -> Value {
4191 let Some(snapshot) = job.callgraph_snapshot.as_deref() else {
4192 return super::scanners::dead_code::callgraph_unavailable_aggregate(job.scope_files.len());
4193 };
4194
4195 let public_api_files = super::scanners::dead_code::collect_public_api_files(&job.project_root);
4196 let roles = super::entry_points::resolve_project_roles(&job.project_root);
4197 super::scanners::dead_code::aggregate_dead_code_contributions_with_snapshot(
4198 &job.project_root,
4199 snapshot,
4200 contributions,
4201 &public_api_files,
4202 &roles,
4203 drill_down_limit,
4204 )
4205}
4206
4207fn roll_up_unused_exports_contributions(
4208 job: &InspectJob,
4209 contributions: &[FileContribution],
4210 drill_down_limit: Option<usize>,
4211) -> Value {
4212 let parsed = contributions
4213 .iter()
4214 .filter_map(|contribution| {
4215 serde_json::from_value::<UnusedExportsContribution>(contribution.contribution.clone())
4216 .ok()
4217 })
4218 .collect::<Vec<_>>();
4219
4220 if parsed.iter().any(|scan| scan.oxc_facts.is_some()) {
4221 return roll_up_unused_exports_oxc_contributions(job, &parsed, drill_down_limit);
4222 }
4223
4224 let (public_api_files, package_warnings) = unused_public_api_entries(&job.project_root);
4225 let mut imported_by: BTreeMap<(String, String), BTreeSet<String>> = BTreeMap::new();
4226 let mut uncertain_by: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
4227 for scan in &parsed {
4228 for import in &scan.imports {
4229 let Some(resolved_file) = &import.resolved_file else {
4230 continue;
4231 };
4232 for name in &import.named {
4233 if name == "*" {
4234 uncertain_by
4235 .entry(resolved_file.clone())
4236 .or_default()
4237 .insert(scan.file.clone());
4238 } else {
4239 imported_by
4240 .entry((resolved_file.clone(), name.clone()))
4241 .or_default()
4242 .insert(scan.file.clone());
4243 }
4244 }
4245 }
4246 }
4247
4248 let mut count = 0usize;
4249 let mut items = Vec::new();
4250 let mut generated_count = 0usize;
4251 let mut generated_items = Vec::new();
4252 let test_only_count = 0usize;
4253 let test_only_items = Vec::new();
4254 let mut uncertain_count = 0usize;
4255 let mut uncertain_items = Vec::new();
4256 for scan in &parsed {
4257 if public_api_files.contains(&scan.file) {
4258 continue;
4259 }
4260 if super::job::is_test_support_file(&scan.file) {
4263 continue;
4264 }
4265 let generated_file = super::generated::is_generated_file_with_cached_hint(
4266 &job.project_root,
4267 &scan.file,
4268 scan.generated,
4269 );
4270
4271 for export in &scan.exports {
4272 if export_uses_oxc(export) {
4273 match export.verdict.unwrap_or(LivenessVerdict::Unused) {
4274 LivenessVerdict::Used => continue,
4275 LivenessVerdict::Uncertain => {
4276 uncertain_count += 1;
4277 if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
4278 uncertain_items.push(json!({
4279 "file": scan.file,
4280 "symbol": export.symbol,
4281 "kind": export.kind,
4282 "line": export.line,
4283 "reason": export.reason.as_deref().unwrap_or("oxc_uncertain"),
4284 "provenance": export.provenance.as_deref().unwrap_or(OXC_PROVENANCE),
4285 }));
4286 }
4287 continue;
4288 }
4289 LivenessVerdict::Unused => {}
4290 }
4291 } else {
4292 let imported = imported_by
4293 .get(&(scan.file.clone(), export.symbol.clone()))
4294 .map(|files| !files.is_empty())
4295 .unwrap_or(false);
4296 let uncertain = uncertain_by
4297 .get(&scan.file)
4298 .map(|files| !files.is_empty())
4299 .unwrap_or(false);
4300
4301 if imported {
4302 continue;
4303 }
4304 if uncertain {
4305 uncertain_count += 1;
4306 if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
4307 uncertain_items.push(json!({
4308 "file": scan.file,
4309 "symbol": export.symbol,
4310 "kind": export.kind,
4311 "line": export.line,
4312 "reason": "wildcard_import",
4313 }));
4314 }
4315 continue;
4316 }
4317 }
4318
4319 let mut item = json!({
4320 "file": scan.file,
4321 "symbol": export.symbol,
4322 "kind": export.kind,
4323 "line": export.line,
4324 });
4325 if let Some(provenance) = &export.provenance {
4326 item["provenance"] = json!(provenance);
4327 }
4328 if generated_file {
4329 item["generated"] = json!(true);
4330 generated_count += 1;
4331 generated_items.push(item);
4332 } else {
4333 count += 1;
4334 items.push(item);
4335 }
4336 }
4337 }
4338
4339 let roles = super::entry_points::resolve_project_roles(&job.project_root);
4340 let items = super::entry_points::rank_and_truncate_items(items, &roles, drill_down_limit);
4341 let generated_items =
4342 super::entry_points::rank_and_truncate_items(generated_items, &roles, drill_down_limit);
4343 let top = super::entry_points::top_preview_symbols(&items);
4344 let generated_top = generated_items
4345 .iter()
4346 .take(super::entry_points::TOP_PREVIEW_ITEMS)
4347 .cloned()
4348 .collect::<Vec<_>>();
4349 let mut all_items = items;
4350 all_items.extend(generated_items.iter().cloned());
4351 if let Some(limit) = drill_down_limit {
4352 all_items.truncate(limit);
4353 }
4354 let test_only_items =
4355 super::entry_points::rank_and_truncate_items(test_only_items, &roles, drill_down_limit);
4356 let test_only_top = test_only_items
4357 .iter()
4358 .take(super::entry_points::TOP_PREVIEW_ITEMS)
4359 .cloned()
4360 .collect::<Vec<_>>();
4361
4362 let (parse_errors, skipped_files) = unused_exports_honesty_fields(&parsed);
4363 let mut aggregate = json!({
4364 "count": count,
4365 "generated_count": generated_count,
4366 "total_count": count + test_only_count + generated_count,
4367 "items": all_items,
4368 "top": top,
4369 "generated_items": generated_items,
4370 "generated_top": generated_top,
4371 "test_only_count": test_only_count,
4372 "test_only_items": test_only_items,
4373 "test_only_top": test_only_top,
4374 "drill_down_capped": drill_down_limit.is_some_and(|limit| count + generated_count > limit),
4375 "generated_drill_down_capped": drill_down_limit.is_some_and(|limit| generated_count > limit),
4376 "test_only_drill_down_capped": drill_down_limit.is_some_and(|limit| test_only_count > limit),
4377 "scanned_files": parsed.len(),
4378 "languages_skipped": skipped_languages(&job.scope_files, LanguageSkipMode::UnusedExports),
4379 "uncertain_count": uncertain_count,
4380 "uncertain_items": uncertain_items,
4381 "complete": parse_errors.is_empty() && skipped_files.is_empty(),
4382 });
4383 if !parse_errors.is_empty() {
4384 aggregate["parse_errors"] = Value::Array(parse_errors);
4385 }
4386 if !skipped_files.is_empty() {
4387 aggregate["skipped_files"] = Value::Array(skipped_files);
4388 }
4389 if !package_warnings.is_empty() {
4390 aggregate["note"] = Value::String(package_warnings.join("; "));
4391 }
4392 aggregate
4393}
4394
4395fn roll_up_unused_exports_oxc_contributions(
4396 job: &InspectJob,
4397 parsed: &[UnusedExportsContribution],
4398 drill_down_limit: Option<usize>,
4399) -> Value {
4400 let (public_api_files, package_warnings) = unused_public_api_entries(&job.project_root);
4401 let facts = parsed
4402 .iter()
4403 .filter_map(|scan| {
4404 let oxc_facts = scan.oxc_facts.as_ref()?;
4405 let path = job.project_root.join(&scan.file);
4406 Some(FileFacts {
4407 file_id: FileId(0),
4408 path: normalize_input_path(&job.project_root, &path),
4409 content_hash: oxc_facts.content_hash.clone(),
4410 exports: oxc_facts.exports.clone(),
4411 imports: oxc_facts.imports.clone(),
4412 re_exports: oxc_facts.re_exports.clone(),
4413 dynamic_imports: oxc_facts.dynamic_imports.clone(),
4414 same_file_value_references: oxc_facts.same_file_value_references.clone(),
4415 used_import_bindings: oxc_facts.used_import_bindings.clone(),
4416 type_referenced_import_bindings: oxc_facts.type_referenced_import_bindings.clone(),
4417 value_referenced_import_bindings: oxc_facts
4418 .value_referenced_import_bindings
4419 .clone(),
4420 parse_error: oxc_facts.parse_error.clone(),
4421 })
4422 })
4423 .collect::<Vec<_>>();
4424 let generated_by_file = parsed
4425 .iter()
4426 .map(|scan| {
4427 (
4428 scan.file.clone(),
4429 super::generated::is_generated_file_with_cached_hint(
4430 &job.project_root,
4431 &scan.file,
4432 scan.generated,
4433 ),
4434 )
4435 })
4436 .collect::<BTreeMap<_, _>>();
4437 let entry_point_set = crate::inspect::entry_points::resolve_entry_points(&job.project_root);
4438 let oxc_result = analyze_file_facts(
4439 &job.project_root,
4440 facts,
4441 AnalyzeOptions {
4442 entry_points: Vec::new(),
4443 public_api_files: entry_point_set.public_api_files(),
4444 executable_root_exports: entry_point_set.executable_root_exports(),
4445 force_reparse_files: Vec::new(),
4446 entry_reachability: false,
4447 },
4448 Vec::new(),
4449 );
4450 let roles = super::entry_points::resolve_project_roles(&job.project_root);
4451
4452 let mut count = 0usize;
4453 let mut items = Vec::new();
4454 let mut generated_count = 0usize;
4455 let mut generated_items = Vec::new();
4456 let mut test_only_count = 0usize;
4457 let mut test_only_items = Vec::new();
4458 let mut uncertain_count = 0usize;
4459 let mut uncertain_items = Vec::new();
4460 for file in &oxc_result.files {
4461 if public_api_files.contains(&file.relative_file)
4462 || super::job::is_test_support_file(&file.relative_file)
4463 {
4464 continue;
4465 }
4466 let generated_file = generated_by_file
4467 .get(&file.relative_file)
4468 .copied()
4469 .unwrap_or_else(|| {
4470 super::generated::is_generated_file(
4471 &job.project_root,
4472 Path::new(&file.relative_file),
4473 )
4474 });
4475
4476 for export in &file.exports {
4477 match export.verdict {
4478 LivenessVerdict::Used => {
4479 if !is_test_file(&file.relative_file)
4480 && !export.test_only_reference_files.is_empty()
4481 {
4482 let mut item = json!({
4483 "file": file.relative_file,
4484 "symbol": export.symbol,
4485 "kind": export.kind,
4486 "line": export.line,
4487 "provenance": export.provenance,
4488 "used_by": export.test_only_reference_files,
4489 });
4490 add_oxc_reexport_contexts(&mut item, &export.also_reexported);
4491 if generated_file {
4492 item["generated"] = json!(true);
4493 generated_count += 1;
4494 generated_items.push(item);
4495 } else {
4496 test_only_count += 1;
4497 test_only_items.push(item);
4498 }
4499 }
4500 }
4501 LivenessVerdict::Uncertain => {
4502 uncertain_count += 1;
4503 if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
4504 let mut item = json!({
4505 "file": file.relative_file,
4506 "symbol": export.symbol,
4507 "kind": export.kind,
4508 "line": export.line,
4509 "reason": export.reason,
4510 "provenance": export.provenance,
4511 });
4512 add_oxc_reexport_contexts(&mut item, &export.also_reexported);
4513 uncertain_items.push(item);
4514 }
4515 }
4516 LivenessVerdict::Unused => {
4517 if !is_test_file(&file.relative_file)
4518 && !export.test_only_reference_files.is_empty()
4519 {
4520 let mut item = json!({
4521 "file": file.relative_file,
4522 "symbol": export.symbol,
4523 "kind": export.kind,
4524 "line": export.line,
4525 "provenance": export.provenance,
4526 "used_by": export.test_only_reference_files,
4527 });
4528 add_oxc_reexport_contexts(&mut item, &export.also_reexported);
4529 if generated_file {
4530 item["generated"] = json!(true);
4531 generated_count += 1;
4532 generated_items.push(item);
4533 } else {
4534 test_only_count += 1;
4535 test_only_items.push(item);
4536 }
4537 continue;
4538 }
4539 if export.has_references {
4540 continue;
4541 }
4542 let mut item = json!({
4543 "file": file.relative_file,
4544 "symbol": export.symbol,
4545 "kind": export.kind,
4546 "line": export.line,
4547 "provenance": export.provenance,
4548 });
4549 add_oxc_reexport_contexts(&mut item, &export.also_reexported);
4550 if generated_file {
4551 item["generated"] = json!(true);
4552 generated_count += 1;
4553 generated_items.push(item);
4554 } else {
4555 count += 1;
4556 items.push(item);
4557 }
4558 }
4559 }
4560 }
4561 }
4562
4563 let items = super::entry_points::rank_and_truncate_items(items, &roles, drill_down_limit);
4564 let generated_items =
4565 super::entry_points::rank_and_truncate_items(generated_items, &roles, drill_down_limit);
4566 let top = super::entry_points::top_preview_symbols(&items);
4567 let generated_top = generated_items
4568 .iter()
4569 .take(super::entry_points::TOP_PREVIEW_ITEMS)
4570 .cloned()
4571 .collect::<Vec<_>>();
4572 let mut all_items = items;
4573 all_items.extend(generated_items.iter().cloned());
4574 if let Some(limit) = drill_down_limit {
4575 all_items.truncate(limit);
4576 }
4577 let test_only_items =
4578 super::entry_points::rank_and_truncate_items(test_only_items, &roles, drill_down_limit);
4579 let test_only_top = test_only_items
4580 .iter()
4581 .take(super::entry_points::TOP_PREVIEW_ITEMS)
4582 .cloned()
4583 .collect::<Vec<_>>();
4584 let (mut parse_errors, skipped_files) = unused_exports_honesty_fields(parsed);
4585 for scan in parsed {
4586 if let Some(oxc_facts) = &scan.oxc_facts {
4587 if oxc_facts.format_version != FACTS_FORMAT_VERSION {
4588 parse_errors.push(json!({
4589 "file": scan.file,
4590 "message": format!(
4591 "unsupported oxc facts format {}; expected {}",
4592 oxc_facts.format_version, FACTS_FORMAT_VERSION
4593 ),
4594 }));
4595 }
4596 }
4597 }
4598
4599 let mut aggregate = json!({
4600 "count": count,
4601 "generated_count": generated_count,
4602 "total_count": count + test_only_count + generated_count,
4603 "items": all_items,
4604 "top": top,
4605 "generated_items": generated_items,
4606 "generated_top": generated_top,
4607 "test_only_count": test_only_count,
4608 "test_only_items": test_only_items,
4609 "test_only_top": test_only_top,
4610 "drill_down_capped": drill_down_limit.is_some_and(|limit| count + generated_count > limit),
4611 "generated_drill_down_capped": drill_down_limit.is_some_and(|limit| generated_count > limit),
4612 "test_only_drill_down_capped": drill_down_limit.is_some_and(|limit| test_only_count > limit),
4613 "scanned_files": parsed.len(),
4614 "languages_skipped": skipped_languages(&job.scope_files, LanguageSkipMode::UnusedExports),
4615 "uncertain_count": uncertain_count,
4616 "uncertain_items": uncertain_items,
4617 "complete": parse_errors.is_empty() && skipped_files.is_empty(),
4618 });
4619 if !parse_errors.is_empty() {
4620 aggregate["parse_errors"] = Value::Array(parse_errors);
4621 }
4622 if !skipped_files.is_empty() {
4623 aggregate["skipped_files"] = Value::Array(skipped_files);
4624 }
4625 if !package_warnings.is_empty() {
4626 aggregate["note"] = Value::String(package_warnings.join("; "));
4627 }
4628 aggregate
4629}
4630
4631fn add_oxc_reexport_contexts(
4632 item: &mut Value,
4633 contexts: &[crate::inspect::oxc_engine::OxcReExportContext],
4634) {
4635 if !contexts.is_empty() {
4636 item["also_reexported"] = json!(contexts);
4637 }
4638}
4639
4640fn unused_exports_honesty_fields(parsed: &[UnusedExportsContribution]) -> (Vec<Value>, Vec<Value>) {
4641 let mut parse_error_keys = BTreeSet::new();
4642 let mut parse_errors = Vec::new();
4643 let mut skipped_file_keys = BTreeSet::new();
4644 let mut skipped_files = Vec::new();
4645 for contribution in parsed {
4646 for value in &contribution.parse_errors {
4647 let key = value.to_string();
4648 if parse_error_keys.insert(key) {
4649 parse_errors.push(value.clone());
4650 }
4651 }
4652 for value in &contribution.skipped_files {
4653 let key = value.to_string();
4654 if skipped_file_keys.insert(key) {
4655 skipped_files.push(value.clone());
4656 }
4657 }
4658 }
4659 (parse_errors, skipped_files)
4660}
4661
4662fn roll_up_duplicate_contributions(
4663 job: &InspectJob,
4664 contributions: &[FileContribution],
4665 drill_down_limit: Option<usize>,
4666) -> Value {
4667 super::scanners::duplicates::aggregate_duplicate_contributions_with_limit(
4668 contributions,
4669 skipped_languages(&job.scope_files, LanguageSkipMode::Duplicates),
4670 drill_down_limit,
4671 &job.config.inspect.duplicates.expected_mirrors,
4672 )
4673}
4674
4675fn roll_up_cycle_contributions(
4676 job: &InspectJob,
4677 contributions: &[FileContribution],
4678 drill_down_limit: Option<usize>,
4679) -> Value {
4680 super::scanners::cycles::aggregate_cycle_contributions_with_limit(
4681 &job.project_root,
4682 contributions,
4683 skipped_languages(&job.scope_files, LanguageSkipMode::Cycles),
4684 drill_down_limit,
4685 )
4686}
4687
4688fn roll_up_complexity_contributions(
4689 job: &InspectJob,
4690 contributions: &[FileContribution],
4691 drill_down_limit: Option<usize>,
4692) -> Value {
4693 super::scanners::complexity::aggregate_complexity_contributions_with_limit(
4694 &job.project_root,
4695 contributions,
4696 drill_down_limit,
4697 )
4698}
4699
4700fn cap_payload_drill_down(mut payload: Value, limit: usize) -> Value {
4701 let mut capped = false;
4702 if let Some(items) = payload.get_mut("items").and_then(Value::as_array_mut) {
4703 capped |= items.len() > limit;
4704 items.truncate(limit);
4705 }
4706 if let Some(groups) = payload.get_mut("groups").and_then(Value::as_array_mut) {
4707 capped |= groups.len() > limit;
4708 groups.truncate(limit);
4709 }
4710 if let Some(object) = payload.as_object_mut() {
4711 object.insert("drill_down_capped".to_string(), json!(capped));
4712 }
4713 payload
4714}
4715
4716const MAX_DRILL_DOWN_ITEMS: usize = 100;
4717
4718#[derive(Debug, Clone, Deserialize)]
4719struct ExportContribution {
4720 symbol: String,
4721 kind: String,
4722 line: u32,
4723 #[serde(default)]
4724 verdict: Option<LivenessVerdict>,
4725 #[serde(default)]
4726 reason: Option<String>,
4727 #[serde(default)]
4728 provenance: Option<String>,
4729}
4730
4731fn export_uses_oxc(export: &ExportContribution) -> bool {
4732 export.verdict.is_some() || export.provenance.as_deref() == Some(OXC_PROVENANCE)
4733}
4734
4735#[derive(Debug, Clone, Deserialize)]
4736struct DeadCodeRefreshContribution {
4737 #[serde(default)]
4738 facts_format_version: Option<u32>,
4739 #[serde(default)]
4740 oxc_facts: Option<OxcFactsContribution>,
4741}
4742
4743#[derive(Debug, Clone, Deserialize)]
4744struct UnusedExportsContribution {
4745 file: String,
4746 #[serde(default)]
4747 generated: Option<bool>,
4748 exports: Vec<ExportContribution>,
4749 #[serde(default)]
4750 imports: Vec<ImportContribution>,
4751 #[serde(default)]
4752 oxc_facts: Option<OxcFactsContribution>,
4753 #[serde(default)]
4754 parse_errors: Vec<Value>,
4755 #[serde(default)]
4756 skipped_files: Vec<Value>,
4757}
4758
4759#[derive(Debug, Clone, Deserialize)]
4760struct ImportContribution {
4761 resolved_file: Option<String>,
4762 named: Vec<String>,
4763}
4764
4765#[derive(Debug, Clone, Deserialize)]
4766struct OxcFactsContribution {
4767 format_version: u32,
4768 content_hash: String,
4769 exports: Vec<ExportFact>,
4770 imports: Vec<ImportFact>,
4771 re_exports: Vec<ReExportFact>,
4772 dynamic_imports: Vec<DynamicImportFact>,
4773 same_file_value_references: BTreeSet<String>,
4774 used_import_bindings: BTreeSet<String>,
4775 type_referenced_import_bindings: BTreeSet<String>,
4776 value_referenced_import_bindings: BTreeSet<String>,
4777 #[serde(default)]
4778 parse_error: Option<String>,
4779}
4780
4781#[derive(Debug, Clone, Copy)]
4782enum LanguageSkipMode {
4783 Duplicates,
4784 Cycles,
4785 UnusedExports,
4786}
4787
4788fn category_uses_oxc(category: InspectCategory) -> bool {
4789 matches!(
4790 category,
4791 InspectCategory::DeadCode | InspectCategory::UnusedExports | InspectCategory::Cycles
4792 )
4793}
4794
4795fn skipped_languages(files: &[PathBuf], mode: LanguageSkipMode) -> Vec<String> {
4796 files
4797 .iter()
4798 .filter_map(|file| skipped_language(file, mode))
4799 .collect::<BTreeSet<_>>()
4800 .into_iter()
4801 .collect()
4802}
4803
4804fn skipped_language(file: &Path, mode: LanguageSkipMode) -> Option<String> {
4805 let Some(language) = crate::parser::detect_language(file) else {
4806 return match mode {
4807 LanguageSkipMode::Duplicates => Some("unknown".to_string()),
4808 LanguageSkipMode::Cycles => Some("unknown".to_string()),
4809 LanguageSkipMode::UnusedExports => None,
4810 };
4811 };
4812
4813 let skipped = match mode {
4814 LanguageSkipMode::Duplicates => !duplicates_supports_language(language),
4815 LanguageSkipMode::Cycles => !is_js_ts_language(language),
4816 LanguageSkipMode::UnusedExports => !is_js_ts_language(language),
4817 };
4818 skipped.then(|| language_name(language).to_string())
4819}
4820
4821fn duplicates_supports_language(language: crate::parser::LangId) -> bool {
4822 !matches!(
4823 language,
4824 crate::parser::LangId::Bash
4825 | crate::parser::LangId::Html
4826 | crate::parser::LangId::Json
4827 | crate::parser::LangId::Scala
4828 | crate::parser::LangId::Solidity
4829 | crate::parser::LangId::Scss
4830 | crate::parser::LangId::Vue
4831 | crate::parser::LangId::Markdown
4832 | crate::parser::LangId::Java
4833 | crate::parser::LangId::Ruby
4834 | crate::parser::LangId::Kotlin
4835 | crate::parser::LangId::Swift
4836 | crate::parser::LangId::Php
4837 | crate::parser::LangId::Lua
4838 | crate::parser::LangId::Perl
4839 | crate::parser::LangId::Pascal
4840 | crate::parser::LangId::R
4841 | crate::parser::LangId::Groovy
4842 | crate::parser::LangId::ObjC
4843 | crate::parser::LangId::Toml
4844 )
4845}
4846
4847fn is_js_ts_language(language: crate::parser::LangId) -> bool {
4848 matches!(
4849 language,
4850 crate::parser::LangId::TypeScript
4851 | crate::parser::LangId::Tsx
4852 | crate::parser::LangId::JavaScript
4853 )
4854}
4855
4856fn language_name(language: crate::parser::LangId) -> &'static str {
4857 match language {
4858 crate::parser::LangId::TypeScript => "typescript",
4859 crate::parser::LangId::Tsx => "tsx",
4860 crate::parser::LangId::JavaScript => "javascript",
4861 crate::parser::LangId::Python => "python",
4862 crate::parser::LangId::Rust => "rust",
4863 crate::parser::LangId::Go => "go",
4864 crate::parser::LangId::C => "c",
4865 crate::parser::LangId::Cpp => "cpp",
4866 crate::parser::LangId::Cuda => "cuda",
4867 crate::parser::LangId::Metal => "metal",
4868 crate::parser::LangId::Zig => "zig",
4869 crate::parser::LangId::CSharp => "csharp",
4870 crate::parser::LangId::Bash => "bash",
4871 crate::parser::LangId::Html => "html",
4872 crate::parser::LangId::Markdown => "markdown",
4873 crate::parser::LangId::Yaml => "yaml",
4874 crate::parser::LangId::Solidity => "solidity",
4875 crate::parser::LangId::Scss => "scss",
4876 crate::parser::LangId::Vue => "vue",
4877 crate::parser::LangId::Json => "json",
4878 crate::parser::LangId::Scala => "scala",
4879 crate::parser::LangId::Java => "java",
4880 crate::parser::LangId::Ruby => "ruby",
4881 crate::parser::LangId::Kotlin => "kotlin",
4882 crate::parser::LangId::Swift => "swift",
4883 crate::parser::LangId::Php => "php",
4884 crate::parser::LangId::Lua => "lua",
4885 crate::parser::LangId::Perl => "perl",
4886 crate::parser::LangId::Pascal => "pascal",
4887 crate::parser::LangId::R => "r",
4888 crate::parser::LangId::Groovy => "groovy",
4889 crate::parser::LangId::ObjC => "objc",
4890 crate::parser::LangId::Toml => "toml",
4891 }
4892}
4893
4894fn unused_public_api_entries(project_root: &Path) -> (BTreeSet<String>, Vec<String>) {
4895 let entry_points = crate::inspect::entry_points::resolve_entry_points(project_root);
4896 (
4897 entry_points.public_api_files_relative(project_root),
4898 entry_points.warnings().to_vec(),
4899 )
4900}
4901
4902fn filter_outcome_for_scope_with_contributions(
4903 outcome: JobOutcome,
4904 snapshot: &InspectSnapshot,
4905 category: InspectCategory,
4906 cache: &(impl InspectCacheRead + ?Sized),
4907 scope: &JobScope,
4908) -> JobOutcome {
4909 if !category.is_tier2() || scope.is_project_wide() {
4910 return filter_outcome_for_scope(outcome, scope);
4911 }
4912
4913 match outcome {
4914 JobOutcome::Fresh { payload } => {
4915 match scoped_tier2_payload_from_contributions(snapshot, category, cache, payload, scope)
4916 {
4917 Ok(payload) => JobOutcome::Fresh { payload },
4918 Err(message) => JobOutcome::Failed { message },
4919 }
4920 }
4921 JobOutcome::Stale { cached, in_flight } => match cached {
4922 Some(payload) => {
4923 match scoped_tier2_payload_from_contributions(
4924 snapshot, category, cache, payload, scope,
4925 ) {
4926 Ok(payload) => JobOutcome::Stale {
4927 cached: Some(payload),
4928 in_flight,
4929 },
4930 Err(message) => JobOutcome::Failed { message },
4931 }
4932 }
4933 None => JobOutcome::Stale {
4934 cached: None,
4935 in_flight,
4936 },
4937 },
4938 JobOutcome::Pending { in_flight, wait } => JobOutcome::Pending { in_flight, wait },
4939 JobOutcome::Failed { message } => JobOutcome::Failed { message },
4940 }
4941}
4942
4943fn filter_outcome_for_scope(outcome: JobOutcome, scope: &JobScope) -> JobOutcome {
4944 match outcome {
4945 JobOutcome::Fresh { payload } => JobOutcome::Fresh {
4946 payload: filter_payload_for_scope(payload, scope),
4947 },
4948 JobOutcome::Stale { cached, in_flight } => JobOutcome::Stale {
4949 cached: cached.map(|payload| filter_payload_for_scope(payload, scope)),
4950 in_flight,
4951 },
4952 JobOutcome::Pending { in_flight, wait } => JobOutcome::Pending { in_flight, wait },
4953 JobOutcome::Failed { message } => JobOutcome::Failed { message },
4954 }
4955}
4956
4957fn filter_payload_for_scope(mut payload: serde_json::Value, scope: &JobScope) -> serde_json::Value {
4958 if scope.is_project_wide() {
4959 return payload;
4960 }
4961
4962 if let Some(items) = payload
4966 .get_mut("items")
4967 .and_then(|value| value.as_array_mut())
4968 {
4969 let count = filter_values_for_scope(items, scope);
4970 let largest_cycle = items
4971 .iter()
4972 .filter_map(|item| item.get("files").and_then(Value::as_array).map(Vec::len))
4973 .max();
4974 if let Some(object) = payload.as_object_mut() {
4975 object.insert("count".to_string(), serde_json::json!(count));
4976 if object.contains_key("largest") {
4977 object.insert(
4978 "largest".to_string(),
4979 serde_json::json!(largest_cycle.unwrap_or(0)),
4980 );
4981 }
4982 if object.contains_key("total_groups") {
4983 object.insert("total_groups".to_string(), serde_json::json!(count));
4984 }
4985 if object.contains_key("groups_count") {
4986 object.insert("groups_count".to_string(), serde_json::json!(count));
4987 }
4988 }
4989 }
4990
4991 if let Some(groups) = payload
4992 .get_mut("groups")
4993 .and_then(|value| value.as_array_mut())
4994 {
4995 let count = filter_values_for_scope(groups, scope);
4996 if let Some(object) = payload.as_object_mut() {
4997 object.insert("count".to_string(), serde_json::json!(count));
4998 object.insert("total_groups".to_string(), serde_json::json!(count));
4999 if object.contains_key("groups_count") {
5000 object.insert("groups_count".to_string(), serde_json::json!(count));
5001 }
5002 }
5003 }
5004
5005 if let Some(object) = payload.as_object_mut() {
5011 if object.contains_key("top") {
5012 if let Some(top) = recompute_scoped_top_preview(object) {
5013 object.insert("top".to_string(), top);
5014 } else if let Some(top) = object.get_mut("top").and_then(Value::as_array_mut) {
5015 filter_values_for_scope(top, scope);
5016 }
5017 }
5018 if object.contains_key("duplicated_lines") {
5019 recompute_duplicate_payload_stats(object);
5020 }
5021 object.remove("by_language");
5022 }
5023
5024 payload
5025}
5026
5027fn recompute_duplicate_payload_stats(object: &mut serde_json::Map<String, Value>) {
5028 let values = object
5029 .get("items")
5030 .or_else(|| object.get("groups"))
5031 .and_then(Value::as_array)
5032 .cloned()
5033 .unwrap_or_default();
5034 let (duplicated_lines, duplicated_file_count) = duplicate_line_stats_from_values(&values);
5035 let total_analyzed_lines = object
5036 .get("total_analyzed_lines")
5037 .and_then(Value::as_u64)
5038 .unwrap_or(0);
5039 let duplicated_percent = if total_analyzed_lines == 0 {
5040 0.0
5041 } else {
5042 (duplicated_lines as f64 * 100.0) / total_analyzed_lines as f64
5043 };
5044 object.insert("duplicated_lines".to_string(), json!(duplicated_lines));
5045 object.insert(
5046 "duplicated_file_count".to_string(),
5047 json!(duplicated_file_count),
5048 );
5049 object.insert("duplicated_percent".to_string(), json!(duplicated_percent));
5050}
5051
5052fn duplicate_line_stats_from_values(values: &[Value]) -> (u64, usize) {
5053 let mut by_file = BTreeMap::<String, Vec<(u64, u64)>>::new();
5054 for value in values {
5055 let Some(files) = value.get("files").and_then(Value::as_array) else {
5056 continue;
5057 };
5058 for occurrence in files.iter().filter_map(Value::as_str) {
5059 let Some((file, start, end)) = parse_duplicate_occurrence(occurrence) else {
5060 continue;
5061 };
5062 by_file
5063 .entry(file.to_string())
5064 .or_default()
5065 .push((start, end));
5066 }
5067 }
5068 let file_count = by_file.len();
5069 let duplicated_lines = by_file
5070 .values_mut()
5071 .map(|intervals| merged_duplicate_interval_lines(intervals))
5072 .sum();
5073 (duplicated_lines, file_count)
5074}
5075
5076fn merged_duplicate_interval_lines(intervals: &mut [(u64, u64)]) -> u64 {
5077 if intervals.is_empty() {
5078 return 0;
5079 }
5080 intervals.sort_by(|left, right| left.0.cmp(&right.0).then(left.1.cmp(&right.1)));
5081 let (mut current_start, mut current_end) = intervals[0];
5082 let mut total = 0;
5083 for &(start, end) in &intervals[1..] {
5084 if start <= current_end.saturating_add(1) {
5085 current_end = current_end.max(end);
5086 } else {
5087 total += current_end.saturating_sub(current_start).saturating_add(1);
5088 current_start = start;
5089 current_end = end;
5090 }
5091 }
5092 total + current_end.saturating_sub(current_start).saturating_add(1)
5093}
5094
5095fn recompute_scoped_top_preview(
5096 object: &serde_json::Map<String, Value>,
5097) -> Option<serde_json::Value> {
5098 let values = object
5099 .get("items")
5100 .or_else(|| object.get("groups"))
5101 .and_then(Value::as_array)?;
5102 Some(Value::Array(
5103 values
5104 .iter()
5105 .take(super::entry_points::TOP_PREVIEW_ITEMS)
5106 .map(top_preview_value)
5107 .collect(),
5108 ))
5109}
5110
5111fn top_preview_value(value: &Value) -> Value {
5112 if let Some(files) = value.get("files").and_then(Value::as_array) {
5113 let mut object = serde_json::Map::new();
5114 object.insert("files".to_string(), Value::Array(files.clone()));
5115 if let Some(cost) = value.get("cost").cloned() {
5116 object.insert("cost".to_string(), cost);
5117 }
5118 return Value::Object(object);
5119 }
5120
5121 json!({
5122 "file": value.get("file").and_then(Value::as_str).unwrap_or(""),
5123 "symbol": value.get("symbol").and_then(Value::as_str).unwrap_or(""),
5124 })
5125}
5126
5127fn filter_values_for_scope(values: &mut Vec<serde_json::Value>, scope: &JobScope) -> usize {
5128 values.retain_mut(|value| prune_value_for_scope(value, scope));
5129 values.len()
5130}
5131
5132fn prune_value_for_scope(value: &mut serde_json::Value, scope: &JobScope) -> bool {
5133 if let Some(file) = value.get("file").and_then(|file| file.as_str()) {
5134 return scope.contains_display_path(file);
5135 }
5136
5137 let first_scoped_occurrence = if let Some(files) = value
5138 .get_mut("files")
5139 .and_then(|files| files.as_array_mut())
5140 {
5141 files.retain(|file| {
5142 file.as_str()
5143 .is_some_and(|file| scope.contains_display_path(display_file_from_occurrence(file)))
5144 });
5145 if files.len() < 2 {
5146 return false;
5147 }
5148 files.first().and_then(Value::as_str).map(str::to_string)
5149 } else {
5150 None
5151 };
5152
5153 if let Some(occurrence) = first_scoped_occurrence {
5154 update_duplicate_group_sample(value, &occurrence);
5155 }
5156
5157 true
5158}
5159
5160fn update_duplicate_group_sample(value: &mut serde_json::Value, occurrence: &str) {
5161 let Some((file, start_line, end_line)) = parse_duplicate_occurrence(occurrence) else {
5162 return;
5163 };
5164 let Some(object) = value.as_object_mut() else {
5165 return;
5166 };
5167
5168 if object.contains_key("sample_file") {
5169 object.insert("sample_file".to_string(), json!(file));
5170 }
5171 if object.contains_key("sample_start_line") {
5172 object.insert("sample_start_line".to_string(), json!(start_line));
5173 }
5174 if object.contains_key("sample_end_line") {
5175 object.insert("sample_end_line".to_string(), json!(end_line));
5176 }
5177}
5178
5179fn parse_duplicate_occurrence(value: &str) -> Option<(&str, u64, u64)> {
5180 let (file, range) = value.rsplit_once(':')?;
5181 let (start, end) = range.split_once('-')?;
5182 if !start.chars().all(|char| char.is_ascii_digit())
5183 || !end.chars().all(|char| char.is_ascii_digit())
5184 {
5185 return None;
5186 }
5187
5188 Some((file, start.parse().ok()?, end.parse().ok()?))
5189}
5190
5191fn display_file_from_occurrence(value: &str) -> &str {
5192 let Some((file, range)) = value.rsplit_once(':') else {
5193 return value;
5194 };
5195 let Some((start, end)) = range.split_once('-') else {
5196 return value;
5197 };
5198 if start.chars().all(|char| char.is_ascii_digit())
5199 && end.chars().all(|char| char.is_ascii_digit())
5200 {
5201 file
5202 } else {
5203 value
5204 }
5205}
5206
5207#[cfg(test)]
5208#[cfg_attr(not(debug_assertions), allow(dead_code))]
5211mod guard_tests {
5212 use super::*;
5213
5214 fn write_ts_project(file_count: usize) -> tempfile::TempDir {
5215 let dir = tempfile::tempdir().expect("tempdir");
5216 let root = dir.path();
5217 for i in 0..file_count {
5218 std::fs::write(
5219 root.join(format!("mod{i}.ts")),
5220 format!("export function f{i}() {{ return {i}; }}\n"),
5221 )
5222 .expect("write fixture");
5223 }
5224 let canonical_root = std::fs::canonicalize(root).expect("canonical fixture root");
5225 let project_key = crate::search_index::artifact_cache_key(&canonical_root);
5226 crate::root_cache::configure_artifact_access(&canonical_root, &project_key, false);
5227 dir
5228 }
5229
5230 fn tier1_snapshot(root: &Path) -> InspectSnapshot {
5231 use crate::config::Config;
5232 use crate::parser::SymbolCache;
5233 use std::sync::RwLock;
5234
5235 InspectSnapshot::new(
5236 root.to_path_buf(),
5237 root.join(".aft-cache/inspect"),
5238 Arc::new(Config {
5239 project_root: Some(root.to_path_buf()),
5240 ..Config::default()
5241 }),
5242 Arc::new(RwLock::new(SymbolCache::new())),
5243 )
5244 }
5245
5246 #[test]
5247 fn tier1_worker_panic_delivers_failed_to_waiter() {
5248 let dir = write_ts_project(2);
5249 let root = std::fs::canonicalize(dir.path()).expect("canonical fixture root");
5250 let snapshot = tier1_snapshot(&root);
5251 let manager = InspectManager::with_worker(
5255 Arc::new(|_| panic!("forced Tier-1 worker panic")),
5256 Duration::from_secs(10),
5257 );
5258
5259 let outcome = manager.submit_category(
5260 snapshot,
5261 InspectCategory::Metrics,
5262 JobScope::for_project(root),
5263 );
5264
5265 match outcome {
5266 JobOutcome::Failed { message } => assert!(
5267 message.contains(
5268 "inspect worker panicked before completion: forced Tier-1 worker panic"
5269 ),
5270 "unexpected panic terminal: {message}"
5271 ),
5272 other => panic!("worker panic must deliver Failed, got {other:?}"),
5273 }
5274 assert!(
5275 manager
5276 .in_flight
5277 .lock()
5278 .unwrap_or_else(std::sync::PoisonError::into_inner)
5279 .is_empty(),
5280 "panic completion must clear its waiter registration"
5281 );
5282 }
5283
5284 #[test]
5285 fn ready_worker_result_wins_over_simultaneously_ready_deadline() {
5286 let dir = write_ts_project(2);
5287 let root = std::fs::canonicalize(dir.path()).expect("canonical fixture root");
5288 let snapshot = tier1_snapshot(&root);
5289 let manager = InspectManager::with_worker(
5290 Arc::new(|job| {
5291 InspectResult::success(
5292 &job,
5293 InspectScanSuccess {
5294 scanned_files: job.scope_files.clone(),
5295 contributions: Vec::new(),
5296 aggregate: json!({"count": 2}),
5297 },
5298 Duration::ZERO,
5299 )
5300 }),
5301 Duration::from_secs(1),
5302 );
5303 let scope = JobScope::for_project(root);
5304 let key = JobKey::for_category_scope(InspectCategory::Metrics, &scope);
5305 let cache = manager
5306 .cache_for_snapshot(&snapshot)
5307 .expect("open inspect cache");
5308 let (waiter_tx, waiter_rx) = bounded(1);
5309 manager
5310 .enqueue_with_waiter(
5311 snapshot.clone(),
5312 InspectCategory::Metrics,
5313 scope.clone(),
5314 key.clone(),
5315 waiter_tx,
5316 None,
5317 )
5318 .expect("enqueue metrics scan");
5319
5320 let result_deadline = Instant::now() + Duration::from_secs(5);
5321 while manager.result_rx.is_empty() {
5322 assert!(
5323 Instant::now() < result_deadline,
5324 "worker result did not become ready"
5325 );
5326 std::thread::sleep(Duration::from_millis(1));
5327 }
5328 let wait_started = Instant::now();
5329 let outcome = manager.wait_for_outcome(
5330 key,
5331 scope,
5332 cache,
5333 waiter_rx,
5334 snapshot,
5335 wait_started,
5336 wait_started,
5337 Duration::ZERO,
5338 );
5339
5340 assert!(
5341 matches!(outcome, JobOutcome::Fresh { .. }),
5342 "an already-ready terminal result must beat the deadline: {outcome:?}"
5343 );
5344 }
5345
5346 struct ProjectionObserverReset;
5347
5348 impl Drop for ProjectionObserverReset {
5349 fn drop(&mut self) {
5350 crate::callgraph_store::set_projection_before_open_observer(None);
5351 }
5352 }
5353
5354 fn count_projections() -> (Arc<std::sync::atomic::AtomicUsize>, ProjectionObserverReset) {
5355 let count = Arc::new(std::sync::atomic::AtomicUsize::new(0));
5356 let observed = Arc::clone(&count);
5357 crate::callgraph_store::set_projection_before_open_observer(Some(Arc::new(move |_| {
5358 observed.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
5359 })));
5360 (count, ProjectionObserverReset)
5361 }
5362
5363 fn write_projection_cache_file(path: &Path, contents: &str) {
5364 std::fs::create_dir_all(path.parent().expect("fixture file parent"))
5365 .expect("create fixture parent");
5366 std::fs::write(path, contents).expect("write fixture file");
5367 }
5368
5369 fn published_projection_fixture() -> (tempfile::TempDir, PathBuf, PathBuf, InspectJob) {
5370 let dir = tempfile::tempdir().expect("tempdir");
5371 let root = std::fs::canonicalize(dir.path()).expect("canonical fixture root");
5372 write_projection_cache_file(
5373 &root.join("src/main.ts"),
5374 "import { firstTarget } from './target';\nexport function main() { firstTarget(); }\n",
5375 );
5376 write_projection_cache_file(
5377 &root.join("src/target.ts"),
5378 "export function firstTarget() {}\n",
5379 );
5380 let inspect_dir = root.join(".aft-cache").join("inspect");
5381 let project_key = crate::search_index::artifact_cache_key(&root);
5382 crate::root_cache::configure_artifact_access(&root, &project_key, false);
5383 let callgraph_dir =
5384 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("callgraph dir");
5385 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
5386 let (store, _) = CallGraphStore::cold_build_with_lease(callgraph_dir, root.clone(), &files)
5387 .expect("publish initial generation");
5388 drop(store);
5389 let mut job = snapshot_job(&root, &inspect_dir, true);
5390 job.callgraph_writer = false;
5391 (dir, root, inspect_dir, job)
5392 }
5393
5394 #[test]
5395 fn scoped_filter_recomputes_top_preview_from_scoped_items() {
5396 let project_root = PathBuf::from("/project");
5397 let scope = JobScope::from_roots(project_root.clone(), vec![project_root.join("src/in")]);
5398 let payload = json!({
5399 "count": 4,
5400 "items": [
5401 { "file": "src/out/a.ts", "symbol": "outside" },
5402 { "file": "src/in/b.ts", "symbol": "inside_b" },
5403 { "file": "src/in/c.ts", "symbol": "inside_c" }
5404 ],
5405 "top": [
5406 { "file": "src/out/a.ts", "symbol": "outside" },
5407 { "file": "src/out/z.ts", "symbol": "outside_z" }
5408 ],
5409 "by_language": { "typescript": 4 }
5410 });
5411
5412 let filtered = filter_payload_for_scope(payload, &scope);
5413
5414 assert_eq!(filtered["count"], json!(2));
5415 assert_eq!(
5416 filtered["top"],
5417 json!([
5418 { "file": "src/in/b.ts", "symbol": "inside_b" },
5419 { "file": "src/in/c.ts", "symbol": "inside_c" }
5420 ])
5421 );
5422 assert!(filtered["top"]
5423 .as_array()
5424 .unwrap()
5425 .iter()
5426 .all(|item| item["file"]
5427 .as_str()
5428 .is_some_and(|file| file.starts_with("src/in/"))));
5429 }
5430
5431 fn artifact_cache_key_for_test(project_root: &std::path::Path) -> String {
5432 let _git_env = crate::test_env::hermetic_git_env_guard();
5433 crate::search_index::artifact_cache_key(project_root)
5434 }
5435
5436 #[test]
5437 fn cache_for_paths_rebinds_same_project_key_to_current_root() {
5438 let _git_env = crate::test_env::hermetic_git_env_guard();
5439 let dir = tempfile::tempdir().expect("tempdir");
5440 let source = dir.path().join("source");
5441 std::fs::create_dir_all(&source).expect("create source repo");
5442 std::fs::write(
5443 source.join("package.json"),
5444 r#"{"name":"inspect-cache-fixture","version":"1.0.0"}"#,
5445 )
5446 .expect("write source manifest");
5447 std::fs::write(source.join("index.ts"), "export const source = 1;\n")
5448 .expect("write source file");
5449 let mut init = std::process::Command::new("git");
5450 assert!(
5451 crate::test_env::apply_hermetic_git_env(init.current_dir(&source))
5452 .arg("init")
5453 .status()
5454 .expect("git init source repo")
5455 .success()
5456 );
5457 let mut add = std::process::Command::new("git");
5458 assert!(
5459 crate::test_env::apply_hermetic_git_env(add.current_dir(&source))
5460 .args(["add", "."])
5461 .status()
5462 .expect("git add source repo")
5463 .success()
5464 );
5465 let mut commit = std::process::Command::new("git");
5466 assert!(
5467 crate::test_env::apply_hermetic_git_env(commit.current_dir(&source))
5468 .args([
5469 "-c",
5470 "user.name=AFT Tests",
5471 "-c",
5472 "user.email=aft-tests@example.com",
5473 "commit",
5474 "-m",
5475 "initial",
5476 ])
5477 .status()
5478 .expect("git commit source repo")
5479 .success()
5480 );
5481
5482 let clone = dir.path().join("clone");
5483 let mut clone_command = std::process::Command::new("git");
5484 assert!(crate::test_env::apply_hermetic_git_env(&mut clone_command)
5485 .args(["clone", "--quiet"])
5486 .arg(&source)
5487 .arg(&clone)
5488 .status()
5489 .expect("git clone source repo")
5490 .success());
5491 std::fs::write(
5492 clone.join("package.json"),
5493 r#"{"name":"inspect-cache-fixture","version":"2.0.0"}"#,
5494 )
5495 .expect("write clone manifest edit");
5496 assert_eq!(
5497 artifact_cache_key_for_test(&source),
5498 artifact_cache_key_for_test(&clone),
5499 "clones with the same root commit should share the sqlite project key"
5500 );
5501
5502 let source = std::fs::canonicalize(source).expect("canonical source root");
5503 let clone = std::fs::canonicalize(clone).expect("canonical clone root");
5504 let manager = InspectManager::new();
5505 let inspect_dir = dir.path().join("inspect");
5506 let key = JobKey::for_project_category(InspectCategory::DeadCode);
5507 let source_cache = manager
5508 .cache_for_paths(inspect_dir.clone(), source.clone())
5509 .expect("open source cache");
5510 let source_hash = source_cache
5511 .contribution_set_hash(InspectCategory::DeadCode)
5512 .expect("source contribution hash");
5513 source_cache
5514 .store_tier2_aggregate(
5515 key.clone(),
5516 &source_hash,
5517 serde_json::json!({ "count": 7, "items": [] }),
5518 )
5519 .expect("store source aggregate");
5520 assert_eq!(
5521 source_cache
5522 .get_aggregated(&key)
5523 .expect("read source aggregate")
5524 .and_then(|payload| payload.get("count").and_then(Value::as_u64)),
5525 Some(7)
5526 );
5527
5528 let clone_cache = manager
5529 .cache_for_paths(inspect_dir, clone.clone())
5530 .expect("open clone cache");
5531 assert_eq!(clone_cache.project_root(), clone.as_path());
5532 assert!(
5533 clone_cache
5534 .get_aggregated(&key)
5535 .expect("read clone aggregate")
5536 .is_none(),
5537 "same-key clone with a different manifest must not reuse the source root's cached count"
5538 );
5539 }
5540
5541 #[test]
5542 fn dead_code_blocked_on_callgraph_reads_latest_aggregate_flag() {
5543 let dir = tempfile::tempdir().unwrap();
5548 let project_root = std::fs::canonicalize(dir.path()).unwrap();
5549 std::fs::write(project_root.join("lib.rs"), "pub fn marker() {}\n").unwrap();
5550 let manager = InspectManager::new();
5551 let inspect_dir = dir.path().join("inspect");
5552
5553 assert!(!manager.dead_code_blocked_on_callgraph(inspect_dir.clone(), project_root.clone()));
5555
5556 let cache = manager
5557 .cache_for_paths(inspect_dir.clone(), project_root.clone())
5558 .expect("open cache");
5559 let key = JobKey::for_project_category(InspectCategory::DeadCode);
5560 let hash = cache
5561 .contribution_set_hash(InspectCategory::DeadCode)
5562 .expect("contribution hash");
5563
5564 cache
5566 .store_tier2_aggregate(
5567 key.clone(),
5568 &hash,
5569 serde_json::json!({ "count": 3, "callgraph_available": true }),
5570 )
5571 .expect("store callgraph-backed aggregate");
5572 assert!(!manager.dead_code_blocked_on_callgraph(inspect_dir.clone(), project_root.clone()));
5573 assert_eq!(
5574 manager
5575 .latest_tier2_counts(inspect_dir.clone(), project_root.clone())
5576 .0,
5577 Some(3)
5578 );
5579
5580 cache
5583 .store_tier2_aggregate(
5584 key,
5585 &hash,
5586 crate::inspect::scanners::dead_code::callgraph_unavailable_aggregate(1),
5587 )
5588 .expect("store callgraph_unavailable aggregate");
5589 assert!(manager.dead_code_blocked_on_callgraph(inspect_dir.clone(), project_root.clone()));
5590 assert_eq!(
5591 manager.latest_tier2_counts(inspect_dir, project_root).0,
5592 None,
5593 "callgraph_unavailable dead_code must stay suppressed"
5594 );
5595 }
5596
5597 fn snapshot_job(root: &Path, inspect_dir: &Path, callgraph_store: bool) -> InspectJob {
5598 use crate::config::Config;
5599 use crate::parser::SymbolCache;
5600 use std::sync::RwLock;
5601
5602 InspectJob {
5603 job_id: 1,
5604 key: JobKey::for_project_category(InspectCategory::DeadCode),
5605 category: InspectCategory::DeadCode,
5606 scope_files: Vec::new(),
5607 project_root: root.to_path_buf(),
5608 inspect_dir: inspect_dir.to_path_buf(),
5609 config: Arc::new(Config {
5610 project_root: Some(root.to_path_buf()),
5611 callgraph_store,
5612 ..Config::default()
5613 }),
5614 symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
5615 inspect_writer: true,
5616 callgraph_writer: true,
5617 callgraph_snapshot: None,
5618 }
5619 }
5620
5621 #[test]
5622 fn blocking_inspect_overtakes_queued_maintenance_after_active_seed_releases() {
5623 use crate::config::Config;
5624 use crate::parser::SymbolCache;
5625 use std::sync::RwLock;
5626
5627 let dir = tempfile::tempdir().expect("tempdir");
5628 let root = std::fs::canonicalize(dir.path()).expect("canonical fixture root");
5629 std::fs::create_dir_all(root.join("src")).expect("create source directory");
5630 std::fs::write(
5631 root.join("src/main.ts"),
5632 "export function plantedDead() { return 1; }\n",
5633 )
5634 .expect("write source fixture");
5635 let project_key = crate::search_index::artifact_cache_key(&root);
5636 crate::root_cache::configure_artifact_access(&root, &project_key, false);
5637 let inspect_dir = root.join(".aft-cache").join("inspect");
5638 let snapshot = InspectSnapshot::new_with_capabilities(
5639 root.clone(),
5640 inspect_dir,
5641 Arc::new(Config {
5642 project_root: Some(root.clone()),
5643 callgraph_store: true,
5644 ..Config::default()
5645 }),
5646 Arc::new(RwLock::new(SymbolCache::new())),
5647 true,
5648 true,
5649 );
5650
5651 let limiter = cold_build_limiter::test_limiter(1);
5652 let active_request = cold_build_limiter::ColdBuildAdmissionRequest::new(
5653 "active-semantic-seed",
5654 cold_build_limiter::ColdBuildAdmissionClass::Maintenance,
5655 );
5656 let active =
5657 cold_build_limiter::try_acquire_classified_with_limiter(&limiter, &active_request)
5658 .expect("active semantic seed holds the only slot");
5659 let semantic_seed_active = Arc::new(AtomicBool::new(true));
5660 let manager = Arc::new(InspectManager::with_root_work_gates(
5661 Arc::new(AtomicBool::new(true)),
5662 Arc::clone(&semantic_seed_active),
5663 ));
5664 manager.set_cold_build_limiter(Arc::clone(&limiter));
5665
5666 let inspect_manager = Arc::clone(&manager);
5667 let scope = JobScope::for_project(root);
5668 let (outcome_tx, outcome_rx) = std::sync::mpsc::channel();
5669 std::thread::spawn(move || {
5670 let outcome = inspect_manager.tier2_run_with_reuse_blocking_fresh(
5671 snapshot,
5672 InspectCategory::DeadCode,
5673 scope,
5674 );
5675 outcome_tx.send(outcome).expect("send inspect outcome");
5676 });
5677
5678 let deadline = Instant::now() + Duration::from_secs(3);
5679 while manager.tier2_builder_state(InspectCategory::DeadCode)
5680 != InspectBuilderState::GatedBySemanticSeed
5681 {
5682 assert!(
5683 Instant::now() < deadline,
5684 "inspect must queue while the active seed owns the slot"
5685 );
5686 std::thread::yield_now();
5687 }
5688 assert!(
5689 outcome_rx.try_recv().is_err(),
5690 "in-flight work is not preempted"
5691 );
5692
5693 let maintenance_limiter = Arc::clone(&limiter);
5694 let maintenance = std::thread::spawn(move || {
5695 cold_build_limiter::acquire_blocking_while_with_limiter(
5696 &maintenance_limiter,
5697 "queued background refresh",
5698 || true,
5699 )
5700 .expect("background refresh eventually resumes")
5701 });
5702 std::thread::sleep(Duration::from_millis(150));
5703 semantic_seed_active.store(false, Ordering::SeqCst);
5704 drop(active);
5705
5706 let outcome = outcome_rx
5707 .recv_timeout(Duration::from_secs(10))
5708 .expect("blocking inspect completes after the active seed releases");
5709 let payload = outcome.payload().expect("blocking inspect is fresh");
5710 assert_eq!(
5711 payload.get("callgraph_available").and_then(Value::as_bool),
5712 Some(true)
5713 );
5714 drop(maintenance.join().expect("background waiter joins"));
5715
5716 let events = limiter.admission_events();
5717 assert_eq!(
5718 events[0].class,
5719 cold_build_limiter::ColdBuildAdmissionClass::Maintenance
5720 );
5721 assert_eq!(
5722 events[1].class,
5723 cold_build_limiter::ColdBuildAdmissionClass::InspectTriggered,
5724 "explicit inspect takes the first released slot"
5725 );
5726 assert_eq!(
5727 events[2].class,
5728 cold_build_limiter::ColdBuildAdmissionClass::Maintenance
5729 );
5730 }
5731
5732 #[test]
5733 fn post_eviction_rebind_serves_unchanged_tier2_aggregate_without_cold_slot() {
5734 use crate::config::Config;
5735 use crate::parser::SymbolCache;
5736 use std::sync::RwLock;
5737
5738 let dir = tempfile::tempdir().expect("tempdir");
5739 let root = std::fs::canonicalize(dir.path()).expect("canonical fixture root");
5740 std::fs::create_dir_all(root.join("src")).expect("create source directory");
5741 std::fs::write(
5742 root.join("src/main.ts"),
5743 "export function plantedDead() { return 1; }\n",
5744 )
5745 .expect("write source fixture");
5746 let project_key = crate::search_index::artifact_cache_key(&root);
5747 crate::root_cache::configure_artifact_access(&root, &project_key, false);
5748 let inspect_dir = root.join(".aft-cache").join("inspect");
5749 let snapshot = InspectSnapshot::new_with_capabilities(
5750 root.clone(),
5751 inspect_dir,
5752 Arc::new(Config {
5753 project_root: Some(root.clone()),
5754 callgraph_store: true,
5755 ..Config::default()
5756 }),
5757 Arc::new(RwLock::new(SymbolCache::new())),
5758 true,
5759 true,
5760 );
5761 let limiter = cold_build_limiter::test_limiter(1);
5762 let manager = Arc::new(InspectManager::new());
5763 manager.set_cold_build_limiter(Arc::clone(&limiter));
5764 let first = manager.tier2_run_with_reuse_blocking_fresh(
5765 snapshot.clone(),
5766 InspectCategory::DeadCode,
5767 JobScope::for_project(root.clone()),
5768 );
5769 assert!(
5770 first.payload().is_some(),
5771 "initial scan persists a fresh aggregate"
5772 );
5773 assert!(!manager.tier2_any_in_flight());
5774 manager.evict_idle_caches();
5775
5776 let maintenance_request = cold_build_limiter::ColdBuildAdmissionRequest::new(
5777 "post-eviction-search-verify",
5778 cold_build_limiter::ColdBuildAdmissionClass::Maintenance,
5779 );
5780 let maintenance =
5781 cold_build_limiter::try_acquire_classified_with_limiter(&limiter, &maintenance_request)
5782 .expect("background verification owns the only cold slot");
5783 let events_before = limiter.admission_events().len();
5784 let rebound_manager = Arc::clone(&manager);
5785 let (outcome_tx, outcome_rx) = std::sync::mpsc::channel();
5786 std::thread::spawn(move || {
5787 let outcome = rebound_manager.tier2_run_with_reuse_blocking_fresh(
5788 snapshot,
5789 InspectCategory::DeadCode,
5790 JobScope::for_project(root),
5791 );
5792 outcome_tx.send(outcome).expect("send rebound outcome");
5793 });
5794
5795 let rebound = outcome_rx
5796 .recv_timeout(Duration::from_secs(3))
5797 .expect("unchanged persisted aggregate bypasses the occupied cold-build queue");
5798 assert!(rebound.payload().is_some());
5799 assert_eq!(
5800 limiter.admission_events().len(),
5801 events_before,
5802 "quick reuse must not request an interactive cold-build permit"
5803 );
5804 drop(maintenance);
5805 assert_eq!(
5806 manager.tier2_builder_state(InspectCategory::DeadCode),
5807 InspectBuilderState::Absent,
5808 "quick reuse must clear the builder registry on the way out"
5809 );
5810 assert!(!manager.tier2_any_in_flight());
5811 }
5812
5813 #[test]
5814 fn background_tier2_reuse_panic_clears_builder_registration() {
5815 use crate::config::Config;
5816 use crate::parser::SymbolCache;
5817 use std::sync::RwLock;
5818
5819 let dir = tempfile::tempdir().expect("tempdir");
5820 let root = std::fs::canonicalize(dir.path()).expect("canonical fixture root");
5821 std::fs::create_dir_all(root.join("src")).expect("create source directory");
5822 std::fs::write(
5823 root.join("src/dup.ts"),
5824 "export function planted() { return 1; }\n",
5825 )
5826 .expect("write source fixture");
5827 let project_key = crate::search_index::artifact_cache_key(&root);
5828 crate::root_cache::configure_artifact_access(&root, &project_key, false);
5829 let inspect_dir = root.join(".aft-cache").join("inspect");
5830 let snapshot = InspectSnapshot::new_with_capabilities(
5831 root.clone(),
5832 inspect_dir,
5833 Arc::new(Config {
5834 project_root: Some(root.clone()),
5835 ..Config::default()
5836 }),
5837 Arc::new(RwLock::new(SymbolCache::new())),
5838 true,
5839 true,
5840 );
5841
5842 let previous_root = std::env::var_os("AFT_TEST_TIER2_REUSE_PANIC_ROOT");
5843 let previous_category = std::env::var_os("AFT_TEST_TIER2_REUSE_PANIC_CATEGORY");
5844 unsafe {
5845 std::env::set_var("AFT_TEST_TIER2_REUSE_PANIC_ROOT", &root);
5846 std::env::set_var("AFT_TEST_TIER2_REUSE_PANIC_CATEGORY", "duplicates");
5847 }
5848 struct RestorePanicEnv {
5849 root: Option<std::ffi::OsString>,
5850 category: Option<std::ffi::OsString>,
5851 }
5852 impl Drop for RestorePanicEnv {
5853 fn drop(&mut self) {
5854 unsafe {
5855 match self.root.take() {
5856 Some(value) => std::env::set_var("AFT_TEST_TIER2_REUSE_PANIC_ROOT", value),
5857 None => std::env::remove_var("AFT_TEST_TIER2_REUSE_PANIC_ROOT"),
5858 }
5859 match self.category.take() {
5860 Some(value) => {
5861 std::env::set_var("AFT_TEST_TIER2_REUSE_PANIC_CATEGORY", value)
5862 }
5863 None => std::env::remove_var("AFT_TEST_TIER2_REUSE_PANIC_CATEGORY"),
5864 }
5865 }
5866 }
5867 }
5868 let _restore = RestorePanicEnv {
5869 root: previous_root,
5870 category: previous_category,
5871 };
5872
5873 let manager = Arc::new(InspectManager::new());
5874 manager
5875 .submit_tier2_run_with_reuse_background(snapshot, InspectCategory::Duplicates)
5876 .expect("queue background duplicates scan");
5877
5878 let deadline = Instant::now() + Duration::from_secs(20);
5879 loop {
5880 if !manager.tier2_any_in_flight()
5881 && manager.tier2_builder_state(InspectCategory::Duplicates)
5882 == InspectBuilderState::Absent
5883 {
5884 break;
5885 }
5886 assert!(
5887 Instant::now() < deadline,
5888 "a reuse worker that panics before the completion router must still clear the builder registry"
5889 );
5890 std::thread::sleep(Duration::from_millis(10));
5891 }
5892 }
5893
5894 fn generated_unused_exports_fixture() -> (tempfile::TempDir, PathBuf, Vec<PathBuf>) {
5895 let dir = tempfile::tempdir().expect("tempdir");
5896 let root = dir.path().to_path_buf();
5897 let files = [
5898 (
5899 "src/hand.ts",
5900 "export function handUnused() {}
5901",
5902 ),
5903 (
5904 "gen/schema_pb.ts",
5905 "export function generatedPathUnused() {}
5906",
5907 ),
5908 (
5909 "src/banner.ts",
5910 "// Code generated by fixture. DO NOT EDIT.
5911export function bannerUnused() {}
5912",
5913 ),
5914 ];
5915 let paths = files
5916 .iter()
5917 .map(|(relative, contents)| {
5918 let path = root.join(relative);
5919 if let Some(parent) = path.parent() {
5920 std::fs::create_dir_all(parent).expect("create parent");
5921 }
5922 std::fs::write(&path, contents).expect("write fixture file");
5923 std::fs::canonicalize(path).expect("canonical fixture path")
5924 })
5925 .collect::<Vec<_>>();
5926 (
5927 dir,
5928 std::fs::canonicalize(root).expect("canonical root"),
5929 paths,
5930 )
5931 }
5932
5933 fn unused_exports_job(root: &Path, scope_files: Vec<PathBuf>) -> InspectJob {
5934 use crate::config::Config;
5935 use crate::parser::SymbolCache;
5936 use std::sync::RwLock;
5937
5938 InspectJob {
5939 job_id: 1,
5940 key: JobKey::for_project_category(InspectCategory::UnusedExports),
5941 category: InspectCategory::UnusedExports,
5942 scope_files,
5943 project_root: root.to_path_buf(),
5944 inspect_dir: root.join(".aft-cache").join("inspect"),
5945 config: Arc::new(Config {
5946 project_root: Some(root.to_path_buf()),
5947 ..Config::default()
5948 }),
5949 symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
5950 inspect_writer: true,
5951 callgraph_writer: true,
5952 callgraph_snapshot: None,
5953 }
5954 }
5955
5956 #[test]
5957 fn unused_exports_oxc_cached_rollup_preserves_generated_split() {
5958 let (_dir, root, paths) = generated_unused_exports_fixture();
5959 let job = unused_exports_job(&root, paths.clone());
5960 let entry_points = crate::inspect::entry_points::resolve_entry_points(&root);
5961 let oxc_result = crate::inspect::oxc_engine::analyze_files(
5962 &root,
5963 &paths,
5964 AnalyzeOptions {
5965 entry_points: Vec::new(),
5966 public_api_files: entry_points.public_api_files(),
5967 executable_root_exports: entry_points.executable_root_exports(),
5968 force_reparse_files: Vec::new(),
5969 entry_reachability: false,
5970 },
5971 )
5972 .expect("oxc analyze succeeds");
5973 let fresh = crate::inspect::scanners::unused_exports::run_unused_exports_scan_with_oxc(
5974 &job,
5975 Some(&oxc_result),
5976 )
5977 .outcome
5978 .expect("fresh scan succeeds");
5979
5980 let rolled_up = roll_up_unused_exports_contributions(
5981 &job,
5982 &fresh.contributions,
5983 Some(MAX_DRILL_DOWN_ITEMS),
5984 );
5985
5986 assert_eq!(
5987 rolled_up, fresh.aggregate,
5988 "cached rollup must match fresh scan"
5989 );
5990 assert_eq!(rolled_up["count"], 1, "{rolled_up:#}");
5991 assert_eq!(rolled_up["generated_count"], 2, "{rolled_up:#}");
5992 assert_eq!(rolled_up["total_count"], 3, "{rolled_up:#}");
5993 }
5994
5995 #[test]
5996 fn unused_exports_cached_generated_state_avoids_reprobe_with_legacy_fallback() {
5997 let (_dir, root, paths) = generated_unused_exports_fixture();
5998 let job = unused_exports_job(&root, paths.clone());
5999 let entry_points = crate::inspect::entry_points::resolve_entry_points(&root);
6000 let oxc_result = crate::inspect::oxc_engine::analyze_files(
6001 &root,
6002 &paths,
6003 AnalyzeOptions {
6004 entry_points: Vec::new(),
6005 public_api_files: entry_points.public_api_files(),
6006 executable_root_exports: entry_points.executable_root_exports(),
6007 force_reparse_files: Vec::new(),
6008 entry_reachability: false,
6009 },
6010 )
6011 .expect("oxc analyze succeeds");
6012 let fresh = crate::inspect::scanners::unused_exports::run_unused_exports_scan_with_oxc(
6013 &job,
6014 Some(&oxc_result),
6015 )
6016 .outcome
6017 .expect("fresh scan succeeds");
6018 let mut contributions = fresh.contributions;
6019 let handwritten = contributions
6020 .iter_mut()
6021 .find(|contribution| contribution.file_path.ends_with("src/hand.ts"))
6022 .expect("handwritten contribution");
6023 handwritten.contribution["generated"] = json!(false);
6024
6025 crate::inspect::generated::reset_file_probe_count_for_debug(&root);
6026 let explicit_cached =
6027 roll_up_unused_exports_contributions(&job, &contributions, Some(MAX_DRILL_DOWN_ITEMS));
6028 assert_eq!(explicit_cached, fresh.aggregate);
6029 assert_eq!(
6030 crate::inspect::generated::file_probe_count_for_debug(&root),
6031 0,
6032 "an explicit cached generated=false must not probe the file again"
6033 );
6034
6035 let generated_banner = contributions
6036 .iter_mut()
6037 .find(|contribution| contribution.file_path.ends_with("src/banner.ts"))
6038 .expect("generated banner contribution");
6039 generated_banner
6040 .contribution
6041 .as_object_mut()
6042 .expect("contribution object")
6043 .remove("generated");
6044 crate::inspect::generated::reset_file_probe_count_for_debug(&root);
6045 let legacy_cached =
6046 roll_up_unused_exports_contributions(&job, &contributions, Some(MAX_DRILL_DOWN_ITEMS));
6047 assert_eq!(legacy_cached, fresh.aggregate);
6048 assert_eq!(
6049 crate::inspect::generated::file_probe_count_for_debug(&root),
6050 1,
6051 "a legacy contribution without generated must probe and recover its classification"
6052 );
6053 }
6054
6055 #[test]
6056 fn inspect_callgraph_open_waits_out_transient_sqlite_writer_lock() {
6057 let dir = write_ts_project(3);
6058 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
6059 let inspect_dir = root.join(".aft-cache").join("inspect");
6060 let callgraph_dir =
6061 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
6062 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
6063 let (store, _) =
6064 CallGraphStore::cold_build_with_lease(callgraph_dir.clone(), root.clone(), &files)
6065 .expect("publish initial generation");
6066 let sqlite_path = store.sqlite_path().to_path_buf();
6067 drop(store);
6068
6069 let blocker = rusqlite::Connection::open(&sqlite_path).expect("open blocking connection");
6070 blocker
6071 .execute_batch(
6072 "PRAGMA journal_mode=DELETE;
6073 BEGIN EXCLUSIVE;
6074 UPDATE meta SET v = v WHERE k = 'ready';",
6075 )
6076 .expect("hold exclusive write transaction");
6077 let (started_tx, started_rx) = std::sync::mpsc::channel();
6078 let open = std::thread::spawn(move || {
6079 started_tx.send(()).expect("signal inspect open start");
6080 open_or_build_blocking_callgraph_store(callgraph_dir, root, true, &files)
6081 });
6082 started_rx.recv().expect("inspect open thread started");
6083 std::thread::sleep(Duration::from_millis(100));
6084 blocker.execute_batch("COMMIT").expect("release write lock");
6085
6086 assert!(
6087 open.join()
6088 .expect("inspect open thread joined")
6089 .expect("transient contention must stay on the Building/retry path")
6090 .is_some(),
6091 "inspect must reopen the ready callgraph instead of failing terminally"
6092 );
6093 }
6094
6095 #[test]
6096 fn callgraph_snapshot_reports_unavailable_when_store_disabled() {
6097 let dir = write_ts_project(3);
6098 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
6099 let inspect_dir = root.join(".aft-cache").join("inspect");
6100
6101 let snapshot =
6102 build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, false), false);
6103
6104 assert!(
6105 snapshot.is_none(),
6106 "dead_code must not rebuild the legacy graph when the store is disabled"
6107 );
6108 }
6109
6110 #[test]
6111 fn callgraph_snapshot_reports_unavailable_when_store_not_ready() {
6112 let dir = write_ts_project(3);
6113 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
6114 let inspect_dir = root.join(".aft-cache").join("inspect");
6115 let callgraph_dir =
6116 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
6117 let _store = CallGraphStore::open(callgraph_dir, root.clone()).expect("open empty store");
6118
6119 let snapshot =
6120 build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true), false);
6121
6122 assert!(
6123 snapshot.is_none(),
6124 "a cold/mid-build store must surface callgraph_unavailable instead of rebuilding inline"
6125 );
6126 }
6127
6128 #[test]
6129 fn suspended_callgraph_build_sets_distinct_builder_state() {
6130 let dir = write_ts_project(3);
6131 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
6132 let inspect_dir = root.join(".aft-cache").join("inspect");
6133 let callgraph_dir =
6134 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
6135 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
6136 let key = crate::build_breaker::BreakerKey::new(
6137 root.display().to_string(),
6138 crate::build_breaker::BuildDomain::CallgraphCold,
6139 crate::callgraph_store::callgraph_corpus_fingerprint_for_test(&root, &files)
6140 .expect("corpus fingerprint"),
6141 );
6142 let breaker = crate::build_breaker::BuildDeathBreaker::open(
6143 callgraph_dir.join("build-breaker.sqlite"),
6144 )
6145 .expect("open breaker");
6146 let now = SystemTime::now()
6147 .duration_since(UNIX_EPOCH)
6148 .expect("system time")
6149 .as_millis() as u64;
6150 for _ in 0..3 {
6151 let crate::build_breaker::BreakerAdmission::Admitted(attempt) =
6152 breaker.admit_at(&key, 0, now).expect("admit build")
6153 else {
6154 panic!("early suspension before the threshold");
6155 };
6156 breaker
6157 .record_attributed_death_at(&key, &attempt.attempt_id, 0, 0, now)
6158 .expect("record death");
6159 }
6160
6161 let manager = InspectManager::new();
6162 let job = snapshot_job(&root, &inspect_dir, true);
6163 assert!(manager
6164 .build_tier2_callgraph_snapshot_with_refresh(&job, true, true, &files)
6165 .is_none());
6166 assert_eq!(
6167 manager.tier2_builder_state(InspectCategory::DeadCode),
6168 InspectBuilderState::Suspended
6169 );
6170 let detail = manager.tier2_builder_state_detail(InspectCategory::DeadCode);
6171 assert!(detail.starts_with("suspended domain=callgraph_cold deaths=3 age_s="));
6172 assert!(detail.ends_with("reason=zero_credit_death_limit"));
6173 }
6174
6175 #[test]
6176 fn readonly_tier2_projection_keeps_generation_pinned_through_concurrent_gc() {
6177 let dir = write_ts_project(3);
6178 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
6179 let inspect_dir = root.join(".aft-cache").join("inspect");
6180 let callgraph_dir =
6181 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
6182 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
6183 let (store, _) =
6184 CallGraphStore::cold_build_with_lease(callgraph_dir.clone(), root.clone(), &files)
6185 .expect("initial generation");
6186 let initial_generation = store.sqlite_path().to_path_buf();
6187 drop(store);
6188 let project_key = crate::search_index::artifact_cache_key(&root);
6189 crate::root_cache::enable_writer_lease_acquisition_counts_for_test();
6190 let lease_count_before = crate::root_cache::writer_lease_acquisition_count_for_test(
6196 crate::root_cache::RootCacheDomain::Callgraph,
6197 &project_key,
6198 &root,
6199 );
6200
6201 let root_for_observer = root.clone();
6202 let dir_for_observer = callgraph_dir.clone();
6203 let files_for_observer = files.clone();
6204 crate::callgraph_store::set_projection_before_open_observer(Some(Arc::new(
6205 move |projected_path| {
6206 for _ in 0..3 {
6207 let (published, _) = CallGraphStore::cold_build_with_lease(
6208 dir_for_observer.clone(),
6209 root_for_observer.clone(),
6210 &files_for_observer,
6211 )
6212 .expect("concurrent generation publication");
6213 drop(published);
6214 }
6215 assert!(
6216 projected_path.is_file(),
6217 "the tier2 reader marker must pin the selected generation through GC"
6218 );
6219 },
6220 )));
6221 let mut job = snapshot_job(&root, &inspect_dir, true);
6222 job.callgraph_writer = false;
6223
6224 let snapshot =
6225 build_tier2_callgraph_snapshot_with_refresh(&job, false, &[root.join("mod0.ts")]);
6226 crate::callgraph_store::set_projection_before_open_observer(None);
6227
6228 assert!(snapshot.is_some());
6229 assert!(initial_generation.is_file());
6230 assert_eq!(
6231 crate::root_cache::writer_lease_acquisition_count_for_test(
6232 crate::root_cache::RootCacheDomain::Callgraph,
6233 &project_key,
6234 &root,
6235 ) - lease_count_before,
6236 3,
6237 "only the three observer publications may acquire a writer lease; tier2 must stay read-only"
6238 );
6239 }
6240
6241 #[test]
6242 fn direct_callgraph_snapshot_does_not_cold_rebuild_when_store_needs_rebuild() {
6243 let dir = write_ts_project(3);
6244 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
6245 let inspect_dir = root.join(".aft-cache").join("inspect");
6246 let callgraph_dir =
6247 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
6248 let store = CallGraphStore::open(callgraph_dir.clone(), root.clone()).expect("open store");
6249 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
6250 store.cold_build(&files).expect("cold build store");
6251 let sqlite_path = store.sqlite_path().to_path_buf();
6252 drop(store);
6253
6254 let still_existing_previous_root = root.with_file_name("previous-root-still-exists");
6255 std::fs::create_dir_all(&still_existing_previous_root).expect("create previous root");
6256 let conn = rusqlite::Connection::open(&sqlite_path).expect("open store sqlite");
6257 conn.execute(
6258 "UPDATE backend_file_state SET workspace_root = ?1",
6259 rusqlite::params![still_existing_previous_root.display().to_string()],
6260 )
6261 .expect("force root repair rebuild state");
6262 drop(conn);
6263
6264 let snapshot =
6265 build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true), false)
6266 .expect("readonly snapshot should avoid cold-rebuilding the store");
6267
6268 assert_eq!(snapshot.files.len(), 3);
6269 let conn = rusqlite::Connection::open(&sqlite_path).expect("reopen store sqlite");
6270 let stored_root: String = conn
6271 .query_row(
6272 "SELECT workspace_root FROM backend_file_state LIMIT 1",
6273 [],
6274 |row| row.get(0),
6275 )
6276 .expect("read stored root");
6277 assert_eq!(
6278 stored_root,
6279 still_existing_previous_root.display().to_string(),
6280 "direct inspect must not cold-rebuild or re-root a read-only snapshot"
6281 );
6282 }
6283
6284 #[test]
6285 fn callgraph_snapshot_reads_ready_callgraph_store() {
6286 let dir = write_ts_project(3);
6287 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
6288 let inspect_dir = root.join(".aft-cache").join("inspect");
6289 let callgraph_dir =
6290 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
6291 let store = CallGraphStore::open(callgraph_dir, root.clone()).expect("open store");
6292 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
6293 store.cold_build(&files).expect("cold build store");
6294
6295 let snapshot =
6296 build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true), false)
6297 .expect("ready store snapshot");
6298
6299 assert_eq!(snapshot.files.len(), 3);
6300 assert_eq!(snapshot.exported_symbols.len(), 3);
6301 }
6302
6303 #[test]
6304 fn path_identity_mismatch_is_a_named_dead_code_terminal_gap() {
6305 let dir = write_ts_project(1);
6306 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
6307 let inspect_dir = root.join(".aft-cache").join("inspect");
6308 let callgraph_dir =
6309 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
6310 let source = root.join("mod0.ts");
6311 let foreign_dir = tempfile::tempdir().expect("foreign tempdir");
6312 let foreign = foreign_dir.path().join("foreign.ts");
6313 std::fs::write(&foreign, "export function foreign() {}\n").expect("write foreign source");
6314 let store = CallGraphStore::open(callgraph_dir, root.clone()).expect("open store");
6315 store.cold_build(&[source]).expect("cold build store");
6316 let error = store
6317 .refresh_files(&[foreign.clone()])
6318 .expect_err("foreign watcher path cannot be assigned a store-relative key");
6319 assert!(matches!(
6320 error,
6321 CallGraphStoreError::PathIdentityMismatch { .. }
6322 ));
6323 drop(store);
6324
6325 let job = snapshot_job(&root, &inspect_dir, true);
6326 let reason = callgraph_path_identity_gap(&job).expect("durable path identity gap");
6327 assert!(reason.contains("callgraph_path_identity_mismatch"));
6328 assert!(reason.contains(&foreign.display().to_string()));
6329 let aggregate =
6330 crate::inspect::scanners::dead_code::callgraph_unavailable_aggregate_with_reason(
6331 1,
6332 Some(&reason),
6333 );
6334 assert_eq!(
6335 aggregate["notes"],
6336 serde_json::json!(["callgraph_unavailable", "callgraph_path_identity_mismatch"])
6337 );
6338 assert_eq!(aggregate["callgraph_unavailable_reason"], reason);
6339 }
6340
6341 #[test]
6342 fn stale_callgraph_store_refreshes_inline_when_refresh_worker_does_not_run() {
6343 let dir = write_ts_project(2);
6344 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
6345 let inspect_dir = root.join(".aft-cache").join("inspect");
6346 let callgraph_dir =
6347 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
6348 let project_key = crate::search_index::artifact_cache_key(&root);
6349 crate::root_cache::configure_artifact_access(&root, &project_key, false);
6350 let store = CallGraphStore::open(callgraph_dir.clone(), root.clone()).expect("open store");
6351 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
6352 store.cold_build(&files).expect("cold build store");
6353 store
6354 .mark_files_stale(&files)
6355 .expect("mark published store stale");
6356 drop(store);
6357
6358 let job = snapshot_job(&root, &inspect_dir, true);
6359 let manager = InspectManager::new();
6360 assert!(
6361 !manager.callgraph_ready_for_snapshot(&InspectSnapshot::new(
6362 root.clone(),
6363 inspect_dir.clone(),
6364 Arc::clone(&job.config),
6365 Arc::clone(&job.symbol_cache),
6366 )),
6367 "a store with leftover stale rows must not look callgraph-ready"
6368 );
6369
6370 let snapshot = manager
6371 .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6372 .expect("dead_code must refresh stale rows inline when the refresh worker never ran");
6373 assert_eq!(snapshot.files.len(), 2);
6374
6375 let ready = InspectSnapshot::new(
6376 root,
6377 inspect_dir,
6378 Arc::clone(&job.config),
6379 Arc::clone(&job.symbol_cache),
6380 );
6381 assert!(
6382 manager.callgraph_ready_for_snapshot(&ready),
6383 "after the inline refresh, callgraph_ready must agree with a successful projection"
6384 );
6385 }
6386
6387 #[test]
6388 fn callgraph_ready_and_builder_projection_agree_on_stale_rows() {
6389 let dir = write_ts_project(1);
6390 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
6391 let inspect_dir = root.join(".aft-cache").join("inspect");
6392 let callgraph_dir =
6393 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
6394 let project_key = crate::search_index::artifact_cache_key(&root);
6395 crate::root_cache::configure_artifact_access(&root, &project_key, false);
6396 let store = CallGraphStore::open(callgraph_dir, root.clone()).expect("open store");
6397 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
6398 store.cold_build(&files).expect("cold build store");
6399 let sqlite_path = store.sqlite_path().to_path_buf();
6400 drop(store);
6401
6402 let job = snapshot_job(&root, &inspect_dir, true);
6403 let snapshot = InspectSnapshot::new(
6404 root.clone(),
6405 inspect_dir.clone(),
6406 Arc::clone(&job.config),
6407 Arc::clone(&job.symbol_cache),
6408 );
6409 let manager = InspectManager::new();
6410 assert!(
6411 manager.callgraph_ready_for_snapshot(&snapshot),
6412 "a fresh store must be ready for both the phase check and projection"
6413 );
6414 project_dead_code_snapshot(&sqlite_path).expect("fresh store should project");
6415
6416 let store = CallGraphStore::open_ready_no_rebuild(
6417 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir"),
6418 root.clone(),
6419 )
6420 .expect("reopen writer")
6421 .expect("ready writer");
6422 store.mark_files_stale(&files).expect("mark stale");
6423 drop(store);
6424
6425 assert!(
6426 !manager.callgraph_ready_for_snapshot(&snapshot),
6427 "callgraph_ready must use the same stale-row predicate as dead_code projection"
6428 );
6429 let error =
6430 project_dead_code_snapshot(&sqlite_path).expect_err("stale rows must block projection");
6431 match error {
6432 CallGraphStoreError::Unavailable(message) => {
6433 assert_eq!(message, "callgraph has stale files pending refresh")
6434 }
6435 other => panic!("expected Unavailable, got {other:?}"),
6436 }
6437 }
6438
6439 #[test]
6440 fn failed_builder_attempt_history_uses_locked_refusal_detail() {
6441 let manager = InspectManager::new();
6442 let unavailable = JobOutcome::Fresh {
6443 payload: crate::inspect::scanners::dead_code::callgraph_unavailable_aggregate(0),
6444 };
6445 manager
6446 .record_tier2_attempt_outcome_for_test(InspectCategory::DeadCode, unavailable.clone());
6447 let first = manager.tier2_builder_state_detail(InspectCategory::DeadCode);
6448 let first_at = first
6449 .rsplit("first at ")
6450 .next()
6451 .and_then(|tail| tail.strip_suffix(')'))
6452 .expect("first failure detail includes first-at unix time");
6453 for _ in 1..7 {
6454 manager.record_tier2_attempt_outcome_for_test(
6455 InspectCategory::DeadCode,
6456 unavailable.clone(),
6457 );
6458 }
6459 assert_eq!(
6460 manager.tier2_builder_state_detail(InspectCategory::DeadCode),
6461 format!("last attempt failed: callgraph_unavailable (attempt 7, first at {first_at})")
6462 );
6463 assert_eq!(
6464 manager.tier2_builder_state(InspectCategory::DeadCode),
6465 InspectBuilderState::Absent,
6466 "a finished failure must not keep the registry in an in-flight state"
6467 );
6468 assert_eq!(
6469 manager.try_tier2_builder_busy(),
6470 Some(false),
6471 "failed-attempt history must not look like a live rebuild"
6472 );
6473
6474 manager.record_tier2_attempt_outcome_for_test(
6475 InspectCategory::DeadCode,
6476 JobOutcome::Fresh {
6477 payload: serde_json::json!({ "callgraph_available": true, "count": 0 }),
6478 },
6479 );
6480 assert_eq!(
6481 manager.tier2_builder_state_detail(InspectCategory::DeadCode),
6482 InspectBuilderState::Absent.as_str()
6483 );
6484 }
6485
6486 #[test]
6487 fn generation_keyed_projection_cache_reuses_unchanged_snapshot() {
6488 let (_dir, _root, _inspect_dir, job) = published_projection_fixture();
6489 let manager = InspectManager::new();
6490 let (projections, _observer_reset) = count_projections();
6491
6492 let first = manager
6493 .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6494 .expect("first projection");
6495 let second = manager
6496 .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6497 .expect("cached projection");
6498
6499 assert!(
6500 Arc::ptr_eq(&first, &second),
6501 "an unchanged generation and write revision must reuse the projected Arc"
6502 );
6503 assert_eq!(
6504 projections.load(std::sync::atomic::Ordering::SeqCst),
6505 1,
6506 "two dead-code scans without a callgraph mutation must project once"
6507 );
6508 let memory = manager.callgraph_projection_estimated_memory();
6509 assert_eq!(
6510 memory.counts["callgraph_projection_snapshots"], 1,
6511 "the resident projection must be attributed to the root"
6512 );
6513 assert!(
6514 memory.estimated_bytes.unwrap_or_default() > 0,
6515 "a populated projection must report an estimated residency"
6516 );
6517 }
6518
6519 #[test]
6520 fn stale_store_never_reuses_an_equal_revision_projection() {
6521 let (_dir, root, inspect_dir, job) = published_projection_fixture();
6522 let manager = InspectManager::new();
6523 manager
6524 .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6525 .expect("initial projection");
6526 let writer = CallGraphStore::open_ready_no_rebuild(
6527 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("callgraph dir"),
6528 root.clone(),
6529 )
6530 .expect("open writer")
6531 .expect("ready writer");
6532 writer
6533 .mark_files_stale(&[root.join("src/target.ts")])
6534 .expect("mark target stale");
6535 drop(writer);
6536
6537 assert!(
6538 manager
6539 .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6540 .is_none(),
6541 "a stale marker must block a cache hit even though it does not advance the graph revision"
6542 );
6543 }
6544
6545 fn assert_fresh_refresh_reuses_projection(job: InspectJob, target: PathBuf) {
6546 let manager = InspectManager::new();
6547 let (projections, _observer_reset) = count_projections();
6548 let started = Instant::now();
6549 let first = manager
6550 .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6551 .expect("initial projection");
6552 let cold_ms = started.elapsed().as_secs_f64() * 1_000.0;
6553 let callgraph_dir =
6554 callgraph_store_dir_from_inspect_dir(&job.inspect_dir, &job.project_root)
6555 .expect("copied graph directory");
6556 let writer = CallGraphStore::open_ready_no_rebuild(callgraph_dir, job.project_root.clone())
6557 .expect("open writer")
6558 .expect("ready graph");
6559 let revision_before = writer
6560 .projection_write_revision()
6561 .expect("initial revision");
6562 let started = Instant::now();
6563 let (_, profile) = writer
6564 .refresh_files_profiled(&[target])
6565 .expect("already-fresh refresh");
6566 let revision_after = writer
6567 .projection_write_revision()
6568 .expect("revision after refresh");
6569 drop(writer);
6570 let second = manager
6571 .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6572 .expect("projection after fresh refresh");
6573 eprintln!("fresh_refresh_projection initial_ms={cold_ms:.3} refresh_and_snapshot_ms={:.3} index_loads={} projections={} outbound_rows={}",
6574 started.elapsed().as_secs_f64() * 1_000.0,
6575 profile.index_loads, projections.load(std::sync::atomic::Ordering::SeqCst),
6576 first.outbound_calls.len());
6577 assert_eq!(
6578 profile.index_loads, 0,
6579 "a fresh refresh must not load the corpus resolver index"
6580 );
6581 assert_eq!(
6582 revision_after, revision_before,
6583 "no written rows means no projection invalidation"
6584 );
6585 assert_eq!(
6586 projections.load(std::sync::atomic::Ordering::SeqCst),
6587 1,
6588 "a duplicate refresh must not re-project the corpus"
6589 );
6590 assert!(Arc::ptr_eq(&first, &second));
6591 }
6592
6593 #[test]
6594 fn projection_cache_reuses_snapshot_after_already_fresh_refresh() {
6595 let (_dir, root, _inspect_dir, job) = published_projection_fixture();
6596 assert_fresh_refresh_reuses_projection(job, root.join("src/target.ts"));
6597 }
6598
6599 #[test]
6600 fn projection_cache_invalidates_after_deleting_a_file_without_dependents() {
6601 let (_dir, root, inspect_dir, job) = published_projection_fixture();
6602 let manager = InspectManager::new();
6603 let (projections, _observer_reset) = count_projections();
6604 let first = manager
6605 .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6606 .expect("initial projection");
6607 let target = canonicalize_for_snapshot(&root.join("src/main.ts"));
6611 assert!(first.files.contains(&target));
6612 let graph_dir = callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).unwrap();
6613 let writer = CallGraphStore::open_ready_no_rebuild(graph_dir, root.clone())
6614 .expect("open writer")
6615 .expect("ready graph");
6616 std::fs::remove_file(&target).expect("remove unreferenced entry file");
6617 let (stats, profile) = writer
6618 .refresh_files_profiled(std::slice::from_ref(&target))
6619 .expect("refresh deletion");
6620 assert_eq!(stats.deleted_files, vec!["src/main.ts"]);
6621 assert_eq!(
6622 profile.index_loads, 0,
6623 "deletion without surviving callers needs no resolver"
6624 );
6625 drop(writer);
6626 let second = manager
6627 .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6628 .expect("projection after deletion");
6629 assert!(!second.files.contains(&target));
6630 assert_eq!(
6631 projections.load(std::sync::atomic::Ordering::SeqCst),
6632 2,
6633 "row deletion must invalidate even when no resolver was loaded"
6634 );
6635 }
6636
6637 #[test]
6638 #[ignore = "offline probe requires a normalized production graph copy below target"]
6639 fn profile_fresh_refresh_projection_on_store_copy() {
6640 let project = Path::new(env!("CARGO_MANIFEST_DIR"))
6641 .parent()
6642 .unwrap()
6643 .parent()
6644 .unwrap()
6645 .canonicalize()
6646 .expect("checkout root");
6647 let storage = PathBuf::from(
6648 std::env::var_os("AFT_CPU_HUNT_STORAGE_COPY")
6649 .expect("set AFT_CPU_HUNT_STORAGE_COPY to an offline copied storage root"),
6650 )
6651 .canonicalize()
6652 .expect("copied storage");
6653 assert!(
6654 storage.starts_with(project.join("target")),
6655 "never probe a live artifact"
6656 );
6657 let target = project.join("crates/aft/tests/engine_comparator_test.rs");
6658 let inspect_dir = storage.join("inspect");
6659 let key = crate::search_index::artifact_cache_key(&project);
6660 crate::root_cache::configure_artifact_access(&project, &key, false);
6661 let graph_dir = callgraph_store_dir_from_inspect_dir(&inspect_dir, &project).unwrap();
6662 let writer = CallGraphStore::open_ready_no_rebuild(graph_dir, project.clone())
6663 .expect("open copied graph")
6664 .expect("copied graph ready");
6665 writer
6666 .refresh_files(std::slice::from_ref(&target))
6667 .expect("normalize copied freshness");
6668 drop(writer);
6669 let mut job = snapshot_job(&project, &inspect_dir, true);
6670 job.callgraph_writer = false;
6671 assert_fresh_refresh_reuses_projection(job, target);
6672 }
6673
6674 #[test]
6675 fn projection_cache_invalidates_on_in_place_refresh_for_readonly_scans() {
6676 let (_dir, root, inspect_dir, job) = published_projection_fixture();
6677 let unrelated = root.join("src/unrelated.ts");
6678 write_projection_cache_file(&unrelated, &"unrelated();\n".repeat(100));
6679 let store = CallGraphStore::open_ready_no_rebuild(
6680 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).unwrap(),
6681 root.clone(),
6682 )
6683 .unwrap()
6684 .unwrap();
6685 store.refresh_files(&[unrelated]).unwrap();
6686 drop(store);
6687 let manager = InspectManager::new();
6688 let (projections, _observer_reset) = count_projections();
6689 let target = root.join("src/target.ts");
6690 let callgraph_dir =
6691 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("callgraph dir");
6692
6693 let first = manager
6694 .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6695 .expect("initial projection");
6696 let writer = CallGraphStore::open_ready_no_rebuild(callgraph_dir, root.clone())
6697 .expect("open writer")
6698 .expect("ready writer");
6699 let revision_before = writer
6700 .projection_write_revision()
6701 .expect("read initial revision")
6702 .expect("new stores write a projection revision");
6703 write_projection_cache_file(&target, "export function secondTarget() {}\n");
6704 writer
6705 .refresh_files(&[target])
6706 .expect("refresh changed target");
6707 let revision_after = writer
6708 .projection_write_revision()
6709 .expect("read refreshed revision")
6710 .expect("refreshed stores retain a projection revision");
6711 assert!(
6712 revision_after > revision_before,
6713 "the in-place refresh must advance the durable cache identity"
6714 );
6715 drop(writer);
6716
6717 crate::callgraph_store::take_projection_work();
6718 let second = manager
6719 .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6720 .expect("refreshed readonly projection");
6721 let (full_projections, outbound_rows_read) = crate::callgraph_store::take_projection_work();
6722 eprintln!("incremental_work full_projections={full_projections} outbound_rows_read={outbound_rows_read}");
6723 assert_eq!(full_projections, 0);
6724 assert_eq!(outbound_rows_read, 1);
6725 assert!(
6726 !first
6727 .exported_symbols
6728 .iter()
6729 .any(|export| export.symbol == "secondTarget"),
6730 "the initial snapshot must not already contain the refreshed export"
6731 );
6732 assert!(
6733 second
6734 .exported_symbols
6735 .iter()
6736 .any(|export| export.symbol == "secondTarget"),
6737 "the readonly scan must expose graph data from the refreshed store"
6738 );
6739 assert_eq!(
6740 projections.load(std::sync::atomic::Ordering::SeqCst),
6741 2,
6742 "an in-place refresh must force the next scan to re-project"
6743 );
6744 }
6745
6746 #[test]
6747 fn projection_cache_invalidates_when_cold_build_publishes_new_generation() {
6748 let (_dir, root, inspect_dir, job) = published_projection_fixture();
6749 let manager = InspectManager::new();
6750 let (projections, _observer_reset) = count_projections();
6751 let target = root.join("src/target.ts");
6752 let callgraph_dir =
6753 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("callgraph dir");
6754
6755 let first = manager
6756 .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6757 .expect("initial projection");
6758 let before = CallGraphStore::open_readonly(callgraph_dir.clone(), root.clone())
6759 .expect("open initial reader")
6760 .expect("initial reader");
6761 let revision_before = before
6762 .projection_write_revision()
6763 .expect("read initial revision")
6764 .expect("new stores write a projection revision");
6765 drop(before);
6766 write_projection_cache_file(&target, "export function coldBuildTarget() {}\n");
6767 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
6768 let (published, _) =
6769 CallGraphStore::cold_build_with_lease(callgraph_dir, root.clone(), &files)
6770 .expect("publish replacement generation");
6771 let revision_after = published
6772 .projection_write_revision()
6773 .expect("read replacement revision")
6774 .expect("replacement stores write a projection revision");
6775 assert_eq!(
6776 revision_after, revision_before,
6777 "cold builds begin with the same revision, so this assertion exercises the generation half of the cache identity"
6778 );
6779 drop(published);
6780
6781 let second = manager
6782 .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6783 .expect("replacement projection");
6784 assert!(
6785 !first
6786 .exported_symbols
6787 .iter()
6788 .any(|export| export.symbol == "coldBuildTarget"),
6789 "the initial snapshot must not already contain the replacement export"
6790 );
6791 assert!(
6792 second
6793 .exported_symbols
6794 .iter()
6795 .any(|export| export.symbol == "coldBuildTarget"),
6796 "the next scan must expose the generation published by the cold build"
6797 );
6798 assert_eq!(
6799 projections.load(std::sync::atomic::Ordering::SeqCst),
6800 2,
6801 "a new pointer generation must force the next scan to re-project"
6802 );
6803 }
6804
6805 #[test]
6806 fn idle_eviction_drops_generation_keyed_projection_cache() {
6807 let (_dir, _root, _inspect_dir, job) = published_projection_fixture();
6808 let manager = InspectManager::new();
6809 let (projections, _observer_reset) = count_projections();
6810
6811 let first = manager
6812 .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6813 .expect("initial projection");
6814 manager.evict_idle_caches();
6815 assert_eq!(
6816 manager.callgraph_projection_estimated_memory().counts
6817 ["callgraph_projection_snapshots"],
6818 0,
6819 "idle artifact eviction must release the root projection slot"
6820 );
6821 let second = manager
6822 .build_tier2_callgraph_snapshot_with_refresh(&job, false, false, &[])
6823 .expect("reloaded projection");
6824
6825 assert!(
6826 !Arc::ptr_eq(&first, &second),
6827 "eviction must drop the previous projection Arc"
6828 );
6829 assert_eq!(
6830 projections.load(std::sync::atomic::Ordering::SeqCst),
6831 2,
6832 "the next scan after idle eviction must reload the projection"
6833 );
6834 }
6835
6836 #[test]
6837 fn callgraph_snapshot_uses_ready_root_keyed_store() {
6838 let _git_env = crate::test_env::hermetic_git_env_guard();
6839 let dir = write_ts_project(3);
6840 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
6841 let storage_dir = root.join(".aft-cache");
6842 let inspect_dir = storage_dir
6843 .join("inspect")
6844 .join(crate::path_identity::project_scope_key(&root));
6845 let warm_callgraph_dir = storage_dir
6846 .join("callgraph")
6847 .join(artifact_cache_key_for_test(&root));
6848 let store = CallGraphStore::open(warm_callgraph_dir, root.clone()).expect("open store");
6849 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
6850 store.cold_build(&files).expect("cold build store");
6851
6852 let snapshot =
6853 build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true), false)
6854 .expect("ready sibling store snapshot");
6855
6856 assert_eq!(snapshot.files.len(), 3);
6857 assert_eq!(snapshot.exported_symbols.len(), 3);
6858 }
6859
6860 #[test]
6861 fn dead_code_forced_deletion_refreshes_callgraph_store_before_rollup() {
6862 let dir = tempfile::tempdir().expect("tempdir");
6863 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
6864 write_fixture_file(
6865 &root,
6866 "package.json",
6867 r#"{"name":"dead-code-delete-refresh","type":"module","main":"src/main.ts"}"#,
6868 3_100_000_000,
6869 );
6870 write_fixture_file(
6871 &root,
6872 "src/main.ts",
6873 "export function main() {}\n",
6874 3_100_000_001,
6875 );
6876 write_fixture_file(
6877 &root,
6878 "src/dead.ts",
6879 "export function plantedDead() {}\n",
6880 3_100_000_002,
6881 );
6882
6883 let inspect_dir = root.join(".aft-cache").join("opencode").join("inspect");
6884 let callgraph_dir =
6885 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
6886 let project_key = crate::search_index::artifact_cache_key(&root);
6887 crate::root_cache::configure_artifact_access(&root, &project_key, false);
6888 let store = CallGraphStore::open(callgraph_dir.clone(), root.clone()).expect("open store");
6889 let project_files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
6890 store.cold_build(&project_files).expect("cold build store");
6891 drop(store);
6892
6893 let config = Arc::new(crate::config::Config {
6894 project_root: Some(root.clone()),
6895 callgraph_store: true,
6896 ..crate::config::Config::default()
6897 });
6898 let symbol_cache = Arc::new(std::sync::RwLock::new(crate::parser::SymbolCache::new()));
6899 let snapshot = InspectSnapshot::new(
6900 root.clone(),
6901 inspect_dir.clone(),
6902 Arc::clone(&config),
6903 Arc::clone(&symbol_cache),
6904 );
6905 let manager = InspectManager::new();
6906 let initial_job =
6907 manager.tier2_reuse_job(snapshot.clone(), InspectCategory::DeadCode, None);
6908 let initial = manager
6909 .tier2_run_with_reuse_job_result_with_options(initial_job, Tier2ReuseOptions::default())
6910 .outcome
6911 .expect("initial dead_code scan succeeds")
6912 .aggregate;
6913 assert!(
6914 aggregate_has_file_symbol(&initial, "src/dead.ts", "plantedDead"),
6915 "initial scan should report the planted dead export: {initial:#}"
6916 );
6917
6918 let deleted = root.join("src/dead.ts");
6919 std::fs::remove_file(&deleted).expect("delete dead fixture");
6920 let delete_job = manager.tier2_reuse_job(snapshot, InspectCategory::DeadCode, None);
6921 let refreshed = manager
6922 .tier2_run_with_reuse_job_result_with_options(
6923 delete_job,
6924 Tier2ReuseOptions {
6925 force_rescan_paths: [deleted.clone()].into_iter().collect(),
6926 allow_callgraph_cold_build: true,
6927 require_callgraph_snapshot: false,
6928 interactive: false,
6929 },
6930 )
6931 .outcome
6932 .expect("delete refresh dead_code scan succeeds")
6933 .aggregate;
6934
6935 assert_eq!(
6936 refreshed
6937 .get("callgraph_available")
6938 .and_then(Value::as_bool),
6939 Some(true),
6940 "forced watcher paths must keep the callgraph-backed aggregate available: {refreshed:#}"
6941 );
6942 assert!(
6943 !aggregate_has_file_symbol(&refreshed, "src/dead.ts", "plantedDead"),
6944 "delete refresh should remove the planted dead export: {refreshed:#}"
6945 );
6946
6947 let store = CallGraphStore::open_ready_no_rebuild(callgraph_dir, root)
6948 .expect("open refreshed store")
6949 .expect("refreshed store is ready");
6950 let projected = project_dead_code_snapshot(store.sqlite_path()).expect("project snapshot");
6951 assert!(
6952 projected
6953 .files
6954 .iter()
6955 .all(|file| !file.ends_with("src/dead.ts")),
6956 "watcher deletion should be applied to the persisted callgraph store: {:#?}",
6957 projected.files
6958 );
6959 }
6960
6961 fn aggregate_has_file_symbol(aggregate: &Value, file: &str, symbol: &str) -> bool {
6962 aggregate
6963 .get("items")
6964 .and_then(Value::as_array)
6965 .is_some_and(|items| {
6966 items.iter().any(|item| {
6967 item.get("file").and_then(Value::as_str) == Some(file)
6968 && item.get("symbol").and_then(Value::as_str) == Some(symbol)
6969 })
6970 })
6971 }
6972
6973 #[test]
6977 fn scoped_filter_drops_project_wide_by_language() {
6978 let scope = JobScope::from_roots("/proj", vec![PathBuf::from("/proj/src/a")]);
6979 assert!(
6980 !scope.is_project_wide(),
6981 "scope must be non-project for test"
6982 );
6983 let payload = serde_json::json!({
6984 "count": 99,
6985 "by_language": { "rust": 214, "typescript": 143 },
6986 "items": [
6987 { "file": "/proj/src/a/x.rs", "symbol": "live" },
6988 { "file": "/proj/src/other/y.rs", "symbol": "out" },
6989 ],
6990 });
6991 let filtered = filter_payload_for_scope(payload, &scope);
6992 assert!(
6993 filtered.get("by_language").is_none(),
6994 "scoped payload must drop project-wide by_language: {filtered}"
6995 );
6996 assert_eq!(filtered.get("count").and_then(|v| v.as_u64()), Some(1));
6998 }
6999 #[cfg(debug_assertions)]
7000 #[test]
7001 fn tier2_read_cached_freshness_does_not_hash_unchanged_contributions() {
7002 let (_dir, manager, snapshot, scope, _files) = duplicate_cache_fixture();
7003 let fixture_root = snapshot.project_root.clone();
7004
7005 crate::cache_freshness::reset_hash_file_if_small_count_for_debug();
7006 crate::cache_freshness::reset_verify_file_strict_count_for_debug();
7007 assert_fresh(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
7008
7009 assert_eq!(
7010 crate::cache_freshness::verify_file_strict_count_under_for_debug(&fixture_root),
7011 0,
7012 "dispatch-thread inspect freshness must not use strict verification"
7013 );
7014 assert_eq!(
7015 crate::cache_freshness::hash_file_if_small_count_for_debug(),
7016 0,
7017 "unchanged contribution files must stay on the stat-only fast path"
7018 );
7019 }
7020
7021 #[cfg(debug_assertions)]
7022 #[test]
7023 fn tier2_read_cached_freshness_returns_byte_identical_cold_scan_aggregate() {
7024 let (_dir, manager, snapshot, scope, _files) = duplicate_uncached_fixture();
7025 let cold_payload = fresh_payload(manager.tier2_run_with_reuse(
7026 snapshot.clone(),
7027 InspectCategory::Duplicates,
7028 scope.clone(),
7029 None,
7030 ));
7031
7032 crate::cache_freshness::reset_hash_file_if_small_count_for_debug();
7033 crate::cache_freshness::reset_verify_file_strict_count_for_debug();
7034 let fixture_root = snapshot.project_root.clone();
7035 let warm_payload =
7036 fresh_payload(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
7037
7038 let cold_bytes = serde_json::to_vec(&cold_payload).expect("serialize cold aggregate");
7039 let warm_bytes = serde_json::to_vec(&warm_payload).expect("serialize warm aggregate");
7040 assert_eq!(
7041 warm_bytes, cold_bytes,
7042 "warm unchanged read must return the byte-identical aggregate as the cold scan"
7043 );
7044 assert_eq!(
7045 crate::cache_freshness::verify_file_strict_count_under_for_debug(&fixture_root),
7046 0,
7047 "dispatch-thread warm read must not use strict verification"
7048 );
7049 assert_eq!(
7050 crate::cache_freshness::hash_file_if_small_count_for_debug(),
7051 0,
7052 "warm unchanged read must not content-hash cached contribution files"
7053 );
7054 }
7055
7056 #[test]
7057 fn tier2_read_cached_freshness_detects_changed_added_and_deleted_files() {
7058 let (_dir, manager, snapshot, scope, _files) = duplicate_cache_fixture();
7059 write_fixture_file(
7060 &snapshot.project_root,
7061 "src/foo.ts",
7062 "export const foo = 101;\nexport const changed = true;\n",
7063 3_000_000_001,
7064 );
7065 assert_stale(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
7066
7067 let (_dir, manager, snapshot, scope, _files) = duplicate_cache_fixture();
7068 write_fixture_file(
7069 &snapshot.project_root,
7070 "src/added.ts",
7071 "export const added = 3;\n",
7072 3_000_000_002,
7073 );
7074 assert_stale(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
7075
7076 let (_dir, manager, snapshot, scope, files) = duplicate_cache_fixture();
7077 std::fs::remove_file(&files[0]).expect("delete cached contribution file");
7078 assert_stale(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
7079 }
7080
7081 fn duplicate_cache_fixture() -> (
7082 tempfile::TempDir,
7083 InspectManager,
7084 InspectSnapshot,
7085 JobScope,
7086 Vec<PathBuf>,
7087 ) {
7088 let (dir, manager, snapshot, scope, files) = duplicate_uncached_fixture();
7089 store_duplicate_cache(&manager, &snapshot, &files);
7090 (dir, manager, snapshot, scope, files)
7091 }
7092
7093 fn duplicate_uncached_fixture() -> (
7094 tempfile::TempDir,
7095 InspectManager,
7096 InspectSnapshot,
7097 JobScope,
7098 Vec<PathBuf>,
7099 ) {
7100 use crate::config::Config;
7101 use crate::parser::SymbolCache;
7102 use std::sync::RwLock;
7103
7104 let dir = tempfile::tempdir().expect("tempdir");
7105 let root = std::fs::canonicalize(dir.path()).expect("canonical fixture root");
7106 let files = vec![
7107 write_fixture_file(
7108 &root,
7109 "src/foo.ts",
7110 "export const fixture = () => 1;
7111export const shared = 1;
7112",
7113 3_000_000_000,
7114 ),
7115 write_fixture_file(
7116 &root,
7117 "src/bar.ts",
7118 "export const fixture = () => 1;
7119export const shared = 1;
7120",
7121 3_000_000_000,
7122 ),
7123 ];
7124 let inspect_dir = root.join(".aft-cache").join("inspect");
7125 let snapshot = InspectSnapshot::new(
7126 root.clone(),
7127 inspect_dir,
7128 Arc::new(Config {
7129 project_root: Some(root.clone()),
7130 ..Config::default()
7131 }),
7132 Arc::new(RwLock::new(SymbolCache::new())),
7133 );
7134 let scope = JobScope::for_project(root);
7135 let manager = InspectManager::new();
7136 (dir, manager, snapshot, scope, files)
7137 }
7138
7139 fn write_fixture_file(root: &Path, relative: &str, content: &str, mtime_secs: i64) -> PathBuf {
7140 let path = root.join(relative);
7141 if let Some(parent) = path.parent() {
7142 std::fs::create_dir_all(parent).expect("create fixture parent");
7143 }
7144 std::fs::write(&path, content).expect("write fixture file");
7145 filetime::set_file_mtime(&path, filetime::FileTime::from_unix_time(mtime_secs, 0))
7146 .expect("set fixture mtime");
7147 path
7148 }
7149
7150 fn store_duplicate_cache(
7151 manager: &InspectManager,
7152 snapshot: &InspectSnapshot,
7153 files: &[PathBuf],
7154 ) {
7155 let cache = manager
7156 .cache_for_snapshot(snapshot)
7157 .expect("open inspect cache");
7158 let contributions = files
7159 .iter()
7160 .map(|file| {
7161 let freshness = crate::cache_freshness::collect(file).expect("collect freshness");
7162 FileContribution::new(
7163 InspectCategory::Duplicates,
7164 file.clone(),
7165 freshness,
7166 serde_json::json!({
7167 "file": relative_cache_key(&snapshot.project_root, file),
7168 "fragments": [],
7169 }),
7170 )
7171 })
7172 .collect::<Vec<_>>();
7173 cache
7174 .store_tier2_result(
7175 JobKey::for_project_category(InspectCategory::Duplicates),
7176 files,
7177 &contributions,
7178 serde_json::json!({
7179 "count": 0,
7180 "groups": [],
7181 "scanned_files": files.len(),
7182 "total_groups": 0,
7183 }),
7184 )
7185 .expect("store tier2 cache fixture");
7186 }
7187
7188 fn assert_fresh(outcome: JobOutcome) {
7189 let _ = fresh_payload(outcome);
7190 }
7191
7192 fn fresh_payload(outcome: JobOutcome) -> Value {
7193 match outcome {
7194 JobOutcome::Fresh { payload } => payload,
7195 other => panic!("expected fresh cached Tier-2 outcome, got {other:?}"),
7196 }
7197 }
7198
7199 fn assert_stale(outcome: JobOutcome) {
7200 match outcome {
7201 JobOutcome::Stale { .. } => {}
7202 other => panic!("expected stale cached Tier-2 outcome, got {other:?}"),
7203 }
7204 }
7205}
7206
7207#[cfg(test)]
7208mod dead_code_projection_tests {
7209 use super::*;
7210 use crate::callgraph::walk_project_files;
7211 use crate::callgraph_store::{project_dead_code_snapshot, CallGraphStore};
7212 use crate::config::Config;
7213 use crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR;
7214 use crate::inspect::scanners::DEFAULT_EXPORT_MARKER_KIND;
7215 use crate::parser::SymbolCache;
7216 use filetime::FileTime;
7217 use std::sync::atomic::{AtomicI64, Ordering as AtomicOrdering};
7218 use std::sync::RwLock;
7219
7220 static NEXT_MTIME: AtomicI64 = AtomicI64::new(1_900_000_000);
7221
7222 #[test]
7223 fn scoped_dead_code_rollup_uses_ready_callgraph_and_degrades_without_it() {
7224 let dir = tempfile::tempdir().expect("tempdir");
7225 write_projection_fixture(dir.path());
7226 let root = canonical_root(dir.path());
7227 let inspect_dir = root.join(".aft-cache").join("inspect");
7228 let callgraph_dir =
7229 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
7230 let project_key = crate::search_index::artifact_cache_key(&root);
7231 crate::root_cache::configure_artifact_access(&root, &project_key, false);
7232 let store = CallGraphStore::open(callgraph_dir.clone(), root.clone()).expect("open store");
7233 let files = project_files(&root);
7234 store.cold_build(&files).expect("cold build store");
7235 let projected = project_dead_code_snapshot(store.sqlite_path()).expect("project snapshot");
7236 drop(store);
7237
7238 let config = Arc::new(Config {
7239 project_root: Some(root.clone()),
7240 callgraph_store: true,
7241 ..Config::default()
7242 });
7243 let symbol_cache = Arc::new(RwLock::new(SymbolCache::new()));
7244 let scan_job = InspectJob {
7245 job_id: 87,
7246 key: JobKey::for_project_category(InspectCategory::DeadCode),
7247 category: InspectCategory::DeadCode,
7248 scope_files: files.clone(),
7249 project_root: root.clone(),
7250 inspect_dir: inspect_dir.clone(),
7251 config: Arc::clone(&config),
7252 symbol_cache: Arc::clone(&symbol_cache),
7253 inspect_writer: true,
7254 callgraph_writer: true,
7255 callgraph_snapshot: Some(Arc::new(projected)),
7256 };
7257 let success = crate::inspect::scanners::dead_code::run_dead_code_scan(&scan_job)
7258 .outcome
7259 .expect("dead_code scan succeeds");
7260 let cache = InspectCache::open(inspect_dir.clone(), root.clone()).expect("open cache");
7261 cache
7262 .store_tier2_result(
7263 scan_job.key.clone(),
7264 &success.scanned_files,
7265 &success.contributions,
7266 success.aggregate.clone(),
7267 )
7268 .expect("store tier2 result");
7269
7270 let snapshot = InspectSnapshot::new(root.clone(), inspect_dir, config, symbol_cache);
7271 let scope = JobScope::from_roots(root.clone(), vec![root.join("src/live.ts")]);
7272 assert!(
7273 !scope.is_project_wide(),
7274 "live.ts file scope must be scoped"
7275 );
7276
7277 let ready_payload = scoped_tier2_payload_from_contributions(
7278 &snapshot,
7279 InspectCategory::DeadCode,
7280 &cache,
7281 success.aggregate.clone(),
7282 &scope,
7283 )
7284 .expect("ready scoped payload");
7285 assert_eq!(
7286 ready_payload
7287 .get("callgraph_available")
7288 .and_then(Value::as_bool),
7289 Some(true),
7290 "ready store should produce a callgraph-backed scoped rollup: {ready_payload:#}"
7291 );
7292 assert_live_item(&ready_payload, "src/live.ts", "knownLive");
7293
7294 std::fs::remove_dir_all(&callgraph_dir).expect("remove ready callgraph store");
7295 let unavailable_payload = scoped_tier2_payload_from_contributions(
7296 &snapshot,
7297 InspectCategory::DeadCode,
7298 &cache,
7299 success.aggregate,
7300 &scope,
7301 )
7302 .expect("unavailable scoped payload");
7303 assert_eq!(
7304 unavailable_payload
7305 .get("callgraph_available")
7306 .and_then(Value::as_bool),
7307 Some(false),
7308 "missing store must report callgraph_unavailable instead of fabricating an empty graph: {unavailable_payload:#}"
7309 );
7310 assert_live_item(&unavailable_payload, "src/live.ts", "knownLive");
7311 }
7312 #[derive(Debug, PartialEq, Eq)]
7313 struct ComparableSnapshot {
7314 files: BTreeSet<PathBuf>,
7315 exported_symbols: BTreeSet<(PathBuf, String, String, u32)>,
7316 outbound_calls: BTreeSet<(PathBuf, String, String, u32)>,
7317 entry_points: BTreeSet<PathBuf>,
7318 entry_point_symbols: BTreeMap<PathBuf, BTreeSet<String>>,
7319 }
7320
7321 #[test]
7322 fn dead_code_projection_contains_expected_fixture_surface() {
7323 let dir = tempfile::tempdir().expect("tempdir");
7324 write_projection_fixture(dir.path());
7325 let root = canonical_root(dir.path());
7326 let projected = store_projected_snapshot(&root, ".store-dead-code-surface");
7327
7328 assert_projection_fixture_coverage(&root, &projected);
7329 }
7330
7331 #[test]
7332 fn dead_code_projection_incremental_scenario_matrix_matches_cold_rebuild() {
7333 run_projection_scenario("rename", setup_projection_rename, edit_projection_rename);
7334 run_projection_scenario("delete", setup_projection_delete, edit_projection_delete);
7335 run_projection_scenario("new-file", setup_projection_delete, |root| {
7336 let path = root.join("new.ts");
7337 write_file(
7338 &path,
7339 "import { foo } from './foo'; export function added() { foo(); }\n",
7340 );
7341 vec![path]
7342 });
7343 run_projection_scenario(
7344 "barrel delete",
7345 setup_projection_barrel,
7346 edit_projection_barrel_delete,
7347 );
7348 run_projection_scenario(
7349 "dispatch edit",
7350 setup_projection_dispatch,
7351 edit_projection_dispatch,
7352 );
7353 run_projection_scenario(
7354 "body-only edit",
7355 setup_projection_body_only,
7356 edit_projection_body_only,
7357 );
7358 }
7359
7360 #[test]
7361 fn dead_code_projection_dead_code_scan_reports_expected_verdicts() {
7362 let dir = tempfile::tempdir().expect("tempdir");
7363 write_projection_fixture(dir.path());
7364 let root = canonical_root(dir.path());
7365 let files = project_files(&root);
7366 let projected = store_projected_snapshot(&root, ".store-dead-code-e2e");
7367
7368 let projected_aggregate = dead_code_aggregate(&root, files, projected);
7369 assert_dead_item(&projected_aggregate, "src/dead.ts", "knownDead");
7370 assert_live_item(&projected_aggregate, "src/live.ts", "knownLive");
7371 assert_live_item(&projected_aggregate, "src/render.ts", "render");
7372 assert_live_item(&projected_aggregate, "src/other_render.ts", "render");
7373 }
7374
7375 #[test]
7376 fn dead_code_projection_rust_attribute_entry_points_are_live() {
7377 let dir = tempfile::tempdir().expect("tempdir");
7378 write_rust_attribute_entry_fixture(dir.path());
7379 let root = canonical_root(dir.path());
7380 let files = project_files(&root);
7381 let store = CallGraphStore::open(root.join(".store-tauri-commands"), root.clone())
7382 .expect("open store");
7383 store.cold_build(&files).expect("cold build store");
7384 let command = store
7385 .node_for(Path::new("src/commands.rs"), "get_primers")
7386 .expect("command node");
7387 assert!(
7388 command.is_entry_point,
7389 "attribute-rooted commands must be labeled as callgraph entry points"
7390 );
7391 let private_command = store
7392 .node_for(Path::new("src/commands.rs"), "private_command")
7393 .expect("private command node");
7394 assert!(
7395 private_command.is_entry_point,
7396 "private attribute-rooted commands must also be callgraph entry points"
7397 );
7398
7399 let projected = project_dead_code_snapshot(store.sqlite_path()).expect("project snapshot");
7400 let aggregate = dead_code_aggregate(&root, files, projected);
7401 assert_live_item(&aggregate, "src/commands.rs", "get_primers");
7402 assert_live_item(&aggregate, "src/db.rs", "helper");
7403 assert_live_item(&aggregate, "src/db.rs", "private_helper");
7404 assert_live_item(&aggregate, "src/imported.rs", "imported_command");
7405 assert_live_item(&aggregate, "src/db.rs", "imported_helper");
7406 assert_dead_item(&aggregate, "src/commands.rs", "planted_dead");
7407 assert_dead_item(&aggregate, "src/unimported.rs", "false_command");
7408 assert_dead_item(&aggregate, "src/db.rs", "false_helper");
7409 }
7410
7411 #[test]
7412 fn dead_code_projection_rust_attribute_roots_are_cold_deterministic() {
7413 let dir = tempfile::tempdir().expect("tempdir");
7414 write_rust_attribute_entry_fixture(dir.path());
7415 let root = canonical_root(dir.path());
7416 let first = store_projected_snapshot(&root, ".store-tauri-cold-a");
7417 let second = store_projected_snapshot(&root, ".store-tauri-cold-b");
7418
7419 assert_snapshot_parts_eq("rust attribute roots cold", &first, &second);
7420 }
7421
7422 #[test]
7423 fn dead_code_projection_rust_attribute_roots_survive_unrelated_incremental_edit() {
7424 let dir = tempfile::tempdir().expect("tempdir");
7425 write_rust_attribute_entry_fixture(dir.path());
7426 let root = canonical_root(dir.path());
7427 let files_before = project_files(&root);
7428 let incremental_store =
7429 CallGraphStore::open(root.join(".store-tauri-incremental"), root.clone())
7430 .expect("open incremental store");
7431 incremental_store
7432 .cold_build(&files_before)
7433 .expect("initial cold build");
7434
7435 write_file(
7436 &root.join("src/unrelated.rs"),
7437 r#"// unrelated edit should not refresh command attribute facts
7438pub fn unrelated() -> u32 { 2 }
7439"#,
7440 );
7441 let stats = incremental_store
7442 .refresh_files(&[root.join("src/unrelated.rs")])
7443 .expect("refresh unrelated file");
7444 assert_eq!(stats.refreshed_own_files, 1);
7445 assert_eq!(stats.changed_files, vec!["src/unrelated.rs".to_string()]);
7446 assert!(
7447 !stats
7448 .surface_changed
7449 .iter()
7450 .any(|file| file == "src/commands.rs"),
7451 "unrelated edit must not refresh the command module: {stats:#?}"
7452 );
7453 let incremental = project_dead_code_snapshot(incremental_store.sqlite_path())
7454 .expect("project incremental snapshot");
7455
7456 let cold_store = CallGraphStore::open(root.join(".store-tauri-cold"), root.clone())
7457 .expect("open cold store");
7458 cold_store
7459 .cold_build(&project_files(&root))
7460 .expect("cold rebuild");
7461 let cold = project_dead_code_snapshot(cold_store.sqlite_path()).expect("project cold");
7462 assert_snapshot_parts_eq("rust attribute roots unrelated edit", &cold, &incremental);
7463
7464 let aggregate = dead_code_aggregate(&root, project_files(&root), incremental);
7465 assert_live_item(&aggregate, "src/commands.rs", "get_primers");
7466 assert_live_item(&aggregate, "src/db.rs", "helper");
7467 assert_live_item(&aggregate, "src/db.rs", "private_helper");
7468 assert_dead_item(&aggregate, "src/commands.rs", "planted_dead");
7469 }
7470
7471 fn assert_projection_fixture_coverage(root: &Path, snapshot: &CallgraphSnapshot) {
7472 let comparable = comparable_snapshot(snapshot);
7473 assert!(
7474 comparable
7475 .files
7476 .iter()
7477 .any(|file| file.extension().and_then(|ext| ext.to_str()) == Some("ts")),
7478 "fixture must include TypeScript 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("js")),
7486 "fixture must include JavaScript files: {:#?}",
7487 comparable.files
7488 );
7489 assert!(
7490 comparable
7491 .files
7492 .iter()
7493 .any(|file| file.extension().and_then(|ext| ext.to_str()) == Some("rs")),
7494 "fixture must include Rust files: {:#?}",
7495 comparable.files
7496 );
7497
7498 let main_file = canonicalize_for_snapshot(&root.join("src/main.ts"));
7499 let private_dispatch_target = format!("{}::dispatch", main_file.display());
7500 assert!(
7501 comparable
7502 .outbound_calls
7503 .iter()
7504 .any(
7505 |(caller_file, caller_symbol, target, _)| caller_file == &main_file
7506 && caller_symbol == "main"
7507 && target == &private_dispatch_target
7508 ),
7509 "fixture must cover same-file private fallback target {private_dispatch_target}: {:#?}",
7510 comparable.outbound_calls
7511 );
7512 assert!(
7513 comparable
7514 .outbound_calls
7515 .iter()
7516 .any(|(_, _, target, _)| target.contains(DISPATCHED_CALLEE_SEPARATOR)),
7517 "fixture must cover method-dispatch suffixes: {:#?}",
7518 comparable.outbound_calls
7519 );
7520 assert!(
7521 comparable
7522 .exported_symbols
7523 .iter()
7524 .any(|(_, symbol, kind, _)| symbol == "runDefault"
7525 && kind == DEFAULT_EXPORT_MARKER_KIND),
7526 "fixture must cover default-export marker rows: {:#?}",
7527 comparable.exported_symbols
7528 );
7529 }
7530
7531 fn run_projection_scenario(name: &str, setup: fn(&Path), edit: fn(&Path) -> Vec<PathBuf>) {
7532 let dir = tempfile::tempdir().expect("tempdir");
7533 setup(dir.path());
7534 let root = canonical_root(dir.path());
7535 let files_before = project_files(&root);
7536 let incremental_store = CallGraphStore::open(
7537 root.join(format!(".store-dead-code-projection-{name}-incremental")),
7538 root.clone(),
7539 )
7540 .expect("open incremental store");
7541 incremental_store
7542 .cold_build(&files_before)
7543 .expect("initial cold build");
7544
7545 let (revision, previous) =
7546 project_dead_code_snapshot_with_revision(incremental_store.sqlite_path())
7547 .expect("initial projection");
7548 let changed = edit(&root);
7549 incremental_store
7550 .refresh_files(&changed)
7551 .expect("refresh changed files");
7552 let (_, incremental, verdict) =
7553 crate::callgraph_store::project_dead_code_snapshot_incremental(
7554 incremental_store.sqlite_path(),
7555 Some((revision.unwrap(), &previous)),
7556 )
7557 .expect("project incremental snapshot");
7558 assert_eq!(
7559 verdict.reason, None,
7560 "{name}: a spliced projection carries no full-projection reason"
7561 );
7562 assert_eq!(
7563 verdict.kind,
7564 ProjectionKind::Spliced,
7565 "{name}: a clean journal bridge must splice the previous snapshot"
7566 );
7567 let full =
7568 project_dead_code_snapshot(incremental_store.sqlite_path()).expect("full projection");
7569 let files = project_files(&root);
7570 assert_eq!(
7571 serde_json::to_vec(&dead_code_aggregate(
7572 &root,
7573 files.clone(),
7574 incremental.clone()
7575 ))
7576 .unwrap(),
7577 serde_json::to_vec(&dead_code_aggregate(&root, files, full)).unwrap(),
7578 "{name}: incremental and full projection aggregates must be byte-identical",
7579 );
7580
7581 let cold_store = CallGraphStore::open(
7582 root.join(format!(".store-dead-code-projection-{name}-cold")),
7583 root.clone(),
7584 )
7585 .expect("open cold store");
7586 cold_store
7587 .cold_build(&project_files(&root))
7588 .expect("cold rebuild");
7589 let cold =
7590 project_dead_code_snapshot(cold_store.sqlite_path()).expect("project cold snapshot");
7591
7592 assert_snapshot_parts_eq(name, &cold, &incremental);
7593 }
7594
7595 #[test]
7604 #[ignore = "manual benchmark; needs AFT_BENCH_REPO pointing at a large checkout"]
7605 fn dead_code_decision_b_benchmark() {
7606 let Ok(repo) = std::env::var("AFT_BENCH_REPO") else {
7607 eprintln!("AFT_BENCH_REPO unset; skipping");
7608 return;
7609 };
7610 macro_rules! mark {
7612 ($($a:tt)*) => {{ eprintln!($($a)*); let _ = std::io::Write::flush(&mut std::io::stderr()); }};
7613 }
7614 let root = canonical_root(Path::new(&repo));
7615 let files = project_files(&root);
7616 mark!(
7617 "\n=== Store-backed dead_code benchmark ===\nrepo: {}\nsource files (walk_project_files): {}\nstarted store cold_build...",
7618 root.display(),
7619 files.len()
7620 );
7621
7622 let store_dir = root.join(".aft-bench-store");
7625 let _ = std::fs::remove_dir_all(&store_dir);
7626 let store = CallGraphStore::open(store_dir.clone(), root.clone()).expect("open store");
7627 let t = Instant::now();
7628 let cold_stats = store.cold_build(&files).expect("store cold build");
7629 let store_build_ms = t.elapsed().as_millis();
7630 let t = Instant::now();
7631 let projected = project_dead_code_snapshot(store.sqlite_path()).expect("projection");
7632 let proj_ms = t.elapsed().as_millis();
7633 mark!(
7634 "store cold_build: {} ms ({:?}) + projection: {} ms = {} ms (exports={}, outbound={})\nstarted scan...",
7635 store_build_ms, cold_stats, proj_ms, store_build_ms + proj_ms,
7636 projected.exported_symbols.len(), projected.outbound_calls.len()
7637 );
7638
7639 let t = Instant::now();
7641 let _result = dead_code_aggregate(&root, files.clone(), projected.clone());
7642 let scan_ms = t.elapsed().as_millis();
7643 mark!("run_dead_code_scan (cold contributions): {} ms", scan_ms);
7644
7645 mark!(
7646 "\nSUMMARY files={} store_cold_plus_projection={}ms projection={}ms scan_cold={}ms total={}ms",
7647 files.len(),
7648 store_build_ms + proj_ms,
7649 proj_ms,
7650 scan_ms,
7651 store_build_ms + proj_ms + scan_ms
7652 );
7653 let _ = std::fs::remove_dir_all(&store_dir);
7654 }
7655
7656 #[cfg(unix)]
7657 fn projection_bench_cpu_ms() -> f64 {
7658 let mut usage = std::mem::MaybeUninit::<libc::rusage>::uninit();
7659 let result = unsafe { libc::getrusage(libc::RUSAGE_SELF, usage.as_mut_ptr()) };
7661 assert_eq!(result, 0);
7662 let usage = unsafe { usage.assume_init() };
7663 (usage.ru_utime.tv_sec + usage.ru_stime.tv_sec) as f64 * 1000.0
7664 + (usage.ru_utime.tv_usec + usage.ru_stime.tv_usec) as f64 / 1000.0
7665 }
7666
7667 #[cfg(unix)]
7668 #[test]
7669 #[ignore = "offline projection/rollup benchmark copies a production generation"]
7670 fn profile_incremental_projection_on_store_copy() {
7671 use rusqlite::{backup::Backup, Connection, OpenFlags};
7672 let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR"))
7673 .parent()
7674 .unwrap()
7675 .parent()
7676 .unwrap()
7677 .canonicalize()
7678 .unwrap();
7679 let root = std::env::var_os("AFT_PROJECTION_BENCH_ROOT")
7680 .map(PathBuf::from)
7681 .unwrap_or_else(|| workspace_root.clone())
7682 .canonicalize()
7683 .unwrap();
7684 assert!(
7685 root == workspace_root || root.starts_with(workspace_root.join("target")),
7686 "benchmark roots must be this checkout or a disposable copy below target"
7687 );
7688 let source_dir = PathBuf::from(
7689 std::env::var_os("AFT_CALLGRAPH_REFRESH_STORE")
7690 .expect("set AFT_CALLGRAPH_REFRESH_STORE to the source root-key directory"),
7691 );
7692 let pointer = std::fs::read_dir(&source_dir)
7693 .unwrap()
7694 .filter_map(Result::ok)
7695 .map(|entry| entry.path())
7696 .find(|path| path.extension().is_some_and(|ext| ext == "current"))
7697 .expect("source generation pointer");
7698 let source_path = source_dir.join(std::fs::read_to_string(pointer).unwrap().trim());
7699 let temp = tempfile::tempdir_in(workspace_root.join("target")).unwrap();
7700 let store_dir = temp.path().join("store");
7701 std::fs::create_dir_all(&store_dir).unwrap();
7702 let key = crate::search_index::artifact_cache_key(&root);
7703 let db = store_dir.join(format!("{key}.sqlite"));
7704 {
7705 let source =
7706 Connection::open_with_flags(source_path, OpenFlags::SQLITE_OPEN_READ_ONLY).unwrap();
7707 let mut destination = Connection::open(&db).unwrap();
7708 Backup::new(&source, &mut destination)
7709 .unwrap()
7710 .run_to_completion(256, Duration::from_millis(5), None)
7711 .unwrap();
7712 destination
7713 .execute(
7714 "UPDATE backend_file_state SET workspace_root = ?1",
7715 [root.display().to_string()],
7716 )
7717 .unwrap();
7718 }
7719 let store = CallGraphStore::open(store_dir, root.clone()).unwrap();
7720 let changed_count = std::env::var("AFT_PROJECTION_BENCH_CHANGED_FILES")
7723 .ok()
7724 .and_then(|value| value.parse::<usize>().ok())
7725 .unwrap_or(1);
7726 let changed_paths = std::env::var_os("AFT_PROJECTION_BENCH_CHANGED_PATHS");
7727 let changed = if let Some(changed_paths) = changed_paths.as_ref() {
7728 std::fs::read_to_string(changed_paths)
7729 .unwrap()
7730 .lines()
7731 .filter(|line| !line.is_empty())
7732 .take(changed_count)
7733 .map(|relative| root.join(relative))
7734 .collect::<Vec<_>>()
7735 } else {
7736 (0..changed_count)
7737 .map(|index| temp.path().join(format!("probe-{index}.rs")))
7738 .collect::<Vec<_>>()
7739 };
7740 assert_eq!(
7741 changed.len(),
7742 changed_count,
7743 "changed-path corpus is too small"
7744 );
7745 if changed_paths.is_none() {
7746 for path in &changed {
7747 write_file(path, "pub fn projection_probe() {}\n");
7748 }
7749 store.refresh_files(&changed).unwrap();
7750 }
7751 let (revision, previous) =
7752 project_dead_code_snapshot_with_revision(store.sqlite_path()).unwrap();
7753 let mut refresh_ms = 0.0;
7754 if std::env::var_os("AFT_PROJECTION_BENCH_JOURNAL_ONLY").is_some() {
7755 let next = revision.unwrap() + 1;
7756 let callers = changed
7757 .iter()
7758 .map(|path| {
7759 path.strip_prefix(&root)
7760 .unwrap()
7761 .to_string_lossy()
7762 .replace('\\', "/")
7763 })
7764 .collect::<BTreeSet<_>>();
7765 let payload = serde_json::to_string(&(next, callers)).unwrap();
7766 let conn = rusqlite::Connection::open(store.sqlite_path()).unwrap();
7767 conn.execute(
7768 "INSERT OR REPLACE INTO meta(k, v) VALUES('projection_write_revision', ?1)",
7769 [next.to_string()],
7770 )
7771 .unwrap();
7772 conn.execute(
7773 "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
7774 rusqlite::params![format!("projection_delta_{}", next % 64), payload],
7775 )
7776 .unwrap();
7777 } else {
7778 if changed_paths.is_none() {
7779 for path in &changed {
7780 write_file(
7781 path,
7782 "pub fn projection_probe() { projection_probe_target(); }\npub fn projection_probe_target() {}\n",
7783 );
7784 }
7785 }
7786 let refresh_started = Instant::now();
7787 store.refresh_files(&changed).unwrap();
7788 refresh_ms = refresh_started.elapsed().as_secs_f64() * 1000.0;
7789 if let Some(journal_paths) = std::env::var_os("AFT_PROJECTION_BENCH_JOURNAL_PATHS") {
7790 let current = store.projection_write_revision().unwrap().unwrap();
7791 let callers = std::fs::read_to_string(journal_paths)
7792 .unwrap()
7793 .lines()
7794 .filter(|line| !line.is_empty())
7795 .map(str::to_owned)
7796 .collect::<BTreeSet<_>>();
7797 let payload = serde_json::to_string(&(current, callers)).unwrap();
7798 let conn = rusqlite::Connection::open(store.sqlite_path()).unwrap();
7799 conn.execute(
7800 "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
7801 rusqlite::params![format!("projection_delta_{}", current % 64), payload],
7802 )
7803 .unwrap();
7804 }
7805 }
7806 let snapshot_replay_ms = refresh_ms;
7807 let cpu = projection_bench_cpu_ms();
7808 let started = Instant::now();
7809 let full = project_dead_code_snapshot(store.sqlite_path()).unwrap();
7810 let full_elapsed = started.elapsed();
7811 let full_ms = full_elapsed.as_secs_f64() * 1000.0;
7812 let full_cpu = projection_bench_cpu_ms() - cpu;
7813 crate::callgraph_store::take_projection_work();
7814 let cpu = projection_bench_cpu_ms();
7815 let started = Instant::now();
7816 let (_, incremental, verdict) =
7817 crate::callgraph_store::project_dead_code_snapshot_incremental(
7818 store.sqlite_path(),
7819 Some((revision.unwrap(), &previous)),
7820 )
7821 .unwrap();
7822 let delta_elapsed = started.elapsed();
7823 let delta_ms = delta_elapsed.as_secs_f64() * 1000.0;
7824 let delta_cpu = projection_bench_cpu_ms() - cpu;
7825 let work = crate::callgraph_store::take_projection_work();
7826 if std::env::var_os("AFT_PROJECTION_BENCH_PROJECTION_ONLY").is_some() {
7827 let mut costs = ProjectionCostEstimates::default();
7828 costs.observe(
7829 ProjectionVerdict {
7830 kind: ProjectionKind::Full,
7831 reason: Some("cold"),
7832 journal_bytes: 0,
7833 changed_files: 0,
7834 },
7835 full_elapsed,
7836 );
7837 costs.observe(verdict, delta_elapsed);
7838 let (_, _, predicted, _) = project_dead_code_snapshot_incremental_with_costs(
7839 store.sqlite_path(),
7840 Some((revision.unwrap(), &previous)),
7841 costs,
7842 )
7843 .unwrap();
7844 eprintln!(
7845 "projection_crossover changed_files={} refresh_ms={refresh_ms:.3} snapshot_replay_ms={:.3} full_ms={full_ms:.3} full_cpu_ms={full_cpu:.3} splice_ms={delta_ms:.3} splice_cpu_ms={delta_cpu:.3} measured={:?} predicted={:?} predicted_reason={:?} outbound_rows_read={}",
7846 verdict.changed_files, snapshot_replay_ms + delta_ms, verdict.kind, predicted.kind, predicted.reason, work.1
7847 );
7848 return;
7849 }
7850 let mut job = InspectJob {
7851 job_id: 87,
7852 key: JobKey::for_project_category(InspectCategory::DeadCode),
7853 category: InspectCategory::DeadCode,
7854 scope_files: full.files.clone(),
7855 project_root: root.clone(),
7856 inspect_dir: temp.path().join("inspect"),
7857 config: Arc::new(Config {
7858 project_root: Some(root.clone()),
7859 ..Config::default()
7860 }),
7861 symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
7862 inspect_writer: true,
7863 callgraph_writer: false,
7864 callgraph_snapshot: Some(Arc::new(full)),
7865 };
7866 let contribution_load_started = Instant::now();
7867 let copied_inspect = std::env::var_os("AFT_PROJECTION_BENCH_INSPECT_STORE");
7868 let contributions = if let Some(source_path) = copied_inspect.as_ref() {
7869 let project_key = crate::path_identity::project_scope_key(&root);
7870 let project_inspect_dir = job.inspect_dir.join(&project_key);
7871 std::fs::create_dir_all(&project_inspect_dir).unwrap();
7872 let inspect_db = project_inspect_dir.join(format!("{project_key}.sqlite"));
7873 {
7874 let source =
7875 Connection::open_with_flags(source_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
7876 .unwrap();
7877 let mut destination = Connection::open(&inspect_db).unwrap();
7878 Backup::new(&source, &mut destination)
7879 .unwrap()
7880 .run_to_completion(256, Duration::from_millis(5), None)
7881 .unwrap();
7882 for table in ["tier2_contributions", "tier2_aggregates", "tier2_meta"] {
7883 destination
7884 .execute(
7885 &format!("UPDATE {table} SET project_key = ?1"),
7886 [&project_key],
7887 )
7888 .unwrap();
7889 }
7890 }
7891 let cache = InspectCache::open(job.inspect_dir.clone(), root.clone()).unwrap();
7892 load_contributions(&cache, &job).unwrap()
7893 } else {
7894 crate::inspect::scanners::dead_code::run_dead_code_scan(&job)
7895 .outcome
7896 .unwrap()
7897 .contributions
7898 };
7899 let contribution_load_ms = contribution_load_started.elapsed().as_secs_f64() * 1000.0;
7900 let public_api_files = crate::inspect::scanners::dead_code::collect_public_api_files(&root);
7901 let roles = crate::inspect::entry_points::resolve_project_roles(&root);
7902 let cpu = projection_bench_cpu_ms();
7903 let started = Instant::now();
7904 let (full_aggregate, rollup_state, _) =
7905 crate::inspect::scanners::dead_code::aggregate_dead_code_contributions_incremental(
7906 &root,
7907 job.callgraph_snapshot.as_deref().unwrap(),
7908 &contributions,
7909 &public_api_files,
7910 &roles,
7911 None,
7912 Some("store-copy-benchmark"),
7913 None,
7914 &BTreeSet::new(),
7915 );
7916 let full_rollup_ms = started.elapsed().as_secs_f64() * 1000.0;
7917 let full_rollup_cpu = projection_bench_cpu_ms() - cpu;
7918 job.callgraph_snapshot = Some(Arc::new(incremental));
7919 let changed_relative = changed
7920 .iter()
7921 .map(|path| {
7922 path.strip_prefix(&root)
7923 .unwrap()
7924 .to_string_lossy()
7925 .replace('\\', "/")
7926 })
7927 .collect::<BTreeSet<_>>();
7928 let cpu = projection_bench_cpu_ms();
7929 let started = Instant::now();
7930 let (delta_aggregate, _, rollup_verdict) =
7931 crate::inspect::scanners::dead_code::aggregate_dead_code_contributions_incremental(
7932 &root,
7933 job.callgraph_snapshot.as_deref().unwrap(),
7934 &contributions,
7935 &public_api_files,
7936 &roles,
7937 None,
7938 Some("store-copy-benchmark"),
7939 Some(&rollup_state),
7940 &changed_relative,
7941 );
7942 let delta_rollup_ms = started.elapsed().as_secs_f64() * 1000.0;
7943 let delta_rollup_cpu = projection_bench_cpu_ms() - cpu;
7944 assert_eq!(
7945 rollup_verdict.kind,
7946 crate::inspect::scanners::dead_code::RollupKind::Incremental
7947 );
7948 assert_eq!(
7949 serde_json::to_vec(&full_aggregate).unwrap(),
7950 serde_json::to_vec(&delta_aggregate).unwrap()
7951 );
7952 let manager = InspectManager::new();
7953 manager.cache_callgraph_projection(
7954 CallgraphProjectionIdentity {
7955 project_root: root,
7956 generation: None,
7957 legacy_sqlite_path: Some(db),
7958 write_revision: store.projection_write_revision().unwrap().unwrap(),
7959 },
7960 job.callgraph_snapshot.clone().unwrap(),
7961 );
7962 eprintln!("projection_bench changed_files={changed_count} rows={} contributions={} contribution_source={} contribution_load_ms={contribution_load_ms:.3}; refresh_ms={refresh_ms:.3} snapshot_replay_ms={:.3}; before snapshot={full_ms:.3} cpu={full_cpu:.3} rollup={full_rollup_ms:.3} rollup_cpu={full_rollup_cpu:.3}; after snapshot={delta_ms:.3} cpu={delta_cpu:.3} rollup={delta_rollup_ms:.3} rollup_cpu={delta_rollup_cpu:.3}; full_projections={} outbound_rows_read={}", previous.outbound_calls.len(), contributions.len(), if copied_inspect.is_some() { "inspect_copy" } else { "source_scan" }, snapshot_replay_ms + delta_ms, work.0, work.1);
7963 eprintln!(
7964 "projection_bench callgraph_memory={:?}",
7965 manager.callgraph_projection_estimated_memory()
7966 );
7967 }
7968
7969 fn store_projected_snapshot(root: &Path, store_name: &str) -> CallgraphSnapshot {
7970 let store =
7971 CallGraphStore::open(root.join(store_name), root.to_path_buf()).expect("open store");
7972 store
7973 .cold_build(&project_files(root))
7974 .expect("store cold build");
7975 project_dead_code_snapshot(store.sqlite_path()).expect("project snapshot")
7976 }
7977
7978 fn dead_code_aggregate(
7979 root: &Path,
7980 scope_files: Vec<PathBuf>,
7981 snapshot: CallgraphSnapshot,
7982 ) -> Value {
7983 let job = InspectJob {
7984 job_id: 86,
7985 key: JobKey::for_project_category(InspectCategory::DeadCode),
7986 category: InspectCategory::DeadCode,
7987 scope_files,
7988 project_root: root.to_path_buf(),
7989 inspect_dir: root.join(".aft-cache").join("inspect"),
7990 config: Arc::new(Config {
7991 project_root: Some(root.to_path_buf()),
7992 ..Config::default()
7993 }),
7994 symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
7995 inspect_writer: true,
7996 callgraph_writer: true,
7997 callgraph_snapshot: Some(Arc::new(snapshot)),
7998 };
7999 crate::inspect::scanners::dead_code::run_dead_code_scan(&job)
8000 .outcome
8001 .expect("dead_code scan succeeds")
8002 .aggregate
8003 }
8004
8005 fn assert_snapshot_parts_eq(
8006 label: &str,
8007 expected: &CallgraphSnapshot,
8008 actual: &CallgraphSnapshot,
8009 ) {
8010 let expected = comparable_snapshot(expected);
8011 let actual = comparable_snapshot(actual);
8012 assert_eq!(
8013 actual, expected,
8014 "{label} store-projected snapshot must match cold store snapshot"
8015 );
8016 }
8017
8018 fn comparable_snapshot(snapshot: &CallgraphSnapshot) -> ComparableSnapshot {
8019 ComparableSnapshot {
8020 files: snapshot.files.iter().cloned().collect(),
8021 exported_symbols: snapshot
8022 .exported_symbols
8023 .iter()
8024 .map(|export| {
8025 (
8026 export.file.clone(),
8027 export.symbol.clone(),
8028 export.kind.clone(),
8029 export.line,
8030 )
8031 })
8032 .collect(),
8033 outbound_calls: snapshot
8034 .outbound_calls
8035 .iter()
8036 .map(|call| {
8037 (
8038 call.caller_file.clone(),
8039 call.caller_symbol.clone(),
8040 call.target.clone(),
8041 call.line,
8042 )
8043 })
8044 .collect(),
8045 entry_points: snapshot.entry_points.clone(),
8046 entry_point_symbols: snapshot.entry_point_symbols.clone(),
8047 }
8048 }
8049
8050 fn assert_dead_item(aggregate: &Value, file: &str, symbol: &str) {
8051 assert!(
8052 aggregate_has_item(aggregate, file, symbol),
8053 "expected {file}::{symbol} to be reported dead: {aggregate:#}"
8054 );
8055 }
8056
8057 fn assert_live_item(aggregate: &Value, file: &str, symbol: &str) {
8058 assert!(
8059 !aggregate_has_item(aggregate, file, symbol),
8060 "expected {file}::{symbol} to be live/not reported dead: {aggregate:#}"
8061 );
8062 }
8063
8064 fn aggregate_has_item(aggregate: &Value, file: &str, symbol: &str) -> bool {
8065 let Some(items) = aggregate.get("items").and_then(Value::as_array) else {
8066 return false;
8067 };
8068 items.iter().any(|item| {
8069 item.get("file").and_then(Value::as_str) == Some(file)
8070 && item.get("symbol").and_then(Value::as_str) == Some(symbol)
8071 })
8072 }
8073
8074 fn project_files(root: &Path) -> Vec<PathBuf> {
8075 walk_project_files(root).collect()
8076 }
8077
8078 fn canonical_root(root: &Path) -> PathBuf {
8079 std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf())
8080 }
8081
8082 fn write_file(path: &Path, content: &str) {
8083 if let Some(parent) = path.parent() {
8084 std::fs::create_dir_all(parent).expect("create parent");
8085 }
8086 std::fs::write(path, content).expect("write fixture");
8087 bump_mtime(path);
8088 }
8089
8090 fn bump_mtime(path: &Path) {
8091 let secs = NEXT_MTIME.fetch_add(1, AtomicOrdering::SeqCst);
8092 filetime::set_file_mtime(path, FileTime::from_unix_time(secs, 0)).expect("bump mtime");
8093 }
8094
8095 fn remove_file(path: &Path) {
8096 std::fs::remove_file(path).expect("remove fixture");
8097 }
8098
8099 fn write_projection_fixture(root: &Path) {
8100 write_file(
8101 &root.join("package.json"),
8102 r#"{"name":"dead-code-projection-fixture","type":"module","main":"src/main.ts"}"#,
8103 );
8104 write_file(
8105 &root.join("Cargo.toml"),
8106 r#"[package]
8107name = "dead_code_projection_fixture"
8108version = "0.1.0"
8109edition = "2021"
8110"#,
8111 );
8112 write_file(
8113 &root.join("src/main.ts"),
8114 r#"import runDefault from "./default";
8115import { knownLive } from "./live";
8116import { jsEntry } from "./app.js";
8117
8118export function main() {
8119 dispatch();
8120 runDefault();
8121 jsEntry();
8122}
8123
8124function dispatch() {
8125 knownLive();
8126 const service = { render() {} };
8127 service.render();
8128}
8129"#,
8130 );
8131 write_file(
8132 &root.join("src/default.ts"),
8133 r#"export default function runDefault() {}
8134"#,
8135 );
8136 write_file(
8137 &root.join("src/live.ts"),
8138 r#"export function knownLive() {}
8139"#,
8140 );
8141 write_file(
8142 &root.join("src/dead.ts"),
8143 r#"export function knownDead() {}
8144"#,
8145 );
8146 write_file(
8147 &root.join("src/render.ts"),
8148 r#"export function render() {}
8149"#,
8150 );
8151 write_file(
8152 &root.join("src/other_render.ts"),
8153 r#"export function render() {}
8154"#,
8155 );
8156 write_file(
8157 &root.join("src/app.js"),
8158 r#"import { jsHelper } from "./js_helper.js";
8159
8160export function jsEntry() {
8161 jsHelper();
8162}
8163"#,
8164 );
8165 write_file(
8166 &root.join("src/js_helper.js"),
8167 r#"export function jsHelper() {}
8168"#,
8169 );
8170 write_file(
8171 &root.join("src/lib.rs"),
8172 r#"mod util;
8173use crate::util::rust_helper;
8174
8175pub fn rust_entry() {
8176 rust_helper();
8177}
8178"#,
8179 );
8180 write_file(
8181 &root.join("src/util.rs"),
8182 r#"pub fn rust_helper() {}
8183"#,
8184 );
8185 }
8186
8187 fn write_rust_attribute_entry_fixture(root: &Path) {
8188 write_file(
8189 &root.join("src/main.rs"),
8190 r#"mod commands;
8191mod db;
8192mod imported;
8193mod unimported;
8194mod unrelated;
8195
8196fn main() {
8197 tauri::generate_handler![commands::get_primers, imported::imported_command];
8198}
8199"#,
8200 );
8201 write_file(
8202 &root.join("src/commands.rs"),
8203 r#"use crate::db;
8204
8205#[tauri::command]
8206pub fn get_primers() -> String {
8207 db::helper()
8208}
8209
8210pub fn planted_dead() -> String {
8211 "dead".to_string()
8212}
8213
8214#[tauri::command]
8215fn private_command() -> String {
8216 db::private_helper()
8217}
8218"#,
8219 );
8220 write_file(
8221 &root.join("src/imported.rs"),
8222 r#"use crate::db;
8223use tauri::command;
8224
8225#[command]
8226pub fn imported_command() -> String {
8227 db::imported_helper()
8228}
8229"#,
8230 );
8231 write_file(
8232 &root.join("src/unimported.rs"),
8233 r#"use crate::db;
8234
8235#[command]
8236pub fn false_command() -> String {
8237 db::false_helper()
8238}
8239"#,
8240 );
8241 write_file(
8242 &root.join("src/db.rs"),
8243 r#"pub fn helper() -> String { "live".to_string() }
8244pub fn imported_helper() -> String { "live".to_string() }
8245pub fn private_helper() -> String { "live".to_string() }
8246pub fn false_helper() -> String { "dead".to_string() }
8247"#,
8248 );
8249 write_file(
8250 &root.join("src/unrelated.rs"),
8251 r#"pub fn unrelated() -> u32 { 1 }
8252"#,
8253 );
8254 }
8255
8256 fn setup_projection_rename(root: &Path) {
8257 write_file(
8258 &root.join("a.ts"),
8259 r#"export function outer() {
8260 inner();
8261}
8262
8263export function inner() {}
8264"#,
8265 );
8266 }
8267
8268 fn edit_projection_rename(root: &Path) -> Vec<PathBuf> {
8269 let path = root.join("a.ts");
8270 write_file(
8271 &path,
8272 r#"export function outer() {
8273 renamed();
8274}
8275
8276export function renamed() {}
8277"#,
8278 );
8279 vec![path]
8280 }
8281
8282 fn setup_projection_delete(root: &Path) {
8283 write_file(&root.join("other.ts"), "export function foo() {}\n");
8284 write_file(
8285 &root.join("main.ts"),
8286 r#"import { foo } from "./foo";
8287export function main() { foo(); }
8288"#,
8289 );
8290 write_file(&root.join("foo.ts"), "export function foo() {}\n");
8291 }
8292
8293 fn edit_projection_delete(root: &Path) -> Vec<PathBuf> {
8294 let path = root.join("foo.ts");
8295 remove_file(&path);
8296 vec![path]
8297 }
8298
8299 fn setup_projection_barrel(root: &Path) {
8300 write_file(
8301 &root.join("main.ts"),
8302 r#"import { foo } from "./barrel";
8303export function main() { foo(); }
8304"#,
8305 );
8306 write_file(&root.join("barrel.ts"), "export { foo } from \"./foo\";\n");
8307 write_file(&root.join("foo.ts"), "export function foo() {}\n");
8308 }
8309
8310 fn edit_projection_barrel_delete(root: &Path) -> Vec<PathBuf> {
8311 let path = root.join("barrel.ts");
8312 remove_file(&path);
8313 vec![path]
8314 }
8315
8316 fn setup_projection_dispatch(root: &Path) {
8317 write_file(
8318 &root.join("main.ts"),
8319 r#"export function main() {
8320 const service = { render() {}, paint() {} };
8321 service.render();
8322}
8323"#,
8324 );
8325 write_file(&root.join("render.ts"), "export function render() {}\n");
8326 write_file(&root.join("paint.ts"), "export function paint() {}\n");
8327 }
8328
8329 fn edit_projection_dispatch(root: &Path) -> Vec<PathBuf> {
8330 let path = root.join("main.ts");
8331 write_file(
8332 &path,
8333 r#"export function main() {
8334 const service = { render() {}, paint() {} };
8335 service.paint();
8336}
8337"#,
8338 );
8339 vec![path]
8340 }
8341
8342 fn setup_projection_body_only(root: &Path) {
8343 write_file(
8344 &root.join("main.ts"),
8345 r#"import { foo } from "./foo";
8346export function main() { foo(); }
8347"#,
8348 );
8349 write_file(
8350 &root.join("foo.ts"),
8351 r#"export function foo() {
8352 return 1;
8353}
8354"#,
8355 );
8356 }
8357
8358 fn edit_projection_body_only(root: &Path) -> Vec<PathBuf> {
8359 let path = root.join("foo.ts");
8360 write_file(
8361 &path,
8362 r#"export function foo() {
8363 return 2;
8364}
8365"#,
8366 );
8367 vec![path]
8368 }
8369
8370 #[test]
8371 fn forced_paths_downgrade_only_when_strict_hash_matches_cached_fact() {
8372 let dir = tempfile::tempdir().expect("tempdir");
8373 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
8374 let unchanged = root.join("unchanged.ts");
8375 let changed = root.join("changed.ts");
8376 let oversized = root.join("oversized.ts");
8377 std::fs::write(&unchanged, "export const value = 1;\n").expect("write unchanged");
8378 std::fs::write(&changed, "export const before = 1;\n").expect("write changed baseline");
8379 let unchanged_freshness =
8380 cache_freshness::collect(&unchanged).expect("unchanged freshness");
8381 let changed_freshness = cache_freshness::collect(&changed).expect("changed freshness");
8382 std::fs::write(&changed, "export const after_ = 2;\n").expect("change same-size content");
8383 let oversized_file = std::fs::File::create(&oversized).expect("create oversized");
8384 oversized_file
8385 .set_len(cache_freshness::CONTENT_HASH_SIZE_CAP + 1)
8386 .expect("size oversized");
8387 let oversized_freshness =
8388 cache_freshness::collect(&oversized).expect("oversized freshness");
8389 let cached = vec![
8390 CachedContributionFreshness {
8391 file_path: PathBuf::from("unchanged.ts"),
8392 freshness: unchanged_freshness,
8393 },
8394 CachedContributionFreshness {
8395 file_path: PathBuf::from("changed.ts"),
8396 freshness: changed_freshness,
8397 },
8398 CachedContributionFreshness {
8399 file_path: PathBuf::from("oversized.ts"),
8400 freshness: oversized_freshness,
8401 },
8402 ];
8403
8404 let (remaining, downgraded) = downgrade_unchanged_forced_paths_with_freshness(
8405 &root,
8406 &cached,
8407 vec![
8408 PathBuf::from("unchanged.ts"),
8409 PathBuf::from("changed.ts"),
8410 PathBuf::from("oversized.ts"),
8411 ],
8412 );
8413
8414 assert_eq!(downgraded, 1);
8415 assert_eq!(
8416 remaining,
8417 vec![PathBuf::from("changed.ts"), PathBuf::from("oversized.ts")]
8418 );
8419 }
8420
8421 #[test]
8422 fn cached_projection_retains_root_cost_estimates_across_revisions() {
8423 let manager = InspectManager::new();
8424 let root = PathBuf::from("/cost-root");
8425 let identity = CallgraphProjectionIdentity {
8426 project_root: root.clone(),
8427 generation: Some("generation".to_string()),
8428 legacy_sqlite_path: None,
8429 write_revision: 1,
8430 };
8431 let snapshot = Arc::new(CallgraphSnapshot {
8432 generated_at: None,
8433 files: Vec::new(),
8434 exported_symbols: Vec::new(),
8435 outbound_calls: Vec::new(),
8436 entry_points: BTreeSet::new(),
8437 entry_point_symbols: BTreeMap::new(),
8438 });
8439 manager.cache_callgraph_projection(identity.clone(), Arc::clone(&snapshot));
8440 manager.observe_callgraph_projection_cost(
8441 &root,
8442 ProjectionVerdict {
8443 kind: ProjectionKind::Full,
8444 reason: Some("cold"),
8445 journal_bytes: 0,
8446 changed_files: 0,
8447 },
8448 Duration::from_nanos(100),
8449 );
8450 manager.observe_callgraph_projection_cost(
8451 &root,
8452 ProjectionVerdict {
8453 kind: ProjectionKind::Spliced,
8454 reason: None,
8455 journal_bytes: 20,
8456 changed_files: 4,
8457 },
8458 Duration::from_nanos(120),
8459 );
8460 let mut next_identity = identity;
8461 next_identity.write_revision = 2;
8462 manager.cache_callgraph_projection(next_identity.clone(), snapshot);
8463
8464 assert!(
8465 manager
8466 .callgraph_projection_costs(&next_identity)
8467 .splice_is_costlier(4),
8468 "cost estimates must survive replacing a root snapshot at a new revision"
8469 );
8470 }
8471
8472 #[test]
8473 fn fleet_budget_drops_oldest_whole_root_snapshot() {
8474 fn slot(root: &str) -> Arc<ProjectionSlot> {
8475 Arc::new(Mutex::new(Some(CachedCallgraphProjection {
8476 identity: CallgraphProjectionIdentity {
8477 project_root: PathBuf::from(root),
8478 generation: Some("generation".to_string()),
8479 legacy_sqlite_path: None,
8480 write_revision: 1,
8481 },
8482 snapshot: Arc::new(CallgraphSnapshot {
8483 generated_at: None,
8484 files: Vec::new(),
8485 exported_symbols: Vec::new(),
8486 outbound_calls: Vec::new(),
8487 entry_points: BTreeSet::new(),
8488 entry_point_symbols: BTreeMap::new(),
8489 }),
8490 estimated_bytes: 600 * 1024 * 1024,
8491 costs: ProjectionCostEstimates::default(),
8492 rollup: None,
8493 })))
8494 }
8495
8496 let first = slot("/first");
8497 let second = slot("/second");
8498 let third = slot("/third");
8499 let mut fleet = ProjectionFleet::default();
8500 fleet.admit(
8501 PathBuf::from("/first"),
8502 Arc::downgrade(&first),
8503 400 * 1024 * 1024,
8504 DEAD_CODE_SNAPSHOT_FLEET_BUDGET,
8505 );
8506 fleet.admit(
8507 PathBuf::from("/second"),
8508 Arc::downgrade(&second),
8509 400 * 1024 * 1024,
8510 DEAD_CODE_SNAPSHOT_FLEET_BUDGET,
8511 );
8512 fleet.touch(Path::new("/first"));
8513 fleet.admit(
8514 PathBuf::from("/third"),
8515 Arc::downgrade(&third),
8516 400 * 1024 * 1024,
8517 DEAD_CODE_SNAPSHOT_FLEET_BUDGET,
8518 );
8519
8520 assert!(first.lock().unwrap().is_some());
8521 assert!(second.lock().unwrap().is_none());
8522 assert!(third.lock().unwrap().is_some());
8523 assert_eq!(
8524 fleet.census(),
8525 DeadCodeSnapshotCensus {
8526 roots: 2,
8527 bytes: 800 * 1024 * 1024,
8528 drops: 1,
8529 }
8530 );
8531 }
8532
8533 #[test]
8534 fn projection_estimator_and_verdict_report_only_projection_work() {
8535 let phases = Tier2PhaseTimings {
8536 snapshot: Duration::from_millis(2_620),
8537 projection_cost: Duration::from_millis(187),
8538 rollup: Duration::from_millis(3_186),
8539 rollup_verdict: Some(crate::inspect::scanners::dead_code::RollupVerdict {
8540 kind: crate::inspect::scanners::dead_code::RollupKind::Incremental,
8541 reason: None,
8542 }),
8543 projection: Some(ProjectionVerdict {
8544 kind: ProjectionKind::Spliced,
8545 reason: None,
8546 journal_bytes: 2_397,
8547 changed_files: 46,
8548 }),
8549 ..Tier2PhaseTimings::default()
8550 };
8551
8552 assert_eq!(
8553 projection_estimator_duration(&phases),
8554 Duration::from_millis(187)
8555 );
8556 let line = phases.render(InspectCategory::DeadCode, Path::new("/root"), "test-key");
8557 assert!(line.contains("snapshot=2620ms projection_ms=187"));
8558 assert!(line.contains("rollup_ms=3186 rollup="));
8559 assert!(line.contains("projection=spliced journal_bytes=2397 changed_files=46"));
8560 }
8561
8562 #[test]
8563 fn rollup_cost_cannot_poison_projection_crossover() {
8564 let manager = InspectManager::new();
8565 let root = PathBuf::from("/projection-only-cost-root");
8566 let identity = CallgraphProjectionIdentity {
8567 project_root: root.clone(),
8568 generation: Some("generation".to_string()),
8569 legacy_sqlite_path: None,
8570 write_revision: 1,
8571 };
8572 manager.cache_callgraph_projection(
8573 identity.clone(),
8574 Arc::new(CallgraphSnapshot {
8575 generated_at: None,
8576 files: Vec::new(),
8577 exported_symbols: Vec::new(),
8578 outbound_calls: Vec::new(),
8579 entry_points: BTreeSet::new(),
8580 entry_point_symbols: BTreeMap::new(),
8581 }),
8582 );
8583 for (verdict, projection_ms, rollup_ms) in [
8584 (
8585 ProjectionVerdict {
8586 kind: ProjectionKind::Full,
8587 reason: Some("cold"),
8588 journal_bytes: 0,
8589 changed_files: 0,
8590 },
8591 100,
8592 1_000,
8593 ),
8594 (
8595 ProjectionVerdict {
8596 kind: ProjectionKind::Spliced,
8597 reason: None,
8598 journal_bytes: 200,
8599 changed_files: 4,
8600 },
8601 40,
8602 2_000,
8603 ),
8604 ] {
8605 let phases = Tier2PhaseTimings {
8606 projection_cost: Duration::from_millis(projection_ms),
8607 rollup: Duration::from_millis(rollup_ms),
8608 ..Tier2PhaseTimings::default()
8609 };
8610 manager.observe_callgraph_projection_cost(
8611 &root,
8612 verdict,
8613 projection_estimator_duration(&phases),
8614 );
8615 }
8616
8617 assert!(
8618 !manager
8619 .callgraph_projection_costs(&identity)
8620 .splice_is_costlier(5),
8621 "rollup work must not force a full snapshot projection"
8622 );
8623 }
8624
8625 #[test]
8626 fn every_tier2_phases_line_path_renders_projection_key() {
8627 for (category, reason) in [
8628 (InspectCategory::DeadCode, "no_callgraph"),
8629 (InspectCategory::DeadCode, "aggregate_reused"),
8630 (InspectCategory::DeadCode, "provided_snapshot"),
8631 (InspectCategory::UnusedExports, "not_required"),
8632 (InspectCategory::Duplicates, "not_required"),
8633 (InspectCategory::Cycles, "not_required"),
8634 (InspectCategory::Complexity, "not_required"),
8635 ] {
8636 let phases = Tier2PhaseTimings {
8637 projection_skip_reason: Some(reason),
8638 ..Tier2PhaseTimings::default()
8639 };
8640 let line = phases.render(category, Path::new("/root"), "test-key");
8641 assert!(
8642 line.contains(&format!(" projection=none reason={reason} ")),
8643 "{category} phases line omitted its projection verdict: {line}"
8644 );
8645 assert!(line.contains(" journal_bytes=0 changed_files=0 "));
8646 }
8647 }
8648
8649 #[test]
8650 fn perf_tier2_phases_line_renders_each_projection_verdict() {
8651 assert_eq!(
8652 render_rollup_suffix(crate::inspect::scanners::dead_code::RollupVerdict {
8653 kind: crate::inspect::scanners::dead_code::RollupKind::Incremental,
8654 reason: None,
8655 }),
8656 " rollup=incremental"
8657 );
8658 assert_eq!(
8659 render_rollup_suffix(crate::inspect::scanners::dead_code::RollupVerdict {
8660 kind: crate::inspect::scanners::dead_code::RollupKind::Full,
8661 reason: Some("journal_gap"),
8662 }),
8663 " rollup=full reason=journal_gap"
8664 );
8665
8666 let spliced = render_projection_suffix(ProjectionVerdict {
8667 kind: ProjectionKind::Spliced,
8668 reason: None,
8669 journal_bytes: 4096,
8670 changed_files: 3,
8671 });
8672 assert_eq!(
8673 spliced, " projection=spliced journal_bytes=4096 changed_files=3",
8674 "a spliced verdict must omit the reason field"
8675 );
8676
8677 let cold = render_projection_suffix(ProjectionVerdict {
8678 kind: ProjectionKind::Full,
8679 reason: Some("cold"),
8680 journal_bytes: 0,
8681 changed_files: 0,
8682 });
8683 assert_eq!(
8684 cold,
8685 " projection=full reason=cold journal_bytes=0 changed_files=0"
8686 );
8687
8688 let gap = render_projection_suffix(ProjectionVerdict {
8689 kind: ProjectionKind::Full,
8690 reason: Some("journal_gap"),
8691 journal_bytes: 0,
8692 changed_files: 0,
8693 });
8694 assert_eq!(
8695 gap,
8696 " projection=full reason=journal_gap journal_bytes=0 changed_files=0"
8697 );
8698
8699 let costlier = render_projection_suffix(ProjectionVerdict {
8700 kind: ProjectionKind::Full,
8701 reason: Some("splice_costlier"),
8702 journal_bytes: 261_465,
8703 changed_files: 1_486,
8704 });
8705 assert_eq!(
8706 costlier,
8707 " projection=full reason=splice_costlier journal_bytes=261465 changed_files=1486"
8708 );
8709
8710 let reused = render_projection_suffix(ProjectionVerdict {
8711 kind: ProjectionKind::Reused,
8712 reason: None,
8713 journal_bytes: 0,
8714 changed_files: 0,
8715 });
8716 assert_eq!(
8717 reused, " projection=reused journal_bytes=0 changed_files=0",
8718 "a cache hit is not a full projection and must not read as one"
8719 );
8720 }
8721
8722 #[test]
8723 fn spill_backed_171_file_delta_splices_where_old_bound_was_full() {
8724 let dir = tempfile::tempdir().expect("tempdir");
8725 write_projection_fixture(dir.path());
8726 let root = canonical_root(dir.path());
8727 let store =
8728 CallGraphStore::open(root.join(".store-spill"), root.clone()).expect("open store");
8729 store
8730 .cold_build(&project_files(&root))
8731 .expect("cold build fixture");
8732 let (revision, previous) = project_dead_code_snapshot_with_revision(store.sqlite_path())
8733 .expect("initial projection");
8734 let revision = revision.expect("new stores write a projection revision");
8735 let next = revision + 1;
8736 let callers = (0..171)
8737 .map(|index| format!("src/{index:03}-{}.ts", "x".repeat(1_600)))
8738 .collect::<BTreeSet<_>>();
8739 let payload = serde_json::to_string(&(next, &callers)).expect("serialize caller batch");
8740 assert!(
8741 payload.len() > MAX_DELTA_BYTES,
8742 "fixture must exceed the former absolute journal bound"
8743 );
8744
8745 let conn = rusqlite::Connection::open(store.sqlite_path()).expect("open write conn");
8746 conn.execute(
8747 "INSERT INTO meta(k, v) VALUES('projection_write_revision', '1')
8748 ON CONFLICT(k) DO UPDATE SET v = CAST(v AS INTEGER) + 1",
8749 [],
8750 )
8751 .expect("advance write revision");
8752 let journal_key = format!("projection_delta_{}", next % 64);
8753 conn.execute(
8754 "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
8755 rusqlite::params![journal_key, format!("oversize:{next}")],
8756 )
8757 .expect("plant legacy oversize marker");
8758
8759 let (_, _, old_verdict) = crate::callgraph_store::project_dead_code_snapshot_incremental(
8760 store.sqlite_path(),
8761 Some((revision, &previous)),
8762 )
8763 .expect("project legacy oversize marker");
8764 assert_eq!(old_verdict.kind, ProjectionKind::Full);
8765 assert_eq!(old_verdict.reason, Some("journal_gap"));
8766 assert_eq!(
8767 render_projection_suffix(old_verdict),
8768 " projection=full reason=journal_gap journal_bytes=0 changed_files=0"
8769 );
8770
8771 conn.execute_batch(
8772 "CREATE TABLE projection_delta_spill (
8773 revision INTEGER PRIMARY KEY,
8774 payload TEXT NOT NULL
8775 )",
8776 )
8777 .expect("create spill table");
8778 conn.execute(
8779 "INSERT INTO projection_delta_spill(revision, payload) VALUES(?1, ?2)",
8780 rusqlite::params![next, payload],
8781 )
8782 .expect("store spill payload");
8783 conn.execute(
8784 "INSERT OR REPLACE INTO meta(k, v) VALUES(?1, ?2)",
8785 rusqlite::params![journal_key, format!("spill:{next}")],
8786 )
8787 .expect("replace legacy marker with spill marker");
8788 drop(conn);
8789
8790 let (_, _, verdict) = crate::callgraph_store::project_dead_code_snapshot_incremental(
8791 store.sqlite_path(),
8792 Some((revision, &previous)),
8793 )
8794 .expect("project spill-backed delta");
8795 assert_eq!(verdict.kind, ProjectionKind::Spliced);
8796 assert_eq!(verdict.reason, None);
8797 assert_eq!(verdict.changed_files, 171);
8798 assert_eq!(verdict.journal_bytes, payload.len() as u64);
8799 assert_eq!(
8800 render_projection_suffix(verdict),
8801 format!(
8802 " projection=spliced journal_bytes={} changed_files=171",
8803 payload.len()
8804 )
8805 );
8806 }
8807}