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 20:13:36 +05: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."""
from __future__ import annotations
import importlib
import json
import os
@@ -8,7 +9,8 @@ import sys
import unicodedata
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Any
from typing import Any, Callable
from .cache import load_cached, save_cached
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:
"""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:
src = path.read_text(encoding="utf-8", errors="replace")
except OSError:
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)
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 = []
defined: set[str] = set()
# Classes and mixins
for m in re.finditer(r"^\s*(?:abstract\s+)?(?:class|mixin)\s+(\w+)", src, re.MULTILINE):
nid = _make_id(stem, m.group(1))
def add_node(nid: str, label: str, ftype: str = "code", source_file: str | None = str(path)) -> None:
if nid not in defined:
nodes.append({"id": nid, "label": m.group(1), "file_type": "code",
"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})
nodes.append({"id": nid, "label": label, "file_type": ftype,
"source_file": source_file, "source_location": None})
defined.add(nid)
# Top-level and member functions/methods
for m in re.finditer(r"^\s*(?:static\s+|async\s+)?(?:\w+\s+)+(\w+)\s*\(", src, re.MULTILINE):
name = m.group(1)
if name in {"if", "for", "while", "switch", "catch", "return"}:
def add_edge(src_id: str, tgt_id: str, relation: str, weight: float = 1.0, context: str | None = None) -> None:
edge = {"source": src_id, "target": tgt_id, "relation": relation,
"confidence": "EXTRACTED", "confidence_score": 1.0,
"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
nid = _make_id(stem, name)
if nid not in defined:
nodes.append({"id": nid, "label": name, "file_type": "code",
"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)
add_node(nid, name)
add_edge(file_nid, nid, "defines")
# import 'package:...' or import '...'
for m in re.finditer(r"""^import\s+['"]([^'"]+)['"]""", src, re.MULTILINE):
# Get function body using matching brace to extract Riverpod reference patterns
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)
tgt_nid = _make_id(pkg)
if tgt_nid not in defined:
nodes.append({"id": tgt_nid, "label": pkg, "file_type": "code",
"source_file": str(path), "source_location": None})
defined.add(tgt_nid)
edges.append({"source": file_nid, "target": tgt_nid, "relation": "imports",
"confidence": "EXTRACTED", "confidence_score": 1.0,
"source_file": str(path), "source_location": None, "weight": 1.0})
add_node(tgt_nid, pkg, source_file=None)
add_edge(file_nid, tgt_nid, "imports")
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")
# 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}
@@ -6939,8 +7407,8 @@ def _collect_js_symbol_resolution_facts(paths: list[Path], facts: _SymbolResolut
def _parse_python_tree(path: Path):
try:
from tree_sitter import Language, Parser
import tree_sitter_python as tspython
from tree_sitter import Language, Parser
source = path.read_bytes()
parser = Parser(Language(tspython.language()))
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():
return [target]
_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
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()