347 lines
12 KiB
Python
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()
|