feat(php): php projects support added
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import importlib.util
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import textwrap
|
||||
@@ -8,7 +9,18 @@ from contextlib import redirect_stdout
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT_PATH = Path("/Users/alexeus/.codex/skills/codespaces/scripts/belief_search.py")
|
||||
# Resolve belief_search.py via env override, relative path from this test file,
|
||||
# or the legacy absolute path (last resort). Keeps tests runnable on any machine.
|
||||
SCRIPT_PATH = Path(
|
||||
os.environ.get(
|
||||
"BELIEF_SEARCH_PATH",
|
||||
Path(__file__).resolve().parent.parent / "scripts" / "belief_search.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("belief_search", SCRIPT_PATH)
|
||||
belief_search = importlib.util.module_from_spec(SPEC)
|
||||
assert SPEC.loader is not None
|
||||
@@ -57,7 +69,9 @@ class BeliefSearchAliasTest(unittest.TestCase):
|
||||
|
||||
self.assertEqual(
|
||||
graph.resolve_module("asp543"),
|
||||
["app-service/app-service/src/workspace-operatives/workspace-operative.service"],
|
||||
[
|
||||
"app-service/app-service/src/workspace-operatives/workspace-operative.service"
|
||||
],
|
||||
)
|
||||
|
||||
output = io.StringIO()
|
||||
@@ -104,7 +118,10 @@ class BeliefSearchAliasTest(unittest.TestCase):
|
||||
"(boundary-file app-service/app-service/src/workspace-operatives/workspace-operative.service",
|
||||
text,
|
||||
)
|
||||
self.assertNotIn("(boundary app-service/app-service/src/workspace-operatives/workspace-operative.service", text)
|
||||
self.assertNotIn(
|
||||
"(boundary app-service/app-service/src/workspace-operatives/workspace-operative.service",
|
||||
text,
|
||||
)
|
||||
|
||||
def test_quick_resolves_alias_from_raw_search_hits(self) -> None:
|
||||
"""/* REQ-CS-004: quick must resolve legacy path-map aliases found in raw belief-map facts. */"""
|
||||
@@ -125,7 +142,9 @@ class BeliefSearchAliasTest(unittest.TestCase):
|
||||
|
||||
output = io.StringIO()
|
||||
with redirect_stdout(output):
|
||||
belief_search.cmd_boundary(graph, "workspace-operative.service", files_only=True)
|
||||
belief_search.cmd_boundary(
|
||||
graph, "workspace-operative.service", files_only=True
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
output.getvalue().strip().splitlines(),
|
||||
@@ -144,7 +163,9 @@ class BeliefSearchAliasTest(unittest.TestCase):
|
||||
belief_search.cmd_analyze(graph, "workspace-operative.controller")
|
||||
|
||||
text = output.getvalue()
|
||||
self.assertIn("(error no-match workspace-operative.controller :suggestions", text)
|
||||
self.assertIn(
|
||||
"(error no-match workspace-operative.controller :suggestions", text
|
||||
)
|
||||
self.assertIn("workspace-operative.service", text)
|
||||
|
||||
|
||||
|
||||
346
tests/test_build_belief_map_php.py
Normal file
346
tests/test_build_belief_map_php.py
Normal file
@@ -0,0 +1,346 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user