From c61df7c356fda16d90c4af2a2d945747a5e595ff Mon Sep 17 00:00:00 2001 From: Andy Slezak Date: Mon, 27 Jul 2026 12:55:51 -0400 Subject: [PATCH 1/2] feat(extract): add Twig and SCSS extractors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neither extension was in any FileType set, so Twig templates and Sass stylesheets were silently skipped: on a Drupal theme that dropped the entire component-composition and design-token layer from the graph. Both are regex extractors in the shape of extractors/blade.py — no new dependency, and routing them through FileType.CODE means they cost no LLM tokens and yield EXTRACTED-confidence edges. twig.py resolves references to real files rather than stubs: - Drupal SDC (`{% include 'mytheme:card' %}`) via the nearest `.info.yml` plus a components/ index - plain paths and Symfony's `@Namespace/...` via the referring file's dir and any enclosing templates/ root - relations: includes, embeds, extends, imports_macro, uses_template, defines_block, attaches_library scss.py covers the module, mixin, and design-token graphs: - @use/@forward/@import through Sass partial (`_name.scss`) and index (`name/_index.scss`) conventions - @mixin/@include keyed on the bare name so a namespaced use (`@include mx.button-reset`) links to its definition - `--token:` declarations and `var(--token)` references share one node, so token-defining stylesheets connect to every consumer Both strip comments before scanning, via a newline-preserving blank pass that keeps reported line numbers accurate. Without it a component docblock showing example usage ("Usage: {% include 'mytheme:card' %}") became a real dependency and gave the component an edge to itself. .twig/.scss/.sass are also added to _LANG_FAMILY_BY_EXT so their stubs cannot be remapped onto same-labeled symbols in an unrelated language. .css is deliberately out of scope: in a Sass project it is compiled output of the .scss already indexed, so indexing both duplicates the stylesheet layer. Verified against a 141-template / 109-stylesheet Drupal theme: 97% of Twig references and 100% of Sass imports resolve to real files, no self-loops. The unresolved remainder are base-theme templates outside the scanned corpus. --- graphify/detect.py | 2 +- graphify/extract.py | 10 ++ graphify/extractors/__init__.py | 4 + graphify/extractors/base.py | 34 +++++ graphify/extractors/scss.py | 166 ++++++++++++++++++++++ graphify/extractors/twig.py | 238 ++++++++++++++++++++++++++++++++ tests/test_detect.py | 10 ++ tests/test_scss.py | 211 ++++++++++++++++++++++++++++ tests/test_twig.py | 215 +++++++++++++++++++++++++++++ 9 files changed, 889 insertions(+), 1 deletion(-) create mode 100644 graphify/extractors/scss.py create mode 100644 graphify/extractors/twig.py create mode 100644 tests/test_scss.py create mode 100644 tests/test_twig.py diff --git a/graphify/detect.py b/graphify/detect.py index d243641e2..96da82944 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -28,7 +28,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', '.twig', '.scss', '.sass'} 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 9de0c7622..6906678e9 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -49,9 +49,11 @@ from graphify.extractors.powershell import extract_powershell, extract_powershell_manifest # noqa: F401 from graphify.extractors.razor import extract_razor # noqa: F401 from graphify.extractors.rust import extract_rust # noqa: F401 +from graphify.extractors.scss import extract_scss # noqa: F401 from graphify.extractors.sln import extract_sln # noqa: F401 from graphify.extractors.sql import extract_sql # noqa: F401 from graphify.extractors.terraform import extract_terraform # noqa: F401 +from graphify.extractors.twig import extract_twig # noqa: F401 from graphify.extractors.verilog import extract_verilog # noqa: F401 from graphify.extractors.zig import extract_zig # noqa: F401 from graphify.security import sanitize_metadata @@ -1827,6 +1829,11 @@ def _lang_is_case_insensitive(source_file: object) -> bool: ".dart": "dart", ".sh": "shell", ".bash": "shell", ".ps1": "powershell", ".psm1": "powershell", ".psd1": "powershell", + # Template and stylesheet families. Naming them keeps a Twig include stub or + # an SCSS mixin stub from being remapped onto a same-labeled function in an + # unrelated language, which is what an unknown (None) family permits. + ".twig": "twig", + ".scss": "sass", ".sass": "sass", } @@ -4100,6 +4107,9 @@ def add_existing_edge(edge: dict) -> None: ".cshtml": extract_razor, ".cls": extract_apex, ".trigger": extract_apex, + ".twig": extract_twig, + ".scss": extract_scss, + ".sass": extract_scss, } diff --git a/graphify/extractors/__init__.py b/graphify/extractors/__init__.py index ada517094..d31a58f90 100644 --- a/graphify/extractors/__init__.py +++ b/graphify/extractors/__init__.py @@ -27,9 +27,11 @@ from graphify.extractors.powershell import extract_powershell, extract_powershell_manifest from graphify.extractors.razor import extract_razor from graphify.extractors.rust import extract_rust +from graphify.extractors.scss import extract_scss from graphify.extractors.sln import extract_sln 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.zig import extract_zig @@ -56,9 +58,11 @@ "powershell_manifest": extract_powershell_manifest, "razor": extract_razor, "rust": extract_rust, + "scss": extract_scss, "sln": extract_sln, "sql": extract_sql, "terraform": extract_terraform, + "twig": extract_twig, "verilog": extract_verilog, "zig": extract_zig, } diff --git a/graphify/extractors/base.py b/graphify/extractors/base.py index 148fc18c6..a47738b3a 100644 --- a/graphify/extractors/base.py +++ b/graphify/extractors/base.py @@ -1,6 +1,7 @@ # DO NOT import from graphify.extract here — direction is extract.py → extractors/ only. from __future__ import annotations +import re from pathlib import Path from graphify.ids import make_id @@ -83,3 +84,36 @@ def _file_stem(path: Path) -> str: def _read_text(node, source: bytes) -> str: return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace") + + +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/scss.py b/graphify/extractors/scss.py new file mode 100644 index 000000000..fb20c5135 --- /dev/null +++ b/graphify/extractors/scss.py @@ -0,0 +1,166 @@ +"""SCSS/Sass extractor: module graph, mixin graph, and design-token graph. + +Regex-based by design, mirroring ``extractors/blade.py``. The three relationships +worth graphing in a stylesheet are all lexical, so a parser buys nothing here: + +* ``@use`` / ``@forward`` / ``@import`` -- the module graph, resolved through + Sass's partial (``_name.scss``) and index (``name/_index.scss``) conventions so + edges land on real files. +* ``@mixin`` / ``@include`` -- the mixin graph. Both sides key off a shared, + file-independent node, so the file defining a mixin and every file using it + connect through it. A namespaced use (``@include mx.button-reset``) is keyed on + the bare name, since that is what the definition is called. +* ``--custom-property`` declarations and ``var(--custom-property)`` references -- + the design-token graph, likewise sharing one node per token so token-defining + stylesheets connect to every consumer. + +Plain ``.css`` is deliberately not routed here: in a Sass project the CSS files +are compiled output of the very ``.scss`` sources already indexed, so indexing +both would duplicate the whole stylesheet layer. +""" +from __future__ import annotations + +import re +from pathlib import Path + +from graphify.extractors.base import _blank_spans, _corpus_relative_path, _make_id + +# Comments, stripped before extraction so a commented-out or illustrative +# `@use`/`@include` is not read as a real dependency. The `//` form is guarded +# against `:` so a `https://` inside `url(...)` is not mistaken for one. +_SCSS_BLOCK_COMMENT = re.compile(r"/\*.*?\*/", re.DOTALL) +_SCSS_LINE_COMMENT = re.compile(r"(? "Path | None": + """Resolve a Sass module reference through the partial/index conventions.""" + if not raw or raw.startswith(_SCSS_NON_FILE_PREFIXES): + return None + ref = Path(raw) + if ref.is_absolute(): + return None + directory = path.parent / ref.parent + stem = ref.name + if not stem: + return None + candidates: list[Path] = [] + if ref.suffix in _SCSS_SOURCE_EXTS: + bare = stem[: -len(ref.suffix)] + candidates += [directory / stem, directory / f"_{bare}{ref.suffix}"] + else: + # Sass prefers the partial spelling, so `_name.scss` is tried first. + for name in (f"_{stem}", stem): + candidates += [directory / f"{name}{ext}" for ext in _SCSS_SOURCE_EXTS] + for index in ("_index", "index"): + candidates += [ + directory / stem / f"{index}{ext}" for ext in _SCSS_SOURCE_EXTS + ] + for candidate in candidates: + try: + if candidate.is_file(): + return candidate + except OSError: + continue + return None + + +def extract_scss(path: Path) -> dict: + """Extract module imports, mixin definitions/uses, and design-token usage.""" + try: + src = path.read_text(encoding="utf-8", errors="replace") + except OSError: + return {"error": f"cannot read {path}"} + src = _blank_spans(_blank_spans(src, _SCSS_BLOCK_COMMENT), _SCSS_LINE_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 stylesheet referencing the same + # token in 150 declarations is one dependency on it, and duplicate + # parallel edges trip graphify's 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}) + + for rule in _SCSS_AT_RULE.finditer(src): + for quoted in _SCSS_QUOTED.finditer(rule.group(2)): + raw = quoted.group(2).strip() + if not raw: + continue + resolved = _resolve_scss_import(raw, path) + if resolved is not None: + target_file = _corpus_relative_path(resolved, path) + add(_make_id(target_file), resolved.name, "imports", + line_of(rule.start()), target_file=target_file) + else: + add(_make_id(raw), raw, "imports", line_of(rule.start())) + + for m in _SCSS_MIXIN_DEF.finditer(src): + name = m.group(1) + add(_make_id("scss-mixin", name), name, "defines_mixin", line_of(m.start())) + + for m in _SCSS_MIXIN_USE.finditer(src): + # `mx.button-reset` -> `button-reset`: the namespace is the importing + # file's local alias, while the definition carries the bare name. + name = m.group(1).rsplit(".", 1)[-1] + add(_make_id("scss-mixin", name), name, "uses_mixin", line_of(m.start())) + + for m in _SCSS_TOKEN_DEF.finditer(src): + name = m.group(1) + add(_make_id(name), name, "defines_token", line_of(m.start())) + + for m in _SCSS_TOKEN_USE.finditer(src): + name = m.group(1) + add(_make_id(name), name, "uses_token", line_of(m.start())) + + return {"nodes": nodes, "edges": edges} diff --git a/graphify/extractors/twig.py b/graphify/extractors/twig.py new file mode 100644 index 000000000..5f3f84fd4 --- /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 15fb570e4..41048d60e 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -21,6 +21,16 @@ 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_scss(): + assert classify_file(Path("components/card.scss")) == FileType.CODE + assert classify_file(Path("components/card.sass")) == FileType.CODE + def test_classify_markdown(): assert classify_file(Path("README.md")) == FileType.DOCUMENT diff --git a/tests/test_scss.py b/tests/test_scss.py new file mode 100644 index 000000000..b3a47d80e --- /dev/null +++ b/tests/test_scss.py @@ -0,0 +1,211 @@ +"""Tests for the SCSS/Sass extractor (graphify/extractors/scss.py).""" +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract_scss + + +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]: + 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 + } + + +# --- module graph ----------------------------------------------------------- + +def test_use_resolves_through_the_partial_convention(tmp_path): + _write(tmp_path, "tokens/_breakpoints.scss", "$tablet: 768px;\n") + sheet = _write(tmp_path, "tokens/all.scss", "@use 'breakpoints';\n") + + r = extract_scss(sheet) + + assert _rel_targets(r, "imports") == {"_breakpoints.scss"} + assert _target_files(r, "imports") == { + str((tmp_path / "tokens/_breakpoints.scss").resolve()) + } + + +def test_use_resolves_through_the_index_convention(tmp_path): + _write(tmp_path, "tokens/colors/_index.scss", "$red: #f00;\n") + sheet = _write(tmp_path, "tokens/all.scss", "@use 'colors';\n") + + r = extract_scss(sheet) + + assert _rel_targets(r, "imports") == {"_index.scss"} + + +def test_use_with_an_alias_and_a_relative_path(tmp_path): + _write(tmp_path, "tokens/_mixins.scss", "@mixin visually-hidden { }\n") + sheet = _write(tmp_path, "components/card.scss", + "@use '../tokens/mixins' as mx;\n") + + r = extract_scss(sheet) + + assert _rel_targets(r, "imports") == {"_mixins.scss"} + + +def test_import_accepts_a_comma_separated_list(tmp_path): + _write(tmp_path, "_a.scss", "") + _write(tmp_path, "_b.scss", "") + sheet = _write(tmp_path, "main.scss", "@import 'a', 'b';\n") + + r = extract_scss(sheet) + + assert _rel_targets(r, "imports") == {"_a.scss", "_b.scss"} + + +def test_builtin_sass_modules_stay_stubs(tmp_path): + sheet = _write(tmp_path, "main.scss", "@use 'sass:math';\n") + + r = extract_scss(sheet) + + # A real dependency, but not a file in the corpus. + assert _rel_targets(r, "imports") == {"sass:math"} + + +# --- mixin graph ------------------------------------------------------------ + +def test_mixin_definition_and_use_share_one_node(tmp_path): + definer = _write(tmp_path, "_mixins.scss", "@mixin button-reset { border: 0; }\n") + user = _write(tmp_path, "card.scss", "@use 'mixins' as mx;\n" + ".c { @include mx.button-reset; }\n") + + defined = extract_scss(definer) + used = extract_scss(user) + + def_edge = next(e for e in defined["edges"] if e["relation"] == "defines_mixin") + use_edge = next(e for e in used["edges"] if e["relation"] == "uses_mixin") + # The namespace is the consumer's local alias; the definition carries the + # bare name, so both sides must key on the bare name to connect. + assert def_edge["target"] == use_edge["target"] + + +def test_unnamespaced_include_also_links(tmp_path): + sheet = _write(tmp_path, "card.scss", "@mixin focus-ring { }\n" + ".c { @include focus-ring; }\n") + + r = extract_scss(sheet) + + assert _rel_targets(r, "defines_mixin") == {"focus-ring"} + assert _rel_targets(r, "uses_mixin") == {"focus-ring"} + + +def test_include_at_rule_is_not_mistaken_for_import(tmp_path): + sheet = _write(tmp_path, "card.scss", ".c { @include focus-ring; }\n") + + r = extract_scss(sheet) + + assert not [e for e in r["edges"] if e["relation"] == "imports"] + + +# --- design-token graph ----------------------------------------------------- + +def test_token_definition_and_use_share_one_node(tmp_path): + definer = _write(tmp_path, "_tokens.scss", ":root { --spacing-05: 16px; }\n") + user = _write(tmp_path, "card.scss", ".c { padding: var(--spacing-05, 16px); }\n") + + defined = extract_scss(definer) + used = extract_scss(user) + + def_edge = next(e for e in defined["edges"] if e["relation"] == "defines_token") + use_edge = next(e for e in used["edges"] if e["relation"] == "uses_token") + assert def_edge["target"] == use_edge["target"] + assert _rel_targets(defined, "defines_token") == {"--spacing-05"} + + +def test_nested_var_fallback_counts_both_tokens(tmp_path): + sheet = _write(tmp_path, "card.scss", + ".c { color: var(--brand, var(--fallback)); }\n") + + r = extract_scss(sheet) + + assert _rel_targets(r, "uses_token") == {"--brand", "--fallback"} + + +def test_repeated_token_use_is_a_single_edge(tmp_path): + sheet = _write(tmp_path, "card.scss", + ".a { margin: var(--spacing-05); }\n" + ".b { padding: var(--spacing-05); }\n" + ".c { gap: var(--spacing-05); }\n") + + r = extract_scss(sheet) + + assert len([e for e in r["edges"] if e["relation"] == "uses_token"]) == 1 + + +# --- comments --------------------------------------------------------------- + +def test_commented_out_rules_are_ignored(tmp_path): + _write(tmp_path, "_real.scss", "") + _write(tmp_path, "_ghost.scss", "") + sheet = _write(tmp_path, "main.scss", + "// @use 'ghost';\n" + "/* @use 'ghost';\n" + " @include ghost-mixin; */\n" + "@use 'real';\n") + + r = extract_scss(sheet) + + assert _rel_targets(r, "imports") == {"_real.scss"} + assert not [e for e in r["edges"] if e["relation"] == "uses_mixin"] + + +def test_protocol_slashes_are_not_treated_as_a_comment(tmp_path): + sheet = _write(tmp_path, "main.scss", + ".c { background: url(https://example.com/a.png); " + "color: var(--brand); }\n") + + r = extract_scss(sheet) + + # `//` inside the URL must not blank the rest of the line. + assert _rel_targets(r, "uses_token") == {"--brand"} + + +def test_comment_stripping_preserves_line_numbers(tmp_path): + _write(tmp_path, "_real.scss", "") + sheet = _write(tmp_path, "main.scss", + "/* a\n multiline\n comment */\n" + "@use 'real';\n") + + r = extract_scss(sheet) + + location = next(e["source_location"] for e in r["edges"] + if e["relation"] == "imports") + assert location == "L4" + + +# --- misc ------------------------------------------------------------------- + +def test_every_edge_is_extracted_with_full_confidence(tmp_path): + sheet = _write(tmp_path, "card.scss", ".c { padding: var(--spacing-05); }\n") + + r = extract_scss(sheet) + + 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_scss(tmp_path / "missing.scss") diff --git a/tests/test_twig.py b/tests/test_twig.py new file mode 100644 index 000000000..29ffb5a56 --- /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 234579d6a097d7411343cff46e70ae38a29c655d Mon Sep 17 00:00:00 2001 From: Andy Slezak Date: Mon, 27 Jul 2026 12:55:51 -0400 Subject: [PATCH 2/2] fix(extract): keep template composition edges alive through id disambiguation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Single Directory Component keeps `card.twig` beside `card.scss`, so both collapse to the same `card` file id and _disambiguate_colliding_node_ids salts them apart. An `{% include %}` edge from a THIRD template carries neither salt's source_key, so the (target, edge_source_key) lookup missed and the edge dangled on the retired `card` id. That is the #1475 C/ObjC header failure exactly — `foo.h` beside `foo.c`, with the import edge from a third file. Mirror its fix: repoint template-composition edges to the .twig variant, since an include always targeted the template. Also honor the target_file stamp for those relations. It was gated to imports/imports_from/re_exports, so a stamped `includes` edge fell back to the importer's own source_key. On a 141-template Drupal theme this takes dangling composition edges from 198/234 to 0/234, and embeds from 14/14 to 0/14. The extractors emit the edge only (no stub node) for a resolved reference, matching the documented #2195 pattern, so no ghost node duplicates the real file node either. --- graphify/extractors/resolution.py | 39 ++++++++++++++++++- graphify/extractors/scss.py | 27 +++++++++---- graphify/extractors/twig.py | 26 +++++++++---- tests/test_scss.py | 64 +++++++++++++++++++++++++------ tests/test_twig.py | 60 +++++++++++++++++++++++------ 5 files changed, 178 insertions(+), 38 deletions(-) diff --git a/graphify/extractors/resolution.py b/graphify/extractors/resolution.py index 457c26615..0cc60c3ca 100644 --- a/graphify/extractors/resolution.py +++ b/graphify/extractors/resolution.py @@ -652,6 +652,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], @@ -751,6 +767,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) @@ -763,7 +797,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 @@ -780,6 +814,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/scss.py b/graphify/extractors/scss.py index fb20c5135..dc745c417 100644 --- a/graphify/extractors/scss.py +++ b/graphify/extractors/scss.py @@ -115,11 +115,16 @@ 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: + # A reference that resolved to another stylesheet gets NO node here: + # that file mints its own file node when extracted, and a second one + # with the same id makes _disambiguate_colliding_node_ids rename the + # pair apart, severing the very link being drawn. The edge's + # target_file stamp canonicalizes it onto the real node instead — + # 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 stylesheet referencing the same # token in 150 declarations is one dependency on it, and duplicate # parallel edges trip graphify's same-endpoint-collapse diagnostic. @@ -127,10 +132,18 @@ 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} + # Stamp the resolved path so the target canonicalizes to the imported + # stylesheet's real node id; without it the target keeps an + # absolute-path derived id that matches no node in the merged graph and + # the import edge silently drops (#2211). Only set when the reference + # resolved — an unresolvable import must stay dangling. + if target_file is not None: + edge["target_file"] = target_file + edges.append(edge) for rule in _SCSS_AT_RULE.finditer(src): for quoted in _SCSS_QUOTED.finditer(rule.group(2)): diff --git a/graphify/extractors/twig.py b/graphify/extractors/twig.py index 5f3f84fd4..9e044e22c 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_scss.py b/tests/test_scss.py index b3a47d80e..7aa277a8e 100644 --- a/tests/test_scss.py +++ b/tests/test_scss.py @@ -14,21 +14,27 @@ def _write(root: Path, rel: str, body: str) -> Path: def _rel_targets(r, relation: str) -> set[str]: + """Readable name of every target reached by *relation*. + + An import that resolved to another stylesheet 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} # --- module graph ----------------------------------------------------------- @@ -207,5 +213,41 @@ def test_every_edge_is_extracted_with_full_confidence(tmp_path): assert e["confidence_score"] == 1.0 +def test_resolved_import_stamps_target_file(tmp_path): + """Without the stamp the import target never canonicalizes onto the real + stylesheet node and the edge silently drops from the merged graph (#2211).""" + _write(tmp_path, "tokens/_breakpoints.scss", "$tablet: 768px;\n") + sheet = _write(tmp_path, "tokens/all.scss", "@use 'breakpoints';\n") + + r = extract_scss(sheet) + + edge = next(e for e in r["edges"] if e["relation"] == "imports") + assert edge["target_file"] == str( + (tmp_path / "tokens/_breakpoints.scss").resolve()) + + +def test_unresolved_import_is_left_dangling(tmp_path): + sheet = _write(tmp_path, "main.scss", "@use 'sass:math';\n") + + r = extract_scss(sheet) + + edge = next(e for e in r["edges"] if e["relation"] == "imports") + assert "target_file" not in edge + + +def test_token_and_mixin_edges_are_never_stamped(tmp_path): + """Token and mixin nodes are file-independent by design — they are shared + across every consumer, so they must not be pinned to one file.""" + sheet = _write(tmp_path, "card.scss", + "@mixin m { }\n.c { @include m; color: var(--brand); }\n") + + r = extract_scss(sheet) + + for e in r["edges"]: + if e["relation"] in {"uses_token", "defines_token", + "uses_mixin", "defines_mixin"}: + assert "target_file" not in e + + def test_unreadable_file_reports_an_error(tmp_path): assert "error" in extract_scss(tmp_path / "missing.scss") diff --git a/tests/test_twig.py b/tests/test_twig.py index 29ffb5a56..77f096474 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")