diff --git a/.agents/designs/php-support.md b/.agents/designs/php-support.md new file mode 100644 index 0000000..4b18359 --- /dev/null +++ b/.agents/designs/php-support.md @@ -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 ` returns boundary/deps/rdeps + without modifications to the query engine. diff --git a/README.md b/README.md index 27022e8..a32fa7e 100644 --- a/README.md +++ b/README.md @@ -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 )` | Type/class definition | | `(violation :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 ``` diff --git a/SKILL.md b/SKILL.md index 571682d..3b647aa 100644 --- a/SKILL.md +++ b/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`). diff --git a/references/sexp-notation.md b/references/sexp-notation.md index 1f74f08..3c9d335 100644 --- a/references/sexp-notation.md +++ b/references/sexp-notation.md @@ -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 :eid (:methods m1 m2)) (typ :eid ) (enm :eid ) +(trt :eid (:methods m1 m2)) (imports [:via-base] [:lsp]) (calls-api :via-ifc [:lsp]) (data-flow [:validated]) diff --git a/scripts/build_belief_map.py b/scripts/build_belief_map.py index 5c54b14..858d1e1 100755 --- a/scripts/build_belief_map.py +++ b/scripts/build_belief_map.py @@ -14,16 +14,21 @@ Parsers - **TypeScript/TSX**: tree-sitter (``tree-sitter-typescript``). Handles decorators, heritage, all methods, object literals, arrow functions. - **Python**: stdlib ``ast``. Full class/function/import extraction. +- **PHP**: tree-sitter (``tree-sitter-php``). Handles classes, interfaces, + traits, PHP 8.1 enums, attributes (``#[...]``), heritage, trait usage, + and Symfony/Laravel constructor DI. Falls back to regex parser if + ``tree-sitter-php`` is unavailable. Modes ----- **Default** (fast, ~5-8s for full workspace): - Parses all .py/.ts/.tsx files, builds import/reference/data-flow edges. + Parses all .py/.ts/.tsx/.php files, builds import/reference/data-flow edges. **LSP-enhanced** (``--lsp``, ~1-5min): - After default pass, starts ``typescript-language-server`` and - ``pyright-langserver`` per project to query call hierarchy and - references. Adds precise ``calls`` edges and ``:lsp`` annotations. + After default pass, starts ``typescript-language-server``, + ``pyright-langserver``, and ``phpactor`` per project to query call + hierarchy and references. Adds precise ``calls`` edges and ``:lsp`` + annotations. Edge Types ---------- @@ -49,9 +54,10 @@ Requirements ------------ :: - pip3 install tree-sitter tree-sitter-typescript tree-sitter-python + pip3 install tree-sitter tree-sitter-typescript tree-sitter-python tree-sitter-php -Optional for ``--lsp``: ``typescript-language-server``, ``pyright-langserver``. +Optional for ``--lsp``: ``typescript-language-server``, ``pyright-langserver``, +``phpactor`` (``composer global require phpactor/phpactor``). Usage ----- @@ -86,15 +92,45 @@ from tree_sitter import Language as _TsLanguage, Parser as _TsParser _TS_LANG = _TsLanguage(_tst.language_typescript()) _TSX_LANG = _TsLanguage(_tst.language_tsx()) +# Optional tree-sitter-php grammar. If unavailable, parse_php() falls back to +# a regex-based parser (see parse_php_regex). PHP files remain visible in the +# graph, but with reduced entity/edge precision. +try: + import tree_sitter_php as _tsp + + _PHP_LANG = _TsLanguage(_tsp.language_php()) +except (ImportError, AttributeError): + _PHP_LANG = None + # --------------------------------------------------------------------------- # Config # --------------------------------------------------------------------------- SKIP_DIRS = { - "node_modules", ".venv", "venv", "__pycache__", ".git", "dist", - ".next", ".turbo", "build", "coverage", ".tox", "site-packages", - ".mypy_cache", ".pytest_cache", ".ruff_cache", "target", - ".worktrees", ".ralphy-worktrees", ".ralphy-sandboxes", + "node_modules", + ".venv", + "venv", + "__pycache__", + ".git", + "dist", + ".next", + ".turbo", + "build", + "coverage", + ".tox", + "site-packages", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + "target", + ".worktrees", + ".ralphy-worktrees", + ".ralphy-sandboxes", + # PHP: vendor = composer dependencies, var = Symfony cache/logs. + # (Laravel storage/ and bootstrap/ are NOT skipped -- they may contain + # real source like bootstrap/app.php.) + "vendor", + "var", } CACHE_FILE = ".belief_map_cache.json" @@ -121,14 +157,15 @@ KEBAB_CASE_RE = re.compile(r"^[a-z][a-z0-9-]*$") # Data types # --------------------------------------------------------------------------- + @dataclass class Entity: name: str - kind: str # class, function, interface, type, enum, method, decorator + kind: str # class, function, interface, type, enum, trait, method, decorator line: int methods: list[str] # method names (for classes) decorators: list[str] # decorator names - bases: list[str] # parent classes / implemented interfaces + bases: list[str] # parent classes / implemented interfaces / used traits def to_dict(self) -> dict: d: dict = {"name": self.name, "kind": self.kind, "line": self.line} @@ -144,9 +181,10 @@ class Entity: @dataclass class ImportedName: """A single name imported from another module.""" - local_name: str # name used locally - original_name: str # name in source module ("default" for default imports) - module: str # raw import specifier + + local_name: str # name used locally + original_name: str # name in source module ("default" for default imports) + module: str # raw import specifier def to_dict(self) -> dict: return { @@ -170,26 +208,31 @@ class FileResult: purpose: str naming_convention: str has_validation: bool - entities: list[dict] # Entity.to_dict() results - imported_names: list[dict] # ImportedName.to_dict() results - exported_names: list[str] # names exported from this file + entities: list[dict] # Entity.to_dict() results + imported_names: list[dict] # ImportedName.to_dict() results + exported_names: list[str] # names exported from this file + traits_used: list[str] = field( + default_factory=list + ) # PHP: trait names used inside class bodies @dataclass class LspEdge: """An edge discovered by LSP analysis.""" - source: str # "node_id::entity_name" or "node_id" - target: str # "node_id::entity_name" or "node_id" - edge_type: str # "CALLS", "REFERENCES" + + source: str # "node_id::entity_name" or "node_id" + target: str # "node_id::entity_name" or "node_id" + edge_type: str # "CALLS", "REFERENCES" metadata: dict = field(default_factory=dict) @dataclass class ProjectGroup: """A group of files belonging to the same LSP project.""" - root: str # absolute path to project root - language: str # "typescript" or "python" - config_file: str # path to tsconfig.json or pyrightconfig.json + + root: str # absolute path to project root + language: str # "typescript" or "python" + config_file: str # path to tsconfig.json or pyrightconfig.json files: list[FileResult] = field(default_factory=list) @@ -197,6 +240,7 @@ class ProjectGroup: # Helpers # --------------------------------------------------------------------------- + def file_hash(path: str) -> str: try: st = os.stat(path) @@ -242,11 +286,13 @@ def _fuzzy_suffix_match(name: str, suffix: str, max_dist: int = 1) -> bool: """Check if the tail of `name` fuzzy-matches `suffix` within edit distance.""" if len(suffix) < 6: return False # too short for fuzzy -- "Store"/"Storey" false positives - tail = name[-len(suffix) - max_dist:] # grab suffix + slack for insertions + tail = name[-len(suffix) - max_dist :] # grab suffix + slack for insertions return _levenshtein(tail.lower(), suffix.lower()) <= max_dist -def infer_purpose(path: str, content: str, language: str, entities: Optional[list] = None) -> str: +def infer_purpose( + path: str, content: str, language: str, entities: Optional[list] = None +) -> str: """Infer architectural purpose from path, content, and entity names.""" parts = [] rel = path.lower() @@ -291,6 +337,41 @@ def infer_purpose(path: str, content: str, language: str, entities: Optional[lis elif "/api-client/" in rel or "/api_client/" in rel: parts.append("API client") + # PHP-specific path conventions (Laravel / Symfony) + if language == "php": + if ( + "/Http/Controllers/" in rel + or "/Controller/" in rel + or "/Controllers/" in rel + ): + parts.append("API controller") + elif "/Http/Middleware/" in rel or "/Middleware/" in rel: + parts.append("middleware") + elif "/Models/" in rel or "/Entity/" in rel or "/Entities/" in rel: + parts.append("domain logic") + elif "/Services/" in rel: + parts.append("service") + elif "/Repositories/" in rel: + parts.append("repository") + elif "/Resources/" in rel: + parts.append("API resource") + elif "/Requests/" in rel: + parts.append("request validation") + elif "/Providers/" in rel: + parts.append("service provider") + elif "/Console/Commands/" in rel or "/Command/" in rel: + parts.append("command") + elif "/Events/" in rel: + parts.append("event") + elif "/Listeners/" in rel: + parts.append("event listener") + elif "/Jobs/" in rel: + parts.append("worker job") + elif "/Migrations/" in rel or "/database/migrations" in rel: + parts.append("database migration") + elif "/routes/" in rel or "/config/" in rel: + parts.append("configuration") + # Docstring extraction head = content[:2048] if language == "python": @@ -397,7 +478,10 @@ def infer_purpose(path: str, content: str, language: str, entities: Optional[lis # GoF Creational if "factory" not in joined_check: - if re.search(r"class \w*Factory|def (?:create|make|build)_\w+.*->|def get_\w+.*factory", head): + if re.search( + r"class \w*Factory|def (?:create|make|build)_\w+.*->|def get_\w+.*factory", + head, + ): parts.append("Factory pattern") if "strategy" not in joined_check: if re.search(r"class \w*Strategy|strategy_map|STRATEGIES", head): @@ -406,7 +490,9 @@ def infer_purpose(path: str, content: str, language: str, entities: Optional[lis if re.search(r"class \w+Builder\b", head): parts.append("Builder pattern") if "singleton" not in joined_check: - if re.search(r"getInstance\s*\(|_instance\s*[:=]|private\s+static\s+instance", head): + if re.search( + r"getInstance\s*\(|_instance\s*[:=]|private\s+static\s+instance", head + ): parts.append("Singleton pattern") # GoF Structural @@ -451,7 +537,9 @@ def infer_purpose(path: str, content: str, language: str, entities: Optional[lis if re.search(r"class \w+UnitOfWork\b", head): parts.append("Unit of Work") if "domainevent" not in joined_check.replace(" ", ""): - if re.search(r"class \w+DomainEvent\b|class \w+Event\b(?!Emitter|Handler|Listener)", head): + if re.search( + r"class \w+DomainEvent\b|class \w+Event\b(?!Emitter|Handler|Listener)", head + ): parts.append("domain event") if "identitymap" not in joined_check.replace(" ", ""): if re.search(r"class \w+IdentityMap\b", head): @@ -462,15 +550,48 @@ def infer_purpose(path: str, content: str, language: str, entities: Optional[lis # NestJS decorators -- only label when entity-name heuristics didn't # provide a more specific purpose (controller, service, gateway, etc.) - _specific_kws = {"controller", "service", "repository", "guard", "gateway", - "adapter", "provider", "processor", "registry", "agent", - "resolver", "scheduler", "worker", "store", "handler", - "interceptor", "pipe", "listener", "subscriber", - "dispatcher", "emitter", "factory", "builder", "observer", - "strategy", "command", "mediator", "visitor", "proxy", - "facade", "composite", "specification", "aggregate", - "policy", "plugin", "manager", "client", "connector", - "sdk", "api client"} + _specific_kws = { + "controller", + "service", + "repository", + "guard", + "gateway", + "adapter", + "provider", + "processor", + "registry", + "agent", + "resolver", + "scheduler", + "worker", + "store", + "handler", + "interceptor", + "pipe", + "listener", + "subscriber", + "dispatcher", + "emitter", + "factory", + "builder", + "observer", + "strategy", + "command", + "mediator", + "visitor", + "proxy", + "facade", + "composite", + "specification", + "aggregate", + "policy", + "plugin", + "manager", + "client", + "connector", + "sdk", + "api client", + } joined_after = " ".join(parts).lower() has_specific = any(kw in joined_after for kw in _specific_kws) if not has_specific: @@ -499,7 +620,12 @@ def infer_purpose(path: str, content: str, language: str, entities: Optional[lis parts.append("data transfer object") elif stem.endswith("mapper") or stem.endswith("mappers"): parts.append("data mapper") - elif stem.endswith("helpers") or stem.endswith("helper") or stem.endswith("utils") or stem.endswith("util"): + elif ( + stem.endswith("helpers") + or stem.endswith("helper") + or stem.endswith("utils") + or stem.endswith("util") + ): parts.append("utility helpers") elif stem.endswith("constants") or stem.endswith("const"): parts.append("constants") @@ -513,7 +639,12 @@ def infer_purpose(path: str, content: str, language: str, entities: Optional[lis parts.append("data model") elif stem.endswith("entities") or stem.endswith("entity"): parts.append("domain entity") - elif stem.endswith("errors") or stem.endswith("error") or stem.endswith("exceptions") or stem.endswith("exception"): + elif ( + stem.endswith("errors") + or stem.endswith("error") + or stem.endswith("exceptions") + or stem.endswith("exception") + ): parts.append("error definitions") elif stem.endswith("factories") or stem.endswith("factory"): parts.append("factory") @@ -623,24 +754,42 @@ def infer_purpose(path: str, content: str, language: str, entities: Optional[lis # --- Fuzzy stem fallback (distance <= 1, stems >= 4 chars) --- _STEM_SUFFIX_MAP = [ - ("controller", "API controller"), ("service", "service"), - ("repository", "repository"), ("gateway", "gateway"), - ("registry", "registry"), ("agent", "agent"), - ("store", "state store"), ("factory", "factory"), - ("strategy", "Strategy pattern"), ("observer", "Observer pattern"), - ("builder", "Builder pattern"), ("command", "Command pattern"), - ("mediator", "Mediator pattern"), ("visitor", "Visitor pattern"), - ("facade", "Facade pattern"), ("proxy", "Proxy pattern"), - ("composite", "Composite pattern"), ("aggregate", "aggregate root"), - ("specification", "Specification pattern"), ("adapter", "adapter"), - ("provider", "provider"), ("processor", "processor"), - ("handler", "event handler"), ("listener", "event listener"), - ("middleware", "middleware"), ("interceptor", "interceptor"), - ("scheduler", "scheduler"), ("worker", "worker"), - ("dispatcher", "dispatcher"), ("subscriber", "subscriber"), - ("resolver", "resolver"), ("manager", "manager"), - ("connector", "connector"), ("client", "client"), - ("policy", "policy"), ("plugin", "plugin"), + ("controller", "API controller"), + ("service", "service"), + ("repository", "repository"), + ("gateway", "gateway"), + ("registry", "registry"), + ("agent", "agent"), + ("store", "state store"), + ("factory", "factory"), + ("strategy", "Strategy pattern"), + ("observer", "Observer pattern"), + ("builder", "Builder pattern"), + ("command", "Command pattern"), + ("mediator", "Mediator pattern"), + ("visitor", "Visitor pattern"), + ("facade", "Facade pattern"), + ("proxy", "Proxy pattern"), + ("composite", "Composite pattern"), + ("aggregate", "aggregate root"), + ("specification", "Specification pattern"), + ("adapter", "adapter"), + ("provider", "provider"), + ("processor", "processor"), + ("handler", "event handler"), + ("listener", "event listener"), + ("middleware", "middleware"), + ("interceptor", "interceptor"), + ("scheduler", "scheduler"), + ("worker", "worker"), + ("dispatcher", "dispatcher"), + ("subscriber", "subscriber"), + ("resolver", "resolver"), + ("manager", "manager"), + ("connector", "connector"), + ("client", "client"), + ("policy", "policy"), + ("plugin", "plugin"), ] if not parts and len(stem) >= 5: for sfx, purpose_label in _STEM_SUFFIX_MAP: @@ -661,7 +810,10 @@ def infer_purpose(path: str, content: str, language: str, entities: Optional[lis r"^(?:class|function|const|let|var)\s", head4k, re.MULTILINE ): parts.append("barrel re-exports") - elif re.search(r"export\s+(?:default\s+)?(?:const\s+)?(?:config|CONFIG|configuration)\b", head4k): + elif re.search( + r"export\s+(?:default\s+)?(?:const\s+)?(?:config|CONFIG|configuration)\b", + head4k, + ): parts.append("configuration") elif ( language == "python" @@ -695,17 +847,29 @@ def detect_validation(content: str, language: str) -> bool: return True # Typed dataclass schemas used as validation boundary (frozen=True or type-annotated) if re.search(r"@dataclass", content[:4096]) and re.search( - r"frozen\s*=\s*True|def\s+__post_init__|:\s*(?:str|int|float|bool|list|dict|Optional)", content[:4096], + r"frozen\s*=\s*True|def\s+__post_init__|:\s*(?:str|int|float|bool|list|dict|Optional)", + content[:4096], ): return True # TypedDict / NamedTuple schemas if re.search(r"TypedDict|NamedTuple", content[:4096]): return True return False - return bool(re.search( - r"\.safeParse|\.parse\(|zod\.|z\.|Joi\.|yup\.|ajv|validateSchema", - content[:4096], - )) + if language == "php": + # Symfony Validator, Laravel FormRequest/Validator, Respect\Validation, webmozart/assert + return bool( + re.search( + r"->validate\(|Validator::|FormRequest|Constraint|Respect::|" + r"webmozart.*Assert|assert\(|Validation::|Validated", + content[:4096], + ) + ) + return bool( + re.search( + r"\.safeParse|\.parse\(|zod\.|z\.|Joi\.|yup\.|ajv|validateSchema", + content[:4096], + ) + ) def path_to_uri(path: str) -> str: @@ -735,6 +899,7 @@ def find_entity_column(content: str, entity_name: str, line_1based: int) -> int: # Python parser # --------------------------------------------------------------------------- + def parse_python(path: str, content: str, repo: str, mtime: float) -> FileResult: imports: list[str] = [] exports_abstract: list[str] = [] @@ -753,41 +918,45 @@ def parse_python(path: str, content: str, repo: str, mtime: float) -> FileResult for alias in node.names: imports.append(alias.name) local = alias.asname or alias.name.split(".")[-1] - imported_names.append(ImportedName( - local_name=local, - original_name=alias.name, - module=alias.name, - )) + imported_names.append( + ImportedName( + local_name=local, + original_name=alias.name, + module=alias.name, + ) + ) elif isinstance(node, ast.ImportFrom): mod = node.module or "" if mod: imports.append(mod) for alias in node.names: local = alias.asname or alias.name - imported_names.append(ImportedName( - local_name=local, - original_name=alias.name, - module=mod, - )) + imported_names.append( + ImportedName( + local_name=local, + original_name=alias.name, + module=mod, + ) + ) # -- classes -- elif isinstance(node, ast.ClassDef): methods = [] - decorators = [ - _decorator_name(d) for d in node.decorator_list - ] + decorators = [_decorator_name(d) for d in node.decorator_list] for item in node.body: if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)): methods.append(item.name) bases_list = [_name_of(b) for b in node.bases if _name_of(b)] - entities.append(Entity( - name=node.name, - kind="class", - line=node.lineno, - methods=methods, - decorators=decorators, - bases=bases_list, - )) + entities.append( + Entity( + name=node.name, + kind="class", + line=node.lineno, + methods=methods, + decorators=decorators, + bases=bases_list, + ) + ) exported_names.append(node.name) extends.extend(bases_list) for b in bases_list: @@ -796,17 +965,17 @@ def parse_python(path: str, content: str, repo: str, mtime: float) -> FileResult # -- functions -- elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): - decorators = [ - _decorator_name(d) for d in node.decorator_list - ] - entities.append(Entity( - name=node.name, - kind="function", - line=node.lineno, - methods=[], - decorators=decorators, - bases=[], - )) + decorators = [_decorator_name(d) for d in node.decorator_list] + entities.append( + Entity( + name=node.name, + kind="function", + line=node.lineno, + methods=[], + decorators=decorators, + bases=[], + ) + ) exported_names.append(node.name) # -- top-level assignments (constants, type aliases) -- @@ -819,7 +988,8 @@ def parse_python(path: str, content: str, repo: str, mtime: float) -> FileResult # Fallback: regex for imports only for m in re.finditer( r"^\s*(?:from\s+([\w.]+)\s+import|import\s+([\w.]+))", - content, re.MULTILINE, + content, + re.MULTILINE, ): imp = m.group(1) or m.group(2) if imp: @@ -832,10 +1002,15 @@ def parse_python(path: str, content: str, repo: str, mtime: float) -> FileResult exports_abstract.append(name) return FileResult( - path=path, language="python", repo=repo, mtime=mtime, + path=path, + language="python", + repo=repo, + mtime=mtime, content_hash=file_hash(path), - imports=imports, exports_abstract=exports_abstract, - implements=implements, extends=extends, + imports=imports, + exports_abstract=exports_abstract, + implements=implements, + extends=extends, purpose=infer_purpose(path, content, "python", [e.to_dict() for e in entities]), naming_convention=detect_naming(path), has_validation=detect_validation(content, "python"), @@ -867,6 +1042,7 @@ def _name_of(node: ast.expr) -> str: # TypeScript parser (tree-sitter) # --------------------------------------------------------------------------- + def _ts_get_decorators(node) -> list[str]: # type: ignore[no-untyped-def] """Extract @Decorator names from a node's preceding decorator nodes.""" decorators = [] @@ -909,9 +1085,13 @@ def _ts_get_methods(node) -> list[str]: # type: ignore[no-untyped-def] return methods skip = {"constructor", "if", "for", "while", "switch", "return"} for member in body.children: - if member.type in ("method_definition", "method_signature", - "public_field_definition", "property_signature", - "abstract_method_signature"): + if member.type in ( + "method_definition", + "method_signature", + "public_field_definition", + "property_signature", + "abstract_method_signature", + ): name_node = member.child_by_field_name("name") if name_node: name = name_node.text.decode("utf-8") @@ -921,9 +1101,17 @@ def _ts_get_methods(node) -> list[str]: # type: ignore[no-untyped-def] # NestJS decorators that imply constructor-parameter dependency injection. -_NESTJS_INJECTION_DECOS: frozenset[str] = frozenset({ - "Injectable", "Controller", "Module", "Guard", "Interceptor", "Pipe", "Resolver", -}) +_NESTJS_INJECTION_DECOS: frozenset[str] = frozenset( + { + "Injectable", + "Controller", + "Module", + "Guard", + "Interceptor", + "Pipe", + "Resolver", + } +) def _ts_get_constructor_injections(node) -> list[str]: # type: ignore[no-untyped-def] @@ -961,10 +1149,16 @@ def _ts_get_constructor_injections(node) -> list[str]: # type: ignore[no-untype args = dc.child_by_field_name("arguments") if args: for arg in args.children: - if arg.type in ("identifier", "string", "template_string"): + if arg.type in ( + "identifier", + "string", + "template_string", + ): text = arg.text if text: - token = text.decode("utf-8").strip("'\"`") + token = text.decode("utf-8").strip( + "'\"`" + ) if token and token[0].isupper(): injected.append(token) # Check type annotation on the parameter @@ -995,7 +1189,10 @@ def _ts_get_obj_keys(node) -> list[str]: # type: ignore[no-untyped-def] def parse_typescript_treesitter( - path: str, content: str, repo: str, mtime: float, + path: str, + content: str, + repo: str, + mtime: float, ) -> FileResult: """Parse a TypeScript file using tree-sitter for precise entity extraction.""" lang = _TSX_LANG if path.endswith(".tsx") else _TS_LANG @@ -1018,8 +1215,16 @@ def parse_typescript_treesitter( # The actual declaration is a child of export_statement. # Skip keywords, decorators, and punctuation to find the declaration. for child in node.children: - if child.type in ("export", "default", "comment", "decorator", - "{", "}", ",", ";"): + if child.type in ( + "export", + "default", + "comment", + "decorator", + "{", + "}", + ",", + ";", + ): continue target = child break @@ -1036,11 +1241,13 @@ def parse_typescript_treesitter( for ic in child.children: if ic.type == "identifier": # default import - imported_names.append(ImportedName( - local_name=ic.text.decode("utf-8"), - original_name="default", - module=specifier, - )) + imported_names.append( + ImportedName( + local_name=ic.text.decode("utf-8"), + original_name="default", + module=specifier, + ) + ) elif ic.type == "named_imports": for spec in ic.children: if spec.type == "import_specifier": @@ -1048,12 +1255,18 @@ def parse_typescript_treesitter( alias_node = spec.child_by_field_name("alias") if name_node: orig = name_node.text.decode("utf-8") - local = alias_node.text.decode("utf-8") if alias_node else orig - imported_names.append(ImportedName( - local_name=local, - original_name=orig, - module=specifier, - )) + local = ( + alias_node.text.decode("utf-8") + if alias_node + else orig + ) + imported_names.append( + ImportedName( + local_name=local, + original_name=orig, + module=specifier, + ) + ) # -- Class -- elif target.type == "class_declaration": @@ -1076,10 +1289,16 @@ def parse_typescript_treesitter( is_abstract = any(c.type == "abstract" for c in target.children) if is_abstract: exports_abstract.append(name) - entities.append(Entity( - name=name, kind="class", line=line, - methods=methods, decorators=decorators, bases=ext + impl, - )) + entities.append( + Entity( + name=name, + kind="class", + line=line, + methods=methods, + decorators=decorators, + bases=ext + impl, + ) + ) exported_names.append(name) # -- Interface -- @@ -1091,10 +1310,16 @@ def parse_typescript_treesitter( line = target.start_point[0] + 1 methods = _ts_get_methods(target) exports_abstract.append(name) - entities.append(Entity( - name=name, kind="interface", line=line, - methods=methods, decorators=[], bases=[], - )) + entities.append( + Entity( + name=name, + kind="interface", + line=line, + methods=methods, + decorators=[], + bases=[], + ) + ) exported_names.append(name) # -- Type alias -- @@ -1104,10 +1329,16 @@ def parse_typescript_treesitter( continue name = name_node.text.decode("utf-8") line = target.start_point[0] + 1 - entities.append(Entity( - name=name, kind="type", line=line, - methods=[], decorators=[], bases=[], - )) + entities.append( + Entity( + name=name, + kind="type", + line=line, + methods=[], + decorators=[], + bases=[], + ) + ) exported_names.append(name) # -- Enum -- @@ -1117,10 +1348,16 @@ def parse_typescript_treesitter( continue name = name_node.text.decode("utf-8") line = target.start_point[0] + 1 - entities.append(Entity( - name=name, kind="enum", line=line, - methods=[], decorators=[], bases=[], - )) + entities.append( + Entity( + name=name, + kind="enum", + line=line, + methods=[], + decorators=[], + bases=[], + ) + ) exported_names.append(name) # -- Function -- @@ -1131,10 +1368,16 @@ def parse_typescript_treesitter( name = name_node.text.decode("utf-8") line = target.start_point[0] + 1 decorators = _ts_get_decorators(node if is_export else target) - entities.append(Entity( - name=name, kind="function", line=line, - methods=[], decorators=decorators, bases=[], - )) + entities.append( + Entity( + name=name, + kind="function", + line=line, + methods=[], + decorators=decorators, + bases=[], + ) + ) exported_names.append(name) # -- Lexical declaration (const/let) -- @@ -1153,37 +1396,68 @@ def parse_typescript_treesitter( # Object literal: extract keys as methods if value.type == "object": obj_keys = _ts_get_obj_keys(value) - entities.append(Entity( - name=name, kind="function", line=line, - methods=obj_keys, decorators=[], bases=[], - )) + entities.append( + Entity( + name=name, + kind="function", + line=line, + methods=obj_keys, + decorators=[], + bases=[], + ) + ) # new Constructor(...) elif value.type == "new_expression": - entities.append(Entity( - name=name, kind="function", line=line, - methods=[], decorators=[], bases=[], - )) + entities.append( + Entity( + name=name, + kind="function", + line=line, + methods=[], + decorators=[], + bases=[], + ) + ) # Arrow function or call expression elif value.type in ("arrow_function", "call_expression"): - entities.append(Entity( - name=name, kind="function", line=line, - methods=[], decorators=[], bases=[], - )) + entities.append( + Entity( + name=name, + kind="function", + line=line, + methods=[], + decorators=[], + bases=[], + ) + ) # Other const (e.g., z.object, string literal, etc.) else: - entities.append(Entity( - name=name, kind="function", line=line, - methods=[], decorators=[], bases=[], - )) + entities.append( + Entity( + name=name, + kind="function", + line=line, + methods=[], + decorators=[], + bases=[], + ) + ) exported_names.append(name) file_lang = "tsx" if path.endswith(".tsx") else "typescript" return FileResult( - path=path, language=file_lang, repo=repo, mtime=mtime, + path=path, + language=file_lang, + repo=repo, + mtime=mtime, content_hash=file_hash(path), - imports=imports, exports_abstract=exports_abstract, - implements=implements_list, extends=extends_list, - purpose=infer_purpose(path, content, "typescript", [e.to_dict() for e in entities]), + imports=imports, + exports_abstract=exports_abstract, + implements=implements_list, + extends=extends_list, + purpose=infer_purpose( + path, content, "typescript", [e.to_dict() for e in entities] + ), naming_convention=detect_naming(path), has_validation=detect_validation(content, "typescript"), entities=[e.to_dict() for e in entities], @@ -1196,6 +1470,545 @@ def parse_typescript_treesitter( # Worker # --------------------------------------------------------------------------- + +# --------------------------------------------------------------------------- +# PHP parser (tree-sitter, with regex fallback) +# --------------------------------------------------------------------------- + +# PHP 8 attribute syntax: #[Attribute], #[Attribute(args)], #[Route('/path')] +_PHP_ATTRIBUTE_RE = re.compile(r"#\[\s*([\w\\]+)") +# PHP namespace declaration: namespace App\Service; +_PHP_NAMESPACE_RE = re.compile(r"^\s*namespace\s+([\w\\]+)\s*;", re.MULTILINE) +# PHP class/interface/trait/enum declarations with optional modifiers and base list. +_PHP_DECL_RE = re.compile( + r"^\s*(?:(abstract|final)\s+)?" + r"(class|interface|trait|enum)\s+(\w+)" + r"(?::\s*(?:int|string)\s*)?" # backed enum type: enum Status: string + r"(?:\s+extends\s+([\w\\,\s]+?))?" + r"(?:\s+implements\s+([\w\\,\s]+?))?" + r"\s*\{", + re.MULTILINE, +) +# PHP top-level function declarations: function name(...) { ... } +_PHP_FUNCTION_RE = re.compile(r"^\s*function\s+(\w+)\s*\(", re.MULTILINE) +# PHP use statements (imports): use Foo\Bar; use Foo\Bar as Baz; use Foo\Bar\{Qux, Quux}; +_PHP_USE_RE = re.compile( + r"^\s*use\s+((?:function|const)?\s*[\w\\]+)" + r"(?:\s*\\\s*\{([^}]*)\})?" + r"(?:\s+as\s+(\w+))?\s*;", + re.MULTILINE, +) +# PHP class method signatures (simple, for method-name extraction). +_PHP_METHOD_RE = re.compile( + r"^\s*(?:(?:public|protected|private|static|final|abstract)\s+)*" + r"function\s+(\w+)\s*\(", + re.MULTILINE, +) +# PHP trait usage inside class body: use TraitA, TraitB; +_PHP_TRAIT_USE_RE = re.compile(r"^\s*use\s+([\w\\,\s]+?);", re.MULTILINE) + + +def _php_normalize_name(name: str) -> str: + """Strip leading backslash and return short class name from an FQCN. + + `App\\Service\\Foo` -> `Foo`. Bare names pass through. + """ + name = name.strip().lstrip("\\") + if "\\" in name: + return name.rsplit("\\", 1)[-1] + return name + + +def _php_split_names(csv: str) -> list[str]: + """Split 'A, B, \\C\\D' into short names, dropping empties.""" + out = [] + for part in (csv or "").split(","): + part = part.strip() + if part: + out.append(_php_normalize_name(part)) + return out + + +def parse_php_regex(path: str, content: str, repo: str, mtime: float) -> FileResult: + """Fallback PHP parser using regex. Used when tree-sitter-php is unavailable. + + Extracts namespaces, use statements, class/interface/trait/enum declarations + (with heritage and methods), and top-level functions. Less precise than the + tree-sitter parser: no PHP 8 attributes, no constructor DI, and method + extraction is best-effort. Still produces enough facts to make PHP files + visible in the graph. + """ + imports: list[str] = [] + exported_names: list[str] = [] + exported_abstract: list[str] = [] + implements_list: list[str] = [] + extends_list: list[str] = [] + entities: list[Entity] = [] + imported_names: list[ImportedName] = [] + traits_used: list[str] = [] + + # Namespace + ns_match = _PHP_NAMESPACE_RE.search(content) + + # Use statements (imports) + for m in _PHP_USE_RE.finditer(content): + spec = m.group(1).strip() + # Skip 'use function X' / 'use const X' prefixes for module import edges + is_func = spec.lower().startswith("function ") + is_const = spec.lower().startswith("const ") + if is_func or is_const: + spec = spec.split(None, 1)[1] + group = m.group(2) + alias = m.group(3) + if group: + # use Foo\Bar\{Baz, Qux}; + base = spec.rstrip("\\") + for piece in group.split(","): + piece = piece.strip() + if not piece: + continue + fqcn = base + "\\" + piece if base else piece + local = alias or piece + imports.append(fqcn) + imported_names.append( + ImportedName( + local_name=local, + original_name=piece, + module=fqcn, + ) + ) + else: + short = _php_normalize_name(spec) + local = alias or short + imports.append(spec) + imported_names.append( + ImportedName( + local_name=local, + original_name=short, + module=spec, + ) + ) + + # Declarations (class/interface/trait/enum) + for m in _PHP_DECL_RE.finditer(content): + modifier, kind_kw, name, extends_csv, implements_csv = m.groups() + line = content.count("\n", 0, m.start()) + 1 + ext = _php_split_names(extends_csv) + impl = _php_split_names(implements_csv) + extends_list.extend(ext) + implements_list.extend(impl) + + kind_map = { + "class": "class", + "interface": "interface", + "trait": "trait", + "enum": "enum", + } + kind = kind_map.get(kind_kw, "class") + + # Extract methods: search the body bounded by this declaration. Without + # tree-sitter we cannot find the closing brace reliably, so we scan + # method signatures within a window (~next 500 lines). + body_start = m.end() + window = content[body_start : body_start + 20000] + methods = _PHP_METHOD_RE.findall(window) + # Trait usage inside class body + if kind == "class": + for tm in _PHP_TRAIT_USE_RE.finditer(window): + for tname in _php_split_names(tm.group(1)): + if tname not in traits_used: + traits_used.append(tname) + # Deduplicate preserving order + seen: set[str] = set() + methods_clean: list[str] = [] + for meth in methods: + if meth not in seen: + seen.add(meth) + methods_clean.append(meth) + + is_abstract = modifier == "abstract" or kind in ("interface", "trait") + if is_abstract: + exported_abstract.append(name) + entities.append( + Entity( + name=name, + kind=kind, + line=line, + methods=methods_clean, + decorators=[], + bases=ext + impl, + ) + ) + exported_names.append(name) + + # Top-level functions (regex is coarse; may catch methods too -- dedupe by + # only accepting function names not already in entities). + decl_names = {e.name for e in entities} + for m in _PHP_FUNCTION_RE.finditer(content): + fname = m.group(1) + if fname in decl_names: + continue + line = content.count("\n", 0, m.start()) + 1 + entities.append( + Entity( + name=fname, + kind="function", + line=line, + methods=[], + decorators=[], + bases=[], + ) + ) + exported_names.append(fname) + decl_names.add(fname) + + return FileResult( + path=path, + language="php", + repo=repo, + mtime=mtime, + content_hash=file_hash(path), + imports=imports, + exports_abstract=exported_abstract, + implements=implements_list, + extends=extends_list, + purpose=infer_purpose(path, content, "php", [e.to_dict() for e in entities]), + naming_convention=detect_naming(path), + has_validation=detect_validation(content, "php"), + entities=[e.to_dict() for e in entities], + imported_names=[n.to_dict() for n in imported_names], + exported_names=exported_names, + traits_used=traits_used, + ) + + +# --------------------------------------------------------------------------- +# tree-sitter PHP parser helpers +# --------------------------------------------------------------------------- + + +def _php_text(node) -> str: # type: ignore[no-untyped-def] + """Decode a tree-sitter node's text, or return '' if the node is None.""" + return node.text.decode("utf-8") if node and node.text else "" + + +def _php_short(text: str) -> str: + r"""`App\Service\Foo` -> `Foo`, strip leading backslash. Bare names pass through.""" + text = text.strip().lstrip("\\") + if "\\" in text: + return text.rsplit("\\", 1)[-1] + return text + + +def _php_get_attributes(node) -> list[str]: # type: ignore[no-untyped-def] + """Extract PHP 8 #[Attribute] names from attribute_list siblings of a declaration.""" + decos: list[str] = [] + for child in node.children: + if child.type != "attribute_list": + continue + for group in child.children: + if group.type == "attribute_group": + for attr in group.children: + if attr.type == "attribute": + name_node = attr.child_by_field_name("name") + if name_node: + decos.append(_php_short(_php_text(name_node))) + else: + # Fallback: first name/identifier child + for c in attr.children: + if c.type in ("name", "qualified_name"): + decos.append(_php_short(_php_text(c))) + break + return decos + + +def _php_collect_qualified_names(node) -> list[str]: # type: ignore[no-untyped-def] + """Collect short names from all qualified_name/name children of `node`.""" + out: list[str] = [] + for child in node.children: + if child.type in ("qualified_name", "name"): + out.append(_php_short(_php_text(child))) + return out + + +def _php_get_heritage(node) -> tuple[list[str], list[str]]: # type: ignore[no-untyped-def] + """Extract (extends, implements) short names from base_clause / class_interface_clause.""" + extends: list[str] = [] + implements: list[str] = [] + for child in node.children: + if child.type == "base_clause": + extends.extend(_php_collect_qualified_names(child)) + elif child.type == "class_interface_clause": + implements.extend(_php_collect_qualified_names(child)) + return extends, implements + + +def _php_get_methods(body_node) -> list[str]: # type: ignore[no-untyped-def] + """Extract method names from a declaration_list / enum_declaration_list body.""" + methods: list[str] = [] + if not body_node: + return methods + skip = { + "__construct", + "__destruct", + "__get", + "__set", + "__call", + "__isset", + "__unset", + "__toString", + "__invoke", + "__clone", + "__serialize", + "__unserialize", + "__sleep", + "__wakeup", + "__set_state", + } + for member in body_node.children: + if member.type == "method_declaration": + name_node = member.child_by_field_name("name") + if name_node: + name = _php_text(name_node) + if name and name not in skip: + methods.append(name) + return methods + + +def _php_get_trait_uses(body_node) -> list[str]: # type: ignore[no-untyped-def] + """Extract trait names from `use TraitA, TraitB;` inside a class body.""" + traits: list[str] = [] + if not body_node: + return traits + for member in body_node.children: + if member.type != "use_declaration": + continue + for child in member.children: + if child.type in ("qualified_name", "name"): + traits.append(_php_short(_php_text(child))) + elif child.type == "namespace_name": + # A bare namespace_name (uncommon in trait use) + traits.append(_php_short(_php_text(child))) + return traits + + +def _php_get_constructor_di(node) -> list[str]: # type: ignore[no-untyped-def] + """Extract PascalCase type-hints from constructor parameters (Symfony/Laravel DI). + + Mirrors `_ts_get_constructor_injections`: typed constructor params declare + service dependencies. Only PascalCase names are returned (primitives filtered). + """ + injected: list[str] = [] + body = node.child_by_field_name("body") + if not body: + return injected + for member in body.children: + if member.type != "method_declaration": + continue + name_node = member.child_by_field_name("name") + if not name_node or _php_text(name_node) != "__construct": + continue + params = member.child_by_field_name("parameters") + if not params: + continue + for param in params.children: + if param.type not in ( + "simple_parameter", + "property_promotion_parameter", + "variadic_parameter", + ): + continue + # Type node may be: named_type, primitive_type, optional_type, or null. + type_node = None + for c in param.children: + if c.type in ("named_type", "optional_type"): + type_node = c + break + if not type_node: + continue + # optional_type wraps named_type; named_type wraps name/qualified_name. + short = "" + for tc in type_node.children: + if tc.type in ("named_type", "qualified_name", "name"): + txt = _php_text(tc) + if txt: + short = _php_short(txt) + break + if short and short[:1].isupper(): + injected.append(short) + return injected + + +def parse_php_treesitter( + path: str, + content: str, + repo: str, + mtime: float, +) -> FileResult: + """Parse a PHP file using tree-sitter for precise entity extraction. + + Handles classes, interfaces, traits, PHP 8.1 enums, top-level functions, + PHP 8 attributes (#[...]), heritage (extends/implements), trait usage + inside class bodies, and Symfony/Laravel constructor dependency injection. + """ + parser = _TsParser(_PHP_LANG) + tree = parser.parse(content.encode("utf-8")) + root = tree.root_node + + imports: list[str] = [] + exported_abstract: list[str] = [] + implements_list: list[str] = [] + extends_list: list[str] = [] + entities: list[Entity] = [] + imported_names: list[ImportedName] = [] + exported_names: list[str] = [] + traits_used: list[str] = [] + + for node in root.children: + # -- Namespace declaration -- + if node.type == "namespace_definition": + continue # tracked via use-resolution; not an entity + + # -- Use declarations (imports) -- + if node.type == "namespace_use_declaration": + # Check for `use function X` / `use const X` modifier + kind = "class" + for child in node.children: + if child.type in ("function", "const") and _php_text(child) in ( + "function", + "const", + ): + kind = _php_text(child) + # The namespace_use_clause holds the qualified_name + optional alias. + for child in node.children: + if child.type != "namespace_use_clause": + continue + clause = child + qname = "" + alias = "" + for cc in clause.children: + if cc.type in ("qualified_name", "name") and not qname: + qname = _php_text(cc) + elif cc.type == "namespace_name" and not qname: + qname = _php_text(cc) + elif cc.type == "name" and clause.children[0].type in ("as",): + alias = _php_text(cc) + # Alias: look for `as NAME` + for cc in clause.children: + if cc.type == "as": + idx = list(clause.children).index(cc) + if idx + 1 < len(clause.children): + alias = _php_text(clause.children[idx + 1]) + if not qname: + continue + short = _php_short(qname) + local = alias or short + imports.append(qname) + imported_names.append( + ImportedName( + local_name=local, + original_name=short, + module=qname, + ) + ) + continue + + # -- Class / Interface / Trait / Enum declarations -- + decl_kind = { + "class_declaration": "class", + "interface_declaration": "interface", + "trait_declaration": "trait", + "enum_declaration": "enum", + }.get(node.type) + + if decl_kind is None: + # -- Top-level function -- + if node.type == "function_definition": + name_node = node.child_by_field_name("name") + if not name_node: + continue + name = _php_text(name_node) + entities.append( + Entity( + name=name, + kind="function", + line=node.start_point[0] + 1, + methods=[], + decorators=_php_get_attributes(node), + bases=[], + ) + ) + exported_names.append(name) + continue + + name_node = node.child_by_field_name("name") + if not name_node: + continue + name = _php_text(name_node) + line = node.start_point[0] + 1 + decorators = _php_get_attributes(node) + ext, impl = _php_get_heritage(node) + + # Body node differs: declaration_list (class/interface/trait) vs enum_declaration_list. + body_node = None + for child in node.children: + if child.type in ("declaration_list", "enum_declaration_list"): + body_node = child + break + + methods = _php_get_methods(body_node) + extends_list.extend(ext) + implements_list.extend(impl) + + # Trait usage inside class body -> CALLS_API edges (Phase 4). + class_traits = _php_get_trait_uses(body_node) + traits_used.extend(class_traits) + + # Abstract modifier for classes; interfaces/traits/enums are inherently abstract. + is_abstract = any( + c.type == "abstract_modifier" for c in node.children + ) or decl_kind in ("interface", "trait") + if is_abstract: + exported_abstract.append(name) + + # Constructor DI: collect type-hints from __construct params. Triggered for + # any class with a constructor (Symfony/Laravel auto-wiring) -- matches the + # NestJS heuristic in the TS parser. + if decl_kind == "class": + implements_list.extend(_php_get_constructor_di(node)) + + entities.append( + Entity( + name=name, + kind=decl_kind, + line=line, + methods=methods, + decorators=decorators, + bases=ext + impl, + ) + ) + exported_names.append(name) + + return FileResult( + path=path, + language="php", + repo=repo, + mtime=mtime, + content_hash=file_hash(path), + imports=imports, + exports_abstract=exported_abstract, + implements=implements_list, + extends=extends_list, + purpose=infer_purpose(path, content, "php", [e.to_dict() for e in entities]), + naming_convention=detect_naming(path), + has_validation=detect_validation(content, "php"), + entities=[e.to_dict() for e in entities], + imported_names=[n.to_dict() for n in imported_names], + exported_names=exported_names, + traits_used=traits_used, + ) + + def parse_file(args: tuple[str, str, str]) -> Optional[FileResult]: path, language, repo = args try: @@ -1204,6 +2017,14 @@ def parse_file(args: tuple[str, str, str]) -> Optional[FileResult]: content = f.read() if language == "python": return parse_python(path, content, repo, mtime) + if language == "php": + # tree-sitter-php if available, regex fallback otherwise. + if _PHP_LANG is not None: + try: + return parse_php_treesitter(path, content, repo, mtime) + except Exception: + return parse_php_regex(path, content, repo, mtime) + return parse_php_regex(path, content, repo, mtime) return parse_typescript_treesitter(path, content, repo, mtime) except Exception: return None @@ -1213,6 +2034,7 @@ def parse_file(args: tuple[str, str, str]) -> Optional[FileResult]: # File discovery # --------------------------------------------------------------------------- + def discover_files(root: str) -> list[tuple[str, str, str]]: results: list[tuple[str, str, str]] = [] root_path = Path(root).resolve() @@ -1225,15 +2047,24 @@ def discover_files(root: str) -> list[tuple[str, str, str]]: continue if entry.is_dir(follow_symlinks=False): child_repo = repo - if entry.name != ".git" and (dir_path / entry.name / ".git").exists(): + if ( + entry.name != ".git" + and (dir_path / entry.name / ".git").exists() + ): child_repo = entry.name _walk(dir_path / entry.name, child_repo) elif entry.is_file(follow_symlinks=False): name = entry.name if name.endswith(".py"): results.append((entry.path, "python", repo)) - elif (name.endswith(".ts") or name.endswith(".tsx")) and not name.endswith(".d.ts"): + elif ( + name.endswith(".ts") or name.endswith(".tsx") + ) and not name.endswith(".d.ts"): results.append((entry.path, "typescript", repo)) + elif name.endswith(".php") and not name.endswith(".blade.php"): + # Blade templates (.blade.php) are Laravel's HTML + # templating layer -- skip them like .d.ts. + results.append((entry.path, "php", repo)) except PermissionError: pass @@ -1245,6 +2076,7 @@ def discover_files(root: str) -> list[tuple[str, str, str]]: # Incremental cache # --------------------------------------------------------------------------- + def load_cache(root: str) -> dict: cache_path = os.path.join(root, CACHE_FILE) if os.path.exists(cache_path): @@ -1268,6 +2100,7 @@ def save_cache(root: str, cache: dict): # TypeScript path alias resolution (tsconfig.json "paths") # --------------------------------------------------------------------------- + def _load_ts_path_aliases(root: str) -> dict[str, str]: """Scan for tsconfig.json files and build a global alias -> absolute directory map. @@ -1283,10 +2116,11 @@ def _load_ts_path_aliases(root: str) -> dict[str, str]: for dirpath, dirnames, filenames in os.walk(root): # Skip node_modules, .git, dist, build dirnames[:] = [ - d for d in dirnames + d + for d in dirnames if d not in ("node_modules", ".git", "dist", "build", ".next", ".cache") ] - depth = dirpath[len(root):].count(os.sep) + depth = dirpath[len(root) :].count(os.sep) if depth > 5: dirnames.clear() continue @@ -1309,16 +2143,22 @@ def _load_ts_path_aliases(root: str) -> dict[str, str]: target = targets[0] # Take first target if not target.endswith("/*"): continue - target_dir = target[:-2] # "./*" -> ".", "../../sdk/src/*" -> "../../sdk/src" + target_dir = target[ + :-2 + ] # "./*" -> ".", "../../sdk/src/*" -> "../../sdk/src" abs_target = os.path.normpath(os.path.join(dirpath, target_dir)) # If target dir has a `src/` subdirectory, prefer it # (common monorepo pattern: @sdk/* -> ['./*'] maps to pkg root, # but actual source lives in pkg/src/) src_subdir = os.path.join(abs_target, "src") - effective_target = src_subdir if os.path.isdir(src_subdir) else abs_target + effective_target = ( + src_subdir if os.path.isdir(src_subdir) else abs_target + ) if os.path.isdir(abs_target): # Longer (more specific) prefixes take priority - if prefix not in alias_map or len(effective_target) > len(alias_map[prefix]): + if prefix not in alias_map or len(effective_target) > len( + alias_map[prefix] + ): alias_map[prefix] = effective_target except Exception: continue @@ -1342,16 +2182,26 @@ def _resolve_ts_path_alias( # Sort by prefix length descending so "@agent-upgraide/" matches before "@a/" for prefix in sorted(alias_map, key=len, reverse=True): if imp.startswith(prefix): - remainder = imp[len(prefix):] + remainder = imp[len(prefix) :] base_dir = alias_map[prefix] resolved = os.path.normpath(os.path.join(base_dir, remainder)) - for ext in ("", ".ts", ".tsx", "/index.ts", "/index.tsx", ".js", "/index.js"): + for ext in ( + "", + ".ts", + ".tsx", + "/index.ts", + "/index.tsx", + ".js", + "/index.js", + ): candidate = resolved + ext if candidate in path_to_id: return path_to_id[candidate] # path_to_id may use relative paths -- try relative form too try: - rel_candidate = os.path.relpath(candidate, root) if root else candidate + rel_candidate = ( + os.path.relpath(candidate, root) if root else candidate + ) except ValueError: rel_candidate = candidate if rel_candidate in path_to_id: @@ -1362,6 +2212,80 @@ def _resolve_ts_path_alias( # --------------------------------------------------------------------------- + +def _load_composer_autoload(root: str) -> dict[str, str]: + """Scan for composer.json files and build a PSR-4 namespace -> directory map. + + For each composer.json with ``autoload.psr-4`` (and ``autoload-dev.psr-4``), + resolves namespace prefixes like ``"App\\\\": "src/"`` to absolute directories. + + Returns ``{namespace_prefix: absolute_directory}`` e.g. + ``{"App\\\\": "/abs/path/to/src/"}``. + """ + import json as _json + + psr4_map: dict[str, str] = {} + for dirpath, dirnames, filenames in os.walk(root): + dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS] + depth = dirpath[len(root) :].count(os.sep) + if depth > 5: + dirnames.clear() + continue + for fname in filenames: + if fname != "composer.json": + continue + composer_path = os.path.join(dirpath, fname) + try: + with open(composer_path, "r") as f: + data = _json.load(f) + except (ValueError, OSError): + continue + autoload = data.get("autoload", {}) + autoload_dev = data.get("autoload-dev", {}) + for section in (autoload, autoload_dev): + psr4 = section.get("psr-4", {}) + for ns_prefix, paths in psr4.items(): + if not ns_prefix: + continue + # Normalize: ensure trailing backslash + ns = ns_prefix if ns_prefix.endswith("\\") else ns_prefix + "\\" + # paths may be a string or list of strings + if isinstance(paths, str): + paths = [paths] + for rel_dir in paths: + abs_dir = os.path.normpath(os.path.join(dirpath, rel_dir)) + if os.path.isdir(abs_dir): + psr4_map[ns] = abs_dir + return psr4_map + + +def _resolve_php_psr4( + fqcn: str, + path_to_id: dict[str, str], + psr4_map: dict[str, str], +) -> Optional[str]: + """Resolve a PHP FQCN to a module ID via PSR-4 autoload rules. + + Tries the longest matching namespace prefix first (e.g. ``App\\\\Service\\\\`` + before ``App\\\\``), replaces the prefix with the mapped directory, converts + remaining namespace segments to path separators, and appends ``.php``. + """ + fqcn = fqcn.strip().lstrip("\\") + if not psr4_map or not fqcn: + return None + # Sort prefixes by length descending for longest-match-first + for ns_prefix in sorted(psr4_map, key=len, reverse=True): + if fqcn.startswith(ns_prefix): + remainder = fqcn[len(ns_prefix) :] + base_dir = psr4_map[ns_prefix] + # PSR-4: remainder maps directly to filesystem path + rel_path = remainder.replace("\\", os.sep) + ".php" + candidate = os.path.normpath(os.path.join(base_dir, rel_path)) + if candidate in path_to_id: + return path_to_id[candidate] + return None + + def resolve_import( imp: str, source_path: str, @@ -1369,12 +2293,17 @@ def resolve_import( path_to_id: dict[str, str], language: str, ts_path_aliases: "dict[str, str] | None" = None, + php_psr4: "dict[str, str] | None" = None, ) -> Optional[str]: if language == "typescript": if not imp.startswith(".") and not imp.startswith("/"): # Try tsconfig paths alias resolution before giving up resolved_via_alias = _resolve_ts_path_alias( - imp, source_path, root, path_to_id, ts_path_aliases or {}, + imp, + source_path, + root, + path_to_id, + ts_path_aliases or {}, ) if resolved_via_alias: return resolved_via_alias @@ -1421,6 +2350,31 @@ def resolve_import( # so we already resolved to __init__.py above. The sub-module # resolution happens in _resolve_submodule_imports below. pass + elif language == "php": + # PSR-4 autoload resolution: FQCN -> file path via composer.json rules. + resolved_via_psr4 = _resolve_php_psr4(imp, path_to_id, php_psr4 or {}) + if resolved_via_psr4: + return resolved_via_psr4 + # Fallback: walk-up heuristic when no composer.json or no match. + # Convert namespace separators to path separators and try each base. + parts = [p for p in imp.replace("\\", "/").split("/") if p] + if not parts: + return None + bases: list[str] = [root, os.path.dirname(source_path)] + first_segment = parts[0] + cur = os.path.dirname(source_path) + while cur and cur >= root: + if os.path.isdir(os.path.join(cur, first_segment)): + if cur not in bases: + bases.append(cur) + parent = os.path.dirname(cur) + if parent == cur: + break + cur = parent + for base in bases: + candidate = os.path.normpath(os.path.join(base, *parts) + ".php") + if candidate in path_to_id: + return path_to_id[candidate] return None @@ -1443,8 +2397,16 @@ def _resolve_submodule_imports( # Build base dirs: walk up from source to root (same as resolve_import) all_bases: list[str] = [root, os.path.dirname(result.path)] for imp_name in result.imported_names: - mod = imp_name.get("module", "") if isinstance(imp_name, dict) else imp_name.module - orig = imp_name.get("original", "") if isinstance(imp_name, dict) else imp_name.original_name + mod = ( + imp_name.get("module", "") + if isinstance(imp_name, dict) + else imp_name.module + ) + orig = ( + imp_name.get("original", "") + if isinstance(imp_name, dict) + else imp_name.original_name + ) if not mod or not orig: continue parts = mod.split(".") @@ -1475,7 +2437,7 @@ def _resolve_submodule_imports( def make_node_id(path: str, root: str) -> str: rel = os.path.relpath(path, root).replace(os.sep, "/") - for ext in (".tsx", ".ts", ".py"): + for ext in (".tsx", ".ts", ".py", ".php"): if rel.endswith(ext): rel = rel[: -len(ext)] break @@ -1488,8 +2450,10 @@ def make_node_id(path: str, root: str) -> str: # Graph building # --------------------------------------------------------------------------- + def build_graph( - results: list[FileResult], root: str, + results: list[FileResult], + root: str, ) -> tuple[list[dict], list[dict]]: path_to_id: dict[str, str] = {} id_to_result: dict[str, FileResult] = {} @@ -1502,7 +2466,18 @@ def build_graph( # Load tsconfig.json path aliases for resolving @sdk/*, @agent-*/* etc. ts_path_aliases = _load_ts_path_aliases(root) if ts_path_aliases: - print(f"[belief-map] Loaded {len(ts_path_aliases)} TS path aliases", file=sys.stderr) + print( + f"[belief-map] Loaded {len(ts_path_aliases)} TS path aliases", + file=sys.stderr, + ) + + # Load composer.json PSR-4 autoload rules for PHP namespace resolution. + php_psr4 = _load_composer_autoload(root) + if php_psr4: + print( + f"[belief-map] Loaded {len(php_psr4)} PHP PSR-4 namespaces", + file=sys.stderr, + ) # Map: abstract/interface name -> defining node id abstract_providers: dict[str, str] = {} @@ -1516,9 +2491,21 @@ def build_graph( # the CALLS_API detection step, so they must be present in this map. for nid, r in id_to_result.items(): for ent in r.entities: - ent_kind = ent.get("kind", "") if isinstance(ent, dict) else getattr(ent, "kind", "") - ent_name = ent.get("name", "") if isinstance(ent, dict) else getattr(ent, "name", "") - if ent_kind == "interface" and ent_name and ent_name not in abstract_providers: + ent_kind = ( + ent.get("kind", "") + if isinstance(ent, dict) + else getattr(ent, "kind", "") + ) + ent_name = ( + ent.get("name", "") + if isinstance(ent, dict) + else getattr(ent, "name", "") + ) + if ( + ent_kind in ("interface", "trait") + and ent_name + and ent_name not in abstract_providers + ): abstract_providers[ent_name] = nid # Map: (node_id, entity_name) -> True (for cross-file entity lookups) @@ -1549,9 +2536,17 @@ def build_graph( # Gap 3 fix: enrich purpose with entity-name heuristics purpose = r.purpose - if r.entities and purpose in ("general module", "NestJS wiring module", "NestJS injectable"): + if r.entities and purpose in ( + "general module", + "NestJS wiring module", + "NestJS injectable", + ): enhanced = infer_purpose(r.path, "", r.language, r.entities) - if enhanced not in ("general module", "NestJS wiring module", "NestJS injectable"): + if enhanced not in ( + "general module", + "NestJS wiring module", + "NestJS injectable", + ): purpose = enhanced # -- build node with entities -- @@ -1579,28 +2574,34 @@ def build_graph( seen_edges.add(edge_key) target_r = id_to_result.get(target_id) via_base = bool(target_r and target_r.exports_abstract) - edges.append({ - "source": node_id, - "target": target_id, - "type": "IMPORTS", - "via_base": via_base, - }) + edges.append( + { + "source": node_id, + "target": target_id, + "type": "IMPORTS", + "via_base": via_base, + } + ) # -- module-level IMPORTS edges -- for imp in r.imports: - target_id = resolve_import(imp, r.path, root, path_to_id, r.language, ts_path_aliases) + target_id = resolve_import( + imp, r.path, root, path_to_id, r.language, ts_path_aliases, php_psr4 + ) if target_id and target_id != node_id: edge_key = (node_id, target_id, "IMPORTS") if edge_key not in seen_edges: seen_edges.add(edge_key) target_r = id_to_result.get(target_id) via_base = bool(target_r and target_r.exports_abstract) - edges.append({ - "source": node_id, - "target": target_id, - "type": "IMPORTS", - "via_base": via_base, - }) + edges.append( + { + "source": node_id, + "target": target_id, + "type": "IMPORTS", + "via_base": via_base, + } + ) # -- CALLS_API edges (implements/extends + entity :bases) -- # Source 1: FileResult.implements + extends (from heritage clauses) @@ -1618,38 +2619,58 @@ def build_graph( if provider_id and provider_id != node_id: calls_api_targets.add(provider_id) + # Source 3: PHP trait usage (use TraitX; inside class body). + # Traits are abstract providers -- classes that use them should have + # CALLS_API edges to the trait's defining module. + for trait_name in r.traits_used: + provider_id = abstract_providers.get(trait_name) + if provider_id and provider_id != node_id: + calls_api_targets.add(provider_id) + for provider_id in calls_api_targets: edge_key = (node_id, provider_id, "CALLS_API") if edge_key not in seen_edges: seen_edges.add(edge_key) - edges.append({ - "source": node_id, - "target": provider_id, - "type": "CALLS_API", - "via_interface": True, - }) + edges.append( + { + "source": node_id, + "target": provider_id, + "type": "CALLS_API", + "via_interface": True, + } + ) # -- DATA_FLOWS_TO edges -- for imp in r.imports: - target_id = resolve_import(imp, r.path, root, path_to_id, r.language, ts_path_aliases) + target_id = resolve_import( + imp, r.path, root, path_to_id, r.language, ts_path_aliases, php_psr4 + ) if target_id and target_id != node_id: target_r = id_to_result.get(target_id) if target_r and target_r.has_validation: edge_key = (node_id, target_id, "DATA_FLOWS_TO") if edge_key not in seen_edges: seen_edges.add(edge_key) - edges.append({ - "source": node_id, - "target": target_id, - "type": "DATA_FLOWS_TO", - "validated": True, - }) + edges.append( + { + "source": node_id, + "target": target_id, + "type": "DATA_FLOWS_TO", + "validated": True, + } + ) # -- entity-level REFERENCES edges -- # For each imported name, link source_entity -> target_entity for imp_name in r.imported_names: target_id = resolve_import( - imp_name["module"], r.path, root, path_to_id, r.language, ts_path_aliases, + imp_name["module"], + r.path, + root, + path_to_id, + r.language, + ts_path_aliases, + php_psr4, ) if not target_id or target_id == node_id: continue @@ -1687,27 +2708,37 @@ def build_graph( ) if edge_key not in seen_edges: seen_edges.add(edge_key) - edges.append({ - "source": f"{node_id}::{ref_ent}", - "target": f"{target_id}::{target_entity}", - "type": "REFERENCES", - }) + edges.append( + { + "source": f"{node_id}::{ref_ent}", + "target": f"{target_id}::{target_entity}", + "type": "REFERENCES", + } + ) else: # Module-level reference (not inside a specific entity) edge_key = (node_id, f"{target_id}::{target_entity}", "REFERENCES") if edge_key not in seen_edges: seen_edges.add(edge_key) - edges.append({ - "source": node_id, - "target": f"{target_id}::{target_entity}", - "type": "REFERENCES", - }) + edges.append( + { + "source": node_id, + "target": f"{target_id}::{target_entity}", + "type": "REFERENCES", + } + ) # -- Gap 2: BOUNDARY violation detection -- # -- HTTP_CALLS edges (cross-service HTTP client detection) -- # Detect modules with entities named *Client, *ClientService, *ClientApi, # *External, *Remote and infer the target service from the class name. - _HTTP_CLIENT_SUFFIXES = ("Client", "ClientService", "ClientApi", "External", "Remote") + _HTTP_CLIENT_SUFFIXES = ( + "Client", + "ClientService", + "ClientApi", + "External", + "Remote", + ) _REPO_NAMES = {r.repo for r in results} for node_id, r in id_to_result.items(): for ent in r.entities: @@ -1732,20 +2763,26 @@ def build_graph( if repo == r.repo: continue # skip self repo_lower = repo.lower() - if kebab == repo_lower or kebab.startswith(repo_lower) or repo_lower.startswith(kebab): + if ( + kebab == repo_lower + or kebab.startswith(repo_lower) + or repo_lower.startswith(kebab) + ): target_repo = repo break if target_repo: edge_key = (node_id, target_repo, "HTTP_CALLS") if edge_key not in seen_edges: seen_edges.add(edge_key) - edges.append({ - "source": node_id, - "target": target_repo, - "type": "HTTP_CALLS", - "transport": "http", - "client_entity": ent_name, - }) + edges.append( + { + "source": node_id, + "target": target_repo, + "type": "HTTP_CALLS", + "transport": "http", + "client_entity": ent_name, + } + ) # Domain modules should not import directly from infrastructure without # going through an interface (via_base). Flag violations. @@ -1761,21 +2798,31 @@ def build_graph( src_p = (src_purpose.purpose or "").lower() tgt_p = (tgt_purpose.purpose or "").lower() # Domain importing infrastructure without going through base class - if ("domain" in src_p and "infrastructure" in tgt_p and not edge.get("via_base")): - edges.append({ - "source": src, "target": tgt, - "type": "VIOLATION", - "rule": "boundary", - "detail": "domain imports infrastructure without abstract base", - }) + if "domain" in src_p and "infrastructure" in tgt_p and not edge.get("via_base"): + edges.append( + { + "source": src, + "target": tgt, + "type": "VIOLATION", + "rule": "boundary", + "detail": "domain imports infrastructure without abstract base", + } + ) # UI/component importing infrastructure directly - if ("ui component" in src_p and "infrastructure" in tgt_p and not edge.get("via_base")): - edges.append({ - "source": src, "target": tgt, - "type": "VIOLATION", - "rule": "boundary", - "detail": "UI imports infrastructure without abstract base", - }) + if ( + "ui component" in src_p + and "infrastructure" in tgt_p + and not edge.get("via_base") + ): + edges.append( + { + "source": src, + "target": tgt, + "type": "VIOLATION", + "rule": "boundary", + "detail": "UI imports infrastructure without abstract base", + } + ) return nodes, edges @@ -1800,6 +2847,7 @@ def _find_local_references(entities: list[dict], name: str) -> list[str]: # LSP Client -- JSON-RPC over stdio # --------------------------------------------------------------------------- + class LspClient: """ Minimal Language Server Protocol client using JSON-RPC over stdio. @@ -1877,7 +2925,7 @@ class LspClient: header_end = buf.index(b"\r\n\r\n") headers = buf[:header_end].decode("ascii") - buf = buf[header_end + 4:] + buf = buf[header_end + 4 :] content_length = 0 for line in headers.split("\r\n"): @@ -1937,7 +2985,9 @@ class LspClient: # -- Request / Notification helpers -- - def _request(self, method: str, params: Any, timeout: float = LSP_REQUEST_TIMEOUT) -> Any: + def _request( + self, method: str, params: Any, timeout: float = LSP_REQUEST_TIMEOUT + ) -> Any: """Send a request and wait for the response (blocking).""" if not self._alive: return None @@ -1965,7 +3015,9 @@ class LspClient: self._send({"jsonrpc": "2.0", "id": msg_id, "method": method, "params": params}) return msg_id - def _request_collect(self, msg_id: int, timeout: float = LSP_REQUEST_TIMEOUT) -> Any: + def _request_collect( + self, msg_id: int, timeout: float = LSP_REQUEST_TIMEOUT + ) -> Any: """Wait for a previously fired request's response.""" if msg_id < 0 or msg_id not in self._events: return None @@ -1987,25 +3039,31 @@ class LspClient: def initialize(self, root_uri: str) -> bool: """Send initialize request. Returns True on success.""" - result = self._request("initialize", { - "processId": os.getpid(), - "rootUri": root_uri, - "capabilities": { - "textDocument": { - "callHierarchy": { - "dynamicRegistration": False, - }, - "references": { - "dynamicRegistration": False, - }, - "synchronization": { - "didOpen": True, - "didClose": True, + result = self._request( + "initialize", + { + "processId": os.getpid(), + "rootUri": root_uri, + "capabilities": { + "textDocument": { + "callHierarchy": { + "dynamicRegistration": False, + }, + "references": { + "dynamicRegistration": False, + }, + "synchronization": { + "didOpen": True, + "didClose": True, + }, }, }, + "workspaceFolders": [ + {"uri": root_uri, "name": os.path.basename(uri_to_path(root_uri))} + ], }, - "workspaceFolders": [{"uri": root_uri, "name": os.path.basename(uri_to_path(root_uri))}], - }, timeout=LSP_INIT_TIMEOUT) + timeout=LSP_INIT_TIMEOUT, + ) if result is not None: self._notify("initialized", {}) return True @@ -2034,25 +3092,33 @@ class LspClient: def did_open(self, uri: str, language_id: str, text: str) -> None: """Notify server that a document was opened.""" - self._notify("textDocument/didOpen", { - "textDocument": { - "uri": uri, - "languageId": language_id, - "version": 1, - "text": text, + self._notify( + "textDocument/didOpen", + { + "textDocument": { + "uri": uri, + "languageId": language_id, + "version": 1, + "text": text, + }, }, - }) + ) def did_close(self, uri: str) -> None: """Notify server that a document was closed.""" - self._notify("textDocument/didClose", { - "textDocument": {"uri": uri}, - }) + self._notify( + "textDocument/didClose", + { + "textDocument": {"uri": uri}, + }, + ) # -- Query operations -- def document_symbols( - self, uri: str, timeout: float = LSP_REQUEST_TIMEOUT, + self, + uri: str, + timeout: float = LSP_REQUEST_TIMEOUT, ) -> list[dict]: """ Get all symbols in a document. Returns a tree of DocumentSymbol objects. @@ -2061,54 +3127,86 @@ class LspClient: SymbolKind: 5=Class, 6=Method, 11=Interface, 12=Function, 13=Variable, 10=Enum, 26=TypeParameter, 15=Namespace. """ - result = self._request("textDocument/documentSymbol", { - "textDocument": {"uri": uri}, - }, timeout=timeout) + result = self._request( + "textDocument/documentSymbol", + { + "textDocument": {"uri": uri}, + }, + timeout=timeout, + ) return result if isinstance(result, list) else [] def prepare_call_hierarchy( - self, uri: str, line: int, character: int, timeout: float = LSP_REQUEST_TIMEOUT, + self, + uri: str, + line: int, + character: int, + timeout: float = LSP_REQUEST_TIMEOUT, ) -> list[dict]: """ Prepare call hierarchy at position. Returns list of CallHierarchyItem. Line and character are 0-based (LSP convention). """ - result = self._request("textDocument/prepareCallHierarchy", { - "textDocument": {"uri": uri}, - "position": {"line": line, "character": character}, - }, timeout=timeout) + result = self._request( + "textDocument/prepareCallHierarchy", + { + "textDocument": {"uri": uri}, + "position": {"line": line, "character": character}, + }, + timeout=timeout, + ) return result if isinstance(result, list) else [] def outgoing_calls( - self, item: dict, timeout: float = LSP_REQUEST_TIMEOUT, + self, + item: dict, + timeout: float = LSP_REQUEST_TIMEOUT, ) -> list[dict]: """Get outgoing calls from a CallHierarchyItem.""" - result = self._request("callHierarchy/outgoingCalls", { - "item": item, - }, timeout=timeout) + result = self._request( + "callHierarchy/outgoingCalls", + { + "item": item, + }, + timeout=timeout, + ) return result if isinstance(result, list) else [] def incoming_calls( - self, item: dict, timeout: float = LSP_REQUEST_TIMEOUT, + self, + item: dict, + timeout: float = LSP_REQUEST_TIMEOUT, ) -> list[dict]: """Get incoming calls to a CallHierarchyItem.""" - result = self._request("callHierarchy/incomingCalls", { - "item": item, - }, timeout=timeout) + result = self._request( + "callHierarchy/incomingCalls", + { + "item": item, + }, + timeout=timeout, + ) return result if isinstance(result, list) else [] def references( - self, uri: str, line: int, character: int, timeout: float = LSP_REQUEST_TIMEOUT, + self, + uri: str, + line: int, + character: int, + timeout: float = LSP_REQUEST_TIMEOUT, ) -> list[dict]: """ Find all references to the symbol at position. Returns list of Location {uri, range}. """ - result = self._request("textDocument/references", { - "textDocument": {"uri": uri}, - "position": {"line": line, "character": character}, - "context": {"includeDeclaration": False}, - }, timeout=timeout) + result = self._request( + "textDocument/references", + { + "textDocument": {"uri": uri}, + "position": {"line": line, "character": character}, + "context": {"includeDeclaration": False}, + }, + timeout=timeout, + ) return result if isinstance(result, list) else [] @@ -2116,18 +3214,21 @@ class LspClient: # Project discovery for LSP # --------------------------------------------------------------------------- + def discover_projects(root: str, file_results: list[FileResult]) -> list[ProjectGroup]: """ Group files by LSP project root, detected by presence of config files. For TypeScript: looks for tsconfig.json. For Python: looks for pyrightconfig.json or pyproject.toml. + For PHP: looks for composer.json. Files that do not belong to any detected project are skipped. """ # Find all config files ts_configs: list[str] = [] py_configs: list[str] = [] + php_configs: list[str] = [] for dirpath, dirnames, filenames in os.walk(root): # Skip unwanted directories @@ -2138,10 +3239,13 @@ def discover_projects(root: str, file_results: list[FileResult]) -> list[Project ts_configs.append(full) elif fname in ("pyrightconfig.json", "pyproject.toml"): py_configs.append(full) + elif fname == "composer.json": + php_configs.append(full) # Sort configs by depth (deepest first) so files match the most specific project ts_configs.sort(key=lambda p: -p.count(os.sep)) py_configs.sort(key=lambda p: -p.count(os.sep)) + php_configs.sort(key=lambda p: -p.count(os.sep)) projects: list[ProjectGroup] = [] assigned_files: set[str] = set() @@ -2178,6 +3282,22 @@ def discover_projects(root: str, file_results: list[FileResult]) -> list[Project if proj.files: projects.append(proj) + # Assign PHP files to projects + for config in php_configs: + proj_root = os.path.dirname(config) + proj = ProjectGroup( + root=proj_root, + language="php", + config_file=config, + ) + for r in file_results: + if r.language == "php" and r.path not in assigned_files: + if r.path.startswith(proj_root + os.sep): + proj.files.append(r) + assigned_files.add(r.path) + if proj.files: + projects.append(proj) + return projects @@ -2185,10 +3305,13 @@ def discover_projects(root: str, file_results: list[FileResult]) -> list[Project # LSP analysis -- call hierarchy + references # --------------------------------------------------------------------------- + def _language_id(language: str, path: str) -> str: """Map internal language + file extension to LSP languageId.""" if language == "python": return "python" + if language == "php": + return "php" if path.endswith(".tsx"): return "typescriptreact" return "typescript" @@ -2211,7 +3334,7 @@ _SK_TO_KIND = { _SK_METHOD: "method", _SK_INTERFACE: "interface", _SK_FUNCTION: "function", - _SK_VARIABLE: "function", # const exports + _SK_VARIABLE: "function", # const exports _SK_CONSTANT: "function", _SK_ENUM: "enum", _SK_TYPE_PARAMETER: "type", @@ -2291,27 +3414,47 @@ def _process_doc_symbol( if child_kind in (_SK_METHOD, _SK_PROPERTY, _SK_FUNCTION) and child_name: methods.append(child_name) kind = "class" if kind_int == _SK_CLASS else "interface" - entities.append({ - "name": name, "kind": kind, "line": line, - "methods": methods, "bases": [], "decorators": [], - }) + entities.append( + { + "name": name, + "kind": kind, + "line": line, + "methods": methods, + "bases": [], + "decorators": [], + } + ) seen_names.add(name) exported_names.append(name) elif kind_int == _SK_ENUM: - members = [c.get("name", "") for c in children if c.get("kind") == _SK_ENUM_MEMBER] - entities.append({ - "name": name, "kind": "enum", "line": line, - "methods": members, "bases": [], "decorators": [], - }) + members = [ + c.get("name", "") for c in children if c.get("kind") == _SK_ENUM_MEMBER + ] + entities.append( + { + "name": name, + "kind": "enum", + "line": line, + "methods": members, + "bases": [], + "decorators": [], + } + ) seen_names.add(name) exported_names.append(name) elif kind_int == _SK_FUNCTION: - entities.append({ - "name": name, "kind": "function", "line": line, - "methods": [], "bases": [], "decorators": [], - }) + entities.append( + { + "name": name, + "kind": "function", + "line": line, + "methods": [], + "bases": [], + "decorators": [], + } + ) seen_names.add(name) exported_names.append(name) @@ -2326,18 +3469,30 @@ def _process_doc_symbol( child_name = child.get("name", "") if child_name: methods.append(child_name) - entities.append({ - "name": name, "kind": ent_kind, "line": line, - "methods": methods, "bases": [], "decorators": [], - }) + entities.append( + { + "name": name, + "kind": ent_kind, + "line": line, + "methods": methods, + "bases": [], + "decorators": [], + } + ) seen_names.add(name) exported_names.append(name) elif kind_int == _SK_TYPE_PARAMETER: - entities.append({ - "name": name, "kind": "type", "line": line, - "methods": [], "bases": [], "decorators": [], - }) + entities.append( + { + "name": name, + "kind": "type", + "line": line, + "methods": [], + "bases": [], + "decorators": [], + } + ) seen_names.add(name) exported_names.append(name) @@ -2360,10 +3515,16 @@ def _process_symbol_info( if not kind: return - entities.append({ - "name": name, "kind": kind, "line": line, - "methods": [], "bases": [], "decorators": [], - }) + entities.append( + { + "name": name, + "kind": kind, + "line": line, + "methods": [], + "bases": [], + "decorators": [], + } + ) seen_names.add(name) exported_names.append(name) @@ -2409,18 +3570,21 @@ def _drain_prepare_batch( if not target_node_id: continue from_ranges = [fr["start"]["line"] + 1 for fr in call.get("fromRanges", [])] - lsp_edges.append(LspEdge( - source=f"{node_id}::{ent_name}", - target=f"{target_node_id}::{target_name}", - edge_type="CALLS", - metadata={"from_lines": from_ranges, "lsp_verified": True}, - )) + lsp_edges.append( + LspEdge( + source=f"{node_id}::{ent_name}", + target=f"{target_node_id}::{target_name}", + edge_type="CALLS", + metadata={"from_lines": from_ranges, "lsp_verified": True}, + ) + ) calls_found += 1 return calls_found def _find_enclosing_entity( - entities: list[dict], line_0based: int, + entities: list[dict], + line_0based: int, ) -> Optional[str]: """ Find the entity that encloses the given 0-based line. @@ -2452,12 +3616,17 @@ def analyze_project_lsp( # Determine LSP server command if project.language == "typescript": cmd = ["typescript-language-server", "--stdio"] + elif project.language == "php": + # phpactor language-server: JSON-RPC over stdio. + # Install: composer global require phpactor/phpactor + cmd = ["phpactor", "language-server"] else: cmd = ["pyright-langserver", "--stdio"] # Check server availability server_bin = cmd[0] from shutil import which + if not which(server_bin): print(f" [lsp] WARNING: {server_bin} not found, skipping {project.root}") return [] @@ -2524,7 +3693,9 @@ def analyze_project_lsp( if old_ent["name"] == ent["name"]: if not ent.get("bases") and old_ent.get("bases"): ent["bases"] = old_ent["bases"] - if not ent.get("decorators") and old_ent.get("decorators"): + if not ent.get("decorators") and old_ent.get( + "decorators" + ): ent["decorators"] = old_ent["decorators"] break # Add regex entities not found by LSP (e.g., Zod schemas) @@ -2539,7 +3710,9 @@ def analyze_project_lsp( symbols_replaced += 1 if symbols_replaced: - print(f" [lsp] {proj_name}: merged entities in {symbols_replaced} files via documentSymbol") + print( + f" [lsp] {proj_name}: merged entities in {symbols_replaced} files via documentSymbol" + ) # -- Phase 2: Pipelined call hierarchy + references -- # Strategy: for each file, fire all prepareCallHierarchy and @@ -2576,33 +3749,48 @@ def analyze_project_lsp( ent_col = find_entity_column(content, ent_name, ent["line"]) if ent["kind"] in ("function", "class"): - mid = client._request_fire("textDocument/prepareCallHierarchy", { - "textDocument": {"uri": uri}, - "position": {"line": ent_line_0, "character": ent_col}, - }) + mid = client._request_fire( + "textDocument/prepareCallHierarchy", + { + "textDocument": {"uri": uri}, + "position": {"line": ent_line_0, "character": ent_col}, + }, + ) if mid >= 0: pending_prepare.append((mid, ent_name, ent)) if ent_name in r.exported_names: - mid = client._request_fire("textDocument/references", { - "textDocument": {"uri": uri}, - "position": {"line": ent_line_0, "character": ent_col}, - "context": {"includeDeclaration": False}, - }) + mid = client._request_fire( + "textDocument/references", + { + "textDocument": {"uri": uri}, + "position": {"line": ent_line_0, "character": ent_col}, + "context": {"includeDeclaration": False}, + }, + ) if mid >= 0: pending_refs.append((mid, ent_name, node_id)) # Drain pipeline if too many in-flight while len(pending_prepare) + len(pending_refs) >= PIPELINE_BATCH: calls_found += _drain_prepare_batch( - client, pending_prepare[:PIPELINE_BATCH // 2], - path_to_node_id, node_id, lsp_edges, request_timeout, + client, + pending_prepare[: PIPELINE_BATCH // 2], + path_to_node_id, + node_id, + lsp_edges, + request_timeout, ) - pending_prepare = pending_prepare[PIPELINE_BATCH // 2:] + pending_prepare = pending_prepare[PIPELINE_BATCH // 2 :] # -- Collect remaining prepareCallHierarchy results -- calls_found += _drain_prepare_batch( - client, pending_prepare, path_to_node_id, node_id, lsp_edges, request_timeout, + client, + pending_prepare, + path_to_node_id, + node_id, + lsp_edges, + request_timeout, ) # -- Collect references results -- @@ -2621,26 +3809,37 @@ def analyze_project_lsp( ref_entity_name = None if ref_result: ref_entity_name = _find_enclosing_entity( - ref_result.entities, ref_line_0, + ref_result.entities, + ref_line_0, ) - source_key = f"{ref_node_id}::{ref_entity_name}" if ref_entity_name else ref_node_id + source_key = ( + f"{ref_node_id}::{ref_entity_name}" + if ref_entity_name + else ref_node_id + ) target_key = f"{src_node_id}::{ent_name}" - lsp_edges.append(LspEdge( - source=source_key, - target=target_key, - edge_type="REFERENCES", - metadata={"lsp_verified": True, "ref_line": ref_line_0 + 1}, - )) + lsp_edges.append( + LspEdge( + source=source_key, + target=target_key, + edge_type="REFERENCES", + metadata={"lsp_verified": True, "ref_line": ref_line_0 + 1}, + ) + ) refs_found += 1 processed += len(r.entities) if processed % 200 < len(r.entities): - print(f" [lsp] {proj_name}: {processed}/{total_entities} entities " - f"({calls_found} calls, {refs_found} refs)") + print( + f" [lsp] {proj_name}: {processed}/{total_entities} entities " + f"({calls_found} calls, {refs_found} refs)" + ) client.did_close(uri) - print(f" [lsp] {proj_name}: done -- {calls_found} calls, {refs_found} refs from {processed} entities") + print( + f" [lsp] {proj_name}: done -- {calls_found} calls, {refs_found} refs from {processed} entities" + ) except Exception as exc: print(f" [lsp] ERROR in {proj_name}: {exc}") @@ -2654,6 +3853,7 @@ def analyze_project_lsp( # LSP merge -- integrate LSP edges into the graph # --------------------------------------------------------------------------- + def merge_lsp_edges( edges: list[dict], lsp_edges: list[LspEdge], @@ -2702,6 +3902,7 @@ def merge_lsp_edges( # LSP enrichment orchestrator # --------------------------------------------------------------------------- + def enrich_with_lsp( root: str, results: list[FileResult], @@ -2727,21 +3928,34 @@ def enrich_with_lsp( # Discover projects projects = discover_projects(root, results) if not projects: - print("[belief-map] No LSP-compatible projects found (no tsconfig.json or pyrightconfig.json)") + print( + "[belief-map] No LSP-compatible projects found " + "(no tsconfig.json, pyrightconfig.json, or composer.json)" + ) return nodes, edges ts_projects = [p for p in projects if p.language == "typescript"] py_projects = [p for p in projects if p.language == "python"] + php_projects = [p for p in projects if p.language == "php"] ts_files = sum(len(p.files) for p in ts_projects) py_files = sum(len(p.files) for p in py_projects) - print(f"[belief-map] LSP: {len(projects)} projects " - f"({len(ts_projects)} TS/{ts_files} files, {len(py_projects)} Py/{py_files} files)") + php_files = sum(len(p.files) for p in php_projects) + print( + f"[belief-map] LSP: {len(projects)} projects " + f"({len(ts_projects)} TS/{ts_files} files, " + f"{len(py_projects)} Py/{py_files} files, " + f"{len(php_projects)} PHP/{php_files} files)" + ) all_lsp_edges: list[LspEdge] = [] for project in projects: proj_edges = analyze_project_lsp( - project, root, path_to_node_id, node_id_to_result, request_timeout, + project, + root, + path_to_node_id, + node_id_to_result, + request_timeout, ) all_lsp_edges.extend(proj_edges) @@ -2763,6 +3977,7 @@ def enrich_with_lsp( # Output formatters # --------------------------------------------------------------------------- + def _sexp_escape(s: str) -> str: """Escape a string for S-expression output (double-quote delimited).""" return s.replace("\\", "\\\\").replace('"', '\\"') @@ -2778,6 +3993,7 @@ def _entity_id(module_path: str, entity_name: str) -> str: # Path trie for compressed path definitions # --------------------------------------------------------------------------- + def _build_path_tree(path_sid_pairs: list[tuple[str, str]]) -> list[str]: """ Build a compressed path trie and emit as indented S-expression tree. @@ -2919,7 +4135,9 @@ def write_sexp( base = prefix idx = 2 while prefix in _REPO_PREFIX.values(): - prefix = base + repo[idx:idx + 1] if idx < len(repo) else base + str(idx) + prefix = ( + base + repo[idx : idx + 1] if idx < len(repo) else base + str(idx) + ) idx += 1 _REPO_PREFIX[repo] = prefix _repo_counters[repo] = 0 @@ -2945,7 +4163,9 @@ def write_sexp( lines.append(f"; Belief Map") lines.append(f"; generated {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}") lines.append(f"; mode {mode}") - lines.append(f"; {total_files} files {len(sorted_nodes)} nodes {len(real_edges)} edges {len(violations)} violations") + lines.append( + f"; {total_files} files {len(sorted_nodes)} nodes {len(real_edges)} edges {len(violations)} violations" + ) lines.append("") # -- Path tree (compressed trie) -- @@ -2960,7 +4180,14 @@ def write_sexp( lines.append("; --- nodes ---") for node in sorted_nodes: sid = path_to_sid[node["id"]] - _LANG_SHORT = {"python": "py", "typescript": "ts", "tsx": "tsx", "jsx": "jsx", "javascript": "js"} + _LANG_SHORT = { + "python": "py", + "typescript": "ts", + "tsx": "tsx", + "jsx": "jsx", + "javascript": "js", + "php": "php", + } lang = _LANG_SHORT.get(node["language"], node["language"][:2]) purpose = _sexp_escape(node.get("purpose", "")) inv = node.get("invariant", {}) @@ -2979,8 +4206,12 @@ def write_sexp( line_num = ent.get("line", 0) eid = _entity_id(node["id"], name) kind_tag = { - "class": "cls", "function": "fn", "interface": "ifc", - "type": "typ", "enum": "enm", + "class": "cls", + "function": "fn", + "interface": "ifc", + "type": "typ", + "enum": "enm", + "trait": "trt", }.get(kind, kind[:3]) bases = ent.get("bases", []) @@ -3002,7 +4233,9 @@ def write_sexp( # -- Edges -- lines.append("; --- edges ---") - sorted_edges = sorted(real_edges, key=lambda e: (e["type"], e["source"], e["target"])) + sorted_edges = sorted( + real_edges, key=lambda e: (e["type"], e["source"], e["target"]) + ) for edge in sorted_edges: src = _pid(edge["source"]) tgt = _pid(edge["target"]) @@ -3050,6 +4283,7 @@ def write_sexp( # Main # --------------------------------------------------------------------------- + def main(): args = sys.argv[1:] full_rebuild = "--full" in args @@ -3065,7 +4299,10 @@ def main(): try: lsp_timeout = float(args[i + 1]) except ValueError: - print(f"Error: invalid --lsp-timeout value: {args[i + 1]}", file=sys.stderr) + print( + f"Error: invalid --lsp-timeout value: {args[i + 1]}", + file=sys.stderr, + ) sys.exit(1) i += 2 continue @@ -3085,7 +4322,11 @@ def main(): files = discover_files(root) py_count = sum(1 for _, l, _ in files if l == "python") ts_count = sum(1 for _, l, _ in files if l == "typescript") - print(f"[belief-map] Found {len(files)} source files ({py_count} py, {ts_count} ts)") + php_count = sum(1 for _, l, _ in files if l == "php") + print( + f"[belief-map] Found {len(files)} source files " + f"({py_count} py, {ts_count} ts, {php_count} php)" + ) cache = {} if full_rebuild else load_cache(root) to_parse: list[tuple[str, str, str]] = [] @@ -3103,7 +4344,9 @@ def main(): pass to_parse.append((path, lang, repo)) - print(f"[belief-map] Parsing {len(to_parse)} changed files ({len(cached_results)} cached)") + print( + f"[belief-map] Parsing {len(to_parse)} changed files ({len(cached_results)} cached)" + ) new_results: list[FileResult] = [] if to_parse: @@ -3137,6 +4380,7 @@ def main(): "entities": r.entities, "imported_names": r.imported_names, "exported_names": r.exported_names, + "traits_used": r.traits_used, }, } save_cache(root, new_cache) @@ -3145,12 +4389,16 @@ def main(): nodes, edges = build_graph(all_results, root) t_regex = time.monotonic() - t0 - print(f"[belief-map] Regex/AST pass: {t_regex:.2f}s -- {len(nodes)} nodes, {len(edges)} edges") + print( + f"[belief-map] Regex/AST pass: {t_regex:.2f}s -- {len(nodes)} nodes, {len(edges)} edges" + ) # LSP enrichment pass (if requested) if use_lsp: t_lsp_start = time.monotonic() - print(f"[belief-map] Starting LSP enrichment (timeout={lsp_timeout}s per request) ...") + print( + f"[belief-map] Starting LSP enrichment (timeout={lsp_timeout}s per request) ..." + ) nodes, edges = enrich_with_lsp(root, all_results, nodes, edges, lsp_timeout) t_lsp = time.monotonic() - t_lsp_start print(f"[belief-map] LSP pass: {t_lsp:.2f}s") @@ -3160,7 +4408,9 @@ def main(): write_sexp(output_path, nodes, edges, root, mode, len(all_results)) elapsed = time.monotonic() - t0 - print(f"[belief-map] Done in {elapsed:.2f}s -- {len(nodes)} nodes, {len(edges)} edges") + print( + f"[belief-map] Done in {elapsed:.2f}s -- {len(nodes)} nodes, {len(edges)} edges" + ) print(f"[belief-map] Output: {output_path}") # Stats @@ -3168,6 +4418,7 @@ def main(): print(f"[belief-map] Repos: {', '.join(repos)}") from collections import Counter + edge_types = Counter(e["type"] for e in edges) total_entities = sum(len(n.get("entities", [])) for n in nodes) lsp_verified = sum(1 for e in edges if e.get("lsp_verified")) diff --git a/tests/test_belief_search.py b/tests/test_belief_search.py index 90eedfe..d7db7cc 100644 --- a/tests/test_belief_search.py +++ b/tests/test_belief_search.py @@ -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) diff --git a/tests/test_build_belief_map_php.py b/tests/test_build_belief_map_php.py new file mode 100644 index 0000000..221e289 --- /dev/null +++ b/tests/test_build_belief_map_php.py @@ -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("""\ + 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("""\ + 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()