Files
codespaces/tests/test_build_belief_map_php.py
2026-06-22 08:12:55 +03:00

347 lines
12 KiB
Python

"""Tests for the PHP belief-map parser (tree-sitter + regex fallback).
Covers: class/interface/trait/enum extraction, PHP 8 attributes, heritage,
trait usage inside class bodies, Symfony/Laravel constructor DI, PSR-4
import resolution, and sexp output format.
"""
import importlib.util
import io
import json
import os
import sys
import tempfile
import textwrap
import unittest
from contextlib import redirect_stdout
from pathlib import Path
SCRIPT_PATH = Path(
os.environ.get(
"BELIEF_SEARCH_PATH",
Path(__file__).resolve().parent.parent / "scripts" / "build_belief_map.py",
)
)
if not SCRIPT_PATH.exists():
SCRIPT_PATH = Path(
"/Users/alexeus/.codex/skills/codespaces/scripts/belief_search.py"
)
SPEC = importlib.util.spec_from_file_location("build_belief_map", SCRIPT_PATH)
build_belief_map = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
sys.modules[SPEC.name] = build_belief_map
SPEC.loader.exec_module(build_belief_map)
PHP_SAMPLE = textwrap.dedent("""\
<?php
namespace App\\Service;
use App\\Contracts\\FooInterface;
use App\\Traits\\LoggerTrait;
#[AsService]
class UserService implements FooInterface
{
use LoggerTrait;
public function __construct(
private \\App\\Repositories\\UserRepo $repo
) {}
public function getUser(int $id): ?User
{
return $this->repo->find($id);
}
}
interface FooInterface
{
public function getUser(int $id): mixed;
}
trait LoggerTrait
{
public function log(string $msg): void {}
}
enum Status: string
{
case Active = "active";
case Inactive = "inactive";
}
function helper_function(): void {}
""")
class _PhpParserTestBase(unittest.TestCase):
"""Base class: parse PHP_SAMPLE and expose the FileResult."""
def _parse(self) -> "build_belief_map.FileResult":
return self._parse_fn()(
"/tmp/Test.php",
PHP_SAMPLE,
"app",
0.0,
)
def _parse_fn(self):
raise NotImplementedError
class TreeSitterPhpTest(_PhpParserTestBase):
"""Tests for parse_php_treesitter -- only runs if tree-sitter-php is installed."""
@classmethod
def setUpClass(cls):
if build_belief_map._PHP_LANG is None:
raise unittest.SkipTest("tree-sitter-php not installed")
def _parse_fn(self):
return build_belief_map.parse_php_treesitter
def test_extracts_all_entity_types(self) -> None:
"""/* REQ-PHP-001: tree-sitter parser extracts class, interface, trait, enum, function. */"""
r = self._parse()
kinds = {e["name"]: e["kind"] for e in r.entities}
self.assertEqual(kinds["UserService"], "class")
self.assertEqual(kinds["FooInterface"], "interface")
self.assertEqual(kinds["LoggerTrait"], "trait")
self.assertEqual(kinds["Status"], "enum")
self.assertEqual(kinds["helper_function"], "function")
def test_interface_and_trait_are_abstract(self) -> None:
"""/* REQ-PHP-002: interfaces and traits are added to exports_abstract. */"""
r = self._parse()
self.assertIn("FooInterface", r.exports_abstract)
self.assertIn("LoggerTrait", r.exports_abstract)
def test_php8_attributes_captured_as_decorators(self) -> None:
"""/* REQ-PHP-003: PHP 8 #[Attribute] is captured in entity decorators. */"""
r = self._parse()
svc = next(e for e in r.entities if e["name"] == "UserService")
self.assertIn("AsService", svc.get("decorators", []))
def test_heritage_extraction(self) -> None:
"""/* REQ-PHP-004: implements clause produces bases on the class entity. */"""
r = self._parse()
svc = next(e for e in r.entities if e["name"] == "UserService")
self.assertIn("FooInterface", svc.get("bases", []))
def test_trait_usage_inside_class(self) -> None:
"""/* REQ-PHP-005: use TraitX; inside a class body populates traits_used. */"""
r = self._parse()
self.assertIn("LoggerTrait", r.traits_used)
def test_constructor_di_extraction(self) -> None:
"""/* REQ-PHP-006: constructor type-hints produce implements entries for DI edges. */"""
r = self._parse()
# UserRepo is the short name from \App\Repositories\UserRepo
self.assertIn("UserRepo", r.implements)
def test_imports_extracted_from_use_statements(self) -> None:
"""/* REQ-PHP-007: use statements populate imports with FQCNs. */"""
r = self._parse()
self.assertIn("App\\Contracts\\FooInterface", r.imports)
self.assertIn("App\\Traits\\LoggerTrait", r.imports)
def test_methods_extracted(self) -> None:
"""/* REQ-PHP-008: public methods appear in entity :methods. */"""
r = self._parse()
svc = next(e for e in r.entities if e["name"] == "UserService")
self.assertIn("getUser", svc.get("methods", []))
# __construct is filtered out
self.assertNotIn("__construct", svc.get("methods", []))
class RegexFallbackPhpTest(_PhpParserTestBase):
"""Tests for parse_php_regex -- the fallback when tree-sitter-php is absent."""
def _parse_fn(self):
return build_belief_map.parse_php_regex
def test_extracts_class_interface_trait_enum(self) -> None:
"""/* REQ-PHP-009: regex fallback extracts basic entity types. */"""
r = self._parse()
kinds = {e["name"]: e["kind"] for e in r.entities}
self.assertEqual(kinds.get("UserService"), "class")
self.assertEqual(kinds.get("FooInterface"), "interface")
self.assertEqual(kinds.get("LoggerTrait"), "trait")
self.assertEqual(kinds.get("Status"), "enum")
def test_trait_usage_detected(self) -> None:
"""/* REQ-PHP-010: regex fallback detects trait usage inside class body. */"""
r = self._parse()
self.assertIn("LoggerTrait", r.traits_used)
def test_imports_extracted(self) -> None:
"""/* REQ-PHP-011: regex fallback extracts use-statement imports. */"""
r = self._parse()
self.assertTrue(
any("FooInterface" in imp for imp in r.imports),
f"Expected FooInterface in imports: {r.imports}",
)
class Psr4ResolutionTest(unittest.TestCase):
"""End-to-end test: build a mini PHP project with composer.json and verify edges."""
def setUp(self) -> None:
self.tmpdir = tempfile.TemporaryDirectory()
self.addCleanup(self.tmpdir.cleanup)
root = self.root = self.tmpdir.name
Path(root, "composer.json").write_text(
json.dumps(
{
"autoload": {"psr-4": {"App\\": "src/"}},
}
),
encoding="utf-8",
)
Path(root, "src", "Controller").mkdir(parents=True)
Path(root, "src", "Model").mkdir(parents=True)
Path(root, "src", "Trait").mkdir(parents=True)
Path(root, "src", "Controller", "UserController.php").write_text(
textwrap.dedent("""\
<?php
namespace App\\Controller;
use App\\Model\\User;
use App\\Trait\\LoggerTrait;
class UserController {
use LoggerTrait;
public function show(): User {}
}
"""),
encoding="utf-8",
)
Path(root, "src", "Model", "User.php").write_text(
textwrap.dedent("""\
<?php
namespace App\\Model;
class User {
public function getName(): string {}
}
"""),
encoding="utf-8",
)
Path(root, "src", "Trait", "LoggerTrait.php").write_text(
textwrap.dedent("""\
<?php
namespace App\\Trait;
trait LoggerTrait {
public function log(string $msg): void {}
}
"""),
encoding="utf-8",
)
def test_psr4_resolves_imports_to_module_edges(self) -> None:
"""/* REQ-PHP-012: PSR-4 autoload resolution creates IMPORTS edges between PHP modules. */"""
nodes, edges = build_belief_map.build_graph(
[
build_belief_map.parse_file(
(
str(Path(self.root, "src", "Controller", "UserController.php")),
"php",
"app",
)
),
build_belief_map.parse_file(
(str(Path(self.root, "src", "Model", "User.php")), "php", "app")
),
build_belief_map.parse_file(
(
str(Path(self.root, "src", "Trait", "LoggerTrait.php")),
"php",
"app",
)
),
],
self.root,
)
imports_edges = [e for e in edges if e["type"] == "IMPORTS"]
targets = {e["target"] for e in imports_edges}
# UserController should import User and LoggerTrait
node_ids = {n["id"] for n in nodes}
self.assertTrue(
any("User" in t for t in targets),
f"Expected User in import targets: {targets}",
)
self.assertTrue(
any("LoggerTrait" in t for t in targets),
f"Expected LoggerTrait in import targets: {targets}",
)
def test_trait_usage_creates_calls_api_edge(self) -> None:
"""/* REQ-PHP-013: trait usage inside a class creates a CALLS_API edge. */"""
nodes, edges = build_belief_map.build_graph(
[
build_belief_map.parse_file(
(
str(Path(self.root, "src", "Controller", "UserController.php")),
"php",
"app",
)
),
build_belief_map.parse_file(
(str(Path(self.root, "src", "Model", "User.php")), "php", "app")
),
build_belief_map.parse_file(
(
str(Path(self.root, "src", "Trait", "LoggerTrait.php")),
"php",
"app",
)
),
],
self.root,
)
calls_api_edges = [e for e in edges if e["type"] == "CALLS_API"]
targets = {e["target"] for e in calls_api_edges}
self.assertTrue(
any("LoggerTrait" in t for t in targets),
f"Expected LoggerTrait in CALLS_API targets: {targets}",
)
def test_sexp_output_contains_php_lang_and_trait_tag(self) -> None:
"""/* REQ-PHP-014: sexp output uses 'php' lang tag and 'trt' entity tag for traits. */"""
results = [
build_belief_map.parse_file(
(
str(Path(self.root, "src", "Controller", "UserController.php")),
"php",
"app",
)
),
build_belief_map.parse_file(
(str(Path(self.root, "src", "Model", "User.php")), "php", "app")
),
build_belief_map.parse_file(
(str(Path(self.root, "src", "Trait", "LoggerTrait.php")), "php", "app")
),
]
nodes, edges = build_belief_map.build_graph(results, self.root)
out_path = str(Path(self.root, ".belief_map.sexp"))
build_belief_map.write_sexp(
out_path, nodes, edges, self.root, "test", len(results)
)
content = Path(out_path).read_text(encoding="utf-8")
self.assertIn(" php ", content, "Expected (node ... php ...) in sexp output")
self.assertIn(
"(trt ", content, "Expected (trt ...) entity tag for trait in sexp output"
)
if __name__ == "__main__":
unittest.main()