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