diff --git a/graphify/extractors/dart.py b/graphify/extractors/dart.py index a066e5f3d..883954c2d 100644 --- a/graphify/extractors/dart.py +++ b/graphify/extractors/dart.py @@ -539,17 +539,28 @@ def extract_dart(path: Path) -> dict: line=_line_at(brace_pos + om.start(1))) # 6. Imports and Exports - for m in re.finditer(r"""^\s*import\s+['"]([^'"]+)['"]""", src_clean, re.MULTILINE): - pkg = m.group(1) - tgt_nid = _make_id(pkg) - add_node(tgt_nid, pkg, source_file=None) - add_edge(file_nid, tgt_nid, "imports", line=_line_at(m.start(1))) - - for m in re.finditer(r"""^\s*export\s+['"]([^'"]+)['"]""", src_clean, re.MULTILINE): - pkg = m.group(1) - tgt_nid = _make_id(pkg) - add_node(tgt_nid, pkg, source_file=None) - add_edge(file_nid, tgt_nid, "exports", line=_line_at(m.start(1))) + # A directive may carry configurable URIs that pick a platform-specific + # library: `export 'x_stub.dart' if (dart.library.io) 'x_io.dart';`. The + # default URI keeps its plain edge; every `if (...)` alternative is emitted + # too, tagged context="conditional_uri", because the real implementation + # usually lives there. A string literal inside the condition itself + # (`if (dart.library.io == 'true')`) is part of the test, not a URI. + quoted = r"""(?:'[^']*'|"[^"]*")""" + condition = rf"""if\s*\(\s*[\w.]+\s*(?:==\s*{quoted}\s*)?\)\s*""" + branch_re = re.compile(rf"""{condition}(['"])(?P[^'"]+)\1""") + for kind, relation in (("import", "imports"), ("export", "exports")): + directive_re = re.compile( + rf"""^\s*{kind}\s+(['"])(?P[^'"]+)\1(?P(?:\s*{condition}{quoted})*)""", + re.MULTILINE, + ) + for m in directive_re.finditer(src_clean): + uris: list[tuple[str, int, str | None]] = [(m.group("uri"), m.start("uri"), None)] + for bm in branch_re.finditer(m.group("branches")): + uris.append((bm.group("uri"), m.start("branches") + bm.start("uri"), "conditional_uri")) + for pkg, offset, context in uris: + tgt_nid = _make_id(pkg) + add_node(tgt_nid, pkg, source_file=None) + add_edge(file_nid, tgt_nid, relation, context=context, line=_line_at(offset)) # 7. Generic Invocations / Type Lookups (Universal Dependency Lookup) # Matches any method call with type parameters: methodName() or object.methodName() diff --git a/tests/test_dart.py b/tests/test_dart.py index e3b01ce2d..d54f4b6b6 100644 --- a/tests/test_dart.py +++ b/tests/test_dart.py @@ -737,5 +737,46 @@ class TestDart(unittest.TestCase): for nid in defined: self.assertFalse(nid.startswith(part_stem + "_"), nid) + def test_conditional_uris_emit_every_branch(self): + """A configurable export/import must link every platform branch, not only + the default URI; a string literal inside the condition is not a URI.""" + code = textwrap.dedent("""\ + import 'conn_stub.dart' + if (dart.library.io) 'conn_io.dart' + if (dart.library.js_interop) 'conn_web.dart'; + import 'flags.dart' if (app.flavor == 'prod') 'flags_prod.dart'; + export 'service_stub.dart' if (dart.library.io) "service_io.dart"; + """) + path = self.temp_path / "facade.dart" + path.write_text(code, encoding="utf-8") + result = extract_dart(path) + file_nid = _make_id(str(path)) + + def targets(relation, context): + return { + e["target"] + for e in result["edges"] + if e["source"] == file_nid + and e["relation"] == relation + and e.get("context") == context + } + + self.assertEqual(targets("imports", None), {_make_id("conn_stub.dart"), _make_id("flags.dart")}) + self.assertEqual( + targets("imports", "conditional_uri"), + {_make_id("conn_io.dart"), _make_id("conn_web.dart"), _make_id("flags_prod.dart")}, + ) + self.assertEqual(targets("exports", None), {_make_id("service_stub.dart")}) + self.assertEqual(targets("exports", "conditional_uri"), {_make_id("service_io.dart")}) + labels = {n["label"] for n in result["nodes"]} + self.assertNotIn("prod", labels) + loc = { + e["target"]: e["source_location"] + for e in result["edges"] + if e["relation"] in ("imports", "exports") + } + self.assertEqual(loc[_make_id("conn_web.dart")], "L3") + self.assertEqual(loc[_make_id("service_io.dart")], "L5") + if __name__ == "__main__": unittest.main()