refactor(core): share the two hook-dispatch flavours + gate the paired kernel pins (#5282) - #6672
Merged
Merged
Conversation
…d kernel pins (#5282) `ObjectKernel` does not extend `ObjectKernelBase`, so lifecycle-hook dispatch existed twice with no shared code path — the isolating loops printed the same `Hook handler failed: <name>` line only because it was typed twice. Three consecutive bugs grew on that seam (#5170 / #5257 / #5274), each "one hook name means opposite things on the two kernels". B half: the two flavours move verbatim into `packages/core/src/hook-dispatch.ts` (`dispatchHookIsolating` / `dispatchHookPropagating`), called by both kernels. Every call path keeps its flavour, its log wording and its trace line — including `PluginContext.trigger`, which has never emitted a trace and still does not. The two `hooks` maps stay separate and `ObjectKernel` gains no base class (both out of scope by the ruling). C half: `scripts/check-kernel-hook-pairs.mjs` requires every `kernel:*` hook dispatched in `packages/core/src` to be named in a test title in BOTH `kernel.test.ts` and `lite-kernel.test.ts`, failing with the hook and the missing side. Measured against main's corpus first: 4 hooks, 0 problems. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MwoubC3jL271FYt9rGXwxb
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 23 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
os-zhuang
marked this pull request as ready for review
August 8, 2026 10:27
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.
Fixes #5282
Implements the maintainer-approved ruling on this card (2026-08-06), quoted verbatim:
Premise, re-verified against
origin/main(0e043d8)The issue is from 08-04 and its line numbers had moved, so every claim was re-anchored before any edit:
origin/maintodayObjectKernelextends nothingkernel.ts:56kernel.ts:56—export class ObjectKernel {ObjectKernelBasehas one subclasskernel-base.ts:30/lite-kernel.ts:23LiteKernelonlykernel.ts:59privatehooks/kernel-base.ts:33protectedhookskernel.tsprivate methodkernel.ts:736triggerShutdownHookIsolatingvskernel-base.ts:268triggerHook— two loops, same log wordingctx.trigger('kernel:shutdown')inconsistencykernel-base.ts:123kernel-base.ts:123andkernel.ts:153, both bare propagating loops, still nothing triggers the hook by handkernel.tsis 825 lines now, not 747 — the only number that had drifted. The premise stands in full; nobody had unified dispatch in the meantime.PM mechanism assumption, measured (one falsification)
Assumed: the two flavours are the only dispatch shapes in the kernel files. Measured: the two flavours are the only hook dispatch shapes, but the propagating flavour has two sub-shapes that differ by one observable —
triggerHookOrThrowemits theTriggering hook:trace line,PluginContext.trigger(both kernels) never has. Preserving semantics exactly therefore required the shared propagating function to take an optional logger, with its absence, not its presence, being the faithful reproduction ofcontext.trigger. That asymmetry is documented at the function and pinned by a test that asserts no debug call is made.Two loops in
kernel.tsdeliberately stay outside the extraction because they are not hook dispatch at all: theshutdownHandlersloop (kernel.ts:771, a different collection registered viaonShutdown(), loggingShutdown handler error) and the reverse-order plugindestroy()loop. Folding either in would change a log line, which the ruling forbids.B half — one implementation per flavour
New internal module
packages/core/src/hook-dispatch.ts(⛔ not exported from the core barrel):dispatchHookIsolating(name, handlers, logger, args)— a failing handler is logged asHook handler failed:plus the hook name; the remaining handlers still run.dispatchHookPropagating(name, handlers, logger, args)— the first failure escapes unwrapped, the handlers behind it are skipped;loggeroptional, and only then is the trace emitted.Five call sites now reach them, each keeping its exact flavour, log wording and trace behaviour:
ObjectKernelBase.triggerHookObjectKernel.triggerShutdownHookIsolatingObjectKernelBase.triggerHookOrThrowObjectKernelBasecontext.triggerObjectKernelcontext.triggerThe handler array is passed by reference, never copied, because both originals iterated the live array off the hooks map — pinned by a test so a defensive copy cannot change dispatch semantics as a tidy-up.
Out of scope by the ruling and untouched: the two
hooksmaps stay separate,ObjectKernelgains no base class,packages/core/src/index.tsunchanged,packages/core/src/utils/**untouched (#5586 in flight).C half — the pairing becomes a gate
scripts/check-kernel-hook-pairs.mjs+pnpm check:kernel-hook-pairs, wired into the ESLint job oflint.ymlnext to its siblings. Everykernel:*hook dispatched inpackages/core/srcmust be named in a test title in bothkernel.test.tsandlite-kernel.test.ts; a missing pair fails naming the hook and the side that lacks it. Static AST over five files, no build needed, runs its own--self-test(10 cases) first.Subscriptions (
ctx.hook(...)in a plugin) are deliberately not dispatches — only the kernels decide what a hook means.hooks.get('kernel:x')is in the dispatch vocabulary, and that turned out to be load-bearing: onorigin/mainthe ObjectKernel side ofkernel:shutdownhad no other statically visible dispatch, so without it the gate would have been blind to exactly the hand-rolled shape it exists to catch.Measured against main's corpus before being pinned in CI (the gate's audit run over
git show origin/main:sources, 42 files):The dormant inconsistency is pinned, not flipped
On both kernels
kernel:shutdownhas two dispatch paths with opposite flavours: the kernel's own teardown isolates, a plugin's manualctx.trigger('kernel:shutdown')propagates. Nothing in the repo triggers it by hand today, so it is dormant. Per the ruling it is now a documented fact with a named test on each side (dispatches kernel:shutdown two ways: ctx.trigger propagates, the kernel teardown isolates (#5282)) rather than a surprise found at teardown. No behaviour was changed; if it should be flipped, that is a separate decision.Reverse verification — direction written before running
Predictions were recorded before the mutations, and all four matched.
RV-1a — delete the shared isolating dispatcher's error log. Predicted: red on both kernels' files from one edit — the property extraction was ruled to buy, and one the hand-mirrored copies could not have had. Actual: 4 failures across all three files.
RV-1b — re-introduce the divergence by hand (ObjectKernel stops calling the shared function and swallows silently again). Predicted: asymmetric on purpose —
kernel.test.tsred,lite-kernel.test.tsandhook-dispatch.test.tsgreen, i.e. the pre-#5274 shape where one kernel's semantics change with no signal on the other. Actual: exactly that.Worth noting for the record: under RV-1b the gate stayed green, correctly — the hand-rolled loop still dispatches
kernel:shutdown, which is still paired. The gate guards pairing, not implementation sharing; the tests guard the sharing. Neither substitutes for the other, which is why the ruling asked for both.RV-2a — add a fifth dispatched hook
kernel:probewith no pins. Predicted: gate red, naming the hook and both missing sides. Actual:RV-2b — same, pinned in
kernel.test.tsonly. Predicted: still red, naming only the missing side. Actual:Tests and gates
Type-check debt:
@objectstack/corecarries a DEBT entry rather than atypecheckscript, so the raw count was measured both ways — 98 onorigin/main, 98 on this branch, exactly its ledgered number. The first draft measured 102; the four extra were all in the new/edited test files (one missing.jsimport extension plus three implicit-anyctxparams it cascaded into), fixed at the source rather than by raising the ledger.pnpm check:type-check-debtpasses.Every
check:step enumerated from.github/workflows/lint.ymlwas run locally, one by one — 32 in the ESLint job (including the newcheck:kernel-hook-pairs) and 25 in the TypeScript job after a fullturbo run build— all pass.Generated by Claude Code