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; 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    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 | LangId::Go | LangId::C | LangId::Cpp | LangId::Zig | LangId::CSharp
380    );
381    supported_by_store_liveness && !crate::calls::call_node_kinds(language).is_empty()
382}
383
384pub(crate) fn language_name(language: LangId) -> &'static str {
385    match language {
386        LangId::TypeScript => "typescript",
387        LangId::Tsx => "tsx",
388        LangId::JavaScript => "javascript",
389        LangId::Python => "python",
390        LangId::Rust => "rust",
391        LangId::Go => "go",
392        LangId::C => "c",
393        LangId::Cpp => "cpp",
394        LangId::Zig => "zig",
395        LangId::CSharp => "csharp",
396        LangId::Bash => "bash",
397        LangId::Html => "html",
398        LangId::Markdown => "markdown",
399        LangId::Yaml => "yaml",
400        LangId::Solidity => "solidity",
401        LangId::Scss => "scss",
402        LangId::Vue => "vue",
403        LangId::Json => "json",
404        LangId::Scala => "scala",
405        LangId::Java => "java",
406        LangId::Ruby => "ruby",
407        LangId::Kotlin => "kotlin",
408        LangId::Swift => "swift",
409        LangId::Php => "php",
410        LangId::Lua => "lua",
411        LangId::Perl => "perl",
412        LangId::Pascal => "pascal",
413        LangId::R => "r",
414        LangId::Groovy => "groovy",
415        LangId::ObjC => "objc",
416    }
417}
418
419#[derive(Debug, Clone, Default)]
420pub struct CallgraphSnapshot {
421    pub generated_at: Option<SystemTime>,
422    pub files: Vec<PathBuf>,
423    pub exported_symbols: Vec<CallgraphExport>,
424    pub outbound_calls: Vec<CallgraphOutboundCall>,
425    pub entry_points: BTreeSet<PathBuf>,
426    pub entry_point_symbols: BTreeMap<PathBuf, BTreeSet<String>>,
427}
428
429#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
430pub struct CallgraphExport {
431    pub file: PathBuf,
432    pub symbol: String,
433    pub kind: String,
434    pub line: u32,
435}
436
437pub(crate) const DISPATCHED_CALLEE_SEPARATOR: char = '\u{1f}';
438pub(crate) const CALLGRAPH_PROVENANCE_TREESITTER: &str = "treesitter";
439pub(crate) const CALLGRAPH_PROVENANCE_REEXPORT: &str = "reexport";
440
441fn default_callgraph_outbound_provenance() -> String {
442    CALLGRAPH_PROVENANCE_TREESITTER.to_string()
443}
444
445#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
446pub struct CallgraphOutboundCall {
447    pub caller_file: PathBuf,
448    pub caller_symbol: String,
449    pub target: String,
450    pub line: u32,
451    #[serde(default = "default_callgraph_outbound_provenance")]
452    pub provenance: String,
453}
454
455#[derive(Debug, Clone)]
456pub struct FileContribution {
457    pub category: InspectCategory,
458    pub file_path: PathBuf,
459    pub freshness: FileFreshness,
460    pub contribution: serde_json::Value,
461    pub type_ref_names: BTreeSet<String>,
462}
463
464impl FileContribution {
465    pub fn new(
466        category: InspectCategory,
467        file_path: impl Into<PathBuf>,
468        freshness: FileFreshness,
469        contribution: serde_json::Value,
470    ) -> Self {
471        let type_ref_names = type_ref_names_from_contribution(&contribution);
472        Self {
473            category,
474            file_path: file_path.into(),
475            freshness,
476            contribution,
477            type_ref_names,
478        }
479    }
480
481    pub fn with_type_ref_names<I>(mut self, type_ref_names: I) -> Self
482    where
483        I: IntoIterator<Item = String>,
484    {
485        self.type_ref_names = type_ref_names.into_iter().collect();
486        self.contribution =
487            contribution_with_type_ref_names(self.contribution, &self.type_ref_names);
488        self
489    }
490}
491
492pub(crate) fn type_ref_names_from_contribution(
493    contribution: &serde_json::Value,
494) -> BTreeSet<String> {
495    contribution
496        .get("type_ref_names")
497        .and_then(serde_json::Value::as_array)
498        .into_iter()
499        .flatten()
500        .filter_map(serde_json::Value::as_str)
501        .map(str::trim)
502        .filter(|name| !name.is_empty())
503        .map(str::to_string)
504        .collect()
505}
506
507pub(crate) fn contribution_with_type_ref_names(
508    mut contribution: serde_json::Value,
509    type_ref_names: &BTreeSet<String>,
510) -> serde_json::Value {
511    if let serde_json::Value::Object(object) = &mut contribution {
512        if type_ref_names.is_empty() {
513            object.remove("type_ref_names");
514        } else {
515            object.insert(
516                "type_ref_names".to_string(),
517                serde_json::Value::Array(
518                    type_ref_names
519                        .iter()
520                        .map(|name| serde_json::Value::String(name.clone()))
521                        .collect(),
522                ),
523            );
524        }
525    }
526    contribution
527}
528
529#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
530#[serde(rename_all = "snake_case")]
531pub enum JobStatus {
532    Queued,
533    Running,
534    Completed,
535    Failed,
536}
537
538#[derive(Debug, Clone)]
539pub struct InspectScanSuccess {
540    pub scanned_files: Vec<PathBuf>,
541    pub contributions: Vec<FileContribution>,
542    pub aggregate: serde_json::Value,
543}
544
545#[derive(Debug, Clone)]
546pub struct InspectResult {
547    pub job_id: u64,
548    pub key: JobKey,
549    pub category: InspectCategory,
550    pub project_root: PathBuf,
551    pub inspect_dir: PathBuf,
552    pub config: Arc<Config>,
553    pub outcome: Result<InspectScanSuccess, String>,
554    pub duration: Duration,
555}
556
557impl InspectResult {
558    pub fn success(job: &InspectJob, success: InspectScanSuccess, duration: Duration) -> Self {
559        Self {
560            job_id: job.job_id,
561            key: job.key.clone(),
562            category: job.category,
563            project_root: job.project_root.clone(),
564            inspect_dir: job.inspect_dir.clone(),
565            config: Arc::clone(&job.config),
566            outcome: Ok(success),
567            duration,
568        }
569    }
570
571    pub fn failed(job: &InspectJob, message: impl Into<String>, duration: Duration) -> Self {
572        Self {
573            job_id: job.job_id,
574            key: job.key.clone(),
575            category: job.category,
576            project_root: job.project_root.clone(),
577            inspect_dir: job.inspect_dir.clone(),
578            config: Arc::clone(&job.config),
579            outcome: Err(message.into()),
580            duration,
581        }
582    }
583}
584
585#[derive(Debug, Clone, Serialize)]
586#[serde(tag = "status", rename_all = "snake_case")]
587pub enum JobOutcome {
588    Fresh {
589        payload: serde_json::Value,
590    },
591    Stale {
592        cached: Option<serde_json::Value>,
593        in_flight: bool,
594    },
595    Pending {
596        in_flight: bool,
597    },
598    Failed {
599        message: String,
600    },
601}
602
603impl JobOutcome {
604    pub fn payload(&self) -> Option<&serde_json::Value> {
605        match self {
606            JobOutcome::Fresh { payload } => Some(payload),
607            JobOutcome::Stale { cached, .. } => cached.as_ref(),
608            JobOutcome::Pending { .. } | JobOutcome::Failed { .. } => None,
609        }
610    }
611
612    pub fn is_stale(&self) -> bool {
613        matches!(self, JobOutcome::Stale { .. })
614    }
615
616    pub fn is_pending(&self) -> bool {
617        matches!(self, JobOutcome::Pending { .. })
618    }
619
620    pub fn summary_status(&self) -> Option<&'static str> {
621        match self {
622            JobOutcome::Fresh { .. } => None,
623            JobOutcome::Stale { .. } => Some("stale"),
624            JobOutcome::Pending { .. } => Some("pending"),
625            JobOutcome::Failed { .. } => Some("failed"),
626        }
627    }
628}
629
630/// Whether a project-relative path is a test-support / standalone file that
631/// should be EXCLUDED from dead_code and unused_exports reporting. These files
632/// (test fixtures, corpora, mock data, snapshots) are consumed by file path or
633/// loaded dynamically — never imported as modules — so static reachability
634/// always reports their symbols as dead/unused. They are noise, not signal.
635///
636/// Edges FROM these files are still honored elsewhere (their imports keep
637/// product code live); only their own exported symbols are suppressed from the
638/// dead/unused lists. The match is on conventional directory names anywhere in
639/// the path, kept conservative to avoid hiding real product directories.
640pub(crate) fn is_test_support_file(relative_path: &str) -> bool {
641    let normalized = relative_path.replace('\\', "/");
642    normalized.split('/').any(|segment| {
643        matches!(
644            segment,
645            "fixtures"
646                | "__fixtures__"
647                | "testdata"
648                | "test-data"
649                | "__mocks__"
650                | "__snapshots__"
651                | "corpora"
652        )
653    })
654}
655
656/// Whether a project-relative path is an actual automated-test file (unit /
657/// integration / spec), as opposed to product code. Used by `aft_search` to hide
658/// test files by default (the `include_tests` param shows them).
659///
660/// This is intentionally SEPARATE from `is_test_support_file`: dead_code and
661/// unused_exports must NOT use it, because a symbol called only from a test file
662/// is still live via that caller. Matching is high-precision to avoid hiding
663/// product code — filename conventions plus the `__tests__` directory segment,
664/// not bare `test`/`spec` directory names which collide with product modules.
665pub(crate) fn is_test_file(relative_path: &str) -> bool {
666    let normalized = relative_path.replace('\\', "/");
667
668    // Directory-segment conventions: `__tests__` (JS/TS) and a `tests` test root
669    // (Rust integration tests, Python). Singular `test`/`spec` are omitted —
670    // they collide with product modules and their files are caught by name below.
671    if normalized
672        .split('/')
673        .any(|segment| matches!(segment, "__tests__" | "__test__" | "tests"))
674    {
675        return true;
676    }
677
678    let file = normalized.rsplit('/').next().unwrap_or(&normalized);
679    let lower = file.to_ascii_lowercase();
680
681    // `*.test.<ext>` / `*.spec.<ext>` — JS/TS/JSX/TSX/Vue/mjs/cjs/…
682    if lower.contains(".test.") || lower.contains(".spec.") {
683        return true;
684    }
685
686    // Per-language filename suffixes (lowercase conventions).
687    if lower.ends_with("_test.rs")
688        || lower.ends_with("_test.go")
689        || lower.ends_with("_test.py")
690        || lower.ends_with("_test.rb")
691        || lower.ends_with("_test.exs")
692        || lower.ends_with("_spec.rb")
693        || (lower.starts_with("test_") && lower.ends_with(".py"))
694    {
695        return true;
696    }
697
698    // CamelCase test classes — matched case-SENSITIVELY so product files like
699    // `latest.java` (which ends with "test.java" lowercased) don't false-match.
700    const CAMEL_SUFFIXES: &[&str] = &[
701        "Test.java",
702        "Tests.java",
703        "Test.kt",
704        "Tests.kt",
705        "Test.cs",
706        "Tests.cs",
707        "Test.swift",
708        "Tests.swift",
709        "Test.scala",
710        "Spec.scala",
711    ];
712    CAMEL_SUFFIXES.iter().any(|suffix| file.ends_with(suffix))
713}
714
715pub(crate) fn normalize_path(path: &Path) -> PathBuf {
716    // Strip Windows verbatim prefixes before component normalization so paths
717    // that went through fs::canonicalize (\\?\C:\ form) compare equal to paths
718    // that never did. The oxc engine's resolver applies the same rule; the two
719    // normalizers must stay in agreement or membership/strip_prefix checks
720    // that cross the engine boundary silently miss on Windows.
721    #[cfg(windows)]
722    let path = &windows_non_verbatim_path(path);
723
724    let mut result = PathBuf::new();
725    for component in path.components() {
726        match component {
727            std::path::Component::CurDir => {}
728            std::path::Component::ParentDir => {
729                if !result.pop() {
730                    result.push(component);
731                }
732            }
733            other => result.push(other.as_os_str()),
734        }
735    }
736    result
737}
738
739/// Canonicalize a path for comparison inside the inspect subsystem.
740///
741/// Never returns the raw `fs::canonicalize` result: on Windows that is a
742/// verbatim (`\\?\C:\`) path, and mixing verbatim and non-verbatim forms in
743/// the same comparison is this subsystem's recurring bug class. Both branches
744/// route through [`normalize_path`].
745pub(crate) fn canonicalize_normalized(path: &Path) -> PathBuf {
746    match std::fs::canonicalize(path) {
747        Ok(canonical) => normalize_path(&canonical),
748        Err(_) => normalize_path(path),
749    }
750}
751
752#[cfg(windows)]
753fn windows_non_verbatim_path(path: &Path) -> PathBuf {
754    let mut raw = path.to_string_lossy().replace('/', "\\");
755    if let Some(stripped) = raw.strip_prefix("\\\\?\\UNC\\") {
756        raw = format!("\\\\{stripped}");
757    } else if let Some(stripped) = raw.strip_prefix("\\\\?\\") {
758        raw = stripped.to_string();
759    } else if let Some(stripped) = raw.strip_prefix("\\\\??\\") {
760        raw = stripped.to_string();
761    }
762
763    if raw.as_bytes().get(1) == Some(&b':') {
764        let drive = raw.as_bytes()[0];
765        if drive.is_ascii_lowercase() {
766            raw.replace_range(0..1, &(drive as char).to_ascii_uppercase().to_string());
767        }
768    }
769
770    PathBuf::from(raw)
771}
772
773#[cfg(test)]
774mod test_support_tests {
775    use super::{is_test_file, is_test_support_file};
776
777    #[test]
778    fn is_test_file_matches_real_test_files() {
779        // JS/TS family: *.test.* / *.spec.*
780        for p in [
781            "src/foo.test.ts",
782            "src/foo.test.tsx",
783            "src/bar.spec.js",
784            "packages/x/component.test.jsx",
785            "app/foo.test.mjs",
786            "src/comp.spec.vue",
787        ] {
788            assert!(is_test_file(p), "{p} should be a test file");
789        }
790        // __tests__ directory and tests roots.
791        assert!(is_test_file("packages/x/__tests__/reading.ts"));
792        assert!(is_test_file("crates/aft/tests/integration/main.rs"));
793        // Per-language filename suffixes.
794        assert!(is_test_file("crates/aft/src/foo_test.rs"));
795        assert!(is_test_file("pkg/handler_test.go"));
796        assert!(is_test_file("app/test_models.py"));
797        assert!(is_test_file("app/models_test.py"));
798        assert!(is_test_file("spec/user_spec.rb"));
799        // CamelCase test classes (case-sensitive).
800        assert!(is_test_file("src/main/UserServiceTest.java"));
801        assert!(is_test_file("src/FooTests.cs"));
802        assert!(is_test_file("Sources/AppTests.swift"));
803        // Windows separators normalize.
804        assert!(is_test_file("packages\\x\\__tests__\\a.ts"));
805    }
806
807    #[test]
808    fn is_test_file_rejects_product_files() {
809        for p in [
810            "crates/aft/src/inspect/job.rs",
811            "packages/x/src/index.ts",
812            "src/contestant.ts",     // "test" substring, not a test file
813            "src/greatest.ts",       // ends with "test" stem, not ".test."
814            "src/latest.java",       // case-sensitive guard: not "Test.java"
815            "src/my_attestation.py", // not test_*/*_test
816            "src/test/helper.ts",    // singular `test` dir must not blanket-match
817        ] {
818            assert!(!is_test_file(p), "{p} must NOT be a test file");
819        }
820    }
821
822    #[test]
823    fn matches_conventional_support_dirs() {
824        assert!(is_test_support_file("crates/aft/tests/fixtures/sample.ts"));
825        assert!(is_test_support_file(
826            "packages/x/__tests__/e2e/fixtures/a.ts"
827        ));
828        assert!(is_test_support_file(
829            "benchmarks/codegraph/corpora/repo/lib.go"
830        ));
831        assert!(is_test_support_file("src/__mocks__/fs.ts"));
832        assert!(is_test_support_file("src/__snapshots__/render.snap"));
833        assert!(is_test_support_file("internal/testdata/golden.json"));
834        // Windows-style separators normalize.
835        assert!(is_test_support_file("crates\\aft\\tests\\fixtures\\x.rs"));
836    }
837
838    #[test]
839    fn does_not_match_product_or_test_files() {
840        // A real product file under src must never be excluded.
841        assert!(!is_test_support_file("crates/aft/src/inspect/job.rs"));
842        // Test FILES are not support files (they hold real assertions/roots).
843        assert!(!is_test_support_file(
844            "packages/x/__tests__/reading.test.ts"
845        ));
846        assert!(!is_test_support_file(
847            "crates/aft/tests/integration/main.rs"
848        ));
849        // Substring of a segment must not match (only whole segments).
850        assert!(!is_test_support_file("src/fixturesHelper.ts"));
851        assert!(!is_test_support_file("src/my_corpora_loader.rs"));
852    }
853}