diff --git a/.changeset/litekernel-enforces-plugin-contract.md b/.changeset/litekernel-enforces-plugin-contract.md new file mode 100644 index 0000000000..cfb54fe9b5 --- /dev/null +++ b/.changeset/litekernel-enforces-plugin-contract.md @@ -0,0 +1,39 @@ +--- +"@objectstack/core": minor +--- + +`LiteKernel.use()` now enforces the declared plugin contract — the same check, the same refusal, as `ObjectKernel.use()`. A plugin object that `PluginSchema` (`@objectstack/spec`, `kernel/plugin.zod.ts`) refuses is refused at registration on **both** published kernels instead of on one. + +**BREAKING** accept-set narrowing on a published runtime entry point, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **A plugin object `LiteKernel` accepted before can be refused now.** Until this release `LiteKernel.use()` wrote the object straight into its registry: `PluginSchema` was run by `PluginLoader.validatePluginContract` only, and `PluginLoader` is reached from `ObjectKernel.use()` alone. So the same plugin was accepted by one kernel and refused by the other — a `type: 'ui'` plugin with no `slug` was refused by `ObjectKernel` with `PLUGIN_CONTRACT_VIOLATION` and mounted a route on `LiteKernel`. `AGENTS.md` names `LiteKernel` for tests, serverless and edge, so the lenient kernel was the one authors develop against and the strict one was production: a plugin could be green in vitest and refused at boot. Maintainer ruling of 2026-09-08 (option A, under the precedent that the two kernels converge rather than diverge): `LiteKernel` validates too. + +**Exactly what `LiteKernel.use()` newly refuses** is exactly what `ObjectKernel.use()` has refused since the `kernel.use()` enforcement release: all EIGHT declared keys, each refused with the offending key named in the message — + +- **`id`** — a non-string, or the empty string. +- **`type`** — any value outside the closed set `standard`, `ui`, `driver`, `server`, `app`, `theme`, `agent`, `objectql`. +- **`staticPath`** — a non-string. +- **`slug`** — a non-string, or a string that does not match `/^[a-z0-9-_]+$/`. +- **`default`** — a non-boolean. +- **`description`** — a non-string. +- **`author`** — a non-string. +- **`homepage`** — a non-string, or a string that is not a URL. + +**`null` is refused on every one of the eight**, and a `type: 'ui'` plugin missing `staticPath` or `slug` is refused with `PLUGIN_UI_REQUIRED_KEY_MISSING` inside the same envelope. + +**What a refusal looks like — one refusal, from either kernel.** The check is now one function (`assertPluginContract`, package-internal) that both kernels call, so the code and the message are produced once: + +``` +PLUGIN_CONTRACT_VIOLATION: plugin '@acme/console' is refused by the declared +plugin contract at 'slug': PLUGIN_UI_REQUIRED_KEY_MISSING: a `type: 'ui'` plugin must declare `slug` — … +``` + +`LiteKernel.use()` is synchronous and throws that error as-is, so the stable code is on the error's `code` property as well as at the head of the message. `ObjectKernel.use()` is unchanged: it still re-wraps a failed load as `Failed to load plugin: - `, its existing wrapper for every load failure. The text after that prefix is byte-for-byte the `LiteKernel` message for the same input, pinned by test. + +**What is STILL ACCEPTED on `LiteKernel` — the narrowing stops where `ObjectKernel`'s does.** Unknown keys still pass (`PluginSchema` carries no `.strict()`, and the parse output is discarded, so the stored object is the very object passed in). A version-less plugin still loads, and so do `1.0.0-alpha.1` and `1.0.0+20230101`: `version` is excluded from the schema check on both kernels, and `LiteKernel` — which has never judged `version` — still does not. A plugin declaring no `type` still loads and still stores no `type`. A class-based plugin keeps its identity, its prototype and its prototype methods. And `PluginLoader`'s structural checks (`name`, `init`, semver) stay the loader's own: the convergence is on the schema, not on the loader. + +**Ordering, stated because it is observable.** `LiteKernel.use()` checks its state first (a kernel past bootstrap still says `Cannot register plugins after bootstrap has started`, never `PLUGIN_CONTRACT_VIOLATION`), then the contract, then registers — so a refused plugin never reaches the registry and cannot supersede an earlier registration under its name. + +**Blast radius, measured before landing rather than assumed.** Across this repository's suites, 813 `LiteKernel.use()` calls were reachable; 807 were accepted by the schema unchanged and the six refusals came from three test-local fixture objects in two files — zero product or library code. Externally authored plugins registered on `LiteKernel` are the population this reaches, and they are exactly the plugins that would already have been refused by `ObjectKernel` at production boot. + +**Migration.** There is nothing to rename. A plugin refused on `LiteKernel` now was already refused on `ObjectKernel`; fix the named key: give `type` a value from the closed set (or drop it — an absent `type` reads as `standard`), declare `staticPath` and `slug` on a `type: 'ui'` plugin, spell `slug` in `[a-z0-9-_]`, make `homepage` a URL, and never `null` a declared key. The refusal names the plugin and the first violated key. + + diff --git a/content/docs/plugins/anatomy.mdx b/content/docs/plugins/anatomy.mdx index ba629dc66c..a3e3927226 100644 --- a/content/docs/plugins/anatomy.mdx +++ b/content/docs/plugins/anatomy.mdx @@ -69,8 +69,9 @@ ObjectStack uses `type` discrimination to optimize runtime behavior, allowing th * **Behavior:** * **Passive:** Driven by `plugin-hono-server` (or other HTTP adapters). * **Static Assets:** Must provide `staticPath` pointing to a build output (e.g., `dist/`). - * **Routing:** Automatically mounted to `/{slug}` with SPA fallback support. + * **Routing:** Must provide `slug`; automatically mounted to `/{slug}` with SPA fallback support. * **Assets:** Files are served locally under `/{slug}/assets`. + * **Enforced at registration:** a `type: 'ui'` plugin missing `staticPath` or `slug` is refused by `kernel.use()` with `PLUGIN_CONTRACT_VIOLATION` — on `ObjectKernel` and `LiteKernel` alike — so a UI plugin that boots in a `LiteKernel` test harness is one that also boots in production. ### 3. App Plugin (`app`) * **Role:** Vertical Business Solution. @@ -259,6 +260,7 @@ Plugins follow a strict three-phase lifecycle managed by the kernel: └──────────────────────┘ ``` +0. **kernel.use()** — Registers the plugin. Before storing it, both kernels check the object against the declared plugin contract (`PluginSchema` in `@objectstack/spec`) and refuse one that violates it with `PLUGIN_CONTRACT_VIOLATION`, naming the first violated key — a `type` outside the closed set, a non-URL `homepage`, a `null` on a declared key, or a `type: 'ui'` plugin missing `staticPath` / `slug`. The object is validated, never replaced: what is stored is the very instance you passed in, prototype and all. 1. **init()** — Called during kernel initialization. Register services that other plugins may depend on. 2. **start()** — Called after *all* plugins have initialized. Start servers, connect to databases, or execute main logic. 3. **destroy()** — Called during shutdown, in reverse order. Clean up connections, timers, and resources. diff --git a/packages/cli/test/fixtures/option-b-reader-probe.ts b/packages/cli/test/fixtures/option-b-reader-probe.ts index c6e078e48a..03650ac163 100644 --- a/packages/cli/test/fixtures/option-b-reader-probe.ts +++ b/packages/cli/test/fixtures/option-b-reader-probe.ts @@ -186,7 +186,11 @@ function makeRecorder(rec: Recording) { let ctxRef: Bag | undefined; return { name: 'com.objectstack.probe.option-b-recorder', - type: 'service' as const, + // No `type`: `PluginSchema` defaults an absent `type` to `standard`, and + // `'service'` is not a member of the declared closed set — `ObjectKernel` + // refused this object already, and since #16721 `LiteKernel.use()` (the + // kernel `bootAndRecord` boots) runs the same contract. Nothing here reads + // `.type`; the recorder IS the subsystems, not a typed plugin. version: '1.0.0', init: async (ctx: Bag) => { ctxRef = ctx; diff --git a/packages/core/src/lite-kernel.ts b/packages/core/src/lite-kernel.ts index 846474cc92..38912c8785 100644 --- a/packages/core/src/lite-kernel.ts +++ b/packages/core/src/lite-kernel.ts @@ -5,6 +5,7 @@ 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'; +import { assertPluginContract } from './plugin-contract.js'; /** * ObjectKernel - MiniKernel Architecture @@ -34,6 +35,26 @@ export class LiteKernel extends ObjectKernelBase { * Register a plugin * @param plugin - Plugin instance * + * A plugin object the DECLARED plugin contract refuses is refused here, + * with `PLUGIN_CONTRACT_VIOLATION` — the same check, the same envelope, + * that `ObjectKernel.use()` runs through `PluginLoader` (`plugin-contract.ts` + * is the one statement both kernels call; #16721, maintainer ruling + * 2026-09-08, option A under #9864's precedent that the kernels converge). + * + * This method used to write the object straight into the registry, so the + * same plugin was accepted by this kernel and refused by `ObjectKernel` — + * and `AGENTS.md` names THIS kernel for tests, so a plugin could be green + * in vitest and refused at production boot. Measured before converging + * (#16721 step 1): of 813 `LiteKernel.use()` calls reachable in this + * repository's suites, 807 were accepted by the schema unchanged and the + * six refusals came from three test-local fixture objects, none of them + * product code. + * + * Ordering, and why it is pinned: state first (`validateIdle`), then the + * contract, then registration — a refused plugin never reaches the + * registry, so it can neither be booted nor supersede an earlier + * registration under its name. + * * 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). @@ -48,6 +69,9 @@ export class LiteKernel extends ObjectKernelBase { use(plugin: Plugin): this { this.validateIdle(); + // Same check, same envelope, as `ObjectKernel.use()` (#16721). + assertPluginContract(plugin); + registerPluginByName(this.plugins, plugin, this.logger); return this; diff --git a/packages/core/src/plugin-contract-enforcement.test.ts b/packages/core/src/plugin-contract-enforcement.test.ts index b7b1776de0..3f281fe290 100644 --- a/packages/core/src/plugin-contract-enforcement.test.ts +++ b/packages/core/src/plugin-contract-enforcement.test.ts @@ -1,7 +1,16 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * `kernel.use()` enforces the DECLARED plugin contract (#16049). + * `kernel.use()` enforces the DECLARED plugin contract (#16049) — on BOTH + * published kernels (#16721). + * + * WHICH KERNEL. Groups A–F drive `ObjectKernel.use()`, the path #16049 wired + * (`PluginLoader.validatePluginContract`). Group G drives `LiteKernel.use()`, + * which #16721 converged onto the SAME check — `assertPluginContract` in + * `plugin-contract.ts`, the one statement both kernels call. G is not a copy + * of A–F: it pins the cases whose answer DIFFERED between the kernels before + * #16721, the parity of the envelope for one input, and the two orderings + * `LiteKernel.use()` owes (state before contract, contract before registry). * * WHY THIS FILE EXISTS. `PluginSchema` (`@objectstack/spec`, * `kernel/plugin.zod.ts`) had zero runtime callers. The boot path ran three @@ -32,6 +41,7 @@ import { describe, expect, it } from 'vitest'; import { ObjectKernel } from './kernel.js'; +import { LiteKernel } from './lite-kernel.js'; import { PluginLoader } from './plugin-loader.js'; import { ObjectLogger } from './logger.js'; import { PLUGIN_UI_REQUIRED_KEY_MISSING } from '@objectstack/spec/kernel'; @@ -42,12 +52,31 @@ function makeKernel(): ObjectKernel { return new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); } -/** What `kernel.use()` left in the kernel's own plugin map. */ -function stored(kernel: ObjectKernel, name: string): Record | undefined { +/** A `LiteKernel` that registers plugins; it installs no signal handlers of its own. */ +function makeLiteKernel(): LiteKernel { + return new LiteKernel({ logger: { level: 'silent' } }); +} + +/** What `kernel.use()` left in the kernel's own plugin map — either kernel. */ +function stored(kernel: ObjectKernel | LiteKernel, name: string): Record | undefined { return (kernel as unknown as { plugins: Map> }) .plugins.get(name); } +/** + * The synchronous twin of {@link refusal}: `LiteKernel.use()` throws rather + * than rejects. Same discipline — a case whose input STOPPED being refused + * reports "it loaded", never a property miss on a kernel. + */ +function refusalSync(register: () => unknown): Error & { code?: string } { + try { + register(); + } catch (e) { + return e as Error & { code?: string }; + } + throw new Error('expected LiteKernel.use() to refuse the plugin, but it loaded'); +} + /** * A plugin object under test. The keys under test (`type`, `slug`, `homepage`, * `id`, `staticPath`) used to be declared by `PluginSchema` and NOT by the @@ -346,3 +375,170 @@ describe('E — `version` is DELIBERATELY not enforced from the schema', () => { expect(err.message).not.toContain('PLUGIN_CONTRACT_VIOLATION'); }); }); + +describe('G — the SAME contract on LiteKernel.use() (#16721)', () => { + /** + * Before #16721 every refusal above had an accepting twin on this kernel: + * `LiteKernel.use()` wrote the object straight into its registry, so the + * object group A refuses mounted routes here. `AGENTS.md` names this + * kernel for tests, so "green in vitest, refused at boot" was the shape + * of the trap. These cases pin the convergence — same code, same key, + * same message — and the two properties this kernel's `use()` owes that + * the loader path states elsewhere: the `code` PROPERTY survives (there + * is no re-wrap here), and a refused plugin never touches the registry. + */ + it('refuses the legacy `ui-plugin` type, synchronously, with the code on the property AND at the head of the message', () => { + const kernel = makeLiteKernel(); + const legacy = fixture({ + name: '@os-fixture/lite-legacy-ui', + type: 'ui-plugin' as unknown as Plugin['type'], + staticPath: UI_STATIC_PATH, + slug: 'lite-legacy-ui', + }); + + const err = refusalSync(() => kernel.use(legacy)); + expect(err.code).toBe('PLUGIN_CONTRACT_VIOLATION'); + expect(err.message.startsWith('PLUGIN_CONTRACT_VIOLATION: ')).toBe(true); + expect(err.message).toContain('@os-fixture/lite-legacy-ui'); + expect(err.message).toContain("at 'type'"); + + // …and nothing was stored, so no later seam can read it off the kernel. + expect(stored(kernel, '@os-fixture/lite-legacy-ui')).toBeUndefined(); + }); + + it('CALIBRATION — the same fixture with the modern `ui` value loads, stored verbatim', () => { + const kernel = makeLiteKernel(); + const modern = fixture({ + name: '@os-fixture/lite-modern-ui', + type: 'ui', + staticPath: UI_STATIC_PATH, + slug: 'lite-modern-ui', + }); + + expect(kernel.use(modern)).toBe(kernel); + expect(stored(kernel, '@os-fixture/lite-modern-ui')).toBe(modern); + }); + + it.each([ + ['staticPath', { name: '@os-fixture/lite-ui-no-static-path', type: 'ui', slug: 'lite-ui-no-static-path' }], + ['slug', { name: '@os-fixture/lite-ui-no-slug', type: 'ui', staticPath: UI_STATIC_PATH }], + ] as const)('refuses a `ui` plugin with no `%s`, naming the key and the spec code (#16334 reaches this kernel now)', (key, overrides) => { + // The two inputs #16721 was filed on: refused by `ObjectKernel` (group F), + // and until now stored verbatim here — the hono auto-discovery pin's + // group F carried the accepting readings and was rewritten with this. + const kernel = makeLiteKernel(); + const bad = fixture({ ...overrides } as Partial & { name: string }); + + const err = refusalSync(() => kernel.use(bad)); + expect(err.code).toBe('PLUGIN_CONTRACT_VIOLATION'); + expect(err.message).toContain(`at '${key}'`); + expect(err.message).toContain(PLUGIN_UI_REQUIRED_KEY_MISSING); + expect(stored(kernel, overrides.name)).toBeUndefined(); + }); + + it('refuses `null` on a declared key — `.optional()` admits absence, never `null`', () => { + const kernel = makeLiteKernel(); + const bad = fixture({ name: '@os-fixture/lite-null-author', author: null as unknown as string }); + + const err = refusalSync(() => kernel.use(bad)); + expect(err.code).toBe('PLUGIN_CONTRACT_VIOLATION'); + expect(err.message).toContain("at 'author'"); + }); + + it('a plugin declaring NO type still loads and no `type` is written back', () => { + const kernel = makeLiteKernel(); + const untyped: Plugin = { name: 'com.example.lite-untyped', version: '1.0.0', init: () => {} }; + + expect(kernel.use(untyped)).toBe(kernel); + // The parse output is discarded on this kernel too: `.default('standard')` + // must NOT have been written back onto the stored object. + expect(stored(kernel, 'com.example.lite-untyped')).toBe(untyped); + expect(stored(kernel, 'com.example.lite-untyped')?.type).toBeUndefined(); + }); + + it('⭐ a CLASS-BASED plugin keeps its identity, prototype and prototype methods', () => { + class LiteClassPlugin implements Plugin { + name = 'com.example.lite-class-based'; + version = '2.3.4'; + type = 'standard' as const; + async init(_ctx: PluginContext): Promise { /* no services */ } + describeSelf(): string { return `class:${this.name}`; } + } + + const kernel = makeLiteKernel(); + const instance = new LiteClassPlugin(); + + expect(kernel.use(instance)).toBe(kernel); + + const entry = stored(kernel, 'com.example.lite-class-based'); + expect(entry).toBe(instance); + expect(Object.getPrototypeOf(entry)).toBe(LiteClassPlugin.prototype); + expect((entry as unknown as LiteClassPlugin).describeSelf()).toBe('class:com.example.lite-class-based'); + }); + + it.each(['1.0.0-alpha.1', '1.0.0+20230101', '0.0.0-fixture'])( + '`version` stays excluded from the schema check here too — %s loads', + (version) => { + // The convergence is on the SCHEMA. `LiteKernel` has never judged + // `version` (that is `PluginLoader.validatePluginStructure`'s, on the + // other kernel) and still does not; the exclusion group E pins for the + // loader holds on this path for the same measured reason. + const kernel = makeLiteKernel(); + expect(kernel.use(fixture({ name: `com.example.lite-v-${version}`, version }))).toBe(kernel); + }, + ); + + it('a version-less plugin loads — `version` is not among the eight keys', () => { + const kernel = makeLiteKernel(); + const versionless: Plugin = { name: 'com.example.lite-versionless', init: () => {} }; + expect(kernel.use(versionless)).toBe(kernel); + }); + + it('PARITY — for one input, the ObjectKernel refusal IS the LiteKernel refusal behind the loader\'s prefix', async () => { + // "An author gets ONE refusal, with the same code and message shape, + // from either kernel." `ObjectKernel.use()` re-wraps a failed load as + // `Failed to load plugin: - ` for EVERY load failure — + // its existing wrapper, untouched by #16721 — so the parity to pin is + // that the LiteKernel message is exactly what follows that prefix. + const make = () => fixture({ name: '@os-fixture/parity', type: 'ui', staticPath: UI_STATIC_PATH, slug: 'Not A Slug' }); + + const lite = refusalSync(() => makeLiteKernel().use(make())); + const object = await refusal(makeKernel().use(make())); + + expect(lite.code).toBe('PLUGIN_CONTRACT_VIOLATION'); + expect(lite.message).toContain("at 'slug'"); + expect(object.message).toBe(`Failed to load plugin: @os-fixture/parity - ${lite.message}`); + }); + + it('ORDER — a refused plugin never reaches the registry, so it cannot supersede an earlier registration', () => { + // `registerPluginByName` is last-one-wins by declared contract (#9864). + // The contract check runs BEFORE it, so a refused object under an + // already-registered name leaves the earlier registration in place — + // identity, not equality — rather than displacing it and then failing. + const kernel = makeLiteKernel(); + const first = fixture({ name: 'com.example.superseded', version: '1.0.0' }); + const refused = fixture({ name: 'com.example.superseded', version: '2.0.0', homepage: 'not-a-url' }); + + kernel.use(first); + const err = refusalSync(() => kernel.use(refused)); + + expect(err.code).toBe('PLUGIN_CONTRACT_VIOLATION'); + expect(stored(kernel, 'com.example.superseded')).toBe(first); + }); + + it('ORDER — state is checked before the contract: after bootstrap the refusal is the idle one', async () => { + // `validateIdle()` first, then the contract — the wiring #16721 step 1 + // measured with. A kernel that can no longer register plugins says so, + // and does not run the schema over an object it would not store anyway. + const kernel = makeLiteKernel(); + await kernel.bootstrap(); + try { + const err = refusalSync(() => kernel.use(fixture({ name: 'com.example.late', homepage: 'not-a-url' }))); + expect(err.message).toContain('Cannot register plugins after bootstrap has started'); + expect(err.message).not.toContain('PLUGIN_CONTRACT_VIOLATION'); + expect(err.code).toBeUndefined(); + } finally { + await kernel.shutdown(); + } + }); +}); diff --git a/packages/core/src/plugin-contract.ts b/packages/core/src/plugin-contract.ts new file mode 100644 index 0000000000..fd5204bf81 --- /dev/null +++ b/packages/core/src/plugin-contract.ts @@ -0,0 +1,175 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { PluginSchema } from '@objectstack/spec/kernel'; +import type { Plugin } from './types.js'; + +/** + * The DECLARED plugin contract, enforced at `use()` on BOTH kernels — one + * statement, shared by `LiteKernel.use()` and by + * `PluginLoader.validatePluginContract` on the `ObjectKernel.use()` path + * (#16721, maintainer ruling 2026-09-08, option A). + * + * ## Why it is written down here rather than in each kernel + * + * It was previously written once, in `PluginLoader` — and `PluginLoader` is + * reached from `ObjectKernel.use()` alone. `LiteKernel.use()` wrote the plugin + * straight into its registry, so the same plugin object was accepted by one + * published kernel and refused by the other: a `type: 'ui'` plugin with no + * `slug` was refused by `ObjectKernel` with `PLUGIN_CONTRACT_VIOLATION` and + * mounted a route on `LiteKernel`. `AGENTS.md` assigns `LiteKernel` to tests, + * so the lenient kernel was the one authors develop against and the strict one + * was production — a plugin could be green in vitest and refused at boot. + * + * This is the fifth measured instance of one contract implemented twice across + * `ObjectKernel`/`LiteKernel` (#5170, #5282, #8357, #9864 before it), and it + * takes the mechanism #9864 chose: `ObjectKernel` does not extend + * `ObjectKernelBase`, so a shared base class is not available; a module both + * kernels import by relative path is, the same way `plugin-registration.ts`, + * `plugin-order.ts` and `hook-dispatch.ts` carry the contracts they own. + * + * ⛔ Deliberately NOT exported from the package barrel. Under the ruling this + * module converges EXISTING enforcement onto the second kernel; it does not + * mint a public validation API. Both kernels import it by relative path. + * + * ## What this refuses: the EIGHT declared keys, and `null` on any of them + * + * `PluginSchema` (`@objectstack/spec`, `kernel/plugin.zod.ts`) declares nine + * optional keys; the filter below drops `version` (see below), so the + * accept-set narrowing this function performs covers exactly these eight, each + * reported as `at ''`: + * + * - `id` — a non-string, or the empty string (`z.string().min(1)`). + * - `type` — outside the closed set `'standard'` + `CORE_PLUGIN_TYPES`. + * - `staticPath` — a non-string. + * - `slug` — a non-string, or not matching `/^[a-z0-9-_]+$/`. + * - `default` — a non-boolean. + * - `description` — a non-string. + * - `author` — a non-string; an object such as `{ name }` is refused. + * - `homepage` — a non-string, or a string that is not a URL. + * + * All eight are `.optional()`, which admits absence and `undefined` but + * never an explicit `null` — so `null` on any of the eight is refused too. + * + * Since #16334 the schema carries ONE conditional requirement on top of + * the eight: `type: 'ui'` owes `staticPath` and `slug`, and `PluginSchema` + * refuses a `ui` plugin missing either with `PLUGIN_UI_REQUIRED_KEY_MISSING` + * at the head of the issue message (`packages/spec/src/kernel/plugin.zod.ts`). + * That refusal rides this function's envelope unchanged — reported as + * `at 'staticPath'` / `at 'slug'` with the spec's code inside the message — + * because this function surfaces `path` and `message` and reads nothing + * else. `plugin-contract-enforcement.test.ts` group F pins the surfacing on + * `ObjectKernel`; group G pins the same envelope on `LiteKernel`. + * + * ⛔ ENUMERATE ALL EIGHT wherever this is restated. The changeset ships to + * consumers as `CHANGELOG.md` and is what an upgrading author greps after + * the refusal, so a shorter enumeration there does not merely omit keys — + * it tells an author refused `at 'author'` that their key is not enforced. + * This comment, the #16049 changeset and the `PLUGIN_CONTRACT_VIOLATION` row + * in `dispatcher-error-vocabulary.ts` are the three places that restate it. + * + * What this does NOT refuse, which is what bounds the narrowing: UNKNOWN + * keys. `PluginSchema` is a plain `z.object` with no `.strict()` — the + * strip posture — and the parse output is discarded here, so a plugin + * carrying keys the schema never declares still loads, stored verbatim. + * + * ## ⛔ safeParse for VALIDATION ONLY — the parse output is discarded + * + * The returned object is a COPY, and `PluginLoader.toPluginMetadata` exists + * precisely because a copy "destroys the prototype chain for Class-based + * plugins". Substituting the parse output for the plugin would break every + * class-based plugin in the ecosystem while leaving every refusal test green, + * so the result is read for `success` and for nothing else, and NEITHER + * kernel writes anything back onto the object it was handed. + * `plugin-contract-enforcement.test.ts` pins a class-based plugin's prototype + * surviving `use()` on both kernels, which is what makes that a measurement + * rather than a promise. + * + * ## The envelope, and what each kernel does with it + * + * ONE refusal, from either kernel: the stable code `PLUGIN_CONTRACT_VIOLATION` + * at the head of the message and on the error's `code` property, naming the + * plugin and the first violated key: + * + * PLUGIN_CONTRACT_VIOLATION: plugin '@acme/console' is refused by the + * declared plugin contract at 'type': + * + * `LiteKernel.use()` is synchronous and throws this error as-is, so an + * in-process catcher sees the `code` property. `ObjectKernel.use()` runs it + * inside `PluginLoader.loadPlugin` and re-wraps a failed load into a fresh + * `Error` carrying only `result.error?.message` — its existing wrapper for + * EVERY load failure, unchanged here — so on that kernel the text above + * arrives behind a `Failed to load plugin: - ` prefix and the code + * survives only because it is repeated at the head of the message. Group G's + * parity case pins that the `LiteKernel` message is exactly the tail of the + * `ObjectKernel` one for the same input. + * + * The FIRST issue only: a boot refusal is read by a human reading one log + * line, and the first violated key is the one to fix. + * + * ⚠️ The code is spelled the ADR-0112 way and is deliberately NOT wire + * vocabulary, exactly like `SERVICE_NOT_REGISTERED_CODE` one module over: it + * is raised while the kernel is still assembling itself, before any HTTP + * boundary exists, and `dispatcher-error-vocabulary.ts` classifies it + * `door: 'none'` / `boot-refusal` for that reason. + * + * ## Why `version` is excluded, and why that is not a weakening + * + * MEASURED, not assumed. `PluginSchema.version` is `/^\d+\.\d+\.\d+$/`, which + * refuses the prerelease and build-metadata forms SemVer 2.0.0 defines — while + * `PluginLoader.isValidSemanticVersion`, the check the loader has always run, + * implements the full grammar and accepts them. Two declarations in this + * repository disagree about what a version is, and `plugin-loader.test.ts` + * pins the wider one deliberately: "should accept versions with pre-release + * tags" (`1.0.0-alpha.1`) and "should accept versions with build metadata" + * (`1.0.0+20230101`). Two in-repo class-based plugin fixtures ship + * `version = '0.0.0-fixture'` and boot through the real kernel. + * + * So enforcing the schema's `version` here would not enforce the protocol — + * it would RETIRE a pinned capability, silently, under a card that ruled on + * `type`. Version is not among the eight keys enumerated above. On + * `ObjectKernel` the loader's own `validatePluginStructure` still judges + * `version` with the wider grammar; `LiteKernel` has never judged `version` + * and, under this convergence, still does not — the convergence is on the + * SCHEMA (#16721 ruled on `PluginSchema`), not on the loader's structural + * checks (`name`, `init`, semver), which stay `PluginLoader`'s own. + * Reconciling the two `version` spellings belongs in `packages/spec` beside + * #16334; until then this exclusion is declared here rather than performed by + * leaving the disagreement unmeasured. + */ +const PLUGIN_CONTRACT_VIOLATION_CODE = 'PLUGIN_CONTRACT_VIOLATION'; + +/** + * Refuse `plugin` when the DECLARED plugin contract refuses it; return when + * it does not. Reads `PluginSchema.safeParse` for `success` and for the first + * non-`version` issue, and NOTHING else — see the module comment for the + * eight keys this reaches, the `version` exclusion and the envelope. + * + * @throws an `Error` whose `code` is `PLUGIN_CONTRACT_VIOLATION` and whose + * message carries the same code at its head, the plugin's name (and + * `id`, when it declares one) and the first violated key. + */ +export function assertPluginContract(plugin: Plugin): void { + const result = PluginSchema.safeParse(plugin); + if (result.success) { + return; + } + + const issues = result.error.issues.filter((issue) => issue.path[0] !== 'version'); + if (issues.length === 0) { + return; + } + + const first = issues[0]; + const at = first.path.length > 0 ? first.path.join('.') : '(root)'; + const id = (plugin as { id?: unknown }).id; + const named = typeof id === 'string' && id.length > 0 + ? `'${plugin.name}' (id: ${id})` + : `'${plugin.name}'`; + + const error = new Error( + `${PLUGIN_CONTRACT_VIOLATION_CODE}: plugin ${named} is refused by the declared plugin ` + + `contract at '${at}': ${first.message}`, + ) as Error & { code?: string }; + error.code = PLUGIN_CONTRACT_VIOLATION_CODE; + throw error; +} diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts index e5aef258ef..fda7363810 100644 --- a/packages/core/src/plugin-loader.ts +++ b/packages/core/src/plugin-loader.ts @@ -2,26 +2,9 @@ import { Plugin, PluginContext } from './types.js'; import type { Logger } from '@objectstack/spec/contracts'; -import { PluginSchema } from '@objectstack/spec/kernel'; import { parseSignature } from './security/plugin-artifact-signature.js'; import { serviceNotRegisteredError } from './service-not-registered.js'; - -/** - * The code carried by a refusal raised because the plugin object does not - * satisfy `PluginSchema` — the protocol's own declaration of what a plugin - * object may be (`@objectstack/spec`, `kernel/plugin.zod.ts`). - * - * ⚠️ Spelled the ADR-0112 way and deliberately NOT wire vocabulary, exactly - * like {@link SERVICE_NOT_REGISTERED_CODE} one module over: this refusal is - * raised while the kernel is still assembling itself, before any HTTP boundary - * exists, and `dispatcher-error-vocabulary.ts` classifies it `door: 'none'` / - * `boot-refusal` for that reason. It is stamped on `err.code` for an in-process - * catcher AND repeated at the head of the message, because the message is what - * survives: `ObjectKernel.use()` re-wraps a failed load into a fresh `Error` - * carrying only `result.error?.message`, so a code that lived only on the - * property would not reach the caller that actually sees the boot fail. - */ -const PLUGIN_CONTRACT_VIOLATION_CODE = 'PLUGIN_CONTRACT_VIOLATION'; +import { assertPluginContract } from './plugin-contract.js'; /** * Service Lifecycle Types @@ -436,114 +419,21 @@ export class PluginLoader { * maintainer ruling 2026-09-06: "the protocol is the baseline; the runtime * aligns to it"). * - * ## What this closes - * - * `PluginSchema` had **zero runtime callers**. The boot path ran - * {@link validatePluginStructure} — `name`, `init`, semver — and nothing - * else, so every constraint the protocol declared beyond those three was a - * declaration with nothing behind it: `defineStack` accepted a value that - * `PluginSchema.safeParse` refused, and the plugin was stored verbatim and - * mounted routes. A wrong `type` surfaced (if at all) at route mount; it - * now surfaces here, named, at `kernel.use()`. - * - * ## What this refuses: the EIGHT declared keys, and `null` on any of them - * - * `PluginSchema` declares nine optional keys; the filter below drops - * `version` (see below), so the accept-set narrowing this method - * performs covers exactly these eight, each reported as `at ''`: - * - * - `id` — a non-string, or the empty string (`z.string().min(1)`). - * - `type` — outside the closed set `'standard'` + `CORE_PLUGIN_TYPES`. - * - `staticPath` — a non-string. - * - `slug` — a non-string, or not matching `/^[a-z0-9-_]+$/`. - * - `default` — a non-boolean. - * - `description` — a non-string. - * - `author` — a non-string; an object such as `{ name }` is refused. - * - `homepage` — a non-string, or a string that is not a URL. - * - * All eight are `.optional()`, which admits absence and `undefined` but - * never an explicit `null` — so `null` on any of the eight is refused too. - * - * Since #16334 the schema carries ONE conditional requirement on top of - * the eight: `type: 'ui'` owes `staticPath` and `slug`, and `PluginSchema` - * refuses a `ui` plugin missing either with `PLUGIN_UI_REQUIRED_KEY_MISSING` - * at the head of the issue message (`packages/spec/src/kernel/plugin.zod.ts`). - * That refusal rides this method's envelope unchanged — reported as - * `at 'staticPath'` / `at 'slug'` with the spec's code inside the message — - * because this method surfaces `path` and `message` and reads nothing - * else. `plugin-contract-enforcement.test.ts` group F pins the surfacing. - * - * ⛔ ENUMERATE ALL EIGHT wherever this is restated. The changeset ships to - * consumers as `CHANGELOG.md` and is what an upgrading author greps after - * the refusal, so a shorter enumeration there does not merely omit keys — - * it tells an author refused `at 'author'` that their key is not enforced. - * This comment, the changeset and the `PLUGIN_CONTRACT_VIOLATION` row in - * `dispatcher-error-vocabulary.ts` are the three places that restate it. - * - * What this does NOT refuse, which is what bounds the narrowing: UNKNOWN - * keys. `PluginSchema` is a plain `z.object` with no `.strict()` — the - * strip posture — and the parse output is discarded here, so a plugin - * carrying keys the schema never declares still loads, stored verbatim. + * The check itself — `PluginSchema.safeParse` for validation only, the + * eight keys it reaches, the `version` exclusion and the + * `PLUGIN_CONTRACT_VIOLATION` envelope — lives in `plugin-contract.ts`, + * because since #16721 it is ONE statement run by BOTH kernels: + * `LiteKernel.use()` calls it directly, and `ObjectKernel.use()` reaches + * it here, through `loadPlugin`. That module's comment is the authority on + * what is refused; this method adds nothing to it and subtracts nothing. * - * ## ⛔ safeParse for VALIDATION ONLY — the parse output is discarded - * - * The returned object is a COPY, and {@link toPluginMetadata} exists - * precisely because a copy "destroys the prototype chain for Class-based - * plugins". Substituting the parse output for the plugin would break every - * class-based plugin in the ecosystem while leaving this file's own tests - * green, so the result is read for `success` and for nothing else. - * `plugin-contract-enforcement.test.ts` pins a class-based plugin's - * prototype surviving `use()`, which is what makes that a measurement - * rather than a promise. - * - * ## Why `version` is excluded, and why that is not a weakening - * - * MEASURED on this tree, not assumed. `PluginSchema.version` is - * `/^\d+\.\d+\.\d+$/`, which refuses the prerelease and build-metadata - * forms SemVer 2.0.0 defines — while {@link isValidSemanticVersion}, the - * check this loader has always run, implements the full grammar and accepts - * them. Two declarations in this repository disagree about what a version - * is, and `plugin-loader.test.ts` pins the wider one deliberately: "should - * accept versions with pre-release tags" (`1.0.0-alpha.1`) and "should - * accept versions with build metadata" (`1.0.0+20230101`). Two in-repo - * class-based plugin fixtures ship `version = '0.0.0-fixture'` and boot - * through the real kernel. - * - * So enforcing the schema's `version` here would not enforce the protocol — - * it would RETIRE a pinned capability, silently, under a card that ruled on - * `type`. Version is not among the eight keys enumerated above, and the - * version check that already runs is the wider, correct one: a version-less - * plugin loads, and so do `1.0.0-alpha.1` and `1.0.0+20230101`. - * Reconciling the two spellings belongs in `packages/spec` beside - * #16334; until then this exclusion is declared here rather than performed - * by leaving the disagreement unmeasured. + * What stays THIS loader's own, and is deliberately not shared: the + * structural checks one call up ({@link validatePluginStructure} — + * `name`, `init`, semver) and the version-compatibility check below. + * The convergence is on the schema, not on the loader. */ private validatePluginContract(plugin: PluginMetadata): void { - const result = PluginSchema.safeParse(plugin); - if (result.success) { - return; - } - - const issues = result.error.issues.filter((issue) => issue.path[0] !== 'version'); - if (issues.length === 0) { - return; - } - - // The FIRST issue only: a boot refusal is read by a human reading one - // log line, and the first violated key is the one to fix. - const first = issues[0]; - const at = first.path.length > 0 ? first.path.join('.') : '(root)'; - const id = (plugin as { id?: unknown }).id; - const named = typeof id === 'string' && id.length > 0 - ? `'${plugin.name}' (id: ${id})` - : `'${plugin.name}'`; - - const error = new Error( - `${PLUGIN_CONTRACT_VIOLATION_CODE}: plugin ${named} is refused by the declared plugin ` - + `contract at '${at}': ${first.message}`, - ) as Error & { code?: string }; - error.code = PLUGIN_CONTRACT_VIOLATION_CODE; - throw error; + assertPluginContract(plugin); } private checkVersionCompatibility(plugin: PluginMetadata): VersionCompatibility { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 6d58273ee3..93f2b4fdff 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -136,8 +136,9 @@ export type PluginType = 'standard' | (typeof CORE_PLUGIN_TYPES)[number]; * exported alias are the same union. Absent means `'standard'` at the schema * (`.default('standard')`), and the loader never writes that default back * onto the object. A value outside the set no longer type-checks, and since - * #16049 `kernel.use()` REFUSES it at boot — `PluginLoader.validatePluginContract` - * runs `PluginSchema` over every plugin object and raises + * #16049 `kernel.use()` REFUSES it at boot — `assertPluginContract` + * (`plugin-contract.ts`, run by BOTH `ObjectKernel.use()` and `LiteKernel.use()` + * since #16721) runs `PluginSchema` over every plugin object and raises * `PLUGIN_CONTRACT_VIOLATION` naming the plugin and the first violated key. * `type: 'ui'` additionally owes `staticPath` and `slug` (#16334, * `PLUGIN_UI_REQUIRED_KEY_MISSING`), refused on the same path. diff --git a/packages/plugins/plugin-hono-server/src/ui-plugin-auto-discovery.pin.test.ts b/packages/plugins/plugin-hono-server/src/ui-plugin-auto-discovery.pin.test.ts index e73b2fb4ef..066c3b1a16 100644 --- a/packages/plugins/plugin-hono-server/src/ui-plugin-auto-discovery.pin.test.ts +++ b/packages/plugins/plugin-hono-server/src/ui-plugin-auto-discovery.pin.test.ts @@ -24,31 +24,37 @@ * the context that kernel hands its plugins. Nothing here stubs the kernel, the * plugin, or the branch under test. * - * ⭐ WHICH KERNEL, AND WHY THAT IS NOW HALF THE FILE (#16599). This repository - * publishes TWO kernels and `@objectstack/core` exports both. They do not agree - * about this block's inputs, and the disagreement is the reason groups B, D and - * F exist in the shape they do: + * ⭐ WHICH KERNEL, AND WHY THAT IS HALF THE FILE (#16599, then #16721). This + * repository publishes TWO kernels and `@objectstack/core` exports both. When + * group F was written they did NOT agree about this block's inputs, and that + * disagreement is the reason groups B, D and F exist in the shape they do: * * - `ObjectKernel.use()` runs `PluginLoader.loadPlugin` -> * `validatePluginContract` -> `PluginSchema.safeParse` on every plugin * object (#16049, landed as #16363), and since #16334 that schema requires * `staticPath` AND `slug` for `type: 'ui'`. A `ui` plugin missing either is * a boot REFUSAL and never reaches `kernel.plugins` at all. - * - `LiteKernel.use()` calls `registerPluginByName` directly and never - * touches `PluginSchema`, `PluginLoader` or any part of that path — #16363 - * changed `PluginLoader` only, and `PluginLoader` is reached from - * `ObjectKernel.use()` alone. The same object is stored verbatim, and - * `ObjectKernelBase.createContext()` hands plugins a context whose - * `getKernel()` returns that kernel, whose `plugins` map is exactly what - * this block iterates. + * - `LiteKernel.use()` — until #16721 — called `registerPluginByName` + * directly and never touched `PluginSchema`: the same object was stored + * verbatim, and `ObjectKernelBase.createContext()` handed plugins a context + * whose `getKernel()` returned that kernel, whose `plugins` map is exactly + * what this block iterates. Group F measured that, per branch. + * - Since #16721 (maintainer ruling, option A: the kernels converge) + * `LiteKernel.use()` runs the SAME check — `assertPluginContract` in + * `packages/core/src/plugin-contract.ts`, the one statement both kernels + * call — and refuses the same objects with the same envelope. Group F now + * pins THAT, with F0 still proving the harness mounts under this kernel. * * `AGENTS.md`'s Kernel table names `LiteKernel` for "Tests (vitest), serverless, * edge (Workers)", so this is not a curiosity — it is the second supported way * to run a UI plugin, and with zero in-repo `type: 'ui'` producers, externally - * authored plugins are the block's only real callers on EITHER kernel. + * authored plugins are the block's only real callers on EITHER kernel. It is + * also why the divergence bit in the direction that hurt: the lenient kernel + * was the one authors test against, and the strict one was production. * * ⇒ "Reachable" is therefore not a property of a branch here, it is a property - * of a branch PER KERNEL, and this file states both halves rather than one. + * of a branch PER KERNEL, and this file states both halves rather than one — + * and now that both halves answer the same, it says so per kernel too. * * WHAT EACH GROUP ACTUALLY OBSERVES — stated because the difference is the whole * point of this file. A, B, D and F observe ROUTE REGISTRATION: they replace @@ -77,9 +83,9 @@ * * Group F carries its own copy of that discipline rather than borrowing D's, * because it runs on a different kernel: F0 is the firing control showing the - * `LiteKernel` harness CAN mount, so F2's `[]` is caused by the - * `&& plugin.staticPath` conjunct and not by a harness that never mounts under - * that kernel. ⛔ A group that can only ever produce `[]` measures nothing. + * `LiteKernel` harness CAN mount, so F1/F2's refusals are caused by the + * contract and not by a harness that never mounts under that kernel. ⛔ A + * group that can only ever refuse measures nothing. */ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; @@ -202,14 +208,15 @@ async function boot(fixture: UiPluginFixture): Promise { /** * The `LiteKernel` counterpart of {@link boot} — the kernel `AGENTS.md` names for - * tests, serverless and edge. `LiteKernel.use()` is synchronous and stores the - * object through `registerPluginByName` with no schema in the path, so a fixture - * that {@link boot} REFUSES arrives here intact and the block sees it. + * tests, serverless and edge. `LiteKernel.use()` is synchronous; since #16721 it + * runs the same `assertPluginContract` the loader runs for {@link boot} and then + * stores the object through `registerPluginByName`, so a fixture {@link boot} + * REFUSES is refused here too — synchronously, which the `async` wrapper turns + * into the rejection {@link refusal} reads. * - * ⚠️ Not merely "less strict": nothing on this path calls `PluginSchema` at all, - * so #16334's `type: 'ui'` requirements and #16363's enforcement are both absent - * here — which is what makes group F a measurement of the branches rather than a - * second copy of group B. + * ⚠️ Until #16721 nothing on this path called `PluginSchema` at all, so #16334's + * `type: 'ui'` requirements and #16363's enforcement were both absent here — the + * state group F was written to measure. Its header records both readings. * * No `gracefulShutdown` option exists on this kernel and it registers no signal * handlers of its own, so there is nothing to opt out of. @@ -334,11 +341,16 @@ describe('UI plugin auto-discovery (#16050)', () => { // the object never reaches `kernel.plugins`. Pinned as the refusal, // with the spec's stable code surfacing inside the loader's envelope. // - // ⛔ NOT dead code, and this comment used to say it was (#16599). The - // expression is LIVE AND LOAD-BEARING on `LiteKernel`, which never - // calls `PluginSchema` — pin F1 is the measurement, and ablating the - // `||` there moves the mounted route from `/console` to `/undefined`. - // ⇒ The two halves are one fact stated per kernel; read them together. + // The other kernel, and the history this comment carries. #16599 + // measured the expression LIVE on `LiteKernel`, which then never called + // `PluginSchema` (ablating the `||` moved the mounted route from + // `/console` to `/undefined`), and this comment said "⛔ NOT dead code" + // on that basis. Since #16721 `LiteKernel.use()` runs the same contract, + // so the same object is refused there too — pin F1 — and the derivation + // is reachable through NEITHER published kernel's `use()`. Whether that + // makes it removable is `hono-plugin.ts`'s question, noted on #16721 and + // deliberately not pinned here: this file pins what each kernel's + // `use()` lets through, not what the block should do with it. const err = await refusal(boot(makeFixture({ name: '@os-fixture/console' }))); expect(err.message).toContain('PLUGIN_CONTRACT_VIOLATION'); expect(err.message).toContain("at 'slug'"); @@ -359,27 +371,28 @@ describe('UI plugin auto-discovery (#16050)', () => { * * - `ObjectKernel.use()` REFUSES it since #16363, with * `PLUGIN_CONTRACT_VIOLATION … at 'type'` naming the closed set. - * - `LiteKernel.use()` still accepts it and the block still mounts `/slug` - * and `/slug/*`, because #16363 changed `PluginLoader` and this kernel - * never reaches `PluginLoader`. + * - `LiteKernel.use()` accepted it until #16721 and the block mounted + * `/slug` and `/slug/*`; since #16721 it runs the same contract and + * refuses it with the same envelope (core's enforcement test, group G). * - * ⇒ #15638's arm is HALF dead — the same shape as the two arms #16599 - * measured — and whoever lands it owes both halves rather than one. The - * ruling picks between two INCOMPATIBLE pins, so writing either one now would - * pin a guess: + * ⇒ #15638's arm was HALF dead when this note was first written — the same + * shape as the two arms #16599 measured — and is now reachable through + * NEITHER published kernel's `use()`. That is a reading about the tree, not + * the ruling: #15638 still picks between two INCOMPATIBLE pins, so writing + * either one now would pin a guess: * - * - if #15638 rules REMOVE, C inverts: a `ui-plugin` fixture must mount - * NOTHING on `LiteKernel` too, i.e. `routes` equal to `[]`, exactly like - * pin D; + * - if #15638 rules REMOVE, C becomes: a `ui-plugin` fixture is refused at + * `use()` on BOTH kernels (true since #16721) and the arm is deleted, + * so nothing can ever mount it; * - if #15638 rules DECLARE/CONVERT (an ADR-0087 conversion entry), C - * becomes: a `ui-plugin` fixture is normalised to `ui`, mounts `/slug` - * and `/slug/*` exactly like pin B, and emits one deprecation warning. + * becomes: a `ui-plugin` fixture is normalised to `ui` BEFORE the contract + * runs — on both kernels — mounts `/slug` and `/slug/*` exactly like pin + * B, and emits one deprecation warning. * * Whoever lands #15638 writes this case in that PR — the harness above takes - * it unchanged; only the fixture's `type`, the kernel it boots on - * (`bootLite`, per group F) and the expectation differ. Until then the - * placeholder is the honest state: measured as live on `LiteKernel` and - * refused on `ObjectKernel`, unpinned here on purpose. + * it unchanged; only the fixture's `type`, the kernel(s) it boots on and the + * expectation differ. Until then the placeholder is the honest state: + * refused on both kernels, unpinned here on purpose. */ it.todo('C — the legacy `ui-plugin` arm behaves as #15638 rules that it should'); @@ -416,12 +429,15 @@ describe('UI plugin auto-discovery (#16050)', () => { // refusal here, not a silent non-mount. The `NON_UI_TYPES` cases above // remain the proof that this harness CAN produce `[]`. // - // ⛔ Again NOT dead, and this comment used to imply it (#16599): on - // `LiteKernel` the same object reaches the block and the conjunct is - // what skips it — pin F2. Deleting the conjunct there does not - // "remove dead code", it turns a clean boot into a `TypeError` naming - // `paths[1]`, thrown by `path.resolve(process.cwd(), mount.root)` - // further down `start()` once `undefined` is pushed as a mount root. + // The other kernel, with the same history as pin B's twin above. #16599 + // measured the conjunct LIVE on `LiteKernel`: the object reached the block + // there and deleting `&& plugin.staticPath` turned a clean boot into a + // `TypeError` naming `paths[1]`, thrown by + // `path.resolve(process.cwd(), mount.root)` once `undefined` was pushed + // as a mount root. Since #16721 `LiteKernel.use()` refuses the same + // object — pin F2 — so the conjunct is reachable through neither + // published kernel's `use()`. Removable or not is `hono-plugin.ts`'s + // question, noted on #16721; this file pins the kernels' answers. const err = await refusal(boot(makeFixture({ name: '@os-fixture/console-no-assets', staticPath: undefined, @@ -515,45 +531,61 @@ describe('UI plugin auto-discovery (#16050)', () => { }); /** - * F — the SAME two inputs, on `LiteKernel`, where they are not refused. + * F — the SAME two inputs on `LiteKernel`, where they are now refused too. * - * WHY THIS GROUP EXISTS (#16599). B and D pin that `ObjectKernel.use()` - * REFUSES a `ui` plugin missing `slug` or `staticPath`, and until this group - * existed the file went on to assert — in prose, with no case behind it — - * that the two branches those keys feed were therefore dead. ⛔ That is a - * claim about every entry point, argued from one. It was wrong. + * WHY THIS GROUP EXISTS, and what it used to pin (#16599, then #16721). B + * and D pin that `ObjectKernel.use()` REFUSES a `ui` plugin missing `slug` + * or `staticPath`. Until #16721 this group pinned the OPPOSITE half: + * `LiteKernel.use()` — which then never called `PluginSchema` — stored the + * same two objects verbatim and the block ran against them, deriving a slug + * from the package name (old F1: routes `/console`, `/console/*`) and + * skipping the assetless plugin cleanly (old F2: `[]`, boot resolving). + * Those two readings were what falsified #16599's "dead code" claim, and + * they were ⛔ NOT fixture noise: they pinned the leniency itself. That is + * why they could not be "fixed into passing" once the leniency went — the + * step that measured the convergence's cost (#16721 step 1) found F0 + * passing and F1/F2 failing under the wiring, which is the signature of a + * pin on the divergence rather than of a sloppy fixture. * - * `LiteKernel.use()` never calls `PluginSchema` (see the header), so both - * inputs reach `kernel.plugins` intact and the block runs against them. Both - * are also ordinary type-legal `Plugin` values — nothing here needs a cast to - * construct them, so this is not a torture fixture, it is what an external - * `ui` plugin looks like when its author left an optional key out. + * #16721 (maintainer ruling, option A, under #9864's precedent that the two + * kernels converge) made `LiteKernel.use()` run the same + * `assertPluginContract` the loader runs, so the subject of the old F1/F2 + * no longer exists on any published kernel. ⭐ REWRITTEN, not deleted, and + * here is what each case now measures: * - * ⭐ WHAT EACH CASE REPLACES. These three pins carry readings that were - * previously produced by ABLATING `hono-plugin.ts` in a throwaway probe — - * deleting the `||` moved F1's route to `/undefined`, and deleting the - * `&& plugin.staticPath` conjunct turned F2's clean boot into a `TypeError`. - * An ablation proves a branch load-bearing ONCE, in a session nobody can - * re-read. These cases are the same two readings, made permanent, so the next - * reader who concludes "dead code" is contradicted by a red test rather than - * by an argument. + * - F0 is UNCHANGED — the firing control. A fully declared `ui` plugin + * still mounts on this kernel, so F1/F2's refusals are caused by the + * contract and not by a harness that stopped mounting under + * `LiteKernel`. It also proves the convergence is on the SCHEMA: what + * the schema accepts, this kernel still accepts and the block still + * mounts, identically to pin B. + * - F1/F2 pin that the SAME input gets the SAME refusal from either + * kernel: the code, the key and the spec's + * `PLUGIN_UI_REQUIRED_KEY_MISSING` — and, sharper, that the + * `ObjectKernel` message is byte-for-byte the `LiteKernel` message + * behind the loader's existing `Failed to load plugin: - ` + * prefix. One statement, two kernels, one refusal. * - * ⛔ F is NOT a claim about which kernel is right. Whether `LiteKernel` should - * validate at all is a contract question, carried on its own card and - * deliberately not pre-empted here. This group pins only what the tree does - * today. + * What this means for the two branches the old F1/F2 pinned as + * load-bearing — `plugin.slug || plugin.name.split('/').pop()` and the + * `&& plugin.staticPath` conjunct in `hono-plugin.ts`: neither is reachable + * through either published kernel's `use()` any more. That is an + * observation about `hono-plugin.ts`, recorded on #16721 and deliberately + * not acted on here — this file pins the kernels' inputs, and whether the + * block keeps its defensive spelling is that file's call, not this pin's. */ - describe('F — the same inputs on `LiteKernel`, which never calls `PluginSchema` (#16599)', () => { + describe('F — the same inputs on `LiteKernel`, refused since it runs the contract too (#16721)', () => { it('F0 — the firing control: a fully declared `ui` plugin mounts on this kernel too', async () => { const { routes } = await observe( makeFixture({ name: '@os-fixture/console', slug: 'console-fixture' }), bootLite, ); - // The calibration F2 depends on, and the reason F2's `[]` is a - // reading rather than a harness that never mounts under this kernel. + // The calibration F1/F2 depend on, and the reason their refusals are + // readings rather than a harness that never mounts under this kernel. // Identical to pin B's expectation, which is the point: the block - // behaves the same on both kernels once the object gets through. + // behaves the same on both kernels once the object gets through — + // and, since #16721, the same objects get through on both. expect(routes).toEqual([ '/console-fixture', '/console-fixture', @@ -562,56 +594,46 @@ describe('UI plugin auto-discovery (#16050)', () => { ]); }); - it('F1 — with no `slug`, the fallback derives one from the last path segment of the name', async () => { - const { stored, routes } = await observe( - makeFixture({ name: '@os-fixture/console' }), - bootLite, - ); - - // The object B could not get past `ObjectKernel.use()` is stored here - // verbatim, `slug` genuinely absent — so the fallback is reached with - // nothing to short-circuit on. - expect(stored).toBeDefined(); - expect(stored?.slug).toBeUndefined(); - expect(stored?.staticPath).toBe(STATIC_ROOT); - - // `plugin.slug || plugin.name.split('/').pop()` — `@os-fixture/console` - // becomes `console`, exactly the `@org/console -> console` derivation - // the block documents. ⭐ THE NAME IS THE ASSERTION: `console` appears - // in no fixture field, only in the tail of `name`, so this expectation - // cannot be satisfied by anything except the fallback running. Drop the - // `||` and every route below reads `/undefined`. - expect(routes).toEqual([ - '/console', - '/console', - '/console/*', - '/console/*', - ]); + it('F1 — with no `slug`, LiteKernel.use() refuses with the envelope ObjectKernel.use() gives the same input', async () => { + const lite = await refusal(bootLite(makeFixture({ name: '@os-fixture/console' }))); + + // The refusal pin B reads on the other kernel, now read here: the + // object never reaches `kernel.plugins`, so the block never sees it + // and the `||` derivation has nothing to run on. + expect(lite.message).toContain('PLUGIN_CONTRACT_VIOLATION'); + expect(lite.message).toContain("at 'slug'"); + expect(lite.message).toContain('PLUGIN_UI_REQUIRED_KEY_MISSING'); + // `LiteKernel.use()` throws the error as-is, so the stable code is on + // the PROPERTY here — the surface `ObjectKernel`'s re-wrap keeps only + // at the head of the message. + expect((lite as Error & { code?: string }).code).toBe('PLUGIN_CONTRACT_VIOLATION'); + + // Parity, not resemblance: the same input through `boot` (pin B's + // kernel) is the same text behind the loader's own load-failure prefix. + const object = await refusal(boot(makeFixture({ name: '@os-fixture/console' }))); + expect(object.message).toBe(`Failed to load plugin: @os-fixture/console - ${lite.message}`); }); - it('F2 — with no `staticPath`, the guard skips the plugin and the boot stays clean', async () => { - const fixture = makeFixture({ + it('F2 — with no `staticPath`, LiteKernel.use() refuses with the envelope ObjectKernel.use() gives the same input', async () => { + const make = () => makeFixture({ name: '@os-fixture/console-no-assets', slug: 'console-fixture', staticPath: undefined, }); - // ⛔ The assertion is NOT merely `[]`. `start()` resolving is half of - // it: the `&& plugin.staticPath` conjunct is what keeps an assetless - // `ui` plugin from being pushed onto `mounts` with `root: undefined`, - // which `path.resolve(process.cwd(), mount.root)` further down - // `start()` rejects with a `TypeError` naming `paths[1]`. A guard that - // merely "avoided a pointless mount" would be dead weight; this one is - // the difference between a clean boot and a crashed one. - const observation = await observe(fixture, bootLite); - - expect(observation.stored).toBeDefined(); - expect(observation.stored?.staticPath).toBeUndefined(); - - // Same kernel, same harness, same fixture builder as F0 — `staticPath` - // is the only difference, so `[]` is caused by the conjunct and not by - // a harness that never mounts here. - expect(observation.routes).toEqual([]); + const lite = await refusal(bootLite(make())); + + // The refusal pin D reads on the other kernel. The `&& plugin.staticPath` + // conjunct that used to keep this object off `mounts` is no longer what + // stands between it and the `TypeError` further down `start()` — the + // contract is, on both kernels, before `start()` can run at all. + expect(lite.message).toContain('PLUGIN_CONTRACT_VIOLATION'); + expect(lite.message).toContain("at 'staticPath'"); + expect(lite.message).toContain('PLUGIN_UI_REQUIRED_KEY_MISSING'); + expect((lite as Error & { code?: string }).code).toBe('PLUGIN_CONTRACT_VIOLATION'); + + const object = await refusal(boot(make())); + expect(object.message).toBe(`Failed to load plugin: @os-fixture/console-no-assets - ${lite.message}`); }); }); }); diff --git a/packages/runtime/src/dispatcher-error-vocabulary.ts b/packages/runtime/src/dispatcher-error-vocabulary.ts index cf4f742811..c1c3390661 100644 --- a/packages/runtime/src/dispatcher-error-vocabulary.ts +++ b/packages/runtime/src/dispatcher-error-vocabulary.ts @@ -801,23 +801,27 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ }, // [#16049] The plugin-contract refusal `kernel.use()` now raises. Same // pre-HTTP class as the rows above; the ruling that created it is the - // 2026-09-06 ADR-0049 enforce-or-remove call on `PluginSchema`. + // 2026-09-06 ADR-0049 enforce-or-remove call on `PluginSchema`. Since + // #16721 the stamp site is the module BOTH kernels call. { code: 'PLUGIN_CONTRACT_VIOLATION', - file: 'packages/core/src/plugin-loader.ts', + file: 'packages/core/src/plugin-contract.ts', shape: 'assignconst', door: 'none', verdict: 'boot-refusal', why: - 'Raised by `PluginLoader.validatePluginContract` when a plugin object does not satisfy the ' + 'Raised by `assertPluginContract` (`plugin-contract.ts`, the one statement `LiteKernel.use()` ' + + 'calls directly and `PluginLoader.validatePluginContract` runs for `ObjectKernel.use()`) ' + + 'when a plugin object does not satisfy the ' + 'declared `PluginSchema` on any of the EIGHT keys that enforcement covers — `id`, `type`, ' + '`staticPath`, `slug`, `default`, `description`, `author`, `homepage` — including an explicit ' + '`null` on any of them, since all eight are `.optional()` and admit absence but not `null`. ' + '`version` is excluded from the enforcement, and unknown keys are not refused at all (the ' + 'schema carries no `.strict()`), so the narrowing stops at those eight. It is ' + 'raised while the kernel is still registering plugins, before bootstrap and therefore before ' - + 'any HTTP boundary exists: `ObjectKernel.use()` re-wraps it into a fresh `Error` that the host ' - + 'rethrows and the process aborts on, so no door can answer with it and no door can demote it. ' + + 'any HTTP boundary exists: `LiteKernel.use()` throws it as-is and `ObjectKernel.use()` re-wraps ' + + 'it into a fresh `Error` that the host rethrows and the process aborts on, so no door can answer ' + + 'with it and no door can demote it. ' + 'Same class as the migration-journal runner refusals and the service-resolution discriminator ' + 'above, ruled by the same reasoning those rows cite: a composition fact raised pre-HTTP is not wire ' + 'vocabulary. The code is repeated at the head of the message because that re-wrap keeps only '