refactor: align Type and EngineExtension exceptions with the named-constructor convention - #201
Merged
lisachenko merged 4 commits intoAug 13, 2026
Conversation
The Type layer had no domain exception class: nine failure modes threw a bare
\RuntimeException with a hand-written message at the call site, and six of them
were near-duplicates of each other ("Can not add/store/delete an item with
key/index ..."), so the same wording lived in three files at once.
TypeOperationException now owns every one of those messages behind a named
static constructor, per the convention in AGENTS.md. It extends \RuntimeException
- exactly what each replaced throw threw - so every existing catch keeps
matching: Core::shutdown()'s find() pre-check around HashTable::delete(), the
\RuntimeException expectations in PersistentHashTableTest and
StringEntryOwnershipTest, and any consumer catching the base type.
Messages are carried over verbatim; only their home changed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnoZ7wuGepCTzsmFQ5sKxG
…nstructors AbstractModule had no module exception class at all: register() and startup() threw three bare \RuntimeExceptions with inline messages, while ZEngineModule hand-wrote the very same 'The zengine module has no globals block' text twice. Two classes now own those messages behind named constructors (AGENTS.md): - ModuleRegistrationException (EngineExtension, shaped like the neighbouring ExtensionNotRegisteredException) extends \RuntimeException, exactly what the three replaced throws threw - the module-lifecycle-order fixture's catch (RuntimeException) around a rejected registration keeps matching. - HeapAnchorMissingException extends PersistentHeapException, which is what ZEngineModule already threw there. The anchor slot IS heap state, so the class joins the existing heap hierarchy in ZEngine\Memory next to its siblings rather than moving the failure to an unrelated base type and breaking every catch (PersistentHeapException) around a heap lookup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RnoZ7wuGepCTzsmFQ5sKxG
…e_callback preg_replace_callback() returns null when PCRE fails (backtrack or recursion limit), and that null went straight into strtolower() - a deprecation since PHP 8.1 that the baseline had been carrying as "expects string, string|null given". On the error path the class name is now used unsplit instead, which is what the camelCase-to-snake_case conversion degrades to anyway. The freed baseline entry is pruned. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RnoZ7wuGepCTzsmFQ5sKxG
…onstructors
install() and uninstall() reported the two zend_set_user_opcode_handler()
failures with inline messages that share their whole vocabulary ("Can not
install user opcode handler" / "Can not restore original opcode handler").
Both are engine-domain failures of the same operation, so OpCodeHookException
now owns them behind named constructors, extending \RuntimeException exactly
like the throws it replaces.
The three remaining SPL throws in this file stay native on purpose:
- the Core::isShutdown() guard repeats a message that AbstractHook and
IteratorBridge raise verbatim; unifying it means one factory for all three
sites, which belongs with the hook-consolidation work that owns those files,
not to a partial conversion here.
- the out-of-order uninstall guard and the handler-signature check are
programmer-misuse guards on a public API with stable messages (the latter is
asserted verbatim by OpCodeHookTest); a factory adds no clarity there.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RnoZ7wuGepCTzsmFQ5sKxG
lisachenko
marked this pull request as ready for review
August 13, 2026 20:20
lisachenko
deleted the
claude/php-repo-modernization-6pfu7y-exception-factories
branch
August 13, 2026 20:20
lisachenko
pushed a commit
that referenced
this pull request
Aug 13, 2026
Re-bases the CI signal on the true merged state: 8.4 gained the exception-factory classes (#201) that also touch OpCodeHook's install-time guards. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RnoZ7wuGepCTzsmFQ5sKxG
This was referenced Aug 13, 2026
lisachenko
pushed a commit
that referenced
this pull request
Aug 13, 2026
Makes the CI signal concrete on the exact combination the merge preview tests: this branch's dedup plus the merged wave (frame-scope resolution, sizeOfType migration, exception factories). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RnoZ7wuGepCTzsmFQ5sKxG
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
AGENTS.md is explicit: "Domain exceptions are never thrown with a hand-written message at the call site. Each failure mode is a
public staticfactory on the exception class." Two layers had never been brought in line —src/Typehad no domain exception class at all, andEngineExtensionhad one (ExtensionNotRegisteredException) that covered a single failure mode while five others were hand-written inline, two of them character-for-character identical.New exception classes and their factories
ZEngine\Type\TypeOperationException extends \RuntimeExceptionThe Type layer's first domain exception class. Nine failure modes moved onto it; six were near-duplicates of each other spread over three files (
Can not add/store/delete an item with key/index …).cannotAddKey($key)Type/HashTable.phpadd()cannotAddIndex($key)Type/HashTable.phpaddIndex()cannotDeleteKey($key)Type/HashTable.phpdelete()cannotDeleteIndex($key)Type/HashTable.phpdeleteIndex()functionNotPublished($key)Type/HashTable.phpaddFunctionEntry()cannotStoreKey($key)Type/PersistentHashTable.phpaddInterned()cannotStoreIndex($key)Type/PersistentHashTable.phpaddIndex()danglingObjectEntry()Type/ObjectEntry.phpassertObjectAlive()referenceCountUnderflow()Type/ReferenceCountedTrait.phpdecrementReferenceCount()On the class name and scope. I read every message before naming it.
EngineTableExceptionwould have been accurate for seven of the nine but wrong for the dangling-weak-entry and refcount-underflow guards, which are not table failures. Rather than mint a second one-method class, the name generalises to what all nine actually are: an operation on a Type-layer wrapper that the engine state refused.HashTable::deleteIndex()(the "Can not delete an item with index" twin ofdelete()) andReferenceCountedTrait's underflow guard were not in the original brief; they surfaced in the sweep and belong to the same family, so leaving them inline would have re-fragmented the wording the class exists to unify.StringEntry::setCachedClassEntry()'s "This string does not carry an engine class-entry cache slot" stays a bare\LogicException: it is the onlyLogicExceptionamong the Type-layer sites, so folding it in would have forced either a base-type change (forbidden, see below) or a second class for one call site — and it is a plain programmer guard whose predicate (hasClassEntryCache()) is public, so the caller is told to check first.ZEngine\EngineExtension\ModuleRegistrationException extends \RuntimeExceptionShaped like its neighbour
ExtensionNotRegisteredException.AbstractModulepreviously had no module exception class whatsoever.alreadyRegistered($moduleName)AbstractModule::register()registrationRefused($moduleName)AbstractModule::register(), afterzend_register_module_exstartupFailed($moduleName)AbstractModule::startup()ZEngine\Memory\HeapAnchorMissingException extends PersistentHeapExceptionZEngineModule::onHeapDestroyed()andZEngineModule::recoverHeapRegistry()hand-wrote "The zengine module has no globals block" twice, both as aPersistentHeapException.Deviation from the brief, deliberate. The brief suggested folding these two into the new
EngineExtensionmodule class extending\RuntimeException. That would have changed the thrown type fromPersistentHeapExceptionto something outside the heap hierarchy and silently broken everycatch (PersistentHeapException)around a heap lookup — includingZEngineModule::getDisplayInfo()in the very same file, which converts heap failures into an "inert" row rather than throwing across theinfo_funcFFI boundary (issue #50). The base-type preservation guarantee wins over class placement, so the message moved onto a new subclass of what was already being thrown, following the house style ofHeapInertException/MissingClassException(final subclass + one static factory) and living beside its siblings inZEngine\Memory.ZEngine\System\Hook\OpCodeHookException extends \RuntimeExceptionhandlerInstallFailed()OpCodeHook::install()handlerRestoreFailed()OpCodeHook::uninstall()Base-type preservation guarantee
Every new exception class extends exactly the type the throw it replaces was throwing, and every replaced message is carried over verbatim. No
catchanywhere can stop matching as a result of this PR:TypeOperationException\RuntimeException\RuntimeExceptionModuleRegistrationException\RuntimeException\RuntimeExceptionHeapAnchorMissingExceptionPersistentHeapExceptionPersistentHeapExceptionOpCodeHookException\RuntimeException\RuntimeExceptionCatchers verified by grep before choosing each parent:
Core::shutdown()'sfind()pre-check exists precisely becauseHashTable::delete()throws on an absent key — still a\RuntimeException, still caught.tests/Type/PersistentHashTableTest.phpexpects\RuntimeExceptionand asserts/index 2/on the message — the factory emits the identical string.tests/Type/StringEntryOwnershipTest.phpexpects\RuntimeExceptionfrom the refcount underflow,\LogicExceptionfrom the immutable-increment guard (untouched).tests/EngineExtension/fixture/module-lifecycle-order.phpcatches bareRuntimeExceptionaround a rejected registration.ZEngineModule::getDisplayInfo()catchesPersistentHeapException.docs/mentions none of these types.Latent deprecation fixed:
AbstractModule::detectModuleName()strtolower(preg_replace_callback(...))fedstrtolower()a?string.preg_replace_callback()returnsnullon a PCRE failure (backtrack/recursion limit), and passingnulltostrtolower()has been deprecated since PHP 8.1 — a real runtime deprecation on that path, not just a static-analysis artifact. The error path now falls back to the unsplit class name ($snakeCased ?? $className), which is what the camelCase→snake_case conversion degrades to anyway. The freedphpstan-baseline.neonentry is pruned by hand (Parameter #1 $string of function strtolower expects string, string|null given); the secondAbstractModulebaseline entry is unrelated and stays.Sweep: sites kept as-is, with reasoning
src/was swept forthrow new \?(RuntimeException|LogicException|InvalidArgumentException|ReflectionException)(with inline interpolated messages. Excluded from consideration:HotSwap.php,Core.php,ReflectionMethod.php,PersistentHeap.php, theClassExtension/Hook/*proceed()/getOriginalCallable()throws,AbstractMethodResolutionHook.php, andAbstractSyntaxTree/Node.php'sOutOfBoundsExceptionguards — all owned by concurrent PRs. Remaining hits, grouped:OpCodeHook.php,Cannot install an engine hook after Core::shutdown()— kept native\LogicException. It is a genuine shared-vocabulary duplicate, but the same string is raised verbatim byHook/AbstractHook.phpandClassExtension/Hook/IteratorBridge.phptoo. Converting only OpCodeHook's copy would leave the message split between a factory and two inline twins — strictly worse than the status quo. It wants one factory for all three sites, andAbstractHookis owned by the in-flight hook-consolidation PR, so this belongs there.OpCodeHook.php, out-of-order uninstall guard — kept. Programmer-misuse guard on a public API, single site, stable message;OpCodeHookTestandHookLifecycleTestboth assert only the\LogicExceptiontype.OpCodeHook.php, handler-signature check — kept\InvalidArgumentException. Pure argument validation, andOpCodeHookTestasserts the message verbatim; a factory adds no clarity and only adds a way to drift.EngineExtension/ExtensionManager.php:62(Module … is already registered; use get()) — kept\LogicException. Different layer and different base type fromModuleRegistrationException::alreadyRegistered(): this is the framework-side registry rejecting a doubleregister()call (programmer error), not the engine module registry refusing an entry.EngineExtension/ModuleDependency.php:57,61,64— kept. Constructor argument validation (\InvalidArgumentException);AbstractModuleTestasserts the type.Type/StringEntry.php:308,Type/ReleasableTrait.php:98,Type/ReferenceCountedTrait.php:54,71,Type/StructArray.php,Type/ResourceEntry.php,Type/OpLine.php,Type/ObjectEntry.php:254,Type/ClosureEntry.php:120— kept. All either\LogicException/\OutOfBoundsExceptionprogrammer guards with stable single-site messages, or native-reflection-parity\ReflectionExceptions.Reflection/*\ReflectionExceptions (Class X should be in the engine., trait/alias/precedence errors) — kept. These are deliberate native-Reflection*parity errors; consumers catch\ReflectionExceptionby contract.AbstractSyntaxTree/{DeclarationNode,ListNode,NodeFactory,NodeKind}.php— kept. Node-kind argument validation and "not yet supported" guards, adjacent to the AST files a concurrent dedup PR is restructuring.Three clusters are real remaining duplication but sit outside this PR's stated scope, and each needs its own exception class in a layer this PR does not otherwise touch — flagged as follow-ups rather than smuggled in here:
Reflection/FunctionLikeTrait.php's eight "… are available only for user-defined functions" variants (three of them byte-identical),System/ObjectStore.php's twice-duplicated "Object store is read-only structure" plus its thrice-duplicated out-of-bounds message, and the "Unknown code … New version of PHP?" trio spanningNodeKind,OpCodeandReflectionValue.Adjacency with in-flight PRs
OpCodeHook::handle()frame resolution) rewriteshandle()and the twoClosure::fromCallable([$this, 'handle'])call sites in this same file. I inspected its branch: it does not touch either throw, and only shifts them by one line via an edited const docblock. This PR stays rooted atorigin/8.4(70d8d4f); expect a trivial, non-conflicting merge.deleteWithoutDestructor) touchesHotSwap.php,Core.php,ReflectionMethod.php— untouched here.HashTable::deleteWithoutDestructor()sits between two converted throws inHashTable.phpbut its own body is unchanged.PersistentHeap.php:614,663— excluded, not touched.AbstractHook::getOriginalCallable()and theClassExtension/Hook/*proceed()prologues — untouched, and the shutdown-guard message above is left to it on purpose.Validation
vendor/bin/phpstan analyse(level max) andPHP_CS_FIXER_IGNORE_ENV=1 vendor/bin/php-cs-fixer fix --dry-runare both clean, and were clean onorigin/8.4beforehand. All four new files passphp -l.Local tests were not run, by AGENTS.md's non-negotiable version rule: this branch targets PHP 8.4 and the container interpreter is 8.5.9. Running the suite means
Core::init()readingzend_class_entryat 8.4 offsets under an 8.5 engine — silent memory corruption, not a clean failure. Validation here is static-only; CI on the 8.4 runners is the gate. The change is a pure message-relocation refactor with types and strings preserved, which is exactly the shape that survives that constraint.🤖 Generated with Claude Code
https://claude.ai/code/session_01RnoZ7wuGepCTzsmFQ5sKxG
Generated by Claude Code