Skip to content

Generalize the class locator to a kind-agnostic SymbolLocator - #388

Open
Firehed wants to merge 31 commits into
mainfrom
slice/S3.7
Open

Generalize the class locator to a kind-agnostic SymbolLocator#388
Firehed wants to merge 31 commits into
mainfrom
slice/S3.7

Conversation

@Firehed

@Firehed Firehed commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Slice S3.7 (build manifest) — plan step 3b (docs/architecture/0002-execution-plan.md §3, §3b, §5.3), RFC 1 §5.1, §5.2, §5.3.

Generalizes ClassLocator into a kind-agnostic SymbolLocator and folds in autoload.files, so S3.8 can add lookupFunction / lookupConstant over a finished interface.

What's here

SymbolLocator::locate(QualifiedName, NameKind): ?string (src/Knowledge/) — one entry for all three of PHP's symbol namespaces. The kind is a parameter rather than a method per kind, keeping the backends × kinds cross-product closed (§5.6). It is not redundant with the name: QualifiedName is deliberately kind-neutral, and Foo\bar names a different symbol as a function than as a constant.

ComposerSymbolLocator (src/Index/) — the two halves work differently because Composer's data does. Class-likes have a name→file map (PSR-4/PSR-0/classmap), so a lookup is arithmetic on the name and reads no file. Functions and constants have no such map and never can, so a name→file index is derived by parsing the autoload.files set — explicit and usually tiny, which is what makes it affordable (§3).

DeclarationScanner / FileDeclarations — reads the functions and constants a parsed file declares, using the namespacedName that NameResolver already sets in ParserService, rather than tracking namespace statements by hand. Constant reach covers const declarations and literal-name define(); a computed-name define() is a runtime call invisible to a static parse and is out of scope (§3b), which a test asserts rather than leaving to prose.

QualifiedName (src/Domain/) — the kind-neutral base FQN value type from §5.3.

Lazy index, warmed at initialized — the index builds on the first function/constant lookup, so nothing depends on startup ordering and §1's lazy-first principle holds. KnowledgeWarmer (an InitializedListener) also triggers the build so the cost lands before the first keystroke; Cache\Warmable states that this is latency only and an unwarmed implementation must answer identically. Per-file scans sit behind the PSR-16 seam keyed by path, and FilesystemBackend::invalidate() forwards to the locator, so an external change to an autoload.files entry re-reads exactly that file (§5.2, §5.3).

ClassLocator / ComposerClassLocator are removed; all eight of their test cases were ported to ComposerSymbolLocatorTest first.

Acceptance criteria

  • ClassLocator is generalized to a kind-agnostic SymbolLocator
  • autoload.files is folded in — read by ComposerAutoloadMap, split by partitionByVendorDirectory, and indexed by the locator
  • locate answers definitively for all three kinds; absence is a bare null (§5.3)
  • Names are typed identifiers, not bare strings (§5.1)
  • Function names match case-insensitively, constant names case-sensitively
  • Constant reach covers const and literal-name define(); computed-name define() is out of scope and tested as such
  • External-change invalidation reaches the locator's per-file scans
  • FilesystemBackend and KnowledgeStack resolve through the new seam; the superseded locator is gone
  • Behavior-preserving on every Step P surface — all goldens unchanged
  • 100% line/method coverage on new and touched classes

Notes

  • Deviation from the manifest's JIT note, recorded in build-manifest.md: it scheduled QualifiedName for S3.8, but the kind-agnostic signature makes S3.7 its first caller, so it lands here. NameKind already existed in src/Resolution/. FunctionName / ConstantName still have no caller and remain S3.8's.
  • Naming overlap: plan §5.2 reserves a deferred SymbolSource::locate(QualifiedName, NameKind): ?SymbolDefinition for the workspace scope. That is a different layer (a SymbolDefinition on the read interface) from this one (a file path below the backend). Followed the manifest's SymbolLocator naming verbatim; worth a look if the collision reads badly.
  • The warm-up adds startup parses. The two Step 0 parse-budget tests in ServerTest counted from zero, so they now measure a startup baseline instead of hard-coding one — a change to the fixture project's files autoload no longer silently moves a budget it has nothing to do with.
  • The warm-up is synchronous, since Step 6's scheduler tier is deferred. This repository's own autoload.files set is 12 files / 139 KB, ~35 ms at §8.1's measured 4 MB/s.
  • New fixtures tests/Fixtures/AutoloadFiles/{globals,helpers}.php are outside src/ and reached only through the files autoload, so they are absent from the PSR-4 corpus and move no golden. tests/Fixtures needs a composer dump-autoload (or install) to pick them up.

Candidate closes (pending review verification): the manifest lists #239 / #181 / #317 as landing somewhere in S3.7–S3.10; #181 (autoload.files reach) is the plausible one here, but I have not read the issues, so none are wired.

🤖 Generated with Claude Code

Firehed and others added 8 commits August 1, 2026 08:40
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 1, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.61%. Comparing base (7c91158) to head (f6d5e6f).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@             Coverage Diff              @@
##               main     #388      +/-   ##
============================================
+ Coverage     98.55%   98.61%   +0.05%     
- Complexity     1753     1794      +41     
============================================
  Files           115      120       +5     
  Lines          4434     4534     +100     
============================================
+ Hits           4370     4471     +101     
+ Misses           64       63       -1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

Firehed and others added 21 commits August 1, 2026 12:28
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PHP matches namespace names case-insensitively for every kind; only a
constant's final segment is case-sensitive. Keying the whole FQN
case-sensitively made \FIXTURES\HELPERS\HELPER_LIMIT unresolvable even
though PHP resolves it to the declared \Fixtures\Helpers\HELPER_LIMIT.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both invalidation tests passed under mutation. "At least one more parse"
could not tell re-reading the changed file from rescanning the whole set,
and a rebuild from wholly cached scans costs no parse either, so the
parse counter could not observe the autoload.files guard at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deleting the instanceof Invalidatable branch left the suite green. It is
the only wiring between a watched-file event and the autoload.files index,
and it has no production consumer until S3.8, so no integration test could
have caught its loss indirectly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Flipping ??= to = left the suite green: every autoload.files fixture
declares disjoint names, so nothing distinguished first-wins from
last-wins. The new fixture is read as data only and stays out of the
files autoload, since PHP could not load both entries anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The fixture project's autoload.files all sit outside vendor/, so warming
only the first warmable left the suite green. Partitioning at
AutoloadFiles/ puts the sole entry in the vendor half.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The per-message budgets subtract startupParseCount(), which measures the
same path, so any startup regression cancels and is invisible there.
Unwiring KnowledgeWarmer entirely left the suite green, as would a warm-up
that walked vendor/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Asserting the absence of a runtime-concatenated name can never fail, so
recording a placeholder for the computed site left both named tests green.
The constant count catches it; the locator-level test says where its force
comes from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The superseded ComposerClassLocatorTest asserted which file the classmap
resolved to; its successor kept only assertNotNull, so a QualifiedName
conversion that mangled the namespace could still pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A guarded polyfill is the shape most real autoload.files entries take, and
its declaration is nested rather than top-level. Nothing covered it, so
walking only top-level statements would have silently dropped every one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The recorded reason — that the project root is unknown until initialize —
is not true of this server: forProject takes the root as an argument and
builds the whole stack before reading the transport. Nothing in src/ reads
rootUri or workspaceFolders. The real reason is that initialized is the
first point with no client request outstanding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The docs enumerated only the computed-define carve-out, so an entry that
merely requires a sibling read as covered. It is not: this repository's own
symfony/polyfill-deepclone entry declares nothing and requires bootstrap81,
where every symbol lives.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ternary filed every non-function kind under 'constant'. Unreachable
today, but a restructuring of locate()'s early return would have indexed
functions as constants silently rather than failing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This slice made file:// conversion one seam and migrated the two internal
call sites, leaving the one whose output reaches the client. A definition
response for a workspace under a path with a space emitted an unencoded,
invalid URI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An intersection mock satisfies PHPUnit at runtime but is a bare MockObject
to static analysis, so the backendWithLocator() argument failed level max.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Firehed added a commit that referenced this pull request Aug 3, 2026
Re-scopes the remaining Step 3b slices so each is reviewable as one
unit, and records
two decisions found while sizing them.

## Why

S3.7 was built as a single slice (#388: 622 src lines over 17 files, 30
commits) and is
too large to review as one unit. The seam it missed is already in the
code: a class-like
lookup is arithmetic on the name (`findFile` — five lines, the old
`ComposerClassLocator`
verbatim), while a function or constant lookup has no name→file map and
must derive one
by parsing the `autoload.files` set. Those are two different reviews.

Sizing the rest of Step 3b against the merged slices (S3.2 +58/−40, S3.4
+171/−28,
S3.1 +305/−47) showed S3.8 and S3.9 heading the same way.

## What changes

- **S3.7 → S3.7a–d.** `autoload.files` into the autoload map; the
per-file declaration
scan; the kind-agnostic `SymbolLocator` with its class-like branch
(behavior-preserving,
proven by the existing golden); the derived function/constant index (new
behavior, new
  fixtures). S3.7a and S3.7b are independent inputs to S3.7d.
- **S3.8 → S3.8a–c, cut by symbol namespace rather than by layer.** A
layer cut would land
`SymbolBackend` methods no backend implements. S3.8c carries the
`SymbolResolver` edit,
  so S4.2 now serializes against it rather than against S3.8 as a whole.
- **S3.9 → S3.9a–b, cut on provability.** S3.9a widens
`searchClassLikes` to a kind
parameter with every Step P golden frozen; S3.9b makes the backends
answer function
  search and rewrites only the function-surface golden.
- **SC.4 added.** `file://` conversion is hand-rolled in four live
places, each differing
in how it handles the scheme and percent-encoding. Pre-existing
duplication owned by no
step, which is what the SC.* block is for. S3.7c is gated on it rather
than adding a
  fifth copy.

## Decisions recorded

- **The `autoload.files` warm-up is declined**, with numbers (0002 §3).
It was never a
correctness requirement — an unwarmed locator answers identically — so
it only moves
which message pays a one-off cost. At §8.1's measured ~4 MB/s that cost
is ~35 ms here
(~7 ms excluding PHPUnit's `Assert/Functions.php`, which is 111 KB of
the 139 KB).
Not worth ~105 lines, a second `InitializedListener`, and a non-obvious
ordering rule.
A reopen condition is named, and `warm()` changes no interface, so
re-adding it later
  is free.
- **`ConstantName` is already taken** by `Domain\ConstantName` (class
constants), which
collides with §5.3's global-constant FQN type. Flagged to be decided
before S3.8b
  rather than inside it.

Also corrects the name-type JIT note: `NameKind` already exists, and
`QualifiedName`
lands in S3.7c rather than S3.8.

Docs only — no `src/` or `tests/` changes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant