feat(php): php projects support added
This commit is contained in:
126
.agents/designs/php-support.md
Normal file
126
.agents/designs/php-support.md
Normal file
@@ -0,0 +1,126 @@
|
||||
PHP Language Support for Belief Map
|
||||
=====================================
|
||||
|
||||
Context and motivation
|
||||
----------------------
|
||||
The belief map previously supported Python and TypeScript/TSX. PHP projects
|
||||
(Laravel, Symfony, legacy codebases) had no graph representation, meaning
|
||||
agents querying the belief map would get zero results for `.php` files --
|
||||
even in monorepos containing both TS and PHP services.
|
||||
|
||||
This design adds PHP as a first-class language alongside Python and TS,
|
||||
covering the same entity/edge model: classes, interfaces, traits, enums,
|
||||
imports, heritage, trait usage, and constructor dependency injection.
|
||||
|
||||
|
||||
Implementation approach
|
||||
-----------------------
|
||||
**tree-sitter-php** is the primary parser, mirroring the TypeScript approach.
|
||||
A regex fallback (`parse_php_regex`) ensures PHP files remain visible even
|
||||
when `tree-sitter-php` is not installed, at the cost of reduced precision.
|
||||
|
||||
**PSR-4 autoload resolution** reads `composer.json` (`autoload.psr-4` and
|
||||
`autoload-dev.psr-4`) to map FQCNs in `use` statements to file paths. This
|
||||
mirrors the TypeScript `tsconfig.json` path-alias resolver. When no
|
||||
`composer.json` is found, a walk-up heuristic converts namespace separators
|
||||
to path separators (like the Python resolver).
|
||||
|
||||
**Constructor DI extraction** mirrors the NestJS heuristic from the TS
|
||||
parser: typed `__construct` parameters with PascalCase type-hints are
|
||||
treated as service dependencies, producing `calls-api` edges. This works
|
||||
for Symfony autowiring and Laravel service container injection.
|
||||
|
||||
**Trait usage** (`use TraitX;` inside a class body) is a PHP-specific
|
||||
relationship with no direct equivalent in Python or TS. Traits are
|
||||
registered as abstract providers (like interfaces), and classes that use
|
||||
them get `calls-api` edges to the trait's defining module.
|
||||
|
||||
**LSP enrichment** uses `phpactor language-server` (JSON-RPC over stdio),
|
||||
the open-source PHP language server. It integrates with the existing
|
||||
`LspClient` infrastructure without changes to the transport layer.
|
||||
|
||||
|
||||
Supported entities
|
||||
------------------
|
||||
| PHP construct | Entity kind | sexp tag | Notes |
|
||||
|---------------|-------------|----------|-------|
|
||||
| `class Foo` | class | `cls` | abstract modifier, heritage |
|
||||
| `interface Foo` | interface | `ifc` | auto-abstract |
|
||||
| `trait Foo` | trait | `trt` | auto-abstract; usable via `use` |
|
||||
| `enum Foo` (PHP 8.1) | enum | `enm` | backed enums (`: string/int`) |
|
||||
| `function foo()` | function | `fn` | top-level functions only |
|
||||
| `#[Attribute]` | decorator | `:deco` | PHP 8 attributes on classes/fns |
|
||||
| `class { use TraitX; }` | trait usage | edge | `calls-api` to trait module |
|
||||
| `__construct(Foo $f)` | DI dependency | edge | `calls-api` to service module |
|
||||
|
||||
|
||||
Edge types for PHP
|
||||
------------------
|
||||
Same model as other languages:
|
||||
- `imports` -- resolved via PSR-4 from `use` statements
|
||||
- `calls-api` -- implements/extends + trait usage + constructor DI
|
||||
- `data-flow` -- to modules with validation (Symfony Validator, Laravel)
|
||||
- `refs` -- entity-level cross-file references
|
||||
- `calls` (LSP only) -- precise function-to-function call edges
|
||||
|
||||
|
||||
Skipped / out of scope (v1)
|
||||
---------------------------
|
||||
- **Blade templates** (`.blade.php`) -- skipped like `.d.ts`
|
||||
- **Anonymous classes** (`new class { ... }`) -- no name, no ID
|
||||
- **Trait conflict resolution** (`insteadof` / `as`) -- edges on all used traits
|
||||
- **Dynamic imports** (`include`, `require`) -- not tracked
|
||||
- **Runtime service discovery** -- no container introspection
|
||||
- **`use function` / `use const`** -- tracked as imports but not as entity refs
|
||||
|
||||
|
||||
File discovery
|
||||
--------------
|
||||
- `.php` extension (excluding `.blade.php`)
|
||||
- `vendor/` and `var/` directories are skipped (composer deps, Symfony cache)
|
||||
- `bootstrap/` and `storage/` are NOT skipped (may contain real source)
|
||||
- `composer.json` defines PSR-4 autoload rules and LSP project boundaries
|
||||
|
||||
|
||||
Fallback behavior
|
||||
-----------------
|
||||
When `tree-sitter-php` is not installed:
|
||||
- `parse_php_regex` handles classes, interfaces, traits, enums (with heritage)
|
||||
- No PHP 8 attribute extraction
|
||||
- No constructor DI extraction
|
||||
- Method extraction is best-effort (regex within a window)
|
||||
- Trait usage inside class bodies IS detected (regex)
|
||||
- The graph is still usable; precision improves when `tree-sitter-php` is added
|
||||
|
||||
When `phpactor` is not installed:
|
||||
- `--lsp` mode skips PHP projects with a warning
|
||||
- AST-based edges (imports, calls-api, refs) are unaffected
|
||||
- Only `calls` (LSP call-hierarchy) edges are missing
|
||||
|
||||
|
||||
Acceptance criteria
|
||||
-------------------
|
||||
1. Given a Laravel/Symfony project with `composer.json`, when running
|
||||
`build_belief_map.py`, then `(node ... php ...)` facts are emitted for
|
||||
each `.php` file with correct PSR-4-resolved `imports` edges.
|
||||
|
||||
2. Given a class implementing an interface, when parsing, then a
|
||||
`calls-api` edge connects them.
|
||||
|
||||
3. Given a class using a trait (`use TraitX;`), when parsing, then a
|
||||
`calls-api` edge connects the class module to the trait module.
|
||||
|
||||
4. Given a class with constructor type-hints, when parsing, then
|
||||
`calls-api` edges connect the class to each dependency module.
|
||||
|
||||
5. Given PHP 8 attributes (`#[AsService]`), when parsing with
|
||||
tree-sitter-php, then the attribute name appears in `:deco`.
|
||||
|
||||
6. Given `composer.json` with `autoload.psr-4`, when resolving imports,
|
||||
then `use App\Service\Foo` resolves to `src/Service/Foo.php`.
|
||||
|
||||
7. Given `tree-sitter-php` is not installed, when parsing, then
|
||||
`parse_php_regex` produces visible entities (reduced precision).
|
||||
|
||||
8. `belief_search.py analyze <php-class-id>` returns boundary/deps/rdeps
|
||||
without modifications to the query engine.
|
||||
@@ -15,7 +15,7 @@ This skill bundles a belief-map query engine and graph builders for architecture
|
||||
* Architecture-layer and boundary-violation checks
|
||||
* Naming-convention (invariant) checks
|
||||
* Call-chain and data-flow tracing
|
||||
* Cross-language analysis (Python, TypeScript, TSX)
|
||||
* Cross-language analysis (Python, TypeScript, TSX, PHP)
|
||||
* Infrastructure topology from Kustomize, Helm, and Terraform
|
||||
|
||||
---
|
||||
@@ -71,7 +71,7 @@ All output is S-expression facts, one per line.
|
||||
| `(entity-def cls <mod> <Name> <line>)` | Type/class definition |
|
||||
| `(violation <src> <tgt> :src-layer :tgt-layer)` | Architecture violation |
|
||||
|
||||
`:lang` values: `py` (Python), `ts` (TypeScript), `tsx` (TSX).
|
||||
`:lang` values: `py` (Python), `ts` (TypeScript), `tsx` (TSX), `php` (PHP).
|
||||
|
||||
---
|
||||
|
||||
@@ -79,7 +79,7 @@ All output is S-expression facts, one per line.
|
||||
|
||||
1. **Locate or build the map**: check for `.belief_map.sexp`; if absent, build it
|
||||
```bash
|
||||
pip3 install tree-sitter tree-sitter-typescript tree-sitter-python
|
||||
pip3 install tree-sitter tree-sitter-typescript tree-sitter-python tree-sitter-php
|
||||
python3 scripts/build_belief_map.py # incremental ~5s
|
||||
python3 scripts/build_belief_map.py --full # full rebuild ~30s
|
||||
```
|
||||
|
||||
22
SKILL.md
22
SKILL.md
@@ -27,11 +27,15 @@ Locate the belief map. If absent, build it:
|
||||
|
||||
```bash
|
||||
ls .belief_map.sexp belief_map.sexp 2>/dev/null
|
||||
# Build: pip3 install tree-sitter tree-sitter-typescript tree-sitter-python
|
||||
# Build: pip3 install tree-sitter tree-sitter-typescript tree-sitter-python tree-sitter-php
|
||||
python3 scripts/build_belief_map.py # incremental ~5s
|
||||
python3 scripts/build_belief_map.py --full # full rebuild ~30s
|
||||
```
|
||||
|
||||
Supports **Python**, **TypeScript/TSX**, and **PHP** (.php, PSR-4 autoload via
|
||||
composer.json, PHP 8.1 enums, traits, PHP 8 attributes, Symfony/Laravel DI).
|
||||
Blade templates (.blade.php) are skipped.
|
||||
|
||||
## Mandatory Workflow: Query Before Code
|
||||
|
||||
For non-trivial code work, run a belief-map query before `rg`, `find`, `sed`,
|
||||
@@ -134,7 +138,7 @@ Key patterns:
|
||||
(result-count 42)
|
||||
```
|
||||
|
||||
**`:lang` values**: `py` = Python (snake_case), `ts` = TypeScript (camelCase), `tsx` = TSX (PascalCase).
|
||||
**`:lang` values**: `py` = Python (snake_case), `ts` = TypeScript (camelCase), `tsx` = TSX (PascalCase), `php` = PHP (PascalCase classes, snake_case functions).
|
||||
|
||||
## Scheme Query Language
|
||||
|
||||
@@ -233,6 +237,20 @@ python3 scripts/build_belief_map.py --lsp # with LSP call hierarchy (~1-5min)
|
||||
|
||||
Rebuild after structural changes (add/remove files, change imports, add classes).
|
||||
|
||||
**LSP servers** (optional, for `--lsp`):
|
||||
- TypeScript: `typescript-language-server`
|
||||
- Python: `pyright-langserver`
|
||||
- PHP: `phpactor` (`composer global require phpactor/phpactor`)
|
||||
|
||||
**PHP-specific notes**:
|
||||
- PSR-4 autoload resolution reads `composer.json` (`autoload.psr-4`).
|
||||
- Trait usage (`use TraitX;` inside class body) creates `calls-api` edges.
|
||||
- Constructor type-hints create `calls-api` edges (Symfony/Laravel DI).
|
||||
- PHP 8 attributes (`#[AsService]`) are captured as entity decorators.
|
||||
- `vendor/` and `var/` are skipped; `.blade.php` templates are skipped.
|
||||
- If `tree-sitter-php` is not installed, a regex fallback parser is used
|
||||
(reduced precision: no PHP 8 attributes, no constructor DI, coarser methods).
|
||||
|
||||
## Infrastructure Topology
|
||||
|
||||
Extract service topology from Kustomize, Helm, and Terraform files. This script requires `PyYAML` (`pip3 install pyyaml`).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# S-expression Output Notation
|
||||
|
||||
Every output line is a self-contained fact. Each fact that references a module carries `:lang` (py/ts/tsx).
|
||||
Every output line is a self-contained fact. Each fact that references a module carries `:lang` (py/ts/tsx/php).
|
||||
|
||||
**Module ID**: relative path from workspace root, no extension, no `/index`. Example: `drive/modules/drive_db/api/drive_db_api.ts` -> `drive/modules/drive_db/api/drive_db_api`.
|
||||
|
||||
@@ -13,6 +13,7 @@ Every output line is a self-contained fact. Each fact that references a module c
|
||||
(ifc <mod> <name> <line> :eid <uuid> (:methods m1 m2))
|
||||
(typ <mod> <name> <line> :eid <uuid>)
|
||||
(enm <mod> <name> <line> :eid <uuid>)
|
||||
(trt <mod> <name> <line> :eid <uuid> (:methods m1 m2))
|
||||
(imports <src> <tgt> [:via-base] [:lsp])
|
||||
(calls-api <src> <tgt> :via-ifc [:lsp])
|
||||
(data-flow <src> <tgt> [:validated])
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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