# PHPantom — Performance

Internal performance improvements that reduce latency, memory usage,
and lock contention on the hot paths. These items are sequenced so
that structural fixes land before features that would amplify the
underlying costs (parallel file processing, full background indexing).

Items are ordered by **impact** (descending), then **effort** (ascending)
within the same impact tier.

| Label      | Scale                                                                                                                  |
| ---------- | ---------------------------------------------------------------------------------------------------------------------- |
| **Impact** | **Critical**, **High**, **Medium-High**, **Medium**, **Low-Medium**, **Low**                                           |
| **Effort** | **Low** (≤ 1 day), **Medium** (2-5 days), **Medium-High** (1-2 weeks), **High** (2-4 weeks), **Very High** (> 1 month) |

---

## P3. Parallel pre-filter in `find_implementors`

**Impact: Medium · Effort: Medium**

`find_implementors` Phase 3 reads every unloaded classmap file
sequentially: `fs::read_to_string`, string pre-filter for the target
name, then `parse_and_cache_file`. On a project with thousands of
vendor classes, this loop is dominated by I/O latency. The string
pre-filter rejects most files (the target name appears in very few),
so the vast majority of reads are wasted.

### Fix

Split Phase 3 into two sub-phases:

1. **Parallel pre-filter.** Collect the candidate paths into a
   `Vec<PathBuf>`, then use `std::thread::scope` to read files and
   run the `raw.contains(target_short)` check in parallel. Return
   only the paths that pass the filter along with their content.

2. **Sequential parse.** For the (few) files that pass, call
   `parse_and_cache_file` sequentially. This step mutates `uri_classes_index`
   and calls `class_loader`, which may re-lock shared state.

The same pattern applies to Phase 5 (PSR-4 directory walk for files
not in the classmap). The pre-filter I/O is the bottleneck; the
parse step processes very few files and is fast.

### Trade-off

Thread spawning overhead is only worthwhile when the candidate set
is large. Skip parallelism when the candidate count is below a
threshold (e.g. 8 files).

---

## P4. `memmem` for block comment terminator search

**Impact: Low-Medium · Effort: Low**

The current block comment skip in `find_classes` and `find_symbols`
uses `memchr(b'*', ...)` and then checks the next byte for `/`.
This is effective but can false-match on `*` characters inside
docblock annotations (e.g. `@param`, `@return`, starred lines).
Each false match falls through to a single-byte advance, which is
correct but suboptimal for large docblocks.

### Fix

Replace `memchr(b'*', ...)` with `memmem::find(content[i..], b"*/")`.
This searches for the two-byte sequence `*/` directly, skipping all
intermediate `*` characters in a single SIMD pass. The `memmem`
searcher is already imported and used for keyword pre-screening.

For typical PHP files this is a marginal improvement. For files with
very large docblocks (e.g. generated API documentation classes with
hundreds of `@method` tags), it avoids O(n) false `*` matches inside
the comment body.

---

## P5. `memmap2` for file reads during scanning

**Impact: Low-Medium · Effort: Low**

All file-scanning paths (`scan_files_parallel_classes`,
`scan_files_parallel_psr4`, `scan_files_parallel_full`, and the
`find_implementors` pre-filter) use `std::fs::read(path)` which
copies the entire file into a heap-allocated `Vec<u8>`. When the OS
page cache already has the file mapped, `memmap2` can provide a
read-only view of the file's pages without any copy.

### Fix

Add `memmap2` as a dependency. In the parallel scan helpers, replace
`std::fs::read(path)` with `unsafe { Mmap::map(&file) }`. The
`find_classes` and `find_symbols` scanners already accept `&[u8]`,
so the change is confined to the call sites.

### Safety

Memory-mapped reads are `unsafe` because another process could
truncate the file while the map is live, causing a SIGBUS. In
practice this does not happen during LSP initialization (the user is
not deleting PHP files while the editor starts). A fallback to
`fs::read` on map failure handles edge cases.

### When to implement

Profile first. On Linux with a warm page cache the difference
between `read` and `mmap` is small for files under ~100 KB (which
covers most PHP files). The benefit is more pronounced on macOS
where `read` involves an extra kernel-to-userspace copy. If
profiling shows that file I/O is no longer the bottleneck after
parallelisation, this item can be dropped.

---

## P6. O(n²) transitive eviction in `evict_fqn`

**Impact: Low-Medium · Effort: Low**

The `evict_fqn` function in `virtual_members/mod.rs` runs a
fixed-point loop that scans the entire resolved-class cache on each
iteration to find transitive dependents. In a large project with a
deep class hierarchy (common in Laravel codebases with hundreds of
Eloquent models), editing a base class can trigger a cascade of
evictions where each round does a full cache scan.

The `depends_on_any` helper also matches against both the FQN and
the short name of the evicted class, which increases the chance of
false-positive transitive evictions (e.g. two unrelated classes that
share a short name like `Builder`).

### Fix

Build a reverse-dependency index (`HashMap<String, Vec<String>>`)
that maps each FQN to the set of cached FQNs that directly depend
on it. Maintain this index alongside cache insertions and removals.
On eviction, walk the reverse index instead of scanning the entire
cache, turning the O(n²) loop into O(dependents).

If the reverse index is too much bookkeeping, a simpler first step
is to collect all dependents in a single pass (instead of the
current iterative fixed-point loop) by doing a breadth-first walk
of the dependency graph within the cache.

---

## P7. `diag_pending_uris` uses `Vec::contains` for deduplication

**Impact: Low · Effort: Low**

`schedule_diagnostics` and `schedule_diagnostics_for_open_files`
deduplicate pending URIs with `Vec::contains`, which is O(n) per
insertion. When a class signature changes, every open file is
queued, and each insertion scans the entire pending list.

For typical usage (< 50 open files) this is imperceptible. It
becomes measurable only with hundreds of open tabs and rapid
cross-file edits.

### Fix

Replace `Vec<String>` with `IndexSet<String>` (from `indexmap`) or
`HashSet<String>` + a separate `Vec<String>` for ordering. The
worker drains the collection on each wake, so insertion order is
not important and a plain `HashSet` suffices.

---

## P8. `find_class_in_uri_classes_index` linear fallback scan

**Impact: Low · Effort: Low**

The fast O(1) `fqn_index` lookup in `find_class_in_uri_classes_index` covers
the common case. The slow fallback iterates every file in `uri_classes_index`
linearly. The comment says this covers "race conditions during
initial indexing" and anonymous classes.

During initial indexing with many files open, the fallback could
cause micro-stutters if the `fqn_index` has not been populated yet
for a requested class. In steady state the fallback is rarely hit.

### Fix

Audit the code paths that can reach the fallback to determine
whether they are still reachable after the `fqn_index` was added.
If they are not, replace the fallback with a `None` return and a
debug log. If they are, consider populating `fqn_index` earlier in
the pipeline (e.g. during the byte-level scan phase) to close the
window.

---

## P9. `resolved_class_cache` generic-arg specialisation

**Impact: Medium · Effort: Medium**

The resolved-class cache is keyed by `(FQN, Vec<String>)`. Every
distinct generic instantiation of the same class (e.g.
`Builder<User>`, `Builder<Order>`, `Builder<Product>`) triggers a
full `resolve_class_fully` call, even though the base resolution
(inheritance merging, trait merging, virtual member injection) is
identical. Only the final generic substitution differs.

In a Laravel codebase with hundreds of Eloquent models, this means
`Builder` is fully resolved hundreds of times, once per model.

### Fix

Cache the base-resolved class (before generic substitution)
separately, keyed by FQN alone. When a generic instantiation is
requested, look up the base-resolved class and apply
`apply_substitution` on top. The substitution step is cheap
(tree walk) compared to the full resolution (inheritance walking,
trait merging, virtual member providers).

This requires splitting `resolve_class_fully` into two stages:
base resolution (cached by FQN) and generic specialisation (cached
by `(FQN, Vec<String>)` as today, but with a much cheaper miss
path).

---

## P11. Uncached base-resolution in `build_scope_methods_for_builder`

**Impact: Low-Medium · Effort: Low**

`build_scope_methods_for_builder` calls
`resolve_class_with_inheritance` (base resolution) for the model
class. This is not covered by the thread-local resolved-class
cache, which stores fully-resolved classes (after virtual member
injection), not base-resolved ones.

Every time an Eloquent `Builder<Model>` is resolved with scope
injection, the model is base-resolved from scratch. With many
Builder instantiations in a single file this adds up.

### Fix

Either introduce a separate base-resolution cache (keyed by FQN),
or restructure so `build_scope_methods_for_builder` accepts the
already-resolved model class from the caller (which may already
have it from the resolved-class cache).

---

## P12. `find_or_load_function` Phase 1.75 serial bottleneck

**Impact: Low · Effort: Low**

For the first unknown function that misses both `global_functions`
and `autoload_function_index`, Phase 1.75 iterates all known
autoload file paths and calls `update_ast` on each unparsed one
until the function is found. With ~50 autoload files this is a
one-time cost per thread, but it blocks the thread while it
happens.

### Fix

Pre-load all autoload files during initialisation (in the
`initialized` handler, after the byte-level scan). This moves the
cost to startup, where it can run in parallel with other init work,
and eliminates the blocking fallback during interactive use.

---

## P14. Eager docblock parsing into structured fields

**Impact: Medium · Effort: Medium**

> **Note.** This item needs refinement when we work on it. The
> codebase and feature set may change significantly before then.

Currently `ClassInfo::class_docblock` stores the raw docblock
string. Every consumer that needs virtual members (`@method`,
`@property`, `@property-read`, `@property-write`) re-parses the
raw text via `PHPDocProvider`. Hover, completion, and diagnostics
all trigger this independently.

Parse the class-level docblock once during extraction and store the
structured results directly on ClassInfo:

- A list of parsed `@method` signatures (name, parameters, return
  type, static flag, description).
- A list of parsed `@property` / `@property-read` /
  `@property-write` entries (name, type, access mode, description).

This has three benefits:

1. **Drop the raw string.** For heavily-annotated classes (Eloquent
   models, facades) the raw docblock can be hundreds of bytes.
   The structured representation may be comparable in size but is
   directly usable without re-parsing.

2. **Eliminate repeated parsing.** Virtual member resolution
   currently re-parses the same docblock text on every completion,
   hover, and diagnostic pass. Parsing once during extraction
   removes this redundant work.

3. **Simpler consumer code.** Consumers iterate structured fields
   instead of calling into the docblock parser. This removes the
   lazy-parse indirection and makes the data flow easier to follow.

The same principle applies to other docblock data that is currently
extracted from raw text at multiple read sites (descriptions, link
URLs, see references), though those are smaller wins.

---

## P15. Two-phase stub index construction (eliminate `RwLock` on stub maps)

**Impact: Low · Effort: Medium**

The three stub indexes (`stub_index`, `stub_function_index`,
`stub_constant_index`) are write-once-read-many maps. They are
populated at construction time from the compiled-in phpstorm-stubs
arrays, then filtered once in `set_php_version` (called during
`initialized`) to evict entries with `@removed X.Y` tags. After
that single mutation they are never written again.

Because the PHP version is not known at construction time (it comes
from `composer.json` / `.phpantom.toml`, read during `initialized`),
the maps are currently wrapped in `parking_lot::RwLock` so that
`set_php_version` can call `.write().retain(…)`. Every subsequent
read — ~24 call sites across completion, resolution, diagnostics,
hover, and definition — acquires a shared read lock. On the
uncontended path this is a single atomic CAS (~1-5 ns), so the
cost is negligible in practice, but it is architecturally wasteful
for data that never changes after startup.

### Ideal solution

Split `Backend` construction into two phases so that the stub maps
are plain `HashMap`s with zero synchronisation cost on reads:

1. **Phase 1 — skeleton construction.** Create the `Backend` with
   empty (or placeholder) stub maps. No `RwLock` needed because
   nothing reads them yet.

2. **Phase 2 — version-aware population.** In `initialized`, after
   detecting the PHP version, build the filtered maps (applying
   `is_stub_function_removed` / `is_stub_class_removed` during
   construction rather than via `retain`) and store them on the
   backend through a one-shot setter that consumes the maps by
   value.

The setter could use `std::sync::OnceLock<HashMap<…>>` (or simply
an `UnsafeCell` behind a "set-exactly-once" assertion) to make the
write safe without ongoing read-side cost. Alternatively, the
fields can stay as plain `HashMap` if the `Backend` struct is built
in `initialized` rather than `initialize` — moving construction
after the version is known.

### Prerequisites

This interacts with the test helpers (`new_test`,
`new_test_with_stubs`, etc.) which currently call
`set_php_version` in the constructor. They would need to accept
a `PhpVersion` parameter or build the filtered maps inline.

### When to implement

Low priority. The current `RwLock` overhead is unmeasurable in
practice (~10-20 ns per completion request). Worth revisiting if
the stub indexes grow significantly or if `Backend` construction
is restructured for other reasons.

---

## P16. Pre-parsed stub format (eliminate raw PHP embedding)

**Impact: High · Effort: Medium-High**

The 630 phpstorm-stubs PHP files are embedded as raw source via
`include_str!` (~9.8 MB in `.rodata`). This has three costs:

1. **Permanent RSS.** The 9.8 MB is memory-mapped into every
   process regardless of how many stubs are actually accessed.
   That is ~17% of the current 59 MB baseline and will become a
   larger relative share as vendor indexing grows the working set.

2. **Parse cost on first access.** Each stub is parsed with the
   full mago parser on first use (`parse_and_cache_content_versioned`).
   Large files like `intl.php` (296 KB) take several milliseconds.
   A Symfony project can trigger hundreds of stub parses as vendor
   classes extend built-in types.

3. **Duplicate data.** After parsing, the `Arc<ClassInfo>` lives in
   `uri_classes_index` and `fqn_index`, but the raw PHP source stays resident
   in `.rodata` forever. Both copies exist simultaneously.

### Indexing order: stubs → vendor → user

Background indexing will load data in dependency order:

1. **Stubs** (built-in PHP classes, functions, constants)
2. **Vendor** (Composer dependencies)
3. **User** (project source)

This ordering means every layer's parent types are already
resolved before it starts. Vendor classes that extend `ArrayAccess`,
`Iterator`, `JsonSerializable`, etc. find pre-populated
`fqn_index` entries instead of triggering on-demand stub parses.
User classes that extend vendor classes find those already indexed
too.

With the current raw-PHP stubs, the stubs phase itself involves
parsing ~530 PHP files through the full mago pipeline. In a
pre-parsed format, this phase becomes a single deserialization
step (~5-10 ms), making the stubs layer essentially free and
letting vendor indexing start immediately.

### Cascade cost during first-file-open

When the user opens a file before background indexing completes,
the completion/hover path walks type chains synchronously. A
typical Laravel file triggers a cascade like:

- Model → `find_or_load_class` → classmap → parse vendor PHP
- Model implements `ArrayAccess`, `JsonSerializable`, `Countable`,
  uses `Traversable`, `Iterator`, `Stringable`, etc.
- Each of these hits Phase 3 (stub lookup) → full mago parse of
  the stub file containing it
- Stub files contain multiple classes, so parsing `SPL/SPL.php`
  for `ArrayAccess` also parses `Iterator`, `Countable`,
  `SeekableIterator`, etc.

A realistic first-open cascade triggers 20-40 stub file parses,
costing 40-200 ms of CPU time on the critical path. With
pre-parsed stubs, each stub lookup becomes a `HashMap::get`
returning an `Arc<ClassInfo>` in nanoseconds, eliminating this
cost entirely.

### Solution

Parse all stubs at build time in `build.rs` (mago becomes a build
dependency) and serialize the extracted `ClassInfo`, `FunctionInfo`,
and constant data into a compact binary blob using postcard (or
bincode). Embed the blob via `include_bytes!`. At startup,
deserialize the blob and populate `fqn_index` directly.

**Version filtering.** Add `since: Option<PhpVersion>` and
`until: Option<PhpVersion>` fields to `MethodInfo`, `ParameterInfo`,
`FunctionInfo`, `ClassInfo`, and `ConstantInfo`. Embed one
"maximal" blob containing all version variants. After
deserialization, filter elements whose version range excludes the
target PHP version. This replaces both the current byte-level
`@removed` scanning at startup and the `is_available_for_version`
AST filtering at parse time.

**Serde on the type hierarchy.** Add `#[derive(Serialize, Deserialize)]`
to the core structs (`ClassInfo`, `MethodInfo`, `PropertyInfo`,
`ConstantInfo`, `FunctionInfo`, `ParameterInfo`, and their
supporting enums). `SharedVec<T>` needs a custom serde impl that
serializes as `Vec<T>` and deserializes into `SharedVec::from(vec)`.

**What gets removed:**

- The `STUB_FILES` array (raw PHP source embedding)
- The `phpantom-stub://` URI scheme and associated `uri_classes_index` entries
- The `parse_and_cache_content_versioned` path for stubs
- The `is_stub_function_removed` / `is_stub_class_removed` byte
  scanners (replaced by version fields on deserialized structs)
- The `set_php_version` retain-based eviction (replaced by
  post-deserialize filtering)

**Go-to-definition.** Stubs are in-memory-only; the IDE cannot
navigate to them anyway. No raw source needs to be preserved.

**Hover.** The extracted fields (`class_docblock`, `deprecation_message`,
`links`, `see_refs`, parameter type hints and names) are all
carried in the serialized structs. Hover quality is preserved.

### Estimated impact

- **Binary:** −9.8 MB raw PHP, +2-3 MB serialized blob = net −7 MB
- **RSS:** 9.8 MB `.rodata` no longer mapped; stubs loaded as
  heap-allocated structs filtered to the target PHP version
- **First-file-open:** 40-200 ms of stub parse time on the
  critical path eliminated; stub lookups drop to nanoseconds
- **Background indexing:** stubs phase drops from seconds (parsing
  530 PHP files) to <10 ms (deserializing one blob), letting
  vendor indexing start immediately
- **Vendor indexing cascade:** every vendor class that extends a
  built-in type no longer triggers a stub parse; the parent
  `ClassInfo` is already in `fqn_index`
- **Build time:** clean builds gain 10-30 s for the mago parse
  step; incremental builds unaffected (`write_if_changed` caching)

### Prerequisites

- `serde` derive on the core type hierarchy (already in `Cargo.toml`)
- `build.rs` already downloads stubs and generates code; extending
  it to parse PHP is incremental
- Interacts with P15 (stub index `RwLock` elimination): if stubs
  are deserialized eagerly, the two-phase construction in P15
  becomes the natural approach

### When to implement

High priority. This is a prerequisite for efficient stubs → vendor
→ user indexing. The 9.8 MB static cost is already meaningful and
will become the dominant fixed overhead once vendor indexing is
deferred. Implementing this before full vendor indexing lands
avoids hitting the memory ceiling and ensures the stubs layer is
essentially free for both eager and deferred indexing paths.

---

## P17. `mago-names` resolution on the parse hot path

**Impact: Medium · Effort: Low**

The `mago-names` name resolver runs synchronously inside
`update_ast_inner`, adding a full AST walk plus an owned `HashMap`
copy on every `didChange` event. Measured regression from `6a0737a`
("Migrate to use mago-names"):

| Benchmark        | Before | After | Δ    |
| ---------------- | ------ | ----- | ---- |
| with_narrowing   | 12 ms  | 15 ms | +25% |
| 5_methods_chain  | 8 ms   | 10 ms | +25% |
| carbon_class     | 250 ms | 340 ms | +36% |
| large_file       | 150 ms | 210 ms | +40% |

The resolved names are currently consumed only by diagnostics (which
run asynchronously) and `FileContext::resolve_name_at()`. Nothing on
the completion hot path requires this data to be computed eagerly.

### Fix

Defer name resolution out of `update_ast_inner`. Options:

- **Lazy resolution:** compute `OwnedResolvedNames` on first access
  per file version, invalidate on the next `update_ast`. Moves the
  cost off the typing hot path entirely.
- **Diagnostic-worker resolution:** run the resolver in the
  diagnostic worker clone of `Backend`, since diagnostics are the
  primary consumer.

### When to implement

Low priority. The `mago-names` migration is complete, but the
`use_map` is still used by several consumers. Further refactoring
(migrating more consumers to byte-offset lookups, eventually
removing `use_map`) will change the access patterns. Optimizing
now would likely be reworked. Revisit once `use_map` usage is
significantly reduced.

---

## P18. Subtype result caching

**Impact: Medium · Effort: Low**

PHPStan caches subtype check results (`isSuperTypeOf()`) in a static
`HashMap` keyed by type description strings. This avoids redundant
class hierarchy walks when the same type pair is checked multiple
times during a single request. PHPantom resolves class hierarchies
repeatedly during completion (checking if a method override is
covariant, checking if a class implements an interface, etc.). A
per-request `HashMap<(String, String), bool>` cache for subtype
results would reduce redundant hierarchy walks.

PHPStan also uses a `hasTemplateOrLateResolvableType()` fast-path
to skip expensive type traversal when a type has no template
parameters. PHPantom could add a similar flag to its type
representations to short-circuit template substitution on simple
types. Most types in a typical codebase are concrete (no generics),
so this fast-path would apply to the majority of checks.

### Fix

1. Add a thread-local or per-request
   `HashMap<(String, String), bool>` that caches the result of
   "is type A a subtype of type B?" lookups. Clear the map at the
   start of each completion/hover/diagnostic request.

2. Add a `has_template_params: bool` flag (or equivalent) to
   `ClassInfo` or type representations. Set it during parsing when
   `@template` tags or generic syntax are present. Before running
   `apply_substitution`, check the flag and skip the substitution
   walk entirely when it is `false`.

3. Intern class name strings. PHPantom creates many copies of the
   same class name (e.g. `"Illuminate\\Database\\Eloquent\\Builder"`)
   across `ClassInfo`, type strings, and lookup keys. Mago already
   uses `Atom` (an interned string type) in its crates, and names
   flowing through `mago-names` / `mago-syntax` are already atoms.
   Using `Atom` or `Arc<str>` for class names in PHPantom's own
   data structures would reduce memory and make the subtype cache
   keys cheaper to hash and compare. Now that `PhpType` is the
   structured type representation throughout the codebase, interning
   the name strings inside each `PhpType` node (replacing owned
   `String` with `Atom` or `Arc<str>`) is a natural next step.

---

## Appendix: Profiling

### Commands

```sh
# Record (Ctrl-C after ~60s):
perf record -g --call-graph dwarf -- \
  ./target/release/phpantom_lsp analyze \
  src/core/Purchase/Services/PurchaseFileService.php

# Text report (top functions):
perf report --stdio --no-children | head -80

# Flamegraph (requires the `flamegraph` crate or perf-tools):
perf script | flamegraph > /tmp/phpantom.svg
```

### Pathological test file

`PurchaseFileService.php` (~700-line Eloquent-heavy service with
~55 imports) is the most expensive single file encountered so far.
The per-collector timing is controlled by a `>= 2s` threshold in
`src/analyse.rs` Phase 2 (search for `⏱`). It prints a breakdown
like:

```
⏱  63.2s  src/core/Purchase/Services/PurchaseFileService.php
  [fast=1ms cls=40ms mem=23696ms fn=12ms unres=16781ms arg=22568ms impl=0ms depr=54ms]
```

---

## P19. Arena reuse on the parse hot path

**Impact: Medium · Effort: Low**

`update_ast_inner` creates a fresh `Bump::new()` on every call —
every keystroke allocates new backing pages from the OS and every drop
returns them via `mmap`/`munmap` syscalls. There are 12+ additional
`Bump::new()` sites across code action helpers (`extract_function.rs`,
`extract_constant.rs`, `change_visibility.rs`, `generate_constructor.rs`,
`extract_variable.rs`, etc.). No call site reuses an arena.

php-lsp's `ParserContext::reparse()` calls `arena.reset()` — an O(1)
operation that keeps backing memory allocated and just resets the bump
pointer. After the arena grows to fit the largest file, subsequent
re-parses are allocation-free from the system allocator's perspective.

### Fix

Add a `thread_local!` arena that is reset (not dropped) after each
use:

```rust
thread_local! {
    static ARENA: RefCell<Bump> = RefCell::new(Bump::with_capacity(512 * 1024));
}

fn with_arena<R>(f: impl FnOnce(&Bump) -> R) -> R {
    ARENA.with(|cell| {
        let result = f(&cell.borrow());
        cell.borrow_mut().reset(); // O(1) — keeps pages allocated
        result
    })
}
```

`update_ast_inner` is the hot path — called on every keystroke. A
512 KB arena that resets instead of reallocating avoids thousands of
`mmap`/`munmap` syscalls per minute during active editing.

### Safety

The arena's lifetime must not escape the closure. Currently
`update_ast_inner` extracts owned `ClassInfo` and `SymbolMap` from
the AST before the arena is dropped, so this works. The code action
helpers also extract owned data before returning.

### When to implement

Independent of all other items. Pure performance win with no
behavioural change. Can land any time.

---

---

## P20. Content-hash gated resolution cache persistence

**Impact: Medium · Effort: Medium**

The resolved-class cache (`resolved_class_cache`) is ephemeral — it
lives only for the duration of the process. On LSP restart or cold
start, all class resolution (inheritance merging, virtual members,
template substitution) is re-computed from scratch even when files
haven't changed.

**Fix:** Persist resolved `ClassInfo` entries to a project-local cache
directory, keyed by `xxh128(file_contents)`. On startup, walk the
project, compare content hashes, and load cached entries for unchanged
files. Only re-resolve classes whose source files (or dependency files)
have changed.

Psalm implements exactly this pattern with three cache layers:
- Parser cache (serialized AST, keyed by file content hash)
- File storage cache (classes-in-file, functions, constants)
- ClassLike storage cache (methods, properties, template types,
  parent chains — keyed by `xxh128(file_contents)`)

Each layer checks the hash on load and discards stale entries. Schema
versioning (tracking `filemtime` of the storage struct source files)
auto-invalidates all caches when internal types change.

**Design:**

1. Use `bincode` serialization (already evaluated in X6) for
   `ClassInfo` entries.
2. Key: `(fqn, content_hash)` → serialized `ClassInfo`.
3. On startup: load cache entries where content hash matches current
   file. Skip resolution for those classes entirely.
4. On file change: evict entries for the changed file AND entries
   whose classes depend on changed members (using the existing
   dependency tracking from ER4).
5. Schema version: embed a version constant derived from `ClassInfo`
   struct layout. Invalidate entire cache on version mismatch.

**Relationship to X6:** X6 (disk cache) is the broader evaluation of
whether disk caching is worthwhile. P20 is the specific application
to resolved-class storage, which is the most expensive thing to
recompute. P20 can ship independently as a targeted optimization
even if the broader X6 evaluation concludes that full disk caching
isn't needed.

**References:**
- Psalm: `ClassLikeStorageCacheProvider` in
  `references/psalm/src/Psalm/Internal/Provider/ClassLikeStorageCacheProvider.php`
- Psalm: `FileStorageCacheProvider` for the content-hash invalidation
  pattern

---

## P21. Offset-shifting for cached diagnostics on partial edits

**Impact: Medium · Effort: Medium**

When a user edits one method in a file, PHPantom currently re-runs
diagnostics on the entire file. For large files (500+ lines), this
is wasteful — diagnostics in unchanged regions are still valid, just
at shifted byte offsets.

**Fix:** After a file edit, compute a line-level diff (Myers algorithm)
to produce byte-offset shift deltas. Apply the deltas to cached
diagnostics in unchanged regions. Only re-diagnose methods whose
byte ranges overlap with the edited region.

Psalm implements this with:
1. `FileDiffer` — Myers line-level diff producing byte-offset ranges
2. `FileStatementsDiffer` — AST-level statement diff classifying
   statements as keep/keep_signature/add_or_delete
3. `shiftFileOffsets()` — shifts surviving diagnostics/references by
   the offset delta, removes those in deleted ranges

**Design:**

1. On `didChange`, compute a line diff between old and new content.
2. Produce a `diff_map: Vec<(old_start, old_end, offset_delta)>`.
3. Walk cached diagnostics for this file:
   - If diagnostic span falls in a deleted range → remove it.
   - If diagnostic span is after the edit → shift by delta.
   - If diagnostic span is before the edit → keep as-is.
4. Re-run diagnostics only for methods/functions whose spans overlap
   with changed regions (use the member-level AST diff from ER4's
   incremental repopulation).
5. Merge shifted cached diagnostics with freshly-computed ones.

**Prerequisites:** The incremental repopulation (ER4) already
identifies which members changed. This task extends that to the
diagnostic layer.

**References:**
- Psalm: `FileDiffer` and `FileStatementsDiffer` in
  `references/psalm/src/Psalm/Internal/Diff/`
- Psalm: `Analyzer::shiftFileOffsets()` for the offset-shifting logic

---

# Remaining anti-pattern fixes

Most remaining depth-cap issues are addressed by ER5 (class
resolution). The forward walker loop iteration was addressed by the
assignment-depth-bounded strategy. The items below are independent
fixes that do not depend on either.
