"""resolution — moved verbatim from graphify/extract.py.""" from __future__ import annotations from typing import Any, Callable from pathlib import Path from graphify.extractors.models import LanguageConfig, _JS_CACHE_BYPASS_SUFFIXES, _NamespaceExportFact, _StarExportFact, _SymbolAliasFact, _SymbolDeclarationFact, _SymbolExportFact, _SymbolImportFact, _SymbolResolutionFacts, _SymbolUseFact, _WORKSPACE_PACKAGE_CACHE # noqa: E402,F401 from graphify.extractors.base import ( # noqa: F401 _LANGUAGE_BUILTIN_GLOBALS, _file_stem, _make_id, _read_text, ) import hashlib import json import os import re import sys _TSCONFIG_ALIAS_CACHE: dict[str, dict[str, list[str]]] = {} _WORKSPACE_MANIFEST_NAMES = ("pnpm-workspace.yaml", "package.json") _JS_RESOLVE_EXTS = (".ts", ".tsx", ".mts", ".cts", ".svelte", ".js", ".jsx", ".mjs") _JS_INDEX_FILES = ("index.ts", "index.tsx", "index.svelte", "index.js", "index.jsx", "index.mjs") def _resolve_js_import_path(candidate: Path) -> Path: """Resolve a JS/TS/Svelte import target to a local file when it exists.""" candidate = Path(os.path.normpath(candidate)) if candidate.is_file(): return candidate # TS ESM convention: imports often spell .js/.jsx while source is .ts/.tsx. if candidate.suffix == ".js": ts_candidate = candidate.with_suffix(".ts") if ts_candidate.is_file(): return ts_candidate elif candidate.suffix == ".jsx": tsx_candidate = candidate.with_suffix(".tsx") if tsx_candidate.is_file(): return tsx_candidate # Append extensions to the full filename, which covers extensionless imports, # multi-dot helpers, and Svelte 5 rune files like Foo.svelte.ts. for ext in _JS_RESOLVE_EXTS: with_ext = candidate.parent / f"{candidate.name}{ext}" if with_ext.is_file(): return with_ext # Only fall back to directory indexes after file candidates lose. if candidate.is_dir(): for index_name in _JS_INDEX_FILES: index_candidate = candidate / index_name if index_candidate.is_file(): return index_candidate return candidate 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, list[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: 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, list[str]] = {} # `extends` may be a string or, since TypeScript 5.0, an array of paths. # For an array, parents are processed in order with later entries # overriding earlier ones; the extending config (paths below) overrides # all parents. Without the list branch, an array `extends` raised # `AttributeError: 'list' object has no attribute 'startswith'`, which # _safe_extract turned into a skip of the whole file. extends = data.get("extends") if isinstance(extends, str): extends_list = [extends] elif isinstance(extends, list): extends_list = [e for e in extends if isinstance(e, str)] else: extends_list = [] for ext in extends_list: # Skip scoped npm package configs (e.g. @tsconfig/svelte) — not on disk. if not ext or ext.startswith("@"): continue extended_path = (base_dir / ext).resolve() if not extended_path.suffix: extended_path = extended_path.with_suffix(".json") if extended_path.exists(): aliases.update(_read_tsconfig_aliases(extended_path, extended_path.parent, seen)) # tsconfig `paths` are resolved relative to `baseUrl` (itself relative to # the tsconfig's directory), not the tsconfig directory directly. Honoring # baseUrl is required for the common monorepo / NestJS layout where # baseUrl points at a subdirectory, e.g. baseUrl "./src" with # "@services/*": ["services/*"] must resolve to /src/services rather # than /services. Defaults to "." so configs without baseUrl (paths # relative to the tsconfig dir, the TS 4.1+ behavior) keep working. compiler_options = data.get("compilerOptions", {}) base_url = compiler_options.get("baseUrl") or "." paths_base = base_dir / base_url paths = compiler_options.get("paths", {}) for alias, targets in paths.items(): if not targets: continue # 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. 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_patterns: aliases[alias] = target_patterns return aliases 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 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() for candidate in [current, *current.parents]: tsconfig = candidate / "tsconfig.json" if tsconfig.exists(): key = str(tsconfig) if key not in _TSCONFIG_ALIAS_CACHE: _TSCONFIG_ALIAS_CACHE[key] = _read_tsconfig_aliases(tsconfig, candidate, seen=set()) return _TSCONFIG_ALIAS_CACHE[key] return {} 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]: if (candidate / "pnpm-workspace.yaml").exists(): return candidate package_json = candidate / "package.json" if package_json.is_file(): try: data = json.loads(package_json.read_text(encoding="utf-8")) except Exception: continue if "workspaces" in data: return candidate return None def _pnpm_workspace_globs(workspace_file: Path) -> list[str]: globs: list[str] = [] in_packages = False for raw_line in workspace_file.read_text(encoding="utf-8", errors="replace").splitlines(): line = raw_line.strip() if not line or line.startswith("#"): continue if line.startswith("packages:"): in_packages = True continue if in_packages and line.startswith("-"): value = line[1:].strip().strip("'\"") if value and not value.startswith("!"): globs.append(value) continue if in_packages and not raw_line.startswith((" ", "\t")): break return globs def _workspace_globs(root: Path) -> list[str]: pnpm_workspace = root / "pnpm-workspace.yaml" if pnpm_workspace.exists(): return _pnpm_workspace_globs(pnpm_workspace) package_json = root / "package.json" try: data = json.loads(package_json.read_text(encoding="utf-8")) except Exception: return [] workspaces = data.get("workspaces") if isinstance(workspaces, list): return [item for item in workspaces if isinstance(item, str) and not item.startswith("!")] if isinstance(workspaces, dict): packages = workspaces.get("packages") if isinstance(packages, list): return [item for item in packages if isinstance(item, str) and not item.startswith("!")] return [] def _load_workspace_packages(start_dir: Path) -> dict[str, Path]: root = _find_workspace_root(start_dir) if root is None: return {} manifest_mtimes = tuple( (name, (root / name).stat().st_mtime_ns) for name in _WORKSPACE_MANIFEST_NAMES if (root / name).is_file() ) key = str((root, manifest_mtimes)) if key in _WORKSPACE_PACKAGE_CACHE: return _WORKSPACE_PACKAGE_CACHE[key] packages: dict[str, Path] = {} for pattern in _workspace_globs(root): package_dirs: list[Path] = [root] if pattern in (".", "./") else list(root.glob(pattern)) for package_dir in package_dirs: manifest = package_dir / "package.json" if not manifest.is_file(): continue try: data = json.loads(manifest.read_text(encoding="utf-8")) except Exception: continue name = data.get("name") if isinstance(name, str) and name: packages[name] = package_dir _WORKSPACE_PACKAGE_CACHE[key] = packages return packages _EXPORT_CONDITION_PRIORITY = ( "source", "import", "module", "svelte", "types", "require", "default", ) def _resolve_export_target(value: Any) -> str | None: """Resolve an `exports` map value (string or condition object) to a relative target string, honouring _EXPORT_CONDITION_PRIORITY for objects and recursing into nested condition objects.""" if isinstance(value, str): return value if isinstance(value, dict): for cond in _EXPORT_CONDITION_PRIORITY: v = value.get(cond) if isinstance(v, str): return v if isinstance(v, dict): nested = _resolve_export_target(v) if nested: return nested return None def _contained_in_package(resolved: Path, package_dir: Path) -> bool: """Guard against `exports` targets that escape the package directory (e.g. "./evil": "../../../etc/passwd"). Only accept paths that stay within package_dir after resolution.""" try: return resolved.resolve().is_relative_to(package_dir.resolve()) except ValueError: return False def _package_entry_candidates(package_dir: Path, subpath: str) -> list[Path]: manifest = package_dir / "package.json" manifest_data: dict[str, Any] = {} try: manifest_data = json.loads(manifest.read_text(encoding="utf-8")) except Exception: pass if subpath: # Consult the package's `exports` subpath map before the bare-path # fallback (#1308): "./browser" -> conditions -> file, plus single # wildcard "./*" patterns. Targets that escape the package dir are # rejected; resolution then falls through to the bare path. exports = manifest_data.get("exports") if isinstance(exports, dict): subpath_key = "./" + subpath target = _resolve_export_target(exports.get(subpath_key)) if target: candidate = package_dir / target if _contained_in_package(candidate, package_dir): return [candidate] else: for pattern, pattern_value in exports.items(): if "*" in pattern and pattern.count("*") == 1: prefix, suffix = pattern.split("*", 1) if (subpath_key.startswith(prefix) and (not suffix or subpath_key.endswith(suffix))): matched = subpath_key[len(prefix):len(subpath_key) - len(suffix) if suffix else None] resolved = _resolve_export_target(pattern_value) if resolved and "*" in resolved: candidate = package_dir / resolved.replace("*", matched) if _contained_in_package(candidate, package_dir): return [candidate] return [package_dir / subpath] exports = manifest_data.get("exports") if isinstance(exports, str): return [package_dir / exports] if isinstance(exports, dict): dot_target = _resolve_export_target(exports.get(".")) if dot_target: return [package_dir / dot_target] candidates: list[Path] = [] for key in ("svelte", "module", "main", "types"): value = manifest_data.get(key) if isinstance(value, str): candidates.append(package_dir / value) candidates.append(package_dir / "src/index") candidates.append(package_dir / "index") return candidates def _resolve_workspace_import(raw: str, start_dir: Path) -> Path | None: packages = _load_workspace_packages(start_dir) for package_name, package_dir in packages.items(): if raw == package_name: subpath = "" elif raw.startswith(package_name + "/"): subpath = raw[len(package_name) + 1:] else: continue for candidate in _package_entry_candidates(package_dir, subpath): resolved = _resolve_js_import_path(candidate) if resolved.is_file(): return resolved return None def _resolve_js_module_path(raw: str | Path, start_dir: Path | None = None) -> Path | None: """Resolve a JS/TS module path or specifier to a local source file. With a Path argument this preserves the path-based helper API used by import-extension tests. With a string plus start_dir it resolves JS/TS module specifiers including relative paths, tsconfig aliases, and workspace packages. """ if isinstance(raw, Path): return _resolve_js_import_path(raw) if start_dir is None: return _resolve_js_import_path(Path(raw)) if raw.startswith("."): return _resolve_js_import_path(start_dir / raw) aliases = _load_tsconfig_aliases(start_dir) hit = _resolve_tsconfig_alias(raw, aliases) if hit is not None: return _resolve_js_import_path(hit) return _resolve_workspace_import(raw, start_dir) def _resolve_js_import_target(raw: str, str_path: str) -> "tuple[str, Path | None] | None": """Resolve a JS/TS import path string to (target_nid, resolved_path). Handles relative paths, tsconfig path aliases, workspace packages, and bare/scoped imports. Returns None if `raw` is empty. """ if not raw: return None resolved_path = _resolve_js_module_path(raw, Path(str_path).parent) if resolved_path is not None: return _make_id(str(resolved_path)), resolved_path module_name = raw.split("/")[-1] if not module_name: return None # Unresolved: relative/absolute, tsconfig-alias and workspace resolution have # all run and failed, so this is an external package (or a dangling local # path). Namespace the id with the "ref" prefix — the J-4 convention already # used for tsconfig `extends`/`$ref` externals — so it can NEVER collapse to # the same _make_id as a local file/symbol node. Without it, the bare # last-segment id (e.g. "tailwindcss/colors" -> "colors") collides with any # unrelated local file of that stem via build.py's pre-migration alias index, # producing a confident (EXTRACTED) cross-language phantom imports_from edge # (#1638). The ref-namespaced target has no node, so build drops it as an # external reference — the correct outcome for a third-party import. return _make_id("ref", raw), None def _resolve_c_include_path(raw: str, str_path: str) -> "Path | None": """Resolve a quoted #include path to a real file on disk. Searches relative to the including file's directory. Returns None for system headers (<...>) or paths that don't exist on disk. """ if not raw: return None candidate = (Path(str_path).parent / raw).resolve() if candidate.is_file(): return candidate return None def _resolve_lua_import_target(raw_module: str, str_path: str) -> str: """Resolve a Lua require() module name to a node id. Lua module names use dots as path separators: `require("pkg.b")` looks for `pkg/b.lua` (or `pkg/b/init.lua`) relative to a package root. We probe the importing file's directory and walk upward looking for a matching file on disk; if found, the returned id matches the file node id `_extract_generic` assigns to that file (`_make_id(str(path))`), so the edge lands on a real node. When nothing matches, fall back to `_make_id` of the full dotted module name so cross-file resolution can still complete via the symbol resolution pass instead of dropping the edge entirely (#1075). """ if not raw_module: return "" rel = raw_module.replace(".", "/") try: start_dir = Path(str_path).parent except Exception: start_dir = None if start_dir is not None: probe = start_dir # Walk up a few levels so requires from nested files still resolve when # the package root is above the importing file. for _ in range(6): for suffix in (".lua", ".luau"): cand = probe / f"{rel}{suffix}" if cand.is_file(): return _make_id(str(cand)) for suffix in (".lua", ".luau"): cand = probe / rel / f"init{suffix}" if cand.is_file(): return _make_id(str(cand)) if probe.parent == probe: break probe = probe.parent return _make_id(raw_module) _VUE_SCRIPT_RE = re.compile( r"""("'])*>)([\s\S]*?)()""", re.IGNORECASE, ) _VUE_SCRIPT_LANG_RE = re.compile( r"""\blang\s*=\s*['"]?([A-Za-z]+)['"]?""", re.IGNORECASE ) def _vue_mask_non_script(src: str) -> tuple[str, str | None]: """Blank everything outside `` close tag pos = m.end() if lang is None: lang_m = _VUE_SCRIPT_LANG_RE.search(m.group(1)) if lang_m: lang = lang_m.group(1).lower() out.append(_blank(src[pos:])) return "".join(out), lang def _source_key(source_file: str, root: Path) -> str: if not source_file: return "" source_path = Path(source_file) try: return str(source_path.resolve().relative_to(root)) except Exception: return str(source_path) def _node_disambiguation_source_key(node: dict, root: Path) -> str: source_file = str(node.get("source_file", "")) if source_file: return _source_key(source_file, root) return _source_key(str(node.get("origin_file", "")), root) def _disambiguate_colliding_node_ids( nodes: list[dict], edges: list[dict], raw_calls: list[dict], root: Path, ) -> None: """Rewrite only colliding node IDs, using source path as the disambiguator. Module anchor nodes (#1327) are exempt: ``import CoreKit`` from three files yields three ``type=module`` nodes with the same id but different source_files. Those are the *same* module, not distinct same-named symbols, so they must collapse to one shared node — disambiguating them by path would scatter a single module across N file-qualified duplicates. """ by_id: dict[str, list[dict]] = {} for node in nodes: if node.get("type") in ("module", "namespace"): continue nid = node.get("id") if isinstance(nid, str) and nid: by_id.setdefault(nid, []).append(node) remap: dict[tuple[str, str], str] = {} ambiguous_ids: set[str] = set() for old_id, group in by_id.items(): source_keys = {_node_disambiguation_source_key(node, root) for node in group} if len(group) < 2 or len(source_keys) < 2: continue ambiguous_ids.add(old_id) # Salt the colliding id with the *path* it came from. The naive salt is # ``_make_id(source_key, old_id)`` — source_key is the raw repo-relative # path. But _make_id collapses every separator, so two DISTINCT paths # whose only difference is a separator-vs-inner-punctuation swap # (``a/b/c.md`` vs ``a.b/c.md``, ``foo/bar_baz.md`` vs ``foo_bar/baz.md``) # normalize to the SAME salted id and still collide (#1522 — the residual # of #1504 the 0.9.0 full-path stem didn't reach). When that happens, # append a short stable hash of the *raw* source_key, which IS injective # over distinct paths, so the colliders separate. Computed in code from # source_file (never trusted from the LLM), so AST↔semantic parity holds. naive: dict[str, str] = {} # source_key -> _make_id(source_key, old_id) for source_key in source_keys: if source_key: naive[source_key] = _make_id(source_key, old_id) # source_keys that, after normalization, are not unique among themselves. seen: dict[str, int] = {} for nid in naive.values(): seen[nid] = seen.get(nid, 0) + 1 needs_hash = {sk for sk, nid in naive.items() if seen.get(nid, 0) > 1} for node in group: source_key = _node_disambiguation_source_key(node, root) if not source_key: continue if source_key in needs_hash: salt = hashlib.sha1(source_key.encode("utf-8")).hexdigest()[:6] new_id = _make_id(source_key, old_id, salt) else: new_id = naive.get(source_key) or _make_id(source_key, old_id) remap[(old_id, source_key)] = new_id if new_id != old_id: node["id"] = new_id if not remap: return unambiguous_remaps: dict[str, str] = {} for old_id, group in by_id.items(): if old_id in ambiguous_ids: continue candidates = { node["id"] for node in group if isinstance(node.get("id"), str) and node["id"] != old_id } if len(candidates) == 1: unambiguous_remaps[old_id] = next(iter(candidates)) # A C/ObjC/C++ `#include "foo.h"` / `#import "foo.h"` resolves to the header's # file node, but `foo.h` and its sibling `foo.c`/`foo.m`/`foo.cpp` collapse to # the same `foo` file id, so disambiguation salts them apart by path. A # cross-file import edge from a THIRD file carries neither salt's source_key, so # the (target, edge_source_key) lookup misses and the edge dangles on the now # dead `foo` id. Repoint those import edges to the HEADER variant (the include # always targeted the header), keyed by the original colliding id (#1475). _HEADER_SUFFIXES = (".h", ".hpp", ".hh", ".hxx") header_remaps: dict[str, str] = {} for old_id in ambiguous_ids: for node in by_id.get(old_id, []): sk = _node_disambiguation_source_key(node, root) if sk and Path(sk).suffix.lower() in _HEADER_SUFFIXES: new_id = remap.get((old_id, sk)) if new_id: header_remaps[old_id] = new_id break for edge in edges: edge_source_key = _source_key(str(edge.get("source_file", "")), root) source_key = (edge.get("source", ""), edge_source_key) target_key = (edge.get("target", ""), edge_source_key) if source_key in remap: edge["source"] = remap[source_key] elif edge.get("source") in unambiguous_remaps: edge["source"] = unambiguous_remaps[str(edge["source"])] # imports/imports_from always target a header file, so they must resolve to # the header variant BEFORE the same-source-file salt is considered. Keying # the import target by the importer's own source file mis-points a `.m` # importing its own `.h` back at itself (self-loop), and is wrong for any # cross-file import whose importer shares the colliding id (#1475). if (edge.get("relation") in ("imports", "imports_from") and edge.get("target") in header_remaps): edge["target"] = header_remaps[str(edge["target"])] elif target_key in remap: edge["target"] = remap[target_key] elif edge.get("target") in unambiguous_remaps: edge["target"] = unambiguous_remaps[str(edge["target"])] for raw_call in raw_calls: call_source_key = _source_key(str(raw_call.get("source_file", "")), root) caller_key = (raw_call.get("caller_nid", ""), call_source_key) if caller_key in remap: raw_call["caller_nid"] = remap[caller_key] elif raw_call.get("caller_nid") in unambiguous_remaps: raw_call["caller_nid"] = unambiguous_remaps[str(raw_call["caller_nid"])] def _is_type_like_definition(node: dict) -> bool: if node.get("type") == "namespace": return False label = str(node.get("label", "")).strip() if not label: return False if label.endswith(")") or label.startswith("."): return False if "." in label: return False return node.get("file_type") == "code" def _js_source_path(source_file: str, root: Path) -> Path | None: if not source_file: return None path = Path(source_file) if not path.is_absolute(): path = root / path try: return path.resolve() except Exception: return path def _apply_symbol_resolution_facts( paths: list[Path], nodes: list[dict], edges: list[dict], root: Path, facts: _SymbolResolutionFacts, ) -> None: """Apply language-provided import/export/use facts to graph edges.""" if not ( facts.declarations or facts.imports or facts.aliases or facts.exports or facts.star_exports or facts.namespace_exports or facts.uses or facts.module_imports ): return path_by_resolved = {path.resolve(): path for path in paths} source_file_id = {path.resolve(): _make_id(str(path)) for path in paths} symbol_nodes: dict[tuple[Path, str], str] = {} for node in nodes: source_path = _js_source_path(str(node.get("source_file", "")), root) if source_path is None: continue label = str(node.get("label", "")).strip().strip("()").lstrip(".") if label and node.get("id"): symbol_nodes[(source_path, label)] = str(node["id"]) def ensure_symbol_node(path: Path, name: str, line: int) -> str: resolved_path = path.resolve() existing = symbol_nodes.get((resolved_path, name)) if existing is not None: return existing node_id = _make_id(_file_stem(path), name) symbol_nodes[(resolved_path, name)] = node_id nodes.append({ "id": node_id, "label": name, "file_type": "code", "source_file": str(path), "source_location": f"L{line}", }) return node_id existing_edges = { ( str(edge.get("source")), str(edge.get("target")), str(edge.get("relation")), str(edge.get("context") or ""), ) for edge in edges } def add_edge(source: str, target: str, relation: str, context: str, line: int, source_path: Path) -> None: key = (source, target, relation, context or "") if key in existing_edges: return existing_edges.add(key) edges.append({ "source": source, "target": target, "relation": relation, "context": context, "confidence": "EXTRACTED", "source_file": str(source_path), "source_location": f"L{line}", "weight": 1.0, }) for declaration in facts.declarations: ensure_symbol_node(declaration.file_path, declaration.name, declaration.line) local_aliases_by_file: dict[Path, dict[str, tuple[Path, str]]] = {} for import_fact in facts.imports: file_path = import_fact.file_path.resolve() local_aliases_by_file.setdefault(file_path, {})[import_fact.local_name] = ( import_fact.target_path.resolve(), import_fact.imported_name, ) pending_aliases_by_file: dict[Path, list[_SymbolAliasFact]] = {} for alias_fact in facts.aliases: pending_aliases_by_file.setdefault(alias_fact.file_path.resolve(), []).append(alias_fact) for file_path, aliases in pending_aliases_by_file.items(): local_aliases = local_aliases_by_file.setdefault(file_path, {}) changed = True while changed: changed = False for alias_fact in aliases: if alias_fact.alias in local_aliases: continue origin = local_aliases.get(alias_fact.target_name) if origin is not None: local_aliases[alias_fact.alias] = origin changed = True named_exports_by_file: dict[Path, dict[str, tuple[Path, str]]] = {} star_exports_by_file: dict[Path, list[Path]] = {} for star_fact in facts.star_exports: source_path = star_fact.file_path.resolve() target_path = star_fact.target_path.resolve() star_exports_by_file.setdefault(source_path, []).append(target_path) source_id = source_file_id.get(source_path) if source_id is not None: add_edge( source_id, _make_id(str(path_by_resolved.get(target_path, target_path))), "re_exports", "export", star_fact.line, star_fact.file_path, ) for namespace_fact in facts.namespace_exports: source_path = namespace_fact.file_path.resolve() target_path = namespace_fact.target_path.resolve() namespace_id = ensure_symbol_node( namespace_fact.file_path, namespace_fact.exported_name, namespace_fact.line, ) named_exports_by_file.setdefault(source_path, {})[ namespace_fact.exported_name ] = (source_path, namespace_fact.exported_name) source_id = source_file_id.get(source_path) if source_id is not None: add_edge( source_id, namespace_id, "contains", "namespace_export", namespace_fact.line, namespace_fact.file_path, ) add_edge( source_id, _make_id(str(path_by_resolved.get(target_path, target_path))), "re_exports", "export", namespace_fact.line, namespace_fact.file_path, ) for export_fact in facts.exports: file_path = export_fact.file_path.resolve() origin: tuple[Path, str] | None = None if export_fact.target_path is not None and export_fact.target_name is not None: origin = (export_fact.target_path.resolve(), export_fact.target_name) elif export_fact.local_name is not None: origin = local_aliases_by_file.get(file_path, {}).get(export_fact.local_name) if origin is None and (file_path, export_fact.local_name) in symbol_nodes: origin = (file_path, export_fact.local_name) if origin is None: continue named_exports_by_file.setdefault(file_path, {})[export_fact.exported_name] = origin if origin[0] != file_path: source_id = source_file_id.get(file_path) if source_id is not None: add_edge( source_id, _make_id(str(path_by_resolved.get(origin[0], origin[0]))), "re_exports", "export", export_fact.line, export_fact.file_path, ) def resolve_exported_origin(target_path: Path, imported_name: str, seen: set[tuple[Path, str]] | None = None) -> tuple[Path, str]: target_path = target_path.resolve() key = (target_path, imported_name) if seen is None: seen = set() if key in seen: return key seen.add(key) origin = named_exports_by_file.get(target_path, {}).get(imported_name) if origin is not None: return resolve_exported_origin(origin[0], origin[1], seen) for star_target in star_exports_by_file.get(target_path, []): star_key = (star_target, imported_name) if star_key in symbol_nodes: return star_key resolved = resolve_exported_origin(star_target, imported_name, seen) if resolved in symbol_nodes: return resolved return key for import_fact in facts.imports: source_id = source_file_id.get(import_fact.file_path.resolve()) if source_id is None: continue origin_path, origin_symbol = resolve_exported_origin( import_fact.target_path, import_fact.imported_name, ) target_id = symbol_nodes.get((origin_path, origin_symbol)) if target_id is None: continue add_edge( source_id, target_id, "imports", "import", import_fact.line, import_fact.file_path, ) # #1146: emit file-to-file imports_from edges for package-form submodule imports. for from_path, to_path, line in facts.module_imports: try: from_rel = from_path.relative_to(root) to_rel = to_path.relative_to(root) except ValueError: continue source_id = _make_id(_file_stem(from_rel)) target_id = _make_id(_file_stem(to_rel)) add_edge(source_id, target_id, "imports_from", "submodule_import", line, from_path) for use_fact in facts.uses: file_path = use_fact.file_path.resolve() target_id = None unresolved_origin = local_aliases_by_file.get(file_path, {}).get(use_fact.local_name) if unresolved_origin is not None: origin_path, origin_symbol = resolve_exported_origin(*unresolved_origin) target_id = symbol_nodes.get((origin_path, origin_symbol)) if target_id is None and use_fact.relation in ("inherits", "implements"): # Same-file fallback for HERITAGE only: a base declared in the same # file (`class X extends Y`, `interface A extends B`) has no import # alias, so resolve it directly against the file's own symbol nodes. # Scoped to heritage because same-file calls/uses already resolve via # the dedicated call-graph pass; widening this would duplicate those # edges. Import resolution still takes precedence (#1095). target_id = symbol_nodes.get((file_path, use_fact.local_name)) if target_id is None: continue add_edge( use_fact.source_id, target_id, use_fact.relation, use_fact.context, use_fact.line, use_fact.file_path, ) def _parse_js_tree(path: Path): try: from tree_sitter import Language, Parser # .vue embeds the script in non-JS markup; mask it out and parse the #