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