diff --git a/graphify/extract.py b/graphify/extract.py index 7d45a632..2f4f9c1b 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -249,17 +249,18 @@ def _read_tsconfig_aliases(tsconfig: Path, base_dir: Path, seen: set) -> dict[st for alias, targets in paths.items(): if not targets: continue - alias_prefix = alias.rstrip("/*") # Keep ALL targets in declared order — tsc tries each until one resolves # on disk. Discarding the fallbacks (#1531) misresolved/dropped imports - # whose file lived at a non-first target. Empty/non-string entries skipped. - target_bases = [ - str(os.path.normpath(paths_base / t.rstrip("/*"))) + # whose file lived at a non-first target. Preserve wildcard tokens in + # both sides until the resolver substitutes the captured segment, then + # normalizes the concrete path (#927). Empty/non-string entries are skipped. + target_patterns = [ + str(paths_base / t) for t in targets if isinstance(t, str) and t ] - if target_bases: - aliases[alias_prefix] = target_bases + if target_patterns: + aliases[alias] = target_patterns return aliases @@ -268,8 +269,8 @@ def _load_tsconfig_aliases(start_dir: Path) -> dict[str, list[str]]: """Walk up from start_dir to find tsconfig.json and return compilerOptions.paths aliases. Follows extends chains so SvelteKit/Nuxt/NestJS inherited aliases are included. - Returns a dict mapping alias prefix (e.g. "@") to an ordered list of resolved - base dirs (e.g. ["src"]) — tsc tries each in declared order (#1531). + Returns a dict mapping alias patterns to ordered resolved target patterns; + wildcard tokens remain intact for substitution during resolution (#927). Result is cached by tsconfig path string. """ current = start_dir.resolve() @@ -283,26 +284,71 @@ def _load_tsconfig_aliases(start_dir: Path) -> dict[str, list[str]]: return {} -def _resolve_tsconfig_alias(raw: str, aliases: dict[str, list[str]]) -> "Path | None": - """Resolve `raw` against tsconfig path aliases. Try each target in declared - order; return the first whose candidate resolves to a real file (tsc parity). - If none exist, return the first candidate (no false edge fabricated, prior - single-target behavior preserved). Returns a Path or None if no alias matches.""" - for alias_prefix, alias_bases in aliases.items(): - if raw == alias_prefix or raw.startswith(alias_prefix + "/"): - rest = raw[len(alias_prefix):].lstrip("/") - first = None - for base in alias_bases: - cand = Path(os.path.normpath(Path(base) / rest)) - resolved = _resolve_js_import_path(cand) - if resolved.is_file(): - return resolved - if first is None: - first = cand - return first +def _match_tsconfig_alias(raw: str, pattern: str) -> "tuple[tuple[int, int], str, bool] | None": + """Return (specificity, captured text, is_wildcard) when pattern matches raw. + + Exact aliases win first. Wildcard aliases follow TypeScript's longest-prefix + rule. The final branch preserves Graphify's existing support for treating a + non-wildcard alias as a directory prefix, but only after real wildcard matches. + """ + if "*" in pattern: + if pattern.count("*") != 1: + return None + prefix, suffix = pattern.split("*", 1) + if not raw.startswith(prefix) or not raw.endswith(suffix): + return None + end = len(raw) - len(suffix) if suffix else len(raw) + if end < len(prefix): + return None + return (1, -len(prefix)), raw[len(prefix):end], True + + if raw == pattern: + return (0, -len(pattern)), "", False + + prefix = pattern.rstrip("/") + if prefix and raw.startswith(prefix + "/"): + return (2, -len(prefix)), raw[len(prefix):].lstrip("/"), False return None +def _resolve_tsconfig_alias(raw: str, aliases: dict[str, list[str]]) -> "Path | None": + """Resolve `raw` against the most specific matching tsconfig alias pattern. + + Within that pattern, try targets in declared order and return the first whose + candidate resolves to a real file. If none exist, return the first candidate + so existing phantom/external-edge behavior stays unchanged. + """ + best: "tuple[tuple[int, int], str, bool, list[str]] | None" = None + for pattern, targets in aliases.items(): + match = _match_tsconfig_alias(raw, pattern) + if match is None: + continue + specificity, captured, is_wildcard = match + if best is None or specificity < best[0]: + best = specificity, captured, is_wildcard, targets + + if best is None: + return None + + _, captured, is_wildcard, targets = best + first = None + for target in targets: + if is_wildcard: + # TypeScript substitutes only when the matched star is non-empty. + substituted = target.replace("*", captured, 1) if captured else target + cand = Path(os.path.normpath(substituted)) + else: + cand = Path(target) + if captured: + cand = Path(os.path.normpath(cand / captured)) + resolved = _resolve_js_import_path(cand) + if resolved.is_file(): + return resolved + if first is None: + first = cand + return first + + def _find_workspace_root(start_dir: Path) -> Path | None: current = start_dir.resolve() for candidate in [current, *current.parents]: diff --git a/tests/test_js_import_resolution.py b/tests/test_js_import_resolution.py index 231520e2..5872f10d 100644 --- a/tests/test_js_import_resolution.py +++ b/tests/test_js_import_resolution.py @@ -885,6 +885,171 @@ def test_tsconfig_alias_none_exist_creates_no_false_edge(tmp_path: Path): assert not _has_edge(result, "src/routes/page.ts", "src/lib/utils.ts") +# ── #927: wildcard tsconfig path patterns ──────────────────────────────────── + + +def test_tsconfig_wildcard_alias_substitutes_captured_path(tmp_path, monkeypatch): + _write( + tmp_path / "tsconfig.json", + json.dumps({ + "compilerOptions": { + "baseUrl": ".", + "paths": {"@*": ["features/*/src/"]}, + } + }), + ) + _write( + tmp_path / "features/communicate/documentv2/src/index.ts", + "export const FileChipComponent = {}\n", + ) + _write( + tmp_path / "src/routes/page.ts", + "import { FileChipComponent } from '@communicate/documentv2'\n", + ) + + monkeypatch.chdir(tmp_path) + result = extract( + [ + Path("features/communicate/documentv2/src/index.ts"), + Path("src/routes/page.ts"), + ], + cache_root=Path("."), + ) + + assert _has_edge( + result, + "src/routes/page.ts", + "features/communicate/documentv2/src/index.ts", + ) + + +def test_tsconfig_wildcard_alias_substitutes_before_suffix(tmp_path: Path): + _write( + tmp_path / "tsconfig.json", + json.dumps({ + "compilerOptions": { + "baseUrl": ".", + "paths": {"@*/interfaces": ["features/*/src/interfaces.ts"]}, + } + }), + ) + target = _write( + tmp_path / "features/communicate/src/interfaces.ts", + "export interface Message { id: string }\n", + ) + importer = _write( + tmp_path / "src/routes/page.ts", + "import type { Message } from '@communicate/interfaces'\n", + ) + + result = _extract_for([target, importer], tmp_path) + + assert _has_edge( + result, + "src/routes/page.ts", + "features/communicate/src/interfaces.ts", + ) + + +def test_tsconfig_wildcard_alias_substitutes_before_normalizing_target(tmp_path: Path): + _write( + tmp_path / "tsconfig.json", + json.dumps({ + "compilerOptions": { + "baseUrl": ".", + "paths": {"@/*": ["generated/*/../shared"]}, + } + }), + ) + target = _write( + tmp_path / "generated/feature/shared/index.ts", + "export const shared = 1\n", + ) + importer = _write( + tmp_path / "src/routes/page.ts", + "import { shared } from '@/feature/nested'\n", + ) + + result = _extract_for([target, importer], tmp_path) + + assert _has_edge( + result, + "src/routes/page.ts", + "generated/feature/shared/index.ts", + ) + + +def test_tsconfig_wildcard_alias_allows_empty_capture(tmp_path: Path): + _write( + tmp_path / "tsconfig.json", + json.dumps({ + "compilerOptions": { + "baseUrl": ".", + "paths": {"app*": ["src/config/index.ts"]}, + } + }), + ) + target = _write(tmp_path / "src/config/index.ts", "export const config = {}\n") + importer = _write( + tmp_path / "src/routes/page.ts", + "import { config } from 'app'\n", + ) + + result = _extract_for([target, importer], tmp_path) + + assert _has_edge(result, "src/routes/page.ts", "src/config/index.ts") + + +def test_tsconfig_wildcard_alias_prefers_longest_matching_prefix(tmp_path: Path): + _write( + tmp_path / "tsconfig.json", + json.dumps({ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["fallback/*"], + "@/common/integration/*": ["preferred/*"], + }, + } + }), + ) + fallback = _write( + tmp_path / "fallback/common/integration/foo.ts", + "export const Foo = 1\n", + ) + preferred = _write(tmp_path / "preferred/foo.ts", "export const Foo = 2\n") + importer = _write( + tmp_path / "src/routes/page.ts", + "import { Foo } from '@/common/integration/foo'\n", + ) + + result = _extract_for([fallback, preferred, importer], tmp_path) + + assert _has_edge(result, "src/routes/page.ts", "preferred/foo.ts") + assert not _has_edge(result, "src/routes/page.ts", "fallback/common/integration/foo.ts") + + +def test_tsconfig_exact_alias_still_resolves(tmp_path: Path): + _write( + tmp_path / "tsconfig.json", + json.dumps({ + "compilerOptions": { + "baseUrl": ".", + "paths": {"app-config": ["src/config/index.ts"]}, + } + }), + ) + target = _write(tmp_path / "src/config/index.ts", "export const config = {}\n") + importer = _write( + tmp_path / "src/routes/page.ts", + "import { config } from 'app-config'\n", + ) + + result = _extract_for([target, importer], tmp_path) + + assert _has_edge(result, "src/routes/page.ts", "src/config/index.ts") + + # ── #1529: alias/workspace import targets orphaned by the full-path migration ──