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.
This commit is contained in:
Yalkowni
2026-04-27 16:41:19 -07:00
parent 88e2b832f7
commit 563ee80494
3 changed files with 76 additions and 39 deletions
+45 -38
View File
@@ -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
+13 -1
View File
@@ -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 };
+18
View File
@@ -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}"