feat(dart): keep a file node for part files (#4008)

Since #1098 a `part of` file's declarations are redirected to the parent
library so one library has one id namespace. The redirect also dropped
the part's own file node, so a scanned source file could not be found by
name (`explain "<part>.dart"` -> no node).

Keep the redirect and keep the node: the part gets its regular file node,
`library --includes--> part` anchored on the `part of` line, and
`part --contains--> declaration` mirrors every `defines` edge the library
gets from this part. Declaration ids and the library's `defines` edges are
unchanged. test_roadmap_bug_fixes asserted the old "no part node"
behaviour and now asserts the part's own node instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit e32f49dbad)
This commit is contained in:
BrLumen authored and safishamsi committed 2026-10-04 02:07:00 +01:00
1 parent 1dee8307f9
commit 1dcac128c2
2 files changed
+61 -6

No files matched your search

+18 -4
View File
@@ -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}
+43 -2
View File
@@ -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()