From 745ce89fc1efbfb41541b51c69e196d4fe6fbf41 Mon Sep 17 00:00:00 2001 From: Andy Slezak Date: Sun, 11 Oct 2026 13:21:21 +0200 Subject: [PATCH 1/5] feat(extract): add a Twig template extractor Twig templates were in no FileType set, so they were silently skipped and the view layer of a Symfony, Drupal or Craft project was missing from the graph. Regex extractor in the shape of extractors/blade.py: includes, embeds, extends, imports_macro, uses_template, defines_block, attaches_library. References to a real file get an edge stamped with target_file and no stub node; Drupal SDC references resolve through .info.yml. Comments are blanked before scanning so docblock usage examples are not read as dependencies. Cherry-picked from Graphify-Labs/graphify#2240 (c61df7c), Twig part only: the SCSS extractor of that PR is a separate concern and is left out. Refs #19 --- graphify/detect.py | 2 +- graphify/extract.py | 6 + graphify/extractors/__init__.py | 2 + graphify/extractors/base.py | 32 +++++ graphify/extractors/twig.py | 238 ++++++++++++++++++++++++++++++++ tests/test_detect.py | 6 + tests/test_twig.py | 215 +++++++++++++++++++++++++++++ 7 files changed, 500 insertions(+), 1 deletion(-) create mode 100644 graphify/extractors/twig.py create mode 100644 tests/test_twig.py diff --git a/graphify/detect.py b/graphify/detect.py index 73b573a722..3fa87ef2c5 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -44,7 +44,7 @@ class FileType(str, Enum): _MTIME_COARSE_S = 2.0 _MTIME_SUBSECOND_S = 0.05 -CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.ejs', '.ets', '.go', '.rs', '.vb', '.cbl', '.cob', '.cobol', '.cpy', '.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', '.ml', '.mli', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.vh', '.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', '.lisp', '.cl', '.lsp', '.asd', '.robot', '.resource', '.sol', '.erl', '.hrl', '.escript'} +CODE_EXTENSIONS = {'.py', '.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.ejs', '.ets', '.go', '.rs', '.vb', '.cbl', '.cob', '.cobol', '.cpy', '.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', '.ml', '.mli', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.svh', '.vh', '.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', '.twig', '.lisp', '.cl', '.lsp', '.asd', '.robot', '.resource', '.sol', '.erl', '.hrl', '.escript'} 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 9957971a08..58935c5538 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -73,6 +73,7 @@ ) from graphify.extractors.sql import extract_sql # noqa: F401 from graphify.extractors.terraform import extract_terraform, prepare_terraform, resolve_terraform_modules # noqa: F401 +from graphify.extractors.twig import extract_twig # noqa: F401 from graphify.extractors.verilog import extract_verilog # noqa: F401 from graphify.extractors.vbnet import extract_vbnet, resolve_vbnet_partial_calls # noqa: F401 from graphify.extractors.zig import extract_zig # noqa: F401 @@ -3554,6 +3555,10 @@ def _lang_is_case_insensitive(source_file: object) -> bool: ".dart": "dart", ".sh": "shell", ".bash": "shell", ".ps1": "powershell", ".psm1": "powershell", ".psd1": "powershell", + # Naming the template family keeps a Twig include stub from being remapped + # onto a same-labeled function in an unrelated language, which is what an + # unknown (None) family permits. + ".twig": "twig", } @@ -7938,6 +7943,7 @@ def add_existing_edge(edge: dict) -> None: ".resource": extract_robot, ".cls": extract_apex, ".trigger": extract_apex, + ".twig": extract_twig, } diff --git a/graphify/extractors/__init__.py b/graphify/extractors/__init__.py index e2c8afc627..601269077a 100644 --- a/graphify/extractors/__init__.py +++ b/graphify/extractors/__init__.py @@ -35,6 +35,7 @@ from graphify.extractors.solidity import extract_solidity from graphify.extractors.sql import extract_sql from graphify.extractors.terraform import extract_terraform +from graphify.extractors.twig import extract_twig from graphify.extractors.verilog import extract_verilog from graphify.extractors.vbnet import extract_vbnet from graphify.extractors.zig import extract_zig @@ -70,6 +71,7 @@ "solidity": extract_solidity, "sql": extract_sql, "terraform": extract_terraform, + "twig": extract_twig, "verilog": extract_verilog, "vbnet": extract_vbnet, "zig": extract_zig, diff --git a/graphify/extractors/base.py b/graphify/extractors/base.py index 8c5f1d5235..467a146c98 100644 --- a/graphify/extractors/base.py +++ b/graphify/extractors/base.py @@ -2,6 +2,7 @@ from __future__ import annotations import sys +import re from pathlib import Path from graphify.ids import make_id @@ -151,3 +152,34 @@ def _read_source_text(path: Path, *, warn: bool = True) -> str: """ text = _read_source_bytes(path, warn=warn).decode("utf-8") return text.replace("\r\n", "\n").replace("\r", "\n") +def _blank_spans(src: str, pattern: "re.Pattern[str]") -> str: + """Replace every match of *pattern* with newlines and spaces of equal length. + + Used to strip comments before a regex-based extractor scans a file, without + disturbing byte offsets — so reported ``L`` locations still refer to the + real line in the original source. Matters because component docblocks + routinely contain example usage (``{# Usage: {% include 'theme:card' %} #}``) + that would otherwise be extracted as a real dependency, giving the component + a spurious edge to itself. + """ + return pattern.sub( + lambda m: "".join("\n" if ch == "\n" else " " for ch in m.group(0)), src + ) + + +def _corpus_relative_path(resolved: Path, referrer: Path) -> str: + """Express *resolved* the same way *referrer* is, for node-ID parity. + + A file node's ID is ``_make_id(str(path))`` for whatever path form the + extractor was handed. An extractor that resolves a cross-file reference to an + absolute path while the corpus was scanned relatively would mint a second, + disconnected node for a file that already has one, so match the referrer's + form. Mirrors the inline normalization in ``extract_bash``. + """ + target = resolved.resolve() + if not referrer.is_absolute(): + try: + target = target.relative_to(Path.cwd().resolve()) + except ValueError: + pass + return str(target) diff --git a/graphify/extractors/twig.py b/graphify/extractors/twig.py new file mode 100644 index 0000000000..5f3f84fd41 --- /dev/null +++ b/graphify/extractors/twig.py @@ -0,0 +1,238 @@ +"""Twig template extractor: composition, macro imports, and library attachments. + +Regex-based by design, mirroring ``extractors/blade.py``. Twig's tag grammar is +regular enough for composition extraction, and staying dependency-free avoids +pulling a tree-sitter grammar in for a template language. + +Two dialects are resolved to real files rather than left as stubs: + +* **Drupal Single Directory Components** -- ``{% include 'mytheme:card' %}``. + The provider (``mytheme``) is a Drupal extension machine name, so its root is + the nearest ancestor holding ``mytheme.info.yml``, and the component template + lives at ``/components/**/card/card.twig``. +* **Path references** -- ``{% extends 'layout.html.twig' %}`` and Symfony's + ``@Namespace/path.html.twig``, resolved against the referring file's own + directory and any enclosing ``templates/`` root. + +A reference that resolves becomes an edge to that file's node, so templates link +to templates. A reference that does not resolve still emits an edge, to a stub +node carrying the raw reference: the include is a fact of the source even when +its target sits outside the scanned corpus. +""" +from __future__ import annotations + +import re +from pathlib import Path + +from graphify.extractors.base import _blank_spans, _corpus_relative_path, _make_id + +# `{# ... #}`. Twig comments do not nest, so a non-greedy span is exact. +# Stripped before extraction: component docblocks carry "Usage:" examples, and +# reading those as real tags gives a component an include edge to itself. +_TWIG_COMMENT = re.compile(r"\{#.*?#\}", re.DOTALL) + +# `{% include 'x' %}` and friends. The optional `-` is Twig's whitespace-control +# spelling (`{%- include ... %}`). +_TWIG_TAG_REF = re.compile( + r"\{%-?\s*(include|embed|extends|import|use)\s+(['\"])([^'\"]+)\2" +) + +# `{% from 'x' import macro %}` puts the template before the keyword, so it +# cannot share the pattern above. +_TWIG_FROM_REF = re.compile(r"\{%-?\s*from\s+(['\"])([^'\"]+)\1\s+import\b") + +# The function forms, `{{ include('x') }}` and `{{ source('x') }}`. +_TWIG_FN_REF = re.compile(r"\b(include|source)\(\s*(['\"])([^'\"]+)\2") + +# `{% block content %}` -- a named extension point this template defines. +_TWIG_BLOCK = re.compile(r"\{%-?\s*block\s+([A-Za-z_]\w*)") + +# Drupal's `{{ attach_library('mytheme/component') }}`. +_TWIG_ATTACH_LIBRARY = re.compile(r"\battach_library\(\s*(['\"])([^'\"]+)\1") + +_TAG_RELATIONS = { + "include": "includes", + "embed": "embeds", + "extends": "extends", + "import": "imports_macro", + "use": "uses_template", + "from": "imports_macro", + "source": "includes", +} + +# `provider:component-name` -- Drupal SDC. Provider machine names are +# `[a-z0-9_]`; component names additionally allow `-`. +_SDC_REF = re.compile(r"^([a-z][a-z0-9_]*):([a-z0-9][a-z0-9_-]*)$") + +# How far up the tree to look for a `.info.yml` or a `templates/` root. +_MAX_WALK_UP = 12 + +# Component index per Drupal extension root, keyed by that root's path string. +# Module-level dicts rather than functools.lru_cache, matching the caching +# convention in extractors/resolution.py. +_SDC_INDEX_CACHE: dict[str, dict[str, Path]] = {} + +# Resolved extension root per (start dir, provider); None means "not found". +_PROVIDER_ROOT_CACHE: dict[tuple[str, str], "Path | None"] = {} + + +def _provider_root(start: Path, provider: str) -> "Path | None": + """Nearest ancestor of *start* that is the Drupal extension named *provider*. + + Identified by `.info.yml`, the file every Drupal theme and module + carries at its root. + """ + key = (str(start), provider) + if key in _PROVIDER_ROOT_CACHE: + return _PROVIDER_ROOT_CACHE[key] + root: "Path | None" = None + current = start + for _ in range(_MAX_WALK_UP): + try: + if (current / f"{provider}.info.yml").is_file(): + root = current + break + except OSError: + break + parent = current.parent + if parent == current: + break + current = parent + _PROVIDER_ROOT_CACHE[key] = root + return root + + +def _sdc_index(root: Path) -> dict[str, Path]: + """Map SDC component name -> its template, for one extension root. + + An SDC component is a directory whose template shares its name + (`components/molecules/card/card.twig`), so the stem-equals-parent test + identifies component templates without reading any YAML. + """ + key = str(root) + cached = _SDC_INDEX_CACHE.get(key) + if cached is not None: + return cached + index: dict[str, Path] = {} + components = root / "components" + if components.is_dir(): + try: + for template in components.rglob("*.twig"): + if template.stem == template.parent.name: + index.setdefault(template.stem, template) + except OSError: + pass + _SDC_INDEX_CACHE[key] = index + return index + + +def _resolve_path_ref(raw: str, path: Path) -> "Path | None": + """Resolve a non-SDC reference against the referring file's own tree.""" + # Symfony's `@Namespace/rest`: the namespace maps to a directory configured + # outside the template, so only the remainder is usable statically. + relative = raw.split("/", 1)[1] if raw.startswith("@") and "/" in raw else raw + if not relative or relative.startswith("/") or ".." in Path(relative).parts: + return None + candidates = [path.parent / relative] + current = path.parent + for _ in range(_MAX_WALK_UP): + templates = current / "templates" + try: + if templates.is_dir(): + candidates.append(templates / relative) + except OSError: + break + parent = current.parent + if parent == current: + break + current = parent + for candidate in candidates: + try: + if candidate.is_file(): + return candidate + except OSError: + continue + return None + + +def _resolve_twig_ref(raw: str, path: Path) -> "Path | None": + """Resolve a Twig template reference to a real file, or None.""" + sdc = _SDC_REF.match(raw) + if sdc: + provider, name = sdc.groups() + root = _provider_root(path.parent, provider) + if root is None: + return None + return _sdc_index(root).get(name) + return _resolve_path_ref(raw, path) + + +def extract_twig(path: Path) -> dict: + """Extract include/embed/extends/import composition from a Twig template.""" + try: + src = path.read_text(encoding="utf-8", errors="replace") + except OSError: + return {"error": f"cannot read {path}"} + src = _blank_spans(src, _TWIG_COMMENT) + + str_path = str(path) + file_nid = _make_id(str_path) + nodes = [{"id": file_nid, "label": path.name, "file_type": "code", + "source_file": str_path, "source_location": None}] + edges: list[dict] = [] + seen_nodes = {file_nid} + seen_edges: set[tuple[str, str]] = set() + + def line_of(offset: int) -> str: + return f"L{src.count(chr(10), 0, offset) + 1}" + + def add(target_id: str, label: str, relation: str, location: str, + target_file: "str | None" = None) -> None: + if target_id not in seen_nodes: + seen_nodes.add(target_id) + nodes.append({"id": target_id, "label": label, "file_type": "code", + "source_file": target_file or str_path, + "source_location": None}) + # One edge per (target, relation): a template that includes the same + # component twice is one dependency, and duplicate parallel edges trip + # graphify's own same-endpoint-collapse diagnostic. + key = (target_id, relation) + if key in seen_edges: + return + seen_edges.add(key) + edges.append({"source": file_nid, "target": target_id, "relation": relation, + "confidence": "EXTRACTED", "confidence_score": 1.0, + "source_file": str_path, "source_location": location, + "weight": 1.0}) + + def add_template_ref(raw: str, relation: str, offset: int) -> None: + raw = raw.strip() + if not raw: + return + resolved = _resolve_twig_ref(raw, path) + if resolved is not None: + target_file = _corpus_relative_path(resolved, path) + add(_make_id(target_file), resolved.name, relation, line_of(offset), + target_file=target_file) + else: + add(_make_id(raw), raw, relation, line_of(offset)) + + for m in _TWIG_TAG_REF.finditer(src): + add_template_ref(m.group(3), _TAG_RELATIONS[m.group(1)], m.start()) + + for m in _TWIG_FROM_REF.finditer(src): + add_template_ref(m.group(2), _TAG_RELATIONS["from"], m.start()) + + for m in _TWIG_FN_REF.finditer(src): + add_template_ref(m.group(3), _TAG_RELATIONS[m.group(1)], m.start()) + + for m in _TWIG_BLOCK.finditer(src): + name = m.group(1) + add(_make_id(str_path, "block", name), name, "defines_block", + line_of(m.start())) + + for m in _TWIG_ATTACH_LIBRARY.finditer(src): + library = m.group(2) + add(_make_id(library), library, "attaches_library", line_of(m.start())) + + return {"nodes": nodes, "edges": edges} diff --git a/tests/test_detect.py b/tests/test_detect.py index 8aa09ae21f..6176287881 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -68,6 +68,12 @@ def test_classify_powershell_manifest(): # #1331: .psd1 manifests must be classified as CODE so the manifest extractor runs. assert classify_file(Path("MyModule.psd1")) == FileType.CODE +def test_classify_twig(): + # Twig templates carry the composition graph of a Drupal/Symfony/Craft + # project, so they must reach the deterministic extractor rather than the + # LLM document path. + assert classify_file(Path("templates/page.html.twig")) == FileType.CODE + def test_classify_markdown(): assert classify_file(Path("README.md")) == FileType.DOCUMENT diff --git a/tests/test_twig.py b/tests/test_twig.py new file mode 100644 index 0000000000..29ffb5a56c --- /dev/null +++ b/tests/test_twig.py @@ -0,0 +1,215 @@ +"""Tests for the Twig template extractor (graphify/extractors/twig.py).""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract_twig + + +def _write(root: Path, rel: str, body: str) -> Path: + p = root / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(body, encoding="utf-8") + return p + + +def _rel_targets(r, relation: str) -> set[str]: + """Labels of every node reached by *relation*.""" + lab = {n["id"]: n["label"] for n in r["nodes"]} + return { + lab.get(e["target"], e["target"]) + for e in r["edges"] + if e["relation"] == relation + } + + +def _target_files(r, relation: str) -> set[str]: + by_id = {n["id"]: n for n in r["nodes"]} + return { + by_id[e["target"]]["source_file"] + for e in r["edges"] + if e["relation"] == relation + } + + +def _theme(tmp_path: Path) -> Path: + """A minimal Drupal theme: info.yml root plus two SDC components.""" + root = tmp_path / "mytheme" + _write(root, "mytheme.info.yml", "name: My Theme\ntype: theme\n") + _write(root, "components/atoms/icon/icon.twig", "\n") + _write(root, "components/molecules/card/card.twig", "
\n") + return root + + +# --- Drupal SDC dialect ----------------------------------------------------- + +def test_sdc_include_resolves_to_the_component_template(tmp_path): + root = _theme(tmp_path) + page = _write(root, "templates/page.html.twig", + "{% include 'mytheme:card' with { title: 'x' } only %}\n") + + r = extract_twig(page) + + assert _rel_targets(r, "includes") == {"card.twig"} + # Resolved to the real file, not a stub keyed on the raw 'mytheme:card'. + assert _target_files(r, "includes") == { + str((root / "components/molecules/card/card.twig").resolve()) + } + + +def test_sdc_embed_and_extends_get_their_own_relations(tmp_path): + root = _theme(tmp_path) + page = _write(root, "templates/page.html.twig", + "{% extends 'mytheme:card' %}\n" + "{% embed 'mytheme:icon' %}{% endembed %}\n") + + r = extract_twig(page) + + assert _rel_targets(r, "extends") == {"card.twig"} + assert _rel_targets(r, "embeds") == {"icon.twig"} + + +def test_unknown_component_still_emits_an_edge_to_a_stub(tmp_path): + root = _theme(tmp_path) + page = _write(root, "templates/page.html.twig", + "{% include 'mytheme:nope' %}\n") + + r = extract_twig(page) + + # The include is a fact of the source even when the target is not in the + # corpus, so the edge stays and the raw reference becomes the label. + assert _rel_targets(r, "includes") == {"mytheme:nope"} + + +def test_include_of_a_foreign_provider_is_not_resolved_locally(tmp_path): + root = _theme(tmp_path) + # `othertheme` has no othertheme.info.yml anywhere up the tree, so the + # component index of *this* theme must not be consulted for it. + page = _write(root, "templates/page.html.twig", + "{% include 'othertheme:card' %}\n") + + r = extract_twig(page) + + assert _rel_targets(r, "includes") == {"othertheme:card"} + + +# --- plain-path / Symfony dialect ------------------------------------------- + +def test_path_reference_resolves_against_the_templates_root(tmp_path): + root = tmp_path / "app" + _write(root, "templates/layout.html.twig", "\n") + page = _write(root, "templates/pages/home.html.twig", + "{% extends 'layout.html.twig' %}\n") + + r = extract_twig(page) + + assert _rel_targets(r, "extends") == {"layout.html.twig"} + assert _target_files(r, "extends") == { + str((root / "templates/layout.html.twig").resolve()) + } + + +def test_namespaced_path_reference_drops_the_namespace(tmp_path): + root = tmp_path / "app" + _write(root, "templates/parts/nav.html.twig", "\n") + page = _write(root, "templates/home.html.twig", + "{% include '@Shared/parts/nav.html.twig' %}\n") + + r = extract_twig(page) + + # The namespace maps to a directory configured outside the template, so only + # the remainder is resolvable — here it matches under templates/. + assert _rel_targets(r, "includes") == {"nav.html.twig"} + + +def test_include_function_form_is_extracted(tmp_path): + root = _theme(tmp_path) + page = _write(root, "templates/page.html.twig", + "{{ include('mytheme:icon') }}\n") + + r = extract_twig(page) + + assert _rel_targets(r, "includes") == {"icon.twig"} + + +# --- comments --------------------------------------------------------------- + +def test_usage_examples_in_docblocks_are_not_dependencies(tmp_path): + """A component's own docblock routinely shows how to include it. + + Reading that as a real tag gave the component an include edge to itself, + which is both wrong and a self-loop in the built graph. + """ + root = _theme(tmp_path) + card = root / "components/molecules/card/card.twig" + card.write_text( + "{#\n" + " * Card component.\n" + " *\n" + " * Usage:\n" + " * {% include 'mytheme:card' with { title: 'Hello' } %}\n" + " #}\n" + "
{% include 'mytheme:icon' %}
\n", + encoding="utf-8", + ) + + r = extract_twig(card) + + assert _rel_targets(r, "includes") == {"icon.twig"} + file_nid = r["nodes"][0]["id"] + assert not [e for e in r["edges"] if e["target"] == file_nid], "self-loop" + + +def test_comment_stripping_preserves_line_numbers(tmp_path): + root = _theme(tmp_path) + page = _write(root, "templates/page.html.twig", + "{# a\nmultiline\ncomment #}\n" + "{% include 'mytheme:icon' %}\n") + + r = extract_twig(page) + + location = next(e["source_location"] for e in r["edges"] + if e["relation"] == "includes") + assert location == "L4" + + +# --- misc ------------------------------------------------------------------- + +def test_repeated_include_of_one_component_is_a_single_edge(tmp_path): + root = _theme(tmp_path) + page = _write(root, "templates/page.html.twig", + "{% include 'mytheme:card' %}\n" + "{% include 'mytheme:card' %}\n") + + r = extract_twig(page) + + assert len([e for e in r["edges"] if e["relation"] == "includes"]) == 1 + + +def test_blocks_and_attached_libraries(tmp_path): + root = _theme(tmp_path) + page = _write(root, "templates/page.html.twig", + "{{ attach_library('mytheme/global') }}\n" + "{% block content %}{% endblock %}\n") + + r = extract_twig(page) + + assert _rel_targets(r, "defines_block") == {"content"} + assert _rel_targets(r, "attaches_library") == {"mytheme/global"} + + +def test_every_edge_is_extracted_with_full_confidence(tmp_path): + root = _theme(tmp_path) + page = _write(root, "templates/page.html.twig", + "{% include 'mytheme:card' %}\n") + + r = extract_twig(page) + + assert r["edges"], "expected at least one edge" + for e in r["edges"]: + assert e["confidence"] == "EXTRACTED" + assert e["confidence_score"] == 1.0 + + +def test_unreadable_file_reports_an_error(tmp_path): + assert "error" in extract_twig(tmp_path / "missing.twig") From 3dad0a38080ae68861553729387cfe5f88c9f112 Mon Sep 17 00:00:00 2001 From: Andy Slezak Date: Sun, 11 Oct 2026 13:21:33 +0200 Subject: [PATCH 2/5] fix(extract): keep template composition edges alive through id disambiguation When two files share a stem (a component's card.twig next to another extracted card.* file), _disambiguate_colliding_node_ids salts their ids apart, and an include edge from a third template no longer found its target. Mirror the #1475 C/ObjC header fix: repoint template-composition edges to the .twig variant, and honor the target_file stamp for those relations. Cherry-picked from Graphify-Labs/graphify#2240 (234579d), Twig part only. Refs #19 --- graphify/extractors/resolution.py | 39 +++++++++++++++++++- graphify/extractors/twig.py | 26 ++++++++++---- tests/test_twig.py | 60 ++++++++++++++++++++++++------- 3 files changed, 105 insertions(+), 20 deletions(-) diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index ba24e841c8..2d03f09ad7 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -997,6 +997,22 @@ def _node_disambiguation_source_key(node: dict, root: Path) -> str: return _source_key(source_file, root) return _source_key(str(node.get("origin_file", "")), root) +# Relations whose target is a FILE node, so a `target_file` stamp on the edge +# must key the target's salt. Template composition belongs here for the same +# reason imports do, and the need is sharper: a Single Directory Component keeps +# `card.twig` and `card.scss` side by side, so every component's file id +# collides across extensions and is salted apart. An `includes` edge that fell +# back to the importer's own source_key would then look up an id that +# disambiguation has already retired, and the edge would dangle. +_TEMPLATE_TARGET_RELATIONS = frozenset({ + "includes", "embeds", "extends", "imports_macro", "uses_template", +}) + +_FILE_TARGET_RELATIONS = frozenset({ + "imports", "imports_from", "re_exports", +}) | _TEMPLATE_TARGET_RELATIONS + + def _disambiguate_colliding_node_ids( nodes: list[dict], edges: list[dict], @@ -1096,6 +1112,24 @@ def _disambiguate_colliding_node_ids( header_remaps[old_id] = new_id break + # The same shape, for template composition. A Single Directory Component + # keeps `card.twig` beside `card.scss`, so the two collapse to one `card` + # file id and disambiguation salts them apart. An `{% include %}` from a + # THIRD template carries neither salt's source_key, so the + # (target, edge_source_key) lookup misses and the edge dangles on the now + # dead `card` id — the #1475 failure exactly. An include always targeted the + # template, so repoint to the template variant. + _TEMPLATE_SUFFIXES = (".twig",) + template_remaps: dict[str, str] = {} + for old_id in ambiguous_ids: + for node in by_id.get(old_id, []): + sk = _node_disambiguation_source_key(node, root) + if sk and Path(sk).suffix.lower() in _TEMPLATE_SUFFIXES: + new_id = remap.get((old_id, sk)) + if new_id: + template_remaps[old_id] = new_id + break + for edge in edges: edge_source_key = _source_key(str(edge.get("source_file", "")), root) source_key = (edge.get("source", ""), edge_source_key) @@ -1108,7 +1142,7 @@ def _disambiguate_colliding_node_ids( # every language and to re_exports. `pop` it as we consume it: this is the # hint's only reader, and its absolute path must not persist into graph.json. target_file = edge.pop("target_file", None) - if target_file and edge.get("relation") in ("imports", "imports_from", "re_exports"): + if target_file and edge.get("relation") in _FILE_TARGET_RELATIONS: target_edge_key = _source_key(str(target_file), root) else: target_edge_key = edge_source_key @@ -1125,6 +1159,9 @@ def _disambiguate_colliding_node_ids( if (edge.get("relation") in ("imports", "imports_from") and edge.get("target") in header_remaps): edge["target"] = header_remaps[str(edge["target"])] + elif (edge.get("relation") in _TEMPLATE_TARGET_RELATIONS + and edge.get("target") in template_remaps): + edge["target"] = template_remaps[str(edge["target"])] elif target_key in remap: edge["target"] = remap[target_key] elif edge.get("target") in unambiguous_remaps: diff --git a/graphify/extractors/twig.py b/graphify/extractors/twig.py index 5f3f84fd41..9e044e22c5 100644 --- a/graphify/extractors/twig.py +++ b/graphify/extractors/twig.py @@ -188,11 +188,20 @@ def line_of(offset: int) -> str: def add(target_id: str, label: str, relation: str, location: str, target_file: "str | None" = None) -> None: - if target_id not in seen_nodes: + """Emit one edge, and a node for the target only when we own it. + + A reference that resolved to another file in the corpus gets NO node + here: that file mints its own file node when it is extracted, and + emitting a second one with the same id makes + ``_disambiguate_colliding_node_ids`` rename the pair apart, which severs + exactly the link we were trying to draw. Instead the edge carries a + ``target_file`` stamp and the target canonicalizes onto the real node + downstream — the pattern `extract_markdown` uses for doc-to-doc links. + """ + if target_file is None and target_id not in seen_nodes: seen_nodes.add(target_id) nodes.append({"id": target_id, "label": label, "file_type": "code", - "source_file": target_file or str_path, - "source_location": None}) + "source_file": str_path, "source_location": None}) # One edge per (target, relation): a template that includes the same # component twice is one dependency, and duplicate parallel edges trip # graphify's own same-endpoint-collapse diagnostic. @@ -200,10 +209,13 @@ def add(target_id: str, label: str, relation: str, location: str, if key in seen_edges: return seen_edges.add(key) - edges.append({"source": file_nid, "target": target_id, "relation": relation, - "confidence": "EXTRACTED", "confidence_score": 1.0, - "source_file": str_path, "source_location": location, - "weight": 1.0}) + edge = {"source": file_nid, "target": target_id, "relation": relation, + "confidence": "EXTRACTED", "confidence_score": 1.0, + "source_file": str_path, "source_location": location, + "weight": 1.0} + if target_file is not None: + edge["target_file"] = target_file + edges.append(edge) def add_template_ref(raw: str, relation: str, offset: int) -> None: raw = raw.strip() diff --git a/tests/test_twig.py b/tests/test_twig.py index 29ffb5a56c..77f096474c 100644 --- a/tests/test_twig.py +++ b/tests/test_twig.py @@ -14,22 +14,27 @@ def _write(root: Path, rel: str, body: str) -> Path: def _rel_targets(r, relation: str) -> set[str]: - """Labels of every node reached by *relation*.""" + """Readable name of every target reached by *relation*. + + A reference that resolved to another file has no node here (that file owns + its own), so its name comes from the edge's ``target_file`` stamp; an + unresolved one is named by its stub node. + """ lab = {n["id"]: n["label"] for n in r["nodes"]} - return { - lab.get(e["target"], e["target"]) - for e in r["edges"] - if e["relation"] == relation - } + out = set() + for e in r["edges"]: + if e["relation"] != relation: + continue + if "target_file" in e: + out.add(Path(e["target_file"]).name) + else: + out.add(lab.get(e["target"], e["target"])) + return out def _target_files(r, relation: str) -> set[str]: - by_id = {n["id"]: n for n in r["nodes"]} - return { - by_id[e["target"]]["source_file"] - for e in r["edges"] - if e["relation"] == relation - } + return {e["target_file"] for e in r["edges"] + if e["relation"] == relation and "target_file" in e} def _theme(tmp_path: Path) -> Path: @@ -211,5 +216,36 @@ def test_every_edge_is_extracted_with_full_confidence(tmp_path): assert e["confidence_score"] == 1.0 +def test_resolved_reference_stamps_target_file(tmp_path): + """The stamp is what lets the target canonicalize to the real file node. + + Without it the target keeps an absolute-path-derived id that matches no node + in the merged graph, and every template->template edge silently drops + (#2211, the same failure fixed for Python imports and markdown refs). + """ + root = _theme(tmp_path) + page = _write(root, "templates/page.html.twig", + "{% include 'mytheme:card' %}\n") + + r = extract_twig(page) + + edge = next(e for e in r["edges"] if e["relation"] == "includes") + assert edge["target_file"] == str( + (root / "components/molecules/card/card.twig").resolve()) + + +def test_unresolved_reference_is_left_dangling(tmp_path): + root = _theme(tmp_path) + page = _write(root, "templates/page.html.twig", + "{% include 'mytheme:nope' %}\n") + + r = extract_twig(page) + + edge = next(e for e in r["edges"] if e["relation"] == "includes") + # No file to canonicalize onto, so no stamp — mirrors markdown's + # existence-gated behavior for links to nonexistent docs. + assert "target_file" not in edge + + def test_unreadable_file_reports_an_error(tmp_path): assert "error" in extract_twig(tmp_path / "missing.twig") From cdd254e963062391b4ee1da393d5d0d6f2c04c04 Mon Sep 17 00:00:00 2001 From: Allionix Date: Sun, 11 Oct 2026 13:22:38 +0200 Subject: [PATCH 3/5] fix(twig): resolve names against the loader root only Twig names are relative to the loader root, never to the referring file, so a same-named file next to the referrer, or a nested templates/ dir, no longer wins. A Symfony @Namespace/ is a bundle or a configured path and stays a stub instead of being matched against the app templates; Drupal's @extension/ resolves under that extension, as core FilesystemLoader does. Refs #19 Co-Authored-By: Claude --- graphify/extractors/twig.py | 66 ++++++++++++++++++++++++------------- tests/test_twig.py | 63 ++++++++++++++++++++++++++++++++--- 2 files changed, 102 insertions(+), 27 deletions(-) diff --git a/graphify/extractors/twig.py b/graphify/extractors/twig.py index 9e044e22c5..c86400e15c 100644 --- a/graphify/extractors/twig.py +++ b/graphify/extractors/twig.py @@ -10,9 +10,10 @@ The provider (``mytheme``) is a Drupal extension machine name, so its root is the nearest ancestor holding ``mytheme.info.yml``, and the component template lives at ``/components/**/card/card.twig``. -* **Path references** -- ``{% extends 'layout.html.twig' %}`` and Symfony's - ``@Namespace/path.html.twig``, resolved against the referring file's own - directory and any enclosing ``templates/`` root. +* **Path references** -- ``{% extends 'layout.html.twig' %}``, resolved against + the loader root (the enclosing ``templates/`` directory), and Drupal's + ``@extension/path.html.twig``. A Symfony ``@Namespace/`` names a bundle or a + configured path, so it is left unresolved rather than guessed. A reference that resolves becomes an edge to that file's node, so templates link to templates. A reference that does not resolve still emits an edge, to a stub @@ -126,35 +127,54 @@ def _sdc_index(root: Path) -> dict[str, Path]: return index -def _resolve_path_ref(raw: str, path: Path) -> "Path | None": - """Resolve a non-SDC reference against the referring file's own tree.""" - # Symfony's `@Namespace/rest`: the namespace maps to a directory configured - # outside the template, so only the remainder is usable statically. - relative = raw.split("/", 1)[1] if raw.startswith("@") and "/" in raw else raw - if not relative or relative.startswith("/") or ".." in Path(relative).parts: - return None - candidates = [path.parent / relative] +def _twig_loader_root(path: Path) -> "Path | None": + """The `templates/` dir bare names resolve against: Twig names are never relative to the referrer.""" + for parent in list(path.parents)[:_MAX_WALK_UP]: + if parent.name == "templates": + return parent current = path.parent for _ in range(_MAX_WALK_UP): templates = current / "templates" try: if templates.is_dir(): - candidates.append(templates / relative) + return templates except OSError: - break - parent = current.parent - if parent == current: - break - current = parent - for candidate in candidates: - try: - if candidate.is_file(): - return candidate - except OSError: - continue + return None + if current.parent == current: + return None + current = current.parent return None +def _is_safe_relative(name: str) -> bool: + return bool(name) and not name.startswith("/") and ".." not in Path(name).parts + + +def _existing_file(candidate: Path) -> "Path | None": + try: + return candidate if candidate.is_file() else None + except OSError: + return None + + +def _resolve_path_ref(raw: str, path: Path) -> "Path | None": + """Resolve a non-SDC reference against the loader root, or None.""" + if raw.startswith("@"): + # Drupal core maps `@ext/x` to `/templates/x` then `/x`; a Symfony namespace stays unresolved. + namespace, _, relative = raw[1:].partition("/") + if not namespace or not _is_safe_relative(relative): + return None + root = _provider_root(path.parent, namespace) + if root is None: + return None + return (_existing_file(root / "templates" / relative) + or _existing_file(root / relative)) + if not _is_safe_relative(raw): + return None + loader_root = _twig_loader_root(path) + return _existing_file(loader_root / raw) if loader_root is not None else None + + def _resolve_twig_ref(raw: str, path: Path) -> "Path | None": """Resolve a Twig template reference to a real file, or None.""" sdc = _SDC_REF.match(raw) diff --git a/tests/test_twig.py b/tests/test_twig.py index 77f096474c..2848c5cc3b 100644 --- a/tests/test_twig.py +++ b/tests/test_twig.py @@ -114,7 +114,36 @@ def test_path_reference_resolves_against_the_templates_root(tmp_path): } -def test_namespaced_path_reference_drops_the_namespace(tmp_path): +def test_bare_name_ignores_a_same_named_file_beside_the_referrer(tmp_path): + root = tmp_path / "app" + _write(root, "templates/base.html.twig", "\n") + _write(root, "templates/admin/base.html.twig", "
\n") + page = _write(root, "templates/admin/page.html.twig", + "{% extends 'base.html.twig' %}\n") + + r = extract_twig(page) + + # Twig names are relative to the loader root, never to the current file. + assert _target_files(r, "extends") == { + str((root / "templates/base.html.twig").resolve()) + } + + +def test_nested_templates_dir_is_not_taken_for_the_loader_root(tmp_path): + root = tmp_path / "app" + _write(root, "templates/layout.html.twig", "\n") + _write(root, "templates/docs/templates/layout.html.twig", "

\n") + page = _write(root, "templates/docs/page.html.twig", + "{% extends 'layout.html.twig' %}\n") + + r = extract_twig(page) + + assert _target_files(r, "extends") == { + str((root / "templates/layout.html.twig").resolve()) + } + + +def test_bundle_namespace_is_not_resolved_against_the_app_templates(tmp_path): root = tmp_path / "app" _write(root, "templates/parts/nav.html.twig", "\n") page = _write(root, "templates/home.html.twig", @@ -122,9 +151,35 @@ def test_namespaced_path_reference_drops_the_namespace(tmp_path): r = extract_twig(page) - # The namespace maps to a directory configured outside the template, so only - # the remainder is resolvable — here it matches under templates/. - assert _rel_targets(r, "includes") == {"nav.html.twig"} + # A Symfony namespace maps to a bundle or a configured path the template + # cannot see, so the reference stays a stub instead of a guessed file. + assert _target_files(r, "includes") == set() + assert _rel_targets(r, "includes") == {"@Shared/parts/nav.html.twig"} + + +def test_drupal_namespace_resolves_under_the_extension_templates_dir(tmp_path): + root = _theme(tmp_path) + _write(root, "templates/parts/nav.html.twig", "\n") + page = _write(root, "templates/page.html.twig", + "{% include '@mytheme/parts/nav.html.twig' %}\n") + + r = extract_twig(page) + + assert _target_files(r, "includes") == { + str((root / "templates/parts/nav.html.twig").resolve()) + } + + +def test_foreign_drupal_namespace_does_not_link_to_the_local_override(tmp_path): + sub = tmp_path / "subtheme" + _write(sub, "subtheme.info.yml", "name: Sub\ntype: theme\n") + page = _write(sub, "templates/node.html.twig", + "{% extends '@basetheme/node.html.twig' %}\n") + + r = extract_twig(page) + + # The base theme is not under this file's tree: no self-loop to the override. + assert _target_files(r, "extends") == set() def test_include_function_form_is_extracted(tmp_path): From 73724e20df3a988e23b0ef3fe57f0bb298148c68 Mon Sep 17 00:00:00 2001 From: Allionix Date: Sun, 11 Oct 2026 13:27:48 +0200 Subject: [PATCH 4/5] feat(php): link controllers to the Twig template they render A literal template name passed to render(), renderView(), renderBlock(), renderBlockView() or #[Template(...)] now yields a `renders` edge from the method to the template file node, resolved against the app's templates/ dir. A name built at runtime, or one with no file on disk, yields no edge. The mechanism lives in extractors/php_views.py so other template engines can plug their resolver into it. AST cache schema bumped so cached PHP extractions pick the new edges up. Refs #19 Co-Authored-By: Claude --- graphify/cache.py | 2 +- graphify/extractors/engine.py | 18 +++- graphify/extractors/php_views.py | 99 +++++++++++++++++++ graphify/extractors/resolution.py | 2 +- graphify/extractors/twig.py | 9 +- .../src/Controller/ProductController.php | 47 +++++++++ .../symfony_views/templates/base.html.twig | 2 + .../templates/product/_row.html.twig | 1 + .../templates/product/index.html.twig | 4 + tests/test_languages.py | 33 +++++++ tests/test_twig.py | 11 +++ 11 files changed, 221 insertions(+), 7 deletions(-) create mode 100644 graphify/extractors/php_views.py create mode 100644 tests/fixtures/symfony_views/src/Controller/ProductController.php create mode 100644 tests/fixtures/symfony_views/templates/base.html.twig create mode 100644 tests/fixtures/symfony_views/templates/product/_row.html.twig create mode 100644 tests/fixtures/symfony_views/templates/product/index.html.twig diff --git a/graphify/cache.py b/graphify/cache.py index 78bd9f942b..25b9c80e8f 100644 --- a/graphify/cache.py +++ b/graphify/cache.py @@ -37,7 +37,7 @@ _EXTRACTOR_VERSION = "unknown" # Bump when AST cache-key semantics change independently of the package version. -_AST_CACHE_SCHEMA = 8 # PHP typed-receiver, use-import and declared-FQN facts in persisted raw calls. +_AST_CACHE_SCHEMA = 9 # PHP `renders` edges to Twig templates. # Per-process memo: distributions() scans site-packages metadata, which is diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index e9a6270e57..a47f9896b2 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -13,6 +13,7 @@ ) from graphify.ids import normalize_id from graphify.extractors.models import LanguageConfig +from graphify.extractors.php_views import render_call_template, render_edge, template_attribute_templates from graphify.extractors.resolution import _resolve_js_import_target from graphify.security import sanitize_metadata from pathlib import Path @@ -5408,6 +5409,14 @@ def add_edge(src: str, tgt: str, relation: str, line: int, edge["metadata"] = sanitize_metadata(metadata) edges.append(edge) + seen_render_pairs: set[tuple[str, str]] = set() + + def add_render_edge(ref: tuple[str, str], src: str, line: int) -> None: + edge = render_edge(ref, src, path, line) + if edge is not None and (src, edge["target"]) not in seen_render_pairs: + seen_render_pairs.add((src, edge["target"])) + edges.append(edge) + def ensure_named_node(name: str, line: int) -> str: nid = _make_id(stem, ".".join(namespace_stack), name) if nid in seen_ids: @@ -7120,6 +7129,8 @@ def scala_base_name(type_node) -> str | None: annotation_targets.add(target_nid) if config.ts_module == "tree_sitter_php": + for engine_name, template, attr_line in template_attribute_templates(node, source): + add_render_edge((engine_name, template), func_nid, attr_line) params_container = None for c in node.children: if c.type == "formal_parameters": @@ -9135,6 +9146,11 @@ def walk_calls( if arg.type == "identifier": _emit_indirect_ref(arg, caller_nid, enclosing_locals, "argument") + if config.ts_module == "tree_sitter_php": + rendered = render_call_template(node, source) + if rendered is not None: + add_render_edge(rendered, caller_nid, node.start_point[0] + 1) + # Helper function calls: config('foo.bar') → uses_config edge to "foo" if (callee_name and callee_name in config.helper_fn_names): args_node = node.child_by_field_name("arguments") @@ -9498,7 +9514,7 @@ def _scan_js_module_dispatch(n) -> None: _seen_edge_payloads: set[str] = set() for edge in edges: src, tgt = edge["source"], edge["target"] - if src in valid_ids and (tgt in valid_ids or edge["relation"] in ("imports", "imports_from", "re_exports")): + if src in valid_ids and (tgt in valid_ids or edge["relation"] in ("imports", "imports_from", "re_exports", "renders")): payload = json.dumps(edge, sort_keys=True, default=str) if payload in _seen_edge_payloads: continue diff --git a/graphify/extractors/php_views.py b/graphify/extractors/php_views.py new file mode 100644 index 0000000000..d6edd08d24 --- /dev/null +++ b/graphify/extractors/php_views.py @@ -0,0 +1,99 @@ +"""PHP code that renders a template: the call or attribute, resolved to the template file. + +Only a literal template name is followed, and only when it resolves to a file on +disk; a name built at runtime (variable, concatenation, interpolation) yields no +edge. +""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extractors.base import _corpus_relative_path, _make_id, _read_text +from graphify.extractors.twig import _resolve_path_ref as _resolve_twig_name + +# Symfony AbstractController and Twig\Environment render methods (template first). +_TWIG_RENDER_METHODS = frozenset({"render", "renderView", "renderBlock", "renderBlockView"}) + +_RESOLVERS = { + "twig": _resolve_twig_name, +} + + +def _string_literal(node, source: bytes) -> str | None: + """Text of a PHP string with no interpolation, else None.""" + if node is None or node.type not in ("string", "encapsed_string"): + return None + parts = [c for c in node.children if c.is_named] + if len(parts) != 1 or parts[0].type != "string_content": + return None + return _read_text(parts[0], source) + + +def _first_argument_literal(args_node, source: bytes, named: str) -> str | None: + """Literal value of the first positional argument, or of the argument named *named*.""" + if args_node is None: + return None + for arg in args_node.children: + if arg.type != "argument": + continue + name_node = arg.child_by_field_name("name") + if name_node is not None and _read_text(name_node, source) != named: + continue + values = [c for c in arg.children if c.is_named and c != name_node] + return _string_literal(values[0], source) if len(values) == 1 else None + return None + + +def _short_name(node, source: bytes) -> str: + return _read_text(node, source).rsplit("\\", 1)[-1] + + +def render_call_template(node, source: bytes) -> tuple[str, str] | None: + """(engine, name) for a call that renders a literal template name, else None.""" + if node.type in ("member_call_expression", "nullsafe_member_call_expression"): + method = node.child_by_field_name("name") + if method is None or _read_text(method, source) not in _TWIG_RENDER_METHODS: + return None + name = _first_argument_literal(node.child_by_field_name("arguments"), source, "view") + if name and name.endswith(".twig"): + return ("twig", name) + return None + + +def template_attribute_templates(method_node, source: bytes) -> list[tuple[str, str, int]]: + """(engine, name, line) for each `#[Template('...')]` on a method.""" + found: list[tuple[str, str, int]] = [] + attributes = method_node.child_by_field_name("attributes") + if attributes is None: + return found + for group in attributes.children: + for attr in group.children: + if attr.type != "attribute": + continue + name_node = next((c for c in attr.children if c.type in ("name", "qualified_name")), None) + if name_node is None or _short_name(name_node, source) != "Template": + continue + name = _first_argument_literal(attr.child_by_field_name("parameters"), source, "template") + if name and name.endswith(".twig"): + found.append(("twig", name, attr.start_point[0] + 1)) + return found + + +def render_edge(ref: tuple[str, str], source_nid: str, php_path: Path, line: int) -> dict | None: + """`renders` edge from *source_nid* to the template file *ref* names, if it exists.""" + engine, name = ref + resolved = _RESOLVERS[engine](name, php_path) + if resolved is None: + return None + target_file = _corpus_relative_path(resolved, php_path) + return { + "source": source_nid, + "target": _make_id(target_file), + "relation": "renders", + "confidence": "EXTRACTED", + "confidence_score": 1.0, + "source_file": str(php_path), + "source_location": f"L{line}", + "weight": 1.0, + "target_file": target_file, + } diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index 2d03f09ad7..6aa2b61918 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -1005,7 +1005,7 @@ def _node_disambiguation_source_key(node: dict, root: Path) -> str: # back to the importer's own source_key would then look up an id that # disambiguation has already retired, and the edge would dangle. _TEMPLATE_TARGET_RELATIONS = frozenset({ - "includes", "embeds", "extends", "imports_macro", "uses_template", + "includes", "embeds", "extends", "imports_macro", "uses_template", "renders", }) _FILE_TARGET_RELATIONS = frozenset({ diff --git a/graphify/extractors/twig.py b/graphify/extractors/twig.py index c86400e15c..f849f5c090 100644 --- a/graphify/extractors/twig.py +++ b/graphify/extractors/twig.py @@ -128,10 +128,11 @@ def _sdc_index(root: Path) -> dict[str, Path]: def _twig_loader_root(path: Path) -> "Path | None": - """The `templates/` dir bare names resolve against: Twig names are never relative to the referrer.""" - for parent in list(path.parents)[:_MAX_WALK_UP]: - if parent.name == "templates": - return parent + """The `templates/` dir bare names resolve against (a template's own tree, else the nearest `templates/` child).""" + if path.suffix.lower() == ".twig": + for parent in list(path.parents)[:_MAX_WALK_UP]: + if parent.name == "templates": + return parent current = path.parent for _ in range(_MAX_WALK_UP): templates = current / "templates" diff --git a/tests/fixtures/symfony_views/src/Controller/ProductController.php b/tests/fixtures/symfony_views/src/Controller/ProductController.php new file mode 100644 index 0000000000..f55a523917 --- /dev/null +++ b/tests/fixtures/symfony_views/src/Controller/ProductController.php @@ -0,0 +1,47 @@ +render('product/index.html.twig', ['items' => []]); + } + + #[Template('product/index.html.twig')] + public function show(): array + { + return []; + } + + #[Template(template: 'product/_row.html.twig')] + public function partial(): array + { + return []; + } + + public function row(): string + { + return $this->renderView("product/_row.html.twig"); + } + + public function byName(string $name): Response + { + return $this->render('product/'.$name.'.html.twig'); + } + + public function interpolated(string $name): Response + { + return $this->render("product/{$name}.html.twig"); + } + + public function missing(): Response + { + return $this->render('product/missing.html.twig'); + } +} diff --git a/tests/fixtures/symfony_views/templates/base.html.twig b/tests/fixtures/symfony_views/templates/base.html.twig new file mode 100644 index 0000000000..1dc5758bde --- /dev/null +++ b/tests/fixtures/symfony_views/templates/base.html.twig @@ -0,0 +1,2 @@ + +{% block body %}{% endblock %} diff --git a/tests/fixtures/symfony_views/templates/product/_row.html.twig b/tests/fixtures/symfony_views/templates/product/_row.html.twig new file mode 100644 index 0000000000..82681b5144 --- /dev/null +++ b/tests/fixtures/symfony_views/templates/product/_row.html.twig @@ -0,0 +1 @@ +{{ item }} diff --git a/tests/fixtures/symfony_views/templates/product/index.html.twig b/tests/fixtures/symfony_views/templates/product/index.html.twig new file mode 100644 index 0000000000..57dfdfee80 --- /dev/null +++ b/tests/fixtures/symfony_views/templates/product/index.html.twig @@ -0,0 +1,4 @@ +{% extends 'base.html.twig' %} +{% block body %} + {{ include('product/_row.html.twig') }} +{% endblock %} diff --git a/tests/test_languages.py b/tests/test_languages.py index 042ed588a2..bba30ce8a6 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -5665,3 +5665,36 @@ def test_kotlin_bracketed_annotations_emits_multiple_attribute_edges(tmp_path): refs = _edge_labels(result, "references", "attribute") assert ("Foo", "Inject") in refs assert ("Foo", "VisibleForTesting") in refs + + +# ── Controller -> template (`renders`) ─────────────────────────────────────── + +def _views_corpus(fixture: str) -> dict: + """Full extract() over every file of a views fixture, rooted at the fixture.""" + import tempfile + root = (FIXTURES / fixture).resolve() + paths = sorted(p for p in root.rglob("*") if p.is_file()) + with tempfile.TemporaryDirectory() as td: + return _extract_corpus(paths, cache_root=Path(td), root=root) + + +def _renders(r: dict) -> set[tuple[str, str]]: + """(source label, rendered template's source_file) for every `renders` edge.""" + by_id = {n["id"]: n for n in r["nodes"]} + return { + (by_id[e["source"]]["label"], by_id[e["target"]]["source_file"]) + for e in r["edges"] + if e["relation"] == "renders" + } + + +def test_symfony_render_and_template_attribute_link_to_the_template_file(): + r = _views_corpus("symfony_views") + # Concatenated, interpolated and missing names yield no edge. + assert _renders(r) == { + (".list()", "templates/product/index.html.twig"), + (".show()", "templates/product/index.html.twig"), + (".partial()", "templates/product/_row.html.twig"), + (".row()", "templates/product/_row.html.twig"), + } + _assert_no_dangling(r) diff --git a/tests/test_twig.py b/tests/test_twig.py index 2848c5cc3b..a6b3a1e1ef 100644 --- a/tests/test_twig.py +++ b/tests/test_twig.py @@ -304,3 +304,14 @@ def test_unresolved_reference_is_left_dangling(tmp_path): def test_unreadable_file_reports_an_error(tmp_path): assert "error" in extract_twig(tmp_path / "missing.twig") + + +def test_php_referrer_under_a_dir_named_templates_finds_the_app_loader_root(tmp_path): + from graphify.extractors.twig import _resolve_path_ref + + app = tmp_path / "templates" / "app" + _write(app, "templates/a.html.twig", "

\n") + controller = _write(app, "src/Controller/A.php", " Date: Sun, 11 Oct 2026 13:28:11 +0200 Subject: [PATCH 5/5] docs(changelog): Twig templates and controller-to-template links Refs #19 Co-Authored-By: Claude --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a27158b15..a03ac73460 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## Unreleased +**Twig templates and controller-to-template links (`alexwrite/graphify#19`):** + +- Feature: `.twig` files are extracted (Twig extractor from Graphify-Labs/graphify#2240, Twig part only). `{% extends %}`, `{% include %}`, `{% embed %}`, `{% import %}`, `{% from %}`, `{% use %}` and `include()`/`source()` link to the real template file node; blocks and Drupal `attach_library()` are recorded too. +- Fix: a bare Twig name resolves against the loader root (the enclosing `templates/` dir) only, never against the referring file's own directory, and a Symfony `@Namespace/` name stays a stub instead of being matched against the app's templates. Drupal `@extension/` names resolve under that extension, as core does. +- Feature: a PHP method that renders a literal template name (`render()`, `renderView()`, `renderBlock()`, `renderBlockView()`, `#[Template(...)]`) gets a `renders` edge to the template file. A name built at runtime, or one with no file on disk, gets no edge. AST cache schema 9. +- Not covered: Twig components (``, `component()`), and Twig paths configured in `twig.yaml`. + Consolidated PHP-support and resolution work from the [`lawnstarter/graphify`](https://github.com/lawnstarter/graphify) fork — #1682 and everything found while fixing it. The full defect report is #2615. The groups below are ordered as the work landed on the fork, so a later group can lift a refusal an earlier group states; those spots are marked. **PHP member-call resolution, cross-language isolation, and import-edge repair:**