Skip to content

refactor(hooks): consolidate proceed() via getOriginalCallable, stub-type the hook layer, final concrete hooks - #205

Merged
lisachenko merged 6 commits into
8.4from
claude/php-repo-modernization-6pfu7y-hook-consolidation
Aug 14, 2026
Merged

refactor(hooks): consolidate proceed() via getOriginalCallable, stub-type the hook layer, final concrete hooks#205
lisachenko merged 6 commits into
8.4from
claude/php-repo-modernization-6pfu7y-hook-consolidation

Conversation

@lisachenko

@lisachenko lisachenko commented Aug 13, 2026

Copy link
Copy Markdown
Owner

Stacked on #197 — contains its commits; merge #197 first and this diff reduces to the hook-consolidation changes.

Six focused refactors of the engine hook layer (src/ClassExtension/Hook, src/System/Hook), no behavior change. #197 had just rewritten the fake-scope handling in seven of these hooks, so this builds on that shape rather than fighting it.

What changed

1. One proceed() prologue instead of twenty-two. Every hook hand-rolled if (!$this->hasOriginalHandler()) { throw new \LogicException('Original handler is not available'); } followed by an untyped ($this->originalHandler)(...) call. AbstractHook::getOriginalCallable() already is that prologue — it throws the same exception and returns a value narrowed to callable — and two hooks used it already. The rest are converted; five hooks that already called it kept a now-dead guard in front, which is deleted. CreateObjectHook keeps its two-branch shape (it falls back to ReflectionClass::newInstanceRaw() when no handler was captured) but asks hasOriginalHandler() instead of comparing the raw property.

2. Return types on the converted proceed() methods. Half of them were bare public function proceed(). Each type is derived from the C callback typedef the class documents and from what the value actually is: int for the verdict-returning handlers (compare, do_operation, interface_gets_implemented, cast_object), ?object for the pointer-returning ones (get_property_ptr_ptr, get_properties_for), object for write_property (zend_std_write_property reports the written slot or &EG(error_zval), never NULL), void for unset_property and zend_ast_process, mixed for accessors that materialize an engine value into a PHP one. GetPropertyPointerHook::handle() deliberately stays mixed: its value comes from the user handler, and a stricter declaration would turn a userland contract violation into a TypeError raised inside an FFI trampoline where nothing can catch it (issue #50).

3. Stub-typed engine state. The hook layer was the one subsystem that missed the generated-struct-stub cutover, which is where the bulk of its baseline came from: the libffi trampoline delivers callback arguments as mixed, and every assignment into a bare FFI\CData field produced an assign.propertyType entry. Applied the AGENTS.md convention exactly as Reflection\*/Type\* do it — the field is declared object and carries a @var docblock naming its ZEngine\Generated stub view, with one narrowing at the handle() boundary where the raw arguments arrive. That narrowing replaces the assert($x instanceof CData) scaffolding those statements carried (AGENTS.md names that weak form as what the stub migration supersedes); asserts that check real engine or handler invariants stay.

Handles the stubs deliberately do not model keep a CData declaration with a note saying why: the void ** property cache slot, the int * / zend_long * out-parameters of get_debug_info and count_elements, and the zend_object ** / zend_function ** / zend_class_entry ** double pointers of get_method and get_closure. AstProcessHook keeps a raw zend_ast handle too, because the AST wrappers that own that struct (NodeFactory/Node) have not been migrated yet.

Two owning boundaries were widened to the usual @param CData|Stub shape: Executor::setFakeScope()/withFakeScope() (every property hook now passes a typed $object->ce) and ReflectionClass::newInstanceRaw(). Also removed CompareValuesHook::$returnValue, declared but never read or written — compare_values has no result slot.

4. AbstractMethodResolutionHook goes through the owning reflection. resolveRawFunction() looked the class up in the engine class table, read ->function_table off the raw struct, took its address and wrapped it in a Type\HashTable — the "callers never reach into a raw CData" rule, and a duplicate of what ReflectionClass does when constructed. It is now (new ReflectionClass($className))->getMethodTable(): the constructor performs the class-table lookup and raises the "should be in the engine" ReflectionException itself, and getMethodTable() is the accessor that owns ce->function_table. Eleven lines become three.

On the alternative: PR #196 proposed a static entry-level helper (ReflectionClass::entryMethodTable()) for this shape. That helper is being withdrawn as against the framework's object model — consumers hold Reflection* objects, not raw entries plus static utilities — so this uses the instance API and takes no dependency on #196. The extra cost (a native reflection constructor plus three unused table wrappers) is paid only on the slow path: a wrapper produced by proceed() short-circuits on the identity fast path above it, and the VM inline-caches the resolved function per call site for compile-time constant method names.

5. IteratorBridge state is shared, not copied. The per-iteration record was an array shape, and PHP arrays are value types — so what each vtable callback read out of the registry was a snapshot, and marking an iteration broken had to go back through a second registry lookup by address with an isset() recheck around it. It is now a tiny @internal BridgedIterator with public typed properties: one lookup per callback preamble, and breakIteration() writes the flag straight through the record it is handed. The one observable difference is in the pathological case where the engine iterator is destroyed from inside the userland callback that then throws — the swallowed Throwable is now reported as the E_USER_WARNING it always should have been, instead of being dropped because the registry entry had already gone.

6. final on the concrete hooks. Every concrete hook is a leaf implementing one engine callback typedef; nothing in src/ or tests/ extends one, and the two that implement HookInterface directly (OpCodeHook, IteratorBridge) were final already. 27 more are sealed, which makes the extension point explicit: hooks are customized by passing a user-handler closure, not by subclassing.

Baseline delta

before after
phpstan-baseline.neon, total entries 401 315
src/ClassExtension/Hook + src/System/Hook 86 0

Entries were pruned by hand, verified with a throwaway override config setting reportUnmatchedIgnoredErrors: true (phpstan.dist.neon turns it off). Under that config the only remaining findings are the eight pre-existing unmatched inline @phpstan-ignore identifiers in CountElementsHook/GetClosureHook/GetMethodHook — unchanged in count and kind by this PR. Two further inline @phpstan-ignore callable.nonCallable comments were removed because the code no longer produces the error.

The five hook-related baseline entries that remain are outside this PR's scope: Hook\AbstractHook (2), Hook\HookInterface (1) and EngineExtension\Hook\ExtensionConstructorHook (2).

Validation

vendor/bin/phpstan analyse clean at level max and PHP_CS_FIXER_IGNORE_ENV=1 vendor/bin/php-cs-fixer fix --dry-run clean after every commit.

Tests were not run. This branch targets PHP 8.4 and the container runs PHP 8.5, so per the AGENTS.md version rule nothing that reaches Core::init() may be executed here — validation is static-only. CI runs the suite on the matching interpreter.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RnoZ7wuGepCTzsmFQ5sKxG

claude added 6 commits August 13, 2026 19:32
Twenty-two hooks hand-rolled the same prologue before invoking the captured
engine pointer: a hasOriginalHandler() check throwing a literal LogicException,
followed by an untyped `($this->originalHandler)(...)` invocation that static
analysis can only see as "trying to invoke FFI\CData".

AbstractHook::getOriginalCallable() already is that prologue - it throws the
exact same exception and hands back a value narrowed to `callable` - and seven
hooks were converted to it already (five of them kept the now-dead guard in
front of the call). The remaining ones are converted here and the dead guards
are dropped, so "no original handler" is worded in exactly one place.

CreateObjectHook keeps its two-branch shape (it falls back to
ReflectionClass::newInstanceRaw when no handler was captured) but asks
hasOriginalHandler() instead of comparing the raw property, and takes the
handler through the same accessor.

Twelve callable.nonCallable baseline entries and two inline @PHPStan-Ignore
comments become unnecessary and are removed. No behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnoZ7wuGepCTzsmFQ5sKxG
…guarantee

Half the hook family declared `public function proceed()` with no return type
at all, so every consumer of a proceed() result had to guess and PHPStan
carried a missingType.return entry per method. The types are derivable: each
class documents the C callback typedef it implements, and the value proceed()
hands back either comes straight out of that engine handler (guaranteed shape:
FFI returns an int for an int-returning function pointer and CData-or-null for
a pointer-returning one) or out of a ReflectionValue accessor.

Declared accordingly - int for the verdict-returning handlers (compare,
do_operation, interface_gets_implemented, cast_object), ?object for the
pointer-returning ones (get_property_ptr_ptr, get_properties_for), object for
write_property (the standard handler reports the written slot or
&EG(error_zval), never NULL), void for unset_property and zend_ast_process,
mixed for the accessors that materialize an engine value into a PHP one.

One deliberate exception: GetPropertyPointerHook::handle() stays `mixed`,
because its value comes from the USER handler rather than the engine. A
stricter declaration would turn a userland contract violation into a TypeError
raised inside an FFI trampoline, where nothing can catch it (issue #50).

21 more baseline entries (missingType.return plus two return.type) drop out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnoZ7wuGepCTzsmFQ5sKxG
…d struct stubs

The hook layer is the one subsystem that missed the struct-stub cutover: every
engine pointer a hook holds was declared as a bare FFI\CData, and since the
libffi trampoline delivers the callback arguments as `mixed`, each assignment
produced an "(FFI\CData) does not accept mixed" entry in the baseline - 84 of
them across src/ClassExtension/Hook, a fifth of the whole file.

Applied the convention from AGENTS.md ("Engine structs are typed by generated
stub classes"), exactly as the Reflection\* and Type\* layers already do it:
the field is declared `object` and carries a `/** @var <stub> */` docblock
naming its ZEngine\Generated view, and handle() - the single boundary where the
raw callback arguments arrive - narrows them once with a `@var` block over the
destructuring. That narrowing replaces the `assert($x instanceof CData)`
scaffolding the same statements used to carry (AGENTS.md calls out that weak
form as what the stub migration supersedes); the asserts that check real engine
or handler invariants stay.

Fields the stubs deliberately do not model keep their CData declaration with a
note saying why: the `void **` property cache slot, the `int *`/`zend_long *`
out-parameters of get_debug_info and count_elements, and the zend_object** /
zend_function** / zend_class_entry** double pointers of get_method and
get_closure (the stubs model structs, not pointer-to-pointer handles).
AstProcessHook keeps a raw zend_ast handle too, because the AST wrappers that
own that struct (NodeFactory/Node) have not been migrated yet.

Two owning boundaries were widened to accept a stub view alongside CData, the
usual `@param CData|Stub` shape: Executor::setFakeScope()/withFakeScope() (every
property hook now passes a typed `$object->ce`) and
ReflectionClass::newInstanceRaw().

Also removed CompareValuesHook::$returnValue, a protected property that was
declared but never read or written - compare_values has no result slot.

52 more baseline entries drop out; hook entries are down from 91 to 6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnoZ7wuGepCTzsmFQ5sKxG
AbstractMethodResolutionHook::resolveRawFunction() reached into a class entry by
hand: it looked the entry up in the engine class table, read ->function_table
off the raw struct, took its address and built a Type\HashTable around it - the
exact "callers never reach into a raw CData" pattern AGENTS.md forbids, and a
duplicate of what ReflectionClass already does when it is constructed.

It now goes through the owning object: `new ReflectionClass($className)` does
the class-table lookup and raises the "should be in the engine"
ReflectionException itself, and getMethodTable() is the accessor that owns
ce->function_table. Eleven lines become three, and no call site outside
ReflectionClass touches zend_class_entry.fields any more.

Note on the alternative: PR #196 proposed a static entry-level helper
(ReflectionClass::entryMethodTable()) for this shape. That helper is being
withdrawn as against the framework's object model - consumers hold Reflection*
objects, not raw entries plus static utilities - so this uses the instance API
and does not depend on that PR. The extra cost (a native reflection constructor
and three unused table wrappers) is paid only on the slow path: a wrapper
produced by proceed() short-circuits on the identity fast path above, and the VM
inline-caches the resolved function per call site for compile-time constant
method names.

While here, $proceedRawFunction and the two handle() return docs get their
zend_function|zend_internal_function stub views, retiring the last hook
return.type baseline entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnoZ7wuGepCTzsmFQ5sKxG
IteratorBridge kept the per-iteration state as an array shape
(`array{iterator: Iterator, pointer: CData, broken: bool}`). PHP arrays are
value types, so what each vtable callback read out of the registry was a
snapshot: marking an iteration broken could not be done on the record in hand
and had to go back through a second registry lookup by address, with an isset()
recheck around it - a shape that only existed to work around the copy.

The record is now a tiny `@internal` BridgedIterator with public typed
properties, so it is shared by reference. Every callback preamble is one lookup,
breakIteration() takes the state it is given and writes the flag straight
through it, and the iterator/pointer pair is readonly and named rather than
string-keyed.

Behavior is unchanged for every iteration path; the one difference is in the
pathological case where the engine iterator is destroyed from inside the
userland callback that then throws - the swallowed Throwable is now reported as
the E_USER_WARNING it always should have been, instead of being dropped because
the registry entry had already gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnoZ7wuGepCTzsmFQ5sKxG
Every concrete hook in ClassExtension\Hook and System\Hook is a leaf: it
implements one specific engine callback typedef and is instantiated by the
extension machinery, never derived from. Nothing in src/ or tests/ extends one,
and the two hooks that already implement HookInterface directly (OpCodeHook,
IteratorBridge) have been final since they were written.

Marking the remaining 27 final makes the extension point explicit - hooks are
customized by passing a user handler closure, not by subclassing - and keeps the
Abstract* bases as the only place a new hook shape can be introduced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnoZ7wuGepCTzsmFQ5sKxG
@lisachenko
lisachenko marked this pull request as ready for review August 14, 2026 14:12
@lisachenko
lisachenko merged commit 51cb70d into 8.4 Aug 14, 2026
19 checks passed
@lisachenko
lisachenko deleted the claude/php-repo-modernization-6pfu7y-hook-consolidation branch August 14, 2026 14:12
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.

2 participants