Skip to main content

fallow_graph/resolve/
types.rs

1//! Type definitions and constants for import resolution.
2
3use std::path::{Path, PathBuf};
4use std::sync::{Arc, Mutex};
5
6use dashmap::DashMap;
7use oxc_resolver::Resolver;
8use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet};
9use serde_json::Value;
10
11use fallow_types::discover::FileId;
12
13/// Result of resolving an import specifier.
14#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
15pub enum ResolveResult {
16    /// Resolved to a file within the project.
17    InternalModule(FileId),
18    /// Resolved from a CommonJS `require()` to a file within the project.
19    CommonJsInternalModule(FileId),
20    /// Resolved to a project file through a framework convention auto-import.
21    SyntheticAutoImport(FileId),
22    /// Resolved to a workspace or self package source file while preserving
23    /// dependency usage for package accounting.
24    InternalPackageModule {
25        /// Internal source file reached by the package map.
26        file_id: FileId,
27        /// Package name that was used in the import specifier.
28        package_name: String,
29    },
30    /// Resolved from a CommonJS `require()` to workspace or self-package source.
31    CommonJsInternalPackageModule {
32        /// Internal source file reached by the package map.
33        file_id: FileId,
34        /// Package name used in the require specifier.
35        package_name: String,
36    },
37    /// Resolved to a file outside the project (`node_modules`, `.json`, etc.).
38    ExternalFile(PathBuf),
39    /// Bare specifier — an npm package.
40    NpmPackage(String),
41    /// Bare npm package referenced through CommonJS `require()`.
42    CommonJsNpmPackage(String),
43    /// Could not resolve.
44    Unresolvable(String),
45}
46
47impl ResolveResult {
48    /// Return the target file for any project-internal result.
49    #[must_use]
50    pub const fn internal_file_id(&self) -> Option<FileId> {
51        match self {
52            Self::InternalModule(file_id)
53            | Self::CommonJsInternalModule(file_id)
54            | Self::SyntheticAutoImport(file_id)
55            | Self::InternalPackageModule { file_id, .. }
56            | Self::CommonJsInternalPackageModule { file_id, .. } => Some(*file_id),
57            Self::ExternalFile(_)
58            | Self::NpmPackage(_)
59            | Self::CommonJsNpmPackage(_)
60            | Self::Unresolvable(_) => None,
61        }
62    }
63
64    /// Return whether this edge was synthesized from framework auto-import conventions.
65    #[must_use]
66    pub const fn is_synthetic_auto_import(&self) -> bool {
67        matches!(self, Self::SyntheticAutoImport(_))
68    }
69
70    /// Return whether this edge originated from CommonJS `require()`.
71    #[must_use]
72    pub const fn is_commonjs_require(&self) -> bool {
73        matches!(
74            self,
75            Self::CommonJsInternalModule(_)
76                | Self::CommonJsInternalPackageModule { .. }
77                | Self::CommonJsNpmPackage(_)
78        )
79    }
80
81    /// Return whether this target is an unresolved bare package edge.
82    #[must_use]
83    pub const fn is_bare_package(&self) -> bool {
84        matches!(self, Self::NpmPackage(_) | Self::CommonJsNpmPackage(_))
85    }
86
87    /// Retain CommonJS provenance on targets that can participate in upgrades.
88    #[must_use]
89    pub fn into_commonjs_require(self) -> Self {
90        match self {
91            Self::InternalModule(file_id) => Self::CommonJsInternalModule(file_id),
92            Self::InternalPackageModule {
93                file_id,
94                package_name,
95            } => Self::CommonJsInternalPackageModule {
96                file_id,
97                package_name,
98            },
99            Self::NpmPackage(package_name) => Self::CommonJsNpmPackage(package_name),
100            other => other,
101        }
102    }
103
104    /// Remove CommonJS provenance while preserving the resolved destination.
105    #[must_use]
106    pub fn into_es_module(self) -> Self {
107        match self {
108            Self::CommonJsInternalModule(file_id) => Self::InternalModule(file_id),
109            Self::CommonJsInternalPackageModule {
110                file_id,
111                package_name,
112            } => Self::InternalPackageModule {
113                file_id,
114                package_name,
115            },
116            Self::CommonJsNpmPackage(package_name) => Self::NpmPackage(package_name),
117            other => other,
118        }
119    }
120
121    /// Return the package name that should receive dependency usage credit.
122    #[must_use]
123    pub fn package_usage_name(&self) -> Option<&str> {
124        match self {
125            Self::InternalPackageModule { package_name, .. }
126            | Self::CommonJsInternalPackageModule { package_name, .. }
127            | Self::NpmPackage(package_name)
128            | Self::CommonJsNpmPackage(package_name) => Some(package_name),
129            Self::InternalModule(_)
130            | Self::CommonJsInternalModule(_)
131            | Self::SyntheticAutoImport(_)
132            | Self::ExternalFile(_)
133            | Self::Unresolvable(_) => None,
134        }
135    }
136}
137
138/// Resolver output for one extracted project.
139#[derive(Debug, Default)]
140pub struct ResolvedProject {
141    /// Modules with every ordinary import and re-export resolved.
142    pub modules: Vec<ResolvedModule>,
143    /// Proven test-time replacements whose targets resolve inside the project.
144    pub replaced_module_targets: Vec<ResolvedReplacedModuleTarget>,
145    /// Deterministic work counts of the resolution that built this project.
146    /// Zero for a project restored from the graph cache.
147    pub work: super::ResolveWork,
148}
149
150/// One project-internal module replacement resolved from its declaring source file.
151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
152pub struct ResolvedReplacedModuleTarget {
153    /// File that declares the replacement fact.
154    pub source_file: FileId,
155    /// Project file replaced for this source's test-root traversal.
156    pub target_file: FileId,
157}
158
159/// A resolved import with its target.
160#[derive(Debug, Clone)]
161pub struct ResolvedImport {
162    /// The original import information.
163    pub info: fallow_types::extract::ImportInfo,
164    /// Where the import resolved to.
165    pub target: ResolveResult,
166}
167
168/// A resolved re-export with its target.
169#[derive(Debug, Clone)]
170pub struct ResolvedReExport {
171    /// The original re-export information.
172    pub info: fallow_types::extract::ReExportInfo,
173    /// Where the re-export source resolved to.
174    pub target: ResolveResult,
175}
176
177/// Any source-bearing module edge that resolves one literal specifier.
178pub enum ResolvedSourceEdge<'a> {
179    /// Static or literal dynamic import edge.
180    Import(&'a ResolvedImport),
181    /// Re-export source edge.
182    ReExport(&'a ResolvedReExport),
183}
184
185impl<'a> ResolvedSourceEdge<'a> {
186    /// Return the original source specifier.
187    #[must_use]
188    pub fn source_specifier(&self) -> &'a str {
189        match self {
190            Self::Import(import) => &import.info.source,
191            Self::ReExport(re_export) => &re_export.info.source,
192        }
193    }
194
195    /// Return the resolved target.
196    #[must_use]
197    pub const fn target(&self) -> &'a ResolveResult {
198        match self {
199            Self::Import(import) => &import.target,
200            Self::ReExport(re_export) => &re_export.target,
201        }
202    }
203
204    /// Return whether this edge is type-only.
205    #[must_use]
206    pub const fn is_type_only(&self) -> bool {
207        match self {
208            Self::Import(import) => import.info.is_type_only,
209            Self::ReExport(re_export) => re_export.info.is_type_only,
210        }
211    }
212
213    /// Return the span of the full import or re-export declaration.
214    #[must_use]
215    pub const fn span(&self) -> oxc_span::Span {
216        match self {
217            Self::Import(import) => import.info.span,
218            Self::ReExport(re_export) => re_export.info.span,
219        }
220    }
221
222    /// Return the source literal span when the extractor has one.
223    #[must_use]
224    pub const fn source_span(&self) -> oxc_span::Span {
225        match self {
226            Self::Import(import) => import.info.source_span,
227            Self::ReExport(re_export) => re_export.info.source_span,
228        }
229    }
230
231    /// Return the span of the enclosing statement.
232    ///
233    /// Imports do not record a statement span, so their per-binding
234    /// declaration span is the closest available statement anchor.
235    #[must_use]
236    pub const fn statement_span(&self) -> oxc_span::Span {
237        match self {
238            Self::Import(import) => import.info.span,
239            Self::ReExport(re_export) => re_export.info.statement_span,
240        }
241    }
242}
243
244/// Fully resolved module with all imports mapped to targets.
245///
246/// The `Arc<[T]>` fields are refcounted views of the same allocations owned by
247/// the source [`fallow_types::extract::ModuleInfo`], not copies; see the note
248/// on that struct.
249#[derive(Debug)]
250pub struct ResolvedModule {
251    /// Unique file identifier.
252    pub file_id: FileId,
253    /// Absolute path to the module file.
254    pub path: PathBuf,
255    /// All export declarations in this module.
256    pub exports: Arc<[fallow_types::extract::ExportInfo]>,
257    /// All re-exports with resolved targets.
258    pub re_exports: Vec<ResolvedReExport>,
259    /// All static imports with resolved targets.
260    pub resolved_imports: Vec<ResolvedImport>,
261    /// All dynamic imports with resolved targets.
262    pub resolved_dynamic_imports: Vec<ResolvedImport>,
263    /// Dynamic import patterns and their matched target files.
264    pub resolved_dynamic_patterns: Vec<(fallow_types::extract::DynamicImportPattern, Vec<FileId>)>,
265    /// Static member accesses (e.g., `Status.Active`).
266    pub member_accesses: Arc<[fallow_types::extract::MemberAccess]>,
267    /// Typed semantic facts produced by extraction for cross-layer analysis.
268    pub semantic_facts: Arc<[fallow_types::extract::SemanticFact]>,
269    /// Identifiers used as whole objects (Object.values, for..in, spread, etc.).
270    pub whole_object_uses: Arc<[String]>,
271    /// Whether this module uses `CommonJS` exports.
272    pub has_cjs_exports: bool,
273    /// Whether this module declares at least one Angular `@Component({
274    /// templateUrl: ... })` decorator. Mirrors `ModuleInfo.has_angular_component_template_url`;
275    /// see that field for the contract this gate enforces.
276    pub has_angular_component_template_url: bool,
277    /// Local names of import bindings that are never referenced in this file.
278    pub unused_import_bindings: FxHashSet<String>,
279    /// Local import bindings referenced from type positions.
280    pub type_referenced_import_bindings: Vec<String>,
281    /// Local import bindings referenced from runtime/value positions.
282    pub value_referenced_import_bindings: Vec<String>,
283    /// Namespace-import aliases re-exported through an object literal.
284    /// See `fallow_types::extract::NamespaceObjectAlias` for the shape.
285    pub namespace_object_aliases: Vec<fallow_types::extract::NamespaceObjectAlias>,
286    /// Exported free-function factories that provably return one class instance.
287    /// See `fallow_types::extract::FactoryReturnExport` and issue #1441 (Part A).
288    pub exported_factory_returns: Arc<[fallow_types::extract::FactoryReturnExport]>,
289    /// Object-literal factory-return shapes (`export function createUi() {
290    /// return { orders: factory.ordersPage } }`), threaded from `ModuleInfo` for
291    /// the analyze-layer member-crediting join. See issue #1858.
292    pub exported_factory_return_object_shapes:
293        Arc<[fallow_types::extract::FactoryReturnObjectShapeExport]>,
294    /// Named-type property types declared by this module's top-level interfaces
295    /// and type-literal aliases. See `fallow_types::extract::TypeMemberTypeEntry`
296    /// and issue #1785.
297    pub type_member_types: Arc<[fallow_types::extract::TypeMemberTypeEntry]>,
298}
299
300impl Default for ResolvedModule {
301    fn default() -> Self {
302        Self {
303            file_id: FileId(0),
304            path: PathBuf::new(),
305            exports: Arc::default(),
306            re_exports: vec![],
307            resolved_imports: vec![],
308            resolved_dynamic_imports: vec![],
309            resolved_dynamic_patterns: vec![],
310            member_accesses: Arc::default(),
311            semantic_facts: Arc::default(),
312            whole_object_uses: Arc::default(),
313            has_cjs_exports: false,
314            has_angular_component_template_url: false,
315            unused_import_bindings: FxHashSet::default(),
316            type_referenced_import_bindings: vec![],
317            value_referenced_import_bindings: vec![],
318            namespace_object_aliases: vec![],
319            exported_factory_returns: Arc::default(),
320            exported_factory_return_object_shapes: Arc::default(),
321            type_member_types: Arc::default(),
322        }
323    }
324}
325
326impl ResolvedModule {
327    /// Iterate over all concrete resolved imports in source order buckets.
328    ///
329    /// Includes static `import`/`require` edges and literal dynamic `import()`
330    /// edges. Dynamic import patterns are intentionally excluded because they
331    /// resolve to sets of files rather than single import specifiers.
332    pub fn all_resolved_imports(&self) -> impl Iterator<Item = &ResolvedImport> {
333        self.resolved_imports
334            .iter()
335            .chain(self.resolved_dynamic_imports.iter())
336    }
337
338    /// Iterate over every literal source edge that has one resolved target.
339    ///
340    /// Includes static imports, literal dynamic imports, and re-export sources.
341    /// Dynamic import patterns are excluded because they resolve to sets of
342    /// files rather than single import specifiers.
343    pub fn all_resolved_source_edges(&self) -> impl Iterator<Item = ResolvedSourceEdge<'_>> {
344        self.resolved_imports
345            .iter()
346            .map(ResolvedSourceEdge::Import)
347            .chain(
348                self.resolved_dynamic_imports
349                    .iter()
350                    .map(ResolvedSourceEdge::Import),
351            )
352            .chain(self.re_exports.iter().map(ResolvedSourceEdge::ReExport))
353    }
354}
355
356/// Shared context for resolving import specifiers.
357///
358/// Groups the immutable lookup tables and caches that are shared across all
359/// `resolve_specifier` calls within a single `resolve_all_imports` invocation.
360pub(super) struct ResolveContext<'a> {
361    /// The oxc_resolver instance (configured once, shared across threads).
362    pub resolver: &'a Resolver,
363    /// CSS-only resolver with package.json `sass` and `style` conditions enabled.
364    /// Used only for stylesheet package subpaths so JS/TS imports do not
365    /// accidentally prefer CSS export branches.
366    pub style_resolver: &'a Resolver,
367    /// Ordered extension list used by the resolver.
368    pub extensions: &'a [String],
369    /// Canonical path → FileId lookup (raw paths when root is canonical).
370    pub path_to_id: &'a FxHashMap<&'a Path, FileId>,
371    /// Raw (non-canonical) path → FileId lookup.
372    pub raw_path_to_id: &'a FxHashMap<&'a Path, FileId>,
373    /// Workspace name → canonical root path.
374    pub workspace_roots: &'a FxHashMap<&'a str, &'a Path>,
375    /// Package manifests for the root package and workspace packages.
376    pub package_manifests: &'a [PackageManifestInfo],
377    /// Whether any package scope declared a Deno import map; false skips map
378    /// lookup per import.
379    pub has_deno_import_maps: bool,
380    /// Ordered package condition names matching the resolver configuration.
381    pub condition_names: &'a [String],
382    /// Plugin-provided path aliases (prefix, replacement).
383    pub path_aliases: &'a [(String, String)],
384    /// Absolute directories to search when resolving bare SCSS/Sass
385    /// `@import` / `@use` specifiers. Populated from Angular's
386    /// `stylePreprocessorOptions.includePaths` and equivalent settings.
387    pub scss_include_paths: &'a [PathBuf],
388    /// Static directory URL mappings from framework config.
389    /// Each tuple is `(absolute_source_dir, normalized_url_mount)`.
390    pub static_dir_mappings: &'a [(PathBuf, String)],
391    /// Mounts a framework serves for the whole project, reachable from any HTML
392    /// document, unlike `static_dir_mappings`, which stays scoped to the tool
393    /// whose config declared it.
394    pub framework_static_dir_mappings: &'a [(PathBuf, String)],
395    /// Project root directory.
396    pub root: &'a Path,
397    /// Lazy canonical path → FileId fallback for intra-project symlinks.
398    /// Only initialized on first miss when root is canonical. `None` when
399    /// path_to_id already uses canonical paths (root is not canonical).
400    pub canonical_fallback: Option<&'a CanonicalFallback<'a>>,
401    /// Dedup set for broken-tsconfig warnings. Emits one `tracing::warn!`
402    /// per unique error message instead of spamming the log with one
403    /// warning per affected file. Shared across all parallel resolver
404    /// threads via `Mutex`. Empty and unused when no tsconfig errors occur.
405    pub tsconfig_warned: &'a Mutex<FxHashSet<String>>,
406    /// Per-analysis cache for local tsconfig discovery and JSON parsing.
407    /// Import resolution calls these fallbacks for every unresolved or
408    /// tsconfig-poisoned specifier, so keeping it session-local avoids
409    /// repeated filesystem work without risking stale data across runs.
410    pub tsconfig_cache: &'a TsconfigCache,
411    /// Per-analysis cache of `dunce::canonicalize` results keyed by resolved
412    /// path. Every import resolving to a `node_modules` / output-dir / symlinked
413    /// target is realpath'd during classification, and the same package path is
414    /// re-canonicalized for every file that imports the package. The result is a
415    /// pure function of the path's on-disk state (constant within a run), so the
416    /// cache is session-local for watch-mode safety.
417    pub canonicalize_cache: &'a CanonicalizeCache,
418}
419
420/// Session-local cache of `dunce::canonicalize` results keyed by input path.
421#[derive(Default)]
422pub(super) struct CanonicalizeCache {
423    map: DashMap<PathBuf, Option<PathBuf>, FxBuildHasher>,
424}
425
426impl CanonicalizeCache {
427    /// Return the cached `dunce::canonicalize(path)` outcome, computing it on
428    /// first miss. `None` (a path that fails to canonicalize) is cached too so a
429    /// repeated probe of the same missing path does not re-issue the syscall.
430    pub fn get(&self, path: &Path) -> Option<PathBuf> {
431        if let Some(value) = self.map.get(path) {
432            return value.clone();
433        }
434        let value = dunce::canonicalize(path).ok();
435        self.map.insert(path.to_path_buf(), value.clone());
436        value
437    }
438
439    /// Number of distinct paths this cache canonicalized. Two threads that
440    /// miss the same path at once can both call the filesystem, so this is the
441    /// work the run needs, not an exact syscall count.
442    pub fn distinct_paths(&self) -> usize {
443        self.map.len()
444    }
445}
446
447/// Session-local cache for tsconfig helper lookups used during import resolution.
448#[derive(Default)]
449pub(super) struct TsconfigCache {
450    json: DashMap<PathBuf, Option<Arc<Value>>, FxBuildHasher>,
451    chains: DashMap<PathBuf, Arc<[PathBuf]>, FxBuildHasher>,
452}
453
454impl TsconfigCache {
455    /// Return a cached parsed tsconfig JSON value, loading it on first miss.
456    ///
457    /// Handing back an [`Arc`] rather than a clone matters: a tsconfig chain is
458    /// walked several times per import specifier, and deep-copying every parsed
459    /// document on each hop dominated resolution on large project-reference
460    /// graphs.
461    pub fn json(
462        &self,
463        path: &Path,
464        load: impl FnOnce(&Path) -> Option<Value>,
465    ) -> Option<Arc<Value>> {
466        if let Some(value) = self.json.get(path) {
467            return value.clone();
468        }
469
470        let value = load(path).map(Arc::new);
471        self.json.insert(path.to_path_buf(), value.clone());
472        value
473    }
474
475    /// Return the cached tsconfig chain for a source file, if one exists.
476    pub fn chain(&self, from_file: &Path) -> Option<Arc<[PathBuf]>> {
477        self.chains.get(from_file).map(|entry| Arc::clone(&entry))
478    }
479
480    /// Store the computed tsconfig chain for a source file.
481    pub fn store_chain(&self, from_file: &Path, chain: Arc<[PathBuf]>) {
482        self.chains.insert(from_file.to_path_buf(), chain);
483    }
484}
485
486/// Package manifest data used by source fallbacks.
487#[derive(Debug, Clone)]
488pub(super) struct PackageManifestInfo {
489    /// Package root path as discovered from the workspace tree.
490    pub root: PathBuf,
491    /// Canonical package root path for node_modules symlink comparisons.
492    pub canonical_root: PathBuf,
493    /// Parsed package name.
494    pub name: Option<String>,
495    /// Parsed package.json fields.
496    pub package_json: fallow_config::PackageJson,
497    /// Effective Deno import map for this package scope.
498    pub deno_import_map: Vec<DenoImportMapEntry>,
499}
500
501/// One effective Deno import-map entry with the directory that declared it.
502#[derive(Debug, Clone)]
503pub(super) struct DenoImportMapEntry {
504    pub key: String,
505    pub target: String,
506    pub declaring_dir: PathBuf,
507}
508
509/// Thread-safe lazy canonical path index, built on first access.
510pub(super) struct CanonicalFallback<'a> {
511    files: &'a [fallow_types::discover::DiscoveredFile],
512    map: std::sync::OnceLock<FxHashMap<std::path::PathBuf, FileId>>,
513}
514
515impl<'a> CanonicalFallback<'a> {
516    pub const fn new(files: &'a [fallow_types::discover::DiscoveredFile]) -> Self {
517        Self {
518            files,
519            map: std::sync::OnceLock::new(),
520        }
521    }
522
523    /// Look up a canonical path, lazily building the index on first call.
524    pub fn get(&self, canonical: &Path) -> Option<FileId> {
525        let map = self.map.get_or_init(|| {
526            super::work::note_canonicalize(self.files.len() as u64);
527            tracing::debug!(
528                "intra-project symlinks detected, building canonical path index ({} files)",
529                self.files.len()
530            );
531            self.files
532                .iter()
533                .filter_map(|f| {
534                    dunce::canonicalize(&f.path)
535                        .ok()
536                        .map(|canonical| (canonical, f.id))
537                })
538                .collect()
539        });
540        map.get(canonical).copied()
541    }
542}
543
544#[cfg(all(test, not(miri)))]
545mod tests {
546    use super::*;
547    use fallow_types::discover::DiscoveredFile;
548
549    #[test]
550    fn canonical_fallback_returns_none_for_empty_files() {
551        let files: Vec<DiscoveredFile> = vec![];
552        let fallback = CanonicalFallback::new(&files);
553        assert!(fallback.get(Path::new("/nonexistent")).is_none());
554    }
555
556    #[test]
557    fn canonical_fallback_finds_existing_file() {
558        let temp = std::env::temp_dir().join("fallow-test-canonical-fallback");
559        let _ = std::fs::create_dir_all(&temp);
560        let test_file = temp.join("test.ts");
561        std::fs::write(&test_file, "").unwrap();
562
563        let files = vec![DiscoveredFile {
564            id: FileId(42),
565            path: test_file.clone(),
566            size_bytes: 0,
567        }];
568        let fallback = CanonicalFallback::new(&files);
569
570        let canonical = dunce::canonicalize(&test_file).unwrap();
571        assert_eq!(fallback.get(&canonical), Some(FileId(42)));
572
573        assert_eq!(fallback.get(&canonical), Some(FileId(42)));
574
575        let _ = std::fs::remove_dir_all(&temp);
576    }
577
578    #[test]
579    fn canonical_fallback_returns_none_for_missing_path() {
580        let temp = std::env::temp_dir().join("fallow-test-canonical-miss");
581        let _ = std::fs::create_dir_all(&temp);
582        let test_file = temp.join("exists.ts");
583        std::fs::write(&test_file, "").unwrap();
584
585        let files = vec![DiscoveredFile {
586            id: FileId(1),
587            path: test_file,
588            size_bytes: 0,
589        }];
590        let fallback = CanonicalFallback::new(&files);
591        assert!(fallback.get(Path::new("/nonexistent/file.ts")).is_none());
592
593        let _ = std::fs::remove_dir_all(&temp);
594    }
595
596    #[test]
597    fn tsconfig_cache_loads_once_and_shares_the_parsed_document() {
598        let cache = TsconfigCache::default();
599        let path = Path::new("/project/tsconfig.json");
600        let loads = std::sync::atomic::AtomicUsize::new(0);
601        let load = |_: &Path| {
602            loads.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
603            Some(serde_json::json!({ "compilerOptions": {} }))
604        };
605
606        let first = cache.json(path, load).unwrap();
607        let second = cache.json(path, load).unwrap();
608
609        assert_eq!(loads.load(std::sync::atomic::Ordering::SeqCst), 1);
610        assert!(
611            Arc::ptr_eq(&first, &second),
612            "repeat reads must share one allocation rather than deep-copy"
613        );
614    }
615
616    /// An unreadable tsconfig is cached as a miss so the read is not retried.
617    #[test]
618    fn tsconfig_cache_caches_a_failed_load() {
619        let cache = TsconfigCache::default();
620        let path = Path::new("/project/missing.json");
621        let loads = std::sync::atomic::AtomicUsize::new(0);
622        let load = |_: &Path| {
623            loads.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
624            None
625        };
626
627        assert!(cache.json(path, load).is_none());
628        assert!(cache.json(path, load).is_none());
629        assert_eq!(loads.load(std::sync::atomic::Ordering::SeqCst), 1);
630    }
631
632    #[test]
633    fn tsconfig_cache_round_trips_a_chain() {
634        let cache = TsconfigCache::default();
635        let from_file = Path::new("/project/src/index.ts");
636        assert!(cache.chain(from_file).is_none());
637
638        let chain: Arc<[PathBuf]> = vec![PathBuf::from("/project/tsconfig.json")].into();
639        cache.store_chain(from_file, Arc::clone(&chain));
640
641        assert!(Arc::ptr_eq(&cache.chain(from_file).unwrap(), &chain));
642    }
643
644    /// Both caches are read concurrently by rayon workers during resolution.
645    #[test]
646    fn tsconfig_cache_is_consistent_under_concurrent_access() {
647        const THREADS: usize = 8;
648        const PATHS: usize = 32;
649
650        let cache = TsconfigCache::default();
651        std::thread::scope(|scope| {
652            for _ in 0..THREADS {
653                scope.spawn(|| {
654                    for index in 0..PATHS {
655                        let path = PathBuf::from(format!("/project/{index}/tsconfig.json"));
656                        let json = cache
657                            .json(&path, |_| Some(serde_json::json!({ "index": index })))
658                            .unwrap();
659                        assert_eq!(json["index"], index);
660                    }
661                });
662            }
663        });
664    }
665
666    #[test]
667    fn canonicalize_cache_returns_the_same_result_on_repeat_lookups() {
668        let temp = tempfile::tempdir().expect("create temp dir");
669        let file = temp.path().join("file.ts");
670        std::fs::write(&file, "").unwrap();
671
672        let cache = CanonicalizeCache::default();
673        let expected = dunce::canonicalize(&file).ok();
674        assert_eq!(cache.get(&file), expected);
675        assert_eq!(cache.get(&file), expected);
676        assert!(cache.get(&temp.path().join("missing.ts")).is_none());
677    }
678
679    #[test]
680    fn commonjs_provenance_wraps_internal_and_bare_package_targets() {
681        assert!(matches!(
682            ResolveResult::InternalModule(FileId(4)).into_commonjs_require(),
683            ResolveResult::CommonJsInternalModule(FileId(4))
684        ));
685        assert!(matches!(
686            ResolveResult::InternalPackageModule {
687                file_id: FileId(5),
688                package_name: "pkg".to_string(),
689            }
690            .into_commonjs_require(),
691            ResolveResult::CommonJsInternalPackageModule {
692                file_id: FileId(5),
693                package_name,
694            } if package_name == "pkg"
695        ));
696        assert!(matches!(
697            ResolveResult::NpmPackage("pkg".to_string()).into_commonjs_require(),
698            ResolveResult::CommonJsNpmPackage(package_name) if package_name == "pkg"
699        ));
700    }
701}
702
703/// Known output directory names that may appear in exports map targets.
704/// When an exports map points to `./dist/utils.js`, we try replacing these
705/// prefixes with `src/` (the conventional source directory) to find the tracked
706/// source file.
707pub const OUTPUT_DIRS: &[&str] = &["dist", "build", "out", "esm", "cjs"];
708
709/// Source extensions to try when mapping a built output file back to source.
710pub const SOURCE_EXTS: &[&str] = &["ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs"];
711
712/// React Native platform extension prefixes.
713/// Metro resolves platform-specific files (e.g., `./foo` -> `./foo.web.tsx` on web).
714pub const RN_PLATFORM_PREFIXES: &[&str] = &[".web", ".ios", ".android", ".native"];