Skip to main content

fallow_graph/resolve/
fallbacks.rs

1//! Resolution fallback strategies for import specifiers.
2//!
3//! Handles path alias fallbacks, output-to-source directory mapping, pnpm virtual
4//! store detection, node_modules package extraction, and dynamic import glob patterns.
5
6use std::path::{Path, PathBuf};
7
8use rustc_hash::FxHashMap;
9use serde_json::Value;
10
11use fallow_config::TsconfigOutputResolution;
12use fallow_types::discover::FileId;
13
14use super::path_info::{extract_package_name, is_bare_specifier, is_valid_package_name};
15use super::types::{
16    MISSING_ONLY_OUTPUT_DIRS, OUTPUT_DIRS, PackageManifestInfo, ResolveContext, ResolveResult,
17    SOURCE_EXTS,
18};
19
20/// Return the post-prefix remainder when `specifier` matches the alias `prefix`
21/// at a path boundary, else `None`.
22///
23/// The match is segment-aware: a bare exact-key alias (e.g. `@scope/sdk` or
24/// `vscode`) matches only on an exact hit or a `/`-delimited continuation, so it
25/// never captures a longer package that merely shares the prefix
26/// (`@scope/sdk-extra`). Prefixes that already end in `/` (`~/`, `@/`, `$lib/`)
27/// match any continuation by construction, preserving their existing behavior.
28fn alias_match_remainder<'a>(specifier: &'a str, prefix: &str) -> Option<&'a str> {
29    let remainder = specifier.strip_prefix(prefix)?;
30    (remainder.is_empty() || prefix.ends_with('/') || remainder.starts_with('/'))
31        .then_some(remainder)
32}
33
34/// Try resolving a specifier using plugin-provided path aliases.
35///
36/// Substitutes a matching alias prefix (e.g., `~/`) with a directory relative to the
37/// project root (e.g., `app/`) and resolves the resulting path. This handles framework
38/// aliases like Nuxt's `~/`, `~~/`, `#shared/` that aren't defined in tsconfig.json
39/// but map to real filesystem paths.
40pub(super) fn try_path_alias_fallback(
41    ctx: &ResolveContext<'_>,
42    specifier: &str,
43    style_context: bool,
44) -> Option<ResolveResult> {
45    for (prefix, replacement) in ctx.path_aliases {
46        let Some(remainder) = alias_match_remainder(specifier, prefix) else {
47            continue;
48        };
49
50        let substituted = match (replacement.is_empty(), remainder.is_empty()) {
51            (true, _) => format!("./{remainder}"),
52            (false, true) => format!("./{replacement}"),
53            (false, false) => format!("./{replacement}/{remainder}"),
54        };
55
56        if let Ok(resolved) = super::specifier::resolve_with_extension_policy(
57            ctx,
58            ctx.root,
59            &substituted,
60            style_context,
61        ) {
62            let resolved_path = resolved.path();
63            if let Some(&file_id) = ctx.raw_path_to_id.get(resolved_path) {
64                return Some(ResolveResult::InternalModule(file_id));
65            }
66            if let Some(canonical) = ctx.canonicalize_cache.get(resolved_path) {
67                if let Some(&file_id) = ctx.path_to_id.get(canonical.as_path()) {
68                    return Some(ResolveResult::InternalModule(file_id));
69                }
70                if let Some(file_id) = try_source_fallback(&canonical, ctx.path_to_id) {
71                    return Some(ResolveResult::InternalModule(file_id));
72                }
73                if let Some(file_id) =
74                    try_pnpm_workspace_fallback(&canonical, ctx.path_to_id, ctx.workspace_roots)
75                {
76                    return Some(ResolveResult::InternalModule(file_id));
77                }
78                if let Some(pkg_name) = extract_package_name_from_node_modules_path(&canonical) {
79                    return Some(ResolveResult::NpmPackage(pkg_name));
80                }
81                return Some(ResolveResult::ExternalFile(canonical));
82            }
83        }
84    }
85    None
86}
87
88/// Try SCSS partial resolution: `_filename` and `_index` conventions.
89///
90/// SCSS resolves imports in this order:
91/// 1. `@use 'variables'` → `_variables.scss` (partial convention)
92/// 2. `@use 'components'` → `components/_index.scss` or `components/index.scss` (directory index)
93///
94/// Handles both relative (`../styles/variables`) and bare (`variables`) specifiers
95/// that were normalized to `./variables` during extraction.
96pub(super) fn try_scss_partial_fallback(
97    ctx: &ResolveContext<'_>,
98    from_file: &Path,
99    specifier: &str,
100) -> Option<ResolveResult> {
101    if specifier.contains(':') {
102        return None;
103    }
104
105    let spec_path = Path::new(specifier);
106    let filename = spec_path.file_name()?.to_str()?;
107
108    if filename.starts_with('_') {
109        return None;
110    }
111
112    let partial_filename = format!("_{filename}");
113    let partial_specifier = if let Some(parent) = spec_path.parent()
114        && !parent.as_os_str().is_empty()
115    {
116        format!("{}/{partial_filename}", parent.display())
117    } else {
118        partial_filename
119    };
120
121    if let Some(result) = try_resolve_scss(ctx, from_file, &partial_specifier) {
122        return Some(result);
123    }
124
125    let index_partial = format!("{specifier}/_index");
126    if let Some(result) = try_resolve_scss(ctx, from_file, &index_partial) {
127        return Some(result);
128    }
129
130    let index_plain = format!("{specifier}/index");
131    try_resolve_scss(ctx, from_file, &index_plain)
132}
133
134/// Try non-partial CSS-extension resolution: `<spec>.scss`, `<spec>.sass`,
135/// `<spec>.css` from the importing file's parent.
136///
137/// This is needed when the standard resolver's extension list contains both
138/// `.vue` / `.svelte` / `.astro` AND CSS extensions. For an SFC `<style>` block
139/// importing `./Foo`, the standard resolver picks `Foo.vue` (the SFC itself!)
140/// before `Foo.scss` because `.vue` comes earlier in the extension list. SCSS
141/// imports must restrict resolution to CSS-family extensions to avoid this
142/// self-import collision. Only invoked when `from_style = true`. See issue #195.
143pub(super) fn try_css_extension_fallback(
144    ctx: &ResolveContext<'_>,
145    from_file: &Path,
146    specifier: &str,
147) -> Option<ResolveResult> {
148    if specifier.contains(':') {
149        return None;
150    }
151    let spec_path = Path::new(specifier);
152    let already_css_ext = spec_path
153        .extension()
154        .and_then(|e| e.to_str())
155        .is_some_and(|e| {
156            e.eq_ignore_ascii_case("css")
157                || e.eq_ignore_ascii_case("scss")
158                || e.eq_ignore_ascii_case("sass")
159        });
160    if already_css_ext {
161        return try_resolve_scss(ctx, from_file, specifier);
162    }
163    for ext in ["scss", "sass", "css"] {
164        let candidate = format!("{specifier}.{ext}");
165        if let Some(result) = try_resolve_scss(ctx, from_file, &candidate) {
166            return Some(result);
167        }
168    }
169    None
170}
171
172/// Attempt to resolve a single SCSS specifier and map to an internal module.
173fn try_resolve_scss(
174    ctx: &ResolveContext<'_>,
175    from_file: &Path,
176    specifier: &str,
177) -> Option<ResolveResult> {
178    super::work::note_oxc_resolve();
179    let resolved = ctx.resolver.resolve_file(from_file, specifier).ok()?;
180    let resolved_path = resolved.path();
181
182    if let Some(&file_id) = ctx.raw_path_to_id.get(resolved_path) {
183        return Some(ResolveResult::InternalModule(file_id));
184    }
185    if let Some(canonical) = ctx.canonicalize_cache.get(resolved_path)
186        && let Some(&file_id) = ctx.path_to_id.get(canonical.as_path())
187    {
188        return Some(ResolveResult::InternalModule(file_id));
189    }
190    None
191}
192
193/// Try SCSS `includePaths` fallback: resolve the specifier against each
194/// framework-contributed include directory.
195///
196/// Angular's `stylePreprocessorOptions.includePaths` (and Nx's equivalent via
197/// project.json) adds extra search paths that SCSS resolves against before
198/// falling back to node_modules. Bare `@use 'variables'` statements that were
199/// normalized to `./variables` at extraction time fail the usual file-local
200/// resolution, so when the importing file is `.scss`/`.sass` and the spec
201/// originated from such a bare specifier, we retry against each include path,
202/// applying the SCSS partial (`_variables`) and directory-index conventions.
203/// SFC `<style lang="scss">` imports pass `from_style = true` because their
204/// filesystem importer is `.vue` / `.svelte`, not `.scss` / `.sass`.
205///
206/// The specifier arrives with a `./` prefix because `normalize_css_import_path`
207/// rewrites bare extensionless SCSS specifiers to relative ones. We strip that
208/// prefix here to re-enter the include-path search from the root of each
209/// directory. Relative specifiers that already escape the importing file
210/// (e.g. `../shared/variables`) are left untouched — include paths only
211/// disambiguate bare specifiers, not explicit relative paths.
212pub(super) fn try_scss_include_path_fallback(
213    ctx: &ResolveContext<'_>,
214    from_file: &Path,
215    specifier: &str,
216    from_style: bool,
217) -> Option<ResolveResult> {
218    if ctx.scss_include_paths.is_empty() {
219        return None;
220    }
221    let is_scss_importer = from_file
222        .extension()
223        .is_some_and(|e| e == "scss" || e == "sass");
224    if !is_scss_importer && !from_style {
225        return None;
226    }
227    if specifier.contains(':') {
228        return None;
229    }
230    let bare = specifier.strip_prefix("./")?;
231    if bare.starts_with("..") || bare.starts_with('/') {
232        return None;
233    }
234
235    for include_dir in ctx.scss_include_paths {
236        if let Some(file_id) = find_scss_in_dir(include_dir, bare, ctx) {
237            return Some(ResolveResult::InternalModule(file_id));
238        }
239    }
240    None
241}
242
243/// Probe an SCSS include directory for a bare specifier, applying the standard
244/// SCSS resolution order: exact file, `_`-prefixed partial, `_index` / `index`
245/// directory conventions. Supports `.scss` and `.sass` extensions.
246fn find_scss_in_dir(include_dir: &Path, bare: &str, ctx: &ResolveContext<'_>) -> Option<FileId> {
247    let bare_path = Path::new(bare);
248    let has_scss_ext = matches!(
249        bare_path.extension().and_then(|e| e.to_str()),
250        Some(ext) if ext.eq_ignore_ascii_case("scss") || ext.eq_ignore_ascii_case("sass")
251    );
252
253    let parent = bare_path.parent();
254    let stem_with_ext = bare_path.file_name()?.to_str()?;
255    let stem_without_ext = bare_path.file_stem().and_then(|s| s.to_str())?;
256
257    let build = |rel: &Path| -> std::path::PathBuf { include_dir.join(rel) };
258    let join_with_parent = |name: &str| -> std::path::PathBuf {
259        parent.map_or_else(|| build(Path::new(name)), |p| build(&p.join(name)))
260    };
261
262    let exts: &[&str] = if has_scss_ext {
263        &[""]
264    } else {
265        &["scss", "sass"]
266    };
267
268    for ext in exts {
269        let suffix = if ext.is_empty() {
270            String::new()
271        } else {
272            format!(".{ext}")
273        };
274        let direct = if ext.is_empty() {
275            build(bare_path)
276        } else {
277            join_with_parent(&format!("{stem_with_ext}{suffix}"))
278        };
279        if let Some(fid) = lookup_scss_path(&direct, ctx) {
280            return Some(fid);
281        }
282        let partial_name = if ext.is_empty() {
283            format!("_{stem_with_ext}")
284        } else {
285            format!("_{stem_without_ext}{suffix}")
286        };
287        let partial = join_with_parent(&partial_name);
288        if let Some(fid) = lookup_scss_path(&partial, ctx) {
289            return Some(fid);
290        }
291        if ext.is_empty() {
292            continue;
293        }
294        let idx_partial = build(bare_path).join(format!("_index{suffix}"));
295        if let Some(fid) = lookup_scss_path(&idx_partial, ctx) {
296            return Some(fid);
297        }
298        let idx_plain = build(bare_path).join(format!("index{suffix}"));
299        if let Some(fid) = lookup_scss_path(&idx_plain, ctx) {
300            return Some(fid);
301        }
302    }
303    None
304}
305
306/// Look up an absolute candidate path in the file index, falling back to
307/// canonical path lookup for intra-project symlinks.
308fn lookup_scss_path(candidate: &Path, ctx: &ResolveContext<'_>) -> Option<FileId> {
309    if let Some(&file_id) = ctx.raw_path_to_id.get(candidate) {
310        return Some(file_id);
311    }
312    if let Some(canonical) = ctx.canonicalize_cache.get(candidate) {
313        if let Some(&file_id) = ctx.path_to_id.get(canonical.as_path()) {
314            return Some(file_id);
315        }
316        if let Some(fallback) = ctx.canonical_fallback
317            && let Some(file_id) = fallback.get(&canonical)
318        {
319            return Some(file_id);
320        }
321    }
322    None
323}
324
325/// Try SCSS `node_modules` fallback: resolve a bare specifier by walking up
326/// from the importing file and probing each ancestor's `node_modules/` dir.
327///
328/// Sass's `@import` / `@use` resolution algorithm searches `node_modules/` for
329/// bare specifiers after the file-local and `includePaths` searches fail.
330/// `@import 'bootstrap/scss/functions'` resolves to
331/// `node_modules/bootstrap/scss/_functions.scss` via the standard partial
332/// convention; `@import 'animate.css/animate.min'` resolves to
333/// `node_modules/animate.css/animate.min.css` via the CSS-extension fallback.
334///
335/// Files inside `node_modules/` are not in fallow's file index (the default
336/// ignore patterns exclude them), so this function returns
337/// `ResolveResult::NpmPackage` when a candidate exists on disk. That ensures
338/// (1) the `@import` is not reported as unresolved and (2) the npm package is
339/// marked as a used dependency so `unused-dependencies` / `unlisted-dependencies`
340/// stay accurate.
341///
342/// The specifier arrives with a `./` prefix because `normalize_css_import_path`
343/// rewrites bare extensionless SCSS specifiers to relative ones. Parent-relative
344/// specifiers are skipped — they explicitly escape the importing file and must
345/// not be silently redirected to `node_modules`. See issue #125.
346pub(super) fn try_scss_node_modules_fallback(
347    _ctx: &ResolveContext<'_>,
348    from_file: &Path,
349    specifier: &str,
350    from_style: bool,
351) -> Option<ResolveResult> {
352    if specifier.contains(':') {
353        return None;
354    }
355    let is_scss_importer = from_file
356        .extension()
357        .is_some_and(|e| e == "scss" || e == "sass");
358    if !is_scss_importer && !from_style {
359        return None;
360    }
361    let bare = specifier.strip_prefix("./")?;
362    if bare.starts_with("..") || bare.starts_with('/') {
363        return None;
364    }
365    if bare.is_empty() {
366        return None;
367    }
368
369    let mut dir = from_file.parent()?;
370    loop {
371        let nm_dir = dir.join("node_modules");
372        if nm_dir.is_dir()
373            && let Some(path) = find_scss_in_node_modules(&nm_dir, bare)
374            && let Some(pkg_name) = extract_package_name_from_node_modules_path(&path)
375        {
376            return Some(ResolveResult::NpmPackage(pkg_name));
377        }
378        let Some(parent) = dir.parent() else {
379            break;
380        };
381        dir = parent;
382    }
383    None
384}
385
386/// Probe candidate filesystem paths for a bare SCSS specifier inside a single
387/// `node_modules/` directory, applying Sass resolution conventions.
388///
389/// Candidate order:
390/// 1. `<bare>.scss` / `<bare>.sass` / `<bare>.css` (extension append)
391/// 2. `<parent>/_<stem>.scss` / `<parent>/_<stem>.sass` (partial convention)
392/// 3. `<bare>/_index.scss` / `<bare>/index.scss` (and `.sass` variants)
393/// 4. `<bare>` (exact, for specifiers that already carry an extension)
394fn find_scss_in_node_modules(nm_dir: &Path, bare: &str) -> Option<PathBuf> {
395    let bare_path = Path::new(bare);
396    let file_name = bare_path.file_name()?.to_str()?;
397    let parent = bare_path.parent();
398    let join_with_parent = |name: &str| -> PathBuf {
399        parent.map_or_else(|| nm_dir.join(name), |p| nm_dir.join(p).join(name))
400    };
401
402    for ext in &["scss", "sass", "css"] {
403        let candidate = join_with_parent(&format!("{file_name}.{ext}"));
404        if candidate.is_file() {
405            return Some(candidate);
406        }
407    }
408    for ext in &["scss", "sass"] {
409        let candidate = join_with_parent(&format!("_{file_name}.{ext}"));
410        if candidate.is_file() {
411            return Some(candidate);
412        }
413    }
414    for ext in &["scss", "sass"] {
415        let idx_partial = nm_dir.join(bare).join(format!("_index.{ext}"));
416        if idx_partial.is_file() {
417            return Some(idx_partial);
418        }
419        let idx_plain = nm_dir.join(bare).join(format!("index.{ext}"));
420        if idx_plain.is_file() {
421            return Some(idx_plain);
422        }
423    }
424    let exact = nm_dir.join(bare);
425    if exact.is_file() {
426        return Some(exact);
427    }
428    None
429}
430
431/// Try to map a resolved output path (e.g., `packages/ui/dist/utils.js`) back to
432/// the corresponding source file (e.g., `packages/ui/src/utils.ts`).
433///
434/// This handles cross-workspace imports that go through `exports` maps pointing to
435/// built output directories. Since fallow ignores `dist/`, `build/`, etc. by default,
436/// the resolved path won't be in the file set, but the source file will be.
437///
438/// Nested output subdirectories (e.g., `dist/esm/utils.mjs`, `build/cjs/index.cjs`)
439/// are handled by finding the last output directory component (closest to the file,
440/// avoiding false matches on parent directories) and then walking backwards to collect
441/// all consecutive output directory components before it.
442pub(super) fn try_source_fallback(
443    resolved: &Path,
444    path_to_id: &FxHashMap<&Path, FileId>,
445) -> Option<FileId> {
446    try_source_fallback_with(Path::new(""), resolved, path_to_id, &[OUTPUT_DIRS])
447}
448
449/// Map a package manifest target below `lib/` to `src/`.
450///
451/// Callers run this only after the target is absent from the file set and
452/// after [`try_source_fallback`] found no match. `lib` (from
453/// `MISSING_ONLY_OUTPUT_DIRS`) applies only to the path below `package_root`,
454/// so a package directory named `lib` does not change the result. Generic
455/// relative imports never use this function.
456fn try_missing_only_source_fallback(
457    package_root: &Path,
458    resolved: &Path,
459    path_to_id: &FxHashMap<&Path, FileId>,
460) -> Option<FileId> {
461    let relative = resolved.strip_prefix(package_root).ok()?;
462    try_source_fallback_with(
463        package_root,
464        relative,
465        path_to_id,
466        &[OUTPUT_DIRS, MISSING_ONLY_OUTPUT_DIRS],
467    )
468}
469
470/// Map the last output directory component of `relative` (any name in
471/// `dir_lists`) to `src/`, and look up the result below `base` in `path_to_id`.
472fn try_source_fallback_with(
473    base: &Path,
474    relative: &Path,
475    path_to_id: &FxHashMap<&Path, FileId>,
476    dir_lists: &[&[&str]],
477) -> Option<FileId> {
478    let components: Vec<_> = relative.components().collect();
479
480    let is_output_dir = |c: &std::path::Component| -> bool {
481        if let std::path::Component::Normal(s) = c
482            && let Some(name) = s.to_str()
483        {
484            return dir_lists.iter().any(|dirs| dirs.contains(&name));
485        }
486        false
487    };
488
489    let last_output_pos = components.iter().rposition(&is_output_dir)?;
490
491    let mut first_output_pos = last_output_pos;
492    while first_output_pos > 0 && is_output_dir(&components[first_output_pos - 1]) {
493        first_output_pos -= 1;
494    }
495
496    let relative_prefix: PathBuf = components[..first_output_pos].iter().collect();
497    let prefix = if base.as_os_str().is_empty() {
498        relative_prefix
499    } else {
500        base.join(relative_prefix)
501    };
502
503    let suffix: PathBuf = components[last_output_pos + 1..].iter().collect();
504    let file_name = suffix.file_name()?.to_str()?;
505    let stem = output_source_stem(file_name);
506    let source_dir = match suffix.parent() {
507        Some(parent) => prefix.join("src").join(parent),
508        None => prefix.join("src"),
509    };
510
511    for ext in SOURCE_EXTS {
512        let source_candidate = source_dir.join(format!("{stem}.{ext}"));
513        if let Some(&file_id) = path_to_id.get(source_candidate.as_path()) {
514            return Some(file_id);
515        }
516    }
517
518    // A package can copy a hand-written declaration file from `src/` to the
519    // output directory unchanged.
520    if is_declaration_file_name(file_name) {
521        return path_to_id
522            .get(source_dir.join(file_name).as_path())
523            .copied();
524    }
525
526    None
527}
528
529/// Declaration suffixes that build tools emit next to the output files.
530const DECLARATION_SUFFIXES: &[&str] = &[".d.ts", ".d.mts", ".d.cts"];
531
532fn declaration_stem(file_name: &str) -> Option<&str> {
533    DECLARATION_SUFFIXES
534        .iter()
535        .find_map(|suffix| file_name.strip_suffix(suffix))
536        .filter(|stem| !stem.is_empty())
537}
538
539fn is_declaration_file_name(file_name: &str) -> bool {
540    declaration_stem(file_name).is_some()
541}
542
543/// Return the source stem of a build output file name.
544///
545/// The stem keeps every dot in the base name. `feature.port.d.ts` and
546/// `feature.port.js` both give `feature.port`. `Path::with_extension` removes
547/// only the last extension, so it keeps `.d` in a declaration file name.
548fn output_source_stem(file_name: &str) -> &str {
549    if let Some(stem) = declaration_stem(file_name) {
550        return stem;
551    }
552    match file_name.rsplit_once('.') {
553        Some((stem, _)) if !stem.is_empty() => stem,
554        _ => file_name,
555    }
556}
557
558/// Try to resolve a package `imports` entry from the nearest owning package.
559///
560/// `#...` specifiers are package-local by definition, so this fallback is only
561/// allowed when the importing file's nearest package manifest has a matching
562/// `imports` key. That keeps unrelated hash-prefixed path aliases unresolved.
563pub(super) fn try_package_imports_fallback(
564    ctx: &ResolveContext<'_>,
565    from_file: &Path,
566    specifier: &str,
567) -> Option<ResolveResult> {
568    if !specifier.starts_with('#') {
569        return None;
570    }
571    let manifest = nearest_package_manifest(ctx.package_manifests, from_file)?;
572    let imports = manifest.package_json.imports.as_ref()?;
573    let PackageMapTarget::Targets(targets) =
574        package_map_target(imports, specifier, ctx.condition_names)
575    else {
576        return None;
577    };
578    let source_subpath = package_import_source_subpath(manifest, specifier);
579    resolve_package_import_targets(ctx, manifest, &targets, source_subpath.as_deref()).map(
580        |target| match target {
581            PackageImportTarget::Internal(file_id) => match &manifest.name {
582                Some(package_name) => ResolveResult::InternalPackageModule {
583                    file_id,
584                    package_name: package_name.clone(),
585                },
586                None => ResolveResult::InternalModule(file_id),
587            },
588            PackageImportTarget::ExternalPackage(package_name) => {
589                ResolveResult::NpmPackage(package_name)
590            }
591        },
592    )
593}
594
595/// Return the workspace package that a package `imports` alias reached.
596///
597/// An alias such as `"#lib/*": "@acme/lib/*"` can resolve through the install
598/// symlink straight to the workspace source file. The file edge is correct, but
599/// the dependency on `@acme/lib` must still receive usage credit, the same as a
600/// direct `@acme/lib/...` import.
601///
602/// Only the package that owns `resolved_file` can receive the credit, and only
603/// when an alias target names it. Node.js resolves a fallback array such as
604/// `["./src/x.ts", "@acme/lib/x"]` to the local file, so `@acme/lib` receives
605/// no credit for it. A target that names a package outside the known package
606/// manifests also returns `None`.
607pub(super) fn package_imports_workspace_target(
608    ctx: &ResolveContext<'_>,
609    from_file: &Path,
610    specifier: &str,
611    resolved_file: &Path,
612) -> Option<String> {
613    if !specifier.starts_with('#') {
614        return None;
615    }
616    let manifest = nearest_package_manifest(ctx.package_manifests, from_file)?;
617    let imports = manifest.package_json.imports.as_ref()?;
618    let PackageMapTarget::Targets(targets) =
619        package_map_target(imports, specifier, ctx.condition_names)
620    else {
621        return None;
622    };
623    let owner = nearest_package_manifest(ctx.package_manifests, resolved_file)?
624        .name
625        .as_deref()?;
626    targets
627        .iter()
628        .filter(|target| !target.starts_with('#'))
629        .filter_map(|target| package_import_external_target(target))
630        .find(|package_name| package_name == owner)
631}
632
633/// Return the workspace package that a direct bare import reached through its
634/// install link.
635///
636/// npm, yarn and pnpm link a workspace package into `node_modules`, so an
637/// import such as `@acme/lib/x` resolves to the source file of the package.
638/// Node.js uses the first `node_modules/@acme/lib` in the ancestor directories
639/// of the importing file. The package receives the credit only when that link
640/// points at the workspace package root and the resolved file is inside it. An
641/// import that only a tsconfig `paths` alias or a bundler alias resolves has no
642/// such link, so it keeps its plain internal edge.
643pub(super) fn installed_workspace_package_target(
644    ctx: &ResolveContext<'_>,
645    from_file: &Path,
646    specifier: &str,
647    resolved_file: &Path,
648) -> Option<String> {
649    let package_name = super::specifier::package_usage_name_for_external_bare_specifier(specifier)?;
650    let workspace_root = *ctx.workspace_roots.get(package_name.as_str())?;
651    let resolved = ctx
652        .canonicalize_cache
653        .get(resolved_file)
654        .unwrap_or_else(|| resolved_file.to_path_buf());
655    if !resolved.starts_with(workspace_root) {
656        return None;
657    }
658    let installed = from_file.ancestors().skip(1).find_map(|dir| {
659        ctx.canonicalize_cache
660            .get(&dir.join("node_modules").join(&package_name))
661    })?;
662    (installed == workspace_root).then_some(package_name)
663}
664
665/// Resolve a relative import that lands on a known package root whose built
666/// entry points are absent but whose package metadata points at source files.
667pub(super) fn try_relative_package_root_source_fallback(
668    ctx: &ResolveContext<'_>,
669    from_file: &Path,
670    specifier: &str,
671) -> Option<ResolveResult> {
672    if !specifier.starts_with("./") && !specifier.starts_with("../") {
673        return None;
674    }
675
676    let from_dir = from_file.parent()?;
677    let candidate = from_dir.join(specifier);
678    let normalized_candidate = normalize_path_lexically(&candidate);
679    #[cfg(not(miri))]
680    let canonical_candidate = ctx.canonicalize_cache.get(&candidate);
681    #[cfg(miri)]
682    let canonical_candidate: Option<PathBuf> = None;
683
684    ctx.package_manifests.iter().find_map(|manifest| {
685        let matches_manifest = candidate == manifest.root
686            || normalized_candidate == manifest.root
687            || canonical_candidate
688                .as_deref()
689                .is_some_and(|canonical| canonical == manifest.canonical_root);
690        matches_manifest
691            .then(|| try_source_subpath(ctx, manifest, Path::new("")))
692            .flatten()
693            .map(ResolveResult::InternalModule)
694    })
695}
696
697/// Resolve a relative import into a TypeScript output directory to its source.
698///
699/// A script or a test can import the emitted file (`../lib/types/a.js`) of a
700/// package whose tsconfig declares `rootDir` and `outDir`. The output is often
701/// not on disk, and `outDir` can have any name. The tsconfig files of the
702/// package that owns the target path map the output path to the source file.
703pub(super) fn try_relative_tsconfig_output_fallback(
704    ctx: &ResolveContext<'_>,
705    from_file: &Path,
706    specifier: &str,
707) -> Option<ResolveResult> {
708    if !specifier.starts_with("./") && !specifier.starts_with("../") {
709        return None;
710    }
711    let target = normalize_path_lexically(&from_file.parent()?.join(specifier));
712    let manifest = nearest_package_manifest(ctx.package_manifests, &target)?;
713    let output_map = ctx.tsconfig_cache.output_map(&manifest.root);
714    let TsconfigOutputResolution::Resolved(source) =
715        output_map.resolve_source_for_output_path(&target, SOURCE_EXTS)
716    else {
717        return None;
718    };
719    lookup_internal_file_id(ctx, &source).map(ResolveResult::InternalModule)
720}
721
722pub(super) fn normalize_path_lexically(path: &Path) -> PathBuf {
723    let mut normalized = PathBuf::new();
724    for component in path.components() {
725        match component {
726            std::path::Component::CurDir => {}
727            std::path::Component::ParentDir => {
728                if !normalized.pop() {
729                    normalized.push(component.as_os_str());
730                }
731            }
732            std::path::Component::Prefix(_)
733            | std::path::Component::RootDir
734            | std::path::Component::Normal(_) => normalized.push(component.as_os_str()),
735        }
736    }
737    normalized
738}
739
740#[derive(Debug, Clone, PartialEq, Eq)]
741enum PackageMapTarget {
742    NoMatch,
743    Blocked,
744    Targets(Vec<String>),
745}
746
747enum PackageImportTarget {
748    Internal(FileId),
749    ExternalPackage(String),
750}
751
752fn package_map_match_value(
753    value: &Value,
754    condition_names: &[String],
755    capture: Option<&str>,
756) -> PackageMapTarget {
757    resolve_package_map_value(value, condition_names, capture)
758        .filter(|targets| !targets.is_empty())
759        .map_or(PackageMapTarget::Blocked, PackageMapTarget::Targets)
760}
761
762fn package_map_target(
763    map: &Value,
764    specifier_key: &str,
765    condition_names: &[String],
766) -> PackageMapTarget {
767    let Some(obj) = map.as_object() else {
768        if specifier_key == "." {
769            return package_map_match_value(map, condition_names, None);
770        }
771        return PackageMapTarget::NoMatch;
772    };
773
774    let has_subpath_keys = obj
775        .keys()
776        .any(|key| key == "." || key.starts_with("./") || key.starts_with('#'));
777    if !has_subpath_keys {
778        if specifier_key == "." {
779            return package_map_match_value(map, condition_names, None);
780        }
781        return PackageMapTarget::NoMatch;
782    }
783
784    if let Some(value) = obj.get(specifier_key) {
785        return package_map_match_value(value, condition_names, None);
786    }
787
788    let mut patterns: Vec<(&str, &Value, String)> = obj
789        .iter()
790        .filter_map(|(pattern, value)| {
791            package_map_pattern_capture(pattern, specifier_key)
792                .map(|capture| (pattern.as_str(), value, capture))
793        })
794        .collect();
795    patterns.sort_by(|(left, _, _), (right, _, _)| {
796        package_map_pattern_specificity(right).cmp(&package_map_pattern_specificity(left))
797    });
798
799    patterns
800        .first()
801        .map_or(PackageMapTarget::NoMatch, |(_, value, capture)| {
802            package_map_match_value(value, condition_names, Some(capture))
803        })
804}
805
806fn resolve_package_map_value(
807    value: &Value,
808    condition_names: &[String],
809    capture: Option<&str>,
810) -> Option<Vec<String>> {
811    match value {
812        Value::String(target) => Some(vec![match capture {
813            Some(capture) => target.replace('*', capture),
814            None => target.clone(),
815        }]),
816        Value::Object(map) => {
817            for (condition, value) in map {
818                if (condition == "default"
819                    || condition_names
820                        .iter()
821                        .any(|active_condition| active_condition == condition))
822                    && let Some(targets) =
823                        resolve_package_map_value(value, condition_names, capture)
824                {
825                    return Some(targets);
826                }
827            }
828            None
829        }
830        Value::Array(values) => {
831            let targets: Vec<String> = values
832                .iter()
833                .filter_map(|value| resolve_package_map_value(value, condition_names, capture))
834                .flatten()
835                .collect();
836            (!targets.is_empty()).then_some(targets)
837        }
838        Value::Bool(_) | Value::Null | Value::Number(_) => None,
839    }
840}
841
842fn package_map_pattern_capture(pattern: &str, specifier: &str) -> Option<String> {
843    let star = pattern.find('*')?;
844    if pattern[star + 1..].contains('*') {
845        return None;
846    }
847    let (prefix, suffix_with_star) = pattern.split_at(star);
848    let suffix = &suffix_with_star[1..];
849    let captured = specifier.strip_prefix(prefix)?.strip_suffix(suffix)?;
850    Some(captured.to_string())
851}
852
853fn package_map_pattern_specificity(pattern: &str) -> (usize, usize) {
854    let star = pattern.find('*').unwrap_or(pattern.len());
855    (star, pattern.len())
856}
857
858fn package_import_source_subpath(
859    manifest: &PackageManifestInfo,
860    specifier: &str,
861) -> Option<PathBuf> {
862    let stripped = specifier.strip_prefix('#')?;
863    let without_package_name = manifest
864        .name
865        .as_deref()
866        .and_then(|name| stripped.strip_prefix(name))
867        .and_then(|rest| rest.strip_prefix('/'))
868        .unwrap_or(stripped);
869    if without_package_name.is_empty() {
870        None
871    } else {
872        Some(PathBuf::from(without_package_name))
873    }
874}
875
876pub(super) fn nearest_package_manifest<'a>(
877    manifests: &'a [PackageManifestInfo],
878    from_file: &Path,
879) -> Option<&'a PackageManifestInfo> {
880    manifests
881        .iter()
882        .filter(|manifest| {
883            from_file.starts_with(&manifest.root) || from_file.starts_with(&manifest.canonical_root)
884        })
885        .max_by_key(|manifest| manifest.root.components().count())
886}
887
888fn find_package_manifest<'a>(
889    manifests: &'a [PackageManifestInfo],
890    package_name: &str,
891) -> Option<&'a PackageManifestInfo> {
892    manifests
893        .iter()
894        .find(|manifest| manifest.name.as_deref() == Some(package_name))
895}
896
897fn resolve_package_map_target(
898    ctx: &ResolveContext<'_>,
899    manifest: &PackageManifestInfo,
900    target: &str,
901    source_subpath: Option<&Path>,
902) -> Option<FileId> {
903    let target_path = package_map_target_path(manifest, target)?;
904
905    lookup_internal_file_id(ctx, &target_path)
906        .or_else(|| try_source_fallback(&target_path, ctx.raw_path_to_id))
907        .or_else(|| try_source_fallback(&target_path, ctx.path_to_id))
908        .or_else(|| {
909            try_missing_only_source_fallback(&manifest.root, &target_path, ctx.raw_path_to_id)
910        })
911        .or_else(|| try_missing_only_source_fallback(&manifest.root, &target_path, ctx.path_to_id))
912        .or_else(|| source_subpath.and_then(|subpath| try_source_subpath(ctx, manifest, subpath)))
913}
914
915/// Join a package map target (`./src/index.ts`) to the package root. A target
916/// that leaves the package root is not a package path.
917fn package_map_target_path(manifest: &PackageManifestInfo, target: &str) -> Option<PathBuf> {
918    let target = target.strip_prefix("./")?;
919    if target.starts_with("../") || target.starts_with('/') {
920        return None;
921    }
922    Some(manifest.root.join(target))
923}
924
925fn resolve_package_map_targets(
926    ctx: &ResolveContext<'_>,
927    manifest: &PackageManifestInfo,
928    targets: &[String],
929    source_subpath: Option<&Path>,
930) -> Option<FileId> {
931    targets
932        .iter()
933        .find_map(|target| resolve_package_map_target(ctx, manifest, target, source_subpath))
934}
935
936fn resolve_package_import_targets(
937    ctx: &ResolveContext<'_>,
938    manifest: &PackageManifestInfo,
939    targets: &[String],
940    source_subpath: Option<&Path>,
941) -> Option<PackageImportTarget> {
942    targets.iter().find_map(|target| {
943        resolve_package_map_target(ctx, manifest, target, source_subpath)
944            .map(PackageImportTarget::Internal)
945            .or_else(|| {
946                package_import_external_target(target).map(PackageImportTarget::ExternalPackage)
947            })
948    })
949}
950
951fn package_import_external_target(target: &str) -> Option<String> {
952    if is_bare_specifier(target) && is_valid_package_name(target) {
953        Some(extract_package_name(target))
954    } else {
955        None
956    }
957}
958
959fn try_source_subpath(
960    ctx: &ResolveContext<'_>,
961    manifest: &PackageManifestInfo,
962    subpath: &Path,
963) -> Option<FileId> {
964    if subpath.as_os_str().is_empty()
965        && let Some(source) = manifest.package_json.source.as_deref()
966        && let Some(source_path) = safe_relative_package_source_path(source)
967        && let Some(file_id) = lookup_internal_file_id(ctx, &manifest.root.join(source_path))
968    {
969        return Some(file_id);
970    }
971
972    for ext in SOURCE_EXTS {
973        let direct = if subpath.as_os_str().is_empty() {
974            manifest.root.join("src").join(format!("index.{ext}"))
975        } else {
976            manifest.root.join("src").join(subpath).with_extension(ext)
977        };
978        if let Some(file_id) = lookup_internal_file_id(ctx, &direct) {
979            return Some(file_id);
980        }
981
982        if !subpath.as_os_str().is_empty() {
983            let index = manifest
984                .root
985                .join("src")
986                .join(subpath)
987                .join(format!("index.{ext}"));
988            if let Some(file_id) = lookup_internal_file_id(ctx, &index) {
989                return Some(file_id);
990            }
991        }
992
993        if subpath.as_os_str().is_empty() {
994            let root_index = manifest.root.join(format!("index.{ext}"));
995            if let Some(file_id) = lookup_internal_file_id(ctx, &root_index) {
996                return Some(file_id);
997            }
998        }
999    }
1000
1001    None
1002}
1003
1004fn safe_relative_package_source_path(source: &str) -> Option<&Path> {
1005    let source = source.strip_prefix("./").unwrap_or(source);
1006    let path = Path::new(source);
1007    if path.as_os_str().is_empty()
1008        || path.components().any(|component| {
1009            matches!(
1010                component,
1011                std::path::Component::ParentDir
1012                    | std::path::Component::RootDir
1013                    | std::path::Component::Prefix(_)
1014            )
1015        })
1016    {
1017        None
1018    } else {
1019        Some(path)
1020    }
1021}
1022
1023pub(super) fn lookup_internal_file_id(
1024    ctx: &ResolveContext<'_>,
1025    candidate: &Path,
1026) -> Option<FileId> {
1027    if let Some(&file_id) = ctx.raw_path_to_id.get(candidate) {
1028        return Some(file_id);
1029    }
1030    if let Some(&file_id) = ctx.path_to_id.get(candidate) {
1031        return Some(file_id);
1032    }
1033    #[cfg(not(miri))]
1034    if let Some(canonical) = ctx.canonicalize_cache.get(candidate) {
1035        if let Some(&file_id) = ctx.path_to_id.get(canonical.as_path()) {
1036            return Some(file_id);
1037        }
1038        if let Some(fallback) = ctx.canonical_fallback
1039            && let Some(file_id) = fallback.get(&canonical)
1040        {
1041            return Some(file_id);
1042        }
1043    }
1044    None
1045}
1046
1047/// Extract npm package name from a resolved path inside `node_modules`.
1048///
1049/// Given a path like `/project/node_modules/react/index.js`, returns `Some("react")`.
1050/// Given a path like `/project/node_modules/@scope/pkg/dist/index.js`, returns `Some("@scope/pkg")`.
1051/// Returns `None` if the path doesn't contain a `node_modules` segment.
1052pub fn extract_package_name_from_node_modules_path(path: &Path) -> Option<String> {
1053    let components: Vec<&str> = path
1054        .components()
1055        .filter_map(|c| match c {
1056            std::path::Component::Normal(s) => s.to_str(),
1057            _ => None,
1058        })
1059        .collect();
1060
1061    let nm_idx = components.iter().rposition(|&c| c == "node_modules")?;
1062
1063    let after = &components[nm_idx + 1..];
1064    if after.is_empty() {
1065        return None;
1066    }
1067
1068    if after[0].starts_with('@') {
1069        if after.len() >= 2 {
1070            Some(format!("{}/{}", after[0], after[1]))
1071        } else {
1072            Some(after[0].to_string())
1073        }
1074    } else {
1075        Some(after[0].to_string())
1076    }
1077}
1078
1079/// Try to map a pnpm virtual store path back to a workspace source file.
1080///
1081/// When pnpm uses injected dependencies or certain linking strategies, canonical
1082/// paths go through `.pnpm`:
1083///   `/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/index.js`
1084///
1085/// This function detects such paths, extracts the package name, checks if it
1086/// matches a workspace package, and tries to find the source file in that workspace.
1087pub(super) fn try_pnpm_workspace_fallback(
1088    path: &Path,
1089    path_to_id: &FxHashMap<&Path, FileId>,
1090    workspace_roots: &FxHashMap<&str, &Path>,
1091) -> Option<FileId> {
1092    let components: Vec<&str> = path
1093        .components()
1094        .filter_map(|c| match c {
1095            std::path::Component::Normal(s) => s.to_str(),
1096            _ => None,
1097        })
1098        .collect();
1099
1100    let pnpm_idx = components.iter().position(|&c| c == ".pnpm")?;
1101
1102    let after_pnpm = &components[pnpm_idx + 1..];
1103
1104    let inner_nm_idx = after_pnpm.iter().position(|&c| c == "node_modules")?;
1105    let after_inner_nm = &after_pnpm[inner_nm_idx + 1..];
1106
1107    if after_inner_nm.is_empty() {
1108        return None;
1109    }
1110
1111    let (pkg_name, pkg_name_components) = if after_inner_nm[0].starts_with('@') {
1112        if after_inner_nm.len() >= 2 {
1113            (format!("{}/{}", after_inner_nm[0], after_inner_nm[1]), 2)
1114        } else {
1115            return None;
1116        }
1117    } else {
1118        (after_inner_nm[0].to_string(), 1)
1119    };
1120
1121    let ws_root = workspace_roots.get(pkg_name.as_str())?;
1122
1123    let relative_parts = &after_inner_nm[pkg_name_components..];
1124    if relative_parts.is_empty() {
1125        return None;
1126    }
1127
1128    let relative_path: PathBuf = relative_parts.iter().collect();
1129
1130    let direct = ws_root.join(&relative_path);
1131    if let Some(&file_id) = path_to_id.get(direct.as_path()) {
1132        return Some(file_id);
1133    }
1134
1135    try_source_fallback(&direct, path_to_id)
1136}
1137
1138/// Try to resolve a bare specifier as a workspace package reference.
1139///
1140/// When the specifier's package name matches a workspace package, resolve the
1141/// subpath against that package's root directory directly instead of going
1142/// through `node_modules`. Covers two cases:
1143///
1144/// 1. **Self-referencing package imports**: Node.js v12+ lets a package import
1145///    itself via its own name (`import { X } from '@org/pkg/subentry'` from
1146///    inside `@org/pkg`). Angular libraries built with `ng-packagr` rely on
1147///    this to declare secondary entry points.
1148/// 2. **Cross-workspace imports without `node_modules` symlinks**: monorepos
1149///    that have not been installed yet, or bundlers that bypass `node_modules`
1150///    entirely, still need to resolve `@org/other-pkg/sub` to the sibling
1151///    workspace's source file.
1152///
1153/// Strategy: prefer a matching package `exports` target when the manifest has
1154/// one, then try the package source layout directly when no `exports` map exists,
1155/// and finally resolve the stripped subpath as a relative path from inside the
1156/// package root. The manifest branches cover source-only workspaces whose
1157/// package metadata points at missing `dist` output.
1158///
1159/// See issues #106, #641, and #725.
1160pub(super) fn try_workspace_package_fallback(
1161    ctx: &ResolveContext<'_>,
1162    specifier: &str,
1163    style_context: bool,
1164) -> Option<ResolveResult> {
1165    if !super::path_info::is_bare_specifier(specifier) {
1166        return None;
1167    }
1168    let pkg_name = super::path_info::extract_package_name(specifier);
1169
1170    let subpath = specifier
1171        .strip_prefix(pkg_name.as_str())
1172        .and_then(|s| s.strip_prefix('/'))
1173        .unwrap_or("");
1174    let source_subpath = PathBuf::from(subpath);
1175
1176    match try_manifest_workspace_resolution(ctx, &pkg_name, subpath, &source_subpath) {
1177        ManifestWorkspaceResolution::Resolved(result) => return Some(result),
1178        ManifestWorkspaceResolution::Blocked => return None,
1179        ManifestWorkspaceResolution::Continue => {}
1180    }
1181
1182    let ws_root =
1183        if let Some(manifest) = find_package_manifest(ctx.package_manifests, pkg_name.as_str()) {
1184            manifest.root.as_path()
1185        } else {
1186            *ctx.workspace_roots.get(pkg_name.as_str())?
1187        };
1188
1189    resolve_workspace_self_reference(ctx, ws_root, subpath, pkg_name, style_context)
1190}
1191
1192/// Outcome of attempting workspace resolution through a matching package
1193/// manifest's `exports` map or source layout.
1194enum ManifestWorkspaceResolution {
1195    /// A target was resolved.
1196    Resolved(ResolveResult),
1197    /// An `exports` map exists but the subpath is unmatched or null-blocked.
1198    Blocked,
1199    /// No manifest matched, or it had no usable resolution; keep trying.
1200    Continue,
1201}
1202
1203/// Try resolving via the package manifest: `exports` map first, then the source
1204/// layout for manifests without an `exports` map.
1205fn try_manifest_workspace_resolution(
1206    ctx: &ResolveContext<'_>,
1207    pkg_name: &str,
1208    subpath: &str,
1209    source_subpath: &Path,
1210) -> ManifestWorkspaceResolution {
1211    let Some(manifest) = find_package_manifest(ctx.package_manifests, pkg_name) else {
1212        return ManifestWorkspaceResolution::Continue;
1213    };
1214
1215    if let Some(exports) = manifest.package_json.exports.as_ref() {
1216        return match package_map_target(exports, &export_key(subpath), ctx.condition_names) {
1217            PackageMapTarget::Targets(targets) => {
1218                match resolve_package_map_targets(ctx, manifest, &targets, Some(source_subpath)) {
1219                    Some(file_id) => ManifestWorkspaceResolution::Resolved(
1220                        ResolveResult::InternalPackageModule {
1221                            file_id,
1222                            package_name: pkg_name.to_string(),
1223                        },
1224                    ),
1225                    None => ManifestWorkspaceResolution::Continue,
1226                }
1227            }
1228            PackageMapTarget::NoMatch | PackageMapTarget::Blocked => {
1229                ManifestWorkspaceResolution::Blocked
1230            }
1231        };
1232    }
1233
1234    if let Some(file_id) = try_source_subpath(ctx, manifest, source_subpath) {
1235        return ManifestWorkspaceResolution::Resolved(ResolveResult::InternalPackageModule {
1236            file_id,
1237            package_name: pkg_name.to_string(),
1238        });
1239    }
1240
1241    ManifestWorkspaceResolution::Continue
1242}
1243
1244/// The `exports` key that a package subpath matches: `.` for the package
1245/// root, else `./<subpath>`.
1246fn export_key(subpath: &str) -> String {
1247    if subpath.is_empty() {
1248        ".".to_string()
1249    } else {
1250        format!("./{subpath}")
1251    }
1252}
1253
1254/// Return the paths that the `exports` map of a project package names for a
1255/// bare `specifier`, when no named path exists on disk.
1256///
1257/// Generated code (`./src/generated/enums.ts`) does not exist before the
1258/// generator runs, so such an import does not resolve. The unresolved-import
1259/// check uses these paths to find out if the repository ignores the target.
1260/// The result is `None` when the specifier does not name a project package,
1261/// when the package has no `exports` map, when no `exports` key matches the
1262/// subpath, when a target leaves the package root, or when a target exists.
1263pub(super) fn missing_package_export_paths(
1264    ctx: &ResolveContext<'_>,
1265    specifier: &str,
1266) -> Option<Vec<PathBuf>> {
1267    if !is_bare_specifier(specifier) {
1268        return None;
1269    }
1270    let pkg_name = extract_package_name(specifier);
1271    let manifest = find_package_manifest(ctx.package_manifests, &pkg_name)?;
1272    let exports = manifest.package_json.exports.as_ref()?;
1273    let subpath = specifier
1274        .strip_prefix(pkg_name.as_str())
1275        .and_then(|s| s.strip_prefix('/'))
1276        .unwrap_or("");
1277    let PackageMapTarget::Targets(targets) =
1278        package_map_target(exports, &export_key(subpath), ctx.condition_names)
1279    else {
1280        return None;
1281    };
1282    let paths = targets
1283        .iter()
1284        .map(|target| package_map_target_path(manifest, target))
1285        .collect::<Option<Vec<_>>>()?;
1286    (!paths.is_empty() && paths.iter().all(|path| !path.exists())).then_some(paths)
1287}
1288
1289/// Resolve the stripped subpath as a relative import from inside the package
1290/// root, mapping the resolved path back to an internal module via the id maps
1291/// and source fallback.
1292fn resolve_workspace_self_reference(
1293    ctx: &ResolveContext<'_>,
1294    ws_root: &Path,
1295    subpath: &str,
1296    package_name: String,
1297    style_context: bool,
1298) -> Option<ResolveResult> {
1299    let rel_spec = if subpath.is_empty() {
1300        "./".to_string()
1301    } else {
1302        format!("./{subpath}")
1303    };
1304
1305    let root_file = ws_root.join("__fallow_ws_self_resolve__");
1306    let super::specifier::ResolveFileAttempt::Resolved {
1307        resolution: resolved,
1308        ..
1309    } = super::specifier::resolve_file_with_tsconfig_fallback(
1310        ctx,
1311        &root_file,
1312        &rel_spec,
1313        style_context,
1314    )
1315    else {
1316        return None;
1317    };
1318    let resolved_path = resolved.path();
1319
1320    if let Some(&file_id) = ctx.raw_path_to_id.get(resolved_path) {
1321        return Some(ResolveResult::InternalPackageModule {
1322            file_id,
1323            package_name,
1324        });
1325    }
1326    if let Some(canonical) = ctx.canonicalize_cache.get(resolved_path) {
1327        if let Some(&file_id) = ctx.path_to_id.get(canonical.as_path()) {
1328            return Some(ResolveResult::InternalPackageModule {
1329                file_id,
1330                package_name,
1331            });
1332        }
1333        if let Some(fallback) = ctx.canonical_fallback
1334            && let Some(file_id) = fallback.get(&canonical)
1335        {
1336            return Some(ResolveResult::InternalPackageModule {
1337                file_id,
1338                package_name,
1339            });
1340        }
1341        if let Some(file_id) = try_source_fallback(&canonical, ctx.path_to_id) {
1342            return Some(ResolveResult::InternalPackageModule {
1343                file_id,
1344                package_name,
1345            });
1346        }
1347    }
1348    None
1349}
1350
1351/// Convert a `DynamicImportPattern` to a glob string for file matching.
1352pub(super) fn make_glob_from_pattern(
1353    pattern: &fallow_types::extract::DynamicImportPattern,
1354) -> String {
1355    if pattern.prefix.contains('*') || pattern.prefix.contains('{') {
1356        return pattern.prefix.clone();
1357    }
1358    pattern.suffix.as_ref().map_or_else(
1359        || format!("{}*", pattern.prefix),
1360        |suffix| format!("{}*{}", pattern.prefix, suffix),
1361    )
1362}
1363
1364#[cfg(test)]
1365mod tests {
1366    use super::*;
1367    use crate::resolve::types::{CanonicalizeCache, TsconfigCache};
1368    use fallow_types::extract::ModuleLoadMechanism;
1369    use rustc_hash::FxHashSet;
1370
1371    fn with_package_map_ctx(
1372        root: PathBuf,
1373        name: Option<&str>,
1374        package_json: fallow_config::PackageJson,
1375        raw_files: &[(PathBuf, FileId)],
1376        f: impl FnOnce(&ResolveContext<'_>, &PackageManifestInfo, &Path),
1377    ) {
1378        let manifest = PackageManifestInfo {
1379            root: root.clone(),
1380            canonical_root: root,
1381            name: name.map(str::to_string),
1382            package_json,
1383            deno_import_map: Vec::new(),
1384        };
1385        let manifests = [manifest];
1386        let mut raw_path_to_id = FxHashMap::default();
1387        for (path, file_id) in raw_files {
1388            raw_path_to_id.insert(path.as_path(), *file_id);
1389        }
1390        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1391        let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
1392        let condition_names = conditions();
1393        let resolver = oxc_resolver::Resolver::new(oxc_resolver::ResolveOptions::default());
1394        let tsconfig_warned = std::sync::Mutex::new(FxHashSet::default());
1395        let tsconfig_cache = TsconfigCache::default();
1396        let canonicalize_cache = CanonicalizeCache::default();
1397        let script_resolver = crate::resolve::specifier::create_script_resolver(&resolver);
1398        let ctx = ResolveContext {
1399            resolver: &resolver,
1400            script_resolver: &script_resolver,
1401            style_resolver: &resolver,
1402            extensions: &[],
1403            path_to_id: &path_to_id,
1404            raw_path_to_id: &raw_path_to_id,
1405            workspace_roots: &workspace_roots,
1406            package_manifests: &manifests,
1407            has_deno_import_maps: false,
1408            condition_names: &condition_names,
1409            path_aliases: &[],
1410            scss_include_paths: &[],
1411            static_dir_mappings: &[],
1412            framework_static_dir_mappings: &[],
1413            root: &manifests[0].root,
1414            canonical_fallback: None,
1415            tsconfig_warned: &tsconfig_warned,
1416            tsconfig_cache: &tsconfig_cache,
1417            canonicalize_cache: &canonicalize_cache,
1418        };
1419
1420        f(&ctx, &manifests[0], &manifests[0].root);
1421    }
1422
1423    #[test]
1424    fn alias_match_remainder_exact_key() {
1425        assert_eq!(alias_match_remainder("vscode", "vscode"), Some(""));
1426        assert_eq!(alias_match_remainder("@scope/sdk", "@scope/sdk"), Some(""));
1427    }
1428
1429    #[test]
1430    fn alias_match_remainder_slash_continuation() {
1431        assert_eq!(
1432            alias_match_remainder("@scope/sdk/sub", "@scope/sdk"),
1433            Some("/sub")
1434        );
1435        assert_eq!(alias_match_remainder("@/foo", "@/"), Some("foo"));
1436        assert_eq!(
1437            alias_match_remainder("~/components/x", "~/"),
1438            Some("components/x")
1439        );
1440        assert_eq!(alias_match_remainder("$lib/util", "$lib/"), Some("util"));
1441    }
1442
1443    #[test]
1444    fn alias_match_remainder_rejects_prefix_collision() {
1445        assert_eq!(
1446            alias_match_remainder("@scope/sdk-extra", "@scope/sdk"),
1447            None
1448        );
1449        assert_eq!(
1450            alias_match_remainder("vscode-languageserver", "vscode"),
1451            None
1452        );
1453        assert_eq!(alias_match_remainder("#shared-utils", "#shared"), None);
1454    }
1455
1456    #[test]
1457    fn alias_match_remainder_non_match() {
1458        assert_eq!(alias_match_remainder("react", "vscode"), None);
1459    }
1460
1461    #[test]
1462    fn test_extract_package_name_from_node_modules_path_regular() {
1463        let path = PathBuf::from("/project/node_modules/react/index.js");
1464        assert_eq!(
1465            extract_package_name_from_node_modules_path(&path),
1466            Some("react".to_string())
1467        );
1468    }
1469
1470    #[test]
1471    fn test_extract_package_name_from_node_modules_path_scoped() {
1472        let path = PathBuf::from("/project/node_modules/@babel/core/lib/index.js");
1473        assert_eq!(
1474            extract_package_name_from_node_modules_path(&path),
1475            Some("@babel/core".to_string())
1476        );
1477    }
1478
1479    #[test]
1480    fn test_extract_package_name_from_node_modules_path_nested() {
1481        let path = PathBuf::from("/project/node_modules/pkg-a/node_modules/pkg-b/dist/index.js");
1482        assert_eq!(
1483            extract_package_name_from_node_modules_path(&path),
1484            Some("pkg-b".to_string())
1485        );
1486    }
1487
1488    #[test]
1489    fn test_extract_package_name_from_node_modules_path_deep_subpath() {
1490        let path = PathBuf::from("/project/node_modules/react-dom/cjs/react-dom.production.min.js");
1491        assert_eq!(
1492            extract_package_name_from_node_modules_path(&path),
1493            Some("react-dom".to_string())
1494        );
1495    }
1496
1497    #[test]
1498    fn test_extract_package_name_from_node_modules_path_no_node_modules() {
1499        let path = PathBuf::from("/project/src/components/Button.tsx");
1500        assert_eq!(extract_package_name_from_node_modules_path(&path), None);
1501    }
1502
1503    #[test]
1504    fn test_extract_package_name_from_node_modules_path_just_node_modules() {
1505        let path = PathBuf::from("/project/node_modules");
1506        assert_eq!(extract_package_name_from_node_modules_path(&path), None);
1507    }
1508
1509    #[test]
1510    fn test_extract_package_name_from_node_modules_path_scoped_only_scope() {
1511        let path = PathBuf::from("/project/node_modules/@scope");
1512        assert_eq!(
1513            extract_package_name_from_node_modules_path(&path),
1514            Some("@scope".to_string())
1515        );
1516    }
1517
1518    #[test]
1519    fn test_resolve_specifier_node_modules_returns_npm_package() {
1520        let path =
1521            PathBuf::from("/project/node_modules/styled-components/dist/styled-components.esm.js");
1522        assert_eq!(
1523            extract_package_name_from_node_modules_path(&path),
1524            Some("styled-components".to_string())
1525        );
1526
1527        let path = PathBuf::from("/project/node_modules/next/dist/server/next.js");
1528        assert_eq!(
1529            extract_package_name_from_node_modules_path(&path),
1530            Some("next".to_string())
1531        );
1532    }
1533
1534    #[test]
1535    fn test_try_source_fallback_dist_to_src() {
1536        let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1537        let mut path_to_id = FxHashMap::default();
1538        path_to_id.insert(src_path.as_path(), FileId(0));
1539
1540        let dist_path = PathBuf::from("/project/packages/ui/dist/utils.js");
1541        assert_eq!(
1542            try_source_fallback(&dist_path, &path_to_id),
1543            Some(FileId(0)),
1544            "dist/utils.js should fall back to src/utils.ts"
1545        );
1546    }
1547
1548    #[test]
1549    fn test_try_source_fallback_build_to_src() {
1550        let src_path = PathBuf::from("/project/packages/core/src/index.tsx");
1551        let mut path_to_id = FxHashMap::default();
1552        path_to_id.insert(src_path.as_path(), FileId(1));
1553
1554        let build_path = PathBuf::from("/project/packages/core/build/index.js");
1555        assert_eq!(
1556            try_source_fallback(&build_path, &path_to_id),
1557            Some(FileId(1)),
1558            "build/index.js should fall back to src/index.tsx"
1559        );
1560    }
1561
1562    #[test]
1563    fn test_try_source_fallback_no_match() {
1564        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
1565
1566        let dist_path = PathBuf::from("/project/packages/ui/dist/utils.js");
1567        assert_eq!(
1568            try_source_fallback(&dist_path, &path_to_id),
1569            None,
1570            "should return None when no source file exists"
1571        );
1572    }
1573
1574    #[test]
1575    fn test_try_source_fallback_non_output_dir() {
1576        let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1577        let mut path_to_id = FxHashMap::default();
1578        path_to_id.insert(src_path.as_path(), FileId(0));
1579
1580        let normal_path = PathBuf::from("/project/packages/ui/scripts/utils.js");
1581        assert_eq!(
1582            try_source_fallback(&normal_path, &path_to_id),
1583            None,
1584            "non-output directory path should not trigger fallback"
1585        );
1586    }
1587
1588    #[test]
1589    fn test_try_source_fallback_nested_path() {
1590        let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
1591        let mut path_to_id = FxHashMap::default();
1592        path_to_id.insert(src_path.as_path(), FileId(2));
1593
1594        let dist_path = PathBuf::from("/project/packages/ui/dist/components/Button.js");
1595        assert_eq!(
1596            try_source_fallback(&dist_path, &path_to_id),
1597            Some(FileId(2)),
1598            "nested dist path should fall back to nested src path"
1599        );
1600    }
1601
1602    #[test]
1603    fn test_try_source_fallback_nested_dist_esm() {
1604        let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1605        let mut path_to_id = FxHashMap::default();
1606        path_to_id.insert(src_path.as_path(), FileId(0));
1607
1608        let dist_path = PathBuf::from("/project/packages/ui/dist/esm/utils.mjs");
1609        assert_eq!(
1610            try_source_fallback(&dist_path, &path_to_id),
1611            Some(FileId(0)),
1612            "dist/esm/utils.mjs should fall back to src/utils.ts"
1613        );
1614    }
1615
1616    #[test]
1617    fn test_try_source_fallback_nested_build_cjs() {
1618        let src_path = PathBuf::from("/project/packages/core/src/index.ts");
1619        let mut path_to_id = FxHashMap::default();
1620        path_to_id.insert(src_path.as_path(), FileId(1));
1621
1622        let build_path = PathBuf::from("/project/packages/core/build/cjs/index.cjs");
1623        assert_eq!(
1624            try_source_fallback(&build_path, &path_to_id),
1625            Some(FileId(1)),
1626            "build/cjs/index.cjs should fall back to src/index.ts"
1627        );
1628    }
1629
1630    #[test]
1631    fn test_try_source_fallback_nested_dist_esm_deep_path() {
1632        let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
1633        let mut path_to_id = FxHashMap::default();
1634        path_to_id.insert(src_path.as_path(), FileId(2));
1635
1636        let dist_path = PathBuf::from("/project/packages/ui/dist/esm/components/Button.mjs");
1637        assert_eq!(
1638            try_source_fallback(&dist_path, &path_to_id),
1639            Some(FileId(2)),
1640            "dist/esm/components/Button.mjs should fall back to src/components/Button.ts"
1641        );
1642    }
1643
1644    #[test]
1645    fn test_try_source_fallback_triple_nested_output_dirs() {
1646        let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
1647        let mut path_to_id = FxHashMap::default();
1648        path_to_id.insert(src_path.as_path(), FileId(0));
1649
1650        let dist_path = PathBuf::from("/project/packages/ui/out/dist/esm/utils.mjs");
1651        assert_eq!(
1652            try_source_fallback(&dist_path, &path_to_id),
1653            Some(FileId(0)),
1654            "out/dist/esm/utils.mjs should fall back to src/utils.ts"
1655        );
1656    }
1657
1658    #[test]
1659    fn test_try_source_fallback_parent_dir_named_build() {
1660        let src_path = PathBuf::from("/home/user/build/my-project/src/utils.ts");
1661        let mut path_to_id = FxHashMap::default();
1662        path_to_id.insert(src_path.as_path(), FileId(0));
1663
1664        let dist_path = PathBuf::from("/home/user/build/my-project/dist/utils.js");
1665        assert_eq!(
1666            try_source_fallback(&dist_path, &path_to_id),
1667            Some(FileId(0)),
1668            "should resolve dist/ within project, not match parent 'build' dir"
1669        );
1670    }
1671
1672    #[test]
1673    fn package_map_exact_entry_beats_pattern_entry() {
1674        let map = serde_json::json!({
1675            "#nitro/runtime/task": "./dist/special/task.mjs",
1676            "#nitro/runtime/*": "./dist/runtime/internal/*.mjs"
1677        });
1678        assert_eq!(
1679            package_map_target(&map, "#nitro/runtime/task", &conditions()),
1680            PackageMapTarget::Targets(vec!["./dist/special/task.mjs".to_string()])
1681        );
1682    }
1683
1684    #[test]
1685    fn package_map_wildcard_substitutes_capture() {
1686        let map = serde_json::json!({
1687            "#nitro/runtime/*": "./dist/runtime/internal/*.mjs"
1688        });
1689        assert_eq!(
1690            package_map_target(&map, "#nitro/runtime/task", &conditions()),
1691            PackageMapTarget::Targets(vec!["./dist/runtime/internal/task.mjs".to_string()])
1692        );
1693    }
1694
1695    #[test]
1696    fn package_map_exact_entry_with_no_target_blocks_pattern_entry() {
1697        let map = serde_json::json!({
1698            "#nitro/runtime/task": null,
1699            "#nitro/runtime/*": "./dist/runtime/internal/*.mjs"
1700        });
1701        assert_eq!(
1702            package_map_target(&map, "#nitro/runtime/task", &conditions()),
1703            PackageMapTarget::Blocked
1704        );
1705    }
1706
1707    #[test]
1708    fn package_map_best_pattern_with_no_target_blocks_broader_pattern() {
1709        let map = serde_json::json!({
1710            "#nitro/runtime/internal/*": null,
1711            "#nitro/runtime/*": "./dist/runtime/*.mjs"
1712        });
1713        assert_eq!(
1714            package_map_target(&map, "#nitro/runtime/internal/task", &conditions()),
1715            PackageMapTarget::Blocked
1716        );
1717    }
1718
1719    #[test]
1720    fn package_map_unmatched_subpath_is_not_a_target() {
1721        let map = serde_json::json!({
1722            "./query": "./dist/query/index.js"
1723        });
1724        assert_eq!(
1725            package_map_target(&map, "./private", &conditions()),
1726            PackageMapTarget::NoMatch
1727        );
1728    }
1729
1730    #[test]
1731    fn package_map_nested_conditions_follow_manifest_order() {
1732        let map = serde_json::json!({
1733            "./query/react": {
1734                "types": "./dist/query/react/index.d.ts",
1735                "import": {
1736                    "development": "./src/query/react/index.ts",
1737                    "default": "./dist/query/react/index.js"
1738                },
1739                "default": "./dist/query/react/index.cjs"
1740            }
1741        });
1742        assert_eq!(
1743            package_map_target(&map, "./query/react", &conditions()),
1744            PackageMapTarget::Targets(vec!["./dist/query/react/index.d.ts".to_string()])
1745        );
1746    }
1747
1748    #[test]
1749    fn package_map_import_before_types_selects_runtime_branch() {
1750        let map = serde_json::json!({
1751            ".": {
1752                "import": "./dist/index.js",
1753                "types": "./dist/index.d.ts"
1754            }
1755        });
1756        assert_eq!(
1757            package_map_target(&map, ".", &conditions()),
1758            PackageMapTarget::Targets(vec!["./dist/index.js".to_string()])
1759        );
1760    }
1761
1762    #[test]
1763    fn package_map_condition_order_follows_manifest_order() {
1764        let map = serde_json::json!({
1765            ".": {
1766                "node": "./dist/node.js",
1767                "import": "./dist/index.js"
1768            }
1769        });
1770        assert_eq!(
1771            package_map_target(&map, ".", &conditions()),
1772            PackageMapTarget::Targets(vec!["./dist/node.js".to_string()])
1773        );
1774    }
1775
1776    #[test]
1777    fn package_map_arrays_preserve_fallback_order() {
1778        let map = serde_json::json!({
1779            "#array": ["./dist/missing.js", "./src/array.ts"],
1780            "#null": null,
1781            "#false": false
1782        });
1783        assert_eq!(
1784            package_map_target(&map, "#array", &conditions()),
1785            PackageMapTarget::Targets(vec![
1786                "./dist/missing.js".to_string(),
1787                "./src/array.ts".to_string()
1788            ])
1789        );
1790        assert_eq!(
1791            package_map_target(&map, "#null", &conditions()),
1792            PackageMapTarget::Blocked
1793        );
1794        assert_eq!(
1795            package_map_target(&map, "#false", &conditions()),
1796            PackageMapTarget::Blocked
1797        );
1798    }
1799
1800    #[test]
1801    fn package_map_non_relative_target_does_not_trigger_source_fallback() {
1802        with_package_map_ctx(
1803            PathBuf::from("/project"),
1804            Some("pkg"),
1805            fallow_config::PackageJson::default(),
1806            &[],
1807            |ctx, manifest, _| {
1808                assert!(resolve_package_map_target(ctx, manifest, "lodash", None).is_none());
1809                assert!(
1810                    resolve_package_map_target(ctx, manifest, "../dist/index.js", None).is_none()
1811                );
1812            },
1813        );
1814    }
1815
1816    #[test]
1817    fn package_map_targets_use_first_reachable_target() {
1818        let root = PathBuf::from("/project");
1819        let src_path = root.join("src/feature.ts");
1820        let targets = vec![
1821            "./dist/missing.js".to_string(),
1822            "./src/feature.ts".to_string(),
1823        ];
1824
1825        with_package_map_ctx(
1826            root,
1827            Some("pkg"),
1828            fallow_config::PackageJson::default(),
1829            &[(src_path, FileId(9))],
1830            |ctx, manifest, _| {
1831                assert_eq!(
1832                    resolve_package_map_targets(ctx, manifest, &targets, None),
1833                    Some(FileId(9))
1834                );
1835            },
1836        );
1837    }
1838
1839    #[test]
1840    fn package_map_lib_target_maps_to_source_when_absent() {
1841        let root = PathBuf::from("/project/packages/uses-lib");
1842        let feature = root.join("src/features/feature.ts");
1843        let index = root.join("src/index.ts");
1844
1845        with_package_map_ctx(
1846            root,
1847            Some("@repro/uses-lib"),
1848            fallow_config::PackageJson::default(),
1849            &[(feature, FileId(3)), (index, FileId(4))],
1850            |ctx, manifest, _| {
1851                assert_eq!(
1852                    resolve_package_map_target(ctx, manifest, "./lib/features/feature.mjs", None),
1853                    Some(FileId(3))
1854                );
1855                assert_eq!(
1856                    resolve_package_map_target(ctx, manifest, "./lib/index.d.ts", None),
1857                    Some(FileId(4))
1858                );
1859            },
1860        );
1861    }
1862
1863    #[test]
1864    fn package_map_tracked_lib_target_wins_over_source() {
1865        let root = PathBuf::from("/project");
1866        let lib_path = root.join("lib/index.js");
1867        let src_path = root.join("src/index.ts");
1868
1869        with_package_map_ctx(
1870            root,
1871            Some("pkg"),
1872            fallow_config::PackageJson::default(),
1873            &[(lib_path, FileId(1)), (src_path, FileId(2))],
1874            |ctx, manifest, _| {
1875                assert_eq!(
1876                    resolve_package_map_target(ctx, manifest, "./lib/index.js", None),
1877                    Some(FileId(1))
1878                );
1879            },
1880        );
1881    }
1882
1883    #[test]
1884    fn package_map_lib_mapping_ignores_lib_above_package_root() {
1885        let root = PathBuf::from("/project/packages/lib");
1886        let outer_src = PathBuf::from("/project/packages/src/index.ts");
1887
1888        with_package_map_ctx(
1889            root,
1890            Some("pkg"),
1891            fallow_config::PackageJson::default(),
1892            &[(outer_src, FileId(5))],
1893            |ctx, manifest, _| {
1894                assert_eq!(
1895                    resolve_package_map_target(ctx, manifest, "./index.js", None),
1896                    None
1897                );
1898            },
1899        );
1900    }
1901
1902    #[test]
1903    fn generic_source_fallback_does_not_map_lib() {
1904        let src_path = PathBuf::from("/project/src/x.ts");
1905        let mut path_to_id = FxHashMap::default();
1906        path_to_id.insert(src_path.as_path(), FileId(0));
1907
1908        assert_eq!(
1909            try_source_fallback(Path::new("/project/lib/x.js"), &path_to_id),
1910            None
1911        );
1912    }
1913
1914    #[test]
1915    fn package_map_dist_lib_target_prefers_output_dirs_mapping() {
1916        let root = PathBuf::from("/project");
1917        let nested = root.join("src/lib/x.ts");
1918        let flat = root.join("src/x.ts");
1919
1920        with_package_map_ctx(
1921            root,
1922            Some("pkg"),
1923            fallow_config::PackageJson::default(),
1924            &[(nested, FileId(6)), (flat, FileId(7))],
1925            |ctx, manifest, _| {
1926                assert_eq!(
1927                    resolve_package_map_target(ctx, manifest, "./dist/lib/x.js", None),
1928                    Some(FileId(6))
1929                );
1930            },
1931        );
1932    }
1933
1934    #[test]
1935    fn package_imports_fallback_supports_external_package_targets() {
1936        let root = PathBuf::from("/project");
1937        with_package_map_ctx(
1938            root,
1939            Some("pkg"),
1940            fallow_config::PackageJson {
1941                imports: Some(serde_json::json!({
1942                    "#pad": "left-pad",
1943                    "#scoped": "@scope/pkg/subpath"
1944                })),
1945                ..Default::default()
1946            },
1947            &[],
1948            |ctx, _, root| {
1949                let pad = try_package_imports_fallback(ctx, &root.join("src/index.ts"), "#pad");
1950                assert!(matches!(pad, Some(ResolveResult::NpmPackage(pkg)) if pkg == "left-pad"));
1951
1952                let scoped =
1953                    try_package_imports_fallback(ctx, &root.join("src/index.ts"), "#scoped");
1954                assert!(
1955                    matches!(scoped, Some(ResolveResult::NpmPackage(pkg)) if pkg == "@scope/pkg")
1956                );
1957            },
1958        );
1959    }
1960
1961    #[test]
1962    fn package_imports_workspace_target_names_only_known_packages() {
1963        let root = PathBuf::from("/project");
1964        with_package_map_ctx(
1965            root,
1966            Some("pkg"),
1967            fallow_config::PackageJson {
1968                imports: Some(serde_json::json!({
1969                    "#self/*": "pkg/*",
1970                    "#pad": "left-pad",
1971                    "#local/*": "./src/*.ts"
1972                })),
1973                ..Default::default()
1974            },
1975            &[],
1976            |ctx, _, root| {
1977                let from = root.join("src/index.ts");
1978                let feature = root.join("src/feature.ts");
1979                assert_eq!(
1980                    package_imports_workspace_target(ctx, &from, "#self/feature", &feature),
1981                    Some("pkg".to_string())
1982                );
1983                assert_eq!(
1984                    package_imports_workspace_target(
1985                        ctx,
1986                        &from,
1987                        "#self/feature",
1988                        Path::new("/elsewhere/feature.ts")
1989                    ),
1990                    None,
1991                    "a file outside the named package must not credit it"
1992                );
1993                assert_eq!(
1994                    package_imports_workspace_target(
1995                        ctx,
1996                        &from,
1997                        "#pad",
1998                        &root.join("node_modules/left-pad/index.js")
1999                    ),
2000                    None
2001                );
2002                assert_eq!(
2003                    package_imports_workspace_target(ctx, &from, "#local/feature", &feature),
2004                    None
2005                );
2006                assert_eq!(
2007                    package_imports_workspace_target(ctx, &from, "pkg/feature", &feature),
2008                    None
2009                );
2010            },
2011        );
2012    }
2013
2014    #[test]
2015    fn package_imports_fallback_supports_unnamed_packages() {
2016        let root = PathBuf::from("/project");
2017        let src_path = root.join("src/runtime/task.ts");
2018        with_package_map_ctx(
2019            root,
2020            None,
2021            fallow_config::PackageJson {
2022                imports: Some(serde_json::json!({
2023                    "#runtime/*": "./dist/runtime/*.mjs"
2024                })),
2025                ..Default::default()
2026            },
2027            &[(src_path, FileId(7))],
2028            |ctx, _, root| {
2029                let result =
2030                    try_package_imports_fallback(ctx, &root.join("src/index.ts"), "#runtime/task");
2031                assert!(matches!(
2032                    result,
2033                    Some(ResolveResult::InternalModule(FileId(7)))
2034                ));
2035            },
2036        );
2037    }
2038
2039    #[test]
2040    #[cfg_attr(miri, ignore)]
2041    fn relative_package_root_source_fallback_uses_package_source_entry() {
2042        let root = PathBuf::from("/project");
2043        let source_path = root.join("custom/entry.js");
2044        with_package_map_ctx(
2045            root,
2046            Some("pkg"),
2047            fallow_config::PackageJson {
2048                source: Some("custom/entry.js".to_string()),
2049                ..Default::default()
2050            },
2051            &[(source_path, FileId(11))],
2052            |ctx, _, root| {
2053                let result = try_relative_package_root_source_fallback(
2054                    ctx,
2055                    &root.join("test/shared/exports.test.js"),
2056                    "../../",
2057                );
2058                assert!(matches!(
2059                    result,
2060                    Some(ResolveResult::InternalModule(FileId(11)))
2061                ));
2062            },
2063        );
2064    }
2065
2066    #[test]
2067    fn package_source_path_accepts_relative_source_entries() {
2068        assert_eq!(
2069            safe_relative_package_source_path("src/index.js"),
2070            Some(Path::new("src/index.js"))
2071        );
2072        assert_eq!(
2073            safe_relative_package_source_path("./custom/entry.ts"),
2074            Some(Path::new("custom/entry.ts"))
2075        );
2076    }
2077
2078    #[test]
2079    fn package_source_path_rejects_unsafe_entries() {
2080        assert_eq!(safe_relative_package_source_path(""), None);
2081        assert_eq!(safe_relative_package_source_path("./"), None);
2082        assert_eq!(safe_relative_package_source_path("../src/index.js"), None);
2083        assert_eq!(safe_relative_package_source_path("src/../index.js"), None);
2084        assert_eq!(safe_relative_package_source_path("/src/index.js"), None);
2085
2086        #[cfg(windows)]
2087        assert_eq!(safe_relative_package_source_path("C:\\src\\index.js"), None);
2088    }
2089
2090    #[test]
2091    fn test_pnpm_store_path_extract_package_name() {
2092        let path =
2093            PathBuf::from("/project/node_modules/.pnpm/react@18.2.0/node_modules/react/index.js");
2094        assert_eq!(
2095            extract_package_name_from_node_modules_path(&path),
2096            Some("react".to_string())
2097        );
2098    }
2099
2100    #[test]
2101    fn test_pnpm_store_path_scoped_package() {
2102        let path = PathBuf::from(
2103            "/project/node_modules/.pnpm/@babel+core@7.24.0/node_modules/@babel/core/lib/index.js",
2104        );
2105        assert_eq!(
2106            extract_package_name_from_node_modules_path(&path),
2107            Some("@babel/core".to_string())
2108        );
2109    }
2110
2111    fn conditions() -> Vec<String> {
2112        vec![
2113            "development".to_string(),
2114            "import".to_string(),
2115            "require".to_string(),
2116            "default".to_string(),
2117            "types".to_string(),
2118            "node".to_string(),
2119        ]
2120    }
2121
2122    #[test]
2123    fn test_pnpm_store_path_with_peer_deps() {
2124        let path = PathBuf::from(
2125            "/project/node_modules/.pnpm/webpack@5.0.0_esbuild@0.19.0/node_modules/webpack/lib/index.js",
2126        );
2127        assert_eq!(
2128            extract_package_name_from_node_modules_path(&path),
2129            Some("webpack".to_string())
2130        );
2131    }
2132
2133    #[test]
2134    fn test_try_pnpm_workspace_fallback_dist_to_src() {
2135        let src_path = PathBuf::from("/project/packages/ui/src/utils.ts");
2136        let mut path_to_id = FxHashMap::default();
2137        path_to_id.insert(src_path.as_path(), FileId(0));
2138
2139        let mut workspace_roots = FxHashMap::default();
2140        let ws_root = PathBuf::from("/project/packages/ui");
2141        workspace_roots.insert("@myorg/ui", ws_root.as_path());
2142
2143        let pnpm_path = PathBuf::from(
2144            "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/utils.js",
2145        );
2146        assert_eq!(
2147            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2148            Some(FileId(0)),
2149            ".pnpm workspace path should fall back to src/utils.ts"
2150        );
2151    }
2152
2153    #[test]
2154    fn test_try_pnpm_workspace_fallback_direct_source() {
2155        let src_path = PathBuf::from("/project/packages/core/src/index.ts");
2156        let mut path_to_id = FxHashMap::default();
2157        path_to_id.insert(src_path.as_path(), FileId(1));
2158
2159        let mut workspace_roots = FxHashMap::default();
2160        let ws_root = PathBuf::from("/project/packages/core");
2161        workspace_roots.insert("@myorg/core", ws_root.as_path());
2162
2163        let pnpm_path = PathBuf::from(
2164            "/project/node_modules/.pnpm/@myorg+core@workspace/node_modules/@myorg/core/src/index.ts",
2165        );
2166        assert_eq!(
2167            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2168            Some(FileId(1)),
2169            ".pnpm workspace path with src/ should resolve directly"
2170        );
2171    }
2172
2173    #[test]
2174    fn test_try_pnpm_workspace_fallback_non_workspace_package() {
2175        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
2176
2177        let mut workspace_roots = FxHashMap::default();
2178        let ws_root = PathBuf::from("/project/packages/ui");
2179        workspace_roots.insert("@myorg/ui", ws_root.as_path());
2180
2181        let pnpm_path =
2182            PathBuf::from("/project/node_modules/.pnpm/react@18.2.0/node_modules/react/index.js");
2183        assert_eq!(
2184            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2185            None,
2186            "non-workspace package in .pnpm should return None"
2187        );
2188    }
2189
2190    #[test]
2191    fn test_try_pnpm_workspace_fallback_unscoped_package() {
2192        let src_path = PathBuf::from("/project/packages/utils/src/index.ts");
2193        let mut path_to_id = FxHashMap::default();
2194        path_to_id.insert(src_path.as_path(), FileId(2));
2195
2196        let mut workspace_roots = FxHashMap::default();
2197        let ws_root = PathBuf::from("/project/packages/utils");
2198        workspace_roots.insert("my-utils", ws_root.as_path());
2199
2200        let pnpm_path = PathBuf::from(
2201            "/project/node_modules/.pnpm/my-utils@1.0.0/node_modules/my-utils/dist/index.js",
2202        );
2203        assert_eq!(
2204            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2205            Some(FileId(2)),
2206            "unscoped workspace package in .pnpm should resolve"
2207        );
2208    }
2209
2210    #[test]
2211    fn test_try_pnpm_workspace_fallback_nested_path() {
2212        let src_path = PathBuf::from("/project/packages/ui/src/components/Button.ts");
2213        let mut path_to_id = FxHashMap::default();
2214        path_to_id.insert(src_path.as_path(), FileId(3));
2215
2216        let mut workspace_roots = FxHashMap::default();
2217        let ws_root = PathBuf::from("/project/packages/ui");
2218        workspace_roots.insert("@myorg/ui", ws_root.as_path());
2219
2220        let pnpm_path = PathBuf::from(
2221            "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/components/Button.js",
2222        );
2223        assert_eq!(
2224            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2225            Some(FileId(3)),
2226            "nested .pnpm workspace path should resolve through source fallback"
2227        );
2228    }
2229
2230    #[test]
2231    fn test_try_pnpm_workspace_fallback_no_pnpm() {
2232        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
2233        let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
2234
2235        let regular_path = PathBuf::from("/project/node_modules/react/index.js");
2236        assert_eq!(
2237            try_pnpm_workspace_fallback(&regular_path, &path_to_id, &workspace_roots),
2238            None,
2239        );
2240    }
2241
2242    #[test]
2243    fn test_try_pnpm_workspace_fallback_with_peer_deps() {
2244        let src_path = PathBuf::from("/project/packages/ui/src/index.ts");
2245        let mut path_to_id = FxHashMap::default();
2246        path_to_id.insert(src_path.as_path(), FileId(4));
2247
2248        let mut workspace_roots = FxHashMap::default();
2249        let ws_root = PathBuf::from("/project/packages/ui");
2250        workspace_roots.insert("@myorg/ui", ws_root.as_path());
2251
2252        let pnpm_path = PathBuf::from(
2253            "/project/node_modules/.pnpm/@myorg+ui@1.0.0_react@18.2.0/node_modules/@myorg/ui/dist/index.js",
2254        );
2255        assert_eq!(
2256            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2257            Some(FileId(4)),
2258            ".pnpm path with peer dep suffix should still resolve"
2259        );
2260    }
2261
2262    #[test]
2263    fn make_glob_prefix_only_no_suffix() {
2264        let pattern = fallow_types::extract::DynamicImportPattern {
2265            prefix: "./locales/".to_string(),
2266            suffix: None,
2267            span: oxc_span::Span::default(),
2268            mechanism: ModuleLoadMechanism::EsModule,
2269        };
2270        assert_eq!(make_glob_from_pattern(&pattern), "./locales/*");
2271    }
2272
2273    #[test]
2274    fn make_glob_prefix_with_suffix() {
2275        let pattern = fallow_types::extract::DynamicImportPattern {
2276            prefix: "./locales/".to_string(),
2277            suffix: Some(".json".to_string()),
2278            span: oxc_span::Span::default(),
2279            mechanism: ModuleLoadMechanism::EsModule,
2280        };
2281        assert_eq!(make_glob_from_pattern(&pattern), "./locales/*.json");
2282    }
2283
2284    #[test]
2285    fn make_glob_passthrough_star() {
2286        let pattern = fallow_types::extract::DynamicImportPattern {
2287            prefix: "./pages/**/*.tsx".to_string(),
2288            suffix: None,
2289            span: oxc_span::Span::default(),
2290            mechanism: ModuleLoadMechanism::EsModule,
2291        };
2292        assert_eq!(make_glob_from_pattern(&pattern), "./pages/**/*.tsx");
2293    }
2294
2295    #[test]
2296    fn make_glob_passthrough_brace() {
2297        let pattern = fallow_types::extract::DynamicImportPattern {
2298            prefix: "./i18n/{en,de,fr}.json".to_string(),
2299            suffix: None,
2300            span: oxc_span::Span::default(),
2301            mechanism: ModuleLoadMechanism::EsModule,
2302        };
2303        assert_eq!(make_glob_from_pattern(&pattern), "./i18n/{en,de,fr}.json");
2304    }
2305
2306    #[test]
2307    fn make_glob_empty_prefix_no_suffix() {
2308        let pattern = fallow_types::extract::DynamicImportPattern {
2309            prefix: String::new(),
2310            suffix: None,
2311            span: oxc_span::Span::default(),
2312            mechanism: ModuleLoadMechanism::EsModule,
2313        };
2314        assert_eq!(make_glob_from_pattern(&pattern), "*");
2315    }
2316
2317    #[test]
2318    fn make_glob_empty_prefix_with_suffix() {
2319        let pattern = fallow_types::extract::DynamicImportPattern {
2320            prefix: String::new(),
2321            suffix: Some(".ts".to_string()),
2322            span: oxc_span::Span::default(),
2323            mechanism: ModuleLoadMechanism::EsModule,
2324        };
2325        assert_eq!(make_glob_from_pattern(&pattern), "*.ts");
2326    }
2327
2328    #[test]
2329    fn make_glob_template_literal_prefix_only() {
2330        let pattern = fallow_types::extract::DynamicImportPattern {
2331            prefix: "./pages/".to_string(),
2332            suffix: None,
2333            span: oxc_span::Span::default(),
2334            mechanism: ModuleLoadMechanism::EsModule,
2335        };
2336        assert_eq!(make_glob_from_pattern(&pattern), "./pages/*");
2337    }
2338
2339    #[test]
2340    fn make_glob_template_literal_deep_prefix() {
2341        let pattern = fallow_types::extract::DynamicImportPattern {
2342            prefix: "./modules/".to_string(),
2343            suffix: None,
2344            span: oxc_span::Span::default(),
2345            mechanism: ModuleLoadMechanism::EsModule,
2346        };
2347        assert_eq!(make_glob_from_pattern(&pattern), "./modules/*");
2348    }
2349
2350    #[test]
2351    fn make_glob_string_concat_with_extension() {
2352        let pattern = fallow_types::extract::DynamicImportPattern {
2353            prefix: "./views/".to_string(),
2354            suffix: Some(".vue".to_string()),
2355            span: oxc_span::Span::default(),
2356            mechanism: ModuleLoadMechanism::EsModule,
2357        };
2358        assert_eq!(make_glob_from_pattern(&pattern), "./views/*.vue");
2359    }
2360
2361    #[test]
2362    fn make_glob_import_meta_glob_recursive() {
2363        let pattern = fallow_types::extract::DynamicImportPattern {
2364            prefix: "./components/**/*.vue".to_string(),
2365            suffix: None,
2366            span: oxc_span::Span::default(),
2367            mechanism: ModuleLoadMechanism::EsModule,
2368        };
2369        assert_eq!(
2370            make_glob_from_pattern(&pattern),
2371            "./components/**/*.vue",
2372            "import.meta.glob patterns with * should pass through as-is"
2373        );
2374    }
2375
2376    #[test]
2377    fn make_glob_import_meta_glob_brace_expansion() {
2378        let pattern = fallow_types::extract::DynamicImportPattern {
2379            prefix: "./plugins/{auth,analytics}.ts".to_string(),
2380            suffix: None,
2381            span: oxc_span::Span::default(),
2382            mechanism: ModuleLoadMechanism::EsModule,
2383        };
2384        assert_eq!(
2385            make_glob_from_pattern(&pattern),
2386            "./plugins/{auth,analytics}.ts",
2387            "import.meta.glob patterns with braces should pass through as-is"
2388        );
2389    }
2390
2391    #[test]
2392    fn make_glob_import_meta_glob_star_with_brace() {
2393        let pattern = fallow_types::extract::DynamicImportPattern {
2394            prefix: "./routes/**/*.{ts,tsx}".to_string(),
2395            suffix: None,
2396            span: oxc_span::Span::default(),
2397            mechanism: ModuleLoadMechanism::EsModule,
2398        };
2399        assert_eq!(
2400            make_glob_from_pattern(&pattern),
2401            "./routes/**/*.{ts,tsx}",
2402            "combined * and brace patterns should pass through"
2403        );
2404    }
2405
2406    #[test]
2407    fn make_glob_import_meta_glob_ignores_suffix_when_star_present() {
2408        let pattern = fallow_types::extract::DynamicImportPattern {
2409            prefix: "./*.ts".to_string(),
2410            suffix: Some(".extra".to_string()),
2411            span: oxc_span::Span::default(),
2412            mechanism: ModuleLoadMechanism::EsModule,
2413        };
2414        assert_eq!(
2415            make_glob_from_pattern(&pattern),
2416            "./*.ts",
2417            "when prefix has glob chars, suffix is ignored (prefix used as-is)"
2418        );
2419    }
2420
2421    #[test]
2422    fn make_glob_single_dot_prefix() {
2423        let pattern = fallow_types::extract::DynamicImportPattern {
2424            prefix: "./".to_string(),
2425            suffix: None,
2426            span: oxc_span::Span::default(),
2427            mechanism: ModuleLoadMechanism::EsModule,
2428        };
2429        assert_eq!(make_glob_from_pattern(&pattern), "./*");
2430    }
2431
2432    #[test]
2433    fn make_glob_prefix_without_trailing_slash() {
2434        let pattern = fallow_types::extract::DynamicImportPattern {
2435            prefix: "./config".to_string(),
2436            suffix: None,
2437            span: oxc_span::Span::default(),
2438            mechanism: ModuleLoadMechanism::EsModule,
2439        };
2440        assert_eq!(make_glob_from_pattern(&pattern), "./config*");
2441    }
2442
2443    #[test]
2444    fn make_glob_prefix_with_dotdot() {
2445        let pattern = fallow_types::extract::DynamicImportPattern {
2446            prefix: "../shared/".to_string(),
2447            suffix: Some(".ts".to_string()),
2448            span: oxc_span::Span::default(),
2449            mechanism: ModuleLoadMechanism::EsModule,
2450        };
2451        assert_eq!(make_glob_from_pattern(&pattern), "../shared/*.ts");
2452    }
2453
2454    #[test]
2455    fn test_extract_package_name_with_pnpm_plus_encoded_scope() {
2456        let path = PathBuf::from(
2457            "/project/node_modules/.pnpm/@mui+material@5.15.0/node_modules/@mui/material/index.js",
2458        );
2459        assert_eq!(
2460            extract_package_name_from_node_modules_path(&path),
2461            Some("@mui/material".to_string())
2462        );
2463    }
2464
2465    #[test]
2466    fn test_extract_package_name_windows_style_path() {
2467        let path = PathBuf::from("/project/node_modules/typescript/lib/tsc.js");
2468        assert_eq!(
2469            extract_package_name_from_node_modules_path(&path),
2470            Some("typescript".to_string())
2471        );
2472    }
2473
2474    #[test]
2475    fn test_try_source_fallback_out_dir() {
2476        let src_path = PathBuf::from("/project/packages/api/src/handler.ts");
2477        let mut path_to_id = FxHashMap::default();
2478        path_to_id.insert(src_path.as_path(), FileId(5));
2479
2480        let out_path = PathBuf::from("/project/packages/api/out/handler.js");
2481        assert_eq!(
2482            try_source_fallback(&out_path, &path_to_id),
2483            Some(FileId(5)),
2484            "out/handler.js should fall back to src/handler.ts"
2485        );
2486    }
2487
2488    #[test]
2489    fn test_try_source_fallback_mts_extension() {
2490        let src_path = PathBuf::from("/project/packages/lib/src/utils.mts");
2491        let mut path_to_id = FxHashMap::default();
2492        path_to_id.insert(src_path.as_path(), FileId(6));
2493
2494        let dist_path = PathBuf::from("/project/packages/lib/dist/utils.mjs");
2495        assert_eq!(
2496            try_source_fallback(&dist_path, &path_to_id),
2497            Some(FileId(6)),
2498            "dist/utils.mjs should fall back to src/utils.mts"
2499        );
2500    }
2501
2502    #[test]
2503    fn test_try_source_fallback_declaration_with_dotted_name() {
2504        let src_path = PathBuf::from("/project/packages/lib/src/x.port.ts");
2505        let mut path_to_id = FxHashMap::default();
2506        path_to_id.insert(src_path.as_path(), FileId(6));
2507
2508        let dist_path = PathBuf::from("/project/packages/lib/dist/x.port.d.ts");
2509        assert_eq!(
2510            try_source_fallback(&dist_path, &path_to_id),
2511            Some(FileId(6)),
2512            "dist/x.port.d.ts should fall back to src/x.port.ts"
2513        );
2514    }
2515
2516    #[test]
2517    fn test_try_source_fallback_module_declaration() {
2518        let src_path = PathBuf::from("/project/packages/lib/src/x.mts");
2519        let mut path_to_id = FxHashMap::default();
2520        path_to_id.insert(src_path.as_path(), FileId(6));
2521
2522        let dist_path = PathBuf::from("/project/packages/lib/dist/x.d.mts");
2523        assert_eq!(
2524            try_source_fallback(&dist_path, &path_to_id),
2525            Some(FileId(6)),
2526            "dist/x.d.mts should fall back to src/x.mts"
2527        );
2528    }
2529
2530    #[test]
2531    fn test_try_source_fallback_commonjs_declaration_in_subdir() {
2532        let src_path = PathBuf::from("/project/packages/lib/src/sub/x.cts");
2533        let mut path_to_id = FxHashMap::default();
2534        path_to_id.insert(src_path.as_path(), FileId(6));
2535
2536        let dist_path = PathBuf::from("/project/packages/lib/dist/sub/x.d.cts");
2537        assert_eq!(
2538            try_source_fallback(&dist_path, &path_to_id),
2539            Some(FileId(6)),
2540            "dist/sub/x.d.cts should fall back to src/sub/x.cts"
2541        );
2542    }
2543
2544    #[test]
2545    fn test_try_source_fallback_declaration_copied_from_src() {
2546        let src_path = PathBuf::from("/project/packages/lib/src/globals.d.ts");
2547        let mut path_to_id = FxHashMap::default();
2548        path_to_id.insert(src_path.as_path(), FileId(6));
2549
2550        let dist_path = PathBuf::from("/project/packages/lib/dist/globals.d.ts");
2551        assert_eq!(
2552            try_source_fallback(&dist_path, &path_to_id),
2553            Some(FileId(6)),
2554            "dist/globals.d.ts should fall back to a copied src/globals.d.ts"
2555        );
2556    }
2557
2558    #[test]
2559    fn test_try_source_fallback_js_with_dotted_name() {
2560        let src_path = PathBuf::from("/project/packages/lib/src/x.port.ts");
2561        let mut path_to_id = FxHashMap::default();
2562        path_to_id.insert(src_path.as_path(), FileId(6));
2563
2564        let dist_path = PathBuf::from("/project/packages/lib/dist/x.port.js");
2565        assert_eq!(
2566            try_source_fallback(&dist_path, &path_to_id),
2567            Some(FileId(6)),
2568            "dist/x.port.js should fall back to src/x.port.ts"
2569        );
2570    }
2571
2572    #[test]
2573    fn test_try_source_fallback_cts_extension() {
2574        let src_path = PathBuf::from("/project/packages/lib/src/config.cts");
2575        let mut path_to_id = FxHashMap::default();
2576        path_to_id.insert(src_path.as_path(), FileId(7));
2577
2578        let dist_path = PathBuf::from("/project/packages/lib/dist/config.cjs");
2579        assert_eq!(
2580            try_source_fallback(&dist_path, &path_to_id),
2581            Some(FileId(7)),
2582            "dist/config.cjs should fall back to src/config.cts"
2583        );
2584    }
2585
2586    #[test]
2587    fn test_try_source_fallback_jsx_extension() {
2588        let src_path = PathBuf::from("/project/packages/ui/src/App.jsx");
2589        let mut path_to_id = FxHashMap::default();
2590        path_to_id.insert(src_path.as_path(), FileId(8));
2591
2592        let build_path = PathBuf::from("/project/packages/ui/build/App.js");
2593        assert_eq!(
2594            try_source_fallback(&build_path, &path_to_id),
2595            Some(FileId(8)),
2596            "build/App.js should fall back to src/App.jsx"
2597        );
2598    }
2599
2600    #[test]
2601    fn test_try_source_fallback_no_file_stem() {
2602        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
2603        let dist_path = PathBuf::from("/project/packages/ui/dist/");
2604        assert_eq!(
2605            try_source_fallback(&dist_path, &path_to_id),
2606            None,
2607            "directory path with no file should return None"
2608        );
2609    }
2610
2611    #[test]
2612    fn test_try_source_fallback_esm_subdir() {
2613        let src_path = PathBuf::from("/project/lib/src/index.ts");
2614        let mut path_to_id = FxHashMap::default();
2615        path_to_id.insert(src_path.as_path(), FileId(10));
2616
2617        let dist_path = PathBuf::from("/project/lib/esm/index.mjs");
2618        assert_eq!(
2619            try_source_fallback(&dist_path, &path_to_id),
2620            Some(FileId(10)),
2621            "standalone esm/ directory should fall back to src/"
2622        );
2623    }
2624
2625    #[test]
2626    fn test_try_source_fallback_cjs_subdir() {
2627        let src_path = PathBuf::from("/project/lib/src/index.ts");
2628        let mut path_to_id = FxHashMap::default();
2629        path_to_id.insert(src_path.as_path(), FileId(11));
2630
2631        let cjs_path = PathBuf::from("/project/lib/cjs/index.cjs");
2632        assert_eq!(
2633            try_source_fallback(&cjs_path, &path_to_id),
2634            Some(FileId(11)),
2635            "standalone cjs/ directory should fall back to src/"
2636        );
2637    }
2638
2639    #[test]
2640    fn test_try_pnpm_workspace_fallback_empty_after_pnpm() {
2641        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
2642        let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
2643
2644        let pnpm_path = PathBuf::from("/project/node_modules/.pnpm/pkg@1.0.0/node_modules");
2645        assert_eq!(
2646            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2647            None,
2648            "path ending at node_modules with nothing after should return None"
2649        );
2650    }
2651
2652    #[test]
2653    fn test_try_pnpm_workspace_fallback_scoped_package_only_scope() {
2654        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
2655        let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
2656
2657        let pnpm_path =
2658            PathBuf::from("/project/node_modules/.pnpm/@scope+pkg@1.0.0/node_modules/@scope");
2659        assert_eq!(
2660            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2661            None,
2662            "scoped package without full name and no matching workspace should return None"
2663        );
2664    }
2665
2666    #[test]
2667    fn test_try_pnpm_workspace_fallback_no_inner_node_modules() {
2668        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
2669        let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
2670
2671        let pnpm_path = PathBuf::from("/project/node_modules/.pnpm/pkg@1.0.0/dist/index.js");
2672        assert_eq!(
2673            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2674            None,
2675            "path without inner node_modules after .pnpm should return None"
2676        );
2677    }
2678
2679    #[test]
2680    fn test_try_pnpm_workspace_fallback_package_without_relative_path() {
2681        let path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
2682        let mut workspace_roots = FxHashMap::default();
2683        let ws_root = PathBuf::from("/project/packages/ui");
2684        workspace_roots.insert("@myorg/ui", ws_root.as_path());
2685
2686        let pnpm_path =
2687            PathBuf::from("/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui");
2688        assert_eq!(
2689            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2690            None,
2691            "path ending at package name with no relative file should return None"
2692        );
2693    }
2694
2695    #[test]
2696    fn test_try_pnpm_workspace_fallback_nested_dist_esm() {
2697        let src_path = PathBuf::from("/project/packages/ui/src/Button.ts");
2698        let mut path_to_id = FxHashMap::default();
2699        path_to_id.insert(src_path.as_path(), FileId(10));
2700
2701        let mut workspace_roots = FxHashMap::default();
2702        let ws_root = PathBuf::from("/project/packages/ui");
2703        workspace_roots.insert("@myorg/ui", ws_root.as_path());
2704
2705        let pnpm_path = PathBuf::from(
2706            "/project/node_modules/.pnpm/@myorg+ui@1.0.0/node_modules/@myorg/ui/dist/esm/Button.mjs",
2707        );
2708        assert_eq!(
2709            try_pnpm_workspace_fallback(&pnpm_path, &path_to_id, &workspace_roots),
2710            Some(FileId(10)),
2711            "pnpm path with nested dist/esm should resolve through source fallback"
2712        );
2713    }
2714
2715    // --- package_map_target: non-object map branches (lines 583-586) ---
2716
2717    #[test]
2718    fn package_map_target_string_value_dot_key() {
2719        // A non-object top-level map with specifier_key "." delegates to
2720        // package_map_match_value immediately.
2721        let map = serde_json::Value::String("./src/index.ts".to_string());
2722        let conds = conditions();
2723        // A string value resolves to Targets.
2724        let result = package_map_target(&map, ".", &conds);
2725        assert!(
2726            matches!(result, PackageMapTarget::Targets(_)),
2727            "string map with '.' key should return Targets"
2728        );
2729    }
2730
2731    #[test]
2732    fn package_map_target_string_value_non_dot_key_no_match() {
2733        // A non-object top-level map with a non-"." specifier returns NoMatch.
2734        let map = serde_json::Value::String("./src/index.ts".to_string());
2735        let conds = conditions();
2736        let result = package_map_target(&map, "./sub", &conds);
2737        assert!(
2738            matches!(result, PackageMapTarget::NoMatch),
2739            "string map with non-dot key should return NoMatch"
2740        );
2741    }
2742
2743    #[test]
2744    fn package_map_target_null_value_dot_key() {
2745        // A null top-level map with "." returns Blocked (null means blocked).
2746        let map = serde_json::Value::Null;
2747        let conds = conditions();
2748        let result = package_map_target(&map, ".", &conds);
2749        assert!(
2750            matches!(result, PackageMapTarget::Blocked),
2751            "null map with '.' key should return Blocked"
2752        );
2753    }
2754
2755    #[test]
2756    fn package_map_target_bool_value_non_dot_key() {
2757        // A bool top-level map with non-dot key is not an object, returns NoMatch.
2758        let map = serde_json::Value::Bool(true);
2759        let conds = conditions();
2760        let result = package_map_target(&map, "./sub", &conds);
2761        assert!(
2762            matches!(result, PackageMapTarget::NoMatch),
2763            "bool map with non-dot key should return NoMatch"
2764        );
2765    }
2766
2767    // --- package_map_target: condition-only object map (lines 592-596) ---
2768
2769    #[test]
2770    fn package_map_target_condition_only_object_dot_key() {
2771        // An object whose keys are all conditions (not "." or "./...") is treated
2772        // as a condition map when specifier_key is ".".
2773        let map = serde_json::json!({
2774            "import": "./src/index.mjs",
2775            "require": "./src/index.cjs"
2776        });
2777        let conds = conditions();
2778        let result = package_map_target(&map, ".", &conds);
2779        assert!(
2780            matches!(result, PackageMapTarget::Targets(_)),
2781            "condition-only object with '.' key should return Targets"
2782        );
2783    }
2784
2785    #[test]
2786    fn package_map_target_condition_only_object_non_dot_key() {
2787        // Same object, but specifier_key != "." returns NoMatch because no
2788        // subpath key like "./foo" exists.
2789        let map = serde_json::json!({
2790            "import": "./src/index.mjs",
2791            "require": "./src/index.cjs"
2792        });
2793        let conds = conditions();
2794        let result = package_map_target(&map, "./nonexistent", &conds);
2795        assert!(
2796            matches!(result, PackageMapTarget::NoMatch),
2797            "condition-only object with non-dot key should return NoMatch"
2798        );
2799    }
2800
2801    // --- resolve_package_map_value: unmatched conditions, bool, null (lines 641-654) ---
2802
2803    #[test]
2804    fn resolve_package_map_value_unmatched_conditions_returns_none() {
2805        // Object whose only key is not in the active condition set returns None.
2806        let value = serde_json::json!({ "browser": "./src/browser.js" });
2807        let conds = conditions(); // does not include "browser"
2808        assert_eq!(
2809            resolve_package_map_value(&value, &conds, None),
2810            None,
2811            "unmatched condition should return None"
2812        );
2813    }
2814
2815    #[test]
2816    fn resolve_package_map_value_bool_returns_none() {
2817        let value = serde_json::Value::Bool(false);
2818        let conds = conditions();
2819        assert_eq!(
2820            resolve_package_map_value(&value, &conds, None),
2821            None,
2822            "bool value should return None"
2823        );
2824    }
2825
2826    #[test]
2827    fn resolve_package_map_value_number_returns_none() {
2828        let value = serde_json::Value::Number(42.into());
2829        let conds = conditions();
2830        assert_eq!(
2831            resolve_package_map_value(&value, &conds, None),
2832            None,
2833            "number value should return None"
2834        );
2835    }
2836
2837    #[test]
2838    fn resolve_package_map_value_null_returns_none() {
2839        let value = serde_json::Value::Null;
2840        let conds = conditions();
2841        assert_eq!(
2842            resolve_package_map_value(&value, &conds, None),
2843            None,
2844            "null value should return None"
2845        );
2846    }
2847
2848    #[test]
2849    fn resolve_package_map_value_array_all_null_returns_none() {
2850        // Array where every element resolves to None yields None.
2851        let value = serde_json::json!([null, false, 42]);
2852        let conds = conditions();
2853        assert_eq!(
2854            resolve_package_map_value(&value, &conds, None),
2855            None,
2856            "array of unresolvable values should return None"
2857        );
2858    }
2859
2860    #[test]
2861    fn resolve_package_map_value_array_with_valid_entry() {
2862        // Array where one element is a valid string target.
2863        let value = serde_json::json!([null, "./src/index.ts"]);
2864        let conds = conditions();
2865        let result = resolve_package_map_value(&value, &conds, None);
2866        assert_eq!(
2867            result,
2868            Some(vec!["./src/index.ts".to_string()]),
2869            "array with a valid string entry should return that entry"
2870        );
2871    }
2872
2873    // --- package_map_pattern_capture: two-star and no-star branches (lines 659-665) ---
2874
2875    #[test]
2876    fn package_map_pattern_capture_no_star_returns_none() {
2877        // A pattern without '*' returns None (no star found).
2878        assert_eq!(
2879            package_map_pattern_capture("./exact", "./exact"),
2880            None,
2881            "pattern with no star should return None"
2882        );
2883    }
2884
2885    #[test]
2886    fn package_map_pattern_capture_two_stars_returns_none() {
2887        // A pattern with more than one '*' returns None.
2888        assert_eq!(
2889            package_map_pattern_capture("./*/*.js", "./foo/bar.js"),
2890            None,
2891            "pattern with two stars should return None"
2892        );
2893    }
2894
2895    #[test]
2896    fn package_map_pattern_capture_single_star_captures() {
2897        // Sanity: the happy path still works after the guard checks.
2898        assert_eq!(
2899            package_map_pattern_capture("./dist/*/index.js", "./dist/utils/index.js"),
2900            Some("utils".to_string()),
2901        );
2902    }
2903
2904    #[test]
2905    fn package_map_pattern_capture_no_prefix_match_returns_none() {
2906        // Specifier does not start with the pattern prefix.
2907        assert_eq!(
2908            package_map_pattern_capture("./lib/*.js", "./src/foo.js"),
2909            None,
2910        );
2911    }
2912
2913    // --- resolve_package_map_target: parent-dir and root-absolute guard (lines 719-721) ---
2914
2915    #[test]
2916    fn resolve_package_map_target_no_dot_slash_prefix_returns_none() {
2917        // A target that does not start with "./" is rejected by strip_prefix.
2918        let root = PathBuf::from("/project/packages/ui");
2919        let pj = fallow_config::PackageJson::default();
2920        with_package_map_ctx(root, Some("@myorg/ui"), pj, &[], |ctx, manifest, _root| {
2921            let result = resolve_package_map_target(ctx, manifest, "src/index.ts", None);
2922            assert_eq!(result, None, "target without './' should return None");
2923        });
2924    }
2925
2926    #[test]
2927    fn resolve_package_map_target_parent_dir_returns_none() {
2928        // A target starting with "../" is rejected as a path escape.
2929        let root = PathBuf::from("/project/packages/ui");
2930        let pj = fallow_config::PackageJson::default();
2931        with_package_map_ctx(root, Some("@myorg/ui"), pj, &[], |ctx, manifest, _root| {
2932            let result = resolve_package_map_target(ctx, manifest, "./../outside/file.ts", None);
2933            assert_eq!(result, None, "parent-dir target should return None");
2934        });
2935    }
2936
2937    #[test]
2938    fn resolve_package_map_target_absolute_path_returns_none() {
2939        // A target starting with "/" after stripping "./" prefix is rejected.
2940        let root = PathBuf::from("/project/packages/ui");
2941        let pj = fallow_config::PackageJson::default();
2942        with_package_map_ctx(root, Some("@myorg/ui"), pj, &[], |ctx, manifest, _root| {
2943            // "./" + "/" -> "/" after strip_prefix("./") which is no-op, but
2944            // an absolute path disguised as ".//abs" yields "/" start after strip.
2945            let result = resolve_package_map_target(ctx, manifest, ".//abs/path.ts", None);
2946            assert_eq!(result, None, "absolute target should return None");
2947        });
2948    }
2949
2950    #[test]
2951    fn resolve_package_map_target_valid_target_hits_raw_path_map() {
2952        // A valid "./" target resolves when the path is in raw_path_to_id.
2953        let root = PathBuf::from("/project/packages/ui");
2954        let src = root.join("src/index.ts");
2955        let pj = fallow_config::PackageJson::default();
2956        with_package_map_ctx(
2957            root,
2958            Some("@myorg/ui"),
2959            pj,
2960            &[(src, FileId(5))],
2961            |ctx, manifest, _root| {
2962                let result = resolve_package_map_target(ctx, manifest, "./src/index.ts", None);
2963                assert_eq!(
2964                    result,
2965                    Some(FileId(5)),
2966                    "valid target in raw_path_to_id should resolve"
2967                );
2968            },
2969        );
2970    }
2971
2972    // --- package_import_source_subpath: variants (lines 673-689) ---
2973
2974    #[test]
2975    fn package_import_source_subpath_strips_hash_and_package_name() {
2976        let manifest = PackageManifestInfo {
2977            root: PathBuf::from("/project"),
2978            canonical_root: PathBuf::from("/project"),
2979            name: Some("my-pkg".to_string()),
2980            package_json: fallow_config::PackageJson::default(),
2981            deno_import_map: Vec::new(),
2982        };
2983        let result = package_import_source_subpath(&manifest, "#my-pkg/utils");
2984        assert_eq!(
2985            result,
2986            Some(PathBuf::from("utils")),
2987            "should strip '#', package name, and '/' separator"
2988        );
2989    }
2990
2991    #[test]
2992    fn package_import_source_subpath_no_package_name_match_keeps_full_subpath() {
2993        // When the specifier after '#' does not start with the package name,
2994        // the full stripped specifier is returned.
2995        let manifest = PackageManifestInfo {
2996            root: PathBuf::from("/project"),
2997            canonical_root: PathBuf::from("/project"),
2998            name: Some("my-pkg".to_string()),
2999            package_json: fallow_config::PackageJson::default(),
3000            deno_import_map: Vec::new(),
3001        };
3002        let result = package_import_source_subpath(&manifest, "#utils");
3003        assert_eq!(
3004            result,
3005            Some(PathBuf::from("utils")),
3006            "without package-name prefix the full subpath should be kept"
3007        );
3008    }
3009
3010    #[test]
3011    fn package_import_source_subpath_empty_hash_returns_none() {
3012        // "#" with nothing after returns None because stripped is empty and
3013        // the empty string is rejected by the is_empty guard.
3014        let manifest = PackageManifestInfo {
3015            root: PathBuf::from("/project"),
3016            canonical_root: PathBuf::from("/project"),
3017            name: Some("my-pkg".to_string()),
3018            package_json: fallow_config::PackageJson::default(),
3019            deno_import_map: Vec::new(),
3020        };
3021        // "#" strips to "", which is_empty is true, so returns None.
3022        let result = package_import_source_subpath(&manifest, "#");
3023        assert_eq!(
3024            result, None,
3025            "specifier '#' with empty body should return None"
3026        );
3027    }
3028
3029    #[test]
3030    fn package_import_source_subpath_no_hash_returns_none() {
3031        // A specifier not starting with '#' returns None.
3032        let manifest = PackageManifestInfo {
3033            root: PathBuf::from("/project"),
3034            canonical_root: PathBuf::from("/project"),
3035            name: Some("my-pkg".to_string()),
3036            package_json: fallow_config::PackageJson::default(),
3037            deno_import_map: Vec::new(),
3038        };
3039        let result = package_import_source_subpath(&manifest, "no-hash");
3040        assert_eq!(result, None, "specifier without '#' should return None");
3041    }
3042
3043    #[test]
3044    fn package_import_source_subpath_no_manifest_name() {
3045        // When the manifest has no name the full stripped specifier is returned.
3046        let manifest = PackageManifestInfo {
3047            root: PathBuf::from("/project"),
3048            canonical_root: PathBuf::from("/project"),
3049            name: None,
3050            package_json: fallow_config::PackageJson::default(),
3051            deno_import_map: Vec::new(),
3052        };
3053        let result = package_import_source_subpath(&manifest, "#internal/helper");
3054        assert_eq!(
3055            result,
3056            Some(PathBuf::from("internal/helper")),
3057            "manifest without name should return the full stripped specifier"
3058        );
3059    }
3060
3061    // --- nearest_package_manifest: deepest selection (lines 691-701) ---
3062
3063    #[test]
3064    fn nearest_package_manifest_returns_deepest_match() {
3065        let root1 = PathBuf::from("/project");
3066        let root2 = PathBuf::from("/project/packages/ui");
3067        let m1 = PackageManifestInfo {
3068            root: root1.clone(),
3069            canonical_root: root1,
3070            name: Some("root".to_string()),
3071            package_json: fallow_config::PackageJson::default(),
3072            deno_import_map: Vec::new(),
3073        };
3074        let m2 = PackageManifestInfo {
3075            root: root2.clone(),
3076            canonical_root: root2,
3077            name: Some("@myorg/ui".to_string()),
3078            package_json: fallow_config::PackageJson::default(),
3079            deno_import_map: Vec::new(),
3080        };
3081        let manifests = [m1, m2];
3082        let from_file = Path::new("/project/packages/ui/src/index.ts");
3083        let result = nearest_package_manifest(&manifests, from_file);
3084        assert_eq!(
3085            result.and_then(|m| m.name.as_deref()),
3086            Some("@myorg/ui"),
3087            "should pick the deepest (longest path) manifest that contains the file"
3088        );
3089    }
3090
3091    #[test]
3092    fn nearest_package_manifest_no_match_returns_none() {
3093        let root = PathBuf::from("/project/packages/ui");
3094        let m = PackageManifestInfo {
3095            root: root.clone(),
3096            canonical_root: root,
3097            name: Some("@myorg/ui".to_string()),
3098            package_json: fallow_config::PackageJson::default(),
3099            deno_import_map: Vec::new(),
3100        };
3101        let manifests = [m];
3102        // File is outside the manifest root.
3103        let from_file = Path::new("/other/project/src/index.ts");
3104        let result = nearest_package_manifest(&manifests, from_file);
3105        assert!(
3106            result.is_none(),
3107            "file outside all manifest roots should return None"
3108        );
3109    }
3110
3111    // --- lookup_internal_file_id: path_to_id fallback (line 832) ---
3112
3113    #[test]
3114    fn lookup_internal_file_id_uses_path_to_id_when_raw_misses() {
3115        // When raw_path_to_id does not contain the path but path_to_id does,
3116        // lookup_internal_file_id should fall back to path_to_id.
3117        let target = PathBuf::from("/project/src/index.ts");
3118        let mut path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
3119        path_to_id.insert(target.as_path(), FileId(99));
3120        let raw_path_to_id: FxHashMap<&Path, FileId> = FxHashMap::default();
3121        let workspace_roots: FxHashMap<&str, &Path> = FxHashMap::default();
3122        let condition_names = conditions();
3123        let resolver = oxc_resolver::Resolver::new(oxc_resolver::ResolveOptions::default());
3124        let tsconfig_warned = std::sync::Mutex::new(FxHashSet::default());
3125        let tsconfig_cache = TsconfigCache::default();
3126        let canonicalize_cache = CanonicalizeCache::default();
3127        let root = PathBuf::from("/project");
3128        let script_resolver = crate::resolve::specifier::create_script_resolver(&resolver);
3129        let ctx = ResolveContext {
3130            resolver: &resolver,
3131            script_resolver: &script_resolver,
3132            style_resolver: &resolver,
3133            extensions: &[],
3134            path_to_id: &path_to_id,
3135            raw_path_to_id: &raw_path_to_id,
3136            workspace_roots: &workspace_roots,
3137            package_manifests: &[],
3138            has_deno_import_maps: false,
3139            condition_names: &condition_names,
3140            path_aliases: &[],
3141            scss_include_paths: &[],
3142            static_dir_mappings: &[],
3143            framework_static_dir_mappings: &[],
3144            root: &root,
3145            canonical_fallback: None,
3146            tsconfig_warned: &tsconfig_warned,
3147            tsconfig_cache: &tsconfig_cache,
3148            canonicalize_cache: &canonicalize_cache,
3149        };
3150        let result = lookup_internal_file_id(&ctx, &target);
3151        assert_eq!(
3152            result,
3153            Some(FileId(99)),
3154            "should fall back from raw_path_to_id to path_to_id"
3155        );
3156    }
3157
3158    // --- try_scss_partial_fallback: colon guard (line 91) ---
3159
3160    #[test]
3161    fn try_scss_partial_fallback_rejects_colon_specifier() {
3162        // A specifier containing ':' (e.g. a Sass built-in like "sass:math")
3163        // must return None immediately.
3164        let root = PathBuf::from("/project");
3165        let pj = fallow_config::PackageJson::default();
3166        with_package_map_ctx(root.clone(), None, pj, &[], |ctx, _manifest, _r| {
3167            let from_file = root.join("src/main.scss");
3168            let result = try_scss_partial_fallback(ctx, &from_file, "sass:math");
3169            assert!(
3170                result.is_none(),
3171                "specifier with ':' should short-circuit to None"
3172            );
3173        });
3174    }
3175
3176    #[test]
3177    fn try_scss_partial_fallback_rejects_already_partial_filename() {
3178        // A specifier whose filename already starts with '_' (i.e. it's already a
3179        // partial path) must return None immediately.
3180        let root = PathBuf::from("/project");
3181        let pj = fallow_config::PackageJson::default();
3182        with_package_map_ctx(root.clone(), None, pj, &[], |ctx, _manifest, _r| {
3183            let from_file = root.join("src/main.scss");
3184            let result = try_scss_partial_fallback(ctx, &from_file, "./_variables");
3185            assert!(
3186                result.is_none(),
3187                "already-partial filename should short-circuit to None"
3188            );
3189        });
3190    }
3191
3192    // --- try_workspace_package_fallback: bare-specifier guard (lines 967-968) ---
3193
3194    #[test]
3195    fn try_workspace_package_fallback_rejects_relative_specifier() {
3196        // Relative specifiers (starting with "./" or "../") are not bare specifiers
3197        // and must return None without touching any manifest.
3198        let root = PathBuf::from("/project");
3199        let pj = fallow_config::PackageJson::default();
3200        with_package_map_ctx(root, None, pj, &[], |ctx, _manifest, _r| {
3201            let result = try_workspace_package_fallback(ctx, "./local/module", false);
3202            assert!(
3203                result.is_none(),
3204                "relative specifier should return None from workspace fallback"
3205            );
3206        });
3207    }
3208
3209    #[test]
3210    fn try_workspace_package_fallback_rejects_absolute_path() {
3211        // Absolute paths are not bare specifiers either.
3212        let root = PathBuf::from("/project");
3213        let pj = fallow_config::PackageJson::default();
3214        with_package_map_ctx(root, None, pj, &[], |ctx, _manifest, _r| {
3215            let result = try_workspace_package_fallback(ctx, "/absolute/path", false);
3216            assert!(
3217                result.is_none(),
3218                "absolute path should return None from workspace fallback"
3219            );
3220        });
3221    }
3222}