diff --git a/graphify/extract.py b/graphify/extract.py index f14f4638d..2d6aa8701 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -6555,6 +6555,94 @@ def _comment_replace(match: re.Match) -> str: return token src_clean = comment_string_pattern.sub(_comment_replace, src) + # Dart part files can point to their parent library through package: URIs. + # Resolve those through the nearest pubspec.yaml so part declarations use + # the parent library stem instead of a machine-local part-file path. + def _find_package_root(start: Path) -> tuple[Path, str] | None: + for parent in [start, *start.parents]: + pubspec = parent / "pubspec.yaml" + if not pubspec.exists(): + continue + try: + for line in pubspec.read_text(encoding="utf-8", errors="replace").splitlines(): + m = re.match(r"\s*name\s*:\s*([A-Za-z0-9_]+)\s*$", line) + if m: + return parent, m.group(1) + except OSError: + return None + return None + + def _resolve_part_parent(parent_ref: str) -> Path: + if parent_ref.startswith("package:"): + # Dart part files can name their parent via package:pkg/lib-relative path. + package_ref = parent_ref[len("package:"):] + package_name, _, package_path = package_ref.partition("/") + package_root = _find_package_root(path.parent) + if package_root and package_root[1] == package_name and package_path: + return (package_root[0] / "lib" / package_path).resolve() + return (path.parent / Path(package_path or package_name).name).resolve() + return (path.parent / parent_ref).resolve() + + # These SDK/common Dart types usually do not describe project relationships. + # Keep them out of reference nodes unless a legacy extraction path explicitly + # opts in for compatibility. + dart_sdk_noise_types = { + "String", "int", "double", "bool", "num", "dynamic", "Object", + "List", "Map", "Set", "Iterable", "MapEntry", "Iterator", + "Comparable", "Future", "FutureOr", "Stream", + "void", "Function", "Never", "Null", "Type", "Enum", "Record", + "DateTime", "Duration", "Uri", "Runes", "Symbol", "BigInt", + "RegExp", "Pattern", "Match", "StackTrace", "Error", "Exception", + } + # Defensive cleanup for regex matches that accidentally include declaration + # keywords in type-like strings. + declaration_modifiers = { + "static", "late", "final", "const", "var", "external", "abstract", + "factory", "async", "base", "interface", "sealed", "mixin", + } + + def _strip_balanced_generics(text: str) -> str: + current: list[str] = [] + depth = 0 + for char in text: + if char == "<": + depth += 1 + elif char == ">": + depth = max(0, depth - 1) + elif depth == 0: + current.append(char) + return "".join(current) + + def _clean_type_name( + text: str | None, + *, + allow_sdk_noise: bool | set[str] = False, + ) -> str | None: + """Normalize a type-ish Dart fragment before creating a graph node.""" + if not text: + return None + clean = text.strip().rstrip("?") + clean = clean.split(".")[-1].strip() + clean = _strip_balanced_generics(clean).strip().rstrip("?") + if "," in clean or "(" in clean or ")" in clean: + return None + parts = [part for part in clean.split() if part not in declaration_modifiers] + clean = " ".join(parts).strip() + if not clean or clean in {"get", "set", "_"}: + return None + if clean.endswith(" get") or clean.endswith(" set"): + return None + if not re.match(r"^[A-Za-z_]\w*$", clean): + return None + if clean in dart_sdk_noise_types: + if allow_sdk_noise is True: + return clean + if isinstance(allow_sdk_noise, set) and clean in allow_sdk_noise: + return clean + return None + return clean + + # Use stem (not str(path)) for child IDs to keep them machine-independent. stem = _file_stem(path) file_nid = _make_id(str(path)) @@ -6565,7 +6653,7 @@ def _comment_replace(match: re.Match) -> str: parent_ref = part_of_match.group(1) if parent_ref.endswith(".dart"): try: - parent_path = (path.parent / parent_ref).resolve() + parent_path = _resolve_part_parent(parent_ref) if parent_path.exists(): stem = _file_stem(parent_path) file_nid = _make_id(str(parent_path)) @@ -6663,14 +6751,17 @@ def _find_matching_brace(text: str, start_pos: int) -> int: # 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+)" + class_pattern = r"^\s*(?:(?:abstract|sealed|base|interface|final|mixin)\s+)*(?:class|mixin|enum|extension\s+type(?:\s+const)?)\s+(\w+)" for m in re.finditer(class_pattern, src_clean, re.MULTILINE): class_name = m.group(1) + if class_name == "_": + continue 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 + # Manually parse extends/on, with, and implements so nested generic + # brackets do not split relation targets incorrectly. start_idx = m.end() rest = src_clean[start_idx : start_idx + 500] @@ -6752,33 +6843,36 @@ def _find_matching_brace(text: str, start_pos: int) -> int: 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) + clean_base = _clean_type_name(base_class) + if clean_base: + base_nid = _make_id(clean_base) + add_node(base_nid, clean_base, 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_clean = _clean_type_name(gen) + if gen_clean: gen_nid = _make_id(gen_clean) add_node(gen_nid, gen_clean, source_file=None) add_edge(class_nid, gen_nid, "references") - # Map mixins + # Dart `with` clauses are mixins, not interfaces. 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, "mixes_in") + mixin_clean = _clean_type_name(mixin) + if mixin_clean: + mixin_nid = _make_id(mixin_clean) + add_node(mixin_nid, mixin_clean, source_file=None) + add_edge(class_nid, mixin_nid, "mixes_in") # 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") + interface_clean = _clean_type_name(interface, allow_sdk_noise={"Object"}) + if interface_clean: + 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() @@ -6896,11 +6990,11 @@ def _find_matching_brace(text: str, start_pos: int) -> int: 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") + typedef_nid = _make_id(stem, typedef_name) + add_node(typedef_nid, typedef_name) + add_edge(file_nid, typedef_nid, "defines") + target_type = _clean_type_name(m.group(2)) + if target_type: target_nid = _make_id(target_type) add_node(target_nid, target_type, source_file=None) add_edge(typedef_nid, target_nid, "references", context="typedef") @@ -6916,14 +7010,22 @@ def _find_matching_brace(text: str, start_pos: int) -> int: 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") + clean_target = _clean_type_name(target_class) + if clean_target: + target_nid = _make_id(clean_target) + add_node(target_nid, clean_target, 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): + if re.match( + r"^\s*(?:class|mixin|enum|extension|import|export|part|library|typedef)\b", + m.group(0), + ): + continue + var_type = m.group(1) single_name = m.group(2) destructured_names = m.group(3) @@ -6937,8 +7039,8 @@ def _find_matching_brace(text: str, start_pos: int) -> int: 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() + clean_type = _clean_type_name(var_type) + if clean_type: 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") @@ -6947,6 +7049,8 @@ def _find_matching_brace(text: str, start_pos: int) -> int: 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 == "_": + continue if name not in {"if", "for", "while", "switch", "catch", "return"}: var_nid = _make_id(stem, name) add_node(var_nid, name) @@ -6958,7 +7062,7 @@ def _find_matching_brace(text: str, start_pos: int) -> int: 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"}: + if name in {"if", "for", "while", "switch", "catch", "return", "void", "dynamic", "final", "const", "get", "set", "_"}: continue if re.match(r"^[A-Z]", name): continue @@ -7041,11 +7145,13 @@ def _find_matching_brace(text: str, start_pos: int) -> int: # 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: + line_start = src_clean.rfind("\n", 0, m.start()) + 1 + line = src_clean[line_start : m.start()] + if re.match(r"^\s*(?:class|mixin|enum|extension|typedef)\b", line): + continue + clean_name = _clean_type_name(m.group(1)) + if clean_name: 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") diff --git a/tests/test_dart.py b/tests/test_dart.py index 094a6f5e3..b213f380c 100644 --- a/tests/test_dart.py +++ b/tests/test_dart.py @@ -197,15 +197,14 @@ class UserBloc extends Bloc with MyMixin implements Dispos ) self.assertIsNone(bad_disposable_mixes_in) - # E. Extensions (target class string should be global without stem, source_file is None) + # E. Extensions (SDK targets are treated as graph noise, extension node remains) 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") + self.assertIsNone(extends_string) # F. Variable declarations provider_var = next((n for n in nodes if n["label"] == "authServiceProvider"), None) diff --git a/tests/test_extract.py b/tests/test_extract.py index ae88e364b..fd7c7527e 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -1660,9 +1660,6 @@ def test_dart_child_node_ids_are_stem_based(tmp_path): "This suggests an absolute path is still leaking into the ID." ) - - - def test_separator_collision_paths_get_distinct_ids(tmp_path): """#1522: two distinct paths whose only difference is a separator-vs-punctuation swap (foo/bar_baz.py vs foo_bar/baz.py) normalize to the same stem; the @@ -1719,3 +1716,395 @@ def test_case_insensitive_suffix_filtering(tmp_path): assert "myJSFunction()" in labels assert "MyTSClass" in labels + +def _extract_dart_source(tmp_path, source: str, filename: str = "sample.dart"): + from graphify.extract import extract_dart + + src_file = tmp_path / filename + src_file.parent.mkdir(parents=True, exist_ok=True) + src_file.write_text(source, encoding="utf-8") + return extract_dart(src_file) + + +def _dart_labels(result): + return {node["label"] for node in result["nodes"]} + + +def _dart_edges(result, relation: str): + return [edge for edge in result["edges"] if edge["relation"] == relation] + + +def test_dart_package_part_of_resolves_parent_from_package_lib(tmp_path): + from graphify.extract import _file_stem, _make_id + + package_root = tmp_path / "app" + (package_root / "lib" / "src").mkdir(parents=True) + (package_root / "pubspec.yaml").write_text("name: app\n", encoding="utf-8") + parent = package_root / "lib" / "src" / "parent.dart" + parent.write_text("library parent;\npart 'child.dart';\n", encoding="utf-8") + child = package_root / "lib" / "src" / "child.dart" + child.write_text("part of 'package:app/src/parent.dart';\nclass Child {}\n", encoding="utf-8") + + result = _extract_dart_source(tmp_path, child.read_text(encoding="utf-8"), str(child.relative_to(tmp_path))) + labels = _dart_labels(result) + define_edges = _dart_edges(result, "defines") + + parent_file_nid = _make_id(str(parent.resolve())) + child_nid = _make_id(_file_stem(parent), "Child") + assert "child.dart" not in labels + assert child_nid in {node["id"] for node in result["nodes"]} + assert any(edge["source"] == parent_file_nid and edge["target"] == child_nid for edge in define_edges) + + +def test_dart_generated_dollar_prefixed_class_does_not_create_bare_underscore_node(tmp_path): + result = _extract_dart_source( + tmp_path, + "abstract mixin class _$NativeTargetCopyWith<$Res> {}\n", + ) + + assert "_" not in _dart_labels(result) + assert "_" not in {node["id"] for node in result["nodes"]} + + +def test_dart_private_named_constructor_does_not_create_bare_underscore_node(tmp_path): + result = _extract_dart_source( + tmp_path, + "class AppInfo {\n" + " const AppInfo._();\n" + "}\n", + ) + + assert "_" not in _dart_labels(result) + assert "_" not in {node["id"] for node in result["nodes"]} + + +def test_dart_function_type_commas_do_not_split_interface_targets(tmp_path): + result = _extract_dart_source( + tmp_path, + "class X implements A, B {}\n", + ) + + implements_targets = {edge["target"] for edge in _dart_edges(result, "implements")} + assert implements_targets == {"a", "b"} + assert "void Function(int" not in _dart_labels(result) + assert "String)" not in _dart_labels(result) + + +def test_dart_getters_and_static_modifiers_do_not_create_type_nodes(tmp_path): + result = _extract_dart_source( + tmp_path, + "String get title => 'x';\n" + "bool get ok => true;\n" + "static const foo = 1;\n" + "static final bar = 2;\n", + ) + + labels = _dart_labels(result) + ids = {node["id"] for node in result["nodes"]} + assert {"String get", "bool get", "static const", "static final"}.isdisjoint(labels) + assert {"string_get", "bool_get", "static_const", "static_final"}.isdisjoint(ids) + + +def test_dart_class_header_is_not_treated_as_variable_declaration(tmp_path): + from graphify.extract import _file_stem, _make_id + + result = _extract_dart_source( + tmp_path, + "class MockWebViewPlatform extends Mock with MockMixin implements Platform {}\n", + "mocks/mock_webview_platform.dart", + ) + + file_scoped_mock_id = _make_id(_file_stem(tmp_path / "mocks" / "mock_webview_platform.dart"), "Mock") + assert file_scoped_mock_id not in {node["id"] for node in result["nodes"]} + assert ("MockWebViewPlatform", "Mock") in { + ( + next(node["label"] for node in result["nodes"] if node["id"] == edge["source"]), + next(node["label"] for node in result["nodes"] if node["id"] == edge["target"]), + ) + for edge in _dart_edges(result, "inherits") + } + + +def test_dart_multiline_class_header_is_not_treated_as_variable_declaration(tmp_path): + from graphify.extract import _file_stem, _make_id + + result = _extract_dart_source( + tmp_path, + "class MockPlatformInAppWebViewController extends Mock\n" + " implements PlatformInAppWebViewController {}\n", + "mocks/mock_webview_platform.dart", + ) + + file_scoped_mock_id = _make_id(_file_stem(tmp_path / "mocks" / "mock_webview_platform.dart"), "Mock") + assert file_scoped_mock_id not in {node["id"] for node in result["nodes"]} + + +def test_dart_constructor_parameters_do_not_create_comma_joined_type_nodes(tmp_path): + result = _extract_dart_source( + tmp_path, + "class C {\n" + " const C({String? label, int? count});\n" + "}\n", + ) + + labels = _dart_labels(result) + assert not any("," in label for label in labels) + assert "String? label, int?" not in labels + + +def test_dart_freezed_factory_parameters_do_not_create_comma_joined_type_nodes(tmp_path): + result = _extract_dart_source( + tmp_path, + "const factory BucketPatchRequestDto({String? sku, int? amount}) = _BucketPatchRequestDto;\n", + ) + + labels = _dart_labels(result) + assert not any("," in label for label in labels) + assert "String? sku, int?" not in labels + + +def test_dart_three_class_modifiers_define_real_declarations_only(tmp_path): + result = _extract_dart_source( + tmp_path, + "base class BaseThing {}\n" + "interface class InterfaceThing {}\n" + "abstract interface class AbstractInterfaceThing {}\n" + "final class FinalThing {}\n" + "sealed class SealedThing {}\n" + "base mixin BaseMixin {}\n" + "mixin class MixinClassThing {}\n", + ) + + labels = _dart_labels(result) + assert { + "BaseThing", + "InterfaceThing", + "AbstractInterfaceThing", + "FinalThing", + "SealedThing", + "BaseMixin", + "MixinClassThing", + }.issubset(labels) + assert {"base", "interface", "sealed", "final", "abstract", "mixin"}.isdisjoint(labels) + + +def test_dart_sdk_noise_types_do_not_create_reference_nodes(tmp_path): + result = _extract_dart_source( + tmp_path, + "import 'dart:async';\n" + "FutureOr a;\n" + "Iterable b;\n" + "Never fail() => throw Exception();\n" + "Symbol? s;\n" + "Runes r;\n" + "Record? rec;\n" + "StackTrace? st;\n" + "BigInt? big;\n" + "RegExp? regex;\n" + "Pattern? pattern;\n" + "Match? match;\n" + "MapEntry entry;\n" + "Iterator? iterator;\n" + "Comparable? comparable;\n" + "Error? error;\n" + "Exception? exception;\n", + ) + + labels = _dart_labels(result) + assert { + "FutureOr", + "Iterable", + "Never", + "Symbol", + "Runes", + "Record", + "StackTrace", + "BigInt", + "RegExp", + "Pattern", + "Match", + "MapEntry", + "Iterator", + "Comparable", + "Error", + "Exception", + }.isdisjoint(labels) + + +def test_dart_never_bottom_type_does_not_create_reference_node(tmp_path): + result = _extract_dart_source( + tmp_path, + "Never fail() => throw Exception();\n" + "Future load() async => throw Exception();\n" + "Never? value;\n", + ) + + labels = _dart_labels(result) + ids = {node["id"] for node in result["nodes"]} + assert "Never" not in labels + assert "never" not in ids + assert {"fail", "load", "value"}.issubset(labels) + + +def test_dart_records_do_not_create_comma_label_noise(tmp_path): + result = _extract_dart_source( + tmp_path, + "(String, int) tuple;\n" + "({String name, int age}) named;\n" + "final (name, age) = ('a', 1);\n" + "final (:name, :age) = (name: 'a', age: 1);\n", + ) + + labels = _dart_labels(result) + assert not any("," in label for label in labels) + assert {"String, int", "String name, int age", "name, age"}.isdisjoint(labels) + + +def test_dart_patterns_do_not_create_fake_declarations(tmp_path): + result = _extract_dart_source( + tmp_path, + "if (value case User(:final id, :final name)) {}\n" + "switch (state) {\n" + " case Loaded(:final data):\n" + " break;\n" + " case ErrorState(:final error):\n" + " break;\n" + "}\n" + "for (final MapEntry(:key, :value) in entries) {}\n", + ) + + labels = _dart_labels(result) + assert { + "final id", + "final name", + "key,", + "value", + "Loaded(:final data)", + "ErrorState(:final error)", + }.isdisjoint(labels) + + +def test_dart_switch_expressions_and_if_case_do_not_create_fake_nodes(tmp_path): + result = _extract_dart_source( + tmp_path, + "final label = switch (status) {\n" + " OrderStatus.created => 'created',\n" + " OrderStatus.done => 'done',\n" + "};\n" + "if (status case OrderStatus.created) {}\n", + ) + + labels = _dart_labels(result) + assert {"case", "created", "done", "OrderStatus.created"}.isdisjoint(labels) + + +def test_dart_three_class_modifiers_include_mixin_class_form(tmp_path): + result = _extract_dart_source( + tmp_path, + "base class A {}\n" + "interface class B {}\n" + "abstract interface class C {}\n" + "final class D {}\n" + "sealed class E {}\n" + "mixin class F {}\n" + "base mixin class G {}\n", + ) + + labels = _dart_labels(result) + assert {"A", "B", "C", "D", "E", "F", "G"}.issubset(labels) + assert {"base", "interface", "sealed", "final", "abstract", "mixin"}.isdisjoint(labels) + + +def test_dart_extension_types_do_not_create_representation_noise(tmp_path): + result = _extract_dart_source( + tmp_path, + "extension type UserId(int value) {}\n" + "extension type const ProductId._(String value) implements Object {}\n" + "extension type Sequence(List _) implements Iterable {}\n", + ) + + labels = _dart_labels(result) + assert {"UserId", "ProductId", "Sequence"}.issubset(labels) + assert {"value", "_", "Iterable", "Object", "const"}.isdisjoint(labels) + + +def test_dart_extensions_on_sdk_types_do_not_create_sdk_nodes(tmp_path): + result = _extract_dart_source( + tmp_path, + "extension StringExt on String {}\n" + "extension ListExt on List {}\n" + "extension MapExt on Map {}\n" + "extension SetExt on Set {}\n" + "extension IterableExt on Iterable {}\n" + "extension FutureExt on Future {}\n" + "extension StreamExt on Stream {}\n" + "extension UserExt on User {}\n" + "class User {}\n", + ) + + labels = _dart_labels(result) + extends_targets = {edge["target"] for edge in _dart_edges(result, "extends")} + assert { + "StringExt", + "ListExt", + "MapExt", + "SetExt", + "IterableExt", + "FutureExt", + "StreamExt", + "UserExt", + "User", + }.issubset(labels) + assert {"String", "List", "Map", "Set", "Iterable", "Future", "Stream"}.isdisjoint(labels) + assert {"string", "list", "map", "set", "iterable", "future", "stream"}.isdisjoint(extends_targets) + assert "user" in extends_targets + + +def test_dart_typedefs_to_sdk_types_do_not_create_sdk_nodes(tmp_path): + result = _extract_dart_source( + tmp_path, + "typedef JsonMap = Map;\n" + "typedef UserAlias = User;\n" + "class User {}\n", + ) + + labels = _dart_labels(result) + reference_targets = {edge["target"] for edge in _dart_edges(result, "references")} + assert {"JsonMap", "UserAlias", "User"}.issubset(labels) + assert "Map" not in labels + assert "map" not in reference_targets + assert "user" in reference_targets + + +def test_dart_enhanced_enum_header_relations_without_value_noise(tmp_path): + result = _extract_dart_source( + tmp_path, + "enum OrderStatus implements JsonSerializable {\n" + " created('created'),\n" + " done('done');\n" + "\n" + " const OrderStatus(this.code);\n" + " final String code;\n" + "}\n", + ) + + labels = _dart_labels(result) + implements_targets = {edge["target"] for edge in _dart_edges(result, "implements")} + assert "OrderStatus" in labels + assert "jsonserializable" in implements_targets + assert {"String code", "created", "done"}.isdisjoint(labels) + + +def test_dart_wildcard_patterns_never_create_underscore_node(tmp_path): + result = _extract_dart_source( + tmp_path, + "final (_, value) = tuple;\n" + "list.map((_) => 1);\n" + "switch (x) {\n" + " case (_, final y):\n" + " break;\n" + "}\n", + ) + + assert "_" not in _dart_labels(result) + assert "_" not in {node["id"] for node in result["nodes"]}