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