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.
This commit is contained in:
nikhil2004 committed 2026-09-10 03:56:57 +05:30
1 parent 3f82bf7f83
commit c8c3af1960
3 files changed
+39 -3

No files matched your search

+2 -2
View File
@@ -1023,7 +1023,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
@@ -1041,7 +1041,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
@@ -4415,7 +4415,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"