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
92 changes: 81 additions & 11 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import re
import sys
import textwrap
import warnings
from collections import Counter
from dataclasses import dataclass, field
from pathlib import Path, PurePath
Expand Down Expand Up @@ -1366,6 +1367,55 @@ def extract_js(path: Path) -> dict:
return result


def _js_runtime_import_starts(path: Path, source: bytes) -> set[int] | None:
"""Return byte offsets of parsed runtime ``import(...)`` calls.

The rescue needs module-scope calls, not a second lexical parser. Parsing
the same source lets tree-sitter distinguish comments, strings, regexes,
and template text. TypeScript import types are excluded by their enclosing
type-only AST nodes because that grammar can represent ``typeof import('…')``
as a call even though it is not runtime code.
Returns ``None`` when the validator cannot initialize or parse, so the
caller can preserve the pre-filter rescue behavior with a visible warning.
"""
try:
from tree_sitter import Language, Parser

suffix = path.suffix.lower()
if suffix == ".tsx":
module_name = "tree_sitter_typescript"
language_name = "language_tsx"
elif suffix in (".ts", ".mts", ".cts"):
module_name = "tree_sitter_typescript"
language_name = "language_typescript"
else:
module_name = "tree_sitter_javascript"
language_name = "language"
module = importlib.import_module(module_name)
language = Language(getattr(module, language_name)())
tree = Parser(language).parse(source)
except Exception:
return None

starts: set[int] = set()

non_runtime_ancestors = {
"ERROR", "type_alias_declaration", "interface_declaration",
"type_annotation", "type_arguments", "type_parameter",
}
pending = [(tree.root_node, frozenset())]
while pending:
node, ancestors = pending.pop()
if node.type == "call_expression":
function = node.child_by_field_name("function")
if (function is not None and function.type == "import"
and not ancestors.intersection(non_runtime_ancestors)):
starts.add(node.start_byte)
child_ancestors = ancestors | {node.type}
pending.extend((child, child_ancestors) for child in node.children)
return starts


def _rescue_js_dynamic_imports(path: Path, result: dict) -> None:

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_rescue_js_dynamic_imports()

fans out to 7 callees (efferent coupling).

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This coupling delta is confined to the rescue helper’s grammar selection and iterative tree-sitter walk.
The helper runs only after the byte matcher finds candidates, and the reparse is required to align those byte offsets with runtime call nodes while excluding comments, strings, regexes, and type contexts.
No unrelated extraction refactor was added; the existing normal-path and parser-failure regressions cover the behavior described above.

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_rescue_js_dynamic_imports()

fans out to 7 callees (efferent coupling).

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I checked the coupling increase in _rescue_js_dynamic_imports(). The added validator call is needed to distinguish real runtime imports from comment/string text, and the warning makes the parser-failure fallback visible. Grammar selection and AST traversal are already isolated in _js_runtime_import_starts(); the existing resolution and deduplication logic stays in the rescue function. I am keeping this boundary because moving calls behind another wrapper would hide the measured dependency without removing it. The 19 dynamic-import tests and 284 related tests passed, and the Graphify check passed on b31cc43.

"""Recover ``import('…')`` edges the AST pass does not emit for plain JS/TS.

Expand All @@ -1388,12 +1438,14 @@ def _rescue_js_dynamic_imports(path: Path, result: dict) -> None:
written inside one, and that is a different fact from "this file depends on
that module" — the only one file-level traversal can use (#2584).

Regex false positives in comments/strings are the precedented trade of
the Svelte/Vue rescues; a ``//``-prefix guard covers the common case.
The AST filter is safer than a raw regex search: comment and string text can
contain ``import('…')`` verbatim without making a dependency. Template
interpolation is parsed as code, so ```${import('./x')}``` remains live.
"""
try:
import re as _re
src = path.read_text(encoding="utf-8", errors="replace")
source_bytes = path.read_bytes()
src = source_bytes.decode("utf-8", errors="replace")
if "import(" not in src: # cheap bail — most files have none
return
existing_ids = {n["id"] for n in result.get("nodes", [])}
Expand Down Expand Up @@ -1431,20 +1483,38 @@ def _rescue_js_dynamic_imports(path: Path, result: dict) -> None:
deferred_files.add(str(Path(tf).resolve()))
except OSError:
deferred_files.add(str(tf))
matches = list(_re.finditer(
rb"(?<!\w)import\(\s*(?:'([^'\r\n]+)'|\"([^\"\r\n]+)\"|`([^`$\r\n]+)`)\s*\)",
source_bytes,
))
if not matches:
return
runtime_import_starts = _js_runtime_import_starts(path, source_bytes)
parser_failed = runtime_import_starts is None
if parser_failed:
warnings.warn(
f"tree-sitter could not validate dynamic import candidates in {path}; "
"falling back to the guarded lexical rescue, which may include "
"text in comments or strings",
RuntimeWarning,
stacklevel=2,
)
runtime_import_starts = set()
# `(?<!\w)` so `fooimport('x')` and `_import('x')` do not match. The
# backtick alternative mirrors _dynamic_import_js's template-string
# handling: a literal `import(`./x`)` resolves, `${`-substituted ones
# are excluded (no `$` in the class) as statically unresolvable.
for m in _re.finditer(
r"""(?<!\w)import\(\s*(?:'([^'\n]+)'|"([^"\n]+)"|`([^`$\n]+)`)\s*\)""",
src,
):
raw = m.group(1) or m.group(2) or m.group(3)
for m in matches:
if parser_failed:
line_start = source_bytes.rfind(b"\n", 0, m.start()) + 1
if b"//" in source_bytes[line_start:m.start()]:
continue
elif m.start() not in runtime_import_starts:
continue
raw_bytes = m.group(1) or m.group(2) or m.group(3)
raw = raw_bytes.decode("utf-8", errors="replace") if raw_bytes else ""
if not raw:
continue
line_start = src.rfind("\n", 0, m.start()) + 1
if "//" in src[line_start:m.start()]:
continue # line-commented-out import
resolution = _resolve_rescued_specifier(path, raw, aliases, base_url)
if resolution is None:
continue
Expand Down
88 changes: 87 additions & 1 deletion tests/test_js_dynamic_imports.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,18 @@
from pathlib import Path

import networkx as nx
import pytest

from graphify.affected import DEFAULT_AFFECTED_RELATIONS, affected_nodes
from graphify.extract import _file_node_id, extract
import graphify.extract as extract_module
from graphify.extract import (
_TS_CONFIG,
_extract_generic,
_file_node_id,
_rescue_js_dynamic_imports,
extract,
extract_js,
)


def _write(path: Path, text: str) -> Path:
Expand Down Expand Up @@ -213,6 +222,31 @@ def test_template_literal_specifier_without_substitution(tmp_path: Path):
assert _edges_to(result, "src/dep.ts")


def test_runtime_import_in_template_interpolation_survives_rescue(tmp_path: Path):
_write(tmp_path / "src/dep.ts", "export const dep = 1\n")
importer = _write(
tmp_path / "src/boot.ts",
"export const message = `${import('./dep')}`\n",
)

result = extract([tmp_path / "src/dep.ts", importer], root=tmp_path)

assert _edges_to(result, "src/dep.ts")


def test_type_import_in_generic_call_is_not_rescued_as_runtime(tmp_path: Path):
_write(tmp_path / "src/dep.ts", "export const dep = 1\n")
importer = _write(
tmp_path / "src/boot.ts",
"declare function load<T>(): void\n"
"load<typeof import('./dep')>()\n",
)

result = extract([tmp_path / "src/dep.ts", importer], root=tmp_path)

assert not _edges_to(result, "src/dep.ts")


def test_identifier_ending_in_import_is_not_matched(tmp_path: Path):
"""`fooimport('./x')` is a call to `fooimport`, not a dynamic import."""
_write(tmp_path / "src/x.ts", "export const x = 1\n")
Expand Down Expand Up @@ -240,6 +274,58 @@ def test_line_commented_dynamic_import_is_not_matched(tmp_path: Path):
assert not _edges_to(result, "src/x.ts")


@pytest.mark.parametrize(
"source_text",
[
"/* import('./x') */\nexport const r = 1\n",
'const text = "import(\'./x\')"\nexport const r = 1\n',
"const text = `import('./x')`\nexport const r = 1\n",
],
ids=["block-comment", "string", "template"],
)
def test_dynamic_import_text_in_comments_and_strings_is_not_matched(
tmp_path: Path, source_text: str
):
"""Only executable ``import()`` syntax should create a dependency edge."""
_write(tmp_path / "src/x.ts", "export const x = 1\n")
importer = _write(tmp_path / "src/caller.ts", source_text)

result = extract([tmp_path / "src/x.ts", importer], root=tmp_path)

assert not _edges_to(result, "src/x.ts")


def test_dynamic_import_text_in_regex_is_not_matched(tmp_path: Path):
importer = _write(
tmp_path / "src/caller.ts",
"const pattern = /import('x')/\nexport const r = 1\n",
)

result = extract_js(importer)

assert not any(edge["relation"] == "dynamic_import" for edge in result["edges"])


def test_parser_failure_falls_back_with_warning(tmp_path: Path, monkeypatch):
_write(tmp_path / "src/dep.ts", "export const dep = 1\n")
importer = _write(tmp_path / "src/boot.ts", "export const dep = await import('./dep')\n")

result = _extract_generic(importer, _TS_CONFIG)
original_import = extract_module.importlib.import_module

def fail_typescript_grammar(name, *args, **kwargs):
if name == "tree_sitter_typescript":
raise ImportError("simulated parser initialization failure")
return original_import(name, *args, **kwargs)

monkeypatch.setattr(extract_module.importlib, "import_module", fail_typescript_grammar)

with pytest.warns(RuntimeWarning, match="could not validate dynamic import"):
_rescue_js_dynamic_imports(importer, result)

assert any(edge["relation"] == "dynamic_import" for edge in result["edges"])


def test_nested_named_function_calls_resolve(tmp_path: Path):
"""ordinary calls inside a nested named function attribute to that inner function now that #2653 emits nested nodes."""
f = _write(
Expand Down