1use std::collections::{BTreeMap, BTreeSet, HashMap};
2use std::path::{Path, PathBuf};
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::sync::{Arc, Mutex};
5use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
6
7use crossbeam_channel::{after, bounded, select, Receiver, Sender};
8use serde::Deserialize;
9use serde_json::{json, Value};
10
11use super::cache::{InspectCache, Tier2ContributionUpdates};
12use super::dispatch::{default_worker, start_dispatch_loop, InspectWorker};
13use super::freshness::ContributionFreshness;
14use super::job::{
15 normalize_path, CallgraphSnapshot, FileContribution, InspectCategory, InspectJob,
16 InspectResult, InspectScanSuccess, InspectSnapshot, JobKey, JobOutcome, JobScope,
17};
18use super::oxc_engine::LivenessVerdict;
19use super::oxc_engine::{
20 analyze_files_with_cache, AnalyzeOptions, OxcEngineResult, OxcFactsCache, OXC_PROVENANCE,
21};
22use crate::cache_freshness::{self, FileFreshness, FreshnessVerdict};
23use crate::callgraph_store::{project_dead_code_snapshot, CallGraphStore, CallGraphStoreError};
24
25const DEFAULT_SOFT_DEADLINE: Duration = Duration::from_secs(1);
26
27type WaiterTx = Sender<JobOutcome>;
28
29#[derive(Clone)]
30struct Waiter {
31 tx: WaiterTx,
32}
33
34struct CachedContributionFreshness {
35 file_path: PathBuf,
36 freshness: FileFreshness,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Hash)]
40struct InspectCacheIdentity {
41 sqlite_path: PathBuf,
42 project_root: PathBuf,
43}
44
45#[derive(PartialEq, Eq)]
46struct ContributionFingerprint {
47 count: usize,
48 set_hash: String,
49 hash_complete: bool,
50}
51
52#[derive(Debug, Clone)]
53pub struct Tier2RunSubmissionError {
54 pub category: InspectCategory,
55 pub message: String,
56}
57
58#[derive(Debug, Clone, Default)]
59pub struct Tier2RunSubmission {
60 pub queued_categories: Vec<InspectCategory>,
61 pub newly_queued_categories: Vec<InspectCategory>,
62 pub errors: Vec<Tier2RunSubmissionError>,
63}
64
65impl Tier2RunSubmission {
66 pub fn has_new_work(&self) -> bool {
67 !self.newly_queued_categories.is_empty()
68 }
69}
70
71pub struct InspectManager {
72 request_tx: Sender<InspectJob>,
73 result_rx: Receiver<InspectResult>,
74 #[allow(dead_code)]
75 pool: Arc<rayon::ThreadPool>,
76 in_flight: Mutex<HashMap<JobKey, Vec<Waiter>>>,
77 caches: Mutex<HashMap<InspectCacheIdentity, Arc<InspectCache>>>,
78 oxc_facts_cache: Mutex<OxcFactsCache>,
79 soft_deadline: Duration,
80 next_job_id: AtomicU64,
81 reuse_completions: AtomicU64,
86}
87
88impl InspectManager {
89 pub fn new() -> Self {
90 Self::with_worker(default_worker(), DEFAULT_SOFT_DEADLINE)
91 }
92
93 #[doc(hidden)]
94 pub fn with_worker(worker: InspectWorker, soft_deadline: Duration) -> Self {
95 let handles = start_dispatch_loop(worker);
96 Self {
97 request_tx: handles.request_tx,
98 result_rx: handles.result_rx,
99 pool: handles.pool,
100 in_flight: Mutex::new(HashMap::new()),
101 caches: Mutex::new(HashMap::new()),
102 oxc_facts_cache: Mutex::new(OxcFactsCache::new()),
103 soft_deadline,
104 next_job_id: AtomicU64::new(1),
105 reuse_completions: AtomicU64::new(0),
106 }
107 }
108
109 pub fn submit_category(
110 &self,
111 snapshot: InspectSnapshot,
112 category: InspectCategory,
113 caller_scope: JobScope,
114 ) -> JobOutcome {
115 self.submit_category_with_callgraph(snapshot, category, caller_scope, None)
116 }
117
118 pub fn submit_category_with_callgraph(
119 &self,
120 snapshot: InspectSnapshot,
121 category: InspectCategory,
122 caller_scope: JobScope,
123 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
124 ) -> JobOutcome {
125 if !category.is_active() {
126 return JobOutcome::Failed {
127 message: format!("inspect category '{category}' is disabled in v0.33"),
128 };
129 }
130
131 let cache = match self.cache_for_snapshot(&snapshot) {
132 Ok(cache) => cache,
133 Err(message) => return JobOutcome::Failed { message },
134 };
135 let key = JobKey::for_category_scope(category, &caller_scope);
136 let (waiter_tx, waiter_rx) = bounded(1);
137
138 let wait_snapshot = snapshot.clone();
139 match self.enqueue_with_waiter(
140 snapshot,
141 category,
142 caller_scope.clone(),
143 key.clone(),
144 waiter_tx,
145 callgraph_snapshot,
146 ) {
147 Ok(()) => self.wait_for_outcome(key, caller_scope, cache, waiter_rx, wait_snapshot),
148 Err(message) => JobOutcome::Failed { message },
149 }
150 }
151
152 pub fn submit_background(
153 &self,
154 snapshot: InspectSnapshot,
155 category: InspectCategory,
156 caller_scope: JobScope,
157 ) -> Result<JobKey, String> {
158 self.submit_background_with_callgraph(snapshot, category, caller_scope, None)
159 }
160
161 pub fn submit_background_with_callgraph(
162 &self,
163 snapshot: InspectSnapshot,
164 category: InspectCategory,
165 caller_scope: JobScope,
166 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
167 ) -> Result<JobKey, String> {
168 if !category.is_active() {
169 return Err(format!(
170 "inspect category '{category}' is disabled in v0.33"
171 ));
172 }
173 let key = JobKey::for_category_scope(category, &caller_scope);
174 self.enqueue_without_waiter(
175 snapshot,
176 category,
177 caller_scope,
178 key.clone(),
179 callgraph_snapshot,
180 )?;
181 Ok(key)
182 }
183
184 pub fn submit_tier2_run_with_reuse_background(
185 self: &Arc<Self>,
186 snapshot: InspectSnapshot,
187 category: InspectCategory,
188 ) -> Result<JobKey, String> {
189 if !category.is_active() {
190 return Err(format!(
191 "inspect category '{category}' is disabled in v0.33"
192 ));
193 }
194 if !category.is_tier2() {
195 return Err(format!(
196 "inspect category '{category}' is not a Tier 2 category"
197 ));
198 }
199
200 let job = self.tier2_reuse_job(snapshot, category, None);
201 let key = job.key.clone();
202 let mut in_flight = self
203 .in_flight
204 .lock()
205 .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
206 if in_flight.contains_key(&key) {
207 return Ok(key);
208 }
209 in_flight.insert(key.clone(), Vec::new());
210 drop(in_flight);
211
212 let manager = Arc::clone(self);
213 let pool = Arc::clone(&self.pool);
214 pool.spawn(move || {
215 let result = manager.tier2_run_with_reuse_job_result(job);
216 manager.route_tier2_reuse_completion(result);
217 });
218
219 Ok(key)
220 }
221
222 pub fn submit_tier2_run_with_reuse_serial_background(
223 self: &Arc<Self>,
224 snapshot: InspectSnapshot,
225 categories: Vec<InspectCategory>,
226 ) -> Tier2RunSubmission {
227 let mut submission = Tier2RunSubmission::default();
228 let mut requested = Vec::new();
229
230 for category in categories {
231 if !category.is_active() {
232 submission.errors.push(Tier2RunSubmissionError {
233 category,
234 message: format!("inspect category '{category}' is disabled in v0.33"),
235 });
236 continue;
237 }
238 if !category.is_tier2() {
239 submission.errors.push(Tier2RunSubmissionError {
240 category,
241 message: format!("inspect category '{category}' is not a Tier 2 category"),
242 });
243 continue;
244 }
245 requested.push(category);
246 }
247
248 if requested.is_empty() {
249 return submission;
250 }
251
252 let mut in_flight = match self.in_flight.lock() {
253 Ok(in_flight) => in_flight,
254 Err(_) => {
255 for category in requested {
256 submission.errors.push(Tier2RunSubmissionError {
257 category,
258 message: "inspect in-flight map lock poisoned".to_string(),
259 });
260 }
261 return submission;
262 }
263 };
264
265 for category in requested {
266 let key = JobKey::for_project_category(category);
267 submission.queued_categories.push(category);
268 if in_flight.contains_key(&key) {
269 continue;
270 }
271 in_flight.insert(key, Vec::new());
272 submission.newly_queued_categories.push(category);
273 }
274 drop(in_flight);
275
276 if submission.newly_queued_categories.is_empty() {
277 return submission;
278 }
279
280 let categories_for_worker = submission.newly_queued_categories.clone();
281 let manager = Arc::clone(self);
282 let pool = Arc::clone(&self.pool);
283 pool.spawn(move || {
284 for category in categories_for_worker {
285 let result = manager.tier2_run_with_reuse_result(snapshot.clone(), category, None);
286 manager.route_tier2_reuse_completion(result);
287 }
288 });
289
290 submission
291 }
292
293 pub fn tier2_any_in_flight(&self) -> bool {
294 self.in_flight
295 .lock()
296 .map(|in_flight| in_flight.keys().any(|key| key.category.is_tier2()))
297 .unwrap_or(false)
298 }
299
300 pub fn drain_completions(&self) -> usize {
301 let mut drained = 0usize;
302 while let Ok(result) = self.result_rx.try_recv() {
303 self.route_completion(result);
304 drained += 1;
305 }
306 drained
307 }
308
309 pub fn cache_for_snapshot(
310 &self,
311 snapshot: &InspectSnapshot,
312 ) -> Result<Arc<InspectCache>, String> {
313 self.cache_for_paths(snapshot.inspect_dir.clone(), snapshot.project_root.clone())
314 }
315
316 pub fn latest_tier2_counts(
324 &self,
325 inspect_dir: PathBuf,
326 project_root: PathBuf,
327 ) -> (Option<usize>, Option<usize>, Option<usize>) {
328 let Ok(cache) = self.cache_for_paths(inspect_dir, project_root) else {
329 return (None, None, None);
330 };
331 let count_of = |category: InspectCategory| -> Option<usize> {
332 cache
333 .latest_aggregate_any_hash(category)
334 .ok()
335 .flatten()
336 .and_then(|payload| {
337 if category == InspectCategory::DeadCode
338 && payload
339 .get("callgraph_available")
340 .and_then(serde_json::Value::as_bool)
341 == Some(false)
342 {
343 return None;
344 }
345 payload
346 .get("count")
347 .and_then(serde_json::Value::as_u64)
348 .map(|count| count as usize)
349 })
350 };
351 (
352 count_of(InspectCategory::DeadCode),
353 count_of(InspectCategory::UnusedExports),
354 count_of(InspectCategory::Duplicates),
355 )
356 }
357
358 pub fn cache_for_paths(
359 &self,
360 inspect_dir: PathBuf,
361 project_root: PathBuf,
362 ) -> Result<Arc<InspectCache>, String> {
363 let project_key = crate::search_index::project_cache_key(&project_root);
364 let sqlite_path = inspect_dir.join(format!("{project_key}.sqlite"));
365 let identity = InspectCacheIdentity {
366 sqlite_path,
367 project_root: project_root.clone(),
368 };
369 let mut caches = self
370 .caches
371 .lock()
372 .map_err(|_| "inspect manager cache map lock poisoned".to_string())?;
373 if let Some(cache) = caches.get(&identity) {
374 return Ok(Arc::clone(cache));
375 }
376 let cache = Arc::new(
377 InspectCache::open(inspect_dir, project_root)
378 .map_err(|error| format!("failed to open inspect cache: {error}"))?,
379 );
380 caches.insert(identity, Arc::clone(&cache));
381 Ok(cache)
382 }
383
384 fn oxc_result_for_scan(
385 &self,
386 job: &InspectJob,
387 files: &[PathBuf],
388 ) -> Result<Option<OxcEngineResult>, String> {
389 if !category_uses_oxc(job.category) {
390 return Ok(None);
391 }
392 if job.category == InspectCategory::DeadCode && job.callgraph_snapshot.is_none() {
393 return Ok(None);
394 }
395
396 let public_api_entries = super::entry_points::resolve_entry_points(&job.project_root);
397 let entry_points = if job.category == InspectCategory::DeadCode {
398 job.callgraph_snapshot
399 .as_ref()
400 .map(|snapshot| snapshot.entry_points.iter().cloned().collect::<Vec<_>>())
401 .unwrap_or_default()
402 } else {
403 Vec::new()
404 };
405 let options = AnalyzeOptions {
406 entry_points,
407 public_api_files: public_api_entries.public_api_files(),
408 entry_reachability: job.category == InspectCategory::DeadCode,
409 };
410
411 let mut cache = self
412 .oxc_facts_cache
413 .lock()
414 .map_err(|_| "inspect oxc facts cache lock poisoned".to_string())?;
415 analyze_files_with_cache(&job.project_root, files, options, &mut cache)
416 .map(Some)
417 .map_err(|message| format!("oxc analyze failed: {message}"))
418 }
419
420 pub fn tier2_run_with_reuse(
421 &self,
422 snapshot: InspectSnapshot,
423 category: InspectCategory,
424 caller_scope: JobScope,
425 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
426 ) -> JobOutcome {
427 let result =
428 self.tier2_run_with_reuse_result(snapshot.clone(), category, callgraph_snapshot);
429 let outcome = match result.outcome {
430 Ok(success) => JobOutcome::Fresh {
431 payload: success.aggregate,
432 },
433 Err(message) => JobOutcome::Failed { message },
434 };
435 match self.cache_for_snapshot(&snapshot) {
436 Ok(cache) => filter_outcome_for_scope_with_contributions(
437 outcome,
438 &snapshot,
439 category,
440 cache.as_ref(),
441 &caller_scope,
442 ),
443 Err(message) => JobOutcome::Failed { message },
444 }
445 }
446
447 pub fn tier2_read_cached(
454 &self,
455 snapshot: InspectSnapshot,
456 category: InspectCategory,
457 caller_scope: JobScope,
458 ) -> JobOutcome {
459 if let Err(outcome) = validate_tier2_read_category(category) {
460 return outcome;
461 }
462 let cache = match self.cache_for_snapshot(&snapshot) {
463 Ok(cache) => cache,
464 Err(message) => return JobOutcome::Failed { message },
465 };
466 self.tier2_read_cached_from_cache(&snapshot, category, &caller_scope, cache.as_ref())
467 }
468
469 pub fn tier2_read_cached_readonly(
470 &self,
471 snapshot: InspectSnapshot,
472 category: InspectCategory,
473 caller_scope: JobScope,
474 ) -> JobOutcome {
475 if let Err(outcome) = validate_tier2_read_category(category) {
476 return outcome;
477 }
478 let key = JobKey::for_project_category(category);
479 let in_flight = self
480 .in_flight
481 .lock()
482 .map(|guard| guard.contains_key(&key))
483 .unwrap_or(false);
484 let cache = match InspectCache::open_readonly(
485 snapshot.inspect_dir.clone(),
486 snapshot.project_root.clone(),
487 ) {
488 Ok(Some(cache)) => cache,
489 Ok(None) => return JobOutcome::Pending { in_flight },
490 Err(error) => {
491 return JobOutcome::Failed {
492 message: error.to_string(),
493 }
494 }
495 };
496 self.tier2_read_cached_from_cache(&snapshot, category, &caller_scope, &cache)
497 }
498
499 fn tier2_read_cached_from_cache(
500 &self,
501 snapshot: &InspectSnapshot,
502 category: InspectCategory,
503 caller_scope: &JobScope,
504 cache: &InspectCache,
505 ) -> JobOutcome {
506 let key = JobKey::for_project_category(category);
507 let in_flight = self
508 .in_flight
509 .lock()
510 .map(|guard| guard.contains_key(&key))
511 .unwrap_or(false);
512 match cache.get_aggregated(&key) {
513 Ok(Some(payload)) => {
514 match self.tier2_cached_aggregate_is_fresh(snapshot, category, cache) {
515 Ok(true) => filter_outcome_for_scope_with_contributions(
516 JobOutcome::Fresh { payload },
517 snapshot,
518 category,
519 cache,
520 caller_scope,
521 ),
522 Ok(false) => filter_outcome_for_scope_with_contributions(
523 JobOutcome::Stale {
524 cached: Some(payload),
525 in_flight,
526 },
527 snapshot,
528 category,
529 cache,
530 caller_scope,
531 ),
532 Err(message) => JobOutcome::Failed { message },
533 }
534 }
535 Ok(None) => match cache.latest_aggregate_any_hash(category) {
536 Ok(Some(payload)) => filter_outcome_for_scope_with_contributions(
537 JobOutcome::Stale {
538 cached: Some(payload),
539 in_flight,
540 },
541 snapshot,
542 category,
543 cache,
544 caller_scope,
545 ),
546 Ok(None) => JobOutcome::Pending { in_flight },
547 Err(error) => JobOutcome::Failed {
548 message: error.to_string(),
549 },
550 },
551 Err(error) => JobOutcome::Failed {
552 message: error.to_string(),
553 },
554 }
555 }
556
557 fn tier2_cached_aggregate_is_fresh(
558 &self,
559 snapshot: &InspectSnapshot,
560 category: InspectCategory,
561 cache: &InspectCache,
562 ) -> Result<bool, String> {
563 let cached_records = load_contribution_freshness(cache, category)?;
564 let project_scope = JobScope::for_project(snapshot.project_root.clone());
565 let project_files = scope_files(&snapshot.project_root, &project_scope);
566 let current_by_relative = current_project_files(&snapshot.project_root, &project_files);
567 let cached_relative = cached_records
568 .iter()
569 .map(freshness_record_relative_key)
570 .collect::<BTreeSet<_>>();
571
572 for record in &cached_records {
573 let relative = freshness_record_relative_key(record);
574 if !current_by_relative.contains_key(&relative) {
575 return Ok(false);
576 }
577
578 let absolute = if record.file_path.is_absolute() {
579 record.file_path.clone()
580 } else {
581 snapshot.project_root.join(&record.file_path)
582 };
583 match verify_contribution_file_strict(&absolute, &record.freshness) {
584 ContributionFreshness::Fresh { .. } => {}
585 ContributionFreshness::Stale | ContributionFreshness::Deleted => return Ok(false),
586 }
587 }
588
589 Ok(current_by_relative
590 .keys()
591 .all(|relative| cached_relative.contains(relative)))
592 }
593
594 #[doc(hidden)]
595 pub fn tier2_run_with_reuse_result(
596 &self,
597 snapshot: InspectSnapshot,
598 category: InspectCategory,
599 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
600 ) -> InspectResult {
601 let job = self.tier2_reuse_job(snapshot, category, callgraph_snapshot);
602 self.tier2_run_with_reuse_job_result(job)
603 }
604
605 fn tier2_run_with_reuse_job_result(&self, mut job: InspectJob) -> InspectResult {
606 let started = Instant::now();
607 if !job.category.is_active() {
608 let result = InspectResult::failed(
609 &job,
610 format!("inspect category '{}' is disabled in v0.33", job.category),
611 started.elapsed(),
612 );
613 log_tier2_benchmark_category_end(&result);
614 return result;
615 }
616 if !job.category.is_tier2() {
617 let result = InspectResult::failed(
618 &job,
619 format!(
620 "inspect category '{}' is not a Tier 2 category",
621 job.category
622 ),
623 started.elapsed(),
624 );
625 log_tier2_benchmark_category_end(&result);
626 return result;
627 }
628
629 let project_scope = JobScope::for_project(job.project_root.clone());
630 job.scope_files = scope_files(&job.project_root, &project_scope);
631 log_tier2_benchmark_category_start(&job);
632 let cache = match self.cache_for_paths(job.inspect_dir.clone(), job.project_root.clone()) {
633 Ok(cache) => cache,
634 Err(message) => {
635 let result = InspectResult::failed(&job, message, started.elapsed());
636 log_tier2_benchmark_category_end(&result);
637 return result;
638 }
639 };
640 if let Ok(Some(success)) = self.tier2_quick_reuse_success(&job, cache.as_ref()) {
641 let result = InspectResult::success(&job, success, started.elapsed());
642 crate::slog_debug!(
643 "perf tier2 category={} reuse=hit ms={}",
644 job.category,
645 started.elapsed().as_millis()
646 );
647 log_tier2_benchmark_category_end(&result);
648 return result;
649 }
650
651 let result = match self.tier2_run_with_reuse_job(&job, &cache) {
652 Ok(success) => InspectResult::success(&job, success, started.elapsed()),
653 Err(message) => InspectResult::failed(&job, message, started.elapsed()),
654 };
655 crate::slog_info!(
659 "perf tier2 category={} reuse=miss ms={}",
660 job.category,
661 started.elapsed().as_millis()
662 );
663 log_tier2_benchmark_category_end(&result);
664 result
665 }
666
667 fn tier2_reuse_job(
668 &self,
669 snapshot: InspectSnapshot,
670 category: InspectCategory,
671 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
672 ) -> InspectJob {
673 InspectJob {
674 job_id: self.next_job_id.fetch_add(1, Ordering::Relaxed),
675 key: JobKey::for_project_category(category),
676 category,
677 scope_files: Vec::new(),
678 project_root: snapshot.project_root,
679 inspect_dir: snapshot.inspect_dir,
680 config: snapshot.config,
681 symbol_cache: snapshot.symbol_cache,
682 callgraph_snapshot,
683 }
684 }
685
686 fn tier2_quick_reuse_success(
687 &self,
688 job: &InspectJob,
689 cache: &InspectCache,
690 ) -> Result<Option<InspectScanSuccess>, String> {
691 let Some(aggregate) = cache
692 .get_aggregated(&job.key)
693 .map_err(|error| error.to_string())?
694 else {
695 return Ok(None);
696 };
697 let cached = load_contribution_fingerprint(cache, job.category)?;
698 let current = current_file_fingerprint(&job.project_root, &job.scope_files)?;
699 if !cached.hash_complete || !current.hash_complete || cached != current {
700 return Ok(None);
701 }
702
703 cache
704 .touch_tier2_last_full_run(job.category)
705 .map_err(|error| error.to_string())?;
706 Ok(Some(InspectScanSuccess {
707 scanned_files: Vec::new(),
708 contributions: Vec::new(),
709 aggregate,
710 }))
711 }
712
713 #[allow(clippy::too_many_lines)]
714 fn tier2_run_with_reuse_job(
715 &self,
716 job: &InspectJob,
717 cache: &InspectCache,
718 ) -> Result<InspectScanSuccess, String> {
719 let mut phases = Tier2PhaseTimings::default();
720 let phase_started = Instant::now();
721 let cached_records = load_contribution_freshness(cache, job.category)?;
722 let current_by_relative = current_project_files(&job.project_root, &job.scope_files);
723 let cached_relative = cached_records
724 .iter()
725 .map(freshness_record_relative_key)
726 .collect::<BTreeSet<_>>();
727 #[cfg(debug_assertions)]
728 let cold_cache = cached_relative.is_empty();
729
730 let mut updates = Tier2ContributionUpdates::default();
731 let mut scan_by_relative = BTreeMap::<String, PathBuf>::new();
732 let mut aggregate_job = job.clone();
733
734 for record in cached_records {
735 let relative = freshness_record_relative_key(&record);
736 let relative_path = PathBuf::from(&relative);
737 let Some(current_file) = current_by_relative.get(&relative) else {
738 updates.deletes.push(relative_path);
739 continue;
740 };
741
742 let absolute = job.project_root.join(&record.file_path);
743 match verify_contribution_file_strict(&absolute, &record.freshness) {
744 ContributionFreshness::Fresh {
745 metadata_changed,
746 freshness,
747 } => {
748 if metadata_changed {
749 updates.metadata_updates.push((relative_path, freshness));
750 }
751 }
752 ContributionFreshness::Stale => {
753 updates.deletes.push(relative_path);
754 scan_by_relative.insert(relative, current_file.clone());
755 }
756 ContributionFreshness::Deleted => {
757 updates.deletes.push(relative_path);
758 }
759 }
760 }
761
762 for (relative, file) in ¤t_by_relative {
763 if !cached_relative.contains(relative) {
764 scan_by_relative.insert(relative.clone(), file.clone());
765 }
766 }
767 phases.freshness = phase_started.elapsed();
768
769 let mut scan_files = scan_by_relative.into_values().collect::<Vec<_>>();
770 if !scan_files.is_empty() {
771 if category_uses_oxc(job.category) {
772 scan_files = current_by_relative.values().cloned().collect::<Vec<_>>();
773 }
774 let mut scan_job = job.clone();
775 scan_job.job_id = self.next_job_id.fetch_add(1, Ordering::Relaxed);
776 scan_job.scope_files = scan_files.clone();
777 if scan_job.category == InspectCategory::DeadCode
778 && scan_job.callgraph_snapshot.is_none()
779 {
780 let snapshot_started = Instant::now();
781 scan_job.callgraph_snapshot = build_tier2_callgraph_snapshot(&scan_job);
782 phases.snapshot += snapshot_started.elapsed();
783 }
784 aggregate_job.callgraph_snapshot = scan_job.callgraph_snapshot.clone();
785 #[cfg(debug_assertions)]
786 if cold_cache {
787 std::thread::sleep(Duration::from_millis(10));
788 }
789 let scan_started = Instant::now();
790 let oxc_result = self.oxc_result_for_scan(&scan_job, &scan_job.scope_files)?;
791 let scan_result = run_tier2_scan(&scan_job, oxc_result.as_ref());
792 phases.scan += scan_started.elapsed();
793 phases.scanned_files += scan_files.len();
794 let scan_success = scan_result.outcome.map_err(|message| {
795 format!("{} incremental scan failed: {message}", job.category)
796 })?;
797 updates.upserts.extend(scan_success.contributions);
798 }
799
800 let has_updates = !updates.upserts.is_empty()
801 || !updates.deletes.is_empty()
802 || !updates.metadata_updates.is_empty();
803 if !has_updates {
804 if let Some(aggregate) = cache
805 .get_aggregated(&job.key)
806 .map_err(|error| error.to_string())?
807 {
808 cache
809 .touch_tier2_last_full_run(job.category)
810 .map_err(|error| error.to_string())?;
811 phases.log(job.category);
812 return Ok(InspectScanSuccess {
813 scanned_files: scan_files,
814 contributions: Vec::new(),
815 aggregate,
816 });
817 }
818 }
819
820 let db_started = Instant::now();
821 let mut contribution_set_hash = if has_updates {
822 cache
823 .apply_contribution_updates(job.category, updates)
824 .map_err(|error| error.to_string())?
825 } else {
826 cache
827 .contribution_set_hash(job.category)
828 .map_err(|error| error.to_string())?
829 };
830 phases.db = db_started.elapsed();
831
832 if let Some(aggregate) = cache
833 .load_aggregate_if_hash_matches(job.category, &contribution_set_hash)
834 .map_err(|error| error.to_string())?
835 {
836 cache
837 .touch_tier2_last_full_run(job.category)
838 .map_err(|error| error.to_string())?;
839 let contributions = load_contributions(cache, job)?;
840 phases.log(job.category);
841 return Ok(InspectScanSuccess {
842 scanned_files: scan_files,
843 contributions,
844 aggregate,
845 });
846 }
847
848 if category_contributions_depend_on_entry_points(job.category) {
849 let full_scan_files = current_by_relative.into_values().collect::<Vec<_>>();
854 if !full_scan_files.is_empty() {
855 let mut rescan_job = job.clone();
856 rescan_job.job_id = self.next_job_id.fetch_add(1, Ordering::Relaxed);
857 rescan_job.scope_files = full_scan_files.clone();
858 if rescan_job.category == InspectCategory::DeadCode
859 && rescan_job.callgraph_snapshot.is_none()
860 {
861 let snapshot_started = Instant::now();
862 rescan_job.callgraph_snapshot = build_tier2_callgraph_snapshot(&rescan_job);
863 phases.snapshot += snapshot_started.elapsed();
864 }
865 let scan_started = Instant::now();
866 let oxc_result = self.oxc_result_for_scan(&rescan_job, &rescan_job.scope_files)?;
867 let scan_result = run_tier2_scan(&rescan_job, oxc_result.as_ref());
868 phases.scan += scan_started.elapsed();
869 phases.scanned_files += full_scan_files.len();
870 let scan_success = scan_result.outcome.map_err(|message| {
871 format!(
872 "{} full rescan after entry-point cache miss failed: {message}",
873 job.category
874 )
875 })?;
876 let rescan_updates = Tier2ContributionUpdates {
877 upserts: scan_success.contributions,
878 ..Tier2ContributionUpdates::default()
879 };
880 let db_started = Instant::now();
881 contribution_set_hash = cache
882 .apply_contribution_updates(job.category, rescan_updates)
883 .map_err(|error| error.to_string())?;
884 phases.db += db_started.elapsed();
885 aggregate_job.callgraph_snapshot = rescan_job.callgraph_snapshot.clone();
886 scan_files = full_scan_files;
887
888 if let Some(aggregate) = cache
889 .load_aggregate_if_hash_matches(job.category, &contribution_set_hash)
890 .map_err(|error| error.to_string())?
891 {
892 cache
893 .touch_tier2_last_full_run(job.category)
894 .map_err(|error| error.to_string())?;
895 let contributions = load_contributions(cache, job)?;
896 phases.log(job.category);
897 return Ok(InspectScanSuccess {
898 scanned_files: scan_files,
899 contributions,
900 aggregate,
901 });
902 }
903 }
904 }
905
906 if aggregate_job.category == InspectCategory::DeadCode
907 && aggregate_job.callgraph_snapshot.is_none()
908 {
909 let snapshot_started = Instant::now();
910 aggregate_job.callgraph_snapshot = build_tier2_callgraph_snapshot(&aggregate_job);
911 phases.snapshot += snapshot_started.elapsed();
912 }
913 let rollup_started = Instant::now();
914 let contributions = load_contributions(cache, &aggregate_job)?;
915 let aggregate = roll_up_tier2_contributions(&aggregate_job, &contributions);
916 cache
917 .store_tier2_aggregate(job.key.clone(), &contribution_set_hash, aggregate.clone())
918 .map_err(|error| error.to_string())?;
919 phases.rollup = rollup_started.elapsed();
920 phases.log(job.category);
921
922 Ok(InspectScanSuccess {
923 scanned_files: scan_files,
924 contributions,
925 aggregate,
926 })
927 }
928
929 fn enqueue_with_waiter(
930 &self,
931 snapshot: InspectSnapshot,
932 category: InspectCategory,
933 caller_scope: JobScope,
934 key: JobKey,
935 waiter_tx: WaiterTx,
936 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
937 ) -> Result<(), String> {
938 let mut in_flight = self
939 .in_flight
940 .lock()
941 .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
942 if let Some(waiters) = in_flight.get_mut(&key) {
943 waiters.push(Waiter { tx: waiter_tx });
944 return Ok(());
945 }
946
947 in_flight.insert(key.clone(), vec![Waiter { tx: waiter_tx }]);
948 drop(in_flight);
949
950 if let Err(message) = self.enqueue_new_job(
951 snapshot,
952 category,
953 caller_scope,
954 key.clone(),
955 callgraph_snapshot,
956 ) {
957 if let Ok(mut in_flight) = self.in_flight.lock() {
958 in_flight.remove(&key);
959 }
960 return Err(message);
961 }
962 Ok(())
963 }
964
965 fn enqueue_without_waiter(
966 &self,
967 snapshot: InspectSnapshot,
968 category: InspectCategory,
969 caller_scope: JobScope,
970 key: JobKey,
971 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
972 ) -> Result<(), String> {
973 let mut in_flight = self
974 .in_flight
975 .lock()
976 .map_err(|_| "inspect in-flight map lock poisoned".to_string())?;
977 if in_flight.contains_key(&key) {
978 return Ok(());
979 }
980 in_flight.insert(key.clone(), Vec::new());
981 drop(in_flight);
982
983 if let Err(message) = self.enqueue_new_job(
984 snapshot,
985 category,
986 caller_scope,
987 key.clone(),
988 callgraph_snapshot,
989 ) {
990 if let Ok(mut in_flight) = self.in_flight.lock() {
991 in_flight.remove(&key);
992 }
993 return Err(message);
994 }
995 Ok(())
996 }
997
998 fn enqueue_new_job(
999 &self,
1000 snapshot: InspectSnapshot,
1001 category: InspectCategory,
1002 caller_scope: JobScope,
1003 key: JobKey,
1004 callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
1005 ) -> Result<(), String> {
1006 let scan_scope = if category.is_tier2() {
1007 JobScope::for_project(snapshot.project_root.clone())
1008 } else {
1009 caller_scope
1010 };
1011 let scope_files = scope_files(&snapshot.project_root, &scan_scope);
1012 let job = InspectJob {
1013 job_id: self.next_job_id.fetch_add(1, Ordering::Relaxed),
1014 key,
1015 category,
1016 scope_files,
1017 project_root: snapshot.project_root,
1018 inspect_dir: snapshot.inspect_dir,
1019 config: snapshot.config,
1020 symbol_cache: snapshot.symbol_cache,
1021 callgraph_snapshot,
1022 };
1023 self.request_tx
1024 .send(job)
1025 .map_err(|_| "inspect dispatch loop is unavailable".to_string())
1026 }
1027
1028 fn wait_for_outcome(
1029 &self,
1030 key: JobKey,
1031 caller_scope: JobScope,
1032 cache: Arc<InspectCache>,
1033 waiter_rx: Receiver<JobOutcome>,
1034 snapshot: InspectSnapshot,
1035 ) -> JobOutcome {
1036 let timeout = after(self.soft_deadline);
1037 let result_rx = self.result_rx.clone();
1038 loop {
1039 select! {
1040 recv(waiter_rx) -> outcome => {
1041 return match outcome {
1042 Ok(outcome) => filter_outcome_for_scope_with_contributions(
1043 outcome,
1044 &snapshot,
1045 key.category,
1046 cache.as_ref(),
1047 &caller_scope,
1048 ),
1049 Err(_) => self.timeout_outcome(&key, &caller_scope, &cache, &snapshot),
1050 };
1051 }
1052 recv(result_rx) -> result => {
1053 match result {
1054 Ok(result) => self.route_completion(result),
1055 Err(_) => return self.timeout_outcome(&key, &caller_scope, &cache, &snapshot),
1056 }
1057 }
1058 recv(timeout) -> _ => {
1059 return self.timeout_outcome(&key, &caller_scope, &cache, &snapshot);
1060 }
1061 }
1062 }
1063 }
1064
1065 fn timeout_outcome(
1066 &self,
1067 key: &JobKey,
1068 caller_scope: &JobScope,
1069 cache: &InspectCache,
1070 snapshot: &InspectSnapshot,
1071 ) -> JobOutcome {
1072 match cache.get_aggregated(key) {
1073 Ok(Some(cached)) => filter_outcome_for_scope_with_contributions(
1074 JobOutcome::Stale {
1075 cached: Some(cached),
1076 in_flight: true,
1077 },
1078 snapshot,
1079 key.category,
1080 cache,
1081 caller_scope,
1082 ),
1083 Ok(None) => JobOutcome::Pending { in_flight: true },
1084 Err(error) => JobOutcome::Failed {
1085 message: error.to_string(),
1086 },
1087 }
1088 }
1089
1090 fn route_completion(&self, result: InspectResult) {
1091 let outcome = self.completion_outcome(result.clone());
1092 let waiters = self
1093 .in_flight
1094 .lock()
1095 .ok()
1096 .and_then(|mut in_flight| in_flight.remove(&result.key))
1097 .unwrap_or_default();
1098 for waiter in waiters {
1099 let _ = waiter.tx.send(outcome.clone());
1100 }
1101 }
1102
1103 fn route_tier2_reuse_completion(&self, result: InspectResult) {
1104 let outcome = match result.outcome.clone() {
1105 Ok(success) => JobOutcome::Fresh {
1106 payload: success.aggregate,
1107 },
1108 Err(message) => JobOutcome::Failed { message },
1109 };
1110 let waiters = self
1111 .in_flight
1112 .lock()
1113 .ok()
1114 .and_then(|mut in_flight| in_flight.remove(&result.key))
1115 .unwrap_or_default();
1116 for waiter in waiters {
1117 let _ = waiter.tx.send(outcome.clone());
1118 }
1119 self.reuse_completions.fetch_add(1, Ordering::SeqCst);
1124 }
1125
1126 pub fn reuse_completion_count(&self) -> u64 {
1130 self.reuse_completions.load(Ordering::SeqCst)
1131 }
1132
1133 fn completion_outcome(&self, result: InspectResult) -> JobOutcome {
1134 let cache =
1135 match self.cache_for_paths(result.inspect_dir.clone(), result.project_root.clone()) {
1136 Ok(cache) => cache,
1137 Err(message) => return JobOutcome::Failed { message },
1138 };
1139
1140 match result.outcome {
1141 Ok(success) => {
1142 let store_result = if result.category.is_tier2() {
1143 cache.store_tier2_result(
1144 result.key.clone(),
1145 &success.scanned_files,
1146 &success.contributions,
1147 success.aggregate.clone(),
1148 )
1149 } else {
1150 cache.store_aggregated(result.key, success.aggregate.clone())
1151 };
1152
1153 match store_result {
1154 Ok(()) => JobOutcome::Fresh {
1155 payload: success.aggregate,
1156 },
1157 Err(error) => JobOutcome::Failed {
1158 message: error.to_string(),
1159 },
1160 }
1161 }
1162 Err(message) => JobOutcome::Failed { message },
1163 }
1164 }
1165}
1166
1167impl Default for InspectManager {
1168 fn default() -> Self {
1169 Self::new()
1170 }
1171}
1172
1173fn validate_tier2_read_category(category: InspectCategory) -> Result<(), JobOutcome> {
1174 if !category.is_active() {
1175 return Err(JobOutcome::Failed {
1176 message: format!("inspect category '{category}' is disabled in v0.33"),
1177 });
1178 }
1179 if !category.is_tier2() {
1180 return Err(JobOutcome::Failed {
1181 message: format!("inspect category '{category}' is not a Tier 2 category"),
1182 });
1183 }
1184 Ok(())
1185}
1186
1187#[derive(Default)]
1194struct Tier2PhaseTimings {
1195 freshness: Duration,
1197 snapshot: Duration,
1199 scan: Duration,
1201 db: Duration,
1203 rollup: Duration,
1205 scanned_files: usize,
1206}
1207
1208impl Tier2PhaseTimings {
1209 fn log(&self, category: InspectCategory) {
1210 let worked = self.scan + self.snapshot + self.rollup + self.db;
1211 if worked < Duration::from_millis(50) {
1212 return;
1213 }
1214 crate::slog_info!(
1215 "perf tier2 phases category={} freshness={}ms snapshot={}ms scan={}ms({} files) db={}ms rollup={}ms",
1216 category,
1217 self.freshness.as_millis(),
1218 self.snapshot.as_millis(),
1219 self.scan.as_millis(),
1220 self.scanned_files,
1221 self.db.as_millis(),
1222 self.rollup.as_millis()
1223 );
1224 }
1225}
1226
1227fn scope_files(project_root: &Path, scope: &JobScope) -> Vec<PathBuf> {
1228 let mut files = crate::callgraph::walk_project_files(project_root)
1229 .filter(|path| scope.contains(path))
1230 .collect::<Vec<_>>();
1231 files.sort();
1232 files
1233}
1234
1235fn current_project_files(project_root: &Path, files: &[PathBuf]) -> BTreeMap<String, PathBuf> {
1236 files
1237 .iter()
1238 .map(|file| (relative_cache_key(project_root, file), file.clone()))
1239 .collect()
1240}
1241
1242fn tier2_benchmark_logging_enabled() -> bool {
1243 std::env::var_os("AFT_SETTLE_BENCH_LOG").is_some()
1244}
1245
1246fn log_tier2_benchmark_category_start(job: &InspectJob) {
1247 if !tier2_benchmark_logging_enabled() {
1248 return;
1249 }
1250 crate::slog_info!(
1251 "settle bench: tier2_category_start category={} job_id={} files={}",
1252 job.category.as_str(),
1253 job.job_id,
1254 job.scope_files.len()
1255 );
1256}
1257
1258fn log_tier2_benchmark_category_end(result: &InspectResult) {
1259 if !tier2_benchmark_logging_enabled() {
1260 return;
1261 }
1262 match &result.outcome {
1263 Ok(success) => {
1264 let count = success
1265 .aggregate
1266 .get("count")
1267 .and_then(serde_json::Value::as_u64)
1268 .unwrap_or(0);
1269 crate::slog_info!(
1270 "settle bench: tier2_category_end category={} job_id={} status=success total_ms={} scanned_files={} contributions={} count={}",
1271 result.category.as_str(),
1272 result.job_id,
1273 result.duration.as_millis(),
1274 success.scanned_files.len(),
1275 success.contributions.len(),
1276 count
1277 );
1278 }
1279 Err(message) => {
1280 crate::slog_info!(
1281 "settle bench: tier2_category_end category={} job_id={} status=failed total_ms={} error={}",
1282 result.category.as_str(),
1283 result.job_id,
1284 result.duration.as_millis(),
1285 message.replace('\n', " ")
1286 );
1287 }
1288 }
1289}
1290
1291fn build_tier2_callgraph_snapshot(job: &InspectJob) -> Option<Arc<CallgraphSnapshot>> {
1292 let started = Instant::now();
1293 if !job.config.callgraph_store {
1294 crate::slog_info!(
1295 "tier2 dead_code: callgraph store disabled; reporting callgraph_unavailable"
1296 );
1297 return None;
1298 }
1299
1300 let Some(callgraph_dir) = callgraph_store_dir_from_inspect_dir(&job.inspect_dir) else {
1301 crate::slog_info!(
1302 "tier2 dead_code: inspect_dir has no harness parent ({}); reporting callgraph_unavailable",
1303 job.inspect_dir.display()
1304 );
1305 return None;
1306 };
1307
1308 let store = match CallGraphStore::open_ready_repairing(
1314 callgraph_dir.clone(),
1315 job.project_root.clone(),
1316 ) {
1317 Ok(Some(store)) => store,
1318 Ok(None) => {
1319 crate::slog_info!(
1320 "tier2 dead_code: callgraph store unavailable at {} (cold/building/not ready); reporting callgraph_unavailable",
1321 callgraph_dir.display()
1322 );
1323 return None;
1324 }
1325 Err(error) => {
1326 crate::slog_warn!(
1327 "tier2 dead_code: failed to open callgraph store at {}: {}; reporting callgraph_unavailable",
1328 callgraph_dir.display(),
1329 error
1330 );
1331 return None;
1332 }
1333 };
1334
1335 let snapshot = match project_dead_code_snapshot(store.sqlite_path()) {
1336 Ok(snapshot) => snapshot,
1337 Err(CallGraphStoreError::Unavailable(message)) => {
1338 crate::slog_info!(
1339 "tier2 dead_code: callgraph store projection unavailable ({}); reporting callgraph_unavailable",
1340 message
1341 );
1342 return None;
1343 }
1344 Err(error) => {
1345 crate::slog_warn!(
1346 "tier2 dead_code: callgraph store projection failed: {}; reporting callgraph_unavailable",
1347 error
1348 );
1349 return None;
1350 }
1351 };
1352
1353 crate::slog_info!(
1354 "perf tier2_callgraph_snapshot: source=callgraph_store files={} exports={} edges={} entry_points={} ms={}",
1355 snapshot.files.len(),
1356 snapshot.exported_symbols.len(),
1357 snapshot.outbound_calls.len(),
1358 snapshot.entry_points.len(),
1359 started.elapsed().as_millis()
1360 );
1361
1362 Some(Arc::new(snapshot))
1363}
1364
1365fn callgraph_store_dir_from_inspect_dir(inspect_dir: &Path) -> Option<PathBuf> {
1366 inspect_dir
1367 .parent()
1368 .map(|harness_dir| harness_dir.join("callgraph"))
1369}
1370
1371#[cfg(test)]
1372fn canonicalize_for_snapshot(path: &Path) -> PathBuf {
1373 std::fs::canonicalize(path).unwrap_or_else(|_| normalize_path(path))
1374}
1375
1376fn load_contribution_fingerprint(
1377 cache: &InspectCache,
1378 category: InspectCategory,
1379) -> Result<ContributionFingerprint, String> {
1380 let (count, set_hash, hash_complete) = cache
1381 .contribution_fingerprint(category)
1382 .map_err(|error| error.to_string())?;
1383 Ok(ContributionFingerprint {
1384 count,
1385 set_hash,
1386 hash_complete,
1387 })
1388}
1389
1390fn current_file_fingerprint(
1391 project_root: &Path,
1392 files: &[PathBuf],
1393) -> Result<ContributionFingerprint, String> {
1394 let mut entries = Vec::with_capacity(files.len());
1395 let mut hash_complete = true;
1396 for file in files {
1397 let freshness = cache_freshness::collect(file)
1398 .map_err(|error| format!("failed to fingerprint {}: {error}", file.display()))?;
1399 let relative_path = relative_cache_key(project_root, file);
1400 let mtime_ns = system_time_to_ns_i64(freshness.mtime);
1401 if freshness.content_hash == cache_freshness::zero_hash() {
1402 hash_complete = false;
1403 }
1404 entries.push((
1405 relative_path,
1406 mtime_ns,
1407 freshness.size,
1408 freshness.content_hash.to_hex().to_string(),
1409 ));
1410 }
1411 entries.sort_by(|left, right| left.0.cmp(&right.0));
1412
1413 let mut hasher = blake3::Hasher::new();
1414 for (relative_path, mtime_ns, file_size, file_hash) in &entries {
1415 update_contribution_fingerprint_hash(
1416 &mut hasher,
1417 relative_path,
1418 *mtime_ns,
1419 *file_size,
1420 file_hash,
1421 );
1422 }
1423
1424 Ok(ContributionFingerprint {
1425 count: entries.len(),
1426 set_hash: hasher.finalize().to_hex().to_string(),
1427 hash_complete,
1428 })
1429}
1430
1431fn update_contribution_fingerprint_hash(
1432 hasher: &mut blake3::Hasher,
1433 relative_path: &str,
1434 mtime_ns: i64,
1435 file_size: u64,
1436 file_hash: &str,
1437) {
1438 hasher.update(relative_path.as_bytes());
1439 hasher.update(&[0]);
1440 hasher.update(&mtime_ns.to_le_bytes());
1441 hasher.update(&file_size.to_le_bytes());
1442 hasher.update(&[0]);
1443 hasher.update(file_hash.as_bytes());
1444}
1445
1446fn verify_contribution_file_strict(path: &Path, cached: &FileFreshness) -> ContributionFreshness {
1447 match cache_freshness::verify_file_strict(path, cached) {
1448 FreshnessVerdict::HotFresh => ContributionFreshness::Fresh {
1449 metadata_changed: false,
1450 freshness: *cached,
1451 },
1452 FreshnessVerdict::ContentFresh {
1453 new_mtime,
1454 new_size,
1455 } => ContributionFreshness::Fresh {
1456 metadata_changed: true,
1457 freshness: FileFreshness {
1458 mtime: new_mtime,
1459 size: new_size,
1460 content_hash: cached.content_hash,
1461 },
1462 },
1463 FreshnessVerdict::Stale => ContributionFreshness::Stale,
1464 FreshnessVerdict::Deleted => ContributionFreshness::Deleted,
1465 }
1466}
1467
1468fn load_contribution_freshness(
1469 cache: &InspectCache,
1470 category: InspectCategory,
1471) -> Result<Vec<CachedContributionFreshness>, String> {
1472 cache
1473 .contribution_freshness(category)
1474 .map_err(|error| error.to_string())
1475 .map(|records| {
1476 records
1477 .into_iter()
1478 .map(|(file_path, freshness)| CachedContributionFreshness {
1479 file_path,
1480 freshness,
1481 })
1482 .collect()
1483 })
1484}
1485
1486fn freshness_record_relative_key(record: &CachedContributionFreshness) -> String {
1487 record.file_path.to_string_lossy().to_string()
1488}
1489
1490fn system_time_to_ns_i64(time: SystemTime) -> i64 {
1491 let nanos = time
1492 .duration_since(UNIX_EPOCH)
1493 .unwrap_or_else(|_| Duration::from_secs(0))
1494 .as_nanos();
1495 nanos.min(i64::MAX as u128) as i64
1496}
1497
1498fn relative_cache_key(project_root: &Path, path: &Path) -> String {
1499 path.strip_prefix(project_root)
1500 .unwrap_or(path)
1501 .to_string_lossy()
1502 .to_string()
1503}
1504
1505fn load_contributions(
1506 cache: &InspectCache,
1507 job: &InspectJob,
1508) -> Result<Vec<FileContribution>, String> {
1509 cache
1510 .load_tier2_contributions(job.category)
1511 .map_err(|error| error.to_string())
1512 .map(|records| {
1513 records
1514 .into_iter()
1515 .map(|record| contribution_from_record(&job.project_root, record))
1516 .collect()
1517 })
1518}
1519
1520fn contribution_from_record(
1521 project_root: &Path,
1522 record: super::cache::ContributionRecord,
1523) -> FileContribution {
1524 FileContribution::new(
1525 record.category,
1526 project_root.join(record.file_path),
1527 record.freshness,
1528 record.contribution,
1529 )
1530 .with_type_ref_names(record.type_ref_names)
1531}
1532
1533fn run_tier2_scan(job: &InspectJob, oxc_result: Option<&OxcEngineResult>) -> InspectResult {
1534 use super::scanners;
1535
1536 match job.category {
1537 InspectCategory::DeadCode => {
1538 scanners::dead_code::run_dead_code_scan_with_oxc(job, oxc_result)
1539 }
1540 InspectCategory::UnusedExports => {
1541 scanners::unused_exports::run_unused_exports_scan_with_oxc(job, oxc_result)
1542 }
1543 InspectCategory::Duplicates => scanners::duplicates::run_duplicates_scan(job),
1544 other => InspectResult::failed(
1545 job,
1546 format!("inspect category '{other}' is not an active Tier 2 scanner"),
1547 Duration::from_secs(0),
1548 ),
1549 }
1550}
1551
1552fn roll_up_tier2_contributions(job: &InspectJob, contributions: &[FileContribution]) -> Value {
1553 roll_up_tier2_contributions_with_limit(job, contributions, Some(MAX_DRILL_DOWN_ITEMS))
1554}
1555
1556fn roll_up_tier2_contributions_with_limit(
1557 job: &InspectJob,
1558 contributions: &[FileContribution],
1559 drill_down_limit: Option<usize>,
1560) -> Value {
1561 match job.category {
1562 InspectCategory::DeadCode => {
1563 roll_up_dead_code_contributions(job, contributions, drill_down_limit)
1564 }
1565 InspectCategory::UnusedExports => {
1566 roll_up_unused_exports_contributions(job, contributions, drill_down_limit)
1567 }
1568 InspectCategory::Duplicates => {
1569 roll_up_duplicate_contributions(job, contributions, drill_down_limit)
1570 }
1571 _ => json!({
1572 "count": 0,
1573 "items": [],
1574 "scanned_files": contributions.len(),
1575 }),
1576 }
1577}
1578
1579fn scoped_tier2_payload_from_contributions(
1580 snapshot: &InspectSnapshot,
1581 category: InspectCategory,
1582 cache: &InspectCache,
1583 project_payload: Value,
1584 scope: &JobScope,
1585) -> Result<Value, String> {
1586 if scope.is_project_wide() {
1587 return Ok(project_payload);
1588 }
1589
1590 let project_scope = JobScope::for_project(snapshot.project_root.clone());
1591 let rollup_job = scoped_tier2_rollup_job(snapshot, category, &project_scope);
1592 let contributions = load_contributions(cache, &rollup_job)?;
1593 let full_payload = roll_up_tier2_contributions_with_limit(&rollup_job, &contributions, None);
1594 let scoped_payload = filter_payload_for_scope(full_payload, scope);
1595 Ok(cap_payload_drill_down(scoped_payload, MAX_DRILL_DOWN_ITEMS))
1596}
1597
1598fn scoped_tier2_rollup_job(
1599 snapshot: &InspectSnapshot,
1600 category: InspectCategory,
1601 scope: &JobScope,
1602) -> InspectJob {
1603 InspectJob {
1604 job_id: 0,
1605 key: JobKey::for_project_category(category),
1606 category,
1607 scope_files: scope_files(&snapshot.project_root, scope),
1608 project_root: snapshot.project_root.clone(),
1609 inspect_dir: snapshot.inspect_dir.clone(),
1610 config: Arc::clone(&snapshot.config),
1611 symbol_cache: Arc::clone(&snapshot.symbol_cache),
1612 callgraph_snapshot: (category == InspectCategory::DeadCode)
1613 .then(|| Arc::new(CallgraphSnapshot::default())),
1614 }
1615}
1616
1617fn roll_up_dead_code_contributions(
1618 job: &InspectJob,
1619 contributions: &[FileContribution],
1620 drill_down_limit: Option<usize>,
1621) -> Value {
1622 if job.callgraph_snapshot.is_none() {
1623 return super::scanners::dead_code::callgraph_unavailable_aggregate(job.scope_files.len());
1624 }
1625
1626 let public_api_files = super::scanners::dead_code::collect_public_api_files(&job.project_root);
1627 let roles = super::entry_points::resolve_project_roles(&job.project_root);
1628 super::scanners::dead_code::aggregate_dead_code_contributions_with_limit(
1629 contributions,
1630 &public_api_files,
1631 &roles,
1632 drill_down_limit,
1633 )
1634}
1635
1636fn roll_up_unused_exports_contributions(
1637 job: &InspectJob,
1638 contributions: &[FileContribution],
1639 drill_down_limit: Option<usize>,
1640) -> Value {
1641 let parsed = contributions
1642 .iter()
1643 .filter_map(|contribution| {
1644 serde_json::from_value::<UnusedExportsContribution>(contribution.contribution.clone())
1645 .ok()
1646 })
1647 .collect::<Vec<_>>();
1648
1649 let (public_api_entries, package_warnings) = unused_public_api_entries(&job.project_root);
1650 let mut imported_by: BTreeMap<(String, String), BTreeSet<String>> = BTreeMap::new();
1651 let mut uncertain_by: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
1652 for scan in &parsed {
1653 for import in &scan.imports {
1654 let Some(resolved_file) = &import.resolved_file else {
1655 continue;
1656 };
1657 for name in &import.named {
1658 if name == "*" {
1659 uncertain_by
1660 .entry(resolved_file.clone())
1661 .or_default()
1662 .insert(scan.file.clone());
1663 } else {
1664 imported_by
1665 .entry((resolved_file.clone(), name.clone()))
1666 .or_default()
1667 .insert(scan.file.clone());
1668 }
1669 }
1670 }
1671 }
1672
1673 let mut count = 0usize;
1674 let mut items = Vec::new();
1675 let mut uncertain_count = 0usize;
1676 let mut uncertain_items = Vec::new();
1677 for scan in &parsed {
1678 if public_api_entries.contains(&scan.file) {
1679 continue;
1680 }
1681 if super::job::is_test_support_file(&scan.file) {
1684 continue;
1685 }
1686
1687 for export in &scan.exports {
1688 if export_uses_oxc(export) {
1689 match export.verdict.unwrap_or(LivenessVerdict::Unused) {
1690 LivenessVerdict::Used => continue,
1691 LivenessVerdict::Uncertain => {
1692 uncertain_count += 1;
1693 if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
1694 uncertain_items.push(json!({
1695 "file": scan.file,
1696 "symbol": export.symbol,
1697 "kind": export.kind,
1698 "line": export.line,
1699 "reason": export.reason.as_deref().unwrap_or("oxc_uncertain"),
1700 "provenance": export.provenance.as_deref().unwrap_or(OXC_PROVENANCE),
1701 }));
1702 }
1703 continue;
1704 }
1705 LivenessVerdict::Unused => {}
1706 }
1707 } else {
1708 let imported = imported_by
1709 .get(&(scan.file.clone(), export.symbol.clone()))
1710 .map(|files| !files.is_empty())
1711 .unwrap_or(false);
1712 let uncertain = uncertain_by
1713 .get(&scan.file)
1714 .map(|files| !files.is_empty())
1715 .unwrap_or(false);
1716
1717 if imported {
1718 continue;
1719 }
1720 if uncertain {
1721 uncertain_count += 1;
1722 if drill_down_limit.is_none_or(|limit| uncertain_items.len() < limit) {
1723 uncertain_items.push(json!({
1724 "file": scan.file,
1725 "symbol": export.symbol,
1726 "kind": export.kind,
1727 "line": export.line,
1728 "reason": "wildcard_import",
1729 }));
1730 }
1731 continue;
1732 }
1733 }
1734
1735 count += 1;
1736 let mut item = json!({
1738 "file": scan.file,
1739 "symbol": export.symbol,
1740 "kind": export.kind,
1741 "line": export.line,
1742 });
1743 if let Some(provenance) = &export.provenance {
1744 item["provenance"] = json!(provenance);
1745 }
1746 items.push(item);
1747 }
1748 }
1749
1750 let roles = super::entry_points::resolve_project_roles(&job.project_root);
1751 let items = super::entry_points::rank_and_truncate_items(items, &roles, drill_down_limit);
1752 let top = super::entry_points::top_preview_symbols(&items);
1753
1754 let (parse_errors, skipped_files) = unused_exports_honesty_fields(&parsed);
1755 let mut aggregate = json!({
1756 "count": count,
1757 "items": items,
1758 "top": top,
1759 "drill_down_capped": drill_down_limit.is_some_and(|limit| count > limit),
1760 "scanned_files": parsed.len(),
1761 "languages_skipped": skipped_languages(&job.scope_files, LanguageSkipMode::UnusedExports),
1762 "uncertain_count": uncertain_count,
1763 "uncertain_items": uncertain_items,
1764 "complete": parse_errors.is_empty() && skipped_files.is_empty(),
1765 });
1766 if !parse_errors.is_empty() {
1767 aggregate["parse_errors"] = Value::Array(parse_errors);
1768 }
1769 if !skipped_files.is_empty() {
1770 aggregate["skipped_files"] = Value::Array(skipped_files);
1771 }
1772 if !package_warnings.is_empty() {
1773 aggregate["note"] = Value::String(package_warnings.join("; "));
1774 }
1775 aggregate
1776}
1777
1778fn unused_exports_honesty_fields(parsed: &[UnusedExportsContribution]) -> (Vec<Value>, Vec<Value>) {
1779 let mut parse_error_keys = BTreeSet::new();
1780 let mut parse_errors = Vec::new();
1781 let mut skipped_file_keys = BTreeSet::new();
1782 let mut skipped_files = Vec::new();
1783 for contribution in parsed {
1784 for value in &contribution.parse_errors {
1785 let key = value.to_string();
1786 if parse_error_keys.insert(key) {
1787 parse_errors.push(value.clone());
1788 }
1789 }
1790 for value in &contribution.skipped_files {
1791 let key = value.to_string();
1792 if skipped_file_keys.insert(key) {
1793 skipped_files.push(value.clone());
1794 }
1795 }
1796 }
1797 (parse_errors, skipped_files)
1798}
1799
1800fn roll_up_duplicate_contributions(
1801 job: &InspectJob,
1802 contributions: &[FileContribution],
1803 drill_down_limit: Option<usize>,
1804) -> Value {
1805 super::scanners::duplicates::aggregate_duplicate_contributions_with_limit(
1806 contributions,
1807 skipped_languages(&job.scope_files, LanguageSkipMode::Duplicates),
1808 drill_down_limit,
1809 )
1810}
1811
1812fn cap_payload_drill_down(mut payload: Value, limit: usize) -> Value {
1813 let mut capped = false;
1814 if let Some(items) = payload.get_mut("items").and_then(Value::as_array_mut) {
1815 capped |= items.len() > limit;
1816 items.truncate(limit);
1817 }
1818 if let Some(groups) = payload.get_mut("groups").and_then(Value::as_array_mut) {
1819 capped |= groups.len() > limit;
1820 groups.truncate(limit);
1821 }
1822 if let Some(object) = payload.as_object_mut() {
1823 object.insert("drill_down_capped".to_string(), json!(capped));
1824 }
1825 payload
1826}
1827
1828const MAX_DRILL_DOWN_ITEMS: usize = 100;
1829
1830#[derive(Debug, Clone, Deserialize)]
1831struct ExportContribution {
1832 symbol: String,
1833 kind: String,
1834 line: u32,
1835 #[serde(default)]
1836 verdict: Option<LivenessVerdict>,
1837 #[serde(default)]
1838 reason: Option<String>,
1839 #[serde(default)]
1840 provenance: Option<String>,
1841}
1842
1843fn export_uses_oxc(export: &ExportContribution) -> bool {
1844 export.verdict.is_some() || export.provenance.as_deref() == Some(OXC_PROVENANCE)
1845}
1846
1847#[derive(Debug, Clone, Deserialize)]
1848struct UnusedExportsContribution {
1849 file: String,
1850 exports: Vec<ExportContribution>,
1851 #[serde(default)]
1852 imports: Vec<ImportContribution>,
1853 #[serde(default)]
1854 parse_errors: Vec<Value>,
1855 #[serde(default)]
1856 skipped_files: Vec<Value>,
1857}
1858
1859#[derive(Debug, Clone, Deserialize)]
1860struct ImportContribution {
1861 resolved_file: Option<String>,
1862 named: Vec<String>,
1863}
1864
1865#[derive(Debug, Clone, Copy)]
1866enum LanguageSkipMode {
1867 Duplicates,
1868 UnusedExports,
1869}
1870
1871fn category_uses_oxc(category: InspectCategory) -> bool {
1872 matches!(
1873 category,
1874 InspectCategory::DeadCode | InspectCategory::UnusedExports
1875 )
1876}
1877
1878fn category_contributions_depend_on_entry_points(category: InspectCategory) -> bool {
1879 matches!(
1880 category,
1881 InspectCategory::DeadCode | InspectCategory::UnusedExports
1882 )
1883}
1884
1885fn skipped_languages(files: &[PathBuf], mode: LanguageSkipMode) -> Vec<String> {
1886 files
1887 .iter()
1888 .filter_map(|file| skipped_language(file, mode))
1889 .collect::<BTreeSet<_>>()
1890 .into_iter()
1891 .collect()
1892}
1893
1894fn skipped_language(file: &Path, mode: LanguageSkipMode) -> Option<String> {
1895 let Some(language) = crate::parser::detect_language(file) else {
1896 return match mode {
1897 LanguageSkipMode::Duplicates => Some("unknown".to_string()),
1898 LanguageSkipMode::UnusedExports => None,
1899 };
1900 };
1901
1902 let skipped = match mode {
1903 LanguageSkipMode::Duplicates => !duplicates_supports_language(language),
1904 LanguageSkipMode::UnusedExports => !is_js_ts_language(language),
1905 };
1906 skipped.then(|| language_name(language).to_string())
1907}
1908
1909fn duplicates_supports_language(language: crate::parser::LangId) -> bool {
1910 !matches!(
1911 language,
1912 crate::parser::LangId::Bash
1913 | crate::parser::LangId::Html
1914 | crate::parser::LangId::Json
1915 | crate::parser::LangId::Scala
1916 | crate::parser::LangId::Solidity
1917 | crate::parser::LangId::Scss
1918 | crate::parser::LangId::Vue
1919 | crate::parser::LangId::Markdown
1920 | crate::parser::LangId::Java
1921 | crate::parser::LangId::Ruby
1922 | crate::parser::LangId::Kotlin
1923 | crate::parser::LangId::Swift
1924 | crate::parser::LangId::Php
1925 | crate::parser::LangId::Lua
1926 | crate::parser::LangId::Perl
1927 | crate::parser::LangId::Pascal
1928 )
1929}
1930
1931fn is_js_ts_language(language: crate::parser::LangId) -> bool {
1932 matches!(
1933 language,
1934 crate::parser::LangId::TypeScript
1935 | crate::parser::LangId::Tsx
1936 | crate::parser::LangId::JavaScript
1937 )
1938}
1939
1940fn language_name(language: crate::parser::LangId) -> &'static str {
1941 match language {
1942 crate::parser::LangId::TypeScript => "typescript",
1943 crate::parser::LangId::Tsx => "tsx",
1944 crate::parser::LangId::JavaScript => "javascript",
1945 crate::parser::LangId::Python => "python",
1946 crate::parser::LangId::Rust => "rust",
1947 crate::parser::LangId::Go => "go",
1948 crate::parser::LangId::C => "c",
1949 crate::parser::LangId::Cpp => "cpp",
1950 crate::parser::LangId::Zig => "zig",
1951 crate::parser::LangId::CSharp => "csharp",
1952 crate::parser::LangId::Bash => "bash",
1953 crate::parser::LangId::Html => "html",
1954 crate::parser::LangId::Markdown => "markdown",
1955 crate::parser::LangId::Yaml => "yaml",
1956 crate::parser::LangId::Solidity => "solidity",
1957 crate::parser::LangId::Scss => "scss",
1958 crate::parser::LangId::Vue => "vue",
1959 crate::parser::LangId::Json => "json",
1960 crate::parser::LangId::Scala => "scala",
1961 crate::parser::LangId::Java => "java",
1962 crate::parser::LangId::Ruby => "ruby",
1963 crate::parser::LangId::Kotlin => "kotlin",
1964 crate::parser::LangId::Swift => "swift",
1965 crate::parser::LangId::Php => "php",
1966 crate::parser::LangId::Lua => "lua",
1967 crate::parser::LangId::Perl => "perl",
1968 crate::parser::LangId::Pascal => "pascal",
1969 }
1970}
1971
1972fn unused_public_api_entries(project_root: &Path) -> (BTreeSet<String>, Vec<String>) {
1973 let entry_points = super::entry_points::resolve_entry_points(project_root);
1974 (
1975 entry_points.public_api_files_relative(project_root),
1976 entry_points.warnings().to_vec(),
1977 )
1978}
1979
1980fn filter_outcome_for_scope_with_contributions(
1981 outcome: JobOutcome,
1982 snapshot: &InspectSnapshot,
1983 category: InspectCategory,
1984 cache: &InspectCache,
1985 scope: &JobScope,
1986) -> JobOutcome {
1987 if !category.is_tier2() || scope.is_project_wide() {
1988 return filter_outcome_for_scope(outcome, scope);
1989 }
1990
1991 match outcome {
1992 JobOutcome::Fresh { payload } => {
1993 match scoped_tier2_payload_from_contributions(snapshot, category, cache, payload, scope)
1994 {
1995 Ok(payload) => JobOutcome::Fresh { payload },
1996 Err(message) => JobOutcome::Failed { message },
1997 }
1998 }
1999 JobOutcome::Stale { cached, in_flight } => match cached {
2000 Some(payload) => {
2001 match scoped_tier2_payload_from_contributions(
2002 snapshot, category, cache, payload, scope,
2003 ) {
2004 Ok(payload) => JobOutcome::Stale {
2005 cached: Some(payload),
2006 in_flight,
2007 },
2008 Err(message) => JobOutcome::Failed { message },
2009 }
2010 }
2011 None => JobOutcome::Stale {
2012 cached: None,
2013 in_flight,
2014 },
2015 },
2016 JobOutcome::Pending { in_flight } => JobOutcome::Pending { in_flight },
2017 JobOutcome::Failed { message } => JobOutcome::Failed { message },
2018 }
2019}
2020
2021fn filter_outcome_for_scope(outcome: JobOutcome, scope: &JobScope) -> JobOutcome {
2022 match outcome {
2023 JobOutcome::Fresh { payload } => JobOutcome::Fresh {
2024 payload: filter_payload_for_scope(payload, scope),
2025 },
2026 JobOutcome::Stale { cached, in_flight } => JobOutcome::Stale {
2027 cached: cached.map(|payload| filter_payload_for_scope(payload, scope)),
2028 in_flight,
2029 },
2030 JobOutcome::Pending { in_flight } => JobOutcome::Pending { in_flight },
2031 JobOutcome::Failed { message } => JobOutcome::Failed { message },
2032 }
2033}
2034
2035fn filter_payload_for_scope(mut payload: serde_json::Value, scope: &JobScope) -> serde_json::Value {
2036 if scope.is_project_wide() {
2037 return payload;
2038 }
2039
2040 if let Some(items) = payload
2044 .get_mut("items")
2045 .and_then(|value| value.as_array_mut())
2046 {
2047 let count = filter_values_for_scope(items, scope);
2048 if let Some(object) = payload.as_object_mut() {
2049 object.insert("count".to_string(), serde_json::json!(count));
2050 if object.contains_key("total_groups") {
2051 object.insert("total_groups".to_string(), serde_json::json!(count));
2052 }
2053 if object.contains_key("groups_count") {
2054 object.insert("groups_count".to_string(), serde_json::json!(count));
2055 }
2056 }
2057 }
2058
2059 if let Some(groups) = payload
2060 .get_mut("groups")
2061 .and_then(|value| value.as_array_mut())
2062 {
2063 let count = filter_values_for_scope(groups, scope);
2064 if let Some(object) = payload.as_object_mut() {
2065 object.insert("count".to_string(), serde_json::json!(count));
2066 object.insert("total_groups".to_string(), serde_json::json!(count));
2067 if object.contains_key("groups_count") {
2068 object.insert("groups_count".to_string(), serde_json::json!(count));
2069 }
2070 }
2071 }
2072
2073 if let Some(object) = payload.as_object_mut() {
2079 object.remove("by_language");
2080 }
2081
2082 payload
2083}
2084
2085fn filter_values_for_scope(values: &mut Vec<serde_json::Value>, scope: &JobScope) -> usize {
2086 values.retain_mut(|value| prune_value_for_scope(value, scope));
2087 values.len()
2088}
2089
2090fn prune_value_for_scope(value: &mut serde_json::Value, scope: &JobScope) -> bool {
2091 if let Some(file) = value.get("file").and_then(|file| file.as_str()) {
2092 return scope.contains_display_path(file);
2093 }
2094
2095 let first_scoped_occurrence = if let Some(files) = value
2096 .get_mut("files")
2097 .and_then(|files| files.as_array_mut())
2098 {
2099 files.retain(|file| {
2100 file.as_str()
2101 .is_some_and(|file| scope.contains_display_path(display_file_from_occurrence(file)))
2102 });
2103 if files.len() < 2 {
2104 return false;
2105 }
2106 files.first().and_then(Value::as_str).map(str::to_string)
2107 } else {
2108 None
2109 };
2110
2111 if let Some(occurrence) = first_scoped_occurrence {
2112 update_duplicate_group_sample(value, &occurrence);
2113 }
2114
2115 true
2116}
2117
2118fn update_duplicate_group_sample(value: &mut serde_json::Value, occurrence: &str) {
2119 let Some((file, start_line, end_line)) = parse_duplicate_occurrence(occurrence) else {
2120 return;
2121 };
2122 let Some(object) = value.as_object_mut() else {
2123 return;
2124 };
2125
2126 if object.contains_key("sample_file") {
2127 object.insert("sample_file".to_string(), json!(file));
2128 }
2129 if object.contains_key("sample_start_line") {
2130 object.insert("sample_start_line".to_string(), json!(start_line));
2131 }
2132 if object.contains_key("sample_end_line") {
2133 object.insert("sample_end_line".to_string(), json!(end_line));
2134 }
2135}
2136
2137fn parse_duplicate_occurrence(value: &str) -> Option<(&str, u64, u64)> {
2138 let (file, range) = value.rsplit_once(':')?;
2139 let (start, end) = range.split_once('-')?;
2140 if !start.chars().all(|char| char.is_ascii_digit())
2141 || !end.chars().all(|char| char.is_ascii_digit())
2142 {
2143 return None;
2144 }
2145
2146 Some((file, start.parse().ok()?, end.parse().ok()?))
2147}
2148
2149fn display_file_from_occurrence(value: &str) -> &str {
2150 let Some((file, range)) = value.rsplit_once(':') else {
2151 return value;
2152 };
2153 let Some((start, end)) = range.split_once('-') else {
2154 return value;
2155 };
2156 if start.chars().all(|char| char.is_ascii_digit())
2157 && end.chars().all(|char| char.is_ascii_digit())
2158 {
2159 file
2160 } else {
2161 value
2162 }
2163}
2164
2165#[allow(dead_code)]
2166fn normalize_scope_root(path: &Path) -> PathBuf {
2167 std::fs::canonicalize(path).unwrap_or_else(|_| normalize_path(path))
2168}
2169
2170#[cfg(test)]
2171mod guard_tests {
2172 use super::*;
2173
2174 fn write_ts_project(file_count: usize) -> tempfile::TempDir {
2175 let dir = tempfile::tempdir().expect("tempdir");
2176 let root = dir.path();
2177 for i in 0..file_count {
2178 std::fs::write(
2179 root.join(format!("mod{i}.ts")),
2180 format!("export function f{i}() {{ return {i}; }}\n"),
2181 )
2182 .expect("write fixture");
2183 }
2184 dir
2185 }
2186
2187 #[test]
2188 fn cache_for_paths_rebinds_same_project_key_to_current_root() {
2189 let dir = tempfile::tempdir().expect("tempdir");
2190 let source = dir.path().join("source");
2191 std::fs::create_dir_all(&source).expect("create source repo");
2192 std::fs::write(
2193 source.join("package.json"),
2194 r#"{"name":"inspect-cache-fixture","version":"1.0.0"}"#,
2195 )
2196 .expect("write source manifest");
2197 std::fs::write(source.join("index.ts"), "export const source = 1;\n")
2198 .expect("write source file");
2199 assert!(std::process::Command::new("git")
2200 .current_dir(&source)
2201 .arg("init")
2202 .status()
2203 .expect("git init source repo")
2204 .success());
2205 assert!(std::process::Command::new("git")
2206 .current_dir(&source)
2207 .args(["add", "."])
2208 .status()
2209 .expect("git add source repo")
2210 .success());
2211 assert!(std::process::Command::new("git")
2212 .current_dir(&source)
2213 .args([
2214 "-c",
2215 "user.name=AFT Tests",
2216 "-c",
2217 "user.email=aft-tests@example.com",
2218 "commit",
2219 "-m",
2220 "initial",
2221 ])
2222 .status()
2223 .expect("git commit source repo")
2224 .success());
2225
2226 let clone = dir.path().join("clone");
2227 assert!(std::process::Command::new("git")
2228 .args(["clone", "--quiet"])
2229 .arg(&source)
2230 .arg(&clone)
2231 .status()
2232 .expect("git clone source repo")
2233 .success());
2234 std::fs::write(
2235 clone.join("package.json"),
2236 r#"{"name":"inspect-cache-fixture","version":"2.0.0"}"#,
2237 )
2238 .expect("write clone manifest edit");
2239 assert_eq!(
2240 crate::search_index::project_cache_key(&source),
2241 crate::search_index::project_cache_key(&clone),
2242 "clones with the same root commit should share the sqlite project key"
2243 );
2244
2245 let source = std::fs::canonicalize(source).expect("canonical source root");
2246 let clone = std::fs::canonicalize(clone).expect("canonical clone root");
2247 let manager = InspectManager::new();
2248 let inspect_dir = dir.path().join("inspect");
2249 let key = JobKey::for_project_category(InspectCategory::DeadCode);
2250 let source_cache = manager
2251 .cache_for_paths(inspect_dir.clone(), source.clone())
2252 .expect("open source cache");
2253 let source_hash = source_cache
2254 .contribution_set_hash(InspectCategory::DeadCode)
2255 .expect("source contribution hash");
2256 source_cache
2257 .store_tier2_aggregate(
2258 key.clone(),
2259 &source_hash,
2260 serde_json::json!({ "count": 7, "items": [] }),
2261 )
2262 .expect("store source aggregate");
2263 assert_eq!(
2264 source_cache
2265 .get_aggregated(&key)
2266 .expect("read source aggregate")
2267 .and_then(|payload| payload.get("count").and_then(Value::as_u64)),
2268 Some(7)
2269 );
2270
2271 let clone_cache = manager
2272 .cache_for_paths(inspect_dir, clone.clone())
2273 .expect("open clone cache");
2274 assert_eq!(clone_cache.project_root(), clone.as_path());
2275 assert!(
2276 clone_cache
2277 .get_aggregated(&key)
2278 .expect("read clone aggregate")
2279 .is_none(),
2280 "same-key clone with a different manifest must not reuse the source root's cached count"
2281 );
2282 }
2283
2284 fn snapshot_job(root: &Path, inspect_dir: &Path, callgraph_store: bool) -> InspectJob {
2285 use crate::config::Config;
2286 use crate::parser::SymbolCache;
2287 use std::sync::RwLock;
2288
2289 InspectJob {
2290 job_id: 1,
2291 key: JobKey::for_project_category(InspectCategory::DeadCode),
2292 category: InspectCategory::DeadCode,
2293 scope_files: Vec::new(),
2294 project_root: root.to_path_buf(),
2295 inspect_dir: inspect_dir.to_path_buf(),
2296 config: Arc::new(Config {
2297 project_root: Some(root.to_path_buf()),
2298 callgraph_store,
2299 ..Config::default()
2300 }),
2301 symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
2302 callgraph_snapshot: None,
2303 }
2304 }
2305
2306 #[test]
2307 fn callgraph_snapshot_reports_unavailable_when_store_disabled() {
2308 let dir = write_ts_project(3);
2309 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
2310 let inspect_dir = root.join(".aft-cache").join("inspect");
2311
2312 let snapshot = build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, false));
2313
2314 assert!(
2315 snapshot.is_none(),
2316 "dead_code must not rebuild the legacy graph when the store is disabled"
2317 );
2318 }
2319
2320 #[test]
2321 fn callgraph_snapshot_reports_unavailable_when_store_not_ready() {
2322 let dir = write_ts_project(3);
2323 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
2324 let inspect_dir = root.join(".aft-cache").join("inspect");
2325 let callgraph_dir = callgraph_store_dir_from_inspect_dir(&inspect_dir).expect("store dir");
2326 let _store = CallGraphStore::open(callgraph_dir, root.clone()).expect("open empty store");
2327
2328 let snapshot = build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true));
2329
2330 assert!(
2331 snapshot.is_none(),
2332 "a cold/mid-build store must surface callgraph_unavailable instead of rebuilding inline"
2333 );
2334 }
2335
2336 #[test]
2337 fn callgraph_snapshot_reads_ready_callgraph_store() {
2338 let dir = write_ts_project(3);
2339 let root = std::fs::canonicalize(dir.path()).expect("canonical root");
2340 let inspect_dir = root.join(".aft-cache").join("inspect");
2341 let callgraph_dir = callgraph_store_dir_from_inspect_dir(&inspect_dir).expect("store dir");
2342 let store = CallGraphStore::open(callgraph_dir, root.clone()).expect("open store");
2343 let files = crate::callgraph::walk_project_files(&root).collect::<Vec<_>>();
2344 store.cold_build(&files).expect("cold build store");
2345
2346 let snapshot = build_tier2_callgraph_snapshot(&snapshot_job(&root, &inspect_dir, true))
2347 .expect("ready store snapshot");
2348
2349 assert_eq!(snapshot.files.len(), 3);
2350 assert_eq!(snapshot.exported_symbols.len(), 3);
2351 }
2352
2353 #[test]
2357 fn scoped_filter_drops_project_wide_by_language() {
2358 let scope = JobScope::from_roots("/proj", vec![PathBuf::from("/proj/src/a")]);
2359 assert!(
2360 !scope.is_project_wide(),
2361 "scope must be non-project for test"
2362 );
2363 let payload = serde_json::json!({
2364 "count": 99,
2365 "by_language": { "rust": 214, "typescript": 143 },
2366 "items": [
2367 { "file": "/proj/src/a/x.rs", "symbol": "live" },
2368 { "file": "/proj/src/other/y.rs", "symbol": "out" },
2369 ],
2370 });
2371 let filtered = filter_payload_for_scope(payload, &scope);
2372 assert!(
2373 filtered.get("by_language").is_none(),
2374 "scoped payload must drop project-wide by_language: {filtered}"
2375 );
2376 assert_eq!(filtered.get("count").and_then(|v| v.as_u64()), Some(1));
2378 }
2379}
2380
2381#[cfg(test)]
2382mod dead_code_projection_tests {
2383 use super::*;
2384 use crate::callgraph::walk_project_files;
2385 use crate::callgraph_store::{project_dead_code_snapshot, CallGraphStore};
2386 use crate::config::Config;
2387 use crate::inspect::job::DISPATCHED_CALLEE_SEPARATOR;
2388 use crate::inspect::scanners::DEFAULT_EXPORT_MARKER_KIND;
2389 use crate::parser::SymbolCache;
2390 use filetime::FileTime;
2391 use std::sync::atomic::{AtomicI64, Ordering as AtomicOrdering};
2392 use std::sync::RwLock;
2393
2394 static NEXT_MTIME: AtomicI64 = AtomicI64::new(1_900_000_000);
2395
2396 #[derive(Debug, PartialEq, Eq)]
2397 struct ComparableSnapshot {
2398 files: BTreeSet<PathBuf>,
2399 exported_symbols: BTreeSet<(PathBuf, String, String, u32)>,
2400 outbound_calls: BTreeSet<(PathBuf, String, String, u32)>,
2401 entry_points: BTreeSet<PathBuf>,
2402 }
2403
2404 #[test]
2405 fn dead_code_projection_contains_expected_fixture_surface() {
2406 let dir = tempfile::tempdir().expect("tempdir");
2407 write_projection_fixture(dir.path());
2408 let root = canonical_root(dir.path());
2409 let projected = store_projected_snapshot(&root, ".store-dead-code-surface");
2410
2411 assert_projection_fixture_coverage(&root, &projected);
2412 }
2413
2414 #[test]
2415 fn dead_code_projection_incremental_scenario_matrix_matches_cold_rebuild() {
2416 run_projection_scenario("rename", setup_projection_rename, edit_projection_rename);
2417 run_projection_scenario("delete", setup_projection_delete, edit_projection_delete);
2418 run_projection_scenario(
2419 "barrel delete",
2420 setup_projection_barrel,
2421 edit_projection_barrel_delete,
2422 );
2423 run_projection_scenario(
2424 "dispatch edit",
2425 setup_projection_dispatch,
2426 edit_projection_dispatch,
2427 );
2428 run_projection_scenario(
2429 "body-only edit",
2430 setup_projection_body_only,
2431 edit_projection_body_only,
2432 );
2433 }
2434
2435 #[test]
2436 fn dead_code_projection_dead_code_scan_reports_expected_verdicts() {
2437 let dir = tempfile::tempdir().expect("tempdir");
2438 write_projection_fixture(dir.path());
2439 let root = canonical_root(dir.path());
2440 let files = project_files(&root);
2441 let projected = store_projected_snapshot(&root, ".store-dead-code-e2e");
2442
2443 let projected_aggregate = dead_code_aggregate(&root, files, projected);
2444 assert_dead_item(&projected_aggregate, "src/dead.ts", "knownDead");
2445 assert_live_item(&projected_aggregate, "src/live.ts", "knownLive");
2446 assert_live_item(&projected_aggregate, "src/render.ts", "render");
2447 assert_live_item(&projected_aggregate, "src/other_render.ts", "render");
2448 }
2449
2450 fn assert_projection_fixture_coverage(root: &Path, snapshot: &CallgraphSnapshot) {
2451 let comparable = comparable_snapshot(snapshot);
2452 assert!(
2453 comparable
2454 .files
2455 .iter()
2456 .any(|file| file.extension().and_then(|ext| ext.to_str()) == Some("ts")),
2457 "fixture must include TypeScript files: {:#?}",
2458 comparable.files
2459 );
2460 assert!(
2461 comparable
2462 .files
2463 .iter()
2464 .any(|file| file.extension().and_then(|ext| ext.to_str()) == Some("js")),
2465 "fixture must include JavaScript files: {:#?}",
2466 comparable.files
2467 );
2468 assert!(
2469 comparable
2470 .files
2471 .iter()
2472 .any(|file| file.extension().and_then(|ext| ext.to_str()) == Some("rs")),
2473 "fixture must include Rust files: {:#?}",
2474 comparable.files
2475 );
2476
2477 let main_file = canonicalize_for_snapshot(&root.join("src/main.ts"));
2478 let private_dispatch_target = format!("{}::dispatch", main_file.display());
2479 assert!(
2480 comparable
2481 .outbound_calls
2482 .iter()
2483 .any(
2484 |(caller_file, caller_symbol, target, _)| caller_file == &main_file
2485 && caller_symbol == "main"
2486 && target == &private_dispatch_target
2487 ),
2488 "fixture must cover same-file private fallback target {private_dispatch_target}: {:#?}",
2489 comparable.outbound_calls
2490 );
2491 assert!(
2492 comparable
2493 .outbound_calls
2494 .iter()
2495 .any(|(_, _, target, _)| target.contains(DISPATCHED_CALLEE_SEPARATOR)),
2496 "fixture must cover method-dispatch suffixes: {:#?}",
2497 comparable.outbound_calls
2498 );
2499 assert!(
2500 comparable
2501 .exported_symbols
2502 .iter()
2503 .any(|(_, symbol, kind, _)| symbol == "runDefault"
2504 && kind == DEFAULT_EXPORT_MARKER_KIND),
2505 "fixture must cover default-export marker rows: {:#?}",
2506 comparable.exported_symbols
2507 );
2508 }
2509
2510 fn run_projection_scenario(name: &str, setup: fn(&Path), edit: fn(&Path) -> Vec<PathBuf>) {
2511 let dir = tempfile::tempdir().expect("tempdir");
2512 setup(dir.path());
2513 let root = canonical_root(dir.path());
2514 let files_before = project_files(&root);
2515 let incremental_store = CallGraphStore::open(
2516 root.join(format!(".store-dead-code-projection-{name}-incremental")),
2517 root.clone(),
2518 )
2519 .expect("open incremental store");
2520 incremental_store
2521 .cold_build(&files_before)
2522 .expect("initial cold build");
2523
2524 let changed = edit(&root);
2525 incremental_store
2526 .refresh_files(&changed)
2527 .expect("refresh changed files");
2528 let incremental = project_dead_code_snapshot(incremental_store.sqlite_path())
2529 .expect("project incremental snapshot");
2530
2531 let cold_store = CallGraphStore::open(
2532 root.join(format!(".store-dead-code-projection-{name}-cold")),
2533 root.clone(),
2534 )
2535 .expect("open cold store");
2536 cold_store
2537 .cold_build(&project_files(&root))
2538 .expect("cold rebuild");
2539 let cold =
2540 project_dead_code_snapshot(cold_store.sqlite_path()).expect("project cold snapshot");
2541
2542 assert_snapshot_parts_eq(name, &cold, &incremental);
2543 }
2544
2545 #[test]
2554 #[ignore = "manual benchmark; needs AFT_BENCH_REPO pointing at a large checkout"]
2555 fn dead_code_decision_b_benchmark() {
2556 let Ok(repo) = std::env::var("AFT_BENCH_REPO") else {
2557 eprintln!("AFT_BENCH_REPO unset; skipping");
2558 return;
2559 };
2560 macro_rules! mark {
2562 ($($a:tt)*) => {{ eprintln!($($a)*); let _ = std::io::Write::flush(&mut std::io::stderr()); }};
2563 }
2564 let root = canonical_root(Path::new(&repo));
2565 let files = project_files(&root);
2566 mark!(
2567 "\n=== Store-backed dead_code benchmark ===\nrepo: {}\nsource files (walk_project_files): {}\nstarted store cold_build...",
2568 root.display(),
2569 files.len()
2570 );
2571
2572 let store_dir = root.join(".aft-bench-store");
2575 let _ = std::fs::remove_dir_all(&store_dir);
2576 let store = CallGraphStore::open(store_dir.clone(), root.clone()).expect("open store");
2577 let t = Instant::now();
2578 let cold_stats = store.cold_build(&files).expect("store cold build");
2579 let store_build_ms = t.elapsed().as_millis();
2580 let t = Instant::now();
2581 let projected = project_dead_code_snapshot(store.sqlite_path()).expect("projection");
2582 let proj_ms = t.elapsed().as_millis();
2583 mark!(
2584 "store cold_build: {} ms ({:?}) + projection: {} ms = {} ms (exports={}, outbound={})\nstarted scan...",
2585 store_build_ms, cold_stats, proj_ms, store_build_ms + proj_ms,
2586 projected.exported_symbols.len(), projected.outbound_calls.len()
2587 );
2588
2589 let t = Instant::now();
2591 let _result = dead_code_aggregate(&root, files.clone(), projected.clone());
2592 let scan_ms = t.elapsed().as_millis();
2593 mark!("run_dead_code_scan (cold contributions): {} ms", scan_ms);
2594
2595 mark!(
2596 "\nSUMMARY files={} store_cold_plus_projection={}ms projection={}ms scan_cold={}ms total={}ms",
2597 files.len(),
2598 store_build_ms + proj_ms,
2599 proj_ms,
2600 scan_ms,
2601 store_build_ms + proj_ms + scan_ms
2602 );
2603 let _ = std::fs::remove_dir_all(&store_dir);
2604 }
2605
2606 fn store_projected_snapshot(root: &Path, store_name: &str) -> CallgraphSnapshot {
2607 let store =
2608 CallGraphStore::open(root.join(store_name), root.to_path_buf()).expect("open store");
2609 store
2610 .cold_build(&project_files(root))
2611 .expect("store cold build");
2612 project_dead_code_snapshot(store.sqlite_path()).expect("project snapshot")
2613 }
2614
2615 fn dead_code_aggregate(
2616 root: &Path,
2617 scope_files: Vec<PathBuf>,
2618 snapshot: CallgraphSnapshot,
2619 ) -> Value {
2620 let job = InspectJob {
2621 job_id: 86,
2622 key: JobKey::for_project_category(InspectCategory::DeadCode),
2623 category: InspectCategory::DeadCode,
2624 scope_files,
2625 project_root: root.to_path_buf(),
2626 inspect_dir: root.join(".aft-cache").join("inspect"),
2627 config: Arc::new(Config {
2628 project_root: Some(root.to_path_buf()),
2629 ..Config::default()
2630 }),
2631 symbol_cache: Arc::new(RwLock::new(SymbolCache::new())),
2632 callgraph_snapshot: Some(Arc::new(snapshot)),
2633 };
2634 crate::inspect::scanners::dead_code::run_dead_code_scan(&job)
2635 .outcome
2636 .expect("dead_code scan succeeds")
2637 .aggregate
2638 }
2639
2640 fn assert_snapshot_parts_eq(
2641 label: &str,
2642 expected: &CallgraphSnapshot,
2643 actual: &CallgraphSnapshot,
2644 ) {
2645 let expected = comparable_snapshot(expected);
2646 let actual = comparable_snapshot(actual);
2647 assert_eq!(
2648 actual, expected,
2649 "{label} store-projected snapshot must match cold store snapshot"
2650 );
2651 }
2652
2653 fn comparable_snapshot(snapshot: &CallgraphSnapshot) -> ComparableSnapshot {
2654 ComparableSnapshot {
2655 files: snapshot.files.iter().cloned().collect(),
2656 exported_symbols: snapshot
2657 .exported_symbols
2658 .iter()
2659 .map(|export| {
2660 (
2661 export.file.clone(),
2662 export.symbol.clone(),
2663 export.kind.clone(),
2664 export.line,
2665 )
2666 })
2667 .collect(),
2668 outbound_calls: snapshot
2669 .outbound_calls
2670 .iter()
2671 .map(|call| {
2672 (
2673 call.caller_file.clone(),
2674 call.caller_symbol.clone(),
2675 call.target.clone(),
2676 call.line,
2677 )
2678 })
2679 .collect(),
2680 entry_points: snapshot.entry_points.clone(),
2681 }
2682 }
2683
2684 fn assert_dead_item(aggregate: &Value, file: &str, symbol: &str) {
2685 assert!(
2686 aggregate_has_item(aggregate, file, symbol),
2687 "expected {file}::{symbol} to be reported dead: {aggregate:#}"
2688 );
2689 }
2690
2691 fn assert_live_item(aggregate: &Value, file: &str, symbol: &str) {
2692 assert!(
2693 !aggregate_has_item(aggregate, file, symbol),
2694 "expected {file}::{symbol} to be live/not reported dead: {aggregate:#}"
2695 );
2696 }
2697
2698 fn aggregate_has_item(aggregate: &Value, file: &str, symbol: &str) -> bool {
2699 let Some(items) = aggregate.get("items").and_then(Value::as_array) else {
2700 return false;
2701 };
2702 items.iter().any(|item| {
2703 item.get("file").and_then(Value::as_str) == Some(file)
2704 && item.get("symbol").and_then(Value::as_str) == Some(symbol)
2705 })
2706 }
2707
2708 fn project_files(root: &Path) -> Vec<PathBuf> {
2709 walk_project_files(root).collect()
2710 }
2711
2712 fn canonical_root(root: &Path) -> PathBuf {
2713 std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf())
2714 }
2715
2716 fn write_file(path: &Path, content: &str) {
2717 if let Some(parent) = path.parent() {
2718 std::fs::create_dir_all(parent).expect("create parent");
2719 }
2720 std::fs::write(path, content).expect("write fixture");
2721 bump_mtime(path);
2722 }
2723
2724 fn bump_mtime(path: &Path) {
2725 let secs = NEXT_MTIME.fetch_add(1, AtomicOrdering::SeqCst);
2726 filetime::set_file_mtime(path, FileTime::from_unix_time(secs, 0)).expect("bump mtime");
2727 }
2728
2729 fn remove_file(path: &Path) {
2730 std::fs::remove_file(path).expect("remove fixture");
2731 }
2732
2733 fn write_projection_fixture(root: &Path) {
2734 write_file(
2735 &root.join("package.json"),
2736 r#"{"name":"dead-code-projection-fixture","type":"module","main":"src/main.ts"}"#,
2737 );
2738 write_file(
2739 &root.join("Cargo.toml"),
2740 r#"[package]
2741name = "dead_code_projection_fixture"
2742version = "0.1.0"
2743edition = "2021"
2744"#,
2745 );
2746 write_file(
2747 &root.join("src/main.ts"),
2748 r#"import runDefault from "./default";
2749import { knownLive } from "./live";
2750import { jsEntry } from "./app.js";
2751
2752export function main() {
2753 dispatch();
2754 runDefault();
2755 jsEntry();
2756}
2757
2758function dispatch() {
2759 knownLive();
2760 const service = { render() {} };
2761 service.render();
2762}
2763"#,
2764 );
2765 write_file(
2766 &root.join("src/default.ts"),
2767 r#"export default function runDefault() {}
2768"#,
2769 );
2770 write_file(
2771 &root.join("src/live.ts"),
2772 r#"export function knownLive() {}
2773"#,
2774 );
2775 write_file(
2776 &root.join("src/dead.ts"),
2777 r#"export function knownDead() {}
2778"#,
2779 );
2780 write_file(
2781 &root.join("src/render.ts"),
2782 r#"export function render() {}
2783"#,
2784 );
2785 write_file(
2786 &root.join("src/other_render.ts"),
2787 r#"export function render() {}
2788"#,
2789 );
2790 write_file(
2791 &root.join("src/app.js"),
2792 r#"import { jsHelper } from "./js_helper.js";
2793
2794export function jsEntry() {
2795 jsHelper();
2796}
2797"#,
2798 );
2799 write_file(
2800 &root.join("src/js_helper.js"),
2801 r#"export function jsHelper() {}
2802"#,
2803 );
2804 write_file(
2805 &root.join("src/lib.rs"),
2806 r#"mod util;
2807use crate::util::rust_helper;
2808
2809pub fn rust_entry() {
2810 rust_helper();
2811}
2812"#,
2813 );
2814 write_file(
2815 &root.join("src/util.rs"),
2816 r#"pub fn rust_helper() {}
2817"#,
2818 );
2819 }
2820
2821 fn setup_projection_rename(root: &Path) {
2822 write_file(
2823 &root.join("a.ts"),
2824 r#"export function outer() {
2825 inner();
2826}
2827
2828export function inner() {}
2829"#,
2830 );
2831 }
2832
2833 fn edit_projection_rename(root: &Path) -> Vec<PathBuf> {
2834 let path = root.join("a.ts");
2835 write_file(
2836 &path,
2837 r#"export function outer() {
2838 renamed();
2839}
2840
2841export function renamed() {}
2842"#,
2843 );
2844 vec![path]
2845 }
2846
2847 fn setup_projection_delete(root: &Path) {
2848 write_file(
2849 &root.join("main.ts"),
2850 r#"import { foo } from "./foo";
2851export function main() { foo(); }
2852"#,
2853 );
2854 write_file(&root.join("foo.ts"), "export function foo() {}\n");
2855 }
2856
2857 fn edit_projection_delete(root: &Path) -> Vec<PathBuf> {
2858 let path = root.join("foo.ts");
2859 remove_file(&path);
2860 vec![path]
2861 }
2862
2863 fn setup_projection_barrel(root: &Path) {
2864 write_file(
2865 &root.join("main.ts"),
2866 r#"import { foo } from "./barrel";
2867export function main() { foo(); }
2868"#,
2869 );
2870 write_file(&root.join("barrel.ts"), "export { foo } from \"./foo\";\n");
2871 write_file(&root.join("foo.ts"), "export function foo() {}\n");
2872 }
2873
2874 fn edit_projection_barrel_delete(root: &Path) -> Vec<PathBuf> {
2875 let path = root.join("barrel.ts");
2876 remove_file(&path);
2877 vec![path]
2878 }
2879
2880 fn setup_projection_dispatch(root: &Path) {
2881 write_file(
2882 &root.join("main.ts"),
2883 r#"export function main() {
2884 const service = { render() {}, paint() {} };
2885 service.render();
2886}
2887"#,
2888 );
2889 write_file(&root.join("render.ts"), "export function render() {}\n");
2890 write_file(&root.join("paint.ts"), "export function paint() {}\n");
2891 }
2892
2893 fn edit_projection_dispatch(root: &Path) -> Vec<PathBuf> {
2894 let path = root.join("main.ts");
2895 write_file(
2896 &path,
2897 r#"export function main() {
2898 const service = { render() {}, paint() {} };
2899 service.paint();
2900}
2901"#,
2902 );
2903 vec![path]
2904 }
2905
2906 fn setup_projection_body_only(root: &Path) {
2907 write_file(
2908 &root.join("main.ts"),
2909 r#"import { foo } from "./foo";
2910export function main() { foo(); }
2911"#,
2912 );
2913 write_file(
2914 &root.join("foo.ts"),
2915 r#"export function foo() {
2916 return 1;
2917}
2918"#,
2919 );
2920 }
2921
2922 fn edit_projection_body_only(root: &Path) -> Vec<PathBuf> {
2923 let path = root.join("foo.ts");
2924 write_file(
2925 &path,
2926 r#"export function foo() {
2927 return 2;
2928}
2929"#,
2930 );
2931 vec![path]
2932 }
2933}