1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt;
3use std::hash::{Hash, Hasher};
4use std::path::{Path, PathBuf};
5use std::str::FromStr;
6use std::sync::Arc;
7use std::time::{Duration, SystemTime};
8
9use serde::{Deserialize, Serialize};
10
11use crate::cache_freshness::FileFreshness;
12use crate::config::Config;
13use crate::parser::SharedSymbolCache;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum InspectCategory {
18 Diagnostics,
19 Metrics,
20 Todos,
21 DeadCode,
22 UnusedExports,
23 Duplicates,
24 Cycles,
25 Complexity,
26 CircularDeps,
27 OutdatedDeps,
28 Vulnerabilities,
29 TestCoverageGaps,
30 ApiSurface,
31}
32
33impl InspectCategory {
34 pub const ACTIVE: [InspectCategory; 7] = [
35 InspectCategory::Diagnostics,
36 InspectCategory::Metrics,
37 InspectCategory::Todos,
38 InspectCategory::DeadCode,
39 InspectCategory::UnusedExports,
40 InspectCategory::Duplicates,
41 InspectCategory::Cycles,
42 ];
43
44 pub const DISABLED: [InspectCategory; 6] = [
45 InspectCategory::Complexity,
46 InspectCategory::CircularDeps,
47 InspectCategory::OutdatedDeps,
48 InspectCategory::Vulnerabilities,
49 InspectCategory::TestCoverageGaps,
50 InspectCategory::ApiSurface,
51 ];
52
53 pub fn as_str(self) -> &'static str {
54 match self {
55 InspectCategory::Diagnostics => "diagnostics",
56 InspectCategory::Metrics => "metrics",
57 InspectCategory::Todos => "todos",
58 InspectCategory::DeadCode => "dead_code",
59 InspectCategory::UnusedExports => "unused_exports",
60 InspectCategory::Duplicates => "duplicates",
61 InspectCategory::Cycles => "cycles",
62 InspectCategory::Complexity => "complexity",
63 InspectCategory::CircularDeps => "circular_deps",
64 InspectCategory::OutdatedDeps => "outdated_deps",
65 InspectCategory::Vulnerabilities => "vulnerabilities",
66 InspectCategory::TestCoverageGaps => "test_coverage_gaps",
67 InspectCategory::ApiSurface => "api_surface",
68 }
69 }
70
71 pub fn tier(self) -> InspectTier {
72 match self {
73 InspectCategory::Diagnostics | InspectCategory::Metrics | InspectCategory::Todos => {
74 InspectTier::Tier1
75 }
76 InspectCategory::DeadCode
77 | InspectCategory::UnusedExports
78 | InspectCategory::Duplicates
79 | InspectCategory::Cycles
80 | InspectCategory::Complexity
81 | InspectCategory::CircularDeps
82 | InspectCategory::ApiSurface => InspectTier::Tier2,
83 InspectCategory::OutdatedDeps
84 | InspectCategory::Vulnerabilities
85 | InspectCategory::TestCoverageGaps => InspectTier::Tier3,
86 }
87 }
88
89 pub fn is_tier2(self) -> bool {
90 self.tier() == InspectTier::Tier2
91 }
92
93 pub fn is_active(self) -> bool {
94 Self::ACTIVE.contains(&self)
95 }
96
97 pub fn active() -> &'static [InspectCategory] {
98 &Self::ACTIVE
99 }
100
101 pub fn disabled() -> &'static [InspectCategory] {
102 &Self::DISABLED
103 }
104}
105
106impl fmt::Display for InspectCategory {
107 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
108 formatter.write_str(self.as_str())
109 }
110}
111
112impl FromStr for InspectCategory {
113 type Err = InspectCategoryParseError;
114
115 fn from_str(value: &str) -> Result<Self, Self::Err> {
116 match value {
117 "diagnostics" => Ok(Self::Diagnostics),
118 "metrics" => Ok(Self::Metrics),
119 "todos" => Ok(Self::Todos),
120 "dead_code" => Ok(Self::DeadCode),
121 "unused_exports" => Ok(Self::UnusedExports),
122 "duplicates" => Ok(Self::Duplicates),
123 "cycles" => Ok(Self::Cycles),
124 "complexity" => Ok(Self::Complexity),
125 "circular_deps" => Ok(Self::CircularDeps),
126 "outdated_deps" => Ok(Self::OutdatedDeps),
127 "vulnerabilities" => Ok(Self::Vulnerabilities),
128 "test_coverage_gaps" => Ok(Self::TestCoverageGaps),
129 "api_surface" => Ok(Self::ApiSurface),
130 other => Err(InspectCategoryParseError(other.to_string())),
131 }
132 }
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub struct InspectCategoryParseError(String);
137
138impl fmt::Display for InspectCategoryParseError {
139 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
140 write!(formatter, "unknown inspect category '{}'", self.0)
141 }
142}
143
144impl std::error::Error for InspectCategoryParseError {}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
147#[serde(rename_all = "snake_case")]
148pub enum InspectTier {
149 Tier1,
150 Tier2,
151 Tier3,
152}
153
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct JobScope {
156 project_root: PathBuf,
157 roots: Vec<PathBuf>,
158 scope_hash: String,
159}
160
161impl JobScope {
162 pub fn for_project(project_root: impl Into<PathBuf>) -> Self {
163 let project_root = project_root.into();
164 Self {
165 roots: Vec::new(),
166 scope_hash: "project".to_string(),
167 project_root,
168 }
169 }
170
171 pub fn from_roots(project_root: impl Into<PathBuf>, roots: Vec<PathBuf>) -> Self {
172 let project_root = project_root.into();
173 let mut roots = roots
174 .into_iter()
175 .map(|root| normalize_path(&root))
176 .collect::<Vec<_>>();
177 roots.sort();
178 roots.dedup();
179
180 if roots.is_empty() || (roots.len() == 1 && normalize_path(&project_root) == roots[0]) {
181 return Self::for_project(project_root);
182 }
183
184 let mut hasher = std::collections::hash_map::DefaultHasher::new();
185 for root in &roots {
186 root.to_string_lossy().hash(&mut hasher);
187 "\0".hash(&mut hasher);
188 }
189
190 Self {
191 project_root,
192 roots,
193 scope_hash: format!("{:016x}", hasher.finish()),
194 }
195 }
196
197 pub fn project_root(&self) -> &Path {
198 &self.project_root
199 }
200
201 pub fn roots(&self) -> &[PathBuf] {
202 &self.roots
203 }
204
205 pub fn scope_hash(&self) -> &str {
206 &self.scope_hash
207 }
208
209 pub fn is_project_wide(&self) -> bool {
210 self.roots.is_empty()
211 }
212
213 pub fn contains(&self, path: &Path) -> bool {
214 if self.roots.is_empty() {
215 return true;
216 }
217 let normalized = normalize_path(path);
218 self.roots.iter().any(|root| normalized.starts_with(root))
219 }
220
221 pub fn contains_display_path(&self, value: &str) -> bool {
222 let path = PathBuf::from(value);
223 if path.is_absolute() {
224 self.contains(&path)
225 } else {
226 self.contains(&self.project_root.join(path))
227 }
228 }
229}
230
231#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
232pub struct JobKey {
233 pub category: InspectCategory,
234 #[serde(skip_serializing_if = "Option::is_none")]
235 pub scope_hash: Option<String>,
236}
237
238impl JobKey {
239 pub fn for_category_scope(category: InspectCategory, scope: &JobScope) -> Self {
240 if category.is_tier2() {
241 Self::for_project_category(category)
242 } else {
243 Self {
244 category,
245 scope_hash: Some(scope.scope_hash().to_string()),
246 }
247 }
248 }
249
250 pub fn for_project_category(category: InspectCategory) -> Self {
251 Self {
252 category,
253 scope_hash: None,
254 }
255 }
256
257 pub fn display_key(&self) -> String {
258 match &self.scope_hash {
259 Some(scope_hash) => format!("{}:{scope_hash}", self.category),
260 None => self.category.to_string(),
261 }
262 }
263}
264
265#[derive(Clone)]
266pub struct InspectSnapshot {
267 pub project_root: PathBuf,
268 pub inspect_dir: PathBuf,
269 pub config: Arc<Config>,
270 pub symbol_cache: SharedSymbolCache,
271}
272
273impl InspectSnapshot {
274 pub fn new(
275 project_root: PathBuf,
276 inspect_dir: PathBuf,
277 config: Arc<Config>,
278 symbol_cache: SharedSymbolCache,
279 ) -> Self {
280 Self {
281 project_root,
282 inspect_dir,
283 config,
284 symbol_cache,
285 }
286 }
287}
288
289#[derive(Clone)]
290pub struct WorkerCtx {
291 pub project_root: PathBuf,
292 pub inspect_dir: PathBuf,
293 pub config: Arc<Config>,
294 pub symbol_cache: SharedSymbolCache,
295}
296
297impl From<&InspectSnapshot> for WorkerCtx {
298 fn from(snapshot: &InspectSnapshot) -> Self {
299 Self {
300 project_root: snapshot.project_root.clone(),
301 inspect_dir: snapshot.inspect_dir.clone(),
302 config: Arc::clone(&snapshot.config),
303 symbol_cache: Arc::clone(&snapshot.symbol_cache),
304 }
305 }
306}
307
308#[derive(Clone)]
309pub struct InspectJob {
310 pub job_id: u64,
311 pub key: JobKey,
312 pub category: InspectCategory,
313 pub scope_files: Vec<PathBuf>,
314 pub project_root: PathBuf,
315 pub inspect_dir: PathBuf,
316 pub config: Arc<Config>,
317 pub symbol_cache: SharedSymbolCache,
318 pub callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
319}
320
321impl InspectJob {
322 pub fn worker_ctx(&self) -> WorkerCtx {
323 WorkerCtx {
324 project_root: self.project_root.clone(),
325 inspect_dir: self.inspect_dir.clone(),
326 config: Arc::clone(&self.config),
327 symbol_cache: Arc::clone(&self.symbol_cache),
328 }
329 }
330}
331
332#[derive(Debug, Clone, Default)]
333pub struct CallgraphSnapshot {
334 pub generated_at: Option<SystemTime>,
335 pub files: Vec<PathBuf>,
336 pub exported_symbols: Vec<CallgraphExport>,
337 pub outbound_calls: Vec<CallgraphOutboundCall>,
338 pub entry_points: BTreeSet<PathBuf>,
339 pub entry_point_symbols: BTreeMap<PathBuf, BTreeSet<String>>,
340}
341
342#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
343pub struct CallgraphExport {
344 pub file: PathBuf,
345 pub symbol: String,
346 pub kind: String,
347 pub line: u32,
348}
349
350pub(crate) const DISPATCHED_CALLEE_SEPARATOR: char = '\u{1f}';
351pub(crate) const CALLGRAPH_PROVENANCE_TREESITTER: &str = "treesitter";
352pub(crate) const CALLGRAPH_PROVENANCE_REEXPORT: &str = "reexport";
353
354fn default_callgraph_outbound_provenance() -> String {
355 CALLGRAPH_PROVENANCE_TREESITTER.to_string()
356}
357
358#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
359pub struct CallgraphOutboundCall {
360 pub caller_file: PathBuf,
361 pub caller_symbol: String,
362 pub target: String,
363 pub line: u32,
364 #[serde(default = "default_callgraph_outbound_provenance")]
365 pub provenance: String,
366}
367
368#[derive(Debug, Clone)]
369pub struct FileContribution {
370 pub category: InspectCategory,
371 pub file_path: PathBuf,
372 pub freshness: FileFreshness,
373 pub contribution: serde_json::Value,
374 pub type_ref_names: BTreeSet<String>,
375}
376
377impl FileContribution {
378 pub fn new(
379 category: InspectCategory,
380 file_path: impl Into<PathBuf>,
381 freshness: FileFreshness,
382 contribution: serde_json::Value,
383 ) -> Self {
384 let type_ref_names = type_ref_names_from_contribution(&contribution);
385 Self {
386 category,
387 file_path: file_path.into(),
388 freshness,
389 contribution,
390 type_ref_names,
391 }
392 }
393
394 pub fn with_type_ref_names<I>(mut self, type_ref_names: I) -> Self
395 where
396 I: IntoIterator<Item = String>,
397 {
398 self.type_ref_names = type_ref_names.into_iter().collect();
399 self.contribution =
400 contribution_with_type_ref_names(self.contribution, &self.type_ref_names);
401 self
402 }
403}
404
405pub(crate) fn type_ref_names_from_contribution(
406 contribution: &serde_json::Value,
407) -> BTreeSet<String> {
408 contribution
409 .get("type_ref_names")
410 .and_then(serde_json::Value::as_array)
411 .into_iter()
412 .flatten()
413 .filter_map(serde_json::Value::as_str)
414 .map(str::trim)
415 .filter(|name| !name.is_empty())
416 .map(str::to_string)
417 .collect()
418}
419
420pub(crate) fn contribution_with_type_ref_names(
421 mut contribution: serde_json::Value,
422 type_ref_names: &BTreeSet<String>,
423) -> serde_json::Value {
424 if let serde_json::Value::Object(object) = &mut contribution {
425 if type_ref_names.is_empty() {
426 object.remove("type_ref_names");
427 } else {
428 object.insert(
429 "type_ref_names".to_string(),
430 serde_json::Value::Array(
431 type_ref_names
432 .iter()
433 .map(|name| serde_json::Value::String(name.clone()))
434 .collect(),
435 ),
436 );
437 }
438 }
439 contribution
440}
441
442#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
443#[serde(rename_all = "snake_case")]
444pub enum JobStatus {
445 Queued,
446 Running,
447 Completed,
448 Failed,
449}
450
451#[derive(Debug, Clone)]
452pub struct InspectScanSuccess {
453 pub scanned_files: Vec<PathBuf>,
454 pub contributions: Vec<FileContribution>,
455 pub aggregate: serde_json::Value,
456}
457
458#[derive(Debug, Clone)]
459pub struct InspectResult {
460 pub job_id: u64,
461 pub key: JobKey,
462 pub category: InspectCategory,
463 pub project_root: PathBuf,
464 pub inspect_dir: PathBuf,
465 pub config: Arc<Config>,
466 pub outcome: Result<InspectScanSuccess, String>,
467 pub duration: Duration,
468}
469
470impl InspectResult {
471 pub fn success(job: &InspectJob, success: InspectScanSuccess, duration: Duration) -> Self {
472 Self {
473 job_id: job.job_id,
474 key: job.key.clone(),
475 category: job.category,
476 project_root: job.project_root.clone(),
477 inspect_dir: job.inspect_dir.clone(),
478 config: Arc::clone(&job.config),
479 outcome: Ok(success),
480 duration,
481 }
482 }
483
484 pub fn failed(job: &InspectJob, message: impl Into<String>, duration: Duration) -> Self {
485 Self {
486 job_id: job.job_id,
487 key: job.key.clone(),
488 category: job.category,
489 project_root: job.project_root.clone(),
490 inspect_dir: job.inspect_dir.clone(),
491 config: Arc::clone(&job.config),
492 outcome: Err(message.into()),
493 duration,
494 }
495 }
496}
497
498#[derive(Debug, Clone, Serialize)]
499#[serde(tag = "status", rename_all = "snake_case")]
500pub enum JobOutcome {
501 Fresh {
502 payload: serde_json::Value,
503 },
504 Stale {
505 cached: Option<serde_json::Value>,
506 in_flight: bool,
507 },
508 Pending {
509 in_flight: bool,
510 },
511 Failed {
512 message: String,
513 },
514}
515
516impl JobOutcome {
517 pub fn payload(&self) -> Option<&serde_json::Value> {
518 match self {
519 JobOutcome::Fresh { payload } => Some(payload),
520 JobOutcome::Stale { cached, .. } => cached.as_ref(),
521 JobOutcome::Pending { .. } | JobOutcome::Failed { .. } => None,
522 }
523 }
524
525 pub fn is_stale(&self) -> bool {
526 matches!(self, JobOutcome::Stale { .. })
527 }
528
529 pub fn is_pending(&self) -> bool {
530 matches!(self, JobOutcome::Pending { .. })
531 }
532
533 pub fn summary_status(&self) -> Option<&'static str> {
534 match self {
535 JobOutcome::Fresh { .. } => None,
536 JobOutcome::Stale { .. } => Some("stale"),
537 JobOutcome::Pending { .. } => Some("pending"),
538 JobOutcome::Failed { .. } => Some("failed"),
539 }
540 }
541}
542
543pub(crate) fn is_test_support_file(relative_path: &str) -> bool {
554 let normalized = relative_path.replace('\\', "/");
555 normalized.split('/').any(|segment| {
556 matches!(
557 segment,
558 "fixtures"
559 | "__fixtures__"
560 | "testdata"
561 | "test-data"
562 | "__mocks__"
563 | "__snapshots__"
564 | "corpora"
565 )
566 })
567}
568
569pub(crate) fn is_test_file(relative_path: &str) -> bool {
579 let normalized = relative_path.replace('\\', "/");
580
581 if normalized
585 .split('/')
586 .any(|segment| matches!(segment, "__tests__" | "__test__" | "tests"))
587 {
588 return true;
589 }
590
591 let file = normalized.rsplit('/').next().unwrap_or(&normalized);
592 let lower = file.to_ascii_lowercase();
593
594 if lower.contains(".test.") || lower.contains(".spec.") {
596 return true;
597 }
598
599 if lower.ends_with("_test.rs")
601 || lower.ends_with("_test.go")
602 || lower.ends_with("_test.py")
603 || lower.ends_with("_test.rb")
604 || lower.ends_with("_test.exs")
605 || lower.ends_with("_spec.rb")
606 || (lower.starts_with("test_") && lower.ends_with(".py"))
607 {
608 return true;
609 }
610
611 const CAMEL_SUFFIXES: &[&str] = &[
614 "Test.java",
615 "Tests.java",
616 "Test.kt",
617 "Tests.kt",
618 "Test.cs",
619 "Tests.cs",
620 "Test.swift",
621 "Tests.swift",
622 "Test.scala",
623 "Spec.scala",
624 ];
625 CAMEL_SUFFIXES.iter().any(|suffix| file.ends_with(suffix))
626}
627
628pub(crate) fn normalize_path(path: &Path) -> PathBuf {
629 #[cfg(windows)]
635 let path = &windows_non_verbatim_path(path);
636
637 let mut result = PathBuf::new();
638 for component in path.components() {
639 match component {
640 std::path::Component::CurDir => {}
641 std::path::Component::ParentDir => {
642 if !result.pop() {
643 result.push(component);
644 }
645 }
646 other => result.push(other.as_os_str()),
647 }
648 }
649 result
650}
651
652pub(crate) fn canonicalize_normalized(path: &Path) -> PathBuf {
659 match std::fs::canonicalize(path) {
660 Ok(canonical) => normalize_path(&canonical),
661 Err(_) => normalize_path(path),
662 }
663}
664
665#[cfg(windows)]
666fn windows_non_verbatim_path(path: &Path) -> PathBuf {
667 let mut raw = path.to_string_lossy().replace('/', "\\");
668 if let Some(stripped) = raw.strip_prefix("\\\\?\\UNC\\") {
669 raw = format!("\\\\{stripped}");
670 } else if let Some(stripped) = raw.strip_prefix("\\\\?\\") {
671 raw = stripped.to_string();
672 } else if let Some(stripped) = raw.strip_prefix("\\\\??\\") {
673 raw = stripped.to_string();
674 }
675
676 if raw.as_bytes().get(1) == Some(&b':') {
677 let drive = raw.as_bytes()[0];
678 if drive.is_ascii_lowercase() {
679 raw.replace_range(0..1, &(drive as char).to_ascii_uppercase().to_string());
680 }
681 }
682
683 PathBuf::from(raw)
684}
685
686#[cfg(test)]
687mod test_support_tests {
688 use super::{is_test_file, is_test_support_file};
689
690 #[test]
691 fn is_test_file_matches_real_test_files() {
692 for p in [
694 "src/foo.test.ts",
695 "src/foo.test.tsx",
696 "src/bar.spec.js",
697 "packages/x/component.test.jsx",
698 "app/foo.test.mjs",
699 "src/comp.spec.vue",
700 ] {
701 assert!(is_test_file(p), "{p} should be a test file");
702 }
703 assert!(is_test_file("packages/x/__tests__/reading.ts"));
705 assert!(is_test_file("crates/aft/tests/integration/main.rs"));
706 assert!(is_test_file("crates/aft/src/foo_test.rs"));
708 assert!(is_test_file("pkg/handler_test.go"));
709 assert!(is_test_file("app/test_models.py"));
710 assert!(is_test_file("app/models_test.py"));
711 assert!(is_test_file("spec/user_spec.rb"));
712 assert!(is_test_file("src/main/UserServiceTest.java"));
714 assert!(is_test_file("src/FooTests.cs"));
715 assert!(is_test_file("Sources/AppTests.swift"));
716 assert!(is_test_file("packages\\x\\__tests__\\a.ts"));
718 }
719
720 #[test]
721 fn is_test_file_rejects_product_files() {
722 for p in [
723 "crates/aft/src/inspect/job.rs",
724 "packages/x/src/index.ts",
725 "src/contestant.ts", "src/greatest.ts", "src/latest.java", "src/my_attestation.py", "src/test/helper.ts", ] {
731 assert!(!is_test_file(p), "{p} must NOT be a test file");
732 }
733 }
734
735 #[test]
736 fn matches_conventional_support_dirs() {
737 assert!(is_test_support_file("crates/aft/tests/fixtures/sample.ts"));
738 assert!(is_test_support_file(
739 "packages/x/__tests__/e2e/fixtures/a.ts"
740 ));
741 assert!(is_test_support_file(
742 "benchmarks/codegraph/corpora/repo/lib.go"
743 ));
744 assert!(is_test_support_file("src/__mocks__/fs.ts"));
745 assert!(is_test_support_file("src/__snapshots__/render.snap"));
746 assert!(is_test_support_file("internal/testdata/golden.json"));
747 assert!(is_test_support_file("crates\\aft\\tests\\fixtures\\x.rs"));
749 }
750
751 #[test]
752 fn does_not_match_product_or_test_files() {
753 assert!(!is_test_support_file("crates/aft/src/inspect/job.rs"));
755 assert!(!is_test_support_file(
757 "packages/x/__tests__/reading.test.ts"
758 ));
759 assert!(!is_test_support_file(
760 "crates/aft/tests/integration/main.rs"
761 ));
762 assert!(!is_test_support_file("src/fixturesHelper.ts"));
764 assert!(!is_test_support_file("src/my_corpora_loader.rs"));
765 }
766}