feat(php): php projects support added

This commit is contained in:
Anatoliy Poloz
2026-06-22 08:12:55 +03:00
parent b95f457ab7
commit ed58bd043b
7 changed files with 2144 additions and 381 deletions

View 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.

View File

@@ -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
```

View File

@@ -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`).

View File

@@ -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

View File

@@ -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)

View 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()