Skip to main content

aft/inspect/oxc_engine/
mod.rs

1//! Pure oxc-backed TS/JS module graph facts and export-liveness verdicts.
2//!
3//! H1-1 intentionally stops at an engine/API boundary: scanners wire this into
4//! inspect contributions in H1-2. The engine is pure (files in, verdicts out)
5//! and keeps only an in-memory facts cache keyed by file content hash + parser
6//! source type + facts format. That is enough for the required warm-cache perf gate while
7//! avoiding premature persistence coupling to InspectCache/AppContext.
8
9mod facts;
10mod graph;
11mod resolver;
12pub mod types;
13
14use std::collections::{BTreeMap, BTreeSet};
15use std::fs;
16use std::path::{Path, PathBuf};
17use std::time::SystemTime;
18
19use oxc_span::SourceType;
20
21use facts::parse_file_facts;
22use graph::compute_verdicts;
23use resolver::{normalize_path, ModuleResolver};
24pub use types::{
25    DynamicImportFact, ExportFact, ExportName, FileFacts, FileId, ImportFact, ImportKind,
26    LivenessVerdict, OxcEngineError, OxcEngineResult, OxcEngineStats, OxcExportVerdict,
27    OxcFileVerdicts, OxcResolvedEdge, ReExportFact, ReExportKind, ResolverConfigInput,
28    OXC_PROVENANCE,
29};
30
31pub(crate) const FACTS_FORMAT_VERSION: u32 = 3;
32
33#[derive(Debug, Clone, Default)]
34pub struct AnalyzeOptions {
35    pub entry_points: Vec<PathBuf>,
36    pub public_api_files: Vec<PathBuf>,
37    /// Files already proven stale by the inspect freshness layer. These paths
38    /// bypass the path metadata fast path so same-size/same-mtime edits are
39    /// still re-read and content-hashed before facts are reused.
40    pub force_reparse_files: Vec<PathBuf>,
41    /// When true, imports/re-exports only make targets live after execution is
42    /// reachable from entry/public files. Used by dead_code; unused_exports keeps
43    /// the default import-usage semantics.
44    pub entry_reachability: bool,
45}
46
47#[derive(Debug, Clone, Default)]
48pub struct OxcFactsCache {
49    entries_by_hash: BTreeMap<String, FileFacts>,
50    entries_by_path: BTreeMap<PathBuf, OxcFactsPathEntry>,
51}
52
53#[derive(Debug, Clone)]
54struct OxcFactsPathEntry {
55    mtime: SystemTime,
56    size: u64,
57    cache_key: String,
58}
59
60#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
61pub struct OxcFactsCacheStats {
62    pub hits: usize,
63    pub misses: usize,
64}
65
66impl OxcFactsCache {
67    pub fn new() -> Self {
68        Self::default()
69    }
70
71    pub fn len(&self) -> usize {
72        self.entries_by_hash.len()
73    }
74
75    pub fn is_empty(&self) -> bool {
76        self.entries_by_hash.is_empty()
77    }
78
79    fn facts_for_file(
80        &mut self,
81        file_id: FileId,
82        path: &Path,
83        force_reparse: bool,
84        stats: &mut OxcFactsCacheStats,
85    ) -> std::io::Result<FileFacts> {
86        let source_type = SourceType::from_path(path).unwrap_or_default();
87        let source_type_key = source_type_cache_key(source_type);
88        let metadata = fs::metadata(path)?;
89        let mtime = metadata.modified().unwrap_or(std::time::UNIX_EPOCH);
90        let size = metadata.len();
91        let path_key = path.to_path_buf();
92
93        if !force_reparse {
94            if let Some(entry) = self.entries_by_path.get(&path_key) {
95                if entry.mtime == mtime && entry.size == size {
96                    if let Some(cached) = self.entries_by_hash.get(&entry.cache_key) {
97                        stats.hits += 1;
98                        return Ok(rebind_facts(cached, file_id, path, &cached.content_hash));
99                    }
100                }
101            }
102        }
103
104        let source = fs::read_to_string(path)?;
105        Ok(self.facts_for_source_with_metadata(
106            file_id,
107            path,
108            &source,
109            source_type,
110            source_type_key,
111            Some((mtime, size)),
112            stats,
113        ))
114    }
115
116    fn facts_for_source_with_metadata(
117        &mut self,
118        file_id: FileId,
119        path: &Path,
120        source: &str,
121        source_type: SourceType,
122        source_type_key: String,
123        metadata: Option<(SystemTime, u64)>,
124        stats: &mut OxcFactsCacheStats,
125    ) -> FileFacts {
126        let content_hash = crate::cache_freshness::hash_bytes(source.as_bytes())
127            .to_hex()
128            .to_string();
129        let cache_key = format!("v{FACTS_FORMAT_VERSION}:{source_type_key}:{content_hash}");
130        if let Some(cached) = self.entries_by_hash.get(&cache_key) {
131            stats.hits += 1;
132            if let Some((mtime, size)) = metadata {
133                self.entries_by_path.insert(
134                    path.to_path_buf(),
135                    OxcFactsPathEntry {
136                        mtime,
137                        size,
138                        cache_key,
139                    },
140                );
141            }
142            return rebind_facts(cached, file_id, path, &content_hash);
143        }
144
145        stats.misses += 1;
146        let facts = parse_file_facts(file_id, path, source, content_hash, source_type);
147        self.entries_by_hash
148            .insert(cache_key.clone(), facts.clone());
149        if let Some((mtime, size)) = metadata {
150            self.entries_by_path.insert(
151                path.to_path_buf(),
152                OxcFactsPathEntry {
153                    mtime,
154                    size,
155                    cache_key,
156                },
157            );
158        }
159        facts
160    }
161}
162
163fn rebind_facts(cached: &FileFacts, file_id: FileId, path: &Path, content_hash: &str) -> FileFacts {
164    let mut facts = cached.clone();
165    facts.file_id = file_id;
166    facts.path = path.to_path_buf();
167    facts.content_hash = content_hash.to_string();
168    facts
169}
170
171fn source_type_cache_key(source_type: SourceType) -> String {
172    let language = if source_type.is_typescript_definition() {
173        "dts"
174    } else if source_type.is_typescript() {
175        "ts"
176    } else {
177        "js"
178    };
179    let module_kind = if source_type.is_commonjs() {
180        "commonjs"
181    } else if source_type.is_module() {
182        "module"
183    } else if source_type.is_script() {
184        "script"
185    } else {
186        "unambiguous"
187    };
188    let variant = if source_type.is_jsx() {
189        "jsx"
190    } else {
191        "standard"
192    };
193
194    format!("{language}:{module_kind}:{variant}")
195}
196
197pub fn analyze_files(
198    project_root: &Path,
199    files: &[PathBuf],
200    options: AnalyzeOptions,
201) -> Result<OxcEngineResult, String> {
202    let mut cache = OxcFactsCache::new();
203    analyze_files_with_cache(project_root, files, options, &mut cache)
204}
205
206pub fn analyze_files_with_cache(
207    project_root: &Path,
208    files: &[PathBuf],
209    options: AnalyzeOptions,
210    cache: &mut OxcFactsCache,
211) -> Result<OxcEngineResult, String> {
212    let project_root =
213        fs::canonicalize(project_root).unwrap_or_else(|_| normalize_path(project_root));
214    let force_reparse_files = normalize_option_paths(&options.force_reparse_files);
215    let normalized_files = normalize_file_set(&project_root, files);
216    let files = normalized_files.files;
217    let skipped_outside_root = normalized_files.skipped_outside_root;
218    let mut cache_stats = OxcFactsCacheStats::default();
219    let mut errors = Vec::new();
220    let mut facts = Vec::with_capacity(files.len());
221
222    for (idx, path) in files.iter().enumerate() {
223        match cache.facts_for_file(
224            FileId(idx),
225            path,
226            force_reparse_files.contains(path),
227            &mut cache_stats,
228        ) {
229            Ok(file_facts) => facts.push(file_facts),
230            Err(error) => errors.push(OxcEngineError {
231                file: path.clone(),
232                message: format!("read: {error}"),
233            }),
234        }
235    }
236
237    Ok(analyze_preparsed_facts(
238        project_root,
239        facts,
240        options,
241        cache_stats,
242        errors,
243        skipped_outside_root,
244    ))
245}
246
247pub(crate) fn analyze_file_facts(
248    project_root: &Path,
249    facts: Vec<FileFacts>,
250    options: AnalyzeOptions,
251    skipped_outside_root: Vec<PathBuf>,
252) -> OxcEngineResult {
253    let project_root =
254        fs::canonicalize(project_root).unwrap_or_else(|_| normalize_path(project_root));
255    analyze_preparsed_facts(
256        project_root,
257        facts,
258        options,
259        OxcFactsCacheStats::default(),
260        Vec::new(),
261        skipped_outside_root,
262    )
263}
264
265fn analyze_preparsed_facts(
266    project_root: PathBuf,
267    mut facts: Vec<FileFacts>,
268    options: AnalyzeOptions,
269    cache_stats: OxcFactsCacheStats,
270    mut errors: Vec<OxcEngineError>,
271    skipped_outside_root: Vec<PathBuf>,
272) -> OxcEngineResult {
273    // Preserve dense FileId indexing when unreadable files were skipped or facts
274    // were reconstructed from contribution records.
275    for (idx, fact) in facts.iter_mut().enumerate() {
276        fact.file_id = FileId(idx);
277        if let Some(parse_error) = &fact.parse_error {
278            errors.push(OxcEngineError {
279                file: fact.path.clone(),
280                message: format!("parse: {parse_error}"),
281            });
282        }
283    }
284    let resolved_files = facts
285        .iter()
286        .map(|fact| fact.path.clone())
287        .collect::<Vec<_>>();
288    let resolver = ModuleResolver::new(&project_root, &resolved_files);
289    let (resolved_modules, tracker, edges) = resolver.resolve_modules(&facts);
290    let entry_points = normalize_option_paths(&options.entry_points);
291    let public_api_files = normalize_option_paths(&options.public_api_files);
292    let file_verdicts = compute_verdicts(
293        &project_root,
294        &resolved_modules,
295        &entry_points,
296        &public_api_files,
297        options.entry_reachability,
298    );
299    let resolved_edges = edges
300        .iter()
301        .filter(|edge| edge.resolved_file.is_some())
302        .count();
303    let unresolved_edges = edges.len().saturating_sub(resolved_edges);
304    let resolver_config_inputs = tracker.inputs();
305    let resolver_config_fingerprint = tracker.fingerprint();
306
307    OxcEngineResult {
308        files: file_verdicts,
309        facts,
310        resolver_config_inputs,
311        resolver_config_fingerprint,
312        edges,
313        stats: OxcEngineStats {
314            files: resolved_files.len(),
315            cache_hits: cache_stats.hits,
316            cache_misses: cache_stats.misses,
317            resolved_edges,
318            unresolved_edges,
319        },
320        errors,
321        skipped_outside_root,
322    }
323}
324
325#[derive(Debug, Default)]
326struct NormalizedFileSet {
327    files: Vec<PathBuf>,
328    skipped_outside_root: Vec<PathBuf>,
329}
330
331fn normalize_file_set(project_root: &Path, files: &[PathBuf]) -> NormalizedFileSet {
332    let mut normalized = NormalizedFileSet::default();
333    for path in files.iter().filter(|path| is_ts_js_file(path)) {
334        let path = normalize_input_path(project_root, path);
335        if path.strip_prefix(project_root).is_ok() {
336            normalized.files.push(path);
337        } else {
338            normalized.skipped_outside_root.push(path);
339        }
340    }
341
342    normalized.files.sort();
343    normalized.files.dedup();
344    normalized.skipped_outside_root.sort();
345    normalized.skipped_outside_root.dedup();
346    normalized
347}
348
349fn normalize_input_path(project_root: &Path, path: &Path) -> PathBuf {
350    fs::canonicalize(path).unwrap_or_else(|_| {
351        if path.is_absolute() {
352            normalize_path(path)
353        } else {
354            normalize_path(&project_root.join(path))
355        }
356    })
357}
358
359fn normalize_option_paths(paths: &[PathBuf]) -> BTreeSet<PathBuf> {
360    paths
361        .iter()
362        .map(|path| fs::canonicalize(path).unwrap_or_else(|_| normalize_path(path)))
363        .collect()
364}
365
366fn is_ts_js_file(path: &Path) -> bool {
367    path.extension()
368        .and_then(|ext| ext.to_str())
369        .is_some_and(|ext| {
370            matches!(
371                ext,
372                "ts" | "tsx" | "js" | "jsx" | "mts" | "cts" | "mjs" | "cjs"
373            )
374        })
375}