diff --git a/graphify/extractors/dart.py b/graphify/extractors/dart.py index a8b8031a3..a066e5f3d 100644 --- a/graphify/extractors/dart.py +++ b/graphify/extractors/dart.py @@ -45,6 +45,7 @@ def extract_dart(path: Path) -> dict: # Check if this is a part-of file and redirect to parent part_of_match = re.search(r"^\s*part\s+of\s+['\"]([^'\"]+)['\"]", src_clean, re.MULTILINE) is_part = False + part_of_line = None if part_of_match: parent_ref = part_of_match.group(1) if parent_ref.endswith(".dart"): @@ -54,13 +55,16 @@ def extract_dart(path: Path) -> dict: stem = _file_stem(parent_path) file_nid = _make_id(str(parent_path)) is_part = True + part_of_line = _line_at(part_of_match.start(1)) except Exception: pass - nodes = [] - if not is_part: - nodes.append({"id": file_nid, "label": path.name, "file_type": "code", - "source_file": str(path), "source_location": None}) + # Every scanned file keeps its own file node. For a part file the symbols + # still belong to the library (file_nid above); the part's node is linked + # from the library and to its symbols at the end of extraction. + own_nid = _make_id(str(path)) + nodes = [{"id": own_nid, "label": path.name, "file_type": "code", + "source_file": str(path), "source_location": None}] edges = [] defined: set[str] = set() @@ -561,4 +565,14 @@ def extract_dart(path: Path) -> dict: add_edge(file_nid, target_nid, "references", context="type_lookup", line=_line_at(m.start(1))) + if is_part: + # The library includes this part; the part physically contains the + # declarations the library defines from it. Ids stay in the library's + # namespace, so the library is not split in two. + part_edges = [{**e, "source": own_nid, "relation": "contains"} + for e in edges + if e["source"] == file_nid and e["relation"] == "defines"] + add_edge(file_nid, own_nid, "includes", line=part_of_line) + edges.extend(part_edges) + return {"nodes": nodes, "edges": edges} diff --git a/tests/test_dart.py b/tests/test_dart.py index c40ace25f..e3b01ce2d 100644 --- a/tests/test_dart.py +++ b/tests/test_dart.py @@ -588,9 +588,11 @@ class TestDart(unittest.TestCase): nodes = result["nodes"] edges = result["edges"] - # A. Bug D redirect: No child file node should be created in nodes + # A. Bug D redirect: the part keeps its own file node (so it can be looked + # up by name), but its symbols are redirected to the parent library below. child_node = next((n for n in nodes if n["label"] == "child_part.dart"), None) - self.assertIsNone(child_node) + self.assertIsNotNone(child_node) + self.assertEqual(child_node["id"], _make_id(str(child_file))) # B. Check that defines edge source is parent file ID parent_fid = _make_id(str(parent_file.resolve())) @@ -696,5 +698,44 @@ class TestDart(unittest.TestCase): self.assertEqual(inherits["source_location"], "L3") + def test_part_file_keeps_its_own_node(self): + """A `part of` file is a real source file: it must be findable by name and + linked to its library and its declarations, while those declarations keep + the library's id namespace (Bug D redirect).""" + lib_file = self.temp_path / "settlement.dart" + lib_file.write_text("part 'derive.dart';\n\nclass Settlement {}\n", encoding="utf-8") + part_file = self.temp_path / "derive.dart" + part_file.write_text(textwrap.dedent("""\ + part of 'settlement.dart'; + + class DeriveHelper {} + + void deriveAll() {} + """), encoding="utf-8") + + result = extract_dart(part_file) + lib_nid = _make_id(str(lib_file.resolve())) + part_nid = _make_id(str(part_file)) + + part_node = next(n for n in result["nodes"] if n["id"] == part_nid) + self.assertEqual(part_node["label"], "derive.dart") + self.assertEqual(part_node["source_file"], str(part_file)) + + includes = [e for e in result["edges"] if e["relation"] == "includes"] + self.assertEqual([(e["source"], e["target"]) for e in includes], [(lib_nid, part_nid)]) + self.assertEqual(includes[0]["source_location"], "L1") + + defined = {e["target"] for e in result["edges"] + if e["source"] == lib_nid and e["relation"] == "defines"} + contained = {e["target"] for e in result["edges"] + if e["source"] == part_nid and e["relation"] == "contains"} + labels = {n["id"]: n["label"] for n in result["nodes"]} + self.assertEqual({labels[t] for t in defined}, {"DeriveHelper", "deriveAll"}) + self.assertEqual(contained, defined) + # Ids stay in the library's namespace: nothing is minted under the part's stem. + part_stem = _make_id(_file_stem(part_file)) + for nid in defined: + self.assertFalse(nid.startswith(part_stem + "_"), nid) + if __name__ == "__main__": unittest.main()