Skip to content

Commit d149ba6

Browse files
committed
fix: reduce Dart extraction noise
1 parent 7fe58b0 commit d149ba6

3 files changed

Lines changed: 533 additions & 40 deletions

File tree

graphify/extractors/dart.py

Lines changed: 138 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,94 @@ def _comment_replace(match: re.Match) -> str:
3030
return token
3131
src_clean = comment_string_pattern.sub(_comment_replace, src)
3232

33+
# Dart part files can point to their parent library through package: URIs.
34+
# Resolve those through the nearest pubspec.yaml so part declarations use
35+
# the parent library stem instead of a machine-local part-file path.
36+
def _find_package_root(start: Path) -> tuple[Path, str] | None:
37+
for parent in [start, *start.parents]:
38+
pubspec = parent / "pubspec.yaml"
39+
if not pubspec.exists():
40+
continue
41+
try:
42+
for line in pubspec.read_text(encoding="utf-8", errors="replace").splitlines():
43+
m = re.match(r"\s*name\s*:\s*([A-Za-z0-9_]+)\s*$", line)
44+
if m:
45+
return parent, m.group(1)
46+
except OSError:
47+
return None
48+
return None
49+
50+
def _resolve_part_parent(parent_ref: str) -> Path:
51+
if parent_ref.startswith("package:"):
52+
# Dart part files can name their parent via package:pkg/lib-relative path.
53+
package_ref = parent_ref[len("package:"):]
54+
package_name, _, package_path = package_ref.partition("/")
55+
package_root = _find_package_root(path.parent)
56+
if package_root and package_root[1] == package_name and package_path:
57+
return (package_root[0] / "lib" / package_path).resolve()
58+
return (path.parent / Path(package_path or package_name).name).resolve()
59+
return (path.parent / parent_ref).resolve()
60+
61+
# These SDK/common Dart types usually do not describe project relationships.
62+
# Keep them out of reference nodes unless a legacy extraction path explicitly
63+
# opts in for compatibility.
64+
dart_sdk_noise_types = {
65+
"String", "int", "double", "bool", "num", "dynamic", "Object",
66+
"List", "Map", "Set", "Iterable", "MapEntry", "Iterator",
67+
"Comparable", "Future", "FutureOr", "Stream",
68+
"void", "Function", "Never", "Null", "Type", "Enum", "Record",
69+
"DateTime", "Duration", "Uri", "Runes", "Symbol", "BigInt",
70+
"RegExp", "Pattern", "Match", "StackTrace", "Error", "Exception",
71+
}
72+
# Defensive cleanup for regex matches that accidentally include declaration
73+
# keywords in type-like strings.
74+
declaration_modifiers = {
75+
"static", "late", "final", "const", "var", "external", "abstract",
76+
"factory", "async", "base", "interface", "sealed", "mixin",
77+
}
78+
79+
def _strip_balanced_generics(text: str) -> str:
80+
current: list[str] = []
81+
depth = 0
82+
for char in text:
83+
if char == "<":
84+
depth += 1
85+
elif char == ">":
86+
depth = max(0, depth - 1)
87+
elif depth == 0:
88+
current.append(char)
89+
return "".join(current)
90+
91+
def _clean_type_name(
92+
text: str | None,
93+
*,
94+
allow_sdk_noise: bool | set[str] = False,
95+
) -> str | None:
96+
"""Normalize a type-ish Dart fragment before creating a graph node."""
97+
if not text:
98+
return None
99+
clean = text.strip().rstrip("?")
100+
clean = clean.split(".")[-1].strip()
101+
clean = _strip_balanced_generics(clean).strip().rstrip("?")
102+
if "," in clean or "(" in clean or ")" in clean:
103+
return None
104+
parts = [part for part in clean.split() if part not in declaration_modifiers]
105+
clean = " ".join(parts).strip()
106+
if not clean or clean in {"get", "set", "_"}:
107+
return None
108+
if clean.endswith(" get") or clean.endswith(" set"):
109+
return None
110+
if not re.match(r"^[A-Za-z_]\w*$", clean):
111+
return None
112+
if clean in dart_sdk_noise_types:
113+
if allow_sdk_noise is True:
114+
return clean
115+
if isinstance(allow_sdk_noise, set) and clean in allow_sdk_noise:
116+
return clean
117+
return None
118+
return clean
119+
120+
# Use stem (not str(path)) for child IDs to keep them machine-independent.
33121
stem = _file_stem(path)
34122
file_nid = _make_id(str(path))
35123

@@ -40,7 +128,7 @@ def _comment_replace(match: re.Match) -> str:
40128
parent_ref = part_of_match.group(1)
41129
if parent_ref.endswith(".dart"):
42130
try:
43-
parent_path = (path.parent / parent_ref).resolve()
131+
parent_path = _resolve_part_parent(parent_ref)
44132
if parent_path.exists():
45133
stem = _file_stem(parent_path)
46134
file_nid = _make_id(str(parent_path))
@@ -138,14 +226,17 @@ def _find_matching_brace(text: str, start_pos: int) -> int:
138226

139227
# 1. Classes, mixins, and enums declarations (with inheritance, mixins, interfaces, and generics)
140228
# Supports multiple combined modifiers (e.g., abstract base class, mixin class) without capturing "class" as a name
141-
class_pattern = r"^\s*(?:(?:abstract|sealed|base|interface|final|mixin)\s+)*(?:class|mixin|enum|extension\s+type)\s+(\w+)"
229+
class_pattern = r"^\s*(?:(?:abstract|sealed|base|interface|final|mixin)\s+)*(?:class|mixin|enum|extension\s+type(?:\s+const)?)\s+(\w+)"
142230
for m in re.finditer(class_pattern, src_clean, re.MULTILINE):
143231
class_name = m.group(1)
232+
if class_name == "_":
233+
continue
144234
class_nid = _make_id(stem, class_name)
145235
add_node(class_nid, class_name)
146236
add_edge(file_nid, class_nid, "defines")
147237

148-
# Manually parse extends/on, with, and implements in header to handle nested generics brackets balanced
238+
# Manually parse extends/on, with, and implements so nested generic
239+
# brackets do not split relation targets incorrectly.
149240
start_idx = m.end()
150241
rest = src_clean[start_idx : start_idx + 500]
151242

@@ -227,33 +318,36 @@ def _find_matching_brace(text: str, start_pos: int) -> int:
227318
interfaces_list = _split_types(header[impl_m.end():])
228319

229320
# Map extends inheritance relation
230-
if base_class:
231-
base_nid = _make_id(base_class)
232-
add_node(base_nid, base_class, source_file=None)
321+
clean_base = _clean_type_name(base_class)
322+
if clean_base:
323+
base_nid = _make_id(clean_base)
324+
add_node(base_nid, clean_base, source_file=None)
233325
add_edge(class_nid, base_nid, "inherits")
234326

235327
# Map generic type arguments (e.g. MyBloc extends Bloc<MyEvent, MyState>)
236328
if generics:
237329
for gen in _split_types(generics):
238-
gen_clean = gen.split("<")[0].strip()
239-
if gen_clean not in {"String", "int", "double", "bool", "num", "dynamic", "Object", "void"}:
330+
gen_clean = _clean_type_name(gen)
331+
if gen_clean:
240332
gen_nid = _make_id(gen_clean)
241333
add_node(gen_nid, gen_clean, source_file=None)
242334
add_edge(class_nid, gen_nid, "references")
243335

244-
# Map mixins
336+
# Dart `with` clauses are mixins, not interfaces.
245337
for mixin in mixins_list:
246-
mixin_clean = mixin.split("<")[0].strip()
247-
mixin_nid = _make_id(mixin_clean)
248-
add_node(mixin_nid, mixin_clean, source_file=None)
249-
add_edge(class_nid, mixin_nid, "mixes_in")
338+
mixin_clean = _clean_type_name(mixin)
339+
if mixin_clean:
340+
mixin_nid = _make_id(mixin_clean)
341+
add_node(mixin_nid, mixin_clean, source_file=None)
342+
add_edge(class_nid, mixin_nid, "mixes_in")
250343

251344
# Map interfaces
252345
for interface in interfaces_list:
253-
interface_clean = interface.split("<")[0].strip()
254-
interface_nid = _make_id(interface_clean)
255-
add_node(interface_nid, interface_clean, source_file=None)
256-
add_edge(class_nid, interface_nid, "implements")
346+
interface_clean = _clean_type_name(interface, allow_sdk_noise={"Object"})
347+
if interface_clean:
348+
interface_nid = _make_id(interface_clean)
349+
add_node(interface_nid, interface_clean, source_file=None)
350+
add_edge(class_nid, interface_nid, "implements")
257351

258352
# Extract class body for precise framework dependencies and event handling
259353
start_idx = m.start()
@@ -371,11 +465,11 @@ def _find_matching_brace(text: str, start_pos: int) -> int:
371465
typedef_pattern = r"^\s*typedef\s+(\w+)\s*(?:<[^>]+>)?\s*=\s*([a-zA-Z0-9_<>,.?\s]+);"
372466
for m in re.finditer(typedef_pattern, src_clean, re.MULTILINE):
373467
typedef_name = m.group(1)
374-
target_type = m.group(2).split("<")[0].split(".")[-1].strip()
375-
if target_type not in {"String", "int", "double", "bool", "num", "dynamic", "Object", "List", "Map", "Set", "void", "Function"}:
376-
typedef_nid = _make_id(stem, typedef_name)
377-
add_node(typedef_nid, typedef_name)
378-
add_edge(file_nid, typedef_nid, "defines")
468+
typedef_nid = _make_id(stem, typedef_name)
469+
add_node(typedef_nid, typedef_name)
470+
add_edge(file_nid, typedef_nid, "defines")
471+
target_type = _clean_type_name(m.group(2))
472+
if target_type:
379473
target_nid = _make_id(target_type)
380474
add_node(target_nid, target_type, source_file=None)
381475
add_edge(typedef_nid, target_nid, "references", context="typedef")
@@ -391,14 +485,22 @@ def _find_matching_brace(text: str, start_pos: int) -> int:
391485
add_node(ext_nid, label)
392486
add_edge(file_nid, ext_nid, "defines")
393487

394-
target_nid = _make_id(target_class)
395-
add_node(target_nid, target_class, source_file=None)
396-
add_edge(ext_nid, target_nid, "extends")
488+
clean_target = _clean_type_name(target_class)
489+
if clean_target:
490+
target_nid = _make_id(clean_target)
491+
add_node(target_nid, clean_target, source_file=None)
492+
add_edge(ext_nid, target_nid, "extends")
397493

398494
# 4. Top-level and class-level variable declarations (generic variables, records, late, and destructuring)
399495
# Restrict indentation to 0-2 spaces to avoid matching local variables inside functions or switch expressions
400496
var_pattern = r"^\s{0,2}(?:late\s+)?(?:(?:final|const|var)\s+)?(?:\([^)]+\)\s+|([a-zA-Z0-9_<>,.?]+(?:\s+[a-zA-Z0-9_<>,.?]+){0,3})\s+)?(?:(\w+)|(?:\w+\s*)?\(([^)]+)\))\s*(?:=|$|;)"
401497
for m in re.finditer(var_pattern, src_clean, re.MULTILINE):
498+
if re.match(
499+
r"^\s*(?:class|mixin|enum|extension|import|export|part|library|typedef)\b",
500+
m.group(0),
501+
):
502+
continue
503+
402504
var_type = m.group(1)
403505
single_name = m.group(2)
404506
destructured_names = m.group(3)
@@ -412,8 +514,8 @@ def _find_matching_brace(text: str, start_pos: int) -> int:
412514
add_node(var_nid, single_name)
413515
add_edge(file_nid, var_nid, "defines")
414516

415-
if var_type and var_type not in {"String", "int", "double", "bool", "num", "dynamic", "Object", "List", "Map", "Set", "void"}:
416-
clean_type = var_type.split("<")[0].split(".")[-1].strip()
517+
clean_type = _clean_type_name(var_type)
518+
if clean_type:
417519
type_nid = _make_id(clean_type)
418520
add_node(type_nid, clean_type, source_file=None)
419521
add_edge(file_nid, type_nid, "references", context="variable_type")
@@ -422,6 +524,8 @@ def _find_matching_brace(text: str, start_pos: int) -> int:
422524
if ":" in name:
423525
name = name.split(":")[-1].strip()
424526
if re.match(r"^[a-zA-Z_]\w*$", name) and not re.match(r"^[A-Z]", name):
527+
if name == "_":
528+
continue
425529
if name not in {"if", "for", "while", "switch", "catch", "return"}:
426530
var_nid = _make_id(stem, name)
427531
add_node(var_nid, name)
@@ -433,7 +537,7 @@ def _find_matching_brace(text: str, start_pos: int) -> int:
433537
for m in re.finditer(method_pattern, src_clean, re.MULTILINE):
434538
raw_name = m.group(1)
435539
name = raw_name.split(".")[-1]
436-
if name in {"if", "for", "while", "switch", "catch", "return", "void", "dynamic", "final", "const", "get", "set"}:
540+
if name in {"if", "for", "while", "switch", "catch", "return", "void", "dynamic", "final", "const", "get", "set", "_"}:
437541
continue
438542
if re.match(r"^[A-Z]", name):
439543
continue
@@ -516,11 +620,13 @@ def _find_matching_brace(text: str, start_pos: int) -> int:
516620
# Matches any method call with type parameters: methodName<Type>() or object.methodName<Type>()
517621
# Automatically extracts GetIt, Injectable, Riverpod, Provider, BlocProvider, and InheritedWidget type lookups!
518622
generic_call_pattern = r"\b\w+<([a-zA-Z0-9_.]+(?:<[a-zA-Z0-9_.,\s<>]+>)?)\s*>\s*\("
519-
type_blacklist = {"String", "int", "double", "bool", "num", "dynamic", "Object", "List", "Map", "Set", "Future", "Stream", "void"}
520623
for m in re.finditer(generic_call_pattern, src_clean):
521-
type_name = m.group(1).split(".")[-1].strip()
522-
clean_name = type_name.split("<")[0].strip()
523-
if clean_name not in type_blacklist:
624+
line_start = src_clean.rfind("\n", 0, m.start()) + 1
625+
line = src_clean[line_start : m.start()]
626+
if re.match(r"^\s*(?:class|mixin|enum|extension|typedef)\b", line):
627+
continue
628+
clean_name = _clean_type_name(m.group(1))
629+
if clean_name:
524630
target_nid = _make_id(clean_name)
525631
add_node(target_nid, clean_name, source_file=None)
526632
add_edge(file_nid, target_nid, "references", context="type_lookup")

tests/test_dart.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -197,15 +197,14 @@ class UserBloc extends Bloc<UserEvent, UserState> with MyMixin implements Dispos
197197
)
198198
self.assertIsNone(bad_disposable_mixes_in)
199199

200-
# E. Extensions (target class string should be global without stem, source_file is None)
200+
# E. Extensions (SDK targets are treated as graph noise, extension node remains)
201201
ext_node = next((n for n in nodes if n["label"] == "StringExtensions"), None)
202202
self.assertIsNotNone(ext_node)
203203

204204
extends_string = next(
205205
(e for e in edges if e["source"] == ext_node["id"] and e["relation"] == "extends"), None
206206
)
207-
self.assertIsNotNone(extends_string)
208-
self.assertEqual(extends_string["target"], "string")
207+
self.assertIsNone(extends_string)
209208

210209
# F. Variable declarations
211210
provider_var = next((n for n in nodes if n["label"] == "authServiceProvider"), None)

0 commit comments

Comments
 (0)