feat(dart): modernize AST parser, support nested generics and part-of redirection (#1098)

Modernizes the Dart extractor: comment stripping, `part of` redirection, nested-generic-aware extends/with/implements parsing, generic type-argument mapping, and generic call detection.

Verified against current v8: merges cleanly, full suite 1530 passed, and inheritance edges connect to same-file class nodes with no ghost-node splits or dangling edges (consistent with the #1033/#1096 node-ID invariants).
This commit is contained in:
Alexey Z
2026-06-01 16:13:36 +01:00
committed by GitHub
parent a8005c218a
commit ec3cb5eb3e
2 changed files with 1105 additions and 35 deletions
+503 -35
View File
@@ -1,5 +1,6 @@
"""Deterministic structural extraction from source code using tree-sitter. Outputs nodes+edges dicts.""" """Deterministic structural extraction from source code using tree-sitter. Outputs nodes+edges dicts."""
from __future__ import annotations from __future__ import annotations
import importlib import importlib
import json import json
import os import os
@@ -8,7 +9,8 @@ import sys
import unicodedata import unicodedata
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from typing import Callable, Any from typing import Any, Callable
from .cache import load_cached, save_cached from .cache import load_cached, save_cached
from .mcp_ingest import extract_mcp_config, is_mcp_config_path from .mcp_ingest import extract_mcp_config, is_mcp_config_path
@@ -4090,56 +4092,522 @@ def extract_blade(path: Path) -> dict:
def extract_dart(path: Path) -> dict: def extract_dart(path: Path) -> dict:
"""Extract classes, mixins, functions, imports, and calls from a .dart file using regex.""" """Extract classes, mixins, functions, imports, generic calls, and annotations from a .dart file using regex."""
try: try:
src = path.read_text(encoding="utf-8", errors="replace") src = path.read_text(encoding="utf-8", errors="replace")
except OSError: except OSError:
return {"error": f"cannot read {path}"} return {"error": f"cannot read {path}"}
# Use stem (not str(path)) for child IDs to keep them machine-independent. # Remove inline and multi-line comments while leaving string literals untouched to prevent stripping URLs/paths inside strings
comment_string_pattern = re.compile(
r'"""(?:\\.|[\s\S])*?"""'
r"|'''(?:\\.|[\s\S])*?'''"
r'|"(?:\\.|[^"\\])*"'
r"|'(?:\\.|[^'\\])*'"
r"|/\*[\s\S]*?\*/"
r"|//[^\n]*"
)
def _comment_replace(match: re.Match) -> str:
token = match.group(0)
if token.startswith("/"):
return ""
return token
src_clean = comment_string_pattern.sub(_comment_replace, src)
stem = _file_stem(path) stem = _file_stem(path)
file_nid = _make_id(str(path)) file_nid = _make_id(str(path))
nodes = [{"id": file_nid, "label": path.name, "file_type": "code",
"source_file": str(path), "source_location": None}] # 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
if part_of_match:
parent_ref = part_of_match.group(1)
if parent_ref.endswith(".dart"):
try:
parent_path = (path.parent / parent_ref).resolve()
if parent_path.exists():
stem = _file_stem(parent_path)
file_nid = _make_id(str(parent_path))
is_part = True
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})
edges = [] edges = []
defined: set[str] = set() defined: set[str] = set()
# Classes and mixins def add_node(nid: str, label: str, ftype: str = "code", source_file: str | None = str(path)) -> None:
for m in re.finditer(r"^\s*(?:abstract\s+)?(?:class|mixin)\s+(\w+)", src, re.MULTILINE):
nid = _make_id(stem, m.group(1))
if nid not in defined: if nid not in defined:
nodes.append({"id": nid, "label": m.group(1), "file_type": "code", nodes.append({"id": nid, "label": label, "file_type": ftype,
"source_file": str(path), "source_location": None}) "source_file": source_file, "source_location": None})
edges.append({"source": file_nid, "target": nid, "relation": "defines",
"confidence": "EXTRACTED", "confidence_score": 1.0,
"source_file": str(path), "source_location": None, "weight": 1.0})
defined.add(nid) defined.add(nid)
# Top-level and member functions/methods def add_edge(src_id: str, tgt_id: str, relation: str, weight: float = 1.0, context: str | None = None) -> None:
for m in re.finditer(r"^\s*(?:static\s+|async\s+)?(?:\w+\s+)+(\w+)\s*\(", src, re.MULTILINE): edge = {"source": src_id, "target": tgt_id, "relation": relation,
name = m.group(1) "confidence": "EXTRACTED", "confidence_score": 1.0,
if name in {"if", "for", "while", "switch", "catch", "return"}: "source_file": str(path), "source_location": None, "weight": weight}
if context:
edge["context"] = context
edges.append(edge)
def _split_types(text: str) -> list[str]:
parts = []
current = []
depth = 0
for char in text:
if char == "<":
depth += 1
current.append(char)
elif char == ">":
depth -= 1
current.append(char)
elif char == "," and depth == 0:
parts.append("".join(current).strip())
current = []
else:
current.append(char)
if current:
parts.append("".join(current).strip())
return [p for p in parts if p]
def _find_matching_brace(text: str, start_pos: int) -> int:
brace_count = 0
in_double_quote = False
in_single_quote = False
escape = False
first_brace = text.find("{", start_pos)
if first_brace == -1:
return len(text)
brace_count = 1
i = first_brace + 1
n = len(text)
while i < n:
char = text[i]
if escape:
escape = False
i += 1
continue
if char == "\\":
escape = True
i += 1
continue
if text[i:i+3] == '"""' and not in_single_quote:
i += 3
end = text.find('"""', i)
i = end + 3 if end != -1 else n
continue
if text[i:i+3] == "'''" and not in_double_quote:
i += 3
end = text.find("'''", i)
i = end + 3 if end != -1 else n
continue
if char == '"' and not in_single_quote:
in_double_quote = not in_double_quote
elif char == "'" and not in_double_quote:
in_single_quote = not in_single_quote
elif not in_double_quote and not in_single_quote:
if char == "{":
brace_count += 1
elif char == "}":
brace_count -= 1
if brace_count == 0:
return i + 1
i += 1
return len(text)
# 1. Classes, mixins, and enums declarations (with inheritance, mixins, interfaces, and generics)
# Supports multiple combined modifiers (e.g., abstract base class, mixin class) without capturing "class" as a name
class_pattern = r"^\s*(?:(?:abstract|sealed|base|interface|final|mixin)\s+)*(?:class|mixin|enum|extension\s+type)\s+(\w+)"
for m in re.finditer(class_pattern, src_clean, re.MULTILINE):
class_name = m.group(1)
class_nid = _make_id(stem, class_name)
add_node(class_nid, class_name)
add_edge(file_nid, class_nid, "defines")
# Manually parse extends/on, with, and implements in header to handle nested generics brackets balanced
start_idx = m.end()
rest = src_clean[start_idx : start_idx + 500]
# Skip class generic parameters
if rest.lstrip().startswith("<"):
offset = rest.find("<")
depth = 1
i = offset + 1
while i < len(rest) and depth > 0:
if rest[i] == "<": depth += 1
elif rest[i] == ">": depth -= 1
i += 1
rest = rest[i:]
# Skip primary constructor (e.g. extension type MyExt(int id))
if rest.lstrip().startswith("("):
offset = rest.find("(")
depth = 1
i = offset + 1
while i < len(rest) and depth > 0:
if rest[i] == "(": depth += 1
elif rest[i] == ")": depth -= 1
i += 1
rest = rest[i:]
header_end = rest.find("{")
if header_end == -1:
header_end = rest.find(";")
if header_end == -1:
header_end = len(rest)
header = rest[:header_end]
base_class = None
generics = None
mixins_list = []
interfaces_list = []
# Parse extends or on
extends_m = re.search(r"^\s*(?:extends|on)\s+([a-zA-Z0-9_.]+)", header)
if extends_m:
base_class = extends_m.group(1)
rest_header = header[extends_m.end():]
if rest_header.strip().startswith("<"):
start_idx = rest_header.find("<")
depth = 1
i = start_idx + 1
while i < len(rest_header) and depth > 0:
if rest_header[i] == "<":
depth += 1
elif rest_header[i] == ">":
depth -= 1
if depth == 0:
generics = rest_header[start_idx + 1 : i]
break
i += 1
if generics is not None:
header = rest_header[i + 1:]
else:
header = rest_header
else:
header = rest_header
# Parse with
with_m = re.search(r"^\s*with\s+", header)
if with_m:
rest_header = header[with_m.end():]
impl_idx = rest_header.find("implements")
if impl_idx != -1:
mixins_str = rest_header[:impl_idx]
header = rest_header[impl_idx:]
else:
mixins_str = rest_header
header = ""
mixins_list = _split_types(mixins_str)
# Parse implements
impl_m = re.search(r"^\s*implements\s+", header)
if impl_m:
interfaces_list = _split_types(header[impl_m.end():])
# Map extends inheritance relation
if base_class:
base_nid = _make_id(base_class)
add_node(base_nid, base_class, source_file=None)
add_edge(class_nid, base_nid, "inherits")
# Map generic type arguments (e.g. MyBloc extends Bloc<MyEvent, MyState>)
if generics:
for gen in _split_types(generics):
gen_clean = gen.split("<")[0].strip()
if gen_clean not in {"String", "int", "double", "bool", "num", "dynamic", "Object", "void"}:
gen_nid = _make_id(gen_clean)
add_node(gen_nid, gen_clean, source_file=None)
add_edge(class_nid, gen_nid, "references")
# Map mixins
for mixin in mixins_list:
mixin_clean = mixin.split("<")[0].strip()
mixin_nid = _make_id(mixin_clean)
add_node(mixin_nid, mixin_clean, source_file=None)
add_edge(class_nid, mixin_nid, "implements")
# Map interfaces
for interface in interfaces_list:
interface_clean = interface.split("<")[0].strip()
interface_nid = _make_id(interface_clean)
add_node(interface_nid, interface_clean, source_file=None)
add_edge(class_nid, interface_nid, "implements")
# Extract class body for precise framework dependencies and event handling
start_idx = m.start()
brace_pos = src_clean.find("{", start_idx)
semi_pos = src_clean.find(";", start_idx)
has_body = brace_pos != -1
if has_body and semi_pos != -1 and semi_pos < brace_pos:
has_body = False
if has_body:
end_pos = _find_matching_brace(src_clean, start_idx)
class_body = src_clean[brace_pos:end_pos]
# Bloc event registration: on<MyEvent>()
for em in re.finditer(r"\bon<(\w+)>\s*\(", class_body):
event_name = em.group(1)
event_nid = _make_id(event_name)
add_node(event_nid, event_name, source_file=None)
add_edge(class_nid, event_nid, "calls", context="bloc_event")
# Bloc state emissions: emit(MyState) or yield MyState
for sm in re.finditer(r"\b(?:emit|yield)\s*\(?\s*(?:const\s+)?([A-Z]\w*)\b", class_body):
state_name = sm.group(1)
if state_name not in {"String", "List", "Map", "Set", "Future", "Stream", "Object"}:
state_nid = _make_id(state_name)
add_node(state_nid, state_name, source_file=None)
add_edge(class_nid, state_nid, "calls", context="emit_state")
# Bloc event additions: widget.add(MyEvent()) or bloc.add(MyEvent())
for am in re.finditer(r"\b(?:\w*[Bb]loc\w*|context\.read<\w+>\(\))\.add\(\s*(?:const\s+)?([A-Z]\w*)\b", class_body):
event_name = am.group(1)
if event_name not in {"String", "List", "Map", "Set", "Future", "Stream", "Object"}:
event_nid = _make_id(event_name)
add_node(event_nid, event_name, source_file=None)
add_edge(class_nid, event_nid, "calls", context="bloc_add_event")
# Riverpod provider references: ref.watch(provider)
for rm in re.finditer(r"\bref\.(?:watch|read|listen)\s*\(\s*(\w+)\b", class_body):
provider_name = rm.group(1)
provider_nid = _make_id(provider_name)
add_node(provider_nid, provider_name, source_file=None)
add_edge(class_nid, provider_nid, "references", context="riverpod_reference")
# Widget to Bloc references: BlocBuilder<MyBloc, ...>
for bm in re.finditer(r"\bBloc(?:Builder|Listener|Consumer|Provider|Selector)\s*<\s*([a-zA-Z0-9_]+)\b", class_body):
bloc_name = bm.group(1)
if bloc_name not in {"String", "int", "double", "bool", "num", "dynamic", "Object", "void"}:
bloc_nid = _make_id(bloc_name)
add_node(bloc_nid, bloc_name, source_file=None)
add_edge(class_nid, bloc_nid, "references", context="bloc_widget_binding")
# context.read<MyBloc>() or BlocProvider.of<MyBloc>(context)
for lm in re.finditer(r"\b(?:read|watch|select|of)\s*<([a-zA-Z0-9_]+)>", class_body):
bloc_name = lm.group(1)
if bloc_name not in {"String", "int", "double", "bool", "num", "dynamic", "Object", "void"}:
bloc_nid = _make_id(bloc_name)
add_node(bloc_nid, bloc_name, source_file=None)
add_edge(class_nid, bloc_nid, "references", context="bloc_lookup")
# 2. Annotations mapping (class, mixin, enum, or function level annotations)
# Support: @riverpod, @Riverpod(...), @injectable, @singleton, @RoutePage(), @HiveType(typeId: 0), @RestApi()
# Matches `@annotation` and links it to the next class/mixin/enum/function declaration in the file
annotation_pattern = r"@(\w+)(?:\([^)]*\))?"
for am in re.finditer(annotation_pattern, src_clean):
annotation_name = am.group(1)
if annotation_name in {"override", "deprecated", "required", "protected", "mustCallSuper"}:
continue
annotation_pos = am.end()
intervening_text = src_clean[annotation_pos : annotation_pos + 300]
class_m = re.search(r"^\s*(?:(?:abstract|sealed|base|interface|final|mixin)\s+)*(?:class|mixin|enum|extension\s+type)\s+(\w+)", intervening_text, re.MULTILINE)
func_m = re.search(r"^\s*(?:factory\s+|static\s+|async\s+|external\s+|abstract\s+)?(?:\([^)]+\)|[a-zA-Z0-9_<>,.?]+)(?:\s+[a-zA-Z0-9_<>,.?]+){0,3}\s+(\w+)\s*\(", intervening_text, re.MULTILINE)
target_nid = None
target_name = None
target_type = None
if class_m and func_m:
if class_m.start() < func_m.start():
target_name = class_m.group(1)
target_type = "class"
target_nid = _make_id(stem, target_name)
else:
target_name = func_m.group(1)
target_type = "function"
target_nid = _make_id(stem, target_name)
elif class_m:
target_name = class_m.group(1)
target_type = "class"
target_nid = _make_id(stem, target_name)
elif func_m:
target_name = func_m.group(1)
target_type = "function"
target_nid = _make_id(stem, target_name)
if target_nid and target_name:
actual_intervening = intervening_text[:min(class_m.start() if class_m else 300, func_m.start() if func_m else 300)]
if ";" not in actual_intervening and "}" not in actual_intervening and "{" not in actual_intervening:
annotation_nid = _make_id("annotation", annotation_name.lower())
add_node(annotation_nid, f"@{annotation_name}", ftype="concept", source_file=None)
add_edge(target_nid, annotation_nid, "configures")
# Riverpod specific provider generation mapping (supports camelCase class and functional providers)
if annotation_name.lower() == "riverpod":
if target_type == "class":
provider_name = target_name[0].lower() + target_name[1:] + "Provider" if len(target_name) > 1 else target_name.lower() + "Provider"
else:
provider_name = target_name + "Provider"
provider_nid = _make_id(provider_name)
add_node(provider_nid, provider_name, ftype="concept", source_file=str(path))
add_edge(target_nid, provider_nid, "defines", context="riverpod_provider")
# 2.5 Typedefs (Type Aliases)
typedef_pattern = r"^\s*typedef\s+(\w+)\s*(?:<[^>]+>)?\s*=\s*([a-zA-Z0-9_<>,.?\s]+);"
for m in re.finditer(typedef_pattern, src_clean, re.MULTILINE):
typedef_name = m.group(1)
target_type = m.group(2).split("<")[0].split(".")[-1].strip()
if target_type not in {"String", "int", "double", "bool", "num", "dynamic", "Object", "List", "Map", "Set", "void", "Function"}:
typedef_nid = _make_id(stem, typedef_name)
add_node(typedef_nid, typedef_name)
add_edge(file_nid, typedef_nid, "defines")
target_nid = _make_id(target_type)
add_node(target_nid, target_type, source_file=None)
add_edge(typedef_nid, target_nid, "references", context="typedef")
# 3. Extensions (extension MyExt on MyClass)
ext_pattern = r"^\s{0,4}extension\s+(\w+)?(?:<[^>]+>)?\s+on\s+(\w+)"
for m in re.finditer(ext_pattern, src_clean, re.MULTILINE):
ext_name = m.group(1) or f"{stem}_anonymous_extension"
target_class = m.group(2)
ext_nid = _make_id(stem, ext_name)
label = m.group(1) or f"Extension on {target_class}"
add_node(ext_nid, label)
add_edge(file_nid, ext_nid, "defines")
target_nid = _make_id(target_class)
add_node(target_nid, target_class, source_file=None)
add_edge(ext_nid, target_nid, "extends")
# 4. Top-level and class-level variable declarations (generic variables, records, late, and destructuring)
# Restrict indentation to 0-2 spaces to avoid matching local variables inside functions or switch expressions
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*(?:=|$|;)"
for m in re.finditer(var_pattern, src_clean, re.MULTILINE):
var_type = m.group(1)
single_name = m.group(2)
destructured_names = m.group(3)
if not re.match(r"^\s*(?:late|final|const|var)\b", m.group(0)) and not var_type:
continue
if single_name:
if single_name not in {"if", "for", "while", "switch", "catch", "return"}:
var_nid = _make_id(stem, single_name)
add_node(var_nid, single_name)
add_edge(file_nid, var_nid, "defines")
if var_type and var_type not in {"String", "int", "double", "bool", "num", "dynamic", "Object", "List", "Map", "Set", "void"}:
clean_type = var_type.split("<")[0].split(".")[-1].strip()
type_nid = _make_id(clean_type)
add_node(type_nid, clean_type, source_file=None)
add_edge(file_nid, type_nid, "references", context="variable_type")
elif destructured_names:
for name in [n.strip() for n in destructured_names.split(",") if n.strip()]:
if ":" in name:
name = name.split(":")[-1].strip()
if re.match(r"^[a-zA-Z_]\w*$", name) and not re.match(r"^[A-Z]", name):
if name not in {"if", "for", "while", "switch", "catch", "return"}:
var_nid = _make_id(stem, name)
add_node(var_nid, name)
add_edge(file_nid, var_nid, "defines")
# 5. Top-level and member functions/methods (supports typed/generic/record return types and Riverpod/Bloc references)
# Restrict indentation to 0-2 spaces to avoid matching nested local functions or methods inside multiline switch statements
method_pattern = r"^\s{0,2}(?:factory\s+|static\s+|async\s+|external\s+|abstract\s+)?(?:\([^)]+\)|[a-zA-Z0-9_<>,.?]+)(?:\s+[a-zA-Z0-9_<>,.?]+){0,3}\s+(\w+(?:\.\w+)?)\s*\("
for m in re.finditer(method_pattern, src_clean, re.MULTILINE):
raw_name = m.group(1)
name = raw_name.split(".")[-1]
if name in {"if", "for", "while", "switch", "catch", "return", "void", "dynamic", "final", "const", "get", "set"}:
continue
if re.match(r"^[A-Z]", name):
continue continue
nid = _make_id(stem, name) nid = _make_id(stem, name)
if nid not in defined: add_node(nid, name)
nodes.append({"id": nid, "label": name, "file_type": "code", add_edge(file_nid, nid, "defines")
"source_file": str(path), "source_location": None})
edges.append({"source": file_nid, "target": nid, "relation": "defines",
"confidence": "EXTRACTED", "confidence_score": 1.0,
"source_file": str(path), "source_location": None, "weight": 1.0})
defined.add(nid)
# import 'package:...' or import '...' # Get function body using matching brace to extract Riverpod reference patterns
for m in re.finditer(r"""^import\s+['"]([^'"]+)['"]""", src, re.MULTILINE): start_idx = m.start()
brace_pos = src_clean.find("{", start_idx)
semi_pos = src_clean.find(";", start_idx)
arrow_pos = src_clean.find("=>", start_idx)
has_body = brace_pos != -1
if has_body and semi_pos != -1 and semi_pos < brace_pos:
has_body = False
if has_body and arrow_pos != -1 and arrow_pos < brace_pos:
has_body = False
if has_body:
end_pos = _find_matching_brace(src_clean, start_idx)
func_body = src_clean[brace_pos:end_pos]
# Extract Riverpod provider references: ref.watch(provider)
for rm in re.finditer(r"\bref\.(?:watch|read|listen)\s*\(\s*(\w+)\b", func_body):
provider_name = rm.group(1)
provider_nid = _make_id(provider_name)
add_node(provider_nid, provider_name, source_file=None)
add_edge(nid, provider_nid, "references", context="riverpod_reference")
# Extract Bloc event additions: widget.add(MyEvent()) or bloc.add(MyEvent())
for am in re.finditer(r"\b(?:\w*[Bb]loc\w*|context\.read<\w+>\(\))\.add\(\s*(?:const\s+)?([A-Z]\w*)\b", func_body):
event_name = am.group(1)
if event_name not in {"String", "List", "Map", "Set", "Future", "Stream", "Object"}:
event_nid = _make_id(event_name)
add_node(event_nid, event_name, source_file=None)
add_edge(nid, event_nid, "calls", context="bloc_add_event")
# context.read<MyBloc>() or BlocProvider.of<MyBloc>(context)
for lm in re.finditer(r"\b(?:read|watch|select|of)\s*<([a-zA-Z0-9_]+)>", func_body):
bloc_name = lm.group(1)
if bloc_name not in {"String", "int", "double", "bool", "num", "dynamic", "Object", "void"}:
bloc_nid = _make_id(bloc_name)
add_node(bloc_nid, bloc_name, source_file=None)
add_edge(nid, bloc_nid, "references", context="bloc_lookup")
# Universal Navigation Patters (GoRouter, AutoRoute, Navigator)
for nm in re.finditer(r"\b(?:go|push|goNamed|pushNamed|replace|replaceNamed)\s*\(\s*(?:context\s*,\s*)?['\"]([a-zA-Z0-9_/?=&%-]+)['\"]", func_body):
route_path = nm.group(1)
route_nid = _make_id("route", route_path.replace("/", "_").replace("?", "_").replace("=", "_").replace("&", "_"))
add_node(route_nid, f"Route {route_path}", ftype="concept", source_file=None)
add_edge(nid, route_nid, "navigates", context="route_path")
for cm in re.finditer(r"\b(?:go|push|goNamed|pushNamed|replace|replaceNamed)\s*\(\s*(?:context\s*,\s*)?([A-Z][a-zA-Z0-9_]*\.[a-zA-Z0-9_]+)", func_body):
route_const = cm.group(1)
route_nid = _make_id("route", route_const.replace(".", "_"))
add_node(route_nid, route_const, ftype="concept", source_file=None)
add_edge(nid, route_nid, "navigates", context="route_const")
for om in re.finditer(r"\b(?:push|replace)\s*\(\s*(?:context\s*,\s*)?.*?\b([A-Z]\w*(?:Route|Screen|Page))\b", func_body):
route_class = om.group(1)
route_nid = _make_id(route_class)
add_node(route_nid, route_class, source_file=None)
add_edge(nid, route_nid, "navigates", context="route_object")
# 6. Imports and Exports
for m in re.finditer(r"""^\s*import\s+['"]([^'"]+)['"]""", src_clean, re.MULTILINE):
pkg = m.group(1) pkg = m.group(1)
tgt_nid = _make_id(pkg) tgt_nid = _make_id(pkg)
if tgt_nid not in defined: add_node(tgt_nid, pkg, source_file=None)
nodes.append({"id": tgt_nid, "label": pkg, "file_type": "code", add_edge(file_nid, tgt_nid, "imports")
"source_file": str(path), "source_location": None})
defined.add(tgt_nid) for m in re.finditer(r"""^\s*export\s+['"]([^'"]+)['"]""", src_clean, re.MULTILINE):
edges.append({"source": file_nid, "target": tgt_nid, "relation": "imports", pkg = m.group(1)
"confidence": "EXTRACTED", "confidence_score": 1.0, tgt_nid = _make_id(pkg)
"source_file": str(path), "source_location": None, "weight": 1.0}) add_node(tgt_nid, pkg, source_file=None)
add_edge(file_nid, tgt_nid, "exports")
# 7. Generic Invocations / Type Lookups (Universal Dependency Lookup)
# Matches any method call with type parameters: methodName<Type>() or object.methodName<Type>()
# Automatically extracts GetIt, Injectable, Riverpod, Provider, BlocProvider, and InheritedWidget type lookups!
generic_call_pattern = r"\b\w+<([a-zA-Z0-9_.]+(?:<[a-zA-Z0-9_.,\s<>]+>)?)\s*>\s*\("
type_blacklist = {"String", "int", "double", "bool", "num", "dynamic", "Object", "List", "Map", "Set", "Future", "Stream", "void"}
for m in re.finditer(generic_call_pattern, src_clean):
type_name = m.group(1).split(".")[-1].strip()
clean_name = type_name.split("<")[0].strip()
if clean_name not in type_blacklist:
target_nid = _make_id(clean_name)
add_node(target_nid, clean_name, source_file=None)
add_edge(file_nid, target_nid, "references", context="type_lookup")
return {"nodes": nodes, "edges": edges} return {"nodes": nodes, "edges": edges}
@@ -6939,8 +7407,8 @@ def _collect_js_symbol_resolution_facts(paths: list[Path], facts: _SymbolResolut
def _parse_python_tree(path: Path): def _parse_python_tree(path: Path):
try: try:
from tree_sitter import Language, Parser
import tree_sitter_python as tspython import tree_sitter_python as tspython
from tree_sitter import Language, Parser
source = path.read_bytes() source = path.read_bytes()
parser = Parser(Language(tspython.language())) parser = Parser(Language(tspython.language()))
return source, parser.parse(source).root_node return source, parser.parse(source).root_node
@@ -10619,7 +11087,7 @@ def collect_files(target: Path, *, follow_symlinks: bool = False, root: Path | N
if target.is_file(): if target.is_file():
return [target] return [target]
_EXTENSIONS = set(_DISPATCH.keys()) _EXTENSIONS = set(_DISPATCH.keys())
from graphify.detect import _load_graphifyignore, _is_ignored, _is_noise_dir from graphify.detect import _is_ignored, _is_noise_dir, _load_graphifyignore
ignore_root = root if root is not None else target ignore_root = root if root is not None else target
patterns = _load_graphifyignore(ignore_root) patterns = _load_graphifyignore(ignore_root)
+602
View File
@@ -0,0 +1,602 @@
import unittest
import tempfile
import sys
import textwrap
from pathlib import Path
from graphify.extract import extract_dart, _make_id, _file_stem
class TestDart(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.TemporaryDirectory()
self.temp_path = Path(self.temp_dir.name)
def tearDown(self):
self.temp_dir.cleanup()
def test_universal_generic_syntax_extraction(self):
"""Test that the universal parser successfully extracts generic relationships, annotations, extensions, classes, and generic calls."""
code_content = textwrap.dedent("""
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injectable/injectable.dart';
export 'package:flutter_bloc/flutter_bloc.dart';
// 1. Class declarations with generics, inheritance, and implements
@injectable
@HiveType(typeId: 10)
class UserBloc extends Bloc<UserEvent, UserState> with MyMixin implements Disposable {
UserBloc() : super(InitialState());
}
// 2. Enum declarations
@jsonSerializable
enum UserRole { admin, user }
// 3. Extensions
extension StringExtensions on String {
bool get isEmail => contains('@');
}
// 4. Top-level variables
final authServiceProvider = Provider<AuthService>((ref) => AuthService());
final myData = 42;
// 5. Generic method invocations (automatically catches GetIt, Provider, BlocProvider, InheritedWidget!)
void checkDependencies(BuildContext context) {
final custom = context.dependOnInheritedWidgetOfExactType<CustomService>();
final auth = context.read<AuthService>();
final bloc = BlocProvider.of<UserBloc>(context);
final getItService = GetIt.I<DatabaseService>();
final locatorService = locator<api.NetworkFactory>();
}
""")
file_path = self.temp_path / "test_app_bloc.dart"
file_path.write_text(code_content, encoding="utf-8")
result = extract_dart(file_path)
self.assertIn("nodes", result)
self.assertIn("edges", result)
nodes = result["nodes"]
edges = result["edges"]
# A. File node check
file_node = next(
(n for n in nodes if n["file_type"] == "code" and n["label"] == "test_app_bloc.dart"),
None,
)
self.assertIsNotNone(file_node)
self.assertEqual(file_node["source_file"], str(file_path))
# B. Class & Enum extraction check
user_bloc_node = next((n for n in nodes if n["label"] == "UserBloc"), None)
self.assertIsNotNone(user_bloc_node)
self.assertEqual(user_bloc_node["source_file"], str(file_path))
user_role_node = next((n for n in nodes if n["label"] == "UserRole"), None)
self.assertIsNotNone(user_role_node)
# C. Inherits & Generics
# Inherits Bloc (Should be global ID "bloc" without stem, source_file is None)
inherits_bloc = next(
(
e
for e in edges
if e["source"] == user_bloc_node["id"] and e["relation"] == "inherits"
),
None,
)
self.assertIsNotNone(inherits_bloc)
self.assertEqual(inherits_bloc["target"], "bloc")
bloc_node = next((n for n in nodes if n["id"] == "bloc"), None)
self.assertIsNotNone(bloc_node)
self.assertIsNone(bloc_node["source_file"])
# References UserEvent, UserState generics (Should be global IDs without stem, source_file is None)
ref_event = next(
(
e
for e in edges
if e["source"] == user_bloc_node["id"]
and e["relation"] == "references"
and e["target"] == "userevent"
),
None,
)
self.assertIsNotNone(ref_event)
event_node = next((n for n in nodes if n["id"] == "userevent"), None)
self.assertIsNotNone(event_node)
self.assertIsNone(event_node["source_file"])
ref_state = next(
(
e
for e in edges
if e["source"] == user_bloc_node["id"]
and e["relation"] == "references"
and e["target"] == "userstate"
),
None,
)
self.assertIsNotNone(ref_state)
# D. Generic Class Annotations (Should be global annotation ID, source_file is None)
injectable_annotation = next((n for n in nodes if n["label"] == "@injectable"), None)
self.assertIsNotNone(injectable_annotation)
self.assertEqual(injectable_annotation["id"], "annotation_injectable")
self.assertIsNone(injectable_annotation["source_file"])
configures_injectable = next(
(
e
for e in edges
if e["source"] == user_bloc_node["id"]
and e["target"] == injectable_annotation["id"]
and e["relation"] == "configures"
),
None,
)
self.assertIsNotNone(configures_injectable)
# Mixin check (Should have global ID "mymixin" and implements edge)
ref_mixin = next(
(
e
for e in edges
if e["source"] == user_bloc_node["id"]
and e["target"] == "mymixin"
and e["relation"] == "implements"
),
None,
)
self.assertIsNotNone(ref_mixin)
# E. Extensions (target class string should be global without stem, source_file is None)
ext_node = next((n for n in nodes if n["label"] == "StringExtensions"), None)
self.assertIsNotNone(ext_node)
extends_string = next(
(e for e in edges if e["source"] == ext_node["id"] and e["relation"] == "extends"), None
)
self.assertIsNotNone(extends_string)
self.assertEqual(extends_string["target"], "string")
# F. Variable declarations
provider_var = next((n for n in nodes if n["label"] == "authServiceProvider"), None)
self.assertIsNotNone(provider_var)
# G. Universal Generic Invocation mappings (Auto-resolved without hardcoding packages!)
ref_custom = next(
(
e
for e in edges
if e["source"] == file_node["id"]
and e["target"] == "customservice"
and e["relation"] == "references"
),
None,
)
self.assertIsNotNone(ref_custom)
custom_node = next((n for n in nodes if n["id"] == "customservice"), None)
self.assertIsNotNone(custom_node)
self.assertIsNone(custom_node["source_file"])
ref_net = next(
(
e
for e in edges
if e["source"] == file_node["id"]
and e["target"] == "networkfactory"
and e["relation"] == "references"
),
None,
)
self.assertIsNotNone(ref_net)
# H. Imports and Exports (Should have global ID, source_file is None)
import_node = next((n for n in nodes if n["id"] == "package_flutter_material_dart"), None)
self.assertIsNotNone(import_node)
self.assertIsNone(import_node["source_file"])
self.assertEqual(import_node["label"], "package:flutter/material.dart")
export_node = next(
(n for n in nodes if n["id"] == "package_flutter_bloc_flutter_bloc_dart"), None
)
self.assertIsNotNone(export_node)
self.assertIsNone(export_node["source_file"])
self.assertEqual(export_node["label"], "package:flutter_bloc/flutter_bloc.dart")
export_edge = next(
(
e
for e in edges
if e["source"] == file_node["id"]
and e["target"] == export_node["id"]
and e["relation"] == "exports"
),
None,
)
self.assertIsNotNone(export_edge)
def test_advanced_dart_features(self):
"""Test complex Dart 3+ syntax and precise Riverpod/Bloc mappings."""
code_content = textwrap.dedent("""
import 'package:riverpod/riverpod.dart';
# 1. Combined Modifiers & Mixin Class
abstract base class MyBaseClass {}
abstract interface class MyInterface {}
mixin class MyMixinClass {}
# 2. Riverpod Functional & Class Providers with Codegen
@riverpod
class MyNotifier extends _$MyNotifier {
@override
String build() {
ref.watch(anotherProvider);
return "hello";
}
}
@riverpod
String myValue(MyValueRef ref) {
return "world";
}
# 3. Late & Non-Initialized Final Fields
class MyModel {
late final String lateField;
final int noInitField;
final String initField = "init";
}
# 4. Records & Pattern Matching in variables
final (int, String) typedRecord = (1, "one");
var (recA, recB) = (10, 20);
# 5. Records in method returns & switch expressions
(double, double) getCoordinates() {
var localVal = switch (typedRecord) {
(int a, String b) => (1.0, 2.0),
_ => (0.0, 0.0),
};
return localVal;
}
# 6. Bloc constructor event registration & emission
class AuthBloc extends Bloc<AuthEvent, AuthState> {
AuthBloc() : super(AuthInitial()) {
on<AuthLogin>((event, emit) {
emit(AuthLoading());
});
on<AuthLogout>((event, emit) {
yield AuthSuccess();
});
}
}
# 7. Widget Bloc trigger & bindings
class HomeWidget {
void triggerLogin(BuildContext context) {
context.read<AuthBloc>().add(AuthLogin());
}
}
""")
file_path = self.temp_path / "test_advanced.dart"
file_path.write_text(code_content, encoding="utf-8")
result = extract_dart(file_path)
self.assertIn("nodes", result)
self.assertIn("edges", result)
nodes = result["nodes"]
edges = result["edges"]
# Check classes
base_class = next((n for n in nodes if n["label"] == "MyBaseClass"), None)
self.assertIsNotNone(base_class)
interface_class = next((n for n in nodes if n["label"] == "MyInterface"), None)
self.assertIsNotNone(interface_class)
mixin_class = next((n for n in nodes if n["label"] == "MyMixinClass"), None)
self.assertIsNotNone(mixin_class)
# Ensure we didn't mistakenly capture a node named "class"
class_false_positive = next((n for n in nodes if n["label"] == "class"), None)
self.assertIsNone(class_false_positive)
# Check late & final fields
late_field = next((n for n in nodes if n["label"] == "lateField"), None)
self.assertIsNotNone(late_field)
no_init_field = next((n for n in nodes if n["label"] == "noInitField"), None)
self.assertIsNotNone(no_init_field)
init_field = next((n for n in nodes if n["label"] == "initField"), None)
self.assertIsNotNone(init_field)
# Check records & destructuring
typed_rec = next((n for n in nodes if n["label"] == "typedRecord"), None)
self.assertIsNotNone(typed_rec)
rec_a = next((n for n in nodes if n["label"] == "recA"), None)
self.assertIsNotNone(rec_a)
rec_b = next((n for n in nodes if n["label"] == "recB"), None)
self.assertIsNotNone(rec_b)
# Ensure deep nested variable switch-expression 'localVal' is not extracted as a top-level define
local_val = next((n for n in nodes if n["label"] == "localVal"), None)
self.assertIsNone(local_val)
# Check record-returning method
get_coord = next((n for n in nodes if n["label"] == "getCoordinates"), None)
self.assertIsNotNone(get_coord)
# Check Riverpod codegen defines
mynotifier_provider = next((n for n in nodes if n["label"] == "myNotifierProvider"), None)
self.assertIsNotNone(mynotifier_provider)
myvalue_provider = next((n for n in nodes if n["label"] == "myValueProvider"), None)
self.assertIsNotNone(myvalue_provider)
# Check Riverpod watcher references
ref_edge = next(
(
e
for e in edges
if e["target"] == "anotherprovider" and e["relation"] == "references"
),
None,
)
self.assertIsNotNone(ref_edge)
# Check Bloc constructor events & emissions
login_edge = next(
(e for e in edges if e["target"] == "authlogin" and e["context"] == "bloc_event"), None
)
self.assertIsNotNone(login_edge)
emit_edge = next(
(e for e in edges if e["target"] == "authloading" and e["context"] == "emit_state"),
None,
)
self.assertIsNotNone(emit_edge)
# Check Widget Bloc trigger
trigger_edge = next(
(e for e in edges if e["target"] == "authlogin" and e["context"] == "bloc_add_event"),
None,
)
self.assertIsNotNone(trigger_edge)
lookup_edge = next(
(e for e in edges if e["target"] == "authbloc" and e["context"] == "bloc_lookup"), None
)
self.assertIsNotNone(lookup_edge)
def test_namespace_and_spaced_generics(self):
"""Test that the parser successfully handles namespaces in extends/implements, and spaces/commas in nested generic variables and methods."""
code_content = textwrap.dedent("""
class MyWidget extends foo.Bar<Map<String, int>> implements ui.Widget, db.Model {}
final Map<String, int> myVar = 10;
const List<Map<String, int>> myList = [];
late final auth.AuthService authService;
Map<String, Map<String, int>> myMethod(String a) {}
auth.AuthService init() {}
""")
file_path = self.temp_path / "test_namespaces.dart"
file_path.write_text(code_content, encoding="utf-8")
result = extract_dart(file_path)
nodes = result["nodes"]
edges = result["edges"]
# 1. Namespaced Extends/Implements
widget_node = next((n for n in nodes if n["label"] == "MyWidget"), None)
self.assertIsNotNone(widget_node)
# Base class should be 'foo.Bar' -> normalized to 'foo_bar' or 'bar'
extends_edge = next(
(e for e in edges if e["source"] == widget_node["id"] and e["relation"] == "inherits"),
None,
)
self.assertIsNotNone(extends_edge)
self.assertNotEqual(extends_edge["target"], "foo") # Ensure it didn't clip
# 2. Spaced Generics in Variables
self.assertIsNotNone(next((n for n in nodes if n["label"] == "myVar"), None))
self.assertIsNotNone(next((n for n in nodes if n["label"] == "myList"), None))
self.assertIsNotNone(next((n for n in nodes if n["label"] == "authService"), None))
# 3. Spaced Generics & Namespaces in Methods
self.assertIsNotNone(next((n for n in nodes if n["label"] == "myMethod"), None))
self.assertIsNotNone(next((n for n in nodes if n["label"] == "init"), None))
def test_dart_and_flutter_specifics(self):
"""Test typedefs, mixin on, factories, constructor DI types, and universal navigation."""
code_content = textwrap.dedent("""
mixin AuthMixin on BaseWidget {}
typedef JsonMap = Map<String, dynamic>;
extension type UserId(int value) implements Object {}
class MyService {
final AuthService api;
MyService(this.api);
factory MyService.fromJson() {}
void navigate(BuildContext context) {
context.go('/home');
Navigator.pushNamed(context, Routes.login);
context.router.push(ProfileRoute());
}
}
""")
file_path = self.temp_path / "test_specifics.dart"
file_path.write_text(code_content, encoding="utf-8")
result = extract_dart(file_path)
nodes = result["nodes"]
edges = result["edges"]
# 1. Mixin 'on' relation
auth_mixin = next((n for n in nodes if n["label"] == "AuthMixin"), None)
self.assertIsNotNone(auth_mixin)
inherits_base = next(
(
e
for e in edges
if e["source"] == auth_mixin["id"]
and e["relation"] == "inherits"
and e["target"] == "basewidget"
),
None,
)
self.assertIsNotNone(inherits_base)
# 2. Typedefs
json_map = next((n for n in nodes if n["label"] == "JsonMap"), None)
self.assertIsNotNone(json_map)
# 3. Variable DI Type (AuthService)
api_var = next((n for n in nodes if n["label"] == "api"), None)
self.assertIsNotNone(api_var)
ref_auth = next(
(
e
for e in edges
if e["target"] == "authservice"
and e["relation"] == "references"
and e["context"] == "variable_type"
),
None,
)
self.assertIsNotNone(ref_auth)
# 4. Factories
from_json = next((n for n in nodes if n["label"] == "fromJson"), None)
self.assertIsNotNone(from_json)
# 5. Universal Navigation
nav_home = next(
(e for e in edges if e["relation"] == "navigates" and e["context"] == "route_path"),
None,
)
self.assertIsNotNone(nav_home)
nav_login = next(
(e for e in edges if e["relation"] == "navigates" and e["context"] == "route_const"),
None,
)
self.assertIsNotNone(nav_login)
nav_profile = next(
(e for e in edges if e["relation"] == "navigates" and e["context"] == "route_object"),
None,
)
self.assertIsNotNone(nav_profile)
# 6. Extension Types
user_id = next((n for n in nodes if n["label"] == "UserId"), None)
self.assertIsNotNone(user_id)
impl_obj = next(
(
e
for e in edges
if e["source"] == user_id["id"]
and e["relation"] == "implements"
and e["target"] == "object"
),
None,
)
self.assertIsNotNone(impl_obj)
def test_roadmap_bug_fixes(self):
"""Test all 5 roadmap bug fixes (Bug A, B, C, D, E)."""
# Create parent and part child files to test Bug D (Part of file redirect)
parent_file = self.temp_path / "parent_lib.dart"
parent_file.write_text("library parent_lib;\npart 'child_part.dart';", encoding="utf-8")
child_code = textwrap.dedent("""
part of 'parent_lib.dart';
class ChildClass extends Bloc<Pair<UserEvent, MyState>, State> {}
var User(name: myVar, age: myAge) = user;
void runDI(BuildContext context) {
final repo = locator<Repository<User>>();
context.go('/home?id=123&type=auth');
}
""")
child_file = self.temp_path / "child_part.dart"
child_file.write_text(child_code, encoding="utf-8")
# Parse child file and verify redirect
result = extract_dart(child_file)
nodes = result["nodes"]
edges = result["edges"]
# A. Bug D redirect: No child file node should be created in nodes
child_node = next((n for n in nodes if n["label"] == "child_part.dart"), None)
self.assertIsNone(child_node)
# B. Check that defines edge source is parent file ID
parent_fid = _make_id(str(parent_file.resolve()))
child_class = next((n for n in nodes if n["label"] == "ChildClass"), None)
self.assertIsNotNone(child_class)
def_edge = next(
(e for e in edges if e["target"] == child_class["id"] and e["relation"] == "defines"),
None,
)
self.assertIsNotNone(def_edge)
self.assertEqual(def_edge["source"], parent_fid)
# C. Bug A safe generic inheritance commas split: check referenced generics
# Bloc<Pair<UserEvent, MyState>, State> should reference 'Pair<UserEvent, MyState>' and 'State'
# 'Pair<UserEvent, MyState>' will be clean matched to 'Pair' node
pair_node = next((n for n in nodes if n["id"] == "pair"), None)
self.assertIsNotNone(pair_node)
state_node = next((n for n in nodes if n["id"] == "state"), None)
self.assertIsNotNone(state_node)
# Ensure 'MyState>' or 'UserEvent' are NOT mistakenly generated as top-level generic reference nodes from broken comma-split!
bad_node1 = next((n for n in nodes if "mystate" in n["id"]), None)
self.assertIsNone(bad_node1)
# D. Bug B double generics DI lookup: locator<Repository<User>>()
repo_node = next((n for n in nodes if n["id"] == "repository"), None)
self.assertIsNotNone(repo_node)
# E. Bug E object destructuring variables: myVar, myAge
self.assertIsNotNone(next((n for n in nodes if n["label"] == "myVar"), None))
self.assertIsNotNone(next((n for n in nodes if n["label"] == "myAge"), None))
# Ensure "name: myVar" or ":myVar" are NOT registered as variables!
self.assertIsNone(
next((n for n in nodes if "name" in n["label"] or "age" in n["label"]), None)
)
# F. Bug C GoRouter query parameter route mapping
nav_edge = next(
(e for e in edges if e["relation"] == "navigates" and e["context"] == "route_path"),
None,
)
self.assertIsNotNone(nav_edge)
self.assertEqual(nav_edge["target"], "route_home_id_123_type_auth")
if __name__ == "__main__":
unittest.main()