From a1dc610079970e450ebc0f7d5360cfc8b63f9066 Mon Sep 17 00:00:00 2001 From: Yalkowni Date: Mon, 27 Apr 2026 16:23:38 -0700 Subject: [PATCH] feat(extract): add dynamic import() extraction for JS/TS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds _dynamic_import_js() helper (65 lines) that detects import() call expressions in JS/TS, resolves the module path (same logic as static imports including .js→.ts mapping and tsconfig aliases), and emits imports_from edges from the enclosing function. Hooked into walk_calls for JS/TS configs. Also adds tests/fixtures/dynamic_import.ts fixture and 5 new tests in tests/test_languages.py (all passing alongside 110 existing tests). --- graphify/extract.py | 82 ++++++++++++++++++++++++++++++++ tests/fixtures/dynamic_import.ts | 20 ++++++++ tests/test_languages.py | 51 +++++++++++++++++++- 3 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/dynamic_import.ts diff --git a/graphify/extract.py b/graphify/extract.py index 357e4302..937fa075 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -226,6 +226,78 @@ def _import_js(node, source: bytes, file_nid: str, stem: str, edges: list, str_p break +def _dynamic_import_js(node, source: bytes, caller_nid: str, str_path: str, edges: list, + seen_dyn_pairs: set) -> bool: + """Detect dynamic import() calls in JS/TS and emit imports_from edges. + + Handles patterns like: + await import('./foo.js') + import('./foo.js').then(...) + const m = await import(`./foo`) + + Returns True if the node was a dynamic import (caller should skip normal call handling). + """ + # Dynamic import is a call_expression whose function child is the keyword "import". + # tree-sitter-typescript parses `import('...')` as call_expression with first child + # being an "import" token (type="import"). + func_node = node.child_by_field_name("function") + if func_node is None: + # Fallback: check first child directly (some TS versions) + if node.children and _read_text(node.children[0], source) == "import": + func_node = node.children[0] + else: + return False + if _read_text(func_node, source) != "import": + return False + + # Extract the module path from the arguments + args = node.child_by_field_name("arguments") + if args is None: + return True # It's an import() but no args — skip + for arg in args.children: + if arg.type in ("string", "template_string"): + raw = _read_text(arg, source).strip("'\"` ") + if not raw: + break + # Resolve path using the same logic as static imports + if raw.startswith("."): + resolved = Path(os.path.normpath(Path(str_path).parent / raw)) + if resolved.suffix == ".js": + resolved = resolved.with_suffix(".ts") + elif resolved.suffix == ".jsx": + resolved = resolved.with_suffix(".tsx") + tgt_nid = _make_id(str(resolved)) + else: + aliases = _load_tsconfig_aliases(Path(str_path).parent) + resolved_alias = None + for alias_prefix, alias_base in aliases.items(): + if raw == alias_prefix or raw.startswith(alias_prefix + "/"): + rest = raw[len(alias_prefix):].lstrip("/") + resolved_alias = Path(os.path.normpath(Path(alias_base) / rest)) + break + if resolved_alias is not None: + tgt_nid = _make_id(str(resolved_alias)) + else: + module_name = raw.split("/")[-1] + if not module_name: + break + tgt_nid = _make_id(module_name) + pair = (caller_nid, tgt_nid) + if pair not in seen_dyn_pairs: + seen_dyn_pairs.add(pair) + edges.append({ + "source": caller_nid, + "target": tgt_nid, + "relation": "imports_from", + "confidence": "EXTRACTED", + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + "weight": 1.0, + }) + break + return True + + def _import_java(node, source: bytes, file_nid: str, stem: str, edges: list, str_path: str) -> None: def _walk_scoped(n) -> str: parts: list[str] = [] @@ -1030,6 +1102,7 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict: label_to_nid[normalised.lower()] = n["id"] seen_call_pairs: set[tuple[str, str]] = set() + seen_dyn_import_pairs: set[tuple[str, str]] = set() seen_static_ref_pairs: set[tuple[str, str, str]] = set() seen_helper_ref_pairs: set[tuple[str, str, str]] = set() seen_bind_pairs: set[tuple[str, str, str]] = set() @@ -1051,6 +1124,15 @@ def _extract_generic(path: Path, config: LanguageConfig) -> dict: return if node.type in config.call_types: + # JS/TS dynamic imports: await import('./foo.js') + if config.ts_module in ("tree_sitter_javascript", "tree_sitter_typescript"): + if _dynamic_import_js(node, source, caller_nid, str_path, + edges, seen_dyn_import_pairs): + # Still recurse into children (import().then(...) may have calls) + for child in node.children: + walk_calls(child, caller_nid) + return + callee_name: str | None = None # Special handling per language diff --git a/tests/fixtures/dynamic_import.ts b/tests/fixtures/dynamic_import.ts new file mode 100644 index 00000000..a9006c97 --- /dev/null +++ b/tests/fixtures/dynamic_import.ts @@ -0,0 +1,20 @@ +import { logger } from './logger'; + +async function processInbound(orgId: string, phone: string) { + const { shouldHandle, processMessage } = await import('./mayaEngine.js'); + const handle = await shouldHandle(orgId, phone); + if (handle.sessionId) { + await processMessage({ orgId, phone }, handle.sessionId); + } +} + +async function pollMessages(orgId: string) { + const { commsQueue } = await import('./queue.js'); + await commsQueue.add('check-inbound', { orgId }); +} + +function syncOnly() { + logger.info('no dynamic imports here'); +} + +export { processInbound, pollMessages, syncOnly }; diff --git a/tests/test_languages.py b/tests/test_languages.py index 680bb4e2..593fc7c5 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -1,11 +1,11 @@ -"""Tests for language extractors: Java, C, C++, Ruby, C#, Kotlin, Scala, PHP, Swift, Go, Julia.""" +"""Tests for language extractors: Java, C, C++, Ruby, C#, Kotlin, Scala, PHP, Swift, Go, Julia, JS/TS.""" from __future__ import annotations from pathlib import Path import pytest from graphify.extract import ( extract_java, extract_c, extract_cpp, extract_ruby, extract_csharp, extract_kotlin, extract_scala, extract_php, - extract_swift, extract_go, extract_julia, + extract_swift, extract_go, extract_julia, extract_js, ) FIXTURES = Path(__file__).parent / "fixtures" @@ -560,3 +560,50 @@ def test_julia_no_dangling_edges(): node_ids = {n["id"] for n in r["nodes"]} for e in r["edges"]: assert e["source"] in node_ids, f"Dangling source: {e}" + + +# ── TypeScript dynamic imports ─────────────────────────────────────────────── + +def test_ts_dynamic_import_no_error(): + r = extract_js(FIXTURES / "dynamic_import.ts") + assert "error" not in r + +def test_ts_dynamic_import_extracts_edges(): + """Dynamic import() calls inside functions should produce imports_from edges.""" + r = extract_js(FIXTURES / "dynamic_import.ts") + dyn_edges = [e for e in r["edges"] if e["relation"] == "imports_from"] + targets = {e["target"] for e in dyn_edges} + # Should find: static ./logger, dynamic ./mayaEngine.js, dynamic ./queue.js + assert any("logger" in t for t in targets), f"Missing static import of logger: {targets}" + assert any("mayaengine" in t.lower() for t in targets), f"Missing dynamic import of mayaEngine: {targets}" + assert any("queue" in t.lower() for t in targets), f"Missing dynamic import of queue: {targets}" + +def test_ts_dynamic_import_confidence(): + """Dynamic imports should have EXTRACTED confidence (they are deterministic string literals).""" + r = extract_js(FIXTURES / "dynamic_import.ts") + dyn_edges = [e for e in r["edges"] + if e["relation"] == "imports_from" + and "mayaengine" in e["target"].lower()] + assert len(dyn_edges) >= 1 + assert dyn_edges[0]["confidence"] == "EXTRACTED" + +def test_ts_dynamic_import_source_is_function(): + """Dynamic import edge source should be the enclosing function, not the file.""" + r = extract_js(FIXTURES / "dynamic_import.ts") + node_labels = {n["id"]: n["label"] for n in r["nodes"]} + dyn_edges = [e for e in r["edges"] + if e["relation"] == "imports_from" + and "mayaengine" in e["target"].lower()] + assert len(dyn_edges) >= 1 + src_label = node_labels.get(dyn_edges[0]["source"], "") + assert "processInbound" in src_label, f"Expected processInbound as source, got {src_label}" + +def test_ts_no_dynamic_import_in_sync_fn(): + """Functions without dynamic imports should not get spurious imports_from edges.""" + r = extract_js(FIXTURES / "dynamic_import.ts") + node_ids = {n["label"]: n["id"] for n in r["nodes"]} + sync_nid = node_ids.get("syncOnly()") + if sync_nid: + sync_imports = [e for e in r["edges"] + if e["source"] == sync_nid and e["relation"] == "imports_from"] + assert len(sync_imports) == 0