Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 37 additions & 32 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@
_ts_collect_type_refs,
_ts_heritage_clause_entries,
_ts_walk_class_members,
_sfc_mask_non_script,
_vue_mask_non_script,
_walk_js_tree,
_walk_python_tree,
Expand Down Expand Up @@ -1599,56 +1600,60 @@ def _emit_rescued_import(


def extract_svelte(path: Path) -> dict:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionextract_svelte()

fans out to 6 callees (efferent coupling); 13 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

"""Extract imports from .svelte files: script-block via JS AST + template regex fallback.
"""Extract imports, symbols, and type refs from a ``.svelte`` component.

Tree-sitter only sees the <script> block. Svelte template syntax like
{#await import('./X.svelte')} lives in the markup layer and is invisible
to the JS parser, so a regex pass covers those dynamic imports.
Masks the non-``<script>`` regions and parses the script with the grammar
its ``lang`` implies (``tsx``->TSX, ``js``/``jsx``->JS, ``ts`` or unset->TS;
TS is a superset of JS so it is a safe default), mirroring
:func:`extract_vue`. Feeding the whole component to the JS grammar makes the
markup a top-level ERROR node, so ``import_statement`` and declaration nodes
are never reached and everything but a stray symbol is dropped (#713).

Both script blocks of a Svelte 5 component survive the mask, so a
``<script module>`` block is parsed alongside the instance script.

A regex pass then recovers ``import('...')`` dynamic imports, which the AST
pass does not edge and which legally live in markup-layer template syntax
such as ``{#await import('./X.svelte')}`` — outside every script block, and
so blanked out of the masked source the AST sees.
"""
result = _extract_generic(path, _JS_CONFIG)
try:
import re as _re
src = path.read_text(encoding="utf-8", errors="replace")
except OSError:
return {"nodes": [], "edges": []}

masked, lang = _sfc_mask_non_script(src)
if lang == "tsx":
config = _TSX_CONFIG
elif lang in ("js", "jsx"):
config = _JS_CONFIG
else: # "ts" or unspecified — default to the TS grammar (superset of JS)
config = _TS_CONFIG

result = _extract_generic(path, config, source_override=masked.encode("utf-8"))

try:
import re as _re
existing_ids = {n["id"] for n in result.get("nodes", [])}
# 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)
base_url = _load_tsconfig_base_url(path.parent)
# Scanned over the raw source, not the masked one, so template-layer
# dynamic imports are seen. Resolution is shared with the static pass:
# relative paths and tsconfig aliases probe real on-disk extensions
# (#716, #701), and a target that IS a real file emits an edge stamped
# with target_file instead of an absolute-id ghost stub (#2195).
for m in _re.finditer(r"""import\(\s*['"]([^'"]+)['"]\s*\)""", src):
raw = m.group(1)
if not raw:
continue
# Resolution + emit shared with the static pass below: relative
# paths and tsconfig aliases probe real on-disk extensions (#716,
# #701), and a target that IS a real file emits an edge stamped
# with target_file instead of an absolute-id ghost stub (#2195).
_emit_rescued_import(
result, existing_ids, file_node_id, path, raw,
"dynamic_import", aliases, base_url,
)
# Static imports inside <script> blocks. The JS tree-sitter parser fed
# the full .svelte file produces a top-level ERROR node (HTML markup
# is not valid JS), so import_statement nodes are never reached and
# static imports are silently dropped (#713). Regex over each script
# body recovers them.
script_re = _re.compile(
r"<script\b[^>]*>([\s\S]*?)</script\s*>", _re.IGNORECASE
)
static_import_re = _re.compile(
r"""import\s+(?:[^'"`;]+?\s+from\s+)?['"]([^'"]+)['"]"""
)
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
_emit_rescued_import(
result, existing_ids, file_node_id, path, raw,
"imports_from", aliases, base_url,
)
except Exception:
pass
return result
Expand Down Expand Up @@ -1735,7 +1740,7 @@ def extract_vue(path: Path) -> dict:
except OSError:
return {"nodes": [], "edges": []}

masked, lang = _vue_mask_non_script(src)
masked, lang = _sfc_mask_non_script(src)
if lang == "tsx":
config = _TSX_CONFIG
elif lang in ("js", "jsx"):
Expand Down
105 changes: 100 additions & 5 deletions graphify/extractors/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -1912,6 +1912,20 @@ def _find_require_call(value_node):
return _find_require_call(obj)
return None

def _is_require_initializer(value_node, source: bytes) -> bool:
"""True when a declarator's initializer is a literal ``require(...)`` call.

``_find_require_call`` matches the call *shape* only — any
``identifier(...)`` — and leaves the callee-name check to its callers, so
it must not be used alone to recognise a CJS import.
"""
call = _find_require_call(value_node)
if call is None:
return False
fn = call.child_by_field_name("function")
return fn is not None and _read_text(fn, source) == "require"


def _require_imports_js(node, source: bytes, importer_nid: str, stem: str, edges: list, str_path: str) -> bool:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression_require_imports_js()

high coupling complexity (Ca·Ce = 15).

Grounded coupling-delta finding (deterministic), not an LLM guess.

"""Detect CommonJS require imports inside lexical_declaration / variable_declaration.

Expand Down Expand Up @@ -2130,6 +2144,61 @@ def _js_member_assignment_target(left, source: bytes):
return ("prototype", inner_obj_name, member_name)
return None

_JS_PATTERN_TYPES = frozenset({"object_pattern", "array_pattern"})


def _js_pattern_bound_names(name_node, source: bytes) -> list[str]:
"""Return the identifiers a destructuring declarator actually binds.

``const { a, b: renamed, c = 1, ...rest } = x`` binds ``a``, ``renamed``,
``c`` and ``rest`` — not the text of the pattern. Reading the declarator's
``name`` field verbatim instead mints one node labelled with the whole
pattern source (``{ a, b: renamed, c = 1, ...rest }``), which names no
symbol and can never be the target of a reference. Svelte 5 makes the shape
universal — every component destructures ``$props()`` — but the same
declarator shape is ordinary JS/TS.

Walks only the binding side: a ``pair_pattern``'s value (``b: renamed``
binds ``renamed``, not the property key ``b``) and an assignment pattern's
left operand (``c = $bindable()`` binds ``c``, not ``$bindable``). Nested
patterns recurse, so ``{ deep: { inner } }`` binds ``inner``. Returns an
empty list for a non-pattern node.
"""
if name_node is None or name_node.type not in _JS_PATTERN_TYPES:
return []
names: list[str] = []

def visit(node) -> None:
t = node.type
if t in ("identifier", "shorthand_property_identifier_pattern"):
text = _read_text(node, source)
if text and text not in names:
names.append(text)
return
if t == "pair_pattern":
# `key: target` — only the value side is bound.
value = node.child_by_field_name("value")
if value is not None:
visit(value)
return
if t in ("object_assignment_pattern", "assignment_pattern"):
# `target = default` — the default is an expression, not a binding.
left = node.child_by_field_name("left")
if left is None:
left = node.named_children[0] if node.named_children else None
if left is not None:
visit(left)
return
if t in ("rest_pattern", "object_pattern", "array_pattern"):
for child in node.named_children:
visit(child)
return
# Anything else (type annotations, holes in `[a, , c]`) binds nothing.

visit(name_node)
return names


def _js_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression_js_extra_walk()

fans out to 10 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

nodes: list, edges: list, seen_ids: set, function_bodies: list,
parent_class_nid: str | None, add_node_fn, add_edge_fn,
Expand Down Expand Up @@ -2303,13 +2372,39 @@ def _js_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str,
):
# Simple exported identifiers are part of the module API
# regardless of initializer shape. Keep other scalar noise suppressed.
const_nid = None
if name_node:
const_name = _read_text(name_node, source)
line = child.start_point[0] + 1
const_nid = _make_id(stem, const_name)
add_node_fn(const_nid, const_name, line)
add_edge_fn(file_nid, const_nid, "contains", line)
const_found = True
# A destructuring declarator binds several names and
# its `name` field is the pattern source, not a
# symbol — node each identifier it actually binds.
const_names = _js_pattern_bound_names(name_node, source)
if const_names and _is_require_initializer(value, source):
# `const { doWork } = require('./lib')` binds an
# IMPORT, not a local definition. `_require_imports_js`
# already edges those names at the file level; noding
# them here would shadow the real cross-file target,
# so a call to `doWork()` would resolve to this file's
# stub instead of the callee's definition.
const_names = []
elif not const_names:
const_names = [_read_text(name_node, source)]
for const_name in const_names:
# A name that normalizes to nothing would collapse
# the id to the absolute file-stem and leak the
# scan path (#1899); skip it, as the arrow branch does.
if not const_name or not normalize_id(const_name):
continue
nid = _make_id(stem, const_name)
add_node_fn(nid, const_name, line)
add_edge_fn(file_nid, nid, "contains", line)
const_found = True
if const_nid is None:
# Closures in the initializer are attributed to
# the first binding; a destructured initializer
# has no single owning symbol.
const_nid = nid
if const_nid is not None:
# #2552: `const handler = wrapper(async (req) => …)`
# created the const node above but, unlike the arrow
# branch, never tracked the callback's body — so
Expand Down
43 changes: 29 additions & 14 deletions graphify/extractors/resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -605,41 +605,55 @@ def _resolve_lua_import_target(raw_module: str, str_path: str) -> str:
probe = probe.parent
return _make_id(raw_module)

_VUE_SCRIPT_RE = re.compile(
_SFC_SCRIPT_RE = re.compile(
r"""(<script\b(?:"[^"]*"|'[^']*'|[^>"'])*>)([\s\S]*?)(</script\s*>)""",
re.IGNORECASE,
)

_VUE_SCRIPT_LANG_RE = re.compile(
_SFC_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]:
# Back-compat aliases: these were named for Vue before Svelte shared the masker.
_VUE_SCRIPT_RE = _SFC_SCRIPT_RE
_VUE_SCRIPT_LANG_RE = _SFC_SCRIPT_LANG_RE

def _sfc_mask_non_script(src: str) -> tuple[str, str | None]:
"""Blank everything outside ``<script>`` bodies, keeping ``\\r``/``\\n``.

Replaces template/style/tags with spaces so a JS/TS grammar sees only the
script, while preserved newlines keep line numbers accurate. Returns
``(masked_source, lang)``; ``lang`` is the first block's declared ``lang``.
Shared by the ``.vue`` and ``.svelte`` extractors: both wrap JS/TS in markup
a JS grammar cannot parse. Replaces template/style/tags with spaces so the
grammar sees only the script, while preserved newlines keep line numbers
accurate. Every ``<script>`` block is kept, so a Svelte 5 ``<script module>``
/instance pair (or a Vue ``setup``/options pair) is parsed as one unit.
Returns ``(masked_source, lang)``; ``lang`` is the first block's declared
``lang``.
"""
def _blank(s: str) -> str:
return re.sub(r"[^\r\n]", " ", s)

out: list[str] = []
pos = 0
lang: str | None = None
for m in _VUE_SCRIPT_RE.finditer(src):
for m in _SFC_SCRIPT_RE.finditer(src):
out.append(_blank(src[pos:m.start()])) # markup/style before this block
out.append(_blank(m.group(1))) # <script …> open tag
out.append(m.group(2)) # script body, verbatim
out.append(_blank(m.group(3))) # </script> close tag
pos = m.end()
if lang is None:
lang_m = _VUE_SCRIPT_LANG_RE.search(m.group(1))
lang_m = _SFC_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

_vue_mask_non_script = _sfc_mask_non_script

# Single-file-component suffixes whose script blocks need masking before a
# JS/TS grammar can parse them.
_SFC_SUFFIXES = (".vue", ".svelte")

def _source_key(source_file: str, root: Path) -> str:
if not source_file:
return ""
Expand Down Expand Up @@ -1104,18 +1118,19 @@ def resolve_exported_origin(target_path: Path, imported_name: str, seen: set[tup
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
# <script> with TS.
vue_lang: str | None = None
if path.suffix == ".vue":
masked, vue_lang = _vue_mask_non_script(
# .vue and .svelte embed the script in non-JS markup; mask it out and
# parse the <script> with TS. Without the mask the whole file is a
# top-level ERROR node, so this pass contributes no calls/type refs.
sfc_lang: str | None = None
if path.suffix in _SFC_SUFFIXES:
masked, sfc_lang = _sfc_mask_non_script(
path.read_text(encoding="utf-8", errors="replace")
)
source = masked.encode("utf-8")
else:
source = path.read_bytes()
use_ts = path.suffix in (".ts", ".mts", ".cts") or (
path.suffix == ".vue" and vue_lang not in ("js", "jsx")
path.suffix in _SFC_SUFFIXES and sfc_lang not in ("js", "jsx")
)
if path.suffix == ".tsx":
# .tsx must use the JSX-aware TSX grammar, mirroring the engine's
Expand Down
Loading