diff --git a/.changeset/kernel-hook-dispatch-shared.md b/.changeset/kernel-hook-dispatch-shared.md new file mode 100644 index 0000000000..f5dc9b33a8 --- /dev/null +++ b/.changeset/kernel-hook-dispatch-shared.md @@ -0,0 +1,58 @@ +--- +"@objectstack/core": patch +--- + +refactor(core): one implementation per hook-dispatch flavour, plus a paired-pin gate (#5282) + +`ObjectKernel` does not extend `ObjectKernelBase` — it is a standalone +production kernel with its own `hooks` map, and only `LiteKernel` extends the +base. Lifecycle-hook dispatch therefore existed **twice**, with no shared code +path: the base's `triggerHook` (isolating) / `triggerHookOrThrow` (propagating) / +`context.trigger` on one side, and `ObjectKernel`'s private +`triggerShutdownHookIsolating` / `context.trigger` on the other. The two +isolating loops printed the same `Hook handler failed: kernel:shutdown` line +because someone typed it twice. + +That seam produced three consecutive bugs, each the same shape — one hook name +meaning opposite things on the two kernels: `kernel:ready` (#5170), +`kernel:bootstrapped` / `kernel:listening` (#5257, where a swallowed +`server.listen()` failure let a process print "✅ Bootstrap complete" with +nothing listening), and `kernel:shutdown` in the other direction (#5274, where +one bad handler skipped every `destroy()`). + +**No behaviour change.** The two dispatch flavours move verbatim into an +internal module, `packages/core/src/hook-dispatch.ts`, which both kernels now +call: + +- `dispatchHookIsolating` — a failing handler is logged as + `Hook handler failed: ` and the remaining handlers still run. +- `dispatchHookPropagating` — the first failure escapes unwrapped and the + handlers behind it are skipped. + +Every call path keeps the flavour, the log wording and the trace line it had +before, including the one asymmetry inside the propagating flavour: +`PluginContext.trigger` has never emitted the `Triggering hook: ` trace on +either kernel, so it still does not. The kernels' two `hooks` maps are +deliberately **not** unified, and `ObjectKernel` deliberately does **not** gain a +base class — both were considered and ruled out of scope. + +How "no behaviour change" was proved: the paired kernel pins from #5170 / #5257 / +#5274 pass untouched, and deleting the shared dispatcher's error log now turns +**both** kernels' test files red from a single edit — a property the hand-mirrored +copies could not have (editing `ObjectKernel`'s private loop could never turn +`lite-kernel.test.ts` red). + +Shared dispatch cannot cover the residual two-maps seam, so the pairing of the +tests is now a gate rather than a convention: `pnpm check:kernel-hook-pairs` +(`scripts/check-kernel-hook-pairs.mjs`, wired into the ESLint job) 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`, and fails naming the hook +and the side that lacks it. A fifth lifecycle hook can no longer arrive paired on +one kernel only. + +Also pinned, deliberately unchanged: `kernel:shutdown` has two dispatch paths +with different flavours on both kernels — the kernel's own teardown isolates, +while a plugin calling `ctx.trigger('kernel:shutdown')` by hand propagates. +Nothing in the repo triggers it by hand today, so this is dormant; it is now a +documented fact with a named test on each side rather than a surprise found at +teardown. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 701ccca281..283e1b9e81 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -448,6 +448,32 @@ jobs: - name: Engine test-double contract gate run: pnpm check:engine-double-contract + # Paired kernel-hook pin gate (#5282, from #5170 / #5257 / #5274). The two + # kernels — ObjectKernel (production) and LiteKernel (vitest / serverless / + # edge) — run the same plugin code and the same hook vocabulary, but do NOT + # share a class: ObjectKernel does not extend ObjectKernelBase, so each + # keeps its own hooks map. #5282 shared the two dispatch LOOPS + # (packages/core/src/hook-dispatch.ts); this gate covers what sharing them + # cannot: three consecutive bugs were all "one hook name means opposite + # things on the two kernels" (kernel:ready swallowed on one side and fatal + # on the other; kernel:listening swallowing a failed server.listen() behind + # a cheerful "Bootstrap complete"; kernel:shutdown skipping every destroy() + # in the other direction), and each was caught by a human noticing the + # asymmetry. What held the kernels together afterwards was a pair of + # hand-written tests — a convention, not a mechanism: a fifth lifecycle + # hook gets no pairing automatically and nothing goes red when it is + # missing. So every kernel:* hook DISPATCHED in packages/core/src must be + # named in a test title in BOTH kernel.test.ts and lite-kernel.test.ts, and + # a missing pair fails naming the hook and the side that lacks it. + # Subscriptions (ctx.hook) are deliberately not dispatches. Static AST over + # five files, no build needed, so it belongs in this job. Runs its own + # --self-test first: the detector can be broken while every hook is fine, + # and a scan that stops matching would report OK while reading nothing + # (#4868's family). Measured against main's corpus before being pinned + # here: 4 dispatched hooks, 0 problems. + - name: Paired kernel-hook pin gate + run: pnpm check:kernel-hook-pairs + # Resume-authority declaration gate (#5561, from #3823). The #3801 resume # gate keys on the SUSPENDED NODE, so it covers a pausing node type exactly # when that type's author remembered to declare `resumeAuthority`. #3823 is diff --git a/package.json b/package.json index 13868f7b4a..4205537d82 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "check:wildcard-fallthrough": "node scripts/check-wildcard-fallthrough.mjs --self-test && node scripts/check-wildcard-fallthrough.mjs", "check:meta-type-normalized": "node scripts/check-meta-type-normalized.mjs --self-test && node scripts/check-meta-type-normalized.mjs", "check:init-service-contract": "node scripts/check-init-service-contract.mjs --self-test && node scripts/check-init-service-contract.mjs", + "check:kernel-hook-pairs": "node scripts/check-kernel-hook-pairs.mjs --self-test && node scripts/check-kernel-hook-pairs.mjs", "check:durability-log-level": "node scripts/check-durability-degradation-log-level.mjs --self-test && node scripts/check-durability-degradation-log-level.mjs", "check:startup-registry-verdict": "node scripts/check-startup-registry-verdict.mjs --self-test && node scripts/check-startup-registry-verdict.mjs", "check:console-sha": "node scripts/check-console-sha.mjs", diff --git a/packages/core/src/hook-dispatch.test.ts b/packages/core/src/hook-dispatch.test.ts new file mode 100644 index 0000000000..cf5b831563 --- /dev/null +++ b/packages/core/src/hook-dispatch.test.ts @@ -0,0 +1,183 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, vi } from 'vitest'; +import { + dispatchHookIsolating, + dispatchHookPropagating, + type HookDispatchLogger, + type HookHandler, +} from './hook-dispatch.js'; + +/** + * Direct pins for the two shared dispatch flavours (#5282). + * + * These exist as well as — never instead of — the paired kernel pins in + * `kernel.test.ts` / `lite-kernel.test.ts`. The kernel pins say what each hook + * MEANS on each kernel; these say what each flavour DOES, once, at the single + * place both kernels now reach. The division matters for the reverse + * verification: mutating a function here turns both kernels' files red in one + * edit, which is the property option B was ruled to buy. + */ + +const makeLogger = () => { + const debug = vi.fn(); + const error = vi.fn(); + const logger: HookDispatchLogger = { debug, error }; + return { logger, debug, error }; +}; + +describe('hook dispatch (shared by ObjectKernel and ObjectKernelBase, #5282)', () => { + describe('dispatchHookIsolating', () => { + it('runs every remaining handler when one throws, in registration order', async () => { + const reached: string[] = []; + const { logger } = makeLogger(); + const handlers: HookHandler[] = [ + async () => { reached.push('first'); }, + async () => { throw new Error('boom'); }, + async () => { reached.push('third'); }, + ]; + + await expect(dispatchHookIsolating('kernel:shutdown', handlers, logger)).resolves.toBeUndefined(); + + expect(reached).toEqual(['first', 'third']); + }); + + it('logs each failure as `Hook handler failed: ` with the original error', async () => { + const { logger, error } = makeLogger(); + const boom = new Error('boom'); + + await dispatchHookIsolating('kernel:shutdown', [async () => { throw boom; }], logger); + + // The wording is contract: both kernels' pins assert this exact + // line, and before #5282 it was typed out twice to keep them equal. + expect(error).toHaveBeenCalledWith('Hook handler failed: kernel:shutdown', boom); + }); + + it('traces `Triggering hook: ` with the handler count before running any handler', async () => { + const { logger, debug } = makeLogger(); + const order: string[] = []; + debug.mockImplementation(() => { order.push('trace'); }); + + await dispatchHookIsolating( + 'kernel:shutdown', + [async () => { order.push('handler'); }, async () => { order.push('handler'); }], + logger, + ); + + expect(debug).toHaveBeenCalledWith('Triggering hook: kernel:shutdown', { + hook: 'kernel:shutdown', + handlerCount: 2, + }); + expect(order).toEqual(['trace', 'handler', 'handler']); + }); + + it('passes every argument through to each handler', async () => { + const { logger } = makeLogger(); + const seen: unknown[][] = []; + + await dispatchHookIsolating( + 'data:beforeInsert', + [(...args: unknown[]) => { seen.push(args); }, (...args: unknown[]) => { seen.push(args); }], + logger, + ['sys_user', { id: 'u1' }], + ); + + expect(seen).toEqual([ + ['sys_user', { id: 'u1' }], + ['sys_user', { id: 'u1' }], + ]); + }); + + it('takes the handler array by reference — a handler subscribing mid-dispatch still runs', async () => { + // Both hand-written originals read the live array off the hooks map + // and iterated it directly. Pinned so a defensive `[...handlers]` + // copy cannot change dispatch semantics as a tidy-up. + const { logger } = makeLogger(); + const reached: string[] = []; + const handlers: HookHandler[] = []; + handlers.push(async () => { + reached.push('first'); + handlers.push(async () => { reached.push('late-subscriber'); }); + }); + + await dispatchHookIsolating('kernel:shutdown', handlers, logger); + + expect(reached).toEqual(['first', 'late-subscriber']); + }); + }); + + describe('dispatchHookPropagating', () => { + it('lets the first failure escape unwrapped and skips the handlers behind it', async () => { + const reached: string[] = []; + const { logger, error } = makeLogger(); + const boom = new Error('listen EACCES: permission denied 0.0.0.0:80'); + const handlers: HookHandler[] = [ + async () => { reached.push('first'); }, + async () => { throw boom; }, + async () => { reached.push('never'); }, + ]; + + // Identity, not just message: the boot path re-throws the original + // error to its caller, it does not wrap or re-create it. + await expect(dispatchHookPropagating('kernel:listening', handlers, logger)).rejects.toBe(boom); + + expect(reached).toEqual(['first']); + // A propagated failure is the caller's to report — the dispatcher + // does not also log it. + expect(error).not.toHaveBeenCalled(); + }); + + it('traces `Triggering hook: ` with the handler count when a logger is supplied', async () => { + const { logger, debug } = makeLogger(); + + await dispatchHookPropagating('kernel:ready', [async () => {}], logger); + + expect(debug).toHaveBeenCalledWith('Triggering hook: kernel:ready', { + hook: 'kernel:ready', + handlerCount: 1, + }); + }); + + it('emits NO trace when no logger is supplied — the `context.trigger` shape', async () => { + // `PluginContext.trigger` has never logged a dispatch trace on + // either kernel. Handing it a logger would add a debug line to a + // path that has never had one; #5282 preserves every call path's + // semantics exactly, so the absence is pinned rather than assumed. + const { logger, debug } = makeLogger(); + const reached: string[] = []; + + await dispatchHookPropagating('kernel:ready', [async () => { reached.push('ran'); }], undefined); + + expect(reached).toEqual(['ran']); + expect(debug).not.toHaveBeenCalled(); + expect(logger.debug).not.toHaveBeenCalled(); + }); + + it('passes every argument through to each handler', async () => { + const seen: unknown[][] = []; + + await dispatchHookPropagating( + 'metadata:reloaded', + [(...args: unknown[]) => { seen.push(args); }], + undefined, + ['sys_user'], + ); + + expect(seen).toEqual([['sys_user']]); + }); + }); + + it('dispatches nothing, and never throws, for a hook with no subscribers', async () => { + const { logger, debug, error } = makeLogger(); + + await expect(dispatchHookIsolating('kernel:shutdown', [], logger)).resolves.toBeUndefined(); + await expect(dispatchHookPropagating('kernel:ready', [], logger)).resolves.toBeUndefined(); + + expect(error).not.toHaveBeenCalled(); + expect(debug).toHaveBeenCalledTimes(2); + expect(debug.mock.calls.map((c) => c[1])).toEqual([ + { hook: 'kernel:shutdown', handlerCount: 0 }, + { hook: 'kernel:ready', handlerCount: 0 }, + ]); + }); +}); diff --git a/packages/core/src/hook-dispatch.ts b/packages/core/src/hook-dispatch.ts new file mode 100644 index 0000000000..a9b452d8e2 --- /dev/null +++ b/packages/core/src/hook-dispatch.ts @@ -0,0 +1,157 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Shared lifecycle-hook dispatchers (#5282). + * + * ## Why this module exists + * + * `ObjectKernel` does not extend `ObjectKernelBase` — it is a standalone + * production kernel with its own `plugins`/`services`/`hooks`/`state`/`logger` + * fields, and only `LiteKernel` extends the base. Making it inherit was + * considered and **rejected** (#5282, option A: an inheritance refactor of a + * 800-line production kernel, risk out of proportion to the benefit). What was + * ruled instead is this module (option B): the two *dispatch flavours* become + * module-level functions both kernels call, so a change to how a hook is + * dispatched is written once and lands on both kernels. + * + * Before this, dispatch existed twice with no shared code path — the base's + * `triggerHook` / `triggerHookOrThrow` / `context.trigger` on one side, and + * `ObjectKernel`'s private `triggerShutdownHookIsolating` / `context.trigger` + * on the other. The two isolating loops printed the same log line **because + * someone typed it twice**. That hand-mirroring is the structural seam three + * consecutive bugs grew on, every one of them the same shape — one hook name + * meaning opposite things on the two kernels: + * + * - #5170 — `kernel:ready`: LiteKernel swallowed a throwing handler while + * ObjectKernel failed the boot. + * - #5257 — `kernel:bootstrapped` / `kernel:listening`: same, and the ugliest + * specimen — `HonoServerPlugin` awaits `server.listen(port)` inside + * `kernel:listening`, so on LiteKernel a failed listen was swallowed and + * the process printed "✅ Bootstrap complete" with nothing listening. + * - #5274 — `kernel:shutdown`, in the opposite direction: ObjectKernel's + * propagating dispatch let one bad handler skip every `destroy()` and + * `process.exit(1)`. + * + * The **storage** is deliberately still two maps (out of the ruling's scope); + * only the dispatch is shared. The paired-pin gate + * (`scripts/check-kernel-hook-pairs.mjs`, option C of the same ruling) covers + * the residue: every `kernel:*` hook dispatched in `packages/core/src` must + * carry a named pin in BOTH `kernel.test.ts` and `lite-kernel.test.ts`, so a + * fifth lifecycle hook cannot arrive paired on one kernel only. + * + * ## Choosing a flavour + * + * The choice is per hook, not per kernel, and it is a judgement recorded at + * each dispatch site: + * + * - **Boot path** (`kernel:ready`, `kernel:bootstrapped`, `kernel:listening`) + * ⇒ {@link dispatchHookPropagating}. Everything dispatched before + * "✅ Bootstrap complete" is a precondition of that claim; swallowing a + * throw there does not rescue the boot, it only hides the failure behind a + * process reporting success. + * - **Teardown path** (`kernel:shutdown`) ⇒ {@link dispatchHookIsolating}. + * There is no "refuse to proceed" left to buy: what is queued behind a + * failing handler is the rest of the cleanup — the other subscribers, then + * each plugin's `destroy()` — which is what flushes buffers, closes + * connections and releases locks. + * + * ⛔ Internal module: not exported from `packages/core/src/index.ts`. These are + * kernel internals, not a public dispatch API. + */ + +/** A registered lifecycle-hook handler, exactly as `PluginContext.hook` stores it. */ +export type HookHandler = (...args: any[]) => void | Promise; + +/** + * The slice of the logger contract a dispatcher uses. Structural on purpose: + * both `ObjectLogger` (what `ObjectKernel` holds) and the spec `Logger` + * contract (what `ObjectKernelBase` holds) satisfy it without either kernel + * importing the other's logger type. + */ +export interface HookDispatchLogger { + debug(message: string, meta?: Record): void; + error(message: string, error?: Error, meta?: Record): void; +} + +/** + * The trace line every *logged* dispatch emits before running handlers. + * + * Kept in one place because it is asserted verbatim on both kernels. Note the + * handler count is read here, before the loop — the same instant both + * hand-written copies read it. + */ +function traceDispatch(name: string, handlers: readonly HookHandler[], logger: HookDispatchLogger): void { + logger.debug(`Triggering hook: ${name}`, { + hook: name, + handlerCount: handlers.length, + }); +} + +/** + * Dispatch a hook ISOLATING failures: a handler that throws is logged as + * `Hook handler failed: ` and the remaining handlers still run. + * + * Used by `ObjectKernelBase.triggerHook` and by `ObjectKernel`'s own + * `kernel:shutdown` dispatch — the two sites that until #5282 were separate + * hand-written loops printing the same line. + * + * ⛔ Wrong dispatcher for anything on the BOOT path — see the module docs and + * {@link dispatchHookPropagating}. + * + * `handlers` is taken by reference, never copied: a handler that subscribes + * another handler to the same hook mid-dispatch is picked up by this loop, and + * that has always been true of both originals. + * + * @param name - Hook name + * @param handlers - The hook's registered handlers, in registration order + * @param logger - Receives the dispatch trace and one error line per failure + * @param args - Arguments to pass to each handler + */ +export async function dispatchHookIsolating( + name: string, + handlers: readonly HookHandler[], + logger: HookDispatchLogger, + args: readonly any[] = [], +): Promise { + traceDispatch(name, handlers, logger); + + for (const handler of handlers) { + try { + await handler(...args); + } catch (error) { + logger.error(`Hook handler failed: ${name}`, error as Error); + // Continue with other handlers even if one fails + } + } +} + +/** + * Dispatch a hook PROPAGATING the first failure: the remaining handlers do not + * run and the original error reaches the caller unwrapped. + * + * Used by `ObjectKernelBase.triggerHookOrThrow` (the boot-path hooks on + * `LiteKernel`) and by BOTH kernels' `PluginContext.trigger`. + * + * `logger` is optional, and its absence is the one behavioural difference + * between those callers: `context.trigger` has never emitted the + * `Triggering hook: ` trace, so it passes no logger. Handing it one would + * add a debug line to a path that has never had one — a behaviour change, and + * #5282 preserves every call path's semantics exactly. + * + * @param name - Hook name + * @param handlers - The hook's registered handlers, in registration order + * @param logger - When given, receives the dispatch trace; omit to stay silent + * @param args - Arguments to pass to each handler + */ +export async function dispatchHookPropagating( + name: string, + handlers: readonly HookHandler[], + logger: HookDispatchLogger | undefined, + args: readonly any[] = [], +): Promise { + if (logger) traceDispatch(name, handlers, logger); + + for (const handler of handlers) { + await handler(...args); + } +} diff --git a/packages/core/src/kernel-base.ts b/packages/core/src/kernel-base.ts index 775e24573f..5f54284aa1 100644 --- a/packages/core/src/kernel-base.ts +++ b/packages/core/src/kernel-base.ts @@ -9,6 +9,7 @@ import { assertInitServiceRequirements, describeInitOrderFault, } from './plugin-order.js'; +import { dispatchHookIsolating, dispatchHookPropagating } from './hook-dispatch.js'; /** * Kernel state machine @@ -120,11 +121,11 @@ export abstract class ObjectKernelBase { } this.hooks.get(name)!.push(handler); }, + // PROPAGATING dispatch, and deliberately WITHOUT the trace line the + // kernel's own dispatch sites emit — `context.trigger` has never + // logged one, so no logger is handed over (#5282). trigger: async (name, ...args) => { - const handlers = this.hooks.get(name) || []; - for (const handler of handlers) { - await handler(...args); - } + await dispatchHookPropagating(name, this.hooks.get(name) || [], undefined, args); }, getServices: () => { if (this.services instanceof Map) { @@ -262,24 +263,16 @@ export abstract class ObjectKernelBase { * (`kernel:ready`, `kernel:bootstrapped`, `kernel:listening`) use * {@link triggerHookOrThrow} (#5170, #5257). * + * The loop itself lives in {@link dispatchHookIsolating} — one + * implementation shared with `ObjectKernel`'s own `kernel:shutdown` + * dispatch, which cannot inherit this method (`ObjectKernel` does not + * extend this class) and used to hand-mirror it (#5282). + * * @param name - Hook name * @param args - Arguments to pass to handlers */ protected async triggerHook(name: string, ...args: any[]): Promise { - const handlers = this.hooks.get(name) || []; - this.logger.debug(`Triggering hook: ${name}`, { - hook: name, - handlerCount: handlers.length - }); - - for (const handler of handlers) { - try { - await handler(...args); - } catch (error) { - this.logger.error(`Hook handler failed: ${name}`, error as Error); - // Continue with other handlers even if one fails - } - } + await dispatchHookIsolating(name, this.hooks.get(name) || [], this.logger, args); } /** @@ -317,19 +310,15 @@ export abstract class ObjectKernelBase { * default — and it is the reason this dispatcher is chosen per hook rather * than swapped in wholesale. * + * The loop itself lives in {@link dispatchHookPropagating} — the same + * function `PluginContext.trigger` runs on both kernels, so "propagating" + * means one thing repo-wide (#5282). + * * @param name - Hook name * @param args - Arguments to pass to handlers */ protected async triggerHookOrThrow(name: string, ...args: any[]): Promise { - const handlers = this.hooks.get(name) || []; - this.logger.debug(`Triggering hook: ${name}`, { - hook: name, - handlerCount: handlers.length, - }); - - for (const handler of handlers) { - await handler(...args); - } + await dispatchHookPropagating(name, this.hooks.get(name) || [], this.logger, args); } /** diff --git a/packages/core/src/kernel.test.ts b/packages/core/src/kernel.test.ts index 5782e31c6c..41f947a370 100644 --- a/packages/core/src/kernel.test.ts +++ b/packages/core/src/kernel.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { ObjectKernel } from './kernel'; import { ServiceLifecycle, PluginMetadata } from './plugin-loader'; -import type { Plugin } from './types'; +import type { Plugin, PluginContext } from './types'; describe('ObjectKernel', () => { let kernel: ObjectKernel; @@ -868,6 +868,66 @@ describe('ObjectKernel', () => { errorSpy.mockRestore(); } }, 5000); + + // #5282, the ObjectKernel half. `kernel:shutdown` has TWO dispatch + // paths with different flavours, and this pins the difference AS IT IS + // — the ruling that shared the dispatch loops (option B) explicitly did + // NOT flip either path: + // + // - the kernel's own teardown dispatch (`performShutdown`) isolates: + // a throwing handler is logged and everything queued behind it + // still runs (#5274); + // - a plugin calling `ctx.trigger('kernel:shutdown')` by hand goes + // through `PluginContext.trigger`, which propagates — the throw + // reaches that caller and the handlers behind it are skipped. + // + // Nothing in the repo triggers `kernel:shutdown` by hand today, so this + // is DORMANT, not a live defect. It is pinned so that if someone does + // reach for the manual trigger, the difference is a documented fact + // with a test naming it rather than a surprise found at teardown. + it('dispatches kernel:shutdown two ways: ctx.trigger propagates, the kernel teardown isolates (#5282)', async () => { + const exitSpy = spyOnExit(); + const errorSpy = spyOnLog(kernel, 'error'); + const reached: string[] = []; + let captured!: PluginContext; + + const plugin: Plugin = { + name: 'dual-path-shutdown', + version: '1.0.0', + init: async (ctx: PluginContext) => { + captured = ctx; + ctx.hook('kernel:shutdown', async () => { throw new Error('manual boom'); }); + ctx.hook('kernel:shutdown', async () => { reached.push('later-shutdown'); }); + }, + }; + + try { + await kernel.use(plugin); + await kernel.bootstrap(); + + // Path 1 — manual trigger: PROPAGATING. The error reaches the + // caller unwrapped and the second handler never runs. + await expect(captured.trigger('kernel:shutdown')).rejects.toThrow('manual boom'); + expect(reached).toEqual([]); + // …and this path logs nothing itself: reporting a propagated + // failure is the caller's job. + expect(errorSpy.mock.calls.map((c) => String(c[0]))).not.toContain( + 'Hook handler failed: kernel:shutdown', + ); + + // Path 2 — the kernel's own teardown: ISOLATING. Same handlers, + // same hook name, opposite treatment of the same throw. + await kernel.shutdown(); + expect(reached).toEqual(['later-shutdown']); + expect(errorSpy.mock.calls.map((c) => String(c[0]))).toContain( + 'Hook handler failed: kernel:shutdown', + ); + expect(exitSpy).not.toHaveBeenCalled(); + } finally { + exitSpy.mockRestore(); + errorSpy.mockRestore(); + } + }); }); describe('Dependency Resolution', () => { diff --git a/packages/core/src/kernel.ts b/packages/core/src/kernel.ts index 5a07c583c3..33e0ca01d6 100644 --- a/packages/core/src/kernel.ts +++ b/packages/core/src/kernel.ts @@ -13,6 +13,7 @@ import { assertInitServiceRequirements, describeInitOrderFault, } from './plugin-order.js'; +import { dispatchHookIsolating, dispatchHookPropagating } from './hook-dispatch.js'; /** * Enhanced Kernel Configuration @@ -150,11 +151,12 @@ export class ObjectKernel { } this.hooks.get(name)!.push(handler); }, + // PROPAGATING dispatch — the same shared loop `LiteKernel`'s + // context.trigger runs, and deliberately WITHOUT a trace line: + // `context.trigger` has never emitted one on either kernel, so no + // logger is handed over (#5282). trigger: async (name, ...args) => { - const handlers = this.hooks.get(name) || []; - for (const handler of handlers) { - await handler(...args); - } + await dispatchHookPropagating(name, this.hooks.get(name) || [], undefined, args); }, getServices: () => { return new Map(this.services); @@ -723,29 +725,21 @@ export class ObjectKernel { * one bad handler must not amplify into leaked resources and unflushed * writes. Same reasoning, same wording, same `Hook handler failed: * kernel:shutdown` log line as `LiteKernel`'s dispatch site, which reaches - * the shared isolating dispatcher `ObjectKernelBase.triggerHook` (#5257). + * the isolating dispatcher through `ObjectKernelBase.triggerHook` (#5257). * - * `ObjectKernel` cannot call that dispatcher: it does not extend + * Until #5282 "same wording" was literally that — the loop was typed out a + * second time here, because `ObjectKernel` does not extend * `ObjectKernelBase` (only `LiteKernel` does) and owns its own `hooks` map, - * so the semantics are mirrored here rather than shared. One hook name - * meaning two opposite things across the two kernels is exactly the bug - * #5170/#5257 closed, so the pin for this one lives on both sides too. + * so the base's `protected triggerHook` is out of reach. The loop now lives + * in {@link dispatchHookIsolating}, which BOTH sides call: the storage is + * still two maps (deliberately — unifying it was out of #5282's scope), but + * "isolating" is one implementation, so it can no longer drift on one + * kernel while the other keeps the old shape. That drift is exactly the bug + * #5170 / #5257 / #5274 each closed one hook at a time, and the paired-pin + * gate (`scripts/check-kernel-hook-pairs.mjs`) covers the residue. */ private async triggerShutdownHookIsolating(): Promise { - const handlers = this.hooks.get('kernel:shutdown') || []; - this.logger.debug('Triggering hook: kernel:shutdown', { - hook: 'kernel:shutdown', - handlerCount: handlers.length, - }); - - for (const handler of handlers) { - try { - await handler(); - } catch (error) { - this.logger.error('Hook handler failed: kernel:shutdown', error as Error); - // Continue with other handlers even if one fails - } - } + await dispatchHookIsolating('kernel:shutdown', this.hooks.get('kernel:shutdown') || [], this.logger); } private async performShutdown(): Promise { diff --git a/packages/core/src/lite-kernel.test.ts b/packages/core/src/lite-kernel.test.ts index c285fce0c4..67ec564f3d 100644 --- a/packages/core/src/lite-kernel.test.ts +++ b/packages/core/src/lite-kernel.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { LiteKernel } from './lite-kernel'; -import type { Plugin } from './types'; +import type { Plugin, PluginContext } from './types'; // Unique per-process temp path — a shared hardcoded /tmp file can be owned by a // different user (CI / multi-user hosts), causing EACCES under concurrent runs. @@ -395,5 +395,80 @@ describe('LiteKernel with Configurable Logger', () => { expect(reached).toEqual(['later-shutdown', 'plugin-destroy']); expect(kernel.getState()).toBe('stopped'); }); + + // #5282. The behavioural half above says the cleanup continues; this + // says the failure is REPORTED, naming the hook, in the exact wording + // `ObjectKernel`'s teardown pin asserts on the other side + // (`kernel.test.ts`, #5274). Until #5282 the two kernels printed that + // line from two hand-written loops; they now print it from one shared + // dispatcher, and this is the LiteKernel end of the pin that goes red + // if that single implementation stops logging. + it('logs `Hook handler failed: kernel:shutdown` for the handler that threw (#5282)', async () => { + const errorSpy = vi.spyOn( + (kernel as unknown as { logger: { error: (...a: unknown[]) => void } }).logger, + 'error', + ); + + const plugin: Plugin = { + name: 'shutdown-thrower-logging', + init: async (ctx: PluginContext) => { + ctx.hook('kernel:shutdown', async () => { throw new Error('shutdown boom'); }); + }, + }; + + try { + kernel.use(plugin); + await kernel.bootstrap(); + await kernel.shutdown(); + + expect(errorSpy.mock.calls.map((c) => String(c[0]))).toContain( + 'Hook handler failed: kernel:shutdown', + ); + expect(kernel.getState()).toBe('stopped'); + } finally { + errorSpy.mockRestore(); + } + }); + + // #5282, the LiteKernel half of the dual-path pin (`kernel.test.ts` + // carries the ObjectKernel half). `kernel:shutdown` has TWO dispatch + // paths with different flavours on BOTH kernels, and the ruling that + // shared the dispatch loops deliberately did NOT flip either: + // + // - the kernel's own teardown dispatch isolates (`triggerHook`): a + // throwing handler is logged and the cleanup behind it still runs; + // - a plugin calling `ctx.trigger('kernel:shutdown')` by hand goes + // through `PluginContext.trigger`, which propagates. + // + // Nothing in the repo triggers `kernel:shutdown` by hand today, so this + // is DORMANT rather than a live defect — pinned so the difference is a + // documented fact instead of a surprise found at teardown. + it('dispatches kernel:shutdown two ways: ctx.trigger propagates, the kernel teardown isolates (#5282)', async () => { + const reached: string[] = []; + let captured!: PluginContext; + + const plugin: Plugin = { + name: 'dual-path-shutdown', + init: async (ctx: PluginContext) => { + captured = ctx; + ctx.hook('kernel:shutdown', async () => { throw new Error('manual boom'); }); + ctx.hook('kernel:shutdown', async () => { reached.push('later-shutdown'); }); + }, + }; + + kernel.use(plugin); + await kernel.bootstrap(); + + // Path 1 — manual trigger: PROPAGATING. The error reaches the + // caller unwrapped and the second handler never runs. + await expect(captured.trigger('kernel:shutdown')).rejects.toThrow('manual boom'); + expect(reached).toEqual([]); + + // Path 2 — the kernel's own teardown: ISOLATING. Same handlers, + // same hook name, opposite treatment of the same throw. + await kernel.shutdown(); + expect(reached).toEqual(['later-shutdown']); + expect(kernel.getState()).toBe('stopped'); + }); }); }); diff --git a/scripts/check-kernel-hook-pairs.mjs b/scripts/check-kernel-hook-pairs.mjs new file mode 100644 index 0000000000..4cd77c5e26 --- /dev/null +++ b/scripts/check-kernel-hook-pairs.mjs @@ -0,0 +1,452 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Paired kernel-hook pin gate (#5282). + * + * ## What it guards + * + * This repo boots plugins on two kernels: `ObjectKernel` (production) and + * `LiteKernel` (vitest / serverless / edge). They share plugin code, they share + * the hook vocabulary — and they do NOT share a class: `ObjectKernel` does not + * extend `ObjectKernelBase`, so each keeps its own `hooks` map. #5282 shared the + * two DISPATCH loops (`packages/core/src/hook-dispatch.ts`, option B of the + * ruling); the storage is still two maps, which is why the residue needs a gate. + * + * The residue is worth a gate because the same defect landed three times in a + * row, each time as "one hook name means opposite things on the two kernels": + * + * - #5170 — `kernel:ready`: LiteKernel swallowed a throwing handler while + * ObjectKernel failed the boot. + * - #5257 — `kernel:bootstrapped` / `kernel:listening`: the same, and the + * worst-shaped specimen — `HonoServerPlugin` awaits `server.listen(port)` + * in `kernel:listening`, so a failed listen was swallowed on LiteKernel and + * the process printed "✅ Bootstrap complete" with nothing listening. + * - #5274 — `kernel:shutdown`, in the other direction: ObjectKernel's + * propagating dispatch let one bad handler skip every `destroy()` and + * `process.exit(1)`. + * + * All three were caught by a HUMAN noticing the asymmetry, and what holds the + * two kernels together afterwards is a pair of hand-written tests — a + * convention, not a mechanism. A fifth lifecycle hook added tomorrow gets no + * pairing automatically, and nothing goes red when it is missing. This gate is + * that mechanism (option C of the same ruling): + * + * every `kernel:*` hook DISPATCHED in `packages/core/src` must be named in a + * test title in BOTH `kernel.test.ts` and `lite-kernel.test.ts`. + * + * A missing pair fails, naming the hook AND the side that lacks it. + * + * ## What counts as a dispatch + * + * A call to one of `DISPATCH_CALLEES` whose first argument is a `kernel:*` + * string literal — `ctx.trigger('kernel:ready')`, + * `this.triggerHookOrThrow('kernel:listening')`, + * `dispatchHookIsolating('kernel:shutdown', …)` — plus a direct + * `hooks.get('kernel:x')`, which is how both hand-written loops opened before + * #5282 and is therefore how a re-mirrored one would open again. + * + * SUBSCRIPTION is deliberately not a dispatch: `ctx.hook('kernel:ready', fn)` + * in a plugin (e.g. `fallbacks/authored-translation-sync.ts`) consumes a hook + * the kernel already dispatches and pins nothing new. Only the kernels decide + * what a hook MEANS, so only their dispatch sites owe a paired pin. + * + * ## What counts as a named pin + * + * The hook name appearing verbatim in an `it()` / `test()` / `describe()` + * title. Titles, not bodies: a hook that only shows up inside a test body is + * usually incidental setup (`ctx.hook('kernel:ready', …)` to reach some other + * behaviour), whereas a title carrying the name is someone asserting what that + * hook does on that kernel. Both title forms are accepted so the gate stays + * about the PAIRING, never about title style. + * + * ## Usage + * + * node scripts/check-kernel-hook-pairs.mjs # audit the repo + * node scripts/check-kernel-hook-pairs.mjs --list # print what it sees + * node scripts/check-kernel-hook-pairs.mjs --self-test # verify the checker + */ + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import ts from 'typescript'; + +const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..'); + +/** Where kernels live, and the two files that must pin every hook they dispatch. */ +const KERNEL_SRC = 'packages/core/src'; +const PAIRED_TEST_FILES = ['kernel.test.ts', 'lite-kernel.test.ts']; + +/** + * Callees that DISPATCH a hook. `trigger` covers `PluginContext.trigger` on + * both kernels; `triggerHook` / `triggerHookOrThrow` are the base's two + * flavours; the `dispatchHook*` pair is the shared module #5282 introduced. + * `hooks.get` is included because that is how a hand-rolled dispatch loop + * begins — the exact shape #5282 removed, kept in the vocabulary so its return + * is visible rather than silent. + */ +const DISPATCH_CALLEES = new Set([ + 'trigger', + 'triggerHook', + 'triggerHookOrThrow', + 'dispatchHookIsolating', + 'dispatchHookPropagating', + 'get', // only counted on a `hooks.get(...)` receiver — see isHooksGet() +]); + +/** Test-title callees whose first string argument can carry a named pin. */ +const TITLE_CALLEES = new Set(['it', 'test', 'describe']); + +const HOOK_NAME = /^kernel:[A-Za-z][A-Za-z0-9_:-]*$/; + +// ── Scanning ───────────────────────────────────────────────────────────────── + +function parse(fileName, source) { + return ts.createSourceFile(fileName, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); +} + +/** The identifier a call expression ends in: `a.b.c(x)` → `c`, `c(x)` → `c`. */ +function calleeName(expression) { + if (ts.isIdentifier(expression)) return expression.text; + if (ts.isPropertyAccessExpression(expression)) return expression.name.text; + return undefined; +} + +/** `.hooks.get(…)` — the opening line of a hand-rolled dispatch loop. */ +function isHooksGet(expression) { + return ( + ts.isPropertyAccessExpression(expression) && + expression.name.text === 'get' && + ts.isPropertyAccessExpression(expression.expression) && + expression.expression.name.text === 'hooks' + ); +} + +/** + * First argument as a plain string literal, when it is one. `isStringLiteralLike` + * also accepts a substitution-free template literal, which is a perfectly good + * title or hook name; anything interpolated is not statically knowable and is + * deliberately left unresolved rather than guessed. + */ +function firstStringArg(call) { + const [arg] = call.arguments; + if (arg && ts.isStringLiteralLike(arg)) return arg.text; + return undefined; +} + +/** + * Every `kernel:*` hook dispatched in one source file. + * @returns {{hook: string, line: number, callee: string}[]} + */ +export function findDispatches(source, fileName = 'kernel.ts') { + const sf = parse(fileName, source); + const out = []; + const visit = (node) => { + if (ts.isCallExpression(node)) { + const name = calleeName(node.expression); + const counted = name === 'get' ? isHooksGet(node.expression) : DISPATCH_CALLEES.has(name); + if (counted) { + const hook = firstStringArg(node); + if (hook && HOOK_NAME.test(hook)) { + out.push({ + hook, + line: sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1, + callee: name, + }); + } + } + } + ts.forEachChild(node, visit); + }; + visit(sf); + return out; +} + +/** + * Every test/describe title in one test file. + * @returns {string[]} + */ +export function findTitles(source, fileName = 'kernel.test.ts') { + const sf = parse(fileName, source); + const out = []; + const visit = (node) => { + if (ts.isCallExpression(node)) { + // `it`, `it.each(…)`, `describe.skip`, … all reduce to the base + // identifier by peeling property accesses and the `.each(…)` call. + let expression = node.expression; + while (ts.isPropertyAccessExpression(expression) || ts.isCallExpression(expression)) { + expression = expression.expression; + } + if (ts.isIdentifier(expression) && TITLE_CALLEES.has(expression.text)) { + const title = firstStringArg(node); + if (title) out.push(title); + } + } + ts.forEachChild(node, visit); + }; + visit(sf); + return out; +} + +// ── Audit ──────────────────────────────────────────────────────────────────── + +function discoverSourceFiles() { + const out = []; + const skip = new Set(['node_modules', 'dist', 'build', '.turbo', 'coverage']); + const walk = (dir) => { + for (const entry of readdirSync(dir)) { + if (skip.has(entry)) continue; + const full = join(dir, entry); + if (statSync(full).isDirectory()) { walk(full); continue; } + if (!entry.endsWith('.ts') || entry.endsWith('.d.ts')) continue; + if (entry.includes('.test.') || entry.includes('.spec.')) continue; + out.push(relative(ROOT, full).split(sep).join('/')); + } + }; + walk(join(ROOT, KERNEL_SRC)); + return out.sort(); +} + +/** + * The whole audit, over an injectable corpus so the self-test can run it on + * synthetic files instead of the repo. + * + * @param {{sources: Record, tests: Record}} corpus + * @returns {{dispatches: Map, problems: string[]}} + */ +export function audit(corpus) { + /** hook → where it is dispatched */ + const dispatches = new Map(); + for (const [file, source] of Object.entries(corpus.sources)) { + const seen = new Set(); + for (const { hook, line, callee } of findDispatches(source, file)) { + // One dispatch statement can match twice — `dispatchHookIsolating( + // 'kernel:shutdown', this.hooks.get('kernel:shutdown') …)` is both + // an outer dispatch call and an inner `hooks.get`. Report the site + // once, under the outermost callee (visited first). + if (seen.has(`${file}:${line}`)) continue; + seen.add(`${file}:${line}`); + if (!dispatches.has(hook)) dispatches.set(hook, []); + dispatches.get(hook).push(`${file}:${line} (${callee})`); + } + } + + const titles = new Map( + Object.entries(corpus.tests).map(([file, source]) => [file, findTitles(source, file)]), + ); + + const problems = []; + for (const hook of [...dispatches.keys()].sort()) { + const missing = []; + for (const file of PAIRED_TEST_FILES) { + const found = (titles.get(file) ?? []).some((title) => title.includes(hook)); + if (!found) missing.push(file); + } + if (missing.length > 0) { + problems.push( + `${hook} — dispatched at ${dispatches.get(hook).join(', ')} — has no named pin in: ${missing.join(', ')}`, + ); + } + } + return { dispatches, problems }; +} + +function readCorpus() { + const sources = {}; + for (const file of discoverSourceFiles()) sources[file] = readFileSync(join(ROOT, file), 'utf8'); + + const tests = {}; + for (const file of PAIRED_TEST_FILES) { + tests[file] = readFileSync(join(ROOT, KERNEL_SRC, file), 'utf8'); + } + return { sources, tests }; +} + +function run() { + const { dispatches, problems } = audit(readCorpus()); + + if (problems.length > 0) { + console.error('✗ kernel hook pin pairing\n'); + for (const problem of problems) console.error(` ✗ ${problem}`); + console.error( + `\nEvery kernel:* hook dispatched in ${KERNEL_SRC} must be named in a test title in BOTH` + + `\n${PAIRED_TEST_FILES.map((f) => ` ${KERNEL_SRC}/${f}`).join('\n')}` + + '\n\nThe two kernels do not share a hooks map, so a hook pinned on one side only can mean' + + '\nthe opposite thing on the other and no test says so — the shape of #5170, #5257 and' + + '\n#5274. Add a test on the missing side naming the hook in its title (or, if the hook is' + + '\nnot a lifecycle hook both kernels dispatch, stop dispatching it from packages/core/src).', + ); + process.exit(1); + } + + console.log( + `✓ kernel hook pin pairing: ${dispatches.size} dispatched kernel:* hook(s), each pinned in both ` + + PAIRED_TEST_FILES.join(' and '), + ); +} + +function list() { + const { dispatches } = audit(readCorpus()); + for (const hook of [...dispatches.keys()].sort()) { + console.log(`${hook}\n ${dispatches.get(hook).join('\n ')}`); + } +} + +// ── Self-test ──────────────────────────────────────────────────────────────── + +function assert(condition, message) { + if (!condition) { + console.error(`✗ self-test: ${message}`); + process.exit(1); + } +} + +function selfTest() { + const PINNED = (hook) => ` + describe('Kernel', () => { + it('fails the boot when a ${hook} handler throws', async () => {}); + }); + `; + + // 1. A hook pinned on both sides passes. + { + const { problems } = audit({ + sources: { 'kernel.ts': `await this.context.trigger('kernel:ready');` }, + tests: { 'kernel.test.ts': PINNED('kernel:ready'), 'lite-kernel.test.ts': PINNED('kernel:ready') }, + }); + assert(problems.length === 0, `a hook pinned on both sides passes (got ${problems.length})`); + } + + // 2. Pinned on neither side ⇒ one problem naming BOTH files. + { + const { problems } = audit({ + sources: { 'kernel.ts': `await this.context.trigger('kernel:probe');` }, + tests: { 'kernel.test.ts': PINNED('kernel:ready'), 'lite-kernel.test.ts': PINNED('kernel:ready') }, + }); + assert(problems.length === 1, `an unpinned hook is one problem (got ${problems.length})`); + assert(problems[0].includes('kernel:probe'), 'the problem names the hook'); + assert(problems[0].includes('kernel.test.ts'), 'the problem names kernel.test.ts as missing'); + assert(problems[0].includes('lite-kernel.test.ts'), 'the problem names lite-kernel.test.ts as missing'); + assert(problems[0].includes('kernel.ts:1 (trigger)'), 'the problem quotes the dispatch site'); + } + + // 3. Pinned on ONE side ⇒ still red, naming only the side that lacks it. + // This is the whole point: the pair is the unit, not the presence. + { + const { problems } = audit({ + sources: { 'kernel.ts': `await this.context.trigger('kernel:probe');` }, + tests: { 'kernel.test.ts': PINNED('kernel:probe'), 'lite-kernel.test.ts': PINNED('kernel:ready') }, + }); + assert(problems.length === 1, `a half-pinned hook is still a problem (got ${problems.length})`); + assert(problems[0].includes('lite-kernel.test.ts'), 'the missing side is named'); + assert( + !problems[0].includes('in: kernel.test.ts'), + 'the side that DOES pin it is not reported as missing', + ); + } + + // 4. Every dispatch flavour is recognised, including the shared module + // #5282 introduced — a callee missing from the vocabulary is a hook the + // gate cannot see at all, which is worse than a false positive. + { + const { dispatches } = audit({ + sources: { + 'kernel.ts': ` + await this.context.trigger('kernel:a'); + await dispatchHookIsolating('kernel:b', handlers, this.logger); + await dispatchHookPropagating('kernel:c', handlers, undefined, args); + `, + 'lite-kernel.ts': ` + await this.triggerHook('kernel:d'); + await this.triggerHookOrThrow('kernel:e'); + const handlers = this.hooks.get('kernel:f') || []; + `, + }, + tests: { 'kernel.test.ts': '', 'lite-kernel.test.ts': '' }, + }); + assert( + [...dispatches.keys()].sort().join(',') === 'kernel:a,kernel:b,kernel:c,kernel:d,kernel:e,kernel:f', + `every dispatch flavour is seen (got: ${[...dispatches.keys()].sort().join(',')})`, + ); + } + + // 5. Subscription is NOT dispatch. A plugin consuming `kernel:ready` owes + // no pin — the kernels decide what a hook means, plugins only listen. + { + const { dispatches } = audit({ + sources: { 'fallbacks/x.ts': `ctx.hook('kernel:ready', async () => {});` }, + tests: { 'kernel.test.ts': '', 'lite-kernel.test.ts': '' }, + }); + assert(dispatches.size === 0, `ctx.hook() is a subscription, not a dispatch (got ${dispatches.size})`); + } + + // 6. A `get(…)` that is not a `hooks.get(…)` is not a dispatch — the + // vocabulary's most collision-prone name stays scoped to its receiver. + { + const { dispatches } = audit({ + sources: { 'kernel.ts': `const svc = this.services.get('kernel:ready');` }, + tests: { 'kernel.test.ts': '', 'lite-kernel.test.ts': '' }, + }); + assert(dispatches.size === 0, `services.get() is not a hook dispatch (got ${dispatches.size})`); + } + + // 7. Non-`kernel:` hooks are out of scope: `data:beforeInsert` and friends + // are per-plugin vocabularies with no cross-kernel meaning to keep equal. + { + const { dispatches } = audit({ + sources: { 'kernel.ts': `await this.context.trigger('data:beforeInsert', doc);` }, + tests: { 'kernel.test.ts': '', 'lite-kernel.test.ts': '' }, + }); + assert(dispatches.size === 0, `only kernel:* hooks are paired (got ${dispatches.size})`); + } + + // 8. A dynamic hook name cannot be resolved statically and is not guessed — + // that is the generic `trigger(name)` seam every hook flows through. + { + const { dispatches } = audit({ + sources: { 'kernel-base.ts': `await dispatchHookPropagating(name, this.hooks.get(name) || [], undefined, args);` }, + tests: { 'kernel.test.ts': '', 'lite-kernel.test.ts': '' }, + }); + assert(dispatches.size === 0, `a variable hook name is not invented (got ${dispatches.size})`); + } + + // 9. Any title form carries the pin: `describe` counts, and so does a + // modified `it.each(…)`. The gate judges pairing, never title style. + { + const { problems } = audit({ + sources: { 'kernel.ts': `await this.context.trigger('kernel:probe');` }, + tests: { + 'kernel.test.ts': `describe('kernel:probe dispatch', () => { it('propagates', () => {}); });`, + 'lite-kernel.test.ts': `it.each([1])('kernel:probe propagates (%i)', () => {});`, + }, + }); + assert(problems.length === 0, `describe() and it.each() titles both pin (got: ${problems[0] ?? ''})`); + } + + // 10. The name must appear in a TITLE. A hook that only shows up in a test + // BODY is incidental setup for some other assertion, not a pin. + { + const { problems } = audit({ + sources: { 'kernel.ts': `await this.context.trigger('kernel:probe');` }, + tests: { + 'kernel.test.ts': `it('boots', () => { ctx.hook('kernel:probe', fn); });`, + 'lite-kernel.test.ts': `it('boots', () => { ctx.hook('kernel:probe', fn); });`, + }, + }); + assert(problems.length === 1, `a body mention is not a named pin (got ${problems.length})`); + } + + console.log('✓ self-test: 10 cases'); +} + +// Only act when invoked as the entry point. The audit helpers above are +// exported so a measurement can run this checker over a corpus that is not the +// working tree (e.g. `origin/main`, to prove a new gate has no false positives +// before it is pinned in CI) — an import that audited, printed and possibly +// called process.exit(1) as a side effect would make that impossible. +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + if (process.argv.includes('--self-test')) selfTest(); + else if (process.argv.includes('--list')) list(); + else run(); +}