"""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"""()""",
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
#