feat(extract): support PHP closures (#3409)

The bug:
PHP closures (`anonymous_function_creation_expression` and `arrow_function`)
were invisible to the graph, disconnecting API routes and callbacks from
the backend code.

The fix:
- Added closure syntax node types to _PHP_CONFIG's `function_types`.
- Updated engine.py to synthesize line-bound names (e.g., `{closure@42}`)
  for unnamed closures matching these AST node types, enabling them to be
  extracted as linkable nodes in the graph without ID collisions.

(cherry picked from commit c8c3af1960)
This commit is contained in:
nikhil2004 authored and safishamsi committed 2026-09-23 19:34:18 +01:00
1 parent f2b65b78ec
commit cd3fd9436e
3 files changed
+39 -3

No files matched your search

+2 -2
View File
@@ -1459,7 +1459,7 @@ _PHP_CONFIG = LanguageConfig(
"enum_declaration",
"trait_declaration",
}),
function_types=frozenset({"function_definition", "method_declaration"}),
function_types=frozenset({"function_definition", "method_declaration", "anonymous_function_creation_expression", "arrow_function"}),
import_types=frozenset({"namespace_use_clause"}),
# object_creation_expression joins the dispatch set so `new Foo(...)` links
# the constructing method to Foo (engine has a dedicated PHP branch: the
@@ -1477,7 +1477,7 @@ _PHP_CONFIG = LanguageConfig(
# enums wrap their members in an enum_declaration_list rather than a
# declaration_list, so the body walk needs it to reach enum methods/cases.
body_fallback_child_types=("declaration_list", "compound_statement", "enum_declaration_list"),
function_boundary_types=frozenset({"function_definition", "method_declaration"}),
function_boundary_types=frozenset({"function_definition", "method_declaration", "anonymous_function_creation_expression", "arrow_function"}),
import_handler=_import_php,
)
+4 -1
View File
@@ -4928,7 +4928,10 @@ def _extract_generic(
func_name = _read_text(name_node, source) if name_node else None
if not func_name:
return
if t in ("anonymous_function_creation_expression", "arrow_function"):
func_name = f"{{closure@{node.start_point[0] + 1}}}"
else:
return
sanitized_name = (
config.sanitize_symbol_name_fn(func_name)
if config.sanitize_symbol_name_fn is not None
+33
View File
@@ -0,0 +1,33 @@
from pathlib import Path
from graphify.extract import extract_php
import pytest
def test_php_closures(tmp_path):
src = b'''<?php
$app->get('/api', function() {
return 1;
});
$fn = fn($x) => $x + 1;
'''
php_file = tmp_path / 'dummy.php'
php_file.write_bytes(src)
res = extract_php(php_file)
if res.get('error'):
pytest.skip(res['error'])
nodes = {n['id']: n for n in res['nodes']}
labels = {n['label']: n for n in res['nodes']}
# Assert closures are correctly generated
assert '{closure@2}()' in labels, "Missing anonymous_function_creation_expression node"
assert '{closure@5}()' in labels, "Missing arrow_function node"
closure_1 = labels['{closure@2}()']
closure_2 = labels['{closure@5}()']
# Check edges
edges = res['edges']
assert any(e['source'] == 'dummy.php' and e['target'] == closure_1['id'] and e['relation'] == 'contains' for e in edges), "Missing contains edge for closure_1"
assert any(e['source'] == 'dummy.php' and e['target'] == closure_2['id'] and e['relation'] == 'contains' for e in edges), "Missing contains edge for closure_2"