From ec3cb5eb3ebd31d88402d0310fa3be388493bfae Mon Sep 17 00:00:00 2001 From: Alexey Z <47769319+Overman775@users.noreply.github.com> Date: Mon, 1 Jun 2026 20:13:36 +0500 Subject: [PATCH] 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). --- graphify/extract.py | 538 ++++++++++++++++++++++++++++++++++++--- tests/test_dart.py | 602 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1105 insertions(+), 35 deletions(-) create mode 100644 tests/test_dart.py diff --git a/graphify/extract.py b/graphify/extract.py index 939b168b..7b392d6c 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -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) + 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() + 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 + 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() or BlocProvider.of(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() or BlocProvider.of(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() or object.methodName() + # 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) diff --git a/tests/test_dart.py b/tests/test_dart.py new file mode 100644 index 00000000..f7e22980 --- /dev/null +++ b/tests/test_dart.py @@ -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 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((ref) => AuthService()); + final myData = 42; + + // 5. Generic method invocations (automatically catches GetIt, Provider, BlocProvider, InheritedWidget!) + void checkDependencies(BuildContext context) { + final custom = context.dependOnInheritedWidgetOfExactType(); + final auth = context.read(); + final bloc = BlocProvider.of(context); + final getItService = GetIt.I(); + final locatorService = locator(); + + } + """) + + 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 { + AuthBloc() : super(AuthInitial()) { + on((event, emit) { + emit(AuthLoading()); + }); + on((event, emit) { + yield AuthSuccess(); + }); + } + } + + # 7. Widget Bloc trigger & bindings + class HomeWidget { + void triggerLogin(BuildContext context) { + context.read().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> implements ui.Widget, db.Model {} + + final Map myVar = 10; + const List> myList = []; + late final auth.AuthService authService; + + Map> 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; + 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, State> {} + + var User(name: myVar, age: myAge) = user; + + void runDI(BuildContext context) { + final repo = locator>(); + 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, State> should reference 'Pair' and 'State' + # 'Pair' 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>() + 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()