diff --git a/README.md b/README.md index 81685cf72..05cfd01b4 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,7 @@ Codex users also need `multi_agent = true` under `[features]` in `~/.codex/confi | `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]"` | | `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]"` | +| `godot` | GDScript `.gd` + Godot `.tscn`/`.tres`/`project.godot` extraction (grammars via `tree-sitter-language-pack`; scene/resource extractor falls back to a dependency-free line parser) | `uv tool install "graphifyy[godot]"` | | `chinese` | Chinese query segmentation (jieba) | `uv tool install "graphifyy[chinese]"` | | `all` | Everything above | `uv tool install "graphifyy[all]"` | @@ -328,6 +329,8 @@ 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) | +| GDScript | `.gd` (requires `uv tool install graphifyy[godot]`; classes, functions, signals, `preload`/`load` imports, and autoload-resolved calls; grammar via `tree-sitter-language-pack`) | +| Godot resources | `.tscn` `.tres` `project.godot` (requires `uv tool install graphifyy[godot]`; scene-tree nodes, attached scripts, sub-scene instances, autoloads, and signal wires; `godot_resource` grammar via `tree-sitter-language-pack`, falls back to a dependency-free line parser when absent) | | 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 cb6c76484..554e56486 100644 --- a/graphify/analyze.py +++ b/graphify/analyze.py @@ -33,6 +33,8 @@ **{e: "dotnet" for e in (".cs",)}, **{e: "php" for e in (".php",)}, **{e: "r" for e in (".r",)}, + **{e: "gdscript" for e in (".gd",)}, + **{e: "godot_resource" for e in (".tscn", ".tres", ".godot")}, } diff --git a/graphify/build.py b/graphify/build.py index caeaefdf9..e657b5aee 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -50,6 +50,8 @@ ".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", + ".tscn": "godot_resource", ".tres": "godot_resource", ".godot": "godot_resource", } diff --git a/graphify/detect.py b/graphify/detect.py index 1c1c27c3e..4c2b2743b 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', '.cjs', '.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', '.cjs', '.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', '.gd', '.tscn', '.tres', '.godot'} DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.skill', '.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 12a4f108e..2c770ff30 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -38,6 +38,8 @@ ) from graphify.extractors.dart import extract_dart # noqa: F401 from graphify.extractors.dm import extract_dm, extract_dmf, extract_dmi, extract_dmm # noqa: F401 +from graphify.extractors.gdscript import extract_gdscript # noqa: F401 +from graphify.extractors.godot_resource import extract_godot_resource # 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.go import extract_go # noqa: F401 @@ -3890,6 +3892,10 @@ def add_existing_edge(edge: dict) -> None: ".svelte": extract_svelte, ".astro": extract_astro, ".dart": extract_dart, + ".gd": extract_gdscript, + ".tscn": extract_godot_resource, + ".tres": extract_godot_resource, + ".godot": extract_godot_resource, ".v": extract_verilog, ".sv": extract_verilog, ".svh": extract_verilog, diff --git a/graphify/extractors/MIGRATION.md b/graphify/extractors/MIGRATION.md index 75e1525e2..9a92d86e5 100644 --- a/graphify/extractors/MIGRATION.md +++ b/graphify/extractors/MIGRATION.md @@ -25,6 +25,8 @@ written so an AI agent can execute it in a single session. | sln | yes | | pascal_forms (dfm + lfm) | yes | | json_config | yes | +| gdscript | yes (added directly as a per-language extractor) | +| godot_resource (tscn/tres/project.godot) | yes (added directly as a per-language extractor) | | (config-driven core: python, js, java, c, cpp, csharp, kotlin, scala, php, lua, swift, groovy, vue, svelte, astro, xaml, groovy) | no — shared _extract_generic core, move as one batch | | (other bespoke: julia, verilog, markdown, objc, csproj, slnx, lazarus_package, pascal) | no | diff --git a/graphify/extractors/__init__.py b/graphify/extractors/__init__.py index ada517094..b784c814b 100644 --- a/graphify/extractors/__init__.py +++ b/graphify/extractors/__init__.py @@ -17,7 +17,9 @@ 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.godot_resource import extract_godot_resource from graphify.extractors.json_config import extract_json from graphify.extractors.julia import extract_julia from graphify.extractors.markdown import extract_markdown @@ -45,7 +47,9 @@ "dmm": extract_dmm, "elixir": extract_elixir, "fortran": extract_fortran, + "gdscript": extract_gdscript, "go": extract_go, + "godot_resource": extract_godot_resource, "json": extract_json, "julia": extract_julia, "lazarus_form": extract_lazarus_form, diff --git a/graphify/extractors/gdscript.py b/graphify/extractors/gdscript.py new file mode 100644 index 000000000..2a3473e31 --- /dev/null +++ b/graphify/extractors/gdscript.py @@ -0,0 +1,377 @@ +"""GDScript (Godot Engine) extractor. + +Parses ``.gd`` files with a tree-sitter GDScript grammar and emits Graphify's +node/edge dicts. Captures: + +* ``class_name X`` / inner ``class X`` -> class node +* ``extends Base`` / ``extends "res://x.gd"`` -> ``extends`` edge +* ``func f(): ...`` -> function node + ``defines`` edge +* generic ``foo()`` / ``obj.method()`` calls inside a body -> ``calls`` edges +* ``Autoload.method()`` -> ``calls`` edge resolved to the function in the + autoload's script (autoload map read from ``project.godot``) +* ``signal s(args)`` -> signal node + ``declares`` edge +* ``emit_signal("s")`` / ``s.emit()`` -> ``emits`` edge +* ``s.connect(handler)`` -> ``connects`` edge +* ``preload("res://y.gd")`` / ``load("res://y.gd")`` -> ``imports`` edge + +Depends on the GDScript grammar from ``tree-sitter-language-pack`` (the Godot +extra, ``graphify[godot]``). If the grammar is not installed the extractor +degrades to a bare file node so the pipeline never crashes. +""" +from __future__ import annotations + +import re + +from pathlib import Path + +from graphify.extractors.base import _file_stem, _make_id + +# Cache of resolved [autoload] maps keyed by project root, so project.godot is +# parsed once per worker process rather than per .gd file. +_AUTOLOAD_CACHE: dict[str, dict[str, Path]] = {} +_AUTOLOAD_SECTION_RE = re.compile(r'^\s*\[(?P\w+)\]') + + +def _load_gdscript_parser(): + """Return a tree-sitter Parser for GDScript, or None if no grammar is available. + + Tries grammar sources in order of preference: + 1. ``tree_sitter_language_pack`` — the Godot extra (``graphify[godot]``) + installs this; it bundles PrestonKnopp's GDScript grammar and is the + supported path since the standalone wheel is not on PyPI. + 2. ``tree_sitter_gdscript`` — the standalone grammar package, used + opportunistically if a user has it installed directly. + """ + try: + from tree_sitter import Language, Parser + except Exception: + return None + # 1) language-pack (the Godot extra installs this) + try: + from tree_sitter_language_pack import get_parser + return get_parser("gdscript") + except Exception: + pass + # 2) standalone grammar package (opportunistic fallback) + try: + import tree_sitter_gdscript as tsg + return Parser(Language(tsg.language())) + except Exception: + pass + return None + + +def _txt(node, source: bytes) -> str: + return source[node.start_byte:node.end_byte].decode("utf-8", "replace") + + +def _loc(node) -> str: + return f"L{node.start_point[0] + 1}" + + +def _field_ident(node, source: bytes) -> str | None: + """Return the text of a node's ``name`` field (an identifier).""" + n = node.child_by_field_name("name") + if n is not None: + return _txt(n, source) + return None + + +def _first_named(node, types: set[str]): + for c in node.children: + if c.is_named and c.type in types: + return c + return None + + +def _strip_quotes(s: str) -> str: + s = s.strip() + if len(s) >= 2 and s[0] in "\"'" and s[-1] == s[0]: + return s[1:-1] + return s + + +def _resolve_res(res_path: str, path: Path) -> Path | None: + """Resolve a ``res://`` path to a real file relative to the project root. + + The project root is the nearest ancestor containing ``project.godot``; + fall back to the file's own directory when none is found. + """ + if not res_path.startswith("res://"): + return None + rel = res_path[len("res://"):] + root = path.parent + for parent in [path.parent, *path.parents]: + if (parent / "project.godot").exists(): + root = parent + break + candidate = (root / rel) + try: + return candidate.resolve() + except Exception: + return candidate + + +def _autoload_map(path: Path) -> dict[str, Path]: + """Return ``{AutoloadName: resolved script Path}`` from the project's + ``project.godot`` ``[autoload]`` section, so that ``Autoload.method()`` calls + can resolve to the real function node in the autoload's script. + + Only ``.gd`` autoloads are mapped. Result is cached per project root. + """ + root = None + for parent in [path.parent, *path.parents]: + if (parent / "project.godot").exists(): + root = parent + break + if root is None: + return {} + key = str(root) + cached = _AUTOLOAD_CACHE.get(key) + if cached is not None: + return cached + + amap: dict[str, Path] = {} + try: + text = (root / "project.godot").read_text(encoding="utf-8", errors="replace") + except OSError: + _AUTOLOAD_CACHE[key] = amap + return amap + + section: str | None = None + for line in text.splitlines(): + m = _AUTOLOAD_SECTION_RE.match(line) + if m: + section = m.group("name") + continue + if section != "autoload": + continue + s = line.strip() + if not s or "=" not in s: + continue + name, _, val = s.partition("=") + name = name.strip() + raw = val.strip().strip('"') + res = raw[1:] if raw.startswith("*") else raw # '*' = enabled singleton + resolved = _resolve_res(res, path) if res.endswith(".gd") else None + if name and resolved is not None: + amap[name] = resolved + _AUTOLOAD_CACHE[key] = amap + return amap + + +def extract_gdscript(path: Path) -> dict: + parser = _load_gdscript_parser() + try: + raw = path.read_bytes() + except OSError: + return {"error": f"cannot read {path}"} + + stem = _file_stem(path) + file_nid = _make_id(str(path)) + nodes: list[dict] = [{ + "id": file_nid, "label": path.name, "file_type": "code", + "source_file": str(path), "source_location": None, + }] + edges: list[dict] = [] + defined: set[str] = {file_nid} + + def add_node(nid: str, label: str, source_location: str | None = None) -> None: + if nid not in defined: + nodes.append({"id": nid, "label": label, "file_type": "code", + "source_file": str(path), "source_location": source_location}) + defined.add(nid) + + def add_edge(src: str, tgt: str, relation: str, location: str | None = None, + context: str | None = None) -> None: + edge = {"source": src, "target": tgt, "relation": relation, + "confidence": "EXTRACTED", "confidence_score": 1.0, + "source_file": str(path), "source_location": location, "weight": 1.0} + if context: + edge["context"] = context + edges.append(edge) + + if parser is None: + # grammar unavailable: bare file node keeps the pipeline alive + return {"nodes": nodes, "edges": edges} + + tree = parser.parse(raw) + root = tree.root_node + autoloads = _autoload_map(path) # AutoloadName -> resolved script Path + + # ---- pre-scan: local function names so calls resolve to real defs ------- + local_funcs: dict[str, str] = {} + + def _prescan(node) -> None: + for c in node.children: + if c.type in ("function_definition", "constructor_definition"): + fname = _field_ident(c, raw) or ("_init" if c.type == "constructor_definition" else None) + if fname: + local_funcs.setdefault(fname, _make_id(stem, fname)) + _prescan(c) + + _prescan(root) + + # ---- top-level class identity ------------------------------------------- + owner_nid = file_nid + owner_label = path.name + signals: dict[str, str] = {} # signal name -> node id (script-level) + + cn = _first_named(root, {"class_name_statement"}) + if cn is not None: + name = _field_ident(cn, raw) + if name: + owner_label = name + owner_nid = _make_id(stem, name) + add_node(owner_nid, name, _loc(cn)) + add_edge(file_nid, owner_nid, "defines", _loc(cn)) + + # ---- extends ------------------------------------------------------------- + ext = _first_named(root, {"extends_statement"}) + if ext is not None: + type_node = _first_named(ext, {"type"}) + base_txt = _txt(type_node, raw).strip() if type_node is not None else _txt(ext, raw).replace("extends", "", 1).strip() + if base_txt: + res = None + if base_txt.startswith(("\"", "'")): + res = _resolve_res(_strip_quotes(base_txt), path) + if res is not None: + tgt = _make_id(str(res)) + add_node(tgt, res.name) + else: + tgt = _make_id(base_txt) + add_node(tgt, base_txt) + add_edge(owner_nid, tgt, "extends", _loc(ext)) + + # ---- signals (script level) --------------------------------------------- + for sig in [c for c in root.children if c.is_named and c.type == "signal_statement"]: + sname = _field_ident(sig, raw) + if sname: + snid = _make_id(stem, "signal:" + sname) + add_node(snid, sname + " (signal)", _loc(sig)) + add_edge(owner_nid, snid, "declares", _loc(sig)) + signals[sname] = snid + + # ---- functions and their call bodies ------------------------------------ + def handle_call(call_node, func_nid: str) -> None: + """A bare ``call`` node: callee is the first identifier child.""" + callee = None + for c in call_node.children: + if c.type == "identifier": + callee = _txt(c, raw) + break + if c.type in ("attribute",): + break + if callee is None: + return + args = call_node.child_by_field_name("arguments") + if callee in ("preload", "load") and args is not None: + for a in args.children: + if a.type == "string": + res = _resolve_res(_strip_quotes(_txt(a, raw)), path) + if res is not None: + tgt = _make_id(str(res)) + add_node(tgt, res.name) + add_edge(file_nid, tgt, "imports", _loc(call_node), context="preload") + break + return + if callee == "emit_signal" and args is not None: + for a in args.children: + if a.type == "string": + sname = _strip_quotes(_txt(a, raw)) + snid = signals.get(sname) or _make_id(stem, "signal:" + sname) + add_node(snid, sname + " (signal)") + add_edge(func_nid, snid, "emits", _loc(call_node)) + break + return + if callee in local_funcs: + tgt = local_funcs[callee] + else: + tgt = _make_id(callee) + add_node(tgt, callee + "()") + add_edge(func_nid, tgt, "calls", _loc(call_node)) + + def handle_attribute_call(attr_node, func_nid: str) -> None: + """``receiver.method(args)`` -> attribute( identifier, attribute_call ).""" + recv = None + acall = None + for c in attr_node.children: + if c.type == "identifier" and recv is None: + recv = _txt(c, raw) + elif c.type == "attribute_call": + acall = c + if acall is None: + return + method = None + for c in acall.children: + if c.type == "identifier": + method = _txt(c, raw) + break + if method is None: + return + if method == "connect" and recv in signals: + acargs = acall.child_by_field_name("arguments") + handler = None + if acargs is not None: + for a in acargs.children: + if a.type == "identifier": + handler = _txt(a, raw) + break + if handler: + if handler in local_funcs: + htgt = local_funcs[handler] + else: + htgt = _make_id(handler) + add_node(htgt, handler + "()") + add_edge(signals[recv], htgt, "connects", _loc(attr_node)) + return + if method == "emit" and recv in signals: + add_edge(func_nid, signals[recv], "emits", _loc(attr_node)) + return + # ``Autoload.method()`` -> resolve to the real function node in the + # autoload's script (its id matches _make_id(_file_stem(script), method)). + if recv in autoloads: + tgt = _make_id(_file_stem(autoloads[recv]), method) + add_edge(func_nid, tgt, "calls", _loc(attr_node), context=recv) + return + tgt = _make_id(method) + add_node(tgt, method + "()") + add_edge(func_nid, tgt, "calls", _loc(attr_node), context=(recv or "")) + + def walk_body(node, func_nid: str) -> None: + for c in node.children: + if c.type == "function_definition": + continue # nested funcs handled separately + if c.type == "call": + handle_call(c, func_nid) + elif c.type == "attribute": + # attribute may itself contain an attribute_call (method call) + if any(k.type == "attribute_call" for k in c.children): + handle_attribute_call(c, func_nid) + walk_body(c, func_nid) + + def collect_functions(container, owner: str) -> None: + for c in container.children: + if c.type in ("function_definition", "constructor_definition"): + fname = _field_ident(c, raw) or ("_init" if c.type == "constructor_definition" else None) + if not fname: + continue + fnid = _make_id(stem, fname) + add_node(fnid, fname + "()", _loc(c)) + add_edge(owner, fnid, "defines", _loc(c)) + body = c.child_by_field_name("body") + if body is not None: + walk_body(body, fnid) + elif c.type == "class_definition": + iname = _field_ident(c, raw) + inid = _make_id(stem, iname) if iname else owner + if iname: + add_node(inid, iname, _loc(c)) + add_edge(owner, inid, "defines", _loc(c)) + cbody = _first_named(c, {"class_body"}) + if cbody is not None: + collect_functions(cbody, inid) + + collect_functions(root, owner_nid) + + return {"nodes": nodes, "edges": edges} diff --git a/graphify/extractors/godot_resource.py b/graphify/extractors/godot_resource.py new file mode 100644 index 000000000..103ab3d55 --- /dev/null +++ b/graphify/extractors/godot_resource.py @@ -0,0 +1,380 @@ +"""Godot resource extractor (.tscn scenes, .tres resources, project.godot). + +Parses Godot's text formats for ``.tscn`` (PackedScene), ``.tres`` (Resource) +and ``project.godot`` and emits Graphify's node/edge dicts: + +* ``.tscn`` / ``.tres``: + - ``[ext_resource type="Script" path="res://x.gd"]`` -> ``attaches_script`` edge + - ``[ext_resource type="PackedScene" path="res://y.tscn"]`` -> ``instances`` edge + - ``[node name="N" type="T" parent="P"]`` -> scene-tree node + - ``script = ExtResource("id")`` under a node -> that node's ``attaches_script`` edge + - ``[connection signal="s" from="A" to="B" method="m"]`` -> ``connects`` edge, + resolved to the target node's script function when possible + +* ``project.godot``: + - ``[autoload]`` entries -> global singleton node + ``autoload`` edge to the script + - ``run/main_scene`` -> ``main_scene`` edge + +Godot's scene / resource / project files all share one text format. When the +``godot_resource`` tree-sitter grammar is available (bundled in +``tree-sitter-language-pack``, the Godot extra) it is used for robust handling +of quoting, multi-line values and nested constructors. Both front ends lower a +file to the same intermediate list of ``_Block``s, which a single edge builder +consumes -- so the grammar path and the dependency-free line-parser fallback +produce identical nodes/edges. If the grammar is absent (or a file fails to +parse cleanly) the line parser takes over, so scene/autoload/signal-wire edges +keep working out of the box. +""" +from __future__ import annotations + +import re + +from pathlib import Path + +from graphify.extractors.base import _file_stem, _make_id + +_SECTION_RE = re.compile(r'^\[(?P[a-z_]+)(?P[^\]]*)\]\s*$') +_ATTR_RE = re.compile(r'(\w+)\s*=\s*"([^"]*)"') +_PROP_RE = re.compile(r'^\s*(?P[\w/]+)\s*=\s*(?P.+?)\s*$') +_EXTRES_CALL_RE = re.compile(r'ExtResource\(\s*"?([^")]+)"?\s*\)') + + +class _Block: + """One header section (or the file preamble) in normalized form. + + ``kind`` is the section identifier (``ext_resource``, ``node``, + ``connection``, ``autoload`` ...) or ``None`` for top-level properties that + precede any section (e.g. ``config_version`` in ``project.godot``). + ``attrs`` are the header's ``key="value"`` pairs (unquoted). ``props`` are + the ``key = value`` lines inside the section as ``(key, raw_value, loc)``; + the raw value is kept verbatim so each builder applies its own unquoting / + ``ExtResource(...)`` parsing exactly as before. + """ + + __slots__ = ("kind", "attrs", "props", "loc") + + def __init__(self, kind, attrs, props, loc): + self.kind = kind + self.attrs = attrs + self.props = props + self.loc = loc + + +def _project_root(path: Path) -> Path: + for parent in [path.parent, *path.parents]: + if (parent / "project.godot").exists(): + return parent + return path.parent + + +def _resolve_res(res_path: str, root: Path) -> Path | None: + if not res_path.startswith("res://"): + return None + candidate = root / res_path[len("res://"):] + try: + return candidate.resolve() + except Exception: + return candidate + + +def _parse_attrs(attr_str: str) -> dict: + return {k: v for k, v in _ATTR_RE.findall(attr_str)} + + +# --------------------------------------------------------------------------- +# Grammar-based front end (tree-sitter-language-pack: "godot_resource") +# --------------------------------------------------------------------------- + +# Sentinel so a failed import is cached and not retried per file. +_RESOURCE_PARSER: object = "unset" + + +def _load_resource_parser(): + """Return a tree-sitter Parser for Godot resource text, or ``None``. + + The ``godot_resource`` grammar (PrestonKnopp, MIT) is bundled in + ``tree-sitter-language-pack`` (the Godot extra) and parses ``.tscn``, + ``.tres`` and ``project.godot`` alike. When the pack is not installed the + caller falls back to the line parser below. + """ + global _RESOURCE_PARSER + if _RESOURCE_PARSER != "unset": + return _RESOURCE_PARSER + parser = None + try: + from tree_sitter_language_pack import get_parser + parser = get_parser("godot_resource") + except Exception: + parser = None + _RESOURCE_PARSER = parser + return parser + + +def _node_text(node, src: bytes) -> str: + return src[node.start_byte:node.end_byte].decode("utf-8", "replace") + + +def _unquote(s: str) -> str: + if len(s) >= 2 and s[0] == '"' and s[-1] == '"': + return s[1:-1] + return s + + +def _key_value_nodes(node): + """First and last named children of an ``attribute`` / ``property`` node. + + Both grammar shapes are ``key = value`` with the key as the first named + child and the value as the last, so this covers both. + """ + named = [c for c in node.children if c.is_named] + if len(named) >= 2: + return named[0], named[-1] + if len(named) == 1: + return named[0], None + return None, None + + +def _blocks_from_grammar(text: str) -> list[_Block] | None: + parser = _load_resource_parser() + if parser is None: + return None + try: + tree = parser.parse(text.encode("utf-8")) + except Exception: + return None + if tree.root_node.has_error: + # A malformed file: defer to the tolerant line parser instead. + return None + + src = text.encode("utf-8") + blocks: list[_Block] = [] + preamble: list[tuple[str, str, str]] = [] + + for child in tree.root_node.children: + if child.type == "property": + k, v = _key_value_nodes(child) + if k is not None: + preamble.append((_node_text(k, src), + _node_text(v, src) if v is not None else "", + f"L{child.start_point[0] + 1}")) + elif child.type == "section": + kind: str | None = None + attrs: dict = {} + props: list[tuple[str, str, str]] = [] + for c in child.children: + if c.type == "identifier" and kind is None: + kind = _node_text(c, src) + elif c.type == "attribute": + k, v = _key_value_nodes(c) + if k is not None: + attrs[_node_text(k, src)] = ( + _unquote(_node_text(v, src)) if v is not None else "") + elif c.type == "property": + k, v = _key_value_nodes(c) + if k is not None: + props.append((_node_text(k, src), + _node_text(v, src) if v is not None else "", + f"L{c.start_point[0] + 1}")) + blocks.append(_Block(kind, attrs, props, f"L{child.start_point[0] + 1}")) + + if preamble: + blocks.insert(0, _Block(None, {}, preamble, "L1")) + return blocks + + +# --------------------------------------------------------------------------- +# Dependency-free line parser (fallback when the grammar is absent) +# --------------------------------------------------------------------------- + +def _blocks_from_lines(text: str) -> list[_Block]: + blocks: list[_Block] = [] + preamble = _Block(None, {}, [], "L1") + current: _Block | None = None + + for i, line in enumerate(text.splitlines()): + loc = f"L{i + 1}" + m = _SECTION_RE.match(line) + if m: + current = _Block(m.group("kind"), _parse_attrs(m.group("attrs")), [], loc) + blocks.append(current) + continue + stripped = line.strip() + if not stripped or stripped.startswith(";"): + continue + pm = _PROP_RE.match(line) + if pm: + target = current if current is not None else preamble + target.props.append((pm.group("key"), pm.group("val"), loc)) + + if preamble.props: + blocks.insert(0, preamble) + return blocks + + +def _blocks(text: str) -> list[_Block]: + blocks = _blocks_from_grammar(text) + if blocks is None: + blocks = _blocks_from_lines(text) + return blocks + + +# --------------------------------------------------------------------------- +# Shared edge builders +# --------------------------------------------------------------------------- + +def extract_godot_resource(path: Path) -> dict: + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + return {"error": f"cannot read {path}"} + + blocks = _blocks(text) + if path.name == "project.godot": + return _build_project(path, blocks) + return _build_scene(path, blocks) + + +def _build_scene(path: Path, blocks: list[_Block]) -> dict: + root = _project_root(path) + file_nid = _make_id(str(path)) + nodes: list[dict] = [{ + "id": file_nid, "label": path.name, "file_type": "code", + "source_file": str(path), "source_location": None, + }] + edges: list[dict] = [] + defined: set[str] = {file_nid} + + def add_node(nid: str, label: str, loc: str | None = None) -> None: + if nid not in defined: + nodes.append({"id": nid, "label": label, "file_type": "code", + "source_file": str(path), "source_location": loc}) + defined.add(nid) + + def add_edge(src: str, tgt: str, relation: str, loc: str | None = None, + context: str | None = None) -> None: + e = {"source": src, "target": tgt, "relation": relation, + "confidence": "EXTRACTED", "confidence_score": 1.0, + "source_file": str(path), "source_location": loc, "weight": 1.0} + if context: + e["context"] = context + edges.append(e) + + ext_resources: dict[str, dict] = {} # id -> {type, path, resolved} + root_script_stem: str | None = None # stem of the script on the "." node + node_scripts: dict[str, str] = {} # node path -> script stem + + for block in blocks: + kind = block.kind + loc = block.loc + + if kind == "ext_resource": + rid = block.attrs.get("id", "") + rtype = block.attrs.get("type", "") + rpath = block.attrs.get("path", "") + resolved = _resolve_res(rpath, root) + ext_resources[rid] = {"type": rtype, "path": rpath, "resolved": resolved} + if resolved is not None: + tgt = _make_id(str(resolved)) + add_node(tgt, resolved.name) + if rpath.endswith(".gd") or rtype == "Script": + add_edge(file_nid, tgt, "attaches_script", loc) + elif rpath.endswith(".tscn") or rtype == "PackedScene": + add_edge(file_nid, tgt, "instances", loc) + else: + add_edge(file_nid, tgt, "uses_resource", loc, context=rtype or None) + + elif kind == "node": + name = block.attrs.get("name", "") + parent = block.attrs.get("parent") + if parent is None: + node_path = "." # scene root + elif parent == ".": + node_path = name + else: + node_path = f"{parent}/{name}" + + for key, val, ploc in block.props: + if key != "script": + continue + cm = _EXTRES_CALL_RE.search(val) + if not cm: + continue + info = ext_resources.get(cm.group(1)) + if info and info.get("resolved") is not None: + resolved = info["resolved"] + tgt = _make_id(str(resolved)) + add_node(tgt, resolved.name) + add_edge(file_nid, tgt, "attaches_script", ploc, + context=node_path) + stem = _file_stem(resolved) + node_scripts[node_path] = stem + if node_path == ".": + root_script_stem = stem + + elif kind == "connection": + sig = block.attrs.get("signal", "") + frm = block.attrs.get("from", "") + to = block.attrs.get("to", "") + method = block.attrs.get("method", "") + if method: + tgt_stem = node_scripts.get(to) + if tgt_stem is None and to == ".": + tgt_stem = root_script_stem + if tgt_stem is not None: + tgt = _make_id(tgt_stem, method) + else: + tgt = _make_id(method) + add_node(tgt, method + "()") + add_edge(file_nid, tgt, "connects", loc, + context=f"{sig} from {frm}") + + return {"nodes": nodes, "edges": edges} + + +def _build_project(path: Path, blocks: list[_Block]) -> dict: + root = path.parent + file_nid = _make_id(str(path)) + nodes: list[dict] = [{ + "id": file_nid, "label": "project.godot", "file_type": "code", + "source_file": str(path), "source_location": None, + }] + edges: list[dict] = [] + defined: set[str] = {file_nid} + + def add_node(nid: str, label: str, loc: str | None = None) -> None: + if nid not in defined: + nodes.append({"id": nid, "label": label, "file_type": "code", + "source_file": str(path), "source_location": loc}) + defined.add(nid) + + def add_edge(src: str, tgt: str, relation: str, loc: str | None = None, + context: str | None = None) -> None: + e = {"source": src, "target": tgt, "relation": relation, + "confidence": "EXTRACTED", "confidence_score": 1.0, + "source_file": str(path), "source_location": loc, "weight": 1.0} + if context: + e["context"] = context + edges.append(e) + + for block in blocks: + for key, val, loc in block.props: + if block.kind == "autoload": + # GameState="*res://scripts/game_state.gd" + raw = val.strip().strip('"') + res = raw[1:] if raw.startswith("*") else raw + resolved = _resolve_res(res, root) + gid = _make_id("autoload", key) + add_node(gid, key + " (autoload)", loc) + add_edge(file_nid, gid, "autoload", loc) + if resolved is not None: + tgt = _make_id(str(resolved)) + add_node(tgt, resolved.name) + add_edge(gid, tgt, "script", loc) + elif key == "run/main_scene": + resolved = _resolve_res(val.strip().strip('"'), root) + if resolved is not None: + tgt = _make_id(str(resolved)) + add_node(tgt, resolved.name) + add_edge(file_nid, tgt, "main_scene", loc) + + return {"nodes": nodes, "edges": edges} diff --git a/pyproject.toml b/pyproject.toml index 2e5340d39..999fe88ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,7 +80,9 @@ 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"] +# Godot: `gdscript` + `godot_resource` grammars via tree-sitter-language-pack (optional; degrades gracefully). +godot = ["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-pascal", "tree-sitter-language-pack>=1.12.5,<1.13"] [project.scripts] graphify = "graphify.__main__:main" diff --git a/tests/fixtures/godot/gdscript/analytics.gd b/tests/fixtures/godot/gdscript/analytics.gd new file mode 100644 index 000000000..3b2ac6574 --- /dev/null +++ b/tests/fixtures/godot/gdscript/analytics.gd @@ -0,0 +1,3 @@ +extends Node +func track(name): + pass diff --git a/tests/fixtures/godot/gdscript/audio.gd b/tests/fixtures/godot/gdscript/audio.gd new file mode 100644 index 000000000..c03b4cd89 --- /dev/null +++ b/tests/fixtures/godot/gdscript/audio.gd @@ -0,0 +1,2 @@ +extends Node +class_name AudioManager diff --git a/tests/fixtures/godot/gdscript/empty.gd b/tests/fixtures/godot/gdscript/empty.gd new file mode 100644 index 000000000..e69de29bb diff --git a/tests/fixtures/godot/gdscript/enemy.gd b/tests/fixtures/godot/gdscript/enemy.gd new file mode 100644 index 000000000..4e744c395 --- /dev/null +++ b/tests/fixtures/godot/gdscript/enemy.gd @@ -0,0 +1,20 @@ +class_name Enemy +extends CharacterBody2D + +signal died(reason) + +func _ready(): + died.connect(_on_died) + sprite.play("idle") + +func take_damage(amount): + if amount > 0: + emit_signal("died", "killed") + die() + +func die(): + var fx = preload("res://audio.gd") + queue_free() + +func _on_died(reason): + print(reason) diff --git a/tests/fixtures/godot/gdscript/player.gd b/tests/fixtures/godot/gdscript/player.gd new file mode 100644 index 000000000..ad9c2f6f0 --- /dev/null +++ b/tests/fixtures/godot/gdscript/player.gd @@ -0,0 +1,3 @@ +extends Node +func attack(): + Analytics.track("hit") diff --git a/tests/fixtures/godot/gdscript/project.godot b/tests/fixtures/godot/gdscript/project.godot new file mode 100644 index 000000000..2d0585360 --- /dev/null +++ b/tests/fixtures/godot/gdscript/project.godot @@ -0,0 +1,4 @@ +config_version=5 + +[autoload] +Analytics="*res://analytics.gd" diff --git a/tests/fixtures/godot/resource/project.godot b/tests/fixtures/godot/resource/project.godot new file mode 100644 index 000000000..d938daec9 --- /dev/null +++ b/tests/fixtures/godot/resource/project.godot @@ -0,0 +1,7 @@ +config_version=5 + +[application] +run/main_scene="res://scenes/Main.tscn" + +[autoload] +GameState="*res://scripts/game_state.gd" diff --git a/tests/fixtures/godot/resource/scenes/Bullet.tscn b/tests/fixtures/godot/resource/scenes/Bullet.tscn new file mode 100644 index 000000000..76124129d --- /dev/null +++ b/tests/fixtures/godot/resource/scenes/Bullet.tscn @@ -0,0 +1,3 @@ +[gd_scene format=3 uid="uid://bul"] + +[node name="Bullet" type="Area2D"] diff --git a/tests/fixtures/godot/resource/scenes/Main.tscn b/tests/fixtures/godot/resource/scenes/Main.tscn new file mode 100644 index 000000000..e72a721a0 --- /dev/null +++ b/tests/fixtures/godot/resource/scenes/Main.tscn @@ -0,0 +1,11 @@ +[gd_scene load_steps=3 format=3 uid="uid://abc"] + +[ext_resource type="Script" path="res://scripts/enemy.gd" id="1_e"] +[ext_resource type="PackedScene" path="res://scenes/Bullet.tscn" id="2_b"] + +[node name="Enemy" type="CharacterBody2D"] +script = ExtResource("1_e") + +[node name="Hitbox" type="Area2D" parent="."] + +[connection signal="body_entered" from="Hitbox" to="." method="take_damage"] diff --git a/tests/fixtures/godot/resource/scripts/enemy.gd b/tests/fixtures/godot/resource/scripts/enemy.gd new file mode 100644 index 000000000..853108a37 --- /dev/null +++ b/tests/fixtures/godot/resource/scripts/enemy.gd @@ -0,0 +1,4 @@ +class_name Enemy +extends CharacterBody2D +func take_damage(a): + pass diff --git a/tests/fixtures/godot/resource/scripts/game_state.gd b/tests/fixtures/godot/resource/scripts/game_state.gd new file mode 100644 index 000000000..61510e14c --- /dev/null +++ b/tests/fixtures/godot/resource/scripts/game_state.gd @@ -0,0 +1 @@ +extends Node diff --git a/tests/fixtures/godot/resource/weird.tscn b/tests/fixtures/godot/resource/weird.tscn new file mode 100644 index 000000000..7ff5e278b --- /dev/null +++ b/tests/fixtures/godot/resource/weird.tscn @@ -0,0 +1,6 @@ +[gd_scene format=3] + +[ext_resource type="Script" path="res://scripts/enemy.gd" id="1_e"] + +[node name="Odd]Name" type="Node2D"] +script = ExtResource("1_e") diff --git a/tests/test_gdscript.py b/tests/test_gdscript.py new file mode 100644 index 000000000..18f1ad769 --- /dev/null +++ b/tests/test_gdscript.py @@ -0,0 +1,86 @@ +import shutil +import tempfile +import unittest +from pathlib import Path + +from graphify.extract import extract_gdscript, _make_id, _file_stem +from graphify.extractors.gdscript import _load_gdscript_parser + +_HAS_GRAMMAR = _load_gdscript_parser() is not None +_FIXTURES = Path(__file__).parent / "fixtures" / "godot" / "gdscript" + + +def _rels(result): + return {e["relation"] for e in result["edges"]} + + +def _edge(result, relation): + return [e for e in result["edges"] if e["relation"] == relation] + + +@unittest.skipUnless(_HAS_GRAMMAR, "tree-sitter-gdscript grammar not installed") +class TestGDScript(unittest.TestCase): + def setUp(self): + # Copy the fixture project into a temp dir so res:// resolution and the + # per-project autoload cache anchor on a real, isolated project root. + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) / "proj" + shutil.copytree(_FIXTURES, self.root) + + def tearDown(self): + self.tmp.cleanup() + + def test_class_extends_functions_signals_calls(self): + p = self.root / "enemy.gd" + r = extract_gdscript(p) + rels = _rels(r) + for expected in ("defines", "extends", "declares", "emits", "connects", "calls", "imports"): + self.assertIn(expected, rels, f"missing relation {expected}") + + # class node id + stem = _file_stem(p) + enemy_nid = _make_id(stem, "Enemy") + labels = {n["id"]: n["label"] for n in r["nodes"]} + self.assertEqual(labels.get(enemy_nid), "Enemy") + + # extends target is the base type + ext = _edge(r, "extends")[0] + self.assertEqual(labels.get(ext["target"]), "CharacterBody2D") + + # local call resolves to the DEFINED die() (same id, no orphan anchor) + die_nid = _make_id(stem, "die") + calls_targets = {e["target"] for e in _edge(r, "calls")} + self.assertIn(die_nid, calls_targets) + + # signal connect resolves handler to the local function + on_died_nid = _make_id(stem, "_on_died") + conn = _edge(r, "connects")[0] + self.assertEqual(conn["target"], on_died_nid) + + # preload resolves res:// to the real file node id + audio_nid = _make_id(str((self.root / "audio.gd").resolve())) + imports_targets = {e["target"] for e in _edge(r, "imports")} + self.assertIn(audio_nid, imports_targets) + + def test_autoload_method_call_resolves_to_script_function(self): + # project.godot registers Analytics as an autoload -> analytics.gd + caller = self.root / "player.gd" + r = extract_gdscript(caller) + # the call must target analytics.gd's track function node, NOT a bare anchor + want = _make_id(_file_stem(self.root / "analytics.gd"), "track") + resolved = [e for e in _edge(r, "calls") if e.get("context") == "Analytics"] + self.assertTrue(resolved, "no autoload-resolved call edge emitted") + self.assertEqual(resolved[0]["target"], want) + # a bare `track()` anchor must NOT have been created + self.assertFalse(any(n["label"] == "track()" for n in r["nodes"])) + + def test_missing_grammar_is_graceful(self): + # Even with a grammar present, an empty file yields just the file node. + p = self.root / "empty.gd" + r = extract_gdscript(p) + self.assertTrue(any(n["label"] == "empty.gd" for n in r["nodes"])) + self.assertNotIn("error", r) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_godot_resource.py b/tests/test_godot_resource.py new file mode 100644 index 000000000..f014b53e6 --- /dev/null +++ b/tests/test_godot_resource.py @@ -0,0 +1,109 @@ +import shutil +import tempfile +import unittest +from pathlib import Path + +from graphify.extract import extract_godot_resource, _make_id, _file_stem +from graphify.extractors import godot_resource as gr + +_FIXTURES = Path(__file__).parent / "fixtures" / "godot" / "resource" + + +def _edges(result, relation): + return [e for e in result["edges"] if e["relation"] == relation] + + +def _norm(result): + """Order-independent snapshot of a result's nodes and edges.""" + n = sorted((x["id"], x["label"], x.get("source_location") or "") + for x in result["nodes"]) + e = sorted((x["source"], x["target"], x["relation"], + x.get("context") or "", x.get("source_location") or "") + for x in result["edges"]) + return n, e + + +class _FixtureProject(unittest.TestCase): + """Copy the fixture Godot project into an isolated temp dir per test.""" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.root = Path(self.tmp.name) / "proj" + shutil.copytree(_FIXTURES, self.root) + + def tearDown(self): + self.tmp.cleanup() + + +class TestGodotResource(_FixtureProject): + """The .tscn/.tres/project.godot extractor (grammar path when available).""" + + def test_scene_script_subscene_and_connection(self): + p = self.root / "scenes" / "Main.tscn" + r = extract_godot_resource(p) + + enemy_gd = _make_id(str((self.root / "scripts" / "enemy.gd").resolve())) + bullet = _make_id(str((self.root / "scenes" / "Bullet.tscn").resolve())) + + attaches = {e["target"] for e in _edges(r, "attaches_script")} + self.assertIn(enemy_gd, attaches) + + instances = {e["target"] for e in _edges(r, "instances")} + self.assertIn(bullet, instances) + + # the connection method resolves to the ROOT script's function node id, + # i.e. the same id the gdscript extractor emits for take_damage() + stem = _file_stem((self.root / "scripts" / "enemy.gd").resolve()) + take_damage_nid = _make_id(stem, "take_damage") + conn_targets = {e["target"] for e in _edges(r, "connects")} + self.assertIn(take_damage_nid, conn_targets) + + def test_project_godot_autoloads_and_main_scene(self): + p = self.root / "project.godot" + r = extract_godot_resource(p) + + self.assertTrue(_edges(r, "autoload"), "no autoload edge emitted") + self.assertTrue(_edges(r, "main_scene"), "no main_scene edge emitted") + + gstate = _make_id(str((self.root / "scripts" / "game_state.gd").resolve())) + script_targets = {e["target"] for e in _edges(r, "script")} + self.assertIn(gstate, script_targets) + + def test_line_parser_fallback_matches(self): + # Force the dependency-free line parser (as if the grammar were absent) + # and confirm it still emits the same edges the default path produces. + scene = self.root / "scenes" / "Main.tscn" + default = extract_godot_resource(scene) + saved = gr._RESOURCE_PARSER + try: + gr._RESOURCE_PARSER = None # disable grammar -> line parser + forced_lines = extract_godot_resource(scene) + finally: + gr._RESOURCE_PARSER = saved + self.assertEqual(_norm(default), _norm(forced_lines)) + + +@unittest.skipUnless(gr._load_resource_parser() is not None, + "godot_resource grammar (tree-sitter-language-pack) not installed") +class TestGodotResourceGrammar(_FixtureProject): + """Behaviour specific to the grammar front end.""" + + def test_grammar_and_line_parsers_agree(self): + scene = self.root / "scenes" / "Main.tscn" + text = scene.read_text() + via_grammar = gr._build_scene(scene, gr._blocks_from_grammar(text)) + via_lines = gr._build_scene(scene, gr._blocks_from_lines(text)) + self.assertEqual(_norm(via_grammar), _norm(via_lines)) + + def test_grammar_survives_bracket_in_quoted_value(self): + # A ']' inside a quoted attribute value (node name "Odd]Name") trips the + # line-regex section matcher but the grammar parses it correctly. + scene = self.root / "weird.tscn" + r = extract_godot_resource(scene) + enemy_gd = _make_id(str((self.root / "scripts" / "enemy.gd").resolve())) + attaches = {e["target"] for e in _edges(r, "attaches_script")} + self.assertIn(enemy_gd, attaches) + + +if __name__ == "__main__": + unittest.main()