diff --git a/README.md b/README.md index 0a883e75b..0437b534f 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,7 @@ Codex users also need `multi_agent = true` under `[features]` in `~/.codex/confi | `postgres` | Live PostgreSQL introspection (`--postgres DSN`) | `uv tool install "graphifyy[postgres]"` | | `dm` | BYOND DreamMaker `.dm`/`.dme` AST extraction (may need a C compiler + `python3-dev` if no wheel matches your platform) | `uv tool install "graphifyy[dm]"` | | `terraform` | Terraform / HCL `.tf`/`.tfvars`/`.hcl` AST extraction | `uv tool install "graphifyy[terraform]"` | +| `gdscript` | Godot GDScript `.gd` AST extraction | `uv tool install "graphifyy[gdscript]"` | | `pascal` | Pascal / Delphi `.pas`/`.dpr`/`.dpk`/`.inc` AST extraction (more accurate `calls`/`inherits` edges; falls back to a regex extractor when absent) | `uv tool install "graphifyy[pascal]"` | | `chinese` | Chinese query segmentation (jieba) | `uv tool install "graphifyy[chinese]"` | | `all` | Everything above | `uv tool install "graphifyy[all]"` | @@ -327,7 +328,7 @@ To remove graphify from all platforms at once: `graphify uninstall` (add `--purg | Type | Extensions | |------|-----------| -| Code (36 tree-sitter grammars) | `.py .ts .mts .cts .js .jsx .tsx .mjs .go .rs .java .c .cpp .cc .cxx .h .hpp .cu .cuh .metal .rb .cs .kt .kts .scala .php .swift .lua .luau .toc .zig .ps1 .psm1 .psd1 .ex .exs .m .mm .jl .vue .svelte .astro .groovy .gradle .dart .v .sv .svh .sql .f .f90 .f95 .f03 .f08 .pas .pp .dpr .dpk .lpr .inc .dfm .lfm .lpk .sh .bash .json .dm .dme .dmi .dmm .dmf .sln .slnx .csproj .fsproj .vbproj .xaml .razor .cshtml` (`.dm`/`.dme` requires `uv tool install graphifyy[dm]`; `.mts`/`.cts` reuse the TypeScript grammar, `.cc`/`.cxx` and CUDA `.cu`/`.cuh` and Metal `.metal` reuse the C++ grammar) | +| Code (37 tree-sitter grammars) | `.py .ts .mts .cts .js .jsx .tsx .mjs .go .rs .java .c .cpp .cc .cxx .h .hpp .cu .cuh .metal .rb .cs .kt .kts .scala .php .swift .lua .luau .toc .zig .ps1 .psm1 .psd1 .ex .exs .m .mm .jl .vue .svelte .astro .groovy .gradle .dart .gd .v .sv .svh .sql .f .f90 .f95 .f03 .f08 .pas .pp .dpr .dpk .lpr .inc .dfm .lfm .lpk .sh .bash .json .dm .dme .dmi .dmm .dmf .sln .slnx .csproj .fsproj .vbproj .xaml .razor .cshtml` (`.gd` requires `uv tool install graphifyy[gdscript]`; `.dm`/`.dme` requires `uv tool install graphifyy[dm]`; `.mts`/`.cts` reuse the TypeScript grammar, `.cc`/`.cxx` and CUDA `.cu`/`.cuh` and Metal `.metal` reuse the C++ grammar) | | Salesforce Apex | `.cls .trigger` (regex-based; classes, interfaces, enums, methods, triggers, SOQL/DML edges) | | Terraform / HCL | `.tf .tfvars .hcl` (requires `uv tool install graphifyy[terraform]`) | | MCP configs | `.mcp.json` `mcp.json` `mcp_servers.json` `claude_desktop_config.json` — extracts server nodes, package refs, env var requirements | diff --git a/graphify/analyze.py b/graphify/analyze.py index 7f3eb72ff..e1e8c5de8 100644 --- a/graphify/analyze.py +++ b/graphify/analyze.py @@ -33,6 +33,7 @@ **{e: "dotnet" for e in (".cs",)}, **{e: "php" for e in (".php",)}, **{e: "r" for e in (".r",)}, + **{e: "gdscript" for e in (".gd",)}, } diff --git a/graphify/build.py b/graphify/build.py index 428fa1cd4..532f1a408 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -50,6 +50,7 @@ ".cxx": "c", ".hh": "c", ".hxx": "c", ".cu": "c", ".cuh": "c", ".metal": "c", ".m": "c", ".mm": "c", ".rb": "rb", ".rake": "rb", ".php": "php", ".cs": "cs", ".swift": "swift", ".lua": "lua", + ".gd": "gdscript", } diff --git a/graphify/detect.py b/graphify/detect.py index 84dd5e1b6..ae6df4f90 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -27,7 +27,7 @@ class FileType(str, Enum): _MANIFEST_PATH = str(out_path("manifest.json")) -CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.rake', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.xaml', '.razor', '.cshtml', '.cls', '.trigger'} +CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.ejs', '.ets', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.cu', '.cuh', '.metal', '.rb', '.rake', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.psm1', '.psd1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.astro', '.dart', '.gd', '.v', '.sv', '.svh', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json', '.tf', '.tfvars', '.hcl', '.dm', '.dme', '.dmi', '.dmm', '.dmf', '.sln', '.slnx', '.csproj', '.fsproj', '.vbproj', '.xaml', '.razor', '.cshtml', '.cls', '.trigger'} DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.txt', '.rst', '.html', '.yaml', '.yml'} PAPER_EXTENSIONS = {'.pdf'} IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'} diff --git a/graphify/extract.py b/graphify/extract.py index 59d4f8283..9acf17a12 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -40,6 +40,7 @@ from graphify.extractors.dm import extract_dm, extract_dmf, extract_dmi, extract_dmm # noqa: F401 from graphify.extractors.elixir import extract_elixir # noqa: F401 from graphify.extractors.fortran import _cpp_preprocess, extract_fortran # noqa: F401 +from graphify.extractors.gdscript import extract_gdscript # noqa: F401 from graphify.extractors.go import extract_go # noqa: F401 from graphify.extractors.json_config import extract_json # noqa: F401 from graphify.extractors.markdown import extract_markdown # noqa: F401 @@ -1787,6 +1788,7 @@ def _lang_is_case_insensitive(source_file: object) -> bool: ".ex": "elixir", ".exs": "elixir", ".jl": "julia", ".dart": "dart", + ".gd": "gdscript", ".sh": "shell", ".bash": "shell", ".ps1": "powershell", ".psm1": "powershell", ".psd1": "powershell", } @@ -3846,6 +3848,7 @@ def add_existing_edge(edge: dict) -> None: ".svelte": extract_svelte, ".astro": extract_astro, ".dart": extract_dart, + ".gd": extract_gdscript, ".v": extract_verilog, ".sv": extract_verilog, ".svh": extract_verilog, @@ -3891,6 +3894,7 @@ def add_existing_edge(edge: dict) -> None: # rather than falling back like Pascal does. Used by the #1745 warning in # extract() to tell the user which extra restores the language. _EXTRA_FOR_EXTENSION = { + ".gd": "gdscript", ".sql": "sql", ".tf": "terraform", ".tfvars": "terraform", diff --git a/graphify/extractors/MIGRATION.md b/graphify/extractors/MIGRATION.md index 75e1525e2..4adfc3616 100644 --- a/graphify/extractors/MIGRATION.md +++ b/graphify/extractors/MIGRATION.md @@ -17,6 +17,7 @@ written so an AI agent can execute it in a single session. | go | yes | | powershell (ps1 + psd1 manifest) | yes | | fortran | yes | +| gdscript | yes (added directly as a per-language extractor) | | sql | yes | | dm (dm/dmm/dmi/dmf) | yes | | bash | yes | diff --git a/graphify/extractors/__init__.py b/graphify/extractors/__init__.py index ada517094..3d6218851 100644 --- a/graphify/extractors/__init__.py +++ b/graphify/extractors/__init__.py @@ -17,6 +17,7 @@ from graphify.extractors.dm import extract_dm, extract_dmf, extract_dmi, extract_dmm from graphify.extractors.elixir import extract_elixir from graphify.extractors.fortran import extract_fortran +from graphify.extractors.gdscript import extract_gdscript from graphify.extractors.go import extract_go from graphify.extractors.json_config import extract_json from graphify.extractors.julia import extract_julia @@ -45,6 +46,7 @@ "dmm": extract_dmm, "elixir": extract_elixir, "fortran": extract_fortran, + "gdscript": extract_gdscript, "go": extract_go, "json": extract_json, "julia": extract_julia, diff --git a/graphify/extractors/gdscript.py b/graphify/extractors/gdscript.py new file mode 100644 index 000000000..d7986d7ce --- /dev/null +++ b/graphify/extractors/gdscript.py @@ -0,0 +1,451 @@ +"""GDScript extractor for Godot ``.gd`` source files.""" +from __future__ import annotations + +import os + +from pathlib import Path +from typing import Any + +from graphify.extractors.base import _file_stem, _make_id, _read_text + + +# Engine-provided base and value types are useful while parsing, but they are not +# project definitions. Suppressing their stubs prevents ubiquitous Godot types +# from becoming misleading graph hubs. +_GDSCRIPT_BUILTIN_TYPES = frozenset({ + "Object", "RefCounted", "Reference", "Resource", "Node", "Node2D", "Node3D", + "Control", "CanvasItem", "Spatial", "Sprite", "Sprite2D", "Sprite3D", + "Area2D", "Area3D", "RigidBody2D", "RigidBody3D", "CharacterBody2D", + "CharacterBody3D", "StaticBody2D", "StaticBody3D", "CollisionShape2D", + "CollisionShape3D", "Camera2D", "Camera3D", "Button", "Label", "Panel", + "Timer", "Tween", "AnimationPlayer", "SceneTree", "Viewport", "Window", + "String", "StringName", "NodePath", "Variant", "RID", "Callable", "Signal", + "Vector2", "Vector2i", "Vector3", "Vector3i", "Vector4", "Vector4i", + "Color", "Rect2", "Rect2i", "Transform2D", "Transform3D", "Basis", + "Quaternion", "Plane", "AABB", "Projection", "Array", "Dictionary", + "PackedByteArray", "PackedInt32Array", "PackedInt64Array", + "PackedFloat32Array", "PackedFloat64Array", "PackedStringArray", + "PackedVector2Array", "PackedVector3Array", "PackedColorArray", + "Thread", "Mutex", "Semaphore", "Image", "RegEx", "RandomNumberGenerator", + "StreamPeer", "StreamPeerBuffer", "FileAccess", "DirAccess", "JSON", + "Time", "OS", "Engine", "Input", +}) + +# Built-in functions and common Object/Node methods are not project call targets. +_GDSCRIPT_CALL_SKIP = frozenset({ + "preload", "load", "print", "printerr", "print_debug", "push_error", + "push_warning", "assert", "range", "len", "str", "int", "float", "bool", + "abs", "min", "max", "clamp", "round", "floor", "ceil", "sign", "sqrt", + "super", "connect", "emit", "call", "call_deferred", "get_node", "has_node", + "instantiate", "instance", "queue_free", "is_instance_valid", +}) + + +def _gdscript_project_root(path: Path) -> Path | None: + """Return the directory that owns ``project.godot`` for ``path``.""" + for ancestor in path.parents: + try: + if (ancestor / "project.godot").is_file(): + return ancestor + except OSError: + continue + return None + + +def extract_gdscript(path: Path) -> dict: + """Extract declarations and high-confidence relationships from GDScript. + + A script with ``class_name`` gets a named class node; otherwise its file node + acts as the implicit script class. The extractor records functions, members, + signals, enums, inner classes, inheritance, resource loads, local calls, and + ``Type.new()`` instantiations. Unresolved bare calls are left for Graphify's + ambiguity-aware cross-file resolver. + """ + try: + from tree_sitter_language_pack import get_parser + except ImportError: + return { + "nodes": [], + "edges": [], + "error": "tree_sitter_language_pack not installed", + } + + try: + source = path.read_bytes() + tree = get_parser("gdscript").parse(source) + root = tree.root_node + except Exception as exc: + return {"nodes": [], "edges": [], "error": str(exc)} + + stem = _file_stem(path) + str_path = str(path) + nodes: list[dict] = [] + edges: list[dict] = [] + raw_calls: list[dict] = [] + seen_ids: set[str] = set() + seen_edges: set[tuple[str, str, str]] = set() + function_bodies: list[tuple[str, str, Any]] = [] + functions_by_container: dict[str, dict[str, list[str]]] = {} + + def add_node( + nid: str, + label: str, + line: int | None, + *, + source_file: str | None = str_path, + callable_node: bool = False, + ) -> None: + if not nid or nid in seen_ids: + return + seen_ids.add(nid) + node = { + "id": nid, + "label": label, + "file_type": "code", + "source_file": source_file, + "source_location": f"L{line}" if line else None, + } + if callable_node: + node["_callable"] = True + nodes.append(node) + + def add_edge( + source_nid: str, + target_nid: str, + relation: str, + line: int | None, + *, + context: str | None = None, + ) -> None: + if not source_nid or not target_nid or source_nid == target_nid: + return + key = (source_nid, target_nid, relation) + if key in seen_edges: + return + seen_edges.add(key) + edge = { + "source": source_nid, + "target": target_nid, + "relation": relation, + "confidence": "EXTRACTED", + "confidence_score": 1.0, + "source_file": str_path, + "source_location": f"L{line}" if line else None, + "weight": 1.0, + } + if context: + edge["context"] = context + edges.append(edge) + + def child(node, field: str, node_type: str | None = None): + found = node.child_by_field_name(field) + if found is not None: + return found + expected = node_type or field + return next((item for item in node.children if item.type == expected), None) + + def name_text(node) -> str: + name_node = child(node, "name") + return _read_text(name_node, source) if name_node is not None else "" + + def type_name(type_node) -> str: + """Return the most specific identifier in a GDScript type expression.""" + identifiers: list[str] = [] + + def walk(node) -> None: + if node.type == "identifier": + identifiers.append(_read_text(node, source)) + for item in node.named_children: + walk(item) + + walk(type_node) + if identifiers: + return identifiers[-1] + return _read_text(type_node, source).split(".")[-1].strip() + + file_nid = _make_id(str(path)) + add_node(file_nid, path.name, 1) + project_root = _gdscript_project_root(path) + + def resolve_resource(ref: str) -> str | None: + """Resolve a Godot resource path to the target file node when it exists.""" + ref = ref.strip().strip('"').strip("'") + if not ref or ref.startswith("user://"): + return None + if ref.startswith("res://"): + if project_root is None: + return None + target = project_root / ref[len("res://"):] + elif "://" in ref: + return None + else: + target = path.parent / ref + try: + if target.is_file(): + # Keep lexical path identity so this ID matches the target's own + # file node even when an ancestor is a symlink. + return _make_id(os.path.normpath(str(target))) + except OSError: + pass + return None + + class_name = "" + class_line = 1 + extends_node = None + for item in root.children: + if item.type == "class_name_statement": + class_name = name_text(item) + class_line = item.start_point[0] + 1 + elif item.type == "extends_statement": + extends_node = item + + if class_name: + script_nid = _make_id(stem, class_name) + add_node(script_nid, class_name, class_line) + add_edge(file_nid, script_nid, "defines", class_line) + else: + script_nid = file_nid + + if extends_node is not None: + line = extends_node.start_point[0] + 1 + base_type = child(extends_node, "type") + if base_type is not None: + base_name = type_name(base_type) + if base_name and base_name not in _GDSCRIPT_BUILTIN_TYPES: + # Keep unresolved type IDs out of the file-node namespace. A + # class named Actor commonly lives in actor.gd; using bare + # ``actor`` for both would make collision disambiguation treat + # the stub as the file node before the shared rewire pass runs. + base_nid = _make_id("gdscript_type", base_name) + add_node(base_nid, base_name, None, source_file=None) + add_edge(script_nid, base_nid, "inherits", line) + else: + path_node = next( + (item for item in extends_node.named_children if item.type == "string"), + None, + ) + if path_node is not None: + target_nid = resolve_resource(_read_text(path_node, source)) + if target_nid: + add_edge( + script_nid, + target_nid, + "inherits", + line, + context="extends_path", + ) + + def add_type_reference(container_nid: str, type_node, line: int) -> None: + if type_node is None: + return + referenced_type = type_name(type_node) + if ( + not referenced_type + or not referenced_type[:1].isupper() + or referenced_type in _GDSCRIPT_BUILTIN_TYPES + ): + return + type_nid = _make_id("gdscript_type", referenced_type) + add_node(type_nid, referenced_type, None, source_file=None) + add_edge( + container_nid, + type_nid, + "references", + line, + context="member_type", + ) + + def handle_declaration(node, container_nid: str) -> None: + line = node.start_point[0] + 1 + + if node.type == "function_definition": + function_name = name_text(node) + if not function_name: + return + function_nid = _make_id(container_nid, function_name) + add_node( + function_nid, + f"{function_name}()", + line, + callable_node=True, + ) + relation = "contains" if container_nid == file_nid else "method" + add_edge(container_nid, function_nid, relation, line) + functions_by_container.setdefault(container_nid, {}).setdefault( + function_name, [] + ).append(function_nid) + body = child(node, "body") + if body is not None: + function_bodies.append((function_nid, container_nid, body)) + return + + if node.type in ("variable_statement", "const_statement"): + member_name = name_text(node) + if not member_name: + return + member_nid = _make_id(container_nid, member_name) + add_node(member_nid, member_name, line) + add_edge(container_nid, member_nid, "defines", line) + add_type_reference(container_nid, child(node, "type"), line) + return + + if node.type == "signal_statement": + signal_name = name_text(node) + if not signal_name: + return + signal_nid = _make_id(container_nid, "signal", signal_name) + add_node(signal_nid, signal_name, line) + add_edge( + container_nid, + signal_nid, + "defines", + line, + context="signal", + ) + return + + if node.type == "enum_definition": + enum_name = name_text(node) + if not enum_name: + return + enum_nid = _make_id(container_nid, enum_name) + add_node(enum_nid, enum_name, line) + add_edge(container_nid, enum_nid, "defines", line) + enum_body = child(node, "body", "enumerator_list") + if enum_body is not None: + for enumerator in enum_body.named_children: + if enumerator.type != "enumerator": + continue + enum_case = next( + (item for item in enumerator.named_children if item.type == "identifier"), + None, + ) + if enum_case is None: + continue + case_name = _read_text(enum_case, source) + case_line = enumerator.start_point[0] + 1 + case_nid = _make_id(enum_nid, case_name) + add_node(case_nid, case_name, case_line) + add_edge(enum_nid, case_nid, "case_of", case_line) + return + + if node.type == "class_definition": + inner_name = name_text(node) + if not inner_name: + return + inner_nid = _make_id(container_nid, inner_name) + add_node(inner_nid, inner_name, line) + add_edge(container_nid, inner_nid, "defines", line) + class_body = child(node, "body", "class_body") + if class_body is not None: + for member in class_body.named_children: + handle_declaration(member, inner_nid) + + for item in root.named_children: + if item.type not in ("class_name_statement", "extends_statement"): + handle_declaration(item, script_nid) + + def emit_call( + caller_nid: str, + container_nid: str, + callee: str, + line: int, + *, + is_member_call: bool, + ) -> None: + if not callee or callee in _GDSCRIPT_CALL_SKIP: + return + local_targets = functions_by_container.get(container_nid, {}).get(callee, []) + if len(local_targets) == 1 and local_targets[0] != caller_nid: + add_edge(caller_nid, local_targets[0], "calls", line, context="call") + return + raw_calls.append({ + "caller_nid": caller_nid, + "callee": callee, + "is_member_call": is_member_call, + "lang": "gdscript", + "source_file": str_path, + "source_location": f"L{line}", + }) + + def walk_calls(node, caller_nid: str, container_nid: str) -> None: + if node.type == "function_definition": + return + if node.type == "call": + function_node = node.named_children[0] if node.named_children else None + if function_node is not None and function_node.type == "identifier": + emit_call( + caller_nid, + container_nid, + _read_text(function_node, source), + node.start_point[0] + 1, + is_member_call=False, + ) + elif node.type == "attribute": + receiver = node.named_children[0] if node.named_children else None + attribute_call = next( + (item for item in node.named_children if item.type == "attribute_call"), + None, + ) + if receiver is not None and attribute_call is not None: + method_node = next( + (item for item in attribute_call.named_children if item.type == "identifier"), + None, + ) + method_name = _read_text(method_node, source) if method_node is not None else "" + receiver_name = ( + _read_text(receiver, source) if receiver.type == "identifier" else "" + ) + line = node.start_point[0] + 1 + if method_name == "new" and receiver_name[:1].isupper(): + if receiver_name not in _GDSCRIPT_BUILTIN_TYPES: + class_nid = _make_id("gdscript_type", receiver_name) + add_node(class_nid, receiver_name, None, source_file=None) + add_edge( + caller_nid, + class_nid, + "instantiates", + line, + context="call", + ) + elif receiver_name == "self": + emit_call( + caller_nid, + container_nid, + method_name, + line, + is_member_call=False, + ) + for item in node.named_children: + walk_calls(item, caller_nid, container_nid) + + for caller_nid, container_nid, body in function_bodies: + walk_calls(body, caller_nid, container_nid) + + def walk_resource_loads(node) -> None: + if node.type == "call": + function_node = node.named_children[0] if node.named_children else None + if ( + function_node is not None + and function_node.type == "identifier" + and _read_text(function_node, source) in ("preload", "load") + ): + arguments = child(node, "arguments") + if arguments is not None: + for argument in arguments.named_children: + if argument.type != "string": + continue + target_nid = resolve_resource(_read_text(argument, source)) + if target_nid: + add_edge( + file_nid, + target_nid, + "imports_from", + node.start_point[0] + 1, + context="preload", + ) + for item in node.named_children: + walk_resource_loads(item) + + walk_resource_loads(root) + return {"nodes": nodes, "edges": edges, "raw_calls": raw_calls} diff --git a/pyproject.toml b/pyproject.toml index 50a7b63d8..0630f2120 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,7 +80,8 @@ pascal = ["tree-sitter-pascal"] # avoids breaking the default `uv tool install graphifyy` for everyone (#1104). dm = ["tree-sitter-dm"] terraform = ["tree-sitter-hcl"] -all = ["mcp", "starlette>=1.3.1", "neo4j", "falkordb", "pypdf>=6.12.0", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper; python_version >= '3.11'", "yt-dlp>=2026.6.9", "matplotlib", "numpy>=2.0; python_version >= '3.13'", "openai", "tiktoken", "boto3", "anthropic", "tree-sitter-sql", "jieba", "tree-sitter-dm", "tree-sitter-hcl", "tree-sitter-pascal"] +gdscript = ["tree-sitter-language-pack>=1.12.5,<1.13"] +all = ["mcp", "starlette>=1.3.1", "neo4j", "falkordb", "pypdf>=6.12.0", "markdownify", "watchdog", "graspologic; python_version < '3.13'", "python-docx", "openpyxl", "faster-whisper; python_version >= '3.11'", "yt-dlp>=2026.6.9", "matplotlib", "numpy>=2.0; python_version >= '3.13'", "openai", "tiktoken", "boto3", "anthropic", "tree-sitter-sql", "jieba", "tree-sitter-dm", "tree-sitter-hcl", "tree-sitter-language-pack>=1.12.5,<1.13", "tree-sitter-pascal"] [project.scripts] graphify = "graphify.__main__:main" diff --git a/tests/fixtures/sample.gd b/tests/fixtures/sample.gd new file mode 100644 index 000000000..3058773c9 --- /dev/null +++ b/tests/fixtures/sample.gd @@ -0,0 +1,25 @@ +@tool +class_name Player +extends CharacterBody2D + +signal health_changed(value: int) +@export var weapon: Weapon +const MAX_HEALTH: int = 100 +enum State { IDLE, RUNNING } + +func _ready() -> void: + _setup() + self.reset() + var copy := Weapon.new() + +func _setup() -> void: + pass + +func reset() -> void: + pass + +class Inventory: + var count: int + + func clear() -> void: + count = 0 diff --git a/tests/test_gdscript.py b/tests/test_gdscript.py new file mode 100644 index 000000000..f8cfa21b7 --- /dev/null +++ b/tests/test_gdscript.py @@ -0,0 +1,124 @@ +"""GDScript extraction and pipeline integration tests.""" +from __future__ import annotations + +import importlib.util +import sys + +from pathlib import Path + +import pytest + +from graphify.detect import CODE_EXTENSIONS, FileType, classify_file +from graphify.extract import extract +from graphify.extractors.gdscript import extract_gdscript + + +FIXTURES = Path(__file__).parent / "fixtures" +_HAS_GDSCRIPT_GRAMMAR = importlib.util.find_spec("tree_sitter_language_pack") is not None +needs_gdscript_grammar = pytest.mark.skipif( + not _HAS_GDSCRIPT_GRAMMAR, + reason="tree-sitter-language-pack not installed (optional [gdscript] extra)", +) + + +def _labels(result: dict) -> set[str]: + return {str(node.get("label", "")) for node in result["nodes"]} + + +def _edge_labels(result: dict, relation: str) -> set[tuple[str, str]]: + labels = {node["id"]: str(node.get("label", "")) for node in result["nodes"]} + return { + ( + str(labels.get(edge["source"], edge["source"])), + str(labels.get(edge["target"], edge["target"])), + ) + for edge in result["edges"] + if edge.get("relation") == relation + } + + +def test_gdscript_extension_is_classified_as_code() -> None: + assert ".gd" in CODE_EXTENSIONS + assert classify_file(Path("player.gd")) == FileType.CODE + + +@needs_gdscript_grammar +def test_gdscript_extracts_core_declarations_and_calls() -> None: + result = extract_gdscript(FIXTURES / "sample.gd") + + assert "error" not in result + assert { + "Player", + "health_changed", + "weapon", + "MAX_HEALTH", + "State", + "IDLE", + "RUNNING", + "_ready()", + "_setup()", + "reset()", + "Inventory", + "clear()", + } <= _labels(result) + + assert ("State", "IDLE") in _edge_labels(result, "case_of") + assert ("State", "RUNNING") in _edge_labels(result, "case_of") + assert ("Player", "Weapon") in _edge_labels(result, "references") + assert ("_ready()", "Weapon") in _edge_labels(result, "instantiates") + assert ("_ready()", "_setup()") in _edge_labels(result, "calls") + assert ("_ready()", "reset()") in _edge_labels(result, "calls") + assert "CharacterBody2D" not in _labels(result) + + +@needs_gdscript_grammar +def test_gdscript_cross_file_types_and_resource_paths_resolve(tmp_path: Path) -> None: + (tmp_path / "project.godot").write_text("[application]\n") + actor = tmp_path / "actor.gd" + actor.write_text( + "class_name Actor\n" + "extends Node\n\n" + "func wake() -> void:\n" + " pass\n" + ) + player = tmp_path / "player.gd" + player.write_text( + "class_name Player\n" + "extends Actor\n\n" + "var actor: Actor\n\n" + "func spawn() -> void:\n" + " var copy := Actor.new()\n" + ' var script = preload("res://actor.gd")\n' + ) + child = tmp_path / "child.gd" + child.write_text( + 'extends "res://actor.gd"\n' + "class_name Child\n" + ) + + result = extract( + [actor, player, child], + cache_root=tmp_path, + parallel=False, + ) + + assert ("Player", "Actor") in _edge_labels(result, "inherits") + assert ("Player", "Actor") in _edge_labels(result, "references") + assert ("spawn()", "Actor") in _edge_labels(result, "instantiates") + assert ("player.gd", "actor.gd") in _edge_labels(result, "imports_from") + assert ("Child", "actor.gd") in _edge_labels(result, "inherits") + + +def test_gdscript_missing_extra_is_reported(tmp_path: Path, capsys, monkeypatch) -> None: + script = tmp_path / "player.gd" + script.write_text("extends Node\n") + monkeypatch.setitem(sys.modules, "tree_sitter_language_pack", None) + + result = extract([script], cache_root=tmp_path, parallel=False) + error = capsys.readouterr().err + + assert result["nodes"] == [] + assert "1 .gd file(s)" in error + assert "tree_sitter_language_pack not installed" in error + assert "graphifyy[gdscript]" in error + assert "#1745" in error diff --git a/uv.lock b/uv.lock index 088ebbbdc..6a692de5d 100644 --- a/uv.lock +++ b/uv.lock @@ -1090,7 +1090,7 @@ wheels = [ [[package]] name = "graphifyy" -version = "0.9.6" +version = "0.9.13" source = { editable = "." } dependencies = [ { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -1147,6 +1147,7 @@ all = [ { name = "tiktoken" }, { name = "tree-sitter-dm" }, { name = "tree-sitter-hcl" }, + { name = "tree-sitter-language-pack" }, { name = "tree-sitter-pascal" }, { name = "tree-sitter-sql" }, { name = "watchdog" }, @@ -1167,6 +1168,9 @@ dm = [ falkordb = [ { name = "falkordb" }, ] +gdscript = [ + { name = "tree-sitter-language-pack" }, +] gemini = [ { name = "openai" }, { name = "tiktoken" }, @@ -1310,6 +1314,8 @@ requires-dist = [ { name = "tree-sitter-json", specifier = ">=0.23,<0.26" }, { name = "tree-sitter-julia", specifier = ">=0.23,<0.25" }, { name = "tree-sitter-kotlin", specifier = ">=1.0,<2.0" }, + { name = "tree-sitter-language-pack", marker = "extra == 'all'", specifier = ">=1.12.5,<1.13" }, + { name = "tree-sitter-language-pack", marker = "extra == 'gdscript'", specifier = ">=1.12.5,<1.13" }, { name = "tree-sitter-lua", specifier = ">=0.2,<0.6" }, { name = "tree-sitter-objc", specifier = ">=3.0,<4.0" }, { name = "tree-sitter-pascal", marker = "extra == 'all'" }, @@ -1331,7 +1337,7 @@ requires-dist = [ { name = "yt-dlp", marker = "extra == 'all'", specifier = ">=2026.6.9" }, { name = "yt-dlp", marker = "extra == 'video'", specifier = ">=2026.6.9" }, ] -provides-extras = ["mcp", "neo4j", "falkordb", "pdf", "watch", "svg", "leiden", "office", "google", "postgres", "video", "kimi", "ollama", "bedrock", "anthropic", "gemini", "openai", "chinese", "sql", "pascal", "dm", "terraform", "all"] +provides-extras = ["mcp", "neo4j", "falkordb", "pdf", "watch", "svg", "leiden", "office", "google", "postgres", "video", "kimi", "ollama", "bedrock", "anthropic", "gemini", "openai", "chinese", "sql", "pascal", "dm", "terraform", "gdscript", "all"] [package.metadata.requires-dev] dev = [ @@ -4703,6 +4709,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fb/b9/12fa97f63d2b7517c6f5d16938f0c5bfe84d925c652c75ff1c5e29bf6a44/tree_sitter_kotlin-1.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:e030f127a7d07952907adb9070248bd42fb86dc76fd92744727551b50e131ee7", size = 310414, upload-time = "2025-01-09T19:02:16.23Z" }, ] +[[package]] +name = "tree-sitter-language-pack" +version = "1.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tree-sitter" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/d0/06624b30acf3100ed663d604ac893882beafde603971e76f1160808ae02c/tree_sitter_language_pack-1.12.5.tar.gz", hash = "sha256:e6ca8fd7f1cc24496f2fff73156d0ce20284ce56e18a84f4c0c10388a1e4c951", size = 81955, upload-time = "2026-07-07T12:58:02.012Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/b9/015240f1e091f0d7608582e2930b62580839f4b3e94b5813bc1894017cf3/tree_sitter_language_pack-1.12.5-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c3613f26f1660d757626b50e2f7fd50ce269193ec9e8e0ad699ae47bf03fd664", size = 2140086, upload-time = "2026-07-07T12:57:53.574Z" }, + { url = "https://files.pythonhosted.org/packages/08/fd/8ca53b11ea1058c920944b52b41c4c4d96ccf67c62b15174f6d3d2a0126f/tree_sitter_language_pack-1.12.5-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:30010156a06179e7c179ae51ba6f2da148bd0bc364f750bcc5ef81ab1c4e0c53", size = 2031270, upload-time = "2026-07-07T12:57:55.165Z" }, + { url = "https://files.pythonhosted.org/packages/03/e1/af79d0d302f1f47f190fd1f09146a6e5f88a32a7c0d0759820d7ca9aafc3/tree_sitter_language_pack-1.12.5-cp310-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1609b9cc2518e4657f6ef80a8ac533a8f5a04d4a74135d744fb171eca434e6eb", size = 2189197, upload-time = "2026-07-07T12:57:56.37Z" }, + { url = "https://files.pythonhosted.org/packages/25/da/6e3db4ad85802747ead34f46f402508d796cd11be65b79d6ba6d76281a3f/tree_sitter_language_pack-1.12.5-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:de5ec0c1b184176443fc6093f07fe5a1c403ee51ec8593bde372bd5960819941", size = 2299397, upload-time = "2026-07-07T12:57:58.087Z" }, + { url = "https://files.pythonhosted.org/packages/19/fb/ac271b8013ff4f386ddce86cab080b8c6352d3efa45e8b2285d246eed318/tree_sitter_language_pack-1.12.5-cp310-abi3-win_amd64.whl", hash = "sha256:eb58ecee144247d8cde52a567bbe5c6f950ed1d2c416295e00f0e31389f1751e", size = 2073479, upload-time = "2026-07-07T12:57:59.491Z" }, + { url = "https://files.pythonhosted.org/packages/79/80/fbe56f84daec7fec163023f6e8865969e8873ec42b85241c750953320e1f/tree_sitter_language_pack-1.12.5-cp310-abi3-win_arm64.whl", hash = "sha256:41399bdd45eb4ced5840ddf1d44aa02fe69763c867bd7286aa20d182bfe397af", size = 1984602, upload-time = "2026-07-07T12:58:00.918Z" }, +] + [[package]] name = "tree-sitter-lua" version = "0.5.0"