Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
32ad247
Add the SymbolInfo marker for kind-parameterized lookup
Firehed Aug 12, 2026
27ce2b0
Confine AST-route kind dispatch to one factory
Firehed Aug 12, 2026
5b67876
Confine reflection-route kind dispatch to one factory
Firehed Aug 12, 2026
e064fdf
Collapse per-kind backend lookup to one call
Firehed Aug 12, 2026
6a0b86a
Add the backend x kind x query coverage grid
Firehed Aug 12, 2026
21e287b
Document the collapsed backend lookup
Firehed Aug 12, 2026
4866b6c
Trim comments to what the code does not say
Firehed Aug 12, 2026
3dcb56c
Stop rewrapping comments the change did not touch
Firehed Aug 12, 2026
7a44f36
Read cached symbols through one read-through
Firehed Aug 12, 2026
e25e8b5
Keep built-in class-like lookup on confined reflection
Firehed Aug 13, 2026
703bbc1
Point the docs at the relocated cache and factory
Firehed Aug 13, 2026
eee8397
Hold each grid blocker to a real registry row
Firehed Aug 13, 2026
a67f641
Confine runtime symbol-existence checks
Firehed Aug 13, 2026
0f8666f
Add the kind-carrying declared-symbol record
Firehed Aug 13, 2026
a8abbe2
Derive by-name lookup from the whole-file scan
Firehed Aug 13, 2026
057a792
Register open-document symbols by kind, not by bucket
Firehed Aug 13, 2026
7cef217
Point built-in class search at the gap that blocks it
Firehed Aug 13, 2026
04d0f1d
Record that registration collapsed with lookup
Firehed Aug 13, 2026
4c3ce02
Drop BuiltinBackend from the enumeration allowlist
Firehed Aug 13, 2026
4a1f97e
Key the fake backend by the kind's own case rule
Firehed Aug 13, 2026
fcb167f
Merge branch 'main' into slice/S3.8d
Firehed Aug 13, 2026
ca6ab83
Confine reading a constant's runtime value
Firehed Aug 13, 2026
6c8e185
Say which of the grid's axes are derived
Firehed Aug 13, 2026
be46968
Hold a grid lookup to the kind's own info type
Firehed Aug 13, 2026
1bb0b4f
Hold open-document keys to each kind's case rule
Firehed Aug 13, 2026
3b45c8e
Probe the fourth class-like flavour
Firehed Aug 13, 2026
ca6b976
Name what the comment's 'all three' refers to
Firehed Aug 13, 2026
444265a
Share the declared-symbol builders
Firehed Aug 13, 2026
79e3c71
Give the fake backend one kind-agnostic store
Firehed Aug 14, 2026
fcb9df9
File the invalidation fan-out row
Firehed Aug 14, 2026
18b87da
File the symbol-key duplication row
Firehed Aug 14, 2026
6e78cee
Keep the backend list off the composite's surface
Firehed Aug 14, 2026
8481c2c
Say where the invalidation type tests actually are
Firehed Aug 14, 2026
f58b907
Revert "Keep the backend list off the composite's surface"
Firehed Aug 14, 2026
dab67a7
Point the narrowing note at the callers that do it
Firehed Aug 14, 2026
8ea7338
Drop comments that restate their own code
Firehed Aug 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 17 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ composer phpcs -- -q --report=emacs # run code style checks (PSR-12)

Two CI-enforced mechanisms confine where code may live; a rule firing on your change is design feedback, not an obstacle.

- **Capability confinement** (`phpstan.neon`): AST traversal, symbol-name case folding, regex, runtime reflection, and filesystem reads are each usable only in their named homes (allowlists inline, each with its rationale).
- **Capability confinement** (`phpstan.neon`): AST traversal, symbol-name case folding, regex, runtime reflection, runtime symbol existence/enumeration, and filesystem reads are each usable only in their named homes (allowlists inline, each with its rationale).
- **Layer contract** (`deptrac.yaml`): an inter-layer dependency not in the ruleset fails analysis.

When a rule fires on your change, in order of preference:
Expand Down Expand Up @@ -133,15 +133,19 @@ flow through the **`SymbolSource`** read seam (`src/Knowledge/`), implemented by
A lookup takes the first backend that answers; enumeration and search merge every
backend, the earlier (more authoritative) one winning a name clash. Caching is a
per-backend PSR-16 policy (`src/Cache/`); on-disk and built-in results are cached, open
documents never. A cache key carries the `NameKind` (`SymbolCacheKey`): PHP's three
documents never. A cache key carries the `NameKind` (`SymbolCache`): PHP's three
symbol namespaces are independent, so a class and a function may share a name.

Lookup is **per-kind at the `SymbolSource` facade** — a typed method per kind, taking a
name type that carries its kind (`ClassName`, `FunctionName`), because RFC 1 §5.1 requires
a concrete return type rather than a type-erased union — and **kind-parameterized at
`SymbolBackend`**, so a new kind is never a change to every backend. The backends still
carry a method per kind today; S3.8d collapses them (Plan 0002 §5.6). Do not read the
facade's closed method set as licence to add a per-kind backend method.
`SymbolBackend`**: one `lookup(QualifiedName, NameKind): ?SymbolInfo`. Do NOT read the
facade's closed method set as licence to add a per-kind backend method. Kind dispatch
lives in `DeclarationSymbolInfoFactory` and `ReflectionSymbolInfoFactory`, one per
metadata route, so a new kind is a case in each rather than a method on every backend.
`SymbolCoverageGridTest` enforces §5.1 with a backend × kind × query grid whose backend
and kind axes are derived: every cell answers or names its blocker, and an unregistered
cell fails.
`lookupFunction` reaches
open documents, the `autoload.files` set, and PHP's built-ins — the last filtered to
`isInternal()`, because reflection also sees the functions the *server's* own
Expand Down Expand Up @@ -177,11 +181,14 @@ directory listing by `CompositeNamespaceCatalog`. Enumeration is not optional:
requires lookup and enumeration to draw on the same backends, so a name that resolved
on hover while being invisible to completion is the split this tier exists to prevent.

The write path is **`SymbolSink`** (`DocumentSymbolSink`), which registers class and
function metadata and indexes symbols from one document. A declaration at any depth is
registered, not just a top-level one — a class or function guarded by
`class_exists`/`function_exists` is a name the file validly declares, and the on-disk
backends resolve one, so opening the file must not make it disappear.
The write path is **`SymbolSink`** (`DocumentSymbolSink`), which registers a document's
symbols and indexes them. Registration is kind-parameterized like lookup: the sink hands
`OpenDocumentBackend` `DeclaredSymbol`s built by `DeclarationSymbolInfoFactory`, the same
factory the on-disk read path uses, so a new kind is a case there rather than another
parameter on the backend. A declaration at any depth is registered, not just a top-level
one — a class or function guarded by `class_exists`/`function_exists` is a name the file
validly declares, and the on-disk backends resolve one, so opening the file must not make
it disappear.
**`KnowledgeStack::forProject`** assembles the read composite and the write sink,
sharing one open-document backend and symbol index.

Expand Down
24 changes: 23 additions & 1 deletion docs/architecture/build-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ re-runs repo-wide as its completion gate.
SC.13 — Settle Domain->Utility type placement — —
SC.14 — Filter BuiltinBackend class-like lookup to internal — —
SC.15 — Oracle corpus: trait adaptations and enums — —
SC.16 — Index an open document's global constants — —
SC.17 — Collapse the hand-routed invalidation fan-out — —
SC.18 — One home for the kind-qualified symbol key SC.13 —
SZ.1 Z Definition of Done gate + repo-wide dup audit all prior —

Notes:
Expand Down Expand Up @@ -147,6 +150,9 @@ Notes:
pairs; after SC.5 those differ only in which factory builds the metadata. S3.8d also
carries the §8.1 mechanism for §5.1 (see 0002), per the rule that a seam ships with its
enforcement.
`OpenDocumentBackend`'s *registration* is collapsed with its lookup, for the same
reason: a per-kind parameter there would force S3.8b to edit a backend even though the
read seam held.
- **S3.8b is the proof.** Its acceptance carries one criterion that cannot be met by
appearance: **its diff must touch no `SymbolBackend` implementation.** If it does,
S3.8d did not work.
Expand Down Expand Up @@ -290,10 +296,26 @@ Notes:
- **SC.13** — Domain factories reach into Utility (`TypeFactory`, `NamespacePath`); decide the direction in-slice (move the utility into Domain, or the factory methods out) and drain the frozen edges.
Related: `ClassName::shortName`/`getNamespace` hand-roll the split `NamespacePath` owns, so the direction chosen also settles that duplicate.
Likewise `NameKind::normalize` re-implements the path fold `NamespacePath::normalize` owns — layer-blocked from routing through it until this move — so the direction also collapses the two folds into one, and the case-folding allowlist follows the file.
- **SC.14** — `BuiltinBackend::lookupClassLike` lacks the `isInternal()` guard its function sibling has, so hover resolves any class the *server's own* autoloader can load while completion never offers it — the §4.2 lookup/enumeration split, live on the class namespace.
- **SC.14** — `ReflectionSymbolInfoFactory`'s class-like branch lacks the `isInternal()` guard its function sibling has, so hover resolves any class the *server's own* autoloader can load while completion never offers it — the §4.2 lookup/enumeration split, live on the class namespace.
A live defect; owes a regression test against a class the server vendors but the project does not.
- **SC.15** — `TypeGraphParityTest`'s corpus has no trait `insteadof`/`as` shapes and no enums, so the reflection oracle cannot see #73's defect class (nor enum-interface members).
Fixture-only slice; #73's fix lands on top of it and must fail before, pass after.
- **SC.16** — `SymbolExtractor` emits no `SymbolKind::Constant`, so a global constant in an open document is never indexed and `OpenDocumentBackend::childrenOf` cannot enumerate it, while the on-disk and built-in backends both do.
`WorkspaceNamespaceSource` already maps the kind, so the gap is upstream in the extractor.
Found by the S3.8d coverage grid on its first run.
Ungated, and ahead of S3.8b — constant lookup landing on an enumeration blind to open documents would rebuild the §4.2 split on the third symbol namespace.
- **SC.17** — telling the parts that hold file-derived state that a file changed is written out three times: `DocumentSymbolSink` over a list it is handed, `FilesystemBackend` over its catalog and locator, `CompositeSymbolLocator` over its routes.
The latter two steer by an `instanceof` test, three in all; the sink instead takes a pre-filtered list, so the composition root already decides who holds state and the knowledge is split between the two styles.
So adding a holder means finding its parent in that tree by hand, and missing one is silent — the stale value is still served and nothing fails.
Not only caches: the same route drops `AutoloadFilesLocator`'s derived name→file map, which is rebuilt rather than memoized.
`SymbolSink extends Invalidatable` solely to give the handler a way in, which is how the write path came to be named after the response instead of the event.
Scope is one registration list at the composition root, which deletes the three fan-outs and the three type tests. Whether a general published event replaces it is #415 and is deliberately not settled here.
Found while reviewing S3.8d. Ungated.
- **SC.18** — the key a name has under its kind, `$kind->name . '|' . $kind->normalize($name)`, is written out four times: `SymbolCache::keyFor`, `OpenDocumentBackend::key`, `DeclarationSymbolInfoFactory::collect`, and the composite's test fake.
`SymbolCache::keyFor` and `delete` are public for one caller — `FilesystemBackend` holds hashed key strings to reverse-map a path — so a `forget(QualifiedName, NameKind)` takes both off the surface and lets the backend record what it actually knows.
Duplication rather than a defect: the four stores are independent, so no two features can disagree over it today. It is filed because a fifth copy arrives with each new kind.
Gated on SC.13, which decides where the case fold lives; the key helper belongs beside it.
Found while reviewing S3.8d.
- **SC.7** — `MemberResolver` has six near-identical hierarchy walks:
`find{Method,Property,Constant}InHierarchy` and `collect{Methods,Properties,Constants}`,
each a seen-check, a scan of the class's own members, and a recursion over
Expand Down
8 changes: 4 additions & 4 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
parameters:
ignoreErrors:
-
message: '#^Calling get_defined_functions\(\) is forbidden, runtime symbol enumeration is confined to BuiltinBackend and ReflectionNamespaceSource\.$#'
message: '#^Calling get_defined_functions\(\) is forbidden, runtime symbol enumeration is confined to ReflectionNamespaceSource\.$#'
identifier: disallowed.function
count: 1
path: src/Completion/FunctionCandidates.php
Expand Down Expand Up @@ -73,19 +73,19 @@ parameters:
path: src/Index/WorkspaceIndexer.php

-
message: '#^Class ReflectionFunction is forbidden, runtime reflection is confined\: BuiltinBackend, ReflectionNamespaceSource, and the fromReflection factories\. \[ReflectionFunction matches Reflection\*\]$#'
message: '#^Class ReflectionFunction is forbidden, runtime reflection is confined\: ReflectionNamespaceSource and the fromReflection factories\. \[ReflectionFunction matches Reflection\*\]$#'
identifier: disallowed.class
count: 1
path: src/Repository/DefaultFunctionRepository.php

-
message: '#^Namespace ReflectionException is forbidden, runtime reflection is confined\: BuiltinBackend, ReflectionNamespaceSource, and the fromReflection factories\. \[ReflectionException matches Reflection\*\]$#'
message: '#^Namespace ReflectionException is forbidden, runtime reflection is confined\: ReflectionNamespaceSource and the fromReflection factories\. \[ReflectionException matches Reflection\*\]$#'
identifier: disallowed.namespace
count: 1
path: src/Repository/DefaultFunctionRepository.php

-
message: '#^Namespace ReflectionFunction is forbidden, runtime reflection is confined\: BuiltinBackend, ReflectionNamespaceSource, and the fromReflection factories\. \[ReflectionFunction matches Reflection\*\]$#'
message: '#^Namespace ReflectionFunction is forbidden, runtime reflection is confined\: ReflectionNamespaceSource and the fromReflection factories\. \[ReflectionFunction matches Reflection\*\]$#'
identifier: disallowed.namespace
count: 1
path: src/Repository/DefaultFunctionRepository.php
Expand Down
20 changes: 16 additions & 4 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,9 @@ parameters:
- tests/*
-
namespace: 'Reflection*'
message: 'runtime reflection is confined: BuiltinBackend, ReflectionNamespaceSource, and the fromReflection factories'
message: 'runtime reflection is confined: ReflectionNamespaceSource and the fromReflection factories'
allowIn:
- src/Knowledge/BuiltinBackend.php
- src/Knowledge/ReflectionSymbolInfoFactory.php
- src/Index/ReflectionNamespaceSource.php
- src/Repository/ClassInfoFactory.php
- src/Repository/DefaultClassInfoFactory.php
Expand Down Expand Up @@ -86,11 +86,23 @@ parameters:
- 'get_defined_functions()'
- 'get_declared_classes()'
- 'get_defined_constants()'
message: 'runtime symbol enumeration is confined to BuiltinBackend and ReflectionNamespaceSource'
message: 'runtime symbol enumeration is confined to ReflectionNamespaceSource'
allowIn:
- src/Knowledge/BuiltinBackend.php
- src/Index/ReflectionNamespaceSource.php
- tests/*
-
function:
- 'class_exists()'
- 'constant()'
- 'defined()'
- 'enum_exists()'
- 'function_exists()'
- 'interface_exists()'
- 'trait_exists()'
message: 'asking the runtime whether a symbol exists, or what it holds, is a SymbolSource query (RFC 1 §4.2); ReflectionSymbolInfoFactory is the one place it may be the server''s own runtime'
allowIn:
- src/Knowledge/ReflectionSymbolInfoFactory.php
- tests/*
-
function:
- 'file_get_contents()'
Expand Down
2 changes: 1 addition & 1 deletion src/Domain/ClassInfo.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
/**
* Metadata about a class, interface, trait, or enum.
*/
final readonly class ClassInfo implements Formattable
final readonly class ClassInfo implements Formattable, SymbolInfo
{
/**
* @param list<ClassName> $interfaces Implemented interfaces
Expand Down
20 changes: 20 additions & 0 deletions src/Domain/DeclaredSymbol.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace Firehed\PhpLsp\Domain;

/**
* Registration carries the kind rather than splitting into a parameter per kind, so
* a new kind is a case in the info factories and not a signature change on every
* write path (Plan 0002 §5.6).
*/
final readonly class DeclaredSymbol
{
public function __construct(
public QualifiedName $name,
public NameKind $kind,
public SymbolInfo $info,
) {
}
}
2 changes: 1 addition & 1 deletion src/Domain/FunctionInfo.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
/**
* Metadata about a standalone function.
*/
final readonly class FunctionInfo implements Formattable
final readonly class FunctionInfo implements Formattable, SymbolInfo
{
/**
* @param list<ParameterInfo> $parameters
Expand Down
15 changes: 15 additions & 0 deletions src/Domain/SymbolInfo.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Firehed\PhpLsp\Domain;

/**
* Metadata about a symbol in one of PHP's three symbol namespaces (Plan 0002 §5.6).
*
* Not {@see Formattable}, which says a value renders itself rather than that it is a
* symbol; the two sets only coincide today.
*/
interface SymbolInfo
{
}
70 changes: 10 additions & 60 deletions src/Knowledge/BuiltinBackend.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,19 @@

namespace Firehed\PhpLsp\Knowledge;

use Firehed\PhpLsp\Domain\ClassInfo;
use Firehed\PhpLsp\Domain\ClassName;
use Firehed\PhpLsp\Domain\FunctionInfo;
use Firehed\PhpLsp\Domain\FunctionName;
use Firehed\PhpLsp\Domain\NameKind;
use Firehed\PhpLsp\Domain\QualifiedName;
use Firehed\PhpLsp\Domain\SymbolInfo;
use Firehed\PhpLsp\Index\NamespaceCatalog;
use Firehed\PhpLsp\Index\NamespaceContents;
use Firehed\PhpLsp\Repository\ClassInfoFactory;
use Psr\SimpleCache\CacheInterface;
use ReflectionClass;
use ReflectionException;
use ReflectionFunction;

/**
* The lowest-precedence {@see SymbolBackend}: the symbols built into PHP and its
* loaded extensions, described through reflection. It is consulted only after the
* open-document, workspace, and vendor backends, so a name any of them can resolve
* never reaches reflection (RFC 1 §5.3).
*
* Built-ins are fixed for a given target environment, so a resolved class is cached
* Built-ins are fixed for a given target environment, so a resolved symbol is cached
* (RFC 1 §5.3). This backend is reflection-backed and therefore describes the
* *server's* runtime, not the project's target — a known §4.7 gap deferred to Step 5
* (Plan 0002 §5); the interim treats every reflected built-in as available.
Expand All @@ -36,9 +28,9 @@
final class BuiltinBackend implements SymbolBackend
{
public function __construct(
private readonly ClassInfoFactory $factory,
private readonly ReflectionSymbolInfoFactory $infoFactory,
private readonly NamespaceCatalog $namespaces,
private readonly CacheInterface $cache,
private readonly SymbolCache $cache,
) {
}

Expand All @@ -47,55 +39,13 @@ public function childrenOf(NamespaceName $namespace): NamespaceContents
return $this->namespaces->childrenOf($namespace->path);
}

public function lookupClassLike(ClassName $name): ?ClassInfo
public function lookup(QualifiedName $name, NameKind $kind): ?SymbolInfo
{
$cacheKey = SymbolCacheKey::for(QualifiedName::fromClassName($name), NameKind::ClassLike);

$cached = $this->cache->get($cacheKey);
if ($cached !== null) {
assert($cached instanceof ClassInfo);
return $cached;
}

try {
$classInfo = $this->factory->fromReflection(new ReflectionClass($name->fqn));
} catch (ReflectionException) {
return null;
}
$this->cache->set($cacheKey, $classInfo);

return $classInfo;
}

public function lookupFunction(FunctionName $name): ?FunctionInfo
{
$cacheKey = SymbolCacheKey::for($name->qualifiedName, $name->kind());

$cached = $this->cache->get($cacheKey);
if ($cached !== null) {
assert($cached instanceof FunctionInfo);
return $cached;
}

try {
$function = new ReflectionFunction($name->fullyQualifiedName());
} catch (ReflectionException) {
return null;
}

// Reflection sees every function loaded in the *server's* process, which
// includes the ones its own dependencies declare. Those are not the
// project's, and this backend enumerates only internal functions
// (BuiltinFunctionParityTest) — a lookup that answered more broadly would
// resolve a name completion never offers (RFC 1 §4.2).
if (!$function->isInternal()) {
return null;
}

$functionInfo = FunctionInfo::fromReflection($function);
$this->cache->set($cacheKey, $functionInfo);

return $functionInfo;
return $this->cache->remember(
$name,
$kind,
fn(): ?SymbolInfo => $this->infoFactory->fromReflection($name, $kind),
);
}

/**
Expand Down
Loading