mirror of
https://github.com/safishamsi/graphify.git
synced 2026-07-13 19:07:10 +00:00
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:
committed by
safishamsi
parent
544f95efa6
commit
57469641cd
+71
-25
@@ -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]:
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user