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::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
543/// Whether a project-relative path is a test-support / standalone file that
544/// should be EXCLUDED from dead_code and unused_exports reporting. These files
545/// (test fixtures, corpora, mock data, snapshots) are consumed by file path or
546/// loaded dynamically — never imported as modules — so static reachability
547/// always reports their symbols as dead/unused. They are noise, not signal.
548///
549/// Edges FROM these files are still honored elsewhere (their imports keep
550/// product code live); only their own exported symbols are suppressed from the
551/// dead/unused lists. The match is on conventional directory names anywhere in
552/// the path, kept conservative to avoid hiding real product directories.
553pub(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
569/// Whether a project-relative path is an actual automated-test file (unit /
570/// integration / spec), as opposed to product code. Used by `aft_search` to hide
571/// test files by default (the `include_tests` param shows them).
572///
573/// This is intentionally SEPARATE from `is_test_support_file`: dead_code and
574/// unused_exports must NOT use it, because a symbol called only from a test file
575/// is still live via that caller. Matching is high-precision to avoid hiding
576/// product code — filename conventions plus the `__tests__` directory segment,
577/// not bare `test`/`spec` directory names which collide with product modules.
578pub(crate) fn is_test_file(relative_path: &str) -> bool {
579    let normalized = relative_path.replace('\\', "/");
580
581    // Directory-segment conventions: `__tests__` (JS/TS) and a `tests` test root
582    // (Rust integration tests, Python). Singular `test`/`spec` are omitted —
583    // they collide with product modules and their files are caught by name below.
584    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    // `*.test.<ext>` / `*.spec.<ext>` — JS/TS/JSX/TSX/Vue/mjs/cjs/…
595    if lower.contains(".test.") || lower.contains(".spec.") {
596        return true;
597    }
598
599    // Per-language filename suffixes (lowercase conventions).
600    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    // CamelCase test classes — matched case-SENSITIVELY so product files like
612    // `latest.java` (which ends with "test.java" lowercased) don't false-match.
613    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    // Strip Windows verbatim prefixes before component normalization so paths
630    // that went through fs::canonicalize (\\?\C:\ form) compare equal to paths
631    // that never did. The oxc engine's resolver applies the same rule; the two
632    // normalizers must stay in agreement or membership/strip_prefix checks
633    // that cross the engine boundary silently miss on Windows.
634    #[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
652/// Canonicalize a path for comparison inside the inspect subsystem.
653///
654/// Never returns the raw `fs::canonicalize` result: on Windows that is a
655/// verbatim (`\\?\C:\`) path, and mixing verbatim and non-verbatim forms in
656/// the same comparison is this subsystem's recurring bug class. Both branches
657/// route through [`normalize_path`].
658pub(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        // JS/TS family: *.test.* / *.spec.*
693        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        // __tests__ directory and tests roots.
704        assert!(is_test_file("packages/x/__tests__/reading.ts"));
705        assert!(is_test_file("crates/aft/tests/integration/main.rs"));
706        // Per-language filename suffixes.
707        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        // CamelCase test classes (case-sensitive).
713        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        // Windows separators normalize.
717        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",     // "test" substring, not a test file
726            "src/greatest.ts",       // ends with "test" stem, not ".test."
727            "src/latest.java",       // case-sensitive guard: not "Test.java"
728            "src/my_attestation.py", // not test_*/*_test
729            "src/test/helper.ts",    // singular `test` dir must not blanket-match
730        ] {
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        // Windows-style separators normalize.
748        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        // A real product file under src must never be excluded.
754        assert!(!is_test_support_file("crates/aft/src/inspect/job.rs"));
755        // Test FILES are not support files (they hold real assertions/roots).
756        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        // Substring of a segment must not match (only whole segments).
763        assert!(!is_test_support_file("src/fixturesHelper.ts"));
764        assert!(!is_test_support_file("src/my_corpora_loader.rs"));
765    }
766}