diff --git a/graphify/extract.py b/graphify/extract.py index e015c9d715..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,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: """Recover ``import('…')`` edges the AST pass does not emit for plain JS/TS. @@ -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", [])} @@ -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"(? Path: @@ -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(): 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 +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(