diff --git a/graphify/extract.py b/graphify/extract.py index 937fa07..184facc 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 a9006c9..8e98c1d 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 593fc7c..1843262 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}"