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}';
345
346#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
347pub struct CallgraphOutboundCall {
348    pub caller_file: PathBuf,
349    pub caller_symbol: String,
350    pub target: String,
351    pub line: u32,
352}
353
354#[derive(Debug, Clone)]
355pub struct FileContribution {
356    pub category: InspectCategory,
357    pub file_path: PathBuf,
358    pub freshness: FileFreshness,
359    pub contribution: serde_json::Value,
360    pub type_ref_names: BTreeSet<String>,
361}
362
363impl FileContribution {
364    pub fn new(
365        category: InspectCategory,
366        file_path: impl Into<PathBuf>,
367        freshness: FileFreshness,
368        contribution: serde_json::Value,
369    ) -> Self {
370        let type_ref_names = type_ref_names_from_contribution(&contribution);
371        Self {
372            category,
373            file_path: file_path.into(),
374            freshness,
375            contribution,
376            type_ref_names,
377        }
378    }
379
380    pub fn with_type_ref_names<I>(mut self, type_ref_names: I) -> Self
381    where
382        I: IntoIterator<Item = String>,
383    {
384        self.type_ref_names = type_ref_names.into_iter().collect();
385        self.contribution =
386            contribution_with_type_ref_names(self.contribution, &self.type_ref_names);
387        self
388    }
389}
390
391pub(crate) fn type_ref_names_from_contribution(
392    contribution: &serde_json::Value,
393) -> BTreeSet<String> {
394    contribution
395        .get("type_ref_names")
396        .and_then(serde_json::Value::as_array)
397        .into_iter()
398        .flatten()
399        .filter_map(serde_json::Value::as_str)
400        .map(str::trim)
401        .filter(|name| !name.is_empty())
402        .map(str::to_string)
403        .collect()
404}
405
406pub(crate) fn contribution_with_type_ref_names(
407    mut contribution: serde_json::Value,
408    type_ref_names: &BTreeSet<String>,
409) -> serde_json::Value {
410    if let serde_json::Value::Object(object) = &mut contribution {
411        if type_ref_names.is_empty() {
412            object.remove("type_ref_names");
413        } else {
414            object.insert(
415                "type_ref_names".to_string(),
416                serde_json::Value::Array(
417                    type_ref_names
418                        .iter()
419                        .map(|name| serde_json::Value::String(name.clone()))
420                        .collect(),
421                ),
422            );
423        }
424    }
425    contribution
426}
427
428#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
429#[serde(rename_all = "snake_case")]
430pub enum JobStatus {
431    Queued,
432    Running,
433    Completed,
434    Failed,
435}
436
437#[derive(Debug, Clone)]
438pub struct InspectScanSuccess {
439    pub scanned_files: Vec<PathBuf>,
440    pub contributions: Vec<FileContribution>,
441    pub aggregate: serde_json::Value,
442}
443
444#[derive(Debug, Clone)]
445pub struct InspectResult {
446    pub job_id: u64,
447    pub key: JobKey,
448    pub category: InspectCategory,
449    pub project_root: PathBuf,
450    pub inspect_dir: PathBuf,
451    pub outcome: Result<InspectScanSuccess, String>,
452    pub duration: Duration,
453}
454
455impl InspectResult {
456    pub fn success(job: &InspectJob, success: InspectScanSuccess, duration: Duration) -> Self {
457        Self {
458            job_id: job.job_id,
459            key: job.key.clone(),
460            category: job.category,
461            project_root: job.project_root.clone(),
462            inspect_dir: job.inspect_dir.clone(),
463            outcome: Ok(success),
464            duration,
465        }
466    }
467
468    pub fn failed(job: &InspectJob, message: impl Into<String>, duration: Duration) -> Self {
469        Self {
470            job_id: job.job_id,
471            key: job.key.clone(),
472            category: job.category,
473            project_root: job.project_root.clone(),
474            inspect_dir: job.inspect_dir.clone(),
475            outcome: Err(message.into()),
476            duration,
477        }
478    }
479}
480
481#[derive(Debug, Clone, Serialize)]
482#[serde(tag = "status", rename_all = "snake_case")]
483pub enum JobOutcome {
484    Fresh {
485        payload: serde_json::Value,
486    },
487    Stale {
488        cached: Option<serde_json::Value>,
489        in_flight: bool,
490    },
491    Pending {
492        in_flight: bool,
493    },
494    Failed {
495        message: String,
496    },
497}
498
499impl JobOutcome {
500    pub fn payload(&self) -> Option<&serde_json::Value> {
501        match self {
502            JobOutcome::Fresh { payload } => Some(payload),
503            JobOutcome::Stale { cached, .. } => cached.as_ref(),
504            JobOutcome::Pending { .. } | JobOutcome::Failed { .. } => None,
505        }
506    }
507
508    pub fn is_stale(&self) -> bool {
509        matches!(self, JobOutcome::Stale { .. })
510    }
511
512    pub fn is_pending(&self) -> bool {
513        matches!(self, JobOutcome::Pending { .. })
514    }
515
516    pub fn summary_status(&self) -> Option<&'static str> {
517        match self {
518            JobOutcome::Fresh { .. } => None,
519            JobOutcome::Stale { .. } => Some("stale"),
520            JobOutcome::Pending { .. } => Some("pending"),
521            JobOutcome::Failed { .. } => Some("failed"),
522        }
523    }
524}
525
526/// Whether a project-relative path is a test-support / standalone file that
527/// should be EXCLUDED from dead_code and unused_exports reporting. These files
528/// (test fixtures, corpora, mock data, snapshots) are consumed by file path or
529/// loaded dynamically — never imported as modules — so static reachability
530/// always reports their symbols as dead/unused. They are noise, not signal.
531///
532/// Edges FROM these files are still honored elsewhere (their imports keep
533/// product code live); only their own exported symbols are suppressed from the
534/// dead/unused lists. The match is on conventional directory names anywhere in
535/// the path, kept conservative to avoid hiding real product directories.
536pub(crate) fn is_test_support_file(relative_path: &str) -> bool {
537    let normalized = relative_path.replace('\\', "/");
538    normalized.split('/').any(|segment| {
539        matches!(
540            segment,
541            "fixtures"
542                | "__fixtures__"
543                | "testdata"
544                | "test-data"
545                | "__mocks__"
546                | "__snapshots__"
547                | "corpora"
548        )
549    })
550}
551
552pub(crate) fn normalize_path(path: &Path) -> PathBuf {
553    let mut result = PathBuf::new();
554    for component in path.components() {
555        match component {
556            std::path::Component::CurDir => {}
557            std::path::Component::ParentDir => {
558                if !result.pop() {
559                    result.push(component);
560                }
561            }
562            other => result.push(other.as_os_str()),
563        }
564    }
565    result
566}
567
568#[cfg(test)]
569mod test_support_tests {
570    use super::is_test_support_file;
571
572    #[test]
573    fn matches_conventional_support_dirs() {
574        assert!(is_test_support_file("crates/aft/tests/fixtures/sample.ts"));
575        assert!(is_test_support_file(
576            "packages/x/__tests__/e2e/fixtures/a.ts"
577        ));
578        assert!(is_test_support_file(
579            "benchmarks/codegraph/corpora/repo/lib.go"
580        ));
581        assert!(is_test_support_file("src/__mocks__/fs.ts"));
582        assert!(is_test_support_file("src/__snapshots__/render.snap"));
583        assert!(is_test_support_file("internal/testdata/golden.json"));
584        // Windows-style separators normalize.
585        assert!(is_test_support_file("crates\\aft\\tests\\fixtures\\x.rs"));
586    }
587
588    #[test]
589    fn does_not_match_product_or_test_files() {
590        // A real product file under src must never be excluded.
591        assert!(!is_test_support_file("crates/aft/src/inspect/job.rs"));
592        // Test FILES are not support files (they hold real assertions/roots).
593        assert!(!is_test_support_file(
594            "packages/x/__tests__/reading.test.ts"
595        ));
596        assert!(!is_test_support_file(
597            "crates/aft/tests/integration/main.rs"
598        ));
599        // Substring of a segment must not match (only whole segments).
600        assert!(!is_test_support_file("src/fixturesHelper.ts"));
601        assert!(!is_test_support_file("src/my_corpora_loader.rs"));
602    }
603}