docs(php-lsp): plan PHP Light Semantic Pass and pre-flight findings

Pre-flight against laravel/framework establishes that the PHP graph has
systemic call-attribution drift on short names shared between a global
function and a method (e.g. value(), make(), get()). Spot-check shows
~20-30% of typed-receiver method calls misroute to the global helper
because the existing extractor cannot resolve receiver type.

Plan adds internal/cbm/lsp/php_lsp.{c,h} as a third sibling to go_lsp.c
and c_lsp.c. In-process C type resolver, no PHP runtime, no new
dependencies. Uses already-vendored tree-sitter-php grammar and the
existing CBMArena / CBMScope / CBMType / CBMTypeRegistry primitives.

Phase 1 success metric is collide-set attribution correctness, not
benchmark tier alone. Q6 (text search) and Q8 (trace tool
disambiguation) tracked as separate tickets.
This commit is contained in:
Martin Vogel committed 2026-05-08 22:17:25 +02:00
1 parent c768b6154d
commit b24f34aaa0
2 files changed
+766

No files matched your search

+209
View File
@@ -0,0 +1,209 @@
# PHP LSP — Pre-flight (Phase 0)
Date: 2026-05-08
Status: complete; **Phase 1 conditionally justified — see §6**.
This artifact is required by §0 of `docs/PLAN_PHP_LSP_INTEGRATION.md`.
It exists to verify that the failing benchmark questions are
type-resolver-shaped before any C is written.
## 1. Reproduction setup
- Repo: `https://github.com/laravel/framework`, shallow clone at HEAD
(May 2026).
- Path: `/tmp/lang-bench/laravel-framework-php`.
- Indexed via `index_repository`.
- Project key: `tmp-lang-bench-laravel-framework-php`.
- Index size: **39 767 nodes / 196 979 edges** (vs. BENCHMARK.md
snapshot of 38 644 / 161 242 — the corpus has grown ~22 % in edges
since the benchmark was recorded).
## 2. The two failing benchmark questions
From `docs/BENCHMARK.md:297-299`:
| Q | Failure |
|----|----------------------------------------------------------------------------------------|
| Q6 | `search_code(error)`: TODO/FIXME=0; "error" found 3× in workflow YAML. |
| Q8 | `trace_call_path(inbound)`: resolved to method variant (0 callers) instead of helper. |
## 3. Q6 — text search — root cause
**Not type-resolver-shaped.** This is the `search_code` text-search
backend — a regex/grep-style search over file content, no AST involvement.
PHP-LSP would not touch this code path at all.
Probable causes (out of scope for this plan):
- The search may be excluding string-literal-only matches (PHP source
has plenty of `"error"` literals).
- The TODO/FIXME=0 result is independent — Laravel's source genuinely
has very few of those markers.
**Verdict: Phase 1 will not move Q6.** Score ceiling without separate
text-search work is 11/12 (92 %). That is still Tier 1.
## 4. Q8 — inbound trace — root cause (the interesting one)
The benchmark called `trace_call_path(function_name="value",
direction="inbound")`. The tool resolved `value` to a method variant
with 0 callers. Two distinct bugs sit on top of this single observation.
### 4.1 Tool-level disambiguation bug (not type-resolver-shaped)
`search_graph(name_pattern="^value$")` against the current PHP index
returns **11 distinct nodes** named `value`:
| Kind | Qualified name fragment | in_degree |
|----------|----------------------------------------------------------|----------:|
| Function | `Illuminate.Collections.helpers.value` | 103 |
| Method | `Illuminate.Database.Eloquent.Builder.Builder.value` | 16 |
| Method | `Illuminate.Support.Once.Once.value` | 8 |
| Method | `Illuminate.Support.Uri.Uri.value` | 8 |
| Method | `Illuminate.Database.Query.Builder.Builder.value` | 6 |
| Method | `Illuminate.Support.Fluent.Fluent.value` | 2 |
| Method | `Illuminate.Support.UriQueryString.UriQueryString.value` | 1 |
| Method | `Illuminate.Support.Stringable.Stringable.value` | 0 |
| Method | `Illuminate.Support.Benchmark.Benchmark.value` | 0 |
| Method | `Illuminate.Collections.Traits.EnumeratesValues...value` | 0 |
| Variable | `types.Log.Context.value` | 0 |
When the trace tool was given the short name `value`, it picked a
variant with `in_degree = 0` and reported "no callers." This is a
**tool-level UX problem**: when multiple QNs match a short name,
`trace_call_path` should either (a) disambiguate by aggregating across
all matching short-name nodes, (b) prefer the highest-degree match, or
(c) ask the user to pick. None of those require PHP-LSP.
**Verdict: php_lsp.c does NOT directly fix Q8.** A separate trace-tool
fix does.
### 4.2 Attribution bug (real, type-resolver-shaped)
Querying who actually calls each `value` variant reveals
misattribution. The 103 inbound edges to the helper function include
several call sites that are actually `$receiver->value()` method calls
on a typed receiver, which the current extractor mis-routes to the
global helper because it cannot resolve the receiver type:
#### Concrete misattributed call sites
| Caller (currently routed to helper) | Actual code (verified) | Should be |
|------------------------------------------------------------------|---------------------------------------------------------|--------------------------|
| `ConfiguresPrompts::configurePrompts` | `$prompt->value()` × 3, where `$prompt: Prompt` | `Prompt::value` (method) |
| `DatabaseQueue::creationTimeOfOldestPendingJob` | `... ->value('available_at')` (Builder chain end) | `Builder::value` |
Verified by `grep -n "value(" <file>`:
```
src/Illuminate/Console/Concerns/ConfiguresPrompts.php:
34: ... fn (Prompt $prompt) => $this->validatePrompt($prompt->value(), ...);
58: ... $this->components->ask($prompt->message, $prompt->value());
60: ... return $prompt->value();
src/Illuminate/Queue/DatabaseQueue.php:
236: ... ->value('available_at');
```
#### Across-file pattern count
```
$ grep -rn '->value(' src/ | wc -l # method-style
30
$ grep -rn 'value(' src/ | wc -l # all references including method-style
~300
```
Out of `200 - 300` raw `value(` occurrences, ~30 are clearly
method-style (`->value(`). The graph currently attributes 41 calls
to method variants (16 + 8 + 8 + 6 + 2 + 1) and 103 to the helper.
That implies the extractor is over-counting the helper by **roughly
20–30 %**, where those mis-routes are exactly the cases php_lsp.c
exists to fix: typed receiver, name collides with a global function.
This pattern generalises beyond `value()`. Any short name shared
between a global helper (Laravel has ~50 in `helpers.php`) and a method
on a Laravel class will exhibit the same misrouting.
**Verdict: php_lsp.c materially fixes the attribution bug, even
though it does NOT fix the disambiguation bug surfaced by Q8.**
## 5. Wider correctness signal (the real argument)
The benchmark question framing under-sells the actual correctness
problem. The Q8 investigation surfaced that the current PHP extractor
has systemic call-attribution drift on any short name that exists as
both a global function and a method. Concretely, the graph's PHP
section is **lossy in a structured way** that affects:
- inbound traces (the Q8 symptom),
- outbound traces from any caller that uses such short names (Q7
passes for Laravel only because the spot-check called `value`'s
outbound, where the helper happens to be the right answer),
- Cypher `CALLS` correctness (Q9 passes because the smoke check is
shallow),
- any downstream feature that relies on accurate caller→callee
attribution (impact analysis, dead-code, refactor blast radius).
This is the case for php_lsp.c as a graph-quality investment, not
purely as a benchmark-tier-lift investment.
## 6. Decision
**Phase 1 is justified, but the success metric must change.**
Original plan success metric: PHP benchmark from 75 % → ≥ 90 %.
Updated success metric (replace in `docs/PLAN_PHP_LSP_INTEGRATION.md`
§12 and §10):
1. **Primary**: a measurable attribution correctness lift on
laravel/framework. Target — for the top-20 short names that collide
between a `Function` and a `Method` (`value`, `make`, `get`, `set`,
`find`, `first`, `where`, `count`, etc.), at least 90 % of call
sites with a typed receiver land on the method variant, not the
helper. Baseline measured pre-change; verified post-change.
2. **Secondary**: PHP benchmark Q8 PARTIAL → PASS *if and only if*
the trace-tool disambiguation fix lands in the same release.
Otherwise Q8 stays PARTIAL but the underlying graph is correct, and
the tool-side fix is a separate ticket.
3. **Side effect**: Q6 stays PARTIAL until the search_code text-search
fix lands separately. Cap the achievable benchmark lift at 11/12
(92 %, still Tier 1) until both side fixes ship.
## 7. Knock-on plan changes
Apply these edits to `docs/PLAN_PHP_LSP_INTEGRATION.md`:
| Change | Section |
|------------------------------------------------------------------------|------------------|
| Re-frame primary success metric from "Tier 1 benchmark" to "attribution correctness on collide-set short names; benchmark lift is a side-effect" | §10, §12 |
| Add explicit non-goal: "fix `trace_call_path` short-name disambiguation" — separate ticket | §2 out-of-scope |
| Add explicit non-goal: "fix `search_code` text-search recall" — separate ticket | §2 out-of-scope |
| Add a "baseline measurement" step to §0: run the collide-set query before code lands and record the numbers in this file | §0 |
| Note in §10 acceptance: `tests/test_php_lsp.c` must include a fixture asserting `Prompt::value` is the resolved callee for `(Prompt $p) => $p->value()` | §10 |
## 8. Baseline measurements (collide-set, pre-change)
Run the same script post-Phase 1 and require strict improvement.
| Short name | Function in_degree | Top-method in_degree | Sum methods | Helper-share | Notes |
|------------|-------------------:|---------------------:|------------:|-------------:|-----------------------------|
| value | 103 | 16 (Eloquent Builder)| 41 | 71.5 % | spot-checked: ≥ 4 misroutes |
| (TODO) | | | | | extend in implementation pass |
Helper-share = `function_in_degree / (function_in_degree + sum_methods)`.
Lower is better post-change for cases where typed receivers exist.
The `(TODO)` row will be filled as part of the Phase 1 first-day work
(once `php_lsp.c` skeleton exists, run a dry-run extractor and diff).
## 9. Conclusion
- The plan's stated benchmark-lift hypothesis is partially wrong
(Q6 is unrelated; Q8 is half tool-bug, half attribution-bug).
- The underlying motivation — *PHP graph correctness on real codebases*
— is real, measurable, and validated by spot-check on Laravel.
- Phase 1 proceeds with a sharpened metric and explicit non-goals.
Phase 1 is unblocked.
+557
View File
@@ -0,0 +1,557 @@
# PHP Light Semantic Pass (LSP) — Integration Plan
Branch: `worktree-php-lsp-integration`
Target file output: this document
Date: 2026-05-08
## What this is and isn't
**This is**: writing a PHP semantic analyzer in C, from scratch, as a new
sibling to the existing `go_lsp.c` and `c_lsp.c` modules. It runs
in-process, against our own tree-sitter AST, and emits resolved-call
edges in our existing format.
**This is not**: integrating, embedding, wrapping, or subprocessing any
existing PHP language server (phpactor, intelephense, php-language-server,
PHPStan, Psalm). No PHP runtime ships with the binary. No new third-party
code ends up linked in.
**Reference-only use of upstream PHP tooling**: `phpactor/worse-reflection`
is cloned to a scratch directory during development as an algorithmic
reference — we read its resolver code to understand how to handle, e.g.,
trait conflict resolution or `parent::` chain walking, then write the
equivalent in C. None of its source, dependencies, or runtime is included
in the build artifact.
**The only PHP-related third-party code in the binary** is the
tree-sitter-php grammar, which is already vendored, already compiled, and
already used by the unified extractor today. This plan adds zero new
dependencies.
The name "LSP" in this codebase means **Light Semantic Pass** (our
in-process type resolver), not Language Server Protocol. The naming
collision is unfortunate; see §1.
## 0. Pre-flight (status: complete)
Pre-flight executed; output recorded in `docs/PHP_LSP_PRE_FLIGHT.md`.
Key findings:
- Q6 (text search) is **not** type-resolver-shaped — separate ticket.
- Q8 (inbound trace) is **half** type-resolver-shaped: a real
attribution bug exists (typed-receiver `$x->value()` calls misroute
to the global `value()` helper because the receiver type is unknown)
*and* a separate trace-tool disambiguation bug exists. PHP-LSP fixes
the first; the second is a separate ticket.
- The wider correctness signal — systemic call-attribution drift on
any short name shared between a global function and a method — is
the real justification for Phase 1, validated by spot-check on
Laravel.
Phase 1 proceeds with the sharpened success metric below (§10, §12).
A baseline measurement table for the collide-set is in
`docs/PHP_LSP_PRE_FLIGHT.md` §8 and must be re-measured post-change.
## 1. Terminology
In this codebase, **LSP = Light Semantic Pass**, not Language Server Protocol.
It is an in-process C module that runs after `cbm_extract_unified` in `cbm.c` to:
1. Build a `CBMTypeRegistry` of types/funcs from local + cross-file defs.
2. Walk the tree-sitter AST a second time, tracking lexical scopes (`CBMScope`)
and per-variable/parameter types (`CBMType`).
3. Resolve member/static/function calls into precise callee QNs, emitting
`CBMResolvedCall` rows with a strategy + confidence score.
Existing language LSPs:
| File | Lines | Lang |
|--------------------------|------:|-------------|
| `internal/cbm/lsp/go_lsp.c` | 2 744 | Go |
| `internal/cbm/lsp/c_lsp.c` | 4 767 | C / C++ / CUDA |
This plan adds a third: `internal/cbm/lsp/php_lsp.c`.
## 2. Scope
In scope:
- New module `internal/cbm/lsp/php_lsp.{c,h}`.
- New stdlib data file `internal/cbm/lsp/generated/php_stdlib_data.c`
(a curated subset, **not** full PHP stdlib — see §6).
- Wire into `internal/cbm/lsp_all.c`, `internal/cbm/cbm.c`, `Makefile.cbm`.
- New tests `tests/test_php_lsp.c` covering single-file and cross-file
call resolution.
- Update `docs/BENCHMARK.md` after benchmark re-run.
Explicit non-goals (separate tickets, do not block Phase 1):
- **`trace_call_path` short-name disambiguation.** The Q8 benchmark
failure is half tool-bug (picks one of 11 `value` variants and
reports its zero callers). Fixing the tool is a separate change to
the trace tool, not to PHP analysis. See
`docs/PHP_LSP_PRE_FLIGHT.md` §4.1.
- **`search_code` text-search recall.** The Q6 benchmark failure
("error" found 3× in workflow YAML; TODO/FIXME=0) is text-search
backend behaviour with no AST involvement. Separate ticket. See
`docs/PHP_LSP_PRE_FLIGHT.md` §3.
Out of scope (deferred, with justification):
- Embedding phpactor / tolerant-php-parser. Reason: phpactor is 28 KLoC of
PHP plus a fork of tolerant-php-parser; not embeddable into a C process
short of bundling a PHP interpreter. Same arguments-against as for Go and
C — the existing in-process C resolvers prove the pattern works.
- LSP wire-protocol exposure (i.e. exposing the codebase-memory-mcp graph
to editors as a Language Server). Different feature, different name
collision, separate plan.
- Generic/template tracking (`@template`, generic collections).
Defer until single-file + cross-file basics are at parity with Go LSP.
- Full PHPDoc parsing. Phase 2 stretch goal — see §10.
## 3. Why this works in-process
The codebase already has every primitive needed:
| Need | Existing primitive |
|---------------------------------------|---------------------------------------------------------|
| Parse PHP | `tree_sitter_php_only` (vendored, compiled, registered) |
| Walk PHP AST with shared semantics | `cbm_lang_spec(CBM_LANG_PHP)` already defines |
| | `php_func_types`, `php_class_types`, `php_call_types`, |
| | `php_import_types`, `php_decorator_types`, etc. |
| Allocate transient state | `CBMArena` (`internal/cbm/arena.h`) |
| Track variable bindings | `CBMScope` (`internal/cbm/lsp/scope.h`) |
| Represent types | `CBMType` (`internal/cbm/lsp/type_rep.h`) |
| Cross-file lookup | `CBMTypeRegistry` + `CBMLSPDef` |
| Emit resolved calls | `CBMResolvedCallArray` |
PHP fits the existing model cleanly:
- Methods → `CBM_TYPE_NAMED` receiver + method name lookup, identical to Go.
- Static calls → resolved against `CBMRegisteredType` like C++ static methods.
- Namespaces → behave like Go packages: a `use` clause maps a local name
(`Foo` or aliased `Foo as Bar`) to a fully-qualified namespace QN, exactly
the role of `import_local_names` / `import_package_qns` in `GoLSPContext`.
## 4. Design
### 4.1 Module layout
```
internal/cbm/lsp/
├── php_lsp.h (~120 lines, new)
├── php_lsp.c (~2 500 lines, new)
└── generated/
└── php_stdlib_data.c (~200 lines, curated by hand)
```
### 4.2 `PHPLSPContext` (mirrors `GoLSPContext`)
```c
typedef struct {
CBMArena *arena;
const char *source;
int source_len;
const CBMTypeRegistry *registry;
CBMScope *current_scope;
// Namespace + use-statement maps (PHP-specific)
const char *current_namespace_qn; // "" for global namespace
const char **use_local_names; // local alias or last NS segment
const char **use_target_qns; // resolved target QN
int use_count;
enum { USE_CLASS, USE_FUNCTION, USE_CONST } *use_kinds; // PHP `use function` / `use const`
// Current function/method context
const char *enclosing_func_qn;
const char *enclosing_class_qn; // NULL outside class body
const char *module_qn; // file-level QN
CBMResolvedCallArray *resolved_calls;
bool debug;
} PHPLSPContext;
```
The three PHP-specific fields versus `GoLSPContext` are: `current_namespace_qn`,
`enclosing_class_qn` (Go has none — PHP method body uses `$this`), and
`use_kinds` (PHP allows `use function` and `use const`, separate import
namespaces).
### 4.3 Pass structure
`cbm_run_php_lsp(arena, result, source, source_len, root)` follows the
go_lsp.c shape exactly:
1. Build per-file registry from `result->definitions` (already populated
by the unified extractor) + stdlib data.
2. Walk top-level: collect `namespace` declaration, `use_declaration`
nodes, populate `use_*` arrays.
3. Walk each function/method body, push scope, bind parameters with their
declared types, recurse.
4. For each call expression, evaluate receiver type (if any), look up the
method/function in the registry, emit a `CBMResolvedCall`.
### 4.4 Resolver coverage (Phase 1)
| AST node type | Resolver |
|------------------------------------------|-----------------------------------------------------------------------|
| `function_call_expression` | global function lookup, namespaced via `use function` and current NS |
| `member_call_expression` (`$x->m()`) | type of `$x` → method on receiver type or its parents |
| `nullsafe_member_call_expression` | same, drop nullable |
| `scoped_call_expression` (`A::m()`, `self::m()`, `parent::m()`, `static::m()`) | static method on resolved class QN |
| `object_creation_expression` (`new Foo`) | type = `Foo` (resolved via `use` + current NS) |
| `assignment_expression` | bind LHS variable name to RHS evaluated type |
| `parameter` / `simple_parameter` (typed) | bind `$param` to declared type |
| `property_declaration` (typed) | record field type on `CBMRegisteredType` |
| `property_promotion` (constructor) | both bind in scope and record field |
| `return_statement` | unify with declared return type for return-type inference fallback |
| `catch_clause` | bind `$e` to caught exception type |
| `foreach_statement` | bind loop var(s) to element/key types when array `@var` shape known |
| `match_expression` arm result | tuple-style return, used when assigned |
| `static_call_expression` to `parent::` | walk inheritance chain to first method match |
| `enum_declaration` cases | register as named-type members |
| `trait_declaration` + `use_declaration` (inside class body) | flatten trait methods into `CBMRegisteredType` |
Phase 1 explicitly **excludes**: closures bound via `Closure::bind`,
variadic spread argument typing, generic `@template` PHPDoc, intersection
types (treated as union for resolver, exact treatment Phase 2),
DNF (disjunctive normal form) types beyond union/intersection (PHP 8.2+).
### 4.5 Confidence + strategy strings
To match what Go/C LSP emit so existing pipeline stages don't need new
strategy enums:
| Strategy | Confidence | Used when |
|--------------------------------|-----------:|----------------------------------------------------------|
| `php_method_typed` | 0.95 | receiver type known, method found on type |
| `php_method_inherited` | 0.90 | method found on parent class via hierarchy walk |
| `php_method_trait` | 0.90 | method found via flattened trait |
| `php_static_resolved` | 0.95 | `Class::m()` with class QN resolvable |
| `php_self_static` | 0.95 | `self::m()` / `static::m()` inside known class |
| `php_function_namespaced` | 0.95 | function in current namespace or via `use function` |
| `php_function_global_fallback` | 0.70 | function not found in current NS, falls back to root NS |
| `php_method_unresolved` | 0.30 | receiver type unknown, name match only |
| `php_method_dynamic` | 0.20 | receiver resolved to class with `__call`, method not in registry |
| `php_dynamic_unresolved` | 0.10 | static call to class with `__callStatic` (e.g. Laravel facade) |
## 5. File-by-file change list
| Path | Change | Est. lines |
|------------------------------------------------------|------------------|-----------------:|
| `internal/cbm/lsp/php_lsp.h` | new | ~120 |
| `internal/cbm/lsp/php_lsp.c` | new | 2 500–3 500 (3 500 hard gate) |
| `internal/cbm/lsp/php_composer.{c,h}` | new (Phase 1) | ~200 |
| `internal/cbm/lsp/generated/php_stdlib_data.c` | new (corpus-seeded) | ~200 |
| `internal/cbm/lsp_all.c` | +2 includes | +2 |
| `internal/cbm/cbm.c` | dispatch | +4 |
| `Makefile.cbm` | none expected | 0 (covered by `lsp_all.c`) |
| `tests/test_php_lsp.c` | new | ~1 500 |
| `Makefile.cbm` test wiring (`TEST_PHP_LSP_SRCS`) | +3 | +3 |
| `docs/BENCHMARK.md` | rerun + update | ~30 line edit |
The dispatch insertion in `cbm.c` is exactly:
```c
if (language == CBM_LANG_PHP) {
cbm_run_php_lsp(a, result, source, source_len, root);
}
```
Inserted between the existing Go LSP and C LSP `if` blocks at
`internal/cbm/cbm.c:355–360`.
## 6. Stdlib data strategy (corpus-driven)
Go LSP ships a 30 630-line auto-generated `go_stdlib_data.c`. We will
**not** mirror that for PHP. Strategy is corpus-driven, not taxonomy-
driven:
1. **Seed from the benchmark corpus.** Query the existing
`laravel/framework` index for the top-N most-connected named
types/interfaces by inbound CALLS edge count. The current data shows
`Illuminate\Database\Eloquent\Model` (in=836),
`Illuminate\Database\Schema\Blueprint` (in=785),
`Illuminate\Container\Container` (in=625), and `Collection` (via
`value()`, in=1 024). Top ~30 types like these go in first.
2. **Add the genuinely-stdlib base.** SPL container/iterator hierarchy
(`Iterator`, `Generator`, `Countable`, `Traversable`,
`IteratorAggregate`, `ArrayIterator`, `SplStack`, `SplQueue`,
`SplObjectStorage`, `ArrayObject`); PSR interfaces (`Psr\Log\…`,
`Psr\Container\…`, `Psr\Http\Message\…`); DateTime /
DateTimeImmutable / DateInterval; `Throwable` / `Exception` / `Error`
chain — the last is needed for catch-clause type binding.
3. **Document each entry.** One-line comment with the source (corpus
metric or stdlib taxonomy) so the file is auditable.
Stays hand-curated, but seeded from real usage data. Re-evaluate if
benchmark shows specific classes missing — easy add. Auto-generation can
come later (Phase 2 stretch) if the maintenance burden grows.
## 7. Build + dependency changes
No new third-party dependencies. The only library changes:
- `lsp_all.c` adds `#include "lsp/php_lsp.c"` and
`#include "lsp/generated/php_stdlib_data.c"`.
The build already compiles `grammar_php.c` (`internal/cbm/grammar_php.c`)
and links `tree_sitter_php_only`. No `Makefile.cbm` changes needed for the
production binary.
For tests, mirror the existing `test_go_lsp` / `test_c_lsp` wiring:
```make
TEST_PHP_LSP_SRCS = tests/test_php_lsp.c
ALL_TEST_SRCS += $(TEST_PHP_LSP_SRCS)
```
## 8. Dependency Version Audit
Scope: dependencies that would be touched if we added third-party PHP
parsers/analyzers. For the chosen in-process design, **none** of these are
added — table is here for the record so that the choice "no new deps" is
auditable.
| Dependency | Latest stable (May 2026) | Action | Reason |
|----------------------------------|--------------------------|------------|---------------------------------------------------------------------------|
| `tree-sitter-php` grammar | already vendored | none | grammar already compiled and exposed via `tree_sitter_php_only` |
| `phpactor/worse-reflection` | n/a (PHP, MIT) | reference only | algorithmic reference; not embedded |
| `nikic/PHP-Parser` | n/a (PHP, BSD-3) | reject | duplicates tree-sitter-php for our purposes; would require PHP runtime |
| `microsoft/tolerant-php-parser` | n/a (PHP, MIT) | reject | same as above |
| `php-language-server` (felix-becker) | unmaintained | reject | dead since 2018; LSP-protocol scope, not what we need |
| `intelephense` / phpstorm | proprietary | reject | license incompatible |
Required existing primitives in the codebase verified present:
`CBMArena`, `CBMScope`, `CBMType*`, `CBMTypeRegistry`, `CBMResolvedCall`,
`CBMLangSpec` for PHP (`internal/cbm/lang_specs.c:1542`),
`tree_sitter_php_only` (`internal/cbm/grammar_php.c`).
## 9. Test plan
### 9.1 Unit tests (`tests/test_php_lsp.c`)
Patterned after `tests/test_go_lsp.c` (98 tests). Target ~80 tests.
Single-file (≈55):
- Param type inference: typed param, untyped param fallback, nullable,
union, intersection, DNF, `self`/`static`/`parent` types, scalar +
primitive types (`int|string|bool|float|null|true|false|array|callable|iterable|object|never|void|mixed|string`).
- Method chaining: `$db->select()->where()->limit()->get()`.
- Static dispatch: `Foo::bar()`, `self::bar()`, `parent::bar()`,
`static::bar()`.
- `new` object creation + immediate method call: `(new Foo())->bar()`.
- Constructor property promotion (`public function __construct(public Foo $foo) {}`).
- Trait method flattening.
- Enum methods + `Enum::Case`.
- Catch binding: `catch (RuntimeException $e) { $e->getMessage(); }`.
- Foreach over `iterable<T>` PHPDoc — accept failure in Phase 1.
- Match expression assignment.
- Closure/arrow function variable capture.
- Namespace resolution: `use App\Foo;` then `new Foo()`,
`use App\Foo as F;` then `new F()`,
`use function App\bar;`, `use const App\BAZ;`.
- Global namespace fallback for builtin functions (`strlen`, `array_map`).
Cross-file (≈25):
- Method dispatch across files (file A defines `Foo::bar()`, file B
calls it via `$foo->bar()`).
- Interface dispatch (file A defines interface, file B implements,
file C calls method via interface variable).
- Trait flattening across files (trait in file A, used in class in file B).
- Inheritance chain (parent in file A, child in file B, call resolves to
parent method).
- Stdlib `DateTime` chain across files.
- PSR LoggerInterface dispatch.
### 9.2 Integration tests
Re-run the language benchmark in `docs/BENCHMARK.md` against
`laravel/framework`. Target: lift PHP from 9/12 (Tier 2, 75 %) to ≥11/12
(Tier 1, ≥92 %). The two questions PHP currently fails (per BENCHMARK.md
inspection task) are method-resolution-dependent, so this is a direct
test of the new module.
### 9.3 CI hooks
Existing build/test pipeline (`./build-all.sh`) already covers
`tests/test_go_lsp` and `tests/test_c_lsp` — adding `tests/test_php_lsp.c`
plugs into the same harness via `ALL_TEST_SRCS`.
### 9.4 Memory-safety + race tests
The Go and C LSP modules are exercised under `-fsanitize=address,thread`
in the existing test binaries (`GRAMMAR_CFLAGS_TEST` / `_TSAN`). PHP LSP
inherits the same compile flags by virtue of using the same arena +
shared scope/type primitives — no additional memory-safety wiring needed.
## 10. Phasing
**Phase 0** — Pre-flight, 1 day. Output `docs/PHP_LSP_PRE_FLIGHT.md`
(see §0). Gate on type-resolver-shaped failure set.
**Phase 1** — Core resolver, **≈3 weeks** of focused work.
- `php_lsp.{c,h}` covering §4.4 table. Hard gate: 3 500 lines for
`php_lsp.c`. If hit, defer DNF and trait conflict-resolution rules to
Phase 2.
- `php_composer.{c,h}` parser for `composer.json` `autoload.psr-4` and
`vendor/composer/autoload_psr4.php` (when present). Pre-populates the
type registry with namespace→file mappings before the LSP walk.
- Corpus-seeded stdlib (§6).
- ~80 unit tests + 2 PSR-4 parser tests, ≥ 85 % line coverage of
`php_lsp.c`, ≥ 40 % test:code line ratio.
- Facade / dynamic strategy emission (`php_dynamic_unresolved`,
`php_method_dynamic`).
- **Primary metric**: collide-set attribution correctness on
laravel/framework (per `docs/PHP_LSP_PRE_FLIGHT.md` §6.1). For the
top-20 names that exist as both `Function` and `Method`, ≥ 90 % of
call sites with a typed receiver land on the method variant.
- **Secondary metric**: PHP benchmark lift. Cap is 11/12 (92 %, Tier 1)
unless the Q6 search-recall and Q8 trace-disambiguation tickets ship
in the same release.
- Land before any of Phase 2.
**Phase 2** — Type system depth, after Phase 1 lands.
- PHPDoc parser (just `@var`, `@param`, `@return`, `@template`).
- Generic / template propagation for collection types.
- DNF type handling on call dispatch.
- Auto-generated stdlib if benchmark shows recall gaps.
**Phase 3** — Cross-cutting nice-to-haves.
- `Closure::bind` / `Closure::fromCallable` resolution.
- Magic methods (`__call`, `__callStatic`) — usually unresolvable, emit
with low confidence + reason.
- Attribute (`#[…]`) → method binding (route/handler discovery), if it
proves valuable for the existing route extraction pipeline.
## 11. Risks
| Risk | Mitigation |
|---------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------|
| `php_lsp.c` grows beyond go_lsp.c size and becomes unmaintainable | Mirror go_lsp.c's static-helper conventions; lift shared sub-routines into `lsp/scope.c` if they recur |
| Cross-file resolution misses because of PHP's `autoload` runtime nature | We resolve by static `use`-clause + namespace; document the assumption in module header. Composer PSR-4 mapping is a Phase 3 stretch |
| Tree-sitter-php node naming drifts on upgrade | Pin grammar via vendoring (already done); add a smoke test that asserts the PHP node-type names we depend on are still emitted |
| PHPDoc-only typed code (very common in legacy PHP) gets ~0 resolution | Phase 2 handles PHPDoc; document Phase 1 limitation in the module README so users understand |
| Stdlib gaps cause benchmark misses | Stdlib is hand-curated, easy to extend; keep a TODO list of misses surfaced by the bench |
| Memory-safety regressions vs sanitizer | Run `make test-asan test-tsan` (existing targets) before merge; add to PR template |
## 12. Acceptance criteria
- [x] Pre-flight (§0) complete; root causes recorded in
`docs/PHP_LSP_PRE_FLIGHT.md`; type-resolver-shaped subset
confirmed.
- [ ] Collide-set attribution: ≥ 90 % of typed-receiver method-call
sites for the top-20 collide-set short names route to the method
variant, not the global function.
- [ ] Specific regression: `(Prompt $p) => $p->value()` resolves to
`Prompt::value`, not `Illuminate\Collections\helpers.value`.
Encoded as a unit test in `tests/test_php_lsp.c`.
- [ ] All Phase 1 tests pass under `-O2`, ASan, and TSan.
- [ ] `./build-all.sh` exits 0 with no new warnings.
- [ ] PHP benchmark in `docs/BENCHMARK.md` updated. Score lift target
is 11/12 (92 %, Tier 1) once trace-tool disambiguation also
ships; PHP-LSP alone may show 10/12 (83 %) in isolation, which
is acceptable for this PR.
- [ ] Coverage of `internal/cbm/lsp/php_lsp.c` ≥ 85 % per existing
coverage harness.
- [ ] Test:code line ratio ≥ 40 % (`wc -l tests/test_php_lsp.c
internal/cbm/lsp/php_lsp.c`).
- [ ] `php_lsp.c` ≤ 3 500 lines; if exceeded, scope cuts documented.
- [ ] `php_composer.c` parses Laravel's `composer.json` correctly in a
regression test.
- [ ] Facade strategy verified: `DB::table()` against Laravel emits
`php_dynamic_unresolved`, not zero edges.
- [ ] No new third-party dependencies added.
- [ ] `tests/test_php_lsp.c` referenced from `Makefile.cbm`
`ALL_TEST_SRCS`.
## 13. Challenger Review (full, verbatim)
The plan was reviewed by a separate challenger pass. The headline findings:
1. **Critical factual gap.** The plan claimed the two benchmark PARTIAL
scores (Q6 text search, Q8 inbound trace) are method-resolution-shaped.
They are not. Shipping the new module will not move PHP from 75 % to
Tier 1 unless the actual failing questions are something the resolver
touches. **A debug-mode benchmark must be re-run on the current
binary before any C is written**, and the failing-question set
recorded here. Phase 1 is gated on that data.
2. **Facade / `__callStatic` calls are not addressed.** Laravel's
`DB::table()`, `Cache::get()`, `Auth::user()`, etc. resolve at runtime
via `__callStatic` → container. With static `use`-only resolution,
these produce zero edges silently. Action: emit a
`php_dynamic_unresolved` strategy (confidence 0.10) for any call
where the receiver resolves to a class that contains a `__call` or
`__callStatic` magic method, so the call site shows up in the graph
even if the callee is unknown. Add this row to §4.5.
3. **Composer PSR-4 parsing is too valuable to defer.** Reading
`composer.json` `autoload.psr-4` (and `vendor/composer/autoload_psr4.php`
if present) lifts cross-file resolution from "files that include `use`"
to "every class in the project + vendored deps." This is how PHPStan
and Psalm work without running PHP. Cost: ~200 lines of JSON parsing.
Promoted from Phase 3 to **Phase 1**.
4. **Stdlib should be corpus-driven, not taxonomy-driven.** The
benchmark corpus already exposes the high-leverage types: `Model`
(in=836), `Blueprint` (in=785), `Container` (in=625), `Collection`
(in=1024 via `value()`). Hand-curating SPL + PSR while leaving these
out is a recall miss. Bootstrap the 200-line file from the top-N most-
connected types in the corpus, then add SPL/PSR.
5. **Effort estimate is optimistic.** 2 weeks for ≈2 500 lines of C is
tight. C-LSP is 4 767 lines for a simpler-typed language. PHP has
union/intersection/DNF types, traits with conflict resolution, enums,
nullsafe chains, `self`/`static`/`parent`. Re-target as **3 weeks**
with a 3 500-line hard gate for `php_lsp.c`; if the gate is hit,
defer DNF and trait conflict-resolution rules to Phase 2.
6. **Test:code ratio is tight but acceptable.** C-LSP is 31 %; we plan
~43 % (1 500 / 3 500). Hold ≥ 40 % at merge time.
7. **Strategy table has a gap.** There is no
`php_method_dynamic` for "receiver resolved, method not in registry"
(e.g., methods only existing via `__call`). Add it explicitly to §4.5
with confidence 0.20.
8. **Tree-sitter-php grammar version is current** (v0.24.2,
2024-08-18); runtime is v0.25.8 vs upstream v0.26.8. Runtime upgrade
is out of scope for this plan but worth a follow-up ticket.
### Challenge → plan diffs
| # | Challenge | Plan change |
|---|--------------------------------------------|---------------------------------------------------------------------------------------------------|
| 1 | Failing-question set unverified | New §0 "Pre-flight" added below; **blocks Phase 1** |
| 2 | Facade calls silently miss | Add `php_dynamic_unresolved` row to §4.5 |
| 3 | Composer PSR-4 deferred too aggressively | Promote PSR-4 parser to Phase 1; +200 lines, +2 unit tests |
| 4 | Stdlib taxonomy-driven | §6 reframed as corpus-driven seed list |
| 5 | 2-week estimate optimistic | §10 timeline updated to 3 weeks; 3 500-line hard gate added |
| 6 | Test ratio | Acceptance criteria §12 includes ≥ 40 % test:code ratio |
| 7 | Strategy gap | Add `php_method_dynamic` to §4.5 |
| 8 | Runtime version note | Recorded in §8 audit; no plan change |
(The plan body above already incorporates these diffs.)
## 14. References
Algorithmic references (read-only, not embedded):
- phpactor/worse-reflection — `src/Core/Inference/Resolver/` is the
single best reference for resolver-by-resolver coverage. 41 resolver
classes; we collapse them into one C dispatch in `php_lsp.c` since C
doesn't pay for the indirection.
License: MIT.
- phpactor/phpactor — the LSP server. We reference its `WorseReflection`
bindings only to confirm node-type assumptions are correct.
- nikic/PHP-Parser — used as a sanity check for unusual syntax shapes
(PHP 8.4 property hooks, asymmetric visibility); we do not pull in
code, only docs.
In-tree references:
- `internal/cbm/lsp/go_lsp.c` — primary structural template.
- `internal/cbm/lsp/c_lsp.c` — secondary template, shows how to handle
inheritance chains and namespace-like scopes.
- `internal/cbm/lang_specs.c:1542` — existing PHP language spec; do not
modify in Phase 1.
- `internal/cbm/cbm.c:340–360` — dispatch site; one-block insertion.