Skip to main content

aft/inspect/
job.rs

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::{LangId, 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; 8] = [
35        InspectCategory::Diagnostics,
36        InspectCategory::Metrics,
37        InspectCategory::Todos,
38        InspectCategory::DeadCode,
39        InspectCategory::UnusedExports,
40        InspectCategory::Duplicates,
41        InspectCategory::Cycles,
42        InspectCategory::Complexity,
43    ];
44
45    pub const DISABLED: [InspectCategory; 5] = [
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    pub inspect_writer: bool,
272    pub callgraph_writer: bool,
273}
274
275impl InspectSnapshot {
276    pub fn new(
277        project_root: PathBuf,
278        inspect_dir: PathBuf,
279        config: Arc<Config>,
280        symbol_cache: SharedSymbolCache,
281    ) -> Self {
282        Self::new_with_capabilities(project_root, inspect_dir, config, symbol_cache, true, true)
283    }
284
285    pub fn new_with_capabilities(
286        project_root: PathBuf,
287        inspect_dir: PathBuf,
288        config: Arc<Config>,
289        symbol_cache: SharedSymbolCache,
290        inspect_writer: bool,
291        callgraph_writer: bool,
292    ) -> Self {
293        Self {
294            project_root,
295            inspect_dir,
296            config,
297            symbol_cache,
298            inspect_writer,
299            callgraph_writer,
300        }
301    }
302}
303
304#[derive(Clone)]
305pub struct WorkerCtx {
306    pub project_root: PathBuf,
307    pub inspect_dir: PathBuf,
308    pub config: Arc<Config>,
309    pub symbol_cache: SharedSymbolCache,
310}
311
312impl From<&InspectSnapshot> for WorkerCtx {
313    fn from(snapshot: &InspectSnapshot) -> Self {
314        Self {
315            project_root: snapshot.project_root.clone(),
316            inspect_dir: snapshot.inspect_dir.clone(),
317            config: Arc::clone(&snapshot.config),
318            symbol_cache: Arc::clone(&snapshot.symbol_cache),
319        }
320    }
321}
322
323#[derive(Clone)]
324pub struct InspectJob {
325    pub job_id: u64,
326    pub key: JobKey,
327    pub category: InspectCategory,
328    pub scope_files: Vec<PathBuf>,
329    pub project_root: PathBuf,
330    pub inspect_dir: PathBuf,
331    pub config: Arc<Config>,
332    pub symbol_cache: SharedSymbolCache,
333    pub inspect_writer: bool,
334    pub callgraph_writer: bool,
335    pub callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
336}
337
338impl InspectJob {
339    pub fn worker_ctx(&self) -> WorkerCtx {
340        WorkerCtx {
341            project_root: self.project_root.clone(),
342            inspect_dir: self.inspect_dir.clone(),
343            config: Arc::clone(&self.config),
344            symbol_cache: Arc::clone(&self.symbol_cache),
345        }
346    }
347
348    /// Tier-1 jobs retain their scope hash, and `project` identifies the
349    /// complete project file set rather than a user-selected subset.
350    pub fn is_full_project_scope(&self) -> bool {
351        self.key.scope_hash.as_deref() == Some("project")
352    }
353}
354
355pub(crate) fn is_js_ts_language(language: LangId) -> bool {
356    matches!(
357        language,
358        LangId::TypeScript | LangId::Tsx | LangId::JavaScript
359    )
360}
361
362pub(crate) fn dead_code_supports_language(language: LangId) -> bool {
363    is_js_ts_language(language) || callgraph_store_dead_code_supports_language(language)
364}
365
366pub(crate) fn dead_code_skipped_language(file: &Path) -> Option<&'static str> {
367    let language = crate::parser::detect_language(file)?;
368    (!dead_code_supports_language(language)).then(|| language_name(language))
369}
370
371fn callgraph_store_dead_code_supports_language(language: LangId) -> bool {
372    // Dead-code reachability needs real call edges, not just outline symbols.
373    // Keep this narrower than every language with a tree-sitter grammar: the
374    // store-backed liveness path is trusted only for languages with call-edge
375    // extraction that dead-code consumes, and the call_node_kinds check keeps the
376    // gate tied to the extraction substrate instead of extension names alone.
377    let supported_by_store_liveness = matches!(
378        language,
379        LangId::Rust
380            | LangId::Go
381            | LangId::C
382            | LangId::Cpp
383            | LangId::Cuda
384            | LangId::Metal
385            | LangId::Zig
386            | LangId::CSharp
387    );
388    supported_by_store_liveness && !crate::calls::call_node_kinds(language).is_empty()
389}
390
391pub(crate) fn language_name(language: LangId) -> &'static str {
392    match language {
393        LangId::TypeScript => "typescript",
394        LangId::Tsx => "tsx",
395        LangId::JavaScript => "javascript",
396        LangId::Python => "python",
397        LangId::Rust => "rust",
398        LangId::Go => "go",
399        LangId::C => "c",
400        LangId::Cpp => "cpp",
401        LangId::Cuda => "cuda",
402        LangId::Metal => "metal",
403        LangId::Zig => "zig",
404        LangId::CSharp => "csharp",
405        LangId::Bash => "bash",
406        LangId::Html => "html",
407        LangId::Markdown => "markdown",
408        LangId::Yaml => "yaml",
409        LangId::Solidity => "solidity",
410        LangId::Scss => "scss",
411        LangId::Vue => "vue",
412        LangId::Json => "json",
413        LangId::Scala => "scala",
414        LangId::Java => "java",
415        LangId::Ruby => "ruby",
416        LangId::Kotlin => "kotlin",
417        LangId::Swift => "swift",
418        LangId::Php => "php",
419        LangId::Lua => "lua",
420        LangId::Perl => "perl",
421        LangId::Pascal => "pascal",
422        LangId::R => "r",
423        LangId::Groovy => "groovy",
424        LangId::ObjC => "objc",
425        LangId::Toml => "toml",
426    }
427}
428
429#[derive(Debug, Clone, Default)]
430pub struct CallgraphSnapshot {
431    pub generated_at: Option<SystemTime>,
432    pub files: Vec<PathBuf>,
433    pub exported_symbols: Vec<CallgraphExport>,
434    pub outbound_calls: Vec<CallgraphOutboundCall>,
435    pub entry_points: BTreeSet<PathBuf>,
436    pub entry_point_symbols: BTreeMap<PathBuf, BTreeSet<String>>,
437}
438
439#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
440pub struct CallgraphExport {
441    pub file: PathBuf,
442    pub symbol: String,
443    pub kind: String,
444    pub line: u32,
445}
446
447pub(crate) const DISPATCHED_CALLEE_SEPARATOR: char = '\u{1f}';
448pub(crate) const CALLGRAPH_PROVENANCE_TREESITTER: &str = "treesitter";
449pub(crate) const CALLGRAPH_PROVENANCE_REEXPORT: &str = "reexport";
450
451fn default_callgraph_outbound_provenance() -> String {
452    CALLGRAPH_PROVENANCE_TREESITTER.to_string()
453}
454
455#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
456pub struct CallgraphOutboundCall {
457    pub caller_file: PathBuf,
458    pub caller_symbol: String,
459    pub target: String,
460    pub line: u32,
461    #[serde(default = "default_callgraph_outbound_provenance")]
462    pub provenance: String,
463}
464
465#[derive(Debug, Clone)]
466pub struct FileContribution {
467    pub category: InspectCategory,
468    pub file_path: PathBuf,
469    pub freshness: FileFreshness,
470    pub contribution: serde_json::Value,
471    pub type_ref_names: BTreeSet<String>,
472}
473
474impl FileContribution {
475    pub fn new(
476        category: InspectCategory,
477        file_path: impl Into<PathBuf>,
478        freshness: FileFreshness,
479        contribution: serde_json::Value,
480    ) -> Self {
481        let type_ref_names = type_ref_names_from_contribution(&contribution);
482        Self {
483            category,
484            file_path: file_path.into(),
485            freshness,
486            contribution,
487            type_ref_names,
488        }
489    }
490
491    pub fn with_type_ref_names<I>(mut self, type_ref_names: I) -> Self
492    where
493        I: IntoIterator<Item = String>,
494    {
495        self.type_ref_names = type_ref_names.into_iter().collect();
496        self.contribution =
497            contribution_with_type_ref_names(self.contribution, &self.type_ref_names);
498        self
499    }
500}
501
502pub(crate) fn type_ref_names_from_contribution(
503    contribution: &serde_json::Value,
504) -> BTreeSet<String> {
505    contribution
506        .get("type_ref_names")
507        .and_then(serde_json::Value::as_array)
508        .into_iter()
509        .flatten()
510        .filter_map(serde_json::Value::as_str)
511        .map(str::trim)
512        .filter(|name| !name.is_empty())
513        .map(str::to_string)
514        .collect()
515}
516
517pub(crate) fn contribution_with_type_ref_names(
518    mut contribution: serde_json::Value,
519    type_ref_names: &BTreeSet<String>,
520) -> serde_json::Value {
521    if let serde_json::Value::Object(object) = &mut contribution {
522        if type_ref_names.is_empty() {
523            object.remove("type_ref_names");
524        } else {
525            object.insert(
526                "type_ref_names".to_string(),
527                serde_json::Value::Array(
528                    type_ref_names
529                        .iter()
530                        .map(|name| serde_json::Value::String(name.clone()))
531                        .collect(),
532                ),
533            );
534        }
535    }
536    contribution
537}
538
539#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
540#[serde(rename_all = "snake_case")]
541pub enum JobStatus {
542    Queued,
543    Running,
544    Completed,
545    Failed,
546}
547
548#[derive(Debug, Clone)]
549pub struct InspectScanSuccess {
550    pub scanned_files: Vec<PathBuf>,
551    pub contributions: Vec<FileContribution>,
552    pub aggregate: serde_json::Value,
553}
554
555#[derive(Debug, Clone)]
556pub struct InspectResult {
557    pub job_id: u64,
558    pub key: JobKey,
559    pub category: InspectCategory,
560    pub project_root: PathBuf,
561    pub inspect_dir: PathBuf,
562    pub config: Arc<Config>,
563    pub outcome: Result<InspectScanSuccess, String>,
564    pub duration: Duration,
565}
566
567impl InspectResult {
568    pub fn success(job: &InspectJob, success: InspectScanSuccess, duration: Duration) -> Self {
569        Self {
570            job_id: job.job_id,
571            key: job.key.clone(),
572            category: job.category,
573            project_root: job.project_root.clone(),
574            inspect_dir: job.inspect_dir.clone(),
575            config: Arc::clone(&job.config),
576            outcome: Ok(success),
577            duration,
578        }
579    }
580
581    pub fn failed(job: &InspectJob, message: impl Into<String>, duration: Duration) -> Self {
582        Self {
583            job_id: job.job_id,
584            key: job.key.clone(),
585            category: job.category,
586            project_root: job.project_root.clone(),
587            inspect_dir: job.inspect_dir.clone(),
588            config: Arc::clone(&job.config),
589            outcome: Err(message.into()),
590            duration,
591        }
592    }
593}
594
595#[derive(Debug, Clone, Copy, Serialize)]
596#[serde(rename_all = "snake_case")]
597pub enum PendingWaitCause {
598    WaiterDropped,
599    ResultChannelDisconnected,
600    DeadlineElapsed,
601}
602
603#[derive(Debug, Clone, Serialize)]
604pub struct PendingWait {
605    pub cause: PendingWaitCause,
606    pub elapsed_ms: u64,
607    pub budget_ms: u64,
608}
609
610impl PendingWait {
611    pub fn new(cause: PendingWaitCause, elapsed: Duration, budget: Duration) -> Self {
612        Self {
613            cause,
614            elapsed_ms: duration_millis(elapsed),
615            budget_ms: duration_millis(budget),
616        }
617    }
618
619    pub fn detail(&self) -> String {
620        let elapsed = display_duration_millis(self.elapsed_ms);
621        let budget = display_duration_millis(self.budget_ms);
622        match self.cause {
623            PendingWaitCause::WaiterDropped => {
624                format!("waiter dropped without outcome after {elapsed}; budget {budget}")
625            }
626            PendingWaitCause::ResultChannelDisconnected => {
627                format!("result channel disconnected after {elapsed}; budget {budget}")
628            }
629            PendingWaitCause::DeadlineElapsed => {
630                format!("deadline elapsed after {elapsed}; budget {budget}")
631            }
632        }
633    }
634}
635
636fn duration_millis(duration: Duration) -> u64 {
637    duration.as_millis().min(u128::from(u64::MAX)) as u64
638}
639
640fn display_duration_millis(millis: u64) -> String {
641    if millis >= 1_000 && millis % 1_000 == 0 {
642        format!("{}s", millis / 1_000)
643    } else if millis >= 1_000 {
644        format!("{:.1}s", millis as f64 / 1_000.0)
645    } else {
646        format!("{millis}ms")
647    }
648}
649
650#[derive(Debug, Clone, Serialize)]
651#[serde(tag = "status", rename_all = "snake_case")]
652pub enum JobOutcome {
653    Fresh {
654        payload: serde_json::Value,
655    },
656    Stale {
657        cached: Option<serde_json::Value>,
658        in_flight: bool,
659    },
660    Pending {
661        in_flight: bool,
662        #[serde(skip_serializing_if = "Option::is_none")]
663        wait: Option<PendingWait>,
664    },
665    Failed {
666        message: String,
667    },
668}
669
670impl JobOutcome {
671    pub fn pending(in_flight: bool) -> Self {
672        Self::Pending {
673            in_flight,
674            wait: None,
675        }
676    }
677
678    pub fn pending_wait(
679        in_flight: bool,
680        cause: PendingWaitCause,
681        elapsed: Duration,
682        budget: Duration,
683    ) -> Self {
684        Self::Pending {
685            in_flight,
686            wait: Some(PendingWait::new(cause, elapsed, budget)),
687        }
688    }
689
690    pub fn pending_detail(&self) -> Option<String> {
691        match self {
692            Self::Pending {
693                wait: Some(wait), ..
694            } => Some(wait.detail()),
695            _ => None,
696        }
697    }
698
699    pub fn payload(&self) -> Option<&serde_json::Value> {
700        match self {
701            JobOutcome::Fresh { payload } => Some(payload),
702            JobOutcome::Stale { cached, .. } => cached.as_ref(),
703            JobOutcome::Pending { .. } | JobOutcome::Failed { .. } => None,
704        }
705    }
706
707    pub fn is_stale(&self) -> bool {
708        matches!(self, JobOutcome::Stale { .. })
709    }
710
711    pub fn is_pending(&self) -> bool {
712        matches!(self, JobOutcome::Pending { .. })
713    }
714
715    pub fn summary_status(&self) -> Option<&'static str> {
716        match self {
717            JobOutcome::Fresh { .. } => None,
718            JobOutcome::Stale { .. } => Some("stale"),
719            JobOutcome::Pending { .. } => Some("pending"),
720            JobOutcome::Failed { .. } => Some("failed"),
721        }
722    }
723}
724
725/// Whether a project-relative path is a test-support / standalone file that
726/// should be EXCLUDED from dead_code and unused_exports reporting. These files
727/// (test fixtures, corpora, mock data, snapshots) are consumed by file path or
728/// loaded dynamically — never imported as modules — so static reachability
729/// always reports their symbols as dead/unused. They are noise, not signal.
730///
731/// Edges FROM these files are still honored elsewhere (their imports keep
732/// product code live); only their own exported symbols are suppressed from the
733/// dead/unused lists. The match is on conventional directory names anywhere in
734/// the path, kept conservative to avoid hiding real product directories.
735pub(crate) fn is_test_support_file(relative_path: &str) -> bool {
736    let normalized = relative_path.replace('\\', "/");
737    normalized.split('/').any(|segment| {
738        matches!(
739            segment,
740            "fixtures"
741                | "__fixtures__"
742                | "testdata"
743                | "test-data"
744                | "__mocks__"
745                | "__snapshots__"
746                | "corpora"
747        )
748    })
749}
750
751/// Whether a project-relative path is an actual automated-test file (unit /
752/// integration / spec), as opposed to product code. Search uses this to hide test
753/// files by default; inspect uses it to distinguish test-origin references from
754/// production usage without excluding the test files from analysis.
755///
756/// This is intentionally SEPARATE from `is_test_support_file`: test-support files
757/// are reporting noise excluded by path, while real tests remain scanned and can
758/// move otherwise-dead production symbols into the test-only bucket. Matching is
759/// high-precision to avoid hiding real product code.
760pub(crate) fn is_test_file(relative_path: &str) -> bool {
761    let normalized = relative_path.replace('\\', "/");
762
763    // Directory-segment conventions: `__tests__` (JS/TS) and a `tests` test root
764    // (Rust integration tests, Python). Singular `test`/`spec` are omitted —
765    // they collide with product modules and their files are caught by name below.
766    if normalized
767        .split('/')
768        .any(|segment| matches!(segment, "__tests__" | "__test__" | "tests"))
769    {
770        return true;
771    }
772
773    let file = normalized.rsplit('/').next().unwrap_or(&normalized);
774    let lower = file.to_ascii_lowercase();
775
776    // `*.test.<ext>` / `*.spec.<ext>` — JS/TS/JSX/TSX/Vue/mjs/cjs/…
777    if lower.contains(".test.") || lower.contains(".spec.") {
778        return true;
779    }
780
781    // Per-language filename suffixes (lowercase conventions).
782    if lower.ends_with("_test.rs")
783        || lower.ends_with("_test.go")
784        || lower.ends_with("_test.py")
785        || lower.ends_with("_test.rb")
786        || lower.ends_with("_test.exs")
787        || lower.ends_with("_spec.rb")
788        || (lower.starts_with("test_") && lower.ends_with(".py"))
789    {
790        return true;
791    }
792
793    // CamelCase test classes — matched case-SENSITIVELY so product files like
794    // `latest.java` (which ends with "test.java" lowercased) don't false-match.
795    const CAMEL_SUFFIXES: &[&str] = &[
796        "Test.java",
797        "Tests.java",
798        "Test.kt",
799        "Tests.kt",
800        "Test.cs",
801        "Tests.cs",
802        "Test.swift",
803        "Tests.swift",
804        "Test.scala",
805        "Spec.scala",
806    ];
807    CAMEL_SUFFIXES.iter().any(|suffix| file.ends_with(suffix))
808}
809
810pub(crate) fn normalize_path(path: &Path) -> PathBuf {
811    // Strip Windows verbatim prefixes before component normalization so paths
812    // that went through fs::canonicalize (\\?\C:\ form) compare equal to paths
813    // that never did. The oxc engine's resolver applies the same rule; the two
814    // normalizers must stay in agreement or membership/strip_prefix checks
815    // that cross the engine boundary silently miss on Windows.
816    #[cfg(windows)]
817    let path = &crate::windows_path::normalize_windows_path(path);
818
819    let mut result = PathBuf::new();
820    for component in path.components() {
821        match component {
822            std::path::Component::CurDir => {}
823            std::path::Component::ParentDir => {
824                if !result.pop() {
825                    result.push(component);
826                }
827            }
828            other => result.push(other.as_os_str()),
829        }
830    }
831    result
832}
833
834/// Canonicalize a path for comparison inside the inspect subsystem.
835///
836/// Never returns the raw `fs::canonicalize` result: on Windows that is a
837/// verbatim (`\\?\C:\`) path, and mixing verbatim and non-verbatim forms in
838/// the same comparison is this subsystem's recurring bug class. Both branches
839/// route through [`normalize_path`].
840pub(crate) fn canonicalize_normalized(path: &Path) -> PathBuf {
841    match std::fs::canonicalize(path) {
842        Ok(canonical) => normalize_path(&canonical),
843        Err(_) => normalize_path(path),
844    }
845}
846
847#[cfg(test)]
848mod test_support_tests {
849    use super::{is_test_file, is_test_support_file};
850
851    #[test]
852    fn is_test_file_matches_real_test_files() {
853        // JS/TS family: *.test.* / *.spec.*
854        for p in [
855            "src/foo.test.ts",
856            "src/foo.test.tsx",
857            "src/bar.spec.js",
858            "packages/x/component.test.jsx",
859            "app/foo.test.mjs",
860            "src/comp.spec.vue",
861        ] {
862            assert!(is_test_file(p), "{p} should be a test file");
863        }
864        // __tests__ directory and tests roots.
865        assert!(is_test_file("packages/x/__tests__/reading.ts"));
866        assert!(is_test_file("crates/aft/tests/integration/main.rs"));
867        // Per-language filename suffixes.
868        assert!(is_test_file("crates/aft/src/foo_test.rs"));
869        assert!(is_test_file("pkg/handler_test.go"));
870        assert!(is_test_file("app/test_models.py"));
871        assert!(is_test_file("app/models_test.py"));
872        assert!(is_test_file("spec/user_spec.rb"));
873        // CamelCase test classes (case-sensitive).
874        assert!(is_test_file("src/main/UserServiceTest.java"));
875        assert!(is_test_file("src/FooTests.cs"));
876        assert!(is_test_file("Sources/AppTests.swift"));
877        // Windows separators normalize.
878        assert!(is_test_file("packages\\x\\__tests__\\a.ts"));
879    }
880
881    #[test]
882    fn is_test_file_rejects_product_files() {
883        for p in [
884            "crates/aft/src/inspect/job.rs",
885            "packages/x/src/index.ts",
886            "src/contestant.ts",     // "test" substring, not a test file
887            "src/greatest.ts",       // ends with "test" stem, not ".test."
888            "src/latest.java",       // case-sensitive guard: not "Test.java"
889            "src/my_attestation.py", // not test_*/*_test
890            "src/test/helper.ts",    // singular `test` dir must not blanket-match
891        ] {
892            assert!(!is_test_file(p), "{p} must NOT be a test file");
893        }
894    }
895
896    #[test]
897    fn matches_conventional_support_dirs() {
898        assert!(is_test_support_file("crates/aft/tests/fixtures/sample.ts"));
899        assert!(is_test_support_file(
900            "packages/x/__tests__/e2e/fixtures/a.ts"
901        ));
902        assert!(is_test_support_file(
903            "benchmarks/codegraph/corpora/repo/lib.go"
904        ));
905        assert!(is_test_support_file("src/__mocks__/fs.ts"));
906        assert!(is_test_support_file("src/__snapshots__/render.snap"));
907        assert!(is_test_support_file("internal/testdata/golden.json"));
908        // Windows-style separators normalize.
909        assert!(is_test_support_file("crates\\aft\\tests\\fixtures\\x.rs"));
910    }
911
912    #[test]
913    fn does_not_match_product_or_test_files() {
914        // A real product file under src must never be excluded.
915        assert!(!is_test_support_file("crates/aft/src/inspect/job.rs"));
916        // Test FILES are not support files (they hold real assertions/roots).
917        assert!(!is_test_support_file(
918            "packages/x/__tests__/reading.test.ts"
919        ));
920        assert!(!is_test_support_file(
921            "crates/aft/tests/integration/main.rs"
922        ));
923        // Substring of a segment must not match (only whole segments).
924        assert!(!is_test_support_file("src/fixturesHelper.ts"));
925        assert!(!is_test_support_file("src/my_corpora_loader.rs"));
926    }
927}