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
16 changes: 16 additions & 0 deletions libs/openant-core/parsers/php/function_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,21 @@ def _get_parameters(self, node, source: bytes) -> List[str]:

return params

def _get_attributes(self, node, source: bytes) -> List[str]:
"""Extract PHP 8 attributes (`#[Route(...)]`, `#[Get]`, ...) as strings.

Attributes decorate a method/function via one or more `attribute_list`
children (`#[...]`). They carry routing/framework semantics (Symfony /
API-Platform `#[Route]`, `#[Get]`, `#[Post]`), so they are stored under
`decorators` — mirroring the Python/JS extractors — for the entry-point
detector to classify routed methods regardless of the class name.
"""
attributes: List[str] = []
for child in node.children:
if child.type == 'attribute_list':
attributes.append(self._node_text(child, source))
return attributes

def _is_static_method(self, node, source: bytes) -> bool:
"""Check if a method_declaration has a static modifier."""
for child in node.children:
Expand Down Expand Up @@ -670,6 +685,7 @@ def _process_function_node(self, node, source: bytes, relative_path: str,
'parameters': parameters,
'is_static': is_static,
'unit_type': unit_type,
'decorators': self._get_attributes(node, source),
}

self._store_function(func_id, func_data)
Expand Down
6 changes: 6 additions & 0 deletions libs/openant-core/parsers/php/test_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,12 @@ def apply_reachability_filter(self) -> bool:
'endLine': func_data.get('endLine', func_data.get('end_line', 0)),
'isExported': func_data.get('isExported', True),
'isSingleton': func_data.get('isSingleton', func_data.get('is_singleton', False)),
# Carry decorators/attributes so EntryPointDetector Check-1c
# (PHP 8 #[Route] attribute routing) and Check-2 (decorator
# patterns) fire on this per-parser reachable path — the
# whitelist previously dropped them, silently disabling
# attribute-based entry-point detection here.
'decorators': func_data.get('decorators', []),
}

# Build call graph from dataset unit metadata
Expand Down
3 changes: 3 additions & 0 deletions libs/openant-core/parsers/php/unit_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,9 @@ def generate_analyzer_output(self) -> Dict:
'isStatic': func_data.get('is_static', False),
'parameters': func_data.get('parameters', []),
'className': func_data.get('class_name'),
# Carry PHP 8 attributes (#[Route], ...) so the reachable pipeline's
# EntryPointDetector can seed attribute-routed handlers.
'decorators': func_data.get('decorators', []),
}

return {
Expand Down
77 changes: 77 additions & 0 deletions libs/openant-core/tests/parsers/php/test_php_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,83 @@ def test_closure_units_capture_their_body(tmp_path):


# ---------------------------------------------------------------------------
# PHP 8 #[Route] attribute handlers on a NON-*Controller class must seed (B11)
# ---------------------------------------------------------------------------

# Symfony/API-Platform style: the handler lives on a class that is NOT named
# *Controller and the file path contains no 'controller' segment, so the
# extractor's name/path-based route_handler classifier misses it. The routing
# attribute (#[Route]) is what makes it an endpoint. The body reads user input
# via the Symfony request bag ($request->query->get(...)).
ROUTE_ATTRIBUTE_SOURCE = """<?php
namespace App\\Api;

class ProductApi {
#[Route("/p/{id}")]
public function show($id) {
$q = $request->query->get("x");
return $q;
}
}
"""


def test_php_route_attribute_captured_by_extractor(tmp_path):
"""The extractor must capture #[Route] into func_data['decorators']."""
result = _extract(tmp_path, "ProductApi.php", ROUTE_ATTRIBUTE_SOURCE)
show = next(fd for fd in result["functions"].values() if fd["name"] == "show")
decorators = show.get("decorators", [])
assert any("Route" in d for d in decorators), (
"extractor must capture the #[Route] PHP8 attribute into "
f"func_data['decorators']; got decorators={decorators!r}"
)


def test_php_route_attribute_handler_is_entry_point(tmp_path):
"""A #[Route] method on a non-*Controller class must seed as an entry point."""
result = _extract(tmp_path, "ProductApi.php", ROUTE_ATTRIBUTE_SOURCE)

# Sanity: on the buggy path this method is classified as a plain 'method'
# (class 'ProductApi' has no 'controller', path has no 'controller').
show = next(fd for fd in result["functions"].values() if fd["name"] == "show")
assert show["class_name"] == "ProductApi"

detector = EntryPointDetector(result["functions"], {})
detector.detect_entry_points()
show_id = next(
fid for fid, fd in result["functions"].items() if fd["name"] == "show"
)
assert detector.is_entry_point(show_id), (
"ProductApi::show carries a #[Route] attribute and must be seeded as an "
"entry point INDEPENDENT of the class name; "
f"reason={detector.get_entry_point_reason(show_id)!r}"
)


def test_symfony_request_query_get_is_input_pattern(tmp_path):
"""$request->query->get( / $request->request->get( must count as user input."""
src = """<?php
class Widget {
public function build($request) {
$x = $request->query->get("q");
$y = $request->request->get("p");
return $x . $y;
}
}
"""
result = _extract(tmp_path, "Widget.php", src)
detector = EntryPointDetector(result["functions"], {})
detector.detect_entry_points()
build_id = next(
fid for fid, fd in result["functions"].items() if fd["name"] == "build"
)
assert detector.is_entry_point(build_id), (
"Widget::build reads Symfony request bags "
"($request->query->get / $request->request->get) and must be flagged as "
f"an entry point; reason={detector.get_entry_point_reason(build_id)!r}"
)


# A named function nested in a method body is GLOBAL in PHP (bug B7)
# ---------------------------------------------------------------------------

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""End-to-end regression for PHP 8 #[Route] attribute entry-point seeding
through the php *reachable* pipeline.

The per-parser reachable path (parsers/php/test_pipeline.py) rebuilds every unit
through a field whitelist before running EntryPointDetector. That whitelist
dropped 'decorators', so a #[Route] handler on a class NOT named *Controller
seeded ZERO entry points on this path and was pruned by the reachability filter
(Units: 1 -> 0) — while the raw-detector unit tests stayed green. This drives the
whole pipeline (parse_repository -> normalization -> detector -> reachability
filter) so the fix is exercised on the shipped path.
"""
import json
import sys
from pathlib import Path

CORE = Path(__file__).resolve().parents[3]
if str(CORE) not in sys.path:
sys.path.insert(0, str(CORE))

from core.parser_adapter import parse_repository # noqa: E402

ROUTE_PHP = (
"<?php\n"
"namespace App\\Api;\n"
"class ProductApi {\n"
' #[Route("/products/{id}", methods: ["GET"])]\n'
" public function show($id) { return $id; }\n"
"}\n"
)


def test_route_attribute_handler_survives_reachability_filter(tmp_path):
repo = tmp_path / "repo"
(repo / "src").mkdir(parents=True)
(repo / "src" / "ProductApi.php").write_text(ROUTE_PHP)
out = tmp_path / "out"
out.mkdir()

parse_repository(
str(repo), str(out), language="php",
processing_level="reachable", skip_tests=True, name="t",
)

cg = json.loads((out / "call_graph.json").read_text())
funcs = cg.get("functions", {})
hid = "src/ProductApi.php:ProductApi.show"
assert hid in funcs, f"handler missing from call graph: {list(funcs)}"
assert funcs[hid].get("decorators"), (
"the #[Route] attribute must reach the reachable pipeline as a decorator; "
f"got {funcs[hid].get('decorators')!r}"
)

# At processing_level='reachable' a unit survives only if reachable from a
# seed. The handler is the only function, so it survives iff it was seeded as
# an entry point (which requires decorators to survive the normalization).
blob = json.dumps(json.loads((out / "dataset.json").read_text()))
assert "ProductApi.show" in blob, (
"the #[Route] handler was pruned from the reachable dataset -> it was not "
"seeded (decorators dropped by the pipeline normalization whitelist)"
)
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,19 @@ def _unit_type(func_data: Dict) -> str:
r'@WebSocketGateway',
]

# PHP 8 routing attributes (Symfony / API-Platform): `#[Route(...)]`, `#[Get]`,
# `#[Post]`, ... A method carrying one of these IS a route handler regardless of
# the class name, so a handler on a class NOT named *Controller (which the PHP
# extractor's name/path-based classifier leaves as a plain `method`) is still
# seeded as an entry point.
ROUTE_ATTRIBUTE_PATTERNS = [
# A PHP 8 routing attribute anywhere in the attribute list — not only right
# after `#[`. Allows a namespace prefix (#[Routing\Route], #[\Symfony\...\Route]),
# grouped attributes (#[Foo, Route(...)]), and (with IGNORECASE at compile)
# case-insensitive class names, since PHP class names are case-insensitive.
r'#\[[^\]]*\b(Route|Get|Post|Put|Delete|Patch|Options|Head)\b',
]

# Code patterns indicating direct user input sources
USER_INPUT_PATTERNS = [
# Flask
Expand Down Expand Up @@ -140,6 +153,14 @@ def _unit_type(func_data: Dict) -> str:
r'php://input',
r'\bfile_get_contents\s*\(\s*["\']php://input',
r'\bfilter_input\s*\(',
# Symfony request reads, anchored to a $request / $req / $this->request
# receiver so they read HTTP input, not an unrelated ->query->all() on an
# ORM builder or a ->headers->get() on the app's own response object.
# - request bags: $request->query->get(...) / ->request-> / ->cookies-> / ...
# - direct methods: $request->get(...) / ->getPayload() / ->getContent() /
# ->toArray() / ->input(...) / ->all()
r'(\$(request|req)\b|\$this\s*->\s*request\b)\s*->\s*(query|request|cookies|attributes|headers|files)\s*->\s*(get|all)\s*\(',
r'(\$(request|req)\b|\$this\s*->\s*request\b)\s*->\s*(get|getPayload|getContent|toArray|input|all)\s*\(',
]

# Patterns that indicate module-level scripts with user input
Expand Down Expand Up @@ -198,6 +219,9 @@ def __init__(self, functions: Dict, call_graph: Dict):
self._decorator_patterns = [
re.compile(p, re.IGNORECASE) for p in ENTRY_POINT_DECORATORS
]
self._route_attribute_patterns = [
re.compile(p, re.IGNORECASE) for p in ROUTE_ATTRIBUTE_PATTERNS
]
self._input_patterns = [
re.compile(p) for p in USER_INPUT_PATTERNS
]
Expand Down Expand Up @@ -249,9 +273,19 @@ def _get_entry_point_reasons(self, func_data: Dict) -> List[str]:
elif func_data.get('name') == 'main':
reasons.append('name:main')

# Check 2: Decorators indicate entry point
# Check 1c: A PHP 8 routing attribute (#[Route]/#[Get]/#[Post]/...) marks
# the method as a route handler INDEPENDENT of the class name. Symfony /
# API-Platform endpoints live on classes not named *Controller, which the
# PHP extractor's name/path-based classifier leaves as a plain `method`;
# the attribute is the authoritative signal.
decorators = func_data.get('decorators', [])
decorators_str = ' '.join(decorators)
for pattern in self._route_attribute_patterns:
if pattern.search(decorators_str):
reasons.append('unit_type:route_handler')
break

# Check 2: Decorators indicate entry point
for pattern in self._decorator_patterns:
if pattern.search(decorators_str):
reasons.append(f'decorator:{pattern.pattern}')
Expand Down
Loading