diff --git a/graphify/extract.py b/graphify/extract.py index 2e694eb..42fd78f 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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,