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};
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};
14#[cfg(test)]
15use super::job::normalize_path;
16use super::job::{
17 is_test_file, CallgraphSnapshot, FileContribution, InspectCategory, InspectJob, InspectResult,
18 InspectScanSuccess, InspectSnapshot, JobKey, JobOutcome, JobScope,
19};
20use super::oxc_engine::LivenessVerdict;
21use super::oxc_engine::{
22 analyze_file_facts, analyze_files_with_cache, normalize_input_path, AnalyzeOptions,
23 DynamicImportFact, ExportFact, FileFacts, FileId, ImportFact, OxcEngineResult, OxcFactsCache,
24 ReExportFact, FACTS_FORMAT_VERSION, OXC_PROVENANCE,
25};
26use crate::cache_freshness::{self, FileFreshness, FreshnessVerdict};
27use crate::callgraph_store::{project_dead_code_snapshot, CallGraphStore, CallGraphStoreError};
28use crate::cold_build_limiter;
29
30const DEFAULT_SOFT_DEADLINE: Duration = Duration::from_secs(1);
31
32type WaiterTx = Sender<JobOutcome>;
33
34#[derive(Clone)]
35struct Waiter {
36 tx: WaiterTx,
37}
38
39struct CachedContributionFreshness {
40 file_path: PathBuf,
41 freshness: FileFreshness,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Hash)]
45struct InspectCacheIdentity {
46 sqlite_path: PathBuf,
47 project_root: PathBuf,
48}
49
50#[derive(Debug, Clone)]
51pub struct Tier2RunSubmissionError {
52 pub category: InspectCategory,
53 pub message: String,
54}
55
56#[derive(Debug, Clone, Default)]
57pub struct Tier2RunSubmission {
58 pub queued_categories: Vec<InspectCategory>,
59 pub newly_queued_categories: Vec<InspectCategory>,
60 pub deferred_categories: Vec<InspectCategory>,
61 pub errors: Vec<Tier2RunSubmissionError>,
62}
63
64impl Tier2RunSubmission {
65 pub fn has_new_work(&self) -> bool {
66 !self.newly_queued_categories.is_empty()
67 }
68}
69
70#[derive(Debug, Clone)]
71pub struct DirectTier2RunOutcome {
72 pub outcome: JobOutcome,
73 pub force_paths_completed: bool,
74}
75
76#[derive(Debug, Clone)]
77struct Tier2ReuseOptions {
78 force_rescan_paths: BTreeSet<PathBuf>,
79 allow_callgraph_cold_build: bool,
80}
81
82impl Tier2ReuseOptions {
83 fn direct(paths: Vec<PathBuf>) -> Self {
84 Self {
85 force_rescan_paths: paths.into_iter().collect(),
86 allow_callgraph_cold_build: false,
87 }
88 }
89
90 fn has_force_paths(&self) -> bool {
91 !self.force_rescan_paths.is_empty()
92 }
93}
94
95impl Default for Tier2ReuseOptions {
96 fn default() -> Self {
97 Self {
98 force_rescan_paths: BTreeSet::new(),
99 allow_callgraph_cold_build: true,
100 }
101 }
102}
103
104fn cached_tier2_aggregate_usable(
105 category: InspectCategory,
106 options: &Tier2ReuseOptions,
107 aggregate: &Value,
108) -> bool {
109 if category == InspectCategory::DeadCode
110 && options.allow_callgraph_cold_build
111 && aggregate
112 .get("callgraph_available")
113 .and_then(Value::as_bool)
114 == Some(false)
115 {
116 return false;
117 }
118 true
119}
120
121pub struct InspectManager {
122 request_tx: Sender<InspectJob>,
123 result_rx: Receiver<InspectResult>,
124 #[allow(dead_code)]
125 pool: Arc<rayon::ThreadPool>,
126 in_flight: Mutex<HashMap<JobKey, Vec<Waiter>>>,
127 in_flight_changed: Condvar,
128 caches: Mutex<HashMap<InspectCacheIdentity, Arc<InspectCache>>>,
129 oxc_facts_cache: Mutex<OxcFactsCache>,
130 soft_deadline: Duration,
131 next_job_id: AtomicU64,
132 heavy_root_work_allowed: Arc<AtomicBool>,
133 automatic_tier2_refresh_allowed: AtomicBool,
134 automatic_tier2_skip_logged: AtomicBool,
135 automatic_tier2_schedule_count: AtomicU64,
136 reuse_completions: AtomicU64,
141 reuse_starts: AtomicU64,
144}
145
146impl InspectManager {
147 pub fn new() -> Self {
148 Self::with_heavy_root_work_gate(Arc::new(AtomicBool::new(true)))
149 }
150
151 pub fn with_heavy_root_work_gate(heavy_root_work_allowed: Arc<AtomicBool>) -> Self {
152 Self::with_worker_and_gate(
153 default_worker(),
154 DEFAULT_SOFT_DEADLINE,
155 heavy_root_work_allowed,
156 )
157 }
158
159 #[doc(hidden)]
160 pub fn with_worker(worker: InspectWorker, soft_deadline: Duration) -> Self {
161 Self::with_worker_and_gate(worker, soft_deadline, Arc::new(AtomicBool::new(true)))
162 }
163
164 #[doc(hidden)]
165 pub fn with_worker_and_gate(
166 worker: InspectWorker,
167 soft_deadline: Duration,
168 heavy_root_work_allowed: Arc<AtomicBool>,
169 ) -> Self {
170 let handles = start_dispatch_loop(worker);
171 Self {
172 request_tx: handles.request_tx,
173 result_rx: handles.result_rx,
174 pool: handles.pool,
175 in_flight: Mutex::new(HashMap::new()),
176 in_flight_changed: Condvar::new(),
177 caches: Mutex::new(HashMap::new()),
178 oxc_facts_cache: Mutex::new(OxcFactsCache::new()),
179 soft_deadline,
180 next_job_id: AtomicU64::new(1),
181 heavy_root_work_allowed,
182 automatic_tier2_refresh_allowed: AtomicBool::new(true),
183 automatic_tier2_skip_logged: AtomicBool::new(false),
184 automatic_tier2_schedule_count: AtomicU64::new(0),
185 reuse_completions: AtomicU64::new(0),
186 reuse_starts: AtomicU64::new(0),
187 }
188 }
189
190 fn heavy_root_work_allowed(&self) -> bool {
191 self.heavy_root_work_allowed.load(Ordering::SeqCst)
192 }
193
194 pub fn set_automatic_tier2_refresh_allowed(&self, allowed: bool) {
195 self.automatic_tier2_refresh_allowed
196 .store(allowed, Ordering::SeqCst);
197 self.automatic_tier2_skip_logged
198 .store(false, Ordering::SeqCst);
199 }
200
201 pub fn automatic_tier2_refresh_enabled(&self) -> bool {
202 self.automatic_tier2_refresh_allowed.load(Ordering::SeqCst)
203 }
204
205 pub fn automatic_tier2_refresh_allowed(&self) -> bool {
206 let allowed = self.automatic_tier2_refresh_enabled();
207 if !allowed
208 && !self
209 .automatic_tier2_skip_logged
210 .swap(true, Ordering::SeqCst)
211 {
212 crate::slog_debug!("automatic Tier-2 scan scheduling skipped for linked worktree root");
213 }
214 allowed
215 }
216
217 #[doc(hidden)]
218 pub fn automatic_tier2_schedule_count_for_test(&self) -> u64 {
219 self.automatic_tier2_schedule_count.load(Ordering::SeqCst)
220 }
221
222 fn category_needs_heavy_root_work(category: InspectCategory) -> bool {
223 category != InspectCategory::Diagnostics
224 }
225
226 fn heavy_root_work_block_message(category: InspectCategory) -> String {
227 format!(
228 "inspect category '{category}' is unavailable because heavy project-wide work is disabled for this root"
229 )
230 }
231
232 pub fn submit_category(
233 &self,
234 snapshot: InspectSnapshot,
235 category: InspectCategory,
236 caller_scope: JobScope,
237 ) -> JobOutcome {
238 self.submit_category_with_callgraph(snapshot, category, caller_scope, None)
239 }
240
241 pub fn submit_category_with_callgraph(
242 &self,
243 snapshot: InspectSnapshot,
244 category: InspectCategory,
245 caller_scope: JobScope,
246 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
247 ) -> JobOutcome {
248 if !category.is_active() {
249 return JobOutcome::Failed {
250 message: format!("inspect category '{category}' is disabled in v0.33"),
251 };
252 }
253 if Self::category_needs_heavy_root_work(category) && !self.heavy_root_work_allowed() {
254 return JobOutcome::Failed {
255 message: Self::heavy_root_work_block_message(category),
256 };
257 }
258
259 let cache = match self.cache_for_snapshot(&snapshot) {
260 Ok(cache) => cache,
261 Err(message) => return JobOutcome::Failed { message },
262 };
263 let key = JobKey::for_category_scope(category, &caller_scope);
264 let (waiter_tx, waiter_rx) = bounded(1);
265
266 let wait_snapshot = snapshot.clone();
267 match self.enqueue_with_waiter(
268 snapshot,
269 category,
270 caller_scope.clone(),
271 key.clone(),
272 waiter_tx,
273 callgraph_snapshot,
274 ) {
275 Ok(()) => self.wait_for_outcome(key, caller_scope, cache, waiter_rx, wait_snapshot),
276 Err(message) => JobOutcome::Failed { message },
277 }
278 }
279
280 pub fn submit_background(
281 &self,
282 snapshot: InspectSnapshot,
283 category: InspectCategory,
284 caller_scope: JobScope,
285 ) -> Result<JobKey, String> {
286 self.submit_background_with_callgraph(snapshot, category, caller_scope, None)
287 }
288
289 pub fn submit_background_with_callgraph(
290 &self,
291 snapshot: InspectSnapshot,
292 category: InspectCategory,
293 caller_scope: JobScope,
294 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
295 ) -> Result<JobKey, String> {
296 if !category.is_active() {
297 return Err(format!(
298 "inspect category '{category}' is disabled in v0.33"
299 ));
300 }
301 if Self::category_needs_heavy_root_work(category) && !self.heavy_root_work_allowed() {
302 return Err(Self::heavy_root_work_block_message(category));
303 }
304 let key = JobKey::for_category_scope(category, &caller_scope);
305 self.enqueue_without_waiter(
306 snapshot,
307 category,
308 caller_scope,
309 key.clone(),
310 callgraph_snapshot,
311 )?;
312 Ok(key)
313 }
314
315 pub fn submit_tier2_run_with_reuse_background(
316 self: &Arc<Self>,
317 snapshot: InspectSnapshot,
318 category: InspectCategory,
319 ) -> Result<Option<JobKey>, String> {
320 if !category.is_active() {
321 return Err(format!(
322 "inspect category '{category}' is disabled in v0.33"
323 ));
324 }
325 if !category.is_tier2() {
326 return Err(format!(
327 "inspect category '{category}' is not a Tier 2 category"
328 ));
329 }
330 if !self.heavy_root_work_allowed() {
331 return Err(Self::heavy_root_work_block_message(category));
332 }
333 if !self.automatic_tier2_refresh_allowed() {
334 return Ok(None);
335 }
336 self.automatic_tier2_schedule_count
337 .fetch_add(1, Ordering::SeqCst);
338
339 let job = self.tier2_reuse_job(snapshot, category, None);
340 let key = job.key.clone();
341 let mut in_flight = self
342 .in_flight
343 .lock()
344 .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
345 if in_flight.contains_key(&key) {
346 return Ok(Some(key));
347 }
348 let Some(permit) = cold_build_limiter::try_acquire() else {
349 return Err(format!(
350 "cold build concurrency limit ({}) reached; retrying later",
351 cold_build_limiter::limit()
352 ));
353 };
354 in_flight.insert(key.clone(), Vec::new());
355 drop(in_flight);
356
357 let manager = Arc::clone(self);
358 let pool = Arc::clone(&self.pool);
359 pool.spawn(move || {
360 let _permit = permit;
361 let result = manager.tier2_run_with_reuse_job_result(job);
362 manager.route_tier2_reuse_completion(result);
363 });
364
365 Ok(Some(key))
366 }
367
368 pub fn submit_tier2_run_with_reuse_serial_background(
369 self: &Arc<Self>,
370 snapshot: InspectSnapshot,
371 categories: Vec<InspectCategory>,
372 ) -> Tier2RunSubmission {
373 let mut submission = Tier2RunSubmission::default();
374 let mut requested = Vec::new();
375
376 for category in categories {
377 if !category.is_active() {
378 submission.errors.push(Tier2RunSubmissionError {
379 category,
380 message: format!("inspect category '{category}' is disabled in v0.33"),
381 });
382 continue;
383 }
384 if !category.is_tier2() {
385 submission.errors.push(Tier2RunSubmissionError {
386 category,
387 message: format!("inspect category '{category}' is not a Tier 2 category"),
388 });
389 continue;
390 }
391 requested.push(category);
392 }
393
394 if requested.is_empty() {
395 return submission;
396 }
397 if !self.heavy_root_work_allowed() {
398 for category in requested {
399 submission.errors.push(Tier2RunSubmissionError {
400 category,
401 message: Self::heavy_root_work_block_message(category),
402 });
403 }
404 return submission;
405 }
406 if !self.automatic_tier2_refresh_allowed() {
407 return submission;
408 }
409 self.automatic_tier2_schedule_count
410 .fetch_add(requested.len() as u64, Ordering::SeqCst);
411
412 let mut in_flight = match self.in_flight.lock() {
413 Ok(in_flight) => in_flight,
414 Err(_) => {
415 for category in requested {
416 submission.errors.push(Tier2RunSubmissionError {
417 category,
418 message: "inspect in-flight map lock poisoned".to_string(),
419 });
420 }
421 return submission;
422 }
423 };
424
425 for category in requested {
426 let key = JobKey::for_project_category(category);
427 submission.queued_categories.push(category);
428 if in_flight.contains_key(&key) {
429 continue;
430 }
431 in_flight.insert(key, Vec::new());
432 submission.newly_queued_categories.push(category);
433 }
434 drop(in_flight);
435
436 if submission.newly_queued_categories.is_empty() {
437 return submission;
438 }
439
440 let Some(permit) = cold_build_limiter::try_acquire() else {
441 let deferred = submission.newly_queued_categories.clone();
442 if let Ok(mut in_flight) = self.in_flight.lock() {
443 for category in &deferred {
444 in_flight.remove(&JobKey::for_project_category(*category));
445 }
446 }
447 submission
448 .queued_categories
449 .retain(|category| !deferred.contains(category));
450 submission.deferred_categories = deferred;
451 submission.newly_queued_categories.clear();
452 return submission;
453 };
454
455 let categories_for_worker = submission.newly_queued_categories.clone();
456 let manager = Arc::clone(self);
457 let pool = Arc::clone(&self.pool);
458 pool.spawn(move || {
459 let _permit = permit;
460 for category in categories_for_worker {
461 let result = manager.tier2_run_with_reuse_result(snapshot.clone(), category, None);
462 manager.route_tier2_reuse_completion(result);
463 }
464 });
465
466 submission
467 }
468
469 pub fn tier2_any_in_flight(&self) -> bool {
470 self.in_flight
471 .lock()
472 .map(|in_flight| in_flight.keys().any(|key| key.category.is_tier2()))
473 .unwrap_or(false)
474 }
475
476 pub fn evict_idle_caches(&self) {
481 if let Ok(mut caches) = self.caches.lock() {
482 caches.clear();
483 }
484 if let Ok(mut facts) = self.oxc_facts_cache.lock() {
485 *facts = OxcFactsCache::new();
486 }
487 }
488
489 pub fn estimated_memory(&self) -> crate::memory::MemoryEstimate {
493 let caches = match self.caches.try_lock() {
494 Ok(caches) => caches.values().cloned().collect::<Vec<_>>(),
495 Err(_) => return crate::memory::MemoryEstimate::busy(),
496 };
497 let facts_entries = match self.oxc_facts_cache.try_lock() {
498 Ok(facts) => facts.len(),
499 Err(_) => return crate::memory::MemoryEstimate::busy(),
500 };
501 let mut bytes = 0u64;
502 let mut memory_aggregates = 0u64;
503 for cache in &caches {
504 let estimate = cache.estimated_memory();
505 let Some(cache_bytes) = estimate.estimated_bytes else {
506 return crate::memory::MemoryEstimate::busy();
507 };
508 bytes = bytes.saturating_add(cache_bytes);
509 memory_aggregates = memory_aggregates.saturating_add(
510 estimate
511 .counts
512 .get("memory_aggregates")
513 .copied()
514 .unwrap_or(0),
515 );
516 }
517 crate::memory::MemoryEstimate::partial(bytes)
518 .count("open_generation_handles", caches.len())
519 .count("oxc_fact_entries", facts_entries)
520 .count_u64("memory_aggregates", memory_aggregates)
521 .gap("oxc_fact_bytes")
522 }
523
524 pub fn has_pending_completions(&self) -> bool {
527 !self.result_rx.is_empty()
528 }
529
530 pub fn drain_completions(&self) -> usize {
531 let mut drained = 0usize;
532 while let Ok(result) = self.result_rx.try_recv() {
533 self.route_completion(result);
534 drained += 1;
535 }
536 drained
537 }
538
539 pub fn discard_completions(&self) -> usize {
540 let mut discarded = 0usize;
541 while self.result_rx.try_recv().is_ok() {
542 discarded += 1;
543 }
544 discarded
545 }
546
547 pub fn cache_for_snapshot(
548 &self,
549 snapshot: &InspectSnapshot,
550 ) -> Result<Arc<InspectCache>, String> {
551 self.cache_for_paths(snapshot.inspect_dir.clone(), snapshot.project_root.clone())
552 }
553
554 pub fn latest_tier2_counts(
562 &self,
563 inspect_dir: PathBuf,
564 project_root: PathBuf,
565 ) -> (Option<usize>, Option<usize>, Option<usize>) {
566 let Ok(cache) = self.cache_for_paths(inspect_dir, project_root) else {
567 return (None, None, None);
568 };
569 let count_of = |category: InspectCategory| -> Option<usize> {
570 cache
571 .latest_aggregate_any_hash(category)
572 .ok()
573 .flatten()
574 .and_then(|payload| {
575 if category == InspectCategory::DeadCode
576 && payload
577 .get("callgraph_available")
578 .and_then(serde_json::Value::as_bool)
579 == Some(false)
580 {
581 return None;
582 }
583 payload
584 .get("count")
585 .and_then(serde_json::Value::as_u64)
586 .map(|count| count as usize)
587 })
588 };
589 (
590 count_of(InspectCategory::DeadCode),
591 count_of(InspectCategory::UnusedExports),
592 count_of(InspectCategory::Duplicates),
593 )
594 }
595
596 pub fn cache_for_paths(
597 &self,
598 inspect_dir: PathBuf,
599 project_root: PathBuf,
600 ) -> Result<Arc<InspectCache>, String> {
601 let project_key = crate::path_identity::project_scope_key(&project_root);
602 let inspect_dir = if inspect_dir
603 .file_name()
604 .and_then(|name| name.to_str())
605 .is_some_and(|name| name == project_key)
606 {
607 inspect_dir
608 } else {
609 inspect_dir.join(&project_key)
610 };
611 let identity = InspectCacheIdentity {
612 sqlite_path: inspect_dir.join(format!("{project_key}.current")),
613 project_root: project_root.clone(),
614 };
615 let mut caches = self
616 .caches
617 .lock()
618 .map_err(|_| "inspect manager cache map lock poisoned".to_string())?;
619 if let Some(cache) = caches.get(&identity) {
620 return Ok(Arc::clone(cache));
621 }
622 let cache = Arc::new(
623 InspectCache::open(inspect_dir, project_root)
624 .map_err(|error| format!("failed to open inspect cache: {error}"))?,
625 );
626 caches.insert(identity, Arc::clone(&cache));
627 Ok(cache)
628 }
629
630 fn oxc_result_for_scan(
631 &self,
632 job: &InspectJob,
633 files: &[PathBuf],
634 force_reparse_files: &[PathBuf],
635 ) -> Result<Option<OxcEngineResult>, String> {
636 if !category_uses_oxc(job.category) {
637 return Ok(None);
638 }
639 if job.category == InspectCategory::DeadCode && job.callgraph_snapshot.is_none() {
640 return Ok(None);
641 }
642
643 let public_api_entries =
644 crate::inspect::entry_points::resolve_entry_points(&job.project_root);
645 let entry_points = if job.category == InspectCategory::DeadCode {
646 job.callgraph_snapshot
647 .as_ref()
648 .map(|snapshot| snapshot.entry_points.iter().cloned().collect::<Vec<_>>())
649 .unwrap_or_default()
650 } else {
651 Vec::new()
652 };
653 let options = AnalyzeOptions {
654 entry_points,
655 public_api_files: public_api_entries.public_api_files(),
656 executable_root_exports: public_api_entries.executable_root_exports(),
657 force_reparse_files: force_reparse_files.to_vec(),
658 entry_reachability: job.category == InspectCategory::DeadCode,
659 };
660
661 let mut cache = self
662 .oxc_facts_cache
663 .lock()
664 .map_err(|_| "inspect oxc facts cache lock poisoned".to_string())?;
665 analyze_files_with_cache(&job.project_root, files, options, &mut cache)
666 .map(Some)
667 .map_err(|message| format!("oxc analyze failed: {message}"))
668 }
669
670 pub fn tier2_run_with_reuse(
671 &self,
672 snapshot: InspectSnapshot,
673 category: InspectCategory,
674 caller_scope: JobScope,
675 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
676 ) -> JobOutcome {
677 if let Err(outcome) = validate_tier2_read_category(category) {
678 return outcome;
679 }
680 if !self.heavy_root_work_allowed() {
681 return JobOutcome::Failed {
682 message: Self::heavy_root_work_block_message(category),
683 };
684 }
685 let cache = match self.cache_for_snapshot(&snapshot) {
686 Ok(cache) => cache,
687 Err(message) => return JobOutcome::Failed { message },
688 };
689 let job = self.tier2_reuse_job(snapshot.clone(), category, callgraph_snapshot);
690 let key = job.key.clone();
691 let (waiter_tx, waiter_rx) = bounded(1);
692 let claimed = match self.register_tier2_reuse_waiter(&key, waiter_tx) {
693 Ok(claimed) => claimed,
694 Err(message) => return JobOutcome::Failed { message },
695 };
696
697 if claimed {
698 let result = self
699 .tier2_run_with_reuse_job_result_with_options(job, Tier2ReuseOptions::default());
700 self.route_tier2_reuse_completion(result);
701 }
702
703 match waiter_rx.recv() {
704 Ok(outcome) => filter_outcome_for_scope_with_contributions(
705 outcome,
706 &snapshot,
707 category,
708 cache.as_ref(),
709 &caller_scope,
710 ),
711 Err(_) => JobOutcome::Pending { in_flight: true },
712 }
713 }
714
715 pub fn tier2_run_with_reuse_direct(
716 self: &Arc<Self>,
717 snapshot: InspectSnapshot,
718 category: InspectCategory,
719 caller_scope: JobScope,
720 deadline: Instant,
721 force_rescan_paths: Vec<PathBuf>,
722 ) -> DirectTier2RunOutcome {
723 if let Err(outcome) = validate_tier2_read_category(category) {
724 return DirectTier2RunOutcome {
725 outcome,
726 force_paths_completed: false,
727 };
728 }
729 if !self.heavy_root_work_allowed() {
730 return DirectTier2RunOutcome {
731 outcome: JobOutcome::Failed {
732 message: Self::heavy_root_work_block_message(category),
733 },
734 force_paths_completed: false,
735 };
736 }
737 let cache = match self.cache_for_snapshot(&snapshot) {
738 Ok(cache) => cache,
739 Err(message) => {
740 return DirectTier2RunOutcome {
741 outcome: JobOutcome::Failed { message },
742 force_paths_completed: false,
743 }
744 }
745 };
746
747 let must_run_forced_followup = !force_rescan_paths.is_empty();
748 loop {
749 let options = if must_run_forced_followup {
750 Tier2ReuseOptions::direct(force_rescan_paths.clone())
751 } else {
752 Tier2ReuseOptions::direct(Vec::new())
753 };
754 let job = self.tier2_reuse_job(snapshot.clone(), category, None);
755 let key = job.key.clone();
756 let (waiter_tx, waiter_rx) = bounded(1);
757 let claimed = match self.register_tier2_reuse_waiter(&key, waiter_tx) {
758 Ok(claimed) => claimed,
759 Err(message) => {
760 return DirectTier2RunOutcome {
761 outcome: JobOutcome::Failed { message },
762 force_paths_completed: false,
763 }
764 }
765 };
766 if claimed {
767 self.spawn_tier2_reuse_job(job, options);
768 }
769
770 let completed_force_run = claimed && must_run_forced_followup;
771 let outcome = self.wait_for_tier2_reuse_until(
772 &key,
773 &caller_scope,
774 cache.as_ref(),
775 waiter_rx,
776 &snapshot,
777 deadline,
778 );
779
780 delay_direct_force_followup_deadline_check_for_debug(&snapshot.project_root);
781 if must_run_forced_followup
782 && !claimed
783 && !matches!(outcome, JobOutcome::Pending { .. })
784 {
785 if Instant::now() < deadline {
793 continue;
794 }
795 return DirectTier2RunOutcome {
796 outcome: JobOutcome::Pending { in_flight: true },
797 force_paths_completed: false,
798 };
799 }
800
801 return DirectTier2RunOutcome {
802 outcome,
803 force_paths_completed: completed_force_run,
804 };
805 }
806 }
807
808 fn register_tier2_reuse_waiter(
809 &self,
810 key: &JobKey,
811 waiter_tx: WaiterTx,
812 ) -> Result<bool, String> {
813 let mut in_flight = self
814 .in_flight
815 .lock()
816 .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
817 if let Some(waiters) = in_flight.get_mut(key) {
818 waiters.push(Waiter { tx: waiter_tx });
819 self.in_flight_changed.notify_all();
820 return Ok(false);
821 }
822
823 in_flight.insert(key.clone(), vec![Waiter { tx: waiter_tx }]);
824 Ok(true)
825 }
826
827 fn wait_for_tier2_reuse_waiter_for_debug(&self, job: &InspectJob) {
828 #[cfg(not(debug_assertions))]
829 let _ = job;
830 #[cfg(debug_assertions)]
831 {
832 const WAIT_ROOT_ENV: &str = "AFT_TEST_TIER2_REUSE_WAIT_FOR_WAITER_ROOT";
833 if std::env::var_os(WAIT_ROOT_ENV).is_none()
834 || !env_project_root_matches(WAIT_ROOT_ENV, &job.project_root)
835 {
836 return;
837 }
838
839 let deadline = Instant::now() + Duration::from_secs(30);
843 let mut in_flight = self
844 .in_flight
845 .lock()
846 .unwrap_or_else(std::sync::PoisonError::into_inner);
847 loop {
848 match in_flight.get(&job.key) {
849 Some(waiters) if waiters.is_empty() => {}
850 _ => return,
851 }
852 let now = Instant::now();
853 if now >= deadline {
854 return;
855 }
856 let (next, wait_result) = self
857 .in_flight_changed
858 .wait_timeout(in_flight, deadline.saturating_duration_since(now))
859 .unwrap_or_else(std::sync::PoisonError::into_inner);
860 in_flight = next;
861 if wait_result.timed_out() {
862 return;
863 }
864 }
865 }
866 }
867
868 fn spawn_tier2_reuse_job(self: &Arc<Self>, job: InspectJob, options: Tier2ReuseOptions) {
869 let manager = Arc::clone(self);
870 let pool = Arc::clone(&self.pool);
871 pool.spawn(move || {
872 let result = manager.tier2_run_with_reuse_job_result_catching(job, options);
873 manager.route_tier2_reuse_completion(result);
874 });
875 }
876
877 fn wait_for_tier2_reuse_until(
878 &self,
879 key: &JobKey,
880 caller_scope: &JobScope,
881 cache: &(impl InspectCacheRead + ?Sized),
882 waiter_rx: Receiver<JobOutcome>,
883 snapshot: &InspectSnapshot,
884 deadline: Instant,
885 ) -> JobOutcome {
886 let Some(remaining) = deadline.checked_duration_since(Instant::now()) else {
887 return JobOutcome::Pending { in_flight: true };
888 };
889 if remaining.is_zero() {
890 return JobOutcome::Pending { in_flight: true };
891 }
892
893 match waiter_rx.recv_timeout(remaining) {
894 Ok(outcome) => filter_outcome_for_scope_with_contributions(
895 outcome,
896 snapshot,
897 key.category,
898 cache,
899 caller_scope,
900 ),
901 Err(crossbeam_channel::RecvTimeoutError::Timeout) => {
902 JobOutcome::Pending { in_flight: true }
903 }
904 Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
905 JobOutcome::Pending { in_flight: true }
906 }
907 }
908 }
909
910 pub fn tier2_read_cached(
917 &self,
918 snapshot: InspectSnapshot,
919 category: InspectCategory,
920 caller_scope: JobScope,
921 ) -> JobOutcome {
922 if let Err(outcome) = validate_tier2_read_category(category) {
923 return outcome;
924 }
925 if !self.heavy_root_work_allowed() {
926 return JobOutcome::Failed {
927 message: Self::heavy_root_work_block_message(category),
928 };
929 }
930 let cache = match self.cache_for_snapshot(&snapshot) {
931 Ok(cache) => cache,
932 Err(message) => return JobOutcome::Failed { message },
933 };
934 self.tier2_read_cached_from_cache(&snapshot, category, &caller_scope, cache.as_ref())
935 }
936
937 pub fn tier2_read_cached_readonly(
938 &self,
939 snapshot: InspectSnapshot,
940 category: InspectCategory,
941 caller_scope: JobScope,
942 ) -> JobOutcome {
943 if let Err(outcome) = validate_tier2_read_category(category) {
944 return outcome;
945 }
946 if !self.heavy_root_work_allowed() {
947 return JobOutcome::Failed {
948 message: Self::heavy_root_work_block_message(category),
949 };
950 }
951 let key = JobKey::for_project_category(category);
952 let in_flight = self
953 .in_flight
954 .lock()
955 .map(|guard| guard.contains_key(&key))
956 .unwrap_or(false);
957 let cache = match InspectCache::open_readonly(
958 snapshot.inspect_dir.clone(),
959 snapshot.project_root.clone(),
960 ) {
961 Ok(Some(cache)) => cache,
962 Ok(None) => return JobOutcome::Pending { in_flight },
963 Err(error) => {
964 return JobOutcome::Failed {
965 message: error.to_string(),
966 }
967 }
968 };
969 self.tier2_read_cached_from_cache(&snapshot, category, &caller_scope, &cache)
970 }
971
972 fn tier2_read_cached_from_cache(
973 &self,
974 snapshot: &InspectSnapshot,
975 category: InspectCategory,
976 caller_scope: &JobScope,
977 cache: &(impl InspectCacheRead + ?Sized),
978 ) -> JobOutcome {
979 let key = JobKey::for_project_category(category);
980 let in_flight = self
981 .in_flight
982 .lock()
983 .map(|guard| guard.contains_key(&key))
984 .unwrap_or(false);
985 match cache.get_aggregated_for_config(&key, snapshot.config.as_ref()) {
986 Ok(Some(payload)) => {
987 match self.tier2_cached_aggregate_is_fresh(snapshot, category, cache) {
988 Ok(true) => filter_outcome_for_scope_with_contributions(
989 JobOutcome::Fresh { payload },
990 snapshot,
991 category,
992 cache,
993 caller_scope,
994 ),
995 Ok(false) => filter_outcome_for_scope_with_contributions(
996 JobOutcome::Stale {
997 cached: Some(payload),
998 in_flight,
999 },
1000 snapshot,
1001 category,
1002 cache,
1003 caller_scope,
1004 ),
1005 Err(message) => JobOutcome::Failed { message },
1006 }
1007 }
1008 Ok(None) => match cache.latest_aggregate_any_hash(category) {
1009 Ok(Some(payload)) => filter_outcome_for_scope_with_contributions(
1010 JobOutcome::Stale {
1011 cached: Some(payload),
1012 in_flight,
1013 },
1014 snapshot,
1015 category,
1016 cache,
1017 caller_scope,
1018 ),
1019 Ok(None) => JobOutcome::Pending { in_flight },
1020 Err(error) => JobOutcome::Failed {
1021 message: error.to_string(),
1022 },
1023 },
1024 Err(error) => JobOutcome::Failed {
1025 message: error.to_string(),
1026 },
1027 }
1028 }
1029
1030 fn tier2_cached_aggregate_is_fresh(
1031 &self,
1032 snapshot: &InspectSnapshot,
1033 category: InspectCategory,
1034 cache: &(impl InspectCacheRead + ?Sized),
1035 ) -> Result<bool, String> {
1036 let cached_records = load_contribution_freshness(cache, category)?;
1037 let cached_relative = cached_records
1038 .iter()
1039 .map(freshness_record_relative_key)
1040 .collect::<BTreeSet<_>>();
1041
1042 for record in &cached_records {
1043 let absolute = if record.file_path.is_absolute() {
1044 record.file_path.clone()
1045 } else {
1046 snapshot.project_root.join(&record.file_path)
1047 };
1048 match verify_contribution_file(&absolute, &record.freshness) {
1049 ContributionFreshness::Fresh { .. } => {}
1050 ContributionFreshness::Stale | ContributionFreshness::Deleted => return Ok(false),
1051 }
1052 }
1053
1054 let project_scope = JobScope::for_project(snapshot.project_root.clone());
1062 let project_files = scope_files(&snapshot.project_root, &project_scope);
1063 let current_by_relative = current_project_files(&snapshot.project_root, &project_files);
1064
1065 Ok(current_by_relative.len() == cached_relative.len()
1066 && current_by_relative
1067 .keys()
1068 .all(|relative| cached_relative.contains(relative)))
1069 }
1070
1071 #[doc(hidden)]
1072 pub fn tier2_run_with_reuse_result(
1073 &self,
1074 snapshot: InspectSnapshot,
1075 category: InspectCategory,
1076 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
1077 ) -> InspectResult {
1078 let job = self.tier2_reuse_job(snapshot, category, callgraph_snapshot);
1079 self.tier2_run_with_reuse_job_result(job)
1080 }
1081
1082 fn tier2_run_with_reuse_job_result(&self, job: InspectJob) -> InspectResult {
1083 self.tier2_run_with_reuse_job_result_with_options(job, Tier2ReuseOptions::default())
1084 }
1085
1086 fn tier2_run_with_reuse_job_result_catching(
1087 &self,
1088 job: InspectJob,
1089 options: Tier2ReuseOptions,
1090 ) -> InspectResult {
1091 let started = Instant::now();
1092 match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1093 self.tier2_run_with_reuse_job_result_with_options(job.clone(), options)
1094 })) {
1095 Ok(result) => result,
1096 Err(_) => InspectResult::failed(
1097 &job,
1098 "tier2 reuse worker panicked before completion",
1099 started.elapsed(),
1100 ),
1101 }
1102 }
1103
1104 fn tier2_run_with_reuse_job_result_with_options(
1105 &self,
1106 mut job: InspectJob,
1107 mut options: Tier2ReuseOptions,
1108 ) -> InspectResult {
1109 let started = Instant::now();
1110 self.reuse_starts.fetch_add(1, Ordering::SeqCst);
1111 self.wait_for_tier2_reuse_waiter_for_debug(&job);
1112 panic_tier2_reuse_for_debug(&job);
1113 if !job.category.is_active() {
1114 let result = InspectResult::failed(
1115 &job,
1116 format!("inspect category '{}' is disabled in v0.33", job.category),
1117 started.elapsed(),
1118 );
1119 log_tier2_benchmark_category_end(&result);
1120 return result;
1121 }
1122 if !job.category.is_tier2() {
1123 let result = InspectResult::failed(
1124 &job,
1125 format!(
1126 "inspect category '{}' is not a Tier 2 category",
1127 job.category
1128 ),
1129 started.elapsed(),
1130 );
1131 log_tier2_benchmark_category_end(&result);
1132 return result;
1133 }
1134
1135 if !job.inspect_writer {
1136 let result = InspectResult::failed(
1137 &job,
1138 "inspect writer capability is unavailable for this read-only cache path",
1139 started.elapsed(),
1140 );
1141 log_tier2_benchmark_category_end(&result);
1142 return result;
1143 }
1144
1145 let project_scope = JobScope::for_project(job.project_root.clone());
1146 job.scope_files = scope_files(&job.project_root, &project_scope);
1147 log_tier2_benchmark_category_start(&job);
1148 let cache = match self.cache_for_paths(job.inspect_dir.clone(), job.project_root.clone()) {
1149 Ok(cache) => cache,
1150 Err(message) => {
1151 let result = InspectResult::failed(&job, message, started.elapsed());
1152 log_tier2_benchmark_category_end(&result);
1153 return result;
1154 }
1155 };
1156 delay_tier2_reuse_for_debug(&job.project_root);
1157 if options.has_force_paths() {
1158 if let Ok(cached) = load_contribution_freshness(cache.as_ref(), job.category) {
1159 let (remaining, downgraded) = downgrade_unchanged_forced_paths_with_freshness(
1160 &job.project_root,
1161 &cached,
1162 options.force_rescan_paths.iter().cloned().collect(),
1163 );
1164 options.force_rescan_paths = remaining.into_iter().collect();
1165 if downgraded > 0 {
1166 crate::slog_info!(
1167 "inspect: {} forced paths downgraded to cached (content unchanged)",
1168 downgraded
1169 );
1170 }
1171 }
1172 }
1173 if !options.has_force_paths() {
1174 if let Ok(Some(success)) =
1175 self.tier2_quick_reuse_success(&job, cache.as_ref(), &options)
1176 {
1177 let result = InspectResult::success(&job, success, started.elapsed());
1178 crate::slog_debug!(
1179 "perf tier2 category={} reuse=hit ms={}",
1180 job.category,
1181 started.elapsed().as_millis()
1182 );
1183 log_tier2_benchmark_category_end(&result);
1184 return result;
1185 }
1186 }
1187
1188 let result = match self.tier2_run_with_reuse_job(&job, &cache, &options) {
1189 Ok(success) => InspectResult::success(&job, success, started.elapsed()),
1190 Err(message) => InspectResult::failed(&job, message, started.elapsed()),
1191 };
1192 crate::slog_info!(
1196 "perf tier2 category={} reuse=miss ms={}",
1197 job.category,
1198 started.elapsed().as_millis()
1199 );
1200 log_tier2_benchmark_category_end(&result);
1201 result
1202 }
1203
1204 fn tier2_reuse_job(
1205 &self,
1206 snapshot: InspectSnapshot,
1207 category: InspectCategory,
1208 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
1209 ) -> InspectJob {
1210 InspectJob {
1211 job_id: self.next_job_id.fetch_add(1, Ordering::Relaxed),
1212 key: JobKey::for_project_category(category),
1213 category,
1214 scope_files: Vec::new(),
1215 project_root: snapshot.project_root,
1216 inspect_dir: snapshot.inspect_dir,
1217 config: snapshot.config,
1218 symbol_cache: snapshot.symbol_cache,
1219 inspect_writer: snapshot.inspect_writer,
1220 callgraph_writer: snapshot.callgraph_writer,
1221 callgraph_snapshot,
1222 }
1223 }
1224
1225 fn tier2_quick_reuse_success(
1226 &self,
1227 job: &InspectJob,
1228 cache: &InspectCache,
1229 options: &Tier2ReuseOptions,
1230 ) -> Result<Option<InspectScanSuccess>, String> {
1231 let cached_records = load_contribution_freshness(cache, job.category)?;
1232 let current_by_relative = current_project_files(&job.project_root, &job.scope_files);
1233 if cached_records.len() != current_by_relative.len() {
1234 return Ok(None);
1235 }
1236 for record in &cached_records {
1237 let relative = freshness_record_relative_key(record);
1238 let Some(current_file) = current_by_relative.get(&relative) else {
1239 return Ok(None);
1240 };
1241 match cache_freshness::metadata_matches(current_file, &record.freshness) {
1242 Ok(true) => {}
1243 Ok(false) => return Ok(None),
1244 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1245 Err(error) => {
1246 return Err(format!(
1247 "failed to stat {} for tier2 quick reuse: {error}",
1248 current_file.display()
1249 ));
1250 }
1251 }
1252 }
1253
1254 let contribution_set_hash = cache
1255 .contribution_set_hash_for_config(job.category, job.config.as_ref())
1256 .map_err(|error| error.to_string())?;
1257 let Some(aggregate) = cache
1258 .load_aggregate_if_hash_matches(job.category, &contribution_set_hash)
1259 .map_err(|error| error.to_string())?
1260 else {
1261 return Ok(None);
1262 };
1263 if !cached_tier2_aggregate_usable(job.category, options, &aggregate) {
1264 return Ok(None);
1265 }
1266
1267 cache
1268 .touch_tier2_last_full_run(job.category)
1269 .map_err(|error| error.to_string())?;
1270 Ok(Some(InspectScanSuccess {
1271 scanned_files: Vec::new(),
1272 contributions: Vec::new(),
1273 aggregate,
1274 }))
1275 }
1276
1277 #[allow(clippy::too_many_lines)]
1278 fn tier2_run_with_reuse_job(
1279 &self,
1280 job: &InspectJob,
1281 cache: &InspectCache,
1282 options: &Tier2ReuseOptions,
1283 ) -> Result<InspectScanSuccess, String> {
1284 let mut phases = Tier2PhaseTimings::default();
1285 let phase_started = Instant::now();
1286 let cached_records = load_contribution_freshness(cache, job.category)?;
1287 let current_by_relative = current_project_files(&job.project_root, &job.scope_files);
1288 let cached_relative = cached_records
1289 .iter()
1290 .map(freshness_record_relative_key)
1291 .collect::<BTreeSet<_>>();
1292 let force_relative = forced_relative_paths(job, &options.force_rescan_paths);
1293 let cold_cache = cached_relative.is_empty();
1294 #[cfg(debug_assertions)]
1295 let debug_cold_cache = cold_cache;
1296
1297 let mut updates = Tier2ContributionUpdates::default();
1298 let mut scan_by_relative = BTreeMap::<String, PathBuf>::new();
1299 let mut callgraph_refresh_paths = options
1300 .force_rescan_paths
1301 .iter()
1302 .filter(|path| callgraph_store_indexes_path(path))
1303 .cloned()
1304 .collect::<BTreeSet<_>>();
1305 let mut aggregate_job = job.clone();
1306
1307 for record in cached_records {
1308 let relative = freshness_record_relative_key(&record);
1309 let relative_path = PathBuf::from(&relative);
1310 let Some(current_file) = current_by_relative.get(&relative) else {
1311 updates.deletes.push(relative_path);
1312 insert_callgraph_refresh_path(
1313 &mut callgraph_refresh_paths,
1314 job.project_root.join(&relative),
1315 );
1316 continue;
1317 };
1318
1319 if force_relative.contains(&relative) {
1320 updates.deletes.push(relative_path);
1321 scan_by_relative.insert(relative, current_file.clone());
1322 insert_callgraph_refresh_path(&mut callgraph_refresh_paths, current_file.clone());
1323 continue;
1324 }
1325
1326 let absolute = job.project_root.join(&record.file_path);
1327 match verify_contribution_file(&absolute, &record.freshness) {
1328 ContributionFreshness::Fresh {
1329 metadata_changed,
1330 freshness,
1331 } => {
1332 if metadata_changed {
1333 updates.metadata_updates.push((relative_path, freshness));
1334 }
1335 }
1336 ContributionFreshness::Stale => {
1337 updates.deletes.push(relative_path);
1338 scan_by_relative.insert(relative, current_file.clone());
1339 insert_callgraph_refresh_path(
1340 &mut callgraph_refresh_paths,
1341 current_file.clone(),
1342 );
1343 }
1344 ContributionFreshness::Deleted => {
1345 updates.deletes.push(relative_path);
1346 insert_callgraph_refresh_path(
1347 &mut callgraph_refresh_paths,
1348 job.project_root.join(&record.file_path),
1349 );
1350 }
1351 }
1352 }
1353
1354 for (relative, file) in ¤t_by_relative {
1355 if !cached_relative.contains(relative) {
1356 scan_by_relative.insert(relative.clone(), file.clone());
1357 if !cold_cache {
1358 insert_callgraph_refresh_path(&mut callgraph_refresh_paths, file.clone());
1359 }
1360 }
1361 }
1362 phases.freshness = phase_started.elapsed();
1363
1364 let mut scan_files = scan_by_relative.into_values().collect::<Vec<_>>();
1365 let force_reparse_files = scan_files.clone();
1366 let callgraph_refresh_files = callgraph_refresh_paths.into_iter().collect::<Vec<_>>();
1367 let dead_code_callgraph_refresh =
1368 job.category == InspectCategory::DeadCode && !callgraph_refresh_files.is_empty();
1369 if !scan_files.is_empty() {
1370 let mut scan_job = job.clone();
1371 scan_job.job_id = self.next_job_id.fetch_add(1, Ordering::Relaxed);
1372 scan_job.scope_files = scan_files.clone();
1373 if scan_job.category == InspectCategory::DeadCode
1374 && scan_job.callgraph_snapshot.is_none()
1375 {
1376 let snapshot_started = Instant::now();
1377 scan_job.callgraph_snapshot = build_tier2_callgraph_snapshot_with_refresh(
1378 &scan_job,
1379 options.allow_callgraph_cold_build,
1380 &callgraph_refresh_files,
1381 );
1382 phases.snapshot += snapshot_started.elapsed();
1383 }
1384 aggregate_job.callgraph_snapshot = scan_job.callgraph_snapshot.clone();
1385 #[cfg(debug_assertions)]
1386 if debug_cold_cache {
1387 std::thread::sleep(Duration::from_millis(10));
1388 }
1389 let scan_started = Instant::now();
1390 let oxc_result =
1391 self.oxc_result_for_scan(&scan_job, &scan_job.scope_files, &force_reparse_files)?;
1392 let scan_result = run_tier2_scan(&scan_job, oxc_result.as_ref());
1393 phases.scan += scan_started.elapsed();
1394 phases.scanned_files += scan_files.len();
1395 let scan_success = scan_result.outcome.map_err(|message| {
1396 format!("{} incremental scan failed: {message}", job.category)
1397 })?;
1398 updates.upserts.extend(scan_success.contributions);
1399 }
1400
1401 let has_updates = !updates.upserts.is_empty()
1402 || !updates.deletes.is_empty()
1403 || !updates.metadata_updates.is_empty();
1404 if !has_updates && !dead_code_callgraph_refresh {
1405 if let Some(aggregate) = cache
1406 .get_aggregated_for_config(&job.key, job.config.as_ref())
1407 .map_err(|error| error.to_string())?
1408 {
1409 if cached_tier2_aggregate_usable(job.category, options, &aggregate) {
1410 cache
1411 .touch_tier2_last_full_run(job.category)
1412 .map_err(|error| error.to_string())?;
1413 phases.log(job.category);
1414 return Ok(InspectScanSuccess {
1415 scanned_files: scan_files,
1416 contributions: Vec::new(),
1417 aggregate,
1418 });
1419 }
1420 }
1421 }
1422
1423 let db_started = Instant::now();
1424 let mut contribution_set_hash = if has_updates {
1425 let (hash, db_timings) = cache
1426 .apply_contribution_updates_for_config(job.category, updates, job.config.as_ref())
1427 .map_err(|error| error.to_string())?;
1428 phases.add_db_timings(db_timings);
1429 hash
1430 } else {
1431 cache
1432 .contribution_set_hash_for_config(job.category, job.config.as_ref())
1433 .map_err(|error| error.to_string())?
1434 };
1435 phases.db = db_started.elapsed();
1436
1437 if !dead_code_callgraph_refresh {
1438 if let Some(aggregate) = cache
1439 .load_aggregate_if_hash_matches(job.category, &contribution_set_hash)
1440 .map_err(|error| error.to_string())?
1441 {
1442 if cached_tier2_aggregate_usable(job.category, options, &aggregate) {
1443 cache
1444 .touch_tier2_last_full_run(job.category)
1445 .map_err(|error| error.to_string())?;
1446 let contributions = load_contributions(cache, job)?;
1447 phases.log(job.category);
1448 return Ok(InspectScanSuccess {
1449 scanned_files: scan_files,
1450 contributions,
1451 aggregate,
1452 });
1453 }
1454 }
1455 }
1456
1457 let refresh_dead_code_facts = if job.category == InspectCategory::DeadCode {
1458 dead_code_contributions_need_fact_refresh(cache, job)?
1459 } else {
1460 false
1461 };
1462 let refresh_unused_exports_facts = if job.category == InspectCategory::UnusedExports {
1463 unused_exports_contributions_need_fact_refresh(cache, job)?
1464 } else {
1465 false
1466 };
1467 let refresh_duplicates_facts = if job.category == InspectCategory::Duplicates {
1468 duplicates_contributions_need_fact_refresh(cache, job)?
1469 } else {
1470 false
1471 };
1472 if refresh_dead_code_facts || refresh_unused_exports_facts || refresh_duplicates_facts {
1473 let full_scan_files = current_by_relative.into_values().collect::<Vec<_>>();
1478 if !full_scan_files.is_empty() {
1479 let mut rescan_job = job.clone();
1480 rescan_job.job_id = self.next_job_id.fetch_add(1, Ordering::Relaxed);
1481 rescan_job.scope_files = full_scan_files.clone();
1482 if rescan_job.category == InspectCategory::DeadCode
1483 && rescan_job.callgraph_snapshot.is_none()
1484 {
1485 let snapshot_started = Instant::now();
1486 rescan_job.callgraph_snapshot = build_tier2_callgraph_snapshot_with_refresh(
1487 &rescan_job,
1488 options.allow_callgraph_cold_build,
1489 &callgraph_refresh_files,
1490 );
1491 phases.snapshot += snapshot_started.elapsed();
1492 }
1493 let scan_started = Instant::now();
1494 let oxc_result = self.oxc_result_for_scan(
1495 &rescan_job,
1496 &rescan_job.scope_files,
1497 &force_reparse_files,
1498 )?;
1499 let scan_result = run_tier2_scan(&rescan_job, oxc_result.as_ref());
1500 phases.scan += scan_started.elapsed();
1501 phases.scanned_files += full_scan_files.len();
1502 let scan_success = scan_result.outcome.map_err(|message| {
1503 format!(
1504 "{} full rescan after entry-point cache miss failed: {message}",
1505 job.category
1506 )
1507 })?;
1508 let rescan_updates = Tier2ContributionUpdates {
1509 upserts: scan_success.contributions,
1510 ..Tier2ContributionUpdates::default()
1511 };
1512 let db_started = Instant::now();
1513 let (hash, db_timings) = cache
1514 .apply_contribution_updates_for_config(
1515 job.category,
1516 rescan_updates,
1517 job.config.as_ref(),
1518 )
1519 .map_err(|error| error.to_string())?;
1520 contribution_set_hash = hash;
1521 phases.add_db_timings(db_timings);
1522 phases.db += db_started.elapsed();
1523 aggregate_job.callgraph_snapshot = rescan_job.callgraph_snapshot.clone();
1524 scan_files = full_scan_files;
1525
1526 if !dead_code_callgraph_refresh {
1527 if let Some(aggregate) = cache
1528 .load_aggregate_if_hash_matches(job.category, &contribution_set_hash)
1529 .map_err(|error| error.to_string())?
1530 {
1531 if cached_tier2_aggregate_usable(job.category, options, &aggregate) {
1532 cache
1533 .touch_tier2_last_full_run(job.category)
1534 .map_err(|error| error.to_string())?;
1535 let contributions = load_contributions(cache, job)?;
1536 phases.log(job.category);
1537 return Ok(InspectScanSuccess {
1538 scanned_files: scan_files,
1539 contributions,
1540 aggregate,
1541 });
1542 }
1543 }
1544 }
1545 }
1546 }
1547
1548 if aggregate_job.category == InspectCategory::DeadCode
1549 && aggregate_job.callgraph_snapshot.is_none()
1550 {
1551 let snapshot_started = Instant::now();
1552 aggregate_job.callgraph_snapshot = build_tier2_callgraph_snapshot_with_refresh(
1553 &aggregate_job,
1554 options.allow_callgraph_cold_build,
1555 &callgraph_refresh_files,
1556 );
1557 phases.snapshot += snapshot_started.elapsed();
1558 }
1559 let rollup_started = Instant::now();
1560 let contributions = load_contributions(cache, &aggregate_job)?;
1561 let aggregate = roll_up_tier2_contributions(&aggregate_job, &contributions);
1562 cache
1563 .store_tier2_aggregate(job.key.clone(), &contribution_set_hash, aggregate.clone())
1564 .map_err(|error| error.to_string())?;
1565 phases.rollup = rollup_started.elapsed();
1566 phases.log(job.category);
1567
1568 Ok(InspectScanSuccess {
1569 scanned_files: scan_files,
1570 contributions,
1571 aggregate,
1572 })
1573 }
1574
1575 fn enqueue_with_waiter(
1576 &self,
1577 snapshot: InspectSnapshot,
1578 category: InspectCategory,
1579 caller_scope: JobScope,
1580 key: JobKey,
1581 waiter_tx: WaiterTx,
1582 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
1583 ) -> Result<(), String> {
1584 let mut in_flight = self
1585 .in_flight
1586 .lock()
1587 .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
1588 if let Some(waiters) = in_flight.get_mut(&key) {
1589 waiters.push(Waiter { tx: waiter_tx });
1590 return Ok(());
1591 }
1592
1593 in_flight.insert(key.clone(), vec![Waiter { tx: waiter_tx }]);
1594 drop(in_flight);
1595
1596 if let Err(message) = self.enqueue_new_job(
1597 snapshot,
1598 category,
1599 caller_scope,
1600 key.clone(),
1601 callgraph_snapshot,
1602 ) {
1603 if let Ok(mut in_flight) = self.in_flight.lock() {
1604 in_flight.remove(&key);
1605 }
1606 return Err(message);
1607 }
1608 Ok(())
1609 }
1610
1611 fn enqueue_without_waiter(
1612 &self,
1613 snapshot: InspectSnapshot,
1614 category: InspectCategory,
1615 caller_scope: JobScope,
1616 key: JobKey,
1617 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
1618 ) -> Result<(), String> {
1619 let mut in_flight = self
1620 .in_flight
1621 .lock()
1622 .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
1623 if in_flight.contains_key(&key) {
1624 return Ok(());
1625 }
1626 in_flight.insert(key.clone(), Vec::new());
1627 drop(in_flight);
1628
1629 if let Err(message) = self.enqueue_new_job(
1630 snapshot,
1631 category,
1632 caller_scope,
1633 key.clone(),
1634 callgraph_snapshot,
1635 ) {
1636 if let Ok(mut in_flight) = self.in_flight.lock() {
1637 in_flight.remove(&key);
1638 }
1639 return Err(message);
1640 }
1641 Ok(())
1642 }
1643
1644 fn enqueue_new_job(
1645 &self,
1646 snapshot: InspectSnapshot,
1647 category: InspectCategory,
1648 caller_scope: JobScope,
1649 key: JobKey,
1650 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
1651 ) -> Result<(), String> {
1652 let scan_scope = if category.is_tier2() {
1653 JobScope::for_project(snapshot.project_root.clone())
1654 } else {
1655 caller_scope
1656 };
1657 let scope_files = scope_files(&snapshot.project_root, &scan_scope);
1658 let job = InspectJob {
1659 job_id: self.next_job_id.fetch_add(1, Ordering::Relaxed),
1660 key,
1661 category,
1662 scope_files,
1663 project_root: snapshot.project_root,
1664 inspect_dir: snapshot.inspect_dir,
1665 config: snapshot.config,
1666 symbol_cache: snapshot.symbol_cache,
1667 inspect_writer: snapshot.inspect_writer,
1668 callgraph_writer: snapshot.callgraph_writer,
1669 callgraph_snapshot,
1670 };
1671 self.request_tx
1672 .send(job)
1673 .map_err(|_| "inspect dispatch loop is unavailable".to_string())
1674 }
1675
1676 fn wait_for_outcome(
1677 &self,
1678 key: JobKey,
1679 caller_scope: JobScope,
1680 cache: Arc<InspectCache>,
1681 waiter_rx: Receiver<JobOutcome>,
1682 snapshot: InspectSnapshot,
1683 ) -> JobOutcome {
1684 let timeout = after(self.soft_deadline);
1685 let result_rx = self.result_rx.clone();
1686 loop {
1687 select! {
1688 recv(waiter_rx) -> outcome => {
1689 return match outcome {
1690 Ok(outcome) => filter_outcome_for_scope_with_contributions(
1691 outcome,
1692 &snapshot,
1693 key.category,
1694 cache.as_ref(),
1695 &caller_scope,
1696 ),
1697 Err(_) => self.timeout_outcome(&key, &caller_scope, &cache, &snapshot),
1698 };
1699 }
1700 recv(result_rx) -> result => {
1701 match result {
1702 Ok(result) => self.route_completion(result),
1703 Err(_) => return self.timeout_outcome(&key, &caller_scope, &cache, &snapshot),
1704 }
1705 }
1706 recv(timeout) -> _ => {
1707 return self.timeout_outcome(&key, &caller_scope, &cache, &snapshot);
1708 }
1709 }
1710 }
1711 }
1712
1713 fn timeout_outcome(
1714 &self,
1715 key: &JobKey,
1716 caller_scope: &JobScope,
1717 cache: &(impl InspectCacheRead + ?Sized),
1718 snapshot: &InspectSnapshot,
1719 ) -> JobOutcome {
1720 match cache.get_aggregated_for_config(key, snapshot.config.as_ref()) {
1721 Ok(Some(cached)) => filter_outcome_for_scope_with_contributions(
1722 JobOutcome::Stale {
1723 cached: Some(cached),
1724 in_flight: true,
1725 },
1726 snapshot,
1727 key.category,
1728 cache,
1729 caller_scope,
1730 ),
1731 Ok(None) => JobOutcome::Pending { in_flight: true },
1732 Err(error) => JobOutcome::Failed {
1733 message: error.to_string(),
1734 },
1735 }
1736 }
1737
1738 fn route_completion(&self, result: InspectResult) {
1739 let outcome = self.completion_outcome(result.clone());
1740 let waiters = self
1741 .in_flight
1742 .lock()
1743 .ok()
1744 .and_then(|mut in_flight| in_flight.remove(&result.key))
1745 .unwrap_or_default();
1746 for waiter in waiters {
1747 let _ = waiter.tx.send(outcome.clone());
1748 }
1749 }
1750
1751 fn route_tier2_reuse_completion(&self, result: InspectResult) {
1752 let outcome = match result.outcome.clone() {
1753 Ok(success) => JobOutcome::Fresh {
1754 payload: success.aggregate,
1755 },
1756 Err(message) => JobOutcome::Failed { message },
1757 };
1758 let waiters = self
1759 .in_flight
1760 .lock()
1761 .ok()
1762 .and_then(|mut in_flight| in_flight.remove(&result.key))
1763 .unwrap_or_default();
1764 self.reuse_completions.fetch_add(1, Ordering::SeqCst);
1767 for waiter in waiters {
1768 let _ = waiter.tx.send(outcome.clone());
1769 }
1770 }
1775
1776 pub fn reuse_completion_count(&self) -> u64 {
1780 self.reuse_completions.load(Ordering::SeqCst)
1781 }
1782
1783 #[doc(hidden)]
1784 pub fn reuse_start_count_for_test(&self) -> u64 {
1785 self.reuse_starts.load(Ordering::SeqCst)
1786 }
1787
1788 fn completion_outcome(&self, result: InspectResult) -> JobOutcome {
1789 let cache =
1790 match self.cache_for_paths(result.inspect_dir.clone(), result.project_root.clone()) {
1791 Ok(cache) => cache,
1792 Err(message) => return JobOutcome::Failed { message },
1793 };
1794
1795 match result.outcome {
1796 Ok(success) => {
1797 let store_result = if result.category.is_tier2() {
1798 cache.store_tier2_result_for_config(
1799 result.key.clone(),
1800 &success.scanned_files,
1801 &success.contributions,
1802 success.aggregate.clone(),
1803 result.config.as_ref(),
1804 )
1805 } else {
1806 cache.store_aggregated(result.key, success.aggregate.clone())
1807 };
1808
1809 match store_result {
1810 Ok(()) => JobOutcome::Fresh {
1811 payload: success.aggregate,
1812 },
1813 Err(error) => JobOutcome::Failed {
1814 message: error.to_string(),
1815 },
1816 }
1817 }
1818 Err(message) => JobOutcome::Failed { message },
1819 }
1820 }
1821}
1822
1823impl Default for InspectManager {
1824 fn default() -> Self {
1825 Self::new()
1826 }
1827}
1828
1829fn validate_tier2_read_category(category: InspectCategory) -> Result<(), JobOutcome> {
1830 if !category.is_active() {
1831 return Err(JobOutcome::Failed {
1832 message: format!("inspect category '{category}' is disabled in v0.33"),
1833 });
1834 }
1835 if !category.is_tier2() {
1836 return Err(JobOutcome::Failed {
1837 message: format!("inspect category '{category}' is not a Tier 2 category"),
1838 });
1839 }
1840 Ok(())
1841}
1842
1843#[derive(Default)]
1850struct Tier2PhaseTimings {
1851 freshness: Duration,
1853 snapshot: Duration,
1855 scan: Duration,
1857 db: Duration,
1859 db_lock: Duration,
1861 db_txn: Duration,
1863 rollup: Duration,
1865 scanned_files: usize,
1866}
1867
1868impl Tier2PhaseTimings {
1869 fn add_db_timings(&mut self, timings: InspectDbTimings) {
1870 self.db_lock += timings.lock_wait;
1871 self.db_txn += timings.transaction;
1872 }
1873
1874 fn log(&self, category: InspectCategory) {
1875 let worked = self.freshness + self.scan + self.snapshot + self.rollup + self.db;
1876 if !worked.is_zero() {
1877 crate::logging::note_tier2_scan(
1878 category.to_string(),
1879 worked.as_millis().min(u128::from(u64::MAX)) as u64,
1880 );
1881 }
1882 if worked < Duration::from_millis(50) {
1883 return;
1884 }
1885 crate::slog_info!(
1886 "perf tier2 phases category={} freshness={}ms snapshot={}ms scan={}ms({} files) db={}ms(lock={},txn={}) rollup={}ms",
1887 category,
1888 self.freshness.as_millis(),
1889 self.snapshot.as_millis(),
1890 self.scan.as_millis(),
1891 self.scanned_files,
1892 self.db.as_millis(),
1893 self.db_lock.as_millis(),
1894 self.db_txn.as_millis(),
1895 self.rollup.as_millis()
1896 );
1897 }
1898}
1899
1900fn scope_files(project_root: &Path, scope: &JobScope) -> Vec<PathBuf> {
1901 let mut files = crate::callgraph::walk_project_files(project_root)
1902 .filter(|path| scope.contains(path))
1903 .collect::<Vec<_>>();
1904 files.sort();
1905 files
1906}
1907
1908fn forced_relative_paths(job: &InspectJob, paths: &BTreeSet<PathBuf>) -> BTreeSet<String> {
1909 let mut keys = BTreeSet::new();
1910 for path in paths {
1911 let absolute = if path.is_absolute() {
1912 path.clone()
1913 } else {
1914 job.project_root.join(path)
1915 };
1916 keys.insert(relative_cache_key(&job.project_root, &absolute));
1917 if let Ok(canonical) = std::fs::canonicalize(&absolute) {
1918 keys.insert(relative_cache_key(&job.project_root, &canonical));
1919 }
1920 }
1921 keys
1922}
1923
1924fn downgrade_unchanged_forced_paths_with_freshness(
1925 project_root: &Path,
1926 cached: &[CachedContributionFreshness],
1927 paths: Vec<PathBuf>,
1928) -> (Vec<PathBuf>, usize) {
1929 let cached = cached
1930 .iter()
1931 .map(|record| (freshness_record_relative_key(record), record.freshness))
1932 .collect::<BTreeMap<_, _>>();
1933 let mut remaining = Vec::with_capacity(paths.len());
1934 let mut downgraded = 0;
1935
1936 for path in paths {
1937 let absolute = if path.is_absolute() {
1938 path.clone()
1939 } else {
1940 project_root.join(&path)
1941 };
1942 let direct_key = relative_cache_key(project_root, &absolute);
1943 let canonical_key = std::fs::canonicalize(&absolute)
1944 .ok()
1945 .map(|canonical| relative_cache_key(project_root, &canonical));
1946 let freshness = cached
1947 .get(&direct_key)
1948 .or_else(|| canonical_key.as_ref().and_then(|key| cached.get(key)));
1949 let content_unchanged = freshness.is_some_and(|freshness| {
1950 matches!(
1951 cache_freshness::verify_file_strict(&absolute, freshness),
1952 FreshnessVerdict::HotFresh | FreshnessVerdict::ContentFresh { .. }
1953 )
1954 });
1955 if content_unchanged {
1956 downgraded += 1;
1957 } else {
1958 remaining.push(path);
1959 }
1960 }
1961
1962 (remaining, downgraded)
1963}
1964
1965fn panic_tier2_reuse_for_debug(job: &InspectJob) {
1966 #[cfg(not(debug_assertions))]
1967 let _ = job;
1968 #[cfg(debug_assertions)]
1969 {
1970 if !env_project_root_matches("AFT_TEST_TIER2_REUSE_PANIC_ROOT", &job.project_root) {
1971 return;
1972 }
1973 let should_panic = std::env::var("AFT_TEST_TIER2_REUSE_PANIC_CATEGORY")
1974 .ok()
1975 .is_some_and(|category| category == job.category.as_str());
1976 if should_panic {
1977 panic!("forced tier2 reuse panic for {}", job.category);
1978 }
1979 }
1980}
1981
1982fn delay_direct_force_followup_deadline_check_for_debug(project_root: &Path) {
1983 #[cfg(not(debug_assertions))]
1984 let _ = project_root;
1985 #[cfg(debug_assertions)]
1986 {
1987 if !env_project_root_matches("AFT_TEST_DIRECT_FORCE_FOLLOWUP_DELAY_ROOT", project_root) {
1988 return;
1989 }
1990 if let Some(delay_ms) = std::env::var("AFT_TEST_DIRECT_FORCE_FOLLOWUP_DELAY_MS")
1991 .ok()
1992 .and_then(|raw| raw.parse::<u64>().ok())
1993 {
1994 std::thread::sleep(Duration::from_millis(delay_ms));
1995 }
1996 }
1997}
1998
1999fn delay_tier2_reuse_for_debug(project_root: &Path) {
2000 #[cfg(not(debug_assertions))]
2001 let _ = project_root;
2002 #[cfg(debug_assertions)]
2003 {
2004 if !env_project_root_matches("AFT_TEST_TIER2_REUSE_DELAY_ROOT", project_root) {
2005 return;
2006 }
2007 if let Some(delay_ms) = std::env::var("AFT_TEST_TIER2_REUSE_DELAY_MS")
2008 .ok()
2009 .and_then(|raw| raw.parse::<u64>().ok())
2010 {
2011 std::thread::sleep(Duration::from_millis(delay_ms));
2012 }
2013 }
2014}
2015
2016#[cfg(debug_assertions)]
2017fn env_project_root_matches(var: &str, project_root: &Path) -> bool {
2018 let Some(raw) = std::env::var_os(var) else {
2019 return true;
2020 };
2021 let expected = PathBuf::from(raw);
2022 let expected = std::fs::canonicalize(&expected).unwrap_or(expected);
2023 let actual = std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
2024 expected == actual
2025}
2026
2027fn current_project_files(project_root: &Path, files: &[PathBuf]) -> BTreeMap<String, PathBuf> {
2028 files
2029 .iter()
2030 .map(|file| (relative_cache_key(project_root, file), file.clone()))
2031 .collect()
2032}
2033
2034fn insert_callgraph_refresh_path(paths: &mut BTreeSet<PathBuf>, path: PathBuf) {
2035 if callgraph_store_indexes_path(&path) {
2036 paths.insert(path);
2037 }
2038}
2039
2040fn callgraph_store_indexes_path(path: &Path) -> bool {
2041 crate::parser::detect_language(path).is_some()
2042}
2043
2044fn tier2_benchmark_logging_enabled() -> bool {
2045 std::env::var_os("AFT_SETTLE_BENCH_LOG").is_some()
2046}
2047
2048fn log_tier2_benchmark_category_start(job: &InspectJob) {
2049 if !tier2_benchmark_logging_enabled() {
2050 return;
2051 }
2052 crate::slog_info!(
2053 "settle bench: tier2_category_start category={} job_id={} files={}",
2054 job.category.as_str(),
2055 job.job_id,
2056 job.scope_files.len()
2057 );
2058}
2059
2060fn log_tier2_benchmark_category_end(result: &InspectResult) {
2061 if !tier2_benchmark_logging_enabled() {
2062 return;
2063 }
2064 match &result.outcome {
2065 Ok(success) => {
2066 let count = success
2067 .aggregate
2068 .get("count")
2069 .and_then(serde_json::Value::as_u64)
2070 .unwrap_or(0);
2071 crate::slog_info!(
2072 "settle bench: tier2_category_end category={} job_id={} status=success total_ms={} scanned_files={} contributions={} count={}",
2073 result.category.as_str(),
2074 result.job_id,
2075 result.duration.as_millis(),
2076 success.scanned_files.len(),
2077 success.contributions.len(),
2078 count
2079 );
2080 }
2081 Err(message) => {
2082 crate::slog_info!(
2083 "settle bench: tier2_category_end category={} job_id={} status=failed total_ms={} error={}",
2084 result.category.as_str(),
2085 result.job_id,
2086 result.duration.as_millis(),
2087 message.replace('\n', " ")
2088 );
2089 }
2090 }
2091}
2092
2093fn build_tier2_callgraph_snapshot(
2094 job: &InspectJob,
2095 allow_cold_build: bool,
2096) -> Option<Arc<CallgraphSnapshot>> {
2097 build_tier2_callgraph_snapshot_with_refresh(job, allow_cold_build, &[])
2098}
2099
2100fn build_tier2_callgraph_snapshot_with_refresh(
2101 job: &InspectJob,
2102 allow_cold_build: bool,
2103 refresh_paths: &[PathBuf],
2104) -> Option<Arc<CallgraphSnapshot>> {
2105 let started = Instant::now();
2106 if !job.config.callgraph_store {
2107 crate::slog_info!(
2108 "tier2 dead_code: callgraph store disabled; reporting callgraph_unavailable"
2109 );
2110 return None;
2111 }
2112
2113 let callgraph_dirs = callgraph_store_dirs_from_inspect_dir(&job.inspect_dir, &job.project_root);
2114 if callgraph_dirs.is_empty() {
2115 crate::slog_info!(
2116 "tier2 dead_code: inspect_dir has no root-keyed storage parent ({}); reporting callgraph_unavailable",
2117 job.inspect_dir.display()
2118 );
2119 return None;
2120 };
2121
2122 for (index, callgraph_dir) in callgraph_dirs.iter().enumerate() {
2123 let sqlite_path = if refresh_paths.is_empty() || !job.callgraph_writer {
2127 let store = match CallGraphStore::open_readonly(
2128 callgraph_dir.clone(),
2129 job.project_root.clone(),
2130 ) {
2131 Ok(Some(store)) => store,
2132 Ok(None) => {
2133 crate::slog_info!(
2134 "tier2 dead_code: callgraph store unavailable at {} (cold/building/not ready); trying fallback={}",
2135 callgraph_dir.display(),
2136 index + 1 < callgraph_dirs.len()
2137 );
2138 continue;
2139 }
2140 Err(error) => {
2141 crate::slog_warn!(
2142 "tier2 dead_code: failed to open callgraph store read-only at {}: {}; trying fallback={}",
2143 callgraph_dir.display(),
2144 error,
2145 index + 1 < callgraph_dirs.len()
2146 );
2147 continue;
2148 }
2149 };
2150 store.sqlite_path().to_path_buf()
2151 } else {
2152 let store = match if allow_cold_build {
2153 CallGraphStore::open_ready_repairing(
2154 callgraph_dir.clone(),
2155 job.project_root.clone(),
2156 )
2157 } else {
2158 CallGraphStore::open_ready_no_rebuild(
2159 callgraph_dir.clone(),
2160 job.project_root.clone(),
2161 )
2162 } {
2163 Ok(Some(store)) => store,
2164 Ok(None) => {
2165 crate::slog_info!(
2166 "tier2 dead_code: callgraph store unavailable at {} (cold/building/not ready); trying fallback={}",
2167 callgraph_dir.display(),
2168 index + 1 < callgraph_dirs.len()
2169 );
2170 continue;
2171 }
2172 Err(error) => {
2173 crate::slog_warn!(
2174 "tier2 dead_code: failed to open callgraph writer at {}: {}; trying fallback={}",
2175 callgraph_dir.display(),
2176 error,
2177 index + 1 < callgraph_dirs.len()
2178 );
2179 continue;
2180 }
2181 };
2182 match store.refresh_files(refresh_paths) {
2183 Ok(stats) => {
2184 crate::slog_info!(
2185 "tier2 dead_code: refreshed callgraph store at {} for {} watcher path(s): changed={} deleted={} refreshed_own={}",
2186 callgraph_dir.display(),
2187 refresh_paths.len(),
2188 stats.changed_files.len(),
2189 stats.deleted_files.len(),
2190 stats.refreshed_own_files
2191 );
2192 }
2193 Err(error) => {
2194 crate::slog_warn!(
2195 "tier2 dead_code: failed to refresh callgraph store at {} before projection: {}",
2196 callgraph_dir.display(),
2197 error
2198 );
2199 if let Err(mark_error) = store.mark_files_stale(refresh_paths) {
2200 crate::slog_warn!(
2201 "tier2 dead_code: failed to mark callgraph store files stale at {} after refresh failure: {}",
2202 callgraph_dir.display(),
2203 mark_error
2204 );
2205 }
2206 }
2207 }
2208 store.sqlite_path().to_path_buf()
2209 };
2210
2211 let snapshot = match project_dead_code_snapshot(&sqlite_path) {
2212 Ok(snapshot) => snapshot,
2213 Err(CallGraphStoreError::Unavailable(message)) => {
2214 crate::slog_info!(
2215 "tier2 dead_code: callgraph store projection unavailable at {} ({}); trying fallback={}",
2216 callgraph_dir.display(),
2217 message,
2218 index + 1 < callgraph_dirs.len()
2219 );
2220 continue;
2221 }
2222 Err(error) => {
2223 crate::slog_warn!(
2224 "tier2 dead_code: callgraph store projection failed at {}: {}; trying fallback={}",
2225 callgraph_dir.display(),
2226 error,
2227 index + 1 < callgraph_dirs.len()
2228 );
2229 continue;
2230 }
2231 };
2232
2233 if index > 0 {
2234 crate::slog_info!(
2235 "tier2 dead_code: using ready callgraph store fallback {} for inspect_dir {}",
2236 callgraph_dir.display(),
2237 job.inspect_dir.display()
2238 );
2239 }
2240
2241 crate::slog_info!(
2242 "perf tier2_callgraph_snapshot: source=callgraph_store files={} exports={} edges={} entry_points={} ms={}",
2243 snapshot.files.len(),
2244 snapshot.exported_symbols.len(),
2245 snapshot.outbound_calls.len(),
2246 snapshot.entry_points.len(),
2247 started.elapsed().as_millis()
2248 );
2249
2250 return Some(Arc::new(snapshot));
2251 }
2252
2253 crate::slog_info!(
2254 "tier2 dead_code: no ready callgraph store found for inspect_dir {}; reporting callgraph_unavailable",
2255 job.inspect_dir.display()
2256 );
2257 None
2258}
2259
2260fn callgraph_store_dir_from_inspect_dir(
2261 inspect_dir: &Path,
2262 project_root: &Path,
2263) -> Option<PathBuf> {
2264 let scope_key = crate::path_identity::project_scope_key(project_root);
2265 let storage_dir = if inspect_dir
2266 .file_name()
2267 .and_then(|name| name.to_str())
2268 .is_some_and(|name| name == scope_key)
2269 {
2270 inspect_dir.parent()?.parent()?
2271 } else {
2272 inspect_dir.parent()?
2273 };
2274 let project_key = crate::search_index::artifact_cache_key(project_root);
2275 Some(storage_dir.join("callgraph").join(project_key))
2276}
2277
2278fn callgraph_store_dirs_from_inspect_dir(inspect_dir: &Path, project_root: &Path) -> Vec<PathBuf> {
2279 callgraph_store_dir_from_inspect_dir(inspect_dir, project_root)
2280 .into_iter()
2281 .collect()
2282}
2283
2284#[cfg(test)]
2285fn canonicalize_for_snapshot(path: &Path) -> PathBuf {
2286 std::fs::canonicalize(path).unwrap_or_else(|_| normalize_path(path))
2287}
2288
2289fn load_contribution_freshness(
2290 cache: &(impl InspectCacheRead + ?Sized),
2291 category: InspectCategory,
2292) -> Result<Vec<CachedContributionFreshness>, String> {
2293 cache
2294 .contribution_freshness(category)
2295 .map_err(|error| error.to_string())
2296 .map(|records| {
2297 records
2298 .into_iter()
2299 .map(|(file_path, freshness)| CachedContributionFreshness {
2300 file_path,
2301 freshness,
2302 })
2303 .collect()
2304 })
2305}
2306
2307fn freshness_record_relative_key(record: &CachedContributionFreshness) -> String {
2308 record.file_path.to_string_lossy().to_string()
2309}
2310
2311fn relative_cache_key(project_root: &Path, path: &Path) -> String {
2312 path.strip_prefix(project_root)
2313 .unwrap_or(path)
2314 .to_string_lossy()
2315 .to_string()
2316}
2317
2318fn load_contributions(
2319 cache: &(impl InspectCacheRead + ?Sized),
2320 job: &InspectJob,
2321) -> Result<Vec<FileContribution>, String> {
2322 cache
2323 .load_tier2_contributions(job.category)
2324 .map_err(|error| error.to_string())
2325 .map(|records| {
2326 records
2327 .into_iter()
2328 .map(|record| contribution_from_record(&job.project_root, record))
2329 .collect()
2330 })
2331}
2332
2333fn dead_code_contributions_need_fact_refresh(
2334 cache: &(impl InspectCacheRead + ?Sized),
2335 job: &InspectJob,
2336) -> Result<bool, String> {
2337 let contributions = load_contributions(cache, job)?;
2338 Ok(contributions
2339 .iter()
2340 .any(dead_code_contribution_needs_fact_refresh))
2341}
2342
2343fn dead_code_contribution_needs_fact_refresh(contribution: &FileContribution) -> bool {
2344 let Ok(parsed) =
2345 serde_json::from_value::<DeadCodeRefreshContribution>(contribution.contribution.clone())
2346 else {
2347 return true;
2348 };
2349
2350 if parsed.facts_format_version
2351 != Some(super::scanners::dead_code::DEAD_CODE_FACTS_FORMAT_VERSION)
2352 {
2353 return true;
2354 }
2355
2356 matches!(
2357 parsed.oxc_facts,
2358 Some(facts) if facts.format_version != FACTS_FORMAT_VERSION
2359 )
2360}
2361
2362fn unused_exports_contributions_need_fact_refresh(
2363 cache: &(impl InspectCacheRead + ?Sized),
2364 job: &InspectJob,
2365) -> Result<bool, String> {
2366 let contributions = load_contributions(cache, job)?;
2367 Ok(contributions
2368 .iter()
2369 .any(unused_exports_contribution_needs_fact_refresh))
2370}
2371
2372fn duplicates_contributions_need_fact_refresh(
2377 cache: &(impl InspectCacheRead + ?Sized),
2378 job: &InspectJob,
2379) -> Result<bool, String> {
2380 let contributions = load_contributions(cache, job)?;
2381 Ok(contributions
2382 .iter()
2383 .any(|contribution| contribution.contribution.get("line_count").is_none()))
2384}
2385
2386fn unused_exports_contribution_needs_fact_refresh(contribution: &FileContribution) -> bool {
2387 let top_level_oxc = contribution
2388 .contribution
2389 .get("provenance")
2390 .and_then(Value::as_str)
2391 == Some(OXC_PROVENANCE);
2392 let Ok(parsed) =
2393 serde_json::from_value::<UnusedExportsContribution>(contribution.contribution.clone())
2394 else {
2395 return false;
2396 };
2397 let uses_oxc =
2398 top_level_oxc || parsed.oxc_facts.is_some() || parsed.exports.iter().any(export_uses_oxc);
2399 if !uses_oxc {
2400 return false;
2401 }
2402
2403 !matches!(
2404 parsed.oxc_facts,
2405 Some(facts) if facts.format_version == FACTS_FORMAT_VERSION
2406 )
2407}
2408
2409fn contribution_from_record(
2410 project_root: &Path,
2411 record: super::cache::ContributionRecord,
2412) -> FileContribution {
2413 FileContribution::new(
2414 record.category,
2415 project_root.join(record.file_path),
2416 record.freshness,
2417 record.contribution,
2418 )
2419 .with_type_ref_names(record.type_ref_names)
2420}
2421
2422fn run_tier2_scan(job: &InspectJob, oxc_result: Option<&OxcEngineResult>) -> InspectResult {
2423 use super::scanners;
2424
2425 match job.category {
2426 InspectCategory::DeadCode => {
2427 scanners::dead_code::run_dead_code_scan_with_oxc(job, oxc_result)
2428 }
2429 InspectCategory::UnusedExports => {
2430 scanners::unused_exports::run_unused_exports_scan_with_oxc(job, oxc_result)
2431 }
2432 InspectCategory::Duplicates => scanners::duplicates::run_duplicates_scan(job),
2433 InspectCategory::Cycles => scanners::cycles::run_cycles_scan_with_oxc(job, oxc_result),
2434 other => InspectResult::failed(
2435 job,
2436 format!("inspect category '{other}' is not an active Tier 2 scanner"),
2437 Duration::from_secs(0),
2438 ),
2439 }
2440}
2441
2442fn roll_up_tier2_contributions(job: &InspectJob, contributions: &[FileContribution]) -> Value {
2443 roll_up_tier2_contributions_with_limit(job, contributions, Some(MAX_DRILL_DOWN_ITEMS))
2444}
2445
2446fn roll_up_tier2_contributions_with_limit(
2447 job: &InspectJob,
2448 contributions: &[FileContribution],
2449 drill_down_limit: Option<usize>,
2450) -> Value {
2451 match job.category {
2452 InspectCategory::DeadCode => {
2453 roll_up_dead_code_contributions(job, contributions, drill_down_limit)
2454 }
2455 InspectCategory::UnusedExports => {
2456 roll_up_unused_exports_contributions(job, contributions, drill_down_limit)
2457 }
2458 InspectCategory::Duplicates => {
2459 roll_up_duplicate_contributions(job, contributions, drill_down_limit)
2460 }
2461 InspectCategory::Cycles => {
2462 roll_up_cycle_contributions(job, contributions, drill_down_limit)
2463 }
2464 _ => json!({
2465 "count": 0,
2466 "items": [],
2467 "scanned_files": contributions.len(),
2468 }),
2469 }
2470}
2471
2472fn scoped_tier2_payload_from_contributions(
2473 snapshot: &InspectSnapshot,
2474 category: InspectCategory,
2475 cache: &(impl InspectCacheRead + ?Sized),
2476 project_payload: Value,
2477 scope: &JobScope,
2478) -> Result<Value, String> {
2479 if scope.is_project_wide() {
2480 return Ok(project_payload);
2481 }
2482
2483 let project_scope = JobScope::for_project(snapshot.project_root.clone());
2484 let rollup_job = scoped_tier2_rollup_job(snapshot, category, &project_scope);
2485 let contributions = load_contributions(cache, &rollup_job)?;
2486 let full_payload = roll_up_tier2_contributions_with_limit(&rollup_job, &contributions, None);
2487 let scoped_payload = filter_payload_for_scope(full_payload, scope);
2488 Ok(cap_payload_drill_down(scoped_payload, MAX_DRILL_DOWN_ITEMS))
2489}
2490
2491fn scoped_tier2_rollup_job(
2492 snapshot: &InspectSnapshot,
2493 category: InspectCategory,
2494 scope: &JobScope,
2495) -> InspectJob {
2496 let mut job = InspectJob {
2497 job_id: 0,
2498 key: JobKey::for_project_category(category),
2499 category,
2500 scope_files: scope_files(&snapshot.project_root, scope),
2501 project_root: snapshot.project_root.clone(),
2502 inspect_dir: snapshot.inspect_dir.clone(),
2503 config: Arc::clone(&snapshot.config),
2504 symbol_cache: Arc::clone(&snapshot.symbol_cache),
2505 inspect_writer: snapshot.inspect_writer,
2506 callgraph_writer: snapshot.callgraph_writer,
2507 callgraph_snapshot: None,
2508 };
2509
2510 if category == InspectCategory::DeadCode {
2511 job.callgraph_snapshot = build_tier2_callgraph_snapshot(&job, false);
2516 }
2517
2518 job
2519}
2520
2521fn roll_up_dead_code_contributions(
2522 job: &InspectJob,
2523 contributions: &[FileContribution],
2524 drill_down_limit: Option<usize>,
2525) -> Value {
2526 let Some(snapshot) = job.callgraph_snapshot.as_deref() else {
2527 return super::scanners::dead_code::callgraph_unavailable_aggregate(job.scope_files.len());
2528 };
2529
2530 let public_api_files = super::scanners::dead_code::collect_public_api_files(&job.project_root);
2531 let roles = super::entry_points::resolve_project_roles(&job.project_root);
2532 super::scanners::dead_code::aggregate_dead_code_contributions_with_snapshot(
2533 &job.project_root,
2534 snapshot,
2535 contributions,
2536 &public_api_files,
2537 &roles,
2538 drill_down_limit,
2539 )
2540}
2541
2542fn roll_up_unused_exports_contributions(
2543 job: &InspectJob,
2544 contributions: &[FileContribution],
2545 drill_down_limit: Option<usize>,
2546) -> Value {
2547 let parsed = contributions
2548 .iter()
2549 .filter_map(|contribution| {
2550 serde_json::from_value::<UnusedExportsContribution>(contribution.contribution.clone())
2551 .ok()
2552 })
2553 .collect::<Vec<_>>();
2554
2555 if parsed.iter().any(|scan| scan.oxc_facts.is_some()) {
2556 return roll_up_unused_exports_oxc_contributions(job, &parsed, drill_down_limit);
2557 }
2558
2559 let (public_api_files, package_warnings) = unused_public_api_entries(&job.project_root);
2560 let mut imported_by: BTreeMap<(String, String), BTreeSet<String>> = BTreeMap::new();
2561 let mut uncertain_by: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
2562 for scan in &parsed {
2563 for import in &scan.imports {
2564 let Some(resolved_file) = &import.resolved_file else {
2565 continue;
2566 };
2567 for name in &import.named {
2568 if name == "*" {
2569 uncertain_by
2570 .entry(resolved_file.clone())
2571 .or_default()
2572 .insert(scan.file.clone());
2573 } else {
2574 imported_by
2575 .entry((resolved_file.clone(), name.clone()))
2576 .or_default()
2577 .insert(scan.file.clone());
2578 }
2579 }
2580 }
2581 }
2582
2583 let mut count = 0usize;
2584 let mut items = Vec::new();
2585 let mut generated_count = 0usize;
2586 let mut generated_items = Vec::new();
2587 let test_only_count = 0usize;
2588 let test_only_items = Vec::new();
2589 let mut uncertain_count = 0usize;
2590 let mut uncertain_items = Vec::new();
2591 for scan in &parsed {
2592 if public_api_files.contains(&scan.file) {
2593 continue;
2594 }
2595 if super::job::is_test_support_file(&scan.file) {
2598 continue;
2599 }
2600 let generated_file = super::generated::is_generated_file_with_cached_hint(
2601 &job.project_root,
2602 &scan.file,
2603 scan.generated,
2604 );
2605
2606 for export in &scan.exports {
2607 if export_uses_oxc(export) {
2608 match export.verdict.unwrap_or(LivenessVerdict::Unused) {
2609 LivenessVerdict::Used => continue,
2610 LivenessVerdict::Uncertain => {
2611 uncertain_count += 1;
2612 if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
2613 uncertain_items.push(json!({
2614 "file": scan.file,
2615 "symbol": export.symbol,
2616 "kind": export.kind,
2617 "line": export.line,
2618 "reason": export.reason.as_deref().unwrap_or("oxc_uncertain"),
2619 "provenance": export.provenance.as_deref().unwrap_or(OXC_PROVENANCE),
2620 }));
2621 }
2622 continue;
2623 }
2624 LivenessVerdict::Unused => {}
2625 }
2626 } else {
2627 let imported = imported_by
2628 .get(&(scan.file.clone(), export.symbol.clone()))
2629 .map(|files| !files.is_empty())
2630 .unwrap_or(false);
2631 let uncertain = uncertain_by
2632 .get(&scan.file)
2633 .map(|files| !files.is_empty())
2634 .unwrap_or(false);
2635
2636 if imported {
2637 continue;
2638 }
2639 if uncertain {
2640 uncertain_count += 1;
2641 if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
2642 uncertain_items.push(json!({
2643 "file": scan.file,
2644 "symbol": export.symbol,
2645 "kind": export.kind,
2646 "line": export.line,
2647 "reason": "wildcard_import",
2648 }));
2649 }
2650 continue;
2651 }
2652 }
2653
2654 let mut item = json!({
2655 "file": scan.file,
2656 "symbol": export.symbol,
2657 "kind": export.kind,
2658 "line": export.line,
2659 });
2660 if let Some(provenance) = &export.provenance {
2661 item["provenance"] = json!(provenance);
2662 }
2663 if generated_file {
2664 item["generated"] = json!(true);
2665 generated_count += 1;
2666 generated_items.push(item);
2667 } else {
2668 count += 1;
2669 items.push(item);
2670 }
2671 }
2672 }
2673
2674 let roles = super::entry_points::resolve_project_roles(&job.project_root);
2675 let items = super::entry_points::rank_and_truncate_items(items, &roles, drill_down_limit);
2676 let generated_items =
2677 super::entry_points::rank_and_truncate_items(generated_items, &roles, drill_down_limit);
2678 let top = super::entry_points::top_preview_symbols(&items);
2679 let generated_top = generated_items
2680 .iter()
2681 .take(super::entry_points::TOP_PREVIEW_ITEMS)
2682 .cloned()
2683 .collect::<Vec<_>>();
2684 let mut all_items = items;
2685 all_items.extend(generated_items.iter().cloned());
2686 if let Some(limit) = drill_down_limit {
2687 all_items.truncate(limit);
2688 }
2689 let test_only_items =
2690 super::entry_points::rank_and_truncate_items(test_only_items, &roles, drill_down_limit);
2691 let test_only_top = test_only_items
2692 .iter()
2693 .take(super::entry_points::TOP_PREVIEW_ITEMS)
2694 .cloned()
2695 .collect::<Vec<_>>();
2696
2697 let (parse_errors, skipped_files) = unused_exports_honesty_fields(&parsed);
2698 let mut aggregate = json!({
2699 "count": count,
2700 "generated_count": generated_count,
2701 "total_count": count + test_only_count + generated_count,
2702 "items": all_items,
2703 "top": top,
2704 "generated_items": generated_items,
2705 "generated_top": generated_top,
2706 "test_only_count": test_only_count,
2707 "test_only_items": test_only_items,
2708 "test_only_top": test_only_top,
2709 "drill_down_capped": drill_down_limit.is_some_and(|limit| count + generated_count > limit),
2710 "generated_drill_down_capped": drill_down_limit.is_some_and(|limit| generated_count > limit),
2711 "test_only_drill_down_capped": drill_down_limit.is_some_and(|limit| test_only_count > limit),
2712 "scanned_files": parsed.len(),
2713 "languages_skipped": skipped_languages(&job.scope_files, LanguageSkipMode::UnusedExports),
2714 "uncertain_count": uncertain_count,
2715 "uncertain_items": uncertain_items,
2716 "complete": parse_errors.is_empty() && skipped_files.is_empty(),
2717 });
2718 if !parse_errors.is_empty() {
2719 aggregate["parse_errors"] = Value::Array(parse_errors);
2720 }
2721 if !skipped_files.is_empty() {
2722 aggregate["skipped_files"] = Value::Array(skipped_files);
2723 }
2724 if !package_warnings.is_empty() {
2725 aggregate["note"] = Value::String(package_warnings.join("; "));
2726 }
2727 aggregate
2728}
2729
2730fn roll_up_unused_exports_oxc_contributions(
2731 job: &InspectJob,
2732 parsed: &[UnusedExportsContribution],
2733 drill_down_limit: Option<usize>,
2734) -> Value {
2735 let (public_api_files, package_warnings) = unused_public_api_entries(&job.project_root);
2736 let facts = parsed
2737 .iter()
2738 .filter_map(|scan| {
2739 let oxc_facts = scan.oxc_facts.as_ref()?;
2740 let path = job.project_root.join(&scan.file);
2741 Some(FileFacts {
2742 file_id: FileId(0),
2743 path: normalize_input_path(&job.project_root, &path),
2744 content_hash: oxc_facts.content_hash.clone(),
2745 exports: oxc_facts.exports.clone(),
2746 imports: oxc_facts.imports.clone(),
2747 re_exports: oxc_facts.re_exports.clone(),
2748 dynamic_imports: oxc_facts.dynamic_imports.clone(),
2749 same_file_value_references: oxc_facts.same_file_value_references.clone(),
2750 used_import_bindings: oxc_facts.used_import_bindings.clone(),
2751 type_referenced_import_bindings: oxc_facts.type_referenced_import_bindings.clone(),
2752 value_referenced_import_bindings: oxc_facts
2753 .value_referenced_import_bindings
2754 .clone(),
2755 parse_error: oxc_facts.parse_error.clone(),
2756 })
2757 })
2758 .collect::<Vec<_>>();
2759 let generated_by_file = parsed
2760 .iter()
2761 .map(|scan| {
2762 (
2763 scan.file.clone(),
2764 super::generated::is_generated_file_with_cached_hint(
2765 &job.project_root,
2766 &scan.file,
2767 scan.generated,
2768 ),
2769 )
2770 })
2771 .collect::<BTreeMap<_, _>>();
2772 let entry_point_set = crate::inspect::entry_points::resolve_entry_points(&job.project_root);
2773 let oxc_result = analyze_file_facts(
2774 &job.project_root,
2775 facts,
2776 AnalyzeOptions {
2777 entry_points: Vec::new(),
2778 public_api_files: entry_point_set.public_api_files(),
2779 executable_root_exports: entry_point_set.executable_root_exports(),
2780 force_reparse_files: Vec::new(),
2781 entry_reachability: false,
2782 },
2783 Vec::new(),
2784 );
2785 let roles = super::entry_points::resolve_project_roles(&job.project_root);
2786
2787 let mut count = 0usize;
2788 let mut items = Vec::new();
2789 let mut generated_count = 0usize;
2790 let mut generated_items = Vec::new();
2791 let mut test_only_count = 0usize;
2792 let mut test_only_items = Vec::new();
2793 let mut uncertain_count = 0usize;
2794 let mut uncertain_items = Vec::new();
2795 for file in &oxc_result.files {
2796 if public_api_files.contains(&file.relative_file)
2797 || super::job::is_test_support_file(&file.relative_file)
2798 {
2799 continue;
2800 }
2801 let generated_file = generated_by_file
2802 .get(&file.relative_file)
2803 .copied()
2804 .unwrap_or_else(|| {
2805 super::generated::is_generated_file(
2806 &job.project_root,
2807 Path::new(&file.relative_file),
2808 )
2809 });
2810
2811 for export in &file.exports {
2812 match export.verdict {
2813 LivenessVerdict::Used => {
2814 if !is_test_file(&file.relative_file)
2815 && !export.test_only_reference_files.is_empty()
2816 {
2817 let mut item = json!({
2818 "file": file.relative_file,
2819 "symbol": export.symbol,
2820 "kind": export.kind,
2821 "line": export.line,
2822 "provenance": export.provenance,
2823 "used_by": export.test_only_reference_files,
2824 });
2825 add_oxc_reexport_contexts(&mut item, &export.also_reexported);
2826 if generated_file {
2827 item["generated"] = json!(true);
2828 generated_count += 1;
2829 generated_items.push(item);
2830 } else {
2831 test_only_count += 1;
2832 test_only_items.push(item);
2833 }
2834 }
2835 }
2836 LivenessVerdict::Uncertain => {
2837 uncertain_count += 1;
2838 if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
2839 let mut item = json!({
2840 "file": file.relative_file,
2841 "symbol": export.symbol,
2842 "kind": export.kind,
2843 "line": export.line,
2844 "reason": export.reason,
2845 "provenance": export.provenance,
2846 });
2847 add_oxc_reexport_contexts(&mut item, &export.also_reexported);
2848 uncertain_items.push(item);
2849 }
2850 }
2851 LivenessVerdict::Unused => {
2852 if !is_test_file(&file.relative_file)
2853 && !export.test_only_reference_files.is_empty()
2854 {
2855 let mut item = json!({
2856 "file": file.relative_file,
2857 "symbol": export.symbol,
2858 "kind": export.kind,
2859 "line": export.line,
2860 "provenance": export.provenance,
2861 "used_by": export.test_only_reference_files,
2862 });
2863 add_oxc_reexport_contexts(&mut item, &export.also_reexported);
2864 if generated_file {
2865 item["generated"] = json!(true);
2866 generated_count += 1;
2867 generated_items.push(item);
2868 } else {
2869 test_only_count += 1;
2870 test_only_items.push(item);
2871 }
2872 continue;
2873 }
2874 if export.has_references {
2875 continue;
2876 }
2877 let mut item = json!({
2878 "file": file.relative_file,
2879 "symbol": export.symbol,
2880 "kind": export.kind,
2881 "line": export.line,
2882 "provenance": export.provenance,
2883 });
2884 add_oxc_reexport_contexts(&mut item, &export.also_reexported);
2885 if generated_file {
2886 item["generated"] = json!(true);
2887 generated_count += 1;
2888 generated_items.push(item);
2889 } else {
2890 count += 1;
2891 items.push(item);
2892 }
2893 }
2894 }
2895 }
2896 }
2897
2898 let items = super::entry_points::rank_and_truncate_items(items, &roles, drill_down_limit);
2899 let generated_items =
2900 super::entry_points::rank_and_truncate_items(generated_items, &roles, drill_down_limit);
2901 let top = super::entry_points::top_preview_symbols(&items);
2902 let generated_top = generated_items
2903 .iter()
2904 .take(super::entry_points::TOP_PREVIEW_ITEMS)
2905 .cloned()
2906 .collect::<Vec<_>>();
2907 let mut all_items = items;
2908 all_items.extend(generated_items.iter().cloned());
2909 if let Some(limit) = drill_down_limit {
2910 all_items.truncate(limit);
2911 }
2912 let test_only_items =
2913 super::entry_points::rank_and_truncate_items(test_only_items, &roles, drill_down_limit);
2914 let test_only_top = test_only_items
2915 .iter()
2916 .take(super::entry_points::TOP_PREVIEW_ITEMS)
2917 .cloned()
2918 .collect::<Vec<_>>();
2919 let (mut parse_errors, skipped_files) = unused_exports_honesty_fields(parsed);
2920 for scan in parsed {
2921 if let Some(oxc_facts) = &scan.oxc_facts {
2922 if oxc_facts.format_version != FACTS_FORMAT_VERSION {
2923 parse_errors.push(json!({
2924 "file": scan.file,
2925 "message": format!(
2926 "unsupported oxc facts format {}; expected {}",
2927 oxc_facts.format_version, FACTS_FORMAT_VERSION
2928 ),
2929 }));
2930 }
2931 }
2932 }
2933
2934 let mut aggregate = json!({
2935 "count": count,
2936 "generated_count": generated_count,
2937 "total_count": count + test_only_count + generated_count,
2938 "items": all_items,
2939 "top": top,
2940 "generated_items": generated_items,
2941 "generated_top": generated_top,
2942 "test_only_count": test_only_count,
2943 "test_only_items": test_only_items,
2944 "test_only_top": test_only_top,
2945 "drill_down_capped": drill_down_limit.is_some_and(|limit| count + generated_count > limit),
2946 "generated_drill_down_capped": drill_down_limit.is_some_and(|limit| generated_count > limit),
2947 "test_only_drill_down_capped": drill_down_limit.is_some_and(|limit| test_only_count > limit),
2948 "scanned_files": parsed.len(),
2949 "languages_skipped": skipped_languages(&job.scope_files, LanguageSkipMode::UnusedExports),
2950 "uncertain_count": uncertain_count,
2951 "uncertain_items": uncertain_items,
2952 "complete": parse_errors.is_empty() && skipped_files.is_empty(),
2953 });
2954 if !parse_errors.is_empty() {
2955 aggregate["parse_errors"] = Value::Array(parse_errors);
2956 }
2957 if !skipped_files.is_empty() {
2958 aggregate["skipped_files"] = Value::Array(skipped_files);
2959 }
2960 if !package_warnings.is_empty() {
2961 aggregate["note"] = Value::String(package_warnings.join("; "));
2962 }
2963 aggregate
2964}
2965
2966fn add_oxc_reexport_contexts(
2967 item: &mut Value,
2968 contexts: &[crate::inspect::oxc_engine::OxcReExportContext],
2969) {
2970 if !contexts.is_empty() {
2971 item["also_reexported"] = json!(contexts);
2972 }
2973}
2974
2975fn unused_exports_honesty_fields(parsed: &[UnusedExportsContribution]) -> (Vec<Value>, Vec<Value>) {
2976 let mut parse_error_keys = BTreeSet::new();
2977 let mut parse_errors = Vec::new();
2978 let mut skipped_file_keys = BTreeSet::new();
2979 let mut skipped_files = Vec::new();
2980 for contribution in parsed {
2981 for value in &contribution.parse_errors {
2982 let key = value.to_string();
2983 if parse_error_keys.insert(key) {
2984 parse_errors.push(value.clone());
2985 }
2986 }
2987 for value in &contribution.skipped_files {
2988 let key = value.to_string();
2989 if skipped_file_keys.insert(key) {
2990 skipped_files.push(value.clone());
2991 }
2992 }
2993 }
2994 (parse_errors, skipped_files)
2995}
2996
2997fn roll_up_duplicate_contributions(
2998 job: &InspectJob,
2999 contributions: &[FileContribution],
3000 drill_down_limit: Option<usize>,
3001) -> Value {
3002 super::scanners::duplicates::aggregate_duplicate_contributions_with_limit(
3003 contributions,
3004 skipped_languages(&job.scope_files, LanguageSkipMode::Duplicates),
3005 drill_down_limit,
3006 &job.config.inspect.duplicates.expected_mirrors,
3007 )
3008}
3009
3010fn roll_up_cycle_contributions(
3011 job: &InspectJob,
3012 contributions: &[FileContribution],
3013 drill_down_limit: Option<usize>,
3014) -> Value {
3015 super::scanners::cycles::aggregate_cycle_contributions_with_limit(
3016 &job.project_root,
3017 contributions,
3018 skipped_languages(&job.scope_files, LanguageSkipMode::Cycles),
3019 drill_down_limit,
3020 )
3021}
3022
3023fn cap_payload_drill_down(mut payload: Value, limit: usize) -> Value {
3024 let mut capped = false;
3025 if let Some(items) = payload.get_mut("items").and_then(Value::as_array_mut) {
3026 capped |= items.len() > limit;
3027 items.truncate(limit);
3028 }
3029 if let Some(groups) = payload.get_mut("groups").and_then(Value::as_array_mut) {
3030 capped |= groups.len() > limit;
3031 groups.truncate(limit);
3032 }
3033 if let Some(object) = payload.as_object_mut() {
3034 object.insert("drill_down_capped".to_string(), json!(capped));
3035 }
3036 payload
3037}
3038
3039const MAX_DRILL_DOWN_ITEMS: usize = 100;
3040
3041#[derive(Debug, Clone, Deserialize)]
3042struct ExportContribution {
3043 symbol: String,
3044 kind: String,
3045 line: u32,
3046 #[serde(default)]
3047 verdict: Option<LivenessVerdict>,
3048 #[serde(default)]
3049 reason: Option<String>,
3050 #[serde(default)]
3051 provenance: Option<String>,
3052}
3053
3054fn export_uses_oxc(export: &ExportContribution) -> bool {
3055 export.verdict.is_some() || export.provenance.as_deref() == Some(OXC_PROVENANCE)
3056}
3057
3058#[derive(Debug, Clone, Deserialize)]
3059struct DeadCodeRefreshContribution {
3060 #[serde(default)]
3061 facts_format_version: Option<u32>,
3062 #[serde(default)]
3063 oxc_facts: Option<OxcFactsContribution>,
3064}
3065
3066#[derive(Debug, Clone, Deserialize)]
3067struct UnusedExportsContribution {
3068 file: String,
3069 #[serde(default)]
3070 generated: bool,
3071 exports: Vec<ExportContribution>,
3072 #[serde(default)]
3073 imports: Vec<ImportContribution>,
3074 #[serde(default)]
3075 oxc_facts: Option<OxcFactsContribution>,
3076 #[serde(default)]
3077 parse_errors: Vec<Value>,
3078 #[serde(default)]
3079 skipped_files: Vec<Value>,
3080}
3081
3082#[derive(Debug, Clone, Deserialize)]
3083struct ImportContribution {
3084 resolved_file: Option<String>,
3085 named: Vec<String>,
3086}
3087
3088#[derive(Debug, Clone, Deserialize)]
3089struct OxcFactsContribution {
3090 format_version: u32,
3091 content_hash: String,
3092 exports: Vec<ExportFact>,
3093 imports: Vec<ImportFact>,
3094 re_exports: Vec<ReExportFact>,
3095 dynamic_imports: Vec<DynamicImportFact>,
3096 same_file_value_references: BTreeSet<String>,
3097 used_import_bindings: BTreeSet<String>,
3098 type_referenced_import_bindings: BTreeSet<String>,
3099 value_referenced_import_bindings: BTreeSet<String>,
3100 #[serde(default)]
3101 parse_error: Option<String>,
3102}
3103
3104#[derive(Debug, Clone, Copy)]
3105enum LanguageSkipMode {
3106 Duplicates,
3107 Cycles,
3108 UnusedExports,
3109}
3110
3111fn category_uses_oxc(category: InspectCategory) -> bool {
3112 matches!(
3113 category,
3114 InspectCategory::DeadCode | InspectCategory::UnusedExports | InspectCategory::Cycles
3115 )
3116}
3117
3118fn skipped_languages(files: &[PathBuf], mode: LanguageSkipMode) -> Vec<String> {
3119 files
3120 .iter()
3121 .filter_map(|file| skipped_language(file, mode))
3122 .collect::<BTreeSet<_>>()
3123 .into_iter()
3124 .collect()
3125}
3126
3127fn skipped_language(file: &Path, mode: LanguageSkipMode) -> Option<String> {
3128 let Some(language) = crate::parser::detect_language(file) else {
3129 return match mode {
3130 LanguageSkipMode::Duplicates => Some("unknown".to_string()),
3131 LanguageSkipMode::Cycles => Some("unknown".to_string()),
3132 LanguageSkipMode::UnusedExports => None,
3133 };
3134 };
3135
3136 let skipped = match mode {
3137 LanguageSkipMode::Duplicates => !duplicates_supports_language(language),
3138 LanguageSkipMode::Cycles => !is_js_ts_language(language),
3139 LanguageSkipMode::UnusedExports => !is_js_ts_language(language),
3140 };
3141 skipped.then(|| language_name(language).to_string())
3142}
3143
3144fn duplicates_supports_language(language: crate::parser::LangId) -> bool {
3145 !matches!(
3146 language,
3147 crate::parser::LangId::Bash
3148 | crate::parser::LangId::Html
3149 | crate::parser::LangId::Json
3150 | crate::parser::LangId::Scala
3151 | crate::parser::LangId::Solidity
3152 | crate::parser::LangId::Scss
3153 | crate::parser::LangId::Vue
3154 | crate::parser::LangId::Markdown
3155 | crate::parser::LangId::Java
3156 | crate::parser::LangId::Ruby
3157 | crate::parser::LangId::Kotlin
3158 | crate::parser::LangId::Swift
3159 | crate::parser::LangId::Php
3160 | crate::parser::LangId::Lua
3161 | crate::parser::LangId::Perl
3162 | crate::parser::LangId::Pascal
3163 | crate::parser::LangId::R
3164 | crate::parser::LangId::Groovy
3165 | crate::parser::LangId::ObjC
3166 )
3167}
3168
3169fn is_js_ts_language(language: crate::parser::LangId) -> bool {
3170 matches!(
3171 language,
3172 crate::parser::LangId::TypeScript
3173 | crate::parser::LangId::Tsx
3174 | crate::parser::LangId::JavaScript
3175 )
3176}
3177
3178fn language_name(language: crate::parser::LangId) -> &'static str {
3179 match language {
3180 crate::parser::LangId::TypeScript => "typescript",
3181 crate::parser::LangId::Tsx => "tsx",
3182 crate::parser::LangId::JavaScript => "javascript",
3183 crate::parser::LangId::Python => "python",
3184 crate::parser::LangId::Rust => "rust",
3185 crate::parser::LangId::Go => "go",
3186 crate::parser::LangId::C => "c",
3187 crate::parser::LangId::Cpp => "cpp",
3188 crate::parser::LangId::Zig => "zig",
3189 crate::parser::LangId::CSharp => "csharp",
3190 crate::parser::LangId::Bash => "bash",
3191 crate::parser::LangId::Html => "html",
3192 crate::parser::LangId::Markdown => "markdown",
3193 crate::parser::LangId::Yaml => "yaml",
3194 crate::parser::LangId::Solidity => "solidity",
3195 crate::parser::LangId::Scss => "scss",
3196 crate::parser::LangId::Vue => "vue",
3197 crate::parser::LangId::Json => "json",
3198 crate::parser::LangId::Scala => "scala",
3199 crate::parser::LangId::Java => "java",
3200 crate::parser::LangId::Ruby => "ruby",
3201 crate::parser::LangId::Kotlin => "kotlin",
3202 crate::parser::LangId::Swift => "swift",
3203 crate::parser::LangId::Php => "php",
3204 crate::parser::LangId::Lua => "lua",
3205 crate::parser::LangId::Perl => "perl",
3206 crate::parser::LangId::Pascal => "pascal",
3207 crate::parser::LangId::R => "r",
3208 crate::parser::LangId::Groovy => "groovy",
3209 crate::parser::LangId::ObjC => "objc",
3210 }
3211}
3212
3213fn unused_public_api_entries(project_root: &Path) -> (BTreeSet<String>, Vec<String>) {
3214 let entry_points = crate::inspect::entry_points::resolve_entry_points(project_root);
3215 (
3216 entry_points.public_api_files_relative(project_root),
3217 entry_points.warnings().to_vec(),
3218 )
3219}
3220
3221fn filter_outcome_for_scope_with_contributions(
3222 outcome: JobOutcome,
3223 snapshot: &InspectSnapshot,
3224 category: InspectCategory,
3225 cache: &(impl InspectCacheRead + ?Sized),
3226 scope: &JobScope,
3227) -> JobOutcome {
3228 if !category.is_tier2() || scope.is_project_wide() {
3229 return filter_outcome_for_scope(outcome, scope);
3230 }
3231
3232 match outcome {
3233 JobOutcome::Fresh { payload } => {
3234 match scoped_tier2_payload_from_contributions(snapshot, category, cache, payload, scope)
3235 {
3236 Ok(payload) => JobOutcome::Fresh { payload },
3237 Err(message) => JobOutcome::Failed { message },
3238 }
3239 }
3240 JobOutcome::Stale { cached, in_flight } => match cached {
3241 Some(payload) => {
3242 match scoped_tier2_payload_from_contributions(
3243 snapshot, category, cache, payload, scope,
3244 ) {
3245 Ok(payload) => JobOutcome::Stale {
3246 cached: Some(payload),
3247 in_flight,
3248 },
3249 Err(message) => JobOutcome::Failed { message },
3250 }
3251 }
3252 None => JobOutcome::Stale {
3253 cached: None,
3254 in_flight,
3255 },
3256 },
3257 JobOutcome::Pending { in_flight } => JobOutcome::Pending { in_flight },
3258 JobOutcome::Failed { message } => JobOutcome::Failed { message },
3259 }
3260}
3261
3262fn filter_outcome_for_scope(outcome: JobOutcome, scope: &JobScope) -> JobOutcome {
3263 match outcome {
3264 JobOutcome::Fresh { payload } => JobOutcome::Fresh {
3265 payload: filter_payload_for_scope(payload, scope),
3266 },
3267 JobOutcome::Stale { cached, in_flight } => JobOutcome::Stale {
3268 cached: cached.map(|payload| filter_payload_for_scope(payload, scope)),
3269 in_flight,
3270 },
3271 JobOutcome::Pending { in_flight } => JobOutcome::Pending { in_flight },
3272 JobOutcome::Failed { message } => JobOutcome::Failed { message },
3273 }
3274}
3275
3276fn filter_payload_for_scope(mut payload: serde_json::Value, scope: &JobScope) -> serde_json::Value {
3277 if scope.is_project_wide() {
3278 return payload;
3279 }
3280
3281 if let Some(items) = payload
3285 .get_mut("items")
3286 .and_then(|value| value.as_array_mut())
3287 {
3288 let count = filter_values_for_scope(items, scope);
3289 let largest_cycle = items
3290 .iter()
3291 .filter_map(|item| item.get("files").and_then(Value::as_array).map(Vec::len))
3292 .max();
3293 if let Some(object) = payload.as_object_mut() {
3294 object.insert("count".to_string(), serde_json::json!(count));
3295 if object.contains_key("largest") {
3296 object.insert(
3297 "largest".to_string(),
3298 serde_json::json!(largest_cycle.unwrap_or(0)),
3299 );
3300 }
3301 if object.contains_key("total_groups") {
3302 object.insert("total_groups".to_string(), serde_json::json!(count));
3303 }
3304 if object.contains_key("groups_count") {
3305 object.insert("groups_count".to_string(), serde_json::json!(count));
3306 }
3307 }
3308 }
3309
3310 if let Some(groups) = payload
3311 .get_mut("groups")
3312 .and_then(|value| value.as_array_mut())
3313 {
3314 let count = filter_values_for_scope(groups, scope);
3315 if let Some(object) = payload.as_object_mut() {
3316 object.insert("count".to_string(), serde_json::json!(count));
3317 object.insert("total_groups".to_string(), serde_json::json!(count));
3318 if object.contains_key("groups_count") {
3319 object.insert("groups_count".to_string(), serde_json::json!(count));
3320 }
3321 }
3322 }
3323
3324 if let Some(object) = payload.as_object_mut() {
3330 if object.contains_key("top") {
3331 if let Some(top) = recompute_scoped_top_preview(object) {
3332 object.insert("top".to_string(), top);
3333 } else if let Some(top) = object.get_mut("top").and_then(Value::as_array_mut) {
3334 filter_values_for_scope(top, scope);
3335 }
3336 }
3337 if object.contains_key("duplicated_lines") {
3338 recompute_duplicate_payload_stats(object);
3339 }
3340 object.remove("by_language");
3341 }
3342
3343 payload
3344}
3345
3346fn recompute_duplicate_payload_stats(object: &mut serde_json::Map<String, Value>) {
3347 let values = object
3348 .get("items")
3349 .or_else(|| object.get("groups"))
3350 .and_then(Value::as_array)
3351 .cloned()
3352 .unwrap_or_default();
3353 let (duplicated_lines, duplicated_file_count) = duplicate_line_stats_from_values(&values);
3354 let total_analyzed_lines = object
3355 .get("total_analyzed_lines")
3356 .and_then(Value::as_u64)
3357 .unwrap_or(0);
3358 let duplicated_percent = if total_analyzed_lines == 0 {
3359 0.0
3360 } else {
3361 (duplicated_lines as f64 * 100.0) / total_analyzed_lines as f64
3362 };
3363 object.insert("duplicated_lines".to_string(), json!(duplicated_lines));
3364 object.insert(
3365 "duplicated_file_count".to_string(),
3366 json!(duplicated_file_count),
3367 );
3368 object.insert("duplicated_percent".to_string(), json!(duplicated_percent));
3369}
3370
3371fn duplicate_line_stats_from_values(values: &[Value]) -> (u64, usize) {
3372 let mut by_file = BTreeMap::<String, Vec<(u64, u64)>>::new();
3373 for value in values {
3374 let Some(files) = value.get("files").and_then(Value::as_array) else {
3375 continue;
3376 };
3377 for occurrence in files.iter().filter_map(Value::as_str) {
3378 let Some((file, start, end)) = parse_duplicate_occurrence(occurrence) else {
3379 continue;
3380 };
3381 by_file
3382 .entry(file.to_string())
3383 .or_default()
3384 .push((start, end));
3385 }
3386 }
3387 let file_count = by_file.len();
3388 let duplicated_lines = by_file
3389 .values_mut()
3390 .map(|intervals| merged_duplicate_interval_lines(intervals))
3391 .sum();
3392 (duplicated_lines, file_count)
3393}
3394
3395fn merged_duplicate_interval_lines(intervals: &mut [(u64, u64)]) -> u64 {
3396 if intervals.is_empty() {
3397 return 0;
3398 }
3399 intervals.sort_by(|left, right| left.0.cmp(&right.0).then(left.1.cmp(&right.1)));
3400 let (mut current_start, mut current_end) = intervals[0];
3401 let mut total = 0;
3402 for &(start, end) in &intervals[1..] {
3403 if start <= current_end.saturating_add(1) {
3404 current_end = current_end.max(end);
3405 } else {
3406 total += current_end.saturating_sub(current_start).saturating_add(1);
3407 current_start = start;
3408 current_end = end;
3409 }
3410 }
3411 total + current_end.saturating_sub(current_start).saturating_add(1)
3412}
3413
3414fn recompute_scoped_top_preview(
3415 object: &serde_json::Map<String, Value>,
3416) -> Option<serde_json::Value> {
3417 let values = object
3418 .get("items")
3419 .or_else(|| object.get("groups"))
3420 .and_then(Value::as_array)?;
3421 Some(Value::Array(
3422 values
3423 .iter()
3424 .take(super::entry_points::TOP_PREVIEW_ITEMS)
3425 .map(top_preview_value)
3426 .collect(),
3427 ))
3428}
3429
3430fn top_preview_value(value: &Value) -> Value {
3431 if let Some(files) = value.get("files").and_then(Value::as_array) {
3432 let mut object = serde_json::Map::new();
3433 object.insert("files".to_string(), Value::Array(files.clone()));
3434 if let Some(cost) = value.get("cost").cloned() {
3435 object.insert("cost".to_string(), cost);
3436 }
3437 return Value::Object(object);
3438 }
3439
3440 json!({
3441 "file": value.get("file").and_then(Value::as_str).unwrap_or(""),
3442 "symbol": value.get("symbol").and_then(Value::as_str).unwrap_or(""),
3443 })
3444}
3445
3446fn filter_values_for_scope(values: &mut Vec<serde_json::Value>, scope: &JobScope) -> usize {
3447 values.retain_mut(|value| prune_value_for_scope(value, scope));
3448 values.len()
3449}
3450
3451fn prune_value_for_scope(value: &mut serde_json::Value, scope: &JobScope) -> bool {
3452 if let Some(file) = value.get("file").and_then(|file| file.as_str()) {
3453 return scope.contains_display_path(file);
3454 }
3455
3456 let first_scoped_occurrence = if let Some(files) = value
3457 .get_mut("files")
3458 .and_then(|files| files.as_array_mut())
3459 {
3460 files.retain(|file| {
3461 file.as_str()
3462 .is_some_and(|file| scope.contains_display_path(display_file_from_occurrence(file)))
3463 });
3464 if files.len() < 2 {
3465 return false;
3466 }
3467 files.first().and_then(Value::as_str).map(str::to_string)
3468 } else {
3469 None
3470 };
3471
3472 if let Some(occurrence) = first_scoped_occurrence {
3473 update_duplicate_group_sample(value, &occurrence);
3474 }
3475
3476 true
3477}
3478
3479fn update_duplicate_group_sample(value: &mut serde_json::Value, occurrence: &str) {
3480 let Some((file, start_line, end_line)) = parse_duplicate_occurrence(occurrence) else {
3481 return;
3482 };
3483 let Some(object) = value.as_object_mut() else {
3484 return;
3485 };
3486
3487 if object.contains_key("sample_file") {
3488 object.insert("sample_file".to_string(), json!(file));
3489 }
3490 if object.contains_key("sample_start_line") {
3491 object.insert("sample_start_line".to_string(), json!(start_line));
3492 }
3493 if object.contains_key("sample_end_line") {
3494 object.insert("sample_end_line".to_string(), json!(end_line));
3495 }
3496}
3497
3498fn parse_duplicate_occurrence(value: &str) -> Option<(&str, u64, u64)> {
3499 let (file, range) = value.rsplit_once(':')?;
3500 let (start, end) = range.split_once('-')?;
3501 if !start.chars().all(|char| char.is_ascii_digit())
3502 || !end.chars().all(|char| char.is_ascii_digit())
3503 {
3504 return None;
3505 }
3506
3507 Some((file, start.parse().ok()?, end.parse().ok()?))
3508}
3509
3510fn display_file_from_occurrence(value: &str) -> &str {
3511 let Some((file, range)) = value.rsplit_once(':') else {
3512 return value;
3513 };
3514 let Some((start, end)) = range.split_once('-') else {
3515 return value;
3516 };
3517 if start.chars().all(|char| char.is_ascii_digit())
3518 && end.chars().all(|char| char.is_ascii_digit())
3519 {
3520 file
3521 } else {
3522 value
3523 }
3524}
3525
3526#[cfg(test)]
3527mod guard_tests {
3528 use super::*;
3529
3530 fn write_ts_project(file_count: usize) -> tempfile::TempDir {
3531 let dir = tempfile::tempdir().expect("tempdir");
3532 let root = dir.path();
3533 for i in 0..file_count {
3534 std::fs::write(
3535 root.join(format!("mod{i}.ts")),
3536 format!("export function f{i}() {{ return {i}; }}\n"),
3537 )
3538 .expect("write fixture");
3539 }
3540 dir
3541 }
3542
3543 #[test]
3544 fn scoped_filter_recomputes_top_preview_from_scoped_items() {
3545 let project_root = PathBuf::from("/project");
3546 let scope = JobScope::from_roots(project_root.clone(), vec![project_root.join("src/in")]);
3547 let payload = json!({
3548 "count": 4,
3549 "items": [
3550 { "file": "src/out/a.ts", "symbol": "outside" },
3551 { "file": "src/in/b.ts", "symbol": "inside_b" },
3552 { "file": "src/in/c.ts", "symbol": "inside_c" }
3553 ],
3554 "top": [
3555 { "file": "src/out/a.ts", "symbol": "outside" },
3556 { "file": "src/out/z.ts", "symbol": "outside_z" }
3557 ],
3558 "by_language": { "typescript": 4 }
3559 });
3560
3561 let filtered = filter_payload_for_scope(payload, &scope);
3562
3563 assert_eq!(filtered["count"], json!(2));
3564 assert_eq!(
3565 filtered["top"],
3566 json!([
3567 { "file": "src/in/b.ts", "symbol": "inside_b" },
3568 { "file": "src/in/c.ts", "symbol": "inside_c" }
3569 ])
3570 );
3571 assert!(filtered["top"]
3572 .as_array()
3573 .unwrap()
3574 .iter()
3575 .all(|item| item["file"]
3576 .as_str()
3577 .is_some_and(|file| file.starts_with("src/in/"))));
3578 }
3579
3580 fn artifact_cache_key_for_test(project_root: &std::path::Path) -> String {
3581 let _git_env = crate::test_env::hermetic_git_env_guard();
3582 crate::search_index::artifact_cache_key(project_root)
3583 }
3584
3585 #[test]
3586 fn cache_for_paths_rebinds_same_project_key_to_current_root() {
3587 let _git_env = crate::test_env::hermetic_git_env_guard();
3588 let dir = tempfile::tempdir().expect("tempdir");
3589 let source = dir.path().join("source");
3590 std::fs::create_dir_all(&source).expect("create source repo");
3591 std::fs::write(
3592 source.join("package.json"),
3593 r#"{"name":"inspect-cache-fixture","version":"1.0.0"}"#,
3594 )
3595 .expect("write source manifest");
3596 std::fs::write(source.join("index.ts"), "export const source = 1;\n")
3597 .expect("write source file");
3598 let mut init = std::process::Command::new("git");
3599 assert!(
3600 crate::test_env::apply_hermetic_git_env(init.current_dir(&source))
3601 .arg("init")
3602 .status()
3603 .expect("git init source repo")
3604 .success()
3605 );
3606 let mut add = std::process::Command::new("git");
3607 assert!(
3608 crate::test_env::apply_hermetic_git_env(add.current_dir(&source))
3609 .args(["add", "."])
3610 .status()
3611 .expect("git add source repo")
3612 .success()
3613 );
3614 let mut commit = std::process::Command::new("git");
3615 assert!(
3616 crate::test_env::apply_hermetic_git_env(commit.current_dir(&source))
3617 .args([
3618 "-c",
3619 "user.name=AFT Tests",
3620 "-c",
3621 "user.email=aft-tests@example.com",
3622 "commit",
3623 "-m",
3624 "initial",
3625 ])
3626 .status()
3627 .expect("git commit source repo")
3628 .success()
3629 );
3630
3631 let clone = dir.path().join("clone");
3632 let mut clone_command = std::process::Command::new("git");
3633 assert!(crate::test_env::apply_hermetic_git_env(&mut clone_command)
3634 .args(["clone", "--quiet"])
3635 .arg(&source)
3636 .arg(&clone)
3637 .status()
3638 .expect("git clone source repo")
3639 .success());
3640 std::fs::write(
3641 clone.join("package.json"),
3642 r#"{"name":"inspect-cache-fixture","version":"2.0.0"}"#,
3643 )
3644 .expect("write clone manifest edit");
3645 assert_eq!(
3646 artifact_cache_key_for_test(&source),
3647 artifact_cache_key_for_test(&clone),
3648 "clones with the same root commit should share the sqlite project key"
3649 );
3650
3651 let source = std::fs::canonicalize(source).expect("canonical source root");
3652 let clone = std::fs::canonicalize(clone).expect("canonical clone root");
3653 let manager = InspectManager::new();
3654 let inspect_dir = dir.path().join("inspect");
3655 let key = JobKey::for_project_category(InspectCategory::DeadCode);
3656 let source_cache = manager
3657 .cache_for_paths(inspect_dir.clone(), source.clone())
3658 .expect("open source cache");
3659 let source_hash = source_cache
3660 .contribution_set_hash(InspectCategory::DeadCode)
3661 .expect("source contribution hash");
3662 source_cache
3663 .store_tier2_aggregate(
3664 key.clone(),
3665 &source_hash,
3666 serde_json::json!({ "count": 7, "items": [] }),
3667 )
3668 .expect("store source aggregate");
3669 assert_eq!(
3670 source_cache
3671 .get_aggregated(&key)
3672 .expect("read source aggregate")
3673 .and_then(|payload| payload.get("count").and_then(Value::as_u64)),
3674 Some(7)
3675 );
3676
3677 let clone_cache = manager
3678 .cache_for_paths(inspect_dir, clone.clone())
3679 .expect("open clone cache");
3680 assert_eq!(clone_cache.project_root(), clone.as_path());
3681 assert!(
3682 clone_cache
3683 .get_aggregated(&key)
3684 .expect("read clone aggregate")
3685 .is_none(),
3686 "same-key clone with a different manifest must not reuse the source root's cached count"
3687 );
3688 }
3689
3690 fn snapshot_job(root: &Path, inspect_dir: &Path, callgraph_store: bool) -> InspectJob {
3691 use crate::config::Config;
3692 use crate::parser::SymbolCache;
3693 use std::sync::RwLock;
3694
3695 InspectJob {
3696 job_id: 1,
3697 key: JobKey::for_project_category(InspectCategory::DeadCode),
3698 category: InspectCategory::DeadCode,
3699 scope_files: Vec::new(),
3700 project_root: root.to_path_buf(),
3701 inspect_dir: inspect_dir.to_path_buf(),
3702 config: Arc::new(Config {
3703 project_root: Some(root.to_path_buf()),
3704 callgraph_store,
3705 ..Config::default()
3706 }),
3707 symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
3708 inspect_writer: true,
3709 callgraph_writer: true,
3710 callgraph_snapshot: None,
3711 }
3712 }
3713
3714 fn generated_unused_exports_fixture() -> (tempfile::TempDir, PathBuf, Vec<PathBuf>) {
3715 let dir = tempfile::tempdir().expect("tempdir");
3716 let root = dir.path().to_path_buf();
3717 let files = [
3718 (
3719 "src/hand.ts",
3720 "export function handUnused() {}
3721",
3722 ),
3723 (
3724 "gen/schema_pb.ts",
3725 "export function generatedPathUnused() {}
3726",
3727 ),
3728 (
3729 "src/banner.ts",
3730 "// Code generated by fixture. DO NOT EDIT.
3731export function bannerUnused() {}
3732",
3733 ),
3734 ];
3735 let paths = files
3736 .iter()
3737 .map(|(relative, contents)| {
3738 let path = root.join(relative);
3739 if let Some(parent) = path.parent() {
3740 std::fs::create_dir_all(parent).expect("create parent");
3741 }
3742 std::fs::write(&path, contents).expect("write fixture file");
3743 std::fs::canonicalize(path).expect("canonical fixture path")
3744 })
3745 .collect::<Vec<_>>();
3746 (
3747 dir,
3748 std::fs::canonicalize(root).expect("canonical root"),
3749 paths,
3750 )
3751 }
3752
3753 fn unused_exports_job(root: &Path, scope_files: Vec<PathBuf>) -> InspectJob {
3754 use crate::config::Config;
3755 use crate::parser::SymbolCache;
3756 use std::sync::RwLock;
3757
3758 InspectJob {
3759 job_id: 1,
3760 key: JobKey::for_project_category(InspectCategory::UnusedExports),
3761 category: InspectCategory::UnusedExports,
3762 scope_files,
3763 project_root: root.to_path_buf(),
3764 inspect_dir: root.join(".aft-cache").join("inspect"),
3765 config: Arc::new(Config {
3766 project_root: Some(root.to_path_buf()),
3767 ..Config::default()
3768 }),
3769 symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
3770 inspect_writer: true,
3771 callgraph_writer: true,
3772 callgraph_snapshot: None,
3773 }
3774 }
3775
3776 #[test]
3777 fn unused_exports_oxc_cached_rollup_preserves_generated_split() {
3778 let (_dir, root, paths) = generated_unused_exports_fixture();
3779 let job = unused_exports_job(&root, paths.clone());
3780 let entry_points = crate::inspect::entry_points::resolve_entry_points(&root);
3781 let oxc_result = crate::inspect::oxc_engine::analyze_files(
3782 &root,
3783 &paths,
3784 AnalyzeOptions {
3785 entry_points: Vec::new(),
3786 public_api_files: entry_points.public_api_files(),
3787 executable_root_exports: entry_points.executable_root_exports(),
3788 force_reparse_files: Vec::new(),
3789 entry_reachability: false,
3790 },
3791 )
3792 .expect("oxc analyze succeeds");
3793 let fresh = crate::inspect::scanners::unused_exports::run_unused_exports_scan_with_oxc(
3794 &job,
3795 Some(&oxc_result),
3796 )
3797 .outcome
3798 .expect("fresh scan succeeds");
3799
3800 let rolled_up = roll_up_unused_exports_contributions(
3801 &job,
3802 &fresh.contributions,
3803 Some(MAX_DRILL_DOWN_ITEMS),
3804 );
3805
3806 assert_eq!(
3807 rolled_up, fresh.aggregate,
3808 "cached rollup must match fresh scan"
3809 );
3810 assert_eq!(rolled_up["count"], 1, "{rolled_up:#}");
3811 assert_eq!(rolled_up["generated_count"], 2, "{rolled_up:#}");
3812 assert_eq!(rolled_up["total_count"], 3, "{rolled_up:#}");
3813 }
3814
3815 #[test]
3816 fn callgraph_snapshot_reports_unavailable_when_store_disabled() {
3817 let dir = write_ts_project(3);
3818 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
3819 let inspect_dir = root.join(".aft-cache").join("inspect");
3820
3821 let snapshot =
3822 build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, false), false);
3823
3824 assert!(
3825 snapshot.is_none(),
3826 "dead_code must not rebuild the legacy graph when the store is disabled"
3827 );
3828 }
3829
3830 #[test]
3831 fn callgraph_snapshot_reports_unavailable_when_store_not_ready() {
3832 let dir = write_ts_project(3);
3833 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
3834 let inspect_dir = root.join(".aft-cache").join("inspect");
3835 let callgraph_dir =
3836 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
3837 let _store = CallGraphStore::open(callgraph_dir, root.clone()).expect("open empty store");
3838
3839 let snapshot =
3840 build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true), false);
3841
3842 assert!(
3843 snapshot.is_none(),
3844 "a cold/mid-build store must surface callgraph_unavailable instead of rebuilding inline"
3845 );
3846 }
3847
3848 #[test]
3849 fn direct_callgraph_snapshot_does_not_cold_rebuild_when_store_needs_rebuild() {
3850 let dir = write_ts_project(3);
3851 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
3852 let inspect_dir = root.join(".aft-cache").join("inspect");
3853 let callgraph_dir =
3854 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
3855 let store = CallGraphStore::open(callgraph_dir.clone(), root.clone()).expect("open store");
3856 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
3857 store.cold_build(&files).expect("cold build store");
3858 let sqlite_path = store.sqlite_path().to_path_buf();
3859 drop(store);
3860
3861 let still_existing_previous_root = root.with_file_name("previous-root-still-exists");
3862 std::fs::create_dir_all(&still_existing_previous_root).expect("create previous root");
3863 let conn = rusqlite::Connection::open(&sqlite_path).expect("open store sqlite");
3864 conn.execute(
3865 "UPDATE backend_file_state SET workspace_root = ?1",
3866 rusqlite::params![still_existing_previous_root.display().to_string()],
3867 )
3868 .expect("force root repair rebuild state");
3869 drop(conn);
3870
3871 let snapshot =
3872 build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true), false)
3873 .expect("readonly snapshot should avoid cold-rebuilding the store");
3874
3875 assert_eq!(snapshot.files.len(), 3);
3876 let conn = rusqlite::Connection::open(&sqlite_path).expect("reopen store sqlite");
3877 let stored_root: String = conn
3878 .query_row(
3879 "SELECT workspace_root FROM backend_file_state LIMIT 1",
3880 [],
3881 |row| row.get(0),
3882 )
3883 .expect("read stored root");
3884 assert_eq!(
3885 stored_root,
3886 still_existing_previous_root.display().to_string(),
3887 "direct inspect must not cold-rebuild or re-root a read-only snapshot"
3888 );
3889 }
3890
3891 #[test]
3892 fn callgraph_snapshot_reads_ready_callgraph_store() {
3893 let dir = write_ts_project(3);
3894 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
3895 let inspect_dir = root.join(".aft-cache").join("inspect");
3896 let callgraph_dir =
3897 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
3898 let store = CallGraphStore::open(callgraph_dir, root.clone()).expect("open store");
3899 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
3900 store.cold_build(&files).expect("cold build store");
3901
3902 let snapshot =
3903 build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true), false)
3904 .expect("ready store snapshot");
3905
3906 assert_eq!(snapshot.files.len(), 3);
3907 assert_eq!(snapshot.exported_symbols.len(), 3);
3908 }
3909
3910 #[test]
3911 fn callgraph_snapshot_uses_ready_root_keyed_store() {
3912 let _git_env = crate::test_env::hermetic_git_env_guard();
3913 let dir = write_ts_project(3);
3914 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
3915 let storage_dir = root.join(".aft-cache");
3916 let inspect_dir = storage_dir
3917 .join("inspect")
3918 .join(crate::path_identity::project_scope_key(&root));
3919 let warm_callgraph_dir = storage_dir
3920 .join("callgraph")
3921 .join(artifact_cache_key_for_test(&root));
3922 let store = CallGraphStore::open(warm_callgraph_dir, root.clone()).expect("open store");
3923 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
3924 store.cold_build(&files).expect("cold build store");
3925
3926 let snapshot =
3927 build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true), false)
3928 .expect("ready sibling store snapshot");
3929
3930 assert_eq!(snapshot.files.len(), 3);
3931 assert_eq!(snapshot.exported_symbols.len(), 3);
3932 }
3933
3934 #[test]
3935 fn dead_code_forced_deletion_refreshes_callgraph_store_before_rollup() {
3936 let dir = tempfile::tempdir().expect("tempdir");
3937 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
3938 write_fixture_file(
3939 &root,
3940 "package.json",
3941 r#"{"name":"dead-code-delete-refresh","type":"module","main":"src/main.ts"}"#,
3942 3_100_000_000,
3943 );
3944 write_fixture_file(
3945 &root,
3946 "src/main.ts",
3947 "export function main() {}\n",
3948 3_100_000_001,
3949 );
3950 write_fixture_file(
3951 &root,
3952 "src/dead.ts",
3953 "export function plantedDead() {}\n",
3954 3_100_000_002,
3955 );
3956
3957 let inspect_dir = root.join(".aft-cache").join("opencode").join("inspect");
3958 let callgraph_dir =
3959 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
3960 let store = CallGraphStore::open(callgraph_dir.clone(), root.clone()).expect("open store");
3961 let project_files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
3962 store.cold_build(&project_files).expect("cold build store");
3963 drop(store);
3964
3965 let config = Arc::new(crate::config::Config {
3966 project_root: Some(root.clone()),
3967 callgraph_store: true,
3968 ..crate::config::Config::default()
3969 });
3970 let symbol_cache = Arc::new(std::sync::RwLock::new(crate::parser::SymbolCache::new()));
3971 let snapshot = InspectSnapshot::new(
3972 root.clone(),
3973 inspect_dir.clone(),
3974 Arc::clone(&config),
3975 Arc::clone(&symbol_cache),
3976 );
3977 let manager = InspectManager::new();
3978 let initial_job =
3979 manager.tier2_reuse_job(snapshot.clone(), InspectCategory::DeadCode, None);
3980 let initial = manager
3981 .tier2_run_with_reuse_job_result_with_options(initial_job, Tier2ReuseOptions::default())
3982 .outcome
3983 .expect("initial dead_code scan succeeds")
3984 .aggregate;
3985 assert!(
3986 aggregate_has_file_symbol(&initial, "src/dead.ts", "plantedDead"),
3987 "initial scan should report the planted dead export: {initial:#}"
3988 );
3989
3990 let deleted = root.join("src/dead.ts");
3991 std::fs::remove_file(&deleted).expect("delete dead fixture");
3992 let delete_job = manager.tier2_reuse_job(snapshot, InspectCategory::DeadCode, None);
3993 let refreshed = manager
3994 .tier2_run_with_reuse_job_result_with_options(
3995 delete_job,
3996 Tier2ReuseOptions::direct(vec![deleted.clone()]),
3997 )
3998 .outcome
3999 .expect("delete refresh dead_code scan succeeds")
4000 .aggregate;
4001
4002 assert_eq!(
4003 refreshed
4004 .get("callgraph_available")
4005 .and_then(Value::as_bool),
4006 Some(true),
4007 "forced watcher paths must keep the callgraph-backed aggregate available: {refreshed:#}"
4008 );
4009 assert!(
4010 !aggregate_has_file_symbol(&refreshed, "src/dead.ts", "plantedDead"),
4011 "delete refresh should remove the planted dead export: {refreshed:#}"
4012 );
4013
4014 let store = CallGraphStore::open_ready_no_rebuild(callgraph_dir, root)
4015 .expect("open refreshed store")
4016 .expect("refreshed store is ready");
4017 let projected = project_dead_code_snapshot(store.sqlite_path()).expect("project snapshot");
4018 assert!(
4019 projected
4020 .files
4021 .iter()
4022 .all(|file| !file.ends_with("src/dead.ts")),
4023 "watcher deletion should be applied to the persisted callgraph store: {:#?}",
4024 projected.files
4025 );
4026 }
4027
4028 fn aggregate_has_file_symbol(aggregate: &Value, file: &str, symbol: &str) -> bool {
4029 aggregate
4030 .get("items")
4031 .and_then(Value::as_array)
4032 .is_some_and(|items| {
4033 items.iter().any(|item| {
4034 item.get("file").and_then(Value::as_str) == Some(file)
4035 && item.get("symbol").and_then(Value::as_str) == Some(symbol)
4036 })
4037 })
4038 }
4039
4040 #[test]
4044 fn scoped_filter_drops_project_wide_by_language() {
4045 let scope = JobScope::from_roots("/proj", vec![PathBuf::from("/proj/src/a")]);
4046 assert!(
4047 !scope.is_project_wide(),
4048 "scope must be non-project for test"
4049 );
4050 let payload = serde_json::json!({
4051 "count": 99,
4052 "by_language": { "rust": 214, "typescript": 143 },
4053 "items": [
4054 { "file": "/proj/src/a/x.rs", "symbol": "live" },
4055 { "file": "/proj/src/other/y.rs", "symbol": "out" },
4056 ],
4057 });
4058 let filtered = filter_payload_for_scope(payload, &scope);
4059 assert!(
4060 filtered.get("by_language").is_none(),
4061 "scoped payload must drop project-wide by_language: {filtered}"
4062 );
4063 assert_eq!(filtered.get("count").and_then(|v| v.as_u64()), Some(1));
4065 }
4066 #[cfg(debug_assertions)]
4067 #[test]
4068 fn tier2_read_cached_freshness_does_not_hash_unchanged_contributions() {
4069 let (_dir, manager, snapshot, scope, _files) = duplicate_cache_fixture();
4070 let fixture_root = snapshot.project_root.clone();
4071
4072 crate::cache_freshness::reset_hash_file_if_small_count_for_debug();
4073 crate::cache_freshness::reset_verify_file_strict_count_for_debug();
4074 assert_fresh(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
4075
4076 assert_eq!(
4077 crate::cache_freshness::verify_file_strict_count_under_for_debug(&fixture_root),
4078 0,
4079 "dispatch-thread inspect freshness must not use strict verification"
4080 );
4081 assert_eq!(
4082 crate::cache_freshness::hash_file_if_small_count_for_debug(),
4083 0,
4084 "unchanged contribution files must stay on the stat-only fast path"
4085 );
4086 }
4087
4088 #[cfg(debug_assertions)]
4089 #[test]
4090 fn tier2_read_cached_freshness_returns_byte_identical_cold_scan_aggregate() {
4091 let (_dir, manager, snapshot, scope, _files) = duplicate_uncached_fixture();
4092 let cold_payload = fresh_payload(manager.tier2_run_with_reuse(
4093 snapshot.clone(),
4094 InspectCategory::Duplicates,
4095 scope.clone(),
4096 None,
4097 ));
4098
4099 crate::cache_freshness::reset_hash_file_if_small_count_for_debug();
4100 crate::cache_freshness::reset_verify_file_strict_count_for_debug();
4101 let fixture_root = snapshot.project_root.clone();
4102 let warm_payload =
4103 fresh_payload(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
4104
4105 let cold_bytes = serde_json::to_vec(&cold_payload).expect("serialize cold aggregate");
4106 let warm_bytes = serde_json::to_vec(&warm_payload).expect("serialize warm aggregate");
4107 assert_eq!(
4108 warm_bytes, cold_bytes,
4109 "warm unchanged read must return the byte-identical aggregate as the cold scan"
4110 );
4111 assert_eq!(
4112 crate::cache_freshness::verify_file_strict_count_under_for_debug(&fixture_root),
4113 0,
4114 "dispatch-thread warm read must not use strict verification"
4115 );
4116 assert_eq!(
4117 crate::cache_freshness::hash_file_if_small_count_for_debug(),
4118 0,
4119 "warm unchanged read must not content-hash cached contribution files"
4120 );
4121 }
4122
4123 #[test]
4124 fn tier2_read_cached_freshness_detects_changed_added_and_deleted_files() {
4125 let (_dir, manager, snapshot, scope, _files) = duplicate_cache_fixture();
4126 write_fixture_file(
4127 &snapshot.project_root,
4128 "src/foo.ts",
4129 "export const foo = 101;\nexport const changed = true;\n",
4130 3_000_000_001,
4131 );
4132 assert_stale(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
4133
4134 let (_dir, manager, snapshot, scope, _files) = duplicate_cache_fixture();
4135 write_fixture_file(
4136 &snapshot.project_root,
4137 "src/added.ts",
4138 "export const added = 3;\n",
4139 3_000_000_002,
4140 );
4141 assert_stale(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
4142
4143 let (_dir, manager, snapshot, scope, files) = duplicate_cache_fixture();
4144 std::fs::remove_file(&files[0]).expect("delete cached contribution file");
4145 assert_stale(manager.tier2_read_cached(snapshot, InspectCategory::Duplicates, scope));
4146 }
4147
4148 fn duplicate_cache_fixture() -> (
4149 tempfile::TempDir,
4150 InspectManager,
4151 InspectSnapshot,
4152 JobScope,
4153 Vec<PathBuf>,
4154 ) {
4155 let (dir, manager, snapshot, scope, files) = duplicate_uncached_fixture();
4156 store_duplicate_cache(&manager, &snapshot, &files);
4157 (dir, manager, snapshot, scope, files)
4158 }
4159
4160 fn duplicate_uncached_fixture() -> (
4161 tempfile::TempDir,
4162 InspectManager,
4163 InspectSnapshot,
4164 JobScope,
4165 Vec<PathBuf>,
4166 ) {
4167 use crate::config::Config;
4168 use crate::parser::SymbolCache;
4169 use std::sync::RwLock;
4170
4171 let dir = tempfile::tempdir().expect("tempdir");
4172 let root = std::fs::canonicalize(dir.path()).expect("canonical fixture root");
4173 let files = vec![
4174 write_fixture_file(
4175 &root,
4176 "src/foo.ts",
4177 "export const fixture = () => 1;
4178export const shared = 1;
4179",
4180 3_000_000_000,
4181 ),
4182 write_fixture_file(
4183 &root,
4184 "src/bar.ts",
4185 "export const fixture = () => 1;
4186export const shared = 1;
4187",
4188 3_000_000_000,
4189 ),
4190 ];
4191 let inspect_dir = root.join(".aft-cache").join("inspect");
4192 let snapshot = InspectSnapshot::new(
4193 root.clone(),
4194 inspect_dir,
4195 Arc::new(Config {
4196 project_root: Some(root.clone()),
4197 ..Config::default()
4198 }),
4199 Arc::new(RwLock::new(SymbolCache::new())),
4200 );
4201 let scope = JobScope::for_project(root);
4202 let manager = InspectManager::new();
4203 (dir, manager, snapshot, scope, files)
4204 }
4205
4206 fn write_fixture_file(root: &Path, relative: &str, content: &str, mtime_secs: i64) -> PathBuf {
4207 let path = root.join(relative);
4208 if let Some(parent) = path.parent() {
4209 std::fs::create_dir_all(parent).expect("create fixture parent");
4210 }
4211 std::fs::write(&path, content).expect("write fixture file");
4212 filetime::set_file_mtime(&path, filetime::FileTime::from_unix_time(mtime_secs, 0))
4213 .expect("set fixture mtime");
4214 path
4215 }
4216
4217 fn store_duplicate_cache(
4218 manager: &InspectManager,
4219 snapshot: &InspectSnapshot,
4220 files: &[PathBuf],
4221 ) {
4222 let cache = manager
4223 .cache_for_snapshot(snapshot)
4224 .expect("open inspect cache");
4225 let contributions = files
4226 .iter()
4227 .map(|file| {
4228 let freshness = crate::cache_freshness::collect(file).expect("collect freshness");
4229 FileContribution::new(
4230 InspectCategory::Duplicates,
4231 file.clone(),
4232 freshness,
4233 serde_json::json!({
4234 "file": relative_cache_key(&snapshot.project_root, file),
4235 "fragments": [],
4236 }),
4237 )
4238 })
4239 .collect::<Vec<_>>();
4240 cache
4241 .store_tier2_result(
4242 JobKey::for_project_category(InspectCategory::Duplicates),
4243 files,
4244 &contributions,
4245 serde_json::json!({
4246 "count": 0,
4247 "groups": [],
4248 "scanned_files": files.len(),
4249 "total_groups": 0,
4250 }),
4251 )
4252 .expect("store tier2 cache fixture");
4253 }
4254
4255 fn assert_fresh(outcome: JobOutcome) {
4256 let _ = fresh_payload(outcome);
4257 }
4258
4259 fn fresh_payload(outcome: JobOutcome) -> Value {
4260 match outcome {
4261 JobOutcome::Fresh { payload } => payload,
4262 other => panic!("expected fresh cached Tier-2 outcome, got {other:?}"),
4263 }
4264 }
4265
4266 fn assert_stale(outcome: JobOutcome) {
4267 match outcome {
4268 JobOutcome::Stale { .. } => {}
4269 other => panic!("expected stale cached Tier-2 outcome, got {other:?}"),
4270 }
4271 }
4272}
4273
4274#[cfg(test)]
4275mod dead_code_projection_tests {
4276 use super::*;
4277 use crate::callgraph::walk_project_files;
4278 use crate::callgraph_store::{project_dead_code_snapshot, CallGraphStore};
4279 use crate::config::Config;
4280 use crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR;
4281 use crate::inspect::scanners::DEFAULT_EXPORT_MARKER_KIND;
4282 use crate::parser::SymbolCache;
4283 use filetime::FileTime;
4284 use std::sync::atomic::{AtomicI64, Ordering as AtomicOrdering};
4285 use std::sync::RwLock;
4286
4287 static NEXT_MTIME: AtomicI64 = AtomicI64::new(1_900_000_000);
4288
4289 #[test]
4290 fn scoped_dead_code_rollup_uses_ready_callgraph_and_degrades_without_it() {
4291 let dir = tempfile::tempdir().expect("tempdir");
4292 write_projection_fixture(dir.path());
4293 let root = canonical_root(dir.path());
4294 let inspect_dir = root.join(".aft-cache").join("inspect");
4295 let callgraph_dir =
4296 callgraph_store_dir_from_inspect_dir(&inspect_dir, &root).expect("store dir");
4297 let store = CallGraphStore::open(callgraph_dir.clone(), root.clone()).expect("open store");
4298 let files = project_files(&root);
4299 store.cold_build(&files).expect("cold build store");
4300 let projected = project_dead_code_snapshot(store.sqlite_path()).expect("project snapshot");
4301 drop(store);
4302
4303 let config = Arc::new(Config {
4304 project_root: Some(root.clone()),
4305 callgraph_store: true,
4306 ..Config::default()
4307 });
4308 let symbol_cache = Arc::new(RwLock::new(SymbolCache::new()));
4309 let scan_job = InspectJob {
4310 job_id: 87,
4311 key: JobKey::for_project_category(InspectCategory::DeadCode),
4312 category: InspectCategory::DeadCode,
4313 scope_files: files.clone(),
4314 project_root: root.clone(),
4315 inspect_dir: inspect_dir.clone(),
4316 config: Arc::clone(&config),
4317 symbol_cache: Arc::clone(&symbol_cache),
4318 inspect_writer: true,
4319 callgraph_writer: true,
4320 callgraph_snapshot: Some(Arc::new(projected)),
4321 };
4322 let success = crate::inspect::scanners::dead_code::run_dead_code_scan(&scan_job)
4323 .outcome
4324 .expect("dead_code scan succeeds");
4325 let cache = InspectCache::open(inspect_dir.clone(), root.clone()).expect("open cache");
4326 cache
4327 .store_tier2_result(
4328 scan_job.key.clone(),
4329 &success.scanned_files,
4330 &success.contributions,
4331 success.aggregate.clone(),
4332 )
4333 .expect("store tier2 result");
4334
4335 let snapshot = InspectSnapshot::new(root.clone(), inspect_dir, config, symbol_cache);
4336 let scope = JobScope::from_roots(root.clone(), vec![root.join("src/live.ts")]);
4337 assert!(
4338 !scope.is_project_wide(),
4339 "live.ts file scope must be scoped"
4340 );
4341
4342 let ready_payload = scoped_tier2_payload_from_contributions(
4343 &snapshot,
4344 InspectCategory::DeadCode,
4345 &cache,
4346 success.aggregate.clone(),
4347 &scope,
4348 )
4349 .expect("ready scoped payload");
4350 assert_eq!(
4351 ready_payload
4352 .get("callgraph_available")
4353 .and_then(Value::as_bool),
4354 Some(true),
4355 "ready store should produce a callgraph-backed scoped rollup: {ready_payload:#}"
4356 );
4357 assert_live_item(&ready_payload, "src/live.ts", "knownLive");
4358
4359 std::fs::remove_dir_all(&callgraph_dir).expect("remove ready callgraph store");
4360 let unavailable_payload = scoped_tier2_payload_from_contributions(
4361 &snapshot,
4362 InspectCategory::DeadCode,
4363 &cache,
4364 success.aggregate,
4365 &scope,
4366 )
4367 .expect("unavailable scoped payload");
4368 assert_eq!(
4369 unavailable_payload
4370 .get("callgraph_available")
4371 .and_then(Value::as_bool),
4372 Some(false),
4373 "missing store must report callgraph_unavailable instead of fabricating an empty graph: {unavailable_payload:#}"
4374 );
4375 assert_live_item(&unavailable_payload, "src/live.ts", "knownLive");
4376 }
4377 #[derive(Debug, PartialEq, Eq)]
4378 struct ComparableSnapshot {
4379 files: BTreeSet<PathBuf>,
4380 exported_symbols: BTreeSet<(PathBuf, String, String, u32)>,
4381 outbound_calls: BTreeSet<(PathBuf, String, String, u32)>,
4382 entry_points: BTreeSet<PathBuf>,
4383 entry_point_symbols: BTreeMap<PathBuf, BTreeSet<String>>,
4384 }
4385
4386 #[test]
4387 fn dead_code_projection_contains_expected_fixture_surface() {
4388 let dir = tempfile::tempdir().expect("tempdir");
4389 write_projection_fixture(dir.path());
4390 let root = canonical_root(dir.path());
4391 let projected = store_projected_snapshot(&root, ".store-dead-code-surface");
4392
4393 assert_projection_fixture_coverage(&root, &projected);
4394 }
4395
4396 #[test]
4397 fn dead_code_projection_incremental_scenario_matrix_matches_cold_rebuild() {
4398 run_projection_scenario("rename", setup_projection_rename, edit_projection_rename);
4399 run_projection_scenario("delete", setup_projection_delete, edit_projection_delete);
4400 run_projection_scenario(
4401 "barrel delete",
4402 setup_projection_barrel,
4403 edit_projection_barrel_delete,
4404 );
4405 run_projection_scenario(
4406 "dispatch edit",
4407 setup_projection_dispatch,
4408 edit_projection_dispatch,
4409 );
4410 run_projection_scenario(
4411 "body-only edit",
4412 setup_projection_body_only,
4413 edit_projection_body_only,
4414 );
4415 }
4416
4417 #[test]
4418 fn dead_code_projection_dead_code_scan_reports_expected_verdicts() {
4419 let dir = tempfile::tempdir().expect("tempdir");
4420 write_projection_fixture(dir.path());
4421 let root = canonical_root(dir.path());
4422 let files = project_files(&root);
4423 let projected = store_projected_snapshot(&root, ".store-dead-code-e2e");
4424
4425 let projected_aggregate = dead_code_aggregate(&root, files, projected);
4426 assert_dead_item(&projected_aggregate, "src/dead.ts", "knownDead");
4427 assert_live_item(&projected_aggregate, "src/live.ts", "knownLive");
4428 assert_live_item(&projected_aggregate, "src/render.ts", "render");
4429 assert_live_item(&projected_aggregate, "src/other_render.ts", "render");
4430 }
4431
4432 #[test]
4433 fn dead_code_projection_rust_attribute_entry_points_are_live() {
4434 let dir = tempfile::tempdir().expect("tempdir");
4435 write_rust_attribute_entry_fixture(dir.path());
4436 let root = canonical_root(dir.path());
4437 let files = project_files(&root);
4438 let store = CallGraphStore::open(root.join(".store-tauri-commands"), root.clone())
4439 .expect("open store");
4440 store.cold_build(&files).expect("cold build store");
4441 let command = store
4442 .node_for(Path::new("src/commands.rs"), "get_primers")
4443 .expect("command node");
4444 assert!(
4445 command.is_entry_point,
4446 "attribute-rooted commands must be labeled as callgraph entry points"
4447 );
4448 let private_command = store
4449 .node_for(Path::new("src/commands.rs"), "private_command")
4450 .expect("private command node");
4451 assert!(
4452 private_command.is_entry_point,
4453 "private attribute-rooted commands must also be callgraph entry points"
4454 );
4455
4456 let projected = project_dead_code_snapshot(store.sqlite_path()).expect("project snapshot");
4457 let aggregate = dead_code_aggregate(&root, files, projected);
4458 assert_live_item(&aggregate, "src/commands.rs", "get_primers");
4459 assert_live_item(&aggregate, "src/db.rs", "helper");
4460 assert_live_item(&aggregate, "src/db.rs", "private_helper");
4461 assert_live_item(&aggregate, "src/imported.rs", "imported_command");
4462 assert_live_item(&aggregate, "src/db.rs", "imported_helper");
4463 assert_dead_item(&aggregate, "src/commands.rs", "planted_dead");
4464 assert_dead_item(&aggregate, "src/unimported.rs", "false_command");
4465 assert_dead_item(&aggregate, "src/db.rs", "false_helper");
4466 }
4467
4468 #[test]
4469 fn dead_code_projection_rust_attribute_roots_are_cold_deterministic() {
4470 let dir = tempfile::tempdir().expect("tempdir");
4471 write_rust_attribute_entry_fixture(dir.path());
4472 let root = canonical_root(dir.path());
4473 let first = store_projected_snapshot(&root, ".store-tauri-cold-a");
4474 let second = store_projected_snapshot(&root, ".store-tauri-cold-b");
4475
4476 assert_snapshot_parts_eq("rust attribute roots cold", &first, &second);
4477 }
4478
4479 #[test]
4480 fn dead_code_projection_rust_attribute_roots_survive_unrelated_incremental_edit() {
4481 let dir = tempfile::tempdir().expect("tempdir");
4482 write_rust_attribute_entry_fixture(dir.path());
4483 let root = canonical_root(dir.path());
4484 let files_before = project_files(&root);
4485 let incremental_store =
4486 CallGraphStore::open(root.join(".store-tauri-incremental"), root.clone())
4487 .expect("open incremental store");
4488 incremental_store
4489 .cold_build(&files_before)
4490 .expect("initial cold build");
4491
4492 write_file(
4493 &root.join("src/unrelated.rs"),
4494 r#"// unrelated edit should not refresh command attribute facts
4495pub fn unrelated() -> u32 { 2 }
4496"#,
4497 );
4498 let stats = incremental_store
4499 .refresh_files(&[root.join("src/unrelated.rs")])
4500 .expect("refresh unrelated file");
4501 assert_eq!(stats.refreshed_own_files, 1);
4502 assert_eq!(stats.changed_files, vec!["src/unrelated.rs".to_string()]);
4503 assert!(
4504 !stats
4505 .surface_changed
4506 .iter()
4507 .any(|file| file == "src/commands.rs"),
4508 "unrelated edit must not refresh the command module: {stats:#?}"
4509 );
4510 let incremental = project_dead_code_snapshot(incremental_store.sqlite_path())
4511 .expect("project incremental snapshot");
4512
4513 let cold_store = CallGraphStore::open(root.join(".store-tauri-cold"), root.clone())
4514 .expect("open cold store");
4515 cold_store
4516 .cold_build(&project_files(&root))
4517 .expect("cold rebuild");
4518 let cold = project_dead_code_snapshot(cold_store.sqlite_path()).expect("project cold");
4519 assert_snapshot_parts_eq("rust attribute roots unrelated edit", &cold, &incremental);
4520
4521 let aggregate = dead_code_aggregate(&root, project_files(&root), incremental);
4522 assert_live_item(&aggregate, "src/commands.rs", "get_primers");
4523 assert_live_item(&aggregate, "src/db.rs", "helper");
4524 assert_live_item(&aggregate, "src/db.rs", "private_helper");
4525 assert_dead_item(&aggregate, "src/commands.rs", "planted_dead");
4526 }
4527
4528 fn assert_projection_fixture_coverage(root: &Path, snapshot: &CallgraphSnapshot) {
4529 let comparable = comparable_snapshot(snapshot);
4530 assert!(
4531 comparable
4532 .files
4533 .iter()
4534 .any(|file| file.extension().and_then(|ext| ext.to_str()) == Some("ts")),
4535 "fixture must include TypeScript files: {:#?}",
4536 comparable.files
4537 );
4538 assert!(
4539 comparable
4540 .files
4541 .iter()
4542 .any(|file| file.extension().and_then(|ext| ext.to_str()) == Some("js")),
4543 "fixture must include JavaScript files: {:#?}",
4544 comparable.files
4545 );
4546 assert!(
4547 comparable
4548 .files
4549 .iter()
4550 .any(|file| file.extension().and_then(|ext| ext.to_str()) == Some("rs")),
4551 "fixture must include Rust files: {:#?}",
4552 comparable.files
4553 );
4554
4555 let main_file = canonicalize_for_snapshot(&root.join("src/main.ts"));
4556 let private_dispatch_target = format!("{}::dispatch", main_file.display());
4557 assert!(
4558 comparable
4559 .outbound_calls
4560 .iter()
4561 .any(
4562 |(caller_file, caller_symbol, target, _)| caller_file == &main_file
4563 && caller_symbol == "main"
4564 && target == &private_dispatch_target
4565 ),
4566 "fixture must cover same-file private fallback target {private_dispatch_target}: {:#?}",
4567 comparable.outbound_calls
4568 );
4569 assert!(
4570 comparable
4571 .outbound_calls
4572 .iter()
4573 .any(|(_, _, target, _)| target.contains(DISPATCHED_CALLEE_SEPARATOR)),
4574 "fixture must cover method-dispatch suffixes: {:#?}",
4575 comparable.outbound_calls
4576 );
4577 assert!(
4578 comparable
4579 .exported_symbols
4580 .iter()
4581 .any(|(_, symbol, kind, _)| symbol == "runDefault"
4582 && kind == DEFAULT_EXPORT_MARKER_KIND),
4583 "fixture must cover default-export marker rows: {:#?}",
4584 comparable.exported_symbols
4585 );
4586 }
4587
4588 fn run_projection_scenario(name: &str, setup: fn(&Path), edit: fn(&Path) -> Vec<PathBuf>) {
4589 let dir = tempfile::tempdir().expect("tempdir");
4590 setup(dir.path());
4591 let root = canonical_root(dir.path());
4592 let files_before = project_files(&root);
4593 let incremental_store = CallGraphStore::open(
4594 root.join(format!(".store-dead-code-projection-{name}-incremental")),
4595 root.clone(),
4596 )
4597 .expect("open incremental store");
4598 incremental_store
4599 .cold_build(&files_before)
4600 .expect("initial cold build");
4601
4602 let changed = edit(&root);
4603 incremental_store
4604 .refresh_files(&changed)
4605 .expect("refresh changed files");
4606 let incremental = project_dead_code_snapshot(incremental_store.sqlite_path())
4607 .expect("project incremental snapshot");
4608
4609 let cold_store = CallGraphStore::open(
4610 root.join(format!(".store-dead-code-projection-{name}-cold")),
4611 root.clone(),
4612 )
4613 .expect("open cold store");
4614 cold_store
4615 .cold_build(&project_files(&root))
4616 .expect("cold rebuild");
4617 let cold =
4618 project_dead_code_snapshot(cold_store.sqlite_path()).expect("project cold snapshot");
4619
4620 assert_snapshot_parts_eq(name, &cold, &incremental);
4621 }
4622
4623 #[test]
4632 #[ignore = "manual benchmark; needs AFT_BENCH_REPO pointing at a large checkout"]
4633 fn dead_code_decision_b_benchmark() {
4634 let Ok(repo) = std::env::var("AFT_BENCH_REPO") else {
4635 eprintln!("AFT_BENCH_REPO unset; skipping");
4636 return;
4637 };
4638 macro_rules! mark {
4640 ($($a:tt)*) => {{ eprintln!($($a)*); let _ = std::io::Write::flush(&mut std::io::stderr()); }};
4641 }
4642 let root = canonical_root(Path::new(&repo));
4643 let files = project_files(&root);
4644 mark!(
4645 "\n=== Store-backed dead_code benchmark ===\nrepo: {}\nsource files (walk_project_files): {}\nstarted store cold_build...",
4646 root.display(),
4647 files.len()
4648 );
4649
4650 let store_dir = root.join(".aft-bench-store");
4653 let _ = std::fs::remove_dir_all(&store_dir);
4654 let store = CallGraphStore::open(store_dir.clone(), root.clone()).expect("open store");
4655 let t = Instant::now();
4656 let cold_stats = store.cold_build(&files).expect("store cold build");
4657 let store_build_ms = t.elapsed().as_millis();
4658 let t = Instant::now();
4659 let projected = project_dead_code_snapshot(store.sqlite_path()).expect("projection");
4660 let proj_ms = t.elapsed().as_millis();
4661 mark!(
4662 "store cold_build: {} ms ({:?}) + projection: {} ms = {} ms (exports={}, outbound={})\nstarted scan...",
4663 store_build_ms, cold_stats, proj_ms, store_build_ms + proj_ms,
4664 projected.exported_symbols.len(), projected.outbound_calls.len()
4665 );
4666
4667 let t = Instant::now();
4669 let _result = dead_code_aggregate(&root, files.clone(), projected.clone());
4670 let scan_ms = t.elapsed().as_millis();
4671 mark!("run_dead_code_scan (cold contributions): {} ms", scan_ms);
4672
4673 mark!(
4674 "\nSUMMARY files={} store_cold_plus_projection={}ms projection={}ms scan_cold={}ms total={}ms",
4675 files.len(),
4676 store_build_ms + proj_ms,
4677 proj_ms,
4678 scan_ms,
4679 store_build_ms + proj_ms + scan_ms
4680 );
4681 let _ = std::fs::remove_dir_all(&store_dir);
4682 }
4683
4684 fn store_projected_snapshot(root: &Path, store_name: &str) -> CallgraphSnapshot {
4685 let store =
4686 CallGraphStore::open(root.join(store_name), root.to_path_buf()).expect("open store");
4687 store
4688 .cold_build(&project_files(root))
4689 .expect("store cold build");
4690 project_dead_code_snapshot(store.sqlite_path()).expect("project snapshot")
4691 }
4692
4693 fn dead_code_aggregate(
4694 root: &Path,
4695 scope_files: Vec<PathBuf>,
4696 snapshot: CallgraphSnapshot,
4697 ) -> Value {
4698 let job = InspectJob {
4699 job_id: 86,
4700 key: JobKey::for_project_category(InspectCategory::DeadCode),
4701 category: InspectCategory::DeadCode,
4702 scope_files,
4703 project_root: root.to_path_buf(),
4704 inspect_dir: root.join(".aft-cache").join("inspect"),
4705 config: Arc::new(Config {
4706 project_root: Some(root.to_path_buf()),
4707 ..Config::default()
4708 }),
4709 symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
4710 inspect_writer: true,
4711 callgraph_writer: true,
4712 callgraph_snapshot: Some(Arc::new(snapshot)),
4713 };
4714 crate::inspect::scanners::dead_code::run_dead_code_scan(&job)
4715 .outcome
4716 .expect("dead_code scan succeeds")
4717 .aggregate
4718 }
4719
4720 fn assert_snapshot_parts_eq(
4721 label: &str,
4722 expected: &CallgraphSnapshot,
4723 actual: &CallgraphSnapshot,
4724 ) {
4725 let expected = comparable_snapshot(expected);
4726 let actual = comparable_snapshot(actual);
4727 assert_eq!(
4728 actual, expected,
4729 "{label} store-projected snapshot must match cold store snapshot"
4730 );
4731 }
4732
4733 fn comparable_snapshot(snapshot: &CallgraphSnapshot) -> ComparableSnapshot {
4734 ComparableSnapshot {
4735 files: snapshot.files.iter().cloned().collect(),
4736 exported_symbols: snapshot
4737 .exported_symbols
4738 .iter()
4739 .map(|export| {
4740 (
4741 export.file.clone(),
4742 export.symbol.clone(),
4743 export.kind.clone(),
4744 export.line,
4745 )
4746 })
4747 .collect(),
4748 outbound_calls: snapshot
4749 .outbound_calls
4750 .iter()
4751 .map(|call| {
4752 (
4753 call.caller_file.clone(),
4754 call.caller_symbol.clone(),
4755 call.target.clone(),
4756 call.line,
4757 )
4758 })
4759 .collect(),
4760 entry_points: snapshot.entry_points.clone(),
4761 entry_point_symbols: snapshot.entry_point_symbols.clone(),
4762 }
4763 }
4764
4765 fn assert_dead_item(aggregate: &Value, file: &str, symbol: &str) {
4766 assert!(
4767 aggregate_has_item(aggregate, file, symbol),
4768 "expected {file}::{symbol} to be reported dead: {aggregate:#}"
4769 );
4770 }
4771
4772 fn assert_live_item(aggregate: &Value, file: &str, symbol: &str) {
4773 assert!(
4774 !aggregate_has_item(aggregate, file, symbol),
4775 "expected {file}::{symbol} to be live/not reported dead: {aggregate:#}"
4776 );
4777 }
4778
4779 fn aggregate_has_item(aggregate: &Value, file: &str, symbol: &str) -> bool {
4780 let Some(items) = aggregate.get("items").and_then(Value::as_array) else {
4781 return false;
4782 };
4783 items.iter().any(|item| {
4784 item.get("file").and_then(Value::as_str) == Some(file)
4785 && item.get("symbol").and_then(Value::as_str) == Some(symbol)
4786 })
4787 }
4788
4789 fn project_files(root: &Path) -> Vec<PathBuf> {
4790 walk_project_files(root).collect()
4791 }
4792
4793 fn canonical_root(root: &Path) -> PathBuf {
4794 std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf())
4795 }
4796
4797 fn write_file(path: &Path, content: &str) {
4798 if let Some(parent) = path.parent() {
4799 std::fs::create_dir_all(parent).expect("create parent");
4800 }
4801 std::fs::write(path, content).expect("write fixture");
4802 bump_mtime(path);
4803 }
4804
4805 fn bump_mtime(path: &Path) {
4806 let secs = NEXT_MTIME.fetch_add(1, AtomicOrdering::SeqCst);
4807 filetime::set_file_mtime(path, FileTime::from_unix_time(secs, 0)).expect("bump mtime");
4808 }
4809
4810 fn remove_file(path: &Path) {
4811 std::fs::remove_file(path).expect("remove fixture");
4812 }
4813
4814 fn write_projection_fixture(root: &Path) {
4815 write_file(
4816 &root.join("package.json"),
4817 r#"{"name":"dead-code-projection-fixture","type":"module","main":"src/main.ts"}"#,
4818 );
4819 write_file(
4820 &root.join("Cargo.toml"),
4821 r#"[package]
4822name = "dead_code_projection_fixture"
4823version = "0.1.0"
4824edition = "2021"
4825"#,
4826 );
4827 write_file(
4828 &root.join("src/main.ts"),
4829 r#"import runDefault from "./default";
4830import { knownLive } from "./live";
4831import { jsEntry } from "./app.js";
4832
4833export function main() {
4834 dispatch();
4835 runDefault();
4836 jsEntry();
4837}
4838
4839function dispatch() {
4840 knownLive();
4841 const service = { render() {} };
4842 service.render();
4843}
4844"#,
4845 );
4846 write_file(
4847 &root.join("src/default.ts"),
4848 r#"export default function runDefault() {}
4849"#,
4850 );
4851 write_file(
4852 &root.join("src/live.ts"),
4853 r#"export function knownLive() {}
4854"#,
4855 );
4856 write_file(
4857 &root.join("src/dead.ts"),
4858 r#"export function knownDead() {}
4859"#,
4860 );
4861 write_file(
4862 &root.join("src/render.ts"),
4863 r#"export function render() {}
4864"#,
4865 );
4866 write_file(
4867 &root.join("src/other_render.ts"),
4868 r#"export function render() {}
4869"#,
4870 );
4871 write_file(
4872 &root.join("src/app.js"),
4873 r#"import { jsHelper } from "./js_helper.js";
4874
4875export function jsEntry() {
4876 jsHelper();
4877}
4878"#,
4879 );
4880 write_file(
4881 &root.join("src/js_helper.js"),
4882 r#"export function jsHelper() {}
4883"#,
4884 );
4885 write_file(
4886 &root.join("src/lib.rs"),
4887 r#"mod util;
4888use crate::util::rust_helper;
4889
4890pub fn rust_entry() {
4891 rust_helper();
4892}
4893"#,
4894 );
4895 write_file(
4896 &root.join("src/util.rs"),
4897 r#"pub fn rust_helper() {}
4898"#,
4899 );
4900 }
4901
4902 fn write_rust_attribute_entry_fixture(root: &Path) {
4903 write_file(
4904 &root.join("src/main.rs"),
4905 r#"mod commands;
4906mod db;
4907mod imported;
4908mod unimported;
4909mod unrelated;
4910
4911fn main() {
4912 tauri::generate_handler![commands::get_primers, imported::imported_command];
4913}
4914"#,
4915 );
4916 write_file(
4917 &root.join("src/commands.rs"),
4918 r#"use crate::db;
4919
4920#[tauri::command]
4921pub fn get_primers() -> String {
4922 db::helper()
4923}
4924
4925pub fn planted_dead() -> String {
4926 "dead".to_string()
4927}
4928
4929#[tauri::command]
4930fn private_command() -> String {
4931 db::private_helper()
4932}
4933"#,
4934 );
4935 write_file(
4936 &root.join("src/imported.rs"),
4937 r#"use crate::db;
4938use tauri::command;
4939
4940#[command]
4941pub fn imported_command() -> String {
4942 db::imported_helper()
4943}
4944"#,
4945 );
4946 write_file(
4947 &root.join("src/unimported.rs"),
4948 r#"use crate::db;
4949
4950#[command]
4951pub fn false_command() -> String {
4952 db::false_helper()
4953}
4954"#,
4955 );
4956 write_file(
4957 &root.join("src/db.rs"),
4958 r#"pub fn helper() -> String { "live".to_string() }
4959pub fn imported_helper() -> String { "live".to_string() }
4960pub fn private_helper() -> String { "live".to_string() }
4961pub fn false_helper() -> String { "dead".to_string() }
4962"#,
4963 );
4964 write_file(
4965 &root.join("src/unrelated.rs"),
4966 r#"pub fn unrelated() -> u32 { 1 }
4967"#,
4968 );
4969 }
4970
4971 fn setup_projection_rename(root: &Path) {
4972 write_file(
4973 &root.join("a.ts"),
4974 r#"export function outer() {
4975 inner();
4976}
4977
4978export function inner() {}
4979"#,
4980 );
4981 }
4982
4983 fn edit_projection_rename(root: &Path) -> Vec<PathBuf> {
4984 let path = root.join("a.ts");
4985 write_file(
4986 &path,
4987 r#"export function outer() {
4988 renamed();
4989}
4990
4991export function renamed() {}
4992"#,
4993 );
4994 vec![path]
4995 }
4996
4997 fn setup_projection_delete(root: &Path) {
4998 write_file(
4999 &root.join("main.ts"),
5000 r#"import { foo } from "./foo";
5001export function main() { foo(); }
5002"#,
5003 );
5004 write_file(&root.join("foo.ts"), "export function foo() {}\n");
5005 }
5006
5007 fn edit_projection_delete(root: &Path) -> Vec<PathBuf> {
5008 let path = root.join("foo.ts");
5009 remove_file(&path);
5010 vec![path]
5011 }
5012
5013 fn setup_projection_barrel(root: &Path) {
5014 write_file(
5015 &root.join("main.ts"),
5016 r#"import { foo } from "./barrel";
5017export function main() { foo(); }
5018"#,
5019 );
5020 write_file(&root.join("barrel.ts"), "export { foo } from \"./foo\";\n");
5021 write_file(&root.join("foo.ts"), "export function foo() {}\n");
5022 }
5023
5024 fn edit_projection_barrel_delete(root: &Path) -> Vec<PathBuf> {
5025 let path = root.join("barrel.ts");
5026 remove_file(&path);
5027 vec![path]
5028 }
5029
5030 fn setup_projection_dispatch(root: &Path) {
5031 write_file(
5032 &root.join("main.ts"),
5033 r#"export function main() {
5034 const service = { render() {}, paint() {} };
5035 service.render();
5036}
5037"#,
5038 );
5039 write_file(&root.join("render.ts"), "export function render() {}\n");
5040 write_file(&root.join("paint.ts"), "export function paint() {}\n");
5041 }
5042
5043 fn edit_projection_dispatch(root: &Path) -> Vec<PathBuf> {
5044 let path = root.join("main.ts");
5045 write_file(
5046 &path,
5047 r#"export function main() {
5048 const service = { render() {}, paint() {} };
5049 service.paint();
5050}
5051"#,
5052 );
5053 vec![path]
5054 }
5055
5056 fn setup_projection_body_only(root: &Path) {
5057 write_file(
5058 &root.join("main.ts"),
5059 r#"import { foo } from "./foo";
5060export function main() { foo(); }
5061"#,
5062 );
5063 write_file(
5064 &root.join("foo.ts"),
5065 r#"export function foo() {
5066 return 1;
5067}
5068"#,
5069 );
5070 }
5071
5072 fn edit_projection_body_only(root: &Path) -> Vec<PathBuf> {
5073 let path = root.join("foo.ts");
5074 write_file(
5075 &path,
5076 r#"export function foo() {
5077 return 2;
5078}
5079"#,
5080 );
5081 vec![path]
5082 }
5083
5084 #[test]
5085 fn forced_paths_downgrade_only_when_strict_hash_matches_cached_fact() {
5086 let dir = tempfile::tempdir().expect("tempdir");
5087 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
5088 let unchanged = root.join("unchanged.ts");
5089 let changed = root.join("changed.ts");
5090 let oversized = root.join("oversized.ts");
5091 std::fs::write(&unchanged, "export const value = 1;\n").expect("write unchanged");
5092 std::fs::write(&changed, "export const before = 1;\n").expect("write changed baseline");
5093 let unchanged_freshness =
5094 cache_freshness::collect(&unchanged).expect("unchanged freshness");
5095 let changed_freshness = cache_freshness::collect(&changed).expect("changed freshness");
5096 std::fs::write(&changed, "export const after_ = 2;\n").expect("change same-size content");
5097 let oversized_file = std::fs::File::create(&oversized).expect("create oversized");
5098 oversized_file
5099 .set_len(cache_freshness::CONTENT_HASH_SIZE_CAP + 1)
5100 .expect("size oversized");
5101 let oversized_freshness =
5102 cache_freshness::collect(&oversized).expect("oversized freshness");
5103 let cached = vec![
5104 CachedContributionFreshness {
5105 file_path: PathBuf::from("unchanged.ts"),
5106 freshness: unchanged_freshness,
5107 },
5108 CachedContributionFreshness {
5109 file_path: PathBuf::from("changed.ts"),
5110 freshness: changed_freshness,
5111 },
5112 CachedContributionFreshness {
5113 file_path: PathBuf::from("oversized.ts"),
5114 freshness: oversized_freshness,
5115 },
5116 ];
5117
5118 let (remaining, downgraded) = downgrade_unchanged_forced_paths_with_freshness(
5119 &root,
5120 &cached,
5121 vec![
5122 PathBuf::from("unchanged.ts"),
5123 PathBuf::from("changed.ts"),
5124 PathBuf::from("oversized.ts"),
5125 ],
5126 );
5127
5128 assert_eq!(downgraded, 1);
5129 assert_eq!(
5130 remaining,
5131 vec![PathBuf::from("changed.ts"), PathBuf::from("oversized.ts")]
5132 );
5133 }
5134}