From 793aca67147ed2c0e986d5dc682248e02ed3bf2f Mon Sep 17 00:00:00 2001 From: safishamsi Date: Wed, 23 Sep 2026 19:45:40 +0100 Subject: [PATCH] test(php): cover file-scope, no-duplicate, and method-scope closures (#3409/#3461 follow-up) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #3461 fixes #3409 (PHP closures produce no nodes / inner calls lost). Adds the coverage its tests omitted: - the reporter's exact repro — a FILE-SCOPE argument-position closure — asserting the closure node exists AND its inner call resolves to the closure (not the file); - a no-duplicate-node assertion guarding the walk partition against double-walk; - a method-scope closure asserting its inner call attributes to the closure, not the enclosing method (pins the function_boundary_types behavior). Verified end-to-end: closure->handler() edges attribute to {closure#N} nodes with no duplicates. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_extract_php_closures.py | 82 ++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/tests/test_extract_php_closures.py b/tests/test_extract_php_closures.py index 4877c5d9e..cc71f2fb1 100644 --- a/tests/test_extract_php_closures.py +++ b/tests/test_extract_php_closures.py @@ -96,3 +96,85 @@ $value = $cache->get('user:42', function () { return 2; }); assert not any(l.startswith("GET ") for l in labels), "Expected no route label for cache method" + + +def _php_grammar_available(tmp_path) -> bool: + r = extract_php(tmp_path / "__probe.php") + return not (r.get("error") and "No language parser" in (r.get("error") or "")) + + +def test_php_file_scope_arg_closure_call_resolves_to_the_closure(tmp_path): + """#3409 reporter repro: a closure passed as an argument at FILE SCOPE must + produce a node AND its inner call must attribute to that closure. Uses the + full extract() pipeline so raw_calls resolve to a real defined target.""" + (tmp_path / "__probe.php").write_bytes(b"= 2, f"expected file-scope closures as nodes, got {sorted(labels)}" + # each closure's inner call to handler() resolves and attributes to the closure + call_pairs = { + (id2label.get(e["source"]), id2label.get(e["target"])) + for e in r["edges"] if e["relation"] == "calls" + } + handler_callers = {src for src, tgt in call_pairs if tgt == "handler()"} + assert handler_callers, "closure inner call to handler() was not captured" + assert all(c and c.startswith("{closure#") for c in handler_callers), ( + f"handler() call must attribute to a closure, not the file: {handler_callers}" + ) + + +def test_php_closures_produce_no_duplicate_nodes(tmp_path): + """The walk partition must emit each closure exactly once (no double-walk).""" + (tmp_path / "__probe.php").write_bytes(b" 2; +array_map(function() { return 3; }, []); +""" + (tmp_path / "dup.php").write_bytes(src) + res = extract_php(tmp_path / "dup.php") + from collections import Counter + counts = Counter(n["label"] for n in res["nodes"] if n.get("label", "").startswith("{closure#")) + assert counts and all(c == 1 for c in counts.values()), f"duplicate closure nodes: {counts}" + + +def test_php_method_scope_closure_call_attributes_to_closure_not_method(tmp_path): + """A closure inside a method: its inner call attributes to the closure node, + not the enclosing method (closures are function_boundary_types) (#3409).""" + (tmp_path / "__probe.php").write_bytes(b"