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