diff --git a/.changeset/dual-kernel-duplicate-plugin-registration.md b/.changeset/dual-kernel-duplicate-plugin-registration.md new file mode 100644 index 0000000000..7e7b744a94 --- /dev/null +++ b/.changeset/dual-kernel-duplicate-plugin-registration.md @@ -0,0 +1,60 @@ +--- +"@objectstack/core": minor +--- + +fix(core): both kernels agree that a duplicate plugin registration OVERWRITES, and say so out loud (#9864) + +Registering two plugins under the same `name` used to mean two different things +depending on which kernel was running: + +| kernel | behaviour before | +|---|---| +| `ObjectKernel` (what `os serve` runs) | accepted and overwrote, with **no check and no distinguishing log line** — `Plugin registered: @` printed twice, reading as two plugins running | +| `LiteKernel` (tests, serverless, edge) | threw `[Kernel] Plugin '' already registered` | + +Under the maintainer's ruling (2026-08-19, option B) both kernels now apply one +declared contract: **duplicate registration by `name` overwrites — last-one-wins +— and emits a `warn` naming the plugin and both versions.** + +``` +WARN Plugin superseded: 'com.objectstack.audit' — the later registration (v2.0.0) + REPLACED the earlier one (v1.0.0). Only the later instance is initialized and + started; the earlier one is discarded without ever running init(). Duplicate + registration by name is last-one-wins on both kernels by declared contract + (#9864) — register the plugin once if that is not what you meant. +``` + +**This declares and warns about behaviour that already shipped; it does not fix a +user-visible bug.** The overwrite is load-bearing today — it is exactly what lets +a stack's own `plugins` entry supersede a plugin the CLI auto-registered earlier +in the same boot (`AuditPlugin`, #9863) — and every boot path that worked before +works the same way now. What changes is that the behaviour is declared, audible, +and pinned against **both** kernels +(`packages/core/src/plugin-registration.contract.test.ts`) rather than being an +accident of whichever kernel a reader happened to open. This was the fourth +measured instance of one contract implemented twice across the two kernels +(#5170, #5282, #8357 adjacent). + +**What this changes for a caller** + +- `LiteKernel.use()` no longer throws on a duplicate name. FROM: catch + `[Kernel] Plugin '' already registered` to detect a double registration. + TO: there is no throw to catch — a duplicate is a `warn` and the later instance + wins. Code that registered a plugin twice and relied on the refusal should + register it once instead. +- `ObjectKernel` emits one `warn` where it previously emitted nothing, and + **suppresses** its `Plugin registered:` line for the superseding registration, + so the count of those lines equals the number of plugins that actually boot. +- The level is part of the contract: `warn`, never `info`. The CLI's default + kernel level is `warn`, and its boot-quiet window replays `warn` while + discarding in-window `info` — an `info` notice would be invisible on exactly + the boot path where this was measured. + +**Measured, not assumed:** the displaced instance holds nothing that needs +teardown. Registration is legal only while the kernel is `idle`, so a supersede +can only ever displace a plugin that has never been initialized; `init()`, +`start()` and `destroy()` all run later, over a registry the displaced entry has +already left. `PluginLoader.loadPlugin()` — which `ObjectKernel` runs first — is +pure validation plus a name-keyed map write of its own, and invokes nothing on +the plugin. Calling `destroy()` on the displaced instance would be the bug, not +the fix: it is the paired teardown for an `init()` that never ran. diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 934d502946..ab81571ca5 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -2491,6 +2491,32 @@ export default class Serve extends Command { } // Pair: AuditPlugin — optional + // + // [#9863 / #9864] Registered with NO options, so record-view + // auditing (`readAudit`) is off on this path. The one way an app + // turns it on today is to put its own + // `new AuditPlugin({ readAudit: … })` in the stack's `plugins` + // array, which this file registers further down — AFTER this line. + // Both instances carry the name `com.objectstack.audit`, so the + // app's supersedes this one and the opt-in takes effect. + // + // That is a DECLARED contract now, not the accident #9863 found it + // as: duplicate registration by name overwrites — last-one-wins, + // with a `warn` naming both versions — identically on both kernels, + // stated in `packages/core/src/plugin-registration.ts` and pinned + // against `ObjectKernel` AND `LiteKernel` by + // `packages/core/src/plugin-registration.contract.test.ts` (#9864, + // maintainer ruling 2026-08-19, option B). Before that ruling the + // behaviour was undeclared, untested and order-dependent, and + // `LiteKernel.use()` threw on the very same input. + // + // ⚠️ The dependency is on the ORDER as much as on the overwrite: + // this registration must stay ABOVE the stack's `plugins` loop, or + // the CLI's option-less instance would supersede the app's + // configured one instead. #9863 remains open on its own question — + // whether `os serve` should grow an `appAuditPluginOptions(config)` + // helper like its `SecurityPlugin` sibling above, rather than + // reaching the capability only through a supersede. try { const auditPkg = '@objectstack/plugin-audit'; const { AuditPlugin } = await import(/* webpackIgnore: true */ auditPkg); @@ -2535,6 +2561,12 @@ export default class Serve extends Command { } } + // [#9863 / #9864] The superseding half of the pair documented at + // the `AuditPlugin` auto-registration above: a stack plugin whose + // `name` matches one auto-registered earlier REPLACES it, by + // declared contract (`packages/core/src/plugin-registration.ts`), + // with a `warn` naming both versions. That is how an app supplies + // options to a plugin this CLI mounts without them. await kernel.use(pluginToLoad); const pluginName = plugin.name || plugin.constructor?.name || 'unnamed'; trackPlugin(pluginName); diff --git a/packages/core/src/kernel.ts b/packages/core/src/kernel.ts index 33e0ca01d6..94c646f7b2 100644 --- a/packages/core/src/kernel.ts +++ b/packages/core/src/kernel.ts @@ -14,6 +14,7 @@ import { describeInitOrderFault, } from './plugin-order.js'; import { dispatchHookIsolating, dispatchHookPropagating } from './hook-dispatch.js'; +import { registerPluginByName } from './plugin-registration.js'; /** * Enhanced Kernel Configuration @@ -178,6 +179,14 @@ export class ObjectKernel { /** * Register a plugin with enhanced validation + * + * Duplicate names OVERWRITE, with one `warn` naming both versions — the + * declared contract in `plugin-registration.ts`, applied identically by + * `LiteKernel.use()` (#9864, maintainer ruling 2026-08-19). The overwrite + * itself is unchanged: it is what lets an app config's `plugins` entry + * supersede a plugin the CLI auto-registered earlier in the same boot + * (#9863). What changes is that it is no longer silent, and no longer + * disagrees with the other kernel. */ async use(plugin: Plugin): Promise { if (this.state !== 'idle') { @@ -186,18 +195,27 @@ export class ObjectKernel { // Load plugin through enhanced loader const result = await this.pluginLoader.loadPlugin(plugin); - + if (!result.success || !result.plugin) { throw new Error(`Failed to load plugin: ${plugin.name} - ${result.error?.message}`); } const pluginMeta = result.plugin; - this.plugins.set(pluginMeta.name, pluginMeta); - - this.logger.info(`Plugin registered: ${pluginMeta.name}@${pluginMeta.version}`, { - plugin: pluginMeta.name, - version: pluginMeta.version, - }); + const superseded = registerPluginByName(this.plugins, pluginMeta, this.logger); + + // [#9864] Suppressed for a superseding registration, deliberately. The + // defect the ruling names is that this line printed TWICE for one + // surviving plugin and so read as two plugins running; the `warn` + // `registerPluginByName` just emitted says everything this line would + // and says which instance survived. Suppressing it here makes the + // count of `Plugin registered:` lines in a boot log equal the number + // of plugins that will actually boot. + if (superseded === undefined) { + this.logger.info(`Plugin registered: ${pluginMeta.name}@${pluginMeta.version}`, { + plugin: pluginMeta.name, + version: pluginMeta.version, + }); + } return this; } diff --git a/packages/core/src/lite-kernel.ts b/packages/core/src/lite-kernel.ts index e4631feaf9..846474cc92 100644 --- a/packages/core/src/lite-kernel.ts +++ b/packages/core/src/lite-kernel.ts @@ -4,6 +4,7 @@ import { Plugin } from './types.js'; import { createLogger, ObjectLogger } from './logger.js'; import type { LoggerConfig } from '@objectstack/spec/system'; import { ObjectKernelBase } from './kernel-base.js'; +import { registerPluginByName } from './plugin-registration.js'; /** * ObjectKernel - MiniKernel Architecture @@ -32,16 +33,23 @@ export class LiteKernel extends ObjectKernelBase { /** * Register a plugin * @param plugin - Plugin instance + * + * Duplicate names OVERWRITE, with one `warn` naming both versions — the + * declared contract in `plugin-registration.ts`, applied identically by + * `ObjectKernel.use()` (#9864, maintainer ruling 2026-08-19). + * + * This method used to `throw` `[Kernel] Plugin '' already + * registered` here while `ObjectKernel` overwrote silently, so one input + * had two meanings depending on which kernel was running — and the kernel + * that runs in production was the silent one. The ruling converged them on + * the behaviour that already works (an app config superseding a plugin the + * CLI auto-registered, #9863) and made it audible rather than removing it. */ use(plugin: Plugin): this { this.validateIdle(); - const pluginName = plugin.name; - if (this.plugins.has(pluginName)) { - throw new Error(`[Kernel] Plugin '${pluginName}' already registered`); - } + registerPluginByName(this.plugins, plugin, this.logger); - this.plugins.set(pluginName, plugin); return this; } diff --git a/packages/core/src/plugin-registration.contract.test.ts b/packages/core/src/plugin-registration.contract.test.ts new file mode 100644 index 0000000000..b95258adfe --- /dev/null +++ b/packages/core/src/plugin-registration.contract.test.ts @@ -0,0 +1,326 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The DECLARED duplicate-plugin-registration contract (#9864, maintainer + * ruling 2026-08-19, option B), pinned against BOTH kernels out of ONE table + * of cases. + * + * ⭐ The dual-kernel shape of this file is the deliverable, not a convenience. + * The card is the FOURTH measured instance of one contract implemented twice + * across `ObjectKernel`/`LiteKernel` and diverging unnoticed (#5170, #5282, + * #8357 adjacent), every one of them found by a human reading both files side + * by side. So the cases below are written ONCE and executed against both + * kernels through a thin adapter: a fifth divergence has to reproduce the + * bug in a case that already exists, rather than waiting to be noticed. + * + * ⛔ Do not "simplify" this into two sibling `describe`s with copied bodies — + * that is the shape the seam keeps growing back through, and a case added to + * one copy is exactly how the two contracts drifted the previous four times. + * The adapter exists so that adding a case cannot cover only one kernel. + * + * What the contract says, in full: + * 1. A duplicate `name` OVERWRITES — it is not an error on either kernel. + * 2. Last-one-wins: only the later instance boots. + * 3. The registry does not accumulate: one entry per name. + * 4. Exactly one `warn` is emitted, and it says *superseded*, names the + * plugin and BOTH versions. + * 5. A supersede never announces a second FIRST registration — the failure + * the ruling names ("`Plugin registered:` prints twice and reads as two + * plugins"). + * 6. The displaced instance is never initialized, started or destroyed — + * the kernel acquired nothing for it, so there is nothing to tear down. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { LiteKernel } from './lite-kernel.js'; +import { ObjectKernel } from './kernel.js'; +import { describeSupersededRegistration } from './plugin-registration.js'; +import type { Plugin } from './types.js'; + +const DUPLICATE_NAME = 'com.objectstack.test.duplicate'; + +/** Lifecycle calls a plugin instance received, in order. */ +type LifecycleLog = string[]; + +function makePlugin( + name: string, + version: string, + tag: string, + log: LifecycleLog, +): Plugin & { healthCheck(): Promise<{ healthy: boolean; message: string }> } { + return { + name, + version, + init: () => { log.push(`${tag}:init`); }, + start: () => { log.push(`${tag}:start`); }, + destroy: () => { log.push(`${tag}:destroy`); }, + healthCheck: async () => ({ healthy: true, message: tag }), + }; +} + +/** + * The uniform surface the cases below drive. Everything kernel-specific — the + * sync/async `use()` split, and the fact that the two kernels expose their + * registry through different public accessors — is absorbed here, so no case + * can accidentally be written for one kernel only. + */ +interface KernelUnderTest { + use(plugin: Plugin): Promise; + bootstrap(): Promise; + shutdown(): Promise; + /** Registered plugin names, read through the kernel's own public API. */ + registeredNames(): Promise; + /** Every message passed to `logger.warn`, in order. */ + warnings(): string[]; + /** Every message passed to `logger.info`, in order. */ + infos(): string[]; +} + +/** The `logger` field both kernels keep on the instance (see `kernel.test.ts`). */ +type WithLogger = { logger: Record<'info' | 'warn', (...args: unknown[]) => void> }; + +function captureLogs(kernel: unknown): { warnings: string[]; infos: string[] } { + const warnings: string[] = []; + const infos: string[] = []; + const logger = (kernel as WithLogger).logger; + // Spied at the METHOD, so the records are captured whatever level the + // logger is configured to emit at — the level filter lives downstream. + vi.spyOn(logger, 'warn').mockImplementation((message: unknown) => { + warnings.push(String(message)); + }); + vi.spyOn(logger, 'info').mockImplementation((message: unknown) => { + infos.push(String(message)); + }); + return { warnings, infos }; +} + +const KERNELS: Array<{ label: string; create(): KernelUnderTest }> = [ + { + label: 'LiteKernel', + create() { + const kernel = new LiteKernel({ logger: { level: 'error' } }); + const captured = captureLogs(kernel); + return { + use: async (plugin) => { kernel.use(plugin); }, + bootstrap: () => kernel.bootstrap(), + shutdown: () => kernel.shutdown(), + registeredNames: async () => [...kernel.getPlugins().keys()], + warnings: () => captured.warnings, + infos: () => captured.infos, + }; + }, + }, + { + label: 'ObjectKernel', + create() { + const kernel = new ObjectKernel({ + skipSystemValidation: true, + gracefulShutdown: false, + logger: { level: 'error' }, + }); + const captured = captureLogs(kernel); + return { + use: async (plugin) => { await kernel.use(plugin); }, + bootstrap: () => kernel.bootstrap(), + shutdown: () => kernel.shutdown(), + // `checkAllPluginsHealth()` walks the KERNEL's registry keys and + // resolves each name through the PluginLoader's own map, so this + // read covers both name-keyed maps a registration writes. + registeredNames: async () => [...(await kernel.checkAllPluginsHealth()).keys()], + warnings: () => captured.warnings, + infos: () => captured.infos, + }; + }, + }, +]; + +describe.each(KERNELS)('duplicate plugin registration — $label', ({ create }) => { + it('OVERWRITES instead of refusing: a duplicate name does not throw', async () => { + const log: LifecycleLog = []; + const kernel = create(); + + await kernel.use(makePlugin(DUPLICATE_NAME, '1.0.0', 'first', log)); + + // The convergence itself. `LiteKernel.use()` threw + // `[Kernel] Plugin '' already registered` here while + // `ObjectKernel` overwrote silently — one input, two meanings. + await expect( + kernel.use(makePlugin(DUPLICATE_NAME, '2.0.0', 'second', log)), + ).resolves.toBeUndefined(); + }); + + it('is LAST-one-wins: only the later instance boots, and the registry does not accumulate', async () => { + const log: LifecycleLog = []; + const kernel = create(); + + await kernel.use(makePlugin(DUPLICATE_NAME, '1.0.0', 'first', log)); + await kernel.use(makePlugin(DUPLICATE_NAME, '2.0.0', 'second', log)); + await kernel.use(makePlugin('com.objectstack.test.control', '1.0.0', 'control', log)); + + await kernel.bootstrap(); + + // The displaced instance never boots; the later one does. + expect(log).toContain('second:init'); + expect(log).toContain('second:start'); + expect(log).not.toContain('first:init'); + expect(log).not.toContain('first:start'); + + // Three `use()` calls, two names: the earlier entry is DROPPED, not + // shadowed behind the later one. + expect((await kernel.registeredNames()).sort()).toEqual([ + 'com.objectstack.test.control', + DUPLICATE_NAME, + ]); + + await kernel.shutdown(); + }); + + it('warns EXACTLY once, saying superseded and naming the plugin and BOTH versions', async () => { + const log: LifecycleLog = []; + const kernel = create(); + const first = makePlugin(DUPLICATE_NAME, '1.0.0', 'first', log); + const second = makePlugin(DUPLICATE_NAME, '2.0.0', 'second', log); + + await kernel.use(first); + await kernel.use(second); + + const superseding = kernel.warnings().filter((line) => line.includes(DUPLICATE_NAME)); + expect(superseding).toHaveLength(1); + + // The whole line, from the one function that renders it — so a + // reworded warning has to be reworded here too, deliberately. + expect(superseding[0]).toBe(describeSupersededRegistration(first, second)); + + // …and the properties that wording has to keep, asserted against the + // RUNTIME string rather than against the source that produces it. + expect(superseding[0]).toContain('superseded'); + expect(superseding[0]).toContain(DUPLICATE_NAME); + expect(superseding[0]).toContain('v1.0.0'); // the one being replaced + expect(superseding[0]).toContain('v2.0.0'); // the one that survives + }); + + it('cannot be read as a first registration: different verb, and never at info level', async () => { + const log: LifecycleLog = []; + const kernel = create(); + + await kernel.use(makePlugin(DUPLICATE_NAME, '1.0.0', 'first', log)); + await kernel.use(makePlugin(DUPLICATE_NAME, '2.0.0', 'second', log)); + + const superseding = kernel.warnings().filter((line) => line.includes('superseded')); + expect(superseding).toHaveLength(1); + + // Leads with a different verb than the registration line, so the two + // are told apart by the first token of the message. + expect(superseding[0].startsWith('Plugin superseded:')).toBe(true); + expect(superseding[0].startsWith('Plugin registered:')).toBe(false); + + // Level is part of the contract, not a preference. The CLI's default + // kernel level is `warn` and its boot-quiet window + // (`BOOT_DIAGNOSTIC_FLOOR`) discards in-window `info` while replaying + // `warn` — an `info` supersede notice would be invisible on the very + // boot path (`os serve`) where the defect was measured. + expect(kernel.infos().filter((line) => line.includes('superseded'))).toEqual([]); + }); + + it('the displaced instance is never initialized, started or destroyed — nothing to tear down', async () => { + // #9864 asked this to be ANSWERED, not assumed: the displaced plugin + // has been through `pluginLoader.loadPlugin()` on `ObjectKernel`, so if + // registration acquired anything on its behalf, "overwrite" would also + // be a leak. It does not. Registration is legal only while the kernel + // is `idle`, and every lifecycle call is made from `bootstrap()` / + // `destroy()` over the resolved order read out of the registry the + // displaced entry has already left. + const log: LifecycleLog = []; + const kernel = create(); + + await kernel.use(makePlugin(DUPLICATE_NAME, '1.0.0', 'first', log)); + await kernel.use(makePlugin(DUPLICATE_NAME, '2.0.0', 'second', log)); + + await kernel.bootstrap(); + await kernel.shutdown(); + + // A FULL lifecycle has run — so the absence below is a measurement, + // not an empty log. `destroy()` in particular is the teardown that a + // leak would have needed and, correctly, never runs for `first`: + // running it would tear down state `init()` never set up. + expect(log).toEqual( + expect.arrayContaining(['second:init', 'second:start', 'second:destroy']), + ); + expect(log.filter((entry) => entry.startsWith('first:'))).toEqual([]); + }); +}); + +/** + * The half of the contract that has no LiteKernel counterpart, and is scoped + * here rather than being forced into the shared table above. + * + * `ObjectKernel` announces every registration (`Plugin registered: + * @`) and keeps a second name-keyed map inside `PluginLoader`. + * `LiteKernel` does neither — it logs nothing on registration and owns one + * map. Writing these two cases into the shared table would make them assert + * `0 === 0` on LiteKernel: a case that cannot fail there, reading as coverage + * it does not have. What is genuinely shared is above; this is the rest. + */ +describe('duplicate plugin registration — ObjectKernel-only surface', () => { + function objectKernel() { + const kernel = new ObjectKernel({ + skipSystemValidation: true, + gracefulShutdown: false, + logger: { level: 'error' }, + }); + return { kernel, captured: captureLogs(kernel) }; + } + + /** `Plugin registered: @` — the line that says "this is in the registry now". */ + const registrationAnnouncements = (infos: string[]) => + infos.filter((line) => line.startsWith('Plugin registered:') && line.includes(DUPLICATE_NAME)); + + it('a supersede does not announce a SECOND first-registration', async () => { + // The ruling's exact complaint: `Plugin registered: @` + // printed twice for ONE surviving plugin and so read as two plugins + // running. The single-registration run below is the control — it + // expects ONE announcement, so this case fails if the announcement + // stops being emitted at all, not only if it is emitted twice. + const log: LifecycleLog = []; + + const once = objectKernel(); + await once.kernel.use(makePlugin(DUPLICATE_NAME, '1.0.0', 'only', log)); + expect(registrationAnnouncements(once.captured.infos)).toHaveLength(1); + + const twice = objectKernel(); + await twice.kernel.use(makePlugin(DUPLICATE_NAME, '1.0.0', 'first', log)); + await twice.kernel.use(makePlugin(DUPLICATE_NAME, '2.0.0', 'second', log)); + expect(registrationAnnouncements(twice.captured.infos)).toHaveLength(1); + + // The surviving announcement is the FIRST one, chronologically — the + // duplicate's is suppressed. Which instance actually boots is stated + // by the `warn` that immediately follows it, and that pairing is the + // whole readable sequence a boot log now carries: + // + // INFO Plugin registered: @1.0.0 + // WARN Plugin superseded: '' — the later registration (v2.0.0) + // REPLACED the earlier one (v1.0.0). … + expect(registrationAnnouncements(twice.captured.infos)[0]).toContain('@1.0.0'); + expect(twice.captured.warnings.filter((l) => l.includes('superseded'))).toHaveLength(1); + }); + + it('the PluginLoader keeps the surviving instance, not the displaced one', async () => { + // `ObjectKernel` writes TWO name-keyed maps per registration: its own + // `plugins`, and `PluginLoader.loadedPlugins` (written inside + // `loadPlugin()`, before the kernel's map). The second is invisible to + // the shared cases above, and it is the map `checkPluginHealth()` + // reads — so a supersede that updated only one of them would answer a + // health probe from the DISPLACED instance. + const log: LifecycleLog = []; + const { kernel } = objectKernel(); + + // Control first: one registration answers from the instance that was + // registered, so a `second` below is a real displacement rather than + // this probe answering the same way whatever is in the map. + await kernel.use(makePlugin(DUPLICATE_NAME, '1.0.0', 'first', log)); + expect((await kernel.checkPluginHealth(DUPLICATE_NAME)).message).toBe('first'); + + await kernel.use(makePlugin(DUPLICATE_NAME, '2.0.0', 'second', log)); + expect((await kernel.checkPluginHealth(DUPLICATE_NAME)).message).toBe('second'); + }); +}); diff --git a/packages/core/src/plugin-registration.ts b/packages/core/src/plugin-registration.ts new file mode 100644 index 0000000000..dec7eb10fb --- /dev/null +++ b/packages/core/src/plugin-registration.ts @@ -0,0 +1,160 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { Logger } from '@objectstack/spec/contracts'; + +/** + * The DECLARED duplicate-plugin-registration contract — one statement, shared + * by both kernels (#9864, maintainer ruling 2026-08-19, option B). + * + * ## What the contract says + * + * Registering a plugin whose `name` is already registered **OVERWRITES** the + * earlier registration — last-one-wins — and emits ONE `warn` naming the + * plugin and BOTH versions. It is not an error on either kernel. + * + * ## Why it is written down here rather than in each kernel + * + * It was previously written twice, and the two copies disagreed: + * `ObjectKernel.use()` accepted the duplicate silently (a bare + * `plugins.set(name, meta)` with no check and no distinguishing log line), + * while `LiteKernel.use()` threw `[Kernel] Plugin '' already registered`. + * `ObjectKernel` is the kernel `os serve` runs, so the production meaning of a + * duplicate registration was the silent one — and it is load-bearing: it is + * exactly what lets an app config's `plugins` entry supersede a plugin the CLI + * auto-registered earlier in the same boot (#9863's `AuditPlugin` case). That + * behaviour is PRESERVED here on purpose; what changes is that it is now + * declared, audible, and pinned against both kernels + * (`plugin-registration.contract.test.ts`) instead of being an accident of + * whichever kernel a reader happened to open. + * + * This is the fourth measured instance of one contract implemented twice + * across `ObjectKernel`/`LiteKernel` (#5170 hook-error propagation, #5282 + * `ObjectKernel` not inheriting `ObjectKernelBase`, #8357 adjacent one layer + * up). `ObjectKernel` still does not extend `ObjectKernelBase`, so a shared + * base class is not available as the sharing mechanism — a module both kernels + * import is, and it is the same mechanism `plugin-order.ts` and + * `hook-dispatch.ts` already use for the contracts they own. + * + * ⛔ Deliberately NOT exported from the package barrel. Under the ruling this + * card declares EXISTING behaviour; it does not mint a public registration or + * supersede API (that was the shape of the option that was NOT ruled). Both + * kernels import it by relative path, as they do `plugin-order.js`'s internals. + */ + +/** + * The only members the registration contract reads. Satisfied by both kernels' + * registry value types — `Plugin` (LiteKernel, `version` optional) and + * `PluginMetadata` (ObjectKernel, `version` always present because + * `PluginLoader.toPluginMetadata()` defaults it). + */ +export interface NamedRegistration { + name: string; + version?: string; +} + +/** Render a version for the warning; a plugin may legitimately carry none. */ +function versionLabel(plugin: NamedRegistration): string { + return plugin.version ? `v${plugin.version}` : 'unversioned'; +} + +/** + * The superseding warning's text. + * + * ## Why it cannot be confused with a first registration + * + * The failure this card exists to end is that a superseding registration was + * indistinguishable from a first one: `ObjectKernel` logged + * `Plugin registered: @` for BOTH, so one plugin replacing + * another read as two plugins running. Four properties keep them apart, and + * each is pinned by the contract test: + * + * 1. **A different verb, first thing on the line** — `Plugin superseded:`, not + * `Plugin registered:`. A boot log greps and eyeballs the same way. + * 2. **A different LEVEL** — `warn`, never `info`. This is not decoration on + * the `os serve` path: the CLI's default kernel level is `warn` + * (`DEFAULT_LOG_LEVEL`, `packages/cli/src/utils/log-level.ts`), at which + * `Plugin registered:` is not emitted at all; and the boot-quiet window + * (`BootLogCapture`, `BOOT_DIAGNOSTIC_FLOOR = 'warn'`) DISCARDS in-window + * `info` and replays only `warn` and above. An `info`-level supersede notice + * would be invisible on precisely the boot path where the defect lives. + * 3. **BOTH versions, in order** — `(v1.0.0) → (v2.0.0)`. A plugin silently + * replaced by a differently-configured instance of ITSELF is the expensive + * direction, and there the two names are identical; the versions and the + * arrow are what make one line say which instance survived. + * 4. **The consequence, stated** — the earlier instance is discarded before it + * ever boots, so a reader is not left to infer whether two plugins are now + * running. + * + * `ObjectKernel` additionally SUPPRESSES its `Plugin registered:` line for a + * superseding registration, so the count of `Plugin registered:` lines in a + * boot log equals the number of plugins that will actually boot. + */ +export function describeSupersededRegistration( + previous: NamedRegistration, + next: NamedRegistration, +): string { + return ( + `Plugin superseded: '${next.name}' — the later registration (${versionLabel(next)}) ` + + `REPLACED the earlier one (${versionLabel(previous)}). Only the later instance is ` + + `initialized and started; the earlier one is discarded without ever running init(). ` + + `Duplicate registration by name is last-one-wins on both kernels by declared contract ` + + `(#9864) — register the plugin once if that is not what you meant.` + ); +} + +/** + * Apply the declared contract: write `plugin` into `registry` under its name, + * warning when that displaces an earlier registration. + * + * @returns the displaced registration, or `undefined` for a first registration. + * Callers use it to decide whether this was a plain registration — + * `ObjectKernel` suppresses its `Plugin registered:` line when it was + * not. + * + * ## Why no teardown of the displaced plugin (measured, #9864) + * + * Both kernels refuse registration outside the `idle` state + * (`ObjectKernelBase.validateIdle()`; `ObjectKernel.use()`'s own state check), + * so a supersede can only ever displace a plugin the kernel has **not yet + * initialized** — `init()`, `start()` and `destroy()` all run from + * `bootstrap()`/`destroy()`, over the resolved order read out of this very + * registry, from which the displaced entry is already gone. `PluginLoader. + * loadPlugin()`, which `ObjectKernel` runs BEFORE this point, is pure + * validation plus a name-keyed map write of its own (so it drops the displaced + * metadata for the same reason, rather than accumulating it); it invokes + * nothing on the plugin. + * + * The kernel therefore acquired nothing on the displaced plugin's behalf, and + * there is nothing here to tear down. Calling `destroy()` on it would be the + * bug, not the fix: `destroy()` is the paired teardown for `init()`, and + * running it against a never-initialized instance runs cleanup over state that + * was never set up. Anything the displaced instance holds was acquired by the + * CALLER's own `new` before `use()` was reached — a survey of all 52 in-tree + * `implements Plugin` classes found none that acquires an OS-level resource in + * its constructor (41 have a constructor body; every one normalizes options or + * builds in-memory helpers — e.g. `HonoServerPlugin`'s `new HonoHttpServer()` + * only constructs a `Hono` app, its socket opening at `kernel:listening`). + */ +export function registerPluginByName( + registry: Map, + plugin: T, + logger: Pick, +): T | undefined { + const previous = registry.get(plugin.name); + + if (previous !== undefined) { + // `warn`, not `error`: this is a FUNCTIONAL, fully-visible outcome — + // the composition the host asked for is the one that boots, and + // nothing that claims to be persisted fails to land. See AGENTS.md + // "Degradation log levels" for why that distinction decides the level. + logger.warn(describeSupersededRegistration(previous, plugin), { + plugin: plugin.name, + supersededVersion: previous.version, + supersedingVersion: plugin.version, + }); + } + + registry.set(plugin.name, plugin); + + return previous; +}