From 2236f54fd44a7907f485cf63e13cecb50978d7af Mon Sep 17 00:00:00 2001 From: Vasu Bansal Date: Sat, 5 Sep 2026 05:24:35 +0530 Subject: [PATCH 1/2] fix(ts): filter dynamic import rescue to runtime calls --- graphify/extract.py | 75 +++++++++++++++++++++++++++----- tests/test_js_dynamic_imports.py | 60 ++++++++++++++++++++++++- 2 files changed, 123 insertions(+), 12 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index e015c9d715..c52e4b889a 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -1366,6 +1366,53 @@ def extract_js(path: Path) -> dict: return result +def _js_runtime_import_starts(path: Path, source: bytes) -> set[int]: + """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. + """ + 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 set() + + 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: """Recover ``import('…')`` edges the AST pass does not emit for plain JS/TS. @@ -1388,12 +1435,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", [])} @@ -1431,20 +1480,24 @@ 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"(? Path: @@ -213,6 +214,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(): void\n" + "load()\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") @@ -240,6 +266,38 @@ 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_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( From b31cc4383c0cc842385c3f526402f4b93d9f4860 Mon Sep 17 00:00:00 2001 From: Vasu Bansal Date: Sat, 5 Sep 2026 05:52:22 +0530 Subject: [PATCH 2/2] fix(ts): preserve dynamic imports when validation fails --- graphify/extract.py | 23 ++++++++++++++++++++--- tests/test_js_dynamic_imports.py | 30 +++++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index c52e4b889a..2ddb17761d 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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 @@ -1366,7 +1367,7 @@ def extract_js(path: Path) -> dict: return result -def _js_runtime_import_starts(path: Path, source: bytes) -> set[int]: +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 @@ -1374,6 +1375,8 @@ def _js_runtime_import_starts(path: Path, source: bytes) -> set[int]: 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 @@ -1392,7 +1395,7 @@ def _js_runtime_import_starts(path: Path, source: bytes) -> set[int]: language = Language(getattr(module, language_name)()) tree = Parser(language).parse(source) except Exception: - return set() + return None starts: set[int] = set() @@ -1487,12 +1490,26 @@ def _rescue_js_dynamic_imports(path: Path, result: dict) -> None: 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() # `(? Path: @@ -298,6 +306,26 @@ def test_dynamic_import_text_in_regex_is_not_matched(tmp_path: Path): 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(