Skip to content

fix: respawn on same-file reflection staleness and post-boot classes#5

Open
fdaviddpt wants to merge 1 commit into
mainfrom
fix/reflection-staleness-same-file
Open

fix: respawn on same-file reflection staleness and post-boot classes#5
fdaviddpt wants to merge 1 commit into
mainfrom
fix/reflection-staleness-same-file

Conversation

@fdaviddpt

Copy link
Copy Markdown
Contributor

What broke

Two ways a warm worker served answers that disagreed with a cold phpstan run on the same file. Both were found from the consumer side — the validator kept reporting errors I had just fixed, and a fresh run said PASS.

1. Same-file reflection staleness. 0.6.0 deliberately excluded the analysis target from the staleness check:

The target itself is excluded (phpstan re-reads the analysed file's own AST each call)

That is true of the AST and false of the reflection. After editing a class-level @extends generic, the worker kept resolving $this->options against the shape captured at first analysis — so a correct fix kept reporting the error it had just fixed. The symmetric case is worse and silent: a newly introduced shape error would not be reported at all.

2. Classes created after boot are invisible. The worker's analysable file set is fixed at boot, so a class added afterwards reads as extends unknown class Foo for a file sitting on disk that a cold run resolves fine — with phantom errors at stale line numbers on top.

The fix

Structural fingerprint for the target. Declarations, signatures, property types and docblocks, with function bodies stripped (token_get_all, skipping from a function's { to its matching }). A changed fingerprint respawns the worker. A body edit leaves it identical, so the edit-check-edit loop on one file stays warm — which is the whole point of the package.

One respawn on class.notFound. Content alone cannot distinguish "created after boot" from "genuinely missing", so pay one respawn and report the fresh worker's verdict. Bounded to a single retry per call.

MCP_PHPSTAN_PHPSTAN_BIN. Found while building the harness, and a real bug on its own: findPhpstanBin() resolved phpstan from the server's vendor directory, so a global install analyses a project with the wrong phpstan — none of the project's extensions or custom rules, far fewer errors, no complaint.

Evidence

Against a real project (~14k PHP files), same config and paths, one warm server per run:

Scenario 0.6.0 this branch
Class created after boot class.notFound ×2 + phantom return.missing clean
Genuine shape mismatch (masked by the above) offsetAccess.notFound — still caught
After fixing the @extends generic same errors, repeated clean

Warm-path cost, measured on the same server:

[cold]        first analyse :   6.4s
[warm]  body edit #1 analyse:   0.0s
[warm]  body edit #2 analyse:   0.0s
[warm]  body edit #3 analyse:   0.0s
[respawn] signature change  :   6.1s

A note on the harness. Its first version reported CLEAN for every scenario, which looked like a total fix. It was a broken harness: the dev-repo server resolved its own phpstan, which loads none of the project's extensions, so it analysed almost nothing. A control case — a blatant return 42; in a : string method — caught it. That control is why MCP_PHPSTAN_PHPSTAN_BIN exists in this PR, and it is worth keeping in mind for any future benchmark here: a warm-process server that quietly does less work looks exactly like a fast one.

Tests

11 new unit cases in PhpstanRunnerStalenessTest — body-only edits keep the fingerprint stable; class-level generics, method signatures and property types change it; formatting and line comments do not; a bodyless abstract method does not derail the token scan; plus the binary-override contract. Full suite 25/25, including the 0.6.0 cross-file regression test.

0.6.0 excluded the analysis target from the staleness check, reasoning that
phpstan re-reads the analysed file. That holds for the file's AST and not for
its reflection. Editing a class-level @extends generic left the worker
resolving $this->options against the shape captured at first analysis, so a
correct fix kept reporting the error it had just fixed — and a newly
introduced shape error would have been missed just as silently.

The target is now fingerprinted by structure: declarations, signatures,
property types and docblocks, with function bodies stripped. A changed
fingerprint respawns the worker; a body edit does not, so the
edit-check-edit loop on one file stays warm (measured 0.0s warm, ~6s only
when the structure actually moves).

Second bug, same root: the worker's analysable file set is fixed at boot, so
a class created afterwards reads as "unknown class" for a file that a cold
run resolves fine, with phantom errors at stale line numbers on top. Content
alone cannot separate that from a genuinely missing class, so a result
containing class.notFound now buys one respawn and reports the fresh
worker's verdict.

Third, found while building the end-to-end harness: findPhpstanBin resolved
phpstan from the server's own vendor directory, so a global install analyses
with the wrong phpstan — no project extensions, no custom rules, far too few
errors, and no complaint. MCP_PHPSTAN_PHPSTAN_BIN now takes precedence and
fails loudly on a bad path.

Verified against a real project (~14k PHP files): both failures reproduce on
0.6.0 and are clean after, with a control proving the harness actually
analyses — the first version of it reported CLEAN for everything because it
was silently using the wrong phpstan.

Co-Authored-By: Max <noreply>
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