Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ Full release notes with details on each version: [GitHub Releases](https://githu

## Unreleased

**Blade views resolved to their file (`alexwrite/graphify#4`):**

- Fix: `@extends('layouts.app')` and `@include('partials.nav')` point at the view file's own node (`resources/views/layouts/app.blade.php`) instead of a stub `layouts/app`, resolved like Laravel's `FileViewFinder` (dots to slashes, `blade.php` then `php`, default `resources/views` path). A namespaced (`pkg::view`), dynamic or missing view keeps its stub. Repeated includes of one view are one edge.
- Feature: `view('...')`, `View::make('...')` and `response()->view('...')` with a literal name get a `renders` edge to the view file. AST cache schema 10.
- Not covered: view locations added at runtime (`View::addLocation`, themes) and `loadViewsFrom` namespaces.

**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 (`<twig:Name>`, `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:**
Expand Down
2 changes: 1 addition & 1 deletion graphify/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = 10 # Blade views resolved to their file, PHP `renders` edges to Blade views.


# Per-process memo: distributions() scans site-packages metadata, which is
Expand Down
2 changes: 1 addition & 1 deletion graphify/detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'}
Expand Down
6 changes: 6 additions & 0 deletions graphify/extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
}


Expand Down Expand Up @@ -7938,6 +7943,7 @@ def add_existing_edge(edge: dict) -> None:
".resource": extract_robot,
".cls": extract_apex,
".trigger": extract_apex,
".twig": extract_twig,
}


Expand Down
2 changes: 2 additions & 0 deletions graphify/extractors/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
32 changes: 32 additions & 0 deletions graphify/extractors/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from __future__ import annotations

import sys
import re
from pathlib import Path

from graphify.ids import make_id
Expand Down Expand Up @@ -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<n>`` 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)
89 changes: 66 additions & 23 deletions graphify/extractors/blade.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,51 @@
"""Laravel Blade template extractor. Moved verbatim from graphify/extract.py."""
"""Laravel Blade template extractor."""
from __future__ import annotations

from pathlib import Path

from graphify.extractors.base import _make_id, _read_source_text
from graphify.extractors.base import _corpus_relative_path, _make_id, _read_source_text

# Laravel's default view path and FileViewFinder's extension order.
_VIEWS_DIR = Path("resources") / "views"
_VIEW_EXTENSIONS = ("blade.php", "php")
_MAX_WALK_UP = 12


def _views_root(referrer: Path) -> Path | None:
"""Nearest `resources/views` dir above *referrer*: the app's default view path."""
current = referrer.parent
for _ in range(_MAX_WALK_UP):
views = current / _VIEWS_DIR
try:
if views.is_dir():
return views
except OSError:
return None
if current.parent == current:
return None
current = current.parent
return None


def resolve_blade_name(name: str, referrer: Path) -> Path | None:
"""File a view name maps to under `resources/views`, or None (namespaced, dynamic or missing)."""
if "::" in name or any(c in name for c in "$\\{}"):
return None
parts = name.split(".")
if any(not p or p.startswith("/") or ".." in Path(p).parts for p in parts):
return None
root = _views_root(referrer)
if root is None:
return None
stem = "/".join(parts)
for ext in _VIEW_EXTENSIONS:
candidate = root / f"{stem}.{ext}"
try:
if candidate.is_file():
return candidate
except OSError:
return None
return None


def extract_blade(path: Path) -> dict:
Expand All @@ -19,30 +61,31 @@ def extract_blade(path: Path) -> dict:
"source_file": str(path), "source_location": None}]
edges = []

# @extends('layouts.app') — template inheritance: this view renders inside
# the named parent layout. It is the primary structural relationship in a
# Blade view (most page templates extend a layout), so dropping it left the
# child disconnected from its layout in the graph.
for m in re.finditer(r"@extends\(['\"]([^'\"]+)['\"]", src):
tgt = m.group(1).replace(".", "/")
tgt_nid = _make_id(tgt)
if tgt_nid not in {n["id"] for n in nodes}:
nodes.append({"id": tgt_nid, "label": m.group(1), "file_type": "code",
seen_view_edges: set[tuple[str, str]] = set()

def add_view_ref(name: str, relation: str) -> None:
# Only a view found under resources/views links to a file node; anything else keeps its stub.
resolved = resolve_blade_name(name, path)
target_file = _corpus_relative_path(resolved, path) if resolved is not None else None
tgt_nid = _make_id(target_file if target_file is not None else name.replace(".", "/"))
if (tgt_nid, relation) in seen_view_edges:
return
seen_view_edges.add((tgt_nid, relation))
if target_file is None and tgt_nid not in {n["id"] for n in nodes}:
nodes.append({"id": tgt_nid, "label": name, "file_type": "code",
"source_file": str(path), "source_location": None})
edges.append({"source": file_nid, "target": tgt_nid, "relation": "extends",
"confidence": "EXTRACTED", "confidence_score": 1.0,
"source_file": str(path), "source_location": None, "weight": 1.0})
edge = {"source": file_nid, "target": tgt_nid, "relation": relation,
"confidence": "EXTRACTED", "confidence_score": 1.0,
"source_file": str(path), "source_location": None, "weight": 1.0}
if target_file is not None:
edge["target_file"] = target_file
edges.append(edge)

# @include('path.to.partial') or @include("path.to.partial")
# @extends('layouts.app') is the view's layout, @include('partials.nav') a partial it embeds.
for m in re.finditer(r"@extends\(['\"]([^'\"]+)['\"]", src):
add_view_ref(m.group(1), "extends")
for m in re.finditer(r"@include\(['\"]([^'\"]+)['\"]", src):
tgt = m.group(1).replace(".", "/")
tgt_nid = _make_id(tgt)
if tgt_nid not in {n["id"] for n in nodes}:
nodes.append({"id": tgt_nid, "label": m.group(1), "file_type": "code",
"source_file": str(path), "source_location": None})
edges.append({"source": file_nid, "target": tgt_nid, "relation": "includes",
"confidence": "EXTRACTED", "confidence_score": 1.0,
"source_file": str(path), "source_location": None, "weight": 1.0})
add_view_ref(m.group(1), "includes")

# <livewire:component.name /> or <livewire:component.name>
for m in re.finditer(r"<livewire:([\w.\-]+)", src):
Expand Down
18 changes: 17 additions & 1 deletion graphify/extractors/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading