feat(js): resolve TypeScript wildcard path aliases (#1544)

Extends the tsconfig path-alias resolver (#1531) with single-`*` wildcard
capture and substitution: a pattern like `@app/*` or `@*/interfaces`
captures the matched segment and substitutes it into each target in
declared order, honoring baseUrl and tsc's longest-prefix / exact-wins
specificity rules, and preserving #1531's first-existing-target-wins
fallback (no false edge when nothing resolves). Builds on the
_resolve_tsconfig_alias helper rather than reintroducing inline loops;
multi-star patterns remain out of scope.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
oleksii-tumanov
2026-06-30 09:41:50 +01:00
committed by safishamsi
parent 544f95efa6
commit 57469641cd
2 changed files with 236 additions and 25 deletions
+71 -25
View File
@@ -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]:
+165
View File
@@ -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 ──