#700: _read_tsconfig_aliases() now handles // comments, /* */ block comments, and trailing commas via a regex-based _strip_jsonc() helper. Tries plain json.loads first, falls back to stripped parse, warns to stderr on failure instead of silently returning {}. #701 Bug A: removed startswith('.') filter from Svelte regex fallback so aliased imports ($lib/, $partials/, @/) are no longer skipped. #701 Bug B: fixed synthetic node IDs — source uses _make_id(str(path)), target uses _make_id(str(normpath(parent/raw))) matching _extract_generic and _import_js conventions so build_from_json no longer drops every edge. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+83
-5
@@ -48,18 +48,59 @@ def _file_stem(path: Path) -> str:
|
||||
_TSCONFIG_ALIAS_CACHE: dict[str, dict[str, str]] = {}
|
||||
|
||||
|
||||
def _strip_jsonc(text: str) -> str:
|
||||
"""Strip // line comments, /* */ block comments, and trailing commas from JSONC.
|
||||
|
||||
Preserves string contents (including // and /* inside strings) by skipping over
|
||||
quoted spans first. Required for tsconfig.json files generated by SvelteKit,
|
||||
NestJS, Vite, T3, Astro, etc., which use JSONC by default (#700).
|
||||
"""
|
||||
# Remove block and line comments while leaving string literals untouched.
|
||||
pattern = re.compile(
|
||||
r'"(?:\\.|[^"\\])*"' # double-quoted string (with escapes)
|
||||
r"|/\*.*?\*/" # /* block comment */
|
||||
r"|//[^\n]*", # // line comment
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
def _replace(match: re.Match) -> str:
|
||||
token = match.group(0)
|
||||
if token.startswith('"'):
|
||||
return token
|
||||
return ""
|
||||
|
||||
stripped = pattern.sub(_replace, text)
|
||||
# Remove trailing commas before } or ] (allowing whitespace between).
|
||||
stripped = re.sub(r",(\s*[}\]])", r"\1", stripped)
|
||||
return stripped
|
||||
|
||||
|
||||
def _read_tsconfig_aliases(tsconfig: Path, base_dir: Path, seen: set) -> dict[str, str]:
|
||||
"""Recursively read path aliases from a tsconfig, following extends chains.
|
||||
|
||||
Child config paths override parent. Circular extends are detected via seen set.
|
||||
npm package configs (e.g. @tsconfig/svelte) are skipped since they're not on disk.
|
||||
Handles JSONC (comments + trailing commas) which is the default tsconfig format
|
||||
for SvelteKit, NestJS, Vite, T3, Astro, etc. (#700).
|
||||
"""
|
||||
if str(tsconfig) in seen:
|
||||
return {}
|
||||
seen.add(str(tsconfig))
|
||||
try:
|
||||
data = json.loads(tsconfig.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
raw = tsconfig.read_text(encoding="utf-8")
|
||||
except Exception as e:
|
||||
print(f" warning: could not read {tsconfig} ({type(e).__name__}: {e})", file=sys.stderr, flush=True)
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
try:
|
||||
data = json.loads(_strip_jsonc(raw))
|
||||
except json.JSONDecodeError as e:
|
||||
print(f" warning: failed to parse {tsconfig} as JSON/JSONC ({e.msg} at line {e.lineno} col {e.colno})", file=sys.stderr, flush=True)
|
||||
return {}
|
||||
except Exception as e:
|
||||
print(f" warning: failed to parse {tsconfig} ({type(e).__name__}: {e})", file=sys.stderr, flush=True)
|
||||
return {}
|
||||
|
||||
aliases: dict[str, str] = {}
|
||||
@@ -1708,13 +1749,50 @@ def extract_svelte(path: Path) -> dict:
|
||||
import re as _re
|
||||
src = path.read_text(encoding="utf-8", errors="replace")
|
||||
existing_ids = {n["id"] for n in result.get("nodes", [])}
|
||||
file_node_id = _make_id(path.stem, str(path))
|
||||
# Source file node ID must match the one _extract_generic creates:
|
||||
# _make_id(str(path)) - single arg, no stem prefix. Otherwise the source
|
||||
# endpoint is a phantom node and build_from_json drops the edge (#701).
|
||||
file_node_id = _make_id(str(path))
|
||||
aliases = _load_tsconfig_aliases(path.parent)
|
||||
for m in _re.finditer(r"""import\(\s*['"]([^'"]+)['"]\s*\)""", src):
|
||||
raw = m.group(1)
|
||||
if not raw.startswith("."):
|
||||
if not raw:
|
||||
continue
|
||||
node_id = _make_id(raw, str(path))
|
||||
if raw.startswith("."):
|
||||
# Relative import - resolve to full path so IDs match file node IDs.
|
||||
resolved = Path(os.path.normpath(path.parent / raw))
|
||||
# TypeScript ESM: imports written as .js but actual file is .ts/.tsx
|
||||
if resolved.suffix == ".js":
|
||||
resolved = resolved.with_suffix(".ts")
|
||||
elif resolved.suffix == ".jsx":
|
||||
resolved = resolved.with_suffix(".tsx")
|
||||
node_id = _make_id(str(resolved))
|
||||
else:
|
||||
# Check tsconfig.json path aliases (e.g. "$lib/" -> "src/lib/", "@/" -> "src/")
|
||||
# before treating as external. Mirrors _import_js logic so SvelteKit alias
|
||||
# imports resolve to the same file node IDs the extractor creates (#701).
|
||||
resolved_alias = None
|
||||
for alias_prefix, alias_base in aliases.items():
|
||||
if raw == alias_prefix or raw.startswith(alias_prefix + "/"):
|
||||
rest = raw[len(alias_prefix):].lstrip("/")
|
||||
resolved_alias = Path(os.path.normpath(Path(alias_base) / rest))
|
||||
break
|
||||
if resolved_alias is not None:
|
||||
node_id = _make_id(str(resolved_alias))
|
||||
else:
|
||||
# Bare/scoped import (node_modules) - use last segment;
|
||||
# build_from_json drops as external if no matching node exists.
|
||||
module_name = raw.split("/")[-1]
|
||||
if not module_name:
|
||||
continue
|
||||
node_id = _make_id(module_name)
|
||||
if node_id in existing_ids:
|
||||
# Edge target already a real node - just add the edge, don't add a node.
|
||||
result.setdefault("edges", []).append({
|
||||
"source": file_node_id, "target": node_id,
|
||||
"relation": "dynamic_import", "confidence": "EXTRACTED",
|
||||
"source_file": str(path),
|
||||
})
|
||||
continue
|
||||
result.setdefault("nodes", []).append({
|
||||
"id": node_id, "label": raw,
|
||||
|
||||
Reference in New Issue
Block a user