diff --git a/.gitattributes b/.gitattributes
index faa0f4b26..ccd0ea132 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -4,3 +4,8 @@
worked/**/*.html linguist-vendored=true
graphify-out/**/*.html linguist-vendored=true
*.html linguist-detectable=false
+
+# Generated dependency bundle and verbatim third-party licenses preserve
+# upstream whitespace so deterministic rebuild hashes remain stable.
+graphify/vendor/svelte_ast_bridge.mjs -whitespace
+graphify/vendor/svelte_ast_bridge.mjs.NOTICES.txt -whitespace
diff --git a/README.md b/README.md
index 970ebf179..fa79129fe 100644
--- a/README.md
+++ b/README.md
@@ -126,6 +126,7 @@ Every system ran on the same harness with the same model and budgets, scored by
| Requirement | Minimum | Check | Install |
|---|---|---|---|
| Python | 3.10+ | `python --version` | [python.org](https://www.python.org/downloads/) |
+| Node.js *(Svelte extraction)* | 18+ | `node --version` | [nodejs.org](https://nodejs.org/) |
| uv *(recommended)* | any | `uv --version` | `curl -LsSf https://astral.sh/uv/install.sh \| sh` |
| pipx *(alternative)* | any | `pipx --version` | `pip install pipx` |
diff --git a/graphify/extract.py b/graphify/extract.py
index a18a9b1c3..a98ed3ebb 100644
--- a/graphify/extract.py
+++ b/graphify/extract.py
@@ -50,6 +50,17 @@
from graphify.extractors.rust import extract_rust # noqa: F401
from graphify.extractors.sln import extract_sln # noqa: F401
from graphify.extractors.sql import extract_sql # noqa: F401
+from graphify.extractors.svelte import (
+ SvelteExtractionContext,
+ SvelteSourceFacts,
+ augment_svelte_component,
+ augment_svelte_runes,
+ augment_svelte_semantic_edges,
+ has_fatal_svelte_diagnostics,
+ mask_svelte_script_facts,
+ parse_svelte_ast_batch,
+ svelte_script_facts,
+)
from graphify.extractors.terraform import extract_terraform # noqa: F401
from graphify.extractors.verilog import extract_verilog # noqa: F401
from graphify.extractors.zig import extract_zig # noqa: F401
@@ -1070,6 +1081,7 @@ def extract_js(path: Path) -> dict:
config = _JS_CONFIG
result = _extract_generic(path, config)
if "error" not in result:
+ augment_svelte_runes(path, result)
_extract_js_rationale(path, result)
return result
@@ -1178,129 +1190,114 @@ def _add_doc_ref(token: str, line: int) -> None:
_add_doc_ref(m.group(1), lineno)
-def extract_svelte(path: Path) -> dict:
- """Extract imports from .svelte files: script-block via JS AST + template regex fallback.
+def extract_svelte(
+ path: Path,
+ *,
+ _ast_facts: dict | None = None,
+ _source: str | None = None,
+ _source_facts: SvelteSourceFacts | None = None,
+ _defer_semantic_targets: bool = False,
+) -> dict:
+ """Extract a Svelte component from its modern author AST.
- Tree-sitter only sees the ", _re.IGNORECASE
- )
- static_import_re = _re.compile(
- r"""import\s+(?:[^'"`;]+?\s+from\s+)?['"]([^'"]+)['"]"""
+ if merged_type_table:
+ result["ts_type_table"] = {"path": str(path), "table": merged_type_table}
+
+ seen_nodes: set[str] = set()
+ result["nodes"] = [
+ node for node in result["nodes"]
+ if isinstance(node.get("id"), str) and not (
+ node["id"] in seen_nodes or seen_nodes.add(node["id"])
+ )
+ ]
+ seen_edges: set[tuple] = set()
+ deduped_edges = []
+ for edge in result["edges"]:
+ key = (
+ edge.get("source"), edge.get("target"), edge.get("relation"),
+ edge.get("source_location"), edge.get("context"),
+ )
+ if key in seen_edges:
+ continue
+ seen_edges.add(key)
+ deduped_edges.append(edge)
+ result["edges"] = deduped_edges
+ augment_svelte_component(
+ path,
+ src,
+ facts,
+ result,
+ include_standalone_dynamic_targets=not _defer_semantic_targets,
)
- for script_match in script_re.finditer(src):
- script_body = script_match.group(1)
- for m in static_import_re.finditer(script_body):
- raw = m.group(1)
- if not raw:
- continue
- if raw.startswith("."):
- resolved = Path(os.path.normpath(path.parent / raw))
- if resolved.suffix == ".js":
- resolved = resolved.with_suffix(".ts")
- elif resolved.suffix == ".jsx":
- resolved = resolved.with_suffix(".tsx")
- node_id = _make_id(str(resolved))
- stub_source_file = str(resolved)
- else:
- resolved_alias = _resolve_tsconfig_alias(raw, aliases)
- if resolved_alias is not None:
- node_id = _make_id(str(resolved_alias))
- stub_source_file = str(resolved_alias)
- else:
- module_name = raw.split("/")[-1]
- if not module_name:
- continue
- node_id = _make_id(module_name)
- stub_source_file = raw
- if node_id in existing_ids:
- result.setdefault("edges", []).append({
- "source": file_node_id, "target": node_id,
- "relation": "imports_from", "confidence": "EXTRACTED",
- "source_file": str(path),
- })
- continue
- result.setdefault("nodes", []).append({
- "id": node_id, "label": raw,
- "file_type": "code", "source_file": stub_source_file,
- "confidence": "EXTRACTED",
- })
- result.setdefault("edges", []).append({
- "source": file_node_id, "target": node_id,
- "relation": "imports_from", "confidence": "EXTRACTED",
- "source_file": str(path),
- })
- existing_ids.add(node_id)
- except Exception:
- pass
- return result
+ return result
+ except Exception as exc:
+ return {"nodes": [], "edges": [], "error": str(exc)}
def extract_astro(path: Path) -> dict:
@@ -2307,6 +2304,28 @@ def _key(label: str) -> str:
if tnode is not None:
method_index[(src, _key(tnode.get("label", "")))] = tgt
+ imported_type_by_file_local: dict[tuple[str, str], str] = {}
+ for edge in all_edges:
+ if edge.get("relation") != "imports":
+ continue
+ metadata = edge.get("metadata") or {}
+ alias_names = {
+ alias.get("local_name")
+ for alias in metadata.get("aliases", [])
+ if isinstance(alias, dict) and isinstance(alias.get("local_name"), str)
+ }
+ if isinstance(metadata.get("local_name"), str):
+ alias_names.add(metadata["local_name"])
+ target = edge.get("target")
+ source_file = edge.get("source_file")
+ if (
+ isinstance(target, str)
+ and isinstance(source_file, str)
+ and _is_type_like_definition(node_by_id.get(target, {}))
+ ):
+ for local_name in alias_names:
+ imported_type_by_file_local[(source_file, local_name)] = target
+
all_raw_calls: list[dict] = []
for result in per_file:
all_raw_calls.extend(result.get("raw_calls", []))
@@ -2333,10 +2352,16 @@ def _key(label: str) -> str:
# cross-file CALL resolver already skips these globals; do the same here.
if type_name in _LANGUAGE_BUILTIN_GLOBALS:
continue
- type_defs = type_def_nids.get(_key(type_name), [])
- if len(type_defs) != 1:
- continue
- type_nid = type_defs[0]
+ imported_type = imported_type_by_file_local.get(
+ (str(rc.get("source_file", "")), type_name)
+ )
+ if imported_type is not None:
+ type_nid = imported_type
+ else:
+ type_defs = type_def_nids.get(_key(type_name), [])
+ if len(type_defs) != 1:
+ continue
+ type_nid = type_defs[0]
method_nid = method_index.get((type_nid, _key(callee)))
target = method_nid or type_nid
relation = "calls" if method_nid else "references"
@@ -2813,7 +2838,11 @@ def _key(label: str) -> str:
LanguageResolver("ruby_member_calls", frozenset({".rb", ".rake"}), resolve_ruby_member_calls)
)
register_language_resolver(
- LanguageResolver("typescript_member_calls", frozenset({".ts", ".tsx", ".mts", ".cts", ".js", ".jsx"}), _resolve_typescript_member_calls)
+ LanguageResolver(
+ "typescript_member_calls",
+ frozenset({".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".svelte"}),
+ _resolve_typescript_member_calls,
+ )
)
# C++ (#1547) and ObjC (#1556) receiver-typed member-call resolution. `.h` is in
# both suffix sets because it routes to extract_cpp or extract_objc by content; the
@@ -4388,6 +4417,30 @@ def extract(
continue
uncached_work.append((i, path))
+ # Parse all Svelte author sources in one bundled-compiler invocation. These
+ # stay in the parent process so ProcessPool extraction never starts one Node
+ # compiler per worker (and behaves identically under Windows spawn).
+ svelte_context = SvelteExtractionContext({})
+ svelte_work = [(idx, path) for idx, path in uncached_work if path.suffix == ".svelte"]
+ if svelte_work:
+ svelte_sources: dict[Path, str] = {}
+ for idx, path in svelte_work:
+ try:
+ svelte_sources[path] = path.read_text(encoding="utf-8", errors="replace")
+ except OSError as exc:
+ per_file[idx] = {"nodes": [], "edges": [], "error": str(exc)}
+ svelte_context = SvelteExtractionContext.parse(svelte_sources)
+ for idx, path in svelte_work:
+ source_facts = svelte_context.get(path)
+ if source_facts is None:
+ continue
+ per_file[idx] = extract_svelte(
+ path,
+ _source_facts=source_facts,
+ _defer_semantic_targets=True,
+ )
+ uncached_work = [item for item in uncached_work if item[1].suffix != ".svelte"]
+
# Phase 2: extract uncached files (parallel or sequential)
if uncached_work:
ran_parallel = False
@@ -4489,7 +4542,21 @@ def extract(
# marker set in the per-file extractor. Populated just before the pass that uses it.
callable_nids: set[str] = set()
- _augment_symbol_resolution_edges(paths, all_nodes, all_edges, root)
+ _augment_symbol_resolution_edges(
+ paths,
+ all_nodes,
+ all_edges,
+ root,
+ svelte_context=svelte_context,
+ )
+ augment_svelte_semantic_edges(
+ paths,
+ per_file,
+ all_nodes,
+ all_edges,
+ root,
+ svelte_context=svelte_context,
+ )
# Merge a header-declared class (and its methods) with its sibling-impl
# definition into ONE node (C/C++/ObjC #1547/#1556). Runs BEFORE the id-remap
@@ -4618,10 +4685,23 @@ def extract(
if cn in sym_remap:
rc["caller_nid"] = sym_remap[cn]
if edge_alias_candidates:
- edge_key_counts = Counter(
- json.dumps(edge, sort_keys=True, separators=(",", ":"), default=str)
+ def _import_identity(edge: dict) -> str:
+ return json.dumps(
+ {
+ key: value
+ for key, value in edge.items()
+ if key not in ("metadata", "target")
+ },
+ sort_keys=True,
+ separators=(",", ":"),
+ default=str,
+ )
+
+ canonical_imports = {
+ (edge.get("target"), _import_identity(edge))
for edge in all_edges
- )
+ if edge.get("relation") == "imports"
+ }
owned_node_ids = {node.get("id") for node in all_nodes}
deduped_edges: list[dict] = []
for edge in all_edges:
@@ -4638,16 +4718,16 @@ def extract(
)
if len(candidates) == 1:
candidate = next(iter(candidates))
- twin = {**edge, "target": candidate}
- twin_key = json.dumps(
- twin, sort_keys=True, separators=(",", ":"), default=str
- )
- # Drop only when the shared resolver emitted the exact
- # canonical twin. Otherwise the target may be a legitimate
- # owned node id.
- if edge_key_counts[twin_key]:
- if edge.get("target") in owned_node_ids:
- edge_key_counts[twin_key] -= 1
+ has_canonical_twin = (
+ candidate,
+ _import_identity(edge),
+ ) in canonical_imports
+ # A generic AST import edge has no alias metadata. When the
+ # shared resolver emitted its canonical target twin, this
+ # absolute-prefixed edge is redundant. A metadata-bearing
+ # edge is independently resolved and may legitimately target
+ # an owned same-id symbol (#1529 collision fixtures).
+ if has_canonical_twin and not edge.get("metadata"):
continue
deduped_edges.append(edge)
all_edges[:] = deduped_edges
diff --git a/graphify/extractors/models.py b/graphify/extractors/models.py
index e6da5173e..158d38336 100644
--- a/graphify/extractors/models.py
+++ b/graphify/extractors/models.py
@@ -66,6 +66,12 @@ class _SymbolImportFact:
target_path: Path
imported_name: str
line: int
+ binding_id: str | None = None
+ script_context: str | None = None
+ start_offset: int | None = None
+ end_offset: int | None = None
+ start_byte: int | None = None
+ end_byte: int | None = None
@dataclass(frozen=True)
class _SymbolAliasFact:
@@ -73,6 +79,7 @@ class _SymbolAliasFact:
alias: str
target_name: str
line: int
+ script_context: str | None = None
@dataclass(frozen=True)
class _SymbolExportFact:
@@ -82,6 +89,7 @@ class _SymbolExportFact:
local_name: str | None = None
target_path: Path | None = None
target_name: str | None = None
+ script_context: str | None = None
@dataclass(frozen=True)
class _StarExportFact:
@@ -104,6 +112,7 @@ class _SymbolUseFact:
relation: str
context: str
line: int
+ script_context: str | None = None
@dataclass
class _SymbolResolutionFacts:
diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py
index 31a2e7979..d6b3a7953 100644
--- a/graphify/extractors/resolution.py
+++ b/graphify/extractors/resolution.py
@@ -10,6 +10,12 @@
_make_id,
_read_text,
)
+from graphify.extractors.svelte import (
+ SvelteExtractionContext,
+ has_fatal_svelte_diagnostics,
+ svelte_script_facts,
+)
+from graphify.security import sanitize_metadata
import hashlib
import json
import os
@@ -448,6 +454,12 @@ def _resolve_js_module_path(raw: str | Path, start_dir: Path | None = None) -> P
return _resolve_workspace_import(raw, start_dir)
+
+def _canonical_js_file_identity(path: Path) -> tuple[Path, str]:
+ """Return the resolver-owned canonical path and corresponding file node id."""
+ canonical = path.resolve()
+ return canonical, _make_id(str(canonical))
+
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).
@@ -786,21 +798,54 @@ def ensure_symbol_node(path: Path, name: str, line: int) -> str:
})
return node_id
- existing_edges = {
+ existing_edge_by_key = {
(
str(edge.get("source")),
str(edge.get("target")),
str(edge.get("relation")),
str(edge.get("context") or ""),
- )
+ ): edge
for edge in edges
}
- def add_edge(source: str, target: str, relation: str, context: str, line: int, source_path: Path, target_file: str | None = None) -> None:
+ def add_edge(
+ source: str,
+ target: str,
+ relation: str,
+ context: str,
+ line: int,
+ source_path: Path,
+ target_file: str | None = None,
+ metadata: dict | None = None,
+ ) -> None:
key = (source, target, relation, context or "")
- if key in existing_edges:
+ existing = existing_edge_by_key.get(key)
+ if existing is not None:
+ if target_file is not None:
+ existing.setdefault("target_file", target_file)
+ if metadata:
+ prior = existing.get("metadata")
+ merged = dict(prior) if isinstance(prior, dict) else {}
+ incoming = sanitize_metadata(metadata)
+ aliases: list[dict] = []
+ for candidate in (merged.get("aliases"), incoming.get("aliases")):
+ if isinstance(candidate, list):
+ aliases.extend(item for item in candidate if isinstance(item, dict))
+ if aliases:
+ merged["aliases"] = sorted(
+ {
+ json.dumps(alias, sort_keys=True): alias
+ for alias in aliases
+ }.values(),
+ key=lambda alias: (
+ str(alias.get("local_name", "")),
+ str(alias.get("imported_name", "")),
+ str(alias.get("script_context", "")),
+ ),
+ )
+ merged.update({key: value for key, value in incoming.items() if key != "aliases"})
+ existing["metadata"] = merged
return
- existing_edges.add(key)
edge = {
"source": source,
"target": target,
@@ -816,18 +861,23 @@ def add_edge(source: str, target: str, relation: str, context: str, line: int, s
# the id-disambiguation salt is keyed by the TARGET, not the importer (#1814).
if target_file is not None:
edge["target_file"] = target_file
+ if metadata:
+ edge["metadata"] = sanitize_metadata(metadata)
edges.append(edge)
+ existing_edge_by_key[key] = edge
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]]] = {}
+ local_aliases_by_file: dict[
+ Path,
+ dict[tuple[str, str | None], 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,
- )
+ local_aliases_by_file.setdefault(file_path, {})[
+ (import_fact.local_name, import_fact.script_context)
+ ] = (import_fact.target_path.resolve(), import_fact.imported_name)
pending_aliases_by_file: dict[Path, list[_SymbolAliasFact]] = {}
for alias_fact in facts.aliases:
@@ -839,11 +889,14 @@ def add_edge(source: str, target: str, relation: str, context: str, line: int, s
while changed:
changed = False
for alias_fact in aliases:
- if alias_fact.alias in local_aliases:
+ alias_key = (alias_fact.alias, alias_fact.script_context)
+ if alias_key in local_aliases:
continue
- origin = local_aliases.get(alias_fact.target_name)
+ origin = local_aliases.get(
+ (alias_fact.target_name, alias_fact.script_context)
+ )
if origin is not None:
- local_aliases[alias_fact.alias] = origin
+ local_aliases[alias_key] = origin
changed = True
named_exports_by_file: dict[Path, dict[str, tuple[Path, str]]] = {}
@@ -902,7 +955,9 @@ def add_edge(source: str, target: str, relation: str, context: str, line: int, s
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)
+ origin = local_aliases_by_file.get(file_path, {}).get(
+ (export_fact.local_name, export_fact.script_context)
+ )
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:
@@ -950,8 +1005,30 @@ def resolve_exported_origin(target_path: Path, imported_name: str, seen: set[tup
import_fact.imported_name,
)
target_id = symbol_nodes.get((origin_path, origin_symbol))
+ if (
+ target_id is None
+ and origin_symbol in ("default", "*")
+ and origin_path.name.endswith(".svelte")
+ ):
+ target_id = source_file_id.get(origin_path)
if target_id is None:
continue
+ alias = {
+ "local_name": import_fact.local_name,
+ "imported_name": import_fact.imported_name,
+ }
+ if import_fact.binding_id is not None:
+ alias["binding_id"] = import_fact.binding_id
+ if import_fact.script_context is not None:
+ alias["script_context"] = import_fact.script_context
+ for key, value in (
+ ("start_offset", import_fact.start_offset),
+ ("end_offset", import_fact.end_offset),
+ ("start_byte", import_fact.start_byte),
+ ("end_byte", import_fact.end_byte),
+ ):
+ if value is not None:
+ alias[key] = value
add_edge(
source_id,
target_id,
@@ -959,6 +1036,11 @@ def resolve_exported_origin(target_path: Path, imported_name: str, seen: set[tup
"import",
import_fact.line,
import_fact.file_path,
+ metadata={
+ "local_name": import_fact.local_name,
+ "imported_name": import_fact.imported_name,
+ "aliases": [alias],
+ },
)
# #1146: emit file-to-file imports_from edges for package-form submodule imports.
@@ -975,7 +1057,9 @@ def resolve_exported_origin(target_path: Path, imported_name: str, seen: set[tup
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)
+ unresolved_origin = local_aliases_by_file.get(file_path, {}).get(
+ (use_fact.local_name, use_fact.script_context)
+ )
if unresolved_origin is not None:
origin_path, origin_symbol = resolve_exported_origin(*unresolved_origin)
target_id = symbol_nodes.get((origin_path, origin_symbol))
@@ -998,33 +1082,70 @@ def resolve_exported_origin(target_path: Path, imported_name: str, seen: set[tup
use_fact.file_path,
)
+def _parse_js_source(source: bytes, *, use_ts: bool):
+ from tree_sitter import Language, Parser
+
+ if use_ts:
+ import tree_sitter_typescript as tstypescript
+
+ language = Language(tstypescript.language_typescript())
+ else:
+ import tree_sitter_javascript as tsjavascript
+
+ language = Language(tsjavascript.language())
+ parser = Parser(language)
+ return source, parser.parse(source).root_node
+
+
def _parse_js_tree(path: Path):
+ """Parse one ordinary JS/TS or Vue lexical program.
+
+ Svelte programs require extraction-scoped compiler facts and are handled by
+ ``_parse_js_programs``. Keeping that dependency explicit prevents a hidden
+ per-file Node invocation in resolution.
+ """
try:
- from tree_sitter import Language, Parser
- # .vue embeds the script in non-JS markup; mask it out and parse the
- #