From a1dc610079970e450ebc0f7d5360cfc8b63f9066 Mon Sep 17 00:00:00 2001 From: Yalkowni Date: Mon, 27 Apr 2026 16:23:38 -0700 Subject: [PATCH 1/3] 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 357e4302f..937fa075e 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 000000000..a9006c97f --- /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 680bb4e2d..593fc7c58 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 From 88e2b832f7037d4649b770638d295d2da00c649d Mon Sep 17 00:00:00 2001 From: Yalkowni Date: Mon, 27 Apr 2026 16:27:28 -0700 Subject: [PATCH 2/3] fix: drop Python <3.14 upper bound --- pyproject.toml | 136 ++++++++++++++++++++++++------------------------- 1 file changed, 68 insertions(+), 68 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index fcbc304cb..8bfe7ff78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,68 +1,68 @@ -[build-system] -requires = ["setuptools>=68"] -build-backend = "setuptools.build_meta" - -[project] -name = "graphifyy" -version = "0.5.1" -description = "AI coding assistant skill (Claude Code, Codex, OpenCode, Cursor, Gemini CLI, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, Google Antigravity) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph" -readme = "README.md" -license = { file = "LICENSE" } -keywords = ["claude", "claude-code", "codex", "opencode", "cursor", "gemini", "aider", "kiro", "knowledge-graph", "rag", "graphrag", "obsidian", "community-detection", "tree-sitter", "leiden", "llm"] -requires-python = ">=3.10,<3.14" -dependencies = [ - "networkx", - "tree-sitter>=0.23.0", - "tree-sitter-python", - "tree-sitter-javascript", - "tree-sitter-typescript", - "tree-sitter-go", - "tree-sitter-rust", - "tree-sitter-java", - "tree-sitter-c", - "tree-sitter-cpp", - "tree-sitter-ruby", - "tree-sitter-c-sharp", - "tree-sitter-kotlin", - "tree-sitter-scala", - "tree-sitter-php", - "tree-sitter-swift", - "tree-sitter-lua", - "tree-sitter-zig", - "tree-sitter-powershell", - "tree-sitter-elixir", - "tree-sitter-objc", - "tree-sitter-julia", - "tree-sitter-verilog", -] - -[project.urls] -Homepage = "https://github.com/safishamsi/graphify" -Repository = "https://github.com/safishamsi/graphify" -Issues = "https://github.com/safishamsi/graphify/issues" - -[project.optional-dependencies] -mcp = ["mcp"] -neo4j = ["neo4j"] -pdf = ["pypdf", "html2text"] -watch = ["watchdog"] -svg = ["matplotlib"] -leiden = ["graspologic; python_version < '3.13'"] -office = ["python-docx", "openpyxl"] -video = ["faster-whisper", "yt-dlp"] -all = ["mcp", "neo4j", "pypdf", "html2text", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper", "yt-dlp", "matplotlib"] - -[project.scripts] -graphify = "graphify.__main__:main" - -[tool.uv] -# Install via: uv tool install graphifyy -# Run without installing: uvx graphifyy install -package = true - -[tool.setuptools.packages.find] -where = ["."] -include = ["graphify*"] - -[tool.setuptools.package-data] -graphify = ["skill.md", "skill-codex.md", "skill-opencode.md", "skill-aider.md", "skill-copilot.md", "skill-claw.md", "skill-windows.md", "skill-droid.md", "skill-trae.md", "skill-kiro.md", "skill-vscode.md"] +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "graphifyy" +version = "0.5.1" +description = "AI coding assistant skill (Claude Code, Codex, OpenCode, Cursor, Gemini CLI, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, Google Antigravity) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph" +readme = "README.md" +license = { file = "LICENSE" } +keywords = ["claude", "claude-code", "codex", "opencode", "cursor", "gemini", "aider", "kiro", "knowledge-graph", "rag", "graphrag", "obsidian", "community-detection", "tree-sitter", "leiden", "llm"] +requires-python = ">=3.10" +dependencies = [ + "networkx", + "tree-sitter>=0.23.0", + "tree-sitter-python", + "tree-sitter-javascript", + "tree-sitter-typescript", + "tree-sitter-go", + "tree-sitter-rust", + "tree-sitter-java", + "tree-sitter-c", + "tree-sitter-cpp", + "tree-sitter-ruby", + "tree-sitter-c-sharp", + "tree-sitter-kotlin", + "tree-sitter-scala", + "tree-sitter-php", + "tree-sitter-swift", + "tree-sitter-lua", + "tree-sitter-zig", + "tree-sitter-powershell", + "tree-sitter-elixir", + "tree-sitter-objc", + "tree-sitter-julia", + "tree-sitter-verilog", +] + +[project.urls] +Homepage = "https://github.com/safishamsi/graphify" +Repository = "https://github.com/safishamsi/graphify" +Issues = "https://github.com/safishamsi/graphify/issues" + +[project.optional-dependencies] +mcp = ["mcp"] +neo4j = ["neo4j"] +pdf = ["pypdf", "html2text"] +watch = ["watchdog"] +svg = ["matplotlib"] +leiden = ["graspologic; python_version < '3.13'"] +office = ["python-docx", "openpyxl"] +video = ["faster-whisper", "yt-dlp"] +all = ["mcp", "neo4j", "pypdf", "html2text", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper", "yt-dlp", "matplotlib"] + +[project.scripts] +graphify = "graphify.__main__:main" + +[tool.uv] +# Install via: uv tool install graphifyy +# Run without installing: uvx graphifyy install +package = true + +[tool.setuptools.packages.find] +where = ["."] +include = ["graphify*"] + +[tool.setuptools.package-data] +graphify = ["skill.md", "skill-codex.md", "skill-opencode.md", "skill-aider.md", "skill-copilot.md", "skill-claw.md", "skill-windows.md", "skill-droid.md", "skill-trae.md", "skill-kiro.md", "skill-vscode.md"] From 563ee80494a9da01a7890504f1a8c63bdea37347 Mon Sep 17 00:00:00 2001 From: Yalkowni Date: Mon, 27 Apr 2026 16:41:19 -0700 Subject: [PATCH 3/3] fix(extract): skip dynamic template literals in import() args import(`./handlers/${name}`) previously produced a garbage edge to a path containing the unresolved ${name} expression. Now detects template_substitution child nodes and breaks without emitting an edge. Static template literals (no interpolation) still resolve correctly. Adds 2 new tests: one asserting dynamic templates produce no edge, one asserting static templates resolve like plain strings. --- graphify/extract.py | 83 +++++++++++++++++--------------- tests/fixtures/dynamic_import.ts | 14 +++++- tests/test_languages.py | 18 +++++++ 3 files changed, 76 insertions(+), 39 deletions(-) diff --git a/graphify/extract.py b/graphify/extract.py index 937fa075e..184faccb5 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -255,46 +255,53 @@ def _dynamic_import_js(node, source: bytes, caller_nid: str, str_path: str, edge 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: + if arg.type == "template_string": + # Skip dynamic template literals — path can't be statically resolved + if any(c.type == "template_substitution" for c in arg.children): 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, - }) + raw = _read_text(arg, source).strip("`") + elif arg.type == "string": + raw = _read_text(arg, source).strip("'\" ") + else: + continue + 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 diff --git a/tests/fixtures/dynamic_import.ts b/tests/fixtures/dynamic_import.ts index a9006c97f..8e98c1d50 100644 --- a/tests/fixtures/dynamic_import.ts +++ b/tests/fixtures/dynamic_import.ts @@ -13,8 +13,20 @@ async function pollMessages(orgId: string) { await commsQueue.add('check-inbound', { orgId }); } +async function loadHandler(handlerName: string) { + // dynamic template literal — path not statically resolvable, should produce no edge + const mod = await import(`./handlers/${handlerName}`); + return mod.default; +} + +async function loadStatic() { + // static template literal (no interpolation) — should resolve like a plain string + const { helper } = await import(`./staticHelper`); + return helper; +} + function syncOnly() { logger.info('no dynamic imports here'); } -export { processInbound, pollMessages, syncOnly }; +export { processInbound, pollMessages, loadHandler, loadStatic, syncOnly }; diff --git a/tests/test_languages.py b/tests/test_languages.py index 593fc7c58..18432621e 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -607,3 +607,21 @@ def test_ts_no_dynamic_import_in_sync_fn(): sync_imports = [e for e in r["edges"] if e["source"] == sync_nid and e["relation"] == "imports_from"] assert len(sync_imports) == 0 + +def test_ts_dynamic_template_literal_skipped(): + """Dynamic template literals (with ${}) must not produce an imports_from edge.""" + r = extract_js(FIXTURES / "dynamic_import.ts") + targets = {e["target"] for e in r["edges"] if e["relation"] == "imports_from"} + # loadHandler uses `./handlers/${handlerName}` — no static path, must be absent + assert not any("handler" in t.lower() and "$" in t for t in targets), \ + f"Garbage edge from dynamic template literal found: {targets}" + # More robust: no target should contain a brace character + assert not any("{" in t or "}" in t for t in targets), \ + f"Target contains unresolved template expression: {targets}" + +def test_ts_static_template_literal_resolved(): + """Static template literals (no ${}) should resolve the same as a plain string.""" + r = extract_js(FIXTURES / "dynamic_import.ts") + targets = {e["target"] for e in r["edges"] if e["relation"] == "imports_from"} + assert any("statichelper" in t.lower() for t in targets), \ + f"Static template literal import not resolved: {targets}"