Skip to main content

aft/inspect/
job.rs

1use std::collections::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    Complexity,
25    CircularDeps,
26    OutdatedDeps,
27    Vulnerabilities,
28    TestCoverageGaps,
29    ApiSurface,
30}
31
32impl InspectCategory {
33    pub const ACTIVE: [InspectCategory; 6] = [
34        InspectCategory::Diagnostics,
35        InspectCategory::Metrics,
36        InspectCategory::Todos,
37        InspectCategory::DeadCode,
38        InspectCategory::UnusedExports,
39        InspectCategory::Duplicates,
40    ];
41
42    pub const DISABLED: [InspectCategory; 6] = [
43        InspectCategory::Complexity,
44        InspectCategory::CircularDeps,
45        InspectCategory::OutdatedDeps,
46        InspectCategory::Vulnerabilities,
47        InspectCategory::TestCoverageGaps,
48        InspectCategory::ApiSurface,
49    ];
50
51    pub fn as_str(self) -> &'static str {
52        match self {
53            InspectCategory::Diagnostics => "diagnostics",
54            InspectCategory::Metrics => "metrics",
55            InspectCategory::Todos => "todos",
56            InspectCategory::DeadCode => "dead_code",
57            InspectCategory::UnusedExports => "unused_exports",
58            InspectCategory::Duplicates => "duplicates",
59            InspectCategory::Complexity => "complexity",
60            InspectCategory::CircularDeps => "circular_deps",
61            InspectCategory::OutdatedDeps => "outdated_deps",
62            InspectCategory::Vulnerabilities => "vulnerabilities",
63            InspectCategory::TestCoverageGaps => "test_coverage_gaps",
64            InspectCategory::ApiSurface => "api_surface",
65        }
66    }
67
68    pub fn tier(self) -> InspectTier {
69        match self {
70            InspectCategory::Diagnostics | InspectCategory::Metrics | InspectCategory::Todos => {
71                InspectTier::Tier1
72            }
73            InspectCategory::DeadCode
74            | InspectCategory::UnusedExports
75            | InspectCategory::Duplicates
76            | InspectCategory::Complexity
77            | InspectCategory::CircularDeps
78            | InspectCategory::ApiSurface => InspectTier::Tier2,
79            InspectCategory::OutdatedDeps
80            | InspectCategory::Vulnerabilities
81            | InspectCategory::TestCoverageGaps => InspectTier::Tier3,
82        }
83    }
84
85    pub fn is_tier2(self) -> bool {
86        self.tier() == InspectTier::Tier2
87    }
88
89    pub fn is_active(self) -> bool {
90        Self::ACTIVE.contains(&self)
91    }
92
93    pub fn active() -> &'static [InspectCategory] {
94        &Self::ACTIVE
95    }
96
97    pub fn disabled() -> &'static [InspectCategory] {
98        &Self::DISABLED
99    }
100}
101
102impl fmt::Display for InspectCategory {
103    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
104        formatter.write_str(self.as_str())
105    }
106}
107
108impl FromStr for InspectCategory {
109    type Err = InspectCategoryParseError;
110
111    fn from_str(value: &str) -> Result<Self, Self::Err> {
112        match value {
113            "diagnostics" => Ok(Self::Diagnostics),
114            "metrics" => Ok(Self::Metrics),
115            "todos" => Ok(Self::Todos),
116            "dead_code" => Ok(Self::DeadCode),
117            "unused_exports" => Ok(Self::UnusedExports),
118            "duplicates" => Ok(Self::Duplicates),
119            "complexity" => Ok(Self::Complexity),
120            "circular_deps" => Ok(Self::CircularDeps),
121            "outdated_deps" => Ok(Self::OutdatedDeps),
122            "vulnerabilities" => Ok(Self::Vulnerabilities),
123            "test_coverage_gaps" => Ok(Self::TestCoverageGaps),
124            "api_surface" => Ok(Self::ApiSurface),
125            other => Err(InspectCategoryParseError(other.to_string())),
126        }
127    }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct InspectCategoryParseError(String);
132
133impl fmt::Display for InspectCategoryParseError {
134    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
135        write!(formatter, "unknown inspect category '{}'", self.0)
136    }
137}
138
139impl std::error::Error for InspectCategoryParseError {}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
142#[serde(rename_all = "snake_case")]
143pub enum InspectTier {
144    Tier1,
145    Tier2,
146    Tier3,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct JobScope {
151    project_root: PathBuf,
152    roots: Vec<PathBuf>,
153    scope_hash: String,
154}
155
156impl JobScope {
157    pub fn for_project(project_root: impl Into<PathBuf>) -> Self {
158        let project_root = project_root.into();
159        Self {
160            roots: Vec::new(),
161            scope_hash: "project".to_string(),
162            project_root,
163        }
164    }
165
166    pub fn from_roots(project_root: impl Into<PathBuf>, roots: Vec<PathBuf>) -> Self {
167        let project_root = project_root.into();
168        let mut roots = roots
169            .into_iter()
170            .map(|root| normalize_path(&root))
171            .collect::<Vec<_>>();
172        roots.sort();
173        roots.dedup();
174
175        if roots.is_empty() || (roots.len() == 1 && normalize_path(&project_root) == roots[0]) {
176            return Self::for_project(project_root);
177        }
178
179        let mut hasher = std::collections::hash_map::DefaultHasher::new();
180        for root in &roots {
181            root.to_string_lossy().hash(&mut hasher);
182            "\0".hash(&mut hasher);
183        }
184
185        Self {
186            project_root,
187            roots,
188            scope_hash: format!("{:016x}", hasher.finish()),
189        }
190    }
191
192    pub fn project_root(&self) -> &Path {
193        &self.project_root
194    }
195
196    pub fn roots(&self) -> &[PathBuf] {
197        &self.roots
198    }
199
200    pub fn scope_hash(&self) -> &str {
201        &self.scope_hash
202    }
203
204    pub fn is_project_wide(&self) -> bool {
205        self.roots.is_empty()
206    }
207
208    pub fn contains(&self, path: &Path) -> bool {
209        if self.roots.is_empty() {
210            return true;
211        }
212        let normalized = normalize_path(path);
213        self.roots.iter().any(|root| normalized.starts_with(root))
214    }
215
216    pub fn contains_display_path(&self, value: &str) -> bool {
217        let path = PathBuf::from(value);
218        if path.is_absolute() {
219            self.contains(&path)
220        } else {
221            self.contains(&self.project_root.join(path))
222        }
223    }
224}
225
226#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
227pub struct JobKey {
228    pub category: InspectCategory,
229    #[serde(skip_serializing_if = "Option::is_none")]
230    pub scope_hash: Option<String>,
231}
232
233impl JobKey {
234    pub fn for_category_scope(category: InspectCategory, scope: &JobScope) -> Self {
235        if category.is_tier2() {
236            Self::for_project_category(category)
237        } else {
238            Self {
239                category,
240                scope_hash: Some(scope.scope_hash().to_string()),
241            }
242        }
243    }
244
245    pub fn for_project_category(category: InspectCategory) -> Self {
246        Self {
247            category,
248            scope_hash: None,
249        }
250    }
251
252    pub fn display_key(&self) -> String {
253        match &self.scope_hash {
254            Some(scope_hash) => format!("{}:{scope_hash}", self.category),
255            None => self.category.to_string(),
256        }
257    }
258}
259
260#[derive(Clone)]
261pub struct InspectSnapshot {
262    pub project_root: PathBuf,
263    pub inspect_dir: PathBuf,
264    pub config: Arc<Config>,
265    pub symbol_cache: SharedSymbolCache,
266}
267
268impl InspectSnapshot {
269    pub fn new(
270        project_root: PathBuf,
271        inspect_dir: PathBuf,
272        config: Arc<Config>,
273        symbol_cache: SharedSymbolCache,
274    ) -> Self {
275        Self {
276            project_root,
277            inspect_dir,
278            config,
279            symbol_cache,
280        }
281    }
282}
283
284#[derive(Clone)]
285pub struct WorkerCtx {
286    pub project_root: PathBuf,
287    pub inspect_dir: PathBuf,
288    pub config: Arc<Config>,
289    pub symbol_cache: SharedSymbolCache,
290}
291
292impl From<&InspectSnapshot> for WorkerCtx {
293    fn from(snapshot: &InspectSnapshot) -> Self {
294        Self {
295            project_root: snapshot.project_root.clone(),
296            inspect_dir: snapshot.inspect_dir.clone(),
297            config: Arc::clone(&snapshot.config),
298            symbol_cache: Arc::clone(&snapshot.symbol_cache),
299        }
300    }
301}
302
303#[derive(Clone)]
304pub struct InspectJob {
305    pub job_id: u64,
306    pub key: JobKey,
307    pub category: InspectCategory,
308    pub scope_files: Vec<PathBuf>,
309    pub project_root: PathBuf,
310    pub inspect_dir: PathBuf,
311    pub config: Arc<Config>,
312    pub symbol_cache: SharedSymbolCache,
313    pub callgraph_snapshot: Option<Arc<CallgraphSnapshot>>,
314}
315
316impl InspectJob {
317    pub fn worker_ctx(&self) -> WorkerCtx {
318        WorkerCtx {
319            project_root: self.project_root.clone(),
320            inspect_dir: self.inspect_dir.clone(),
321            config: Arc::clone(&self.config),
322            symbol_cache: Arc::clone(&self.symbol_cache),
323        }
324    }
325}
326
327#[derive(Debug, Clone, Default)]
328pub struct CallgraphSnapshot {
329    pub generated_at: Option<SystemTime>,
330    pub files: Vec<PathBuf>,
331    pub exported_symbols: Vec<CallgraphExport>,
332    pub outbound_calls: Vec<CallgraphOutboundCall>,
333    pub entry_points: BTreeSet<PathBuf>,
334}
335
336#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
337pub struct CallgraphExport {
338    pub file: PathBuf,
339    pub symbol: String,
340    pub kind: String,
341    pub line: u32,
342}
343
344pub(crate) const DISPATCHED_CALLEE_SEPARATOR: char = '\u{1f}';
345pub(crate) const CALLGRAPH_PROVENANCE_TREESITTER: &str = "treesitter";
346pub(crate) const CALLGRAPH_PROVENANCE_REEXPORT: &str = "reexport";
347
348fn default_callgraph_outbound_provenance() -> String {
349    CALLGRAPH_PROVENANCE_TREESITTER.to_string()
350}
351
352#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
353pub struct CallgraphOutboundCall {
354    pub caller_file: PathBuf,
355    pub caller_symbol: String,
356    pub target: String,
357    pub line: u32,
358    #[serde(default = "default_callgraph_outbound_provenance")]
359    pub provenance: String,
360}
361
362#[derive(Debug, Clone)]
363pub struct FileContribution {
364    pub category: InspectCategory,
365    pub file_path: PathBuf,
366    pub freshness: FileFreshness,
367    pub contribution: serde_json::Value,
368    pub type_ref_names: BTreeSet<String>,
369}
370
371impl FileContribution {
372    pub fn new(
373        category: InspectCategory,
374        file_path: impl Into<PathBuf>,
375        freshness: FileFreshness,
376        contribution: serde_json::Value,
377    ) -> Self {
378        let type_ref_names = type_ref_names_from_contribution(&contribution);
379        Self {
380            category,
381            file_path: file_path.into(),
382            freshness,
383            contribution,
384            type_ref_names,
385        }
386    }
387
388    pub fn with_type_ref_names<I>(mut self, type_ref_names: I) -> Self
389    where
390        I: IntoIterator<Item = String>,
391    {
392        self.type_ref_names = type_ref_names.into_iter().collect();
393        self.contribution =
394            contribution_with_type_ref_names(self.contribution, &self.type_ref_names);
395        self
396    }
397}
398
399pub(crate) fn type_ref_names_from_contribution(
400    contribution: &serde_json::Value,
401) -> BTreeSet<String> {
402    contribution
403        .get("type_ref_names")
404        .and_then(serde_json::Value::as_array)
405        .into_iter()
406        .flatten()
407        .filter_map(serde_json::Value::as_str)
408        .map(str::trim)
409        .filter(|name| !name.is_empty())
410        .map(str::to_string)
411        .collect()
412}
413
414pub(crate) fn contribution_with_type_ref_names(
415    mut contribution: serde_json::Value,
416    type_ref_names: &BTreeSet<String>,
417) -> serde_json::Value {
418    if let serde_json::Value::Object(object) = &mut contribution {
419        if type_ref_names.is_empty() {
420            object.remove("type_ref_names");
421        } else {
422            object.insert(
423                "type_ref_names".to_string(),
424                serde_json::Value::Array(
425                    type_ref_names
426                        .iter()
427                        .map(|name| serde_json::Value::String(name.clone()))
428                        .collect(),
429                ),
430            );
431        }
432    }
433    contribution
434}
435
436#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
437#[serde(rename_all = "snake_case")]
438pub enum JobStatus {
439    Queued,
440    Running,
441    Completed,
442    Failed,
443}
444
445#[derive(Debug, Clone)]
446pub struct InspectScanSuccess {
447    pub scanned_files: Vec<PathBuf>,
448    pub contributions: Vec<FileContribution>,
449    pub aggregate: serde_json::Value,
450}
451
452#[derive(Debug, Clone)]
453pub struct InspectResult {
454    pub job_id: u64,
455    pub key: JobKey,
456    pub category: InspectCategory,
457    pub project_root: PathBuf,
458    pub inspect_dir: PathBuf,
459    pub outcome: Result<InspectScanSuccess, String>,
460    pub duration: Duration,
461}
462
463impl InspectResult {
464    pub fn success(job: &InspectJob, success: InspectScanSuccess, duration: Duration) -> Self {
465        Self {
466            job_id: job.job_id,
467            key: job.key.clone(),
468            category: job.category,
469            project_root: job.project_root.clone(),
470            inspect_dir: job.inspect_dir.clone(),
471            outcome: Ok(success),
472            duration,
473        }
474    }
475
476    pub fn failed(job: &InspectJob, message: impl Into<String>, duration: Duration) -> Self {
477        Self {
478            job_id: job.job_id,
479            key: job.key.clone(),
480            category: job.category,
481            project_root: job.project_root.clone(),
482            inspect_dir: job.inspect_dir.clone(),
483            outcome: Err(message.into()),
484            duration,
485        }
486    }
487}
488
489#[derive(Debug, Clone, Serialize)]
490#[serde(tag = "status", rename_all = "snake_case")]
491pub enum JobOutcome {
492    Fresh {
493        payload: serde_json::Value,
494    },
495    Stale {
496        cached: Option<serde_json::Value>,
497        in_flight: bool,
498    },
499    Pending {
500        in_flight: bool,
501    },
502    Failed {
503        message: String,
504    },
505}
506
507impl JobOutcome {
508    pub fn payload(&self) -> Option<&serde_json::Value> {
509        match self {
510            JobOutcome::Fresh { payload } => Some(payload),
511            JobOutcome::Stale { cached, .. } => cached.as_ref(),
512            JobOutcome::Pending { .. } | JobOutcome::Failed { .. } => None,
513        }
514    }
515
516    pub fn is_stale(&self) -> bool {
517        matches!(self, JobOutcome::Stale { .. })
518    }
519
520    pub fn is_pending(&self) -> bool {
521        matches!(self, JobOutcome::Pending { .. })
522    }
523
524    pub fn summary_status(&self) -> Option<&'static str> {
525        match self {
526            JobOutcome::Fresh { .. } => None,
527            JobOutcome::Stale { .. } => Some("stale"),
528            JobOutcome::Pending { .. } => Some("pending"),
529            JobOutcome::Failed { .. } => Some("failed"),
530        }
531    }
532}
533
534/// Whether a project-relative path is a test-support / standalone file that
535/// should be EXCLUDED from dead_code and unused_exports reporting. These files
536/// (test fixtures, corpora, mock data, snapshots) are consumed by file path or
537/// loaded dynamically — never imported as modules — so static reachability
538/// always reports their symbols as dead/unused. They are noise, not signal.
539///
540/// Edges FROM these files are still honored elsewhere (their imports keep
541/// product code live); only their own exported symbols are suppressed from the
542/// dead/unused lists. The match is on conventional directory names anywhere in
543/// the path, kept conservative to avoid hiding real product directories.
544pub(crate) fn is_test_support_file(relative_path: &str) -> bool {
545    let normalized = relative_path.replace('\\', "/");
546    normalized.split('/').any(|segment| {
547        matches!(
548            segment,
549            "fixtures"
550                | "__fixtures__"
551                | "testdata"
552                | "test-data"
553                | "__mocks__"
554                | "__snapshots__"
555                | "corpora"
556        )
557    })
558}
559
560pub(crate) fn normalize_path(path: &Path) -> PathBuf {
561    let mut result = PathBuf::new();
562    for component in path.components() {
563        match component {
564            std::path::Component::CurDir => {}
565            std::path::Component::ParentDir => {
566                if !result.pop() {
567                    result.push(component);
568                }
569            }
570            other => result.push(other.as_os_str()),
571        }
572    }
573    result
574}
575
576#[cfg(test)]
577mod test_support_tests {
578    use super::is_test_support_file;
579
580    #[test]
581    fn matches_conventional_support_dirs() {
582        assert!(is_test_support_file("crates/aft/tests/fixtures/sample.ts"));
583        assert!(is_test_support_file(
584            "packages/x/__tests__/e2e/fixtures/a.ts"
585        ));
586        assert!(is_test_support_file(
587            "benchmarks/codegraph/corpora/repo/lib.go"
588        ));
589        assert!(is_test_support_file("src/__mocks__/fs.ts"));
590        assert!(is_test_support_file("src/__snapshots__/render.snap"));
591        assert!(is_test_support_file("internal/testdata/golden.json"));
592        // Windows-style separators normalize.
593        assert!(is_test_support_file("crates\\aft\\tests\\fixtures\\x.rs"));
594    }
595
596    #[test]
597    fn does_not_match_product_or_test_files() {
598        // A real product file under src must never be excluded.
599        assert!(!is_test_support_file("crates/aft/src/inspect/job.rs"));
600        // Test FILES are not support files (they hold real assertions/roots).
601        assert!(!is_test_support_file(
602            "packages/x/__tests__/reading.test.ts"
603        ));
604        assert!(!is_test_support_file(
605            "crates/aft/tests/integration/main.rs"
606        ));
607        // Substring of a segment must not match (only whole segments).
608        assert!(!is_test_support_file("src/fixturesHelper.ts"));
609        assert!(!is_test_support_file("src/my_corpora_loader.rs"));
610    }
611}